去年折腾的一个东西,以前 blog 里也写过,不过那时边琢磨边写,因此比较杂乱,如今简单完整地讲解一下。html
当时看到一本虚拟机相关的书,正好又在想 JS 混淆相关的事,无心中冒出个想法:能不能把某种 CPU 指令翻译成等价的 JS 逻辑?这样就能在浏览器里直接运行。git
注意,这里说的是「翻译」,而不是模拟。模拟简单多了,网上甚至连 JS 版的 x86 模拟器都有不少。github
翻译原则上应该在运行以前完成的,而且逻辑上也尽量作到一一对应。浏览器
为了尝试这个想法,因而选择了古董级 CPU 6502 摸索。一是简单,二是情怀~(曾经玩红白机时还盼望能作个小游戏,不过发现 6502 很是蛋疼并且早就过期了,还不如学点 VBScript 作网页版的小游戏~)缓存
网上 6502 资料不少,好比这里有个 简单教程并自带模拟器,能够方便测试。性能优化
顺便再分享几个有趣的:app
6502 —— 伟大的心(上)wordpress
对于简单的指令,实际上是很容易转成 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 以上 😀