四十年前的 6502 CPU 指令翻譯成 JS 代碼會是怎樣

去年折騰的一個東西,之前 blog 裏也寫過,不過那時邊琢磨邊寫,所以比較雜亂,現在簡單完整地講解一下。

前言

當時看到一本虛擬機相關的書,正好又在想 JS 混淆相關的事,無意中冒出個想法:能不能把某種 CPU 指令翻譯成等價的 JS 邏輯?這樣就能在瀏覽器裏直接運行。

注意,這裏說的是「翻譯」,而不是模擬。模擬簡單多了,網上甚至連 JS 版的 x86 模擬器都有很多。

翻譯原則上應該在運行之前完成的,並且邏輯上也儘可能做到一一對應。

爲了嘗試這個想法,於是選擇了古董級 CPU 6502 摸索。一是簡單,二是情懷~(曾經玩紅白機時還盼望能做個小遊戲,不過發現 6502 非常蛋疼而且早就過時了,還不如學點 VBScript 做網頁版的小遊戲~)

網上 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-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、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: A = 0x01; ...
L_0602: write(A, 0x02);
L_0604: goto L_0600;

事實上 C 語言有「宏」功能,所以可將指令邏輯隱藏起來。這樣只需更少的轉換,符合基本 C 語法就行:

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

對應的宏實現,可參考這個文件:6502.h

對於「動態跳轉」的指令,可通過運行時查表實現:

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 將 C 代碼編譯成 JS 代碼

在線演示(ASDW 控制方向,請用 Chrome 瀏覽器)

當然,這種方式雖然很簡單,但生成的 JS 很大。而且所有的 6502 指令對應的 JS 最終都在一個 function 裏面,對瀏覽器優化也不利。


2018-01-25 更新

有天在 GitHub 上看到有人把原版的《超級瑪麗》彙編加上了詳細的註釋: https://gist.github.com/1wErt3r/4048722,立即回想起了本文。

於是在此基礎上做了一些改進,加上了 NES 的圖像、聲音、手柄等接口。由於《超級瑪麗》遊戲的中斷(NMI)邏輯很簡單,只需簡單定時調用即可,無需處理 CPU 週期等複雜的問題,因此很容易翻譯。

然後用同樣的方式,將 6502 ASM 翻譯成 C,然後再通過 emscripten 編譯成 JavaScript:

演示: https://www.etherdream.com/FunnyScript/smb-js/game.html

(由於最新版的瀏覽器會把 asm.js 代碼自動轉成 WebAssembly,所以部分瀏覽器初始化比較慢,比如 Chrome 啓動需要等好幾秒。像 FireFox 會緩存 asm.js 的解析,所以只有首次加載會慢)

需要注意的是,這不是模擬器!最明顯的特徵,就是性能。

點擊 Benchmark 按鈕可測試遊戲邏輯的極限 FPS,目前最快的是 Firefox,在我筆記本上可以跑到 19 萬 FPS !就算 IE10 也能跑到 600 FPS。( IE10 以下的瀏覽器不支持)

當然,這還只是沒做任何性能優化的結果,之後還會嘗試更好的翻譯方案,比如指令層的 call/jump 儘可能翻譯成代碼層的函數調用、高級分支等。希望能達到 50 萬 FPS 以上 😀

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章