天天看點

病例:不了解局部變量超出作用域之後的行為

病人:醫生,局部變量超出作用域之後會發生什麼事?我為此頭疼了很久。 中醫:哦,它們不能被通路了,消亡了。你的病不會這麼簡單吧,到底什麼問題,較長的描述一下。 病人:我想知道的是,指針所指的局部變量,超出作用域之後,那個指針的行為。比如這段程式 #include "stdio.h" int main(int argc, char* argv[]) { int i=10; int *piToTest=&i; printf("%d/n",*piToTest); { int iGone=20; piToTest=&iGone; // <---咔咔 } printf("%d/n",*piToTest); // <---啊啊 return 0; } Figure 1. 運作結果是打出“20”,正是*piToTest生前的值,難道iGone其實沒有消亡,永遠活在我們心中? 中醫:嗯,你要這麼說的話,iGone“生前”究竟活在什麼地方呢?用術語來說,其storage在何處? 病人:哦哦,不知道。 中醫:這就對了,你的問題的本質正是局部變量的storage問題,是以你會頭疼。現在這個問題先擱置一下,你也不知道函數傳遞參數的機制對麼? 病人:對。 中醫:我們得從這裡着手。每次函數調用,都要有一塊記憶體存放其參數(不妨假設所有函數都有一些參數), 而編譯時刻無從知道某個函數究竟會被(遞歸)調用幾次,要為他留多少份參數的空間,對不對? 病人:。。。 對,即使是有一個main函數,也不妨礙他調用自己n次m層。放參數的地方,必定是一個很“動态”的地方。 中醫:這個很動态的地方,術語叫堆棧(簡稱“棧”)。其運作機制和資料結構中的堆棧一樣,都是先進後出所有操作都在棧頂發生,不過這裡的堆棧是由CPU和OS來實作的。 函數調用的時候,①把其參數push進堆棧,②把傳回位址push進堆棧,③跳轉到函數入口位址。 void func1(int a,int b); ... func1(10,20); ... ┌--------------------┐ │傳回位址 │ ├--------------------┤ │左面的參數 -- 10 │ ├--------------------┤ │最右面的參數 -- 20 │ ├--------------------┤ │之前的堆棧 │ Figure 2. 進入func1時刻的堆棧情況(假定堆棧向上生長) 參數自右向左入棧,最後是傳回位址 現在,你說說函數傳回的時候,應該發生什麼事。 病人:我猜是調用的逆序列吧。(1)取得傳回位址跳轉回去,(2)堆棧恢複成“之前的堆棧” 中醫:很好,請記住(1)是callee做的事,而(2)是caller的責任。看完病之後,你再想一下printf之類不定個數參數函數的機制來了解這樣做的必要性。不過現在,我們得整理一下函數調用的過程。 ①caller 把其參數push進堆棧 ②caller 把傳回位址push進堆棧 ③跳轉到函數入口位址。 ④callee的函數體被執行 ⑤callee取得傳回位址跳轉回去 ⑥caller把堆棧恢複成“之前的堆棧” Figure 3. 函數調用的過程 病人:嗯,這些我了解了。這和我的病根“局部變量的storage問題”之間的關系是--? 中醫:真的了解了麼?這裡面還隐含了一個前提,callee必定可以取得傳回位址。 病人:這還用推,不就在棧頂麼? 中醫:嘿嘿,你這句話也隐含了一個前提,函數體内,除了調用函數這樣的“堆棧平衡的操作”,堆棧不生長,不然傳回位址不會在棧頂讓你唾手可得。 病人:難道不是? 。。。 啊! 莫非函數體内把局部變量給放進了堆棧?局部變量的作用域和函數的參數非常接近,他的storage也應該是堆棧吧! 中醫:能悟出這點,強。我們來把圖3中的函數體再細化一下。 ④.1 把局部變量放進堆棧(函數内有多少局部變量編譯器很清楚) ④.2 函數代碼 ④.3 把堆棧恢複成“進入函數時的堆棧”(姑且認為是.1的逆操作) Figure 4. 函數體細化 ┌--------------------┐ │ │ │各局部變量 │ │ │ ├--------------------┤ │傳回位址 │ ├--------------------┤ │左面的參數 -- 10 │ ├--------------------┤ │最右面的參數 -- 20 │ ├--------------------┤ │之前的堆棧 │ Figure 5. 局部變量給放進了堆棧(函數内有多少局部變量編譯器很清楚) 這樣,傳回時的确可以在棧頂取到傳回位址。順便,你說說函數代碼中如何定位某個具體參數? 病人:這下總可以根據棧頂了吧!編譯器為每一個局部變量定下一個該函數内棧頂的偏移量, 函數代碼中就根據當時的棧頂和那個偏移量來确定每個變量。 等等! 我的病,我想想。。。 ┌--------------------┐ │iGone=20 │ │piToTest │ │i=10 │ ├--------------------┤ │傳回位址 │ ├--------------------┤ │左面的參數 -- 10 │ ├--------------------┤ │最右面的參數 -- 20 │ ├--------------------┤ │之前的堆棧 │ Figure 6. 運作到“咔咔”時,堆棧的概貌。 由于C++編譯器,隻對類和結構産生析構函數, 在簡單類型變量消亡時不對它們做清理操作(如果一個int也要配上析構函數,那C++還能有效率麼), 是以iGone消亡後,他的空間還在那裡,又沒有人去用那個堆棧位置, 那麼“啊啊”處打出iGone的前身也就不奇怪了。 中醫:推理正确! 病人:看來這病看似輕微,其實根子很深哩! 中醫:現在好了麼? 病人:大部分好了,如果你帶我看看編譯器産生的彙編碼就全好了。 中醫:嘿嘿,否決。 第一,看彙編碼有點西醫化,我們中醫講究推理(玩笑^^); 第二,我們研究的是C++,不是特定CPU特定OS下,特定編譯器的行為; 第三,由于try...catch,棧中動态配置設定記憶體這些東西的存在, 真正的函數調用中,堆棧結構比圖5複雜,确定每個變量的确靠偏移量,但不是到棧頂的偏移量。 是以,你現在看彙編碼,容易把自己搞混。今天先到這裡,能了解這些,以後的也快了。 病人:啊!!! 還沒有到底,我的頭比剛才更疼了,你這是治病還是傳病?啊... 中醫:你不是也想當中醫麼? 病人:那是。 中醫:久病成醫。