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

指令層級的堆疊

你已經認識了暫存器,也看過提取—解碼—執行循環如何轉動。現在來看 CPU 時時刻刻倚靠的那一塊記憶體:堆疊。呼叫與返回到底怎麼運作、返回位址跑到哪裡去,以及為什麼一個暫存器的值,就決定了你的程式是平安回家、還是當機收場。

一個暫存器、一塊區域,以及兩者之間的約定

從本級稍早的導引你已經知道,CPU 主要就是一小組快速的暫存器,加上一個程式計數器(rip)告訴它下一條指令是哪一個,全都由提取—解碼—執行循環推動。這足以做加法、做跳躍,卻不足以呼叫一個函式再回來。要回來需要記憶體——具體說是一塊叫做堆疊的區域,以及一個暫存器,也就是堆疊指標(rsp),它永遠指著堆疊當下的頂端。這篇導引講的就是這兩者達成的約定:區域裝資料,指標記住這堆東西此刻的頂在哪裡。

在開始之前,有兩個聽起來一樣的詞必須拆開,因為把它們搞混正是初學者的經典陷阱。堆疊資料結構是那個抽象的後進先出概念,你往上推、又把它彈出。堆疊區域(或稱呼叫堆疊)則是作業系統在你的程式啟動時交給它的一整塊具體位元組,住在它位址空間靠高位的一端。這塊區域剛好是以後進先出的方式被使用的,這才借用了那個名字——但它是位於真實位址上的真實記憶體,不是一個概念。本導引說「堆疊」時,指的是那塊區域。

推入與彈出:兩條移動一個暫存器的指令

整套機制建立在你稍早認識的兩條常見指令上:`push` 與 `pop`。一條 `push rax` 不可分割地做兩件小事——把 rsp 減 8(騰出空間,因為堆疊往下長),再把 rax 的那 8 個位元組寫到 rsp 現在指的那個新位址。一條 `pop rcx` 做的是鏡像——從 rsp 指的位置讀出 8 個位元組放進 rcx,然後把 rsp 回 8,釋放那一格。彈出時什麼都沒被擦掉;舊的位元組只是不再「使用中」,下一次推入就會把它們覆寫掉。整份契約就這麼簡單:rsp 標出活著的堆疊與它下方空閒空間之間的界線。

Before:  rsp = 0x7fffffffe040

  push rax    ; rax = 0x11
     rsp -= 8 ->  0x7fffffffe038
     store 0x11 at [0x7fffffffe038]

  high addr
  +------------------+ 0x7fffffffe040  (old top, untouched)
  |       0x11       | 0x7fffffffe038  <- rsp now points HERE
  +------------------+
  low addr   (free space below, not yet used)

  pop rcx        ; rcx = 0x11, rsp += 8 -> back to 0x7fffffffe040
push 先把 rsp 減 8 再儲存;pop 先載入再把 rsp 加回 8。rsp 永遠標著頂端。

請注意堆疊並不是什麼特殊硬體——它是普通的記憶體,而 `push`/`pop` 不過是「對 rsp 做算術,再加一次記憶體搬移」的簡寫,你大可自己用 `mov` 和 `sub` 把它寫出來。CPU 提供這兩條指令,是因為這個模式太常見了,值得給它一個又短又單獨的運算碼。後面所有花俏的東西——呼叫函式、保存暫存器、替區域變數騰出空間——全都是用這一招砌出來的:調整 rsp,再讀或寫它現在指著的那些位元組。

call 與 ret:函式如何記住回家的路

現在來到核心問題:當 CPU 跳進一個函式,它究竟怎麼找回到 call 之那一條確切指令?單純的跳躍會弄丟這個資訊——它把 rip 覆寫掉,原本的位置就消失了。答案是 `call` 是一種更聰明的跳躍。一條 `call func` 做兩件事:它把下一條指令的位址推進堆疊(這個被存起來的位址就是返回位址),然後才把 rip 設成 `func`。這枚麵包屑此刻安穩地坐在堆疊上,就在最頂端。

返回正是這個動作的逆操作。一條 `ret` 指令把堆疊頂端彈進 rip——它從堆疊上把返回位址讀回來並跳過去,正好落在當初那條 `call` 之後的指令上。所以 `call`/`ret` 是一對配套的兄弟,完全由上一節的推入/彈出概念砌成:`call` 是「推入返回位址,然後跳」,`ret` 是「彈出到 rip」。堆疊也讓這套對巢狀呼叫照樣管用——若 `main` 呼叫 `f`、`f` 又呼叫 `g`,兩個返回位址就疊起來,最新的在最上面,而每一次 `ret` 都會以相反順序剛好剝下對的那一個。這種後進先出的回退,正是這塊區域被稱為呼叫堆疊的原因。

堆疊框架:每個函式的私人草稿紙

光有返回位址還不夠——一個函式還需要地方放它的區域變數和幾個被保存的暫存器。屬於某一次活躍函式呼叫的那一段堆疊,就是它的堆疊框架。函式一開始會跑一小段前導碼來建立框架,結束時則跑一段收尾碼把框架拆掉,讓堆疊回到呼叫者離開時一模一樣的狀態。框架是那一次呼叫私有的:當 `g` 在執行時,它的框架坐在最上面,而 `f` 的框架就凍結著、待在它正下方。

一段典型的前導碼用兩個動作騰出空間。它可能先用 `push rbp` 保存上一個框架的基底指標,再用 `mov rbp, rsp` 把 rsp 複製進 rbp,讓 rbp 成為這個框架穩定的錨點,而 rsp 繼續移動。接著它直接從 rsp 減去一個數來替區域變數刻出空間,例如 `sub rsp, 0x20` 保留 32 個位元組。區域變數於是住在相對 rbp 的固定偏移處,像某個 `int` 在 `[rbp - 0x4]`。收尾碼把這一切還原:`mov rsp, rbp` 接著 `pop rbp` 復原兩個暫存器,再 `ret` 回家。這就是你寫過的每一個尋常 C 函式的機器層級樣貌

這幅圖像有一個深刻的回報,也有一個誠實的危險。回報是:因為每一次呼叫都從頂端重新刻出自己的框架,函式的每一次喚起都擁有它區域變數自己的私有副本——這正是為什麼遞迴與可重入的程式碼能各自保有狀態而不相撞。危險是:一個區域陣列住在這個框架裡,就緊鄰著被保存的返回位址。寫過那個陣列的尾端——一個差一錯誤、一次未經檢查的複製——就可能覆寫掉坐在它正上方的返回位址。這就是緩衝區溢位,而當函式執行 `ret` 時,它會跳到此刻佔據那一格的任何垃圾。這是整個計算領域最古老、被人利用最多的臭蟲類別之一,而它的生死,就在這裡、就在指令層級上演。

一步一步走過一次真實的呼叫

讓我們用慢動作追蹤一次完整的呼叫,把這些活動的零件變得具體。想像 `main` 正要在某個位址執行 `call f`,而物理上緊跟在這條 call 之後的指令坐落在 0x401150。我們只盯著 rsp 與 rip——這兩個暫存器就道盡了整個故事。看返回位址如何上到堆疊、又如何下來,以及 rsp 如何最終回到它一開始的地方。

  1. main 執行 `call f`。CPU 把返回位址 0x401150 推進堆疊(rsp 下降 8),再把 rip 設成 f 的第一條指令。執行此刻來到了 f 內部。
  2. f 的前導碼開跑:`push rbp` 保存 main 的框架錨點,`mov rbp, rsp` 設定 f 自己的錨點,`sub rsp, 0x10` 替 f 的區域變數保留 16 個位元組。f 的框架此刻存在於堆疊頂端。
  3. f 做它的事,在 `[rbp - 0x8]` 這類固定偏移處讀寫它的區域變數。返回位址安穩地坐在堆疊更上方,只要 f 待在它保留的空間之內就不會被碰到。
  4. f 的收尾碼開跑:`mov rsp, rbp` 接著 `pop rbp`,丟棄區域變數並復原 main 的錨點,讓 rsp 又剛好指回那個被保存的返回位址。
  5. f 執行 `ret`。它把 0x401150 彈進 rip,rsp 上升 8。控制權落在當初那條 call 之後緊鄰的指令上,rsp 也回到呼叫前的值。main 從頭到尾沒察覺這趟繞路。

這段追蹤是整個主題的核心,而它留下兩條尚未收尾的線,由本級其餘的導引接手。第一:跨越一次呼叫時,rsp 和 rbp 由誰負責,又有哪些暫存器是函式必須保住、哪些可以隨意覆寫的?這就是呼叫慣例ABI,是每個函式都遵守的共享規則手冊,好讓各自獨立編譯出來的程式碼能互相搭配——第 4 篇。第二:你要怎麼在自己的程式裡看見這一切?你從一份反組譯裡讀出來,那是實際機器指令對人類可讀的清單——第 5 篇。堆疊是舞台,慣例是劇本,而反組譯,終於把布幕拉開。