跳到主要內容

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 應該會比較小才對??

    回覆刪除

張貼留言

這個網誌中的熱門文章

(C/C++ ) 如何在 Linux 上使用自行編譯的第三方函式庫

以使用 LevelDB 為例。 抓好並編好相關檔案,編譯方式見第三方函式庫附的說明:$ ls include/ # header files leveldb/ $ ls out-shared/libleveldb.so* # shared library out-shared/libleveldb.so@ out-shared/libleveldb.so.1@ out-shared/libleveldb.so.1.20* 下面的例子用 clang++ 編譯,這裡用到的參數和 g++ 一樣。 問題一:找不到 header$ clang++ sample.cpp sample.cpp:5:10: fatal error: 'leveldb/db.h' file not found #include "leveldb/db.h" ^ 1 error generated. 解法:用 -I 指定 header 位置 問題二:找不到 shared library$ clang++ sample.cpp -I include/ /tmp/sample-2e7dd8.o: In function `main': sample.cpp:(.text+0x1e): undefined reference to `leveldb::Options::Options()' sample.cpp:(.text+0x6f): undefined reference to `leveldb::DB::Open(leveldb::Options const&, std::string const&, leveldb::DB**)' sample.cpp:(.text+0x10c): undefined reference to `leveldb::Status::ToString() const' sample.cpp:(.text+0x7d0): undefined reference to `leveldb::Status::ToString() const' clang: error: linker command failed with exit code 1 (u…

熟悉系統工具好處多多

記一下以前很困擾, 現在秒殺的小事。 更新這篇的時候, 忘了函式庫用的 man page 裝在那個 package。以前就會想辦法 google, 運氣好一下會找到, 運氣不好會多找一會兒。 這回我想到新作法:$ strace -e open man 3 printf > /dev/null # 發現是讀 /usr/share/man/man3/printf.3.gz $ dpkg --search /usr/share/man/man3/printf.3.gz # 找到套件名稱 manpages-dev $ aptitude show manpages-dev # 確認描述符合, 收工

virtualbox 使用 USB 裝置

2012-12-16 更新 現在 (4.x 版) 似乎無需做任何設定, 只要有裝 Oracle VM VirtualBox Extension Pack, 在 VirtualBox 視窗右下角按 USB 的圖示, 再點目標裝置, 即可加入或移除該裝置 同一時間只有 host 或 guest 可擁有該裝置, 所以從 guest OS 移除, 相當於接回 host OS 目前 VirtualBox 只支援 USB 2.0 的插槽, 若偵測不到時, 注意一下是否為這個問題 有時拔拔插插, VirtualBox 會進入奇怪的狀態, 接上去 guest OS 無法連接且跳出 device is busy 的錯誤訊息。試看看拔除該裝置, 重開 guest OS (續上則) 若重開 guest OS 無效, 並且 host OS 已移除該裝置, VirtualBox 的 USB 清單卻仍顯示 "captured", 試看看拔除該裝置, 重開 host OS原文網路上搜一下, 比較多是 Ubuntu 當 host 的解法, 我的情況是 Win7 當 host, Ubuntu 當 guest。 這兩篇說明很詳細《Learn How to Set Up USB and Networking Options in VirtualBox》《幻影千瞳的部落格: VirtualBox 使用筆記(二):使用 USB 裝置》 現在的版本圖形介面很好用了, 不用像第二篇說的那樣用指令操作。這裡記下我的操作步驟: 關掉 guest OS 在 VirtualBox 選單, 選擇 guest OS -> Settings -> USB -> Enable USB 2.0 會出現訊息框, 說明要安裝 Oracle VM VirtualBox Extension Pack。下載後安裝它 host OS 插入 USB 隨身碟 在 VirtualBox 選單, 選擇 guest OS -> Settings -> USB, 點右邊有綠色 "+" 的 USB 頭的圖示, 選擇該 USB 隨身碟, 加入它的 filter 從 host OS 移除 USB 隨身碟 開啟 guest OS 插入 USB 隨身碟, 於是 guest OS 會自動偵測…