用 3100 个数字造一台计算机

13 阅读11分钟

你有没有想过,一台计算机最少需要什么?

不是说你桌上那台——那个有几十亿个晶体管、跑着操作系统和浏览器的庞然大物。我说的是最本质的那个东西:能算数、能画画、能放音乐、能响应你的键盘和鼠标。

答案可能会让你意外:一个数组就够了。

Little Virtual Computer 是一台用 TypeScript 写的虚拟计算机,原作者是 jsdf。我在他的基础上做了不少重构和优化——把代码拆分成了清晰的模块结构,加了音频系统、断点调试、内存追踪、中英文切换等功能。3100 个内存槽位,23 条指令,你可以在上面写汇编程序,画像素,甚至播放一首 Chocolate Rain。打开链接就能玩,不用装任何东西。

接下来聊聊拆解和重构这台计算机的过程中,那些让我觉得"原来如此"的时刻。

"硬件"就是一行代码

这台计算机的内存,就是这行:

static ram: number[] = new Array(3100).fill(0)

3100 个数字,所有东西都住在里面——变量、程序、输入设备、屏幕、声卡:

地址用途
0 - 999工作内存(变量)
1000 - 1999程序代码
2000 - 2051键盘、鼠标、随机数、时钟
2100 - 2999屏幕(30x30 像素)
3000 - 3008声卡(3 个通道)

这就是"内存映射 I/O"。真实计算机里,显卡有自己的显存,声卡有自己的缓冲区,键盘通过中断传递信号。但在这里,一切都是内存地址。想在屏幕左上角画一个红色像素?往地址 2100 写个 2。想让扬声器发出正弦波?往地址 3001 写频率,地址 3000 写 3

第一次把重构后的代码跑起来,盯着屏幕上亮起的那个像素,我突然理解了一件事:CPU 不需要"知道"什么是屏幕。它只是往一个地址写了个数字,恰好有人在监听那个地址。 输入输出不需要特殊的指令,读写内存就是一切。

CPU 其实在做一件很无聊的事

读原作者的 CPU 代码时,我以为会很复杂。结果核心逻辑是这样的:

static step(trace: boolean = true) {
  if (trace) Memory.beginTrace();       // 需要调试时才追踪
  const opcode = this.advanceProgramCounter();  // 从内存读一个数
  const instructionName = this.opcodesToInstructions.get(opcode); // 数字变指令名
  const operands = instruction.operands.map(() => this.advanceProgramCounter()); // 再读几个数当参数
  instruction.execute.apply(null, operands);  // 执行
  if (trace) this.lastStepTrace = Memory.endTrace();
}

程序计数器从地址 1000 开始。读一个数,往前走一步。读到 9010?那是 add,再读三个数当参数,加一下,写回去。然后继续读下一个。没有流水线,没有分支预测,没有乱序执行。一个 while 循环,一直读数字、执行、读数字、执行。

这就是冯·诺依曼架构的全部:程序和数据住在同一片内存里,CPU 按顺序取指令执行。 你桌上那台电脑的 CPU,不管它有多少核、多少级缓存,本质上也在做同样的事——只是快了几十亿倍。

23 条指令够写一个游戏吗

一开始觉得不够。23 条指令,连函数调用都没有,能干什么?

结果发现,不只是够了,还能写出让人意外的东西。这 23 条指令分成五类:

搬运数据(5 条)—— 把值从一个地址复制到另一个,或者写入一个常量。还有两条指针操作,让你可以"地址 A 里存着地址 B,去 B 里取值"——间接寻址,这是实现数组遍历的关键。

算术(10 条)—— 加减乘除取模,每种都有两个版本:两个地址相加,或者一个地址加一个常量。add_constant counter 1 counter 就是 counter++

比较(2 条)—— 比较两个值,结果是 -1、0 或 1。没有布尔值,没有大于小于等于,就一个三态数字。刚开始觉得别扭,后来发现这样反而更灵活。

跳转(5 条)—— jump_to 无条件跳转,branch_if_equal 条件跳转。没有 for 循环?跳回去就是循环。没有 if-else?跳过去就是 else。

系统(3 条)—— data 嵌入原始数据,break 暂停调试,halt 终止。

用这些东西,能写出画板程序、弹球、乒乓球游戏,甚至音乐播放器。

从文本到数字

手动往内存里填操作码太痛苦了,所以需要一个汇编器。你写这样的文本:

define counter 0
define limit 10
copy_to_from_constant counter 0
Loop:
  add_constant counter 1 counter
  branch_if_not_equal_constant counter limit Loop
halt

汇编器把它变成内存里的一串数字:9001 0 0 9011 0 1 0 9104 0 10 1003 9999

过程本身很有启发性。define 给地址起名字,Loop: 标记跳转目标。汇编器用经典的两遍扫描:第一遍收集所有标签的地址(这样你可以先 jump_to SomeLabel,后面再定义 SomeLabel:),第二遍把指令名替换成操作码,把标签和变量名替换成数字,逐个写入程序内存。

所谓"编译",最原始的形态就是这样——把人能读的东西翻译成机器能读的数字。

900 个像素的屏幕

30x30,900 个像素。听起来少得可怜。

但当你亲手用汇编一个像素一个像素地画出一个弹跳的小球时,你会对"像素"这个词产生全新的理解。每个像素就是一个内存地址,颜色就是 0 到 15 的一个数字。像素地址 = 2100 + y * 30 + x。16 种颜色:黑、白、红、绿、蓝、黄、青、品红、银、灰、栗、橄榄、深绿、紫、蓝绿、海军蓝。

渲染做了分场景优化:慢放模式下追踪"脏像素",只更新被写过的像素,被写入的像素还会短暂闪白,让你看到程序正在画什么——慢放下看着像素一个一个亮起来,有种看延时摄影的感觉。全速模式则跳过逐像素追踪,直接全量重绘,因为每帧都有大量像素变化,追踪反而是浪费。

用内存地址弹钢琴

音频部分是我最喜欢的设计。三个独立的振荡器通道,每个通道就是三个连续的内存地址:波形、频率、音量。

地址 3000: 波形 (0=方波, 1=锯齿波, 2=三角波, 3=正弦波)
地址 3001: 频率 (值 / 1000 = Hz)
地址 3002: 音量 (0-100)

往这几个地址写数字,声音就出来了。改个数字,音调就变了。

内置的 ChocolateRain 程序用两个通道演奏了一首完整的曲子。音乐数据全部用 data 指令嵌入在程序里——本质上就是一个大数组,记录着"第几拍、哪个通道、什么频率、多大音量"。程序读取当前时间,算出现在是第几拍,然后去数组里找对应的音符,写入音频内存。

一首歌,就是一个按时间索引的数组。

调试器:这才是重点

说实话,这台虚拟计算机最有价值的部分不是 CPU,不是显示器,不是音频——是调试器。

点"单步",程序计数器往前走一步。你能看到它读了哪个地址(蓝色高亮),写了哪个地址(橙色高亮)。设个断点,程序跑到那里自动停下来。把速度拉到慢放,看着弹球程序一帧一帧地擦掉旧位置、算出新位置、画上新像素。

我见过很多人学编程时卡在"不知道程序在干什么"。代码写完,跑起来,结果不对,然后就懵了。这台计算机的调试器让一切都暴露在外面:每一步读了什么、写了什么、程序计数器在哪里。没有黑箱,没有抽象层,你看到的就是全部。

六个程序,六种"原来如此"

内置的六个示例程序,每个都在教一件事:

Add —— 4 + 4 = 8。三行代码,结果存在地址 2。这是"指令怎么工作"的最小演示。

RandomPixels —— 用一个指针从地址 2100 扫到 2999,每个位置写一个随机颜色,然后从头再来。满屏闪烁的彩色像素,其实只是一个循环在往内存里写数字。

Paint —— 屏幕顶部一行是 16 色调色板,点击选色,然后在画布上画。鼠标位置就是一个内存地址里的数字,点击就是另一个地址从 0 变成 1。

BouncingBall —— 白色小球弹来弹去。用 Date.now() 控制帧率,每 60ms 更新一次位置,碰到边界就反转方向。这是"游戏循环"的最小实现。

MiniPong —— 乒乓球。两个挡板,一个球,碰到挡板反弹,错过就重置。这是最复杂的示例,用到了几乎所有指令。读完它的代码,你会对"游戏不过是一堆条件判断"有切身体会。

ChocolateRain —— 用汇编写的音乐播放器。理解这个程序怎么工作,就理解了数据驱动编程的本质。

重构与实现细节

原作者 jsdf 的实现是一个完整的单体,功能齐全但耦合度较高。我把它拆成了独立模块——CPU、内存、显示器、音频、输入、汇编器——通过内存这个"总线"连接,加了 TypeScript 类型系统。

拆的过程本身就是一次学习。当你必须决定"这个职责属于 CPU 还是属于 Memory"的时候,你对计算机架构的理解会变得非常具体。

架构

项目分成两个独立的 bundle:

src/index.ts    → dist/computer.module.js   (核心计算机)
src/simulator.ts → dist/simulator.module.js  (模拟器 UI)

index.ts 初始化所有硬件组件,返回一个 Computer 接口对象——这是两层之间唯一的契约。模拟器只通过这个接口操作计算机,不直接碰内部类。换掉整个计算机实现,只要接口不变,模拟器照常工作。

几个有意思的实现决策

内存布局用 const enum——MemoryPosition 定义所有地址常量,编译后直接内联为数字,零运行时开销。改一个数字,整台计算机的内存布局就变了。这就是"硬件规格"。

指令是数据驱动的——每条指令是一个对象,包含名称、操作码、操作数描述和执行函数。operands 数组不只是文档——汇编器用它验证操作数数量,调试器用它显示操作数含义。一份数据,三个用途。

流程控制指令直接改程序计数器——jump_to 的 execute 就是 CPU.programCounter = labelAddress。这形成了循环依赖(CPU → instructions → CPU),更"干净"的做法是把 CPU 状态作为参数传入,但在这个规模的项目里,简单直接比架构纯洁更重要。

性能:在不同场景下做不同的事

性能优化的核心思路不是"让代码更快",而是"在不同场景下做不同的事"——和真实系统的优化思路一样。

全速模式用帧预算策略:用 performance.now() 在每帧 14ms 的预算内尽量多跑 CPU 周期(留 2ms 给浏览器渲染和 GC),用 requestAnimationFrame 和屏幕刷新率同步。同时跳过内存追踪和调试面板更新,显示器切换到全量重绘。

慢放模式每次只执行一条指令,开启内存读写追踪,更新所有调试面板,显示器用脏像素增量重绘。

音频也做了状态缓存——用 state 对象记录上一次的参数值,只在值真正变化时才调用 Web Audio API,避免每帧 9 次无意义的 API 调用。CPU 停止时只需静音所有通道然后立即返回。

其他细节:内存重置用 Array.fill(0) 替代 for 循环;endTrace() 复用同一个对象避免每周期分配新数组;显示器用预计算的 Uint8Array 颜色查找表,位移 << 2 代替乘法索引;程序内存视图用虚拟滚动,只渲染可见区域 ± 10 行。

最后

折腾这台计算机的过程中,我反复体会到一件事:我们日常使用的那些抽象——变量、循环、函数、屏幕、声音——在最底层都是同一个东西:往一个地址读一个数字,或者写一个数字。

3100 个数字,23 条规则。这就是一台计算机的全部。

不信的话,打开试试:wsafight.github.io/little-virt…

点"单步",看看你的程序在做什么。


原项目:github.com/jsdf/little…

重构版源码:github.com/wsafight/li…