在開始本篇的內容前,咱們先來思考幾個問題。程序員
- 咱們先來看一段簡單的代碼:
void func(int a) { if (a > 100000000) return; int arr[100] = {0}; func(a + 1); }
你能看出這段代碼會有什麼問題嗎?服務器
- 咱們在以前的文章《高性能高併發服務器是如何實現的》一中提到了一項關鍵技術——協程,你知道協程的本質是什麼嗎?有的同窗可能會說是用戶線程,那麼什麼是用戶態線程,這是怎麼實現的?
- 函數運行起來後是什麼樣子?
這個問題看似沒什麼關聯,但這背後有同樣東西你須要理解,這就是所謂的函數運行時棧,run time stack。數據結構
接下來咱們就好好看看到底什麼是函數運行時棧,爲何完全理解函數運行時棧對程序員來講很是重要。併發
從進程、線程到函數調用
汽車在高速上行駛時有不少信息,像速度、位置等等,經過這些信息咱們能夠直觀的感覺汽車的運行時狀態。app
一樣的,程序在運行時也有不少信息,像有哪些程序正在運行、這些程序執行到了哪裏等等,經過這些信息咱們能夠直觀的感覺系統中程序運行的狀態。函數
其中,咱們創造了進程、線程這樣的概念來記錄有哪些程序正在運行,關於進程和線程的概念請參見《看完這篇還不懂進程和線程你來打我》。高併發
進程和線程的運行體如今函數執行上,函數的執行除了函數內部執行的順序執行還有子函數調用的控制轉移以及子函數執行完畢的返回。其中函數內部的順序執行乏善可陳,重點是函數的調用。性能
所以接下來咱們的視角將從宏觀的進程和線程拉近到微觀下的函數調用,重點來討論一下函數調用是怎樣實現的。url
函數調用的活動軌跡:棧
玩過遊戲的同窗應該知道,有時你爲了完成一項主線任務不得不去打一些支線的任務,支線任務中可能還有支線任務,當一個支線任務完成後退回到前一個支線任務,這是什麼意思呢,舉個例子你就明白了。操作系統
假設主線任務西天取經A依賴支線任務收服孫悟空B和收服豬八戒C,也就是說收服孫悟空B和收服豬八戒C完成後才能繼續主線任務西天取經A;
支線任務收服孫悟空B依賴任務拿到緊箍咒D,只有當任務D完成後才能回到任務B;
整個任務的依賴關係如圖所示:
如今咱們來模擬一下任務完成過程。
首先咱們來到任務A,執行主線任務:
執行任務A的過程當中咱們發現任務A依賴任務B,這時咱們暫停任務A去執行任務B:
執行任務B的時候,咱們又發現依賴任務D:
執行任務D的時候咱們發現該任務再也不依賴任何其它任務,所以C完成後咱們能夠會退到前一個任務,也就是B:
任務B除了依賴任務C外再也不依賴其它任務,這樣任務B完成後就能夠回到任務A:
如今咱們回到了主線任務A,依賴的任務B執行完成,接下來是任務C:
和任務D同樣,C不依賴任何其它其它任務,任務C完成後就能夠再次回到任務A,再以後任務A執行完畢,整個任務執行完成。
讓咱們來看一下整個任務的活動軌跡:
仔細觀察,實際上你會發現這是一個First In Last Out 的順序,自然適用於棧這種數據結構來處理。
再仔細看一下棧頂的軌跡,也就是A、B、D、B、A、C、A,實際上你會發現這裏的軌跡就是任務依賴樹的遍歷過程,是否是很神奇,這也是爲何樹這種數據結構的遍歷除了能夠用遞歸也能夠用棧來實現的緣由。
A box
函數調用也是一樣的道理,你把上面的ABCD換成函數ABCD,本質不變。
所以,如今咱們知道了,使用棧這種結構就能夠用來保存函數調用信息。
和遊戲中的每一個任務同樣,當函數在運行時每一個函數也要有本身的一個「小盒子」,這個小盒子中保存了函數運行時的各類信息,這些小盒子經過棧這種結構組織起來,這個小盒子就被稱爲棧幀,stack frames,也有的稱之爲call stack,無論什麼命名方式,總之,就是這裏所說的小盒子,這個小盒子就是函數運行起來後佔用的內存,這些小盒子構成了咱們一般所說的棧區。關於棧區詳細的講解你能夠參考《深刻理解操做系統:程序員應如何理解內存》一文。
那麼函數調用時都有哪些信息呢?
函數調用與返回信息
咱們知道當函數A調用函數B的時候,控制從A轉移到了B,所謂控制其實就是指CPU執行屬於哪一個函數的機器指令,CPU從開始執行屬於函數A的指令切換到執行屬於函數B的指令,咱們就說控制從函數A轉移到了函數B。
控制從函數A轉移到函數B,那麼咱們須要有這樣兩個信息:
-
我從哪裏來 (返回)
-
要到去哪裏 (跳轉)
是否是很簡單,就比如你出去旅遊,你須要知道去哪裏,還須要記住回家的路。
函數調用也是一樣的道理。
當函數A調用函數B時,咱們只要知道:
-
函數A對於的機器指令執行到了哪裏 (我從哪裏來,返回)
-
函數B第一條機器指令所在的地址 (要到哪裏去,跳轉)
有這兩條信息就足以讓CPU開始執行函數B對應的機器指令,當函數B執行完畢後跳轉回函數A。
那麼這些信息是怎麼獲取並保持的呢?
如今咱們就能夠打開這個小盒子,看看是怎麼使用的了。
假設函數A調用函數B,如圖所示:
當前,CPU執行函數A的機器指令,該指令的地址爲0x400564,接下來CPU將執行下一條機器指令也就是:
call 0x400540
這條機器指令是什麼意思呢?
這條機器指令對應的就是咱們在代碼中所寫的函數調用,注意call後有一條機器指令地址,注意觀察上圖你會看到,該地址就是函數B的第一條機器指令,從這條機器指令後CPU將跳轉到函數B。
如今咱們已經解決了控制跳轉的「要到哪裏去」問題,當函數B執行完畢後怎麼跳轉回來呢?
原來,call指令除了給出跳轉地址以外還有這樣一個做用,也就是把call指令的下一條指令的地址,也就是0x40056a push到函數A的棧幀中,如圖所示:
如今,函數A的小盒子變大了一些,由於裝入了返回地址:
如今CPU開始執行函數B對應的機器指令,注意觀察,函數B也有一個屬於本身的小盒子(棧幀),能夠往裏面扔一些必要的信息。
若是函數B中又調用了其它函數呢?
道理和函數A調用函數B是同樣的。
讓咱們來看一下函數B最後一條機器指令ret,這條機器指令的做用是告訴CPU跳轉到函數A保存在棧幀上的返回地址,這樣當函數B執行完畢後就能夠跳轉到函數A繼續執行了。
至此,咱們解決了控制轉移中「我從哪裏來」的問題。
參數傳遞與返回值
函數調用與返回使得咱們能夠編寫函數,進行函數調用。但調用函數除了提供函數名稱以外還須要傳遞參數以及獲取返回值,那麼這又是怎樣實現的呢?
在x86-64中,多數狀況下參數的傳遞與獲取返回值是經過寄存器來實現的。
假設函數A調用了函數B,函數A將一些參數寫入相應的寄存器,當CPU執行函數B時就能夠從這些寄存器中獲取參數了。
一樣的,函數B也能夠將返回值寫入寄存器,當函數B執行結束後函數A從該寄存器中就能夠讀取到返回值了。
咱們知道寄存器的數量是有限的,當傳遞的參數個數多於寄存器的數量該怎麼辦呢?
這時那個屬於函數的小盒子也就是棧幀又能發揮做用了。
原來,當參數個數多於寄存器數量時剩下的參數直接放到棧幀中,這樣被調函數就能夠從前一個函數的棧幀中獲取到參數了。
如今棧幀的樣子又能夠進一步豐富了,如圖所示:
從圖中咱們能夠看到,調用函數B時有部分參數放到了函數A的棧幀中,同時函數A棧幀的頂部依然保存的是返回地址。
局部變量
咱們知道在函數內部定義的變量被稱爲局部變量,這些變量在函數運行時被放在了哪裏呢?
原來,這些變量一樣能夠放在寄存器中,可是當局部變量的數量超過寄存器的時候這些變量就必須放到棧幀中了。
所以,咱們的棧幀內容又一步豐富了。
細心的同窗可能會有這樣的疑問,咱們知道寄存器是共享資源能夠被全部函數使用,既然能夠將函數A的局部變量寫入寄存器,那麼當函數A調用函數B時,函數B的局部變量也能夠寫到寄存器,這樣的話當函數B執行完畢回到函數A時寄存器的值已經被函數B修改過了,這樣會有問題吧。
這樣的確會有問題,所以咱們在向寄存器中寫入局部變量以前,必定要先將寄存器中開始的值保存起來,當寄存器使用完畢後再恢復原值就能夠了。
那麼咱們要將寄存器中的原始值保存在哪裏呢?
有的同窗可能已經猜到了,沒錯,依然是函數的棧幀中。
最終,咱們的小盒子就變成了如圖所示的樣子,當寄存器使用完畢後根據棧幀中保存的初始值恢復其內容就能夠了。
如今你應該知道函數在運行時究竟是什麼樣子了吧,以上就是問題3的答案。
Big Picture
須要再次強調的一點就是,上述討論的棧幀就位於咱們常說的棧區。
棧區,屬於進程地址空間的一部分,如圖所示,咱們將棧區放大就是圖左邊的樣子。
關於棧區詳細的講解你能夠參考《深刻理解操做系統:程序員應如何理解內存》這篇。
最後,讓咱們回到文章開始的這段簡單代碼:
void func(int a) { if (a > 100000000) return; int arr[100] = {0}; func(a + 1); } void main(){ func(0); }
想想這段代碼會有什麼問題?
總結
本章咱們從幾個看似沒什麼關聯的問題出發,詳細講解了函數運行時棧是怎麼一回事,爲何咱們不能建立過多的局部變量。細心的同窗會發現第2個問題咱們沒有解答,這個問題講解放到下一篇,也就是協程中講解。
但願這篇文章能對你們理解函數運行時棧有所幫助。