机器指令翻译成 JavaScript —— No.4 动态跳转

时间:2021-11-22 20:26:38

上一篇,我们用模拟流程的方式,解决了跳转问题。

不过静态跳转,好歹事先是知道来龙去脉的。而动态跳转,只有运行时才知道要去哪。既然流程都是未知的,翻译从何谈起?

动态跳转,平时出现的多吗?非常多!除了 JMP 指令,还有一个更常用的,就是 RTS 指令。

它用于子流程的返回 —— 从栈上取出数据给程序计数器 PC,回到之前执行 JSR 指令的位置(相当于 call / return)。如果把栈上数据改了,那也是可以任意跳转的。

动态跳转很常用,因此必须得支持。

已有流程

动态跳转,理论上可以跳到任意位置,但事实上很少会乱跳。大多数时候,跳转的仍然是某个已有的流程。

比如 RTS 指令,跳转的就是之前执行 JSR 时的位置。(除非破坏了栈上的数据,跳到未知流程,但这是极小概率情况)

所以我们在翻译时,记录下每个 block_xxx 对应的原始位置:

addr_block_map = {
0x0600: block_0,
0x0612: block_1,
0x0618: block_2,
...
}

这样就可在运行时,通过「目标地址」查询对应的 JS 流程块。例如:

JMP ($00f0)

翻译成类似如下的 JS 代码:

pc = mem_read_uint16(0x00f0)
nextFn = addr_block_map[pc]

虽然 pc 的值不确定,但 addr_block_map[pc] 通常还是存在的。

使用这种方式,就能处理大多数情况下的「动态跳转」了!

未知流程

但是,总会有不存在的情况。最极端的,就是跳到栈内存上,将动态的数据当指令执行。。。这时,光靠翻译显然是做不到了。

不过,上一篇已给我们启示:如果翻译做不到,就用模拟凑合。现在完全无法翻译,那就 100% 模拟吧!

我们把模拟器、原始二进制指令,都打包在一起。运行过程中,一旦进入未知流程,就切换至模拟:

nextFn = addr_block_map[pc]

if (!nextFn) {       // 没有对应的流程,进入解释模式
nextFn = interpreter
return
}

模拟虽然很慢,但总比不支持好啊!

事实上,不必一直模拟下去,只要抓住机会,还是有可能翻身的:

function interpreter() {
do { // 解释模式
opcode = MEM[pc++] switch (opcode) {
case 0xA9: // LDA
...
case 0x85: // STA
...
case 0x4C: // JMP
pc = ...
nextFn = addr_block_map[pc]
}
} while(...)
}

一旦解释到「跳转指令」,并且跳到已有的 JS 流程上,这时就可以退出解释器,重回翻译模式了!

有了模拟器这个后备方案,我们总能活下去。并且大多数情况下,只是用来应急而已,不会模拟太久,因此性能损失不会太大。

到此,任意跳转的问题,就这样解决了。

结尾

前面提到,跳到栈上可以执行动态指令。事实上还有一种情况,不用跳转也可以,那就是:修改已有的指令。

下一篇,将讨论动态指令相关的问题。