一个 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 是读 addl、movl 这些词来干活的。不是。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,在内存里就是一个字节 :0x55。retq 就是 0xc3。所谓汇编语言,就是给这些字节贴了张人能读的贴纸而已------0x55 贴上"pushq %rbp",0xc3 贴上"retq"。CPU 撕掉贴纸,只看字节。
挑两条拆开看字节是怎么编码的,你会发现它不是乱来的:
addl -0x8(%rbp), %eax → 03 45 f8,三个字节各管一段:
03:操作码(opcode),告诉 CPU"这是一条加法,把内存里的值加到寄存器上"。45:寻址字节,编码了"目标是%eax、源操作数要用%rbp加一个偏移"。f8:那个偏移量。f8是 -8 的字节表示(用的是补码),正好对上汇编里的-0x8。
retq → c3,就一个字节,干一件事:返回。
看出门道了吗:指令是变长 的。pushq %rbp 一个字节,movl %edi,-0x4(%rbp) 三个字节,main 里还有七个字节的。这就是为什么左边地址栏是跳着走的------0、1、4、7、a......每条指令占几个字节,下一条的地址就往后挪几个。0 处的 pushq 占 1 字节,所以下一条从 1 开始;1 处的 movq 占 3 字节(48 89 e5),所以再下一条跳到 4。这个"地址跟着指令长度走"的细节,是下一节理解 CPU 怎么取指令的钥匙。
四、CPU 怎么把这串字节跑起来
现在字节都在内存里躺好了。CPU 上场。
CPU 干活,说穿了就是一个死循环,永远在重复三个动作,这叫取指---译码---执行:
- 取指:去内存里,把"下一条指令"的字节读出来。
- 译码:撕掉贴纸,看清这串字节是什么指令(是加法?是搬运?)。
- 执行:真的去做(动寄存器、动内存)。
- 干完,回到第 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 偷偷做了两件事,不只是跳转:
- 把"下一条指令的地址"------也就是
0x3e------压到栈上 。这是留给自己回来的路标,叫返回地址。 - 把
%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 下一拍就回到了 main 里 callq 的下一条------movl %eax, -0x8(%rbp)。回家了。
这就是函数调用全部的秘密:call 跳走前在栈上留个返回地址当路标,ret 照着路标跳回来。没有什么魔法,就是一压一弹。也正因为路标存在栈上,函数才能层层嵌套------A 调 B、B 调 C,每一层的返回地址在栈上叠成一摞,回来时一层层弹,绝不会迷路。(这也顺便解释了"栈溢出"是怎么回事:递归太深,返回地址在栈上堆太多,把栈这块内存撑爆了。)
注意返回值是怎么传回来的:add 在 retq 之前,把结果 7 放进了 %eax(第一节的第⑧步);ret 跳回 main 后,main 第一句就是 movl %eax, -0x8(%rbp)------把 %eax 里的 7 取走,存进自己的栈帧。%eax 就是双方约定的"返回值信箱",add 走前把信放进去,main 回来第一件事就是去信箱取信。
五、跟着代码跑:三幕看栈帧的起落
上一节把 call 和 ret 的机制拆开了,但还是静态的。这一节我们跟着代码一条条往下跑,把整段程序执行时栈帧的"一生"拍下来,分三幕:
- 第一幕 :进入
main,它怎么一格格搭起自己的栈帧; - 第二幕 :调用
add,add怎么在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 走到 0x39 的 callq 上。这是调用前最后一瞬------记住此刻 main 帧的样子,第三幕要拿它对比:
perl
高地址 ▲
┌────────────────────────┐
│ main 的返回地址 │
├────────────────────────┤
│ 存起来的上层 %rbp │ ◀═══ %rbp(main 基准线)
├────────────────────────┤
│ 返回值槽 = 0 │
├────────────────────────┤
│ x(还没值) │
└────────────────────────┘ ◀── %rsp 栈顶,以下还是空地
低地址 ▼
rip = 0x39(callq) rsp = ..fef0 rbp = ..ff00 edi=3 esi=4 ← 参数就位
下面进入 add 的过程,关键就三条指令:callq → pushq %rbp → movq %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=7,movl %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 %rbp → retq,同样一条一张图。
① 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 那一层的表象,而是底下真正在动的那些字节和寄存器了。