1. 程式人生 > >函式呼叫實現過程詳解(棧空間解析)

函式呼叫實現過程詳解(棧空間解析)

轉自:函式呼叫棧 剖析+圖解

 

棧: 在函式呼叫時,第一個進棧的是主函式中函式呼叫後的下一條指令(函式呼叫語句的下一條可執行語句)的地址,然後是函式的各個引數,在大多數的C編譯器中,引數是由右往左入棧的,然後是函式中的區域性變數。注意靜態變數是不入棧的。

當本次函式呼叫結束後,區域性變數先出棧,然後是引數,最後棧頂指標指向最開始存的地址,也就是主函式中的下一條指令,程式由該點繼續執行。函式呼叫棧 - kom118 - Just do IT!!

當發生函式呼叫的時候,棧空間中存放的資料是這樣的:
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呼叫foofoo呼叫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指令,這個指令有兩個作用:

  1. foo函式呼叫完之後要返回到call的下一條指令繼續執行,所以把call的下一條指令的地址134513631壓棧,同時把esp的值減4,esp的值現在是0xbffff3ec。

  2. 修改程式計數器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函式的引數ab分別通過ebp+8ebp+12來訪問。所以下面的指令把引數ab再次壓棧,為呼叫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+8ebp+12分別可以訪問引數cdbar函式還有一個區域性變數e,可以通過ebp-4來訪問。所以後面幾條指令的意思是把引數cd取出來存在暫存器中做加法,計算結果儲存在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 %ebpmov %esp,%ebp的逆操作:

  1. ebp的值賦給esp,現在esp的值是0xbffff3d8。

  2. 現在esp所指向的棧頂儲存著foo函式棧幀的ebp,把這個值恢復給ebp,同時esp增加4,esp的值變成0xbffff3dc。

最後是ret指令,它是call指令的逆操作:

  1. 現在esp所指向的棧頂儲存著返回地址,把這個值恢復給eip,同時esp增加4,esp的值變成0xbffff3e0。

  2. 修改了程式計數器eip,因此跳轉到返回地址0x80483c2繼續執行。

地址0x80483c2處是foo函式的返回指令:

 80483c3:	c9                   	leave  
 80483c4:	c3                   	ret  

 

重複同樣的過程,又返回到了main函式。注意函式呼叫和返回過程中的這些規則:

  1. 引數壓棧傳遞,並且是從右向左依次壓棧。

  2. ebp總是指向當前棧幀的棧底。

  3. 返回值通過eax暫存器傳遞。

這些規則並不是體系結構所強加的,ebp暫存器並不是必須這麼用,函式的引數和返回值也不是必須這麼傳,只是作業系統和編譯器選擇了以這樣的方式實現C程式碼中的函式呼叫,這稱為CallingConvention,Calling Convention是作業系統二進位制介面規範(ABI,Application BinaryInterface)的一部分。