本节摘要:函数调用不是"跳过去执行",而是一次栈帧的完整生命轮回——参数入栈、返回地址压入、局部对象诞生、返回值送出、栈帧弹出一气呵成。理解栈帧,你才能解释为什么不能返回局部变量的引用、为什么递归会栈溢出、为什么 RAII 能自动清理资源。本节是全册内存主线从"静态布局"走向"动态生命"的转折点。
阅读完本节,你应当能够:
先上主角代码:
#include <iostream> int add(int a, int b) { int sum = a + b; return sum; } int main() { int r = add(3, 4); std::cout << r << '\n'; }
六行代码,在机器层面是一场精确的接力。调用 add(3, 4) 时,CPU 与编译器配合完成如下动作(细节因调用约定而异,主干一致):
call 指令自动把"调用点的下一条指令地址"压栈,这是函数知道"回哪儿"的依据;add 先保存调用者的栈基指针,再把自己的栈基指针设到当前位置,并在帧内为局部变量 sum 划出 4 字节;a + b 的结果写进 sum 的内存;ret 指令弹出返回地址跳回去;r 的栈位置。
图末尾那句话是理解后面所有悬垂指针问题的钥匙:栈帧弹出只是移动栈指针,不擦除数据。读一块"已归还"的内存,读到的可能是旧值,也可能是下一个函数覆盖后的新垃圾——未定义行为从不承诺立刻崩给你看。
掌握了栈帧模型,经典错误瞬间透明:
int& bad() { int local = 42; // local 住在 bad 的栈帧里 return local; // 返回它的引用 } // 栈帧弹出 local 死亡 int main() { int& r = bad(); // r 绑定到一块已归还的内存 std::cout << r; // 未定义行为:可能42 可能垃圾 可能崩 }
现代编译器对此基本都会给警告(-Wreturn-local-addr),但引用比指针隐蔽,模板层层包装后偶尔仍会漏网。规律一句话:**凡是函数内诞生的对象,其地址或引用都活不过这次调用的返回。**要么按值返回(编译器有返回值优化,代价常比想象小),要么对象生在调用者的地盘(输出参数),要么生在堆上(下一章的主角)。
顺带解开一个疑惑:为什么按值返回大对象不慢?因为 C++ 标准明确允许拷贝省略——return big; 时编译器直接在调用者预留的内存里构造 big,一次拷贝都不发生,C++17 起对部分场景这是强制的。"按值返回很贵"是上个时代的老经验,如今先测再怕。
每层递归都是一帧,栈不是无限的。Linux 默认栈常为 8 MB,Windows 常为 1 MB:
void countdown(long n) { if (n == 0) return; countdown(n - 1); // 每层一帧 } // countdown(100000) 可能平安无事 // countdown(1000000) 大概率栈溢出 进程直接终止
溢出没有异常可捕获,操作系统直接回收进程——这是 C++ 最"干脆"的崩溃方式。工程上三条出路:改成迭代(用显式栈容器模拟)、尾递归改循环(编译器虽可优化尾调用,但别依赖)、把大缓冲从栈上挪到堆上(std::array 换 std::vector)。判断"会不会爆栈"的粗略心法:帧大小 × 递归深度 与栈容量比较;帧里有大数组时尤其要警觉。
三种传参方式没有绝对优劣,只有内存账单不同:
void byValue(std::vector<int> v); // 拷贝整个向量:堆分配 + 逐元素复制 void byRef(const std::vector<int>& v); // 传引用:栈上一个地址大小的间接 void byPtr(const std::vector<int>* v); // 传指针:与引用同价 但可为 null
| 方式 | 栈上开销 | 堆上开销 | 可为空 | 能改指向本身 |
|---|---|---|---|---|
| 传值(小类型) | 复制值本身 | 无 | 否 | 否 |
| 传值(大对象) | 复制可能触发堆分配 | 有 | 否 | 否 |
| 传 const 引用 | 一个地址 | 无 | 否 | 否 |
| 传指针 | 一个地址 | 无 | 可 | 可 |
我的默认选型:**小型可拷贝类型(int、double、指针、string_view)传值;其余一律 const&;需要修改实参才去掉 const;可能"没有对象"是语义一部分时用指针或 std::optional。**传值并非总慢——拷贝一个 int 比取它的地址再间接访问更便宜;而 std::span、string_view 这类"视图类型"本身就是为按值传递设计的轻量句柄。
函数重载也与内存无关地补充在此:同名函数按参数类型区分(print(int) 与 print(double)),编译器在编译期根据实参类型挑选——名字相同,实际是两个独立函数,地址各不相同。返回值类型不参与重载决议,因为调用处可以不接返回值,编译器无从分辨。
💡 关键直觉:把每次函数调用都想成"租一块栈上短命的地皮"。地皮上的东西必须在这帧内处理完毕;想让活过这次调用的东西,要么拷出去,要么一开始就别放栈上。
下一节把多个对象排进一块连续内存:数组与字符串,以及围绕它们的最著名陷阱。