這是我的第一篇部落格,由於公司專案需要,將暫時告別C語言一段時間。所以在此記錄一下自己之前學習C語言的一些心得體會,希望可以分享給大家,也可以記錄下自己學習過程中遇到的問題以及存在的疑惑(其實就是自己學習過程中不解的地方)。好了,廢話不多說,開始微博內容了,O(∩_∩)O哈哈~
接下來將通過下面幾個問題解析函式呼叫中對堆疊理解:
- 函式呼叫過程中堆疊在記憶體中存放的結構如何?
- 組合語言中call,ret,leave等具體操作時如何?
- linux中任務的堆疊,資料存放是如何?
1. 函式呼叫過程中堆疊在記憶體中存放的結構如何?
計算機,嵌入式裝置,智慧裝置等其實都是有軟體和硬體兩部分組成,具體實現也許複雜,但整體的結構也就如此。軟體執行在硬體上,告訴硬體該幹什麼。作業系統軟體是在啟動過程中經過BIOS,bootloarder等(如果有這些過程的話)從磁碟載入到記憶體中,而自定義軟體則是編寫存放到磁碟中,只有通過載入才會到記憶體中執行。
首先我們來看一下什麼是堆、棧還有堆疊,我們經常說堆疊其實它是等同於棧的概念。
可以通俗意義上這樣理解堆,堆是一段非常大的記憶體空間,供不同的程式設計師從其中取出一段供自己使用,使用之後要由程式設計師自己釋放,如果不釋放的話,這部分儲存空間將不能被其他程式使用。堆的儲存空間是不連續的,因為會因為不同時間,不同大小的堆空間的申請導致其不連續性。堆的生長是從低地址向高地址增長的。
對棧的理解是,棧是一段儲存空間,供系統或者作業系統使用,對程式設計師來說一般是不可見的,除非從一開始由程式設計師自己通過彙編等自己構建棧,棧會由系統管理單元自己申請釋放。棧是從高地址向低地址生長的,既棧底在高地址,棧頂低地址。
其次我們看一下應用程式的載入,應用程式被載入進記憶體後,由作業系統為其分配堆疊,程式的入口函式會是main函式。不過main函式也不是第一個被呼叫的函式,我們通過簡單的例子講解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#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的彙編程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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函式來看棧頂的變化,彙編程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
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任務使用者態的堆疊和普通應用程式一樣,由作業系統分配和釋放,對程式設計師來說不可見,不過因為作業系統的原因,任務使用者程式定址有限制。如果有機會之後介紹一下linux記憶體管理的個人理解。