很多 C 語言初學者都會犯一個經典錯誤:在函數中返回棧上局部變量的地址。教科書上明確說這是未定義行為(UB),但在實際運行中,程序往往能「正常」輸出預期結果,甚至連續多次調用返回相同地址。這究竟是編譯器寬容,還是記憶體佈局的必然規律?本文通過實驗現象剖析底層原理,揭開這層迷霧。
考慮如下代碼:
int* sum(int x, int y) {
int a = x + y;
return &a; // 返回局部變量地址
}
int main() {
int *p = sum(10, 2);
printf("*p=%d, p=%p\\n", *p, p); // 輸出 12 和某地址
*p = 100; // 甚至能修改成功!
printf("*p=%d\\n", *p); // 輸出 100
}
在許多編譯器(如 MSVC、GCC 未開啟優化)下,這段代碼不僅編譯通過,運行結果還看似正確。更有趣的是,連續兩次調用 sum(10,2) 返回的地址竟然完全相同!
這並非編譯器「幫你修好 Bug」,而是調用約定與棧佈局決定的必然結果:
a 相對於幀指針(rbp/ebp)的偏移量在編譯期確定。main 兩次調用 sum 時,調用鏈完全相同,棧指針 rsp 指向位置相同,導致 sum 的棧幀落在同一記憶體區域。sum 返回後,其棧幀雖邏輯上失效,但物理內存數據暫時保留,直到被後續函數調用覆蓋。因此,main 拿到的地址恰好指向剛被釋放的棧空間,數據尚未被破壞,讀寫看似正常。但這屬於「懸空指針」,屬於未定義行為,隨時可能因中斷、信號處理、編譯器優化或後續函數調用而崩潰。
在兩次 sum 調用之間插入任意函數調用(如 getsum()),哪怕該函數不使用相同棧空間,也會改變棧指針位置,導致第二次 sum 的棧幀偏移,返回地址發生變化,且第一次返回的指針指向的內容被覆蓋:
int *p = sum(10, 2); // 第一次調用
printf("*p=%d\\n", *p); // 12
getsum(1, 2); // 干擾棧
p = sum(10, 2); // 第二次調用,地址可能不同
printf("*p=%d\\n", *p); // 仍為 12,但第一個 p 指向的內容已不可靠
若在中間調用 free() 或進行大量棧操作,程序更會直接因堆棧破壞而崩潰(如實驗截圖所示的 heap/stack corruption)。
int、struct),直接返回值而非指針,利用返回值優化(RVO/NRVO)避免拷貝開銷。malloc/new 在堆上分配,調用者負責釋放。static 或 thread_local 延長生命週期,但需注意線程安全與重入性。「能跑通」不等於「正確」。返回局部變量地址利用了棧幀重用的巧合,屬於典型的未定義行為。理解棧幀生命週期、調用約定與內存佈局,是寫出健壯 C 代碼的基礎功。請始終記住:函數返回時,其棧上所有內容即刻失效。