JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

讀懂自己程式碼的反組譯

你已經學過中央處理器在做什麼、堆疊如何運作、以及呼叫慣例承諾了什麼。現在你要把這一切兜起來,做一件讓組合語言變得真實的事:拿一個你親手寫的小 C 函式,請編譯器把機器碼攤給你看,然後一行一行地讀,直到它不再是一面十六進位的牆,而成了一個你能跟得上的故事。

反組譯是什麼,以及你為什麼會想要它

在這一階裡,你已經把所需的每一塊都搭好了:一個暫存器是中央處理器拿來運算的一個有名字的小抽屜、rip 走過一條條指令、rsp 標記堆疊的頂端,而一個呼叫慣例則定死了誰在哪裡傳引數、誰負責收拾。反組譯不過就是這件事:拿已經做好的機器碼——中央處理器真正執行的那些原始位元組——把每一個都翻回它所代表、人類讀得懂的組合語言助憶符。它是組譯器所做之事的反向:組譯器把 `mov rax, 0` 變成位元組,反組譯器把那些位元組變回 `mov rax, 0`。

明明 C 一開始就是你寫的,何必費這個事?因為編譯器並不執行你的 C——它執行的是它對你的 C 的翻譯,而這兩者之間的落差,正是真正的理解所棲身之處。讀反組譯,是你終於看清你的 C 究竟變成了什麼的途徑:哪幾行消失了、哪個迴圈被展開了、一次函式呼叫真正發生在哪裡、為什麼某個變數住在暫存器裡而不是記憶體裡。當除錯器在一次當機處停下、卻沒有原始碼可看時,當一個臭蟲只在 `-O2` 出現時,或當你純粹想要停止「信任」、開始「知道」機器在做什麼時,你去的也正是這裡。

讓位元組現身

看反組譯有三種日常途徑,三種都值得知道,因為每一種呈現的視角都略有不同。第一,請編譯器直接吐出組合語言、而非一個可執行檔:`gcc -O0 -S add.c` 會寫出 `add.s`,那是它「還沒」變成位元組「之前」、人類讀得懂的組合語言。第二,用 objdump 反組譯一個已完成的目的檔或程式:`objdump -d a.out` 會走過機器碼,把每一條指令印在它的原始位元組旁邊。第三,在像 gdb 這樣的除錯器裡,`disassemble main` 會顯示一支執行中程式的即時指令,並在即將執行的那一條上標一個箭頭。

對第一次閱讀而言,有一個選擇比其他任何選擇都重要:把最佳化器「關掉」,用 `-O0`。在 `-O0` 下,編譯器幾乎是逐字翻譯你的 C——每個變數都在堆疊上有個家、每條敘述都對映到一團認得出來的指令、沒有任何東西被重排或刪除。程式碼較大、較慢,但讀起來像是你原始碼的一份忠實謄本。在 `-O2` 下,編譯器既聰明又無情:它把值留在暫存器裡、刪掉死碼、合併並重排,結果便不再對得上你那一行一行。先學會讀 `-O0`;等到這套詞彙琅琅上口了,再升級去讀 `-O2`。

走過一個小小的函式

我們來讀點真的東西。拿那個最小卻有意思的函式——收兩個整數、把它們相加——以 x86-64、不最佳化來編譯。你這一階學到的一切,即將在八行裡現身,而你會認得它的每一部分。下面是 C,旁邊是 Intel 語法、`-O0` 的反組譯,左邊放著原始位元組,好讓你看見這就是真正的機器碼。

int add(int a, int b) { return a + b; }

         bytes            instruction          what it does
add:
 55                push   rbp                ; save caller's base pointer
 48 89 e5         mov    rbp, rsp           ; rbp = frame anchor (prologue)
 89 7d fc         mov    [rbp-0x4], edi     ; spill arg a (edi) into the frame
 89 75 f8         mov    [rbp-0x8], esi     ; spill arg b (esi) into the frame
 8b 55 fc         mov    edx, [rbp-0x4]     ; edx = a
 8b 45 f8         mov    eax, [rbp-0x8]     ; eax = b
 01 d0            add    eax, edx           ; eax = a + b   (result in eax)
 5d               pop    rbp                ; restore caller's rbp (epilogue)
 c3               ret                       ; pop return address into rip, jump back
一個四個字元的 C 函式在中央處理器眼中的樣子:序言、依呼叫慣例抵達於 edi 與 esi 的引數、相加、尾聲,以及 ret。

由上往下讀,並替每一部分命名。頭兩行 `push rbp` 接著 `mov rbp, rsp`,就是第 3 篇的序言——每個函式開場都先存下舊的基底指標、錨定一個新的堆疊框架。接下來兩行 `mov` 把抵達的引數從呼叫慣例交付它們的暫存器裡複製出來——`int a` 抵達於 edi、`int b` 抵達於 esi,正是第 4 篇的規則——並把它們溢存進框架裡的 `[rbp-0x4]` 和 `[rbp-0x8]`。(在 `-O0` 下編譯器總是把引數停在記憶體裡,這正是程式碼如此逐字的原因。)然後它再把它們載回 eax 與 edx、執行那唯一一條來自算術家族的 `add`,把總和留在 eax 裡。

最後兩行收束了這個故事。`pop rbp` 是尾聲,藉由還原呼叫端的基底指標來撤銷序言。接著 `ret` 做了整份清單裡最重要的一件事:它把 `call` 先前推上堆疊的返回位址彈出來,並讓 rip 跳回那裡——這正是第 3 篇那個讓函式返回到呼叫它的人那裡的機制。那答案在哪呢?在 eax 裡。這又是呼叫慣例:依約定,一個 `int` 的回傳值會回到 eax,所以呼叫端知道要去那裡找。八條指令,而其中每一條,都是你早已學過名字的東西。

一套你能套在任何東西上的方法

那段走讀的重點不在 `add` 函式——而在那套程序。你遇到的多數反組譯只是看起來嚇人,直到你把它分進那同樣幾個籃子裡。下面是一套閱讀方法,能把幾乎任何函式清單變成你能口述的東西,連你沒寫過的那種也行。

  1. 先找出邊界:序言(push rbp / mov rbp, rsp,或一個切出區域空間的 sub rsp, N)標記開頭,而 ret 標記結尾。它們之間的一切,就是一個函式的本體。
  2. 找出引數:依呼叫慣例,前幾個整數/指標引數在進入時住在 rdi、rsi、rdx、rcx、r8、r9 裡。追蹤每一個被搬去哪、存去哪,就能跟著一個值穿過整個函式。
  3. 把每條指令分進一個家族:資料搬移(mov、lea、push、pop)、算術/邏輯(add、sub、and、shl)、比較與分支(cmp、test、jmp、je、jne),或一次呼叫另一個函式。這些你都以「常見指令家族」見過了。
  4. 靠跳躍追蹤控制流:一個 cmp/test 後面跟著一個條件跳躍,就是一個 if 或一個迴圈;一個往回跳的目標,幾乎一定是某個迴圈的頂端。把箭頭畫出來,你 C 的形狀就重新浮現了。
  5. 在 ret 之前,從 eax/rax 讀出回傳值,你就拿到了函式的輸出。現在你能用一句話說出它算的是什麼。

誠實的界線、兩種架構,以及這為什麼值得

對「讀反組譯能給你什麼、不能給你什麼」要看得清楚。上面清單裡的一切都是 x86-64;同一個 `add()` 編譯給一顆 ARM 晶片——你的手機、一台近期的筆電——會產出不同的助憶符和不同的暫存器組(x0、x1、w0,而非 edi、esi、eax),因為指令家族是普世的,但確切的組合語言是逐架構的。這正是第 1 篇的教訓被具體化了:那些概念——暫存器、一個堆疊、一個呼叫慣例、序言與尾聲——能跨越,但拼法不能。知道這點,能讓你不至於從一份反組譯過度推廣到所有機器。

還有第二條誠實的界線。讀反組譯能精準告訴你機器做了什麼,卻從不告訴你你為什麼要它這麼做——那份意圖活在原始碼裡、註解裡、你的腦袋裡,而編譯器在把一切降階成位元組時,把它丟掉了。所以反組譯對「對的問題」是強大的工具(「這個迴圈真的呼叫了 malloc() 嗎?」、「為什麼它在這裡當機?」、「最佳化器有沒有保留我的空指標檢查?」),對「錯的問題」則是糟糕的工具(「這支程式究竟是為了什麼?」)。在「機器確切的行為正是疑點所在」之處用它;其他一切地方,就去找原始碼。

整個這一階就此收尾。你起步時還不知道暫存器是什麼;你結束時已能拿一個你寫的函式、請編譯器露出它真正的臉,並把每一條指令讀回你如今擁有的概念裡——序言搭起一個框架、呼叫慣例在固定的暫存器裡交付引數、算術做著正事,而 `ret` 順著一個返回位址回家。你再也不必對任何事只聽編譯器的一面之詞。當有人說「機器就是做了 X」,你能打開反組譯去查證——而這個習慣,勝過任何單一事實,正是「貼著金屬幹活」的真正意涵。