1. 程式人生 > >函式呼叫棧的獲取原理分析

函式呼叫棧的獲取原理分析

上一篇文章《在Linux程式中輸出函式呼叫棧》,講述了在Linux中如何利用backtrace獲取呼叫棧,本篇文章主要介紹一下獲取函式呼叫棧的原理,並給出相應的實現方式。

要了解呼叫棧,首先需要了解函式的呼叫過程,下面用一段程式碼作為例子:

#include <stdio.h>

int add(int a, int b) {
    int result = 0;

    result = a + b;

    return result;
}

int main(int argc, char *argv[]) {
    int result = 0;

    result
= test(1, 2); printf("result = %d \r\n", result); return 0; }

使用gcc編譯,然後gdb反彙編main函式,看看它是如何呼叫add函式的:

(gdb) disassemble main 
Dump of assembler code for function main:
   0x08048439 <+0>:     push   %ebp
   0x0804843a <+1>:     mov    %esp,%ebp
   0x0804843c <+3>:     and    $0xfffffff0
,%esp 0x0804843f <+6>: sub $0x20,%esp 0x08048442 <+9>: movl $0x0,0x1c(%esp) # 給result變數賦0值 0x0804844a <+17>: movl $0x2,0x4(%esp) # 將第2個引數壓棧(該引數偏移為esp+0x04) 0x08048452 <+25>: movl $0x1,(%esp) # 將第1個引數壓棧(該引數偏移為esp+0x00) 0x08048459 <+32>: call 0x804841c
<add> # 呼叫add函式 0x0804845e <+37>: mov %eax,0x1c(%esp) # 將add函式的返回值賦給result變數 0x08048462 <+41>: mov 0x1c(%esp),%eax 0x08048466 <+45>: mov %eax,0x4(%esp) 0x0804846a <+49>: movl $0x8048510,(%esp) 0x08048471 <+56>: call 0x80482f0 <printf@plt> 0x08048476 <+61>: mov $0x0,%eax 0x0804847b <+66>: leave 0x0804847c <+67>: ret End of assembler dump.

可以看到,引數是在add函式呼叫前壓棧,換句話說,引數壓棧由呼叫者進行,引數儲存在呼叫者的棧空間中,下面再看一下進入add函式後都做了什麼:

(gdb) disassemble add
Dump of assembler code for function add:
   0x0804841c <+0>:     push   %ebp             # 將ebp壓棧(儲存函式呼叫者的棧基址)
   0x0804841d <+1>:     mov    %esp,%ebp        # 將ebp指向棧頂esp(設定當前函式的棧基址)
   0x0804841f <+3>:     sub    $0x10,%esp       # 分配棧空間(棧向低地址方向生長)
   0x08048422 <+6>:     movl   $0x0,-0x4(%ebp)  # 給result變數賦0值(該變數偏移為ebp-0x04)
   0x08048429 <+13>:    mov    0xc(%ebp),%eax   # 將第2個引數的值賦給eax(準備運算)
   0x0804842c <+16>:    mov    0x8(%ebp),%edx   # 將第1個引數的值賦給edx(準備運算)
   0x0804842f <+19>:    add    %edx,%eax        # 加法運算(edx+eax)結果儲存在eax中
   0x08048431 <+21>:    mov    %eax,-0x4(%ebp)  # 將運算結果eax賦給result變數
   0x08048434 <+24>:    mov    -0x4(%ebp),%eax  # 將result變數的值賦給eax(eax將作為函式返回值)
   0x08048437 <+27>:    leave                   # 恢復函式呼叫者的棧基址(pop %ebp)
   0x08048438 <+28>:    ret                     # 返回(準備執行下條指令)
End of assembler dump.

進入add函式後,首先進行的操作是將當前的棧基址ebp壓棧(此棧基址是呼叫者main函式的),然後將ebp指向棧頂esp,接下來再進行函式內的處理流程。函式結束前,會將函式呼叫者的棧基址恢復,然後返回準備執行下一指令。這個過程中,棧上的空間會是下面的樣子:

函式呼叫過程中棧的情況

可以發現,每呼叫一次函式,都會對呼叫者的棧基址(ebp)進行壓棧操作,並且由於棧基址是由當時棧頂指標(esp)而來,會發現,各層函式的棧基址很巧妙的構成了一個鏈,即當前的棧基址指向下一層函式棧基址所在的位置,如下圖所示:

呼叫棧中各層函式棧基址間的關係

瞭解了函式的呼叫過程,想要回溯呼叫棧也就很簡單了,首先獲取當前函式的棧基址(暫存器ebp)的值,然後獲取該地址所指向的棧的值,該值也就是下層函式的棧基址,找到下層函式的棧基址後,重複剛才的動作,即可以將每一層函式的棧基址都找出來,這也就是我們所需要的呼叫棧了。

下面是根據原理實現的一段獲取函式呼叫棧的程式碼,供參考。

#include <stdio.h>

/* 列印呼叫棧的最大深度 */
#define DUMP_STACK_DEPTH_MAX 16

/* 獲取暫存器ebp的值 */
void get_ebp(unsigned long *ebp) {
    __asm__ __volatile__ (
        "mov %%ebp, %0"
        :"=m"(*ebp)
        ::"memory");
}

/* 獲取呼叫棧 */
int dump_stack(void **stack, int size) {
    unsigned long ebp = 0;
    int depth = 0;

    /* 1.得到首層函式的棧基址 */
    get_ebp(&ebp);

    /* 2.逐層回溯棧基址 */
    for (depth = 0; (depth < size) && (0 != ebp) && (0 != *(unsigned long *)ebp) && (ebp != *(unsigned long *)ebp); ++depth) {
        stack[depth] = (void *)(*(unsigned long *)(ebp + sizeof(unsigned long)));
        ebp = *(unsigned long *)ebp;
    }

    return depth;
}

/* 測試函式 2 */
void test_meloner() {
    void *stack[DUMP_STACK_DEPTH_MAX] = {0};
    int stack_depth = 0;
    int i = 0;

    /* 獲取呼叫棧 */
    stack_depth = dump_stack(stack, DUMP_STACK_DEPTH_MAX);

    /* 列印呼叫棧 */
    printf(" Stack Track: \r\n");
    for (i = 0; i < stack_depth; ++i) {
        printf(" [%d] %p \r\n", i, stack[i]);
    }

    return;
}

/* 測試函式 1 */
void test_hutaow() {
    test_meloner();
    return;
}

/* 主函式 */
int main(int argc, char *argv[]) {
    test_hutaow();
    return 0;
}

原始檔下載:連結

執行gcc dumpstack.c -o dumpstack編譯並執行,執行結果如下:

 Stack Track: 
 [0] 0x8048475 
 [1] 0x8048508 
 [2] 0x804855c 
 [3] 0x804856a