目錄
一、函數棧幀(Stack Frame)整理
1、核心概念
2、為什么需要函數棧幀?
3、函數棧幀的主要內容
二、理解函數棧幀能解決的核心問題
1、局部變量的生命周期與本質
2、函數調用的參數傳遞機制
3、函數返回值的傳遞
三、函數棧幀的創建和銷毀解析
1、什么是棧(Stack)?
2、認識相關寄存器和匯編指令
a) 相關寄存器
b) 相關匯編指令
3、解析函數棧幀的創建和銷毀
1. 預備知識
2. 函數的調用堆棧
3.?準備環境
4. 轉到反匯編
5. 函數棧幀的創建
小知識:燙燙燙~
Add函數的傳參
函數調用過程
6. 函數棧幀的銷毀
拓展了解
一、函數棧幀(Stack Frame)整理
1、核心概念
????????函數棧幀(也稱為活動記錄)是函數被調用時,在程序的調用棧(Call Stack)?上為其分配的一塊內存空間。它用于支持函數的執行和管理函數調用過程。
2、為什么需要函數棧幀?
????????C程序以函數為基本單位。當一個函數調用另一個函數時,需要解決以下幾個關鍵問題,而函數棧幀正是解決這些問題的機制:
-
函數參數如何傳遞?
-
函數內部的局部變量如何存儲?
-
函數調用結束后,如何返回到正確的位置繼續執行?
-
函數的返回值如何傳遞給調用者?
-
函數執行前后,如何保證調用者寄存器的狀態不被破壞?
3、函數棧幀的主要內容
一塊函數棧幀通常包含以下幾類信息:
-
函數參數與返回值:存儲傳遞給被調用函數的參數以及函數返回時的返回值。
-
臨時變量:
-
函數的非靜態局部變量。
-
編譯器自動生成的其他臨時變量。
-
-
上下文信息:
-
調用函數的返回地址(調用指令下一條指令的地址)。
-
調用函數的棧幀基地址,用于在當前函數返回后恢復調用者的棧幀。
-
為保持函數調用前后不變而需要保存的寄存器值。
-
二、理解函數棧幀能解決的核心問題
????????深入理解函數棧幀的創建和銷毀過程,就像是獲得了C語言函數底層工作機制的“地圖”,許多令人困惑的語法現象和編程難題都會變得清晰明了。具體來說,它能幫助我們徹底理解以下關鍵問題:
1、局部變量的生命周期與本質
-
局部變量是如何創建的?:它們并非憑空產生,而是在其所屬函數的棧幀被創建時,在棧上分配了內存空間。所謂的“創建”,就是移動棧指針來預留一塊足夠大的內存。
-
為什么局部變量不初始化內容是隨機的?:因為“創建”僅僅是在棧上分配了空間,而這片空間之前很可能被其他函數使用過,殘留著之前的數據。如果不主動初始化(賦值),直接使用該內存的值,看到的自然就是不可預測的“隨機值”或“垃圾值”。
2、函數調用的參數傳遞機制
-
函數調用時參數是如何傳遞的?:通常不是在被調用函數的棧幀里直接“變出”參數。而是由調用者將自己的實參(的值或地址)壓入棧中(或存入約定的寄存器)。隨后,被調用函數才能在自己的棧幀中找到這些參數。
-
傳參的順序是怎樣的?:理解棧幀可以清楚地看到,參數通常是從右向左依次壓入棧中(超重要!!!)。這是為了支持像
printf
這樣的可變參數函數。 -
形參和實參的關系:形參其實就是函數棧幀中為參數預留的位置。在函數被調用時,實參的值會被拷貝(復制)?到形參所在的內存中。因此,形參是實參的一份副本,修改形參(在大多數情況下)不會影響實參,這解釋了為何值傳遞是有效的。
3、函數返回值的傳遞
-
函數的返回值是如何帶回的?:通常,返回值不會通過棧幀的主要部分傳遞。而是通過一個特定的寄存器(如
eax/rax
)來存儲并帶回給調用者。如果返回值較大,可能會采用調用者預先分配空間并傳入地址等其他機制。
????????總結而言,理解函數棧幀就是將編程語言中“函數調用”這個抽象概念,轉化為CPU和內存中“分配空間、拷貝數據、跳轉指令、恢復現場”等一系列具體操作的過程。這是連接高級語言語法和計算機底層邏輯的關鍵橋梁。讓我們一同深入分析函數棧幀創建和銷毀的詳細過程。
三、函數棧幀的創建和銷毀解析
1、什么是棧(Stack)?
????????棧是現代計算機程序中一個至關重要的基礎概念。它支撐著函數調用、局部變量管理等核心功能,可以說沒有棧,就沒有我們現在看到的高級編程語言。
棧被定義為一個遵守?“后進先出”(Last In First Out, LIFO)?原則的特殊容器。
-
操作:數據可以壓入(Push)?棧頂,也可以從棧頂彈出(Pop)。
-
規則:最先壓入的數據最后彈出,最后壓入的數據最先彈出。(類比:疊放的盤子,總是取最上面的那個,最下面的盤子是最后才能被取到的)。
在計算機系統的具體實現中:
-
棧是一塊動態的內存區域。
-
壓棧(Push)?使棧增大,出棧(Pop)?使棧減小。
-
在經典的操作系統(如i386, x86-64架構)中,棧的增長方向是向下的,即從高地址向低地址擴展。
-
棧頂的位置由一個名為?
esp
?(Stack Pointer) 的專用寄存器來定位和跟蹤。
2、認識相關寄存器和匯編指令
理解函數棧幀的操作需要了解一些關鍵的底層硬件寄存器和匯編指令。
a) 相關寄存器
寄存器 | 全稱與用途 |
---|---|
eax | 通用寄存器,通常用于存儲臨時數據和函數的返回值。 |
ebx | 通用寄存器,用于保留臨時數據。 |
ebp | 棧底指針寄存器 (Base Pointer),用于定位當前函數棧幀的底部。在函數執行過程中,其值通常保持穩定,從而可以通過ebp 方便地訪問參數和局部變量。 |
esp | 棧頂指針寄存器 (Stack Pointer),始終指向系統棧的最頂部(下一個可用的最低地址)。push 和pop 操作都會直接改變esp 的值。 |
eip | 指令指針寄存器 (Instruction Pointer),保存著CPU下一條要執行的指令的地址。程序的執行流程就是由eip 的指向決定的。 |
b) 相關匯編指令
匯編指令 | 功能描述 |
---|---|
mov | 數據轉移指令。例如?mov eax, ebx ?將ebx 的值拷貝到eax 中。 |
push | 數據入棧。1. 先將esp 的值減小(棧向下增長)。2. 再將數據寫入新的棧頂地址。 |
pop | 數據出棧。1. 先將esp 指向的數據讀出來。2. 再將esp 的值增加(棧收縮)。 |
sub | 減法指令。常用于減小esp 的值來為函數局部變量開辟空間。例如?sub esp, 0Ch |
add | 加法指令。常用于增加esp 的值來回收函數局部變量的空間。例如?add esp, 0Ch |
call | 函數調用指令。它主要做兩件事: 1.?壓入返回地址:將 call 指令的下一條指令的地址壓入棧中。2.?轉入目標函數:修改 eip ,開始執行被調用函數的代碼。 |
jump | 跳轉指令。通過直接修改eip 寄存器的值,來改變程序的執行流程。 |
ret | 函數返回指令。它的作用類似于?pop eip ,即將call 指令壓入棧的返回地址彈出,并放入eip 寄存器中,從而使程序跳回到調用者函數中繼續執行。 |
3、解析函數棧幀的創建和銷毀
1. 預備知識
首先我們達成一些預備知識才能有效的幫助我們理解,函數棧幀的創建和銷毀。
- 每一次函數調用,都要為本次函數調用開辟空間,就是函數棧幀的空間。
- 這塊空間的維護是使用了2個寄存器: esp 和 ebp , ebp 記錄的是棧底的地址, esp 記錄的是棧頂的地址。
如圖所示:
- 函數棧幀的創建和銷毀過程,在不同的編譯器上實現的方法大同小異,本次演示以VS2019為例。
2. 函數的調用堆棧
演示代碼:
#include <stdio.h>int Add(int x, int y)
{int z = 0;z = x + y;return z;
}int main()
{int a = 3;int b = 5;int ret = 0;ret = Add(a, b);printf("%d\n", ret);return 0;
}
????????這段代碼,如果我們在VS2019編譯器上調試,調試進入Add函數后,我們就可以觀察到函數的調用堆棧 (右擊勾選【顯示外部代碼】),如下圖:
????????函數調用堆棧是反饋函數調用邏輯的,那我們可以清晰的觀察到, main 函數調用之前,是由 invoke_main 函數來調用main函數。 在 invoke_main 函數之前的函數調用我們就暫時不考慮了。那我們可以確定, invoke_main 函數應該會有自己的棧幀, main 函數和 Add 函數也會維護自己的棧幀,每個函數棧幀都有自己的 ebp 和 esp 來維護棧幀空間。 那接下來我們從main函數的棧幀創建開始講解:
3.?準備環境
????????為了讓我們研究函數棧幀的過程足夠清晰,不要太多干擾,我們可以關閉下面的選項,讓匯編代碼中排除一些編譯器附加的代碼,首先右擊“解決方案”欄中的項目,打開如下,跟著步驟來:
4. 轉到反匯編
????????調試到main函數開始執行的第一行,右擊鼠標找到“反匯編”選項并點擊,轉到反匯編。 注:VS編譯器每次調試都會為程序重新分配內存,博客中的反匯編代碼是一次調試代碼過程中數據,每次調試略有差異。
int main()
{
//函數棧幀的創建
00BE1820 ?push ? ? ? ?ebp ?
00BE1821 ?mov ? ? ? ? ebp,esp ?
00BE1823 ?sub ? ? ? ? esp,0E4h ?
00BE1829 ?push ? ? ? ?ebx ?
00BE182A ?push ? ? ? ?esi ?
00BE182B ?push ? ? ? ?edi ?
00BE182C ?lea ? ? ? ? edi,[ebp-24h] ?
00BE182F ?mov ? ? ? ? ecx,9 ?
00BE1834 ?mov ? ? ? ? eax,0CCCCCCCCh ?
00BE1839 ?rep stos ? ?dword ptr es:[edi] ?
//main函數中的核心代碼int a = 3;
00BE183B ?mov ? ? ? ? dword ptr [ebp-8],3 ?int b = 5;
00BE1842 ?mov ? ? ? ? dword ptr [ebp-14h],5 ?int ret = 0;
00BE1849 ?mov ? ? ? ? dword ptr [ebp-20h],0 ?ret = Add(a, b);
00BE1850 ?mov ? ? ? ? eax,dword ptr [ebp-14h] ?
00BE1853 ?push ? ? ? ?eax ?
00BE1854 ?mov ? ? ? ? ecx,dword ptr [ebp-8] ?
00BE1857 ?push ? ? ? ?ecx ?
00BE1858 ?call ? ? ? ?00BE10B4 ?
00BE185D ?add ? ? ? ? esp,8 ?
00BE1860 ?mov ? ? ? ? dword ptr [ebp-20h],eax ?printf("%d\n", ret);
00BE1863 ?mov ? ? ? ? eax,dword ptr [ebp-20h] ?
00BE1866 ?push ? ? ? ?eax ?
00BE1867 ?push ? ? ? ?0BE7B30h ?
00BE186C ?call ? ? ? ?00BE10D2 ?
00BE1871 ?add ? ? ? ? esp,8 ?return 0;
00BE1874 ?xor ? ? ? ? eax,eax ?
}
5. 函數棧幀的創建
這里看到 main 函數轉化來的匯編代碼如上所示。 接下來我們就一行行拆解匯編代碼
00BE1820 ?push ? ? ? ?ebp ? ?//把ebp寄存器中的值進行壓棧,此時的ebp中存放的是invoke_main函數棧幀的ebp,esp-4
00BE1821 ?mov ? ? ? ? ebp,esp ?//move指令會把esp的值存放到ebp中,相當于產生了main函數的ebp,這個值就是invoke_main函數棧幀的esp
00BE1823 ?sub ? ? ? ? esp,0E4h ?//sub會讓esp中的地址減去一個16進制數字0xe4,產生新的esp,此時的esp是main函數棧幀的esp,此時結合上一條指令的ebp和當前的esp,ebp和esp之間維護了一個塊棧空間,這塊棧空間就是為main函數開辟的,就是main函數的棧幀空間,這一段空間中將存儲main函數中的局部變量,臨時數據已經調試信息等。
00BE1829 ?push ? ? ? ?ebx ?//將寄存器ebx的值壓棧,esp-4
00BE182A ?push ? ? ? ?esi ?//將寄存器esi的值壓棧,esp-4
00BE182B ?push ? ? ? ?edi ?//將寄存器edi的值壓棧,esp-4
//上面3條指令保存了3個寄存器的值在棧區,這3個寄存器的在函數隨后執行中可能會被修改,所以先保存寄存器原來的值,以便在退出函數時恢復。//下面的代碼是在初始化main函數的棧幀空間。
//1. 先把ebp-24h的地址,放在edi中
//2. 把9放在ecx中
//3. 把0xCCCCCCCC放在eax中
//4. 將從edp-0x2h到ebp這一段的內存的每個字節都初始化為0xCC
00BE182C ?lea ? ? ? ? edi,[ebp-24h] ?
00BE182F ?mov ? ? ? ? ecx,9 ?
00BE1834 ?mov ? ? ? ? eax,0CCCCCCCCh ?
00BE1839 ?rep stos ? ?dword ptr es:[edi]
上面的這段代碼最后4句,等價于下面的偽代碼:
edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx = 0; --ecx,edi+=4)
{*(int*)edi = eax;
}
小知識:燙燙燙~
????????之所以上面的程序輸出“燙”這么一個奇怪的字,是因為main函數調用時,在棧區開辟的空間的其中每一 個字節都被初始化為0xCC,而arr數組是一個未初始化的數組,恰好在這塊空間上創建的,0xCCCC(兩 個連續排列的0xCC)的漢字編碼就是“燙”,所以0xCCCC被當作文本就是“燙”。接下來我們再分析main函數中的核心代碼:
int a = 3;
00BE183B ?mov ? ? ? ? dword ptr [ebp-8],3 ?//將3存儲到ebp-8的地址處,ebp-8的位置其實就是a變量int b = 5;
00BE1842 ?mov ? ? ? ? dword ptr [ebp-14h],5 //將5存儲到ebp-14h的地址處,ebp-14h的位置其實是b變量int ret = 0;
00BE1849 ?mov ? ? ? ? dword ptr [ebp-20h],0 ?//將0存儲到ebp-20h的地址處,ebp-20h的位置其實是ret變量
//以上匯編代碼表示的變量a,b,ret的創建和初始化,這就是局部的變量的創建和初始化
//其實是局部變量的創建時在局部變量所在函數的棧幀空間中創建的
//調用Add函數ret = Add(a, b);
//調用Add函數時的傳參
//其實傳參就是把參數push到棧幀空間中
00BE1850 ?mov ? ? ? ? eax,dword ptr [ebp-14h] ?//傳遞b,將ebp-14h處放的5放在eax寄存器中
00BE1853 ?push ? ? ? ?eax ? ? ? ? ? ? ? ? ? ? ?//將eax的值壓棧,esp-4
00BE1854 ?mov ? ? ? ? ecx,dword ptr [ebp-8] ? ?//傳遞a,將ebp-8處放的3放在ecx寄存器中
00BE1857 ?push ? ? ? ?ecx ? ? ? ? ? ? ? ? ? ? ?//將ecx的值壓棧,esp-4
//跳轉調用函數
00BE1858 ?call ? ? ? ?00BE10B4 ?
00BE185D ?add ? ? ? ? esp,8 ?
00BE1860 ?mov ? ? ? ? dword ptr [ebp-20h],eax
Add函數的傳參
//調用Add函數ret = Add(a, b);
//調用Add函數時的傳參
//其實傳參就是把參數push到棧幀空間中,這里就是函數傳參
00BE1850 ?mov ? ? ? ? eax,dword ptr [ebp-14h] ?//傳遞b,將ebp-14h處放的5放在eax寄存器
中
00BE1853 ?push ? ? ? ?eax ? ? ? ? ? ? ? ? ? ? ?//將eax的值壓棧,esp-4
00BE1854 ?mov ? ? ? ? ecx,dword ptr [ebp-8] ? ?//傳遞a,將ebp-8處放的3放在ecx寄存器中
00BE1857 ?push ? ? ? ?ecx ? ? ? ? ? ? ? ? ? ? ?//將ecx的值壓棧,esp-4
//跳轉調用函數
00BE1858 ?call ? ? ? ?00BE10B4 ?
00BE185D ?add ? ? ? ? esp,8 ?
00BE1860 ?mov ? ? ? ? dword ptr [ebp-20h],eax
函數調用過程
//跳轉調用函數
00BE1858 ?call ? ? ? ?00BE10B4 ?
00BE185D ?add ? ? ? ? esp,8 ?
00BE1860 ?mov ? ? ? ? dword ptr [ebp-20h],eax
????????call 指令是要執行函數調用邏輯的,在執行call指令之前先會把call指令的下一條指令的地址進行壓棧操作,這個操作是為了解決當函數調用結束后要回到call指令的下一條指令的地方,繼續往后執行。
當我們跳轉到Add函數,就要開始觀察Add函數的反匯編代碼了。
int Add(int x, int y)
{
00BE1760 ?push ? ? ? ?ebp ?//將main函數棧幀的ebp保存,esp-4
00BE1761 ?mov ? ? ? ? ebp,esp ? //將main函數的esp賦值給新的ebp,ebp現在是Add函數的ebp
00BE1763 ?sub ? ? ? ? esp,0CCh ?//給esp-0xCC,求出Add函數的esp
00BE1769 ?push ? ? ? ?ebx ? ? ? //將ebx的值壓棧,esp-4
00BE176A ?push ? ? ? ?esi ? ? ? //將esi的值壓棧,esp-4
00BE176B ?push ? ? ? ?edi ? ? ? //將edi的值壓棧,esp-4int z = 0; ? ? ?
00BE176C ?mov ? ? ? ? dword ptr [ebp-8],0 ?//將0放在ebp-8的地址處,其實就是創建zz = x + y;//接下來計算的是x+y,結果保存到z中
00BE1773 ?mov ? ? ? ? eax,dword ptr [ebp+8] ? //將ebp+8地址處的數字存儲到eax中
00BE1776 ?add ? ? ? ? eax,dword ptr [ebp+0Ch] ?//將ebp+12地址處的數字加到eax寄存中
00BE1779 ?mov ? ? ? ? dword ptr [ebp-8],eax ? ?//將eax的結果保存到ebp-8的地址處,其實就是放到z中return z;
00BE177C ?mov ? ? ? ? eax,dword ptr [ebp-8] ? ?//將ebp-8地址處的值放在eax中,其實就是把z的值存儲到eax寄存器中,這里是想通過eax寄存器帶回計算的結果,做函數的返回值。
}
00BE177F ?pop ? ? ? ? edi ?
00BE1780 ?pop ? ? ? ? esi ?
00BE1781 ?pop ? ? ? ? ebx ?
00BE1782 ?mov ? ? ? ? esp,ebp ?
00BE1784 ?pop ? ? ? ? ebp ?
00BE1785 ?ret ?
代碼執行到Add函數的時候,就要開始創建Add函數的棧幀空間了。
在Add函數中創建棧幀的方法和在main函數中是相似的,在棧幀空間的大小上略有差異而已。
- 將main函數的 ebp 壓棧
- 計算新的 ebp 和 esp
- 將 ebx , esi , edi 寄存器的值保存
- 計算求和,在計算求和的時候,我們是通過 ebp 中的地址進行偏移訪問到了函數調用前壓棧進去的參數,這就是形參訪問。
- 將求出的和放在 eax 寄存器準備帶回
????????圖片中的 a' 和 b' 其實就是 Add 函數的形參 x , y 。這里的分析很好的說明了函數的傳參過程,以及函數在進行值傳遞調用的時候,形參其實是實參的一份拷貝。對形參的修改不會影響實參。
6. 函數棧幀的銷毀
當函數調用要結束返回的時候,前面創建的函數棧幀也開始銷毀。 那具體是怎么銷毀的呢?我們看一下反匯編代碼。
00BE177F ?pop ? ? ? ? edi ?//在棧頂彈出一個值,存放到edi中,esp+4
00BE1780 ?pop ? ? ? ? esi ?//在棧頂彈出一個值,存放到esi中,esp+4
00BE1781 ?pop ? ? ? ? ebx ?//在棧頂彈出一個值,存放到ebx中,esp+4
00BE1782 ?mov ? ? ? ? esp,ebp ?//再將Add函數的ebp的值賦值給esp,相當于回收了Add函數的棧幀空間
00BE1784 ?pop ? ? ? ? ebp ?//彈出棧頂的值存放到ebp,棧頂此時的值恰好就是main函數的ebp,esp+4,此時恢復了main函數的棧幀維護,esp指向main函數棧幀的棧頂,ebp指向了main函數棧幀的棧底。
00BE1785 ?ret ? ? ? ? ? ? ?//ret指令的執行,首先是從棧頂彈出一個值,此時棧頂的值就是call指令下一條指令的地址,此時esp+4,然后直接跳轉到call指令下一條指令的地址處,繼續往下執行。
回到了call指令的下一條指令的地方:
但調用完Add函數,回到main函數的時候,繼續往下執行,可以看到:
00BE185D ?add ? ? ? ? esp,8 ? ? ? ? ? ? ? ? ?//esp直接+8,相當于跳過了main函數中壓棧的 'a'和b'
00BE1860 ?mov ? ? ? ? dword ptr [ebp-20h],eax ?//將eax中值,存檔到ebp-0x20的地址處,其實就是存儲到main函數中ret變量中,而此時eax中就是Add函數中計算的x和y的和,可以看出來,本次函數的返回值是由eax寄存器帶回來的。程序是在函數調用返回之后,在eax中去讀取返回值的。
拓展了解
????????其實返回對象時內置類型時,一般都是通過寄存器來帶回返回值的,返回對象如果時較大的對象時,一般會在主調函數的棧幀中開辟一塊空間,然后把這塊空間的地址,隱式傳遞給被調函數,在被調函數中通過地址找到主調函數中預留的空間,將返回值直接保存到主調函數的。具體可以參考《程序員的自我修養》一書的第10章。 ?
????????到這里我們給大家完整的演示了main函數棧幀的創建,Add函數棧幀的創建和銷毀的過程,相信大家已經能夠基本理解函數的調用過程,函數傳參的方式,也能夠回答開始的問題了。