目錄
- 1.什么是函數棧幀
- 2.理解函數棧幀能解決什么問題
- 3.函數棧幀的創建和銷毀的過程解析
- 3.1 什么是棧
- 3.2 認識相關寄存器和匯編指令
- 3.3 解析函數棧幀的創建和銷毀過程
- 3.3.1 準備環境
- 3.3.2 函數的調用堆棧
- 3.3.3 轉到反匯編
- 3.3.4 函數棧幀的創建和銷毀
1.什么是函數棧幀
在寫C語言代碼的時候,我們經常會把一個獨立的功能抽象成函數,C程序是以函數為基本單位的,那么函數又是如何調用的呢?函數的參數是怎樣傳遞的呢?這些答案都可以在函數棧幀中尋找
函數棧幀(stack frame):函數調用過程中在程序的調用棧(call stack)所開辟的空間,這些空間是用來存放:
- 函數參數和函數返回值
- 臨時變量(包括函數的非靜態的局部變量以及編譯器自動產生的其他臨時變量)
- 保存上下文信息(包括用來維護函數調用前后的寄存器)
2.理解函數棧幀能解決什么問題
只要理解好函數棧幀就可以對一下問題有額外的理解:
- 局部變量是如何創建的?
- 為什么局部變量不初始化時為隨機值?
- 函數調用時形參的傳遞的順序是怎樣的?
- 函數的形參和實參的聯系是怎樣的?
- 函數的返回值是如何帶回來的?
3.函數棧幀的創建和銷毀的過程解析
3.1 什么是棧
棧(stack)是現代計算機程序中最為重要的概念之一,幾乎每一個程序都要用到棧,沒有棧就沒有函數,沒有局部變量,更沒有更正語言的橋接
在經典的計算機科學中,棧被定義為一種特殊的容器,用戶可以將數據壓入棧中(該操作被稱為壓棧:push),也可以將已經壓入棧中的數據彈出(出棧:pop),但是棧這個容器遵守一條規則:先進后出
在計算機系統中,棧則是一個具有以上屬性的動態內存區域,程序可以將數據壓入棧中,也可以將棧彈出,在經典的操作系統中,棧總是向下增長(由高地址到低地址)的,在我們常見的i386或者x86-64下,棧頂由esp的寄存器定位
3.2 認識相關寄存器和匯編指令
<1.相關寄存器
- eax:通用寄存器,保留臨時數據,常用于返回值
- ebx:同樣寄存器,保留臨時數據
- ebp:棧底寄存器
- esp:棧頂寄存器,與ebp共同維護當前的函數棧幀
- eip:指令寄存器,保存當前指令的下一條指令的地址
<2.相關匯編命令
- mov:數據轉移指令,將后面的數據賦值給前面的數據
- push:數據入棧,同時esp寄存器也要發生改變
- pop:數據彈出指定位置,同時esp也要發生改變
- sub:減法命令
- add:加法命令
- call:函數調用,1.壓入返回地址 2.轉入目標函數
- jump:通過修改eip,轉入目標函數,進行調用
- ret:恢復返回地址,壓入eip,類似pop eip命令
3.3 解析函數棧幀的創建和銷毀過程
3.3.1 準備環境
為了更好地觀察函數棧幀的整個過程,需要先關閉一些選項以免受到干擾:
3.3.2 函數的調用堆棧
這里我們寫一段簡單的代碼,并將代碼一條一條拆解處理足夠好觀察內部的細節
注意:函數棧幀的創建和銷毀過程,在不同的編譯器的實現方法大同小異,但大體的邏輯層次是不會差很多的,本次演示用的是VS2019環境
演示代碼:
#include<stdio.h>int Add(int x, int y)
{int z = 0;z = x + y;return z;
}int main()
{int a = 10;int b = 20;int c = 0;c = Add(a, b);printf("%d\n", c);return 0;
}
在VS2019環境下,按F10進入調試,打開窗口的調用堆棧:
調用堆棧是用來反饋函數調用邏輯的
然后繼續按F10(按完整個主函數),進入界面:
我們會發現main函數也是被調用的,這里可以清晰地觀察到:invoke_main函數調用了main函數,至于是什么函數調用了invoke_main就不再考慮了
3.3.3 轉到反匯編
按F10調試到main函數的第一行,右擊鼠標轉到反匯編
注意:這里調試出來的地址是由系統自動分配的,所以每一次進去調試出來的地址都是不同的
int main()
{
//main函數的函數棧幀的創建
004C1820 push ebp
004C1821 mov ebp,esp
004C1823 sub esp,0E4h
004C1829 push ebx
004C182A push esi
004C182B push edi
004C182C lea edi,[ebp-24h]
004C182F mov ecx,9
004C1834 mov eax,0CCCCCCCCh
004C1839 rep stos dword ptr es:[edi] //main函數中的核心代碼int a = 10;
004C183B mov dword ptr [ebp-8],0Ah int b = 20;
004C1842 mov dword ptr [ebp-14h],14h int c = 0;
004C1849 mov dword ptr [ebp-20h],0 c = Add(a, b);
004C1850 mov eax,dword ptr [ebp-14h]
004C1853 push eax
004C1854 mov ecx,dword ptr [ebp-8]
004C1857 push ecx//執行call指令會跳轉到Add函數內部
004C1858 call 004C10B4
004C185D add esp,8
004C1860 mov dword ptr [ebp-20h],eax printf("%d\n", c);
004C1863 mov eax,dword ptr [ebp-20h]
004C1866 push eax
004C1867 push 4C7B30h
004C186C call 004C10D2
004C1871 add esp,8 return 0;
004C1874 xor eax,eax }
調試至call指令,按住F11:
//Add函數的函數棧幀
int Add(int x, int y)
{
004C1760 push ebp
004C1761 mov ebp,esp
004C1763 sub esp,0CCh
004C1769 push ebx
004C176A push esi
004C176B push edi int z = 0;
004C176C mov dword ptr [ebp-8],0 z = x + y;
004C1773 mov eax,dword ptr [ebp+8]
004C1776 add eax,dword ptr [ebp+0Ch]
004C1779 mov dword ptr [ebp-8],eax return z;
004C177C mov eax,dword ptr [ebp-8]
}
004C177F pop edi
004C1780 pop esi
004C1781 pop ebx
004C1782 mov esp,ebp
004C1784 pop ebp
004C1785 ret
3.3.4 函數棧幀的創建和銷毀
這里我們將拆解每一行匯編代碼:
//main函數棧幀的創建,在創建之前esp和ebp維護的是invoke_main的函數棧幀
004C1820 push ebp //將ebp寄存器的值進行壓棧,此時存放的是invoke_main的函數棧幀的ebp,esp-4
004C1821 mov ebp,esp //將esp中的值賦給ebp
004C1823 sub esp,0E4h //將esp減去0eE4(十六進制的表示),此時的esp已經指向了一個新的區域用來維護main的函數棧幀
004C1829 push ebx //把ebx的值進行壓棧,esp-4
004C182A push esi //把esi的值進行壓棧,esp-4
004C182B push edi //把edi的值進行壓棧,esp-4
//上面3條指令保存了3個寄存器的值在棧區,這3個寄存器的在函數隨后執行中可能會被修改,所以先保存寄
//存器原來的值,以便在退出函數時恢復。004C182C lea edi,[ebp-24h] //lea(load effective address)加載有效地址,將ebp-24h的地址放到edi中
004C182F mov ecx,9 //將9賦給ecx
004C1834 mov eax,0CCCCCCCCh //將0CCCCCCCCh賦給eax
004C1839 rep stos dword ptr es:[edi]
//從edi開始將以ecx的存儲數值為個數的4個字節的數據全部改為eax存儲的值
//dword:double word(雙字),一個字為2個字節,雙字就是4個字節
該段匯編的內存:
解釋燙燙燙的產生:
之所以得到了這些奇怪的漢字,是因為這里使用了未初始化的字符數組,就導致buf中存儲的就是上面的0CCCCCCCCh的值,而0xCCCC的漢字編碼就是“燙”
//main函數中的核心代碼int a = 10;
004C183B mov dword ptr [ebp-8],0Ah //將0Ah(10)賦給ebp-8的地址處,對變量a初始化int b = 20;
004C1842 mov dword ptr [ebp-14h],14h //將14h(20)賦給ebp-14h的地址處,對變量b初始化int c = 0;
004C1849 mov dword ptr [ebp-20h],0 //將0賦給ebp-20h的地址處,對變量c初始化//調用Add函數c = Add(a, b);
004C1850 mov eax,dword ptr [ebp-14h] //將ebp-14h地址處的值(20)存放在eax中,這里其實就是傳遞參數b
004C1853 push eax //把eax的值進行壓棧
004C1854 mov ecx,dword ptr [ebp-8] //將ebp-8地址處的值(10)存放在ecx中,這里是傳遞參數a
004C1857 push ecx //把ecx的值進行壓棧
該段匯編的內存:
//執行call指令會跳轉到Add函數內部,在跳轉之前會進行壓棧操作
004C1858 call 004C10B4 //把call指令的下一條的地址進行壓棧,esp-4,回調函數//Add函數的函數棧幀的創建
004C1760 push ebp //將main函數的ebp的值壓棧進行保存,esp-4
004C1761 mov ebp,esp //將esp的值賦給ebp
004C1763 sub esp,0CCh //將esp減去0CCh,esp開始維護新函數Add的函數棧幀
004C1769 push ebx //把ebx的值進行壓棧,esp-4
004C176A push esi //將esi的值進行壓棧,esp-4
004C176B push edi //將edi的值進行壓棧,esp-4
//Add函數中的核心代碼int z = 0;
004C176C mov dword ptr [ebp-8],0 //將0賦給ebp-8的地址處 z = x + y;
004C1773 mov eax,dword ptr [ebp+8] //將ebp+8地址處的值賦給eax
004C1776 add eax,dword ptr [ebp+0Ch] //把ebp+0Ch地址處的值加到eax中
004C1779 mov dword ptr [ebp-8],eax //將eax中的值賦到ebp-8的地址處return z;
004C177C mov eax,dword ptr [ebp-8] //將ebp-8地址處的值賦給eax
}
該段匯編的內存:
可以看出形參和實參的關系:形參是實參的一份臨時拷貝,對形參的修改并不會改變實參
004C177F pop edi //把edi的值進行出棧,esp+4
004C1780 pop esi //把esi的值進行出棧,esp+4
004C1781 pop ebx //把ebx的值進行出棧,esp+4
004C1782 mov esp,ebp //將ebp的值賦給esp
004C1784 pop ebp //把ebp的值進行出棧,ebp此時又回到main函數的ebp,開始維護main函數的函數棧幀,esp+4
004C1785 ret //首先彈出棧頂的值,此時esp+4,并回到call指令的下一條地址處繼續執行代碼
//Add函數的函數棧幀銷毀
回到call指令的下一條指令:
004C185D add esp,8 //esp+8
004C1860 mov dword ptr [ebp-20h],eax //將eax的值(存儲的就是Add函數的返回值)賦到ebp-20h的地址處printf("%d\n", c);
004C1863 mov eax,dword ptr [ebp-20h] //將ebp-20h地址處的值賦給eax
004C1866 push eax //將eax的值進行壓棧
004C1867 push 4C7B30h //將4C7B30h進行壓棧
004C186C call 004C10D2 //繼續回調函數
004C1871 add esp,8 //esp+8return 0;
004C1874 xor eax,eax }
該段匯編的內存():
小結:對于函數棧幀的創建和銷毀過程可以在VS上獨自進行反匯編代碼解析 + 內存和監視的觀察,理解該過程更有助于對代碼底層的東西了解的更深,此外,這里對于main函數的函數棧幀的銷毀不再解析,相信了解過Add函數的整個過程后便能明白,上述提出的問題也可以直接回答出