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=0x7fffffffe110)
0x0000000000400568 <+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-208)
0x000000000040057c <+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=76 按16字節對齊) , 傳入rsi作爲strcopy第二個參數
0x000000000040058e <+42>: lea -0xc0(%rbp),%rdi ;局部變量str2 存放於 rbp-192 位置 (80+100=180 按16字節對齊) , 傳入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,%eax ;0傳入 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=0x7fffffffe030)
0x0000000000400540 <+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取得第二個參數, 以此類推。