接下來將通過下面幾個問題解析函數調用中對堆棧理解:

(1)函數調用過程中堆棧在內存中存放的結構如何?

(2)彙編語言中call,ret,leave等具體操作時如何?

(3)linux中任務的堆棧,數據存放是如何?

1. 函數調用過程中堆棧在內存中存放的結構如何?

計算機,嵌入式設備,智能設備等其實都是有軟體和硬體兩部分組成,具體實現也許複雜,但整體的結構也就如此。軟體運行在硬體上,告訴硬體該幹什麼。操作系統軟體是在啟動過程中經過BIOS,bootloarder等(如果有這些過程的話)從磁碟載入到內存中,而自定義軟體則是編寫存放到磁碟中,只有通過載入才會到內存中運行。

首先我們來看一下什麼是堆、棧還有堆棧,我們經常說堆棧其實它是等同於棧的概念。

可以通俗意義上這樣理解堆,堆是一段非常大的內存空間,供不同的程序員從其中取出一段供自己使用,使用之後要由程序員自己釋放,如果不釋放的話,這部分存儲空間將不能被其他程序使用。堆的存儲空間是不連續的,因為會因為不同時間,不同大小的堆空間的申請導致其不連續性。堆的生長是從低地址向高地址增長的。

對棧的理解是,棧是一段存儲空間,供系統或者操作系統使用,對程序員來說一般是不可見的,除非從一開始由程序員自己通過彙編等自己構建棧,棧會由系統管理單元自己申請釋放。棧是從高地址向低地址生長的,既棧底在高地址,棧頂低地址。

其次我們看一下應用程序的載入,應用程序被載入進內存後,由操作系統為其分配堆棧,程序的入口函數會是main函數。不過main函數也不是第一個被調用的函數,我們通過簡單的例子講解。

#include

#include string.h>int function(int arg){ return arg;}

int main(void)

{ int i = 10; int j; j = function(i); printf("%dn",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

看以看到當函數被調用時,首先會把調用函數的棧底壓棧到自己函數的棧中(pushq %rbp),然後將原來函數棧頂rsp作為當前函數的棧底(movq %rsp, %rbp)。函數運行完成時,會將壓入棧中的rbp重新出棧到rbp中(popq %rbp)。當前function彙編函數沒有顯示出棧頂的變化(rsp的變化),我們可以通過main函數來看棧頂的變化,彙編代碼如下:

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

從上面的彙編代碼可以看到首先也是壓棧和設置新棧底的過程,從此可以看出main函數也是被調用的函數,而不是第一個調用函數。代碼中的黃色部分是當前棧頂變化,從使用的subq可以知道,棧頂的地址要小於棧底的地址,所以棧是從高地址向低地址生長。

接下來可能有點繞,慢慢讀,將用語言描述函數調用過程,調用函數會將被調用函數的實參從右往左的順序壓入調用函數的棧中,通過call指令調用被調用函數,首先將return address(也就是call指令的後一條指令的地址)壓入調用函數棧中,這時rsp寄存器中存儲的地址是存放return address內存地址的下一地址值,這時調用函數的棧結構形成,然後就會進入被調用函數的作用域中。被調用函數首先將調用函數的rbp壓入被調用函數棧中(其實這個地址就是rsp寄存器中存儲的地址),接下來將會將這個地址作為被調用函數的rbp地址,才會有movq %rsp, %rbp指令設置被調用函數的棧底。如上所描述的構成了函數調用的堆棧結構如下圖所示。

2. 彙編語言中call,ret,leave等具體操作時如何?

push:將數據壓入棧中,具體操作是rsp先減,然後將數據壓入sp所指的內存地址中。rsp寄存器總是指向棧頂,但不是空單元。

pop:將數據從棧中彈出,然後rsp加操作,確保rsp寄存器指向棧頂,不是空單元。

call:將下一條指令的地址壓入當前調用函數的棧中(將PC指令壓入棧中,因為在從內存中取出call指令時,PC指令已經自動增加),然後改變PC指令的為call的function的地址,程序指針跳轉到新function。

ret:當指令指到ret指令行時,說明一個函數已經結束了,這時候rsp已經從被調用函數的棧指到了調用函數構建的返回地址位置。ret是將rsp所指棧頂地址中的內容賦值給PC,接下來將執行call function的下一條指令。

leave:相當於mov %esp, %ebp, pop ebp。頭一條指令其實是把ebp所指的被調用函數的棧底作為新的棧頂,pop指令時相當於把被調用函數的棧底彈出,rsp指向返回地址。

int:通過其後加中斷號,實現軟體引發中斷,linux操作系統中系統調用多有此實現,其他實時操作系統中在操作系統移植時,會有tick心臟函數也有此實現。

其他的彙編指令在此就不多講了,因為彙編指令眾多,硬體cpu寄存器也因硬體不同而不同,此節就講了函數構建進入和離開函數時用到的幾個彙編指令,這幾條指令和棧變化有關。自己構建彙編函數,或者是在讀linux操作系統的系統調用時會對其理解有幫助。硬體寄存器中rsp,和rbp用於指示棧頂和棧底。

3. linux中任務的堆棧,數據存放是如何?

linux的任務堆棧分為兩種:內核態堆棧和用戶態堆棧。接下來簡單介紹一下這兩個堆棧,如果以後有機會將詳細介紹這兩個堆棧。

1. 內核態堆棧

linux操作系統分為內核態和用戶態。用戶態代碼訪問代碼和數據收到諸多限制,用戶態主要是為程序員編寫程序使用,處於用戶態的代碼不可以隨便訪問linux內核態的數據,這主要就是設置用戶態的許可權,安全考慮。但是用戶態可以通過系統調用介面,中斷,異常等訪問指定內核態的內容。內核態主要是用於操作系統內核運行以及管理,可以無限制的訪問內存地址和數據,許可權比較大。

linux操作系統的進程是動態的,有生命周期,進程的運行和普通的程序運行一樣,需要堆棧的幫助,如果在內核存儲區域內為其提前分配堆棧的話,既浪費內核內存(任務地址大約3G的空間),也不能靈活的構建任務,所以linux操作系統在創建新的任務時,為其分配了8k的存儲區域用於存放進程內核態的堆棧和線程描述符。線程描述符位於分配的存儲區域的低地址區域,大小固定,而內核態堆棧則從存儲區域的高地址開始向低地址延伸。如果之前版本為內核態堆棧和線程描述符分配4k的存儲空間時,則需要為中斷和異常分配額外的棧供其使用,防止任務堆棧溢出。

2. 用戶態堆棧

對於32位的linux操作系統,每個任務都會有4G的定址空間,其中0-3G為用戶定址空間,3G-4G為內核定址空間。每個任務的創建都會有0-3G的用戶定址空間,但是3G-4G的內核定址空間是屬於所有任務共享的。這些地址都屬於線性地址,需要通過地址映射轉換成物理地址。為了實現每個任務在訪問0-3G的用戶空間時不至於混淆地址,每個任務的內存管理單元都會有一個屬於自身的頁目錄pgd,在任務創建之初會創建新的pgd,任務會通過地址映射為0-3G空間映射物理地址。用戶態的堆棧就在這0-3G的用戶定址空間中分配,和之前的main函數以及function函數構建堆棧一樣,但是具體映射到哪個物理地址,還需要內存管理單元去做映射操作。總之,linux任務用戶態的堆棧和普通應用程序一樣,由操作系統分配和釋放,對程序員來說不可見,不過因為操作系統的原因,任務用戶程序定址有限制。

推薦閱讀:

相关文章