C函數調用過程解析(x86-64 )

C函數調用過程解析(x86-64 )

函數棧保存了一個函數調用所需的維護信息,一般包括:
- 函數的參數和返回值
- 臨時變量:包括函數的非靜態局部變量以及編譯器自動生成的其他臨時變量
- 保存的上下文:包括在函數調用前後需要保持不變的寄存器

下圖顯示了,在Linux操作系統中一個進程的虛擬地址佈局(本圖來自 《深入理解計算機系統》),從圖中可以看出,棧總是向下增長的, 在x86-64下, 棧頂由棧頂寄存器rsp 進行定位。 棧底有基址寄存器rbp進行定位, 也就是說一個函數的整個活動過程由這兩個寄存器劃定範圍。

這裏寫圖片描述

1,代碼示例:

#include <stdio.h>
int add(int a, int b) {
    int c = a + b;
    return c;
}
void strcopy(char * dst, char * src){
}
int main(int argc, char *argv[]) {
    int tmp = 10;
    int tmp1 = 11;
    char   str1[60];
    char   str2[100];

    strcopy(str2, str1);

    int result = add(2, 3);
    printf("result=%d\n", result);
    return 0;
}

2, 函數調用過程 (x86-64)

編譯:gcc -m64 -o test test.c
生成彙編:gcc -m64 -S test.c //熟悉INTEL彙編指令格式的,可以指定彙編代碼格式 -masm=intel
反彙編 : objdump -d test > test.dump

gdb ./test
(gdb) info b                     ;將斷點設在 _start, 這是程序真正的入口
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000400450 ../sysdeps/x86_64/elf/start.S:65

(gdb) set disassemble-next-line on  ;打開彙編指令開關, 交替使用n  ni 進行調試

(gdb) info reg 
rax            0x1c 28                          ;
rbx            0x0  0
rcx            0x7fffffffe1f8   140737488347640
rdx            0x7ffff7deb680   140737351956096
rsi            0x7ffff7df698f   140737352001935
rdi            0x7ffff7ffe208   140737354129928
rbp            0x0  0x0                         ;程序未開始,基址寄存器爲0
rsp            0x7fffffffe1e0   0x7fffffffe1e0  ;棧頂寄存器初始值 
r8             0xb  11
r9             0x3  3
r10            0x2  2
r11            0xd  13
r12            0x400450 4195408
r13            0x7fffffffe1e0   140737488347616
r14            0x0  0
r15            0x0  0
rip            0x400450 0x400450 <_start>    ;此時指令寄存器指向_start 開始位置
eflags         0x202    [ IF ]
cs             0xe033   57395
ss             0xe02b   57387
ds             0x0  0
es             0x0  0
fs             0x0  0
gs             0x0  0
(gdb) display /i $pc     ;用display關注$pc 的內容變化, $pc爲GDB內部變量相當於指令寄存器rip  
(gdb) display $rsp       ;利用display關注 $rsp  的內容變化
(gdb) display  *(char*)$rsp
(gdb) display  *(short*)$rsp
(gdb) display  *(int *)$rsp
(gdb) disas _start
Dump of assembler code for function _start:
   0x0000000000400450 <+0>: xor    %ebp,%ebp  ;異或操作用於清零 ebp=0
   0x0000000000400452 <+2>: mov    %rdx,%r9   ;數據寄存器rdx 內容存入 r9
   0x0000000000400455 <+5>: pop    %rsi       ;rsi=1  rsp=rsp+8 = 0x7fffffffe1e8
   0x0000000000400456 <+6>: mov    %rsp,%rdx  ;將rsp 暫存入 rdx
   0x0000000000400459 <+9>: and    $0xfffffffffffffff0,%rsp ;與操作 使 rsp 按照16字節對齊  rsp= 0x7fffffffe1e0   note1
   0x000000000040045d <+13>:    push   %rax   ;rax 進棧,rsp-8 = 0x7fffffffe1d8  (rsp)=28
   0x000000000040045e <+14>:    push   %rsp   ;rsp 進棧 保存原棧頂位置  rsp-8=0x7fffffffe1d0
   0x000000000040045f <+15>:    mov    $0x4005a0,%r8 ;nm 可見, 0x4005a0 爲 __libc_csu_fini會調用_fini,它是留給程序結束時用的
   0x0000000000400466 <+22>:    mov    $0x4005b0,%rcx ;0x4005b0 爲 __libc_csu_init會調用_init
   0x000000000040046d <+29>:    mov    $0x400556,%rdi ;0x400556 爲 main 函數 入口地址
   0x0000000000400474 <+36>:    callq  0x400440 <__libc_start_main@plt> ; 以上三個參數 從右到左 分別存在  r8,rcx,rdi    __libc_start_main 內部不再繼續展開
   0x0000000000400479 <+41>:    hlt    ;使程序停止運行,處理器進入暫停狀態,不執行任何操作,不影響標誌
   0x000000000040047a <+42>:    xchg   %ax,%ax  ;相當於nop, 空操作
End of assembler dump.
(gdb) disas main
Dump of assembler code for function main:
   0x0000000000400564 <+0>: push   %rbp             ;上層函數基址入棧
   0x0000000000400565 <+1>: mov    %rsp,%rbp            ;將當前棧頂作爲main函數的基址  (rsp=rbp=0x7fffffffe1100x0000000000400568 <+4>: sub    $0xd0,%rsp          ;rsp下移208字節 用於保存寄存器和局部變量  
   0x000000000040056f <+11>:    mov    %edi,-0xc4(%rbp)     ;把寄存器edi中的值 (int argc)保存在棧幀中 (rbp-196)。因爲寄存器edi接下來要被使用
   0x0000000000400575 <+17>:    mov    %rsi,-0xd0(%rbp)     ;把寄存器rsi中的值( char *argv)保存在棧幀中。 (rbp-2080x000000000040057c <+24>:    movl   $0xa,-0xc(%rbp)     ;局部變量tmp 存放於  rbp-12 位置 ,賦值10
   0x0000000000400583 <+31>:    movl   $0xb,-0x8(%rbp)     ;局部變量tmp1 存放於  rbp-8 位置 ,賦值11
   0x000000000040058a <+38>:    lea    -0x50(%rbp),%rsi     ;局部變量str1 存放於  rbp-80 位置 (12+60=7616字節對齊) , 傳入rsi作爲strcopy第二個參數
   0x000000000040058e <+42>:    lea    -0xc0(%rbp),%rdi     ;局部變量str2 存放於  rbp-192 位置 (80+100=18016字節對齊) , 傳入rdi作爲strcopy第二個參數
   0x0000000000400595 <+49>:    callq  0x400556 <strcopy>   ;調用strcopy
   0x000000000040059a <+54>:    mov    $0x3,%esi           ;第二個參數 3 存入 esi
   0x000000000040059f <+59>:    mov    $0x2,%edi           ;第一個參數2 存入 edi
   0x00000000004005a4 <+64>:    callq  0x40053c <add>       ;調用 add 函數 ;refer to note2
   0x00000000004005a9 <+69>:    mov    %eax,-0x4(%rbp)      ;將eax中存放的返回值 存入( rbp-4 )位置, 局部變量 result
   0x00000000004005ac <+72>:    mov    -0x4(%rbp),%esi       ;將rbp-4 位置 的內容 存入 esi 作爲 printf 的 第二個參數
   0x00000000004005af <+75>:    mov    $0x4006c4,%edi       ;將 "result=%d\n"  的地址 存入 edi 作爲 printf 的第二個參數(查看數據段內容 readelf -p .rodata test)
   0x00000000004005b4 <+80>:    mov    $0x0,%eax           ; eax 清零
   0x00000000004005b9 <+85>:    callq  0x400430 <printf@plt>    ;調用 printf
   0x00000000004005be <+90>:    mov    $0x0,%eax0傳入 eax 作爲返回值 
   0x00000000004005c3 <+95>:    leaveq 
   0x00000000004005c4 <+96>:    retq   
End of assembler dump.
(gdb) disas add
Dump of assembler code for function add:
   0x000000000040053c <+0>: push   %rbp             ;main 函數基址進棧
   0x000000000040053d <+1>: mov    %rsp,%rbp            ;將當前棧頂作爲add函數的基址  (rsp=rbp=0x7fffffffe0300x0000000000400540 <+4>: mov    %edi,-0x14(%rbp)      ;第二個參數 b存入 rbp -20位置
   0x0000000000400543 <+7>: mov    %esi,-0x18(%rbp)      ;第二個參數 b 存入 rbp -24位置
   0x0000000000400546 <+10>:    mov    -0x18(%rbp),%edx  ;第二個參數 b 傳給 edx
   0x0000000000400549 <+13>:    mov    -0x14(%rbp),%eax  ;第二個參數 a 傳給 eax
   0x000000000040054c <+16>:    add    %edx,%eax            ;eax=eax+edx
   0x000000000040054e <+18>:    mov    %eax,-0x4(%rbp)       ;eax 中的值放入  rbp-4 位置
   0x0000000000400551 <+21>:    mov    -0x4(%rbp),%eax      ;將rbp-4位置中的內容 存入 eax作爲返回值
   0x0000000000400554 <+24>:    leaveq                  ;refer to note3
   0x0000000000400555 <+25>:    retq                    ;refer to note4
End of assembler dump.

note1:0x0000000000400459 <+9>: and $0xfffffffffffffff0,%rsp
棧的字節對齊,實際是指棧頂指針須是某字節的整數倍。我們都知道棧對齊幫助在儘可能少的內存訪問週期內讀取數據,不對齊堆棧指針可能導致嚴重的性能下降。
但是我不太理解,爲什麼這裏會要求棧頂 16 字節對齊。
查閱 “ Intel-64 and IA-32 Architectures Software Developer Manuals (https://software.intel.com/en-us/articles/intel-sdm)”
裏面說: 堆棧段的堆棧指針(esp)應在16位(字)或32位(雙字)邊界上對齊,取決於堆棧段的寬度。當前代碼段的段描述符中的D標誌設置堆棧段寬度。
此外,在64位模式下,寄存器E(SP),E(IP)和E(BP)分別被提升爲64位,分別被稱爲RSP,RIP和RBP。一些分段加載指令的形式無效(例如,LDS,POP ES)。PUSH / POP指令使用64位寬度遞增/遞減堆棧。當段的內容寄存器被推入64位堆棧,指針自動對齊到64位。
也就是說 x86-64 系統中應該是 8字節對齊纔對呀。

根據 x86-64 ABI ( http://refspecs.linuxbase.org/elf/x86_64-abi-0.21.pdf) 的描述。
x86-64要求堆棧指針在函數調用時始終爲16字節對齊,以允許在數組上進行操作時使用向量化的SSE指令。具有Intel SSE指令集支持的處理器有8個128位的寄存器,每一個寄存器可以存放4個(32位)單精度的浮點數。SSE同時提供了一個指令集,其中的指令可以允許把浮點數加載到這些128位的寄存器之中,這些數就可以在這些寄存器中進行算術邏輯運算,然後把結果放回內存。看起來,這種對浮點數計算進行優化的指令,對一般程序用處不大,但這是標準,gcc遵循標準。

note2:0x00000000004005a4 <+64>: callq 0x40053c
調用add函數。add函數調用完之後要返回到callq的下一條指令繼續執行,因此callq指令會做兩件事:
1)把callq指令的下一條指令地址0x00000000004005a9壓棧,同時寄存器rsp的值將減8。
2)修改程序計數器rip,使其指向add函數的首地址,然後跳轉到add函數的開頭執行。

note3:0x0000000000400554 <+24>: leaveq :
add函數的開頭有兩條指令(push%rbp;mov %rsp,%rbp),leaveq就是這兩條指令的逆操作。分爲兩步:
1)mov %rbp,%rsp :把寄存器rbp的值賦給寄存器rsp,讓寄存器rsp指向保存main函數棧底的地址
2)pop %rbp 把寄存器rsp所指向的內存單元值賦值給rbp,這樣rbp現在就指向main函數的棧底。同時寄存器rsp加8,此時rsp指向調用函數main的返回地址。

note4:0x0000000000400555 <+25>: retq:
main函數調用add時需要callq指令,add函數返回時就需要retq指令,它是callq指令的逆操作。同樣需要分爲兩步:
1)把rsp指向調用函數的返回地址賦值給程序計數器rip,同時rsp寄存器加8。
2)程序返回到rip寄存器所指向的地址繼續執行。

3, 函數調用過程總結(x86-64)

整個調用過程和我之前瞭解的差不多,但是仍有些出入, 以下列入一些我覺得需要注意到的。
1)x86-64 下,參數可以通過寄存器直接傳遞,不需要通過壓棧傳遞(當參數變量數量較多時,寄存器無法保存所有變量,這個時候需要通過壓棧傳遞)
手冊上說 x86-64下,整數和指針型的參數會從左至右依次保存到rdi,rsi,rdx,rcx,r8,r9中, 浮點型參數會保存到xmm0,xmm1……中, 多餘這些寄存器的參數會被保存到棧中.
2)在每個函數的棧幀中,寄存器rbp指向棧底,寄存器rsp指向棧頂,在函數執行過程中rsp隨着壓棧和出棧操作會發生變化,而rbp卻是不變的。
3)函數返回值是通過eax寄存器傳遞的, eax 有4字節, 如果返回long,eax放不下,則使用rax返回。 更長更復雜的類型可能會用到rdx 或者 開闢棧空間的方式返回。
4)局部變量的空間不是一個一個壓入棧中的,而是一次性分配好的,所以理解爲變量依次入棧是錯誤的。C語言也沒有規定局部變量在內存中的位置, 之前總認爲先定義的變量在高地址、後定義的變量在低地址。但從實際例子中看不一定是這樣的, 三個int 型變量正好相反。可見局部變量在棧上的位置沒有絕對的關係,甚至不一定會出現在棧上。比如聲明一個變量,無非是告訴編譯器,在棧上給它準備一塊空間。先聲明的話,就一定會先在棧上爲它分配空間嗎?舉個最簡單的栗子,如果根本沒被用到,編譯器完全可以不爲它分配空間。所以這個最終還是取決於編譯器的實現。局部變量並不總在棧中,有時出於性能(速度)考慮會存放在寄存器中。數組/結構體型的局部變量通常分配在棧內存中。
5)觀察局部變量的起始地址可以更好的理解 數據對齊的概念。 數據都有nature length,如char=1,short=2,int=4,double=8,。所謂自對齊,指的是該成員的起始位置的內存地址必須是它nature length的整數倍。如int只能以0,4,8這類的地址開始
這裏寫圖片描述

4, 對比函數調用 (IA32)

簡單對比一下代碼在 32位環境下的表現。

(gdb) disas main
Dump of assembler code for function main:
   0x0804843f <+0>:     lea    0x4(%esp),%ecx        ; esp+4 暫存入 ecx
   0x08048443 <+4>: and    $0xfffffff0,%esp        ;esp 地址按16字節對齊作爲main函數棧頂
   0x08048446 <+7>: pushl  -0x4(%ecx)         ;ecx-4 進棧, 保存之前的棧頂  (esp)
   0x08048449 <+10>:    push   %ebp             ;上層函數ebp 進棧
   0x0804844a <+11>:    mov    %esp,%ebp            ;將當前棧頂作爲main函數的基址
   0x0804844c <+13>:    push   %ecx             ;ecx 進棧
   0x0804844d <+14>:    sub    $0xc4,%esp          ;esp=esp - 196
   0x08048453 <+20>:    movl   $0xa,-0x10(%ebp)        ;tmp 存放在 ebp -16
   0x0804845a <+27>:    movl   $0xb,-0xc(%ebp)      ;tmp 存放在 ebp - 12
   0x08048461 <+34>:    lea    -0x4c(%ebp),%eax     ;str1 地址存入 eax 
   0x08048464 <+37>:    mov    %eax,0x4(%esp)        ;eax 存入esp+4  作爲第二個參數
   0x08048468 <+41>:    lea    -0xb0(%ebp),%eax     ;str2 地址存入 eax
   0x0804846e <+47>:    mov    %eax,(%esp)          ;eax 存入esp+4  作爲第一個參數
   0x08048471 <+50>:    call   0x804843a <strcopy>       ;調用 strcopy
   0x08048476 <+55>:    movl   $0x3,0x4(%esp)             ;參數 3 存入 esp+4
   0x0804847e <+63>:    movl   $0x2,(%esp)                   ;參數 2 存入esp
   0x08048485 <+70>:    call   0x8048424 <add>             ; 調用add
   0x0804848a <+75>:    mov    %eax,-0x8(%ebp)
   0x0804848d <+78>:    mov    -0x8(%ebp),%eax
   0x08048490 <+81>:    mov    %eax,0x4(%esp)
   0x08048494 <+85>:    movl   $0x8048580,(%esp)
   0x0804849b <+92>:    call   0x8048340 <printf@plt>
   0x080484a0 <+97>:    mov    $0x0,%eax
   0x080484a5 <+102>:   add    $0xc4,%esp
   0x080484ab <+108>:   pop    %ecx
   0x080484ac <+109>:   pop    %ebp
   0x080484ad <+110>:   lea    -0x4(%ecx),%esp
   0x080484b0 <+113>:   ret  

總結: 比較明顯的不同是參數的傳遞方式,IA32上, 原則上參數全部堆放在棧中,參數壓棧時從右向左依次壓棧,而被調用函數的參數是從棧幀的低地址向高地址去取, 因此可以在函數入口中斷後,用 esp+4 取得第一個參數, esp+8取得第二個參數, 以此類推。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章