在看了《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