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

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

前言

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

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

翻譯原則上應該在運行以前完成的,而且邏輯上也儘量作到一一對應。瀏覽器

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

網上 6502 資料不少,好比這裏有個 簡單教程並自帶模擬器,能夠方便測試。性能優化

順便再分享幾個有趣的:app

簡單的指令很容易翻譯

對於簡單的指令,實際上是很容易轉成 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 以上 😀

相關文章
相關標籤/搜索