目录

我最近翻看 MySQL 源码时看到了一个很有意思的编程实践:通过自主检测栈空间的大小来做防御,避免栈溢出导致进程崩溃。

今天就来研究下这个实践的原理与实现方式。

简单回顾下栈的特点

栈是每个线程私有的、遵循后进先出(LIFO)规则的内存区域。

当函数被调用时,运行时会向栈中压入一个栈帧,然后在函数返回时再将其弹出。

栈帧隶属于被调用函数,其中保存着该函数自身的局部变量、由调用方传入的函数参数,以及调用结束后应当返回到调用方继续执行的返回地址;为了满足内存对齐要求,编译器有时还会在帧内插入若干没有实际用途的填充字节。

栈指针属于当前线程,始终指向栈顶(即已用内存的边界)。每调用一层函数,它便沿着该平台约定的栈增长方向移动,为被调用函数划出新的栈帧空间。

至于移动的方向,C 语言标准并未作出规定,实际由编译器、调用约定与操作系统共同决定。

例如,在 x86-64 与 ARM(满递减栈,full-descending stack)架构下,栈均向低地址方向增长;而 PA-RISC 则相反,向高地址增长。因此,同一段 C 代码在不同平台编译,栈的增长方向可能相反。

将地址空间类比为一栋楼宇:低地址对应底层,高地址对应顶层。栈向下增长时,自高层逐层向低层占据空间;向上增长则反之。

图1:栈为线程私有,遵循后进先出;栈帧含返回地址、参数、局部变量与对齐填充,栈指针指向栈顶

什么是栈溢出

每个线程的栈空间大小是有限的。操作系统在创建线程时会为其划分出一块固定大小的连续内存(例如 Linux 上线程栈默认通常为 8 MB),栈指针只能在这块已分配区域内移动。

所谓栈溢出(stack overflow),是指函数调用层次过深或单个栈帧过大,导致已分配的栈帧累计占用空间超出了这块预留区域,栈指针越过边界、开始访问尚未分配给栈的内存。此时程序通常会触发段错误(SIGSEGV)而崩溃。

图2:栈向下增长时,递归越深栈帧越逼近低地址;越过栈边界即发生溢出

栈溢出常见于以下情形:

  • 无限或极深的递归:函数不断调用自身却迟迟不满足终止条件,栈帧一层层叠加,直至耗尽栈空间。
  • 单个栈帧过大:函数内部定义了体积庞大的局部变量(如巨型数组),仅一帧就逼近甚至超过栈容量。
  • 输入规模驱动的递归深度:某些算法(如未做尾递归优化的递归、深度优先遍历)在输入规模很大时递归层数随之激增。

正因栈溢出会直接拖垮进程,MySQL 才会在源码中主动检测栈空间、提前防御崩溃。

栈溢出的主动检测

栈溢出一旦发生,内核直接发 SIGSEGV 把进程杀掉,程序连打印一条日志的机会都没有,更谈不上优雅退出。

如果希望程序能提前预判这类异常并做兜底处理,避免栈溢出直接拖垮服务,主动检测就是个可行的办法。

主动检测的原理并不复杂:在栈真正被用尽之前先算一下还剩多少栈可用,不够了就自己退出或报错,把主动权拿回来。

具体做法是:先给线程栈定一个用量上限(比如 Linux 上默认 8 MB),运行中随时估算已用栈空间,一旦接近上限就主动停下当前的操作。

接下来要解决的问题是:怎样用一种廉价且可移植的方法,算出准确的已用字节数。办法很直接:取两个栈帧的地址相减(跟 MySQL 学的)。

那取的是哪两个帧呢?一个在程序刚启动、进入 main 的时候取:取一个局部变量的地址记为”锚点”,把它当作计量的基准;另一个在某个可能深递归的函数入口、轮到要检查的时候取,记为”当前点”。这两个地址之间的距离,就是从 main 一路调用到当前函数已经占用掉的栈空间。

不过谁减谁,得看栈往哪边涨,所以还需要先检测出当前环境下栈的增长方向。

探测的做法是:在外层函数里取一个局部变量的地址记下来,再调用一个内层函数,把内层函数局部变量的地址和刚才记下的地址做比较。如果内层地址更小,说明栈是向下增长的;如果内层地址更大,说明栈是向上增长的。

在代码实现上需要注意:要用函数指针调用内层函数,并禁止它内联,否则编译器会把它内联进外层函数,此时内层函数就失去了独立的栈帧,比较的结果也就不准了。

一次检查仅代表”这一刻”这一帧的位置,两次检查之间还会继续压栈,而且单个栈帧本身也可能很大,因此得留一段安全余量(如 64 KB):只要”已用 + 余量 > 上限”就提前退出,给最后一帧和后面的调用留点缓冲。

每次自主检查仅仅是”取一次地址 + 做一次减法 + 比一次大小”,代价极小,可以放进任何关键函数的入口。而栈增长方向的探测在启动时做一次就够了。

这是一种”协作式”自检,它替代不了操作系统的 guard page,也替代不了编译器 -fstack-clash-protection 这类编译期保护。

这个检测方案成立有两个前提需要知晓:一是你得清楚被检测线程真实的栈大小上限(8 MB 是常见默认值,若用 pthread_attr_setstacksize 或 ulimit -s 改过,代码里的常量也要跟着改);二是它只在你主动埋了检查的那条调用路径上才生效,没埋检查的地方它保护不到。

图3:以锚点与当前点之差估算已用栈,依增长方向定减法顺序,逼近上限即预警

模拟一次栈溢出导致的进程崩溃

不用构造特殊输入,一段不断递归自己的程序就能把栈跑爆。下面这段每层栈帧占一千多字节,栈很快就会耗尽。

#include <stdio.h>

void deep(int n) {
  char buf[1024];
  printf("depth=%d buf=%p\n", n, (void *)buf);
  deep(n + 1);
}

int main(void) {
  deep(0);
  return 0;
}

这段程序在 macOS 下的运行表现:

depth=7681 buf=0x7ff7b926fc10
depth=7682 buf=0x7ff7b926fbash: line 1: 79717 Segmentation fault: 11  /tmp/overflow

8MB 栈下递归到 7682 层就触发 SIGSEGV,程序自身来不及留下任何报错,只有 shell 打出的一行 Segmentation fault。最后一行尤其明显:depth=7682 没打印完整,进程就没了,shell 的报错直接接在同一行。buf 的地址则从 0x7ff7b92708a0 一路递减到 0x7ff7b926f…,与”向下增长”相吻合。

把栈尺寸压到 1MB 重跑,递归 568 层就崩了。

增加一段栈溢出的防御检测

想在进程被系统杀掉之前自己收手,就得在关键函数入口估算已用栈,接近上限就退出。

原理就是上一节讲的锚点减当前点,减法顺序由增长方向决定。完整实现如下,编译即可运行。

#include <stdio.h>
#include <stdlib.h>

// 在 main 入口取一个局部变量地址当作栈起点锚点
static const char *g_stack_base;
// 探测结果:-1 表示向下增长,+1 表示向上增长
static int g_direction = -1;

// 禁止编译器把内层函数内联进调用方
__attribute__((noinline))
int probe_inner(const char *outer) {
  const char inner;
  // 内层地址更大,说明栈向上增长
  if (&inner > outer) {
    return 1;
  }
  // 否则栈向下增长
  return -1;
}

// 用函数指针调用,切断内联摊平,保留真实栈帧
int (*probe_fn)(const char *) = probe_inner;

// 启动时探测一次,结果保存在 g_direction
static void probe_direction(void) {
  char anchor;
  g_direction = probe_fn((const char *)&anchor);
}

// 按方向算已用字节数:base 减 now,或 now 减 base
static size_t used_stack(const char *now) {
  if (g_direction < 0) {
    return (size_t)(g_stack_base - now);
  }
  return (size_t)(now - g_stack_base);
}

// 关键函数入口处先检查:离栈顶还剩多少,留 64KB 余量
static int stack_almost_full(size_t margin) {
  char here;
  return used_stack((const char *)&here) + margin > (size_t)8 * 1024 * 1024;
}

void deep(int n) {
  if (stack_almost_full(64 * 1024)) {
    fprintf(stderr, "stack too deep at depth=%d, abort\n", n);
    abort();
  }
  if (n < 5) {
    printf("depth=%d ok\n", n);
  }
  deep(n + 1);
}

int main(void) {
  char base;
  g_stack_base = (const char *)&base;
  probe_direction();
  printf("direction=%d\n", g_direction);
  deep(0);
  return 0;
}

运行输出(macOS,Apple Clang):

direction=-1
depth=0 ok
depth=1 ok
depth=2 ok
depth=3 ok
depth=4 ok
stack too deep at depth=260094, abort

进程这次没被 SIGSEGV 杀掉。递归到 260094 层时检查命中,打印深度后 abort。同样的递归不加检测会直接 SIGSEGV,连深度都留不下。能在这里收手,靠的是开篇探测出的 -1。

这里的深度远超上一节的 7682 层,不是栈变大了,而是这版 deep 的栈帧小得多:没有 1KB 的 buf,也没有 printf 的帧开销,每层只占几十字节,同样的 8MB 栈自然能压更多层。

探测只在启动时做一次。防御每次只做一次取地址、一次减法、一次比较,可以放进任意关键函数入口。

把同一段程序搬到 Linux(GCC 13.3.0)上跑,direction 同样是 -1;开不开编译优化,结果一致。换到别的架构仍以本机实测为准。

参考资料

  1. x86-64 ABI 文档(System V AMD64 ABI),栈朝低地址增长的过程调用约定。
  2. ARM 64 位架构过程调用标准(AAPCS64),满递减栈(full-descending stack)约定。
  3. C 语言标准文档,未规定调用栈的增长方向,该行为由实现决定。
  4. Unix 手册页:sigaltstack(2)、getcontext(3) / makecontext(3) / swapcontext(3),备用栈与用户态上下文的接口说明。
  5. GCC 官方手册,-fstack-clash-protection 与栈探测(stack probing)相关章节。
  6. Boost.Context 官方文档,有栈协程上下文中栈指针按增长方向初始化的说明。
  7. GNU ld 手册,链接器脚本与 MEMORY 命令,裸机环境中栈段与 RAM 区域的划分。