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

堆疊與堆疊框

你寫過的每一次函式呼叫,都會悄悄切出一小塊記憶體,用完就扔。本篇把那塊記憶體——堆疊框——打開來看,並讓你看見一整座由它們疊成的塔,如何隨著程式的呼叫與返回而生長、又收縮。

區域變數到底住在哪裡?

到現在,你已經能用 `&x` 取位址、用 `*p` 跟著一個指標走,也知道函式裡的 `int x` 是一個確實存在、坐落在位址空間某處的盒子。但究竟在哪裡呢?當你寫一個有三個區域變數的函式、又呼叫它一千次時,難道有一千份副本在搶同一塊記憶體嗎?答案正是本篇的主題,而它是「電腦如何執行你的程式碼」當中最優雅的點子之一:每一次呼叫都會自動拿到一塊全新的記憶體,並在呼叫一返回的瞬間就把它還回去。

要看清這一點,先記得:你程式的記憶體不是一整塊不可分割的東西——它被切成多個各司其職的區段。你的機器碼坐在一個區段裡。全域與 `static` 變數坐在另一個,整個執行期間固定在原地。接著是兩個著名的、會生長的區段:堆積(heap),要由你親手去要(下一個段位我們會正式認識它),以及堆疊(stack),由機制替你管理。區域變數——也就是你在函式裡宣告的那些普通變數——就住在堆疊上。

「堆疊」這個詞在這裡是有實際作用的,所以把這個畫面記住:堆疊是一疊東西,你永遠只在頂端加、只從頂端拿——後進先出,就像一疊盤子。這條單一規則,正好就是函式呼叫所需要的。當你呼叫一個函式時,就在頂端推上一塊新的板子;當它返回時,就把那塊板子彈掉。因為呼叫總是以「與發出時相反」的順序返回(你最近呼叫的那個函式,會最先結束),這種盤子堆的紀律與函式呼叫完美契合,毫無剩餘。

一次呼叫,一個框

那疊東西裡的每一塊盤子都有名字:堆疊框(stack frame)(也叫活動記錄)。一個堆疊框是專屬於「某個函式某一次進行中呼叫」的私有記憶體板子。它存放那次呼叫的區域變數、用來在巢狀呼叫間保存幾個值的空間,以及——最關鍵的——返回位址:當這次呼叫結束時,要跳回呼叫端的哪個位置。一次呼叫、一個框;程式一生中可能有一萬次呼叫,就有一萬個框誕生又被銷毀,但任一瞬間,只有當下還在進行中的那些框存在。

這正是我們開篇那道謎題的乾淨答案。呼叫一個函式一千次,並不會讓一千份副本共存——每次呼叫都建立它的框、用它、再在下一次開始前把它拆掉。而「遞迴地」呼叫函式也不再神秘了:在遞迴中,一個呼叫自己的函式,只是在每一層遞迴拿到一個全新的框,各自有自己那份區域變數的副本。深入十層,就是同時疊起十個框,每一個都記著自己的值、以及自己要返回的位置。同一段程式碼,十塊互不相干的板子。

機器如何讓堆疊生長與收縮

在這個整齊的盤子堆畫面底下,是一個暫存器在做真正的記帳工作:堆疊指標(stack pointer),在 x86-64 上叫 rsp。它永遠指著堆疊當前的頂端——也就是最新那個框的邊緣。在每一台常見機器上,堆疊都是「向下」生長的:推入一個新框,意味著 rsp 「減小」到一個較低的位址;而彈出,則意味著 rsp 「增大」回去。這種向下生長感覺很反直覺,但它只是一個慣例,選成這樣,是為了讓堆疊能從位址空間的另一端朝著堆積生長,給兩者之間留下最大的空間。

所以「製造一個框」的實際機制其實很樸素:從 rsp 減去若干位元組以保留出空間,你就一口氣把這次呼叫的所有區域變數都配置好了。沒有搜尋、沒有串列、不必向作業系統申請——就只是一道算術指令。這正是為什麼相較於堆積,堆疊配置幾乎是免費的,也是堆疊與堆積取捨的核心:堆疊快得驚人、完全自動,但它的生命週期被死死綁在「呼叫與返回」上;而堆積較慢、需手動,卻能讓一個值活得比建立它的那個函式更久。

  high addresses
  +---------------------+  <- bottom of stack (oldest frame)
  |  frame: main()      |     locals of main, return addr into _start
  +---------------------+
  |  frame: compute()   |     main called compute()
  +---------------------+
  |  frame: helper()    |  <- compute called helper(); newest frame
  |    return address   |     where to jump back into compute()
  |    saved rbp        |     caller's frame base
  |    int n; char buf[8]     this call's locals
  +---------------------+  <- rsp points HERE (top of stack)
  low addresses              the stack grew DOWNWARD to get here
helper() 執行時有三個存活中的框;rsp 標記著頂端,而堆疊從 main() 向下生長到 helper()。

走過一次呼叫

讓我們用慢動作追一下:當 `compute()` 呼叫 `helper()` 時發生了什麼。這些步驟不必你親手寫——編譯器產生的序言與尾聲會在每個函式的開頭與結尾替你做——但看過一次,整座建築就會「卡」地一聲對上位。框的「建立」與「拆除」這成對的動作,正是保證堆疊被原封不動地交還的關鍵,使呼叫端甚至不知道有個框來過又走了。

  1. 呼叫端把引數放到呼叫慣例規定的位置(在 x86-64 上前幾個放暫存器,其餘推入堆疊),好讓 helper() 找得到它們。
  2. call 指令把返回位址——也就是 compute() 裡緊接著的下一道指令的位址——推入堆疊,然後跳到 helper() 的程式碼。
  3. helper() 的序言保存呼叫端的框基底(rbp)、建立自己的,再從 rsp 減去若干位元組為 n 與 buf 切出空間。新的框於是成形。
  4. helper() 執行,只在自己的框、以及被交付的引數之內讀寫。
  5. 尾聲還原序言所做的:它復原 rsp 與 rbp,一步丟棄整個框,於是區域變數消失。
  6. ret 指令彈出先前保存的返回位址並跳過去,正好回到 compute() 離開的地方——而堆疊也精確地復原成先前的樣子。

注意這個機制把多大的信任,押在那寥寥幾個被保存的值上。返回位址與被保存的框基底,是坐在框裡的普通位元組,就緊挨著你的區域陣列。如果一段程式寫過了某個區域 `char buf[8]` 的尾端——一次發生在堆疊上的緩衝區溢位——它就能蓋掉緊鄰其後的那個被保存的返回位址,使得 `ret` 執行時,CPU 跳到攻擊者選定的位址。這就是經典的「堆疊破壞(stack smashing)」攻擊,也是 C 之所以以「處處是利刃」聞名的一個誠實理由。讓呼叫變快的同一套機制,也把操控權留在了一次脫韁寫入伸手可及之處。

誠實面對它的極限

堆疊很美妙,卻是有限的。作業系統給每個執行緒一塊固定大小的區域當作它的堆疊——在 Linux 上預設常見約為 8 MiB,別處可能更小——它能拿到的就只有這麼多。框推得太多就會衝出尾端:一次堆疊溢位(stack overflow)。最常見的元凶是失控的遞迴(漏了終止條件,函式永遠呼叫自己),但單一一個巨大的區域變數也辦得到——宣告 `char buf[16 * 1024 * 1024]`,你就是在向一個框要「比整個堆疊還大」的空間。當堆疊指標越過它的守衛邊界,程式就會被終止,通常伴隨一個記憶體區段錯誤。

最後這個區分值得放慢來談,因為它幾乎絆倒每一個人。呼叫堆疊(call stack)是一塊由硬體與作業系統管理的記憶體區段,向下生長,裝著一個個框。而作為「資料結構」的堆疊,是一個抽象的後進先出容器,你可能自己用一個陣列、或一塊堆積記憶體把它做出來。它們共用一個名字,是因為呼叫堆疊「行為上」像那個資料結構,但它們並不是同一個東西——把兩者混為一談,正是那種讓系統程式設計顯得滑溜的「層次混淆」。把它們分清楚:一個是你的函式呼叫住的地方;另一個是你某個星期二可能會自己寫出來的東西。