x86 呼叫函式的設計慣例 (call convention)

在看了《BUFFER OVERFLOW 5 - C/C++ Function Operation》後, 才明白 stack frame 的細節, 果然教人攻擊的文件講得最清楚啊。後來又看了《x86 calling conventions》, 才明白 compiler 編譯高階語言為組語時, 只是定好一套規範, 確保能和 OS 的 linker、loader 搭起來, 到沒有規定一定得怎麼做。編譯的其中一項設計, 是規範 caller 和 callee (被呼叫的函式) 之間怎麼互動, 也就是怎麼傳參數過去, 怎麼取回運算結果。《x86 calling conventions》有列多種做法。

換句話說, 這和設計及使用框架一樣, 為了方便上層開發者, 針對某項工作制定了一整個框架, 只要照著框架的規範使用框架提供的工具, 寫起來就會很快。不過也因此犠牲掉一些彈性和效率, 是不可免的 trade-off。巨觀來看, 設計工具將高階語言編成可執行檔, 也是設計一套框架。如同使用其它框架一樣, 框架幫開發者省去細節, 專心於開發應用程式上; 而了解框架能進一步讓使用者用得更順手, 還有從框架的設計中學到深入的技巧。

呼叫函式時, caller 大致上需要做以下的事, 順序是我大概列的, 只是一種 call convention, 沒有寫得很精確:

  1. 準備參數給 callee
  2. 備份 caller 用到的一些暫存器
  3. 備份 caller 下一個要執行的指令位置
  4. 跳到 callee 要執行的指令位置
  5. ( callee 備份 caller 的 frame's base pointer, 改變 frame's base pointer (ebp)、 stack pointer (esp) )
  6. ( callee 自己準備空間做為區域變數 )
  7. ( callee 結束後, callee 要清掉自己的區域變數 )
  8. 待 callee 結束後, 取得 callee 運算結果
  9. 待 callee 結束後, 清掉傳給 callee 的參數

對照用 gcc -S 編譯 C 程式成組語來看原組語碼, 有一些小心得:

  • 了解呼叫函式要做這麼多事, 也難怪小動作要盡量 inline, 不要真的呼叫函式; 還有避免使用遞迴, 改用 loop + stack。
  • 明白函式內用 static 宣告的變數, 會放到 .data 或 .bss (視有無初始值), 而不在 stack 上。所以函式結束後, 能夠保留原本狀態。
  • 因為呼叫函式是一層層地將函式參數、caller return address、區域變數等資料丟到 stack 上, 當函式結束時, 不用清掉剛才用的區域變數, 只要將 frame pointer 和 stack pointer 指回 caller 用的位置, 就能跳回 caller 之前執行的指令接著跑 (有時仍需還原一些用到的 register)。雖然因此不能取得區域變數, 但省下清記憶體的時間, 算不錯的 trade-off。反過來說, 也沒人規定「一定不能取回結束函式內的區域變數」, 這是 C/C++ 的語法規範, 而相關工具也只是照語法實作而已。
  • 由於 stack pointer (esp) 會變動, 使用 frame pointer (ebp) 比較方便取得參數和區域變數, 分別在 ebp 的上下方。ebp 附近也存了準備還原回 caller 狀態的暫存器, 如 caller 的 ebp。
  • gcc 在進入函式時, 會先計算之後呼叫函式時, 需要多大的空間傳參數, 多讓 stack 往下長一些空間, 這樣之後要呼叫函式時, 可以直接將參數放到預留空間上, 這樣函式結束時, 不需要清掉參數。下面舉例說明。
$ cat f.c
#include <stdio.h>

int add2(int a, int b) {
    return a + b;
}

int add3(int a, int b, int c) {
    return a + b + c;
}

int main(void) {
    add2(3, 5);
    add3(3, 5, 8);
    return 0;
}
$ gcc -S f.c
$ cat f.s
        .file   "f.c"
        .text
.globl add2
        .type   add2, @function
add2:
        pushl   %ebp
        movl    %esp, %ebp
        movl    12(%ebp), %eax
        movl    8(%ebp), %edx
        leal    (%edx,%eax), %eax
        popl    %ebp
        ret
        .size   add2, .-add2
.globl add3
        .type   add3, @function
add3:
        pushl   %ebp
        movl    %esp, %ebp
        movl    12(%ebp), %eax
        movl    8(%ebp), %edx
        leal    (%edx,%eax), %eax
        addl    16(%ebp), %eax
        popl    %ebp
        ret
        .size   add3, .-add3
.globl main
        .type   main, @function
main:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $12, %esp  # 在 stack 上預留 4 * 3 = 12 bytes 放參數
        movl    $5, 4(%esp)
        movl    $3, (%esp)
        call    add2
        movl    $8, 8(%esp)  # 呼叫完 add2 後不需清 stack 上的參數
        movl    $5, 4(%esp)
        movl    $3, (%esp)
        call    add3
        movl    $0, %eax
        leave
        ret
        .size   main, .-main
        .ident  "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
        .section        .note.GNU-stack,"",@progbits

原本的作法是在呼叫函式前用 push 放參數, 呼叫完函式後, caller 用 pop 清掉參數。上面的例子, 則省掉清參數的動作, 也是空間換時間的好例子。

之所以如此強調「清函式參數」和「清區域變數」的行為, 是因為記憶體相關操作相對於 CPU 運算很慢, 要記憶體也慢、清記憶體也慢 (可以實驗看看跑 tree-based 的演算法, 然後比較有無 free tree 的情況, 效能差多少), 了解這些小動作省去清記憶體的動作, 覺得很讚。仔細一想, 這和預先要一塊 buffer, 然後自己管記憶體以減少頻繁地要小塊記憶體的概念相似, 只是在呼叫函式的時候, stack 就是那塊 buffer。

最後附上兩個自己試的小例子, 說明 stack frame 結束後, 原記憶體上的資料仍會留著, 可以將它讀出來, 不過不是什麼正確的行為就是了。

例一:

#include <stdio.h>

void a(void) {
    int i;
    printf("%d ", i);
    i = 10;
    printf("%d\n", i);
}

void b(void) {
    int i;
    printf("%d ", i);
    i = 20;
    printf("%d\n", i);
}

int main(void) {
    a();
    b();
    a();
    return 0;
}

執行結果:

-1080771896 10
10 20
20 10

例二:

#include <stdio.h>

struct Point {
    long x, y;
};

struct Point get_p(void) {
    struct Point a;
    return a;
}

void set_x(void) {
    struct Point a;
    a.x = 1;
}

void set_y(void) {
    struct Point a;
    a.y = 2;
}

int main(void) {
    struct Point t;
    t.x = t.y = 100;
    set_x();
    set_y();
    t = get_p();
    printf("%ld %ld\n", t.x, t.y);
    return 0;
}

執行結果:

1 2

留言

  1. MSDN中有介紹 __stdcall (callee清除)或 __cdecl (預設calling convention,caller清除)的差別。

    同一個程式碼,其中的函式用 __cdecl會比用__stdcall宣告的程式碼size肥胖一點。
    大部份的Win32 API都用__stdcall宣告,可能就是為了省一點size吧?

    回覆刪除
  2. 為什麼用 __cdecl 會比較肥啊? 若編出來的東西像文中 gcc 編出來的結果, caller/callee 少了幾個 pop 指令, binary size 應該會比較小才對??

    回覆刪除

張貼留言

這個網誌中的熱門文章

virtualbox 使用 USB 裝置

熟悉系統工具好處多多

如何 git merge 更改檔名的檔案