從棧溢出到獲取棧大小

從棧溢出到獲取棧大小

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 棧溢出的幾種典型情況

  1. 函數遞歸調用,在到達遞歸終止條件之前,棧的開銷超過了限定值。

  2. 函數調用鏈路過長,callstack 呈現爲超長鏈表,累計的棧內存開銷較大。

這種情況不是遞歸,但和遞歸很像。 好的程序的 callstack 應當是有分叉, 呈現樹狀, 而不是鏈表狀。

  1. 局部變量size過大,儘管函數調用層次可能很淺(甚至只有 main 函數), 仍然棧溢出。 具體又包含如下典型情況:
  • 在函數內,直接定義了超大數組:解決方法是改用new/malloc申請
  • 定義了size很大的結構體或class,並且在函數內創建了實例:
    • 例如直觀的: 單個結構體裏有一個超大數組的成員(e.g. Visual Studio 鼠標懸停,Intellisense會提示棧大小)
    • 或者間接的: 結構體的成員也是結構體,內層結構體的數組成員M, 乘以外層結構體的數組元素數量N, 乘積很大
    • 解決方法是改爲new/malloc申請
  1. 平臺或編譯工具鏈限定, 例如 HVX cDSP 平臺的 Hexagon-clang, 限定了14000 bytes 左右。

3. 確定最大棧大小

3.1 暴力法

  1. 編寫代碼,創建大數組
  2. 編譯
  3. 運行
  4. 判斷運行結果
    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 減小棧開銷

  1. 使用 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 文件驗證。

  1. 使用柔性數組

使用柔性數組替代結構體中定義的大數組。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 方式而不是存放在棧上, 都可以規避棧開銷過大的問題。

8. References

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章