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

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

模拟器开发入门

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

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

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

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

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

这样一来,咱们须要思考的问题就进一步细化成了这几个:编程

  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 多分😅)。这一步看似麻烦,但也能够分为两个很是容易解释清楚的小步骤:sass

  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 还有一些相似的玩具,旨在用最简单的逻辑来实现编译器、解释器、前端框架等轮子的基础。欢迎关注哦😀

参考

相关文章
相关标签/搜索