在看了《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, 沒有寫得很精確:
- 準備參數給 callee
- 備份 caller 用到的一些暫存器
- 備份 caller 下一個要執行的指令位置
- 跳到 callee 要執行的指令位置
- ( callee 備份 caller 的 frame's base pointer, 改變 frame's base pointer (ebp)、 stack pointer (esp) )
- ( callee 自己準備空間做為區域變數 )
- ( callee 結束後, callee 要清掉自己的區域變數 )
- 待 callee 結束後, 取得 callee 運算結果
- 待 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
MSDN中有介紹 __stdcall (callee清除)或 __cdecl (預設calling convention,caller清除)的差別。
回覆刪除同一個程式碼,其中的函式用 __cdecl會比用__stdcall宣告的程式碼size肥胖一點。
大部份的Win32 API都用__stdcall宣告,可能就是為了省一點size吧?
為什麼用 __cdecl 會比較肥啊? 若編出來的東西像文中 gcc 編出來的結果, caller/callee 少了幾個 pop 指令, binary size 應該會比較小才對??
回覆刪除