1. 为什么汇编指令总让人记了又忘
刚接触汇编那会儿,我最大的感受就是:指令太多了,而且长得还特别像。MOV、MOVS、MOVZX、MOVSX,光看名字就头大;ADD、ADC、INC,功能相近但标志位影响完全不同。更别提还有一堆条件跳转指令,JE、JZ、JNE、JNZ,初学者很容易搞混。
后来在逆向分析和底层调试的活儿里摸爬滚打久了,才慢慢悟出一个道理:汇编指令不是靠死记硬背的,而是靠分类理解和反复使用。你不需要记住每一条指令的所有细节,但你必须清楚每一类指令解决什么问题、操作数怎么组合、标志位怎么变化。这三件事搞明白了,剩下的就是查手册和积累经验的事。
这篇汇总就是基于这个思路整理的。它覆盖了x86架构下最常用的汇编指令,按功能分类,每条指令都标注了核心用途、操作数形式和典型场景。适合正在学汇编的初学者、做逆向分析的安全从业者、以及需要阅读底层代码的开发者。我会尽量用大白话解释每条指令在干什么,配合实际例子,让你看完就能用。
提示:本文以x86架构(32位为主,兼顾16位和64位差异)为基准,AT&T语法和Intel语法在操作数顺序上有区别,文中默认使用Intel语法(目标操作数在前,源操作数在后),这也是大多数教材和调试器默认的格式。
2. 数据传送类指令:最基础也最容易踩坑
数据传送指令是汇编里用得最多的一类,几乎每一段代码都离不开它们。别看它们功能简单,真要用好,细节还挺多。
2.1 MOV指令:看似简单,限制不少
MOV是汇编里最基础的指令,作用就是把源操作数的值复制到目标操作数。格式是MOV 目标, 源。
但MOV有几个硬性限制,初学者经常在这里翻车:
- 源和目标不能同时为内存操作数。也就是说
MOV [eax], [ebx]是非法的,必须借助寄存器中转。 - 目标操作数不能是
CS、IP这类控制寄存器。 - 立即数不能直接送到段寄存器。
- 操作数宽度必须一致。
MOV AL, 0x1234是错的,因为AL是8位,0x1234是16位。
我见过太多人在调试时发现MOV报错,排查半天结果是操作数宽度不匹配。记住一个原则:汇编器不会帮你做隐式类型转换,8位就是8位,32位就是32位,对不上就报错。
MOV EAX, 0x12345678 ; 把32位立即数送到EAX MOV EBX, EAX ; 寄存器到寄存器 MOV [EBP-4], EAX ; 寄存器到栈上局部变量 MOV AL, [ESI] ; 从内存取一个字节到AL2.2 MOVZX和MOVSX:零扩展与符号扩展
这两条指令是MOV的补充,专门解决小宽度数据到大宽度寄存器的传送问题。
MOVZX(Move with Zero-Extend)把源操作数的值零扩展到目标寄存器。比如MOVZX EAX, BL,如果BL是0x80,那么EAX变成0x00000080,高位全部补零。
MOVSX(Move with Sign-Extend)则是符号扩展。同样MOVSX EAX, BL,如果BL是0x80(最高位为1,表示负数),那么EAX变成0xFFFFFF80,高位全部补符号位。
这两条指令在处理有符号和无符号数据时非常关键。我踩过的坑是:从内存读取一个字节做算术运算时,忘了用MOVSX,结果负数被当成正数处理,程序逻辑完全跑偏。后来养成了习惯,只要涉及小宽度有符号数,一律用MOVSX。
2.3 XCHG和LEA:交换与取地址
XCHG用于交换两个操作数的值,至少有一个操作数是寄存器。XCHG EAX, EBX就是把EAX和EBX的值互换。这条指令在多线程编程里实现自旋锁时经常用到,因为它是原子的。
LEA(Load Effective Address)看起来像传送指令,实际上做的是地址计算。LEA EAX, [EBX+ECX*4+8]会把EBX+ECX*4+8这个地址值算出来放到EAX,而不是去访问那个地址的内存。这个特性让LEA经常被用来做快速算术运算,比如乘加组合,比用MUL和ADD快得多。
实操心得:
LEA不访问内存,只做地址计算,所以它不会触发内存访问异常。在需要计算复杂地址表达式但又不想真正读取内存时,LEA是首选。
2.4 PUSH和POP:栈操作的核心
PUSH把操作数压入栈,POP从栈顶弹出。在32位模式下,每次PUSH/POP操作4字节,栈指针ESP相应减4或加4。
栈操作在函数调用、局部变量分配、参数传递中无处不在。但要注意:PUSH和POP的操作数宽度必须匹配,而且PUSH一个立即数时,汇编器会把它扩展成栈宽度。
常见的一个错误是PUSH AL,这在32位模式下是非法的,因为栈操作最小单位是16位(实际32位模式下通常要求32位对齐)。正确做法是MOVZX EAX, AL然后PUSH EAX,或者用PUSHAD保存所有通用寄存器。
3. 算术运算类指令:标志位才是重点
算术指令本身不难,难的是它们对标志位的影响。很多条件跳转指令依赖的标志位,就是算术指令设置的。
3.1 ADD、SUB、INC、DEC:加减法四兄弟
ADD和SUB是最基本的加减法。INC和DEC是自增和自减,相当于加1和减1。
这四条指令都会影响标志位:CF(进位/借位)、ZF(零标志)、SF(符号标志)、OF(溢出标志)、AF(辅助进位)、PF(奇偶标志)。
关键区别在于:INC和DEC不影响CF标志,而ADD和SUB会影响。这个差异在循环计数和边界判断时非常重要。比如你在一个循环里用INC递减计数器,同时依赖之前CMP设置的CF,那么INC不会破坏CF,逻辑就能正常工作。
MOV ECX, 10 loop_start: ; 做一些事情 DEC ECX JNZ loop_start ; ECX不为零就继续循环3.2 ADC和SBB:带进位加减法
ADC是带进位加法,计算目标 = 目标 + 源 + CF。SBB是带借位减法,计算目标 = 目标 - 源 - CF。
这两条指令专门用于多精度运算。比如你要做64位加法,但寄存器只有32位,就可以把64位数拆成高低两个32位,低位用ADD,高位用ADC,自动把低位的进位传上去。
; 64位加法:EDX:EAX + EBX:ECX ADD EAX, ECX ; 低32位相加,设置CF ADC EDX, EBX ; 高32位相加,加上CF这个模式在密码学实现和大数运算里非常常见。我第一次写大数加法时没注意进位传递,结果高位总是差1,排查了好久才发现是忘了用ADC。
3.3 MUL、IMUL、DIV、IDIV:乘除法有陷阱
乘法指令分无符号MUL和有符号IMUL。MUL的结果总是放在AX(8位乘法)、DX:AX(16位乘法)或EDX:EAX(32位乘法)里。也就是说,32位乘法会产生64位结果,分别放在EDX和EAX中。
除法指令DIV和IDIV更需要注意:被除数总是双倍宽度。32位除法时,被除数是EDX:EAX组成的64位数,除数由指令指定,商放在EAX,余数放在EDX。
这里有个大坑:除法溢出。如果被除数的高半部分(EDX)大于或等于除数,除法会触发#DE异常。所以在做除法前,通常需要把EDX清零(无符号)或用CDQ指令符号扩展(有符号)。
; 32位无符号除法:EAX / EBX XOR EDX, EDX ; 清零EDX,避免溢出 DIV EBX ; 商在EAX,余数在EDX ; 32位有符号除法:EAX / EBX CDQ ; 把EAX的符号位扩展到EDX IDIV EBX ; 商在EAX,余数在EDX注意:
CDQ指令专门用于有符号除法前的准备,它把EAX的符号位复制到EDX的所有位。如果EAX是正数,EDX变成0;如果是负数,EDX变成0xFFFFFFFF。这个细节在写有符号除法时绝对不能漏。
3.4 CMP和TEST:只影响标志位,不保存结果
CMP做减法但不保存结果,只设置标志位。TEST做逻辑与但不保存结果,只设置标志位。
这两条指令是条件跳转的前置指令。CMP EAX, EBX后面通常跟JE、JG、JL等条件跳转。TEST EAX, EAX常用来判断EAX是否为零,后面跟JZ或JNZ。
TEST的一个经典用法是检查某一位是否被设置:TEST AL, 0x01,如果AL的最低位是1,ZF为0;否则ZF为1。这个技巧在驱动开发和位操作里用得很多。
4. 逻辑与移位类指令:位操作的利器
逻辑指令和移位指令是处理二进制位的核心工具,在加密算法、压缩算法、图形处理里随处可见。
4.1 AND、OR、XOR、NOT:逻辑四则运算
AND、OR、XOR、NOT分别对应逻辑与、或、异或、非。它们按位操作,不产生进位。
XOR有个特殊用途:XOR EAX, EAX可以把EAX清零,比MOV EAX, 0更短更快。而且XOR不影响CF和OF,只影响SF、ZF、PF。
XOR还常用于简单加密:对数据异或一个密钥,再异或同一个密钥就能还原。这个特性在壳代码和混淆代码里很常见。
NOT是按位取反,不影响任何标志位。这一点和NEG不同,NEG是取负(相当于用0减去操作数),会影响标志位。
4.2 SHL、SHR、SAR:移位三兄弟
SHL是逻辑左移,低位补0,最高位移入CF。SHR是逻辑右移,高位补0,最低位移入CF。SAR是算术右移,高位补符号位,最低位移入CF。
左移一位相当于乘2,右移一位相当于除2(对于无符号数)。但要注意溢出问题:左移可能改变符号位,导致正数变负数。
SAR和SHR的区别在于对负数的处理。SHR把负数当无符号数处理,高位补0;SAR保持符号位不变,高位补1。所以有符号数除法用SAR,无符号数除法用SHR。
MOV EAX, -8 SAR EAX, 1 ; EAX = -4,保持符号 MOV EBX, -8 SHR EBX, 1 ; EBX = 0x7FFFFFFC,变成大正数4.3 ROL、ROR、RCL、RCR:循环移位
循环移位把移出的位补到另一端。ROL是循环左移,ROR是循环右移。RCL和RCR是带进位的循环移位,把CF也纳入循环。
循环移位在哈希算法和校验和计算里很常见。比如CRC校验就会用到带进位的循环移位。
4.4 BT、BTS、BTR、BTC:位测试与修改
这组指令专门用于测试和修改单个位。BT测试某位并把该位送入CF。BTS测试并置位,BTR测试并复位,BTC测试并取反。
在操作位图或标志位集合时,这组指令非常高效。比如你要检查一个32位掩码的第5位是否设置,用BT EAX, 5然后看CF就行,比用TEST加移位组合简洁得多。
5. 控制转移类指令:程序流程的指挥棒
控制转移指令决定了程序的执行顺序,包括跳转、循环、函数调用和返回。
5.1 JMP:无条件跳转
JMP是无条件跳转,直接改变EIP的值。可以跳转到绝对地址,也可以跳转到相对偏移。
JMP分短跳转(8位偏移)、近跳转(16/32位偏移)和远跳转(改变CS和EIP)。在同一个代码段内跳转用近跳转就够了,跨段跳转才需要远跳转。
实操心得:在逆向分析时,
JMP指令经常被用来混淆控制流。看到大量无条件的JMP跳来跳去,很可能是代码被混淆了,需要耐心跟踪执行路径。
5.2 条件跳转:JE、JNE、JG、JL等
条件跳转指令根据标志位的状态决定是否跳转。常用的有:
| 指令 | 含义 | 依赖标志位 |
|---|---|---|
| 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 |
| JC | 进位 | CF=1 |
| JNC | 无进位 | CF=0 |
| JO | 溢出 | OF=1 |
| JNO | 无溢出 | OF=0 |
| JS | 符号为负 | SF=1 |
| JNS | 符号为正 | SF=0 |
有符号和无符号的比较跳转是两套不同的指令。用错了会导致逻辑错误。比如CMP EAX, EBX后,如果EAX和EBX是有符号数,应该用JG;如果是无符号数,应该用JA。这个区别在边界判断时特别重要。
5.3 LOOP:循环专用指令
LOOP指令用ECX作为计数器,每次执行ECX减1,如果ECX不为零就跳转。LOOPE/LOOPZ额外检查ZF,LOOPNE/LOOPNZ额外检查ZF是否为零。
LOOP指令的跳转范围有限,只有8位偏移,所以只能短距离跳转。在现代处理器上,LOOP的性能不如DEC加JNZ的组合,所以编译器通常不会生成LOOP指令。但阅读老代码时还是会遇到。
5.4 CALL和RET:函数调用与返回
CALL把返回地址压栈,然后跳转到目标地址。RET从栈顶弹出返回地址,跳转回去。
CALL分近调用和远调用。近调用只压入EIP,远调用还会压入CS。在32位保护模式下,近调用是主流。
RET可以带一个立即数参数,表示返回时额外弹出多少字节的栈空间。这个用法在stdcall调用约定里很常见,由被调用者清理栈。
; stdcall调用约定示例 PUSH 参数2 PUSH 参数1 CALL 函数 ; 函数内部用 RET 8 返回,自动清理8字节参数5.5 INT和IRET:中断与中断返回
INT指令触发软件中断,通常用于系统调用。IRET从中断返回,恢复EIP、CS和EFLAGS。
在Linux系统调用里,INT 0x80是32位系统的经典入口。到了64位系统,改用SYSCALL指令。这些细节在做底层开发时绕不开。
6. 字符串操作类指令:批量处理的加速器
字符串指令专门用于批量处理内存数据,配合REP前缀可以自动重复执行。
6.1 MOVS、LODS、STOS:传送与加载存储
MOVS把数据从DS:ESI复制到ES:EDI,然后根据方向标志DF自动调整ESI和EDI。MOVSB操作字节,MOVSW操作字,MOVSD操作双字。
LODS从DS:ESI加载到AL/AX/EAX。STOS把AL/AX/EAX存储到ES:EDI。
配合REP前缀,这些指令可以一次性处理大量数据。REP MOVSD相当于memcpy,REP STOSD相当于memset。
; 把ECX个双字从ESI复制到EDI CLD ; 清除方向标志,地址递增 REP MOVSD ; 重复执行MOVSD,直到ECX为0注意:使用字符串指令前一定要用
CLD清除方向标志,确保地址递增。如果DF被设置,地址会递减,数据会被反向复制,结果完全错误。这个坑我踩过不止一次。
6.2 CMPS和SCAS:比较与扫描
CMPS比较DS:ESI和ES:EDI指向的数据,设置标志位。SCAS把AL/AX/EAX与ES:EDI指向的数据比较。
配合REPE/REPZ(相等时重复)和REPNE/REPNZ(不相等时重复),可以实现字符串查找和比较。
; 查找第一个非零字节 MOV EDI, 缓冲区地址 MOV ECX, 缓冲区长度 XOR EAX, EAX CLD REPNE SCASB ; 重复扫描直到找到非零字节或ECX为0 JNZ 找到6.3 REP前缀家族:REP、REPE、REPNE
REP无条件重复,直到ECX为零。REPE/REPZ在ECX不为零且ZF=1时重复。REPNE/REPNZ在ECX不为零且ZF=0时重复。
这些前缀只能用在字符串指令上。用错了汇编器会报错。而且ECX的初始值决定了重复次数,如果ECX为0,指令不执行任何操作。
7. 处理器控制类指令:细节决定成败
这类指令不处理数据,而是控制处理器状态、标志位和同步。
7.1 标志位操作:CLC、STC、CMC、CLD、STD
CLC清除进位标志,STC设置进位标志,CMC取反进位标志。CLD清除方向标志,STD设置方向标志。
方向标志DF控制字符串指令的地址调整方向。CLD让地址递增,STD让地址递减。在调用字符串指令前,必须明确设置DF。
7.2 NOP:空操作
NOP什么都不做,只占用一个字节。它的用途包括:代码对齐、延迟、占位、以及热补丁时替换指令。
在逆向分析里,NOP经常被用来填充被移除的指令。看到大段NOP,说明代码可能被修改过。
7.3 CPUID:获取处理器信息
CPUID指令根据EAX的输入值返回处理器信息。EAX=0返回厂商ID,EAX=1返回版本和特性信息,EAX=7返回扩展特性。
在做性能优化和特性检测时,CPUID是必不可少的工具。比如检测是否支持AVX指令集,就需要用CPUID查询。
7.4 原子操作:LOCK前缀和XADD、CMPXCHG
LOCK前缀让后面的指令变成原子操作,在多处理器环境下保证内存访问的原子性。
XADD交换并相加,CMPXCHG比较并交换。这两条指令是实现无锁数据结构和自旋锁的基础。
; 原子比较并交换 MOV EAX, 期望值 MOV EBX, 新值 LOCK CMPXCHG [内存地址], EBX ; 如果内存地址的值等于EAX,则写入EBX,否则EAX更新为内存地址的当前值实操心得:
CMPXCHG的语义比较绕,建议画个流程图理清楚。简单说就是:比较内存值和EAX,相等就写入新值并设置ZF=1,不等就把内存值加载到EAX并清除ZF。这个指令在实现自旋锁和原子计数器时非常关键。
8. 常见问题与排查技巧实录
汇编编程和调试过程中,遇到的问题往往很隐蔽。下面整理了一些典型问题和排查思路。
8.1 指令报错速查表
| 错误现象 | 可能原因 | 排查方法 |
|---|---|---|
| 操作数大小不匹配 | 源和目标宽度不一致 | 检查寄存器是8/16/32/64位,确保一致 |
| 无效指令操作数 | 内存到内存传送 | 用寄存器中转 |
| 除法溢出异常 | EDX未清零或未符号扩展 | 无符号除法前XOR EDX,EDX,有符号除法前CDQ |
| 字符串指令结果错误 | DF方向标志设置错误 | 执行前用CLD确保地址递增 |
| 条件跳转不生效 | 标志位被中间指令修改 | 检查CMP和跳转之间是否有影响标志位的指令 |
| 栈不平衡 | PUSH和POP数量不匹配 | 统计函数内PUSH和POP,检查调用约定 |
| 段错误 | 访问了未映射的内存 | 检查指针是否有效,地址计算是否正确 |
8.2 调试汇编代码的实用技巧
第一,善用调试器的反汇编视图。把机器码和汇编指令对照看,能快速定位问题。很多调试器支持单步执行并实时显示寄存器变化,这是理解指令行为最直接的方式。
第二,关注标志位变化。条件跳转依赖标志位,如果跳转方向不对,先看标志位是否符合预期。大多数调试器都能显示EFLAGS的每一位。
第三,用最小可复现代码验证指令行为。遇到不确定的指令,写一个几行的测试程序,单独执行并观察结果。这比查手册快得多。
第四,注意调用约定。函数调用时参数怎么传、栈怎么清理、哪些寄存器需要保存,这些细节决定了代码能否正常工作。常见的调用约定有cdecl、stdcall、fastcall,它们的区别主要体现在参数传递顺序和栈清理责任上。
8.3 性能优化中的指令选择
同样的功能,不同指令的性能可能差很多。比如清零寄存器,XOR EAX, EAX比MOV EAX, 0短且快。乘法用LEA或移位加加法,比MUL快得多。
但现代处理器有乱序执行和指令融合,实际性能还取决于指令的组合和依赖关系。我的经验是:先保证正确性,再用性能分析工具找瓶颈,最后针对性优化。不要过早优化,也不要凭感觉优化。
提示:在64位模式下,
XOR EAX, EAX会自动清零RAX的高32位,这是x86-64架构的一个特性。所以64位代码里清零64位寄存器,用XOR EAX, EAX比XOR RAX, RAX更短。
8.4 跨平台差异注意事项
16位、32位、64位模式下,同样的指令可能有不同的行为。比如PUSH在16位模式下压2字节,32位模式下压4字节,64位模式下压8字节。CALL和RET的栈操作宽度也不同。
从32位迁移到64位时,还要注意寄存器名称变化(EAX变成RAX)、新增寄存器(R8-R15)、调用约定变化(前4个参数用寄存器传递)等。
这些差异在写跨平台汇编时必须考虑。我的建议是:尽量用宏或条件汇编来处理平台差异,避免硬编码。
9. 指令学习的个人体会
汇编指令这东西,看一遍记不住很正常。我的方法是:先按功能分类理解,然后写小段代码验证,最后在实际项目中反复使用。用得多了,自然就记住了。
另外,不要试图记住所有指令。x86指令集有上千条,常用的也就一两百条。把常用的吃透,生僻的知道怎么查就行。真正重要的是理解指令背后的原理:操作数怎么寻址、标志位怎么变化、栈怎么平衡。这些搞明白了,遇到新指令也能快速上手。
最后分享一个我常用的练习方法:找一段简单的C代码,用编译器生成汇编,然后逐行阅读并对照C代码理解。这个过程中会遇到各种指令组合,比单纯背指令表有效得多。坚持一段时间,你会发现看汇编代码越来越顺,调试底层问题时也越来越有底气。