从栈溢出到获取栈大小

从栈溢出到获取栈大小

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 栈溢出的几种典型情况

  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

posted @ 2024-05-17 22:41  ChrisZZ  阅读(305)  评论(0编辑  收藏  举报