乐于分享
好东西不私藏

函数调用背后都发生了什么?从源码到汇编看 C++ 执行全过程!

函数调用背后都发生了什么?从源码到汇编看 C++ 执行全过程!
有一次我在调试一个 core dump,GDB 里打 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(35);return0;}

就这么几行,编译成可执行文件后,执行的时候发生了什么?

我用 g++ -O0 -g 编译,然后 objdump -d 看汇编。为了读懂,我先假设我们在 x86-64 Linux 环境下,使用 System V AMD64 ABI 调用约定。

先看 main 函数里的那一行调用

call add

你以为 call 就是跳转吗?不是的。call 是一个复合指令,它做了两件事:

  1. 下一条指令的地址(也就是返回地址)压入栈中。
  2. 跳转到目标函数的地址。

这个返回地址就是核心。没有它,函数执行完就不知道回哪儿。

这时候栈上发生了什么?假设 call 前 rsp 指向地址 0x1000,那么执行完 call 后,栈顶变成了 0x0FF8(在 x86-64 上是减 8),且 0x0FF8 位置上写入了一个地址——就是 main 里 call 后面那条指令的地址。

进入 add 函数后,第一件事是搭建栈帧。

push rbp![面试要点快速复习](images/04-interview.jpg)mov  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 个整数参数是通过寄存器传递的,不是走栈:

参数位置
寄存器
说明
第一个整数参数
rdi
例如被调函数的第一个参数
第二个整数参数
rsi
第二个参数
第三个整数参数
rdx
第三个参数
第四个整数参数
rcx
第四个参数
第五个整数参数
r8
第五个参数
第六个整数参数
r9
第六个参数
更多参数
从右到左压入栈

所以 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++,编译器都在帮你做大量的底层工作。

分配栈帧、保存寄存器、压入参数、跳转、返回、恢复。这些你看不到的细节,才是函数调用的真正全貌。

如果你以后遇到调试时看不懂栈回溯的情况,建议你也试试反汇编。看看 callpush rbpleaveret 这些指令是怎么配合工作的。你会发现,所谓的高级抽象,其实就是一层很薄的糖衣。

现在很多同学都在参加校招 / 准备社招跳槽,我们上线了 👉C++项目实战营除了系统梳理 C++ 基础与进阶知识,你还可以从项目池中任选C++ 实战项目,从 0 到 1 动手做轮子导师1v1亲自 review 代码 + 专业辅导答疑。

常规的刷题/学习,只能提高代码能力,但面试时,企业更看重你从 0 到 1 做项目、解决实际问题的能力!

而我们的训练营,正是为了这个目标设计的:

  • 项目全流程实战:开发环境、编译脚本、架构设计、框架搭建、代码发布、问题调试、单元测试。
  • 锻炼从需求分析到任务拆解、版本管理的全流程能力
  • 提高你调试能力、定位问题的技巧,掌握更多真实工作中的技能
  • 项目资料齐全:源码 + 注释 + 视频 + 文档一应俱全
  • 导师1v1在线答疑,实打实帮你把项目做好!

感兴趣的同学欢迎后台回复关键词:训练营查看训练营介绍或直接添加vx(chuzi345),快速了解训练营详情!

相信我,这些项目绝对能够让你进步巨大!下面是其中几个项目的说明文档

训练营适用人群:

  • 备战春招和秋招的应届生,科班非科班均可,
  • 工作 3 年以内,想跳槽的社招同学
  • 如果你有以下困扰,欢迎联系我们,我们愿意为你提供帮助和支持
  • 不知道该复习哪些内容,如何开始复习。
  • 对面试考察重点不清楚,复习效率低下。
  • 缺乏有含金量的实战项目经验。
  • 想要提升自己的实战能力,提升做项目及解决问题的能力
  • 对算法题无从下手,缺乏解题思路和常见解题模板。
  • 自控力不足,难以专注于系统复习。
  • 希望获得大厂的内推机会。
  • 独自备战校招社招感到孤单,想要找到学习伙伴。

不适合人群:

  • 缺乏耐心和毅力,急于求成的人
  • 对编程逻辑思维基础薄弱,且不愿努力提升的人
  • 只想快速获得成果而不注重基础学习的人
推荐阅读:

做完这个项目,才算真正摸到 AI Infra 的门槛

AI数字人项目即将上线,简历上终于有硬货了!

拿到字节的实习offer了!