从栈溢出到获取栈大小
从栈溢出到获取栈大小
Author: ChrisZZ imzhuo@foxmail.com
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 方式而不是存放在栈上, 都可以规避栈开销过大的问题。