版權聲明:本文為博主原創文章,未經博主允許不得轉載。歡迎聯系我qq2488890051 https://blog.csdn.net/kangkanglhb88008/article/details/89739105
先了解如下幾點知識和過程:
* 馮諾伊曼體系計算機程序指令代碼都是提前從硬盤加載進入內存從而執行的(如果是哈佛體系結構的計算機指令代碼是直接在外存里面執行的,具體可以看我這篇文章,計算機馮諾伊曼體系結構和哈佛體系結構區別和處理器性能評判標准),這些指令代碼是存放在內存中進程的代碼段,同一個函數內的指令代碼是按照地址順序存儲的(編譯器決定的)(也就是說只要指令地址+1就可以自動得到下一條指令的地址),那么當發生函數調用的時候,就是進入另一個函數的連續地址代碼段了,所以才有了調用函數時候得提前入棧保存此函數后的一條指令的地址。
* 棧的棧頂和棧底定義不是按照地址高低定義的,是按照入棧出棧的位置定義的,入棧出棧的地方就叫做棧頂(盡管在Windows操作系統中棧的增長是從高地址到低地址),棧是后入先出的,被調用函數后進棧,那么函數返回的時候,也是它先被回收,即一層層回退的回收空間
* stack是棧,但是也經常被人們稱為堆棧,heap是堆,不要亂取名。關於內存中堆棧如何分配空間以及區別是什么,可以看我這篇文章,計算機程序存儲分配詳解和c語言函數調用過程概述
* 整個程序維護一個棧(如果是跑操作系統的話,可能會有多個進程,那么就會有多個獨立的棧,而比如單片機裸機程序就只有一個棧),這個棧在動態改變,變量的分配和釋放都是棧頂指針在動態的移動罷了,如果需要釋放的棧內變量是需要保存起來繼續使用的,那么就用pop方式,出棧同時會進行保存在某個cpu寄存器里,比如EAX,這個寄存器可以用來臨時保存變量值或者最后的函數返回值
* cpu有多個寄存器,主要是用於當前活動函數(一個函數在正運行的時候,我這里稱他為活動函數)的一些暫存值,而且可能會動態改變寄存器里面內容,跟當前活動函數進行交互啥的,但是我們比較關心的就四個,EAX,ESP,EBP,EIP,其解釋如下
* 活動函數會把局部變量和一些寄存器里面的值入棧(而不是寄存器的地址入棧,因為寄存器的地址就是通過宏定義為ebx等等名字了,也就是寄存器地址已經公之於眾的了,關於這個具體可以看我這篇文章詳細講解,嵌入式微處理器結構和上電啟動到開始運行程序的過程講解),因為整個cpu只有這么一組寄存器,但是函數調用卻可以有很多層,所以當前函數調用了下一個函數后,所以活動函數就變成下一個函數了,此時這組寄存器的值就先入棧也就是保存起來,然后就得用於支持新的活動函數運行了,當新的活動函數結束后,就會把剛剛入棧保存的值重新賦值給這組寄存器,因此恢復調用前的執行狀態。
* 程序只有一個棧,但是卻可以有函數的層級調用,而每個函數都會在這個總棧里有一個局部棧,也叫做棧幀
比如當前處於主函數main中,棧內如下:
這里面的局部變量就是main函數內定義的,而ebx,esi,edi具體是干啥的,為什么要入棧(肯定是一些記錄信息),不用明白,只要知道每個活動函數都會把這三個寄存器里的值入棧就行了。EBP寄存器的值存儲的是當前活動函數棧幀的棧底地址,而ESP存的就是當前棧幀的棧頂地址了。cpu下一條執行的指令地址是直接去EIP寄存器里面讀取的,EIP這個寄存器每次就是用來存放 即將執行的指令的地址的,那么每次執行之前就得把下一指令的地址我們手動填進去,也就是后面會看到的函數調用完成后從棧內pop出那個提前入棧的地址。(pop那個下一指令的地址的話,這個匯編指令應該是同時還把此地址給填進EIP寄存器了)
// main函數里面具體執行的指令過程的匯編代碼,第一列代表的是指令的地址,我自己去掉了無關指令代碼
011C1540 push ebp //壓棧,保存ebp(這個是調用main函數那個函數的棧幀的棧底地址,我也不知道到底是誰調用了main函數,應該是操作系統了吧),注意push操作隱含esp-4
011C1541 mov ebp,esp //把esp的值傳遞給ebp,設置當前ebp
011C1543 sub esp,0F0h //給函數開辟空間,范圍是(ebp, ebp-0xF0)
011C1549 push ebx
011C154A push esi
011C154B push edi
011C154C lea edi,[ebp-0F0h] //把edi賦值為ebp-0xF0 接下來這幾條指令可以不用看
011C1552 mov ecx,3Ch //函數空間的dword數目,0xF0>>2 = 0x3C
011C1557 mov eax,0CCCCCCCCh
011C155C rep stos dword ptr es:[edi]
//rep指令的目的是重復其上面的指令.ECX的值是重復的次數.
//STOS指令的作用是將eax中的值拷貝到ES:EDI指向的地址,然后EDI+4
// 這里就是開始調用 print_out(0, 2)了
013D155E push 2 //第二個實參壓棧
013D1560 push 0 //第一個實參壓棧
013D1562 call print_out (13D10FAh)//返回地址壓棧,本例中是013D1567,然后調用print_out函數
013D1567 add esp,8 //兩個實參出棧
//注意在call命令中,隱含操作是把下一條指令的地址壓棧,也就是所謂的返回地址
// 被調用函數執行到了return語句時候,即准備結束此函數了,做的返回過程
013D141C mov eax,1 //返回值傳入eax中
013D1421 pop edi
013D1422 pop esi
013D1423 pop ebx //寄存器出棧
013D1424 add esp,0D0h //以下3條命令是調用VS的__RTC_CheckEsp,檢查棧溢出
013D142A cmp ebp,esp
013D142C call @ILT+315(__RTC_CheckEsp) (13D1140h)
013D1431 mov esp,ebp //ebp的值傳給esp,也就是恢復調用前esp的值
013D1433 pop ebp //彈出ebp,恢復ebp的值
013D1434 ret //把返回地址寫入EIP中,相當於pop EIP
現在在main函數里調用了另一個函數print_out函數,其棧變化如下:
我們可以看出函數的層級調用實際上就是重復的入棧不同活動函數的內容(相同的方式),如果print_out函數再調用一個函數,也是同樣的再加一個棧幀罷了。
現在我們來分析這個入棧的過程和順序:
main函數也是被其他某函數調用的,這里我們就不追究了,因為棧是往低地址增長的,我們可以看出main函數執行過程(即還現在是當前活動函數)是先把main內定義的局部變量入棧了,緊接着是那3個寄存器的內容,此時繼續往下執行,發現遇到函數prin_out調用了,這時首先會在棧內開辟兩個4字節的空間(因為只發現兩個int型的形參),也就是C語言中的聲明了兩個變量,同時把這兩個空間中分別填入0和2,即完成了函數形參的聲明和初始化(因為現在還處在main函數棧幀內,所以我們可以看出被調用函數的形參的聲明和賦值都是在調用函數中完成的,而不是被調函數自己分配的空間),也就是上面看到的實參1,2在棧中存在了,接下來即將進入print_out函數之前,main函數還得把print_out函數的下一條指令地址(也就是上圖的返回地址)給入棧保存起來(這個過程是call print_out匯編指令就會自動完成的,其實這個下一條指令的地址就是回收剛剛分配的那兩個實參占用空間的操作,即add esp,8這句指令的地址,不急,我后面會詳細分析為什么是這條),因為print_out函數運行完后,此時main函數才知道應該繼續怎樣運行。(疑問點:這個print_out函數的下一條指令的地址不能是print_out函數執行快執行完時自己告訴main函數嗎,當然不行,因為print_out函數自己根本不知道自己下一條指令是誰,自己都可能被不同函數調用呢,對外層函數(調用者)一點也不知情)。當把返回地址也壓入棧后,就可以進入print_out函數了。
進入print_out函數后,還是同曾經進入main函數一樣的方式,首先入棧調用者(main函數)的棧幀的棧底地址
main函數棧幀的棧底地址,也就是圖中紅色箭頭指向內存單元的地址,在棧中就是ebp(main)這個值(目的是為了print_out函數調用完成后,main函數又成為了活動函數,main的棧幀也就成了當前棧幀,填入EBP寄存器這個地址值,使得EBP能夠迅速指到正確位置,即紅色箭頭處,此時ESP當然就得指向edi那兒的位置也就是main函數棧幀的棧頂位置了,這樣一看,就是還原回未調用print_out函數時候的棧的模樣了,就是上面右圖,perfect,如此的完美),這時就可以進入print_out函數內部了。
然后為當前活動函數(print_out函數)分配局部變量需要的總空間(這里分配8個不一定准確,因為ebx,esi,edi三個寄存器內的值也要接着入棧,准確應該是20個字節,但是這里為了簡便,就沒有這么嚴謹,但是原理是對的),接着入棧局部變量,ebx,esi,edi三個寄存器內的值,然后進行相應的運算過程后,一旦遇到了return語句,此時print_out函數才知道自己即將執行結束了,所以就開始做本函數棧幀的回收工作了,僅僅把返回值給保存到EXA寄存器即可(存在返回值的情況,如果是無返回值,函數是void類型,那就不需要保存返回值到EXA寄存器了),由於局部變量以及ebx,esi,edi三個寄存器內的值都是無意義的值了,直接丟掉即可,即把esp寄存器的內容直接賦值為ebp寄存器里的地址值,即esp和ebp指向同一個內存單元,此時的棧頂就變成ebp(main)這兒了,就實現了棧內存的回收,如下圖所示,具體對應的匯編代碼就是 mov esp,ebp,
此時再把ebp寄存器填入提前壓入棧內的main函數棧幀的棧底地址,也就是ebp(main)出棧,同時賦值進ebp寄存器里
即:pop ebp //彈出ebp,會同時進行把這個地址值賦值進入ebp寄存器,即恢復ebp的值,即ebp指向了main函數棧幀的棧底了,如下圖
此時已經回收完print_out函數的棧幀了,此時,已經來到了main函數棧幀了,但是並沒有來到main函數的指令代碼段
然后print_out函數里面就來到ret指令了,即把返回地址(存在main棧幀里的)寫入EIP中,相當於pop EIP,如下圖
此時,print_out函數完全執行完了,就回到了main函數指令段了,很明顯接下來的一條指令就是繼續回收一下main函數內當初為print_out函數形參分配的兩個變量空間(當初main函數為調用函數分配形參的過程也是屬於main函數的指令),即下面這句指令
add esp,8 //兩個實參出棧,即回收兩個實參的空間,如下圖
那也就是說在main函數內調用print_out函數這個指令之后的指令就是add esp,8這句指令(編譯器就可以知道這兩個指令的前后關系,所以這個並不是動態的),所以當初即將調用print_out函數時入棧的那個返回地址就是add esp,8這句指令的地址,也就是回收實參的空間這句指令的地址,那再下一條指令的地址呢,因為最開始我們就說了同一個函數的指令代碼都是在連續的地址空間存放的,所以只需要把add esp,8這句指令的地址+1即可得到下一條待執行指令的地址了。
就這樣,整個print_out函數調用完成了,同時main函數棧幀也恢復原來未調用print_out函數時候的模樣了,如上圖所示,perfect,完成。
接下來我們再來看一個例子,有了上面的分析基礎,那么下面這個也就很容易同理分析出來了,里面的匯編指令代碼清晰明了,整個過程清清楚楚了
/-------------------------------------------------------------------------------------------------------------------/
現在,我們來總結一下函數調用時候棧的變化過程:
1.調用者在自己的棧幀里開辟好被調函數形參需要的空間
2.入棧 函數調用結束后應該執行的地址值,即返回地址,其實就是回收第一步為形參開辟的空間的指令的地址
3.進入被調函數了,入棧調用函數棧幀的棧底地址
4.在新函數的當前棧幀內為局部變量分配空間后,入棧局部變量
5.被調函數遇到return語句了,說明即將結束本函數了,就開始做回收本棧幀的空間的事了:
1)如果有返回值,那么把返回值賦值給EAX,如果沒有則忽略這一步。
2)回收局部變量空間,即esp指向調用函數棧幀的棧頂了
3)提前存好的main函數棧幀的棧底地址賦值進入ebp寄存器,從而使得ebp指向main函數棧幀的棧底
4)把返回地址填入EIP寄存器,接着就會指向,回收main函數當初為被調函數開辟的兩個形參的空間的指令地址
5)回收形參空間
這樣就還原了main函數棧幀,回到了未調用那個函數的時候棧幀的模樣。
從上面可以得出一些結論,一個函數實際上是一種動態的概念,它的存在體現在內存中而已,也就是它對應的棧幀,當它的棧幀被回收了,那么這個函數就結束了。
最后再來討論一下這樣一個問題:剛剛我們看到被調用函數是通過cpu的eax,edx兩個寄存器傳遞返回值,然后調用函數只需要去讀取這兩個寄存器的值就得到了被調函數的返回值,但是eax,edx這兩個寄存器都是32位的,也就是總共能夠返回8字節的數據,對於基本類型的數據(比如char,int,float,double(占8字節),指針類型)的返回沒問題,但是假如我們想返回一個結構體類型的數據且成員總大小超過了8字節(常見方法是傳遞結構體指針。但作為語言上允許的方式,有必要弄清楚編譯器如何實現這種方式),其原理是怎樣的呢?
答:我們的編譯器編譯同樣一個程序一般支持生成兩個版本的目標代碼,debug版本和release版本,debug版本編譯結果一般是針對調試程序使用的,代碼優化較低,更好的還原了開發者寫的C語言源程序結構。release版本是指發行版,即軟件上架發布使用,編譯器對代碼優化程度較高,對無用代碼和不可達狀態等等都進行了刪除(有興趣具體了解代碼優化的可以查閱編譯原理一書),不便於調試,但是運行效率更高。其實這兩者的原理都是基本一樣的,這里我們對debug版本和release版本分別進行簡單講解。
第一種情況,不超過8字節的結構體返回過程:如下圖所示:
總結:
(1.1)用 edx:eax 傳遞返回值。調用方不需要在棧上向 add 函數傳遞接受返回值的地址。也就是跟基本數據類型變量返回是一樣的過程。
(2.2)debug 版本在調用方生成臨時對象返回值(而release版本就不是這樣,上圖中紅色方框內的內存空間就不會存在,而是寄存器的值直接拷貝到main函數的t變量里面,所以release版本效率更高),然后再把臨時對象拷貝到 main 指定變量t所在地址。效率低。我們可以看到臨時對象是在main函數的棧幀里的,也就是說main函數在調用add函數前分析了一下它的返回值類型大小,就分配了空間,當調用完成后就會把臨時對象(返回值內容)的值復制到左邊的指定賦值的變量t,此時臨時對象就完成了它的使命,main函數就回收臨時對象的空間了。
第二種情況,超過8字節的結構體返回過程:如下圖所示:
總結:
(1)當結構體超過 8 bytes,不能用 EDX:EAX 傳遞,這時調用者在自己棧幀棧上保留有一個用於填充返回值的結構體,其地址在入棧實參后 push 到棧上,如上圖藍色箭頭處。被調用函數add將會根據這個地址,把返回值設置到這個地址,紅色箭頭處。
(2)在 main 函數中,debug 版本比 release 版本還多了一個臨時對象,效率低。而 release 版本中只有返回值和臨時變量 t(圖中紅色方框的臨時對象不存在),效率略高於 debug。但兩者模型基本一致,還是得從返回值那兒的空間的內容復制到左邊指定的賦值變量t的空間(指的都是main函數內的t),然后回收返回值所對應的空間,總體效率還是低於傳結構體指針(因為指針只占用4個字節,直接通過eax寄存器即可返回,然后賦值給指針t即可),所以建議在C語言中返回結構體類型數據時候盡量用指針返回,代碼運行效率更高。
(3)對於上述兩個實驗,release 版本優化都比較厲害,main 函數中對 t 的賦值是不完整的,因為編譯器認為有些成員沒有使用到(比如t.b,t.c兩成員的賦值,即無用代碼),所以沒有必要復制,只要滿足代碼等效即可(具體知識可參考編譯原理一書代碼優化章節)。
這里就不貼出上述兩個實驗對應的匯編代碼了,編譯器優化功能不是萬能的,我們知道底層這樣的過程后,以后寫代碼時候才能心中有數,寫出更高質量更高效率的代碼。
歡迎關注我的博客,我有空會寫一些關於計算機基礎理論方面通俗易懂的科普性文章,一方面用於記錄自己的學習過程,另一方面可以分享給其他人,讓更多的人了解如今生活中無處不在的計算機的工作原理。
參考文章:
函數調用--函數棧 https://www.cnblogs.com/rain-lei/p/3622057.html
程序編譯后運行時的內存分配 https://www.cnblogs.com/guochaoxxl/p/6977712.html
堆和棧的區別 https://www.cnblogs.com/yechanglv/p/6941993.html
關於返回結構體的函數 https://www.cnblogs.com/hoodlum1980/archive/2012/07/18/2598185.html
---------------------
作者:biao2488890051
來源:CSDN
原文:https://blog.csdn.net/kangkanglhb88008/article/details/89739105
版權聲明:本文為博主原創文章,轉載請附上博文鏈接!