ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Nand2Tetris 全项目通关:从与非门到俄罗斯方块的计算机系统构建指南

Nand2Tetris 全项目通关:从与非门到俄罗斯方块的计算机系统构建指南 简介这份资源是《Nand2Tetris从NAND到TETRIS的计算机系统构建》课程的完整项目合集面向希望深入理解计算机底层运作机制的学习者无论是计算机专业学生还是自学硬件的开发者都能借此从逻辑门起步逐步搭建出可运行游戏的完整计算机系统。压缩包共556个文件约544KB涵盖hdl硬件描述、tst测试脚本、cmp比较文件、jack高级语言源码、asm汇编程序、vm虚拟机代码以及c、h等辅助实现并附有md说明与pdf文档结构清晰便于按章节检索。目前已有523人学习下载说明该课程在系统能力训练方面具有较高认可度。资源完整覆盖从NAND门、触发器、加法器、寄存器到RAM、CPU、总线与I/O的硬件构建再到汇编语言、虚拟机设计与编译器实现最终以TETRIS游戏作为综合项目收尾读者可亲手实践每一层抽象掌握软硬件协同的核心原理是深入计算机体系结构不可多得的实战起点。1. 从与非门到俄罗斯方块一套课把计算机栈全打通很多人学计算机学了四年会写 Java、会调 PyTorch但被问到「一条a b c到底怎么变成电信号」时脑子里是一片空白的。Nand2Tetris 这套课程就是冲着这个空白来的它从最基础的一个 NAND 逻辑门出发让你亲手搭出 ALU、CPU、汇编器、虚拟机、编译器最后跑起一个能玩俄罗斯方块的完整系统。标题里说的「所有项目」指的就是这套课程从 Project 1 到 Project 12 的全部十二个关卡覆盖硬件描述、体系结构、编译原理、操作系统四个层次。它适合两类人一类是科班出身但知识全是孤岛、想打通任督二脉的另一类是非科班、想真正理解「计算机到底怎么跑起来」的。整套东西不依赖任何特定学校或平台工具链是开源的一台普通笔记本就能跑完。2. 先把工具链和十二个项目的地图铺开在动手写第一行 HDL 之前得先搞清楚这套课程到底要你交什么、用什么交。很多人一上来就闷头写代码写到 Project 4 才发现自己连测试脚本怎么跑都没弄明白返工成本极高。这一章先把工具链、项目地图和最小验证流程讲清楚后面几章再逐个啃硬骨头。2.1 硬件模拟器与 CPU 模拟器的分工Nand2Tetris 的工具链分成两半前半段Project 1 到 5跑在硬件模拟器里后半段Project 6 到 12跑在 CPU 模拟器和软件工具链里。硬件模拟器负责加载.hdl文件、跑.tst测试脚本、比对.cmp期望输出CPU 模拟器负责加载.hack二进制、执行机器码。这两套东西的边界一定要清楚Project 5 之前你写的全是硬件描述Project 6 开始你写的汇编器、VM、编译器都是跑在你自己搭的 CPU 上的软件。常见做法是先把官方工具包解压到一个固定目录然后建一个自己的工作区每个项目一个子目录。我一般会这样组织# 工作区目录结构每个项目独立避免互相污染 mkdir -p nand2tetris/{projects,tools} cd nand2tetris # tools 放官方模拟器和工具projects 放自己的实现 # 每个项目一个子目录例如 projects/01、projects/02 ... ls tools # 典型会看到 HardwareSimulator、CPUEmulator、Assembler 等可执行脚本这段命令本身没什么玄机关键是「工具和实现分离」这个习惯。工具目录只读实现目录随便改出问题的时候可以直接把实现目录删掉重来不会把工具链搞坏。参数上唯一要注意的是路径里不要有空格和中文硬件模拟器对路径的处理比较脆弱带空格的路径在某些系统上会直接加载失败。2.2 十二个项目的依赖关系与验收标准这十二个项目不是并列的是严格串行的。Project 1 的芯片会被 Project 2 复用Project 2 的 ALU 会被 Project 3 复用一路往上叠。所以任何一个项目没通过测试后面的都会连锁失败。下面这张表是我自己整理的项目地图把每个项目的产出物和验收方式列清楚项目产出物验收方式依赖1基础逻辑门 HDL.tst脚本比对.cmp无2加法器、ALU同上项目13寄存器、RAM同上项目24汇编程序汇编器执行 模拟器项目35CPU、计算机跑测试程序项目3、46汇编器汇编.asm比对.hack项目47VM 翻译器算术比对.asm项目68VM 翻译器控制流同上项目79高级语言程序在 VM 上跑项目810编译器语法分析比对 XML项目911编译器代码生成生成.vm并跑通项目1012操作系统跑系统测试项目11这张表的价值在于当你在 Project 8 卡住的时候能立刻判断是 Project 8 本身的问题还是 Project 7 的 VM 翻译器埋了雷。血泪经验是Project 7 和 8 的边界最容易出问题因为两者共用一套 VM 命令集但控制流命令的翻译逻辑和算术命令完全不同很多人会把goto和if-goto的标签生成搞混。2.3 跑通第一个测试Project 1 的 Nand 门理论说再多不如跑通一个。Project 1 的第一个芯片就是 Nand 门本身它是所有其他门的原子。官方会给你一个Nand.hdl的骨架你要做的是确认它已经正确实现因为 Nand 是原语不需要你实现然后基于它写出 Not、And、Or 等门。// Not.hdl基于 Nand 实现非门 // 逻辑Not(a) Nand(a, a) CHIP Not { IN a; OUT out; PARTS: Nand(aa, ba, outout); }这段 HDL 的逻辑说明Nand 门的真值是「两个输入都为 1 时输出 0否则输出 1」。把同一个输入 a 接到 Nand 的两个输入端当 a0 时 Nand(0,0)1当 a1 时 Nand(1,1)0正好就是非门。参数说明IN a声明输入引脚OUT out声明输出引脚PARTS段里每个芯片实例的引脚名必须和芯片定义严格对应大小写敏感。跑测试的时候用硬件模拟器加载Not.hdl再加载Not.tst如果输出和Not.cmp完全一致这个芯片就算过了。提示HDL 里所有引脚连接都是「按名字绑定」不是按位置。写Nand(aa, ba, outout)的时候等号左边是 Nand 芯片的引脚名右边是你当前芯片的引脚名搞反了会直接报错。3. 硬件层从逻辑门到 CPU 的四个项目怎么啃Project 1 到 5 是硬件层也是整套课程里最「反直觉」的部分。软件出身的人习惯了「函数调用」突然要面对「信号在同一时刻并行传播」这件事脑子会转不过来。这一章把硬件层的四个关键节点拆开讲每个节点给出可复现的实现思路和参数细节。3.1 组合逻辑与时序逻辑的分界线Project 1 和 2 是纯组合逻辑输出只取决于当前输入没有记忆。Project 3 开始引入时序逻辑输出取决于当前输入和历史状态这就是寄存器和 RAM 的由来。这条分界线一定要在脑子里划清楚因为组合逻辑可以用真值表硬推时序逻辑必须考虑时钟周期。组合逻辑的典型代表是 ALU。Project 2 的 ALU 要支持六种控制位组合实现加、减、与、或、取反等操作。我一般会先把控制位和操作的对应关系列成表再逐条实现// ALU.hdl 的核心片段展示控制位如何选择操作 // zx: 输入 x 置零nx: x 取反zy: 输入 y 置零ny: y 取反 // f: 1 表示加法0 表示与no: 输出取反 CHIP ALU { IN x[16], y[16], zx, nx, zy, ny, f, no; OUT out[16], zr, ng; PARTS: // 第一步按 zx/nx 处理 x Mux16(ax, bfalse, selzx, outx1); Not16(inx1, outnotx1); Mux16(ax1, bnotx1, selnx, outx2); // 第二步按 zy/ny 处理 y逻辑同上 // 第三步按 f 选择加法还是与 // 第四步按 no 决定是否取反输出 // 第五步根据 out 计算 zr是否为零和 ng是否为负 }逻辑说明这段代码展示了 ALU 的「分层处理」思路先处理 x再处理 y再选操作最后算标志位。参数说明x[16]表示 16 位总线Mux16是 16 位二选一多路器sel为 1 时选 b 端。标志位zr和ng是组合逻辑的输出zr为 1 表示 out 全零ng为 1 表示 out 的最高位为 1补码负数。这里最容易翻车的地方是zr的计算很多人忘了 16 位全零判断需要把每一位或起来再取反。3.2 寄存器与 RAM 的时钟驱动写法Project 3 的寄存器是时序逻辑的入门。一个 1 位寄存器可以用 D 触发器实现但 HDL 里不直接给你触发器而是给你一个DFF原语。你要做的是用DFF加上多路器实现「load 为 1 时更新为 0 时保持」的寄存器。// Bit.hdl1 位寄存器load 控制是否写入 CHIP Bit { IN in, load; OUT out; PARTS: // DFF 输出当前存储的值 DFF(inin, outout); // 但 DFF 每个时钟都写入需要 Mux 在 load0 时保持旧值 // 这里需要把 out 反馈回来形成保持逻辑 }逻辑说明DFF是边沿触发的每个时钟上升沿都会把输入锁存到输出。要实现「load0 时保持」必须用多路器在load0时把DFF的输出反馈回它的输入。参数说明load是写使能信号in是待写入的数据。这里有个经典坑HDL 里不允许组合逻辑环路但DFF的反馈是允许的因为DFF打断了组合路径。如果你把Mux的输出直接接回Mux的输入而不经过DFF模拟器会报「组合环路」错误。RAM 的实现在 Project 3 里是分层的先做 1 位寄存器再做 16 位寄存器再做 n 位寄存器组最后拼成 RAM8、RAM64、RAM512 等。每一层都是「地址解码 多路选择」的组合。我一般会先写一个通用的 RAM 模板然后逐层实例化避免重复劳动。3.3 汇编与 CPU机器码到底长什么样Project 4 是写汇编程序Project 5 是搭 CPU。这两个项目要连起来看因为 CPU 执行的机器码就是你 Project 4 里写的汇编翻译过来的。Hack 平台的指令集非常精简只有 A 指令和 C 指令两种。A 指令是value把 value 加载到 A 寄存器C 指令是destcomp;jump做计算并可能跳转。// 一个简单的汇编片段计算 RAM[0] RAM[1] 存入 RAM[2] 0 // A 指令A0 DM // C 指令D RAM[A] RAM[0] 1 // A1 DDM // D D RAM[1] 2 // A2 MD // RAM[2] D逻辑说明这段汇编展示了 Hack 平台的基本操作模式用 A 寄存器做地址指针用 D 寄存器做数据暂存。参数说明value里的 value 可以是常数也可以是符号汇编器负责把符号解析成地址。M表示当前 A 指向的内存单元D是数据寄存器。Project 5 的 CPU 要能正确解析这些指令核心是「指令解码器」根据指令的最高位判断是 A 指令还是 C 指令再根据 C 指令的控制位驱动 ALU 和寄存器。CPU 的实现里程序计数器PC是最容易出错的部分。PC 要支持三种行为复位、按顺序加一、跳转到 A 寄存器的值。这三种行为由 C 指令的 jump 位控制。我一般会先把 jump 位的真值表列出来再写 HDL避免逻辑遗漏。3.4 硬件层的验证节奏别等全写完再测硬件层最大的坑是「攒着一起测」。很多人写完 Project 1 到 3 的所有芯片最后一起跑测试结果一个错导致后面全错排查起来像大海捞针。正确的节奏是每写完一个芯片立刻跑它的.tst通过了再写下一个。硬件模拟器的测试脚本是自动比对的通过就是通过不通过会告诉你哪一行输出不匹配。注意硬件模拟器的测试脚本里output-list决定了输出格式如果你改了芯片的引脚顺序但没改测试脚本比对会失败。这不是你的逻辑错是格式错别浪费时间在逻辑上。4. 软件层汇编器、VM 和编译器的实现要点Project 6 到 12 是软件层从汇编器一路做到操作系统。这一层的核心是「翻译」把一种表示翻译成另一种表示。汇编器把汇编翻译成机器码VM 翻译器把 VM 命令翻译成汇编编译器把高级语言翻译成 VM 命令。每一层都是独立的但依赖下一层的正确性。4.1 汇编器的两遍扫描策略Project 6 的汇编器要把.asm文件翻译成.hack文件。核心难点是符号解析变量符号要分配内存地址标签符号要解析成指令地址。常见做法是两遍扫描第一遍收集所有标签符号和它们的指令地址第二遍翻译指令并处理变量符号。# 汇编器核心逻辑两遍扫描 def assemble(asm_lines): # 第一遍收集标签符号 symbols {} rom_address 0 for line in asm_lines: line strip_comment(line) if not line: continue if line.startswith((): # 标签定义 label line[1:-1] symbols[label] rom_address else: rom_address 1 # 第二遍翻译指令变量符号从 16 开始分配 next_ram 16 output [] for line in asm_lines: line strip_comment(line) if not line or line.startswith((): continue if line.startswith(): symbol line[1:] if symbol.isdigit(): value int(symbol) elif symbol in symbols: value symbols[symbol] else: symbols[symbol] next_ram value next_ram next_ram 1 output.append(format_a_instruction(value)) else: output.append(format_c_instruction(line)) return output逻辑说明第一遍只处理标签因为标签的地址是它在 ROM 中的位置和变量无关。第二遍处理变量变量从 RAM 地址 16 开始分配因为 0 到 15 被预定义符号占用了R0 到 R15、SP、LCL、ARG 等。参数说明strip_comment要去掉//后面的内容和空白行format_a_instruction把十进制转成 16 位二进制format_c_instruction解析destcomp;jump三部分并查表转成二进制。这里最容易翻车的是预定义符号的处理很多人忘了 R0 到 R15 是预定义的导致变量分配从 0 开始覆盖了系统保留区。4.2 VM 翻译器的函数调用协议Project 7 和 8 的 VM 翻译器是整套课程里最烧脑的部分因为它涉及函数调用协议。VM 命令里的call、function、return要翻译成一大段汇编涉及栈帧的建立和销毁。这个协议是 Hack 平台约定的必须严格遵守否则函数调用会乱套。函数调用的核心是栈帧布局。调用函数时调用方要把返回地址、当前 LCL、ARG、THIS、THAT 压栈然后跳转到函数体。函数体开头要建立新的栈帧把 SP 赋值给 LCL把 ARG 赋值给 ARG。返回时要把返回值放到 ARG 指向的位置恢复调用方的栈帧跳回返回地址。// call f n 的翻译模板n 是参数个数 // 1. 压入返回地址 RETURN_ADDR DA SP AM MD SP MM1 // 2. 压入 LCL、ARG、THIS、THAT // 3. ARG SP - 5 - n // 4. LCL SP // 5. 跳转到 f // 6. 定义返回地址标签逻辑说明这段模板展示了call命令的完整翻译每一步都不能少。参数说明n是参数个数RETURN_ADDR是调用后返回的地址标签。这里最坑的是ARG SP - 5 - n这个计算5 是返回地址加四个寄存器LCL、ARG、THIS、THAT占用的栈空间n 是参数个数。算错了会导致函数体里访问参数时偏移错误表现为「函数读到的参数值不对」。4.3 编译器的语法分析与代码生成Project 10 和 11 是编译器分两阶段语法分析生成 XML代码生成生成 VM 命令。语法分析相对机械按 Jack 语言的文法递归下降就行。代码生成才是难点因为要把面向对象的 Jack 代码翻译成面向过程的 VM 命令。Jack 语言支持类、方法、构造函数、字段、静态变量。编译器的核心任务是维护符号表记录每个标识符的类型类、方法、字段、参数、局部变量和它的作用域。符号表用哈希表实现键是标识符名值是类型和索引。# 符号表的核心结构 class SymbolTable: def __init__(self): self.class_scope {} # 静态变量和字段 self.subroutine_scope {} # 参数和局部变量 self.counts {static: 0, field: 0, arg: 0, var: 0} def define(self, name, type_, kind): # kind 是 static/field/arg/var 之一 index self.counts[kind] self.counts[kind] 1 if kind in (static, field): self.class_scope[name] (type_, kind, index) else: self.subroutine_scope[name] (type_, kind, index) def lookup(self, name): # 先查子程序作用域再查类作用域 if name in self.subroutine_scope: return self.subroutine_scope[name] if name in self.class_scope: return self.class_scope[name] return None逻辑说明符号表分两级作用域子程序作用域优先于类作用域。参数说明kind决定变量存储在哪static和field存在类级别arg和var存在子程序级别。索引是同类变量中的序号用于生成 VM 命令时的偏移。这里最容易出错的是field和static的区分field是每个对象独立的static是所有对象共享的翻译成 VM 命令时field用this段static用static段。4.4 操作系统的实现顺序Project 12 是操作系统包含 Math、String、Array、Output、Screen、Keyboard、Memory、Sys 八个模块。这些模块用 Jack 语言写编译后跑在你自己搭的 VM 和 CPU 上。实现顺序很重要因为模块之间有依赖Sys 依赖 MemoryMemory 依赖 ArrayOutput 依赖 Screen 和 String。我一般会按这个顺序做先做 Math 和 Array基础工具再做 String字符串处理再做 Memory内存分配再做 Screen 和 Output显示再做 Keyboard输入最后做 Sys系统初始化。Sys 的init函数要初始化栈指针、调用Memory.init、调用Main.main最后进入死循环。这个顺序能保证每个模块实现时它依赖的模块已经可用。5. 避坑与排查那些让项目卡三天的细节这套课程看起来每一步都有测试应该很顺但实际做下来有些坑能让老手都卡三天。这一章把最常见的五个坑列出来按「现象 → 原因 → 解决」写清楚。5.1 硬件模拟器报「组合环路」现象加载 HDL 文件后模拟器直接报错提示存在组合环路无法求值。原因HDL 里的信号传播是即时的如果你把某个芯片的输出直接或间接接回它的输入且中间没有时序元件DFF就形成了无限循环。解决检查反馈路径上是否有 DFF。寄存器的保持逻辑必须经过 DFF不能纯组合反馈。如果确实需要组合反馈说明设计有问题要重新考虑用时序逻辑。5.2 汇编器翻译结果和期望差一位现象汇编器输出的.hack文件和官方期望文件比对内容几乎一样但某些行差一位。原因多半是标签地址计算错了。标签的地址是它在 ROM 中的指令序号不是行号。如果汇编文件里有空行或注释行行号和指令序号不一致用行号当标签地址就会错。解决第一遍扫描时只对实际指令计数跳过空行、注释行和标签定义行。标签定义行本身不占 ROM 地址。5.3 VM 翻译器函数调用后栈不平衡现象跑 VM 测试时函数调用后栈指针位置不对后续操作全部错位。原因call和return的栈操作不对称。call压入 5 个值返回地址加四个寄存器return要弹出这些值并恢复。如果return少弹或多弹了栈就不平衡。解决把call和return的翻译模板对照着看确保压入和弹出的数量一致。return的模板里返回值要放到 ARG 指向的位置然后 SP 要恢复到调用前的位置。5.4 编译器生成的 VM 命令跑不通现象编译器生成的.vm文件在 VM 翻译器里跑报「未知命令」或「段索引越界」。原因多半是符号表索引错了。比如field变量的索引从 0 开始但翻译成 VM 命令时用了错误的段名或索引。解决在代码生成阶段打印符号表逐个核对每个变量的类型和索引。field用this段static用static段arg用argument段var用local段。索引从 0 开始不能跳号。5.5 操作系统跑起来就死机现象Project 12 的操作系统编译后跑测试程序直接死机或输出乱码。原因多半是Sys.init的初始化顺序错了或者Memory.alloc的分配逻辑有 bug。Sys.init必须先初始化 SP再调用Memory.init再调用Main.main。如果顺序错了Main.main里的内存分配会用到未初始化的堆。解决在Sys.init里加打印语句如果 Output 已经可用确认每一步都执行到了。Memory.alloc的实现要保证分配的内存块不重叠最简单的做法是用一个指针从头到尾线性分配。提示Project 12 的调试是最痛苦的因为你的操作系统跑在你自己的 CPU 上任何一层出错都会表现为「死机」。建议先把 Project 11 的编译器测透确保生成的 VM 命令正确再上操作系统。6. 进阶怎么把这套东西变成自己的长期资产跑完十二个项目只是开始真正有价值的是把这套东西变成自己的知识骨架。我自己的习惯是每做完一个项目写一篇「反向笔记」不看代码凭记忆把这一层的核心机制画出来画不出来的地方就是没真懂的地方。这个习惯帮我抓出了好几个「以为懂了其实没懂」的点比如 VM 翻译器的函数调用协议我画了三次才画对。进阶用法有几个方向。第一个方向是「换平台」Hack 平台的指令集太精简了你可以试着把 Project 5 的 CPU 扩展成支持更多指令比如乘法、除法、中断。这会逼你重新思考指令解码和控制逻辑。第二个方向是「换语言」把 Jack 编译器扩展成支持更多语言特性比如继承、接口、异常。这会逼你重新设计符号表和代码生成。第三个方向是「换后端」把 VM 翻译器的目标从 Hack 汇编换成真实的汇编比如 RISC-V 或 x86这样你的编译器就能生成真正能跑在硬件上的代码。验证方法上我一般会用「交叉验证」用官方工具跑一遍再用自己写的工具跑一遍两者结果一致才算过。比如汇编器官方有一个 Assembler 工具你可以用它翻译.asm再用自己的汇编器翻译同一个文件比对两个.hack是否完全一致。这个方法能抓出很多边界 bug比如符号解析的优先级、指令编码的位序。最后一个具体技巧把每个项目的测试脚本收集起来做成一个自动化测试套件。每次改代码跑一遍全套测试确保没有回归。这个套件不需要多复杂一个 shell 脚本遍历所有.tst文件调用模拟器跑测试统计通过率就行。我自己的套件跑了三年每次重构都靠它兜底。说个我自己的教训我第一遍做 Project 7 的时候觉得 VM 翻译器太简单两天就写完了测试也过了。结果到 Project 11 编译器的时候生成的 VM 命令在 VM 翻译器里跑不通排查了两天才发现是 Project 7 的call翻译里返回地址的标签生成有重复多个函数调用同一个函数时标签冲突。如果当时写个自动化测试套件跑一遍所有 VM 测试文件这个问题第一天就会暴露。所以别省测试的功夫后悔药没地方买。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进