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 边界。
汇编器负责什么
输入同时包含三类信息:
解析后的源码有两个去向:
records:真正进入 ROM 的MachineWord;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:
它同时保存行号和原始文本。后续即使一条伪指令展开成四条机器指令,四个结果仍指回同一 SourceLine。因此生成文件可以显示:
这是一个很实用的编译器设计原则:源码位置应当属于中间表示,而不是等到报错或打印时再猜。
第二阶段:类型化语句
Parser.parse() 不直接生成整数,而是生成 Statement 联合类型:
解析优先级是有意安排的:
- 去掉
//后的注释和首尾空白; - 识别
.description、.assert、.max_steps; - 识别标签;
- 识别以
@开头的 A 指令; - 根据首个单词识别 Hack+;
- 剩余内容按 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 指令规范化
解析器允许字段周围出现空白,然后规范化:
得到:
接着通过 COMP、DEST、JUMP 表验证。这里的表既是编码表,也是语言允许列表;未知组合不会进入后续阶段。
第三阶段:Hack+ 降级
expand() 遍历 Statement:
- 指令和标签进入待汇编代码;
- 点指令收集到独立 metadata;
PseudoInstruction变成标准 A/C 指令。
例如:
展开为:
每条展开指令都携带 PseudoExpansion(instruction, index, count),而 source 仍指向原始 JEQ 行。因此四个结果既保留正式指令写法,也保留 [1/4] 到 [4/4] 的展开位置。
为什么必须先展开再计算标签
假设:
SET 占四个 ROM 机器字,因此 AFTER=4。如果第一遍先按源码行计算标签,再展开 SET,标签会错误地指向 1。
正确顺序只能是:
HALT 也在这里生成唯一私有标签:
私有标签地址同时被收集到 halt_addresses,供 executor 判断完成。
完整 Hack+ 展开表见 ISA 文档。
第四阶段:两遍汇编
第一遍:构造 ROM 符号表
assemble_text() 从预定义符号表开始:
R0..R15 直接映射到 RAM 地址 0..15;它们是预定义汇编符号,不是架构寄存器。SP/LCL/ARG/THIS/THAT 则是前五个相同地址的别名。
然后遍历展开后的代码:
Label记录当前 ROM 地址;- A/C 指令追加到指令列表,并使 ROM 地址加一;
- 标签自身不占机器字;
- 重复标签和超过 32768 字的程序立即失败。
第一遍结束后,每个跳转标签都已有确定地址。
第二遍:解析 A 符号并编码
第二遍只遍历真实 A/C 指令。
对于 A 指令:
- 十进制操作数直接使用;
- 已知符号查表;
- 未知符号被视为变量,从 RAM 地址
16起顺序分配; - 在所选 profile 中写入前导
0后的立即数:hack16为 15 位,hack32为 31 位。
同一个变量名再次出现时复用第一次分配的地址。
两个 profile 的 C 指令复用同一组 canonical 字段:
例如 D=M+1;JGT:
每个结果封装为:
普通 A/C 指令的 expansion 为 None;Hack+ 产物则保存含正式指令和 [i/n] 位置的 PseudoExpansion。因此机器码、原始源码和伪指令展开关系始终绑在一起。
metadata 是旁路,不是指令
AssemblyMetadata 包含:
点指令不增加 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。
带源码映射的机器字
这个默认示例行首仍是普通 16 位 hack16 二进制。hack32 行首为 32 个二进制位,不是普通 nand2tetris .hack 机器字;项目中理解 profile 的 executor 会从 manifest 与注释恢复完整运行契约。
教学信息级别
可选 comments 参数控制解释性内容,不改变机器字或语义 metadata;manifest 始终存在,并记录 selected comment level:
默认使用 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() 重新读取文件。
这建立了一条清晰边界:
如果不重新加载,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 的
bitsmode,ordered assertion 使用显式signed或unsignedmode; - 每个机器字恰好具有所选 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:源程序不合法;ValueErrorfromload_hack:中间产物损坏或不可信。
不要把二者合并成模糊的“汇编失败”,否则调试时无法判断问题发生在哪个边界。
函数调用地图
关键函数阅读顺序:
Parser.parseParser._parse_instructionexpandassemble_textwrite_hackload_hack
测试怎样覆盖汇编器
test_assembler.py 不是只测几个示例程序,它固定了每个阶段的契约:
programs/*.asm 的集成运行则验证“解析和编码出的程序确实能在 Sail ISA 上得到预期状态”。
建议练习
练习一:手算一次两遍汇编
对下面源码列出展开后的 ROM 地址、符号表和机器字:
特别注意变量 sum 从 16 分配,而 LOOP 使用展开后的 ROM 地址。
练习二:增加一个伪指令
先写出它的标准 Hack 展开,再思考:
- 会覆盖哪些寄存器?
- 展开必须发生在哪个阶段?
- 是否需要私有标签?
- 怎样保留
SourceLine? - 单元测试要固定机器字、源码映射还是 metadata?
只有这些问题都有明确答案后,才适合修改 PSEUDO_NAMES、解析模式和 expand()。
练习三:观察 round trip
打开 isa/hack/.build/hack16/asm/multiply/multiply.hack(或用 --profile hack32 汇编后打开 .build/hack32/asm/multiply/multiply.hack)
- 源码中的每一条伪指令;
- 展开的标准指令;
- ROM 地址;
- profile 宽度的机器码;
- 文件顶部 metadata。
下一步阅读 执行器与测试工作流,继续追踪这个 .hack 文件怎样变成真正运行的程序。