CS:APP 读书笔记:如何开始读 x86-64 汇编与程序的机器级表示
systems, engineering
systems, engineering
很多人第一次看汇编时,会觉得它像一堆没有语义的缩写。但程序真正交给机器执行时,最终看到的就是这种机器级表示。
如果你能把 C 代码、编译器输出和寄存器里的数据流连起来看,很多关于性能、调用约定、参数传递和内存访问的问题,就不再只是“背结论”。
机器并不直接理解 C 代码。一个典型的 GCC 编译过程,大致会经过四步:
#include 和宏定义。如果只是想看看某个 C 文件对应的汇编输出,可以直接让 GCC 停在生成汇编这一步:
gcc -Og -S mstore.c
这会得到一个 .s 文件。比如下面这段函数汇编:
multstore:
endbr64
pushq %rbx
movq %rdx, %rbx
call mult2@PLT
movq %rax, (%rbx)
popq %rbx
ret
光看这几行,已经能读出一些信息:
%rbx。%rdx 里的地址被暂存到 %rbx。mult2。%rax 取出,再写回 %rbx 指向的内存位置。也就是说,汇编不是“另一种语言”,而是程序执行过程的展开视图。
x86-64 汇编里,很多指令后缀直接决定了操作数宽度:
b:1 字节w:2 字节l:4 字节q:8 字节把它映射回常见 C 类型,最常见的对应关系大致如下:
| C 类型 | 常见宽度 | 汇编后缀 |
|---|---|---|
char | 1 字节 | b |
short | 2 字节 | w |
int | 4 字节 | l |
long / 指针 | 8 字节 | q |
因此像 movb、movl、movq 这种名字,本质上都在说明“搬多少字节”。
这一点很重要,因为你在读汇编时,第一反应不应该只是“它在搬值”,而是“它在搬几字节的值”。很多符号扩展、零扩展和寄存器高位清零行为,都是从这里开始分叉的。
x86-64 有一组 64 位通用寄存器,比如:
%rax:返回值寄存器,也常用于累加计算。%rdi、%rsi、%rdx、%rcx、%r8、%r9:前 6 个整数或指针参数。%rbx、%rbp、%r12 到 %r15:常见的被调用者保存寄存器。%rsp:栈顶指针。在 System V AMD64 调用约定里,最常见的规则是:
%rdi、%rsi、%rdx、%rcx、%r8、%r9 传递。%rax。所以读函数汇编时,一个很有效的切入点就是:
%rax 或内存里留下了什么结果。汇编里的操作数通常分三类:
$123。%rax。真正让初学者容易卡住的是内存寻址。因为像下面这样的写法,乍看很难读:
0x12(%rax,%rdx,2)
它的含义其实是:
内存地址 = 0x12 + %rax + %rdx * 2
也就是说,x86-64 常见的寻址模式可以理解成一个统一公式:
偏移 + 基址 + 变址 * 比例因子
这也是为什么数组、结构体字段和栈帧里的局部变量,在汇编里看起来经常像同一类表达式。它们本质上都是在算地址,只是语义来源不同。
最常见的数据传送指令是 mov 系列。它的核心语义很简单:把源操作数复制到目的位置。
movq %rax, %rbx
movl %eax, (%rsp)
但 mov 真正容易出错的地方,在于“复制多少位”和“高位怎么处理”。
movmovb:搬 1 字节movw:搬 2 字节movl:搬 4 字节movq:搬 8 字节x86-64 还有一个很值得记住的细节:
当目的操作数是寄存器时,movl 会把目标寄存器的高 32 位清零。
这也是为什么很多编译器喜欢用 32 位写寄存器,顺手就把高位状态也一起整理干净了。
当一个较小的值要放进更大的寄存器时,就需要决定高位怎么填:
movz...:高位补 0movs...:高位补符号位例如:
movzbl:把 1 字节值零扩展后写入 4 字节目的位置。movsbq:把 1 字节值符号扩展后写入 8 字节目的位置。movslq:把 4 字节值符号扩展后写入 8 字节目的位置。如果这里理解错了,就很容易把一个本来应该是 -1 的值读成 255,或者反过来。
pushq 和 popq 是读汇编时必须熟悉的两条指令:
pushq:把一个 8 字节值压栈。popq:从栈顶弹出一个 8 字节值。栈是后进先出结构,并且在 x86-64 下通常向低地址方向增长。%rsp 始终指向当前栈顶。
这也意味着:
call / ret 能工作,也是因为返回地址被压入并弹出。因此看到 pushq %rbx、popq %rbx 这种配对操作时,你可以把它理解成:“这个函数借用了 %rbx,但结束前要把原值还回去。”
如果你刚开始读 x86-64 汇编,建议按这个顺序看:
%rax 还是某块内存里。mov 时,先确认后缀,也就是搬运宽度。push / pop 时,把它放到“保存现场”和“恢复现场”的语境里理解。读汇编最难的不是语法,而是还没建立“寄存器、内存、调用约定和数据宽度是同一套执行模型”的感觉。
把这几件事串起来之后,汇编就不再只是反汇编工具里的噪音,而会变成一种非常直接的观察窗口。你可以通过它看到:编译器到底生成了什么,函数到底怎么传参,数据到底是怎么在机器里流动的。
日志还会继续,欢迎常来。
返回首页