yqg800

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

0
阅读(2243)

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

前言

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

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

翻译原则上应该在运行之前完成的,并且逻辑上也尽可能做到一一对应。

为了尝试这个想法,于是选择了古董级 CPU 6502 摸索。一是简单,二是情怀~(曾经玩红白机时还盼望能做个小游戏,后来发现 6502 不仅麻烦还早就过时了,还不如学 VB~)


网上 6502 资料很多,比如这里有个简单教程并自带模拟器,可以方便测试。

顺便再分享几个有趣的:

简单的指令很容易翻译

对于简单的指令,其实是很容易转成 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-BDIZCvar 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: LDA(0x01) L_0602: STA(0x02) L_0604: JMP(0600)

因为 C 语言有「宏」功能,所以只需微小转换,符合基本语法就行。

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

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 编译成JS 代码

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

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

后记

后来还考虑用类似的思路,做一个带 JIT 功能的红白机模拟器。不过了解下 NES 的功能后就基本放弃了,因为远不止 6502 CPU 那么简单,还要考虑硬件中断、卡带 Mapper 等各种复杂的情况,所以就没做了。

大家有什么好的想法,也可以聊聊。


Baidu
map