整理一下反汇编遇到的指令
- 先来看一个例子
- Intel语法怎么看
- 数据在寄存器和内存之间移动
lea为什么看起来像读取内存- 加减乘除
- 比较为什么不保存结果
- 条件跳转
- 不跳转也能做条件选择
- 函数调用和栈
- 位运算中的常见模式
- 看到
XMM和YMM怎么办 - 多线程代码里的
lock - 几种常见代码形态
- 回到最开始的例子
- 总结
- 参考资料
本文使用Intel语法,主要目标是读懂普通C/C++函数生成的汇编,不会覆盖所有指令。 ## 先来看一个例子
我们先写一个取较大值的函数:
int Max(int a, int b) {
return a > b ? a : b;
}在Windows x64环境下,优化后可能得到类似代码:
cmp ecx, edx
mov eax, edx
cmovg eax, ecx
ret这里没有传统意义上的if,也没有看到任何变量名,只剩四条指令。我们先粗略翻译一下:
cmp ecx, edx比较参数a和bmov eax, edx先假设返回b- 如果
a>b,cmovg eax, ecx再把返回值换成a ret返回调用者
其中ECX、EDX是Windows下前两个整数参数,EAX用来保存返回值。寄存器与函数参数的关系可以先看x86-64寄存器 & 调用约定。
下面我们就从这个例子出现的mov开始,慢慢把常见指令串起来。
Intel语法怎么看
Intel语法通常把目标写在前面,来源写在后面:
mov eax, ecx意思是把ECX的值复制到EAX,可以类比成:
eax = ecx;如果操作数外面带有方括号,表示访问这个地址指向的内存:
mov eax, [rcx]这次不是把RCX本身复制给EAX,而是把RCX当成指针,读取它所指向的内容,类似:
eax = *reinterpret_cast<int*>(rcx);有时反汇编里还会明确写出读取宽度:
mov eax, dword ptr [rcx]dword在x86命名中表示32位,也就是4字节。常见宽度如下:
| 名称 | 大小 |
|---|---|
byte | 1字节 |
word | 2字节 |
dword | 4字节 |
qword | 8字节 |
数据在寄存器和内存之间移动
mov:最常见的复制
mov可以在寄存器、内存和立即数之间复制数据。这里的“立即数”就是直接写在指令里的常量,例如下面第一行中的10:
mov eax, 10 ; EAX = 10
mov edx, eax ; EDX = EAX
mov [rcx], eax ; *RCX = EAX
mov eax, [rcx] ; EAX = *RCX虽然名字叫Move,但源数据不会被清除,它更接近“复制”。
普通mov不能直接从一块内存复制到另一块内存,通常要经过寄存器:
mov eax, [rcx]
mov [rdx], eax不同位宽之间怎么复制
假设内存里只有一个8位整数,现在要把它放入32位寄存器,多出来的高位应该填什么?这取决于原数字有没有符号。
无符号数使用零扩展,高位补0:
movzx eax, byte ptr [rcx]有符号数使用符号扩展,高位复制原来的符号位:
movsx eax, byte ptr [rcx]例如8位的0xff:
- 当成无符号数是255,零扩展后得到
0x000000ff - 当成有符号数是-1,符号扩展后得到
0xffffffff
所以看到movzx和movsx时,编译器其实是在告诉我们:它把原数据理解成无符号还是有符号。
lea为什么看起来像读取内存
lea的全名是Load Effective Address,也就是“计算有效地址”。它的写法带方括号,但只计算括号里的地址表达式,不会真的读取内存。
例如:
lea rax, [rcx + rdx*4 + 16]对应的计算是:
RAX = RCX + RDX × 4 + 16方括号里的通用形式是:
基址 + 下标 × 比例 + 固定偏移其中比例只能是1、2、4或8,正好对应常见数据类型的大小。
数组访问
假设RCX指向一个int数组,RDX是下标:
return array[index];可能会看到:
mov eax, [rcx + rdx*4]int占4字节,因此第index个元素地址就是:
数组首地址 + index × 4这里mov真的读取了内存。如果换成:
lea rax, [rcx + rdx*4]得到的则是&array[index],只算地址,不取出元素。
用lea做普通算术
既然lea可以计算加法和有限的乘法,编译器也会直接拿它做普通整数运算:
lea rax, [rcx + rcx*2]结果是RAX = RCX × 3。这就是为什么最开始的Add函数也可能只用一条lea完成加法。
加减乘除
常见加减法很直观:
add eax, edx ; EAX += EDX
sub eax, edx ; EAX -= EDX
inc eax ; EAX += 1
dec eax ; EAX -= 1
neg eax ; EAX = -EAX这些指令除了写回计算结果,还会更新RFLAGS中的零、符号、进位、溢出等标志,后面的条件跳转会读取它们。
乘法常见的是imul:
imul eax, edx ; EAX *= EDX
imul eax, edx, 10 ; EAX = EDX × 10除法稍微麻烦一些。64位无符号除法会把RDX:RAX拼成一个128位被除数,除数写在指令里,商放回RAX,余数放到RDX:
xor edx, edx ; 高64位先清零
div rcx ; RDX:RAX / RCX有符号除法使用idiv,通常先用cqo把RAX的符号扩展到RDX:RAX:
cqo
idiv rcx普通阅读反汇编时,只要先记住“除法会同时用到RAX和RDX”就足够了。
比较为什么不保存结果
cmp可以理解成执行一次减法,但丢弃计算结果,只保留标志位:
cmp eax, edx内部相当于计算:
EAX - EDX如果两者相等,结果为0,零标志就会被设置。后面的je会检查这个标志:
cmp eax, edx
je equal所以cmp和条件跳转通常成对出现。
判断是否为0
编译器经常用test判断一个数是不是0:
test eax, eax
jz is_zerotest会执行按位与,但同样不保存结果。一个数和自己做按位与,结果仍然是它本身,因此结果为0就说明原数为0。
下面两组代码在“判断是否为0”这件事上效果相同:
cmp eax, 0
je is_zerotest eax, eax
jz is_zero条件跳转
最简单的跳转是:
| 指令 | 含义 |
|---|---|
je或jz | 相等或结果为0时跳转 |
jne或jnz | 不相等或结果不为0时跳转 |
jmp | 无条件跳转 |
大于、小于则要先区分有符号数和无符号数。
有符号比较
普通int比较常看到:
| 指令 | 含义 |
|---|---|
jg | 大于时跳转 |
jge | 大于等于时跳转 |
jl | 小于时跳转 |
jle | 小于等于时跳转 |
这里的字母来自Greater和Less。
无符号比较
无符号整数和地址比较常看到:
| 指令 | 含义 |
|---|---|
ja | 高于时跳转 |
jae | 高于或等于时跳转 |
jb | 低于时跳转 |
jbe | 低于或等于时跳转 |
这里使用Above和Below,是为了和有符号的Greater、Less区分。
为什么要分两套?我们看8位数字0xff:
- 当成无符号数,它是255
- 当成有符号数,它是-1
同一组二进制位,因为解释方式不同,大小关系也会完全相反。cmp只负责设置标志,后面使用哪一类跳转,才决定这些标志按有符号还是无符号方式解释。
不跳转也能做条件选择
回到最开始的代码:
cmp ecx, edx
mov eax, edx
cmovg eax, ecxcmovg叫条件移动。只有前面的比较结果满足“大于”时,它才会执行复制;否则EAX保持原值。
它相当于:
eax = edx;
if (ecx > edx) {
eax = ecx;
}这种方式不需要改变执行方向。对于非常短的条件选择,编译器可能用它避免分支预测失败的代价;具体是否更快仍然取决于数据和处理器。
函数调用和栈
call会做两件事:
- 把下一条指令的地址压入栈中,作为返回地址
- 跳转到目标函数
目标函数最后执行ret,从栈中取出返回地址并跳回去。
call Add
; Add执行ret后回到这里函数需要局部空间时,常见写法是移动栈顶:
sub rsp, 40 ; 在栈上预留空间
...
add rsp, 40 ; 返回前恢复栈顶
ret有些函数还会保存后面要使用的寄存器:
push rbx
sub rsp, 32
; 函数主体
add rsp, 32
pop rbx
ret开头这部分通常叫函数序言,结尾叫函数尾声。优化后的简单函数可能完全不需要它们,例如最开始的Max只有四条指令。
尾调用
如果一个函数最后只是调用另一个函数,并直接返回它的结果,编译器有时会把:
call Other
ret优化成:
jmp OtherOther返回时会直接回到更上层调用者,省掉一层返回地址和栈帧,这就是尾调用优化的一种常见形态。
位运算中的常见模式
按位运算和C++写法基本一致:
and eax, edx ; EAX &= EDX
or eax, edx ; EAX |= EDX
xor eax, edx ; EAX ^= EDX
not eax ; EAX = ~EAX最常见的特殊写法是清零:
xor eax, eax一个数和自己异或一定得到0。相比mov eax, 0,这种编码通常更短,现代CPU也会把它识别为不依赖旧值的清零操作。
移位则包括:
shl eax, 1 ; 左移,高位丢弃,低位补0
shr eax, 1 ; 逻辑右移,高位补0
sar eax, 1 ; 算术右移,高位复制符号位shr适合无符号数,sar用于保持有符号数的正负号。左移在不溢出的前提下常对应乘以2,右移常对应除以2,但有符号除法的取整规则还需要额外注意。
看到XMM和YMM怎么办
浮点数和向量计算通常使用XMM、YMM寄存器。我们不需要一开始就背完整套指令,只要先学会拆后缀。
例如:
addss xmm0, xmm1
addps xmm0, xmm1两条都是加法,区别在最后两个字母:
ss表示只处理最低位置的一个32位浮点数ps表示同时处理一组32位浮点数,128位XMM里一次可以放4个
因此addss更接近普通的单个float加法,addps则是4个float同时相加。
扩展到256位YMM寄存器后,指令前通常多一个v,并且可以把目标与两个来源分开:
vaddps ymm0, ymm1, ymm2意思是把YMM1和YMM2中的多组浮点数分别相加,结果写入YMM0。
真正阅读时,可以先确认三件事:数据宽度是多少、处理的是整数还是浮点数、一次处理一个元素还是多个元素。这样比直接背助记符容易得多。
多线程代码里的lock
如果多个CPU核心同时修改同一块内存,普通的“读出来、加一、写回去”可能互相覆盖。
反汇编里看到lock前缀,通常表示这个内存读改写操作需要作为一个不可分割的整体完成:
lock add [rcx], 1它常对应C++中的原子操作,例如:
counter.fetch_add(1);另一条常见指令是:
lock cmpxchg [rcx], rdx它会比较内存中的旧值,只有符合预期时才写入新值,是许多无锁数据结构的基础。
不过从C++层面写代码时,应该优先使用std::atomic提供的原子操作,而不是自己拼底层指令。编译器会根据目标平台生成正确实现。
几种常见代码形态
返回0
xor eax, eax
ret判断指针是否为空
test rcx, rcx
jz is_null读取数组元素
mov eax, [rcx + rdx*4]这里RCX是数组首地址,RDX是下标,元素大小为4字节。
简单循环
xor eax, eax ; sum = 0
xor edx, edx ; i = 0
loop_begin:
add eax, [rcx + rdx*4]
inc edx
cmp edx, r8d
jl loop_begin对应的大致逻辑是:
int sum = 0;
for (int i = 0; i < count; ++i) {
sum += array[i];
}编译器实际生成的循环可能因为优化而完全不同,但“初始化 → 读取元素 → 更新下标 → 比较并跳回”这条主线通常仍然能认出来。
回到最开始的例子
现在再看:
cmp ecx, edx
mov eax, edx
cmovg eax, ecx
ret我们已经可以完整读出来:
- Windows x64把两个
int参数放在ECX和EDX。 cmp按ECX-EDX设置标志位。- 先把第二个参数放入返回值寄存器
EAX。 cmovg按有符号“大于”条件,决定是否换成第一个参数。ret返回,调用者从EAX取得结果。
反汇编看起来是一堆缩写,本质上仍然是数据移动、计算、比较和控制流,只不过变量名被寄存器与内存地址取代了。
总结
- Intel语法把目标写在前、来源写在后,方括号表示访问内存。
mov负责复制数据,movzx和movsx负责不同方式的位宽扩展。lea只计算地址表达式,不访问内存,也经常被编译器用于普通加法和乘加。cmp和test只更新标志位,通常要结合后面的条件跳转一起理解。- 有符号比较常用Greater/Less系列,无符号比较常用Above/Below系列。
call压入返回地址并跳转,ret取出地址返回。XMM、YMM指令先看数据类型和一次处理的元素数量,不必一开始背完整指令表。- 阅读反汇编时,先找参数、返回值、比较和跳转,再逐条补细节,会比从第一条指令硬啃容易很多。
参考资料
- Intel® 64 and IA-32 Architectures Software Developer's Manual, Volume 2
- AMD64 Architecture Programmer's Manual
- x86 Instruction Reference
- Agner Fog's Optimization Manuals
