函式呼叫實現過程詳解(棧空間解析)
轉自:函式呼叫棧 剖析+圖解
棧: 在函式呼叫時,第一個進棧的是主函式中函式呼叫後的下一條指令(函式呼叫語句的下一條可執行語句)的地址,然後是函式的各個引數,在大多數的C編譯器中,引數是由右往左入棧的,然後是函式中的區域性變數。注意靜態變數是不入棧的。
當本次函式呼叫結束後,區域性變數先出棧,然後是引數,最後棧頂指標指向最開始存的地址,也就是主函式中的下一條指令,程式由該點繼續執行。
當發生函式呼叫的時候,棧空間中存放的資料是這樣的:
1、呼叫者函式把被調函式所需要的引數按照與被調函式的形參順序相反的順序壓入棧中,即:從右向左依次把被調函式所需要的引數壓入棧;
2、呼叫者函式使用call指令呼叫被調函式,並把call指令的下一條指令的地址當成返回地址壓入棧中(這個壓棧操作隱含在call指令中);
3、在被調函式中,被調函式會先儲存呼叫者函式的棧底地址(push ebp),然後再儲存呼叫者函式的棧頂地址,即:當前被調函式的棧底地址(mov ebp,esp);
4、在被調函式中,從ebp的位置處開始存放被調函式中的區域性變數和臨時變數,並且這些變數的地址按照定義時的順序依次減小,即:這些變數的地址是按照棧的延伸方向排列的,先定義的變數先入棧,後定義的變數後入棧;
所以,發生函式呼叫時,入棧的順序為:
引數N
引數N-1
引數N-2
.....
引數3
引數2
引數1
函式返回地址
上一層呼叫函式的EBP/BP
區域性變數1
區域性變數2
....
區域性變數N
函式呼叫棧如下圖所示:
解釋: //EBP 基址指標,是儲存呼叫者函式的地址,總是指向函式棧棧底,ESP被調函式的指標,總是指向函式棧棧頂。
首 先,將呼叫者函式的EBP入棧(pushebp),然後將呼叫者函式的棧頂指標ESP賦值給被調函式的EBP(作為被調函式的棧底,movebp,esp),此時,EBP暫存器處於一個非常重要的位置,該暫存器中存放著一個地址(原EBP入棧後的棧頂),以該地址為基準,向上(棧底方向)能獲取返回地址、引數值,向下(棧頂方向)能獲取函式的區域性變數值,而該地址處又存放著上一層函式呼叫時的EBP值;
一般規律,SS:[ebp+4]處為被調函式的返回地址,SS:[EBP+8]處為傳遞給被調函式的第一個引數(最後一個入棧的引數,此處假設其佔用4位元組記憶體)的值,SS:[EBP-4]處為被調函式中的第一個區域性變數,SS:[EBP]處為上一層EBP值;由於EBP中的地址處總是"上一層函式呼叫時的EBP值",而在每一層函式呼叫中,都能通過當時的EBP值"向上(棧底方向)能獲取返回地址、引數值,向下(棧頂方向)能獲取被調函式的區域性變數值";
如此遞迴,就形成了函式呼叫棧;
Eg函式內區域性變數佈局示例:
#include <stdio.h> #include <string.h> struct C { int a; int b; int c; }; int test2(int x, int y, int z) { printf("hello,test2\n"); return 0; } int test(int x, int y, int z) { int a = 1; int b = 2; int c = 3; struct C st; printf("addr x = %u\n",(unsigned int)(&x)); printf("addr y = %u\n",(unsigned int)(&y)); printf("addr z = %u\n",(unsigned int)(&z)); printf("addr a = %u\n",(unsigned int)(&a)); printf("addr b = %u\n",(unsigned int)(&b)); printf("addr c = %u\n",(unsigned int)(&c)); printf("addr st = %u\n",(unsigned int)(&st)); printf("addr st.a = %u\n",(unsigned int)(&st.a)); printf("addr st.b = %u\n",(unsigned int)(&st.b)); printf("addr st.c = %u\n",(unsigned int)(&st.c)); return 0; } int main(int argc, char** argv) { int x = 1; int y = 2; int z = 3; test(x,y,z); printf("x = %d; y = %d; z = %d;\n", x,y,z); memset(&y, 0, 8); printf("x = %d; y = %d; z = %d;\n", x,y,z); return 0; }
列印輸出如下:
addr x = 3220024704
addr y = 3220024708
addr z = 3220024712
addr a = 3220024684
addr b = 3220024680
addr c = 3220024676
addr st = 3220024664
addr st.a = 3220024664
addr st.b = 3220024668
addr st.c = 3220024672
x = 1; y = 2; z = 3;
x = 0; y = 0; z = 3;
區域性變數在棧中佈局示意圖:
該圖中的區域性變數都是在該示例中定義的:
這個圖片中反映的是一個典型的函式呼叫棧的記憶體佈局;
訪問函式的區域性變數和訪問函式引數的區別:
區域性變數總是通過將ebp減去偏移量來訪問,函式引數總是通過將ebp加上偏移量來訪問。對於32位變數而言,第一個區域性變數位於ebp-4,第二個位於ebp-8,以此類推,32位區域性變數在棧中形成一個逆序陣列;第一個函式引數位於ebp+8,第二個位於ebp+12,以此類推,32位函式引數在棧中形成一個正序陣列。
Eg、研究函式呼叫過程:
#include <stdio.h>
int bar(int c,int d)
{
int e=c+d;
return e;
}
int foo(int a,int b)
{
return bar(a,b);
}
int main(int argc,int argv)
{
foo(2,3);
return 0;
}
上面是一個很簡單的函式呼叫過程,整個程式的執行過程是main
呼叫foo
,foo
呼叫bar
。
//檢視反彙編檔案(要檢視編譯後的彙編程式碼,其實還有一種辦法是gcc -S text_stack.c
,這樣只生成彙編程式碼
,而不生成二進位制的目標檔案。)text_stack
.s
[email protected]:/home/wangye# gcc text_stack.c -g
[email protected]:/home/wangye# objdump -dS a.out
反彙編結果很長,下面只列出我們關心的部分。
08048394 <bar>:
#include <stdio.h>
int bar(int c,int d)
{
8048394: 55 push %ebp
8048395: 89 e5 mov %esp,%ebp
8048397: 83 ec 10 sub $0x10,%esp
int e=c+d;
804839a: 8b 45 0c mov 0xc(%ebp),%eax
804839d: 8b 55 08 mov 0x8(%ebp),%edx
80483a0: 8d 04 02 lea (%edx,%eax,1),%eax
80483a3: 89 45 fc mov %eax,-0x4(%ebp)
return e;
80483a6: 8b 45 fc mov -0x4(%ebp),%eax
}
80483a9: c9 leave
80483aa: c3 ret
080483ab <foo>:
int foo(int a,int b)
{
80483ab: 55 push %ebp
80483ac: 89 e5 mov %esp,%ebp
80483ae: 83 ec 08 sub $0x8,%esp
return bar(a,b);
80483b1: 8b 45 0c mov 0xc(%ebp),%eax
80483b4: 89 44 24 04 mov %eax,0x4(%esp)
80483b8: 8b 45 08 mov 0x8(%ebp),%eax
80483bb: 89 04 24 mov %eax,(%esp)
80483be: e8 d1 ff ff ff call 8048394 <bar>
}
80483c3: c9 leave
80483c4: c3 ret
080483c5 <main>:
int main(int argc,int argv)
{
80483c5: 55 push %ebp
80483c6: 89 e5 mov %esp,%ebp
80483c8: 83 ec 08 sub $0x8,%esp
foo(2,3);
80483cb: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp)
80483d2: 00
80483d3: c7 04 24 02 00 00 00 movl $0x2,(%esp)
80483da: e8 cc ff ff ff call 80483ab <foo>
return 0;
80483df: b8 00 00 00 00 mov $0x0,%eax
}
//我們用gdb
跟蹤程式的執行,直到bar
函式中的int e = c + d;
語句執行完畢準備返回時,這時在gdb
中列印函式棧幀。
[email protected]:~$ gdb text_stack
GNU gdb (GDB) 7.0.1-debian
Copyright (C) 2009 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/wangye/text_stack...done.
(gdb) start
Temporary breakpoint 1 at 0x80483cb: file text_stack.c, line 16.
Starting program: /home/wangye/text_stack
Temporary breakpoint 1, main (argc=1, argv=-1073744732) at text_stack.c:16
16 foo(2,3);
(gdb) s
foo (a=2, b=3) at text_stack.c:11
11 return bar(a,b);
(gdb) s
bar (c=2, d=3) at text_stack.c:5
5 int e=c+d;
(gdb) disassemble
Dump of assembler code for function bar:
0x08048394 <bar+0>: push %ebp
0x08048395 <bar+1>: mov %esp,%ebp
0x08048397 <bar+3>: sub $0x10,%esp
0x0804839a <bar+6>: mov 0xc(%ebp),%eax
0x0804839d <bar+9>: mov 0x8(%ebp),%edx
0x080483a0 <bar+12>: lea (%edx,%eax,1),%eax
0x080483a3 <bar+15>: mov %eax,-0x4(%ebp)
0x080483a6 <bar+18>: mov -0x4(%ebp),%eax
0x080483a9 <bar+21>: leave
0x080483aa <bar+22>: ret
End of assembler dump.
(gdb) si
0x0804839d 5 int e=c+d;
(gdb) si
0x080483a0 5 int e=c+d;
(gdb) si
0x080483a3 5 int e=c+d;
(gdb) si
6 return e;
(gdb) si
7 }
(gdb) bt
#0 bar (c=2, d=3) at text_stack.c:7
#1 0x080483c3 in foo (a=2, b=3) at text_stack.c:11
#2 0x080483df in main (argc=1, argv=-1073744732) at text_stack.c:16
(gdb) info re
record registers
(gdb) info regi
eax 0x5 5
ecx 0x4c2f5d43 1278172483
edx 0x2 2
ebx 0xb7fcaff4 -1208176652
esp 0xbffff3c8 0xbffff3c8
ebp 0xbffff3d8 0xbffff3d8
esi 0x0 0
edi 0x0 0
eip 0x80483a9 0x80483a9 <bar+21>
eflags 0x282 [ SF IF ]
cs 0x73 115
ss 0x7b 123
ds 0x7b 123
es 0x7b 123
fs 0x0 0
gs 0x33 51
(gdb) info regi
eax 0x5 5
ecx 0x4c2f5d43 1278172483
edx 0x2 2
ebx 0xb7fcaff4 -1208176652
esp 0xbffff3c8 0xbffff3c8
ebp 0xbffff3d8 0xbffff3d8
esi 0x0 0
edi 0x0 0
eip 0x80483a9 0x80483a9 <bar+21>
eflags 0x282 [ SF IF ]
cs 0x73 115
ss 0x7b 123
ds 0x7b 123
es 0x7b 123
fs 0x0 0
gs 0x33 51
(gdb) x/20 $esp
0xbffff3c8: -1073744904 134513689 -1208175868 5
0xbffff3d8: -1073744920 134513603 2 3
0xbffff3e8: -1073744904 134513631 2 3
0xbffff3f8: -1073744776 -1209406298 1 -1073744732
0xbffff408: -1073744724 -1208084392 -1073744800 -1
這裡我們又用了幾個新的gdb命令,簡單解釋一下:info registers
可以顯示所有暫存器的當前值。在gdb
中表示暫存器名時前面要加個$
,例如p $esp
可以列印esp
暫存器的值,在上例中esp
暫存器的值是0xbffff3c8,所以x/20 $esp
命令檢視記憶體中從0xbffff3c8 地址開始的20個32位數。在執行程式時,作業系統為程序分配一塊棧空間來儲存函式棧幀,esp
暫存器總是指向棧頂,在x86平臺上這個棧是從高地址向低地址增長的,我們知道每次呼叫一個函式都要分配一個棧幀來儲存引數和區域性變數,現在我們詳細分析這些資料在棧空間的佈局,根據gdb
的輸出結果圖示如下:
圖中每個小方格表示4個位元組的記憶體單元,例如b: 3
這個小方格佔的記憶體地址是0xbffff3f4~0xbffff3f7,把地址寫在每個小方格的下邊界線上,是為了強調該地址是記憶體單元的起始地址。我們從main
函式的這裡開始看起:
foo(2,3);
80483cb: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp)
80483d2: 00
80483d3: c7 04 24 02 00 00 00 movl $0x2,(%esp)
80483da: e8 cc ff ff ff call 80483ab <foo>
return 0;
80483df: b8 00 00 00 00 mov $0x0,%eax
}
要呼叫函式foo
先要把引數準備好,第二個引數儲存在esp+4
指向的記憶體位置,第一個引數儲存在esp
指向的記憶體位置,可見引數是從右向左依次壓棧的。然後執行call
指令,這個指令有兩個作用:
-
foo
函式呼叫完之後要返回到call
的下一條指令繼續執行,所以把call
的下一條指令的地址134513631壓棧,同時把esp
的值減4,esp
的值現在是0xbffff3ec。 -
修改程式計數器
eip
,跳轉到foo
函式的開頭執行。
現在看foo
函式的彙編程式碼:
080483ab <foo>:
int foo(int a,int b)
{
80483ab: 55 push %ebp
80483ac: 89 e5 mov %esp,%ebp
80483ae: 83 ec 08 sub $0x8,%esp
push %ebp
指令把ebp
暫存器的值壓棧,同時把esp
的值減4。esp
的值現在是0xbff1c414,下一條指令把這個值傳送給ebp
暫存器。這兩條指令合起來是把原來ebp
的值儲存在棧上,然後又給ebp
賦了新值。在每個函式的棧幀中,ebp
指向棧底,而esp
指向棧頂,在函式執行過程中esp
隨著壓棧和出棧操作隨時變化,而ebp
是不動的,函式的引數和區域性變數都是通過ebp
的值加上一個偏移量來訪問,例如foo
函式的引數a
和b
分別通過ebp+8
和ebp+12
來訪問。所以下面的指令把引數a
和b
再次壓棧,為呼叫bar
函式做準備,然後把返回地址壓棧,呼叫bar
函式:
return bar(a,b);
80483b1: 8b 45 0c mov 0xc(%ebp),%eax
80483b4: 89 44 24 04 mov %eax,0x4(%esp)
80483b8: 8b 45 08 mov 0x8(%ebp),%eax
80483bb: 89 04 24 mov %eax,(%esp)
80483be: e8 d1 ff ff ff call 8048394 <bar>
}
80483c3: c9 leave
80483c4: c3 ret
現在看bar
函式的指令:
int bar(int c,int d)
{
8048394: 55 push %ebp
8048395: 89 e5 mov %esp,%ebp
8048397: 83 ec 10 sub $0x10,%esp
int e=c+d;
804839a: 8b 45 0c mov 0xc(%ebp),%eax
804839d: 8b 55 08 mov 0x8(%ebp),%edx
80483a0: 8d 04 02 lea (%edx,%eax,1),%eax
80483a3: 89 45 fc mov %eax,-0x4(%ebp)
這次又把foo
函式的ebp
壓棧儲存,然後給ebp
賦了新值,指向bar
函式棧幀的棧底,通過ebp+8
和ebp+12
分別可以訪問引數c
和d
。bar
函式還有一個區域性變數e
,可以通過ebp-4
來訪問。所以後面幾條指令的意思是把引數c
和d
取出來存在暫存器中做加法,計算結果儲存在eax
暫存器中,再把eax
暫存器存回區域性變數e
的記憶體單元。
在gdb
中可以用bt
命令和frame
命令檢視每層棧幀上的引數和區域性變數,現在可以解釋它的工作原理了:如果我當前在bar
函式中,我可以通過ebp
找到bar
函式的引數和區域性變數,也可以找到foo
函式的ebp
儲存在棧上的值,有了foo
函式的ebp
,又可以找到它的引數和區域性變數,也可以找到main
函式的ebp
儲存在棧上的值,因此各層函式棧幀通過儲存在棧上的ebp
的值串起來了。
現在看bar
函式的返回指令:
return e;
80483a6: 8b 45 fc mov -0x4(%ebp),%eax
}
80483a9: c9 leave
80483aa: c3 ret
bar
函式有一個int
型的返回值,這個返回值是通過eax
暫存器傳遞的,所以首先把e
的值讀到eax
暫存器中。然後執行leave
指令,這個指令是函式開頭的push %ebp
和mov %esp,%ebp
的逆操作:
-
把
ebp
的值賦給esp
,現在esp
的值是0xbffff3d8。 -
現在
esp
所指向的棧頂儲存著foo
函式棧幀的ebp
,把這個值恢復給ebp
,同時esp
增加4,esp
的值變成0xbffff3dc。
最後是ret
指令,它是call
指令的逆操作:
-
現在
esp
所指向的棧頂儲存著返回地址,把這個值恢復給eip
,同時esp
增加4,esp
的值變成0xbffff3e0。 -
修改了程式計數器
eip
,因此跳轉到返回地址0x80483c2繼續執行。
地址0x80483c2處是foo
函式的返回指令:
80483c3: c9 leave
80483c4: c3 ret
重複同樣的過程,又返回到了main
函式。注意函式呼叫和返回過程中的這些規則:
-
引數壓棧傳遞,並且是從右向左依次壓棧。
-
ebp
總是指向當前棧幀的棧底。 -
返回值通過
eax
暫存器傳遞。
這些規則並不是體系結構所強加的,ebp
暫存器並不是必須這麼用,函式的引數和返回值也不是必須這麼傳,只是作業系統和編譯器選擇了以這樣的方式實現C程式碼中的函式呼叫,這稱為CallingConvention,Calling Convention是作業系統二進位制介面規範(ABI,Application BinaryInterface)的一部分。