机器指令翻译成 JavaScript —— 终极目标
上一篇,我们顺利将 6502 指令翻译成 C 代码,并演示了一个案例。
现在,我们来完成最后的目标 —— 转换成 JavaScript。
中间码输出
我们之所以选择 C,就是为了使用 LLVM。现在来看看,生成的 LLVM 中间表示:
不难看出,顺序执行的逻辑都在一个 label 中,跳转则用 br 符号。
这种风格,和我们之前讨论的指令切割非常相似。一个 label 块,正好翻译成一个 block_xxx 的 JS 函数。
所以,理论上翻译成 JS 并不困难,写一个 LLVM backend 插件即可。
现有工具
不过,实际操作起来还是挺麻烦的。
LLVM 中间码和汇编差不多,一步一个操作。如果直接翻译,生成的 JS 会很累赘,类似这样:
$301 = MEM[16];
$302 = $301 + 10;
$303 = ($302 == 0);
if ($303) {
...
}
如果能将多步操作合并成一行 JS,则会简洁得多。另外,变量名分配和重用,也是比较麻烦的。
事实上 LLVM 输出成 JS,前辈们早就尝试过了。例如 emscripten,目前已非常成熟。所以,我们不如就用现成的工具吧。
emscripten 是如何处理流程及跳转的?其实和我们之前讨论的切割类似,也是用额外的变量模拟流程。只不过它是用数字变量,而不是函数变量。
例如这样的流程:
a: xx 1
goto c
b: xx 2
goto a
c: xx 3
goto b
生成的 JS 类似这样:
while (1) {
switch (label) {
case 1:
xx 1
label = 3; continue;
case 2:
xx 2
label = 1; continue;
case 3:
xx 3
label = 2; continue;
}
}
使用数字,就可以在同个 function 里控制流程,因此更合理一些。
但如果流程复杂,则会陷入众多的判断。哪个方案更好,还得看浏览器的实际优化能力。
线程模型
既然要在浏览器中运行,当然就不能用 sleep 了,取而代之的是 yield。
但 emscripten 生成的代码,显然是不会有 yield 的。因此,我们得手动实现上下文的切换。
我们在切出前,记住当前的流程位置,然后 return;下次调用时,根据上一次的流程位置,跳转到相应的地方继续执行(用上一篇提到的动态表)。
这样,就符合浏览器的线程模型了。
接口交互
之前为了演示,使用新线程 + getchar() 来接受输入,这显然不符合浏览器的模型。
我们得监听键盘事件,在回调中更新相应的内存数据。
不过,emscripten 内置了 SDL 框架(类似于 DirectX),它封装了各种事件处理、图形渲染、音频播放等,非常实用。
SDL 会把消息记录在自己的队列里。任何时候,我们可以通过 Poll 的方式去拉取。这样就避免了回调,也不会有阻塞。
因此最终的模型,就类似这样:
void render() {
cycle_remain = N;
input(); // 获取输入
update(); // 指令逻辑(执行到 cycle_remain <= 0)
output(); // 屏幕输出
}
// 通过浏览器的 rAF 接口实现
emscripten_set_main_loop(render);
具体可以参考:这个文件。
最终结果
我们将上一篇的「贪吃蛇」编译成 JavaScript,在浏览器中运行:
在线演示 (ASDW 控制方向)
由于 emscripten 打包了一些 C 运行时、辅助函数、SDL 框架等各种程序,所以生成的脚本很大,超过 200 KB。事实上 6502 指令对应的 JS 并不多,可以参考下面链接。
回顾下整个翻译过程:
机器码 --> (现有工具) --> 汇编码 --> (小脚本) --> C 代码 --> (emscripten) --> JS 代码
虽然这种方式不是最完美的,但实现起来很简单。
当然,我们的目标并非为了实现 6502 指令,只是借此学习一下「程序流程」相关的知识,以及探索一些开脑洞的想法。