區域變數到底住在哪裡?
到現在,你已經能用 `&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
走過一次呼叫
讓我們用慢動作追一下:當 `compute()` 呼叫 `helper()` 時發生了什麼。這些步驟不必你親手寫——編譯器產生的序言與尾聲會在每個函式的開頭與結尾替你做——但看過一次,整座建築就會「卡」地一聲對上位。框的「建立」與「拆除」這成對的動作,正是保證堆疊被原封不動地交還的關鍵,使呼叫端甚至不知道有個框來過又走了。
- 呼叫端把引數放到呼叫慣例規定的位置(在 x86-64 上前幾個放暫存器,其餘推入堆疊),好讓 helper() 找得到它們。
- call 指令把返回位址——也就是 compute() 裡緊接著的下一道指令的位址——推入堆疊,然後跳到 helper() 的程式碼。
- helper() 的序言保存呼叫端的框基底(rbp)、建立自己的,再從 rsp 減去若干位元組為 n 與 buf 切出空間。新的框於是成形。
- helper() 執行,只在自己的框、以及被交付的引數之內讀寫。
- 尾聲還原序言所做的:它復原 rsp 與 rbp,一步丟棄整個框,於是區域變數消失。
- ret 指令彈出先前保存的返回位址並跳過去,正好回到 compute() 離開的地方——而堆疊也精確地復原成先前的樣子。
注意這個機制把多大的信任,押在那寥寥幾個被保存的值上。返回位址與被保存的框基底,是坐在框裡的普通位元組,就緊挨著你的區域陣列。如果一段程式寫過了某個區域 `char buf[8]` 的尾端——一次發生在堆疊上的緩衝區溢位——它就能蓋掉緊鄰其後的那個被保存的返回位址,使得 `ret` 執行時,CPU 跳到攻擊者選定的位址。這就是經典的「堆疊破壞(stack smashing)」攻擊,也是 C 之所以以「處處是利刃」聞名的一個誠實理由。讓呼叫變快的同一套機制,也把操控權留在了一次脫韁寫入伸手可及之處。
誠實面對它的極限
堆疊很美妙,卻是有限的。作業系統給每個執行緒一塊固定大小的區域當作它的堆疊——在 Linux 上預設常見約為 8 MiB,別處可能更小——它能拿到的就只有這麼多。框推得太多就會衝出尾端:一次堆疊溢位(stack overflow)。最常見的元凶是失控的遞迴(漏了終止條件,函式永遠呼叫自己),但單一一個巨大的區域變數也辦得到——宣告 `char buf[16 * 1024 * 1024]`,你就是在向一個框要「比整個堆疊還大」的空間。當堆疊指標越過它的守衛邊界,程式就會被終止,通常伴隨一個記憶體區段錯誤。
最後這個區分值得放慢來談,因為它幾乎絆倒每一個人。呼叫堆疊(call stack)是一塊由硬體與作業系統管理的記憶體區段,向下生長,裝著一個個框。而作為「資料結構」的堆疊,是一個抽象的後進先出容器,你可能自己用一個陣列、或一塊堆積記憶體把它做出來。它們共用一個名字,是因為呼叫堆疊「行為上」像那個資料結構,但它們並不是同一個東西——把兩者混為一談,正是那種讓系統程式設計顯得滑溜的「層次混淆」。把它們分清楚:一個是你的函式呼叫住的地方;另一個是你某個星期二可能會自己寫出來的東西。