去年折騰的一個東西,以前 blog 裏也寫過,不過那時邊琢磨邊寫,因此比較雜亂,如今簡單完整地講解一下。html
當時看到一本虛擬機相關的書,正好又在想 JS 混淆相關的事,無心中冒出個想法:能不能把某種 CPU 指令翻譯成等價的 JS 邏輯?這樣就能在瀏覽器裏直接運行。git
注意,這裏說的是「翻譯」,而不是模擬。模擬簡單多了,網上甚至連 JS 版的 x86 模擬器都有不少。github
翻譯原則上應該在運行以前完成的,而且邏輯上也儘量作到一一對應。瀏覽器
爲了嘗試這個想法,因而選擇了古董級 CPU 6502 摸索。一是簡單,二是情懷~(曾經玩紅白機時還盼望能作個小遊戲,不過發現 6502 很是蛋疼並且早就過期了,還不如學點 VBScript 作網頁版的小遊戲~)緩存
網上 6502 資料不少,好比這裏有個 簡單教程並自帶模擬器,能夠方便測試。性能優化
順便再分享幾個有趣的:app
6502 —— 偉大的心(上)wordpress
對於簡單的指令,實際上是很容易轉成 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 以上 😀