x86 汇编入门:用 GDB 看懂你的 C 程序在干什么
你写了一段 C 代码,编译运行一气呵成,结果也完全正确——但你对它到底”怎么跑的”其实一无所知。
不是说理解逻辑层面的流程就够了——你知道变量存在内存的哪个位置吗?函数调用时参数是怎么传过去的?return 背后发生了什么?如果你对这些问题的回答还是”大概是用栈吧”这种模糊答案,那你其实一直隔着一层雾在编程。
GDB 不只是用来 debug 的。它的反汇编功能可以把 C 代码踩碎了给你看——每一条语句对应几条汇编指令、寄存器的值怎么变、栈怎么推怎么弹,全都能一步步跟。今天就用一个最简单的例子,带你走进 C 程序的底层世界。
适合人群: 学过 C 基本语法,想深入了解底层运行机制的人
前置知识: 知道 C 语言的函数、指针概念即可
你需要: gcc、gdb(没有的话apt install gdb装一个)
一、准备一个目标程序
创建一个文件 add.c:
#include <stdio.h>
int add(int a, int b) { int sum = a + b; return sum;}
int main() { int x = 10; int y = 20; int result = add(x, y); printf("Result: %d\n", result); return 0;}然后编译,注意两个关键参数:
gcc -g -O0 -o add add.c-g 把调试符号打包进二进制,这样 GDB 才能把地址和源码行号对应起来。-O0 关掉优化——优化后的汇编跟你写的 C 代码基本对不上号,初学阶段先关了。
二、disassemble:把 C 函数翻译成汇编
启动 GDB 加载程序:
gdb ./add在 GDB 里输入:
(gdb) break main(gdb) run程序停在 main 函数入口。现在输入:
(gdb) disassemble你看到的输出大概是这样(地址偏移可能略有不同):
Dump of assembler code for function main: 0x0000000000001169 <+0>: push %rbp 0x000000000000116a <+1>: mov %rsp,%rbp 0x000000000000116d <+4>: sub $0x20,%rsp 0x0000000000001171 <+8>: movl $0xa,-0x4(%rbp) 0x0000000000001178 <+15>: movl $0x14,-0x8(%rbp) 0x000000000000117f <+22>: mov -0x8(%rbp),%edx 0x0000000000001182 <+25>: mov -0x4(%rbp),%eax 0x0000000000001185 <+28>: mov %edx,%esi 0x0000000000001187 <+30>: mov %eax,%edi 0x0000000000001189 <+32>: call 0x1139 <add> 0x000000000000118e <+37>: mov %eax,-0xc(%rbp) 0x0000000000001191 <+40>: mov -0xc(%rbp),%eax 0x0000000000001194 <+43>: mov %eax,%esi 0x0000000000001196 <+45>: lea 0xe67(%rip),%rdi 0x000000000000119d <+52>: mov $0x0,%eax 0x00000000000011a2 <+57>: call 0x1030 <printf@plt> 0x00000000000011a7 <+62>: mov $0x0,%eax 0x00000000000011ac <+67>: leave 0x00000000000011ad <+68>: ret看着眼晕很正常。一行一行拆开看,其实规律非常清晰。
三、函数开头三件套
每个函数的开头几乎都是这三条:
push %rbp # 保存调用者的 rbpmov %rsp,%rbp # 设置当前函数的栈帧基址sub $0x20,%rsp # 为局部变量腾出 32 字节空间rbp 是栈基址寄存器,指向当前函数栈帧的底部。rsp 是栈顶指针,始终指向栈的当前位置。
函数进来第一件事就是把上一个函数的 rbp 存起来,然后把自己的栈帧搭好。sub $0x20, %rsp 就是在栈上挖一块 32 字节的空间——main 函数有 3 个局部变量(x, y, result),32 字节绰绰有余。
四、局部变量在栈上怎么存的
movl $0xa,-0x4(%rbp) # rbp-4 = 10 ← xmovl $0x14,-0x8(%rbp) # rbp-8 = 20 ← y0xa 是 10 的十六进制,0x14 是 20。这两条指令就是把 10 和 20 写到栈上,分别放在 rbp 往下 4 字节和 8 字节的位置。
局部变量在栈上,地址从 rbp 往下分配。 这就是为什么局部变量的地址比你想象的低——往下是栈的生长方向。
mov -0x8(%rbp),%edx # edx = y (20)mov -0x4(%rbp),%eax # eax = x (10)mov %edx,%esi # 第二个参数 = ymov %eax,%edi # 第一个参数 = x这是在准备调用 add 函数的参数。在 Linux x86-64 上,函数调用的前两个参数放在 rdi 和 rsi 寄存器里(第三个放 rdx,第四个放 rcx,以此类推)。这就是 System V AMD64 ABI 调用约定。
五、call 指令背后发生了什么
call 0x1139 <add>call 指令做两件事:
- 把返回地址(也就是 call 的下一条指令的地址
0x118e)压入栈 - 跳转到 add 函数的地址
你现在在 add 函数里做个断点,看看里面:
(gdb) break add(gdb) continue(gdb) disassembleDump of assembler code for function add: 0x0000000000001139 <+0>: push %rbp 0x000000000000113a <+1>: mov %rsp,%rbp 0x000000000000113d <+4>: mov %edi,-0x14(%rbp) # rbp-20 = a (10) 0x0000000000001140 <+7>: mov %esi,-0x18(%rbp) # rbp-24 = b (20) 0x0000000000001143 <+10>: mov -0x14(%rbp),%edx # edx = a 0x0000000000001146 <+13>: mov -0x18(%rbp),%eax # eax = b 0x0000000000001149 <+16>: add %edx,%eax # eax = a + b 0x000000000000114b <+18>: mov %eax,-0x4(%rbp) # rbp-4 = sum 0x000000000000114e <+21>: mov -0x4(%rbp),%eax # eax = sum ← 返回值 0x0000000000001151 <+24>: pop %rbp 0x0000000000001152 <+25>: ret看看这里做了什么:
参数传递: edi 和 esi(rdi/rsi 的 32 位版本)里存着调用方传进来的 10 和 20。函数把它们存到自己的栈上,因为后面还要用寄存器做计算。
计算: add %edx, %eax 把两个数加起来,结果存在 eax 里。返回值永远放在 eax(或 rax)寄存器里。 这就是为什么你 return 之后调用方马上能从 eax 拿到结果。
ret 指令: 从栈顶弹出之前 call 指令压进去的返回地址,跳回去继续执行。
你看——函数调用本质就是一个”把参数塞进约定好的寄存器→跳过去执行→把结果塞进 eax→跳回来”的过程。没有魔法,全是约定。
六、用 si 逐条看汇编执行
回到 main 函数,用 stepi(简写 si)一条一条执行汇编指令,观察每一步的变化:
(gdb) si(gdb) info registers rbp rsp rip每执行一条 si,处理器就往前走一条指令。info registers 看关键寄存器的变化——rbp 和 rsp 怎么变、eax 什么时候被写入。
试试在 add 函数里单步走,观察 eax 的值从 0 变成 a,加上 b,变成 a+b 的过程——比你想象中直观得多。
七、总结
| 概念 | 汇编层面的真相 |
|---|---|
| 局部变量 | 存在栈上(rbp 往下偏移),不是”某个神秘空间” |
| 函数参数 | 前 6 个放寄存器(rdi, rsi, rdx, rcx, r8, r9),多的压栈 |
| 返回值 | 放在 rax/eax 寄存器里 |
| 函数调用 | call 压入返回地址 → 跳转 → 执行 → ret 弹出地址跳回 |
| 栈帧 | 每个函数有自己的栈帧区间,由 rbp 和 rsp 划定边界 |
常见问题
Q:为什么我的汇编跟文章里的地址不一样? 地址偏移取决于编译器和系统版本,这不重要。看指令本身,地址只是位置标记。
Q:-O0 和 -O2 编译出来的汇编差别大吗?
非常大。-O2 会把一些变量直接优化到寄存器里不写内存,甚至内联函数消除调用开销。这就是为什么用真实程序的汇编做逆向分析比教材例子难得多——编译器帮你做了大量优化,你看到的是优化后的结果,不是原始代码逐句翻译。
Q:怎么看内存里的具体值?
用 x 命令。x/4xb 0x7fffffffdabc 以十六进制显示指定地址的 4 个字节。配合 &变量名 可以看变量的内存布局。比如 x/4xb &x 看变量 x 的 4 个字节内容。
Q:学这个对写 C 代码有什么实际帮助? 最大的帮助是让你不再对”函数调用有开销”这句话停留在感性认识——你亲眼看到 call/ret 做了多少事,push/pop 改变了什么。以后写性能敏感代码时,你会自然而然地想”这里少调一个函数就能省一次 call 和栈帧搭建”。
从今天开始,遇到想不通的 C 代码,不要只靠猜。用 GDB 加载它,disassemble 看一眼,si 走几步——你的 C 代码在你面前就不再是魔法了。
这篇文章是 GDB 看汇编的入门第一课。下次可以聊聊怎么用 GDB 看结构体的内存布局、数组在汇编里怎么寻址——感兴趣的话可以关注更新 🐾
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!