四十年前的 6502 CPU 指令翻译成 JS 代码会是怎样

去年折腾的一个东西,以前 blog 里也写过,不过那时边琢磨边写,因此比较杂乱,如今简单完整地讲解一下。html

前言

当时看到一本虚拟机相关的书,正好又在想 JS 混淆相关的事,无心中冒出个想法:能不能把某种 CPU 指令翻译成等价的 JS 逻辑?这样就能在浏览器里直接运行。git

注意,这里说的是「翻译」,而不是模拟。模拟简单多了,网上甚至连 JS 版的 x86 模拟器都有不少。github

翻译原则上应该在运行以前完成的,而且逻辑上也尽量作到一一对应。浏览器

为了尝试这个想法,因而选择了古董级 CPU 6502 摸索。一是简单,二是情怀~(曾经玩红白机时还盼望能作个小游戏,不过发现 6502 很是蛋疼并且早就过期了,还不如学点 VBScript 作网页版的小游戏~)缓存

网上 6502 资料不少,好比这里有个 简单教程并自带模拟器,能够方便测试。性能优化

顺便再分享几个有趣的:app

简单的指令很容易翻译

对于简单的指令,实际上是很容易转成 JS 的,好比 STA 100 指令,就是把寄存器 A 写到地址空间 100 的位置。由于 6502 是 8 位 CPU,不用考虑内存对齐这些复杂问题,因此对应的 JS 很简单:工具

mem[100] = A;

因为 6502 没有 IO 指令,而是经过 Memory Mapped IO 实现的,因此理论上「写入空间」不必定就是「写入内存」,也有可能写到屏幕、卡带等设备里。不过暂时先不考虑这个,假设都是写到内存里:

var mem = new Uint8Array(65536);

一样的,读取操做也很简单,就是得更新标记位。为了简单,能够把状态寄存器里的每一个 bit 定义成单独的变量:

// SR: NV-BDIZC

var SR_N = false,
    SR_V = false,
    SR_B = false,
    ...
    SR_C = false;

好比翻译 LDA 100 这条指令,变成 JS 就是这样:

A = mem[100];
SR_Z = (A == 0);
SR_N = (A > 127);

相似的,数学计算、位运算等都是很容易翻译的。可是,跳转指令却十分棘手。

由于 JS 里没有 goto,流程控制能力只能到语块,好比 for 里面能够用 break 跳出,但不能从外面跳入。

而 6502 的跳转能够精确到字节的级别,跳到半个指令上,甚至跳到指令区外,将数据当指令执行。

这样灵活的特征,光靠「翻译」确定是无解的。只能将模拟器打包进去,普通状况执行翻译的 JS ,遇到特殊状况用模拟解释执行,才能凑合着跑下去。

退一步考虑

不过为了简单,就不考虑特殊状况了,只考虑指令区内跳转,而且没有跳到半个指令中间,也不考虑指令自修改的状况,这样就容易多了。

仔细思考,JS 能经过 break、return、throw 等跳出语块,但没有任何「跳入语块」的能力。因此,要避开跳入的逻辑。

因而想了个方案:把指令中「能被跳入的地方」都切开,分割成好几块:

-------------
    XXX 1               |  block 0  |
    JXX L2  --.         |           |
    XXX 2     |         |           |
L1:           | <-.  ~~~~~~~~~~~~~~~~~~~
    XXX 3     |   |     |  block 1  |
    XXX 4     |   |     |           |
L2:         <-|   |  ~~~~~~~~~~~~~~~~~~~
    XXX 5         |     |  block 2  |
    XXX 6         |     |           |
    JXX L1      --|     |           |
    XXX 7               -------------

这样每一个块里面只剩跳出的,没有跳入的。

而后把每一个块变成一个 function,这样就能经过「函数变量」控制跳转了:

var nextFn = block_0;   // 经过该变量流程控制

function block_0() {
    XXX 1
    if (...) {          // JXX L2
        nextFn = block_2;
        return;
    }
    XXX 2
    nextFn = block_1    // 默认下一块
}

function block_1() {
    XXX 3
    XXX 4
    nextFn = block_2    // 默认下一块
}

function block_2() {
    XXX 5
    XXX 6
    if (...) {          // JXX L1
        nextFn = block_1;
        return;
    }
    XXX 7
    nextFn = null       // end
}

因而用一个简单的状态机,就能驱动这些指令块:

while (nextFn) {
    nextFn();
}

不过有些程序是无限循环的,例如游戏。这样就会卡死浏览器,并且也没法交互。

因此还需增长个控制 CPU 周期的变量,能让程序按照理想的速度运行:

function block_1() {
    ...
    if (...) {
        nextFn = ...
        cycle_remain -= 8   // 在此跳出,当前 block 消耗 8 周期
        return
    }
    ...
    cycle_remain -= 12      // 运行到此,当前 block 消耗 12 周期
}

...

// 模拟 1MHz 的速度(若是使用 50FPS,每帧就得跑 20000 周期)
setInterval(function() {
    cycle_remain = 20000;

    while (cycle_remain > 0) {
        nextFn();
    }
}, 20);

虽然函数之间切换会有必定的开销,但总比没法实现好。比起纯模拟,效率仍是高一些。

借助现成工具实现

不过上述都是理论探讨而已,并无实践尝试。由于想到个更取巧的办法,能够很方便实现。

由于 emscripten 工具能够把 C 程序编译成 JS,因此不如把 6502 翻译成 C 代码,这样就简单多了,毕竟 C 支持 goto。

因而写了个小脚本,把 6502 汇编码转成 C 代码。好比:

$0600  LDA #$01
$0602  STA $02
$0604  JMP $0600

变成这样的 C 代码:

L_0600: A = 0x01; ...
L_0602: write(A, 0x02);
L_0604: goto L_0600;

事实上 C 语言有「宏」功能,因此可将指令逻辑隐藏起来。这样只需更少的转换,符合基本 C 语法就行:

L_0600: LDA(0x01)
L_0602: STA(0x02)
L_0604: JMP(0600)

对应的宏实现,可参考这个文件:6502.h

对于「动态跳转」的指令,可经过运行时查表实现:

jump_map:

switch (pc) {
    case 0x0600: goto L_0600;
    case 0x0608: goto L_0608;
    case 0x0620: goto L_0620;
    ...
}

而后再实现基本的 IO,可经过 emscripten 内置的 SDL 库实现。C 代码的主逻辑大体就是这样:

void render() {
    cycle_remain = N;

    input();        // 获取输入
    update();       // 指令逻辑(执行到 cycle_remain <= 0)
    output();       // 屏幕输出
}

// 经过浏览器的 rAF 接口实现
emscripten_set_main_loop(render);

演示

咱们尝试将一个 6502 版的「贪吃蛇」翻译成 JS 代码。

这是 原始的机器码

20 06 06 20 38 06 20 0d 06 20 2a 06 60 a9 02 85
02 a9 04 85 03 a9 11 85 10 a9 10 85 12 a9 0f 85
14 a9 04 85 11 85 13 85 15 60 a5 fe 85 00 a5 fe
....
ea ca d0 fb 60

经过现成的反编译工具,变成 汇编码

$0600    20 06 06  JSR $0606
$0603    20 38 06  JSR $0638
$0606    20 0d 06  JSR $060d
$0609    20 2a 06  JSR $062a
$060c    60        RTS
$060d    a9 02     LDA #$02
....
$0731    ca        DEX
$0732    d0 fb     BNE $072f
$0734    60        RTS

而后经过小脚本的正则替换,变成符合 C 语法的 代码

L_0600: JSR(0606, 0600)
L_0603: JSR(0638, 0603)
L_0606: JSR(060d, 0606)
L_0609: JSR(062a, 0609)
L_060c: RTS()
L_060d: LDA_IMM(0x02)
....
L_0731: DEX()
L_0732: BNE(072f)
L_0734: RTS()

最后使用 emscripten 将 C 代码编译成 JS 代码

在线演示(ASDW 控制方向,请用 Chrome 浏览器)

固然,这种方式虽然很简单,但生成的 JS 很大。并且全部的 6502 指令对应的 JS 最终都在一个 function 里面,对浏览器优化也不利。


2018-01-25 更新

有天在 GitHub 上看到有人把原版的《超级玛丽》汇编加上了详细的注释: https://gist.github.com/1wErt3r/4048722,当即回想起了本文。

因而在此基础上作了一些改进,加上了 NES 的图像、声音、手柄等接口。因为《超级玛丽》游戏的中断(NMI)逻辑很简单,只需简单定时调用便可,无需处理 CPU 周期等复杂的问题,所以很容易翻译。

而后用一样的方式,将 6502 ASM 翻译成 C,而后再经过 emscripten 编译成 JavaScript:

演示: https://www.etherdream.com/FunnyScript/smb-js/game.html

(因为最新版的浏览器会把 asm.js 代码自动转成 WebAssembly,因此部分浏览器初始化比较慢,好比 Chrome 启动须要等好几秒。像 FireFox 会缓存 asm.js 的解析,因此只有首次加载会慢)

须要注意的是,这不是模拟器!最明显的特征,就是性能。

点击 Benchmark 按钮可测试游戏逻辑的极限 FPS,目前最快的是 Firefox,在我笔记本上能够跑到 19 万 FPS !就算 IE10 也能跑到 600 FPS。( IE10 如下的浏览器不支持)

固然,这还只是没作任何性能优化的结果,以后还会尝试更好的翻译方案,好比指令层的 call/jump 尽量翻译成代码层的函数调用、高级分支等。但愿能达到 50 万 FPS 以上 😀

相关文章
相关标签/搜索