1. 程式人生 > >函式呼叫堆疊的彙編解析

函式呼叫堆疊的彙編解析

大家可能都會做過這個的gcc編譯過程:gcc -S test.c -o test.s ,通過這樣的編譯得到的是我們的彙編程式碼,開啟test.s檔案會發現都是我們看不懂的彙編指令。也許我們都想過去看看這些彙編程式碼是什麼意思,可是這些晦澀難懂的彙編程式碼,又讓我們望洋興嘆。我們都知道函式的形參是放在棧區的,函式呼叫必須需要棧,可是編譯器究竟是怎樣為我們分配棧區的呢?今天我們就來通過一個簡單的C語言程式來認識一下編譯後所得到的彙編,揭開程式底層函式呼叫堆疊的實現。
首先,我們編寫一個簡單的c語言程式:實現最簡單的函式呼叫。


int add(int a,int b)
{
    return
a+b; } int main(int argc, const char *argv[]) { add(3,4); return 0; }

大家會發現,這個程式竟然沒有標頭檔案,是的我們沒有使用到c庫函式,也沒用到一些函式沒有定義的一些符號(一些變數名或函式名)。程式實現的功能很簡單:只是在主函式中呼叫,add()函式完成簡單的加法運算。現在我們將這個程式進行彙編(gcc -S test.c -o test.s ):得到彙編程式碼如下:

    .file   "test.c"
    .text
    .globl  add
    .type   add, @function
add:
.LFB0: .cfi_startproc pushl %ebp .cfi_def_cfa_offset 8 .cfi_offset 5, -8 movl %esp, %ebp .cfi_def_cfa_register 5 movl 12(%ebp), %eax movl 8(%ebp), %edx addl %edx, %eax popl %ebp .cfi_def_cfa 4, 4 .cfi_restore 5 ret .cfi_endproc .LFE0:
.size add, .-add .globl main .type main, @function main: .LFB1: .cfi_startproc pushl %ebp .cfi_def_cfa_offset 8 .cfi_offset 5, -8 movl %esp, %ebp .cfi_def_cfa_register 5 subl $8, %esp movl $4, 4(%esp) movl $3, (%esp) call add movl $0, %eax leave .cfi_restore 5 .cfi_def_cfa 4, 4 ret .cfi_endproc .LFE1: .size main, .-main .ident "GCC: (Ubuntu/Linaro 4.6.3-1ubuntu5) 4.6.3" .section .note.GNU-stack,"",@progbits

這段彙編程式碼看似很多的彙編指令,實際是很多並不是真正的彙編指令,將一些偽指令等刪除後得到:

add:
    pushl   %ebp
    movl    %esp, %ebp
    movl    12(%ebp), %eax
    movl    8(%ebp), %edx
    addl    %edx, %eax
    popl    %ebp
    ret

main:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $8, %esp
    movl    $4, 4(%esp)
    movl    $3, (%esp)
    call    add
    movl    $0, %eax
    leave
    ret

得到的這段彙編程式碼才是真正要轉化為計算機能識別的機器語言(即是0和1),我們注意是分析這段程式碼:
首先介紹幾個常用x86的暫存器:
eip:程式計數器,指向下一條將要執行的指令
ebp:棧底暫存器,指向棧底
esp:棧頂暫存器,指向棧頂
eax,ebx,ecx,edx:一些通用的暫存器,做資料的搬移使用
首先,在man函式中,pushl %ebp 將ebp壓棧,接下來movl %esp, %ebp 是將ebp指向當前的esp位置,subl $8, %esp是為man函式分配棧區 大小為8B(分析:將esp減8,由於棧是向下增長,所以是sub指令)。
棧區分配好之後:movl $4, 4(%esp) 和 movl $3 (%esp), 做了兩次資料的搬移,即是4和3分別放在esp增加4的位置和esp的位置,這是所謂的實參在棧區的存放。接下來,call add 是函式的呼叫指令,實際上這條指令相當於兩條指令:將下一條指令的地址即是eip入棧,然後跳轉到add的函式處。
進入add後,首先執行兩條指令: pushl %ebp ,movl %esp %ebp 這兩條指令和man函式中的頭兩條一樣,所在的工作是是重新調整當前函式的棧區,這時棧底和棧頂指向相同的位置6,接下來就是分配合適的棧區,由於這裡面沒有涉及到區域性變數,編譯器沒有分配新的棧區,只是把原來實參的資料取出放入通用暫存器(eax和edx)而已(為何?):
movl 12(%ebp), %eax movl 8(%ebp), %edx
下面就進入了add函式的核心部分:運算,指令為addl %edx, %eax 發現這是一條加法指令,將eax和edx暫存器的值取出來進行加法運算,然後在把運算結果放入到eax中,我們發現記憶體中的資料都是必須放入cpu的暫存器中才能進行運算的。add中返回的是a+b也就是eax的值,實際上函式的返回值都會放在eax中被返回。然後後面是一條彈棧指令:popl %ebp 這是ebp指向了原來棧底的位置,即是2的位置,esp加4指向5位置。add中最後一條ret指令,這是函式呼叫的返回指令實際上是等價於pop指令,即是彈出eip,這是我們前面壓棧的main函式 中 call add的下條指令movl $0, %eax的地址,彈出後程序計數器eip就指向了這條指令,開始執行此指令,當然esp也加4到達4位置。這條指令是將eax清零,這是main函式的返回值0。接下來,leave指令,實際上這條指令等價於:movl %ebp, %esp 和 popl %ebp即是做清棧操作,相當於回收棧區分配的資源,這時ebp就指向了剛開始呼叫main函式時棧底的位置,esp就指向了剛開始呼叫main函式時棧頂的位置,最後一條ret指令使得程式計數器eip指向了剛開始呼叫main函式的下一條指令處,程式執行到這裡,程式執行結束,分析也到此結束。堆疊建立過程堆疊釋放過程
最後總結一下(這也是函式的呼叫規則):
1.函式在呼叫的時候都會提前儲存下呼叫指令的下一條指令的地址,保證函式呼叫結束能夠回到下一條指令繼續執行。
2.進入被呼叫的函式中的時候,首先都會執行pushl %ebp 和
movl %esp, %ebp兩條指令,來儲存原來呼叫函式的棧底指標,以及重新定位下棧底指標到棧頂指標。
3.棧區的增長都是向下增長,即是向地址減小的方向增長。
4.都會為函式分配合適的棧區為函式中的區域性變數使用。
5.資料運算時候都會從記憶體中將資料搬移到cpu的暫存器中才能運算。
6.函式的形參都是從右向左儲存到通用暫存器中,然後進行運算。
7.函式呼叫結束,如果有返回值都會儲存在eax暫存器中。
8.函式呼叫結束,如果原來有棧區的分配,都會呼叫leave來“釋放”棧區。
9.函式呼叫結束,釋放完棧區,都會通過ret返回到呼叫函式的的下一條指令處執行。
以上就是函式呼叫堆疊的彙編分析,也是本人對於呼叫規則的粗鄙理解,望提出寶貴意見。