比它所指之物活得更久的指標
你花了一整級在學一個指標能做什麼:裝著一個位址、用單一個 `*` 順著它走、用指標算術巡行過一個陣列、並安坐在函式被呼叫時所建起的那個堆疊框架上。這每一招都暗藏一個沒說出口的前提——箭頭另一端的那個東西還在那裡。本篇要談的,正是它不在時會發生什麼。指標不過是個數字;它完全不知道自己所指名的那塊儲存是活著、已被釋放、還是悄悄變成了別人的。當這個數字活得比它所指之物還久,你手裡握的就是一個懸空指標:一個曾經是完美門牌號、如今卻指向一塊被拆掉的空地的位址。
還有一個你在第 2 篇就見過的近親:野指標。野指標從來不曾被刻意瞄向任何地方——它是個未初始化的指標,仍裝著那塊記憶體裡殘留的隨便什麼位元,一個它偶然繼承來的隨機門牌號。懸空指標更陰險,恰恰因為它曾經是對的。野指標從未得到你的信任;懸空指標贏得了信任、用了信任,然後悄悄失去了享有它的資格。兩者共有一個致命特性:那個位址看起來和有效位址一模一樣地平凡,所以光看數字本身,沒有任何東西會警告你「順著它走現在是個錯誤」。
製造它的兩種方式:逃走的框架與太早的釋放
製造懸空指標的第一種經典方式,是回傳一個區域變數的位址。回想第 4 篇:函式的區域變數住在它的堆疊框架裡,而這個框架在函式一返回就被從呼叫堆疊上彈掉。所以函式一交回 `&local`,那個位址所指名的儲存就被收回了;緊接著的下一個函式呼叫,會把自己的框架直接蓋在它上面,覆寫掉那個值。呼叫端如今握著的指標,瞄準的是被重新利用的堆疊空間。它可能看似能用——舊的位元組也許還苟延殘喘一瞬——這正是最殘忍之處:臭蟲一直藏著,直到某個毫不相干的呼叫踩上那些位元組,於是你的資料便在腳下悄悄變了樣。
int *make_counter(void) {
int c = 0; /* c lives in THIS frame */
return &c; /* BUG: hands back a soon-dead address */
} /* frame popped here; c is gone */
int *p = make_counter(); /* p now dangles */
printf("%d\n", *p); /* use-after-return: UNDEFINED */第二種方式發生在堆積上,而且在真實程式碼裡更常見:太早的 free()。當你對一塊記憶體呼叫 free(),你是在告訴配置器「我用完了,你可以把這塊區域交給別人」。你的指標仍裝著那塊的位址,但那裡的位元組如今是配置器拿去回收的了。之後再透過那個指標讀或寫,就是釋放後使用,是整個系統程式設計裡最危險的臭蟲之一。輕則當機;重則配置器早已把那些位元組交給了別處一次新的 malloc(),於是你那個過時的寫入,悄悄弄壞一個毫不相干的物件——而攻擊者整套漏洞利用,建立的正是「精準操縱這件事」之上。
區段錯誤究竟是什麼
你現在已經被丟過好幾次區段錯誤這個詞了,通常是作為對「解參考 NULL」或「越界漫遊」的懲罰。我們就把它是什麼直白說清楚,因為這名字藏著一件漂亮的機械裝置。你的程式並不跑在硬體的原始位址上;它跑在自己一張私有的記憶體地圖裡,而作業系統替那張地圖的每一塊區域標上權限——可讀、可寫、可執行,或者乾脆根本沒對映。這就是記憶體保護,它在每一次記憶體存取時都由硬體強制執行,且不花你一分一毫。
區段錯誤,就是當你的程式以地圖所禁止的方式碰一個位址時所發生的事:在沒對映之處讀、或在只准讀之處寫。中央處理器在指令執行到一半時偵測到這個違規,並陷入核心,核心判定你的行程毀約了,於是遞送一個訊號——預設情況下這個訊號當場殺掉它——印出那一行簡短的「Segmentation fault (core dumped)」。位址 0x0 會造成區段錯誤,是因為作業系統刻意把最低的那一頁留成沒對映,正是為了讓「解參考空指標」這個普世的錯誤被即刻逮住,而不是去弄壞真實的資料。
未定義行為、對齊,以及為什麼 -O2 會「說謊」
每一次對懸空指標的解參考都是未定義行為,而把這個詞的意思說精確很值得,因為隨口的那種理解是錯的。它不代表「結果取決於你的平台」。它代表 C 標準對這支程式完全不施加任何要求——而要緊的是,最佳化器獲准假設未定義行為從不發生。所以當你的程式碼解參考一個已釋放的指標,編譯器可能推理「這次存取是有效的(因為它必須有效),因此這個指標非空,因此我可以把三行之下的那道空指標檢查刪掉」。這個臭蟲不只是一個錯誤的值;它能把離解參考很遠的程式碼重新塑形。
這正是為什麼一個記憶體臭蟲可以在 `-O0` 下消失、在 `-O2` 下弄壞東西,也是為什麼「可是它在我機器上能跑」根本不是辯護。在低度最佳化時編譯器幾乎不做推理,所以壞掉的程式碼靠著碰巧在那裡的位元組一拐一拐地撐著;把最佳化器轉到底,它就依照你那有臭蟲的程式碼所違反的那些假設行事,行為便完全變了。第 2 篇的教訓在這裡以全副力道成立:不要信任一支只是看起來能跑的程式。未定義行為是你違反了的契約,自此之後,標準——以及你的最佳化器——什麼都不欠你。
有一個比較安靜的親戚值得一句:對齊。每個型別都想坐落在「自己大小的倍數」位址上——一個 4 位元組的 `uint32_t` 在 4 的倍數、一個 8 位元組的指標在 8 的倍數——而編譯器會安排誠實的變數來滿足這點。你只能靠耍小聰明打破它,例如把一個 `char *` 在任意偏移處轉型成更寬的指標,製造出一個未對齊的位址。在某些中央處理器上這只是跑得慢;在另一些上則這次存取直接出錯。一如既往:尊重型別,對齊自會照料好自己。
把無聲的臭蟲變吵
正因為最糟的懸空指標臭蟲不會當機,幹活的程式設計師真正的防線,是「把沉默變成一份大聲、定了位的報告」的工具。這裡最有價值的單一工具是 位址偵測器:用 `gcc -fsanitize=address -g main.c` 來建置,它替每一次記憶體存取都加上偵測,於是一次釋放後使用、一次回傳區域變數的解參考、或一次越界的觸碰,都會在發生的那一刻印出確切的行號和完整的回溯——包括那塊記憶體是在哪裡配置、又在哪裡被釋放的。這就是「一個毫無頭緒的區段錯誤」與「一份把答案直接遞給你的臭蟲報告」之間的差別。
- 用偵測器加除錯資訊來建置:gcc -fsanitize=address -g main.c,接著跑 ./a.out,好讓臭蟲觸發的當下偵測機制是開著的。
- 由上而下讀那份報告:它指明錯誤的種類(heap-use-after-free、stack-use-after-return)、出問題的那一行,以及佈下陷阱的「配置處」與「釋放處」。
- 去修「存活期」,而不是修「症狀」:縮短一個指標的觸及範圍,讓它絕不活得比自己的儲存久;把已釋放的指標設成 NULL;或當資料必須活得比造它的框架久時,把它搬到堆積上。
最後那一步,就是整個這一級濃縮成一句話。一個指標的可信賴程度,至多等於它所指名儲存的存活期——而讓這兩座時鐘保持同步的紀律,正是區分「安全的系統程式碼」與「危險那種」的關鍵。這也正是 Rust 的所有權與借用檢查器在編譯期就要逮住的問題,一個我們在這道階梯後段會抵達的誠實對照:不是魔法,不是所有臭蟲的終結,而是一組不同的取捨,它讓懸空指標變成一個編譯錯誤、而非凌晨兩點的核心傾印。眼下你擁有的是 C 給你的——你自己的細心,以及鋒利到足以讓粗心變吵的工具。