爱意满满的作品展示区。
zjcqoo

分享 6502 CPU 指令翻译成 JS 的思路

  •  
  •   zjcqoo · Mar 2, 2017 · 4537 views
    This topic created in 3508 days ago, the information mentioned may be changed or developed.

    去年折腾的一个东西,不过后来一直没更新,所以来分享下。有兴趣的可以探讨。。。

    前言

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

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

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

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

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

    顺便再分享几个有趣的:

    简单的指令很容易翻译

    对于简单的指令,其实是很容易转成 JS 的,比如 STA 100 指令,就是把寄存器 A 写到地址空间 100 的位置。因为 6502 是 8bit 的,所以不用考虑内存对齐这些复杂问题,所以对应的 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 跳出的能力,但没有跳入的能力,所以把指令「能被跳入的地方」都切开,分割成好几块:

                            -------------
        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   // 在此跳出,本流程消耗 8 周期
            return
        }
        ...
        cycle_remain -= 12      // 运行到此,本流程消耗 12 周期
    }
    
    ...
    
    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 很大。而且所有指令都在一个 function 里面,对浏览器优化也不利。

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

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

    4 replies  •  2017-03-03 11:06:48 +08:00
    nanpuyue
        1
    nanpuyue  
       Mar 3, 2017 via iPhone
    赞
    xummer
        2
    xummer  
       Mar 3, 2017
    GJ
    freestyleyooo
        3
    freestyleyooo  
       Mar 3, 2017
    完全看不懂
    bigxixi
        4
    bigxixi  
       Mar 3, 2017
    有意思
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Privacy   ·   Solana   ·   3335 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 35ms · UTC 11:56 · PVG 19:56 · LAX 04:56 · JFK 07:56
    ♥ Do have faith in what you're doing.