“汇编指令”这四个字,很多人一看到就头皮发麻,觉得那是操作系统内核作者和逆向工程师才碰的东西。但实际情况是:只要你写过 C/C++,调过一次段错误,或者盯着 gdb 里那一屏mov、lea、cmp发过呆,你就已经站在汇编的门口了。这份总结是我自己攒了几年的一份指令速查表加踩坑笔记,从寄存器模型、数据传送、算术逻辑、控制流、栈帧与调用约定,一直讲到反汇编阅读和内联汇编写法。它不适合零基础当教材从第一页读到最后一页,更适合已经能写代码、但看汇编像看天书的人拿来对照实战。标题里那个“史上最详细”是标题党,我认,但内容确实是我一条条指令试出来、在崩溃现场抠出来的。
1. 先把心智模型建起来:汇编到底在跟什么东西打交道
学汇编最容易犯的错,是一上来就背指令表。指令表是查的,不是背的。真正需要先立起来的,是“这台机器手里有哪些牌”的心智模型。这个模型立不住,后面每一条指令你都只能死记,记完就忘。
1.1 寄存器、内存、标志位这三件套
x86-64 架构下,你能直接操作的资源就三类:寄存器、内存、标志位。寄存器是 CPU 内部的高速暂存区,数量固定且极少,通用寄存器一共 16 个 64 位宽的:rax rbx rcx rdx rsi rdi rbp rsp加上r8到r15。每个 64 位寄存器还能按宽度切片使用,比如rax的低 32 位叫eax,低 16 位叫ax,低 8 位叫al,第 8 到 15 位叫ah。这个切片设计是历史包袱,也是新手踩坑最密集的地方,后面讲部分寄存器写入时会专门说。
除了通用寄存器,还有几类必须认识:指令指针rip指向下一条要执行的指令,你改它就是跳转;标志寄存器rflags里的各个位记录上一次运算的结果特征,条件跳转全靠它;向量寄存器xmm0到xmm15(AVX 下还有ymm、zmm)负责浮点和 SIMD 运算,宽度分别是 128、256、512 位。这些名字在反汇编里出现频率极高,看到xmm就该条件反射想到浮点或者批量数据处理。
内存这一侧,x86 用的是平坦地址空间模型,你写[rbx+8]就是“把 rbx 里的值当基址,加 8 当偏移,去访问那个地址”。注意方括号的意义是解引用,不加方括号就是操作寄存器里的值本身。这个区别在 AT&T 语法和 Intel 语法里表现形式还不一样,下面会讲。标志位这一侧最容易忽略,很多人写汇编出错不是指令用错,是中间插了一条指令把标志位污染了,导致后面的条件跳转判断的是错的依据。
提示:寄存器没有“类型”。同一个
rax,你既可以当整数用,也可以当指针用,CPU 不会拦你。类型是编译器和人脑强加的约定,汇编层面只有宽度。
1.2 指令格式与操作数宽度的硬约束
一条汇编指令基本形态是“助记符 + 目的操作数 + 源操作数”,Intel 语法是mov dest, src,AT&T 语法反过来写成mov src, dest并且寄存器要加百分号、立即数要加美元符号。看反汇编输出时先确认是哪套语法,objdump默认吐 AT&T,加-M intel才切成大家更习惯的 Intel 风格。这两套语法混着看是初学阶段最大的时间黑洞,建议一开始就固定只看 Intel 语法。
操作数宽度必须严格一致,这是硬约束。mov rax, ebx是非法的,汇编器直接报错,因为一个是 64 位一个是 32 位。想要窄转宽,必须显式用扩展指令movzx(零扩展)或movsx(符号扩展)。这条规则看起来琐碎,但它恰恰是防止你无意间丢数据的安全带。我见过太多人第一次手写汇编时想当然地写mov eax, [rbx]然后奇怪为什么高 32 位没了,其实是因为写 32 位寄存器会隐式把高 32 位清零,这是 x86-64 的明确规定,不是 bug。
另外要建立“指令长度不固定”的直觉。x86 是变长指令集,同样功能的指令,换个寻址方式长度可能从 2 字节变成 8 字节。这个特性直接影响了指令对齐和代码密度,也解释了为什么编译器在某些场景下宁愿多用几条短指令也不愿用一条长指令。你在阅读反汇编时看到的左侧那串十六进制字节,就是这条指令的机器码,长度一目了然。
2. 数据传送类指令:mov 家族远没有想象中简单
mov是所有汇编教程的第一条指令,也是被严重低估的一条。它的规则比大部分人以为的多得多,而这些规则恰恰是理解后续所有指令的基础。
2.1 mov 家族必须记住的五条硬规则
第一条,源和目的不能同时是内存。mov [rbx], [rcx]不存在,想搬内存到内存必须经过寄存器中转。这不是设计缺陷,是编码空间限制,一条指令里塞两个内存地址会让机器码长度爆炸。
第二条,操作数宽度必须匹配。前面说过,不再赘述。
第三条,立即数写入 64 位寄存器受到编码限制。x86-64 的mov r64, imm64只有一种特殊编码(汇编器会退化成movabs),绝大多数情况下立即数被限制为 32 位并且做符号扩展。也就是说mov rax, -1能正确得到全 1,但mov rax, 0x1122334455667788就需要特殊处理。写汇编时如果发现高位莫名其妙全是 F,多半是符号扩展惹的祸。
第四条,写入 32 位寄存器会零扩展高 32 位,写入 8 位或 16 位寄存器只改对应低位,高位保持不变。后者就是著名的“部分寄存器写入”问题,现代 CPU 处理这种情况要么插入合并微操作,要么产生依赖,两种情况都会拖慢速度。所以编译器在生成清零代码时永远用xor eax, eax而不是mov al, 0。
第五条,段寄存器的赋值必须经过通用寄存器中转,不能直接mov ds, 0x10。这条在 64 位模式下基本用不上了,但看 32 位时代的老代码时还会遇到。
; 常见的几种传送写法 mov eax, 0x10 ; 立即数到寄存器 mov rax, [rbx] ; 内存到寄存器 mov [rbx+8], rax ; 寄存器到内存 mov qword [rbx], 0 ; 立即数到内存(必须显式指定宽度) movzx eax, byte [rbx] ; 8 位零扩展到 32 位 movsx rax, word [rbx] ; 16 位符号扩展到 64 位注意第四行必须写qword,否则汇编器不知道你要写 8 位、16 位还是 32 位,直接报“操作数大小不明确”。这个报错新手遇到频率极高,加个宽度前缀就解决了。
2.2 lea:编译器最偏爱的“假传送”
lea是 load effective address 的缩写,字面意思是“加载有效地址”,但它真正的用途是“做地址算术但不去访问内存”。这是整个指令集里性价比最高的指令之一。
lea rax, [rbx + rcx*8 + 16]这一条指令完成了“rbx 加 rcx 乘 8 加 16”的三步运算,结果放进 rax,全程不碰内存。如果同样的计算用add和shl写,至少要三四条指令。所以编译器在看到p = &arr[i].field这种表达式时,几乎必然生成一条lea。
lea还有两个隐藏优势。第一,它不修改任何标志位,这意味着你可以把它安全地插在cmp和条件跳转之间而不破坏比较结果。第二,寻址模式里的比例因子只能是 1、2、4、8,但巧妙组合之后能覆盖大量乘法场景,比如lea eax, [rdi + rdi*2]就是乘 3,lea eax, [rdi*4 + rdi]又是乘 5。编译器做常量乘法优化时,对小常数基本都走这条路,而不是用imul。
识别lea的技巧:看方括号里有没有出现rip或者基址寄存器。如果方括号里是[rip+0x2f4c]这种,那它其实是取一个全局变量的地址或者常量地址,本质是位置无关代码(PIC)的实现手段。现代编译器默认生成 PIC 代码,所以你在反汇编里会看到大量lea rax, [rip+xxx],这就是在拿字符串常量或者全局变量的地址。
2.3 扩展传送与条件传送
movzx和movsx的区别只有一句话:源操作数按有符号还是无符号解释。movzx eax, byte [rbx]读一个字节,高 24 位补 0;movsx eax, byte [rbx]读一个字节,如果最高位是 1,高 24 位补 1。用错了的后果非常典型:一个char类型的 -1 被movzx读出来变成了 255,然后参与后续计算,最终结果差得离谱,而且这种 bug 极难定位,因为中间过程一切“看起来正常”。
movsxd是movsx的一个特例,专门把 32 位符号扩展到 64 位。它在 64 位代码里出现频率极高,因为 C 语言里int转long就是它。看到movsxd rax, eax就该知道,这里有个 32 位有符号数被提升成了 64 位。
cmovcc系列是条件传送,cmove、cmovne、cmovg、cmova等等,语义是“如果条件成立就把源传给目的,否则不动”。它最大的价值是消除分支。分支预测失败在现代 CPU 上的代价是十几个周期,而cmov是固定开销。但cmov不是万能药,它要求两个候选值都被算出来,如果其中一个计算代价很高(比如会触发缺页),那还不如用分支。另外cmov引入数据依赖,在某些流水线场景下反而更慢。
// 编译器很可能把这段变成 cmov int abs_int(int x) { return x < 0 ? -x : x; }注意:
cmov的源操作数可以是内存,但目的必须是寄存器。它也不支持 8 位操作数,用的时候注意宽度。
3. 算术逻辑与位操作:标志位才是真正的考点
算术指令的表面功能是算数,实际考点全在标志位上。你把add的加法部分学会了只算入门,能把 CF、ZF、SF、OF 四个标志位在什么情况下被置位说清楚,才算真的会了。
3.1 加减法与进位链的正确用法
add和sub会影响 CF(进位/借位)、ZF(结果为零)、SF(结果为负)、OF(有符号溢出)、AF(半字节进位)、PF(低字节奇偶)。前四个是常用的,后两个基本只有daa那类老指令和某些特定检测会用到。
inc和dec有个特殊行为:它们不修改 CF,其他标志照改。这个设计不是拍脑袋,是为了配合多精度加法循环——你在循环里用inc计数,就不会破坏上一轮adc留下的进位。这也解释了为什么编译器在做i++时有时用inc有时用add,看上下文有没有依赖 CF。
adc和sbb是带进位加减,专门用来做多精度运算。写一个 128 位加法就是两条指令:
; rdx:rax += rcx:rbx (高 64 位在 rdx/rcx,低 64 位在 rax/rbx) add rax, rbx adc rdx, rcxadc会把第一条指令产生的 CF 加进去,这就是“进位链”的本质。理解了这个,大数运算库的底层实现就不再神秘。sbb同理,sbb rax, rax甚至是一个常用的技巧,用来根据 CF 得到 0 或 -1。
neg是对操作数取负,等价于0 - x,标志位行为跟sub一致。cmp本质就是sub但不写回结果,只更新标志位。test本质是and但不写回。这两条指令的存在纯粹是为了省一次写操作,同时避免破坏原值。
3.2 乘除法的隐藏陷阱
乘法分两种。单操作数形式mul(无符号)和imul(有符号),结果是双倍宽度,存在rdx:rax里。双操作数或三操作数形式的imul只保留低位,不产生rdx,这也是编译器最常用的形式,因为 C 语言的乘法本来就只保留低位。
除法就危险多了。div和idiv的被除数是rdx:rax组成的双倍宽度值,除数是操作数,商进rax,余数进rdx。这里有两个必须记住的坑。
第一个坑:做 64 位除法前必须处理rdx。无符号除法要xor edx, edx清零,有符号除法要cqo(把rax的符号位扩展到整个rdx)。忘了这一步,rdx里残留的垃圾会让被除数变成一个天文数字,商直接溢出。
第二个坑:商溢出会触发 #DE 异常,不只是除零。比如rax = INT64_MIN,除数是 -1,商的绝对值超出了 64 位能表示的范围,CPU 直接抛异常。这个异常在 C 层面表现为“浮点数异常”或者直接崩溃,排查的时候完全想不到是除法惹的祸。
; 正确的有符号 64 位除法 mov rax, [rbx] ; 被除数 cqo ; 符号扩展到 rdx:rax,千万别省 idiv qword [rcx] ; 除以内存里的值 ; 商在 rax,余数在 rdx提示:除法的延迟非常高,64 位整数除法在主流桌面 CPU 上要二三十个周期,而且不是完全流水化的。热点循环里如果出现除法,第一反应应该是想办法换成移位或者乘法倒数。
3.3 逻辑、移位与位扫描指令
and、or、xor、not四条逻辑指令里,not不影响标志位,其他三条会把 CF 和 OF 清零,同时更新 ZF、SF、PF。xor reg, reg是最经典的清零手法,比mov reg, 0少一个字节,而且因为不依赖旧值,在乱序执行时能立刻被重命名掉,不会形成依赖链。这是编译器生成清零代码的默认选择,你手写汇编时也应该照做。
xor交换两个变量的技巧(三次异或)现在完全不推荐,可读性差、依赖链长,寄存器足够的情况下纯属自找麻烦。
移位指令家族:shl/sal左移,shr逻辑右移,sar算术右移。shr和sar的区别是符号位补什么,前者补 0,后者补符号位。对有符号数做除法优化时,编译器必须用sar,还要额外处理“负数右移除不尽要向零取整”的偏差,这就是为什么x / 2在负数情况下生成的不是一条sar而是一小段序列。
移位计数超过位宽时,x86 会先把计数按 0x1F(32 位操作数)或 0x3F(64 位操作数)掩码。也就是说shl eax, 33实际执行的是shl eax, 1。这个行为非常反直觉,如果你从其他架构转过来,很容易写出依赖“大移位清零”的代码,结果完全不对。
shlx、shrx、sarx是较新的位操作扩展指令,优势是不修改标志位、计数不占用cl、不掩码。写高性能代码时优先考虑它们,前提是目标机器支持。
位扫描系列值得单独记一下:bt/bts/btr/btc做位测试和置位/清位/取反,bsf/bsr找最低/最高置位位,popcnt统计置位个数,lzcnt/tzcnt数前导/尾随零。这几个指令在实现位图、哈希、压缩算法时能省掉大量循环。
4. 控制流与栈:函数调用背后的机械结构
前面三节讲的是“怎么算”,这一节讲“怎么跳”和“怎么调用”。这是汇编里最容易出错、也最考验基本功的部分。
4.1 条件跳转与有符号无符号的分水岭
条件跳转全部依赖标志位。关键是要把两组跳转分清楚:无符号比较用ja(大于)、jae(大于等于)、jb(小于)、jbe(小于等于);有符号比较用jg、jge、jl、jle。相等判断je/jne两组通用。
底层原理:cmp a, b计算a - b。无符号比较看 CF,有符号比较看 SF 和 OF 的组合(SF 异或 OF)。混用的后果可以用一个经典例子说明:比较-1和1,有符号意义下 -1 小于 1,应该跳jl;但如果错用jb,CPU 按无符号解释,-1 是 0xFFFFFFFFFFFFFFFF,比 1 大得多,跳转方向完全反了。这个 bug 在正常输入下可能永远不触发,只在边界值上炸,排查成本极高。
jcxz/jecxz/jrcxz是“计数寄存器为零则跳转”,配合loop用。但loop指令在现代 CPU 上速度很慢,固定开销大概五六个周期,而手写dec rcx; jnz label只要一两个周期。所以编译器几乎不生成loop,你自己写也别用。
call和ret的语义很简单:call把下一条指令的地址压栈,然后跳到目标;ret从栈顶弹出地址并跳过去。所有函数调用的本质就是这个。ret imm16是变体,弹栈之后再给rsp加一个立即数,用于“调用者参数由被调用方清理”的约定。
4.2 栈帧解剖与调用约定
一个标准函数在汇编层面长这样:
my_func: push rbp ; 保存调用者的栈基址 mov rbp, rsp ; 建立自己的栈帧 sub rsp, 32 ; 给局部变量腾空间,通常按 16 字节对齐 mov [rbp-8], rdi ; 第一个参数存到局部变量位置 ; ... 函数体 ... mov eax, 0 ; 返回值放 rax leave ; 等价于 mov rsp,rbp + pop rbp retpush rbp/mov rbp, rsp这一对叫函数序言,leave/ret叫尾声。开了-fomit-frame-pointer优化后,rbp会被当成普通通用寄存器使用,局部变量直接用rsp相对寻址。现在绝大多数发行版默认开这个优化,所以你在反汇编里看到的更多是sub rsp, N配合[rsp+8]这种形式。
调用约定是跨语言、跨模块交互的契约,必须记牢。类 UNIX 平台通用的 SysV AMD64 约定:整型参数依次用rdi、rsi、rdx、rcx、r8、r9,浮点参数用xmm0到xmm7,超过六个的整型参数从右往左压栈,返回值放rax(128 位返回值用rdx:rax)。被调用方必须保护rbx、rbp、r12到r15,调用方负责保护其余寄存器。栈必须在call前保持 16 字节对齐,因为call压入 8 字节返回地址,所以函数入口处rsp+8是对齐的。
另一套在桌面系统上广泛使用的 64 位约定:整型参数用rcx、rdx、r8、r9,调用方要在栈上预留 32 字节的影子空间,哪怕一个参数都不压栈也得留。被调用方还要额外保护rsi、rdi。跨平台写汇编时,这两套约定混用的后果是参数全错,且错得很隐蔽——因为前几个参数位置只是偏移了一位,看起来“好像有值”。
rbp之上是返回地址和参数,rbp之下是局部变量,rsp之下是红区(red zone),128 字节,信号处理程序不会碰它,所以叶子函数可以不调整rsp直接用这块空间。这是个小优化点,编译器在叶子函数里经常利用。
4.3 串操作与方向标志
串操作指令是一组带隐式操作数的指令:movs(内存到内存搬运)、stos(存)、lods(取)、cmps(比较)、scas(扫描)。它们默认用rsi当源地址、rdi当目的地址、rcx当计数,配合rep前缀就能实现批量操作。
方向由标志位 DF 决定。DF 为 0 时地址递增,为 1 时递减。cld清 DF,std置 DF。这里有个非常隐蔽的坑:DF 是全局状态,某些库函数或者系统调用返回后可能没有把它复位。如果你在rep movsb之前不显式cld,遇到 DF 为 1 的情况,数据会被从后往前倒着拷贝,结果就是一段乱码,而且只在特定调用序列下复现。
rep movsb的性能取决于 CPU 是否支持快速字符串搬运。大块拷贝(几 KB 以上)它确实比手写循环快,小块的固定开销反而不划算。一般建议:小于 128 字节用手写循环或者向量指令,更大的交给rep movsb。
; 安全的块拷贝写法 cld ; 先确保方向正确 mov rsi, src mov rdi, dst mov rcx, count rep movsb5. 从看懂到改得动:反汇编阅读与内联汇编实战
前三节是知识,这一节是手艺。看懂反汇编和能写内联汇编,是两件不同的事,但都需要大量练习。
5.1 反汇编阅读的三遍法
我自己的习惯是分三遍看。
第一遍扫结构:找函数边界、找call指令、找循环。循环的特征很明显——某个标签被向后跳转引用,那就是循环头;cmp加条件跳转基本就是循环条件。这一遍不要纠结每条指令干什么,先把控制流骨架画出来。
第二遍认数据:看方括号里的偏移量。[rdi]、[rdi+8]、[rdi+0x18]这种连续的偏移,基本就是在访问结构体字段。把偏移量列出来,你就能反推出结构体布局。[rbx+rcx*4]这种带比例因子的,基本是数组,元素大小是 4 字节,也就是int或float。
第三遍对逻辑:这时候再逐条看指令,重点是mov、lea、cmp和算术指令的组合。看到lea rax, [rip+xxx]就去查那个地址对应什么,通常是字符串常量或者全局变量;看到movsxd就知道有个int转long;看到imul加移位就知道在做常量乘法优化。
工具方面,objdump -d -M intel ./binary是最省事的入口,gdb的disassemble /m能把源码和汇编混排,调试时非常直观。readelf -S看节区布局,nm -C看符号表(-C做名字还原)。这些命令不需要记全,记住两三个常用的就行。
一个实用的练习方法:写一个只包含一个函数的小程序,用-O2编译,然后对照源码看汇编。先从最简单的int add(int a, int b) { return a + b; }开始,看着参数怎么从edi、esi进来,怎么lea或者add,怎么从eax出去。再看结构体访问、再看循环、再看函数调用。循序渐进,两周就能建立基本直觉。
5.2 内联汇编的写法与约束
GCC 系的内联汇编格式是:
__asm__ __volatile__ ( "汇编模板" : 输出操作数列表 : 输入操作数列表 : 会被修改的寄存器/内存列表 );读时间戳计数器是个标准例子:
static inline unsigned long long rdtsc_now(void) { unsigned int lo, hi; __asm__ __volatile__("rdtsc" : "=a"(lo), "=d"(hi)); return ((unsigned long long)hi << 32) | lo; }"=a"表示“输出到rax(32 位是eax)”,"=d"表示输出到rdx。约束字母和寄存器的映射关系必须查手册,写错了编译器不会报错,只会生成错误代码,非常危险。
volatile的作用是禁止编译器把这段汇编优化掉或者重排。如果你只是做纯计算且结果被使用,可以不加,但涉及 I/O、时间戳、内存屏障时必须加。
第三个冒号后面是 clobber 列表,用来声明“这段汇编会破坏哪些寄存器或内存”。最常见的两个是"cc"(标志位会被修改)和"memory"(会读写内存,编译器必须把缓存的值刷新回内存)。漏写"memory"是内联汇编最常见的 bug 来源,表现为编译器把变量缓存在寄存器里,而你的汇编代码改的是内存,两边不同步,读出来的值永远对不上。
写内联汇编的第一原则:能不写就不写。编译器在绝大多数场景下生成的代码比你手写的更好,因为它知道所有微架构细节。只有在需要访问特殊指令(如cpuid、rdtsc、加密指令、SIMD 特殊组合)、或者需要在 C 层面无法表达的原子操作时,才值得动内联汇编。第二原则是每次写完都反汇编验证,看编译器到底生成了什么,别相信自己的直觉。
5.3 一个手工优化的实例
假设要统计一个字节数组里有多少个置位位。朴素写法是循环逐位判断,汇编层面会生成一堆移位和条件跳转。用popcnt之后,逻辑变成:
; rdi = 数组指针,rsi = 长度,结果在 rax xor eax, eax xor ecx, ecx .loop: cmp rcx, rsi jae .done movzx edx, byte [rdi + rcx] popcnt edx, edx add eax, edx inc rcx jmp .loop .done: ret但这个版本还能再优化。inc rcx加cmp两个操作可以合并成“指针递减直到零”,减少一次比较;更重要的是,如果数组很长,可以一次读 8 字节,用 64 位popcnt,循环次数直接除以 8。这类改动看起来简单,实际收益取决于数据规模和内存带宽,我实测下来在中大规模数据上有两三倍提升,小数组反而因为尾部处理的开销变得更慢。
注意:
popcnt需要目标 CPU 支持,在编译选项里要开对应的指令集扩展,否则运行时会直接非法指令崩溃。发布前一定要在最低目标平台上验证。
6. 常见问题与排查技巧实录
这一节是我这些年踩过的坑的汇总。汇编层面的 bug 有个共同特点:现象诡异、复现困难、跟源码对不上。下面这张表是我自己整理的速查清单,遇到问题先扫一遍。
6.1 崩溃类问题速查表
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
在ret指令处崩溃 | 栈不平衡,返回地址被覆盖 | 检查push/pop是否配对,检查局部数组越界 |
| 随机位置崩溃,每次地址不同 | 被调用方没保存 callee-saved 寄存器 | 检查rbx、rbp、r12-r15是否在函数入口保存 |
| 除法指令处抛异常 | rdx未清零或未符号扩展,或商溢出 | 无符号除法前xor edx,edx,有符号前cqo |
| 比较结果永远不对 | 有符号/无符号跳转混用 | 检查ja/jb和jg/jl是否用反 |
| 批量拷贝后数据错乱 | 方向标志 DF 未清零 | 拷贝前显式cld |
| 浮点或 SIMD 指令崩溃 | 内存未按 16 字节对齐 | 改用movups系列,或对数据做对齐 |
| 数值莫名其妙变成两倍或负数 | movzx和movsx用错 | 有符号数据必须用movsx |
| 内联汇编结果与预期不符 | clobber 列表缺失 | 补"memory"、"cc" |
这张表覆盖了我遇到的八成问题。剩下两成通常是编译器优化和代码假设冲突导致的,比如你手动改了rsp却没告诉编译器,或者用了未定义行为让编译器做出了激进优化。
6.2 那些只有踩过才知道的坑
第一个坑:部分寄存器写入的合并惩罚。写al之后读eax,CPU 需要把新写的低 8 位和保留的高 24 位合并,这个操作在某些微架构上要额外几个周期。频繁在al/ah和eax之间切换的代码,性能可能比你想的差一截。解决办法很简单:尽量用 32 位或 64 位操作数,别没事去碰 8 位寄存器。
第二个坑:cmp和test之后插了条lea。lea不改标志位,安全;但如果你插的是inc,它会改除 CF 之外的所有标志位,紧随其后的ja判断的就是错的东西了。我在一个手写排序里中过这个招,症状是数组只在特定排列下出错,查了整整一天。
第三个坑:imul的立即数溢出。imul rax, rbx, 0x100000000这种写法,立即数被限制在 32 位有符号范围内,超出会被截断。想乘大常数必须先mov到一个寄存器再乘。
第四个坑:栈对齐。SysV 约定要求call前rsp是 16 字节对齐。如果你手写汇编时sub rsp, 24,那就是 8 字节对齐,进入某些使用 SSE 对齐访问的被调用函数时会直接崩溃。对齐计算很简单:保证sub rsp的量是 16 的倍数。
第五个坑:rep movsb的隐式寄存器。它同时用rsi、rdi、rcx三个寄存器,执行完这三个的值都会变。如果你在循环外还指望rcx保持原值,那就等着出错吧。用之前先想清楚哪些寄存器可以牺牲。
第六个坑:反汇编里的nop不是废指令。编译器插入nop是为了对齐热点循环或者给补丁预留空间,nop还有多种变长形式(多字节nop编码)。看到一串nop不用惊讶,那是正常的对齐填充。
第七个坑:调试版和发布版的汇编差异巨大。-O0下每个变量都在栈上有位置,-O2下大量变量被优化进寄存器甚至完全消失。拿调试版的汇编去理解发布版的崩溃,方向从一开始就错了。看汇编永远看和崩溃版本一致的构建产物。
第八个坑:内联汇编里的内存操作要小心别名。你从 C 传进来一个指针,在汇编里改了它指向的内存,但编译器不知道,它可能还缓存着旧值。这时候必须在 clobber 里写"memory",或者用volatile修饰指针。我见过一次线上事故,就是因为内联汇编改了结构体字段却没声明,编译器把读操作提到了汇编之前,读到的是旧值。
7. 最后分享几条我自己的练习心得
学汇编这件事,我的体会是“用进废退”,看十遍不如动手改一次。我自己最有效的练习方式,是拿一个自己写的小程序,用-O2编译,然后尝试用汇编重写其中一个热点函数,写完跑基准测试对比。第一次大概率比编译器慢,慢的原因通常是分支没排好、寄存器分配不优、或者忘了考虑缓存。把这些原因一个个找出来,比看任何教程都管用。
另一个习惯是遇到崩溃先看dmesg或者调试器给的异常地址和寄存器快照。汇编层面的崩溃信息量其实非常大:异常地址告诉你访问了哪里,rip告诉你正在执行哪条指令,那一屏寄存器就是完整的现场。学会读这个现场,排查效率会成倍提升。
还有一点,别迷信“指令越少越快”。现代 CPU 是乱序执行的,指令数量、依赖链长度、分支预测成功率、缓存命中率,四个因素共同决定性能。我见过精简到极致的汇编比朴素版本慢 30% 的情况,原因就是为了减少指令数引入了长依赖链。想优化,先测量,别先猜。