去年折騰的一個東西,之前 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 以上 ?