? ? ? ?我們每次在調用函數的時候,都說會進行傳參。每次創建函數,或者進行遞歸的時候,也會說會進行壓棧。
? ? ? ?那么,今天我們就來具體看看函數到底是如何進行壓棧,傳參的操作。
什么是棧?
? ? ? ?首先我們要知道,我們將內存一般劃分為三個區域:
- 靜態區
- 堆區
- 棧區
? ? ? ?我們平時創建的臨時變量,函數都會在棧區中占據空間:
? ? ? ?此時我們也要知道棧區的使用規則:從高地址向低地址使用
棧的使用規則:
? ? ? ?我們知道搶的彈夾,我們要逐個把子彈往里面壓,之后如果取出子彈,就需要將上一次壓入的子彈取出,之后逐個取出子彈,并只能按照順序取出。
? ? ? ?棧就是這樣的使用規則,遵循先進后出,后進先出。? ? ? ? 此時你會想,不能把任意的數據取出,必須一個一個拿,這種結構真的好用嗎?
? ? ? ? 起初我也這樣認為,但是計算機就喜歡用這種結構。
? ? ? ? 在內存中,棧區的使用規則是從高地址向低地址使用的。
函數的棧幀:
? ? ? ?C語言中,我們要想觀察函數棧幀就需要用到調試。當我們調試時所在的函數(此時函數未運行完),每個棧幀對應著一個未運行完的函數。棧幀中保存了該函數的返回地址和局部變量。
? ? ? ?因為我們知道,只要運行函數就會進行壓棧操作,所以分析出一下信息:
- 棧幀是一塊因函數運行而創建的的臨時空間。
- 每調用一次函數都會創建一個獨立的函數棧幀。
- 棧幀中存放著函數重要信息,如局部變量,函數返回地址,函數參數等。
- 當函數運行完畢后棧幀會銷毀。
? ? ? ?既然會創建函數棧幀,那么就會維護其空間,計算機使用寄存器維護空間。
什么是寄存器?
? ? ? ?這里牽扯很多內容,我們只給出籠統解釋:寄存器是集成到CPU上的,是獨立的,寄存器可以暫存指令,地址和數據。所以寄存器也可以理解為指針。
? ? ? ?我們會使用很多寄存器,要理解清楚函數棧幀,就必須理解ebp和esp,這兩個寄存器中存放的是地址,這兩個地址是用來維護函數棧幀的。我們詳細來講esp和ebp兩個寄存器。其余寄存器混個臉熟即可,一會會用到。
esp寄存器:
? ? ? ?維護棧頂,始終指向棧頂(此時esp寄存器存儲棧頂地址)。
ebp寄存器:
? ? ? ?維護當前函數棧幀的棧低(此時ebp寄存器存儲函數棧幀棧低地址)。
幾個必要的匯編指令:
? ? ? ?我們觀察函數棧幀的創建和銷毀,就要知道幾個匯編指令,這樣可以更好的閱讀以下內容。
? ? ? ?記不住沒關系,我們一下會一一講解。?
圖解:?
? ? ? ? 這里我們使用VS2013來觀察,由于VS2022太過高級,有些內部細節就會看不到,所以用VS2013來觀察。此時我們執行以下代碼:
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;
}
? ? ? ?esp和ebp就是維護當前調用的函數。?
? ? ? ?點擊F10開始調試。? ? ? ?接下來我們看main函數被誰調用了,我們進入main函數,并直接執行完。
? ? ? ?在VS2013中,main函數也是被其他函數調用的。沒想到main函數也是被調用的函數,也理解了為什么每次都要有返回值。?
? ? ? ?mainCRTStartup函數調用 __tmainCRTStartup函數,__tmainCRTStartup函數調用main函數。
? ? ? ?之后我們按住F10,之后右擊鼠標找到“轉到反編匯”,就可以找到C語言所對應的匯編代碼,箭頭的指向就可以一行一行的執行,我們來逐過程分析:
? ? ? ?
? ? ? ? push ebp,將ebp壓入棧區。因為esp維護棧頂,所以esp指向改變。我們可以觀察其存放地址的改變。
? ? ? ?之后執行move,move是把后面的值賦到前面去。
? ? ? ?此時ebp和esp指向的位置相同。
? ? ? ? 之后,就要創建函數的棧幀了,執行sub,就是將其寄存器存放地址減去一個地址,因為棧區是高地址到底地址,所以該地址向上。
?
? ? ? ?此時esp維護main函數的棧頂,ebp維護main函數的棧低。我們可以看內存:
? ? ? ?這些內存都是為main函數開辟的空間。
? ? ? ?之后有執行了3次push,push時會有一個動作,就是棧頂指針esp會變,一直指向棧頂。
? ? ? ?我們通過內存窗口來觀察:? ? ? ? 因為我們說過,寄存器既可以存地址,也可以存數據,此時ebx存的是數據(就是內存里面的內容),而esp存放的是ebx的地址。壓入ebx以后,繼續將esi、edi壓入。
? ? ? ?之后執行lea : load effective address 加載有效地址。? ? ? ? 此時會發現,正好是加載的空間正好是最開始esp減去的空間,就是main函數棧幀的低地址。
? ? ? ?之后執行的命令,我們就需要先講解一下了。
? ? ? ?我們應該聽過字節的概念,1字節等于8比特位,那么字和字節又什么關系?一個字等于兩個字節。
? ? ? ?比特記為bit,字節記為Byte,字記為word,所以有如下關系:
- 1Byte=8bits
- 1word=2Bytes=16bits?
- dword:一個word是兩個字節,d代表double,就是雙字,就是4個字節。
? ? ? ?此時我們要看其以下的三個步驟:
? ? ? ? 此時edi里面存放main函數棧幀的低地址。
? ? ? ??這樣就可以理解為什么每次打印未初始化的空間,打印出來的字符都是一個漢字“燙燙燙燙”了。
? ? ? ?此時才會開始執行有效的代碼。在此之前都是為main函數開辟的空間。
? ? ? ?此時就要調用Add函數了,一樣的,我們要改變ebp和esp的指向,因為進入Add函數就需要維護Add函數棧幀了,但是還是要做以下準備,就是傳參,我們來看形式參數的創建。
? ? ? ?這兩個動作相當于傳參,之后執行call,就是調用函數,要記住call的地址,此時點擊F11才能進入Add函數。
? ? ? ?我們可以發現就在ecx的下一個地址里面存儲了call指令下一個執行的地址。為什么要記錄地址?我們先埋個伏筆,此時我們會先進入Add函數,流程如下:
? ? ? ?注意此時main函數的函數棧幀已經增長到call指令的下一個地址了。? ? ? ? 此時我們來觀察Add函數的細節:將esp減去一個地址改變指向:
? ? ? ?之后還是main函數棧幀的那一套操作,壓入3個寄存器并初始化空間,并將z初始化為0:?
? ? ? ?此時先將 ebp + 8 的值賦給 eax ,此時 eax = 10;之后又執行add,將 ebp + 12 的值等于30,最后將eax的值賦給 ebp - 8 ,此時 ebp - 8 地址的值是30.? ? ? ? 我們可以發現,我們使用Add函數并沒有創建形參,在我們傳參時其實已經壓棧過了,而且參數是從右向左傳參的。
? ? ? ?返回的話z會被銷毀,我們來觀察其如何返回。
? ? ? ?我們將結果放入eax寄存器當中,此時就不用擔心函數銷毀。
? ? ? ?此時將上面的3個寄存器彈出棧頂。
? ? ? ?之后mov esp的位置,esp的指向改變:? ? ? ??此時彈出ebp,ebp彈出以后會指向main函數的棧低,因為之前記錄著mian函數的棧低。
?
? ? ? ??當前棧頂元素為call指令的下一個指令的地址,ret這條指令就是找到之前call指令記錄的地址,并pop一次棧頂元素。
? ? ? ??此時執行add ? esp,8 因為沒有dword 所以是改變指向。
? ? ? ?此時將形參x,y的空間還給操作系統。此時又執行mov,將eax存放的值賦給 ebp - 20h 就是給c賦值。?
? ? ? ?此時main函數執行完,也是以上步驟,我們不再贅述。
總結:?
? ? ? ?我們通過觀察函數棧幀的創建和銷毀,最后返回值是由寄存器帶回來的;也可以理解為什么局部變量的值是隨機的,形參和實參的關系,確實是一份臨時拷貝。希望大家下去多加練習,逐漸就會頓悟其中的原理。
? ? ? ?爆肝一整天,點點贊吧,嗚嗚~