• 简体中文
  • Hack 汇编器内部原理

    ← Hack ISA · 公共教学契约 · English · 执行器与测试 →

    本文沿着 isa/hack/tools/assembler.py 与 artifact.py,解释源码怎样变成 profile-specific Hack 机器码,再跨越严格的持久化 artifact 边界。命令默认生成 16 位 hack16,--profile hack32 选择 32 位机器字。共享 directive 与 manifest 规则见公共教学契约。assembler.py 保持为纯库,assembler_cli.py 是处理参数与发布的薄 CLI 边界。

    汇编器负责什么

    输入同时包含三类信息:

    .description Example program
    SET R0, 6             // Hack+ 伪指令
    (LOOP)                // 汇编符号
    @R0                   // 标准 A 指令
    D=M                   // 标准 C 指令
    .assert R0 == 6       // 执行元数据
    .max_steps 1000

    解析后的源码有两个去向:

    1. records:真正进入 ROM 的 MachineWord;
    2. AssemblyMetadata:HALT 地址、断言、带 origin 的有效运行配置、source identity 与 .description。

    Workflow 通过同一 parser 读取 description 以发现程序;artifact.py 再把它序列化进 .hack manifest,而 directive 仍不产生 ROM word。教学 metadata 因此可观察,但不会伪装成 Hack 代码。

    为什么手写解析器就够了

    Hack 汇编是按行组织的简单语言:没有嵌套表达式、运算符优先级或跨行语法。这里不需要引入 parser generator;正则表达式负责识别一行的形状,Python dataclass 负责形成类型化中间表示。

    “简单”不等于“宽松”。解析器会拒绝:

    • 非法标签与符号;
    • 不存在的 comp、dest、jump;
    • 重复 .description 或 .max_steps;
    • 未知点指令;
    • 越界 A 指令、RAM 地址和断言值;

    对教学汇编器而言,明确拒绝错误比猜测用户意图更重要。

    第一阶段:保留源码身份

    每个有效源码行先绑定一个 SourceLine:

    @dataclass(frozen=True)
    class SourceLine:
        line: int
        text: str

    它同时保存行号和原始文本。后续即使一条伪指令展开成四条机器指令,四个结果仍指回同一 SourceLine。因此生成文件可以显示:

    ROM[0000] L1 [1/4] SET R0, 6 => @6
    ROM[0001] L1 [2/4] SET R0, 6 => D=A

    这是一个很实用的编译器设计原则:源码位置应当属于中间表示,而不是等到报错或打印时再猜。

    第二阶段:类型化语句

    Parser.parse() 不直接生成整数,而是生成 Statement 联合类型:

    中间节点表示什么是否进入 ROM
    AInstruction@value 或 @symbol是
    CInstructiondest=comp;jump是
    Label(NAME)否
    PseudoInstructionHack+ 写法展开后进入
    AssertionDirective.assert否,进入 metadata
    MaxStepsDirective.max_steps否,进入 metadata
    DescriptionDirective.description否,供 discovery 与 manifest metadata 使用

    解析优先级是有意安排的:

    1. 去掉 // 后的注释和首尾空白;
    2. 识别 .description、.assert、.max_steps;
    3. 识别标签;
    4. 识别以 @ 开头的 A 指令;
    5. 根据首个单词识别 Hack+;
    6. 剩余内容按 C 指令解析。

    点指令必须先于普通指令,否则 .assert 或 .description 可能得到含糊的“非法 C 指令”错误。以 @ 开头的内容单独处理,则能把“非法操作数”和“根本不是 A 指令”区分开。

    source_description(text) 从同一组类型化语句中读取 description,供 workflow discovery 使用。expand() 不把 DescriptionDirective 放入代码 lowering;assemble_text() 将文本复制到 AssemblyMetadata.description,随后由 artifact.py 序列化为 manifest description。它始终不进入 ROM。

    A 指令验证

    A 操作数只能是:

    • 所选 A 立即数范围内的十进制数:hack16 为 0..32767,hack32 为 0..2147483647;
    • 匹配 [A-Za-z_.$:][A-Za-z0-9_.$:]* 的 Hack 符号。

    解析阶段保留符号字符串,因为此时还不知道标签地址,也不应提前分配变量。

    C 指令规范化

    解析器允许字段周围出现空白,然后规范化:

    AD = D + 1 ; JGT

    得到:

    CInstruction(dest="AD", comp="D+1", jump="JGT")

    接着通过 COMP、DEST、JUMP 表验证。这里的表既是编码表,也是语言允许列表;未知组合不会进入后续阶段。

    第三阶段:Hack+ 降级

    expand() 遍历 Statement:

    • 指令和标签进入待汇编代码;
    • 点指令收集到独立 metadata;
    • PseudoInstruction 变成标准 A/C 指令。

    例如:

    JEQ R1, DONE

    展开为:

    @R1
    D=M
    @DONE
    D;JEQ

    每条展开指令都携带 PseudoExpansion(instruction, index, count),而 source 仍指向原始 JEQ 行。因此四个结果既保留正式指令写法,也保留 [1/4] 到 [4/4] 的展开位置。

    为什么必须先展开再计算标签

    假设:

    SET R0, 6
    (AFTER)
    @AFTER
    0;JMP

    SET 占四个 ROM 机器字,因此 AFTER=4。如果第一遍先按源码行计算标签,再展开 SET,标签会错误地指向 1。

    正确顺序只能是:

    解析 → 展开 → 标签遍历 → 最终编码

    HALT 也在这里生成唯一私有标签:

    (__HACKPLUS_HALT_0)
    @__HACKPLUS_HALT_0
    0;JMP

    私有标签地址同时被收集到 halt_addresses,供 executor 判断完成。

    完整 Hack+ 展开表见 ISA 文档。

    第四阶段:两遍汇编

    第一遍:构造 ROM 符号表

    assemble_text() 从预定义符号表开始:

    R0..R15
    SP LCL ARG THIS THAT
    SCREEN KBD

    R0..R15 直接映射到 RAM 地址 0..15;它们是预定义汇编符号,不是架构寄存器。SP/LCL/ARG/THIS/THAT 则是前五个相同地址的别名。

    然后遍历展开后的代码:

    • Label 记录当前 ROM 地址;
    • A/C 指令追加到指令列表,并使 ROM 地址加一;
    • 标签自身不占机器字;
    • 重复标签和超过 32768 字的程序立即失败。

    第一遍结束后,每个跳转标签都已有确定地址。

    第二遍:解析 A 符号并编码

    第二遍只遍历真实 A/C 指令。

    对于 A 指令:

    1. 十进制操作数直接使用;
    2. 已知符号查表;
    3. 未知符号被视为变量,从 RAM 地址 16 起顺序分配;
    4. 在所选 profile 中写入前导 0 后的立即数:hack16 为 15 位,hack32 为 31 位。

    同一个变量名再次出现时复用第一次分配的地址。

    两个 profile 的 C 指令复用同一组 canonical 字段:

    hack16: 111 + COMP[comp] + DEST[dest] + JUMP[jump]
    hack32: 0xFFFF + 111 + COMP[comp] + DEST[dest] + JUMP[jump]

    例如 D=M+1;JGT:

    comp=M+1  -> 1110111
    dest=D    -> 010
    jump=JGT  -> 001
    word       = 1111110111010001

    每个结果封装为:

    MachineWord(value, source, expansion)

    普通 A/C 指令的 expansion 为 None;Hack+ 产物则保存含正式指令和 [i/n] 位置的 PseudoExpansion。因此机器码、原始源码和伪指令展开关系始终绑在一起。

    metadata 是旁路,不是指令

    AssemblyMetadata 包含:

    halt_addresses: tuple[int, ...]
    assertions: tuple[Assertion, ...]
    max_steps: int
    max_steps_origin: Literal["default", "source", "cli"]

    点指令不增加 ROM 地址。如果删除所有 .assert,标准机器字应完全不变;测试套件专门固定了这条性质。

    断言在汇编阶段做什么

    汇编器只负责:

    • 使用公共 parser 强制 equality bit-exact,并要求 ordered comparison 显式写 signed(...)/unsigned(...);
    • 校验 Hack target 与位宽,再规范化表示;
    • 保存源码行号。

    它不读取最终寄存器,也不在 Python 中执行断言。真正的断言表达式由 executor 写入生成的 Sail driver。

    这种职责分离很重要:Python 负责翻译,Sail 运行程序并判断架构状态。

    write_hack():把结果变成可审计产物

    artifact.py 写出两类记录。

    文件开头的结构化 manifest block

    write_hack() 委托公共 renderer。summary 和 full 在文件开头写入一个连续的多行缩进 canonical S-expression block,每一行都带 //% 前缀;none 把同一 form 写成一条带前缀的紧凑行。Schema 是 verylogic.annotated-image;公共 envelope 包含 runtime max_steps、assertions、completion 与 ISA metadata。Hack 直接汇编省略空 frontend provenance。Hack-specific validation 固定 isa=hack、profile=hack16|hack32、source kind asm、profile word width、memory dimensions 与 lowered-self-loop completion;standard 从来不是合法 profile。Runtime value 同时记录 cli、source 或 default origin。

    带源码映射的机器字

    0000000000000110 // ROM[0000] L1 [1/4] SET R0, 6 => @6
    1110110000010000 // ROM[0001] L1 [2/4] SET R0, 6 => D=A

    这个默认示例行首仍是普通 16 位 hack16 二进制。hack32 行首为 32 个二进制位,不是普通 nand2tetris .hack 机器字;项目中理解 profile 的 executor 会从 manifest 与注释恢复完整运行契约。

    教学信息级别

    可选 comments 参数控制解释性内容,不改变机器字或语义 metadata;manifest 始终存在,并记录 selected comment level:

    级别机器字注释
    none文件开头一条紧凑 manifest 行后直接写机器字;无 human preamble 或行尾解释
    summary每个机器字都显示 ROM/源码位置和规范化汇编;Hack+ 增加 [i/n] 源伪指令 => 正式指令;行内注释放在最右侧
    fullsummary 信息加精确的完整原始源码文本

    默认使用 summary:普通 A/C 源码和伪指令展开都会显示,行内汇编注释放在最右侧。文件开头的 //% manifest block 在所有级别都存在。可以运行 pixi run just hack assemble multiply,也可以用 pixi run just hack assemble multiply summary --max-steps 10000 覆盖 max_steps。apply_runtime_overrides() 在 write_hack() 前按 CLI > source > default 解析,因此最终 value 与 origin 都会穿过 artifact 边界。

    为什么还要 load_hack()

    executor 不直接使用内存中的 AssemblyResult。它先调用 write_hack(),再通过 load_hack() 重新读取文件。

    这建立了一条清晰边界:

    汇编器内部状态 --写出--> .hack 产物 --严格加载--> executor 输入

    如果不重新加载,writer 忘记保存某项 metadata 时,运行仍可能从内存对象中“碰巧成功”,磁盘产物却不完整。重新加载迫使执行只依赖真正交付的文件。

    Python artifact loader 的严格规则

    这里的 loader 是 isa/hack/tools/artifact.py::load_hack()。它会检查:

    • 文件开头恰好有一个连续 manifest block,格式符合 comment level,并具有公共 version-1 精确键集合;
    • ISA/profile、source identity、description、comment level、runtime value/origin、assertions、completion 与 isa_metadata 类型和值合法;
    • equality assertion 使用无 wrapper 的 bits mode,ordered assertion 使用显式 signed 或 unsigned mode;
    • 每个机器字恰好具有所选 profile 宽度(16 或 32 个二进制位),注释是否存在符合 manifest level;
    • completion address 真实指向 @address; 0;JMP 自循环;
    • ROM 不超过 32768 个机器字。

    load_hack() 只接受严格带注释格式;缺少文件开头 manifest block 或 profile 不匹配都会被拒绝。原始 nand2tetris 互操作必须使用独立显式 frontend,因为 raw image 无法如实提供 description、assertions、watchdog 来源或 completion metadata;hack32 还使用 32 位机器字,不属于普通 nand2tetris .hack 兼容格式。

    错误设计

    源码错误统一使用 AssemblyError(line, message),CLI 最终通过 argparse 报告。行号来自最早保存的 SourceLine,因此伪指令展开后出现的问题仍指向用户写的那一行。

    产物加载错误使用带文件路径和文件行号的 ValueError。这是不同的错误域:

    • AssemblyError:源程序不合法;
    • ValueError from load_hack:中间产物损坏或不可信。

    不要把二者合并成模糊的“汇编失败”,否则调试时无法判断问题发生在哪个边界。

    函数调用地图

    assembler_cli.py
    ├── assembler.assemble(source)
    ├── artifact.apply_runtime_overrides(result)
    └── artifact.write_hack(result, output)
    
    assembler.py
    └── assemble(source)
        └── assemble_text(text)
            ├── parse(text)
            │   └── Parser.parse
            ├── expand(statements)
            ├── pass 1: labels
            └── pass 2: symbols + encode
    
    artifact.py
    ├── apply_runtime_overrides(result)
    ├── write_hack(result, output)
    └── load_hack(output)
        └── LoadedHack(words, metadata, word_comments, manifest)
    
    executor.py
    └── strict reload → driver generation

    关键函数阅读顺序:

    1. Parser.parse
    2. Parser._parse_instruction
    3. expand
    4. assemble_text
    5. write_hack
    6. load_hack

    测试怎样覆盖汇编器

    test_assembler.py 不是只测几个示例程序,它固定了每个阶段的契约:

    测试方向防止什么回归
    合法/非法符号把错误标签静默当成变量
    伪指令源码映射展开后丢失原始行号和文本
    .hack round tripwriter 与 loader 语义漂移
    点指令不发射机器字测试功能改变正式程序
    断言模式和范围有符号/无符号解释错误
    metadata 严格校验损坏文件被宽松接受
    官方 comp/dest/jump 参数化测试编码表偏离 nand2tetris 规范

    programs/*.asm 的集成运行则验证“解析和编码出的程序确实能在 Sail ISA 上得到预期状态”。

    建议练习

    练习一:手算一次两遍汇编

    对下面源码列出展开后的 ROM 地址、符号表和机器字:

    SET sum, 1
    (LOOP)
    INC sum
    GOTO LOOP

    特别注意变量 sum 从 16 分配,而 LOOP 使用展开后的 ROM 地址。

    练习二:增加一个伪指令

    先写出它的标准 Hack 展开,再思考:

    • 会覆盖哪些寄存器?
    • 展开必须发生在哪个阶段?
    • 是否需要私有标签?
    • 怎样保留 SourceLine?
    • 单元测试要固定机器字、源码映射还是 metadata?

    只有这些问题都有明确答案后,才适合修改 PSEUDO_NAMES、解析模式和 expand()。

    练习三:观察 round trip

    pixi run just hack assemble multiply

    打开 isa/hack/.build/hack16/asm/multiply/multiply.hack(或用 --profile hack32 汇编后打开 .build/hack32/asm/multiply/multiply.hack)

    • 源码中的每一条伪指令;
    • 展开的标准指令;
    • ROM 地址;
    • profile 宽度的机器码;
    • 文件顶部 metadata。

    下一步阅读 执行器与测试工作流,继续追踪这个 .hack 文件怎样变成真正运行的程序。