机器指令翻译成 JavaScript —— No.7 过渡语言

时间:2021-08-19 20:10:20

上一篇,我们决定使用 LLVM 来优化程序,并打算用 C 作为输入语言。现在我们来研究一下,将 6502 指令转换成 C 的可行性。

跳转支持

翻译成 C 语言,可比 JS 容易多了。因为 C 支持 goto,跳转可轻松实现。例如:

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

就能翻译成如下 C 代码:

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

我们把指令所在的位置,标记成对应的 label 名。例如 $0600 变成 L_0600:

这样 JMP $0600 即可翻译成 goto L_0600。很简单,完全符合 C 语法。

只要跳转是以指令为粒度的,都可以翻译成 goto。(跳到指令中间、指令区外,另当别论)

转换工具

现在,我们需要一个 6502 指令转 C 的工具。

本着不造*的原则,我们直接用现成的反汇编工具,生成上述那种的汇编代码。

但是,这不符合 C 语法啊 —— 没事,可以用脚本简单处理一下。例如:

  • 把每行开头的 $nnnn 替换成 L_nnnn:

  • 把 $ 替换成 0x

  • 补上括号、分号

几个正则一上,于是就变成这样:

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

这不就符合 C 语法了!

我们把每个指令定义成宏,让编译器自己去展开。调试时,就像调汇编一样,一步一个指令,不会进入逻辑细节。

当然,指令有很多种寻址模式。比如 LDA 指令,就有立即数、零页、零页偏移、绝对地址等模式。

因此,我们再增加一个后缀,以分区它们。例如 LDA $01 翻译成 LDA_ZP(0x01)

类似还有 _ZP_X_ZP_Y_ABS 等等。所以,最终 C 代码是这样的:

L_0600: LDA_ZP(0x01)
L_0602: STA_ZP(0x02)
L_0604: JMP_ABS(0600)

翻译跳转

现在换成 C 语言,跳转又该如何实现?

静态跳转很简单,只是包了一个语法糖:

#define JMP_ABS(addr)  goto L_##addr;

但是动态跳转,就需要斟酌一番了。

因为现在是用 goto 流程控制,没有将指令切割成 block_xxx 函数块,所以不能用之前那种查表的方式。

但是,可以用类似的方案。我们把已知的跳转点,事先都准备好:

jump_map:

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

动态跳转时,先 goto jump_map,然后根据 pc 的值,跳到相应的位置;如果没有符合的,则进入解释器模式。

这样,就实现了 C 版的动态跳转!

跳转点优化

当然,已知的跳转点会有很多,其实可以再精简一下。

动态跳转,绝大多数时候都是 RTS 指令,返回之前 JSR 的位置。所以,我们可以只列出 JSR 所在的位置。

相当于只提供 call/return 的跳转。其他小概率情况,反正可以用解释模式。

我们把这个跳转表(或者称作返回表),用宏包装一下,看起来变成这样:

RET_BEGIN
RET_ADDR(0600)
RET_ADDR(0604)
RET_ADDR(0620)
RET_END

这样,非常方便工具输出。

事实上精简这个跳转表,不只是为了减少代码量,更多是为了优化。如果每一行都有被 goto 的可能,那么 inline 就很难做了。

时钟模拟

现在,讨论时钟相关的问题。我们还是用一个变量,模拟剩余的周期数:

int cycle_remain = 20000;

然后每执行一条指令,扣除相应的数量。我们可以在宏里面定义,例如:

#define CYCLE(V)        cycle_remain -= V;

#define _ST(R, M)       write(M, R);
#define STA_ZP(v) CYCLE(3); _ST(A, v);
#define STA_ABS(v) CYCLE(4); _ST(A, v);
#define STA_ABS_X(v) CYCLE(5); _ST(A, v + X);

之前我们讨论的,是在跳转时统一扣除;现在,每个指令都要减一次,会不会浪费性能?

其实不用担心,这些都是常量计算,编译器会优化合并的。

接着就是频率控制。因为这是 C 语言,我们先尝试编译成本地程序,所以暂时无需考虑线程问题。如果 cycle_remain 用尽了,sleep 一下就可以。

演示

最后,就是将所有 6502 指令实现成宏。好在数量不算太多,而且很多都有规律。

上周末花了大半天时间,把这唯一的体力活完成了,大致是这样的:6502.h

现在,我们尝试一个案例 —— 贪吃蛇。

这是它的原始机器码:

0600: 20 06 06 20 38 06 20 0d 06 20 2a 06 60 a9 02 85
0610: 02 a9 04 85 03 a9 11 85 10 a9 10 85 12 a9 0f 85
0620: 14 a9 04 85 11 85 13 85 15 60 a5 fe 85 00 a5 fe
....
0730: 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()

以及一个用来支持动态跳转的表:

RET_BEGIN
RET_ADDR(0603)
RET_ADDR(0606)
RET_ADDR(0609)
RET_ADDR(060c)
....
RET_END

此外,还约定了几个特殊地址:

  • [0x0200, 0x0600) 屏幕输出 - 32*32 像素,8 位

  • 0xfe 随机数

  • 0xff 最后按下的键

考虑到屏幕很小,不如直接 print 出来吧。为了简单演示,先不实现颜色。

我们将其编译成本地程序,运行:

机器指令翻译成 JavaScript —— No.7 过渡语言

(由于字体原因,看起来变成了长方形,所以把图片高度缩小了一半)

虽然效果很差,但至少逻辑上是没问题的!

结尾

将 6502 指令翻译成 C 代码,其实还是非常容易的。不过,我们目标可是 JavaScript。

下一篇,我们将尝试终极挑战。