當前位置:網站首頁>函數棧幀的創建與銷毀

函數棧幀的創建與銷毀

2022-01-28 11:12:26 不會三刀流的索隆

函數棧幀是什麼?

C/C++中,每個棧幀對應著一個未運行完的函數。棧幀中保存了該函數的返回地址和局部變量。(來自百度百科)

可以將函數棧幀理解為函數運行在棧區所開辟的空間,既然在棧區開辟空間,就遵循棧區先使用高地址的空間,再使用低地址的空間的原則。下面我們就帶著這六個問題討論一下函數是怎樣創建棧幀又是怎樣銷毀的、

1. 局部變量怎麼創建的?
2. 局部變量不初始化為什麼是隨機值?
3. 函數傳參的順序,怎樣傳參的?
4. 形參和實參的關系?
5. 函數調用怎麼做到的?
6. 函數調用結束後怎麼返回的?

函數棧幀的創建與銷毀

環境:VS2019

以下面這段代碼為例

int Add(int a, int b)
{
    
	int z = 0;
	z = a + b;
	return z;
}

int main()
{
    
	int a = 10;
	int b = 20;
	int c = 0;
	c = Add(a, b);
	return 0;
}

眾所周知,main函數是程序的入口,但在底層中,main函數也是經其他函數調用,經過調試可以看出是一個名為__tmainCRTStartup()函數調用的,其實這個函數也是被其他函數調用的,整個調用的過程是比較複雜的。
在這裏插入圖片描述
之所以要引入這個,是因為在程序開始之前就已經有一個函數棧幀了,這個函數就是__tmainCRTStartup()函數

在這裏插入圖片描述

其中的esp,ebp是計算機CPU中的兩個寄存器,這兩個寄存器中通常存放的是地址,用來維護函數棧幀,其中ebp是棧底地址,esp是棧頂地址,他們之間的區域就稱為函數的棧幀(CPU中還有很多的寄存器,像eax,ebx,epx…)

注意:ebp指向當前比特於系統棧最上邊一個棧幀的底部,而不是系統棧的底部。嚴格說來,“棧幀底部”和“棧底”是不同的概念;ESP所指的棧幀頂部和系統棧的頂部是同一個比特置。

將程序正式運行調試,跳轉到匯編代碼中

調用main函數

00DF17C0  push        ebp  
00DF17C1  mov         ebp,esp  
00DF17C3  sub         esp,0E4h  
00DF17C9  push        ebx  
00DF17CA  push        esi  
00DF17CB  push        edi  
00DF17CC  lea         edi,[ebp-0E4h]  
00DF17D2  mov         ecx,39h  
00DF17D7  mov         eax,0CCCCCCCCh  
00DF17DC  rep stos    dword ptr es:[edi]

這裏涉及到了匯編語言的一些語句,可以參考下面這條鏈接
匯編語言入門教程

  1. push指令,將ebp中存放的值(也就是__tmainStartup函數棧幀底部的地址)壓棧,這個操作的目的是為了調用完函數之後,可以找到原函數的棧幀底部,根據push指令,esp + 4,指向棧頂
    在這裏插入圖片描述

  2. mov指令,將esp的值賦給ebp,這個時候,esp和ebp同時指向棧頂

  3. sub指令,將esp的值减去了0E4h的大小,進而改變了其指向的比特置,這時,esp與ebp之間的區域就是為main函數開辟的空間
    在這裏插入圖片描述
    在這裏插入圖片描述
    同樣根據調試信息,確實能够發現ebp與esp差了0XE4的大小

  4. 然後又有三條push指令,將ebx,esi,edi的值壓棧

  5. 再接下來的三條指令的目的就是將[ebp-0E4h] 指向的比特置(注意,這裏指向的比特置是main函數棧幀的頂部)到ebp指向的比特置全部賦值為0CCCCCCCCh

棧幀結構圖
在這裏插入圖片描述
調試結果圖(其實還有很多比特置被修改了值,這裏沒有截全)
在這裏插入圖片描述

到這裏main函數的棧幀就創建完畢並進行了初始化,也就是說可以在這個空間中創建局部變量了

	int a = 10;
00B84438  mov         dword ptr [ebp-8],0Ah  
	int b = 20;
00B8443F  mov         dword ptr [ebp-14h],14h  
	int c = 0;
00B84446  mov         dword ptr [ebp-20h],0  

在匯編語言中,word錶示兩個字節,dword (double word),雙字,也就是四個字節,所以下面這句指令的意思就是將[ebp-8]指向的四個字節內容賦值為0Ah,也就是賦值為10,可以將其理解為C/C++中的解引用操作,到這裏,main函數的整型局部變量a就創建完成了

int a = 10;
00B84438  mov         dword ptr [ebp-8],0Ah  

後面兩個指令與上述操作一樣,只是拿取的地址不一樣

棧幀結構圖
在這裏插入圖片描述

調試結果圖
在這裏插入圖片描述
所以,如果創建了一個變量並沒有對其進行初始化,變量會存儲一個隨機值,這個隨機值就是編譯器初始化棧幀時的值,也就是0xcccccccc(不同的編譯器初始化的值可能不一樣)

到這裏,定義變量的三個語句結束,程序開始調用我們一開始所創建的Add函數,第一步傳參,下面的指令就是傳參的操作

0043444D  mov         eax,dword ptr [ebp-14h]  
00434450  push        eax  
00434451  mov         ecx,dword ptr [ebp-8]  
00434454  push        ecx  

首先通過mov指令,將[ebp-14h]指向的四個字節的內容賦給寄存器eax,然後將eax的值壓棧;後面的兩個操作與上述一致

其中,[ebp-14h]指向的內容就是變量b,而[ebp-8]指向的內容就是變量a,由此可以得出函數傳參的順序是按照參數列錶從右往左傳值的,並且函數傳參就是簡單的一個值的拷貝

棧幀結構圖
在這裏插入圖片描述

調試結果圖
在這裏插入圖片描述

緊接著進行call指令,call指令就是將下一個語句的地址壓棧,在後續的ret指令會根據這個地址回到原函數,回到原函數之後會將這個地址出棧。這個指令的目的是為了後調用函數之後,能够回到原函數。(可以注意一下call指令的下一條語句的地址)

00434455  call        004313C0  
0043445A  add         esp,8  

棧幀結構圖
在這裏插入圖片描述

調試結果圖(與call指令的下一句地址一致)
在這裏插入圖片描述

調用Add函數

首先是為Add函數開辟棧幀

004325A0  push        ebp  
004325A1  mov         ebp,esp  
004325A3  sub         esp,0CCh  
004325A9  push        ebx  
004325AA  push        esi  
004325AB  push        edi  
004325AC  lea         edi,[ebp-0CCh]  
004325B2  mov         ecx,33h  
004325B7  mov         eax,0CCCCCCCCh
004325BC  rep stos    dword ptr es:[edi] 

與之前調用main函數時的所進行的操作一致,先是將ebp的值壓棧,以便於調用完Add函數之後找到main函數的棧幀底部,隨後也是為Add函數開辟棧幀,並將棧幀中空間的值全部初始化為0CCCCCCCCh

棧幀結構圖
在這裏插入圖片描述

調試結果圖

同樣是沒有截全
在這裏插入圖片描述

int z = 0;
004325C8  mov         dword ptr [ebp-8],0  

創建一個整型變量z,並將其初始化為0

棧幀結構圖
在這裏插入圖片描述

調試結果圖
在這裏插入圖片描述

	z = a + b;
004325CF  mov         eax,dword ptr [ebp+8]  
004325D2  add         eax,dword ptr [ebp+0Ch]  
004325D5  mov         dword ptr [ebp-8],eax  
	return z;
004325D8  mov         eax,dword ptr [ebp-8]  

然後進行相加操作,分別取到之前傳參的值,dword ptr [ebp+8]得到的是a的值,dword ptr [ebp+0Ch]得到的就是b的值,然後通過add指令求和並將和放入eax中,最後將eax的值賦給之前創建的變量z,最後將z的值進行返回操作,將z的值賦值給eax,完成函數返回值返回的操作
到這裏,又更加清晰的認識到了函數返回值的返回與參數傳遞一樣,都是值的拷貝

Add函數棧幀的銷毀

004325DB  pop         edi  
004325DC  pop         esi  
004325DD  pop         ebx  
004325DE  add         esp,0CCh  
004325EB  mov         esp,ebp  
004325ED  pop         ebp  
004325EE  ret

首先將進行三次pop指令,pop指令就是出棧,將比特於棧頂的值消除,並將這個值放入子運算比特置中,然後esp - 4,如:pop edi語句就是將棧頂的消除,並將這個值放入寄存器edi中,esp - 4
在這裏插入圖片描述

將esp的值加上0CCh,並將ebp的值賦值給esp,使得ebp和esp的指向同一個比特置
在這裏插入圖片描述

進行pop操作,這時比特於棧頂的值,也就是之前main函數的棧幀底部的地址,將其出棧,並將這個值賦值給ebp,此時ebp指向的是main函數的棧幀底部
在這裏插入圖片描述
到這裏就完成了Add函數棧幀的銷毀

但還沒有結束,接下來要調用ret指令,返回到之前main函數的下一步操作,然後將call指令存儲的地址出棧
在這裏插入圖片描述
這時,經過調試回到了main函數當中,

0043445A  add         esp,8  
0043445D  mov         dword ptr [ebp-20h],eax  
	return 0;
00434460  xor         eax,eax  
}
00434462  pop         edi  
00434463  pop         esi  
00434464  pop         ebx  
00434465  add         esp,0E4h  
0043446B  cmp         ebp,esp  
0043446D  call        00431235  
00434472  mov         esp,ebp  
00434474  pop         ebp  
00434475  ret  

首先將esp + 8,這裏是將形參a,b返還給操作系統
在這裏插入圖片描述

然後將之前存放Add函數返回值的eax寄存器賦值給之前在main函數中創建的變量c中,此時[ebp-20h]指向這塊變量的內容,到這裏Add函數的返回值成功被main函數的變量接收
在這裏插入圖片描述

最後再進行main函數棧幀的銷毀,與上述Add函數棧幀銷毀的方式一致

一些想說的話

寫到這裏,對於函數棧幀的創建與銷毀的整個過程基本就概述完了,我非常慶幸自己生活在可以寫高級語言的時代,同樣也對之前從匯編語言到高級語言變遷付出努力的工程師們錶示尊敬,repect!

版權聲明
本文為[不會三刀流的索隆]所創,轉載請帶上原文鏈接,感謝
https://cht.chowdera.com/2022/01/202201281112258964.html