在開始本篇的內容前,我們先來思考幾個問題。
- 我們先來看一段簡單的程式碼:
void func(int a) {
if (a > 100000000) return;
int arr[100] = {0};
func(a + 1);
}
你能看出這段程式碼會有什麼問題嗎?
- 我們在之前的文章《高效能高併發伺服器是如何實現的》一中提到了一項關鍵技術——協程,你知道協程的本質是什麼嗎?有的同學可能會說是使用者執行緒,那麼什麼是使用者態執行緒,這是怎麼實現的?
- 函式執行起來後是什麼樣子?
這個問題看似沒什麼關聯,但這背後有一樣東西你需要理解,這就是所謂的函式執行時棧,run time stack。
接下來我們就好好看看到底什麼是函式執行時棧,為什麼徹底理解函式執行時棧對程式設計師來說非常重要。
從程式、執行緒到函式呼叫
汽車在高速上行駛時有很多資訊,像速度、位置等等,通過這些資訊我們可以直觀的感受汽車的執行時狀態。
同樣的,程式在執行時也有很多資訊,像有哪些程式正在執行、這些程式執行到了哪裡等等,通過這些資訊我們可以直觀的感受系統中程式執行的狀態。
其中,我們創造了程式、執行緒這樣的概念來記錄有哪些程式正在執行,關於程式和執行緒的概念請參見《看完這篇還不懂程式和執行緒你來打我》。
程式和執行緒的執行體現在函式執行上,函式的執行除了函式內部執行的順序執行還有子函式呼叫的控制轉移以及子函式執行完畢的返回。其中函式內部的順序執行乏善可陳,重點是函式的呼叫。
因此接下來我們的視角將從巨集觀的程式和執行緒拉近到微觀下的函式呼叫,重點來討論一下函式呼叫是怎樣實現的。
函式呼叫的活動軌跡:棧
玩過遊戲的同學應該知道,有時你為了完成一項主線任務不得不去打一些支線的任務,支線任務中可能還有支線任務,當一個支線任務完成後退回到前一個支線任務,這是什麼意思呢,舉個例子你就明白了。
假設主線任務西天取經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個問題我們沒有解答,這個問題講解放到下一篇,也就是協程中講解。
希望這篇文章能對大家理解函式執行時棧有所幫助。