1.3 函数调用与栈帧


1.3 函数调用与栈帧

本节摘要:函数调用不是"跳过去执行",而是一次栈帧的完整生命轮回——参数入栈、返回地址压入、局部对象诞生、返回值送出、栈帧弹出一气呵成。理解栈帧,你才能解释为什么不能返回局部变量的引用、为什么递归会栈溢出、为什么 RAII 能自动清理资源。本节是全册内存主线从"静态布局"走向"动态生命"的转折点。

学习目标

阅读完本节,你应当能够:

  1. 描述一次函数调用从压栈到返回的完整过程;
  2. 解释返回局部变量引用为什么是悬垂指针;
  3. 说出栈溢出的成因与典型触发场景;
  4. 区分传值、传引用、传指针在内存层面的差异并做出选择。

一、一次调用的全程回放

先上主角代码:

#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 与编译器配合完成如下动作(细节因调用约定而异,主干一致):

  1. 参数就位:3 和 4 或被放进寄存器(现代 x86-64 前几个整数参数走寄存器),或被压入栈区;
  2. 压入返回地址call 指令自动把"调用点的下一条指令地址"压栈,这是函数知道"回哪儿"的依据;
  3. 建立新栈帧add 先保存调用者的栈基指针,再把自己的栈基指针设到当前位置,并在帧内为局部变量 sum 划出 4 字节;
  4. 执行函数体a + b 的结果写进 sum 的内存;
  5. 返回值送出:小返回值(如 int)放进寄存器;大对象走"调用者预留空间 + 隐式指针";
  6. 弹出栈帧:恢复调用者的栈基指针,ret 指令弹出返回地址跳回去;
  7. 调用者接值:从寄存器取回结果,写入 r 的栈位置。

图:add(3,4) 调用期间的栈帧快照

图:add(3,4) 调用期间的栈帧快照

图末尾那句话是理解后面所有悬垂指针问题的钥匙:栈帧弹出只是移动栈指针,不擦除数据。读一块"已归还"的内存,读到的可能是旧值,也可能是下一个函数覆盖后的新垃圾——未定义行为从不承诺立刻崩给你看。

二、悬垂引用:第一现场

掌握了栈帧模型,经典错误瞬间透明:

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::arraystd::vector)。判断"会不会爆栈"的粗略心法:帧大小 × 递归深度 与栈容量比较;帧里有大数组时尤其要警觉。

四、传值、传引用、传指针:一张内存账单

三种传参方式没有绝对优劣,只有内存账单不同:

void byValue(std::vector<int> v); // 拷贝整个向量:堆分配 + 逐元素复制 void byRef(const std::vector<int>& v); // 传引用:栈上一个地址大小的间接 void byPtr(const std::vector<int>* v); // 传指针:与引用同价 但可为 null
方式 栈上开销 堆上开销 可为空 能改指向本身
传值(小类型) 复制值本身
传值(大对象) 复制可能触发堆分配
传 const 引用 一个地址
传指针 一个地址

我的默认选型:**小型可拷贝类型(intdouble、指针、string_view)传值;其余一律 const&;需要修改实参才去掉 const;可能"没有对象"是语义一部分时用指针或 std::optional。**传值并非总慢——拷贝一个 int 比取它的地址再间接访问更便宜;而 std::spanstring_view 这类"视图类型"本身就是为按值传递设计的轻量句柄。

函数重载也与内存无关地补充在此:同名函数按参数类型区分(print(int)print(double)),编译器在编译期根据实参类型挑选——名字相同,实际是两个独立函数,地址各不相同。返回值类型不参与重载决议,因为调用处可以不接返回值,编译器无从分辨。

💡 关键直觉:把每次函数调用都想成"租一块栈上短命的地皮"。地皮上的东西必须在这帧内处理完毕;想让活过这次调用的东西,要么拷出去,要么一开始就别放栈上。

本节要点回顾

  • 栈帧七步:参数就位、压返回地址、建帧、执行、送返回值、弹帧、接值——每一次调用都完整走一遍。
  • 弹出不清零:悬垂引用读到旧值纯属运气,未定义行为不保证立刻现形。
  • 爆栈算术:帧大小乘深度对比栈容量;深递归改迭代,大数组进堆。
  • 传参选型:小值传值、其余 const 引用、可空语义用指针或 optional。
  • 拷贝省略:按值返回大对象常是零拷贝,别用过时经验挡住清晰的接口设计。

下一节把多个对象排进一块连续内存:数组与字符串,以及围绕它们的最著名陷阱。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U