1. 为什么汇编指令总是“学了就忘”
刚接触汇编那会儿,我最大的感受就是:指令太多了,而且每条指令看起来都长得差不多。MOV、MOVS、MOVZX、MOVSX,光是一个“移动”就衍生出七八个变种;ADD、ADC、INC、XADD,做加法的也不止一条路。更让人头疼的是,这些指令在不同的处理器架构下还有不同的行为——x86上跑得好好的代码,换到ARM平台可能连编译都过不了。
后来我慢慢想明白了一件事:汇编指令不是靠“背”来掌握的,而是靠“归类”和“理解设计意图”来记忆的。每一条指令的存在都有它的理由,要么是为了解决某个特定的计算问题,要么是为了优化某类操作的性能,要么是为了兼容早期的硬件设计。一旦你理解了这些“为什么”,指令就不再是一堆零散的符号,而是一套有逻辑的工具箱。
这篇文章是我自己整理的一份汇编语言常用指令汇总,主要面向x86/x86-64架构(这也是大多数人学习汇编的起点),同时会穿插一些ARM架构的对比说明。内容会持续更新,每次遇到新的指令或者踩到新的坑,我都会补充进来。适合正在学习汇编的初学者、需要做底层调试的开发者、以及对计算机体系结构感兴趣的技术爱好者。不管你是为了应付考试、做逆向分析、还是写性能敏感的底层代码,这份汇总都能当做一个随时查阅的参考手册来用。
需要提前说明的是,汇编指令的完整列表非常庞大,Intel和AMD的官方手册动辄上千页。我这里只汇总实际开发中最常遇到的核心指令,那些极其冷门的指令(比如BOUND、ARPL这类)不会展开讲,但会提一下它们的存在和大致用途。
2. 数据传送指令:汇编世界的搬运工
2.1 MOV家族:最基础也最容易踩坑的指令
MOV是汇编里出现频率最高的指令,没有之一。它的作用简单粗暴:把数据从一个地方搬到另一个地方。但就是这么一个“简单”的指令,新手至少会踩三个坑。
第一个坑是操作数大小不匹配。MOV要求源操作数和目的操作数的位宽必须一致。你不能把一个64位的寄存器直接塞进32位的寄存器里,也不能把内存里的一个字节直接搬到64位寄存器。比如下面这段代码就是错的:
mov rax, bl ; 错误:64位寄存器不能直接接收8位数据正确的做法是用MOVZX(零扩展)或MOVSX(符号扩展)来做转换:
movzx rax, bl ; 正确:将bl的8位数据零扩展到rax的64位 movsx rax, bl ; 正确:将bl的8位数据符号扩展到rax的64位第二个坑是内存到内存的传送。x86架构不允许一条MOV指令直接从内存地址搬到另一个内存地址,必须经过寄存器中转:
; 错误写法 mov [var1], [var2] ; 正确写法 mov rax, [var2] mov [var1], rax第三个坑是段寄存器的限制。CS寄存器不能作为MOV的目的操作数,这是硬件层面的硬性规定,因为修改代码段寄存器会导致处理器行为不可预测。
MOV的变种也值得注意。MOVS/MOVSB/MOVSW/MOVSD/MOVSQ是串传送指令,它们配合REP前缀可以高效地复制大块内存。CMOVcc是条件传送指令,根据标志位决定是否执行传送,这在写无分支代码时非常有用。
2.2 栈操作指令:PUSH与POP的隐藏细节
PUSH和POP看起来简单,但有几个细节新手经常忽略。
首先是栈的生长方向。x86的栈是向低地址方向生长的,也就是说,PUSH操作会让RSP(栈指针)减小,POP操作会让RSP增大。这一点和很多人的直觉相反,但理解了之后就能明白为什么栈溢出通常表现为“栈指针越界到非法内存区域”。
其次是操作数大小对栈指针的影响。在64位模式下,PUSH一个64位值会让RSP减8,PUSH一个16位值会让RSP减2。但这里有个历史遗留问题:32位模式下PUSH一个32位值让ESP减4,而16位模式下PUSH一个16位值让SP减2。这种不一致性在混合位宽编程时容易出错。
还有一个容易被忽视的指令是PUSHFQ/POPFQ,它们用来把标志寄存器压栈或出栈。在写中断处理程序或者需要保存/恢复处理器状态时,这两个指令非常关键。
; 保存所有通用寄存器到栈上(典型的函数序言) push rbp push rbx push r12 push r13 push r14 push r15 ; 函数结束时恢复 pop r15 pop r14 pop r13 pop r12 pop rbx pop rbp注意:
PUSH和POP的顺序必须严格对应,后进先出。如果PUSH的顺序是A、B、C,那么POP的顺序必须是C、B、A,否则寄存器里的值就全乱了。
2.3 交换与扩展指令:XCHG、MOVZX、MOVSX
XCHG指令用来交换两个操作数的值,不需要中间寄存器。它的一个经典用途是实现自旋锁的原子操作——XCHG指令自带LOCK语义,在多处理器环境下可以保证原子性。
MOVZX和MOVSX的区别在于对高位字节的处理方式。MOVZX用零填充高位,MOVSX用符号位填充高位。选择哪一个取决于你把数据当作无符号数还是有符号数来处理。比如从内存读取一个字节的温度值,如果温度范围是-40到125度,那就应该用MOVSX;如果读取的是一个ASCII字符,那就用MOVZX。
LEA指令(Load Effective Address)虽然名字里有“Load”,但它实际上不访问内存,只是计算地址表达式的结果。这个特性让它成为了一条“免费”的算术指令——你可以在不改变标志位的情况下做加法和乘法。比如LEA rax, [rbx + rcx*4 + 8]可以一次性完成乘4、加偏移、加基址三个操作,比用ADD和SHL组合要快得多。
3. 算术与逻辑运算指令:计算的基石
3.1 加减法指令:ADD、SUB、ADC、SBB
ADD和SUB是最基本的算术指令,但它们的变种ADC(带进位加法)和SBB(带借位减法)才是实现大数运算的关键。
ADC会把进位标志(CF)也加到结果里。这个特性让多字长加法变得非常简单:从最低字开始,用ADD计算,然后用ADC逐字向上传播进位。比如实现两个128位数的加法:
; 假设第一个128位数在 RDX:RAX,第二个在 RCX:RBX ; 结果存回 RDX:RAX add rax, rbx ; 低64位相加,可能产生进位 adc rdx, rcx ; 高64位相加,同时加上低位的进位SBB同理,用于多字长减法。这里有个容易犯的错误:INC和DEC指令不会影响进位标志(CF),但会影响其他标志位。如果你在循环中用INC来计数,同时又依赖CF做条件判断,就会出问题。这种场景下应该用ADD reg, 1代替INC reg。
3.2 乘除法指令:MUL、IMUL、DIV、IDIV的陷阱
乘除法指令是汇编里最容易出错的部分,因为它们的操作数规则和结果存放位置比较特殊。
MUL(无符号乘法)和IMUL(有符号乘法)都支持单操作数和双操作数形式。单操作数形式下,被乘数隐含在AL/AX/EAX/RAX中,结果存放在AX/DX:AX/EDX:EAX/RDX:RAX中。双操作数形式下,结果直接存放在目的寄存器中,但结果会被截断到操作数大小。
; 单操作数乘法:计算两个64位数的乘积 mov rax, 0x123456789ABCDEF0 mov rbx, 0x1111111111111111 mul rbx ; 结果在 RDX:RAX 中,RDX是高64位,RAX是低64位 ; 双操作数乘法:结果截断为64位 imul rax, rbx ; RAX = RAX * RBX 的低64位DIV和IDIV的陷阱更多。首先,除法指令要求被除数的位宽是除数的两倍。比如用64位除数时,被除数必须是128位(放在RDX:RAX中)。其次,如果商超出了目的寄存器的表示范围,会触发除法溢出异常(#DE),导致程序崩溃。所以在做除法之前,通常需要检查除数是否为零,以及商是否会溢出。
; 安全的64位除法示例 mov rax, dividend_low mov rdx, dividend_high test rcx, rcx ; 检查除数是否为零 jz divide_by_zero_error div rcx ; RAX = 商, RDX = 余数提示:在现代处理器上,除法指令的延迟非常高(通常20-40个时钟周期),如果性能敏感,可以考虑用乘法加移位来替代除以常数的操作。编译器通常会自动做这个优化。
3.3 位运算指令:AND、OR、XOR、NOT、TEST
位运算指令在底层编程中无处不在,从权限掩码到哈希计算,从加密算法到图形处理,都离不开它们。
AND常用于清除特定位(掩码操作),OR用于设置特定位,XOR用于翻转特定位。XOR reg, reg是一个经典的清零操作,它比MOV reg, 0更短、更快,而且不影响标志位以外的处理器状态。
TEST指令和AND做同样的运算,但不保存结果,只更新标志位。它通常用来检查某个位是否被设置:
test al, 0x01 ; 检查AL的最低位 jnz bit_is_set ; 如果最低位为1,跳转NOT指令是按位取反,它会影响所有操作数位,但不影响任何标志位。这一点和NEG(取负)不同,NEG会影响标志位。
3.4 移位与循环移位:SHL、SHR、SAR、ROL、ROR
移位指令分为算术移位和逻辑移位两类。SHL(逻辑左移)和SHR(逻辑右移)用零填充空出的位,SAR(算术右移)用符号位填充空出的位。对于有符号数的除法,SAR可以实现除以2的幂次的效果,而且比IDIV快得多。
; 有符号数除以8(2的3次方) mov rax, -64 sar rax, 3 ; RAX = -8循环移位指令ROL、ROR、RCL、RCR在加密算法中很常见。ROL和ROR是单纯的循环移位,RCL和RCR会通过进位标志参与循环。在实现DES、AES等加密算法时,这些指令是核心操作。
移位指令的一个常见坑是移位计数为零时标志位不变。如果你写SHL rax, 0,RAX不变,标志位也不变。这在某些依赖标志位的条件跳转场景下会导致意外行为。
4. 控制流指令:让程序学会“拐弯”
4.1 无条件跳转与条件跳转:JMP与Jcc家族
JMP是无条件跳转,相当于高级语言里的goto。它有三种形式:短跳转(8位偏移)、近跳转(32位偏移)和远跳转(改变代码段寄存器)。在64位模式下,远跳转基本不用了,短跳转和近跳转由汇编器自动选择。
条件跳转指令Jcc是汇编里最庞大的家族之一,有几十个成员。它们根据标志位的状态决定是否跳转。常用的有:
| 指令 | 含义 | 标志位条件 |
|---|---|---|
| JE/JZ | 相等/为零 | ZF=1 |
| JNE/JNZ | 不相等/非零 | ZF=0 |
| JG/JNLE | 有符号大于 | ZF=0 且 SF=OF |
| JGE/JNL | 有符号大于等于 | SF=OF |
| JL/JNGE | 有符号小于 | SF≠OF |
| JLE/JNG | 有符号小于等于 | ZF=1 或 SF≠OF |
| JA/JNBE | 无符号大于 | CF=0 且 ZF=0 |
| JB/JNAE | 无符号小于 | CF=1 |
这里最容易混淆的是有符号和无符号跳转指令的选择。JG和JA看起来都是“大于”,但JG用于有符号数比较,JA用于无符号数比较。如果你用JA来判断两个有符号数的大小,结果可能完全错误。
; 有符号数比较:-1 和 1 mov rax, -1 cmp rax, 1 jg signed_greater ; 正确:-1 不大于 1,不跳转 ja unsigned_greater ; 错误:-1 作为无符号数是最大值,会跳转4.2 循环指令:LOOP、LOOPE、LOOPNE
LOOP指令用RCX/ECX/CX作为计数器,每次执行自动减1,直到计数器为零。它看起来很方便,但在现代处理器上性能并不好,因为LOOP指令通常比等价的DEC+JNZ组合要慢。Intel的优化手册里明确建议避免使用LOOP。
; 不推荐:使用LOOP mov rcx, 100 loop_start: ; 循环体 loop loop_start ; 推荐:使用DEC + JNZ mov rcx, 100 loop_start: ; 循环体 dec rcx jnz loop_startLOOPE(相等时循环)和LOOPNE(不相等时循环)会同时检查ZF标志和计数器。这两个指令在实际代码中很少见,但在某些字符串处理场景下可能会遇到。
4.3 过程调用与返回:CALL、RET、ENTER、LEAVE
CALL指令把返回地址压栈,然后跳转到目标地址。RET指令从栈上弹出返回地址,然后跳转回去。这两个指令是函数调用的基础。
在64位System V ABI(Linux/macOS使用)中,前六个整数参数通过RDI、RSI、RDX、RCX、R8、R9传递,浮点参数通过XMM0-XMM7传递。返回值放在RAX中。而在Windows x64 ABI中,前四个参数通过RCX、RDX、R8、R9传递,返回值同样在RAX中。
ENTER和LEAVE是一对用来建立和销毁栈帧的指令。ENTER相当于PUSH RBP+MOV RBP, RSP+ 分配局部变量空间,LEAVE相当于MOV RSP, RBP+POP RBP。但在现代编译器中,ENTER很少被使用,因为它的执行速度比手动展开的指令序列要慢。
; 典型的函数序言和尾声(手动展开) my_function: push rbp mov rbp, rsp sub rsp, 32 ; 分配32字节局部变量空间 ; 函数体 mov rsp, rbp pop rbp ret注意:在64位模式下,栈必须保持16字节对齐。
CALL指令会压入8字节的返回地址,所以函数入口处的RSP是8模16的。在PUSH RBP之后,RSP变成16字节对齐,这时候再分配局部变量空间时,分配的大小应该是16的倍数。
5. 字符串操作指令:批量处理的利器
5.1 串传送与串比较:MOVS、CMPS
字符串操作指令是x86架构的一大特色,它们可以配合REP前缀实现高效的内存块操作。MOVS系列指令把数据从DS:RSI搬到ES:RDI,然后根据方向标志(DF)自动调整RSI和RDI。
; 复制一块内存(RCX = 字节数) cld ; 清除方向标志,让RSI/RDI递增 rep movsb ; 重复执行MOVSB,直到RCX为零CMPS系列指令比较两块内存的内容,并设置标志位。配合REPE(相等时重复)前缀,可以实现内存块的比较:
; 比较两块内存是否相同 cld repe cmpsb ; 相等时继续比较,直到不相等或RCX为零 je memory_equal ; 如果ZF=1,说明两块内存完全相同这里有个性能陷阱:REP MOVSB在小块内存复制时可能比手动循环慢,因为REP前缀有启动开销。一般来说,复制超过128字节时REP MOVSB才有优势。现代处理器还有ERMSB(增强型REP MOVSB)特性,在支持该特性的CPU上,REP MOVSB的性能会好很多。
5.2 串扫描与串存储:SCAS、STOS
SCAS(Scan String)在内存中搜索特定值。它把AL/AX/EAX/RAX与ES:RDI指向的内存比较,然后调整RDI。配合REPNE前缀,可以实现“查找第一个匹配项”的功能:
; 在内存块中查找字符'A'(0x41) mov al, 0x41 mov rcx, buffer_size mov rdi, buffer cld repne scasb ; 不相等时重复,直到找到或RCX为零 jz found ; 如果ZF=1,说明找到了STOS(Store String)把AL/AX/EAX/RAX的值写入ES:RDI指向的内存。配合REP前缀,可以快速填充内存块:
; 把一块内存清零 xor eax, eax ; EAX = 0 mov rcx, buffer_size mov rdi, buffer cld rep stosb ; 重复写入0,直到RCX为零STOS和SCAS只能操作ES:RDI指向的内存,不能指定其他段寄存器。这是硬件设计的限制,在写代码时需要注意。
5.3 串输入输出:INS、OUTS
INS和OUTS用于在内存和I/O端口之间直接传输数据。这两个指令在用户态程序中通常不可用(需要特权级别),主要出现在操作系统内核或驱动程序中。
; 从I/O端口读取数据到内存(需要特权级别) mov dx, 0x3F8 ; COM1端口 mov rdi, buffer mov rcx, 16 cld rep insb ; 从端口读取16字节到buffer在用户态编程中,I/O操作通常通过系统调用或内存映射I/O来完成,不会直接使用INS/OUTS。
6. 处理器控制与标志位操作
6.1 标志位操作指令:CLC、STC、CMC、CLD、STD
标志位操作指令用来直接设置或清除特定的标志位。CLC清除进位标志,STC设置进位标志,CMC翻转进位标志。CLD和STD分别清除和设置方向标志,控制字符串操作的方向。
; 在调用字符串操作之前,通常需要明确设置方向标志 cld ; 确保RSI/RDI递增(从低地址向高地址处理) rep movsb方向标志(DF)是一个容易被忽视的坑。有些库函数或系统调用可能会改变DF的值,如果你的代码依赖DF的特定状态,最好在使用字符串指令之前显式设置它。
6.2 处理器同步与空操作:NOP、PAUSE、MFENCE
NOP是最简单的指令,它什么都不做,只占用一个字节的空间。它的主要用途是代码对齐和热补丁。编译器经常在函数入口处插入NOP,以便后续用热补丁替换函数实现。
PAUSE指令告诉处理器当前处于自旋等待状态,可以优化功耗和超线程性能。在自旋锁的实现中,PAUSE应该放在循环体内:
spin_lock: mov rax, 1 xchg rax, [lock_var] test rax, rax jz lock_acquired pause ; 提示处理器正在自旋 jmp spin_lock lock_acquired: ; 临界区代码MFENCE、LFENCE、SFENCE是内存屏障指令,用于控制内存操作的顺序。在弱内存模型的多处理器系统中,这些指令对于保证数据一致性至关重要。MFENCE是全屏障,LFENCE只屏障读操作,SFENCE只屏障写操作。
6.3 系统调用与中断:SYSCALL、INT、IRET
在64位Linux系统中,SYSCALL指令用于发起系统调用。系统调用号放在RAX中,参数按照System V ABI传递,返回值在RAX中。
; 64位Linux的write系统调用 mov rax, 1 ; sys_write mov rdi, 1 ; stdout mov rsi, message ; 字符串地址 mov rdx, length ; 字符串长度 syscallINT指令用于触发软件中断。在早期的DOS系统中,INT 21h是系统调用的入口。在现代操作系统中,INT 0x80在32位Linux上仍然可用,但在64位系统上应该使用SYSCALL。
IRET(中断返回)用于从中断处理程序返回。它会从栈上恢复CS、EIP/RIP和标志寄存器。在写操作系统内核时,IRET是中断处理的标准返回方式。
7. 那些年我踩过的汇编坑
7.1 操作数顺序:AT&T与Intel语法的混淆
汇编有两种主要的语法风格:Intel语法和AT&T语法。Intel语法是MOV 目的, 源,AT&T语法是MOV 源, 目的。GCC默认使用AT&T语法,而大多数汇编教程使用Intel语法。如果你在写内联汇编时搞混了这两种语法,代码会完全错误。
; Intel语法 mov rax, rbx ; RAX = RBX ; AT&T语法 movq %rbx, %rax ; RAX = RBXAT&T语法还有几个特点:寄存器前面加%,立即数前面加$,指令后缀表示操作数大小(b=字节,w=字,l=双字,q=四字)。在GCC内联汇编中,默认是AT&T语法,但可以用.intel_syntax noprefix指令切换到Intel语法。
7.2 标志位被意外修改
标志位是汇编编程中最容易出问题的地方。很多指令会修改标志位,而你可能没有意识到。比如INC和DEC会修改ZF、SF、OF,但不修改CF。MOV和LEA不修改任何标志位。ADD、SUB、AND、OR、XOR会修改所有状态标志。
一个典型的错误场景是在比较和跳转之间插入了修改标志位的指令:
cmp rax, rbx add rcx, 1 ; 错误:ADD修改了标志位,破坏了CMP的结果 jg greater ; 这里的跳转条件已经不可靠了正确的做法是把ADD放到CMP之前,或者用不修改标志位的指令(如LEA)来完成计算。
7.3 栈对齐问题导致的崩溃
在64位系统中,栈对齐是一个硬性要求。很多SSE指令(如MOVAPS)要求操作数16字节对齐,如果栈没有正确对齐,这些指令会触发通用保护异常(#GP)。
函数调用时,CALL指令压入8字节返回地址,所以函数入口处的RSP是8模16。如果函数中使用了需要16字节对齐的SSE指令,必须在函数序言中调整栈指针:
my_function: push rbp mov rbp, rsp sub rsp, 16 ; 确保RSP是16字节对齐 and rsp, -16 ; 强制对齐到16字节边界 ; 函数体 mov rsp, rbp pop rbp ret提示:如果你在写汇编函数时遇到莫名其妙的段错误,而且错误发生在SSE指令附近,十有八九是栈对齐的问题。用调试器检查
RSP的值,确保它是16的倍数。
7.4 寄存器保存约定
在写汇编函数时,必须遵守调用约定中关于寄存器保存的规定。在System V ABI中,RBX、RBP、R12-R15是“被调用者保存”寄存器,函数如果使用了这些寄存器,必须在返回前恢复它们的值。RAX、RCX、RDX、RSI、RDI、R8-R11是“调用者保存”寄存器,函数可以随意修改。
; 正确的做法:保存并恢复被调用者保存寄存器 my_function: push rbx push r12 ; 使用RBX和R12 pop r12 pop rbx ret如果忘记恢复这些寄存器,调用者的代码可能会因为寄存器值被意外修改而崩溃。这种bug通常很难排查,因为崩溃点可能离出错点很远。
8. 如何高效查阅和记忆汇编指令
8.1 建立自己的指令分类体系
我自己的做法是把指令按功能分成几个大类,每个大类下面再按操作数类型和位宽细分。比如传送类指令下面有:通用传送(MOV、CMOV、XCHG)、栈操作(PUSH、POP)、扩展传送(MOVZX、MOVSX)、串传送(MOVS)、地址计算(LEA)。这样在需要的时候,可以快速定位到相关指令。
另一个有用的分类维度是是否影响标志位。把指令分成“影响标志位”和“不影响标志位”两类,在写条件判断代码时特别有用。不影响标志位的指令包括MOV、LEA、PUSH、POP、NOP、XCHG(大部分情况下)等。
8.2 利用官方手册和在线资源
Intel的《Software Developer's Manual》是汇编编程的权威参考,但它的组织方式不太适合快速查阅。我通常用以下几种资源来补充:
- Felix Cloutier的x86指令参考:按字母顺序列出所有x86指令,每条指令都有详细的说明、操作数组合和示例代码。界面简洁,查询速度快。
- Compiler Explorer:可以在线写C/C++代码,实时查看生成的汇编代码。这是学习编译器优化和指令选择的最佳工具。
- Godbolt:和Compiler Explorer类似,但支持更多语言和编译器。
- Agner Fog的指令表:列出了每条指令在不同微架构上的延迟、吞吐量和执行端口。做性能优化时必查。
8.3 通过反汇编来学习
学习汇编最有效的方法之一是反汇编自己写的C代码。用objdump -d或gdb的disassemble命令,看看编译器把你的代码翻译成了什么汇编指令。你会发现很多有趣的优化:比如编译器用LEA来做乘法,用XOR来清零,用CMOV来消除分支。
# 编译C代码并反汇编 gcc -O2 -c example.c -o example.o objdump -d example.o通过对比不同优化级别(-O0、-O1、-O2、-O3)下的汇编输出,可以直观地理解编译器优化对指令选择的影响。这比单纯看手册要生动得多。
8.4 写小的测试程序来验证行为
对于不确定的指令行为,最好的办法是写一个小的测试程序来验证。比如你不确定INC是否影响CF,可以写一段代码来测试:
; 测试INC是否影响CF mov rax, 0xFFFFFFFFFFFFFFFF add rax, 1 ; 产生进位,CF=1 inc rax ; 如果INC不影响CF,CF应该保持为1 jc carry_set ; 如果跳转,说明CF仍然是1 ; 如果执行到这里,说明INC清除了CF这种“实验驱动”的学习方式比死记硬背要有效得多。每次遇到不确定的行为,就写个测试验证一下,印象会非常深刻。
汇编指令的掌握是一个长期积累的过程,不可能一蹴而就。我自己的经验是:先掌握最常用的二三十条指令,能够读懂编译器生成的汇编代码;然后根据实际需要,逐步扩展到特定领域的指令(如SIMD指令、系统指令、加密指令)。这份汇总会随着我遇到的新指令和新问题持续更新,也欢迎有经验的同行补充和指正。