函數調用中堆棧的個人理解


      這是我的第一篇博客,由於公司項目需要,將暫時告別C語言一段時間。所以在此記錄一下自己之前學習C語言的一些心得體會,希望可以分享給大家,也可以記錄下自己學習過程中遇到的問題以及存在的疑惑(其實就是自己學習過程中不解的地方)。好了,廢話不多說,開始微博內容了,O(∩_∩)O哈哈~

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

  • 函數調用過程中堆棧在內存中存放的結構如何?
  • 匯編語言中call,ret,leave等具體操作時如何?
  • linux中任務的堆棧,數據存放是如何?

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

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

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

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

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

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

#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

 

看以看到當函數被調用時,首先會把調用函數的棧底壓棧到自己函數的棧中(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指令設置被調用函數的棧底。如上所描述的構成了函數調用的堆棧結構如下圖所示。

此圖來自http://www.cnblogs.com/taek/archive/2012/02/05/2338877.html,此圖中MOV EBP,ESP與本文的movq指令操作不同。

     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的存儲空間時,則需要為中斷和異常分配額外的棧供其使用,防止任務堆棧溢出。

      

此圖出自http://blog.csdn.net/bailyzheng/article/details/11842553,

2. 用戶態堆棧

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM