LOADING

加载过慢请开启缓存 浏览器默认开启

x86-64汇编指令整理

2026/8/27 C++ Cpp x86-64 汇编
本文总阅读量

整理一下反汇编遇到的指令

本文使用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,也没有看到任何变量名,只剩四条指令。我们先粗略翻译一下:

  1. cmp ecx, edx比较参数ab
  2. mov eax, edx先假设返回b
  3. 如果a>bcmovg eax, ecx再把返回值换成a
  4. ret返回调用者

其中ECXEDX是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字节。常见宽度如下:

名称大小
byte1字节
word2字节
dword4字节
qword8字节

数据在寄存器和内存之间移动

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

所以看到movzxmovsx时,编译器其实是在告诉我们:它把原数据理解成无符号还是有符号。


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,通常先用cqoRAX的符号扩展到RDX:RAX

cqo
idiv rcx

普通阅读反汇编时,只要先记住“除法会同时用到RAXRDX”就足够了。


比较为什么不保存结果

cmp可以理解成执行一次减法,但丢弃计算结果,只保留标志位:

cmp eax, edx

内部相当于计算:

EAX - EDX

如果两者相等,结果为0,零标志就会被设置。后面的je会检查这个标志:

cmp eax, edx
je  equal

所以cmp和条件跳转通常成对出现。

判断是否为0

编译器经常用test判断一个数是不是0:

test eax, eax
jz   is_zero

test会执行按位与,但同样不保存结果。一个数和自己做按位与,结果仍然是它本身,因此结果为0就说明原数为0。

下面两组代码在“判断是否为0”这件事上效果相同:

cmp  eax, 0
je   is_zero
test eax, eax
jz   is_zero

条件跳转

最简单的跳转是:

指令含义
jejz相等或结果为0时跳转
jnejnz不相等或结果不为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, ecx

cmovg叫条件移动。只有前面的比较结果满足“大于”时,它才会执行复制;否则EAX保持原值。

它相当于:

eax = edx;
if (ecx > edx) {
    eax = ecx;
}

这种方式不需要改变执行方向。对于非常短的条件选择,编译器可能用它避免分支预测失败的代价;具体是否更快仍然取决于数据和处理器。


函数调用和栈

call会做两件事:

  1. 把下一条指令的地址压入栈中,作为返回地址
  2. 跳转到目标函数

目标函数最后执行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 Other

Other返回时会直接回到更上层调用者,省掉一层返回地址和栈帧,这就是尾调用优化的一种常见形态。


位运算中的常见模式

按位运算和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,但有符号除法的取整规则还需要额外注意。


看到XMMYMM怎么办

浮点数和向量计算通常使用XMMYMM寄存器。我们不需要一开始就背完整套指令,只要先学会拆后缀。

例如:

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

意思是把YMM1YMM2中的多组浮点数分别相加,结果写入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

我们已经可以完整读出来:

  1. Windows x64把两个int参数放在ECXEDX
  2. cmpECX-EDX设置标志位。
  3. 先把第二个参数放入返回值寄存器EAX
  4. cmovg按有符号“大于”条件,决定是否换成第一个参数。
  5. ret返回,调用者从EAX取得结果。

反汇编看起来是一堆缩写,本质上仍然是数据移动、计算、比较和控制流,只不过变量名被寄存器与内存地址取代了。


总结

  1. Intel语法把目标写在前、来源写在后,方括号表示访问内存。
  2. mov负责复制数据,movzxmovsx负责不同方式的位宽扩展。
  3. lea只计算地址表达式,不访问内存,也经常被编译器用于普通加法和乘加。
  4. cmptest只更新标志位,通常要结合后面的条件跳转一起理解。
  5. 有符号比较常用Greater/Less系列,无符号比较常用Above/Below系列。
  6. call压入返回地址并跳转,ret取出地址返回。
  7. XMMYMM指令先看数据类型和一次处理的元素数量,不必一开始背完整指令表。
  8. 阅读反汇编时,先找参数、返回值、比较和跳转,再逐条补细节,会比从第一条指令硬啃容易很多。

参考资料

本站不开放评论区,如有讨论内容请移步至本站Github讨论区