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) 时,a、b、sum 都只在本次调用期间有效。
参数传递
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语言MallocFree、CandCpp底层知识、未定义行为
学习路径
- 上一节:C语言存储类型
- 当前阶段:C 语言补充
- 下一节:C语言MallocFree