bt 看栈回溯,结果出来一堆 ?? 和奇怪的地址。我当时就懵了。明明代码是我写的,编译也开了调试信息,为什么看不到完整的调用链?
后来我用 objdump -d 把二进制文件反汇编,一行一行看汇编代码,才发现原来我对「函数调用」的理解太浅了。
我以前觉得,函数调用就是从 A 跳到 B,跳完再跳回来。但真相远比这复杂。
最近上线了一套【AI Coding实战课程】,把用AI做开发的整套方法都拆开讲清楚了,如果你也在用AI写代码,但感觉用不顺,想系统提升AI编程能力,可以看下方海报了解详情👇

从一行 C++ 代码到真正执行,中间到底发生了多少事?
我们先看一段最简单的代码:
intadd(int a, int b){int c = a + b;return c;}intmain(){int result = add(3, 5);return0;}就这么几行,编译成可执行文件后,执行的时候发生了什么?

我用 g++ -O0 -g 编译,然后 objdump -d 看汇编。为了读懂,我先假设我们在 x86-64 Linux 环境下,使用 System V AMD64 ABI 调用约定。
先看 main 函数里的那一行调用
call add你以为 call 就是跳转吗?不是的。call 是一个复合指令,它做了两件事:

把下一条指令的地址(也就是返回地址)压入栈中。 跳转到目标函数的地址。
这个返回地址就是核心。没有它,函数执行完就不知道回哪儿。
这时候栈上发生了什么?假设 call 前 rsp 指向地址 0x1000,那么执行完 call 后,栈顶变成了 0x0FF8(在 x86-64 上是减 8),且 0x0FF8 位置上写入了一个地址——就是 main 里 call 后面那条指令的地址。
进入 add 函数后,第一件事是搭建栈帧。
push rbpmov rbp, rspsub rsp, 16这三行是标准的函数开头模板,叫做「函数前导」(function prologue)。
push rbp把父函数的栈底指针保存到栈中。mov rbp, rsp把当前栈顶作为新的栈底,开始一个新栈帧。sub rsp, 16给局部变量分配空间。
这时候栈的结构是这样的:
高地址 | 参数 b (5) | 参数 a (3) | 返回地址 (指向 main 里 call 的下一条) | 旧 rbp (父函数的栈底) | 局部变量 c <-- rbp 指向这里 | 空间低地址 <-- rsp 指向这里注意,参数是在 call 之前就放到栈里的。这就是函数调用的前置工作。
那参数是怎么传的?
在 x86-64 的 System V AMD64 ABI 下,前 6 个整数参数是通过寄存器传递的,不是走栈:
所以 add(3, 5) 在汇编层面是这样的:
mov edi, 3 ; 第一个参数放进 rdimov esi, 5 ; 第二个参数放进 rsicall add ; 跳转没有走栈!这也是 x86-64 比 x86-32 快的原因之一,寄存器传参比内存传参快得多。
但如果参数超过 6 个呢?多出来的那些就按照从右到左的顺序压入栈中。举个例子,如果调用 foo(1,2,3,4,5,6,7,8),那么 7 和 8 会被压入栈,1 到 6 走寄存器。
这种规则有一个专业名词,叫做调用约定(Calling Convention)。不同的操作系统、不同的编译器,调用约定可能不一样。但在 Linux x86-64 上,System V AMD64 ABI 是标准。
这个事情让我想到了一个更大的问题
有一次我在调试一个性能问题,发现一个函数有 8 个参数。当时我想,不就是多几个参数吗,能有多大影响。
后来看汇编才发现,前 6 个参数走寄存器,后 2 个走栈。每次调用都要多做两次内存访问。在一个被调用成百上千次的热路径上,这种开销是可以被放大的。
所以你看到一些 C++ 规范里会建议函数参数别太多,不是没道理的。它不仅是设计上的幻想,底层是有真实成本的。
而且,当你看 GDB 的时候,那些 ?? 往往就是因为栈帧被破坏了。比如缓冲区溢出覆盖了返回地址或旧 rbp,GDB 就无法正确跟踪栈帧链。
那函数执行完怎么回来?
mov eax, [rbp-4] ; 把局部变量 c 的值放进 eaxleave ; 恢复旧栈底和栈顶ret ; 弹出返回地址,跳转这里有几个关键细节:
返回值:在 x86-64 下,整数返回值通过 rax寄存器传回。如果是 128 位的结构体,则通过rax和rdx组合传回。** leave**:这是一条复合指令,等价于mov rsp, rbp+pop rbp,恢复调用前的栈状态。** ret**:从栈顶弹出一个地址,然后跳转到那个地址。就是之前call压进去的那个返回地址。
整个流程环环相扣:call 在栈里留下回家地址,ret 拿走这个地址回家。没有这个配合,函数调用就无法工作。
而且你有没有想过,如果你的程序里出现了「返回地址被覆盖」的情况,会怎么样?这就是 buffer overflow 攻击的经典原理——修改了栈上的返回地址,让程序跳到任意地方执行。
面试题小结
这类问题在 C++ 面试里属于「底层原理」考察,出现频率很高。
核心要点记住:
call指令 = 压返回地址 + 跳转:不只是一个跳转,是复合指令。函数前导 = 保存旧栈底 + 建立新栈帧:每个函数都有自己独立的栈帧。 前 6 个整数参数走寄存器:rdi、rsi、rdx、rcx、r8、r9,更多才走栈。 **返回值走 rax**:整数返回值的标准位置。leave+ret= 释放栈帧 + 返回:两者配合完成函数返回。栈帧被破坏会导致调试困难:GDB 的 bt看不到完整链,很可能是缓冲区溢出。
总结
这次调试经历让我意识到,高级语言的抽象是建立在底层机制上的。你写的每一行 C++,编译器都在帮你做大量的底层工作。
分配栈帧、保存寄存器、压入参数、跳转、返回、恢复。这些你看不到的细节,才是函数调用的真正全貌。
如果你以后遇到调试时看不懂栈回溯的情况,建议你也试试反汇编。看看 call、push rbp、leave、ret 这些指令是怎么配合工作的。你会发现,所谓的高级抽象,其实就是一层很薄的糖衣。
现在很多同学都在参加校招 / 准备社招跳槽,我们上线了 👉C++项目实战营,除了系统梳理 C++ 基础与进阶知识,你还可以从项目池中任选C++ 实战项目,从 0 到 1 动手做轮子!导师1v1亲自 review 代码 + 专业辅导答疑。

常规的刷题/学习,只能提高代码能力,但面试时,企业更看重你从 0 到 1 做项目、解决实际问题的能力!
而我们的训练营,正是为了这个目标设计的:
项目全流程实战:开发环境、编译脚本、架构设计、框架搭建、代码发布、问题调试、单元测试。 锻炼从需求分析到任务拆解、版本管理的全流程能力 提高你调试能力、定位问题的技巧,掌握更多真实工作中的技能 项目资料齐全:源码 + 注释 + 视频 + 文档一应俱全 导师1v1在线答疑,实打实帮你把项目做好!
感兴趣的同学欢迎后台回复关键词:训练营查看训练营介绍或直接添加vx(chuzi345),快速了解训练营详情!
相信我,这些项目绝对能够让你进步巨大!下面是其中几个项目的说明文档
训练营适用人群:
备战春招和秋招的应届生,科班非科班均可, 工作 3 年以内,想跳槽的社招同学 如果你有以下困扰,欢迎联系我们,我们愿意为你提供帮助和支持 不知道该复习哪些内容,如何开始复习。 对面试考察重点不清楚,复习效率低下。 缺乏有含金量的实战项目经验。 想要提升自己的实战能力,提升做项目及解决问题的能力 对算法题无从下手,缺乏解题思路和常见解题模板。 自控力不足,难以专注于系统复习。 希望获得大厂的内推机会。 独自备战校招社招感到孤单,想要找到学习伙伴。
不适合人群:
缺乏耐心和毅力,急于求成的人 对编程逻辑思维基础薄弱,且不愿努力提升的人 只想快速获得成果而不注重基础学习的人
夜雨聆风