C 语言系统栈与过程调用

内容简介

系统栈用于支持函数调用:保存返回地址、参数、局部变量、临时值和调用现场。理解栈与过程调用,可以解释局部变量为什么函数结束后失效、递归为什么会栈溢出、指针返回局部变量地址为什么危险。

这不是要求初学者记住某个 CPU 的汇编细节,而是建立 C 程序运行时的内存直觉。

用法

典型内存区域

高地址
  栈 stack         局部变量、调用帧,通常自动增长/回收
  ...
  堆 heap          malloc/free 管理
  静态存储区       全局变量、static 变量
  代码区 text      程序指令、只读常量
低地址

具体地址方向和区域布局由平台、ABI、操作系统和编译器决定,上图只表达常见概念。

栈帧

每次函数调用通常会形成一个栈帧,里面可能包含:

  • 返回地址。
  • 上一个栈帧的信息。
  • 参数或参数副本。
  • 局部变量。
  • 临时计算结果。
  • 为保存寄存器而预留的空间。
int add(int a, int b)
{
    int sum = a + b;
    return sum;
}

调用 add(1, 2) 时,absum 都只在本次调用期间有效。

参数传递

C 的参数传递是按值传递。函数形参是实参值的一份副本。

void set_value(int x)
{
    x = 100;
}
 
int value = 1;
set_value(value);     // value 仍然是 1

如果要修改调用者对象,需要传地址:

void set_value(int *p)
{
    if (p != NULL) {
        *p = 100;
    }
}

注意:传指针也是“按值传递”,只是复制的是地址值。

局部变量生命周期

int *bad(void)
{
    int value = 10;
    return &value;    // 错误:value 随函数返回失效
}

函数返回后,栈帧被回收,局部变量生命周期结束。旧地址可能暂时还能读到看似正确的数据,但这属于未定义行为。

递归和栈溢出

int factorial(int n)
{
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

递归每深入一层都会产生新的调用帧。递归过深会耗尽栈空间。尾调用优化是否发生不能依赖,除非你明确控制编译器和平台。

栈溢出风险

栈溢出是指函数调用帧、局部变量和临时对象占用的栈空间超过系统或线程允许的上限。它常见于两类情况:

  • 递归层数过深。
  • 在函数内部定义过大的局部数组或结构体对象。
void deep(int n)
{
    int local = n;
    deep(local + 1);  // 没有终止条件,调用帧不断累积
}
void big_local(void)
{
    int data[10000000];  // 大数组放在栈上,可能直接耗尽栈空间
    data[0] = 1;
}

栈溢出的表现不稳定:

  • 可能立刻崩溃。
  • 可能覆盖相邻栈内存后在别处崩溃。
  • 可能在调试版能跑、发布版失败,或反过来。
  • 可能被操作系统栈保护页拦截,也可能在嵌入式/裸机环境中破坏其他内存。

所以不要把“这次输入能跑”当成递归或大栈对象安全的证据。

如何降低栈溢出风险

递归函数要能估算最大深度:

int factorial(int n)
{
    if (n < 0) {
        return 0;
    }
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

如果输入规模可能很大,优先改成迭代:

int factorial_iter(int n)
{
    int result = 1;
    for (int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

大块数据不要默认放栈上:

int *data = malloc(sizeof(int) * count);
if (data == NULL) {
    return;
}
 
/* use data */
 
free(data);

也可以根据场景使用静态存储、全局缓冲区、调用者传入的缓冲区,或 C++ 中的 std::vector

调用约定

调用约定规定:

  • 参数放在哪里。
  • 返回值放在哪里。
  • 哪些寄存器由调用者保存,哪些由被调用者保存。
  • 栈由谁清理。

普通 C 代码通常不需要手动处理调用约定,但跨语言、汇编、动态库 ABI、回调函数类型不匹配时必须关注。

最佳代码实践

  • 不返回局部变量地址。
  • 大对象不要随意放在栈上,尤其是大数组;需要大容量时考虑堆或静态存储。
  • 递归要有明确退出条件,并评估最大深度。
  • 不依赖尾调用优化避免栈增长,除非项目明确控制编译器、优化选项和目标平台。
  • 对外部输入驱动的递归,例如解析树、DFS、目录遍历,要设置深度上限或改成显式栈。
  • 通过指针输出结果时,先检查指针是否为 NULL
  • 函数接口要明确所有权:调用者分配还是函数内部分配,谁负责释放。
  • 不要假设栈地址增长方向、栈帧具体布局或参数物理位置。

错误用法

char *make_name(void)
{
    char buffer[32] = "Alice";
    return buffer;    // 错误:返回局部数组地址
}
void big_local(void)
{
    int data[10000000];  // 错误倾向:可能栈溢出
    data[0] = 1;
}
void forever(int n)
{
    forever(n + 1);   // 错误:没有退出条件
}
typedef int (*Callback)(int);
 
void run(Callback cb)
{
    cb(1);
}
 
int wrong(int a, int b)
{
    return a + b;
}
 
run((Callback)wrong); // 错误:函数指针类型不匹配

注意事项

  • 调试器里看到的栈帧不一定和源代码一一对应,优化会内联函数、删除变量或重排代码。
  • setjmp / longjmp 会跨栈帧跳转,容易破坏普通控制流直觉,初学阶段只需知道它们存在。
  • 多线程程序中,每个线程通常有自己的栈。
  • 线程栈大小可能比主线程小,在线程函数里放大数组更容易触发栈溢出。
  • 栈负责自动存储期对象,堆负责动态存储期对象,静态区负责静态存储期对象。生命周期不同,不能混用直觉。
  • 栈溢出的深层机制和未定义边界见 未定义行为

相关:CandCpp函数CandCpp指针C语言MallocFreeCandCpp底层知识未定义行为

学习路径