1. 程式人生 > >C++函式呼叫詳解

C++函式呼叫詳解

一、 什麼是棧幀?

    什麼是棧幀,相信很多從事C程式設計的童鞋還是沒有搞明白,首先引用百度百科的經典解釋:“棧幀也叫過程活動記錄,是編譯器用來實現過程/函式呼叫的一種資料結構”。

    實際上,可以簡單理解為:棧幀就是儲存在使用者棧上的(當然核心棧同樣適用)每一次函式呼叫涉及的相關資訊的記錄單元。也許這樣感覺更復雜了,好吧,讓我們從棧開始來理解什麼是棧幀...

二、 棧(使用者棧和核心棧)

    在大學學習《資料結構》的時候,瞭解到棧作為一種特殊的資料結構而存在(和“佇列”相反的記錄結構和操作規則),是一種只能在一端進行插入和刪除操作的特殊線性表

    它按照後進先出的原則儲存資料,先進入的資料被壓入棧底,最後的資料在棧頂,需要讀資料的時候從棧頂開始彈出資料(最後一個數據被第一個讀出來)。

    棧有很多自己的特性,它具有記憶功能,對棧的插入與刪除操作中,不需要改變棧底指標;而且棧是從高地址向低地址延伸的。每個函式的每次呼叫,都有它自己獨立的一個棧幀,這個棧幀中維持著所需要的各種資訊。因此棧作用就是用來保持棧幀的活動記錄(即函式呼叫)。下面有這樣一幅圖(源自Unix環境高階程式設計第七章):


    對於一個棧來說,暫存器ebp和esp分別指向指向系統棧最上面一個棧幀的底部和棧幀頂部(實際上也是棧的頂部)。上圖可以清晰的看到棧位置在使用者空間的最頂部(從0xc0000000開始向下增長),下於堆對接,實際上堆與棧之間有很大的未使用空間,這裡不做詳述。

三、棧幀

    棧幀表示程式的函式呼叫記錄,而棧幀又是記錄在棧上面,很明顯棧上保持了N個棧幀的實體,(實際上我們這裡說的棧幀是軟體上的概念,據說有硬體概念,不是很瞭解),那就可以說棧幀將棧分割成了N個記錄塊,但是這些記錄塊大小不是固定的,因為棧幀不僅儲存諸如:函式入參、出參、返回地址和上一個棧幀的棧底指標等資訊,還儲存了函式內部的自動變數(甚至可以是動態分配記憶體,alloca函式就可以實現,但在某些系統中不行),因此,不是所有的棧幀的大小都相同。

void func(int m, int n) {

    int a, b;

    a = m;

     b = n;

}

main() {

...

    func(m, n);

L:  下一條語句

...

    上面是一個簡單的可執行程式碼,目的是為了說明棧幀在棧中的儲存形式,因為一個可執行程式在程式的開始嵌入了啟動例程程式碼(這段彙編程式碼由編譯器嵌入可執行程式的其實位置,這裡不深究該行為),在執行時由啟動例程呼叫main函式,可以說main函式是第一個被呼叫的C程式碼函式,暫且認為是main函式是第一函式。

    這裡的main函式只是簡單呼叫了一個函式func,那麼在main呼叫func函式前,棧的情況是下面這個樣子的:

    此時棧中只有一個main函式的棧幀,從低地址esp(棧頂指標)到高地址ebp(棧幀棧底指標)的這塊區域,就是當前main函式的棧幀。當main中呼叫func時,寫成彙編大致是:

    push m

    push n; 兩個引數壓入棧

    call func; 呼叫func,將返回地址(實際上是當前PC值的下一個值)填入棧,並跳轉到func

    當成功跳轉到func函式中時,func函式的棧幀就已經形成了,但是形成新的棧幀之前,必須要重新記錄當前棧幀的棧底指標ebp,下面的儲存和切換ebp的幾個動作是由系統自動完成的(就像Linux中的中斷一樣,在進入中斷處理函式前要做很多的準備工作:如儲存當前執行環境,這樣才能在處理程式結束後,恢復打斷的程序的環境),可以說這幾個動作被系統自動加入:

    __func:

push ebp; 函式呼叫之所以能夠返回,單靠保持返回地址是不夠的,這一步壓棧動作很重要,因為我們要標記函式呼叫者棧幀的幀底,這樣才能找出儲存了的返回地址,棧頂是不用儲存的,因為上一個棧幀的頂部講會是func的棧幀底部。(兩棧幀相鄰的)

        mov ebp, esp; 上一棧幀的頂部,就是這個棧幀的底部

        ;暫時先看現在的棧的情況

                 ;到這裡,此時新的棧幀開始了,由下圖中間的一根長長的橫線隔開兩個棧幀

                 sub esp, 8   ;  int a, b 這裡聲明瞭兩個int,所以esp減小8個位元組來為a,b分配空間

                 mov dword ptr [esp+4], [ebp+12];   a=m

                 mov dword ptr [esp], [ebp+8]; b=n         

     這樣,棧的情況變為:

                    ret 8     ;  返回,然後8是什麼意思呢,就是自動變數佔用的位元組數,當返回後,esp-8,釋放參數m,n的空間

     由此可見,通過ebp,能夠很容易定位到上面的引數。當從func函式返回時,首先esp移動到棧幀底部(即釋放自動變數),然後把上一個函式的棧幀底部指標彈出到ebp,再彈出返回地址到cs:ip上,esp繼續移動劃過引數,這樣,ebp,esp就回到了呼叫函式前的狀態,即現在恢復了原來的main的棧幀。

    OK,到這裡應該說明白了棧幀在棧幀的分佈和形成過程,那麼棧幀在我們程式設計過程中給我們什麼啟示呢?

(1)棧幀上的動態記憶體分配

    前面已經說明過一點:在大部分系統中,棧幀上可以進行動態記憶體的分配。malloc、calloc和realloc函式都是在堆上動態分配一塊記憶體,在使用過後一定要記得釋放動態分配的記憶體,否則就會產生記憶體洩露,最終降低系統的效能。

    但是如果要在棧幀上動態分配記憶體的話,那麼在函式返回時會自動釋放這些記憶體,而不必擔心忘記釋放動態分配的記憶體。我們知道在Linux核心中,每個程序的棧只有1-2個頁的大小,即4K-8K大小,需要很珍惜的使用這部分空間;不過實使用者棧的空間很大,可以隨著需要動態的擴充,而不必擔心棧不夠用,因此我們還是可以放心的使用alloca動態分配函式在使用者棧幀上分配記憶體。

(2)函式呼叫深度

    在很多系統中都對函式呼叫的深度做了限制,函式呼叫深度是指函式巢狀的程度。函式巢狀的程度決定了在棧上同一時刻所擁有的棧幀的最大數量,函式呼叫的巢狀程度對使用者程序來說不是什麼問題,但是在核心中棧的大小固定且不能重新分配,因此呼叫的深度在核心中就存在很大的意義,這裡我們不做詳述。

(3)函式呼叫的引數

    棧幀部分已經描述了函式引數的儲存位置,即儲存在呼叫者棧幀的尾部固定長度偏移位置,程式執行時就根據函式的定義和該位置取引數進行相應的運算。

    注意:這裡函式呼叫的引數顯然儲存在函式呼叫者的棧幀中,而不是被呼叫函式的棧幀中。

(4)棧的回溯

    學習程式設計和Linux核心的童鞋一定經常聽到“棧的回溯”,它是指系統自主列印程序呼叫棧的行為。從上面描述棧幀的情況可以看出,系統在將棧打印出來的順序應當是呼叫的反順序,它是從esp(低地址)一點一點向高地址回溯,這正是棧幀形成的反過程。因此,我們經常從下到上看函式的呼叫,不過有些日誌系統將匯出的回溯資訊重新排序,可以從上到下來檢視函式呼叫順序

更為詳細的實驗解釋參看:http://blog.chinaunix.net/uid-23069658-id-3981406.html