ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从NAND门到俄罗斯方块:计算机系统分层抽象实践复盘

从NAND门到俄罗斯方块:计算机系统分层抽象实践复盘 我第一次被计算机系统分层抽象这件事真正震撼到是在“计算机系统综合实践”课程上。老师抛出的题目很极端给你一只NAND门最后你要交出一个能玩的俄罗斯方块。当时教室里所有人都觉得这两者中间隔着一条马里亚纳海沟。后来我沿着“从NAND到俄罗斯方块”这条经典路线也就是很多人知道的Nand2Tetris一关一关亲手走完才彻底理解什么叫分层抽象——它不是把复杂性隐藏起来让你看不到而是把复杂性拆开变成每一层都能单独验证、单独调试的接口。你不需要一开始就懂晶体管内部的物理过程也可以完整地知道一台计算机从最底层的一个门如何一层一层长成能跑游戏的系统。这篇文章就是那次完整走链后的复盘。适合正在学“计算机系统导论”或者“深入理解计算机系统”的读者也适合那些写了好几年业务代码、却始终觉得“机器到底是怎么跑起来的”是个黑盒的开发者。1. 为什么非要以NAND门起手底层的最小原语1.1 NAND门的符号约定与画法先解决一个搜索频率很高的问题NAND门怎么画。NAND是NOT AND的缩写中文叫“与非门”表达的逻辑是“先与后非”。真值表非常简单ABNAND(A, B)001011101110只有一种情况输出0就是两个输入都为1的时候。画法上最标准的做法是画一个AND门在输出端加一个小圆圈表示取反。这也是国内数字电路教材和HDL仿真器里最常见的约定。另一种画法利用德摩根等价关系把NAND画成OR门两个输入端各加一个小圆圈。这两种画法在逻辑上完全等价只是观察视角不同第一种强调“先与后非”第二种强调“输入取反则输出为或”。很多初学者画到后面会混淆其实记住一个小技巧——输入端带小圆圈表示“低电平有效”输出端带小圆圈表示“取反”就不会乱。画门这件事看起来简单但它背后是数字电路设计的基本功你画的每一个符号都对应着一张可以被穷举验证的真值表。后续无论组合逻辑还是时序逻辑所有部件最后都可以拆成这种符号的组合。1.2 一个门的完备性所有逻辑门都能由NAND搭出来从NAND门出发最惊人、也最关键的结论是只要给你足够多的NAND门你可以构造出任何逻辑电路。这时候有个术语叫逻辑门的“完备性”。NAND门是完备的NOR门也是完备的但AND、OR、NOT单独拿出来都不完备。为什么会这样核心就是德摩根定律。用NAND构造基本门极其直接NOT(A) NAND(A, A)AND(A, B) NOT(NAND(A, B))OR(A, B) NAND(NOT(A), NOT(B))这意味着什么意味着芯片制造厂只需要生产一种门编译器设计者只需要保证代码最终能被翻译成一种门的组合就能表达全部数字逻辑。整个计算机系统最底层的“积木”尺寸被缩小到极致。这种“用最小原语构造全部复杂性”的思路贯穿了整个项目每上一层你先定义几个原语然后用原语搭出更丰富的东西再把这些东西封装成下一层的原语。1.3 模拟电路层面的一瞥为什么你可以放心跳过它如果你继续往NAND门的内部下探会发现它是用CMOS晶体管搭出来的两个PMOS并联接上拉两个NMOS串联接下拉。输入A和B都为1时下拉网络导通输出被拉到地得到0其他情况下上拉网络导通输出为高电平得到1。这个物理实现非常优雅但它同时也是大多数“计算机系统导论”课程里最容易劝退的地方。我的建议是在这个阶段你要允许自己把NAND门当作一个绝对可靠的黑盒。你不需要关心它是不是真的能在纳秒级完成状态切换不需要关心扇出系数也不需要关心信号在长导线上的延迟。分层抽象的起点恰恰是承认“这一层之下我暂时不管先把这一层用起来”。等你把整台机器搭完如果还有兴趣再回到晶体管层你会对“为什么NAND在物理上也那么适合做基础门”理解得更深。2. 从布尔代数到组合逻辑在没有记忆的世界里搭积木2.1 基本门之间的转换关系整个项目的第一大关卡是用NAND门搭出NOT、AND、OR、XOR、Mux等基本门。这个过程表面看是“画电路图”本质是逻辑表达式转换。目标逻辑和NAND组合的对应关系可以整理成一张表目标逻辑NAND组合方式需要的NAND门数NOT(A)NAND(A, A)1AND(A, B)NAND(NAND(A, B), NAND(A, B))2OR(A, B)NAND(NOT(A), NOT(B))3XOR(A, B)标准四门结构4其中XOR用四个NAND门实现的结构是很多人第一次被绕晕的地方。标准做法是先用一个NAND(A, B)生成中间信号w再用NAND(A, w)和NAND(B, w)分别生成两个中间信号最后再把这两个信号送入一个NAND。它的本质是利用NAND不断做“反向折叠”把“A和B不同则输出1”这件事用与非的运算规则一点点逼出来。我在实践中最深刻的体会是写组合逻辑时不要只在纸上推公式一定要把每一级中间信号标出来。哪怕你只是在HDL文件里写结构描述也要像写代码一样给中间信号起有意义的名字。2.2 加法器从真值表到算术电路组合逻辑里最有里程碑意义的一个部件是加法器。用门电路做加法难点不在“加”本身而在“进位”。先看一位的半加器两个输入A、B输出一个和位Sum和一个进位Carry。列真值表你会发现Sum位刚好就是XOR(A, B)Carry位刚好就是AND(A, B)。这是整个计算机里最优雅的对应关系之一异或天生就是二进制无进位加法与门天生就是进位判断。把半加器串联起来就得到全加器和多位加法器。16位加法器的思路是把第0位的进位送到第1位第1位的进位再送到第2位这样逐位传递下去。这种结构叫行波进位加法器速度不算快但教学意义极佳——它让你直观看到数据是如何在电路里流动的。实际工程中的加法器会优化进位路径但核心逻辑不会变加法的本质就是逐位异或加进位。2.3 多路选择器与HDL封装芯片设计的“接口思维”多路选择器Mux是另一个必须吃透的部件。它的功能很简单根据选择位s决定输出是a还是b。s0输出as1输出b。用逻辑门实现时它的结构天然长成“两条路先各自与上选择条件再或在一起”的样子。这里我想特别强调HDL文件带来的“接口思维”。在项目里你每完成一个芯片就可以把它封装成这样的结构CHIP And { IN a, b; OUT out; PARTS: Nand(aa, bb, outw1); Nand(aw1, bw1, outout); }这个CHIP声明里IN和OUT就是对外接口PARTS里是内部实现。外部世界根本不需要关心你是用几个NAND拼出来的只需要知道这个芯片的行为符合真值表。这种思维会伴随你走完整个项目每一层的完成都是给上一层递上一张干净的接口契约。3. 让机器记住状态DFF、寄存器与RAM的诞生3.1 组合逻辑的边界没有记忆就无法写程序做到这里你会发现一个致命问题前面所有组合逻辑都没有“记忆”。输入变输出立刻变输入消失输出就消失。用这样的电路你连最简单的一个循环都实现不了因为循环需要“记住刚才做到哪一步”了。计算机能执行复杂任务靠的不只是算得快更关键的是能存得住状态。从这一节开始项目从组合逻辑进入时序逻辑这是整个实验里最大的一次认知切换。3.2 D触发器让时间第一次有意义时序逻辑的根基是D触发器简称DFF。它有数据输入、数据输出和时钟输入。它的行为可以理解为在每个时钟信号的边沿把当时输入端的值“采样”下来并且一直保持到下一个时钟边沿。很多初学者会被“时钟”这个概念卡住。你不用想得太玄时钟就是一个每隔固定时间跳变一次的方波信号。DFF做的事情就像一个只在整点才看一眼手表并记录时间的人其他时间不管你问他什么他只会重复上一次整点记录的结果。用HDL写DFF时你不需要自己搭电路项目会直接提供这个底层原语。但从DFF往上寄存器、RAM就要你自己动手了。这里的关键是理解“load”控制位只有load1时DFF才在时钟边沿更新否则就死守旧值。3.3 寄存器、RAM与地址解码寄存器就是把16个DFF并排放在一起共享同一个load位和时钟。16位的数据一次性被锁存这就是CPU里最基础的存储单元。再往上RAM是一堆寄存器的阵列。我们需要根据“地址”选择读写具体哪一组寄存器这个功能靠解码器完成。解码器本质上就是一大片与门和反相器的组合逻辑地址输入有几根线输出就能精确选中对应的寄存器行。我做这一步时最深的感触是RAM芯片的设计完全重复了一个模式——先做一个基本单元再成倍复用。按照这个模式你可以从RAM8做到RAM64再从RAM64做到RAM512最后做到RAM16384。每一级都用前一级封装好的芯片当黑盒根本不关心内部细节。套娃结构让“存储容量翻倍”变成了一件只需要多写几行HDL的事。4. 机器语言与汇编第一次把“想做的事”输给机器4.1 指令的两种形态A指令与C指令有了存储和计算下一层就是把这两种能力暴露给程序员。在经典项目里机器只认识两种指令A指令和C指令。A指令的格式很简单以0开头后面跟15位数值。它做的事情是把一个立即数装入地址寄存器A。C指令以1开头包含计算、跳转和目的地址三部分具体格式是dest comp ; jump。C指令里的comp字段决定ALU执行什么运算dest字段决定结果写到哪jump字段决定是否跳转。有一个非常容易踩坑的区分就是A寄存器和M的关系。在Hack指令集里5 这个A指令先把5送到A寄存器。之后如果你在C指令里看到M它指向的是RAM[5]如果你看到直接使用A它表示数值5。A既可以当“指针”也可以当“立即数”这是整套指令集里最浓缩也最考验理解力的一点。4.2 程序计数器与循环计算机如何“自己往前走”光有运算指令还不够机器还得知道下一步执行哪条指令。这个角色由程序计数器PC承担。PC在每个时钟周期加1如果遇到跳转指令且条件满足PC就被改成跳转目标地址。没有跳转程序就是一条直线有了跳转循环和分支才成为可能。我用汇编实现过一个经典题目计算RAM[2] RAM[0] × RAM[1]。代码非常短却是理解“计算机如何自我控制”的最直接例子// RAM[2] RAM[0] * RAM[1]假设非负整数 R2 M0 (LOOP) R1 DM END D;JLE R0 DM R2 MDM R1 MM-1 LOOP 0;JMP (END) END 0;JMP这段代码里(LOOP)和(END)是标签汇编器会把它解析成具体的指令地址。D;JLE表示“如果D小于等于0则跳转”0;JMP表示无条件跳转。我第一次跑通这个程序时有一种很奇特的体验电脑终于“活”了它在按照我给的规则自行选择下一步。4.3 汇编器没有魔法的文本翻译用机器码写程序太痛苦于是汇编器出现了。它的工作本质并不复杂读一行汇编文本查指令表转换成对应的二进制位。真正麻烦的是符号表管理——你要把程序员写的标签和变量名映射到实际的内存地址或指令地址。我在写汇编器时踩过一个印象很深的坑跳转标签的作用域没有处理好汇编器把所有同名标签都解析成了同一个地址导致循环跳错了位置。调试到最后发现是解析器在遇到标签定义时把它误当成普通指令地址计数多算了一行。这个经历让我明白汇编器虽然简单但它是一个真正的编译过程词法分析、符号表、代码生成一样都不少。只是它的语法比高级语言简单得多刚好适合拿来练手。5. 中间那一层编译器、虚拟机与最简操作系统5.1 高级语言到机器码一段向下的翻译拿到汇编器之后理论上已经可以用汇编写任何程序了但没人愿意用汇编写俄罗斯方块。下一步是让高级语言代码也能跑在这台机器上。这就是编译器要做的事。编译器本质上是一个翻译器把高级语言源代码解析成抽象语法树再根据语法树生成中间表示最后生成汇编。整个过程没有一步是“智能魔法”每一步都是确定性的规则匹配。我强烈建议学到这里时亲手写一遍表达式解析哪怕是只支持加减法和括号的算术表达式。当你看到“1 (2 - 3)”这样的字符串被递归下降解析器还原成一棵树再被后序遍历生成一串指令时你对“程序即数据”这句话的理解会瞬间上好几个台阶。5.2 栈式虚拟机临时仓库的哲学项目里的虚拟机是整个中间层里最有味道的设计。它用一套栈式指令比如push、pop、add、sub、eq、goto等等作为高级语言和汇编之间的“万能中间语言”。为什么要多这一层因为不同高级语言翻译到VM指令后再统一由VM翻译到目标机器这样编译器前端不用管具体硬件VM后端不用管具体语言。栈式虚拟机的核心是一个栈数据结构所谓的add指令就是从栈顶弹出两个数相加再把结果压回去。存变量时用pop取变量时用push。它的计算模型非常像日常生活中的“临时仓库”——你先把数搬进仓库要用的时候再取出来。从VM指令翻译到汇编最核心的是把栈顶的元素映射到D寄存器并把栈指针SP对应的内存位置处理好。这一步一定要亲手写一遍才会理解为什么栈指针加减要放在内存操作前后才会理解局部变量在函数调用时是怎么被隔离的。5.3 最简操作系统把硬件细节藏起来项目走到这里已经具备一台简单计算机的完整形态硬件能算、能存、能执行汇编编译器能把高级语言翻译成汇编但还缺一层专门给应用程序提供“公共服务”。这就是最简操作系统的角色。操作系统层做的事情在很多开发者眼里稀松平常程序启动时初始化分配内存块把程序的内容写到屏幕上读取键盘输入。但在没有操作系统之前这一切都需要应用自己完成。我在实现内存分配函数时才真正理解了为什么malloc这类接口要存在它不过是在一段连续内存里维护空闲区间把可控的地址段交出去。这种“最简操作系统”让应用层和硬件层彻底解耦。你要写游戏不需要再关心RAM地址怎么映射到屏幕只需要调用画点、画线的函数接口。分层到这里已经形成了完整闭环。6. 俄罗斯方块上屏当整条抽象链第一次被看见6.1 屏幕不是屏幕是一块内存很多人第一次接触图像编程时会习惯性认为屏幕和内存之间隔着显卡、驱动、显卡协议等神秘中间层。在自制计算机上屏幕映射极其直白屏幕就是一整块内存区域从某个固定地址开始每一个二进制位对应屏幕上的一个像素。Hack平台的屏幕是256行512列总共需要8192个16位字的内存空间。你把某个内存位置1屏幕上对应的像素就会亮清零则像素熄灭。画一条横线本质上就是往连续几个内存字里填入特定模式的二进制数。我当时第一次点亮一个像素时盯着屏幕看了很久。那块屏幕清楚地告诉我所有绘图接口最后都只是“写内存”这一个动作。所谓图形性能、缓存优化都是在“如何更快地写内存”上做文章。6.2 键盘与游戏循环游戏需要输入。在自制平台上键盘控制器被映射到另一个固定内存地址。程序不需要监听中断也不需要复杂的事件系统只需要循环读取那个地址就能知道当前哪个键被按下。于是游戏循环其实非常简单LOOP: 读取键盘内存 根据按键更新方块状态 判断是否碰撞 清除旧帧、绘制新帧 跳到LOOP这个循环体就是所有交互式应用的雏形。你写的每一个Android应用、每一个Web前端跑起来之后本质都在做类似的事情获取输入、更新状态、刷新输出。6.3 俄罗斯方块实现拆解真正动手写俄罗斯方块时你会发现它没有想象中难但也没有想象中简单。核心模块就几个形状定义7种方块每种用一个小二维数组表示。旋转逻辑对数组做旋转但要处理好旋转中心。碰撞检测方块移动或旋转前先试探目标位置是否和已固定方块冲突。行消除检测到底部哪几行已经满把它们删掉上面的行整体下移。在自制平台上没有高级绘图库绘制每个方块都要自己算坐标再按像素往屏幕内存里写。这时候分层抽象的全部价值彻底体现出来我可以完全无视底层电路和编译器的存在把精力集中在“旋转算法怎么写得优雅”上同时真的出现问题我也知道从哪个层面下去排查。当游戏里第一行被填满并消掉时周围一起做项目的同学都在欢呼。那一刻你看到的不是游戏成绩不是编程技巧而是一条你亲手从NAND门开始搭建的抽象链第一次给出了完整且愉悦的反馈。7. 复现实践中的踩坑与学习建议7.1 我踩过的三个经典坑第一个坑在组合逻辑阶段实现XOR时中间信号连接顺序写错逻辑表达式看起来是对的但仿真测试一直失败。排查了半天最后发现是两根输入线接反。这个经历让我养成了习惯HDL里每个中间信号都起见名知意的名字写完先做单项芯片测试再继续往上封装。第二个坑在汇编阶段就是前面提到的标签解析错误。我当时的教训是不要手工维护符号地址要用程序统一解析。从那以后我写汇编器时强制自己先分离“伪指令处理”和“符号解析”先建立符号表再生成机器码。代码结构清晰了问题不治而愈。第三个坑在项目集成阶段整个硬件、汇编、编译器都完工后游戏程序第一次跑起来时屏幕上出现大量随机噪点。最后排查出来是RAM没有在启动时统一复位。这个经验让我意识到时序逻辑和组合逻辑最大的不同——时序电路里状态必须被显式初始化否则你只能依赖“运气”。7.2 学习顺序先自底向上建一次再自顶向下看一次我的建议非常简单第一遍老老实实从项目1做到项目6每一步都按官方测试脚本验收不要跳步。很多内容比如Mux、DFF你第一眼可能觉得“这东西用不到”但后面所有模块都会用到。别嫌慢这一遍的目标是建立底层直觉。第二遍再自顶向下走一遍先看最终的游戏程序再追到VM指令再追到汇编再追到硬件电路如何执行这条指令最后落到某个NAND门的状态变化。这个“反向走链”的过程会把第一遍建立的碎片化知识彻底串成一张网。7.3 配套资源怎么选如果你正在上“计算机系统综合实践”课程可以搭配这几类资源资源类型作用注意点原版教材提供每章的原理讲解和项目背景先读章节再动手做项目开源仿真器用于编写HDL并验证芯片行为严格按它的语法来配置接口官方测试脚本帮你自动化验证芯片正确性不要自己绕过测试参考实现示例卡住时的方向指引先努力自己想再参考市面上关于“深入理解计算机系统”的课程和书很多但大多是偏理论体系的讲清楚了一层但很少让你从头到底亲手搭一遍。从NAND到俄罗斯方块这条路线正好补上了“亲手搭建”这一环它和理论课程是互补关系不是替代关系。做完这个项目后我最大的变化不是“会写汇编了”或者“会设计芯片了”而是建立起了一种分层排查的心智模型遇到问题先判断问题发生在第几层再决定要不要下钻。这种思维方式比任何单独一项技术都更值钱。
返回列表