1. 程式人生 > >軟體開發底層知識修煉】二十三 ABI-應用程式二進位制介面三之深入理解函式棧幀的形成與摧毀

軟體開發底層知識修煉】二十三 ABI-應用程式二進位制介面三之深入理解函式棧幀的形成與摧毀

文章目錄

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函式的函式棧幀。如下圖:

在這裡插入圖片描述

圖五-func函式棧幀的形成圖

註釋:上面圖中,main函式的.text段,說法有誤,應該是text段中的main函式即將要執行的下一條指令

  • 上面的func函式棧幀行程圖中,已經標示的非常詳細,可以對比文章前面的圖一與上面frame.c程式的執行結果,看看各個地址值是否正確。當然你自己執行的結果的各個值有可能與我的不一樣。可以自己畫圖看看。
  • 上述我只是給出了最終形成的func函式棧幀,具體的形成過程其實也很簡單
  1. main函式使用call指令呼叫函式func,因為沒有傳引數給func函式,所以就沒有入參這一步驟
  2. 首先將main函式下一條即將要馬上執行的指令的地址壓入棧中,如上圖的的return address
  3. 然後將main函式棧幀的ebp的值(注意不是ebp對應記憶體存的值,而是ebp本身的值)
    壓入棧中,此時這個地址就是func函式棧幀的ebp的值。在將main函式的ebp的值壓棧後,就會將此時的存main函式棧幀ebp的地址,也就是上圖的0xbfa370b8這個值,賦值給ebp暫存器。此時的ebp的值立馬就變了。
  4. 然後就是將main函式可能正在使用的通用暫存器壓棧儲存。這裡到底是誰來做的?實際上是在當初編譯程式的時候,由於函式棧幀的大小就已經由編譯器計算出來了,編譯器也就生成了一些指令,這些指令負責此時的暫存器的壓棧操作。注意,這些動作實際上都可以說成是編譯器的行為。雖然編譯該程式是在執行這些動作之前完成的,但是畢竟那些指令是編譯器產生的。
  5. 然後儲存func函式的區域性變數。如上圖5
    • 然後儲存的是一些其他資訊。這些其他資訊一直沒有搞明白的是什麼。這次看得出來,儲存的有ebp的值。至於為什麼儲存它,目前我還沒有詳細研究。有待考證。
  • 因為func函式在最後呼叫了test()函式,並且test函式有引數,所以在呼叫test函式時,test函式的函式棧幀開始形成,形成過程與上述的func函式的形成過程類似。形成後,test函式的函式棧幀大致如下圖所示:
    在這裡插入圖片描述
圖六-test函式棧幀的形成圖

下面大致說一下上面的test函式中的棧幀形成的過程。

  1. 在func函式中呼叫test函式,func使用call指令呼叫test函式。因為func給test函式傳了引數a,b。在Linux系統中ABI規範引數入棧順序是從右向→左,所以先將引數入棧,且引數b先入棧,a再入棧。
  2. 然後將func函式本身接下啦要執行的指令的地址壓入棧中。
  3. 然後將func函式的ebp值的地址也就是0xbfa370b8,壓入棧中,此時test函式棧幀中的ebp就指向這個值。
  4. 然後壓入一些暫存器,比如func函式使用的通用暫存器,需要儲存起來。可以看到teat棧幀中暫存器的大小佔8位元組,很有可能是因為func函式有兩個區域性變數,所以func函式使用了兩個通用暫存器,此時都需要儲存起來。
  5. 然後就是將test函式的區域性變數c壓入棧中。
  6. 然後將ebp這個地址壓入棧中,以方便找到當前函式棧幀的ebp在哪。實際上這裡我也不太明白為什麼還要將ebp這個地址壓入棧,畢竟當前的暫存器EBP肯定已經存的就是這個地址了。
  7. 如果有其他資訊還需要壓入其他資訊的。

3.22 函式棧幀的摧毀

上面一小節講了函式棧幀的形成。當函式執行完之後相應的函式棧幀就會銷燬。下面我們來看看函式棧幀是如何銷燬的?

由上線的fram.c程式碼知道,函式的呼叫時main—>func—>test

那麼當test函式執行完之後,就會返回到func函式中繼續執行,返回到func函式後,test的棧幀就銷燬了。那麼如何從test棧幀返回到func棧幀?如下圖:

在這裡插入圖片描述

不知是否還記得上面講過這些指令的意思不記得的話,最好回去看看。那麼在函式結束並且返回,就是執行上述指令的。

  • test函式棧幀的摧毀過程
  1. move ebp, esp 就是將ebp存的值,也就是0xbfa37088,賦值給esp暫存器。那麼現在由於esp的值變了,如下圖:

在這裡插入圖片描述

圖七-test函式棧幀的摧毀一
  1. pop ebp 就是將當前棧頂指標指向的值(也就是0xbfa370b8)彈出並賦值給ebp暫存器,此時ebp等於0xbfa370b8,它所在位置儲存的是func函式的ebp值。並且esp指標向上偏移4位元組。如下圖:
    在這裡插入圖片描述
圖七-test函式棧幀的摧毀二
> 此時由於esp已經指向了func函式的棧幀的頂部,ebp也是func函式棧幀的ebp了。所以此時test函式棧幀就差一步就要摧毀了。看下一步:
  1. pop eip 就是將當前棧頂指標指向的值彈出,並賦值給eip暫存器。同時esp向上偏移4位元組。如下圖:
    在這裡插入圖片描述
圖七-test函式棧幀的摧毀三

注意上面的eip暫存器的用處;eip儲存的始終是CPU即將要執行的指令地址。所以此時儲存的是func函式中某一條指令的地址,此時開始在func函式中執行。

  1. 好了,現在test函式棧幀已經摧毀。並且也返回到了func函式中執行。那麼就回到了下圖的樣式:

在這裡插入圖片描述

注意上面的esp指向的應該是test函式的引數,這裡沒有顯示出來。

  • func函式棧幀的摧毀過程

現在回到func函式中執行,由於此時func也是最後一條指令,func函式也要結束並返回了。

  1. 同樣是需要先執行move ebp esp。得到如下樣式的棧幀圖:
    在這裡插入圖片描述

  2. 然後執行pop ebp指令得到如下圖所示:
    在這裡插入圖片描述

  3. 最後執行ret指令,pop eip 。得到如下圖:

在這裡插入圖片描述

  1. 好了,最終func函式也返回結束了,接下來就指向下main函式的棧幀了:
    在這裡插入圖片描述

至於main函式棧幀的摧毀,與上線兩個一樣。只不過main函式的返回,是返回給作業系統的了。這裡就不再贅述。

4 總結

本文學習起來異常艱難,但是學會了受益匪淺。

本文使我學會了以下:

  • 棧幀是函式呼叫時形成的鏈式記憶體結構
  • ebp是構成棧幀的核心基準暫存器
  • 深入掌握了函式棧幀的形成與摧毀

歡迎加我好友共同探討學習交流!歡迎指正文章中的錯誤!!!