如何用 3KB 不到的 JavaScript 实现微机模拟器

8,096 阅读14分钟

不知道有多少同学小时候玩过小霸王、GBA 之类游戏主机的模拟器呢?模拟器不仅仅是上面的游戏好玩,编写它的过程也是很有意思的。下面我们会介绍怎样拿 JavaScript 从头做一个带 CPU、内存、输入输出、能玩老游戏,体积还不到 3KB 的模拟器

模拟器开发入门

如果你觉得下面的理论有些枯燥,不妨直接打开玩玩我们最后实现的成果:Merry8 模拟器。它用 2.5KB 的 JavaScript 代码,支持了一门上世纪 70 年代的汇编语言,能够让你在 Canvas 上体验当年用这门语言编写的 PONG 游戏(是的,就是那个来回弹跳的乒乓球),还支持通过 NPM 来安装并使用它,觉得有意思的话请点个 Star 再走哦😀

前戏过后就是正题啦。可能对于绝大多数同学来说,模拟器都是一个陌生的概念,那么它大概是个什么样的东西呢?

宽泛地说,从 Hello World 到 Alpha Go,所有的软件都不过是对【输入】给出【输出】的代码逻辑而已。那么,模拟器也是软件,它的输入和输出又是什么呢?想想你是怎么玩超级玛丽的吧:

  1. 你需要用模拟器打开超级玛丽的 ROM,这是模拟器的输入
  2. 你需要在游戏过程中按键,这也是模拟器的输入
  3. 模拟器有画面和声音,这是模拟器的输出

所以,只要你的代码实现了打开并运行 ROM,对用户输入做出响应,就是个能用的模拟器了!

这样一来,我们需要思考的问题就进一步细化成了这几个:

  1. 游戏 ROM 是什么格式,怎样打开它呢?
  2. 怎样运行游戏 ROM 里的代码呢?
  3. ROM 运行时,怎样接收用户输入,并把结果输出呢?

是不是和 如何把大象装进冰箱 的三部曲一样,非常简单而清晰呢?下面,我们就来逐一回答这三个问题:

把冰箱门打开:游戏 ROM 是什么格式?

Windows 的可执行文件是 .exe 格式,Linux 的可执行文件是 .elf 格式,而游戏主机的可执行文件就是 ROM 了。不同平台的游戏机,支持的 ROM 格式都有所不同。不过总的来说,它们都是由机器码所构成的二进制格式。一些前端同学可能熟悉 ArrayBuffer 这种数据结构,它非常适合表达这样的内容。所以,我们打开 ROM 时做的事情非常简单,只需要这两步:

  1. 发一个 Ajax 请求,获得 ROM 的静态文件。
  2. 把 ROM 文件转换成 JS 的 ArrayBuffer 数组。

这步结束后,我们获得的 ArrayBuffer 数组,每项都是一个大小在 0x000xFF 之间的数字。熟悉 CSS 颜色值的同学们笑了,这不就是十六进制下的 0~255 嘛!不过,这里的取值大小和颜色深浅可没什么关系,而是实打实的机器码。怎样破译这串数字的含义呢?

把大象装进去:如何运行 ROM 的代码?

提到【机器码】和【汇编语言】,可能不少同学首先想到的都是当年被微机原理支配的恐惧……不过请放心,这并没有多难(当年我好像只考了 70 多分😅)。这一步看似麻烦,但也可以分为两个非常容易解释清楚的小步骤:

  1. 0xF0 这样的机器码,翻译成可读性更好的汇编码
  2. 根据汇编码的意义来执行它。

从小霸王到 GBA,从 Apple II 到 80x86,各种曾经是主流的硬件平台,其硬件都有非常完善的开发者文档。文档里会告诉你形如这样的信息:

8xy3 - XOR Vx, Vy
Set Vx = Vx XOR Vy.

这是什么意思呢?大意就是:数值满足 8xy3 的机器码,对应的汇编指令叫做 XOR。这条指令的功能,是把 Vx 寄存器的值设置为 VxVy 做异或操作后的值(这里的 x 和 y 类似通配符,匹配出现在相应位置上的一位数值)。

所以,我们就可以把所有数值满足 8xy3 的机器码,翻译成为 XOR 汇编指令了。如果用函数来表达,这个函数大致形如:

function convert (num) {
  if (num[0] === 8 && num[3] === 3) {
    return 'XOR'
  }
}

上面的判断条件显然是错误的(进制和下标都是瞎写的),不过它的思路和真正可用的版本已经很接近了:输入机器码数值,根据文档判断出它是什么指令,只要写一大堆扁平的 else if 就足够了,不难吧?

把机器码数值转换为汇编码之后,我们需要做的就是最核心的内容了:根据汇编码的意义来执行它。这需要一种非常高端、精妙、富有智慧而通用的设计模式——

兵来将挡,水来土掩模式

这种模式的背后,是一种非常强大的编程思想,强调在代码中需求缺什么,就补什么。在编写模拟器时,这种模式指导我们:

  1. 见到有些汇编码会跳转地址,我们就补一个 count 地址变量,模拟出地址信息让它改。
  2. 见到有些汇编码会改寄存器,我们就补几个 Vx 变量,模拟出寄存器让它改。
  3. 见到有些汇编码会读写内存,我们就补一个 memory 数组,模拟出内存让它改。
  4. 见到有些汇编码会改堆栈数组,我们就补一个 stack 数组和一个 pointer 变量,模拟出一个能进能出的栈让它进进出出。
  5. ……

很多人误用了这种模式,在每次遇上小改动就琐碎地修修补补。在这里,我们的本意其实是在通读文档后,找出所有指令会修改的东西,用变量来模拟它。如果用伪代码表示,我们模拟出的一条汇编指令大致形如:

function ADD (a, b) {
  return a + b
}

我们先定义一个 ADD 函数,在函数内部处理好 ADD 汇编指令所修改的内容,这样我们就模拟出一条汇编指令了!实际的代码牵扯到一些位运算,但总体来说,你大可以把每条指令都当做一个单纯的函数。

在实现了这一堆汇编指令的功能以后,最后的关键问题就是该怎么样运行它呢?我们知道,每个 CPU 都有特定的运行频率,一旦运行就会按照这个频率不停地执行指令。所以,我们可以把 CPU 的这种运行方式,模拟为一个死循环:

while (true) {
  // 读取下一条指令。
  const ins = nextIns()
  // 将指令喂给 CPU 执行。
  cpu.run(ins)
}

好了!到此为止,我们知道了用 JavaScript 写模拟器的话,只需要:

  • 用函数模拟指令功能。
  • 用变量模拟寄存器、内存和堆栈。
  • 用循环模拟 CPU 运行。

这样是不是就足以让模拟器运行起来了呢?事情并没有这么单纯…再坚持一下就够了!

把冰箱门关上:如何处理输入输出?

对函数式编程有所了解的同学们,应该都了解【副作用】的概念。副作用代表着所有计算之外,【不纯粹】的东西,最典型的副作用就是【输入】和【输出】了。

如果没有副作用,那么模拟器就会毫无疑问地陷入死循环(比如用户打开游戏不按键,那么界面会卡在一个 Press Start to Continue 之类的标题画面不动)。所以,我们需要在上一步的基础上,实现某种机制,来合适地处理输入和输出。

在 JavaScript 的语义中,我们有 setTimeout 的概念。通过定时器,我们能够把同步的代码转为异步执行。对 CPU 不停进行计算的模拟会阻塞我们的主线程,所以对于一个真实世界的模拟器,我们需要使用一些异步的小技巧来为输入输出腾出空间。这个过程可以简化为:

  1. 把原来 while 不停执行指令的同步死循环,变成每隔一段时间执行若干条指令的异步循环。
  2. 设置事件监听器,在按下特定按钮的时候,更改模拟器的状态(这时候 CPU 的循环被定时器暂停了)。
  3. 每次触发 CPU 的异步循环,执行到一些判断输入状态的指令时,就可以获取到被输入事件修改过的状态了。

这样,我们就解决了输入的问题了!输出问题则简单得多:在 CPU 执行输出指令时,渲染 Canvas 即可。或者,你也可以另开一个定时器来不停地渲染屏幕状态。

到此为止,我们已经介绍了对模拟器而言,这几个最核心功能点的实现方式:

  • 如何读取 ROM 文件。
  • 如何模拟运行机器指令。
  • 如何处理输入输出。

理论水平已经足够了,下面就是实战啦😀

Chip-8 简介

我们的 Merry8 模拟器实现的是 Chip-8 汇编语言。这是一种上世纪 70 年代的中古语言。和小霸王 NES 使用的 6502 汇编不同的是,Chip-8 并没有一种官方的硬件实现,只要按照它的规范实现了完整的指令集,就可以运行兼容的 ROM 了。由于其结构的简单,它非常适合作为模拟器开发的入门语言

符合 Chip-8 规范的解释器(或者虚拟机、模拟器…)可使用的资源包括:

  • 4KB 大小的内存
  • V0 到 V15 共 16 个 16 位寄存器
  • 一个 PC 计数器
  • 一个 I 索引寄存器
  • 一个 SP 栈指针
  • 一个延迟定时器
  • 一个 16 键的键盘
  • 一个音效寄存器
  • 一个 64x32 的黑白屏幕

基于上面的背景介绍,我们可以非常自然地把这些资源抽象成 JavaScript:

  • 内存和堆栈:存放连续数据的 ArrayBuffer 数组
  • 寄存器和计数器:表示相应值的 number 变量
  • 键盘:表示各按键是否按下的 boolean 数组
  • 黑白屏幕:表达颜色的 boolean 二维数组

对指令而言,基础的 Chip8 规范共有 35 条指令,虽然每条指令长度都固定在 2 个字节,但不同指令中的参数格式是不同的。例如读取到的指令机器码为 60 12 时,整个处理流程就是:

  1. 匹配出该指令是 6xkk 指令,这是 LD(Load)指令,第一个操作数为 0 且第二个操作数为 12。
  2. 根据文档,将 0x12 这个操作数写入 V0 寄存器。

在明白了这条指令的含义后,我们就可以模拟出它的指令逻辑,来操纵模拟的硬件资源了。把这 35 条指令覆盖一遍后,我们就能实现整个模拟器啦。

对每条指令的实现细节,在 Chip-8 文档模拟器 CPU 源码 里都有相应的信息,在此就不再赘述啦。

Merry8 模拟器架构

Merry8 模拟器是笔者在 去年的圣诞节 花了一个周末实现的。这也是 Merry 命名的由来。不过鉴于当时只有不到半年的前端经验,它在一些工程细节上并不优雅,整体更接近于一个应用而非类库,把它写完丢到 Github 上以后也是疏于打理。值此白色相簿的季节,在优化了一些代码结构后,现在它已经是一个有着可用 API 且具备清晰模块结构的轮子了,主要的模块包括:

  • disassembler 模块,负责将机器码反汇编为可读的格式。
  • ops 模块,封装了 Chip-8 的 CPU 指令逻辑。
  • view 模块,负责渲染状态到 Canvas 上。

整个模拟器的运作方式基本和上文中的描述一致,用一句话说清楚,就是在异步循环中处理指令逻辑

在最近的 v0.3.0 更新中,它基于 OO 的基本方式,理清了几个模块之间的关系:你可以通过 new Merry8() 新建一个模拟器实例,每个模拟器实例都有着自己的虚拟 CPU、内存、堆栈指针、寄存器和 Canvas 上下文。这样,你可以很轻松地在一个页面里实例化多个模拟器,加载不同的 ROM 并进行不同的控制。

在前端的工程化方面,这个玩具也有些靠谱的实践:

  • Rollup 构建。
  • StandardJS 风格 Lint。
  • 对若干反汇编函数和 CPU 指令,实现了单元测试。

目前,Merry8 还处于 Beta 状态,它的游戏兼容性还很不理想,测试覆盖也很欠缺,但如果你有兴趣来参与完善它,非常欢迎你提出 PR!

总结

毫无疑问,Merry8 就算再完善,也不过是一个玩具而已。那么,为什么我还愿意花这么多精力来正经地实现、维护并介绍它呢?我能想到几个理由:

  • 开发模拟器,是一个了解计算机基础知识如何工作的好方式。它不光有着容易展示出成果的乐趣,还可以让你借此了解到指令如何运作、内存如何分配、堆栈如何增减的基础知识。
  • 编写模拟器比起其它了解底层技术的方式而言,思维负担更轻。比如,你并不需要学习如何使用 C 或 C++ 之类底层的编程语言。注意,开发模拟器并不需要会写汇编语言,我到现在也不会用 Chip-8 汇编写游戏,只知道每条指令做什么就足够了。
  • 它能给你真正意义上根据文档来思考模块结构的机会。不同于日常根据接口文档编写的【入参格式、出参格式】胶水代码,你要实现的东西是一份技术规范。别忘了,多少人啃过的 ECMA-262 同样也只是一份技术规范。
  • 它能够锻炼你调试与单元测试的能力。在一个每秒执行成千上万次的循环里,一条指令的细微偏移就会让整个模拟器失效。所以,你需要用单元测试来保证每条指令的正确性,并在出现问题时用比 console.log 更靠谱的调试技术来定位并解决。
  • 它能够培养你诊断性能瓶颈并优化的思考方式。比如,在第一个版本里,模拟器的 CPU 占用一直是满的。我以为问题出在定时逻辑上,但 Profiling 后发现问题出在渲染层:当时使用 DOM 绘图,对 64X32 的上千个 DOM 节点,60fps 的全量更新已经使得浏览器不堪重负。在迁移到 Canvas 后,CPU 负载问题就顺利解决了。

最后不得不提的是,在调试模拟器 ROM 的时候,会让你对技术和历史有更多的敬畏。不要觉得 3KB 内实现一个模拟器有多么了不起,要知道它所模拟的 PONG 游戏只有 246 字节!天知道它的作者是怎么在 200 多个字节的空间里实现碰撞、计分和 IO 交互的,也许这就是上古时期程序员的神级操作吧

如果你觉得本文的主题有些意思,在我的 Github 还有一些类似的玩具,旨在用最简单的逻辑来实现编译器、解释器、前端框架等轮子的基础。欢迎关注哦😀

参考