你有没有想过,一台计算机最少需要什么?
不是说你桌上那台------那个有几十亿个晶体管、跑着操作系统和浏览器的庞然大物。我说的是最本质的那个东西:能算数、能画画、能放音乐、能响应你的键盘和鼠标。
答案可能会让你意外:一个数组就够了。
Little Virtual Computer 是一台用 TypeScript 写的虚拟计算机,原作者是 jsdf。我在他的基础上做了不少重构和优化------把代码拆分成了清晰的模块结构,加了音频系统、断点调试、内存追踪、中英文切换等功能。3100 个内存槽位,23 条指令,你可以在上面写汇编程序,画像素,甚至播放一首 Chocolate Rain。打开链接就能玩,不用装任何东西。
接下来聊聊拆解和重构这台计算机的过程中,那些让我觉得"原来如此"的时刻。
"硬件"就是一行代码
这台计算机的内存,就是这行:
typescript
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 代码时,我以为会很复杂。结果核心逻辑是这样的:
typescript
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 终止。
用这些东西,能写出画板程序、弹球、乒乓球游戏,甚至音乐播放器。
从文本到数字
手动往内存里填操作码太痛苦了,所以需要一个汇编器。你写这样的文本:
asm
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 种颜色:黑、白、红、绿、蓝、黄、青、品红、银、灰、栗、橄榄、深绿、紫、蓝绿、海军蓝。
渲染做了分场景优化:慢放模式下追踪"脏像素",只更新被写过的像素,被写入的像素还会短暂闪白,让你看到程序正在画什么------慢放下看着像素一个一个亮起来,有种看延时摄影的感觉。全速模式则跳过逐像素追踪,直接全量重绘,因为每帧都有大量像素变化,追踪反而是浪费。
用内存地址弹钢琴
音频部分是我最喜欢的设计。三个独立的振荡器通道,每个通道就是三个连续的内存地址:波形、频率、音量。
ini
地址 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:
bash
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...
点"单步",看看你的程序在做什么。