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

EtherDream發表於2017-03-02

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

前言

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

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

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

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

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

網上 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 程式碼

四十年前的 6502 CPU 指令翻譯成 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:

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

演示: 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 以上 ?

相關文章