一个 7 行的 C 函数,是怎么一路变成 CPU 上的电信号

一个 7 行的 C 函数,是怎么一路变成 CPU 上的电信号的


我们写 int c = a + b; 的时候,脑子里想的是"加法"。但 CPU 不认识 a,不认识 b,更不认识 +。它认识的是 0x03------一个字节。

这篇想干一件事:拿一个短到不能再短的 C 函数,看它一层层往下掉------C 源码 → 编译器吐出的汇编 → 汇编对应的机器码字节 → CPU 真正怎么把这些字节取出来、一条条执行掉。中间每一层的内存长什么样,谁在栈上、谁在寄存器里、return 到底"返回"到了哪,我们都用真实编译出来的东西对着看。

主角就这么点:

c 复制代码
int add(int a, int b) {
    int c = a + b;
    return c;
}

int main(void) {
    int x = add(3, 4);
    return x;
}

本文所有汇编和机器码都是下面这条命令真编译出来的,复制即可复现(用的是 x86-64 / System V ABI,也就是绝大多数 Linux 和教材里的那套):

bash 复制代码
clang -target x86_64-linux-gnu -O0 -fno-asynchronous-unwind-tables -S add.c

两点说在前面。第一,-O0 是关掉优化------开了优化,编译器会把这个 add 直接算成 7、整个函数都给你抹掉,那就没什么可看了。关掉优化,汇编才会老老实实一行 C 对一段指令。第二,后面讲 CPU 执行时,寄存器和栈里的具体地址数值 (比如某个 0x7fff...)是我给的一套示意值,不是真跑出来的------具体数字每次运行都不同(受地址随机化影响),但关系 是恒定的:参数永远先进寄存器、返回地址永远被压在栈上、ret 永远弹回 call 的下一条。看关系,不看具体数字。

一、编译器把 C 翻译成了什么

编译器(这里是 clang)干的事,本质是把人话翻成 CPU 的话。它读 int c = a + b;,输出的是一串汇编指令。先看 add 函数被翻成了什么------下面这段一个字没改,就是上面那条命令的输出:

asm 复制代码
add:
    pushq   %rbp                # ① 把上一层的「基准线」存起来
    movq    %rsp, %rbp          # ② 给自己立一根新基准线
    movl    %edi, -0x4(%rbp)    # ③ 把参数 a 存到栈上
    movl    %esi, -0x8(%rbp)    # ④ 把参数 b 存到栈上
    movl    -0x4(%rbp), %eax    # ⑤ 把 a 取回寄存器 eax
    addl    -0x8(%rbp), %eax    # ⑥ eax = eax + b   ← 这就是那个「+」
    movl    %eax, -0xc(%rbp)    # ⑦ 把结果 c 存回栈上
    movl    -0xc(%rbp), %eax    # ⑧ 再把 c 取进 eax(约定:返回值放这)
    popq    %rbp                # ⑨ 把基准线还回去
    retq                        # ⑩ 滚回调用我的地方

十行。我们 C 里就三句(建栈、加、返回),怎么冒出来十行?因为 C 里很多"理所当然"的事,在 CPU 这层都得手动一步步做。但你不用怕,这十行就干了三类活:

  • 开场白(①②)和谢幕(⑨⑩) :每个函数进出都要走的固定流程,叫函数序言/尾声。先放着,第二节专门拆。
  • 搬数据(③④⑤⑦⑧) :全是 movl,把数据在"寄存器"和"栈"之间倒来倒去。
  • 干正事(⑥) :只有 addl 这一条,是真在算加法。

先认识两个最关键的角色,后面全靠它们:

寄存器 :CPU 内部的几个格子,数量很少(常用的就十几个),但快得离谱------CPU 算东西只能在寄存器里算,不能直接对内存动手。%eax%edi%esi 都是寄存器。你可以把它当 CPU 的"手":要加两个数,得先把数抓到手里。

:内存里的一块区域,用来放函数的局部变量。它比寄存器慢,但管够。-0x4(%rbp) 这种写法,意思是"以 %rbp 为基准,往回数 4 个字节的那个位置"------那就是栈上的一个格子。

所以这段汇编翻成人话是:参数先从寄存器搬进栈里存好(③④)→ 要算了,再从栈搬回寄存器(⑤)→ 在寄存器里加(⑥)→ 结果搬回栈(⑦)→ 临返回了,按约定把结果放进 %eax(⑧)。

你可能会皱眉:存进栈又取出来,这不脱裤子放屁吗?是的------这正是 -O0 不优化的样子,编译器老老实实,不耍任何聪明。开了 -O2,这些来回搬全没了,它会直接在寄存器里算完。但我们要的就是这份"笨",因为笨,才看得清每一步数据去了哪。

二、栈帧:每个函数都有自己的一块"工作台"

上一节按下不表的开场白和谢幕,是理解整件事的关键,得单独讲。

先说 这块内存有个怪脾气:它从高地址往低地址长。也就是说,越晚进来的东西,地址越小。这有点反直觉,但记住就行------后面所有 subq $0x10, %rsp(把栈指针减小)都是在"开辟新空间"。

每个函数运行时,都在栈上占一小块连续区域,存自己的局部变量、参数副本这些,这块区域叫栈帧(stack frame)。两个寄存器专门用来圈定这块地:

  • %rsp(stack pointer):栈顶指针,永远指着当前栈最顶上(地址最小处)。
  • %rbp(base pointer):基准指针,进函数时钉死在栈帧的底部,整个函数执行期间不动,当"标尺"用。-0x4(%rbp)-0x8(%rbp) 全是以它为原点量出来的。

现在回头看 add 的开场两句,就清楚了:

asm 复制代码
pushq   %rbp        # 把调用者的 rbp 压栈存好(待会要还)
movq    %rsp, %rbp  # 把当前栈顶设为我的新基准线

为什么进门第一件事是 pushq %rbp?因为 %rbp 是个公用寄存器,调用我的那个函数(main)也在用它当自己的标尺。我要拿它来当我自己的标尺,就得先把人家的值替它保管好,等我走的时候原样还回去------这就是谢幕那两句干的事:

asm 复制代码
popq    %rbp        # 把之前存的 rbp 弹出来,还原给调用者
retq                # 返回

pushq/popq 这一存一还,正好对称。中间这段时间,%rbp 归我用,我的栈帧就靠它定位。画成图,add 执行到中段(参数和 c 都存好了)时,它的栈帧长这样:

perl 复制代码
        高地址 ▲
   ┌──────────────────────────┐
   │  ... main 的栈帧 ...      │   ← 调用者的地盘
   ├──────────────────────────┤
   │  main 的返回地址          │   ← callq 自动压进来的(第四节细讲)
   ├──────────────────────────┤
   │  存起来的旧 %rbp          │ ◀── pushq %rbp 压进来的
   ├──────────────────────────┤ ◀═══ %rbp 钉在这里(基准线)
   │  a = 3      @ -0x4(%rbp)  │   ← movl %edi, -0x4(%rbp)
   ├──────────────────────────┤
   │  b = 4      @ -0x8(%rbp)  │   ← movl %esi, -0x8(%rbp)
   ├──────────────────────────┤
   │  c = 7      @ -0xc(%rbp)  │   ← movl %eax, -0xc(%rbp)
   └──────────────────────────┘
        低地址 ▼

   所有 -0xN(%rbp) 都是从那条基准线往下(低地址)量出来的偏移。

看明白这张图,movl %edi, -0x4(%rbp) 就不神秘了:把寄存器 %edi 里的值(参数 a),写到"基准线往下 4 字节"那个格子。add 整个函数就在这块小工作台上倒腾数据。等它干完活 retq 一走,%rsp 弹回去,这块栈帧立刻作废------下一个函数进来会原地覆盖它。栈上的局部变量"出了函数就没了",物理上就是这么回事:地不是被擦了,是被让出来了。

三、汇编再往下:它其实只是机器码的"贴纸"

到这你可能以为 CPU 是读 addlmovl 这些词来干活的。不是。CPU 一个字母都不认识。addl %eax 这种写法是给看的,CPU 真正吃进去的是一串字节。

把上面同一个 add 函数编译成目标文件,让 objdump 把每条指令对应的机器码字节也打出来(左边是地址偏移,中间是机器码字节,右边是汇编)------这又是真编译产物,没一个字是我编的:

perl 复制代码
0000000000000000 <add>:
   0: 55              pushq   %rbp
   1: 48 89 e5        movq    %rsp, %rbp
   4: 89 7d fc        movl    %edi, -0x4(%rbp)
   7: 89 75 f8        movl    %esi, -0x8(%rbp)
   a: 8b 45 fc        movl    -0x4(%rbp), %eax
   d: 03 45 f8        addl    -0x8(%rbp), %eax
  10: 89 45 f4        movl    %eax, -0xc(%rbp)
  13: 8b 45 f4        movl    -0xc(%rbp), %eax
  16: 5d              popq    %rbp
  17: c3              retq

中间那列十六进制,才是真正躺在可执行文件里、被加载进内存、被 CPU 一个个读走的东西。汇编里的 pushq %rbp,在内存里就是一个字节0x55retq 就是 0xc3。所谓汇编语言,就是给这些字节贴了张人能读的贴纸而已------0x55 贴上"pushq %rbp",0xc3 贴上"retq"。CPU 撕掉贴纸,只看字节。

挑两条拆开看字节是怎么编码的,你会发现它不是乱来的:

addl -0x8(%rbp), %eax03 45 f8,三个字节各管一段:

  • 03:操作码(opcode),告诉 CPU"这是一条加法,把内存里的值加到寄存器上"。
  • 45:寻址字节,编码了"目标是 %eax、源操作数要用 %rbp 加一个偏移"。
  • f8:那个偏移量。f8 是 -8 的字节表示(用的是补码),正好对上汇编里的 -0x8

retqc3,就一个字节,干一件事:返回。

看出门道了吗:指令是变长 的。pushq %rbp 一个字节,movl %edi,-0x4(%rbp) 三个字节,main 里还有七个字节的。这就是为什么左边地址栏是跳着走的------0147a......每条指令占几个字节,下一条的地址就往后挪几个。0 处的 pushq 占 1 字节,所以下一条从 1 开始;1 处的 movq 占 3 字节(48 89 e5),所以再下一条跳到 4。这个"地址跟着指令长度走"的细节,是下一节理解 CPU 怎么取指令的钥匙。

四、CPU 怎么把这串字节跑起来

现在字节都在内存里躺好了。CPU 上场。

CPU 干活,说穿了就是一个死循环,永远在重复三个动作,这叫取指---译码---执行

  1. 取指:去内存里,把"下一条指令"的字节读出来。
  2. 译码:撕掉贴纸,看清这串字节是什么指令(是加法?是搬运?)。
  3. 执行:真的去做(动寄存器、动内存)。
  4. 干完,回到第 1 步。

那 CPU 怎么知道"下一条"在哪?靠一个特殊寄存器:%rip(x86-64 里叫它指令指针,别的架构叫 PC,程序计数器,一个意思)。%rip 里永远存着下一条要执行的指令的内存地址。

关键就在这:CPU 取完一条指令,会立刻%rip 加上"这条指令的字节长度",让它指向紧接着的下一条。还记得上一节那个跳着走的地址栏吗------%rip 走的就是那条路。在 0 处取了 1 字节的 pushq%rip 就变成 1;在 1 处取了 3 字节的 movq%rip 就跳到 4。CPU 就这样靠 %rip 自动往前爬,一条接一条把 add 执行完。

所以平时代码"从上往下一行行执行",物理上就是 %rip 在内存里顺着地址往上加 。那函数调用------跳到别处去执行、完了还能跳回来------又是怎么做到的?这就要看 main 里那两句魔法。

call:跳过去,但留个路标

main 调用 add 那一刻的汇编(真编译产物):

perl 复制代码
  2f: bf 03 00 00 00   movl   $0x3, %edi      # 参数 a=3 放进 edi
  34: be 04 00 00 00   movl   $0x4, %esi      # 参数 b=4 放进 esi
  39: e8 ...           callq  add             # 调用!
  3e: 89 45 f8         movl   %eax, -0x8(%rbp) # ← add 返回后从这继续

调用 add 之前,main 先把两个参数 3 和 4 分别放进 %edi%esi------这就是"调用约定":第一个整型参数走 %edi,第二个走 %esi,双方说好的暗号。(回去看 add 开头的 movl %edi, -0x4(%rbp),它就是来接 %edi 里这个 3 的,两边正好接上了。)

然后 callq add 这一条,CPU 偷偷做了两件事,不只是跳转:

  1. 把"下一条指令的地址"------也就是 0x3e------压到栈上 。这是留给自己回来的路标,叫返回地址
  2. %rip 改成 add 的地址,于是 CPU 下一拍就跑去执行 add 了。

为什么压的是 0x3e 而不是 callq 自己的 0x39?因为等 add 干完回来,要从 callq下一条 接着干(把返回值存进栈),可不能再调一次 add。这个 0x3e,正是第二节那张栈帧图里"main 的返回地址"那一格的来历------原来它是 callq 自动压进去的。

ini 复制代码
   callq add  执行的瞬间:

   栈:                          %rip:
   ┌────────────────┐
   │ ...main 的数据  │            执行前  %rip = 0x39 (callq 本身)
   ├────────────────┤
   │ 返回地址 0x3e   │ ◀── 压栈    执行后  %rip = add 的地址
   └────────────────┘                    (跳走了!)
        ▲
        └─ 这就是回家的路标

ret:照着路标跳回来

CPU 跳进 add,老老实实把第一节那十行跑完。最后到了 retq(机器码 c3),它干的事和 callq 正好相反:

从栈上把返回地址弹出来,塞回 %rip

栈上存的是 0x3e,于是 %rip 变回 0x3e,CPU 下一拍就回到了 maincallq 的下一条------movl %eax, -0x8(%rbp)。回家了。

这就是函数调用全部的秘密:call 跳走前在栈上留个返回地址当路标,ret 照着路标跳回来。没有什么魔法,就是一压一弹。也正因为路标存在上,函数才能层层嵌套------A 调 B、B 调 C,每一层的返回地址在栈上叠成一摞,回来时一层层弹,绝不会迷路。(这也顺便解释了"栈溢出"是怎么回事:递归太深,返回地址在栈上堆太多,把栈这块内存撑爆了。)

注意返回值是怎么传回来的:addretq 之前,把结果 7 放进了 %eax(第一节的第⑧步);ret 跳回 main 后,main 第一句就是 movl %eax, -0x8(%rbp)------把 %eax 里的 7 取走,存进自己的栈帧。%eax 就是双方约定的"返回值信箱",add 走前把信放进去,main 回来第一件事就是去信箱取信。

五、跟着代码跑:三幕看栈帧的起落

上一节把 callret 的机制拆开了,但还是静态的。这一节我们跟着代码一条条往下跑,把整段程序执行时栈帧的"一生"拍下来,分三幕:

  • 第一幕 :进入 main,它怎么一格格搭起自己的栈帧;
  • 第二幕 :调用 addadd 怎么在 main 的栈帧之下盖起自己的新帧、算出结果;
  • 第三幕add 返回,它的帧怎么拆掉,栈怎么恢复成调用前的样子。

图都用同一种画法(和第二节那张一致):栈格子摞成一列,右边用箭头标出这格是哪条指令写进去的;寄存器的快照放在每幅图下面单独一行。先把两段汇编摆出来当剧本(真编译产物,左边是地址偏移):

perl 复制代码
main:                              add:
  20: pushq  %rbp        建帧        0: pushq  %rbp        建帧
  21: movq   %rsp, %rbp             1: movq   %rsp, %rbp
  24: subq   $0x10, %rsp            4: movl   %edi, -0x4(%rbp)  存参数 a
  28: movl   $0x0, -0x4(%rbp)       7: movl   %esi, -0x8(%rbp)  存参数 b
  2f: movl   $0x3, %edi   备参数     a: movl   -0x4(%rbp), %eax
  34: movl   $0x4, %esi             d: addl   -0x8(%rbp), %eax  ← 加法
  39: callq  add         ★调用     10: movl   %eax, -0xc(%rbp)  存结果 c
  3e: movl   %eax, -0x8(%rbp) 收值 13: movl   -0xc(%rbp), %eax  c 进返回信箱
  41: movl   -0x8(%rbp), %eax     16: popq   %rbp        拆帧
  44: addq   $0x10, %rsp   拆帧    17: retq               返回
  48: popq   %rbp
  49: retq

栈地址是一套自洽的示意值(真机每次不同,但格子间的相对关系是死的),地址往下越来越小。

第一幕:main 搭起自己的栈帧

程序真正的起点不是 main,而是 C 运行时。它执行 callq main 把控制权交给 main 时,已经顺手把 main 的返回地址压在了栈上。此刻 main 一条指令都还没跑:

css 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  main 的返回地址        │   ← C 运行时 callq 压进来的
 └────────────────────────┘ ◀── %rsp 栈顶
        低地址 ▼

   rip = 0x20(main 第一条)   rsp = ..ff08   rbp = 上层基准线
   main 自己的栈帧还没影儿,接下来三条指令负责把它搭出来

pushq %rbp + movq %rsp, %rbp:先把上层的基准线压栈存好,再把 %rbp 钉在当前栈顶------这根基准线一钉,往后整个 main 都拿它当原点量地址。接着 subq $0x10, %rsp 把栈顶往下挪 16 字节,给局部变量挖出空地,movl $0x0, -0x4(%rbp) 顺手把返回值槽填 0。三条跑完,main 的工作台就铺好了:

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  main 的返回地址        │
 ├────────────────────────┤
 │  存起来的上层 %rbp      │ ◀── pushq %rbp 压进来的
 ├────────────────────────┤ ◀═══ %rbp 钉在这里(main 的基准线)
 │  返回值槽 = 0           │   ← movl $0x0, -0x4(%rbp)
 ├────────────────────────┤
 │  x(还没值)            │   ← 留给 x 的格子 @ -0x8(%rbp)
 └────────────────────────┘ ◀── %rsp 栈顶
        低地址 ▼

   rip = 0x2f   rsp = ..fef0   rbp = ..ff00   ← 基准线立好,空地挖好
   (subq 挖了 16 字节,x 下面还垫了 8 字节对齐填充,rsp 就落在最底)

第二幕:调用 add,新栈帧长在 main 下方

main 把参数备进寄存器(movl $0x3,%edi / movl $0x4,%esi),%rip 走到 0x39callq 上。这是调用前最后一瞬------记住此刻 main 帧的样子,第三幕要拿它对比:

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  main 的返回地址        │
 ├────────────────────────┤
 │  存起来的上层 %rbp      │ ◀═══ %rbp(main 基准线)
 ├────────────────────────┤
 │  返回值槽 = 0           │
 ├────────────────────────┤
 │  x(还没值)            │
 └────────────────────────┘ ◀── %rsp 栈顶,以下还是空地
        低地址 ▼

   rip = 0x39(callq)  rsp = ..fef0  rbp = ..ff00  edi=3  esi=4 ← 参数就位

下面进入 add 的过程,关键就三条指令:callqpushq %rbpmovq %rsp,%rbp。它们每一条都只动栈和那几个寄存器,跟业务逻辑无关,所以我们一条画一张图,看清每条到底改了什么。

callq add :在 main 栈顶之下压入返回地址 0x3e%rsp 降 8),再把 %rip 改成 add 的地址跳走。main 的帧一个字节没动:

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  main 的返回地址        │
 │  存起来的上层 %rbp      │ ◀═══ %rbp(仍是 main 的,没动)
 │  返回值槽 = 0           │
 │  x(还没值)            │
 ├────────────────────────┤
 │  add 的返回地址 = 0x3e  │ ◀── ① callq 压进来的路标
 └────────────────────────┘ ◀── %rsp(降了 8)
        低地址 ▼

   rip = add 第一条 ← 跳进去了   rsp = ..fee8   rbp = ..ff00(没动)

pushq %rbp (add 的第一条):把 main 的基准线 ..ff00 压栈存起来(%rsp 再降 8)。这是借用前的"替人保管",%rbp 此刻还没改:

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  main 的返回地址        │
 │  存起来的上层 %rbp      │ ◀═══ %rbp(还是 ..ff00,下一条才改)
 │  返回值槽 = 0           │
 │  x(还没值)            │
 ├────────────────────────┤
 │  add 的返回地址 = 0x3e  │
 ├────────────────────────┤
 │  存起来的 main %rbp     │ ◀── ② pushq 压进来的(值 = ..ff00)
 └────────────────────────┘ ◀── %rsp(又降 8)
        低地址 ▼

   rip = add 第二条   rsp = ..fee0   rbp = ..ff00(仍指 main,尚未易主)

movq %rsp,%rbp (add 的第二条):把 %rbp 钉到当前栈顶。基准线易主,从这条起 %rbp 归 add,且此刻和 %rsp 重合。add 的栈帧就算立起来了:

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  main 的返回地址        │
 │  存起来的上层 %rbp      │
 │  返回值槽 = 0           │   main 的帧(冻结)
 │  x(还没值)            │
 ├────────────────────────┤
 │  add 的返回地址 = 0x3e  │
 ├────────────────────────┤
 │  存起来的 main %rbp     │ ◀═══ %rbp = %rsp(③ 钉好的 add 基准线)
 └────────────────────────┘
        低地址 ▼

   rip = add 第三条   rsp = ..fee0   rbp = ..fee0(易主,和 rsp 重合)

到这里"进入 add"就完成了:三条指令各司其职------callq 留路标并跳转,pushq 替 main 保管基准线,movq 立起 add 自己的基准线。接下来才是 add 的业务代码 (存参数、算加法),它们不再动栈帧结构,只在已铺好的帧里读写:movl %edi,-0x4(%rbp) / movl %esi,-0x8(%rbp) 把 a、b 写进栈,addl 算出 3+4=7movl %eax,-0xc(%rbp) 存回 c。注意 add 是叶子函数,-O0 下走"红区",直接在 %rsp 下方写 a/b/c,连 subq 挪栈顶都省了,所以 %rsp 全程不动。业务跑完,帧里填上了数据:

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  ... main 的帧(冻结)  │
 ├────────────────────────┤
 │  add 的返回地址 = 0x3e  │
 ├────────────────────────┤
 │  存起来的 main %rbp     │ ◀═══ %rbp = %rsp
 │  a = 3                  │ ← movl %edi, -0x4(%rbp)  ┐ 红区:rbp/rsp
 │  b = 4                  │ ← movl %esi, -0x8(%rbp)  │ 下方直接写,
 │  c = 7                  │ ← movl %eax, -0xc(%rbp)  ┘ %rsp 不挪
 └────────────────────────┘
        低地址 ▼

   rip = add 内部   rsp = ..fee0(不动)   rbp = ..fee0   eax = 7(算好了)

main 的帧在上半截原封不动地等着,add 的帧长在它下方独立干活。两块工作台靠中间「add 的返回地址 + 存起来的 main %rbp」缝在一起------这正是函数能一层层嵌套的根:再深的调用,也只是往下继续摞帧。(顺带一提,main 里那条 subq $0x10 就是非叶子函数的常规做法------它要调用 add,红区靠不住,于是老老实实挪栈顶给局部变量挖地。叶子的 add 才有资格偷这个懒。)

第三幕:add 返回,栈帧拆掉、恢复原样

返回前 add 先干一件业务上的收尾------把结果放进返回值信箱:movl -0xc(%rbp),%eax 把 c 取进 %eax(约定好返回值走这)。这条只动 %eax,不碰栈帧,所以不单画图。真正拆帧的是接下来两条:popq %rbpretq,同样一条一张图。

popq %rbp :把栈顶那格"存起来的 main %rbp"弹出来,写回 %rbp%rsp 升 8)。基准线还给 main,但控制权这一条还没走:

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  main 的返回地址        │
 │  存起来的上层 %rbp      │ ◀═══ %rbp 已还原成 main 的(..ff00)
 │  返回值槽 = 0           │
 │  x(还没值)            │
 ├────────────────────────┤
 │  add 的返回地址 = 0x3e  │ ◀── %rsp(升了 8,停在路标这格)
 └────────────────────────┘
        低地址 ▼
   ┄┄ 下面那格「存的 main %rbp」已弹走、作废 ┄┄

   rip = 仍在 add(retq 前)  rsp = ..fee8  rbp = ..ff00(已复位)  eax = 7

retq :把栈顶的返回地址 0x3e 弹进 %rip%rsp 再升 8),控制权跳回 main。那格路标也消费掉了,add 的帧彻底没了:

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  main 的返回地址        │
 ├────────────────────────┤
 │  存起来的上层 %rbp      │ ◀═══ %rbp(main 基准线)
 ├────────────────────────┤
 │  返回值槽 = 0           │
 ├────────────────────────┤
 │  x(还没值)            │
 └────────────────────────┘ ◀── %rsp 弹回调用前的位置
        低地址 ▼
   ┄┄ 这下面原来 add 的帧全作废,下次谁用谁覆盖 ┄┄

   rip = 0x3e ← 回到 callq 下一条   rsp = ..fef0   rbp = ..ff00   eax = 7

把第②张图和第二幕「调用前最后一瞬」并排看:栈顶 %rsp、基准线 %rbp、main 自己那几格,全部回到原样,add 撑起的那段栈连同路标一个格子不剩。整趟调用对 main 的栈帧而言就像一阵风------下方鼓起一块又瘪回去,自己纹丝没动。唯一带回来的,是 %eax 信箱里那个 7。

回到 main 后,movl %eax, -0x8(%rbp) 把信箱里的 7 存进 x------int x = add(3, 4) 这句 C 到这才真正执行完:

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  main 的返回地址        │
 ├────────────────────────┤
 │  存起来的上层 %rbp      │ ◀═══ %rbp
 ├────────────────────────┤
 │  返回值槽 = 0           │
 ├────────────────────────┤
 │  x = 7                  │ ◀── movl %eax, -0x8(%rbp),add 的成果落进 x
 └────────────────────────┘ ◀── %rsp
        低地址 ▼

   rip = 0x41   rsp = ..fef0   rbp = ..ff00   eax = 7

剩下的 movl -0x8(%rbp),%eax 取 x 当 main 自己的返回值,再走和第一幕建帧严格对称的拆帧:addq $0x10,%rsp(填回挖的 16 字节)→ popq %rbp(还原上层基准线)→ retq(弹出 main 的返回地址,交还控制权)。拆完,main 的栈帧也彻底消失,栈面回到程序刚启动的样子。

三幕连起来看,就是一条干净的起落曲线:main 把帧搭起来 → 调用时 add 的帧摞在它下方、main 冻住不动 → add 返回后它的帧整块拆掉、main 原样露出来 → main 收下返回值,再把自己拆平。 涨上去的每一步,回来时都有严格对称的一步退回去------这就是"栈"这个名字的由来,也是它最干净的地方。

六、加餐:参数到底怎么"存"在栈上

细心的你可能发现了一件事:上面 add(3, 4) 全程,参数 3 和 4 是走 %edi%esi 这两个寄存器 传进去的,压根没在栈上"存"过。这是 x86-64(System V ABI)的规矩:前 6 个整型参数走寄存器 (依次 %edi%esi%edx%ecx%r8d%r9d),又快又省事,根本不碰内存。

那"参数存在栈上"到底什么时候发生?答案是:参数超过 6 个,第 7 个开始才溢出到栈上。我们把 add 撑到 8 个参数看看(其余条件不变,真编译产物):

c 复制代码
int add(int a,int b,int c,int d,int e,int f,int g,int h){ return a+b+c+d+e+f+g+h; }
int main(void){ int x = add(1,2,3,4,5,6,7,8); return x; }

先看 main 怎么准备这 8 个参数(截取关键几条):

perl 复制代码
  movl $0x1, %edi      ┐
  movl $0x2, %esi      │ 前 6 个:1~6 装进 6 个寄存器
  movl $0x3, %edx      │
  movl $0x4, %ecx      │
  movl $0x5, %r8d      │
  movl $0x6, %r9d      ┘
  movl $0x7, (%rsp)    ┐ 第 7、8 个:装不下了,压到 main 自己的栈顶
  movl $0x8, 0x8(%rsp) ┘
  callq add

前 6 个老规矩进寄存器。第 7、8 个寄存器不够用了,main 就把它俩 movl 到自己的栈顶(%rsp)0x8(%rsp))。这就是"参数存在栈上"------由调用方 main 负责,在 callq 之前先把超额的参数码放在栈顶。

接着 callq 像第二幕那样压入返回地址、add 再 pushq %rbp 建帧。等尘埃落定,add 视角的栈长这样------注意第 7、8 个参数和 add 自己的局部变量,分居 %rbp两侧

perl 复制代码
        高地址 ▲
 ┌────────────────────────┐
 │  ...更高处是 main 的帧  │
 ├────────────────────────┤
 │  参数 8 = 8             │   ← add 用 0x18(%rbp) 读(rbp + 24)┐ 正偏移:
 ├────────────────────────┤                                     │ 越过下面
 │  参数 7 = 7             │   ← add 用 0x10(%rbp) 读(rbp + 16)┘ 两格去读
 ├────────────────────────┤
 │  add 的返回地址 = 0x83  │   ← callq 压的(占 8 字节)
 ├────────────────────────┤
 │  存起来的 main %rbp     │ ◀═══ %rbp(add 基准线,rbp + 0)
 ├────────────────────────┤
 │  参数 1~6 的副本        │   ← add 用 -0x4(%rbp) ... 等负偏移读 ┐ 负偏移:
 │  局部变量 r             │   ← 全在 rbp 下方                  ┘ 自己的地盘
 └────────────────────────┘ ◀── %rsp
        低地址 ▼

   关键:参数 7、8 在 %rbp 上方(正偏移),局部变量在下方(负偏移)

这张图藏着本文最值得记的一个细节------偏移的正负,标出了数据的归属

  • add 读自己的局部变量、以及前 6 个寄存器参数的副本,用的全是 -0x4(%rbp) 这种负偏移 ,在 %rbp 下方,是 add 自己的地盘;
  • add 读第 7、8 个参数,用的是 0x10(%rbp)0x18(%rbp) 这种正偏移 ,在 %rbp 上方------它伸过了"存起来的旧 rbp"和"返回地址"这两格,一直够到了 main 栈帧的尾巴上。

换句话说,栈上传递的参数,物理上根本不在 callee 的栈帧里,而是躺在 caller 栈帧的顶端 ,被 callee 隔着边界"借"来读。再回头看一个巧合就不巧了:main 当初是用 (%rsp) 写第 7 个参数的,add 却用 0x10(%rbp) 读同一个值------中间正好隔着 callq 压的返回地址(8 字节)和 add pushq 的旧 rbp(8 字节),0 + 8 + 8 = 16 = 0x10,两个地址说的是同一格内存。调用方写、被调方隔着边界读,参数就这么交接完成。

所以"参数怎么存在栈上"完整的答案是:够 6 个名额时不存栈、走寄存器;超出的,由调用方在 callq 前压到自己栈顶,被调方再用正偏移跨过返回地址去读。 我们主线选 add(3, 4),正是因为两个参数走寄存器最干净;而真要看栈上传参,得让参数多到挤不下寄存器才行。

七、把整条链子连起来看一遍

前面几节把栈帧怎么起落、参数怎么传递都拆到了字节。现在换个视角收个尾:把镜头拉远,不盯栈格子,只追"数据"本身------3 和 4 这两个值,从源码到最后落进 x,一路经过了哪些寄存器和内存格子。下面这张图把"谁动了、动了什么"按指令顺序串成一条线:

scss 复制代码
 ① main 准备参数        edi ← 3,  esi ← 4            (寄存器:约定的传参信箱)
        │
 ② callq add           栈 ← 压入返回地址 0x3e         (留路标)
        │               rip ← add 的地址              (控制权跳进 add)
        ▼
 ③ add 建栈帧          压旧 rbp,rbp 立基准线          (开辟自己的工作台)
        │
 ④ 取参数入栈          -0x4(rbp) ← 3,  -0x8(rbp) ← 4   (栈上存副本)
        │
 ⑤ 算加法              eax ← 3,  eax ← eax + 4 = 7     (唯一真干活的一步)
        │               -0xc(rbp) ← 7                  (结果存回栈)
        │
 ⑥ 准备返回值          eax ← 7                         (放进返回值信箱)
        │
 ⑦ retq                rbp 还原,rip ← 栈上的 0x3e     (照路标跳回 main)
        ▼
 ⑧ main 收返回值       -0x8(rbp) ← eax = 7  →  x = 7   (从信箱取信,存进 x)

从头到尾,CPU 没有一刻"理解"过加法。它只是在 %rip 的牵引下,把一串字节一条条取出来、译码、执行:0x55 就压栈,0x03 就把两个数加起来,0xc3 就照栈上的路标跳回去。寄存器是它干活的手,栈是它放东西和记路标的台子,%rip 是它的眼睛------盯着下一条在哪。

回到开头那句 int c = a + b;。现在你知道了,这一句在 CPU 眼里根本不是"加法"这个概念,而是一连串极其具体的搬运和一次 addl:把 a 从信箱搬到栈,把 b 从信箱搬到栈,要算时再搬回寄存器,加完搬回栈,临走放进返回值信箱。+ 这个我们觉得理所当然的符号,落到最底层,就是机器码里那个孤零零的字节 0x03

编译器的全部工作,就是把我们脑子里"加法"这种人话,翻译成 CPU 唯一听得懂的话------一串带着精确顺序的字节。我们写得越抽象,它替我们填的细节就越多(你看 -O0 下那十行里有八行在搬数据)。理解了这条链子,再看任何"性能""内存""为什么这么写"的问题,你看的就不再是 C 那一层的表象,而是底下真正在动的那些字节和寄存器了。

相关推荐
handler012 小时前
【算法】并查集(普通/扩展/带权)模板与例题
数据结构·c++·笔记·算法·c·图论·查并集
蓝宝石的傻话1 天前
给MibeeNvr 0.6调试的Esp32和树莓派的三个摄像头项目的技术更新细节
c
handler013 天前
【C++11 】Lambda 表达式、std::function 与 std::bind 解析
c++·c·c++11·bind·解耦·function·lamda
handler018 天前
【C++】二叉搜索树详解及其模拟实现(代码)
开发语言·c++·算法·c··二叉搜索树·搜索树
爱学习的程序媛9 天前
C 语言全景指南:从底层原理到工业级实战
c++·c#·c
dozenyaoyida10 天前
RISC-V嵌入式开发:彻底解决“undefined reference to isatty“错误全攻略
经验分享·c·cmake·嵌入式开发·isatty·没有定义问题
Shadow(⊙o⊙)11 天前
模拟实现:glibc_1.0-文件操作函数fopen fclose fwrite fflush实现。
开发语言·c++·学习·c
liulilittle13 天前
TCP UCP:基于卡尔曼滤波的BBR增强型拥塞控制算法
linux·网络·c++·tcp/ip·算法·c·通讯
weixin_4217252614 天前
C语言、C++与C#深度研究报告:从底层控制到现代企业级开发的演进
c语言·c++·c·内存管理·编译模型