1. 程式人生 > >C函式的呼叫過程原理和棧分析

C函式的呼叫過程原理和棧分析

  在程式設計中,相信每個人對函式都不陌生,那麼你真正理解函式的呼叫過程嗎?當一個c函式被呼叫時,一個棧幀(stack frame)是如何被建立,又如何被消除的。本文主要就是來解決這些問題的,不同的作業系統和編譯器可能有所不同,本文主要介紹在linux下的gcc編譯器。

棧幀

  我們先來看一下,一個典型的棧幀的樣子:
  這裡寫圖片描述 
  首先介紹一下這裡面非兩個重要的指標:ebp和esp;
   ebp(base pointer )可稱為“幀指標”或“基址指標”,其實語意是相同的。在未受改變之前始終指向棧幀的開始,也就是棧底,所以ebp的用途是在堆疊中定址用的。

esp(stack pointer)可稱為“ 棧指標”。 esp是會隨著資料的入棧和出棧移動的,也就是說,esp始終指向棧頂。

  瞭解記憶體結構的夥伴肯定知道,從上往下來說,地址從高向低,棧位於核心態之下,是向下生長的,所謂向下生長是指從記憶體高地址->低地址的路徑延伸,那麼就很明顯了,棧有棧底和棧頂,那麼棧頂的地址要比棧底低。
  在瞭解了棧幀的結構之後,下面我們就來看一看函式的呼叫過程及棧幀的變化。

函式呼叫過程

比如說main函式中有如下函式:

int func(int a , int b); 

  func有兩個區域性的int變數。這裡,main是呼叫者,func是被呼叫者。
  ESP被func使用來指示棧頂。EBP相當於一個“基準指標”。從main傳遞到func的引數以及func函式本身的區域性變數都可以通過這個基準指標為參考,加上偏移量找到。
  由於被呼叫者允許使用EAX,ECX和EDX暫存器,所以如果呼叫者希望儲存這些暫存器的值,就必須在呼叫子函式之前顯式地把他們壓棧,儲存在棧中。另一方面,如果除了上面提到的幾個暫存器,被呼叫者還想使用別的暫存器,比如EBX,ESI和EDI,那麼,被呼叫者就必須在棧中儲存這些被額外使用的暫存器,並在呼叫返回前恢復他們。也就是說,如果被呼叫者只使用約定的EAX,ECX和EDX暫存器,他們由呼叫者負責儲存(push)並恢復(pop),但如果被呼叫這還額外使用了別的暫存器,則必須有他們自己儲存並回復這些暫存器的值。

函式的入參

  傳遞給func的引數被壓到棧中,最後一個引數先進棧,所以第一個引數是位於棧頂的。所以說函式是從右往左進行引數的入棧的,這和變長引數有關。此外,func中宣告的區域性變數以及函式執行過程中需要用到的一些臨時變數也都存在棧中。

返回值

  小於等於4個位元組的返回值會被儲存到EAX中,如果大於4位元組,小於8位元組,那麼EDX也會被用來儲存返回值。如果返回值佔用的空間還要大,那麼呼叫者會向被呼叫者傳遞一個額外的引數,這個額外的引數指向將要儲存返回值的地址。用C語言來說,就是函式呼叫:

x = foo(a, b, c);  被轉化為: func(&x, a, b, c);

  注意,這僅僅在返回值佔用大於8個位元組時才發生。有的編譯器不用EDX儲存返回值,所以當返回值大於4個位元組時,就用這種轉換。
  當然,並不是所有函式呼叫都直接賦值給一個變數,還可能是直接參與到某個表示式的計算中,如:

m = foo(a, b, c) + foo(d, e, f);

有或者作為另外的函式的引數, 如:

fooo(foo(a, b, c), 3);

 這些情況下,foo的返回值會被儲存在一個臨時變數中參加後續的運算,所以,foo(a, b, c)還是可以被轉化成

foo(&tmp, a, b, c)。

呼叫過程

假設函式A呼叫函式B,我們稱A函式為”呼叫者”,B函式為“被呼叫者”則函式呼叫過程可以這麼描述:

(1)先將呼叫者(A)的堆疊的基址(ebp)入棧,以儲存之前任務的資訊,函式返回之後可以繼續執行之前的邏輯。
(2)然後將呼叫者(A)的棧頂指標(esp)的值賦給ebp,作為新的基址(即被呼叫者B的棧底)。
(3)然後在這個基址(被呼叫者B的棧底)上開闢(一般用sub指令)相應的空間用作被呼叫者B的棧空間,進行函式入參的壓棧等操作。
(4)函式B返回後,從當前棧幀的ebp即恢復為呼叫者A的棧頂(esp),使棧頂恢復函式B被呼叫前的位置;然後呼叫者A再從恢復後的棧頂可彈出之前的ebp值(可以這麼做是因為這個值在函式呼叫前一步被壓入堆疊)。這樣,ebp和esp就都恢復了呼叫函式B前的位置,也就是棧恢復函式B呼叫前的狀態。

下面,讓我們一步步地看一下在c函式呼叫過程中,一個棧幀是如何建立及消除的。

函式呼叫前呼叫者的動作

  在上面的示例中,呼叫者是main,它準備呼叫函式func。在函式呼叫前,main正在用ESP和EBP暫存器指示它自己的棧幀。
  首先,main把EAX,ECX和EDX壓棧。這是一個可選的步驟,只在這三個暫存器內容需要保留的時候執行此步驟。
  接著,main把傳遞給func的引數一一進棧,最後的引數最先進棧。
  最後,main用call指令呼叫子函式:call func。

  當call指令執行的時候,EIP指令指標暫存器的內容會先被壓入棧中。因為EIP暫存器是指向main中的下一條指令,所以現在返回地址就在棧頂了。在call指令執行完之後,下一個執行週期將從名為foo的標記處開始。
圖2展示了call指令完成後棧的內容。圖2及後續圖中的粗線指示了函式呼叫前棧頂的位置。我們將會看到,當整個函式呼叫過程結束後,棧頂又回到了這個位置。
這裡寫圖片描述

被呼叫者在函式呼叫後的動作

  ①、建立它自己的棧幀,
  ②、為區域性變數分配空間
  ③、如果函式中需要使用暫存器EBX,ESI和EDI,則壓棧儲存暫存器的值,出棧時恢復。

此時棧空間如下:
這裡寫圖片描述
具體過程如下:
  首先被呼叫的函式必須建立它自己的棧幀。EBP暫存器現在正指向main的棧幀中的某個位置,這個值必須被保留,因此,EBP進棧。然後ESP的內容賦值給了EBP。這使得函式的引數可以通過對EBP附加一個偏移量得到,而棧暫存器ESP便可以空出來做其他事情。第一個引數的地址是EBP加8,因為main的EBP和返回地址各在棧中佔了4個位元組。
  下一步,被呼叫的函式必須為它的區域性變數分配空間,同時,也必須為它可能用到的一些臨時變數分配空間。比如,foo中的一些C語句可能包括複雜的表示式,其子表示式的中間值就必須得有地方存放。這些存放中間值的地方同城被稱為臨時的,因為他們可以為下一個複雜表示式所複用。
  最後,如果foo用到EBX,ESI和EDI暫存器,則它f必須在棧裡儲存它們。

被呼叫者返回前的動作

  被呼叫的函式返回前,必須先把返回值儲存在EAX暫存器中。當返回值佔用多於4個或8個位元組時,接收返回值的變數地址會作為一個額外的指標引數被傳到函式中,而函式本身就不需要返回值了。這種情況下,被呼叫者直接通過記憶體拷貝把返回值直接拷貝到接收地址,從而省去了一次通過棧的中轉拷貝。
  其次,被呼叫的函式必須恢復EBX,ESI和EDI暫存器的值。如果這些暫存器被修改,正如我們前面所說,我們會在foo執行開始時把它們的原始值壓入棧中。
  這兩步之後,我們不再需要foo的區域性變數和臨時儲存了,我們可以通過下面的指令消除棧幀:

mov esp, ebp
pop ebp

  最後直接執行返回指令。從棧裡彈出返回地址,賦值給EIP暫存器。

呼叫者在返回後的動作

  在程式控制權返回到呼叫者後,傳遞給被調函式的引數已經不需要了。我們可以把所有個引數一起彈出棧,實現堆疊平衡。
  如果在函式呼叫前,EAX,ECX和EDX暫存器的值被儲存在棧中,呼叫者main函式現在可以把它們彈出。這個動作之後,棧頂就回到了我們開始整個函式呼叫過程前的位置。

  至此,函式的呼叫過程就已經分析完畢了。下面,看個具體的例項:

例項

原始碼

c原始碼:

#include <stdio.h>

int add(int a , int b)
{
    int c = a + b;

    return c;
}

int main()
{
    int result = 0;
    result = add(1 , 2);

    printf("%d\n",result);

    return 0;
}

  在linux下,通過: gcc -S test.c -o test.s 命令將原始檔編譯成彙編檔案,若對c語言的編譯過程感興趣的可以看我的博文c程式編譯全過程
  相應的彙編程式碼如下:

彙編程式碼

    .file   "test.c"
    .text
    .globl  add
    .type   add, @function
add:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    %edi, -20(%rbp)
    movl    %esi, -24(%rbp)
    movl    -24(%rbp), %eax
    movl    -20(%rbp), %edx
    addl    %edx, %eax
    movl    %eax, -4(%rbp)
    movl    -4(%rbp), %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   add, .-add
    .section    .rodata
.LC0:
    .string "%d\n"
    .text
    .globl  main
    .type   main, @function
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    $0, -4(%rbp)
    movl    $2, %esi
    movl    $1, %edi
    call    add
    movl    %eax, -4(%rbp)
    movl    -4(%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
.LFE1:
    .size   main, .-main
    .ident  "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-28)"
    .section    .note.GNU-stack,"",@progbits

  在linux下的彙編程式碼和windows下的有些許差別,但依然類似,比如:windows下的push就是linux下的pushp,不過linux下的源運算元在左邊,目的運算元在右邊。在linux下,%開頭表示暫存器,$開頭表示立即數;在一個彙編函式的開頭和結尾, 分別有.cfi_startproc和.cfi_endproc標示著函式的起止。
  下面,分別對main函式和被調函式的執行過程分析:

main:函式段

函式呼叫前呼叫者的動作

主要彙編程式碼如下:

    pushq   %rbp               #rbp入棧 ,儲存main的棧幀中的某個位置
    movq    %rsp, %rbp         #ESP的內容賦值給了EBP。解放esp用於指向棧頂
    movl    $2, %esi           #引數放入暫存器
    movl    $1, %edi
    call    add               #呼叫add函式

被調函式的動作

主要彙編程式碼如下

    pushq   %rbp
    movq    %rsp, %rbp
    movl    %edi, -20(%rbp)
    movl    %esi, -24(%rbp)
    movl    -24(%rbp), %eax
    movl    -20(%rbp), %edx
    addl    %edx, %eax
    movl    %eax, -4(%rbp)
    movl    -4(%rbp), %eax
    popq    %rbp
    ret

函式呼叫後呼叫者的動作

    movl    %eax, -4(%rbp)
    movl    -4(%rbp), %eax
    movl    %eax, %esi
    movl    $.LC0, %edi
    movl    $0, %eax
    call    printf
    movl    $0, %eax
    leave
    ret