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

呼叫慣例與 ABI

指令集告訴你怎麼搬移位元;它對「引數該放哪裡」或「誰得保住哪個暫存器」隻字未提。那本缺席的規則書就是呼叫慣例——而它所棲身的那份更大的契約,就是 ABI:一紙條約,讓各自分開編譯、甚至以不同語言寫成的程式碼能彼此呼叫,並對答案達成一致。

指令集漏掉的那本規則書

從前三篇導引你已經會讀像 rax 這樣的暫存器,你知道 `call` 會推入一個返回位址並跳走,`ret` 會把它彈回來,而堆疊指標 rsp 標記著指令層級的堆疊頂端。但請注意指令集從沒告訴你的事。當你在 C 裡寫 `f(3, 4)` 而它編譯成一個 `call`,那 3 和 4 去了哪裡?放進 rax?推到堆疊上?以什麼順序?而 `f` 返回之後,你又上哪找它的結果?硬體是真的不在乎——`call` 就只是跳走。任何一致的選法都行得通,只要呼叫端與被呼叫端對它有共識。

那份共識就是呼叫慣例:一套固定、白紙黑字寫下的規則,規範一個函式如何被呼叫、又如何返回。它釘死了哪個暫存器帶第一個引數、第二個引數,依此類推;哪個暫存器裝傳回值;在 `call` 的當下堆疊必須如何排列;以及——我們會花最多時間談的那個微妙之處——誰負責在這趟呼叫之間保住每一個暫存器。這一切都不由 CPU 強制執行。它就字面意義而言是一種慣例:一紙人人遵守的條約,好讓不同人、在不同時候、寫在不同檔案裡的程式碼,依然能拼合在一起。

引數與結果住在哪裡

這裡是你可以記在腦中的具體部分。在 System V AMD64 之下,前六個整數或指標引數以暫存器傳遞,順序固定:rdi、rsi、rdx、rcx、r8、r9。所以在 `f(3, 4)` 裡,編譯器會在 `call` 之前把 3 搬進 rdi、4 搬進 rsi,而 `f` 就直接在那裡讀它們——完全沒有記憶體往返。第七、第八個引數會溢出到堆疊上,以反向順序推入,好讓第一個堆疊引數坐得離頂端最近。傳回值則回到 rax。浮點引數與結果搭乘另一組暫存器(xmm0 到 xmm7),這讓整數暫存器在常見情況下保持空閒。

這正是為什麼一個小函式可以便宜得驚人。對 `r = add(3, 4)` 來說,呼叫端不過是 `mov edi, 3` 再 `mov esi, 4`(載入 rdi 與 rsi 的低 32 位元)然後 `call add`;在裡頭,`add` 做一道 `lea eax, [rdi+rsi]` 再 `ret`,把答案留在 rax 裡——除了 `call`/`ret` 這一對之外沒碰任何記憶體。把這拿來跟跨進核心對照——那是下一個階梯的內容——一個系統呼叫並不是普通的函式呼叫,也不使用這套慣例;它會陷入核心,帶著自己的一套引數暫存器規則,代價也高出許多。把這道界線維持得清清楚楚很要緊:`call add` 仍留在你行程的使用者層級,而一個 `syscall` 指令會改變權限等級。

呼叫端保存與被呼叫端保存:核心所在

現在來談那條讓每個初讀反組譯的人都栽跟頭的微妙規則。暫存器就那麼幾個,而一個函式和它所呼叫的函式都想用它們。那麼誰被允許覆蓋哪些?慣例把暫存器分成兩個陣營。呼叫端保存(也叫易揮發)暫存器——rax、rcx、rdx、rsi、rdi、r8 到 r11——可被你呼叫的任何函式自由覆寫。如果這其中某個暫存器裡有個值,而你在 `call` 之後還需要它,那麼這個呼叫端,必須先把它存起來(推入堆疊,或藏到別處)並在事後還原。被呼叫端保存(非易揮發)暫存器——rbx、rbp,以及 r12 到 r15——帶著相反的承諾:任何想用其中之一的函式,都必須在進入時把它原本的內容存起來、並在返回前還原,好讓它們在呼叫端看來彷彿從沒被動過。

把它想成分擔「保存」這件雜務的兩種方式,而這種劃分存在的唯一目的,就是避免做白工。呼叫端保存暫存器很適合短命的暫存值:如果呼叫端沒什麼值得留的,那沒有人得花一道指令去存任何東西。被呼叫端保存暫存器則很適合一個函式跨越多次呼叫都要持有的值——比方它放在 rbx 裡的一個迴圈計數器——因為它只需在進入時存 rbx 一次,而不必在每個內層 `call` 周圍都存一遍。這就是呼叫端保存對被呼叫端保存的區別,而它一旦在你腦中卡進位,反組譯就不再像雜訊:那些框住一個函式的 `push rbx` / `pop rbx` 配對,正是它在履行被呼叫端保存的承諾。

序言與尾聲:化為程式碼的慣例

這一切都在編譯器於幾乎每個函式的開頭與結尾所發出的那些定型指令中變得可見——也就是序言與尾聲。序言會擺設這個函式的堆疊框架:典型上是 `push rbp`(存下呼叫端的基底指標——注意 rbp 是被呼叫端保存的,所以我們非存不可),接著 `mov rbp, rsp`(把 rbp 錨定到當前頂端,好讓區域變數能以像 `[rbp - 8]` 這樣的固定位移來定址),再來 `sub rsp, N`(藉由壓低 rsp 為區域變數挖出 N 個位元組的空間)。尾聲則是上一篇談堆疊的導引所指向的、分毫不差的鏡像:它還原 rsp 與 rbp,再把控制權交還出去。整段從頭到尾如下。

Prologue (function entry):
        push    rbp           ; save caller's rbp (it is callee-saved)
        mov     rbp, rsp      ; rbp now points at the frame base
        sub     rsp, 0x10     ; reserve 16 bytes for locals
        push    rbx           ; save rbx too, if we will use it
        ...                   ; body: locals at [rbp - 8], etc.

Epilogue (function exit):
        pop     rbx           ; restore rbx
        mov     rsp, rbp      ; discard locals (or: leave)
        pop     rbp           ; restore caller's rbp
        ret                   ; pop return address, jump back
一個課本式的框架:尾聲以相反順序撤銷序言所做的一切,於是 rsp/rbp 及每個被呼叫端保存暫存器,都會原封不動地回到呼叫端離開時的樣子。

把那兩段一起讀,整套慣例就明明白白擺在你眼前。序言的 `push rbp` 與 `push rbx`,是被呼叫端保存承諾的兌現。`sub rsp, 0x10` 是區域儲存空間。尾聲以相反順序還原每個存下的暫存器(後推先彈——堆疊是後進先出),最後以 `ret` 收尾,它彈出 `call` 留下的返回位址並跳回原處。現代編譯器在 `-O2` 下常常整個省略那套 rbp 舞步(把 rbp 釋放成一般暫存器,直接用 rsp 來為區域變數定址),也可能把 `mov rsp,rbp; pop rbp` 摺成單一道 `leave` 指令——所以當一個最佳化過的函式有著比這課本版本更精瘦的序言時,別吃驚。

從慣例向上到 ABI

呼叫慣例是你能看著它運作的那一部分,但它只是一份更大契約裡的一個條款:ABI,也就是應用程式二進位介面。如果說 API 是一份原始碼層級的協定(函式名稱、參數型別——你對著它寫程式),那麼 ABI 就是二進位層級的協定:這一切一旦編譯成機器碼後長什麼樣子。ABI 釘死了呼叫慣例,也釘死了每個型別的確切大小與對齊(`long 是 8 位元組嗎?struct 裡的填補放哪?`)、一個 struct 如何排版、以及它是以暫存器傳回還是透過一個隱藏指標傳回、目的檔的格式、還有哪個暫存器裝返回位址。它就是那一層,讓某個編譯器產出的 `.o` 能與另一個編譯器產出的 `.o` 連結在一起。

這正是軟體世界有那麼大一部分都講 C 的那個低調原因。某個平台上的 C ABI 既穩定又有文件,所以一個多年前以 C 編譯的函式庫,今天依然能正確連結並被呼叫,而 Rust、Python、Go 等等之所以能跨過去使用 C 函式庫,靠的就是講那同一套 ABI——不是 C 這個語言,而是 C 所釘定的那紙二進位條約。穩定性也是為什麼破壞一個 ABI 是樁嚴重的事:在一個已出貨的共享函式庫裡改動某個 struct 的排版、或某個函式的引數暫存器,那麼每個對著舊版本建置的程式,都會把新版本讀錯——把引數傳到錯的地方、在錯的位移讀欄位——卻不會出現任何一個編譯器錯誤,因為從沒有任何原始碼改變過。

所以請把這幾層理清楚,因為把它們攪在一起正是那個經典的混淆。指令集說的是單一道指令做什麼。呼叫慣例說的是如何傳遞引數、以及誰在一個 `call` 之間保住哪個暫存器。ABI 是那份完整的二進位契約,它包含了呼叫慣例外加型別排版與檔案格式。而這一切都不在執行期由 CPU 檢查——弄錯了並不會有例外,只有一個錯誤的答案或一場當機。這恰好就是本階梯最後一篇導引要培養的技能:把這份契約記在心裡,你將去讀你自己編譯出的程式碼的反組譯,一道指令、一道指令地看著這套慣例上演。