為什麼要懂 x86#
x86 是一個超過三十年歷史卻異常長壽的架構,今天絕大多數桌機與筆電都在跑它,此外也出現在 Mac、遊戲主機甚至智慧型手機上。它由 Intel 於 1978 年隨 8086 CPU 推出,多年來 Intel 與 AMD 等廠商大幅提升其效能,從 16 位元運算走到 32 位元、再到 64 位元。現代架構除了處理器指令與程式設計慣用手法之外,幾乎已經和原始的 8086 沒什麼共通點——也正因為歷史悠久,x86 架構非常複雜。
指令集架構#
談 CPU 如何執行機器碼時,通常會談指令集架構(instruction set architecture, ISA)。ISA 定義機器碼如何運作、如何與 CPU 及電腦其他部分互動。對 ISA 有實用等級的理解,是有效逆向的關鍵。
ISA 定義了程式可用的機器語言指令集,每一條機器語言指令都用一個助憶符號指令(mnemonic instruction) 來表示。助憶符號替指令命名,並決定它的參數(運算元,operands)如何呈現。
常見 x86 助憶符號指令對照表
| 指令 | 說明 |
|---|---|
MOV destination, source | 把 source 的值搬到 destination |
ADD destination, value | 把整數值加到 destination |
SUB destination, value | 從 destination 減去整數值 |
CALL address | 呼叫指定位址的副常式 |
JMP address | 無條件跳到指定位址 |
RET | 從先前的副常式返回 |
RETN size | 從先前的副常式返回,並將堆疊遞增 size |
Jcc address | 若 cc 所示條件成立,跳到指定位址 |
PUSH value | 將值推入目前堆疊,並遞減堆疊指標 |
POP destination | 將堆疊頂端彈出到 destination,並遞增堆疊指標 |
CMP valuea, valueb | 比較 valuea 與 valueb,設定對應旗標 |
TEST valuea, valueb | 對兩值做位元 AND,設定對應旗標 |
AND destination, value | 對 destination 與 value 做位元 AND |
OR destination, value | 對 destination 與 value 做位元 OR |
XOR destination, value | 對 destination 與 value 做位元 XOR |
SHL destination, N | 將 destination 左移 N 位元(左為高位) |
SHR destination, N | 將 destination 右移 N 位元(右為低位) |
INC destination | 將 destination 加 1 |
DEC destination | 將 destination 減 1 |
助憶符號指令依運算元數量分成三種形式:
| 運算元數量 | 形式 | 範例 |
|---|---|---|
| 0 | NAME | POP、RET |
| 1 | NAME input | PUSH 1、CALL func |
| 2 | NAME output, input | MOV EAX, EBX、ADD EDI, 1 |
x86 指令在組合語言中有兩種常見寫法:Intel 語法與 AT&T 語法。Intel 語法由 Intel 提出,本章一律採用;AT&T 語法則常見於 Unix-like 系統的開發工具。兩者差異之一是運算元順序,例如「把 1 加到 EAX 暫存器」寫成:
ADD EAX, 1 ; Intel 語法
addl $1, %eax ; AT&T 語法讀反組譯輸出時務必先確認是哪一種語法。運算元順序相反,會讓你把「搬進去」讀成「搬出來」,整段邏輯的理解就全反了。
CPU 暫存器#
CPU 有若干暫存器(registers) 用來極快速地暫存目前的執行狀態。在 x86 中,每個暫存器以兩到三個字元的標籤表示。理解處理器支援的各類暫存器很重要,因為它們各司其職,也決定了指令如何運作。
x86 暫存器分成四大類:通用、記憶體索引、控制、選擇器。

圖表 6-2:32 位元 x86 的主要暫存器
通用暫存器#
通用暫存器(general purpose registers)(EAX、EBX、ECX、EDX)用來暫存非特定用途的運算值,例如加減法的結果。它們大小為 32 位元,但指令可以透過簡單的命名慣例存取其 16 位元與 8 位元版本:例如 EAX 的 16 位元版本是 AX,8 位元版本則是 AH 與 AL。

圖表 6-3:EAX 通用暫存器及其較小的組成暫存器
記憶體索引暫存器#
記憶體索引暫存器(memory index registers)(ESI、EDI、ESP、EBP、EIP)大致上也是通用的,但 ESP 與 EIP 例外:
- ESP:供 PUSH、POP 指令以及副常式呼叫使用,指出目前堆疊基底所在的記憶體位置。雖然你可以拿 ESP 做索引堆疊以外的事,但通常很不明智,因為部分指令會隱含依賴它的值,可能造成記憶體毀損或非預期行為。
- EIP:不能當一般通用暫存器直接存取,因為它指出下一條指令要從哪個記憶體位址讀取。唯一改變 EIP 的方式是使用 CALL、JMP、RET 這類控制指令。
控制暫存器:EFLAGS#
在逆向討論中最重要的控制暫存器是 EFLAGS。它包含一系列布林旗標,表示指令執行的結果,例如上一次運算的結果是否為 0。這些布林旗標是 x86 條件分支的實作基礎:例如兩值相減結果為 0 時,EFLAGS 的 Zero 旗標會設為 1,不適用的旗標則設為 0。EFLAGS 也包含重要的系統旗標,例如中斷是否啟用。注意並非所有指令都會影響 EFLAGS。
| 位元 | 名稱 | 說明 |
|---|---|---|
| 0 | Carry flag | 上一次運算是否產生進位位元 |
| 2 | Parity flag | 上一次運算最低有效位元組的同位 |
| 6 | Zero flag | 上一次運算結果是否為 0;用於比較運算 |
| 7 | Sign flag | 上一次運算的正負號;實際上就是結果的最高有效位元 |
| 11 | Overflow flag | 上一次運算是否溢位 |
選擇器暫存器#
選擇器暫存器(selector registers)(CS、DS、ES、FS、GS、SS)以「指出某個特定記憶體區塊」的方式定址記憶體,實際讀寫用的真實記憶體位址會在 CPU 內部表格中查得。
選擇器暫存器通常只出現在作業系統專屬的操作中。例如在 Windows 上,FS 暫存器用來存取儲存目前執行緒控制資訊的記憶體。
記憶體存取的特性#
- 小端序(little endian):記憶體以小端序位元組順序存取,也就是最低有效位元組存放在最低的記憶體位址。
- 不要求對齊:在對齊架構(如 SPARC)上,所有主記憶體讀寫都必須對齊到運算大小——例如讀取 32 位元值就必須從 4 的倍數位址讀取,讀取未對齊位址會產生錯誤。x86 則允許你在任意記憶體位址讀寫,不論是否對齊。
- 指令可直接吃記憶體位址:不同於 ARM 這類用專屬指令在暫存器與主記憶體間載入/儲存的架構,許多 x86 指令可以直接把記憶體位址當運算元。
x86 支援相當複雜的記憶體定址格式:每個記憶體位址參照可包含一個基底暫存器、一個索引暫存器、索引的乘數(1 到 8 之間)以及一個 32 位元偏移量。以下這條 MOV 指令同時用上四種:
MOV EAX, [ESI + EDI * 8 + 0x50] ; Read 32-bit value from memory address看到方括號
[ ]就代表「這是記憶體位址參照」,而不是值本身。這個小細節在讀結構體或陣列存取時特別關鍵。
程式流程#
程式流程(program flow),或稱控制流程(control flow),是程式決定要執行哪些指令的方式。x86 有三種主要的程式流程指令:
- 副常式呼叫(subroutine calling):把程式流程導向一段指定的指令序列。由
CALL完成,它會把 EIP 改成副常式的位置,同時把「下一條指令的記憶體位址」放到目前堆疊上,作為任務完成後的返回點。返回則用RET,它把 EIP 改成堆疊頂端的位址——也就是 CALL 先前放進去的那個。 - 條件分支(conditional branches):讓程式碼能依先前的運算結果做決定。
CMP比較兩個運算元(可能是兩個暫存器)並計算 EFLAGS 的對應值;它的內部做法是把一個值減去另一個、據此設定 EFLAGS,然後丟棄結果。TEST做的事一樣,只是把減法換成 AND。EFLAGS 算好之後即可執行條件分支,跳往的位址取決於 EFLAGS 的狀態:例如JZ會在 Zero 旗標被設起時跳躍(比如 CMP 比較的兩值相等),否則等同無動作。 - 無條件分支(unconditional branches):由
JMP實作,單純無條件跳到目標位址,沒有其他名堂。
EFLAGS 不是只有 CMP 和 TEST 會動到,算術與其他指令同樣會設定它。例如
SHL把目標值由低位往高位移動指定位元數時也會影響旗標。追蹤條件跳躍時,要往回找的是「最後一個真正設定旗標的指令」,未必是最接近的那條 CMP。