函數(shù)調(diào)用中堆棧的個人理解
這是我的第一篇博客,由于公司項(xiàng)目需要,將暫時告別C語言一段時間。所以在此記錄一下自己之前學(xué)習(xí)C語言的一些心得體會,希望可以分享給大家,也可以記錄下自己學(xué)習(xí)過程中遇到的問題以及存在的疑惑(其實(shí)就是自己學(xué)習(xí)過程中不解的地方)。好了,廢話不多說,開始微博內(nèi)容了,O(∩_∩)O哈哈~
接下來將通過下面幾個問題解析函數(shù)調(diào)用中對堆棧理解:
- 函數(shù)調(diào)用過程中堆棧在內(nèi)存中存放的結(jié)構(gòu)如何?
- 匯編語言中call,ret,leave等具體操作時如何?
- linux中任務(wù)的堆棧,數(shù)據(jù)存放是如何?
1. 函數(shù)調(diào)用過程中堆棧在內(nèi)存中存放的結(jié)構(gòu)如何?
計(jì)算機(jī),嵌入式設(shè)備,智能設(shè)備等其實(shí)都是有軟件和硬件兩部分組成,具體實(shí)現(xiàn)也許復(fù)雜,但整體的結(jié)構(gòu)也就如此。軟件運(yùn)行在硬件上,告訴硬件該干什么。操作系統(tǒng)軟件是在啟動過程中經(jīng)過BIOS,bootloarder等(如果有這些過程的話)從磁盤加載到內(nèi)存中,而自定義軟件則是編寫存放到磁盤中,只有通過加載才會到內(nèi)存中運(yùn)行。
首先我們來看一下什么是堆、棧還有堆棧,我們經(jīng)常說堆棧其實(shí)它是等同于棧的概念。
可以通俗意義上這樣理解堆,堆是一段非常大的內(nèi)存空間,供不同的程序員從其中取出一段供自己使用,使用之后要由程序員自己釋放,如果不釋放的話,這部分存儲空間將不能被其他程序使用。堆的存儲空間是不連續(xù)的,因?yàn)闀驗(yàn)椴煌瑫r間,不同大小的堆空間的申請導(dǎo)致其不連續(xù)性。堆的生長是從低地址向高地址增長的。
對棧的理解是,棧是一段存儲空間,供系統(tǒng)或者操作系統(tǒng)使用,對程序員來說一般是不可見的,除非從一開始由程序員自己通過匯編等自己構(gòu)建棧,棧會由系統(tǒng)管理單元自己申請釋放。棧是從高地址向低地址生長的,既棧底在高地址,棧頂?shù)偷刂贰?/p>
其次我們看一下應(yīng)用程序的加載,應(yīng)用程序被加載進(jìn)內(nèi)存后,由操作系統(tǒng)為其分配堆棧,程序的入口函數(shù)會是main函數(shù)。不過main函數(shù)也不是第一個被調(diào)用的函數(shù),我們通過簡單的例子講解。
#include <stdio.h> #include <string.h> int function(int arg) { return arg; } int main(void) { int i = 10; int j; j = function(i); printf("%d\n",j); return 0; }
用gcc -S main.c 生成匯編文件main.s, 其中function的匯編代碼如下:
function: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movl %edi, -4(%rbp) movl -4(%rbp), %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc
看以看到當(dāng)函數(shù)被調(diào)用時,首先會把調(diào)用函數(shù)的棧底壓棧到自己函數(shù)的棧中(pushq %rbp),然后將原來函數(shù)棧頂rsp作為當(dāng)前函數(shù)的棧底(movq %rsp, %rbp)。函數(shù)運(yùn)行完成時,會將壓入棧中的rbp重新出棧到rbp中(popq %rbp)。當(dāng)前function匯編函數(shù)沒有顯示出棧頂?shù)淖兓╮sp的變化),我們可以通過main函數(shù)來看棧頂?shù)淖兓?,匯編代碼如下:
main: .LFB1: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $16, %rsp movl $10, -4(%rbp) movl -4(%rbp), %eax movl %eax, %edi call function movl %eax, -8(%rbp) movl -8(%rbp), %eax movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf movl $0, %eax leave .cfi_def_cfa 7, 8 ret .cfi_endproc
從上面的匯編代碼可以看到首先也是壓棧和設(shè)置新棧底的過程,從此可以看出main函數(shù)也是被調(diào)用的函數(shù),而不是第一個調(diào)用函數(shù)。代碼中的黃色部分是當(dāng)前棧頂變化,從使用的subq可以知道,棧頂?shù)牡刂芬∮跅5椎牡刂?,所以棧是從高地址向低地址生長。
接下來可能有點(diǎn)繞,慢慢讀,將用語言描述函數(shù)調(diào)用過程,調(diào)用函數(shù)會將被調(diào)用函數(shù)的實(shí)參從右往左的順序壓入調(diào)用函數(shù)的棧中,通過call指令調(diào)用被調(diào)用函數(shù),首先將return address(也就是call指令的后一條指令的地址)壓入調(diào)用函數(shù)棧中,這時rsp寄存器中存儲的地址是存放return address內(nèi)存地址的下一地址值,這時調(diào)用函數(shù)的棧結(jié)構(gòu)形成,然后就會進(jìn)入被調(diào)用函數(shù)的作用域中。被調(diào)用函數(shù)首先將調(diào)用函數(shù)的rbp壓入被調(diào)用函數(shù)棧中(其實(shí)這個地址就是rsp寄存器中存儲的地址),接下來將會將這個地址作為被調(diào)用函數(shù)的rbp地址,才會有movq %rsp, %rbp指令設(shè)置被調(diào)用函數(shù)的棧底。如上所描述的構(gòu)成了函數(shù)調(diào)用的堆棧結(jié)構(gòu)如下圖所示。
2. 匯編語言中call,ret,leave等具體操作時如何?
push:將數(shù)據(jù)壓入棧中,具體操作是rsp先減,然后將數(shù)據(jù)壓入sp所指的內(nèi)存地址中。rsp寄存器總是指向棧頂,但不是空單元。
pop:將數(shù)據(jù)從棧中彈出,然后rsp加操作,確保rsp寄存器指向棧頂,不是空單元。
call:將下一條指令的地址壓入當(dāng)前調(diào)用函數(shù)的棧中(將PC指令壓入棧中,因?yàn)樵趶膬?nèi)存中取出call指令時,PC指令已經(jīng)自動增加),然后改變PC指令的為call的function的地址,程序指針跳轉(zhuǎn)到新function。
ret:當(dāng)指令指到ret指令行時,說明一個函數(shù)已經(jīng)結(jié)束了,這時候rsp已經(jīng)從被調(diào)用函數(shù)的棧指到了調(diào)用函數(shù)構(gòu)建的返回地址位置。ret是將rsp所指棧頂?shù)刂分械膬?nèi)容賦值給PC,接下來將執(zhí)行call function的下一條指令。
leave:相當(dāng)于mov %esp, %ebp, pop ebp。頭一條指令其實(shí)是把ebp所指的被調(diào)用函數(shù)的棧底作為新的棧頂,pop指令時相當(dāng)于把被調(diào)用函數(shù)的棧底彈出,rsp指向返回地址。
int:通過其后加中斷號,實(shí)現(xiàn)軟件引發(fā)中斷,linux操作系統(tǒng)中系統(tǒng)調(diào)用多有此實(shí)現(xiàn),其他實(shí)時操作系統(tǒng)中在操作系統(tǒng)移植時,會有tick心臟函數(shù)也有此實(shí)現(xiàn)。
其他的匯編指令在此就不多講了,因?yàn)閰R編指令眾多,硬件cpu寄存器也因硬件不同而不同,此節(jié)就講了函數(shù)構(gòu)建進(jìn)入和離開函數(shù)時用到的幾個匯編指令,這幾條指令和棧變化有關(guān)。自己構(gòu)建匯編函數(shù),或者是在讀linux操作系統(tǒng)的系統(tǒng)調(diào)用時會對其理解有幫助。硬件寄存器中rsp,和rbp用于指示棧頂和棧底。
3. linux中任務(wù)的堆棧,數(shù)據(jù)存放是如何?
linux的任務(wù)堆棧分為兩種:內(nèi)核態(tài)堆棧和用戶態(tài)堆棧。接下來簡單介紹一下這兩個堆棧,如果以后有機(jī)會將詳細(xì)介紹這兩個堆棧。
1. 內(nèi)核態(tài)堆棧
linux操作系統(tǒng)分為內(nèi)核態(tài)和用戶態(tài)。用戶態(tài)代碼訪問代碼和數(shù)據(jù)收到諸多限制,用戶態(tài)主要是為程序員編寫程序使用,處于用戶態(tài)的代碼不可以隨便訪問linux內(nèi)核態(tài)的數(shù)據(jù),這主要就是設(shè)置用戶態(tài)的權(quán)限,安全考慮。但是用戶態(tài)可以通過系統(tǒng)調(diào)用接口,中斷,異常等訪問指定內(nèi)核態(tài)的內(nèi)容。內(nèi)核態(tài)主要是用于操作系統(tǒng)內(nèi)核運(yùn)行以及管理,可以無限制的訪問內(nèi)存地址和數(shù)據(jù),權(quán)限比較大。
linux操作系統(tǒng)的進(jìn)程是動態(tài)的,有生命周期,進(jìn)程的運(yùn)行和普通的程序運(yùn)行一樣,需要堆棧的幫助,如果在內(nèi)核存儲區(qū)域內(nèi)為其提前分配堆棧的話,既浪費(fèi)內(nèi)核內(nèi)存(任務(wù)地址大約3G的空間),也不能靈活的構(gòu)建任務(wù),所以linux操作系統(tǒng)在創(chuàng)建新的任務(wù)時,為其分配了8k的存儲區(qū)域用于存放進(jìn)程內(nèi)核態(tài)的堆棧和線程描述符。線程描述符位于分配的存儲區(qū)域的低地址區(qū)域,大小固定,而內(nèi)核態(tài)堆棧則從存儲區(qū)域的高地址開始向低地址延伸。如果之前版本為內(nèi)核態(tài)堆棧和線程描述符分配4k的存儲空間時,則需要為中斷和異常分配額外的棧供其使用,防止任務(wù)堆棧溢出。
2. 用戶態(tài)堆棧
對于32位的linux操作系統(tǒng),每個任務(wù)都會有4G的尋址空間,其中0-3G為用戶尋址空間,3G-4G為內(nèi)核尋址空間。每個任務(wù)的創(chuàng)建都會有0-3G的用戶尋址空間,但是3G-4G的內(nèi)核尋址空間是屬于所有任務(wù)共享的。這些地址都屬于線性地址,需要通過地址映射轉(zhuǎn)換成物理地址。為了實(shí)現(xiàn)每個任務(wù)在訪問0-3G的用戶空間時不至于混淆地址,每個任務(wù)的內(nèi)存管理單元都會有一個屬于自身的頁目錄pgd,在任務(wù)創(chuàng)建之初會創(chuàng)建新的pgd,任務(wù)會通過地址映射為0-3G空間映射物理地址。用戶態(tài)的堆棧就在這0-3G的用戶尋址空間中分配,和之前的main函數(shù)以及function函數(shù)構(gòu)建堆棧一樣,但是具體映射到哪個物理地址,還需要內(nèi)存管理單元去做映射操作。總之,linux任務(wù)用戶態(tài)的堆棧和普通應(yīng)用程序一樣,由操作系統(tǒng)分配和釋放,對程序員來說不可見,不過因?yàn)椴僮飨到y(tǒng)的原因,任務(wù)用戶程序?qū)ぶ酚邢拗?。如果有機(jī)會之后介紹一下linux內(nèi)存管理的個人理解。