在開始本篇的內(nèi)容前,我們先來(lái)思考幾個(gè)問(wèn)題。
1. 我們先來(lái)看一段簡(jiǎn)單的代碼:
void func(int a) {
if (a > 100000000) return;
int arr[100] = {0};
func(a + 1);
}
你能看出這段代碼會(huì)有什么問(wèn)題嗎?
你知道協(xié)程的本質(zhì)是什么嗎?有的同學(xué)可能會(huì)說(shuō)是用戶態(tài)線程,那么什么是用戶態(tài)線程,這是怎么實(shí)現(xiàn)的?3. 函數(shù)運(yùn)行起來(lái)后在內(nèi)存中是什么樣子?這幾個(gè)問(wèn)題看似沒什么關(guān)聯(lián),但這背后都指向一樣?xùn)|西,這就是所謂的函數(shù)運(yùn)行時(shí)棧,run time stack。接下來(lái)我們就好好看看到底什么是函數(shù)運(yùn)行時(shí)棧,為什么徹底理解函數(shù)運(yùn)行時(shí)棧對(duì)程序員來(lái)說(shuō)非常重要。
從進(jìn)程、線程到函數(shù)調(diào)用
汽車在高速上行駛時(shí)有很多信息,像速度、位置等等,通過(guò)這些信息我們可以直觀的感受汽車的運(yùn)行時(shí)狀態(tài)。
同樣的,程序在運(yùn)行時(shí)也有很多信息,像有哪些程序正在運(yùn)行、這些程序執(zhí)行到了哪里等等,通過(guò)這些信息我們可以直觀的感受系統(tǒng)中程序運(yùn)行的狀態(tài)。
其中,我們創(chuàng)造了進(jìn)程、線程這樣的概念來(lái)記錄有哪些程序正在運(yùn)行,進(jìn)程和線程的運(yùn)行體現(xiàn)在函數(shù)執(zhí)行上,函數(shù)的執(zhí)行除了函數(shù)內(nèi)部執(zhí)行的順序執(zhí)行還有子函數(shù)調(diào)用的控制轉(zhuǎn)移以及子函數(shù)執(zhí)行完畢的返回。其中函數(shù)內(nèi)部的順序執(zhí)行乏善可陳,重點(diǎn)是函數(shù)的調(diào)用。因此接下來(lái)我們的視角將從宏觀的進(jìn)程和線程拉近到微觀下的函數(shù)調(diào)用,重點(diǎn)來(lái)討論一下函數(shù)調(diào)用是怎樣實(shí)現(xiàn)的。
更多l(xiāng)inux內(nèi)核視頻教程文檔資料免費(fèi)領(lǐng)取后臺(tái)私信【內(nèi)核】自行獲取.
Linux內(nèi)核源碼/內(nèi)存調(diào)優(yōu)/文件系統(tǒng)/進(jìn)程管理/設(shè)備驅(qū)動(dòng)/網(wǎng)絡(luò)協(xié)議棧-學(xué)習(xí)視頻教程-騰訊課堂
函數(shù)執(zhí)行的活動(dòng)軌跡:棧
玩過(guò)游戲的同學(xué)應(yīng)該知道,有時(shí)你為了完成一項(xiàng)主線任務(wù)不得不去打一些支線的任務(wù),支線任務(wù)中可能還有支線任務(wù),當(dāng)一個(gè)支線任務(wù)完成后退回到前一個(gè)支線任務(wù),這是什么意思呢,舉個(gè)例子你就明白了。假設(shè)主線任務(wù)西天取經(jīng)A依賴支線任務(wù)收服孫悟空B和收服豬八戒C,也就是說(shuō)收服孫悟空B和收服豬八戒C完成后才能繼續(xù)主線任務(wù)西天取經(jīng)A;支線任務(wù)收服孫悟空B依賴任務(wù)拿到緊箍咒D,只有當(dāng)任務(wù)D完成后才能回到任務(wù)B;整個(gè)任務(wù)的依賴關(guān)系如圖所示:
現(xiàn)在我們來(lái)模擬一下任務(wù)完成過(guò)程。首先我們來(lái)到任務(wù)A,執(zhí)行主線任務(wù):
執(zhí)行任務(wù)A的過(guò)程中我們發(fā)現(xiàn)任務(wù)A依賴任務(wù)B,這時(shí)我們暫停任務(wù)A去執(zhí)行任務(wù)B:
?
執(zhí)行任務(wù)B的時(shí)候,我們又發(fā)現(xiàn)依賴任務(wù)D:
?
執(zhí)行任務(wù)D的時(shí)候我們發(fā)現(xiàn)該任務(wù)不再依賴任何其它任務(wù),因此C完成后我們可以會(huì)退到前一個(gè)任務(wù),也就是B:
?
任務(wù)B除了依賴任務(wù)C外不再依賴其它任務(wù),這樣任務(wù)B完成后就可以回到任務(wù)A:
現(xiàn)在我們回到了主線任務(wù)A,依賴的任務(wù)B執(zhí)行完成,接下來(lái)是任務(wù)C:
?
和任務(wù)D一樣,C不依賴任何其它其它任務(wù),任務(wù)C完成后就可以再次回到任務(wù)A,再之后任務(wù)A執(zhí)行完畢,整個(gè)任務(wù)執(zhí)行完成。讓我們來(lái)看一下整個(gè)任務(wù)的活動(dòng)軌跡:
?
仔細(xì)觀察,實(shí)際上你會(huì)發(fā)現(xiàn)這是一個(gè)First In Last Out 的順序,天然適用于棧這種數(shù)據(jù)結(jié)構(gòu)來(lái)處理。再仔細(xì)看一下棧頂?shù)能壽E,也就是A、B、D、B、A、C、A,實(shí)際上你會(huì)發(fā)現(xiàn)這里的軌跡就是任務(wù)依賴樹的遍歷過(guò)程,是不是很神奇,這也是為什么樹這種數(shù)據(jù)結(jié)構(gòu)的遍歷除了可以用遞歸也可以用棧來(lái)實(shí)現(xiàn)的原因。
A Box
函數(shù)調(diào)用也是同樣的道理,你把上面的ABCD換成函數(shù)ABCD,本質(zhì)不變。因此,現(xiàn)在我們知道了,使用棧這種結(jié)構(gòu)就可以用來(lái)保存函數(shù)調(diào)用信息。和游戲中的每個(gè)任務(wù)一樣,當(dāng)函數(shù)在運(yùn)行時(shí)每個(gè)函數(shù)也要有自己的一個(gè)“小盒子”,這個(gè)小盒子中保存了函數(shù)運(yùn)行時(shí)的各種信息,這些小盒子通過(guò)棧這種結(jié)構(gòu)組織起來(lái),這個(gè)小盒子就被稱為棧幀,stack frames,也有的稱之為call stack,不管用什么命名方式,總之,就是這里所說(shuō)的小盒子,這個(gè)小盒子就是函數(shù)運(yùn)行起來(lái)后占用的內(nèi)存,這些小盒子構(gòu)成了我們通常所說(shuō)的棧區(qū)。那么函數(shù)調(diào)用時(shí)都有哪些信息呢?
控制轉(zhuǎn)移
我們知道當(dāng)函數(shù)A調(diào)用函數(shù)B的時(shí)候,控制從A轉(zhuǎn)移到了B,所謂控制其實(shí)就是指CPU執(zhí)行屬于哪個(gè)函數(shù)的機(jī)器指令,CPU從開始執(zhí)行屬于函數(shù)A的指令切換到執(zhí)行屬于函數(shù)B的指令,我們就說(shuō)控制從函數(shù)A轉(zhuǎn)移到了函數(shù)B。控制從函數(shù)A轉(zhuǎn)移到函數(shù)B,那么我們需要有這樣兩個(gè)信息:
- 我從哪里來(lái) (返回)
- 要到去哪里 (跳轉(zhuǎn))
是不是很簡(jiǎn)單,就好比你出去旅游,你需要知道去哪里,還需要記住回家的路。函數(shù)調(diào)用也是同樣的道理。當(dāng)函數(shù)A調(diào)用函數(shù)B時(shí),我們只要知道:
- 函數(shù)A對(duì)于的機(jī)器指令執(zhí)行到了哪里 (我從哪里來(lái),返回)
- 函數(shù)B第一條機(jī)器指令所在的地址 (要到哪里去,跳轉(zhuǎn))
有這兩條信息就足以讓CPU開始執(zhí)行函數(shù)B對(duì)應(yīng)的機(jī)器指令,當(dāng)函數(shù)B執(zhí)行完畢后跳轉(zhuǎn)回函數(shù)A。那么這些信息是怎么獲取并保持的呢?現(xiàn)在我們就可以打開這個(gè)小盒子,看看是怎么使用的了。假設(shè)函數(shù)A調(diào)用函數(shù)B,如圖所示:
?
當(dāng)前,CPU執(zhí)行函數(shù)A的機(jī)器指令,該指令的地址為0x400564,接下來(lái)CPU將執(zhí)行下一條機(jī)器指令也就是:
call 0x400540
這條機(jī)器指令是什么意思呢?這條機(jī)器指令對(duì)應(yīng)的就是我們?cè)诖a中所寫的函數(shù)調(diào)用,注意call后有一條機(jī)器指令地址,注意觀察上圖你會(huì)看到,該地址就是函數(shù)B的第一條機(jī)器指令,從這條機(jī)器指令后CPU將跳轉(zhuǎn)到函數(shù)B。現(xiàn)在我們已經(jīng)解決了控制跳轉(zhuǎn)的“要到哪里去”問(wèn)題,當(dāng)函數(shù)B執(zhí)行完畢后怎么跳轉(zhuǎn)回來(lái)呢?原來(lái),call指令除了給出跳轉(zhuǎn)地址之外還有這樣一個(gè)作用,也就是把call指令的下一條指令的地址,也就是0x40056a push到函數(shù)A的棧幀中,如圖所示:
?
現(xiàn)在,函數(shù)A的小盒子變大了一些,因?yàn)檠b入了返回地址:
?
現(xiàn)在CPU開始執(zhí)行函數(shù)B對(duì)應(yīng)的機(jī)器指令,注意觀察,函數(shù)B也有一個(gè)屬于自己的小盒子(棧幀),可以往里面扔一些必要的信息。
?
如果函數(shù)B中又調(diào)用了其它函數(shù)呢?道理和函數(shù)A調(diào)用函數(shù)B是一樣的。讓我們來(lái)看一下函數(shù)B最后一條機(jī)器指令ret,這條機(jī)器指令的作用是告訴CPU跳轉(zhuǎn)到函數(shù)A保存在棧幀上的返回地址,這樣當(dāng)函數(shù)B執(zhí)行完畢后就可以跳轉(zhuǎn)到函數(shù)A繼續(xù)執(zhí)行了。至此,我們解決了控制轉(zhuǎn)移中“我從哪里來(lái)”的問(wèn)題。
傳遞參數(shù)與獲取返回值
函數(shù)調(diào)用與返回使得我們可以編寫函數(shù),進(jìn)行函數(shù)調(diào)用。但調(diào)用函數(shù)除了提供函數(shù)名稱之外還需要傳遞參數(shù)以及獲取返回值,那么這又是怎樣實(shí)現(xiàn)的呢?
在x86-64中,多數(shù)情況下參數(shù)的傳遞與獲取返回值是通過(guò)寄存器來(lái)實(shí)現(xiàn)的。假設(shè)函數(shù)A調(diào)用了函數(shù)B,函數(shù)A將一些參數(shù)寫入相應(yīng)的寄存器,當(dāng)CPU執(zhí)行函數(shù)B時(shí)就可以從這些寄存器中獲取參數(shù)了。同樣的,函數(shù)B也可以將返回值寫入寄存器,當(dāng)函數(shù)B執(zhí)行結(jié)束后函數(shù)A從該寄存器中就可以讀取到返回值了。我們知道寄存器的數(shù)量是有限的,當(dāng)傳遞的參數(shù)個(gè)數(shù)多于寄存器的數(shù)量該怎么辦呢?這時(shí)那個(gè)屬于函數(shù)的小盒子也就是棧幀又能發(fā)揮作用了。原來(lái),當(dāng)參數(shù)個(gè)數(shù)多于寄存器數(shù)量時(shí)剩下的參數(shù)直接放到棧幀中,這樣被調(diào)函數(shù)就可以從前一個(gè)函數(shù)的棧幀中獲取到參數(shù)了。現(xiàn)在棧幀的樣子又可以進(jìn)一步豐富了,如圖所示:
從圖中我們可以看到,調(diào)用函數(shù)B時(shí)有部分參數(shù)放到了函數(shù)A的棧幀中,同時(shí)函數(shù)A棧幀的頂部依然保存的是返回地址。
局部變量
我們知道在函數(shù)內(nèi)部定義的變量被稱為局部變量,這些變量在函數(shù)運(yùn)行時(shí)被放在了哪里呢?原來(lái),這些變量同樣可以放在寄存器中,但是當(dāng)局部變量的數(shù)量超過(guò)寄存器的時(shí)候這些變量就必須放到棧幀中了。因此,我們的棧幀內(nèi)容又一步豐富了。
?
細(xì)心的同學(xué)可能會(huì)有這樣的疑問(wèn),我們知道寄存器是共享資源可以被所有函數(shù)使用,既然可以將函數(shù)A的局部變量寫入寄存器,那么當(dāng)函數(shù)A調(diào)用函數(shù)B時(shí),函數(shù)B的局部變量也可以寫到寄存器,這樣的話當(dāng)函數(shù)B執(zhí)行完畢回到函數(shù)A時(shí)寄存器的值已經(jīng)被函數(shù)B修改過(guò)了,這樣會(huì)有問(wèn)題吧。這樣的確會(huì)有問(wèn)題,因此我們?cè)谙蚣拇嫫髦袑懭刖植孔兞恐埃?strong>一定要先將寄存器中開始的值保存起來(lái),當(dāng)寄存器使用完畢后再恢復(fù)原值就可以了。那么我們要將寄存器中的原始值保存在哪里呢?有的同學(xué)可能已經(jīng)猜到了,沒錯(cuò),依然是函數(shù)的棧幀中。
?
最終,我們的小盒子就變成了如圖所示的樣子,當(dāng)寄存器使用完畢后根據(jù)棧幀中保存的初始值恢復(fù)其內(nèi)容就可以了。現(xiàn)在你應(yīng)該知道函數(shù)在運(yùn)行時(shí)到底是什么樣子了吧,以上就是問(wèn)題3的答案。
Big Picture
需要再次強(qiáng)調(diào)的一點(diǎn)就是,上述討論的棧幀就位于我們常說(shuō)的棧區(qū)。棧區(qū),屬于進(jìn)程地址空間的一部分,如圖所示,我們將棧區(qū)放大就是圖左邊的樣子。
最后,讓我們回到文章開始的這段簡(jiǎn)單代碼:
void func(int a) {
if (a > 100000000) return;
int arr[100] = {0};
func(a + 1);
}
void main(){
func(0);
}
想一想這段代碼會(huì)有什么問(wèn)題?原來(lái),棧區(qū)是有大小限制的,當(dāng)超過(guò)限制后就會(huì)出現(xiàn)著名的棧溢出問(wèn)題,顯然上述代碼會(huì)導(dǎo)致這一問(wèn)題的出現(xiàn)。因此:
- 不要?jiǎng)?chuàng)建過(guò)大的局部變量
- 函數(shù)棧幀,也就是調(diào)用層次不能太多
總結(jié)
本章我們從幾個(gè)看似沒什么關(guān)聯(lián)的問(wèn)題出發(fā),詳細(xì)講解了函數(shù)運(yùn)行時(shí)棧是怎么一回事,為什么我們不能創(chuàng)建過(guò)多的局部變量。細(xì)心的同學(xué)會(huì)發(fā)現(xiàn)第2個(gè)問(wèn)題我們沒有解答,這個(gè)問(wèn)題的講解放到下一篇,也就是協(xié)程中講解。






