1. 程式人生 > >函式呼叫過程探究

函式呼叫過程探究

面試時經常遇到一些蛋疼的題目,儘管實際中從來沒用到,或者大家工作過程中沒有仔細思考的,今天來轉帖一下這個函式呼叫過程,以及引數是怎麼壓棧出棧的:

文章來自:http://www.cnblogs.com/bangerlee/archive/2012/05/22/2508772.html

引言

如何定義函式、呼叫函式,是每個程式設計師學習程式設計的入門課。呼叫函式(caller)向被調函式(callee)傳入引數,被調函式返回結果,看似簡單的過程,其實CPU和系統核心在背後做了很多工作。下面我們通過反彙編工具,來看函式呼叫的底層實現。

基礎知識

我們先來看幾個概念,這有助於理解後面反彙編的輸出結果。

棧(stack)

棧,相信大家都十分熟悉,push/pop,只允許在一端進行操作,後進先出(LIFO),凡是學過程式設計的人都能列出一二三點。但就是這個最簡單的資料結構,構成了計算機中程式執行的基礎,用於核心中程式執行的棧具有以下特點:

  • 每一個程序在使用者態對應一個呼叫棧結構(call stack)
  • 程式中每一個未完成執行的函式對應一個棧幀(stack frame),棧幀中儲存函式區域性變數、傳遞給被調函式的引數等資訊
  • 棧底對應高地址,棧頂對應低地址,棧由記憶體高地址向低地址生長

一個程序的呼叫棧圖示如下:

暫存器(register)

暫存器位於CPU內部,用於存放程式執行中用到的資料和指令,CPU從暫存器中取資料,相比從記憶體中取快得多。暫存器又分通用暫存器和特殊暫存器。

通用暫存器有ax/bx/cx/dx/di/si,儘管這些暫存器在大多數指令中可以任意選用,但也有一些規定某些指令只能用某個特定“通用”暫存器,例如函式返回時需將返回值mov到ax暫存器中;特殊暫存器有bp/sp/ip等,特殊暫存器均有特定用途,例如sp暫存器用於存放以上提到的棧幀的棧頂地址,除此之外,不用於存放區域性變數,或其他用途。

對於有特定用途的幾個暫存器,簡要介紹如下:

  • ax(accumulator): 可用於存放函式返回值
  • bp(base pointer): 用於存放執行中的函式對應的棧幀的棧底地址
  • sp(stack poinger): 用於存放執行中的函式對應的棧幀的棧頂地址
  • ip(instruction pointer)
    : 指向當前執行指令的下一條指令

不同架構的CPU,暫存器名稱被添以不同字首以指示暫存器的大小。例如對於x86架構,字母“e”用作名稱字首,指示各暫存器大小為32位;對於x86_64暫存器,字母“r”用作名稱字首,指示各暫存器大小為64位。

函式呼叫例子

瞭解了棧和暫存器的概念,下面看一個函式呼叫例項:

複製程式碼
//func_call.c
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, 5);
    return 0;
}
複製程式碼

該程式很簡單,main->foo->bar,編譯得到可執行檔案func_call:

# gcc -g func_call.c -o func_call

-g選項使目標檔案func_call包含程式的除錯資訊。

反彙編分析

下面我們使用gdb對func_call進行反彙編,跟蹤main->foo->bar函式呼叫過程。

複製程式碼
# gdb func_call
//此處省略gdb版本資訊
Reading symbols from /tmp/lx/func_call...done.
(gdb) start
Temporary breakpoint 1 at 0x400525: file func_call.c, line 14.
Starting program: /tmp/lx/func_call 

Temporary breakpoint 1, main () at func_call.c:14
14            foo(2, 5);
(gdb)
複製程式碼

start命令用於拉起被除錯程式,並執行至main函式的開始位置,程式被執行之後與一個使用者態的呼叫棧關聯。

main函式

現程序跑在main函式中,我們disassemble命令顯示當前函式的彙編資訊:

複製程式碼
(gdb) disassemble /rm
Dump of assembler code for function main:
13        {
0x0000000000400521 <main+0>:     55                push %rbp
0x0000000000400522 <main+1>:     48 89 e5          mov %rsp,%rbp

14               foo(2, 5);
0x0000000000400525 <main+4>:     be 05 00 00 00    mov $0x5,%esi
0x000000000040052a <main+9>:     bf 02 00 00 00    mov $0x2,%edi
0x000000000040052f <main+14>:    e8 d2 ff ff ff    callq 0x400506 <foo>

15               return 0;
0x0000000000400534 <main+19>:    b8 00 00 00 00    mov $0x0,%eax

16        }
0x0000000000400539 <main+24>:     c9               leaveq 
0x000000000040053a <main+25>:     c3               retq

End of assembler dump.
複製程式碼

disassemble命令的/m指示顯示彙編指令的同時,顯示相應的程式原始碼;/r指示顯示十六進位制的計算機指令(raw instruction)。

以上輸出每行指示一條彙編指令,除程式原始碼外共有四列,各列含義為:

  1. 0x0000000000400521: 該指令對應的虛擬記憶體地址
  2. <main+0>: 該指令的虛擬記憶體地址偏移量
  3. 55: 該指令對應的計算機指令
  4. push %rbp: 彙編指令

一個函式被呼叫,首先預設要完成以下動作:

  • 將呼叫函式的棧幀棧底地址入棧,即將bp暫存器的值壓入呼叫棧中
  • 建立新的棧幀,將被調函式的棧幀棧底地址放入bp暫存器中

以下兩條指令即完成上面動作:

push %rbp
mov  %rsp, %rbp

也許你會問:咦?以上disassemble的輸出不是main函式的彙編指令嗎,怎麼輸出中也有上面兩條指令?難道main也是一個“被調函式”?

是的,皆因main並不是程式拉起後第一個被執行的函式,它被_start函式呼叫,更詳細的資料參看這裡

一個函式呼叫另一個函式,需先將引數準備好。main呼叫foo函式,兩個引數傳入通用暫存器中:

mov $0x5, %esi
mov $0x2, %edi

對於引數傳遞的方式,x86和x86_64定義了不同的函式呼叫規約(calling convention)。相比x86_64將引數傳入通用暫存器的方式,x86將引數壓入呼叫棧中,x86下對應foo函式傳參的彙編指令,有以下形式的輸出:

sub $0x8, %esp
mov $0x5, -0x4(%ebp)
mov $0x2, -0x8(%ebp)

引數的呼叫棧位置通過ebp儲存的棧幀棧底地址索引,棧從記憶體高地址向低地址生長,所以索引值為負數,減少esp暫存器的值表示擴充套件棧幀。

萬事具備,是時候將執行控制權交給foo函數了,call指令完成交接任務:

0x000000000040052f <main+14>:     e8 d2 ff ff ff    callq  0x400506 <foo>

一條call指令,完成了兩個任務:

  1. 將呼叫函式(main)中的下一條指令(這裡為0x400534)入棧,被調函式返回後將取這條指令繼續執行,64位rsp暫存器的值減8
  2. 修改指令指標暫存器rip的值,使其指向被調函式(foo)的執行位置,這裡為0x400506

執行完start命令後,現在程式停在0x400522的位置,下面我們通過gdb的si指令,讓程式執行完call指令:

(gdb) si 3
foo (a=0, b=4195328) at func_call.c:8
8    {
(gdb) 

此時我們再來看rsp、rbp暫存器的值,它們儲存了程式實際用到的實體記憶體地址:

(gdb) info registers rbp rsp
rbp            0x7fffffffe8e0    0x7fffffffe8e0
rsp            0x7fffffffe8d8    0x7fffffffe8d8
(gdb)

main函式君的執行到此就暫時告一段落了,此時func_call的呼叫棧情況如下:

相關暫存器資訊如下:

esi: 0x5   edi: 0x2

foo函式

foo函式被執行之後,我們使用disassemble命令顯示其彙編指令:

複製程式碼
(gdb) disassemble /rm
Dump of assembler code for function foo:
8    {
0x0000000000400506 <foo+0>:     55             push   %rbp
0x0000000000400507 <foo+1>:     48 89 e5       mov    %rsp,%rbp
0x000000000040050a <foo+4>:     48 83 ec 08    sub    $0x8,%rsp
0x000000000040050e <foo+8>:     89 7d fc       mov    %edi,-0x4(%rbp)
0x0000000000400511 <foo+11>:    89 75 f8       mov    %esi,-0x8(%rbp)

9        return bar(a, b);
0x0000000000400514 <foo+14>:     8b 75 f8      mov    -0x8(%rbp),%esi
0x0000000000400517 <foo+17>:     8b 7d fc      mov    -0x4(%rbp),%edi
0x000000000040051a <foo+20>:     e8 cd ff ff ff    callq  0x4004ec <bar>

10    }
0x000000000040051f <foo+25>:     c9    leaveq 
0x0000000000400520 <foo+26>:     c3    retq   

End of assembler dump.
(gdb)
複製程式碼

前面兩條指令將main函式棧幀的棧底地址入棧,建立foo函式的棧幀。接著的三條指令擴充套件棧幀,將傳入的引數存為函式內區域性變數。最後三條指令與bar函式呼叫相對應,也是先將引數傳入esi、edi暫存器,然後執行call指令。

繼續執行si命令,讓程式執行到call指令的位置:

複製程式碼
(gdb) si 8
bar (c=32767, d=-139920736) at func_call.c:2
2    {
(gdb) info registers rbp rsp
rbp            0x7fffffffe8d0    0x7fffffffe8d0
rsp            0x7fffffffe8c0    0x7fffffffe8c0
(gdb)
複製程式碼

foo函式呼叫bar函式之後,bar函式執行之前,呼叫棧資訊如下:

相關暫存器資訊如下:

esi: 0x5   edi: 0x2

bar函式

此時程式執行至bar函式,同樣,我們先用disassemble看一下bar函式的彙編指令:

複製程式碼
(gdb) disassemble /rm
Dump of assembler code for function bar:
2    {
0x00000000004004ec <bar+0>:     55          push   %rbp
0x00000000004004ed <bar+1>:     48 89 e5    mov    %rsp,%rbp
0x00000000004004f0 <bar+4>:     89 7d ec    mov    %edi,-0x14(%rbp)
0x00000000004004f3 <bar+7>:     89 75 e8    mov    %esi,-0x18(%rbp)

3        int e = c + d;
0x00000000004004f6 <bar+10>:     8b 55 e8    mov    -0x18(%rbp),%edx
0x00000000004004f9 <bar+13>:     8b 45 ec    mov    -0x14(%rbp),%eax
0x00000000004004fc <bar+16>:     01 d0       add    %edx,%eax
0x00000000004004fe <bar+18>:     89 45 fc    mov    %eax,-0x4(%rbp)

4        return e;
0x0000000000400501 <bar+21>:     8b 45 fc    mov    -0x4(%rbp),%eax

5    }
0x0000000000400504 <bar+24>:     c9    leaveq 
0x0000000000400505 <bar+25>:     c3    retq   

End of assembler dump.
(gdb)
複製程式碼

對於最前面兩條指令我們應該很熟悉了:將foo函式棧幀的棧底地址入棧,建立bar函式的棧幀。但後面兩條指令與foo函式中對應位置的指令就不一樣了,這裡為什麼不擴充套件棧幀,不像foo函式彙編指令那樣將引數的值存入呼叫棧呢?

原因就是bar函式是最後一個被呼叫的函數了,foo函式中的區域性變數在bar函式返回後還有可能被操作,而bar函式的區域性變數已失去儲存的必要。以上“{}”中剩餘的指令利用edx和eax暫存器完成加法操作,最後結果儲存在eax暫存器中,以作為結果返回。

至此,呼叫棧資訊如下:

相關暫存器資訊如下:

esi: 0x5   edi: 0x2   edx: 0x5   eax: 0x7

這時我們再來使用gdb的x命令檢視記憶體資訊:

複製程式碼
(gdb) x/16x 0x7fffffffe8a0 
0x7fffffffe8a0:    0x00000005    0x00000002    0x00400595    0x00000000
0x7fffffffe8b0:    0xf7ffa658    0x00000007    0xffffe8d0    0x00007fff
0x7fffffffe8c0:    0x0040051f    0x00000000    0x00000005    0x00000002
0x7fffffffe8d0:    0xffffe8e0    0x00007fff    0x00400534    0x00000000
(gdb) 
複製程式碼

以上命令顯示16個4bytes記憶體地址指示的值,且值以十六進位制顯示。比較下,看這裡的輸出與上面的呼叫棧資訊是否一致?

函式返回過程

函式呼叫過程對應著呼叫棧的建立,而函式返回則是進行呼叫棧的銷燬,返回比呼叫過程簡單多了,畢竟破壞比建設來的容易。在main、foo和bar函式的彙編顯示中,我們都可以看到leave和ret兩條指令:

0x0000000000400504 <bar+24>:     c9    leaveq 
0x0000000000400505 <bar+25>:     c3    retq

leave指令等價於以下兩條指令:

mov %rbp, %rsp
pop %rbp

這兩條指令將bp和sp暫存器中的值還原為函式呼叫前的值,是函式開頭兩條指令的逆向過程。ret指令修改了ip暫存器的值,將其設定為原函式棧幀中將要執行的指令地址。bar函式的leave和ret執行完之後,呼叫棧資訊變為:

rip暫存器的值為0x40051f

剩餘的函式返回過程類似,直至所有函式執行完成、呼叫棧被銷燬。

小結

本文通過一個簡單的函式呼叫例項,結合gdb單步除錯和反彙編工具,對函式呼叫的底層實現過程進行了分析。

修改sp、bp暫存器記錄棧幀的高、低地址,以此完成函式調轉;

push/mov操作儲存caller變數、指令資訊,保證callee返回之後caller繼續正常執行;

⋯⋯

棧這種簡單的資料結構優雅地完成了支撐計算機程式執行的任務。

我們可以參照這樣的思路,在編碼實現功能需求時,分析所要實現的功能,選擇恰當的資料結構和實現方式,力求做到優雅、簡潔。

------------------------------------------------------------

本文基於Suse11sp1(x86_64),該發行版可從這裡下載。

# cat /etc/SuSE-release;uname -r
SUSE Linux Enterprise Desktop 11 (x86_64)
VERSION = 11
PATCHLEVEL = 1
2.6.32.12-0.7-default