1. 从零搭一颗CPU,为什么我选在浏览器里动手
第一次看到"用逻辑门搭CPU"这个念头,很多人脑子里浮现的是Logisim里那一堆连线,或者是Verilog仿真波形图。但真正让我决定动手做一个浏览器版CPU模拟器的原因很朴素:我想让一个完全不懂数字电路的朋友,打开一个网页就能看到信号在导线里流动,而不是先装一堆工具链、配环境、学语法。这个项目就是用TypeScript从最基础的与门、或门、非门开始,一层层搭出加法器、寄存器、ALU,最后拼出一颗能跑几条指令的简易CPU,整个过程跑在浏览器里,打开就能玩。
它解决的核心问题其实是"抽象断层"。教科书讲CPU,往往从指令集直接跳到流水线,中间那层"门电路怎么变成运算单元"被一笔带过。而自己动手搭一遍,你会被迫面对每一个细节:一个全加器的进位到底怎么传、寄存器什么时候锁存、时钟上升沿和下降沿的区别在哪。这些细节在纸面上看是常识,真动手时全是坑。这篇文章适合三类人:想真正搞懂计算机组成原理的学生、想用TypeScript练手做可视化项目的前端、以及任何对"CPU是怎么思考问题的"这件事好奇的开发者。我会把整个搭建过程拆开讲,包括每一层的设计取舍、TypeScript里怎么建模信号、浏览器里怎么渲染成千上万根导线,以及我踩过的那些坑。
2. 为什么用TypeScript而不是Verilog或Logisim
2.1 语言选型背后的真实考量
做CPU模拟,专业工具是Verilog/VHDL,教学工具是Logisim,那我为什么偏偏选了TypeScript?这不是为了标新立异,而是三个现实约束逼出来的选择。
第一是分发成本。Verilog要装仿真器,Logisim要装Java环境,而一个网页链接发出去,对方点开就能用。对于一个想给非专业朋友演示的项目,这个差距是决定性的。第二是可视化自由度。Logisim的渲染是固定的,我想让导线上的信号用颜色渐变表示电平变化、想让时钟脉冲有动画,就得自己控制渲染层,而浏览器里的Canvas/SVG给了我这个能力。第三是类型系统。TypeScript的泛型和联合类型,恰好能把"一根导线只能是0或1"这种约束在编译期表达出来,写错类型直接报错,这在建模数字电路时意外地好用。
当然代价也很明显:TypeScript跑逻辑门是纯软件模拟,性能远不如硬件描述语言。一颗几千门的CPU,在浏览器里每个时钟周期要遍历所有门重新计算,如果实现得不好,帧率会掉到个位数。所以这个项目的定位从一开始就不是"高性能仿真",而是"可交互的教学可视化"。
2.2 用类型系统给信号建模
数字电路里最基础的概念是"信号",它只有两种状态。在TypeScript里,我一开始想用boolean,但很快发现不够用——因为真实电路里还有"高阻态"和"未知态",虽然教学项目可以简化,但至少要把"0/1"和"未连接"区分开。最后我用了一个联合类型:
type Signal = 0 | 1 | 'x'; // x 表示未定义/悬空用字面量类型而不是enum,好处是写if (sig === 0)时类型收窄非常自然,而且序列化到JSON时直接就是数字,渲染层不用转换。这里有个小坑:JavaScript里0是falsy值,如果你写if (signal)来判断信号是否为高电平,那信号为0时会被误判。我一开始就栽在这上面,调试了半天发现与门输出永远是0。正确写法必须是if (signal === 1),显式比较。
2.3 门电路的抽象基类设计
所有逻辑门共享一个结构:若干输入引脚、一个输出引脚、一个求值函数。我用一个抽象类来统一:
abstract class Gate { inputs: Wire[] = []; output: Wire = new Wire(); abstract evaluate(): Signal; tick() { this.output.value = this.evaluate(); } }Wire是一个带value属性的对象,门与门之间通过共享同一个Wire实例来连接。这样当上游门更新了output.value,下游门读到的就是新值。这里的关键设计是求值和传播分离:evaluate()只负责根据当前输入算出输出,tick()负责把结果写回导线。为什么要分开?因为如果边算边写,会出现"同一周期内信号多次翻转"的问题,导致组合逻辑震荡。正确的做法是先算完所有门的新值,再统一更新,这就是后面要讲的"两阶段求值"。
3. 从单个逻辑门到全加器的搭建路径
3.1 基础门电路的真值表实现
与门、或门、非门、异或门,这四个是所有数字电路的原子。它们的实现简单到有点无聊,但真值表必须写对:
| 门类型 | 输入A | 输入B | 输出 |
|---|---|---|---|
| AND | 0 | 0 | 0 |
| AND | 0 | 1 | 0 |
| AND | 1 | 1 | 1 |
| OR | 0 | 0 | 0 |
| OR | 0 | 1 | 1 |
| OR | 1 | 1 | 1 |
| XOR | 0 | 0 | 0 |
| XOR | 0 | 1 | 1 |
| XOR | 1 | 0 | 1 |
| XOR | 1 | 1 | 0 |
异或门是重点,因为它是加法器的核心。异或的逻辑是"两个输入不同时输出1",这正好对应二进制加法里"不考虑进位的那一位"。用代码实现时,我建议直接用a !== b来算异或,比用(a && !b) || (!a && b)更简洁,也避免了0被当成falsy的坑。
3.2 半加器与全加器:进位的艺术
半加器只能算两个1位数的和,输出"和"与"进位"。但真正的加法需要处理来自低位的进位,所以要用全加器。全加器由两个半加器加一个或门组成,这个结构不是随便设计的,而是有严格的逻辑推导:
- 两个半加器分别处理
A+B和(A+B的结果)+Cin - 两个半加器产生的进位,只要有一个为1,最终进位就是1,所以用或门合并
class FullAdder { sum: Signal; carryOut: Signal; evaluate(a: Signal, b: Signal, cin: Signal) { const xor1 = (a !== b) ? 1 : 0; const sum = (xor1 !== cin) ? 1 : 0; const carry1 = (a === 1 && b === 1) ? 1 : 0; const carry2 = (xor1 === 1 && cin === 1) ? 1 : 0; this.sum = sum as Signal; this.carryOut = (carry1 || carry2) ? 1 : 0; } }把8个全加器串起来,就得到8位加法器:第一个全加器的进位输入接0,后面每个的进位输入接前一个的进位输出。这就是所谓的"行波进位加法器"。它的缺点是进位要一级级传,位数越多越慢,但教学项目里8位足够,延迟可以忽略。
3.3 用加法器理解补码与减法
有了加法器,减法其实不用单独做电路。二进制里A - B等于A + (-B),而-B在补码里等于~B + 1(按位取反再加1)。所以只要在加法器的B输入端加一排非门,再把最低位的进位输入设为1,同一个加法器就能做减法。这个设计非常优雅,也是我第一次真正理解"为什么计算机用补码"的时刻——因为它让加法和减法共用一套硬件,省了一半电路。
提示:做补码取反时,别忘了进位输入要置1,否则算出来会差1。我调试减法时卡了很久,最后发现就是漏了这个+1。
4. 时序逻辑:寄存器、时钟与状态保持
4.1 组合逻辑的天花板
前面搭的加法器、ALU都是组合逻辑——输出只取决于当前输入,没有记忆。但CPU必须有状态:程序计数器要记住执行到哪了,寄存器要保存中间结果。这就需要时序逻辑,而时序逻辑的核心是"时钟"。
时钟本质上是一个周期性翻转的信号,它给整个电路一个统一的节奏。在软件模拟里,时钟不是真实的时间,而是一个"节拍":每个节拍里,所有时序元件根据当前输入更新自己的状态。这里最容易搞混的是"边沿触发"和"电平触发"。真实CPU大多用边沿触发(上升沿或下降沿才更新),因为电平触发容易产生竞争。我在模拟器里实现了上升沿触发:只有当clk从0变到1的那一瞬间,寄存器才锁存输入。
4.2 D触发器与寄存器的实现
D触发器是最基本的存储单元,它的行为是:在时钟上升沿,把输入D的值保存到输出Q。用代码模拟时,关键是记录"上一个时钟状态":
class DFlipFlop { private lastClk: Signal = 0; q: Signal = 0; tick(d: Signal, clk: Signal) { if (this.lastClk === 0 && clk === 1) { this.q = d; // 上升沿锁存 } this.lastClk = clk; } }把8个D触发器并排,共享同一个时钟,就得到一个8位寄存器。这里有个必须注意的点:lastClk的更新必须放在锁存之后,否则同一周期内会重复触发。这个顺序问题在软件模拟里特别隐蔽,因为代码是顺序执行的,而真实电路是并行的。
4.3 时钟周期内的两阶段求值
这是整个项目里最关键、也最容易出错的设计。一个时钟周期内,电路的状态更新必须分两步:
- 求值阶段:所有组合逻辑根据当前寄存器输出,算出新的结果
- 锁存阶段:时钟上升沿到来,寄存器把新结果锁存进去
如果混在一起,比如某个寄存器更新后立刻影响了下游组合逻辑,而下游又反馈回这个寄存器,就会产生不可预测的震荡。我在模拟器里用一个tick()函数统一调度:先遍历所有组合元件求值,再统一触发所有时序元件锁存。这个"两阶段"模型和真实CPU的建立时间/保持时间概念是对应的,理解了它,你就理解了为什么CPU需要时钟。
5. 指令集与CPU控制单元的组装
5.1 定义一套够用就好的指令集
教学CPU不需要x86那么复杂,我设计了一套4条指令的极简指令集:
| 指令 | 编码 | 功能 |
|---|---|---|
| LOAD | 00 | 从内存读数据到累加器 |
| ADD | 01 | 累加器加上内存数据 |
| STORE | 10 | 累加器写回内存 |
| JMP | 11 | 跳转到指定地址 |
这套指令集虽然简陋,但已经能跑"累加数组求和"这样的程序了。指令编码用2位操作码加若干位地址,具体位数取决于内存大小。如果内存是16字节,地址就是4位,一条指令6位,一个字节装不下,得用两个字节。这里的设计取舍是:指令宽度和内存大小的权衡,教学项目里我选了8位指令、4位地址,操作码2位,剩下2位暂时闲置。
5.2 控制单元:CPU的大脑
控制单元的工作是"翻译"指令:读入操作码,输出一堆控制信号,告诉各个部件该干什么。比如遇到ADD指令,控制单元要拉高ALU的加法使能、打开累加器的写使能、选择内存作为ALU的B输入。这些控制信号可以用一个查找表实现:
const controlSignals: Record<number, ControlWord> = { 0b00: { memRead: 1, accWrite: 1, aluOp: 'pass' }, // LOAD 0b01: { memRead: 1, accWrite: 1, aluOp: 'add' }, // ADD 0b10: { memWrite: 1, aluOp: 'pass' }, // STORE 0b11: { pcLoad: 1 }, // JMP };用查找表而不是一堆if-else,好处是控制逻辑一目了然,改指令集时只改表就行。这也是真实CPU里微码控制器的简化版思路。
5.3 程序计数器与取指执行循环
程序计数器(PC)保存当前指令地址,每个时钟周期它要么加1(顺序执行),要么被跳转指令改写。取指-译码-执行的循环是CPU的心跳:
- 用PC的值作为地址,从内存读出指令
- 把指令送到控制单元译码
- 控制单元发出信号,各部件执行
- PC更新,进入下一周期
在模拟器里,这个循环对应每个时钟节拍调用一次cpu.step()。我建议把每一步的中间状态都暴露出来,方便在UI上高亮显示"现在PC是多少、正在执行哪条指令、累加器里是什么值"。这种可视化对理解CPU工作流程的帮助,比看十遍教科书都大。
6. 浏览器里的渲染与性能优化
6.1 用Canvas还是SVG画电路
渲染成千上万个门和导线,第一个要决定的是用Canvas还是SVG。SVG的优点是每个元素都是DOM节点,可以绑定事件、用CSS做样式,但节点一多浏览器就卡。Canvas的优点是绘制快,但事件处理要自己算坐标。我的选择是混合方案:静态的电路布局用SVG(因为门的位置固定,数量可控),动态的信号流动用Canvas叠加层(因为每帧都要重绘)。
实测下来,几百个门的电路,SVG渲染完全没问题;但如果做到几千门,就必须全用Canvas。这里有个经验:先做对,再做快。一开始别纠结性能,用最直观的方式实现,等跑起来卡了再优化。我第一版全用SVG,跑到200个门就开始掉帧,后来把导线层换成Canvas,帧率立刻回到60。
6.2 信号传播的动画与颜色编码
让信号"看得见"是这个项目的灵魂。我的做法是:导线颜色随信号值变化,0是深灰,1是亮绿,未定义是红色虚线。当信号翻转时,加一个短暂的过渡动画,让用户能看清"这一拍哪些线变了"。这个动画不能太长,否则会拖慢整体节奏,我设的是100毫秒。
颜色编码要遵循直觉:高电平用暖色或亮色,低电平用暗色,这样用户扫一眼就知道哪里在活动。另外,正在被读取的寄存器、正在执行的指令,都应该有高亮边框。这些视觉反馈看似是装饰,实际上是降低认知负担的关键——用户不需要记住每个部件的状态,看颜色就够了。
6.3 避免每帧重算整颗CPU
性能优化上最大的坑是"每帧重算所有门"。如果电路有几千个门,每帧遍历一遍,60帧就是几十万次计算,浏览器扛不住。我的优化策略是脏标记传播:只有输入发生变化的门才需要重新求值,其余门跳过。实现方式是给每个门一个dirty标志,当上游导线值变化时,把下游门标记为脏,求值时只处理脏门。
这个优化在组合逻辑里效果显著,因为大部分门在大多数周期里输入是不变的。但要注意,时序元件(寄存器)每个时钟沿都必须处理,不能跳过。所以脏标记只用于组合逻辑部分,时序逻辑照常全量更新。
7. 我踩过的那些坑与调试心得
7.1 信号震荡与求值顺序
最难忘的一个bug是:加法器的输出偶尔会自己翻转,明明输入没变。排查了半天,发现是求值顺序问题——某个门的输出被下游门读取时,下游门已经算过了,导致它用的是旧值。解决办法就是前面说的两阶段求值:先全部求值到临时变量,再统一写回。这个坑让我深刻理解了为什么真实电路需要"建立时间"——信号传播需要时间,不是瞬间完成的。
7.2 时钟边沿检测的边界情况
边沿检测的代码看起来简单,但边界情况很多。比如初始化时lastClk应该设成什么?如果设成0,那第一个时钟周期如果clk本来就是1,就检测不到上升沿。我的做法是初始化时把lastClk设成'x',第一次tick时无论clk是什么都强制锁存一次,保证初始状态正确。另外,如果用户手动单步调试,时钟可能长时间停在1,这时候不能重复触发锁存,必须严格检测"从0到1"的跳变。
7.3 内存与CPU连接的地址译码
把内存接到CPU上时,地址译码是个容易忽略的细节。CPU发出的地址是二进制,内存需要把它翻译成"选中哪一行"。如果地址位数和内存大小不匹配,比如CPU发4位地址但内存只有8行,高位就会被忽略,导致地址回绕。我在模拟器里加了一个断言,当地址超出内存范围时抛出错误并在UI上标红,这样用户能立刻发现程序跑飞了。这个"存储器与CPU的连接"问题,在真实硬件里对应的是地址总线的位宽设计,是计算机组成原理的经典考点。
7.4 用TypeScript严格模式抓出隐藏bug
项目全程开了strict: true,包括strictNullChecks和noImplicitAny。一开始觉得麻烦,但后来发现它帮我抓出了好几个隐藏bug。比如某个函数的返回值可能是undefined,在非严格模式下会被当成0,导致信号错误。严格模式下编译器直接报错,逼着我处理所有分支。对于这种状态机密集的项目,类型系统的价值远超预期。另外提醒一句,TypeScript 7.0会弃用一些旧的编译选项,新项目建议直接用最新的moduleResolution配置,别用已经标记弃用的node10。
8. 这个项目还能往哪些方向继续长
搭完基础版之后,我发现可扩展的方向比想象中多。第一个方向是加流水线:把取指、译码、执行拆成三级,让不同指令重叠执行,这是理解现代CPU性能的关键。第二个方向是加缓存:在CPU和内存之间加一层小容量高速存储,模拟缓存命中与缺失,这个对理解"为什么CPU要有缓存"特别直观。第三个方向是加中断和IO:让CPU能响应外部事件,这是从"计算器"走向"计算机"的一步。
从工程角度,我还想做的优化是把整个模拟器做成可序列化的——把电路状态存成JSON,用户能分享自己的CPU设计,别人打开链接就能加载。这个功能对教学场景很有价值,老师可以布置"实现一条新指令"的作业,学生做完直接发链接。另外,性能上如果要做大电路,可以考虑把求值逻辑搬到Web Worker里,主线程只负责渲染,避免复杂电路卡住UI。
最后分享一个我在这个项目里最大的体会:动手搭一遍,比读十遍书管用。很多概念比如补码、边沿触发、两阶段求值,纸面上看都是"哦我懂了",真到写代码时才发现处处是坑。而每填一个坑,你对CPU的理解就扎实一分。这个项目最爽的时刻,是第一次看到自己搭的CPU跑通一个循环求和程序,屏幕上寄存器的值一跳一跳地变化——那一刻你会觉得,计算机真的没那么神秘。