編程語言離不開函數,函數是對一段代碼的封裝,每每實現了某個特定的功能,在程序中能夠屢次調用這個函數。稍有編程經驗的同窗都知道,函數是由棧實現的,調用對應入棧,退出對應出棧。在寫遞歸函數的時候,若是遞歸層次太深會出現棧溢出(StackOverFlow)的錯誤。linux
"函數棧"包含了對函數調用的基本理解,可是從細節來看,還有不少疑問,例如:編程
本文以 C 語言爲例,從內存佈局、彙編代碼的角度來分析函數棧的實現原理。編程語言
當程序被執行的時候,Linux 會爲其在內存中分配相應的空間以支撐程序的運行,以下圖所示。函數
在虛擬內存中,內存空間被分爲多個區域。代碼指令保存在文本段,已初始化的全局變量 global
保存在數據段,程序運行中動態申請的內存malloc(10 * char())
放在堆中,而函數執行的時候則在棧中開闢空間運行。例如main
函數便佔有一個函數棧,其中的變量i
和ip
都保存在main
的棧空間中。佈局
函數的棧空間有個名字叫作 棧幀
,下面就具體瞭解一下棧幀。學習
下圖是棧的結構。圖中右側是棧空間,其中有多個棧幀。從上往下由較早的棧幀到較新的棧幀,因爲棧是從高地址往低地址生長的,因此最新的棧永遠在最下面,即棧頂。編碼
圖中有兩個畫出了具體結構的棧幀,分別是函數 A 和函數 B。函數 A 的棧幀最上面有一塊省略號標識的區域,其中保存的是上一個棧幀的寄存器值以及函數 A 本身內部建立的局部變量。下面的參數 n 到參數 1 則是函數 A 要傳給函數 B 的調用參數。那麼函數 B 如何獲取?答案是用寄存器。spa
CPU 計算時會把不少變量放在寄存器中,根據硬件體系的不一樣,寄存器數量和做用也不一樣。通常在 x86 32位中,寄存器 %esp
保存了棧指針的值,也就是棧頂,而 %ebp
做爲當前棧幀的幀指針,也就是當前棧幀的底部,因此經過 %esp
和 %ebp
就能夠知道當前棧幀的頭跟尾。除了這兩個寄存器,還有其它一些通用寄存器(%eax
、%edx
等),用於保存程序執行的臨時值。指針
瞭解了寄存器的基本知識後,下面咱們就能夠知道函數 B 如何獲取到函數 A 傳給它的參數了。參數 1 的地址是 %ebp + 8
,參數 2 的地址是 %ebp + 12
,參數 n 的地址是 %ebp + 4 + 4 * n
。相信你們已經看明白,經過幀指針往上找就能夠取得這些參數,而這些參數之因此在這裏固然是函數 A 預先準備好的,關於這一點下文會有例子。code
另外在全部參數的最下面保存着 返回地址
,這個是在函數 B 返回以後接下來要執行的指令的地址。
看了函數 A 以後,再看看函數 B。在函數 B 的棧幀最上面是 被保存的 %ebp
,這個指的是函數 A 的幀指針,畢竟 %ebp
這個寄存器就一個,因此新的函數入棧的時候要先把老的保存起來,等函數出棧再恢復。在這個老的幀指針下面則是其它須要保存的寄存器變量以及函數 B 本身內部用到的局部變量。再往下是 參數構造區域
,也就是函數 B 即將調用另外一個函數,在這裏先把參數準備好。能夠看出,函數 B 與函數 A 的棧幀結構是相似的。
瞭解了棧幀的理論以後,你們可能會以爲很抽象,下面結合具體實例來看棧幀從產生到消亡的過程。
下面圖是函數 caller
的具體執行過程,左邊是 C 代碼,中間是彙編碼,右邊是對應的棧幀。
咱們一行一行的來分析,看中間彙編碼,上面三行綠色的:
pushl %ebp // 保存舊的 %ebp movl %esp, %ebp // 將 %ebp 設置爲 %esp subl $24, %esp // 將 %esp 減 24 開闢棧空間
這三行實際上是爲棧幀作準備工做。第一行保存舊的 %ebp
,此時新的棧空間尚未建立,但保存舊的 %ebp
的這一行空間將做爲新棧幀的棧底,也就是幀指針,所以第二行將棧指針 %esp
(永遠指向棧頂)的值設置到 %ebp
上。 第三行將 %esp
下移 24 個字節,這一行其實就是爲函數 caller
開闢棧空間了。從圖中能夠看出,下面的空間用於保存 caller
中的變量以及傳給下個函數的參數。有部分空間未使用,這個是爲了地址對齊,不影響咱們的分析,能夠忽略。
在開闢了棧幀以後,就開始執行 caller
內部的邏輯了,caller
首先建立了兩個局部變量(arg1
和arg2
)。對應的彙編代碼爲 movl $534, -4(%ebp); movl $1057, -8(%ebp)
,其中 -4(%ebp)
表示 %ebp - 4
的位置,也就是圖中 arg1
所在的位置, arg2
的位置則是 %ebp - 8
的位置。這兩行是把 534
和 1057
保存到傳送到這兩個位置上。
繼續往下是這幾行:
leal -8(%ebp), %eax // 把 %ebp - 8 這個地址保存到 %eax movl %eax, 4(%esp) // 把 %eax 的值保存到 %esp + 4 這個位置上 leal -4(%ebp), %eax // 把 %ebp - 4 這個地址保存到 %eax movl %eax, ($esp) // 把 %eax 的值保存到 %esp 這個位置上
第一行把 %ebp - 8
這個地址保存到 %eax
中,而 %ebp - 8
是 arg2
的地址,下一行把這個地址放到 %esp + 4
這個位置上,也就是圖中 &arg2
的那個區域塊。其實這一行是在爲函數 swap_add
準備參數 &arg2
,而下面兩行則是準備參數 &arg1
。
再下面一行是 call swap_add
。這一行就是調用函數 swap_add
了,不過在這以前還須要把返回地址壓到棧上,這裏的返回地址是函數 swap_add
返回後要接着執行的代碼的地址,也就是 int diff = arg1 - arg2
地址。
在調用 swap_add
後用到了其返回值 sum
繼續進行計算,咱們還不知道返回值是怎麼拿到的。在這以前,咱們先進入 swap_add
函數,下面是對應的代碼執行圖:
swap_add
對應的彙編代碼的前三行與 caller
相似,一樣是保存舊的幀指針,可是由於 swap_add
不須要保存額外的變量,只須要多用一個寄存器 %ebx
,因此這裏保存了這個寄存器的舊值,可是沒有將 %esp
直接下移一段長度的操做。
接下來綠色的兩行就是關鍵了:
movl 8(%ebp), %edx // 從 %ebp + 8 取值保存到 %edx movl 12(%ebp), %ecx // 從 %ebp + 12 取值保存到 %ecx
這兩行分別是從 caller
中保存參數 &arg1
和 &arg2
的地方取得地址值,並根據地址取得 arg1
和arg2
的實際數值。
接下來的 4 行是交換操做,這裏就不具體看每一行的邏輯了。
再下面一行 addl %ebx, %eax
是將返回值保存到寄存器 %eax
中,這裏很是關鍵,函數 swap_add
的返回值保存在 %eax
中,一下子 caller
就是從這個寄存器獲取的。
swap_add
的最後幾行是出棧操做,將 %ebx
和 %ebp
分別恢復爲 caller
中的值。最後執行 ret
返回到 caller
中。
下面咱們繼續回到 caller
中,剛纔執行到 call swap_add
,下面幾行是執行 int diff = arg1 - arg2
,結果保存在 %edx
中。
最後一行是計算 sum * diff
,對應的彙編代碼爲 imull %edx, %eax
。這裏是把 %edx
和 %eax
的值相乘而且把結果保存到 %eax
中。在上面的分析中,咱們知道 %eax
保存着 swap_add
的返回值,這裏仍是從 %eax
中取出返回值進行計算,而且把結果繼續保存到 %eax
中,而這個值又是 caller
的返回值,這樣調用 caller
的函數也能夠從這個寄存器中獲取返回值了。
caller
函數的最後一行彙編代碼是 ret
,這會銷燬 caller
的棧幀而且恢復相應寄存器的舊值。到此,caller
和 swap_add
這個函數的調用過程就所有分析完了。
本文詳細分析了函數調用過程當中棧幀變化的過程,對於開頭提出的幾個疑問也都有了解答。函數棧的實如今常規的開發中幾乎不會涉及到,可是學習其中的原理有利於更深刻地理解內存以及編程語言的奧祕。
參考
若是個人文章對您有幫助,不妨點個贊支持一下(^_^)