函數調用棧詳解

我們用下面的代碼來研究函數調用的過程。
例 19.1. 研究函數的調用過程
int bar(int c, int d)

{

               int e = c + d;

               return e;

}

 

int foo(int a, int b)

{

               return bar(a, b);

}

 

int main(void)

{

               foo(2, 3);

               return 0;

}

 

如果在編譯時加上-g選項(在第 10 章 gdb講過-g選項),那麼用objdump反彙編時可以把C代碼和彙編代碼穿插起來顯示,這樣C代碼和彙編代碼的對應關係看得更清楚。反彙編的結果很長,以下只列出我們關心的部分。
$ gcc main.c -g

$ objdump -dS a.out

...

08048394 <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 55 0c                          mov    0xc(%ebp),%edx

 804839d:              8b 45 08                         mov    0x8(%ebp),%eax

 80483a0:              01 d0                             add    %edx,%eax

 80483a2:              89 45 fc                           mov    %eax,-0x4(%ebp)

               return e;

 80483a5:              8b 45 fc                           mov    -0x4(%ebp),%eax

}

 80483a8:              c9                                 leave 

 80483a9:              c3                                 ret   

 

080483aa <foo>:

 

int foo(int a, int b)

{

 80483aa:              55                                push   %ebp

 80483ab:              89 e5                             mov    %esp,%ebp

 80483ad:              83 ec 08                          sub    $0x8,%esp

               return bar(a, b);

 80483b0:              8b 45 0c                          mov    0xc(%ebp),%eax

 80483b3:              89 44 24 04                      mov    %eax,0x4(%esp)

 80483b7:              8b 45 08                         mov    0x8(%ebp),%eax

 80483ba:              89 04 24                         mov    %eax,(%esp)

 80483bd:              e8 d2 ff ff ff         call   8048394 <bar>

}

 80483c2:              c9                                 leave 

 80483c3:              c3                                 ret   

 

080483c4 <main>:

 

int main(void)

{

 80483c4:              8d 4c 24 04                      lea    0x4(%esp),%ecx

 80483c8:              83 e4 f0                          and    $0xfffffff0,%esp

 80483cb:              ff 71 fc                             pushl  -0x4(%ecx)

 80483ce:              55                                push   %ebp

 80483cf:               89 e5                             mov    %esp,%ebp

 80483d1:              51                                push   %ecx

 80483d2:              83 ec 08                          sub    $0x8,%esp

               foo(2, 3);

 80483d5:              c7 44 24 04 03 00 00            movl   $0x3,0x4(%esp)

 80483dc:              00

 80483dd:              c7 04 24 02 00 00 00            movl   $0x2,(%esp)

 80483e4:              e8 c1 ff ff ff         call   80483aa <foo>

               return 0;

 80483e9:              b8 00 00 00 00                  mov    $0x0,%eax

}

 80483ee:              83 c4 08                          add    $0x8,%esp

 80483f1:               59                                pop    %ecx

 80483f2:               5d                                pop    %ebp

 80483f3:               8d 61 fc                           lea    -0x4(%ecx),%esp

 80483f6:               c3                                 ret  

...

要查看編譯後的彙編代碼,其實還有一種辦法是gcc -S main.c,這樣只生成彙編代碼main.s,而不生成二進制的目標文件。
整個程序的執行過程是main調用foo,foo調用bar,我們用gdb跟蹤程序的執行,直到bar函數中的int e = c + d;語句執行完畢準備返回時,這時在gdb中打印函數棧幀。
(gdb) start

...

main () at main.c:14

14                           foo(2, 3);

(gdb) s

foo (a=2, b=3) at main.c:9

9                             return bar(a, b);

(gdb) s

bar (c=2, d=3) at main.c:3

3                             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),%edx

0x0804839d <bar+9>:         mov    0x8(%ebp),%eax

0x080483a0 <bar+12>:        add    %edx,%eax

0x080483a2 <bar+14>:        mov    %eax,-0x4(%ebp)

0x080483a5 <bar+17>:        mov    -0x4(%ebp),%eax

0x080483a8 <bar+20>:        leave 

0x080483a9 <bar+21>:        ret   

End of assembler dump.

(gdb) si

0x0804839d           3                             int e = c + d;

(gdb) si

0x080483a0           3                             int e = c + d;

(gdb) si

0x080483a2           3                             int e = c + d;

(gdb) si

4                             return e;

(gdb) si

5             }

(gdb) bt

#0  bar (c=2, d=3) at main.c:5

#1  0x080483c2 in foo (a=2, b=3) at main.c:9

#2  0x080483e9 in main () at main.c:14

(gdb) info registers

eax            0x5 5

ecx            0xbff1c440     -1074674624

edx            0x3 3

ebx            0xb7fe6ff4     -1208061964

esp            0xbff1c3f4      0xbff1c3f4

ebp            0xbff1c404    0xbff1c404

esi            0x8048410      134513680

edi            0x80482e0     134513376

eip            0x80483a8      0x80483a8 <bar+20>

eflags         0x200206       [ PF IF ID ]

cs             0x73               115

ss             0x7b               123

ds             0x7b               123

es             0x7b               123

fs             0x0   0

gs             0x33               51

(gdb) x/20 $esp

0xbff1c3f4:             0x00000000           0xbff1c6f7              0xb7efbdae            0x00000005

0xbff1c404:            0xbff1c414             0x080483c2           0x00000002           0x00000003

0xbff1c414:            0xbff1c428             0x080483e9           0x00000002           0x00000003

0xbff1c424:            0xbff1c440             0xbff1c498             0xb7ea3685           0x08048410

0xbff1c434:            0x080482e0           0xbff1c498             0xb7ea3685           0x00000001

(gdb)

這裏又用到幾個新的gdb命令。disassemble可以反彙編當前函數或者指定的函數,單獨用disassemble命令是反彙編當前函數,如果disassemble命令後面跟函數名或地址則反彙編指定的函數。以前我們講過step命令可以一行代碼一行代碼地單步調試,而這裏用到的si命令可以一條指令一條指令地單步調試。info registers可以顯示所有寄存器的當前值。在gdb中表示寄存器名時前面要加個$,例如p $esp可以打印esp寄存器的值,在上例中esp寄存器的值是0xbff1c3f4,所以x/20 $esp命令查看內存中從0xbff1c3f4地址開始的20個32位數。在執行程序時,操作系統爲進程分配一塊棧空間來保存函數棧幀,esp寄存器總是指向棧頂,在x86平臺上這個棧是從高地址向低地址增長的,我們知道每次調用一個函數都要分配一個棧幀來保存參數和局部變量,現在我們詳細分析這些數據在棧空間的佈局,根據gdb的輸出結果圖示如下[29]:
圖 19.1. 函數棧幀

 


圖中每個小方格表示4個字節的內存單元,例如b: 3這個小方格佔的內存地址是0xbff1c420~0xbff1c423,我把地址寫在每個小方格的下邊界線上,是爲了強調該地址是內存單元的起始地址。我們從main函數的這裏開始看起:
               foo(2, 3);

 80483d5:              c7 44 24 04 03 00 00            movl   $0x3,0x4(%esp)

 80483dc:              00

 80483dd:              c7 04 24 02 00 00 00            movl   $0x2,(%esp)

 80483e4:              e8 c1 ff ff ff         call   80483aa <foo>

               return 0;

 80483e9:              b8 00 00 00 00                  mov    $0x0,%eax

要調用函數foo先要把參數準備好,第二個參數保存在esp+4指向的內存位置,第一個參數保存在esp指向的內存位置,可見參數是從右向左依次壓棧的。然後執行call指令,這個指令有兩個作用:
1.    foo函數調用完之後要返回到call的下一條指令繼續執行,所以把call的下一條指令的地址0x80483e9壓棧,同時把esp的值減4,esp的值現在是0xbff1c418。
2.    修改程序計數器eip,跳轉到foo函數的開頭執行。

現在看foo函數的彙編代碼:
int foo(int a, int b)

{

 80483aa:              55                                push   %ebp

 80483ab:              89 e5                             mov    %esp,%ebp

 80483ad:              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);

 80483b0:              8b 45 0c                          mov    0xc(%ebp),%eax

 80483b3:              89 44 24 04                      mov    %eax,0x4(%esp)

 80483b7:              8b 45 08                         mov    0x8(%ebp),%eax

 80483ba:              89 04 24                         mov    %eax,(%esp)

 80483bd:              e8 d2 ff ff ff         call   8048394 <bar>

現在看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 55 0c                          mov    0xc(%ebp),%edx

 804839d:              8b 45 08                         mov    0x8(%ebp),%eax

 80483a0:              01 d0                             add    %edx,%eax

 80483a2:              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;

 80483a5:              8b 45 fc                           mov    -0x4(%ebp),%eax

}

 80483a8:              c9                                 leave 

 80483a9:              c3                                 ret

bar函數有一個int型的返回值,這個返回值是通過eax寄存器傳遞的,所以首先把e的值讀到eax寄存器中。然後執行leave指令,這個指令是函數開頭的push %ebp和mov %esp,%ebp的逆操作:
1.    把ebp的值賦給esp,現在esp的值是0xbff1c404。
2.    現在esp所指向的棧頂保存着foo函數棧幀的ebp,把這個值恢復給ebp,同時esp增加4,esp的值變成0xbff1c408。

最後是ret指令,它是call指令的逆操作:
1.    現在esp所指向的棧頂保存着返回地址,把這個值恢復給eip,同時esp增加4,esp的值變成0xbff1c40c。
2.    修改了程序計數器eip,因此跳轉到返回地址0x80483c2繼續執行。

地址0x80483c2處是foo函數的返回指令:
 80483c2:              c9                                 leave 

 80483c3:              c3                                 ret

重複同樣的過程,又返回到了main函數。注意函數調用和返回過程中的這些規則:
1.    參數壓棧傳遞,並且是從右向左依次壓棧。
2.    ebp總是指向當前棧幀的棧底。
3.    返回值通過eax寄存器傳遞

這些規則並不是體系結構所強加的,ebp寄存器並不是必須這麼用,函數的參數和返回值也不是必須這麼傳,只是操作系統和編譯器選擇了以這樣的方式實現C代碼中的函數調用,這稱爲Calling Convention,Calling Convention是操作系統二進制接口規範(ABI,Application Binary Interface)的一部分。
發佈了23 篇原創文章 · 獲贊 5 · 訪問量 36萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章