第一次翻开 Project 07 的说明页,大多数人会有一种错觉:不就是把几条 push、pop 换成汇编吗,能有多难。真正动手写完第一版翻译器、把 SimpleAdd.vm 丢进去、在模拟器里看见栈指针跑偏的那一刻,才会明白这一章的真正价值——它是你第一次要把“栈式虚拟机的抽象模型”硬生生摁进“只有 A、D 两个寄存器和一块 RAM”的物理世界里。Nand2Tetris 的 Project 07 要求你实现一个 VM 翻译器的前半部分,输入是 Jack 编译器吐出来的.vm中间代码,输出是 Hack 平台能吃的.asm汇编文件,处理范围锁定在九条栈运算命令(add、sub、neg、eq、gt、lt、and、or、not)和八类内存访问命令(push/pop 配合 constant、local、argument、this、that、temp、pointer、static)。换句话说,这一章不涉及分支、不涉及函数调用、不涉及返回地址,就是一个纯粹的“栈操作翻译器”。它解决的问题非常具体:让一个只有加法器和位运算的 16 位机器,装出一副“我有无限深的操作数栈”的样子。适合谁来啃?正在跟这门课的人当然要写,但更适合那些已经写过一点解释器、想搞明白“虚拟机到底怎么落地”的开发者——你会在这里第一次亲手体验到指令展开带来的代码膨胀、寄存器不够用时的中转技巧,以及为什么几乎所有真实虚拟机都要在“简单”和“高效”之间做取舍。下面我按自己实现时的顺序,把这套东西从思路到代码到排错完整拆一遍。
1. 先把 Project 07 的边界划清楚
1.1 一条从 Jack 到硬件的四级流水线
要理解这一章的位置,得先把整门课的编译链路在脑子里立起来。最上层是 Jack 语言,写出来的.jack文件先被编译器翻译成.vm中间代码;.vm再被 VM 翻译器翻译成.asm汇编;.asm最后被汇编器翻译成.hack二进制,喂给硬件模拟器跑。这四个环节是串联的,每一级只认下一级的输入格式,互不越界。Project 07 卡在第二级,而且只做第二级的前半段。这个位置决定了它的输入输出形态:输入是文本行,每行一条命令,形如push local 3或add;输出也是文本行,每行一条 Hack 汇编指令,形如@3或D=M。
这里有个初学者常踩的认知坑:很多人以为 VM 翻译器要做“优化”,要理解程序的语义。完全不需要。Project 07 的翻译器本质是一个逐行文本替换器,它不需要知道这段代码在算什么,只需要知道“这条命令按照 VM 规范应该产生什么副作用”,然后把副作用翻译成汇编。你甚至可以把它理解成一个宏展开器。理解了这一点,整个项目的难度会瞬间下降一个量级——你不需要构建抽象语法树,不需要做数据流分析,一个for循环读行、一个split切词、一个if/elif分发,骨架就齐了。
再往深一层想,为什么课程要设计这样一层 VM?直接让编译器输出汇编不行吗?原因在于可移植性和编译器的复杂度。如果 Jack 编译器直接生成 Hack 汇编,那么每换一个目标平台,编译器就要重写一遍代码生成部分;而有了 VM 层,编译器只需要面向一套固定的、与平台无关的中间指令,换平台时只需重写 VM 翻译器。这个设计思路在工业界到处都是,只是名字不同。所以 Project 07 表面上在教你写翻译器,实际上在教你一种分层解耦的工程习惯。
1.2 命令清单:九条算术加八类内存
把 Project 07 需要支持的命令列全,其实就一张表。算术逻辑命令九条:add、sub、neg、eq、gt、lt、and、or、not。它们全部是二元或一元操作,操作数从栈顶取,结果压回栈顶。内存访问命令按段划分,push segment index和pop segment index两种形式,段包括constant、local、argument、this、that、temp、pointer、static八种。
这里必须提前说清楚一个约束:constant段只支持push,不支持pop。原因很简单,常量是一个立即数,没有可写的内存位置。如果输入文件里出现了pop constant 3,那是非法命令,翻译器可以直接报错退出,不需要处理。这一点课程材料里写得比较含蓄,但实际写代码时最好显式判断一下,避免后面调试时被莫名其妙的错误带偏。
还有一点值得注意:pointer段的 index 只能是 0 或 1,分别对应THIS和THAT两个指针;temp段的 index 范围是 0 到 7,对应 RAM 的第 5 到 12 号单元。这些范围限制在合法输入下不会越界,但如果你想让自己的翻译器更健壮,加一层范围检查不会花多少时间,却能省掉后面很多“为什么栈炸了”的困惑。
1.3 为什么课程把栈运算放在第一章
从教学节奏上看,这个安排非常讲究。Project 07 的命令集合有一个共同特征:没有控制流,没有函数帧,没有状态跨越。每一条命令执行完之后,机器状态的变化完全由栈顶几个元素和段基址决定,不依赖之前执行过什么,也不影响之后执行什么(除了栈本身)。这意味着翻译器可以做到完全无状态——处理第 N 行时不需要知道第 N-1 行是什么。
无状态的好处是巨大且实在的:你的生成函数可以一条命令一个,互不干扰;测试时可以单独拿一条命令出来验证;出了 bug,范围可以精确锁定到某一条命令的翻译模板上。等到 Project 08 引入label、goto、if-goto和函数调用,状态就开始跨行了,那时候再想“一条命令一个函数”就没这么干净了。所以这一章是整门课里最适合用来打磨“翻译模板”这件事的阶段,模板写扎实了,后面加功能就是往骨架里塞东西。
我个人建议在这一章多花一点时间,不要急着跑通测试就完事。把每条命令的汇编模板抄在纸上、逐条推演栈指针的变化、在心里模拟一遍 RAM 的内容,这个笨功夫在后面会成倍地还给你。
2. 翻译器的整体骨架怎么搭
2.1 解析层:只做三件事
解析层的职责非常窄:读一行、去掉注释、切词。VM 文件里可能有空行,可能有//开头的整行注释,也可能有行尾注释。一个稳妥的处理顺序是:先找到//的位置,把它后面的部分全部砍掉,然后strip(),如果结果为空就跳过,否则按空白切分成字段列表。切分后的第一个字段是命令类型,第二个字段(如果有)是段名,第三个字段(如果有)是索引。
我用 Python 写的时候,核心循环大致长这样:
def translate(lines, out_name): out = [] label_id = 0 for raw in lines: cmd = raw.split('//')[0].strip() if not cmd: continue parts = cmd.split() op = parts[0] if op in ('push', 'pop'): seg, idx = parts[1], int(parts[2]) out.extend(gen_memory(op, seg, idx)) else: out.extend(gen_arithmetic(op)) out.append('') # 可选:命令之间空一行,方便阅读 return out这段代码里唯一需要留意的是标签计数器label_id。它是整个翻译器里唯一需要跨命令维护的状态,因为eq、gt、lt三条命令各自需要一个唯一的跳转标签。除此之外,翻译过程真的是无状态的。
顺便提一个工程习惯:让生成函数返回字符串列表而不是直接往全局缓冲区里写,这样单元测试特别方便。你可以写一个测试,断言gen_arithmetic('add')返回的列表和预期完全一致。这种测试写起来五分钟,省下来的调试时间可能是一下午。
2.2 生成层:一条命令一个函数
生成层我强烈建议按“一条 VM 命令对应一个生成函数”来组织。别图省事写一个大if/elif分支把二十八条命令全塞进去,那样代码会变成一坨很难维护的东西。按段拆函数的好处是:local、argument、this、that四个段的翻译逻辑几乎一模一样,只有基址不同,完全可以共用一个带基址参数的函数;temp、pointer、static各有各的特殊处理,单独拆出来反而更清楚。
推荐的函数划分是这样的:gen_push_constant、gen_push_segment(seg, idx)、gen_pop_segment(seg, idx)、gen_push_temp、gen_pop_temp、gen_push_pointer、gen_pop_pointer、gen_push_static、gen_pop_static,再加上gen_add之类的一堆算术函数。看起来函数多,实际上每个函数都很短,八到十二行汇编,一眼能读完。
这种拆分还有个好处的延伸:当你在 Project 08 里需要处理call和return时,可以直接复用这些生成函数去压返回地址、恢复段指针,不需要重写栈操作逻辑。前期多花的十分钟结构化时间,后面会还给你好几个小时。
2.3 输出层:文件命名与写盘
输出文件名直接把输入文件的.vm后缀换成.asm即可。Project 07 阶段一次只处理一个文件,所以不用考虑多文件合并的问题——那是 Project 08 才需要面对的。写盘时用普通的文本模式、UTF-8 编码、每行末尾换行,就足够被官方汇编器正确识别。
有个小细节值得注意:官方汇编器对空行和注释行是宽容的,你的输出里可以带注释,比如在每条命令生成的汇编前面加一行// push local 3,调试时对着.asm看会舒服很多。但别加得太多,否则文件会膨胀得离谱。
3. 栈运算命令的翻译拆解
3.1 add/sub/neg:三行套路走天下
add的语义是弹出栈顶两个值相加再压回去。对应的汇编是一个固定套路:先把栈指针减一,让 A 指向新的栈顶(也就是第一个操作数),把值读进 D,再把 A 退一格指向第二个操作数,做加法,写回,最后栈指针减一。
@SP AM=M-1 D=M A=A-1 M=D+M @SP M=M-1逐步拆解一下这几行的意图。@SP把 A 指向 RAM[0],也就是栈指针所在单元;AM=M-1是一个组合指令,先把 RAM[0] 的值减一,结果同时写回 RAM[0] 并送进 A 寄存器——注意这里 A 和 M 是同时更新的,M 写的是 SP 的新值,A 拿到的是同一个新值。所以这一条指令执行完,A 指向栈顶元素,SP 已经指向它了。接下来D=M把栈顶值(也就是 y)读进 D;A=A-1把 A 退到次栈顶(x);M=D+M把 x+y 写回次栈顶位置;最后@SP和M=M-1把栈指针再退一格,完成“弹出两个、压回一个”的效果。
sub的模板完全一样,只有M=D+M换成M=M-D。注意顺序:D 里装的是 y(后压入的),M 指向的是 x(先压入的),所以M=M-D算出来的是 x 减 y,正好对应sub的语义。这个顺序很多人第一次会写反,写完跑 StackTest 发现结果是负数,才知道栽在哪。
neg简单得多,只弹一个值、取负、压回,不需要动栈深度:
@SP A=M-1 M=-MA=M-1让 A 指向栈顶元素(SP 指向的是下一个空位),M=-M原地取负。注意 Hack 的 ALU 支持M=-M这种一元运算,直接写就行。
3.2 and/or/not:按位运算与补码的关系
and和or的模板跟add完全同构,只需要把运算部分换掉:
@SP AM=M-1 D=M A=A-1 M=D&M @SP M=M-1or就是把D&M换成D|M。Hack 的 ALU 原生支持这些位运算,所以写起来毫无压力。
not更简单,一行原地取反:
@SP A=M-1 M=!M这里的!是按位取反,不是逻辑非。这一点必须说清楚,因为 Hack 平台用的是 16 位补码表示,not在 VM 规范里的定义就是按位取反,而按位取反恰好等价于“取负再减一”。在补码体系下,not x等于-x-1,这个恒等式在后面处理负数分支条件时会派上用场。
顺带提一句,很多人第一次看到!M会以为是逻辑非(把非零变零、零变一),这个误会会让你在 StackTest 里看到一堆奇怪的负数值。记住一句话:Hack 里没有独立的逻辑非,逻辑非要靠比较命令实现。
3.3 eq/gt/lt:比较命令与唯一标签
三条比较命令的模板结构相同,差别只在跳转条件。以eq为例:
@SP AM=M-1 D=M A=A-1 D=M-D @EQ_TRUE_0 D;JEQ @SP A=M-1 M=0 @EQ_END_0 0;JMP (EQ_TRUE_0) @SP A=M-1 M=-1 (EQ_END_0)前四行的作用跟 add 一样:把 y 读进 D,把 A 指向 x。然后D=M-D计算 x-y 存入 D。为什么是 x 减 y 而不是 y 减 x?因为后面的条件跳转要判断的是“x 和 y 的关系”,比如lt要判断 x < y,写成 x-y<0 最直接。eq无所谓顺序,D;JEQ只要差为零就跳。gt用D;JGT,lt用D;JLT。
然后是两个分支:真分支把-1写回栈顶位置,假分支写0,最后汇合到同一个结束标签。为什么用-1而不是1表示真?因为 VM 规范明确规定 true 就是全 1,也就是 16 位补码里的 -1。这一点如果记错,后面的if-goto判断就会全线崩溃,因为它是拿栈顶值跟零比较的——用 1 表示真其实也能工作,但跟规范不符,等于给自己埋雷。
标签唯一性靠一个全局计数器解决,每生成一条比较命令就自增一次,标签名可以是EQ_TRUE_3、EQ_END_3这种形式。千万别图省事用固定标签名,那样同一个文件里出现两条eq就会产生重复标签,汇编器要么报错,要么(更糟)默默把跳转指向最后一处定义,你会得到一个“有时候对有时候错”的诡异 bug。
3.4 比较命令里最容易翻车的三处细节
第一处是A=M-1的时机。有些人习惯先写@SP、M=M-1,再写A=M,这样写也没错,但多一条指令。用AM=M-1组合指令能省一条,代价是可读性略降。两种写法都行,关键是别混着用导致 SP 被减了两次。
第二处是分支汇合。真分支和假分支都写完值之后,必须有一个共同出口,否则真分支执行完会掉进假分支里,把刚写的 -1 又覆盖成 0。这个 bug 特别隐蔽,因为单条命令测试时可能发现不了——只有连续栈操作时才暴露。
第三处是0;JMP的必要性。假分支末尾需要无条件跳到结束标签,而 Hack 的无条件跳转语法是0;JMP(因为条件恒为零不成立时……实际上0;JMP是惯例写法,等价于无条件跳转)。写成@EQ_END_0、0;JMP两行,别漏了。
4. 内存访问命令的翻译拆解
4.1 段与内存地址的映射总表
在动手写 push/pop 之前,必须把这张映射表刻进脑子里。Hack 平台把 RAM 的前 16 个单元预留给特殊用途,从 16 号单元开始才是通用数据区(也是 static 段的地盘)。
| 段名 | 基址 | 寻址方式 | 备注 |
|---|---|---|---|
| SP | RAM[0] | 直接 | 栈指针,指向下一个空位 |
| LCL | RAM[1] | 直接 | local 段基址 |
| ARG | RAM[2] | 直接 | argument 段基址 |
| THIS | RAM[3] | 直接 | this 段基址,也是 pointer 0 |
| THAT | RAM[4] | 直接 | that 段基址,也是 pointer 1 |
| temp | RAM[5]–RAM[12] | 固定地址 | 8 个临时槽,基址相当于 5 |
| static | RAM[16] 起 | 按文件名分配 | 每个文件独立命名空间 |
| constant | 无 | 立即数 | 只支持 push |
看懂这张表,等于看懂了一半的翻译逻辑。local、argument、this、that四个段是“间接寻址”:基址存在一个固定 RAM 单元里,实际地址是“基址内容 + index”。temp是“直接寻址”:RAM[5+index],不需要读基址。pointer虽然也是间接,但它特殊在 index 只有 0 和 1,直接对应两个指针单元本身。static特殊在它要按文件分配,汇编器负责从 16 开始给每个文件名.索引分配一个地址。
4.2 push 的通用模板与 constant 特例
以push local 3为例,目标是把 LCL 基址加 3 那个位置的值压到栈顶:
@3 D=A @LCL A=D+M D=M @SP A=M M=D @SP M=M+1逐行看意图:@3、D=A把索引 3 装进 D;@LCL把 A 指向存基址的单元;A=D+M是核心——把索引加上基址内容,结果放进 A,于是 A 指向了目标内存位置;D=M取值;@SP、A=M让 A 指向栈顶空位;M=D写入;最后@SP、M=M+1栈指针加一。总共十条指令,是整章里最长的一类模板。
push constant是特例,因为常量不需要读内存:
@7 D=A @SP A=M M=D @SP M=M+1六条指令搞定。注意常量的合法范围是 0 到 32767,因为 Hack 的 A 指令只能装 15 位非负立即数。写翻译器时可以不做范围检查,但如果你的测试用例里出现了负常量,说明要么输入有问题,要么你的解析出错了。
这里有个可以提早优化的点:@SP、A=M、M=D、@SP、M=M+1这五条,可以压成三条:
@SP M=M+1 A=M-1 M=D思路是先把 SP 加一,再让 A 回退一格指向刚才的空位。效果完全一样,但少两条指令。Project 07 阶段我不建议新手用这个优化,因为它把“先写值再移动指针”的直觉顺序打乱了,调试时容易看晕。等所有测试都过了,再回头做这种压缩,当作练手。
4.3 pop 的通用模板与 R13 中转的必要性
pop local 3是整个章节里最容易写错的一条,因为它涉及一个关键的中转问题。先看正确写法:
@3 D=A @LCL D=D+M @R13 M=D @SP AM=M-1 D=M @R13 A=M M=D前六行的意图是先把目标地址算出来,存进 R13。为什么必须中转?因为计算目标地址的过程会占用 D 寄存器,而取栈顶值也要用 D。如果直接一口气写下去,D 里的值会被覆盖,最后写错位置。R13、R14、R15 是课程约定留给翻译器用的三个临时单元,随便挑一个都行,我习惯用 R13。
后面五行是“弹栈并写入”:@SP、AM=M-1让 SP 减一并让 A 指向栈顶元素,D=M把值读进 D,@R13把 A 指向暂存地址,A=M让 A 变成目标地址,M=D写入。到这里弹栈和写入全部完成。
这个模板平均十二到十四条指令,是全部命令里最长的。写的时候建议直接抄,别自己发挥,因为这个顺序是经过验证的,改任何一处都可能引入隐蔽错误。我见过有人把@R13、M=D和@SP、AM=M-1的顺序调换,结果就是“有时候对、有时候把栈顶写坏”,排查起来极其痛苦。
4.4 temp、pointer、static 三个特殊段的处理
temp段的处理比 local 简单,因为不需要读基址。push temp 3直接算 RAM[8]:
@3 D=A @R5 A=D+A D=M @SP A=M M=D @SP M=M+1注意这里是A=D+A而不是A=D+M,因为 R5 这个位置存的是数据,不是指针。这个小差别是很多人栽跟头的地方——写惯了A=D+M之后,看到 temp 也想套,结果读出来的是 R5 单元里的内容当地的址,直接飞到不知道哪里去。
pop temp 3同理,先把R5+3算出来存 R13,再弹栈写入:
@3 D=A @R5 D=D+A @R13 M=D @SP AM=M-1 D=M @R13 A=M M=Dpointer段更简单,因为它直接对应两个固定单元,index 只有 0 和 1:
// push pointer 0 @THIS D=M @SP A=M M=D @SP M=M+1 // pop pointer 1 @SP AM=M-1 D=M @THAT M=Dstatic段的坑在于变量命名。push static 5不能翻译成@5,那样会读到 temp 段去。正确做法是用一个包含文件名的符号,比如输入文件叫StaticTest.vm,就生成@StaticTest.5,汇编器遇到这种带点的符号会自动从 RAM[16] 开始分配地址。这就是为什么 static 段被称为“每个文件独立的全局变量”——同一个文件里的static 5永远指向同一个单元,不同文件的static 5互不干扰。如果你的翻译器只处理单文件,这个命名看起来有点多余,但 Project 08 处理多文件时它就是救命的设计。
4.5 非法命令的防御
pop constant是唯一需要显式拒绝的组合。我的做法是在pop分支里遇到constant直接抛异常或打印错误信息后退出,别让它静默生成一堆垃圾汇编。同样,pointer的 index 超出 0 到 1、temp的 index 超出 0 到 7,都可以加断言。这些检查写起来三五行,但能帮你把“输入有问题”和“翻译逻辑有问题”区分开,调试效率差好几倍。
5. 实操:从写完到跑通测试的完整流程
5.1 准备与第一轮冒烟测试
官方给的测试集有五个:SimpleAdd、StackTest、BasicTest、PointerTest、StaticTest。建议按这个顺序跑,因为难度是递增的。第一轮别急着跑全部,先拿SimpleAdd.vm做冒烟测试——它里面只有几条 push constant 和 add,如果这个都过不了,说明基础模板有问题,继续往下跑纯属浪费时间。
具体操作是:用翻译器生成SimpleAdd.asm,打开 CPU Emulator,先加载官方提供的SimpleAdd.tst测试脚本,再加载汇编文件,然后运行。这个顺序不能反,因为.tst脚本里写好了对比逻辑和期望输出。跑完之后看模拟器的输出区,如果打印的是Comparison ended successfully,说明过了;如果是一堆值不匹配的报错,就要看第一个不匹配出现在哪一步。
一个实用技巧:在 Emulator 里打开 RAM 视图,同时盯着 R0(SP)和栈附近的几个单元。执行一条 push 之后,SP 应该加一,新值出现在原来的 SP 位置;执行一条 pop 之后,SP 应该减一,原来的位置内容理论上还在(我们并不清理),但已经不属于栈了。养成盯着 SP 和栈顶看习惯的人,排错速度会比别人快好几倍。
5.2 用 tst 和 cmp 做回归
每个测试集都配了.tst和.cmp两个文件,前者是测试脚本,后者是期望输出。.tst脚本会调用内置的比较函数,逐行比对模拟器输出的值和.cmp里记录的值。这一套机制非常省心,你不需要自己写断言,只要保证翻译正确就行。
跑测试的推荐节奏是:每改一次翻译器,把五个测试全跑一遍。别只跑改动的那个,因为你很可能在修 A 的时候不小心弄坏了 B。我自己就干过这种事——为了让pop少两条指令,改了模板顺序,结果PointerTest挂了,因为 pointer 的 pop 我之前是单独写的另一套模板,改的时候忘了同步。全量回归跑一遍三十秒,能省掉半小时的困惑。
StackTest值得单独说一下,因为它同时覆盖了算术命令和比较命令,而且会连续压入大量数值来测试栈深度。如果你的比较命令标签不唯一,或者分支汇合写错了,这个测试大概率会在中段某个位置开始输出一堆错误的 -1 和 0。看到这种“前面全对、后面全错”的现象,第一反应就应该是标签冲突。
5.3 断点观察与栈内容追踪
Emulator 支持在指令上打断点。排查栈相关问题时,最有效的做法是在出问题的那条 VM 命令对应的第一行汇编上打断点,然后单步执行,每一步都记录 SP 和栈顶三个单元的值。手边备一张纸,把变化画出来,比盯着屏幕空想快得多。
举个例子,如果你怀疑pop local 0写错了位置,可以在它之前打断点,记下 LCL 的值和栈顶值,单步走完整个模板,再看 RAM[LCL] 是不是变成了刚才的栈顶值,SP 是不是减了一。如果 SP 减了但值没写进去,多半是 R13 中转那步的顺序有问题;如果值写进去了但 SP 没动,多半是漏了AM=M-1。
这种手动追踪看起来很笨,但它能让你对每条模板的内存行为形成肌肉记忆。等到了 Project 08 处理函数帧的时候,这种直觉会让你少走大量弯路。
5.4 一份可以直接对照的模板清单
把八种生成场景的模板整理成一张表,写代码时对照着抄,出错概率会低很多。下面这张表是我自己实现时用的,指令数一栏是经验值,不同写法会有几条出入。
| 命令类型 | 代表命令 | 指令条数 | 关键指令 | 易错点 |
|---|---|---|---|---|
| 二元算术 | add、sub、and、or | 8 | M=D+M、M=M-D | sub 的减数顺序 |
| 一元算术 | neg、not | 3 | M=-M、M=!M | 混淆逻辑非与按位取反 |
| 比较 | eq、gt、lt | 16 至 18 | D=M-D加条件跳转 | 标签唯一性、分支汇合 |
| push constant | push constant 7 | 6 | D=A | 常量范围 |
| push 间接段 | push local 3 | 10 | A=D+M | 与 temp 段混淆 |
| pop 间接段 | pop local 3 | 13 | R13 中转 | 中转顺序颠倒 |
| push/pop temp | push temp 3 | 10 至 13 | A=D+A、@R5 | 误用A=D+M |
| push/pop pointer | pop pointer 1 | 5 | @THAT、@THIS | index 范围 |
| push/pop static | push static 5 | 6 至 13 | @文件名.5 | 漏掉文件名前缀 |
6. 踩坑实录与排查速查
6.1 SP 跑飞:最常见的三类原因
栈指针跑飞是这一章最典型的症状,表现出来就是测试跑到一半突然开始报错,或者模拟器直接卡死。根据我自己的踩坑记录,原因基本集中在三类。
第一类是 push 之后忘了加 SP,或者 pop 之前忘了减 SP。这种错误的表现是栈内容整体错位一格,后续所有操作全歪。排查方法是单步执行一条 push,看 SP 有没有变;如果没有,去检查模板末尾是不是漏了M=M+1。
第二类是AM=M-1用错位置。这个组合指令同时改了 A 和 M,如果后面又写了@SP、M=M-1,等于减了两次。我曾经在比较命令里犯过这个错,导致每次比较之后栈深度少两个,跑几条命令就崩了。修法很简单:要么全用组合指令,要么全用分离指令,别混。
第三类是在 pop 模板里把AM=M-1和@R13的顺序搞反了。正确的顺序一定是先算目标地址存 R13,再弹栈取值,最后写回。如果先弹栈,取值过程会占用 D,后面算地址时 D 已经被覆盖,写进去的就是垃圾。
6.2 比较结果反了:一个符号位引发的血案
如果lt和gt的结果互换,或者eq在应该为真的时候给出了 0,问题多半出在减法顺序或者跳转条件上。检查两点:一是D=M-D里谁是减数,D 装的是栈顶的 y,M 指向的是次栈顶的 x,所以这个式子是 x-y,对应lt就是 x<y,语义正确;二是跳转指令,lt用JLT、gt用JGT、eq用JEQ,别记混。
还有一种更隐蔽的情况:真值用 1 而不是 -1。这样在单独测试比较命令时看不出来(因为非零即真),但一旦后面有if-goto拿它跟零比较,或者在别的运算里把它当数用,就会出问题。建议在 Project 07 阶段就养成写 -1 的习惯,别等 Project 08 再改。
6.3 static 变量错位:文件名前缀不能省
StaticTest挂掉的人里,八成是把push static 5翻译成了@5。这样读到的其实是 temp 段的第一个槽,而 temp 段在测试里正好被别的命令用过,值当然对不上。正确的符号是@StaticTest.5这种形式,前面必须带输入文件的文件名(不含扩展名)。
这里还有一个次生问题:如果你在调试时手动改过输入文件名,比如把StaticTest.vm复制成mytest.vm再跑,那生成的符号就变成@mytest.5,而.cmp文件是按原文件名生成的期望值,两者不匹配会导致测试失败。所以测试时别随便改文件名,要改就同时改.tst和.cmp。
6.4 排查速查表
把上面这些症状整理成一张表,出问题时从上往下对一遍,基本能定位到具体模板。
| 症状 | 最可能原因 | 快速验证方式 | 修复动作 |
|---|---|---|---|
| SP 一路增长不回落 | push 后漏加 SP 或 pop 未减 | 单步一条 push 看 R0 | 检查模板首尾指针操作 |
| 栈内容整体错位一格 | 某条命令多减或多加了一次 SP | 对比命令前后 R0 差值 | 排查 AM=M-1 与 M=M-1 混用 |
| 比较结果恒为 0 | 分支汇合写错,真分支被覆盖 | 单步走完比较模板 | 补上 0;JMP 与结束标签 |
| 汇编器报重复标签 | 比较命令用了固定标签名 | 搜索 asm 里同名标签 | 引入全局计数器 |
| pop 后原位置值未变 | 目标地址算错或写到了别处 | 断点看 R13 的值 | 检查 R13 中转顺序 |
| temp 段读数异常 | 误用 A=D+M 读了 R5 内容 | 断点看 A 的最终值 | 改为 A=D+A |
| static 测试全错 | 漏掉文件名前缀 | 看 asm 里的符号名 | 拼接文件名与索引 |
还有一条经验值得单独拎出来:先让所有测试通过,再考虑优化。Project 07 的模板写出来臃肿,一条 VM 命令展开成十几条汇编,看着确实不舒服,代码体积能膨胀十几倍。但这是栈式虚拟机的固有代价,工业界的 JVM、CPython 也都是这么干的,只是它们在翻译之后还有一层 JIT 来弥补。你在这一章要做的是把语义写对,不是把指令数压到最少。等功能全通了,再回头把 push 的五条压成三条、把 pop 的十四行压成十二行,那时候你对内存模型的理解已经足够深,优化不会引入新 bug。
最后分享一点个人体会。我在写这个翻译器的时候,卡得最久的不是任何一条复杂模板,而是sub的减法顺序。当时觉得“减法不就是减吗”,随手写了M=D-M,跑StackTest前面几条全过,到中间某条开始输出一堆负数,我盯着屏幕看了快四十分钟才反应过来。后来养成了一个习惯:每写完一个模板,先在纸上把栈画出来,标上 x 和 y 的位置,再逐行推演 D 和 A 的变化,推演对了再写代码。这个笨办法让我在 Project 08 处理函数调用时省了大量时间——因为函数帧本质上也是一堆精心安排的 push 和 pop,只不过段基址会变而已。这套栈运算的肌肉记忆,是整个课程里最值得投资的部分。