Why this matters

很多人第一次看汇编时,会觉得它像一堆没有语义的缩写。但程序真正交给机器执行时,最终看到的就是这种机器级表示。

如果你能把 C 代码、编译器输出和寄存器里的数据流连起来看,很多关于性能、调用约定、参数传递和内存访问的问题,就不再只是“背结论”。

GCC is the bridge from C to machine code

机器并不直接理解 C 代码。一个典型的 GCC 编译过程,大致会经过四步:

  • 预处理:展开 #include 和宏定义。
  • 编译:把 C 代码翻译成汇编代码。
  • 汇编:把汇编代码翻译成目标文件。
  • 链接:把目标文件和依赖库组合成最终可执行文件。

如果只是想看看某个 C 文件对应的汇编输出,可以直接让 GCC 停在生成汇编这一步:

Bash
gcc -Og -S mstore.c

这会得到一个 .s 文件。比如下面这段函数汇编:

Asm
multstore:
    endbr64
    pushq   %rbx
    movq    %rdx, %rbx
    call    mult2@PLT
    movq    %rax, (%rbx)
    popq    %rbx
    ret

光看这几行,已经能读出一些信息:

  • 函数开头先保存了 %rbx。
  • %rdx 里的地址被暂存到 %rbx。
  • 调用了 mult2。
  • 返回值从 %rax 取出,再写回 %rbx 指向的内存位置。

也就是说,汇编不是“另一种语言”,而是程序执行过程的展开视图。

Data width is encoded in the instruction suffix

x86-64 汇编里,很多指令后缀直接决定了操作数宽度:

  • b:1 字节
  • w:2 字节
  • l:4 字节
  • q:8 字节

把它映射回常见 C 类型,最常见的对应关系大致如下:

C 类型常见宽度汇编后缀
char1 字节b
short2 字节w
int4 字节l
long / 指针8 字节q

因此像 movb、movl、movq 这种名字,本质上都在说明“搬多少字节”。

这一点很重要,因为你在读汇编时,第一反应不应该只是“它在搬值”,而是“它在搬几字节的值”。很多符号扩展、零扩展和寄存器高位清零行为,都是从这里开始分叉的。

Registers tell you where data is flowing

x86-64 有一组 64 位通用寄存器,比如:

  • %rax:返回值寄存器,也常用于累加计算。
  • %rdi、%rsi、%rdx、%rcx、%r8、%r9:前 6 个整数或指针参数。
  • %rbx、%rbp、%r12 到 %r15:常见的被调用者保存寄存器。
  • %rsp:栈顶指针。

在 System V AMD64 调用约定里,最常见的规则是:

  • 前 6 个整数参数依次通过 %rdi、%rsi、%rdx、%rcx、%r8、%r9 传递。
  • 整数返回值通常放在 %rax。
  • 调用者保存和被调用者保存寄存器需要按约定配合使用。

所以读函数汇编时,一个很有效的切入点就是:

  1. 先看哪些寄存器被当成参数输入。
  2. 再看哪些寄存器被保存或恢复。
  3. 最后看 %rax 或内存里留下了什么结果。

Addressing modes explain how memory is reached

汇编里的操作数通常分三类:

  • 立即数:常量,AT&T 语法里通常写成 $123。
  • 寄存器:例如 %rax。
  • 内存引用:通过某个地址去访问内存里的值。

真正让初学者容易卡住的是内存寻址。因为像下面这样的写法,乍看很难读:

Asm
0x12(%rax,%rdx,2)

它的含义其实是:

内存地址 = 0x12 + %rax + %rdx * 2

也就是说,x86-64 常见的寻址模式可以理解成一个统一公式:

偏移 + 基址 + 变址 * 比例因子

这也是为什么数组、结构体字段和栈帧里的局部变量,在汇编里看起来经常像同一类表达式。它们本质上都是在算地址,只是语义来源不同。

MOV is the baseline, but width and extension matter

最常见的数据传送指令是 mov 系列。它的核心语义很简单:把源操作数复制到目的位置。

Asm
movq %rax, %rbx
movl %eax, (%rsp)

但 mov 真正容易出错的地方,在于“复制多少位”和“高位怎么处理”。

普通 mov

  • movb:搬 1 字节
  • movw:搬 2 字节
  • movl:搬 4 字节
  • movq:搬 8 字节

x86-64 还有一个很值得记住的细节:

当目的操作数是寄存器时,movl 会把目标寄存器的高 32 位清零。

这也是为什么很多编译器喜欢用 32 位写寄存器,顺手就把高位状态也一起整理干净了。

零扩展与符号扩展

当一个较小的值要放进更大的寄存器时,就需要决定高位怎么填:

  • movz...:高位补 0
  • movs...:高位补符号位

例如:

  • movzbl:把 1 字节值零扩展后写入 4 字节目的位置。
  • movsbq:把 1 字节值符号扩展后写入 8 字节目的位置。
  • movslq:把 4 字节值符号扩展后写入 8 字节目的位置。

如果这里理解错了,就很容易把一个本来应该是 -1 的值读成 255,或者反过来。

The stack is just a disciplined memory region

pushq 和 popq 是读汇编时必须熟悉的两条指令:

  • pushq:把一个 8 字节值压栈。
  • popq:从栈顶弹出一个 8 字节值。

栈是后进先出结构,并且在 x86-64 下通常向低地址方向增长。%rsp 始终指向当前栈顶。

这也意味着:

  • 函数调用前后保存现场,本质上是在栈里暂存寄存器值。
  • 局部变量很多时候也只是落在当前栈帧里的某个偏移位置。
  • call / ret 能工作,也是因为返回地址被压入并弹出。

因此看到 pushq %rbx、popq %rbx 这种配对操作时,你可以把它理解成:“这个函数借用了 %rbx,但结束前要把原值还回去。”

Practical checks

如果你刚开始读 x86-64 汇编,建议按这个顺序看:

  • 先看函数参数来自哪些寄存器。
  • 再看返回值最后落在 %rax 还是某块内存里。
  • 遇到 mov 时,先确认后缀,也就是搬运宽度。
  • 遇到内存引用时,先把它改写成“偏移 + 基址 + 变址 * 比例因子”。
  • 遇到 push / pop 时,把它放到“保存现场”和“恢复现场”的语境里理解。

Closing notes

读汇编最难的不是语法,而是还没建立“寄存器、内存、调用约定和数据宽度是同一套执行模型”的感觉。

把这几件事串起来之后,汇编就不再只是反汇编工具里的噪音,而会变成一种非常直接的观察窗口。你可以通过它看到:编译器到底生成了什么,函数到底怎么传参,数据到底是怎么在机器里流动的。

感谢读到这里

日志还会继续,欢迎常来。

返回首页