從棧溢出到獲取棧大小
Author: ChrisZZ [email protected]
Create Time: 2024-05-14 23:22:38
Update Time: 2024-05-18 12:02:52
1. 棧溢出是一個運行時報錯
在谷歌搜索 "Stack Overflow", 靠前的結果是一個問答網站。它的本意是“棧溢出”, 是一種運行時錯誤,例如編寫的 C/C++ 代碼,編譯後運行的時在某個函數的第一句還沒執行就掛了。
當使用 GCC 編譯器,程序運行的表現是 "segment fault".
當使用 MSVC 編譯器, 程序運行的表現是 "Stack Overflow".
2. 爲什麼會出現棧溢出
2.1 運行時的棧大小被限定了
生成可執行程序時, 鏈接器可以指定運行時棧大小, 超過這個尺寸就發生棧溢出。
對於 MSVC 編譯器, 默認棧大小是 1MB; 對於 Linux GCC, 默認棧大小是 8MB。
2.2 棧是怎麼被消耗的
棧,是棧內存的簡稱, 是區別於堆的內存。函數裏定義局部變量,就消耗棧內存,這是單個函數內的情況。 函數之間相互調用, 被調用者的棧內存開銷, 累加到調用者身上, 構成了總的棧內存開銷。
直觀理解: C/C++ 程序運行時,入口函數通常爲 main()
, 假設它調用函數 f1()
, f1()
裏面調用 f2()
, 那麼在執行 f2()
時的棧開銷就是 main + f1 + f2.
如果 main()
先調用 f1()
, 調用完畢再調用 f2()
, 那麼在執行 f2()
時的棧開銷就是 main + f2.
當然,這裏假設了 f2()
是葉子函數, 不會調用函數,也不會調用 f2()
自身。
2.3 棧溢出的幾種典型情況
-
函數遞歸調用,在到達遞歸終止條件之前,棧的開銷超過了限定值。
-
函數調用鏈路過長,callstack 呈現爲超長鏈表,累計的棧內存開銷較大。
這種情況不是遞歸,但和遞歸很像。 好的程序的 callstack 應當是有分叉, 呈現樹狀, 而不是鏈表狀。
- 局部變量size過大,儘管函數調用層次可能很淺(甚至只有 main 函數), 仍然棧溢出。 具體又包含如下典型情況:
- 在函數內,直接定義了超大數組:解決方法是改用new/malloc申請
- 定義了size很大的結構體或class,並且在函數內創建了實例:
- 例如直觀的: 單個結構體裏有一個超大數組的成員(e.g. Visual Studio 鼠標懸停,Intellisense會提示棧大小)
- 或者間接的: 結構體的成員也是結構體,內層結構體的數組成員M, 乘以外層結構體的數組元素數量N, 乘積很大
- 解決方法是改爲new/malloc申請
- 平臺或編譯工具鏈限定, 例如 HVX cDSP 平臺的 Hexagon-clang, 限定了14000 bytes 左右。
3. 確定最大棧大小
3.1 暴力法
- 編寫代碼,創建大數組
- 編譯
- 運行
- 判斷運行結果
a) 如果運行時沒有 crash,則回到步驟1), 並增加數組大小, 否則進入5)
b) 運行 crash 了,說明超過棧的最大大小,則回到步驟1), 減小數組大小
c) 如果恰好到了臨界值,多了會crash,少了不會crash,那就停止
int main()
{
int a[1024*1024*8];
return 0;
}
優點是直觀,容易操作, 缺點是需要多次嘗試, 在交叉編譯和推送設備運行的情況下比較低效率。
3.2 遞歸法
get_stack_size.c:
#include <stdio.h>
enum { unit_size = 1024 };
const char* unit_str = "KB";
void f(int depth)
{
char a[unit_size];
printf("depth = %d, stacksize = %d %s\n", depth, depth * unit_size, unit_str);
f(depth+1);
}
int main()
{
int depth = 1;
f(depth);
return 0;
}
編譯運行:
depth = 7577, stacksize = 7758848 KB
depth = 7578, stacksize = 7759872 KB
[1] 43864 segmentation fault ./a.out
優點:一次編譯、一次運行,比較省事,粗略得到了棧大小。
缺點:打印出的 stacksize 並不準確,單個函數的棧開銷並不是 1024 字節。
4. 確定實際棧開銷大小
4.1 讓編譯器報告棧大小
GCC/Clang 提供了 -fstack-usage
編譯選項, 生成 .c/.cpp 源文件同名的 .su
文件,標註出了每個函數的棧開銷大小.
$ gcc get_stack_size.c -fstack-usage
$ cat get_stack_size.su
get_stack_size.c:6:f 1104 static
get_stack_size.c:13:main 32 static
因此,更準確一點的棧大小是 8564864 字節:
>>> 32 + 1104*7758
8564864
優點:每個函數的棧開銷都是準確的。
缺點:
- 需要在 .su 文件裏找到對應函數的結果, 不如直接在函數裏打印直觀
- 整個調用鏈路的棧開銷總和,需要手動加出來。當調用層級較深時,不好計算。
4.2 在函數裏打印準確的棧大小: 利用棧頂指針的差值
rsp(棧指針寄存器):指向當前棧頂,隨着函數調用和局部變量的分配而變化。使用 rsp 可以準確地反映出函數調用過程中棧的變化。
對於 x86_64 ,使用 rsp 寄存器。 對於 aarch64, 使用 sp 寄存器。
#include <stdio.h>
unsigned long get_stack_pointer() {
unsigned long sp;
#if defined(__x86_64__)
asm("mov %%rsp, %0" : "=r"(sp));
#elif defined(__aarch64__)
asm("mov %0, sp" : "=r"(sp));
#else
#error "Unsupported architecture"
#endif
return sp;
}
void func(unsigned long sp_start) {
char buf[1024];
unsigned long sp_end = get_stack_pointer();
// 計算棧開銷
unsigned long stack_usage = sp_start - sp_end;
printf("Stack usage (using rsp): %lu bytes\n", stack_usage);
}
int main() {
unsigned long sp_start = get_stack_pointer();
// 調用函數
func(sp_start);
return 0;
}
gcc report_stack_size.c -su -o report_stack_size
./report_stack_size
Stack usage (using rsp): 1104 bytes
和 .su 文件裏一致:
report_stack_size.c:3:get_base_pointer 16 static
report_stack_size.c:17:func 1104 static
report_stack_size.c:26:main 48 static
優點: 能準確測量單個函數,或調用鏈路中的一段連續的函數的棧開銷總和.
缺點: 如果程序提前掛了, 無法測量出結果。
4.3 運行三次:準確判斷棧溢出導致程序崩潰
真實情況下的 stack overflow, 如果手頭有源碼可以在穩定崩潰的地方加條件判斷和打印。
第一次運行程序, 找到程序在非預期崩潰的地方。
第二次運行程序, 確認是在相同地方崩潰, 也就是穩定的崩潰。
修改代碼, 在程序 main() 開頭的地方獲取起始棧的值, 在穩定崩潰的地方獲取當前棧大小, 並打印輸出。
第三次運行程序, 在穩定崩潰前打印出當前棧開銷大小。
舉例:
#include <stdio.h>
unsigned long sp_start;
unsigned long sp_end;
unsigned long stack_usage;
unsigned long get_stack_pointer() {
unsigned long sp;
#if defined(__x86_64__)
asm("mov %%rsp, %0" : "=r"(sp));
#elif defined(__aarch64__)
asm("mov %0, sp" : "=r"(sp));
#else
#error "Unsupported architecture"
#endif
return sp;
}
void f(int depth)
{
char buf[1024];
printf("depth = %d\n", depth);
if (depth == 7688)
{
sp_end = get_stack_pointer();
stack_usage = sp_start - sp_end;
printf("Stack usage (using rsp): %lu bytes\n", stack_usage);
}
f(depth+1);
}
int main() {
sp_start = get_stack_pointer();
f(0);
return 0;
}
f()
本身在遞歸深度 depth 較大時會 crash, 通過增加打印知道 depth = 7688 時是穩定崩潰點。
在這個條件滿足時, 增加打印當前棧開銷大小, 並再次運行程序, 使得程序崩潰前打印出準確的棧開銷大小:
depth = 7687
depth = 7688
Stack usage (using rsp): 8365632 bytes
[1] 14465 segmentation fault ./a.out
>>> 8365632/(1024*1024)
7.97808837890625
棧開銷是 7.97 MB 大小。
4.5 檢查單個函數的棧開銷是否過大
GCC 提供了 -stack_size
鏈接選項
gcc -Wl,-stack_size,0x1000000 -o my_program my_program.c
當有函數的棧開銷超過這一數值時就報警.
5. 避免棧溢出
5.1 修改最大棧大小
不改代碼的情況下,修改鏈接選項,增大棧大小。
Linux/macOS: ulimit 命令。
Windows MSVC: 給鏈接器指定 /stack:<棧大小>
參數, 例如 /stack:10485760
, 10MB。
可在 CMakeLists.txt 中設定
if(MSVC)
target_link_options(your_target_name PRIVATE "/STACK:10485760")
endif()
5.2 減小棧開銷
- 使用 new/malloc
使用 new/malloc 替代直接定義大size的棧對象。
#include <stdlib.h>
struct Engine
{
int id;
int data[1024];
};
typedef struct Engine Engine;
int main()
{
//Engine engine; // 這種方式下,總的棧大小是 4128 字節
Engine* engine = (Engine*)malloc(sizeof(Engine)); // 這種方式下,總的棧大小是 48 字節
return 0;
}
gcc -c main.c -fstack-usage
查看 .su 文件驗證。
- 使用柔性數組
使用柔性數組替代結構體中定義的大數組。e.g. https://github.com/jonhoo/pthread_pool/blob/master/pthread_pool.c#L21-L27
struct pool {
char cancelled;
void *(*fn)(void *);
unsigned int remaining;
unsigned int nthreads;
struct pool_queue *q;
struct pool_queue *end;
pthread_mutex_t q_mtx;
pthread_cond_t q_cnd;
pthread_t threads[1];// 柔性數組聲明
};
void * pool_start(void * (*thread_func)(void *), unsigned int threads)
{
struct pool *p = (struct pool *) malloc(sizeof(struct pool) + (threads-1) * sizeof(pthread_t)); //柔性數組填充
int i;
...
}
6. Python 中的棧溢出
Python 默認限定遞歸次數不能超過1000次. 雖然不是直接的超過了棧大小,但是間接避免了棧開銷過大的問題.
def f(depth: int):
print(depth)
f(depth+1)
f(1)
995
996
997
Traceback (most recent call last):
File "/Users/zz/work/immitate/Cpp-homework/test2.py", line 5, in <module>
f(1)
File "/Users/zz/work/immitate/Cpp-homework/test2.py", line 3, in f
f(depth+1)
File "/Users/zz/work/immitate/Cpp-homework/test2.py", line 3, in f
f(depth+1)
File "/Users/zz/work/immitate/Cpp-homework/test2.py", line 3, in f
f(depth+1)
[Previous line repeated 993 more times]
File "/Users/zz/work/immitate/Cpp-homework/test2.py", line 2, in f
print(depth)
RecursionError: maximum recursion depth exceeded while calling a Python object
使用 sys.getrecursionlimit()
和 sys.setrecursionlimit()
來觀察和修改這個限制.
7. 總結
列舉了一些方法, 測量了程序可使用的棧大小的最大值。
基於 rsp 寄存器的測量方法, 更適合在已有的工程代碼中, 測量整體的棧開銷, 從而驗證是否是棧開銷太大導致了程序崩潰。
也列舉了一些方法, 在程序運行前規避問題。
GCC 的編譯、 鏈接選項, 在程序運行前能夠檢查和放寬棧的使用情況。 減小結構體大小, 可以是精簡定義, 也可以是使用柔性數組。 如果實在要用大的結構體, 考慮 new/malloc 方式而不是存放在棧上, 都可以規避棧開銷過大的問題。