目錄
1.函數棧幀是什么?
2. 理解函數棧幀能解決什么問題?
3、函數棧幀的創建和銷毀具體過程
3.1 什么是棧
3.2 認識相關寄存器和匯編指令
3.3函數棧幀的創建和銷毀
3.3.1 預備知識
3.3.2 函數的調用堆棧
3.3.3?準備環境
3.3.4 轉到反匯編
3.3.5 函數棧幀的創建
3.3.6 函數棧幀的銷毀
相關概念知識(輔助理解):
1.棧(Stack)
2.?esp?和?ebp?的作用
3. 寄存器
1.通用寄存器(General-Purpose Registers)
2. 段寄存器(Segment Registers)
3. 控制寄存器(Control Registers)
4. 關鍵寄存器詳解:
1.函數棧幀是什么?
在C語言中書寫代碼時, 我們通常會把一個獨立的功能用函數來實現, 不同的函數用來實現不同的功能,? 所以C程序是以函數為基本單位的。? 那函數是如何被調用的?? 函數的返回值又是如何待會的????????函數的形參和實參是如何傳遞的?? 這些問題都和函數棧幀有關系。
函數棧幀(Stack Frame)?是函數運行時在內存棧(Stack)中占用的一個獨立空間,用來存儲該函數運行所需的所有臨時數據。
獨立空間所存放的數據包括:
函數參數和函數返回的地址(函數返回值)
舊的基指針(保存調用者(Caller)的棧幀基址(EBP/RBP))
局部變量和臨時數據
2. 理解函數棧幀能解決什么問題?
只要理解了函數棧幀的創建和銷毀,就能大概弄懂一下的問題
- 局部變量是如何創建的?
- 為什么局部變量不初始化內容是隨機的?
- 函數調用時參數時如何傳遞的?傳參的順序是怎樣的?
- 函數的形參和實參分別是怎樣實例化的?
- 函數的返回值是如何帶會的?
3、函數棧幀的創建和銷毀具體過程
3.1 什么是棧
棧(Stack)是現代計算機程序的核心基礎之一,幾乎所有程序都依賴它運行。
簡單來說,棧就像一個嚴格遵守"后來先出"規則的容器:數據像疊盤子一樣被壓入(push)棧頂,取出時也只能從最上面彈出(pop)。
在計算機中,棧是一塊特殊的內存區域,由CPU通過棧指針寄存器(如x86架構的ESP/RSP)自動管理,隨著數據壓入棧頂指針向低地址移動(棧向下增長),彈出時則向高地址回退。
正是這個精巧的設計,使得函數調用、局部變量存儲、參數傳遞等關鍵功能得以實現,可以說沒有棧就沒有現代編程語言中的函數概念。
3.2 認識相關寄存器和匯編指令
相關寄存器:
1.eax:通用寄存器,保留臨時數據,常用于返回值
2.ebx:通用寄存器,保留臨時數據
3.ebp:棧底寄存器
4.esp:棧頂寄存器
5.eip:指令寄存器,保存當前指令的下一條指令的地址
匯編指令:
1.call:保存下一條指令地址(返回地址)到棧頂,并跳轉到目標函數
2.ret:從棧頂彈出返回地址,跳轉回調用位置繼續執行。? ? ?
3.push:將數據壓入棧頂,棧指針下移(棧向低地址增長)。?
4.pop:從棧頂彈出數據,棧指針上移。
5.enter:建立新棧幀(保存舊幀指針,分配局部變量空間)。?
6.leave:撤銷當前棧幀(恢復舊幀指針和棧指針)。? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?
7.mov (ebp/esp):直接操作幀指針(ebp)或棧指針(esp),用于調整棧結構。
8.sub/add (esp):動態調整棧空間(如分配/釋放局部變量)。
3.3函數棧幀的創建和銷毀
3.3.1 預備知識
首先我們達成一些預備知識才能有效的幫助我們理解,函數棧幀的創建和銷毀。
1.每一次函數調用,都要為本次函數調用開辟空間,就是函數棧幀的空間。
2.這塊空間的維護是使用了2個寄存器: esp 和 ebp , ebp 記錄的是棧底的地址, esp 記錄的是棧頂的地址。? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?
3.?函數棧幀的創建和銷毀過程,在不同的編譯器上實現的方法大同小異。
如圖:
3.3.2 函數的調用堆棧
演示代碼:
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
int Mystrlen(char* arr)
{if (*arr != '\0')return 1+Mystrlen(arr+1);elsereturn 0;
}
int main()
{char arr1[10] = "abcdedg";int len = Mystrlen(arr1);printf("%d", len);return 0;
}
這段代碼,如果我們在VS2022編譯器上調試,調試進入Mystrlen函數后,我們就可以觀察到函數的調用堆棧(右擊勾選【顯示外部代碼】)如下圖:
打開方法:
通過菜單欄打開: 啟動調試(按?
F5
?或點擊?調試 >)開始調試在調試狀態下,點擊菜單欄的?調試 (Debug)選擇?窗口 (Windows) > 調用堆棧 (Call Stack)。
快捷鍵:
Ctrl + Alt + C
(默認)。
函數調用堆棧是反饋函數調用邏輯的,那我們可以清晰的觀察到, main 函數調用之前,是由 invoke_main 函數來調用main函數。在 invoke_main 函數之前的函數調用我們就暫時不考慮了。那我們可以確定, invoke_main 函數應該會有自己的棧幀, main 函數和 Add 函數也會維護自己的棧幀,每個函數棧幀都有自己的 ebp 和 esp 來維護棧幀空間。那接下來我們從main函數的棧幀創建開始講解:
3.3.3?準備環境
為了讓我們研究函數棧幀的過程足夠清晰,不要太多干擾,我們可以關閉下面的選項,讓匯編代碼中排除一些編譯器附加的代碼:
3.3.4 轉到反匯編
調試到main函數開始執行的第一行,右擊鼠標轉到反匯編。
注:VS編譯器每次調試都會為程序重新分配內存,課件中的反匯編代碼是一次調試代碼過程中數據,每次調試略有差異。
int main()
{
//函數棧幀的創建
00007FF79DB71900 push rbp
00007FF79DB71902 push rdi
00007FF79DB71903 sub rsp,148h
00007FF79DB7190A lea rbp,[rsp+20h]
00007FF79DB7190F lea rdi,[rsp+20h]
00007FF79DB71914 mov ecx,2Ah
00007FF79DB71919 mov eax,0CCCCCCCCh
00007FF79DB7191E rep stos dword ptr [rdi]
00007FF79DB71920 mov rax,qword ptr [__security_cookie (07FF79DB7D000h)]
00007FF79DB71927 xor rax,rbp
00007FF79DB7192A mov qword ptr [rbp+118h],rax
00007FF79DB71931 lea rcx,[__167BB7BA_源@c (07FF79DB82008h)]
00007FF79DB71938 call __CheckForDebuggerJustMyCode (07FF79DB71370h)
00007FF79DB7193D nop
//main函數中的核心代碼char arr1[10] = "abcdedg";
00007FF79DB7193E mov rax,qword ptr [string "abcdedg" (07FF79DB7AC70h)]
00007FF79DB71945 mov qword ptr [arr1],rax
00007FF79DB71949 lea rax,[rbp+60h]
00007FF79DB7194D mov rdi,rax
00007FF79DB71950 xor eax,eax
00007FF79DB71952 mov ecx,2
00007FF79DB71957 rep stos byte ptr [rdi] int len = Mystrlen(arr1);
00007FF79DB71959 lea rcx,[arr1]
00007FF79DB7195D call Mystrlen (07FF79DB713DEh)
00007FF79DB71962 mov dword ptr [len],eax printf("%d", len);
00007FF79DB71968 mov edx,dword ptr [len]
00007FF79DB7196E lea rcx,[string "%d" (07FF79DB7ACB4h)]
00007FF79DB71975 call printf (07FF79DB7119Ah)
00007FF79DB7197A nop return 0;
00007FF79DB7197B xor eax,eax
}
3.3.5 函數棧幀的創建
這里我看到 main 函數轉化來的匯編代碼如上所示。接下來我們就一行行拆解匯編代碼:
00007FF79DB71900 push rbp
//將調用者(如invoke_main)的棧基址rbp壓棧保存,esp自動-8(x64下指針占8字節)
//此時rsp指向棧頂,保存了舊的rbp值00007FF79DB71902 push rdi
//保存rdi寄存器的值到棧中(x64調用約定中rdi可能被調用者修改),esp再-800007FF79DB71903 sub rsp,148h
//給main函數分配棧空間:rsp減去0x148字節(328字節)
//現在rsp指向main函數棧幀的頂部,與后續的rbp構成棧幀范圍00007FF79DB7190A lea rbp,[rsp+20h]
//設置main函數的棧基址rbp = rsp + 0x20
//這樣rbp到rsp之間保留0x20字節(可能用于調試或局部變量)00007FF79DB7190F lea rdi,[rsp+20h]
//將rdi指向棧初始化區域的起始地址(rbp的位置),準備填充0xCC00007FF79DB71914 mov ecx,2Ah
//設置循環次數ecx = 0x2A(42次),每次處理4字節,共初始化42*4=168字節00007FF79DB71919 mov eax,0CCCCCCCCh
//用調試模式填充值0xCCCCCCCC初始化棧空間(未初始化內存的標記)00007FF79DB7191E rep stos dword ptr [rdi]
//從rdi指向的地址開始,重復填充eax的值(0xCCCCCCCC)到內存,共ecx次
//相當于初始化[rbp-0x20]到[rbp+0xA8]的范圍(168字節)00007FF79DB71920 mov rax,qword ptr [__security_cookie (07FF79DB7D000h)]
// 從全局變量加載安全cookie(棧溢出保護值)到rax00007FF79DB71927 xor rax,rbp
//將安全cookie與當前棧基址rbp異或,生成唯一校驗值00007FF79DB7192A mov qword ptr [rbp+118h],rax
//將校驗值存入棧中[rbp+0x118]的位置(函數返回時會驗證是否被篡改)00007FF79DB71931 lea rcx,[__167BB7BA_源@c (07FF79DB82008h)]
//加載調試信息符號地址到rcx(用于"Just My Code"調試功能)00007FF79DB71938 call __CheckForDebuggerJustMyCode (07FF79DB71370h)
//調用VS調試器檢查函數,確認是否在調試模式下運行00007FF79DB7193D nop
//空指令(用于對齊或預留調試斷點位置)
上面的這段代碼,等價于下面的偽代碼:
void main() {// 1. 保存調用者的棧基址和寄存器push(rbp); // 保存invoke_main的rbppush(rdi); // 保存可能被修改的rdi// 2. 分配棧空間(x64下更大)rsp -= 0x148; // 分配328字節空間// 3. 設置新的棧基址(跳過預留區域)rbp = rsp + 0x20; // rbp指向有效棧幀起始處// 4. 初始化棧空間(填充0xCC)rdi = rbp; // 初始化起始地址ecx = 42; // 循環次數(42次×4字節=168字節)eax = 0xCCCCCCCC;memset(rdi, eax, ecx * 4); // 填充168字節// 5. 棧溢出保護(x64特有)rax = __security_cookie; // 加載安全cookierax ^= rbp; // 與棧基址異或加密*(rbp + 0x118) = rax; // 存儲校驗值// 6. 調試檢查(VS特有)if (IsDebuggerPresent()) { // 檢查調試器__CheckForDebuggerJustMyCode(); // 調試鉤子}
}
小知識 : 燙燙燙燙燙燙燙燙燙燙燙燙
出現 “燙燙燙……” 的原因是:在 Windows 下,未初始化的棧內存可能會被初始化為 0xCC ,而 0xCC 對應的字符在當前字符編碼下顯示為 “燙” 。
接下來我們再分析main函數中的核心代碼:
1. 初始化字符數組?arr1[10] = "abcdedg";
00007FF79DB7193E mov rax, qword ptr [string "abcdedg" (07FF79DB7AC70h)]
//從全局數據段(地址 `07FF79DB7AC70h`)加載字符串 `"abcdedg"` 的前 8 字節到 `rax`。
//由于 `"abcdedg"` 是 7 字節(含 `\0`),rax 會包含'a','b','c','d','e','d','g','\0'00007FF79DB71945 mov qword ptr [arr1], rax
//將 `rax` 的值(即字符串的前 8 字節)存儲到 `arr1` 的起始地址(`[arr1]`)。
//此時 `arr1` 的前 8 字節已填充為 `"abcdedg\0"`。00007FF79DB71949 lea rax, [rbp+60h]
//計算 `arr1` 的剩余部分地址(`rbp+60h`)
//即 `arr1[8]` 的位置(因為 `arr1` 是 `char[10]`,前 8 字節已填充,剩余 2 字節)。00007FF79DB7194D mov rdi, rax
//將目標地址 `rbp+60h` 存入 `rdi`(`stos` 指令的目的寄存器)。00007FF79DB71950 xor eax, eax
//清零 `eax`,即 `al = 0`(`\0` 字符)。00007FF79DB71952 mov ecx, 2
//設置循環次數 `ecx = 2`(剩余 2 字節需要填充 `\0`)。00007FF79DB71957 rep stos byte ptr [rdi]
//從 `rdi` 指向的地址開始,重復填充 `al`(`0`)到內存,共 `ecx` 次(2 次)。
//相當于 `arr1[8] = '\0'; arr1[9] = '\0';`,確保數組完全以 `\0` 結尾。
2. 調用?Mystrlen(arr1)
?計算字符串長度
00007FF79DB71959 lea rcx, [arr1]
//將 `arr1` 的地址加載到 `rcx`(x64 調用約定:第一個參數用 `rcx` 傳遞)。00007FF79DB7195D call Mystrlen (07FF79DB713DEh)
//調用 `Mystrlen` 函數,返回值存儲在 `eax` 中。00007FF79DB71962 mov dword ptr [len], eax
//將返回值(字符串長度)存入局部變量 `len`。
3. 調用?printf
?打印長度
00007FF79DB71968 mov edx, dword ptr [len]
//將 `len` 的值(`7`)存入 `edx`(x64 調用約定:第二個參數用 `edx` 傳遞)。00007FF79DB7196E lea rcx, [string "%d" (07FF79DB7ACB4h)]
//加載格式字符串 `"%d"` 的地址到 `rcx`(第一個參數)。00007FF79DB71975 call printf (07FF79DB7119Ah)
//調用 `printf`,輸出 `7`。00007FF79DB7197A nop
//空指令(對齊或占位)。
4. 返回?0
00007FF79DB7197B xor eax, eax
//將 `eax` 清零(`return 0;` 的常見優化寫法)。
3.3.6 函數棧幀的銷毀
當函數調用要結束返回的時候,前面創建的函數棧幀也開始銷毀。那具體是怎么銷毀的呢?我們看一下反匯編代碼。
00007FF773182288 lea rsp, [rbp+0C8h]
//將 rsp 直接設置為 rbp + 0C8h,相當于回收整個函數的棧空間(esp = ebp + 分配的大小)
//此時 rsp 指向調用者棧幀的棧頂(函數調用前的 rsp 值) 00007FF77318228F pop rdi
//從棧頂彈出一個值,存放到 rdi 中(恢復調用者的 rdi 寄存器),rsp + 8(x64 下指針占 8 字節) 00007FF773182290 pop rbp
//從棧頂彈出一個值,存放到 rbp 中,此時棧頂的值就是調用者的 rbp(恢復調用者的棧基址),rsp + 8 00007FF773182291 ret
//ret 指令的執行:
//1. 從棧頂彈出一個值(此時棧頂的值就是 call 指令下一條指令的地址),rsp + 8
//2. 跳轉到該地址,繼續執行調用者的代碼
這樣之后就會跳轉到main函數內繼續執行代碼
本章結束 以上就是函數棧幀創建和銷毀
以下是一些概念知識 需要的可自行閱讀
相關概念知識(輔助理解):
1.棧(Stack)
棧是一種后進先出(LIFO)的數據結構,在內存中從高地址向低地址增長。在函數調用時,棧用于:
- 存儲函數參數(由調用者壓棧)
- 保存返回地址(
call
指令自動壓入)- 保存調用者的
ebp
(被調函數保存)- 分配局部變量
- 存儲臨時數據(如運算中間結果)
2.?esp
?和?ebp
?的作用
寄存器 全稱 作用 esp
Extended Stack Pointer 始終指向棧的當前頂部(最低可用地址),隨 push
/pop
動態變化ebp
Extended Base Pointer 指向當前函數棧幀的基地址,用于定位局部變量和參數
esp
?的特點
- 動態變化,每次
push
、pop
、sub esp, N
(分配空間)或add esp, N
(釋放空間)都會改變。- 在函數調用時,
esp
會調整以容納新的棧幀。
ebp
?的特點
- 在函數執行期間固定,作為局部變量和參數的基準。
- 通過
[ebp + offset]
訪問參數,[ebp - offset]
訪問局部變量。
3. 寄存器
寄存器(Registers)是CPU內部的高速存儲單元,用于臨時存放數據、地址和控制信息。在函數調用和棧幀管理中,關鍵的寄存器包括?通用寄存器、段寄存器?和?控制寄存器
1.通用寄存器(General-Purpose Registers)
這些寄存器可用于計算、尋址和數據傳輸,主要分為:
寄存器 名稱 主要用途 eax
Accumulator 存放函數返回值、算術運算 ebx
Base 數據存儲(較少用于計算) ecx
Counter 循環計數(如 rep
指令)edx
Data 輔助 eax
(如乘法/除法的高位結果)esi
Source Index 字符串/數組操作的源指針 edi
Destination Index 字符串/數組操作的目標指針 esp
Stack Pointer 指向棧頂(動態變化) ebp
Base Pointer 指向當前棧幀基址(固定)
2. 段寄存器(Segment Registers)
用于內存分段(現代操作系統已較少使用):
寄存器 名稱 用途 cs
Code Segment 代碼段基址 ds
Data Segment 數據段基址 ss
Stack Segment 棧段基址( esp
/ebp
默認在此段)es
,?fs
,?gs
Extra Segments 附加數據段
3. 控制寄存器(Control Registers)
寄存器 名稱 用途 eip
Instruction Pointer 指向下一條要執行的指令(不可直接修改) eflags
Flags 存儲狀態標志(如零標志 ZF
、進位標志CF
)
4. 關鍵寄存器詳解:
(1)
esp
(Stack Pointer)
- 作用:始終指向棧的當前頂部(即最后入棧的數據地址)。
- 變化規則:
push
?時:esp
?減小(棧向低地址增長)。pop
?時:esp
?增大。- 函數調用時,
esp
?會動態調整以分配/釋放棧空間。(2)
ebp
(Base Pointer)
- 作用:指向當前函數棧幀的基地址,用于:
- 定位局部變量(
[ebp - offset]
)。- 訪問函數參數(
[ebp + offset]
)。- 特點:
- 在函數執行期間固定不變(除非手動修改)。
- 通過?
mov ebp, esp
?在函數開頭建立棧幀。
本博客借鑒于:函數棧幀的創建與銷毀(超詳解)-CSDN博客