1. 为什么汇编指令总是学了就忘
刚接触汇编那会儿,我最大的感受就是:指令太多了,而且长得还特别像。MOV、MOVS、MOVZX、MOVSX,光一个“搬数据”的动作就能衍生出七八个变种;ADD、ADC、INC、XADD,加个一都能玩出花来。更别提还有一堆带方向标志的串操作指令,MOVSB、MOVSW、MOVSD,看着就头大。
后来在逆向分析、性能调优和嵌入式开发这几个场景里来回折腾了几年,我才慢慢摸清楚一件事:汇编指令不是靠背的,是靠“归类+场景”记的。你真正需要死记硬背的指令,其实不超过三十条,剩下的都是在这三十条基础上的组合和变体。这份汇总就是按这个思路整理的——不追求大而全的字典式罗列,而是按“你实际会用它干什么”来组织,每条指令都配上典型场景和踩坑提示。
这篇文章适合谁看?如果你是正在学《汇编语言》课程的学生,它能帮你把课本上零散的指令串成一条线;如果你是从高级语言转过来做底层调试的开发者,它能当速查手册用;如果你在做嵌入式或者操作系统相关的开发,里面关于标志位和栈操作的细节应该能帮你省不少排查时间。我会持续往里面补充新遇到的指令和坑,也欢迎你在评论区丢出你踩过的雷。
2. 数据传送类指令:最基础也最容易出错
2.1 MOV家族:看似简单,限制不少
MOV是汇编里出现频率最高的指令,没有之一。它的作用就一个:把源操作数的值复制到目标操作数。但就是这个“复制”,x86架构给它加了一堆限制,新手最容易在这里翻车。
最基本的用法:
MOV EAX, EBX ; 寄存器到寄存器 MOV EAX, 1234H ; 立即数到寄存器 MOV EAX, [EBX] ; 内存到寄存器 MOV [EBX], EAX ; 寄存器到内存看起来挺自由对吧?但下面这几种写法全是非法的:
MOV [EBX], [ECX] ; 错误:内存到内存,不允许 MOV CS, AX ; 错误:不能直接给CS赋值 MOV EAX, EBX, ECX ; 错误:一条指令只能有一个源和一个目标内存到内存为什么不行?因为x86的指令编码里,一条MOV指令最多只能有一个内存操作数。CPU需要先通过地址总线寻址,再通过数据总线传输,如果源和目标都在内存里,它没法在一个时钟周期内完成两次寻址。所以你必须借助寄存器中转:
MOV EAX, [EBX] ; 先把内存值读到寄存器 MOV [ECX], EAX ; 再从寄存器写到另一个内存地址注意:
MOV不影响任何标志位。这一点和ADD、SUB这些算术指令完全不同。如果你在条件跳转之前用了MOV,别指望它能改变标志位。
2.2 符号扩展与零扩展:MOVSX和MOVZX
这两个指令在处理不同宽度的数据时特别重要。比如你从内存里读了一个字节,想把它当成有符号数参与32位运算,就必须用MOVSX:
MOV AL, 0FFH ; AL = 0FFH,如果当成有符号数就是-1 MOVSX EAX, AL ; EAX = 0FFFFFFFFH,正确扩展为-1 MOVZX EAX, AL ; EAX = 000000FFH,当成无符号数255我见过不少人在做图像处理或者协议解析时,忘了做符号扩展,结果一个字节的负数被当成正数参与运算,算出来的结果偏了几百。这种bug特别隐蔽,因为数据本身看起来“没毛病”,只是数值不对。
实操心得:如果你不确定数据是有符号还是无符号,先看它的来源。从传感器读的原始值通常是无符号的,从差值计算出来的结果通常是有符号的。拿不准的时候,用
MOVSX更安全,因为它在正数范围内和MOVZX行为一致。
2.3 交换指令XCHG:不加锁的原子操作
XCHG交换两个操作数的值,看起来平平无奇,但它有一个隐藏特性:当其中一个操作数是内存时,XCHG会自动锁定总线,相当于隐含了LOCK前缀。这意味着它可以用来实现简单的原子交换,在多线程编程的底层实现里经常能看到它的身影。
XCHG EAX, [EBX] ; 原子地交换EAX和内存[EBX]的值不过要注意,XCHG同样不影响标志位,而且如果两个操作数都是寄存器,它不会锁定总线,只是普通的寄存器交换。
3. 算术运算指令:标志位才是重点
3.1 ADD/SUB:不只是加减
ADD和SUB的用法很直观,但它们对标志位的影响才是真正需要关注的地方。每次运算后,CPU会更新以下标志位:
| 标志位 | 含义 | 判断依据 |
|---|---|---|
| CF | 进位/借位 | 无符号运算溢出 |
| ZF | 零标志 | 结果为0 |
| SF | 符号标志 | 结果的最高位 |
| OF | 溢出标志 | 有符号运算溢出 |
| PF | 奇偶标志 | 结果低8位中1的个数为偶数 |
| AF | 辅助进位 | 低4位向高4位进位 |
新手最容易混淆的是CF和OF。记住一句话:CF管无符号,OF管有符号。比如ADD AL, 0FFH当AL=1时,结果是0,CF=1(无符号溢出),OF=0(有符号没溢出,因为1+(-1)=0)。同样的结果,两个标志位含义完全不同。
3.2 ADC/SBB:带进位运算的妙用
ADC是带进位加法,SBB是带借位减法。它们的作用是在多字节运算中传递进位。比如你要做两个64位数的加法,在32位机器上就得拆成两步:
; 假设EDX:EAX存第一个64位数,EBX:ECX存第二个 ADD EAX, ECX ; 低32位相加 ADC EDX, EBX ; 高32位相加,同时加上低位的进位ADC会自动把前一条ADD产生的CF加进去,这样就能正确处理跨字节的进位。SBB同理,用于多字节减法。
踩过的坑:有一次我在做大数运算库,忘了用
ADC而是直接ADD高位,结果所有超过32位的加法在进位时都错了。排查了半天才发现是进位丢失。从那以后,只要涉及多字节运算,我第一反应就是检查有没有用ADC/SBB。
3.3 INC/DEC:不改变CF的加减一
INC和DEC就是加一和减一,但它们和ADD reg, 1有一个关键区别:INC/DEC不影响CF标志位。这个特性在循环计数时特别有用,因为你可以用INC更新计数器而不破坏之前比较产生的进位标志。
CMP EAX, EBX ; 比较,设置标志位 INC ECX ; 计数器加一,不影响CF JA label ; 基于CMP的结果跳转,CF仍然有效如果这里用ADD ECX, 1,CF就会被覆盖,JA的判断就失效了。
3.4 MUL/IMUL/DIV/IDIV:乘除法的隐藏陷阱
乘法指令分无符号MUL和有符号IMUL,除法分无符号DIV和有符号IDIV。它们的操作数规则比较特殊:
- 8位乘法:
MUL BL把AL乘以BL,结果放在AX中 - 16位乘法:
MUL BX把AX乘以BX,结果放在DX:AX中 - 32位乘法:
MUL EBX把EAX乘以EBX,结果放在EDX:EAX中
除法更麻烦,它要求被除数的位数是除数的两倍:
- 8位除法:AX除以BL,商在AL,余数在AH
- 16位除法:DX:AX除以BX,商在AX,余数在DX
- 32位除法:EDX:EAX除以EBX,商在EAX,余数在EDX
注意:除法指令如果商超出了目标寄存器的范围,会触发除法错误异常(在x86上表现为中断0)。比如你用16位除法,商超过了0xFFFF,CPU就会抛异常。在做除法之前,最好先判断一下除数是否为零,以及商是否会溢出。
4. 逻辑与移位指令:位操作的利器
4.1 AND/OR/XOR/NOT:按位逻辑运算
这四个指令对操作数的每一位独立进行逻辑运算,不影响进位标志CF和溢出标志OF(它们会被清零),但会影响ZF、SF和PF。
XOR有一个经典用法:清零寄存器。XOR EAX, EAX比MOV EAX, 0更短、更快,而且不依赖立即数编码。编译器在优化时经常这么干。
另一个经典用法是交换两个寄存器的值而不借助临时变量:
XOR EAX, EBX XOR EBX, EAX XOR EAX, EBX三步之后EAX和EBX的值就互换了。不过在实际开发中,这种写法可读性差,除非是在极端的性能场景下,否则还是用XCHG或者临时变量更清晰。
4.2 TEST:只比较不修改
TEST指令做的是AND运算,但它不保存结果,只设置标志位。它最常用的场景是判断某一位是否为1:
TEST AL, 01H ; 测试AL的最低位 JNZ bit_is_set ; 如果最低位为1,跳转这比AND AL, 01H再判断要高效,因为它不破坏AL的原始值。
4.3 SHL/SHR/SAR:移位指令的选择
移位指令有三个容易混淆的:
SHL:逻辑左移,低位补0,最高位移入CFSHR:逻辑右移,高位补0,最低位移入CFSAR:算术右移,高位补符号位,最低位移入CF
关键区别在右移:SHR把最高位补0,适合无符号数;SAR把最高位补符号位,适合有符号数。比如 -8 右移一位:
MOV EAX, -8 ; EAX = 0FFFFFFF8H SAR EAX, 1 ; EAX = 0FFFFFFFCH = -4,正确 SHR EAX, 1 ; EAX = 7FFFFFFCH = 2147483644,错误实操心得:移位操作经常用来代替乘除法。左移n位等于乘以2的n次方,右移n位等于除以2的n次方(对于无符号数)。但要注意,
SAR对于负数的除法是向下取整,而C语言的整数除法是向零取整,两者在负数情况下结果不同。如果你在逆向C代码,看到SAR就要意识到这里可能有一个取整方向的差异。
4.4 循环移位:ROL/ROR/RCL/RCR
循环移位把移出的位补到另一端,分为带进位和不带进位两种。ROL和ROR不经过CF,RCL和RCR把CF当作扩展位参与循环。
这些指令在加密算法和哈希函数里出现频率很高。比如CRC校验的计算就经常用RCR来实现。
5. 控制转移指令:程序流程的指挥棒
5.1 JMP:无条件跳转
JMP分短跳转(8位偏移)、近跳转(16/32位偏移)和远跳转(改变CS)。在同一个函数内部跳转,汇编器会自动选择最短的编码。跨段跳转现在很少用了,除非你在写实模式的操作系统代码。
5.2 条件跳转:标志位的组合逻辑
条件跳转指令有几十条,但本质上都是对标志位的组合判断。下面这张表是我自己整理的高频条件跳转速查:
| 指令 | 含义 | 标志位条件 | 适用场景 |
|---|---|---|---|
| JE/JZ | 相等/为零 | ZF=1 | 比较结果相等 |
| JNE/JNZ | 不等/非零 | ZF=0 | 比较结果不等 |
| JA/JNBE | 高于 | CF=0且ZF=0 | 无符号大于 |
| JAE/JNB | 高于等于 | CF=0 | 无符号大于等于 |
| JB/JNAE | 低于 | CF=1 | 无符号小于 |
| JBE/JNA | 低于等于 | CF=1或ZF=1 | 无符号小于等于 |
| JG/JNLE | 大于 | ZF=0且SF=OF | 有符号大于 |
| JGE/JNL | 大于等于 | SF=OF | 有符号大于等于 |
| JL/JNGE | 小于 | SF≠OF | 有符号小于 |
| JLE/JNG | 小于等于 | ZF=1或SF≠OF | 有符号小于等于 |
新手最容易犯的错误是混用无符号和有符号的跳转指令。比如比较两个数大小,如果它们是无符号的,你用了JG,当出现负数(最高位为1)时判断就会出错。反过来,有符号数用了JA也会错。
踩过的坑:有一次我在解析一个二进制协议,长度字段是无符号的,但我习惯性地用了
JL来判断长度是否小于阈值。结果当长度字段的值超过0x7FFFFFFF时,被当成负数,判断逻辑完全反了。后来我把所有涉及长度的比较都改成了JB/JA,问题才解决。
5.3 LOOP:带计数的循环
LOOP指令把ECX减一,如果ECX不为零就跳转。它等价于:
DEC ECX JNZ label但LOOP更短,而且不影响标志位。不过在现代CPU上,LOOP的性能不如DEC+JNZ的组合,因为LOOP是复杂指令,解码开销更大。所以编译器通常不会生成LOOP,而是用两条简单指令替代。
5.4 CALL/RET:函数调用的底层机制
CALL把返回地址压栈,然后跳转到目标地址;RET从栈中弹出返回地址,跳回去。这是所有高级语言函数调用的基础。
CALL my_function ; 等价于 PUSH next_addr + JMP my_function ... my_function: ; 函数体 RET ; 等价于 POP EIP注意:
CALL和RET必须成对出现,而且栈必须平衡。如果你在函数里压了参数但忘了清理,RET就会弹错地址,程序直接崩溃。在写汇编函数时,我习惯在函数开头和结尾注释栈的变化情况,比如; [ESP+4] = 参数1,这样排查起来快很多。
6. 串操作指令:批量数据处理
6.1 MOVS/STOS/LODS:内存块的搬运工
串操作指令配合REP前缀可以批量处理数据,比循环快得多。它们使用ESI(源索引)、EDI(目标索引)和ECX(计数器):
; 把ECX个字节从ESI复制到EDI CLD ; 清除方向标志,让ESI/EDI自动递增 REP MOVSB ; 重复执行MOVSB,直到ECX=0STOS把AL/AX/EAX的值存储到EDI指向的内存,常用于初始化数组:
MOV EAX, 0 MOV ECX, 100 LEA EDI, buffer CLD REP STOSD ; 把buffer的前400字节清零LODS从ESI加载数据到AL/AX/EAX,但实际中很少单独使用,通常和其他指令配合。
6.2 CMPS/SCAS:串比较与扫描
CMPS比较两个内存块,SCAS在内存中扫描某个值。它们都影响标志位,配合REPE/REPNE前缀可以实现“找到第一个不匹配的字节”或“找到第一个匹配的字节”:
; 在buffer中查找字符'A' MOV AL, 'A' MOV ECX, buffer_len LEA EDI, buffer CLD REPNE SCASB ; 重复扫描直到找到'A'或ECX=0 JZ found ; 如果ZF=1,说明找到了实操心得:串操作指令的性能高度依赖于CPU的微架构。在现代x86上,
REP MOVSB有专门的优化,大块内存复制比手写循环快很多。但在一些嵌入式平台上,串操作指令反而是微码实现的,速度不如简单的寄存器循环。所以如果你在做嵌入式开发,最好实测一下再决定用哪种方式。
7. 栈操作与标志位指令:容易被忽视的细节
7.1 PUSH/POP:栈的平衡是生命线
PUSH把操作数压栈(ESP减4,然后写入),POP从栈顶弹出(读取后ESP加4)。在32位模式下,每次操作都是4字节;在64位模式下是8字节。
栈不平衡是汇编程序崩溃的头号原因。我总结了一个简单的检查方法:在函数入口和出口分别计算ESP的值,如果不一样,说明栈没平衡。在调试器里,你可以直接观察ESP的变化。
my_func: PUSH EBP ; ESP -= 4 MOV EBP, ESP PUSH EBX ; ESP -= 4 PUSH ESI ; ESP -= 4 ; ... 函数体 ... POP ESI ; ESP += 4 POP EBX ; ESP += 4 POP EBP ; ESP += 4 RET7.2 PUSHA/POPA:批量保存寄存器
PUSHA按顺序压入AX、CX、DX、BX、SP、BP、SI、DI,POPA按相反顺序弹出。在中断处理程序里特别有用,可以一次性保存所有通用寄存器。
不过要注意,PUSHA压入的SP值是压栈之前的SP,而不是压栈之后的。这个细节在恢复栈的时候很关键。
7.3 标志位操作:CLC/STC/CLI/STI
CLC/STC:清除/设置进位标志CLD/STD:清除/设置方向标志(影响串操作的方向)CLI/STI:清除/设置中断标志(关中断/开中断)
注意:
CLI和STI是特权指令,只能在Ring 0执行。在用户态程序里执行会触发异常。如果你在做操作系统内核开发,关中断的时间要尽可能短,否则会影响系统响应。
8. 常见问题与排查技巧实录
8.1 指令用错导致程序跑飞
症状:程序执行到某条指令后跳到了完全无关的地址。
排查思路:
- 首先检查
CALL/RET是否配对,栈是否平衡 - 检查条件跳转的标志位条件是否正确,特别是无符号/有符号混用
- 检查
JMP的目标地址是否正确,有没有跳转到数据段
速查表:
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 跳转到随机地址 | 栈不平衡导致RET弹错 | 检查PUSH/POP配对 |
| 条件跳转永远不成立 | 标志位被中间的指令覆盖 | 确保CMP/TEST后紧跟跳转 |
| 循环次数不对 | LOOP的ECX被修改 | 检查循环体内是否改动了ECX |
| 除法异常 | 除数为零或商溢出 | 除法前先判断除数和商的范围 |
| 数据复制不完整 | 方向标志DF设置错误 | 串操作前用CLD确保递增 |
8.2 性能调优中的指令选择
在性能敏感的代码里,指令的选择会直接影响执行效率。我实测过几个常见场景:
- 清零寄存器:
XOR EAX, EAX比MOV EAX, 0快约15%,因为编码更短,解码更快 - 乘以2的幂:
SHL EAX, 3比MUL EAX, 8快一个数量级 - 内存复制:
REP MOVSB在大于64字节时比循环快,小于64字节时循环更快 - 条件赋值:
CMOV系列指令可以避免分支预测失败,在分支不可预测时优势明显
实操心得:不要过早优化。先写出正确的汇编代码,再用性能分析工具找到热点,最后才针对热点做指令级优化。我见过太多人为了省几条指令把代码写得晦涩难懂,结果整体性能提升不到1%。
8.3 调试器中的指令级调试技巧
在调试汇编时,有几个技巧能大幅提升效率:
- 单步执行时观察标志位:大多数调试器都有标志位面板,每执行一条指令就看一眼ZF、CF、OF的变化,能快速定位逻辑错误
- 使用条件断点:比如在循环里设置“ECX==0时断下”,比手动单步快得多
- 反汇编视图和寄存器视图并排:这样能同时看到指令和它的执行效果
- 记录栈的变化:在函数入口和出口分别记录ESP,能快速发现栈不平衡
8.4 不同架构的指令差异
x86和ARM的指令集差异很大,如果你在做跨平台开发,有几个坑要注意:
- x86的
MOV不影响标志位,ARM的MOV也不影响,但ARM的条件执行是每条指令都可以带条件码 - x86的栈是向下增长的,ARM的栈可以是向上或向下,取决于具体实现
- x86的移位指令影响CF,ARM的移位指令可以选择是否影响标志位
- x86有专门的串操作指令,ARM需要用循环实现
踩过的坑:有一次我把x86的汇编代码直接翻译成ARM,忘了ARM的
LDR/STR需要对齐访问,结果在非对齐地址上读写直接触发异常。后来加了LDRB/STRB做字节访问才解决。
9. 持续更新的指令笔记
这份汇总我会持续更新,每次在实际项目中遇到新的指令用法或者踩到新的坑,都会补充进来。汇编指令的学习曲线确实陡,但一旦你把它们按场景归类,形成自己的“指令地图”,后面遇到新指令也只是往地图上添一个节点而已。
如果你也在学汇编或者做底层开发,欢迎在评论区分享你遇到的奇葩问题和解决方案。有些坑一个人踩是事故,一群人踩就是经验了。