简介:这份资源是《Nand2Tetris:从NAND到TETRIS的计算机系统构建》课程的完整项目合集,面向希望深入理解计算机底层运作机制的学习者,无论是计算机专业学生还是自学硬件的开发者,都能借此从逻辑门起步,逐步搭建出可运行游戏的完整计算机系统。压缩包共556个文件,约544KB,涵盖hdl硬件描述、tst测试脚本、cmp比较文件、jack高级语言源码、asm汇编程序、vm虚拟机代码以及c、h等辅助实现,完整覆盖从芯片设计到软件编译的各个阶段。目前已有523人学习下载,说明该课程在系统能力训练方面具有较高认可度。资源中包含逻辑门、触发器、加法器、寄存器、RAM与CPU等硬件模块的实现,也涉及汇编语言、虚拟机、编译器和操作系统层面的练习,最终以TETRIS游戏作为综合项目收尾。目录结构清晰,便于按章节检索与复现,适合作为课程实验的参考起点,帮助读者在动手实践中掌握计算机体系结构与软件栈的衔接逻辑。
1. 从一块与非门到能跑俄罗斯方块的计算机:Nand2Tetris 全项目到底在练什么
很多人第一次听到 Nand2Tetris,以为它不过是又一门讲计算机组成的公开课。真按项目一路做下来才会发现,它逼着你从一块与非门开始,亲手搭出 ALU、寄存器、内存、CPU,再往上写汇编器、虚拟机、编译器和操作系统。标题里说的「所有项目」,指的是这套课程从 Project 1 到 Project 12 的完整链路,每一关都有可运行的硬件描述或软件实现,最后能在自己造的机器上跑起俄罗斯方块。
它解决的不是「考试怎么过」,而是「我天天调 API、写业务代码,却说不清一条a = b + c到底在硬件里发生了什么」这种心虚。适合两类人:一类是科班出身但组成原理全靠背的开发者,另一类是想补底层、又不想一上来啃几十万字手册的转行者。硬件部分用 HDL 描述,软件部分用你熟悉的任意语言写,门槛比想象中低,但工作量实打实。
2. 先把工具链和项目地图理清楚:别一上来就闷头写 HDL
Nand2Tetris 最容易劝退新手的不是逻辑难,而是环境没搭对、项目顺序没搞明白,写了两天发现方向偏了。这一章先把「用什么工具、每个项目产出什么、怎么验证」讲透,后面动手才不慌。
2.1 官方工具链的四个组件和各自职责
整套课程围绕四个工具转,理解它们的分工,比记住快捷键重要得多:
| 工具 | 作用 | 你会在哪个阶段用到 |
|---|---|---|
| 硬件模拟器 | 加载.hdl文件,跑芯片逻辑,看波形和输出 | Project 1 到 Project 5 |
| CPU 模拟器 | 加载机器码.hack,单步执行、看寄存器和内存 | Project 4、5、9 之后 |
| VM 模拟器 | 执行.vm文件,验证虚拟机实现 | Project 7、8 |
| 编译器 | 把 Jack 语言编译成 VM 代码 | Project 10、11 |
硬件模拟器负责「芯片对不对」,CPU 模拟器负责「机器码跑不跑得通」,VM 模拟器负责「中间层语义对不对」,编译器负责「高级语言能不能落地」。很多人卡在 Project 5 之后,就是因为一直用硬件模拟器去测本该用 CPU 模拟器验证的东西,白白浪费时间。
提示:每个项目目录里都有一份测试脚本和对比文件,先跑测试再改代码,别凭感觉判断对错。
2.2 十二个项目按依赖关系分成四段
把 12 个项目当成一条流水线,而不是 12 个孤立作业,心态会稳很多:
- 第一段(Project 1–3):组合逻辑和时序逻辑。从 Nand、Not、And 一路搭到 ALU 和寄存器,全是硬件描述。
- 第二段(Project 4–5):机器语言和 CPU。写汇编、搭 CPU,把前面所有芯片拼成一台能执行指令的机器。
- 第三段(Project 6–8):汇编器和虚拟机。把汇编翻译成机器码,再实现一个栈式虚拟机。
- 第四段(Project 9–12):高级语言和操作系统。写编译器、做 OS,最后跑通俄罗斯方块。
这个顺序不能乱。Project 4 的汇编没写熟,Project 6 的汇编器就会写得稀里糊涂;Project 7、8 的 VM 没吃透,Project 10、11 的编译器就是空中楼阁。
2.3 用最小命令跑通第一个 HDL 芯片
以 Project 1 的 Not 芯片为例,先确认工具链能跑起来。假设你已经把课程材料解压到本地目录,进入 Project 1 的文件夹:
# 进入 Project 1 目录,确认文件结构 cd nand2tetris/projects/01 ls # 典型输出:And.hdl Not.hdl Or.hdl Xor.hdl ...打开硬件模拟器,加载Not.hdl,再加载对应的测试脚本Not.tst,点运行。如果输出全是 0 或者报错,说明 HDL 语法或逻辑有问题。一个正确的 Not 实现长这样:
// Not.hdl:用 Nand 实现非门 // 逻辑:Not(a) = Nand(a, a) CHIP Not { IN a; OUT out; PARTS: Nand(a=a, b=a, out=out); }逻辑说明:Nand 的真值是「两个输入都为 1 时输出 0,否则输出 1」。把同一个输入 a 接到 Nand 的两个端口,当 a=0 时 Nand(0,0)=1,当 a=1 时 Nand(1,1)=0,正好就是非门。参数说明:IN声明输入引脚,OUT声明输出引脚,PARTS里每个芯片调用都要写清端口映射,端口名必须和芯片定义一致,写错一个字母模拟器就报「未定义端口」。
这一步跑通,说明工具链、文件路径、HDL 语法三件事都没问题,后面再搭 And、Or、Xor 就是重复这个流程。
3. 硬件层怎么搭:从 Nand 到 CPU 的四个关键节点
硬件部分是整个课程的地基,也是最容易「看起来会了、一测就错」的地方。这一章按依赖顺序拆四个节点,每个节点给出实现思路和验证方法,不堆代码,重点讲清楚为什么这么连。
3.1 组合逻辑:ALU 之前先把多路器和译码器吃透
Project 1 到 Project 3 里,真正卡人的不是 And、Or 这些基础门,而是多路器(Mux)和译码器(DMux)。它们决定了后面 ALU 和内存的选通逻辑。
Mux 的功能是「根据 sel 信号从两个输入里选一个输出」。用基础门实现时,常见做法是先对 sel 取反得到 notSel,然后用四个 And 分别计算a AND notSel、b AND sel,最后 Or 起来。这个结构在 Project 2 的 ALU 里会反复出现。
DMux 反过来,把一个输入分发到两个输出之一。它的实现依赖 And 和 Not 的组合,逻辑上就是「sel 为 0 时走一路,为 1 时走另一路」。
注意:Project 1 的芯片只能用 Nand 搭,不能直接调用 And、Or。到了 Project 2 才允许调用前面已经实现的芯片。这个限制是故意的,逼你理解每个门的底层构成。
ALU 是 Project 2 的核心,它要支持加法、减法、与、或、取反等操作,还要输出状态标志位 zr(结果为零)和 ng(结果为负)。实现时先把控制位翻译成「对 x、y 做什么操作」,再决定输出哪个结果。测试脚本会穷举所有控制位组合,任何一个组合错了都会报出来。
3.2 时序逻辑:寄存器、RAM 和计数器怎么串起来
Project 3 引入时钟,芯片开始有「状态」。Bit 是最小存储单元,Register 是 16 位版本,RAM 则是一堆 Register 加上地址译码。
Bit 的实现思路是:用一个 Mux 根据 load 信号决定「保持原值还是写入新值」,再用 DFF 锁存。DFF 是课程提供的内置芯片,不用自己实现,但要知道它的行为是「时钟上升沿把输入传到输出」。
RAM 的搭建是递归的:RAM8 用 8 个 Register 加一个 DMux3 做地址选择,RAM64 用 8 个 RAM8 再加一层译码,一路推到 RAM16K。这个递归结构在 Project 5 的内存映射里会再次出现。
计数器(PC)在 Project 3 里也要实现,它支持「自增、清零、加载新值」三种行为。CPU 取指令时靠它指向下一条指令的地址,所以 PC 的正确性直接决定 Project 5 能不能跑通。
3.3 CPU 和计算机:把芯片拼成一台能执行指令的机器
Project 5 是整个硬件部分的高潮。你要把 ALU、寄存器、PC、内存拼成一个 CPU,再连上指令内存和数据内存,组成一台完整的计算机。
CPU 的工作循环是「取指、译码、执行」。取指阶段从指令内存读出 16 位指令,译码阶段根据最高位判断是 A 指令还是 C 指令,执行阶段更新寄存器和 PC。A 指令直接把值写入 A 寄存器,C 指令则根据 comp、dest、jump 三个字段决定 ALU 做什么、结果写哪里、要不要跳转。
实现时最容易翻车的地方是 jump 逻辑。jump 字段有 8 种组合,分别对应「无条件跳」「大于跳」「等于跳」「小于跳」等。判断条件要用 ALU 输出的 zr 和 ng 标志位,写错一个条件,程序就会跳飞。
验证方法是加载官方提供的.hack文件,用 CPU 模拟器单步执行,观察寄存器和内存变化是否符合预期。如果程序跑飞,先检查 PC 的更新逻辑,再检查 jump 条件。
3.4 硬件部分的验证习惯:先跑测试再优化
硬件部分每个芯片都有配套的.tst和.cmp文件。.tst是测试脚本,.cmp是期望输出。模拟器跑完会逐行对比,不一致就标红。
我的习惯是:先让测试全绿,再考虑能不能少用几个门。很多人一上来就想优化,结果逻辑还没对就改结构,最后连错在哪都找不到。测试全绿之后再回头看,哪些芯片可以复用、哪些连线可以简化,这时候优化才有意义。
4. 软件层怎么落地:汇编器、VM 和编译器的实现路径
硬件跑通之后,软件部分才是真正的工作量大头。Project 6 到 Project 12 要用你熟悉的语言写四个软件系统,每个都有明确的输入输出格式,照着规范实现就能过。
4.1 汇编器:两遍扫描和符号表是核心
Project 6 要求把.asm汇编文件翻译成.hack机器码。核心难点是符号处理:汇编里既有预定义符号(如 R0、SCREEN),也有用户定义的标签(如(LOOP))和变量(如@counter)。
常见做法是两遍扫描。第一遍只处理标签,记录每个标签对应的指令地址;第二遍处理变量和指令翻译,遇到未定义的变量就分配一个新地址。符号表用一个字典维护,键是符号名,值是地址。
# 汇编器核心逻辑示意(Python) # 第一遍:收集标签 symbol_table = { 'SP': 0, 'LCL': 1, 'ARG': 2, 'THIS': 3, 'THAT': 4, 'SCREEN': 16384, 'KBD': 24576 } for i in range(16): symbol_table[f'R{i}'] = i rom_address = 0 for line in lines: line = strip_comment(line) if line.startswith('('): label = line[1:-1] symbol_table[label] = rom_address else: rom_address += 1 # 第二遍:翻译指令,未定义变量从 16 开始分配 next_ram = 16 for line in lines: if line.startswith('@'): symbol = line[1:] if symbol.isdigit(): address = int(symbol) else: if symbol not in symbol_table: symbol_table[symbol] = next_ram next_ram += 1 address = symbol_table[symbol] emit(f'0{address:015b}') else: emit(translate_c_instruction(line))逻辑说明:第一遍只关心标签,因为标签地址取决于它在指令流中的位置,和变量无关。第二遍才处理变量,因为变量地址可以顺序分配。参数说明:预定义符号的地址是固定的,R0 到 R15 对应 0 到 15,SCREEN 是 16384,KBD 是 24576,这些值在课程规范里有明确定义,不能改。变量从 16 开始分配,是因为 0 到 15 已经被 R0 到 R15 占用。
4.2 虚拟机:栈操作和函数调用是两道坎
Project 7 和 8 要实现一个栈式虚拟机,把.vm文件翻译成汇编。VM 指令分四类:算术、内存访问、程序控制、函数调用。
算术指令(add、sub、neg 等)直接操作栈顶元素,弹出两个、压入一个。内存访问指令(push、pop)要区分 segment,local、argument、this、that 这些段的基址存在固定内存位置,constant 段直接压入常量,static 段按文件名分配。
函数调用是难点。call 指令要保存返回地址、保存调用者的段指针、跳转到函数入口;return 指令要恢复段指针、跳回返回地址。这套机制和真实 CPU 的函数调用栈是一个思路,只是用软件模拟。
提示:Project 8 的分支控制(label、goto、if-goto)要和函数调用配合好,否则嵌套调用会乱栈。
4.3 编译器:从 Jack 语言到 VM 代码的两级翻译
Project 10 和 11 要求写一个 Jack 编译器,把高级语言翻译成 VM 代码。编译器分两个阶段:语法分析生成解析树,代码生成遍历解析树输出 VM 指令。
语法分析要处理 Jack 的语法规则,包括类、方法、语句、表达式。常见做法是递归下降,每个语法规则对应一个解析函数。代码生成阶段要维护符号表,记录每个变量的类型和作用域,还要处理表达式的求值顺序。
一个容易忽略的点是:Jack 的数组访问arr[i]在 VM 层要翻译成push arr; push i; add; pop pointer 1; push that 0。这个转换链条要写对,否则数组读写会错位。
4.4 操作系统:八个模块的最小实现
Project 12 要求实现 Jack OS 的八个模块:Math、String、Array、Output、Screen、Keyboard、Memory、Sys。每个模块都有明确的函数签名,照着实现就行。
Math 模块实现乘除法和平方根,乘法可以用累加,除法可以用减法循环,平方根用二分逼近。String 模块处理字符和数字的转换,Output 模块负责在屏幕上打印字符,Screen 模块直接操作屏幕内存映射。
Sys 模块的Sys.init是程序入口,它要调用Main.main,然后进入死循环。这个函数写错,整个程序就跑不起来。
5. 避坑与排查:那些让我重写三遍的细节
这一章记录几个高频翻车点,每个都按「现象、原因、解决」写清楚,都是我在做项目时真实踩过的。
5.1 HDL 测试全红但逻辑看着没错
现象:Not.hdl 写完后加载测试脚本,输出全是 0,模拟器报错。
原因:HDL 对大小写和端口名敏感,Nand写成nand或者端口名拼错,模拟器找不到对应芯片。
解决:逐字对照课程提供的芯片定义,确认芯片名、输入输出端口名完全一致。HDL 不区分缩进,但区分大小写。
5.2 汇编器翻译出的机器码和期望差一位
现象:Project 6 的测试文件跑完,大部分指令对,但跳转指令的地址总是差 1。
原因:第一遍扫描时把标签地址算错了,通常是没跳过注释行或者空行,导致 rom_address 多加了一次。
解决:在扫描前先去掉注释和空行,只保留有效指令行。标签行本身不占指令地址,但标签后面的指令要占。
5.3 VM 函数调用后栈指针错乱
现象:Project 8 的 Fibonacci 测试跑不通,函数返回后栈里多出几个值。
原因:call 指令保存的返回地址和段指针数量不对,或者 return 时恢复顺序反了。
解决:对照课程规范,call 要保存返回地址、LCL、ARG、THIS、THAT 五个值,return 时按相反顺序恢复。段指针的恢复顺序错了,栈就会错位。
5.4 编译器生成的代码能跑但结果不对
现象:Project 11 的测试程序能编译,但运行结果和预期不符。
原因:符号表作用域没处理好,子程序里的变量覆盖了类变量,或者表达式求值顺序错了。
解决:给符号表加作用域层级,子程序查找变量时先查局部再查类。表达式求值要严格按照运算符优先级生成 VM 指令,不能想当然。
5.5 操作系统模块调用后程序卡死
现象:Project 12 的测试程序调用 Output.printChar 后没有任何输出,程序也不报错。
原因:Screen 模块的内存映射地址写错,或者 Output 模块没有正确调用 Screen。
解决:确认 Screen 的基址是 16384,每行 32 个字符,每个字符 16 位。Output 模块要先把光标位置算对,再调用 Screen.draw。
6. 进阶玩法:怎么用 Nand2Tetris 验证你真的懂了
做完 12 个项目只是起点,真正拉开差距的是「能不能用这套东西验证自己的理解」。这一章给几个进阶方向,都是我自己试过、觉得有价值的。
6.1 给 CPU 加一条自定义指令
Project 5 的 CPU 只支持课程定义的指令集。你可以试着加一条新指令,比如「取绝对值」或者「交换两个寄存器」。改动涉及译码逻辑、ALU 控制位、PC 更新,改完要用 CPU 模拟器验证旧程序还能跑。
这个练习的价值在于:你会真正理解指令集的扩展不是「加个函数」,而是牵一发动全身。译码器多一个分支,ALU 多一个控制位,测试用例要全部重跑。
6.2 用不同语言重写同一个项目
汇编器、VM、编译器都可以用任意语言写。我的习惯是:第一遍用 Python 快速跑通,第二遍用 C 或者 Rust 重写,逼自己处理内存和类型问题。
重写时会发现很多「Python 帮你兜住」的细节,比如字符串处理、整数溢出、内存分配。这些细节在真实系统里都是要自己管的,提前踩一遍没坏处。
6.3 把 Jack 编译器扩展到新语法
Jack 语言很简单,没有继承、没有泛型、没有异常。你可以试着加一个for循环,或者加一个switch语句。改动涉及词法分析、语法分析、代码生成三层,改完要保证旧程序还能编译。
这个练习能让你看清「语言特性」在编译器里到底对应什么。一个for循环在语法树里是一个节点,在代码生成阶段是一组跳转指令,在 VM 层是 label 和 goto 的组合。
6.4 用真实硬件描述语言重写关键芯片
课程用的 HDL 是简化版,真实硬件用 Verilog 或 VHDL。你可以把 ALU 或者 CPU 用 Verilog 重写一遍,跑仿真验证功能一致。
这个练习的门槛在于工具链,但价值在于:你会接触到真实硬件设计的约束,比如时钟域、复位信号、综合优化。这些在课程 HDL 里是被隐藏的。
6.5 验证方法:用测试脚本覆盖边界情况
每个项目都有官方测试,但官方测试不一定覆盖所有边界。我的习惯是:跑完官方测试后,自己再补几个极端用例。
比如 ALU 测试,官方会穷举控制位,但你可以补一个「两个操作数都是最大值」的用例,看溢出处理对不对。VM 测试,官方会跑 Fibonacci,但你可以补一个「递归深度很大」的用例,看栈会不会爆。
注意:补测试用例时,期望输出要自己算,不能直接抄官方结果。算的过程就是验证理解的过程。
我做完这 12 个项目最大的教训是:别急着赶进度,每个项目跑通测试只是及格,能给别人讲清楚「为什么这么设计」才是真的会了。我一般会在每个项目结束后写一段笔记,记录当时的思路和踩过的坑,过一个月再回头看,很多当时觉得理所当然的地方,其实根本没想透。希望帮到你。
本文还有配套的精品资源,点击获取