簡版:https://www.cnblogs.com/index-html/p/6492418.html
前言
前些時候研究腳本混淆時,打算先學一些「程序流程」相關的概念。為了不因太枯燥而放棄,決定想一個有趣的案例,可以邊探索邊學。
于是想了一個話題:嘗試將機器指令 1:1 翻譯 成 JavaScript,這樣就能在瀏覽器中,直接運行等價的邏輯。
為了簡單起見,這里選擇古董級 CPU —— MOS 6502。
本系列陸續更新了 8 篇,前面幾篇只是理論分析:
原本只打算遐想一下,分析下可行性而已。不過,后來發現實現也不難,于是又補了兩篇:
6502
MOS 6502 是一款經典的 CPU,在上世紀 80 年代十分流行。
例如 Atari、Apple II,還有國內的文曲星,都配置了這個系列的 CPU。小時候常玩的 FC 紅白機,也是相同的指令集。
網上相關的文章也非常多,這里收集了一些:
甚至還有在線模擬器:
事實上,模擬器的原理是很簡單的:讀取一條指令,做相應的操作;然后再讀取下一條指令。。。參照文檔實現即可。
do {
opcode = memory[pc++]
switch (opcode) {
case 0xA9: // LDA
...
case 0x85: // STA
...
case 0xE6: // INC
...
....
}
} while (...)
模擬雖然簡單,但有個很大的缺點:效率低。模擬一個指令,需要很多額外操作 —— 那些原本是硬件的工作,現在要用軟件來完成,顯然會慢得多。
不過,我們的目標并非模擬,而是翻譯 —— 在程序運行前,把「虛擬指令」翻譯成相應的本地「原生指令」,這樣就能直接運行,無需模擬,效率自然大幅提升。
在瀏覽器層面,JavaScript 就是原生指令。那么,能否將 6502 翻譯成 JavaScript 呢?下面開始探索。。。
硬件實現
6502 CPU 有三個 8 位寄存器 A、X、Y,我們用 JS 變量來表示:
var A = 0, X = 0, Y = 0;
至于「狀態寄存器」SR,為了直觀起見,分別用單獨的 bool 變量表示每一位:
// SR: NV-BDIZC
// bit 76543210
var SR_N = false,
SR_V = false,
...
SR_C = false;
其他諸如「棧寄存器」、「指令計數器」,這里暫時先省略。
6502 的地址總線有 16 位,最多能訪問 64K 的空間。數據總線 8 位,因此用一個 Uint8Array 就能實現內存:
var MEM = new Uint8Array(65536);
這里假設把整個地址空間都用做 RAM,事實上屏幕、鍵盤等 IO 交互,還會占用一些地址空間。
嘗試翻譯
現在,嘗試翻譯第一條指令:
STA 100
STA 即 “Store A”,將 A 寫入存儲 —— 寫到第 100 號位置。對應的 JS 即:
MEM[100] = A;
很簡單吧。下面翻譯第二條指令:
LDA #123
LDA 即 “Load A”,給 A 賦值,# 表示立即數。因此,生成的 JS 的就是:
A = 123;
SR_Z = (123 == 0);
SR_N = (123 > 127);
稍了解匯編的都知道,修改寄存器的同時,還得更新狀態標志。SR_Z 表示結果是否為零;SR_N 表示最高位(符號位)是否為 1。
這時「翻譯」的優勢就體現出來了。因為 123 == 0 和 123 > 127 都是常量計算,所以預先就能得出結果:
A = 123;
SR_N = false;
SR_Z = false;
相比模擬,翻譯能減少運行時的計算量。如果有多個指令,效果則更明顯,例如:
LDX 10
INX
翻譯成如下 JS 代碼:
X = MEM[10]; // LDX 10
SR_Z = (X == 0);
SR_N = (X > 127);
X = (X + 1) & 0xff; // INX (X 自增)
SR_Z = (X == 0);
SR_N = (X > 127);
這里雖然沒有預先計算,但不要忘了,JavaScript 最終還得交給瀏覽器解析。
如今的瀏覽器,本身就有很強的優化能力,腳本引擎發現 SR_Z 和 SR_N 重復賦值,并且中間沒有使用,于是就將之前的計算優化掉了。因此,最終效率會非常高。
真正困難
通過這幾個例子,感覺翻譯并不困難。事實上大多數 6502 指令,都可以生成對應的 JS 邏輯。有的很簡短,只有一兩行;有的較復雜,例如算術加減法。但不管怎樣,都是沒有障礙的。
但是,有一類指令很難翻譯,那就是「跳轉指令」。因為不同的層面,流程控制的能力是不一樣的。
在 JavaScript 中,流程控制只能以「語塊」為單位:
if (...) {
block 1
} else {
block 2
}
for (...) {
break;
continue;
}
我們最多只能退出語塊(break),或者重新進入語塊(continue),無法指定從某一行開始運行。
而在 C 語言中,流程控制可以細致到行:
a: ...
goto c;
b: ...
goto a;
c: ...
goto b;
機器指令更底層,因此更靈活。流程控制是以「字節」為單位的,可以跳到任意位置。甚至跳到一個指令的中間:
Address Hexdump Dissassembly
-------------------------------
$0600 a9 00 LDA #$00
$0602 4c 01 06 JMP $0601
于是將 LDA 的參數 0x00 當成另一個指令(BRK 指令)執行。
更有甚者,還可以跳到棧內存上,將動態數據當成指令執行。如此靈活的特性,又該如何實現?
下一篇,我們探討如何處理跳轉指令。
文章列表