【软件开发底层知识修炼】二十三 ABI-应用程序二进制接口三之深入理解函数栈帧的形成与摧毁
- 上兩篇文章我們初步接觸了ABI-應用程序二進制接口的概念,點擊鏈接查看上一篇文章:【軟件開發底層知識修煉】二十二 ABI-應用程序二進制接口 二。了解了為什么會有ABI的存在。本篇文章繼續學習ABI 的內容。學習在ABI規范下,函數棧幀的結構與函數調用時函數棧幀的詳細變化。
文章目錄
- 1 什么是函數棧幀
- 1.1 函數棧幀中ebp寄存器
- 1.2 Linux系統中的棧幀布局
- 2 函數調用時的‘前言’和‘后序’
- 2.1 函數調用時發生的細節操作
- 2.2 函數調用時的前言和后序
- 3 函數棧幀結構的實際代碼案例分析
- 3.1 代碼
- 3.2 分析函數棧幀的形成與摧毀
- 3.21 函數棧幀的形成
- 3.22 函數棧幀的摧毀
- 4 總結
1 什么是函數棧幀
早在之前我們就已經認識到了函數棧幀的作用。只不過一直沒有拿出來說。那么到底什么是函數棧幀呢?下面這幅圖,很多人應該看過的:
上圖是函數運行時函數所需要的棧內存的結構。每個運行的函數都有這么一個棧結構。它記錄了函數的運行狀態信息等。至于為什么是上圖的這種結構,這其實就是ABI的規范所規定的了。現在可能有的人還不懂上述這種結構的作用。不用著急,在后面我們就會詳細說明上述結構的具體作用了。在那之前,我們先要知道以下三點;
ABI定義了函數調用時
- 棧幀的內存布局(就是上圖的布局)
- 棧幀的形成方式(上圖的形成方式)
- 棧幀的銷毀方式(函數調用結束后,上述棧幀就會消失)
以上都是ABI的規范內容。對于不同的平臺很有可能上述的三點都不一樣。那么相應的編譯器一定要滿足相應的ABI規范才可以。由此可見,ABI是多么重要。
1.1 函數棧幀中ebp寄存器
我們學過x86匯編的話,就應該知道寄存器是個什么東西。如果不懂可以看我另一個專欄《x86匯編》–點擊鏈接查看
在函數棧幀中,最重要的寄存器有兩個,一個是棧頂指針寄存器esp,一個是函數幀幀基址寄存器ebp。由于esp的作用很容易理解,它就是指向棧頂的寄存器,這里不再多說。我們想說的是ebp寄存器。它在函數調用的過程中可謂是一個紐帶。用于連接調用函數和被調用函數的。
- ebp為當前棧幀的基準,它存儲的數據是上一個棧幀(即當前函數調用者的棧幀)的ebp的值。有時候我們喜歡叫做old_ebp。
- 通過當前函數的ebp,可以獲得當前函數的棧幀中存的當前函數的參數以及當前函數的局部變量。同時還可以通過ebp這個基準找到上一個函數(即調用者函數)的返回地址。 這就是為什么ebp被稱為當前函數棧幀的基準。
上述的說的兩段話,很多人都聽過,也都知道。但是并不是所有人都知道,如何使用ebp來定位其他的參數。下面我們看一下圖:
注:在x8632位系統中,棧中的最小存儲單位一般是4字節。所以每次偏移都是直接偏移4字節
- 上圖中,就是我們使用ebp這個基準來找到函數棧幀中的其他參數的。
- ebp作為基準,存儲的是上一個棧幀的ebp值。
- ebp向上偏移4字節,存儲的是函數返回地址。這也是當前函數棧幀形的第一個存儲的值(但是一定不是函數發生調用時第一個入棧的,后面會說),用于在當前函數執行完之后能夠返回到調用者的函數中繼續執行
- ebp向下偏移4字節就開始存儲一些需要保存的寄存器的值。這些寄存器的值往往是調用者在之前執行的時候可能正在使用,但是現在突然跳轉到其他函數執行,被調用的函數可能也需要使用一些調用者之前正在使用的寄存器,所以現在先要將那些寄存器壓入棧中存起來,等被調用函數執行完返回給調用者時,再將其彈出,好能夠讓調用函數能夠繼續正常執行。
- 在往下偏移就是存儲的當前運行函數的局部變量以及臨時變量了。主意這里不是存儲的函數參數,函數參數在ebp往上偏移8字節處
- 我們可以看到上面都是說的被調用者函數棧幀中內容。被調用者函數棧幀中并沒有被調用者的參數。參數在哪里?實際上參數存儲在ebp往上偏移8字節處,但是這個地方,已經不屬于當前函數的棧幀了,而是屬于調用者的棧幀。其實這里估計很多人不明白。我們要記住,函數參數,是存在于調用者的函數棧幀中而并非是存在被調用的函數的棧幀中。
- ebp+8存儲的是第一個參數,ebp+4(n+1)位置存儲的是第n個參數。ABI規范中,還涉及到參數的入棧順序,后面還會說明。
- 我認為上述唯一需要注意的就是函數的參數是存儲在調用者的函數棧幀中,而不是被調用函數的函數棧幀中。這一點在面試中也有問到,問你發生函數調用時是函數參數先入棧還是返回地址先入棧?乍一看以為返回地址是在函數棧幀的第一個位置就以為是函數棧幀先入棧,實際上是錯的。發生函數調用時,函數的參數先入棧,只不過入的棧是屬于調用者的棧而已。
1.2 Linux系統中的棧幀布局
中所周知,一般來說棧的增長方向是向下的,下面就給出一個圖,表示在Linux系統下的棧幀的布局。由于與上線的中文的圖幾乎一樣(只是畫反了),這里就不再用過多的語言來描述下圖
圖三2 函數調用時的‘前言’和‘后序’
上一節內容我們很清晰的認識了函數棧幀的結構以及函數棧幀中的重要的寄存器ebp的作用。下面就來詳細說說函數發生調用時,具體的一些細節操作。我們先說調用過程中的一些細節,后面再給出具體的代碼案例。看過代碼案例再結合回來看,就基恩完全掌握了函數棧幀的作用了。
2.1 函數調用時發生的細節操作
- 函數調用時發生的細節操作
- 調用者一般通過call指令調用函數,調用的函數有參數的話先將參數以某種順序壓入到調用者的函數棧幀中,然后將返回地址壓入棧中。從這個返回地址開始往后,就是新的被調用的函數的函數棧幀了。
- 函數所需要的棧幀的空間大小,首先肯定是由編譯器計算出來了,此時函數棧的大小已經是一個固定值,是一個字面常量了,所以函數棧幀的大小是固定的。
- 函數結束時,leave指令恢復上一個棧幀esp和ebp的值。
- 函數返回時,ret指令將返回地址恢復到eip寄存器,即PC指針寄存器。
上一面的leave和ret可能還沒講明白,它們主要表現為下面的具體行為:
我們來解釋一下上面幾條指令的矩形行為:
- move ebp, esp 。是將ebp(這個ebp是當前函數的ebp,它存的是上一個函數棧幀的ebp的值)賦值給esp。也就是說此時esp存的是上一個函數棧幀(調用者的函數棧幀)的ebp的值
- pop ebp。是將棧頂指針,也就是esp指向的值(上面第一步的操作導致現在esp的值存儲的是調用者的old_ebp的值)彈出給ebp寄存器。這一步操作完,此時ebp寄存器存的是調用者的基準了,不再是被調用函數的基準了。同時還需要注意,在發生pop之后,esp就會向上偏移4字節,此時esp就是指向返回地址的存儲地址了(看上面函數棧幀的結構)。
- pop eip 。 是將當前棧頂也就是esp指向的值(由上兩步知此時esp指向的值返回地址,也就是調用者當時發生函數調用時壓入的下一條即將要執行但是卻因為發生函數調用而沒有執行的指令的地址)彈出給eip寄存器。而eip寄存器的主要作用是:它保存的永遠是CPU下一次即將要執行的指令的地址。 剛剛好,此時eip保存就是調用者當時發生函數調用時壓入的下一條即將要執行但是卻因為發生函數調用而沒有執行的指令的地址,那么,順理成章,CPU開始執行這條指令,我們又返回到了調用者開始繼續執行函數。
2.2 函數調用時的前言和后序
什么是前言?什么是后序?
前言:
- 函數發生調用時,總會保存調用者之前正在使用的一些通用寄存器的值,為了能夠在函數調用返回時調用者能夠繼續正常執行程序。這個保存這些寄存器的值就是前言。
后序
- 如上所說,函數調用返回時,會把之前在前言的過程中保存的寄存器的值給pop出來好讓調用者繼續正常執行程序。這就是后序。
前言和后序的具體匯編上的行為大概就是下面表格中所列的一些行為:
其中push的操作就是保存寄存器的值。如果不理解上面的指令,那還需要加強一下匯編指令的學習。參考我其他的文章。
3 函數棧幀結構的實際代碼案例分析
3.1 代碼
#include <stdio.h>#define PRINT_STACK_FRAME_INFO() do \ { \char* ebp = NULL; \char* esp = NULL; \\\asm volatile ( \"movl %%ebp, %0\n" \"movl %%esp, %1\n" \: "=r"(ebp), "=r"(esp) \); \\printf("ebp = %p\n", ebp); \printf("previous ebp = 0x%x\n", *((int*)ebp)); \printf("return address = 0x%x\n", *((int*)(ebp + 4))); \printf("previous esp = %p\n", ebp + 8);//調用者函數棧幀最后一個值,也就是被調用者函數棧幀的第一個參數 \printf("esp = %p\n", esp); \printf("&ebp = %p\n", &ebp); \printf("&esp = %p\n", &esp); \ } while(0)void test(int a, int b) {int c = 3;printf("test() : \n");PRINT_STACK_FRAME_INFO();//打印test函數的函數棧幀信息printf("&a = %p\n", &a);printf("&b = %p\n", &b);printf("&c = %p\n", &c); }void func() {int a = 1;int b = 2;printf("func() : \n");PRINT_STACK_FRAME_INFO();//打印func函數的函數棧幀信息。printf("&a = %p\n", &a);printf("&b = %p\n", &b);test(a, b); //func函數中發生函數調用 }int main() {printf("main() : \n");PRINT_STACK_FRAME_INFO(); //打印main函數的函數棧幀信息func(); //main函數中發生函數調用。return 0; }- 先將該函數編譯運行得到結果,再慢慢分析
3.2 分析函數棧幀的形成與摧毀
下面我們來根據上述代碼的運行結果,來分析上述代碼中的函數棧幀結構的形成過程與摧毀過程
3.21 函數棧幀的形成
上述代碼比較簡單,可以看出函數的調用關系為:main—> func—>test
首先在程序運行起來后,內存中只有main函數的函數棧幀。這里我們忽略main函數的參數,并且main函數也沒有局部變量,這里就不看main函數的函數棧幀。
- func函數中,有兩個局部變量,a和b。首先開始構建func函數的函數棧幀。如下圖:
注釋:上面圖中,main函數的.text段,說法有誤,應該是text段中的main函數即將要執行的下一條指令
- 上面的func函數棧幀行程圖中,已經標示的非常詳細,可以對比文章前面的圖一與上面frame.c程序的運行結果,看看各個地址值是否正確。當然你自己運行的結果的各個值有可能與我的不一樣。可以自己畫圖看看。
- 上述我只是給出了最終形成的func函數棧幀,具體的形成過程其實也很簡單
壓入棧中,此時這個地址就是func函數棧幀的ebp的值。在將main函數的ebp的值壓棧后,就會將此時的存main函數棧幀ebp的地址,也就是上圖的0xbfa370b8這個值,賦值給ebp寄存器。此時的ebp的值立馬就變了。
- 然后保存的是一些其他信息。這些其他信息一直沒有搞明白的是什么。這次看得出來,保存的有ebp的值。至于為什么保存它,目前我還沒有詳細研究。有待考證。
- 因為func函數在最后調用了test()函數,并且test函數有參數,所以在調用test函數時,test函數的函數棧幀開始形成,形成過程與上述的func函數的形成過程類似。形成后,test函數的函數棧幀大致如下圖所示:
下面大致說一下上面的test函數中的棧幀形成的過程。
3.22 函數棧幀的摧毀
上面一小節講了函數棧幀的形成。當函數執行完之后相應的函數棧幀就會銷毀。下面我們來看看函數棧幀是如何銷毀的?
由上線的fram.c代碼知道,函數的調用時main—>func—>test
那么當test函數執行完之后,就會返回到func函數中繼續執行,返回到func函數后,test的棧幀就銷毀了。那么如何從test棧幀返回到func棧幀?如下圖:
不知是否還記得上面講過這些指令的意思不記得的話,最好回去看看。那么在函數結束并且返回,就是執行上述指令的。
- test函數棧幀的摧毀過程
注意上面的eip寄存器的用處;eip保存的始終是CPU即將要執行的指令地址。所以此時保存的是func函數中某一條指令的地址,此時開始在func函數中執行。
注意上面的esp指向的應該是test函數的參數,這里沒有顯示出來。
- func函數棧幀的摧毀過程
現在回到func函數中執行,由于此時func也是最后一條指令,func函數也要結束并返回了。
同樣是需要先執行move ebp esp。得到如下樣式的棧幀圖:
然后執行pop ebp指令得到如下圖所示:
最后執行ret指令,pop eip 。得到如下圖:
至于main函數棧幀的摧毀,與上線兩個一樣。只不過main函數的返回,是返回給操作系統的了。這里就不再贅述。
4 總結
本文學習起來異常艱難,但是學會了受益匪淺。
本文使我學會了以下:
- 棧幀是函數調用時形成的鏈式內存結構
- ebp是構成棧幀的核心基準寄存器
- 深入掌握了函數棧幀的形成與摧毀
歡迎加我好友共同探討學習交流!歡迎指正文章中的錯誤!!!
總結
以上是生活随笔為你收集整理的【软件开发底层知识修炼】二十三 ABI-应用程序二进制接口三之深入理解函数栈帧的形成与摧毁的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: centos7静默搭建oracle11g
- 下一篇: php 加入日志功能,php怎么写一个日