函數棧的實現原理

簡介

編程語言離不開函數,函數是對一段代碼的封裝,每每實現了某個特定的功能,在程序中能夠屢次調用這個函數。稍有編程經驗的同窗都知道,函數是由棧實現的,調用對應入棧,退出對應出棧。在寫遞歸函數的時候,若是遞歸層次太深會出現棧溢出(StackOverFlow)的錯誤。linux

"函數棧"包含了對函數調用的基本理解,可是從細節來看,還有不少疑問,例如:編程

  • 函數的棧是如何開闢的?
  • 如何傳入參數?
  • 返回值是如何獲得的?

本文以 C 語言爲例,從內存佈局、彙編代碼的角度來分析函數棧的實現原理。編程語言

Linux 進程內存佈局

當程序被執行的時候,Linux 會爲其在內存中分配相應的空間以支撐程序的運行,以下圖所示。函數

linux-memory.png

在虛擬內存中,內存空間被分爲多個區域。代碼指令保存在文本段,已初始化的全局變量 global 保存在數據段,程序運行中動態申請的內存malloc(10 * char())放在堆中,而函數執行的時候則在棧中開闢空間運行。例如main函數便佔有一個函數棧,其中的變量iip都保存在main的棧空間中。佈局

函數的棧空間有個名字叫作 棧幀,下面就具體瞭解一下棧幀。學習

棧幀

下圖是棧的結構。圖中右側是棧空間,其中有多個棧幀。從上往下由較早的棧幀到較新的棧幀,因爲棧是從高地址往低地址生長的,因此最新的棧永遠在最下面,即棧頂。編碼

stack-frame.png

圖中有兩個畫出了具體結構的棧幀,分別是函數 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 代碼,中間是彙編碼,右邊是對應的棧幀。

caller-frame.png

咱們一行一行的來分析,看中間彙編碼,上面三行綠色的:

pushl %ebp // 保存舊的 %ebp
movl %esp, %ebp // 將 %ebp 設置爲 %esp
subl $24, %esp // 將 %esp 減 24 開闢棧空間

這三行實際上是爲棧幀作準備工做。第一行保存舊的 %ebp,此時新的棧空間尚未建立,但保存舊的 %ebp 的這一行空間將做爲新棧幀的棧底,也就是幀指針,所以第二行將棧指針 %esp(永遠指向棧頂)的值設置到 %ebp 上。 第三行將 %esp 下移 24 個字節,這一行其實就是爲函數 caller 開闢棧空間了。從圖中能夠看出,下面的空間用於保存 caller 中的變量以及傳給下個函數的參數。有部分空間未使用,這個是爲了地址對齊,不影響咱們的分析,能夠忽略。

在開闢了棧幀以後,就開始執行 caller 內部的邏輯了,caller 首先建立了兩個局部變量(arg1arg2)。對應的彙編代碼爲 movl $534, -4(%ebp); movl $1057, -8(%ebp),其中 -4(%ebp) 表示 %ebp - 4 的位置,也就是圖中 arg1 所在的位置, arg2 的位置則是 %ebp - 8 的位置。這兩行是把 5341057 保存到傳送到這兩個位置上。

繼續往下是這幾行:

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 - 8arg2 的地址,下一行把這個地址放到 %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-frame.png

swap_add 對應的彙編代碼的前三行與 caller 相似,一樣是保存舊的幀指針,可是由於 swap_add 不須要保存額外的變量,只須要多用一個寄存器 %ebx,因此這裏保存了這個寄存器的舊值,可是沒有將 %esp 直接下移一段長度的操做。

接下來綠色的兩行就是關鍵了:

movl 8(%ebp), %edx // 從 %ebp + 8 取值保存到 %edx
movl 12(%ebp), %ecx // 從 %ebp + 12 取值保存到 %ecx

這兩行分別是從 caller 中保存參數 &arg1&arg2 的地方取得地址值,並根據地址取得 arg1arg2 的實際數值。

接下來的 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 的棧幀而且恢復相應寄存器的舊值。到此,callerswap_add 這個函數的調用過程就所有分析完了。

總結

本文詳細分析了函數調用過程當中棧幀變化的過程,對於開頭提出的幾個疑問也都有了解答。函數棧的實如今常規的開發中幾乎不會涉及到,可是學習其中的原理有利於更深刻地理解內存以及編程語言的奧祕。

參考

  • 《深刻理解計算機系統》

若是個人文章對您有幫助,不妨點個贊支持一下(^_^)

相關文章
相關標籤/搜索