汇编语言入门——1.3 常用演算指令一加减运算与标志位实战拆解很多人学汇编卡住的第一关往往不是寄存器、不是内存寻址而是“指令明明看得懂但一写程序就懵”。ADD、SUB这两条指令看着简单无非是加法和减法可一旦涉及标志位、溢出、借位、补码事情立刻变得微妙起来。这套东西不搞清楚后面学跳转指令、条件分支、循环结构全都像踩在棉花上。这篇文章把1.3节的内容拆开揉碎围绕最基础的加法和减法指令展开重点讲清楚ADD、SUB、ADC、SBB四条指令的行为以及CF、OF、ZF、SF、AF这几个标志位到底在干什么。讲完指令之后会给出一套完整的验证思路和一个小练习项目的建议。各位读者尤其是刚接触汇编的朋友建议手边备好调试器一边看文章一边单步执行光读不练标志位的变化永远记不住。1. 为什么先从加减法讲起16位x86汇编的指令系统里加减法指令属于最底层的砖瓦。访问内存、搬数据、跳转、压栈出栈这些都是“搬运”操作真正让数据发生变化的往往是算术和逻辑指令。加减法又是算术指令里最核心的部分因为它们牵动了标志寄存器FLAGS里的多个标志位而这些标志位正是后续所有条件跳转、循环控制的判断依据。简单概括不会加减法就写不出真正有逻辑的程序。像“统计一批数据里正数和负数的个数”“计算校验和”“把一个二进制数转成十进制显示”底子全是ADD和SUB。理解了这两条指令以及它们对标志位的影响也就理解了CPU如何“感知”计算结果。这篇文章的叙述重点集中在数据处理本身不涉及中断、系统调用、字符串操作这些外围内容。选用的汇编风格是16位实模式下的基本指令集这种风格贴近教学场景逻辑直观寄存器和内存模型也相对容易理解。后续如果接触到32位、64位指令虽然操作数宽度不同但标志位行为几乎没有变化学到的原理可以复用。2. 加减指令的基础操作与操作数规则2.1 指令的基本行为ADD指令的语义很简单把左边操作数和右边操作数相加结果存回左边操作数。SUB同理结果是左边减右边结果存回左边。两条指令都支持寄存器与寄存器、寄存器与内存、寄存器与立即数等组合。一个基础例子mov ax, 1000h mov bx, 2000h add ax, bx ; AX 3000h这条ADD执行完之后AX里的值是3000h。寄存器里的内容没有发生“旧值消失”的问题因为目标操作数同时充当了“被加数”和“结果存放处”。这一点和高级语言里的a a b写法是一回事但在汇编里你必须明确目标操作数是谁。SUB的例子同样直接mov ax, 3000h mov bx, 1000h sub ax, bx ; AX 2000h需要注意SUB指令的语义是“目标操作数减去源操作数”方向不能搞反。sub ax, bx和sub bx, ax的结果完全不同这在初学阶段是最容易踩的坑之一。2.2 “不改变操作数”的常用配合方式实际写代码的时候经常需要“比较两个数的大小但不改变原来的值”。SUB能做到这一点但会把结果覆盖到目标操作数上所以工程师一般会用CMP指令来做。CMP本质上就是SUB唯一的区别是它不把差存回去只更新标志位。这个特性在后续的跳转指令里几乎是命脉一样的存在这里提前提一句加深印象。另一个常见配合是清空寄存器。mov ax, 0当然可以但很多汇编代码里写sub ax, ax或者xor ax, ax后者更常见因为指令编码更短。SUB清空寄存器的思路就是“自己减自己”结果必定为零同时ZF标志位置1这个副作用有时候也被利用来传递状态。2.3 关于ADDS和操作数宽度的提醒在16位模式下ADD和SUB的操作数默认宽度是8位或16位两种宽度由寄存器选择决定。比如add al, 05h ; 8位加法 add ax, 05h ; 16位加法写代码时容易犯迷糊的是立即数。add al, 100h这类写法会直接报错因为100h超出了8位立即数能表达的范围。汇编器会根据目标寄存器宽度自动检查常量合法性编译阶段就会提示。别把立即数的宽度的判断交给运行时那是调试器都难救的灾难。还有一个细节ADD和SUB可以操作内存操作数但两个操作数不能同时是内存。比如add [bx], 10合法而add [bx], [si]在经典16位指令集里是不允许的。原因是CPU的硬件设计里一条指令最多只能携带一个内存操作数多个内存操作数会让指令编码结构急剧复杂化而且流水线加载的时序也变难处理。想完成“内存值加到内存值”这件事必须先把其中一个值搬到寄存器里。3. 标志位机制为什么指令的“副作用”才是主角单纯算出一个数值结果那只是ADD和SUB的表面价值。真正让这两条指令有意思的是它们同时更新了标志寄存器里的若干标志位。后续的条件跳转全靠这些标志所以理解标志位的行为比记住指令格式更重要。3.1 ZF零标志位ZFZero Flag记录的是“计算结果是否为0”。如果结果为0ZF1结果非0ZF0。这件事用一句话就能概括但很多人忽略了一点ZF是根据目标操作数的最终结果来判断的不是根据“数学上是否相等”。比如mov ax, 1234h sub ax, 1234h执行后AX0ZF1。这个逻辑看起来平平无奇但在循环计数里是核心判断依据。如果在一个循环里用dec cx递减计数器然后立刻用JNZ判断是否跳转背后的机制就是ZF的变化。3.2 SF符号标志位SFSign Flag记录结果的最高位MSB是0还是1。在无符号数的视角下MSB只是数值的一部分在有符号数的视角下MSB是符号位。SF的行为非常直白结果最高位为1SF1结果最高位为0SF0。初学时容易产生误解SF1就代表结果是负数吗答案是“在把结果解释为有符号数时SF1代表负数”。但CPU本身不区分有符号还是无符号它只是忠实地复制了最高位。举个例子mov al, 0FFh add al, 01hAL的结果是0此时ZF1。但若从有符号数角度理解0FFh是-1-1加1等于0结果符号为正SF应该为0。实际上这条指令执行后SF0因为结果的最高位是0。如果再把AL的值当作无符号数255那这次加法就产生了进位CF会置1。同一个二进制位模式两套解释逻辑并存全靠处理器同时更新对应的标志位这种设计恰恰是为了让后续程序自己选择“看哪个标志”。3.3 CF进位/借位标志位CFCarry Flag是无符号运算的溢出指示。加法运算中如果最高位产生了进位CF1减法运算中如果最高位产生了借位CF1。这句描述是无数教材里反复出现的标准说法但真正理解它需要拆开看。加减法在CPU内部都走加法器。减法不是“独立的减法电路”而是“被减数加减数的补码”。SUB在硬件层面这样做取减数按位取反目标操作数加上取反后的减数再加1。这个过程里如果发生了“最高位进位”就说明减法产生了借位。一个具体的例子mov al, 00h mov bl, 01h sub al, blAL的结果是0FFh因为没有进位CF1。从无符号数角度说0减1不够减必须向更高位借1所以CF置1。这个CF标志位是后续编写多字节加减法、BCD运算、多精度算术的枢纽。比如要做16位以上的大数运算必须把低位字节的进位或借位传递到高位字节这正是ADC和SBB指令存在的理由。3.4 AF辅助进位标志位AFAuxiliary Carry Flag只关注低4位一个十六进制数字位是否发生进位或借位。它的服务对象是BCD二进制编码十进制运算的调整指令普通加减法里基本用不到但调试时看到AF变化不要慌那是正常现象。具体规则加法时低4位向高4位进位AF1减法时低4位向高4位借位AF1。判断方式是看结果的低4位即AL的低4位和两个操作数的低4位之间的关系。比如mov al, 09h add al, 01hAL结果是0Ah。低4位从9变成0同时向高4位进1所以AF1。类似的情况在ASCII码调整和BCD调整指令中非常关键比如DAA和AAA指令就依赖AF来决定是否需要调整。这里先不做展开但务必记住AF只跟低4位有关别拿它和CF混为一谈。3.5 OF溢出标志位OFOverflow Flag是有符号数运算的溢出指示。这是全文章最容易被混淆的一个标志。区分CF和OF建议用这个思路CF服务于“无符号数”视角OF服务于“有符号数”视角。无符号数溢出意味着“装不下”有符号数溢出意味着“符号错乱”。OF的判定标准是两个同号数相加结果符号与操作数符号相反则OF1两个异号数相加不可能产生有符号溢出。减法判断类似正数减负数得到负数或负数减正数得到正数都视为溢出。用生活化方式理解把有符号数想象成温度计温度范围是-128到127。如果当前是100度再加50度读数会跳到“负温度”这显然是读数被“拧断”了。OF1就是这种“读数拧断”的警告。机械计数器、球赛记分牌出现负分也可以类比这种感觉。具体到机器行为OF的硬件实现是“最高位进位”和“次高位进位”的异或。这两个进位如果相同说明没有符号位错乱如果不同说明发生了溢出。这个判定细节学数字电路的时候会更直观汇编层面只需要记住最终结论即可。一个经典例子mov al, 7Fh add al, 01h7Fh127 1 128超出了8位有符号数的上限。AL结果变成了80h-128从有符号数角度结果符号错误OF1。同时因为128需要用9个bit表示但AL只有8个bit最高位的进位丢了CF1。一条指令两个溢出标志同时置位恰好说明两个不同视角都出了问题。一个让初学者很困惑的点大多数“正常人”写程序时其实不会同时在乎CF和OF。判断无符号数大小时看CF判断有符号数大小时看OF两者各管一摊极少交叉。但CPU不管这些它总是“同时”更新所有标志位这恰恰是它的优势——你根据需要取用。3.6 标志位更新方式的朴素总结把上面几个标志位合并起来看ADD和SUB执行完之后标志寄存器的变化规律可以整理成一张速查表。这张表值得贴在调试器旁边。标志位加法时的置位条件减法时的置位条件主要服务的视角ZF结果为0结果为0通用SF结果最高位为1结果最高位为1有符号/通用CF最高位产生进位最高位产生借位无符号OF同号相加符号异常异号相减符号异常有符号AF低4位向高4位进位低4位向高4位借位BCD这里面最核心的记忆口诀就一句话“CF看高位、AF看低4位、OF看符号错乱、ZF看全零、SF看符号”。4. ADC与SBB把进位/借位传到高位的关键单字节加减法只管8位数据但实际项目里经常要处理16位、32位的数据。虽然寄存器本身可以是16位但一旦数据宽度超过寄存器上限就必须把“进位”逐级传递下去。ADCAdd with Carry和SBBSubtract with Borrow就是干这个的。4.1 ADC指令的行为ADC的完整语义是目标操作数 目标操作数 源操作数 CF。当前CF的值也会被加进去。一个典型的多字节加法流程先把低16位加起来然后用ADC加高16位。这样如果低16位产生了进位ADC就会把它计入高位。; 计算 DX:AX DX:AX BX:CX add ax, cx adc dx, bx这段代码执行完低16位已经存入AX高16位存入DX。如果低16位加法有进位ADC指令会自动把它带入高16位的结果中整个过程不需要额外的条件判断。这正是CF标志位最有价值的使用场景。很多读者第一次看到ADC会觉得“多此一举”因为直接用ADD把高位相加进位不就丢了吗确实丢了。ADD不读CF无法把上一段运算的进位纳入当前结果。遇到超出16位宽度的加法只靠ADD会产生错误结果。ADC的出现就是为了保证多精度算术的正确性。4.2 SBB指令的行为SBB的语义是目标操作数 目标操作数 - 源操作数 - CF。这里的CF被看作“上一步减法产生的借位”。; 计算 DX:AX DX:AX - BX:CX sub ax, cx sbb dx, bx和ADC的套路完全对称。低16位相减时如果发生借位CF1接下来直接用SBB做高位减法把CF减去就自动完成了借位传递。这里要特别强调SBB里的CF在减法操作中代表“借位”但在硬件实现上它依然是同一个标志位。SBB执行时CPU会同时考虑这个标志的位置逻辑上等价于“目标 目标 - 源 - 借位”。写多字节减法时有个高频错误低位的减法和高位的减法用了两条SUB而不是“SUBSBB”。这样做的结果就是低位借位被直接丢掉高位结果没有减去借位整个减法就算错了。4.3 实战场景两个32位无符号数相加假设有这样一个数据布局第一个数存放在BX:AX高16位在BX低16位在AX第二个数存放在DX:CX目标是计算它们的和并保存回BX:AXadd ax, cx adc bx, dx执行完成后BX:AX内就是完整结果。这句代码用到的知识已经覆盖了ADD、ADC和CF的使用。如果把这里换成64位数据无非是再多一组高位寄存器关键仍然是把CF一级一级往下传。5. 实操过程用调试器见证标志位的每一步变化这一节直接用调试器走完一段代码把每一步的寄存器变化和对标志位的影响陈列出来。建议读者动手指跟一遍尤其是第一次接触标志位的新手。以下示例使用一个典型调试器比如常见汇编教学环境里内置的调试功能来单步执行。环境的具体名称不重要关键是每个人都能在自己的工具链里做同样的验证。5.1 示例代码准备代码片段如下mov ax, 1234h mov bx, 0EDCCh add ax, bx sub ax, 2000h add ax, ax sub ax, ax这段代码的逻辑很直白但它涵盖了ADD、SUB、ADD自身相加、SUB自身清零四种典型情况。逐条执行并观察“执行前的寄存器、执行后的寄存器、相关标志位”三列信息就能把前面讲到的理论全部变成亲眼所见的事实。5.2 逐步执行与标志位观察记录第一步mov ax, 1234h。这一步只是加载数据按x86的标准行为MOV指令不会更新标志位。调试器显示AX1234h标志位继上一条指令之后保持不变。第二步mov bx, 0EDCCh同样不影响标志位。第三步add ax, bx。这条指令是本轮观察的核心。1234h 0EDCCh 10000h但这个结果超出了一般16位寄存器的容纳范围如果以16位寄存器AX为目标这里实际应关注AX的变化。单步执行之后AX的值取决于具体调试环境的寄存器宽度设定。在本例的16位视角下AX的结果应为0000hCF1ZF1SF0OF0。进位被CF捕获结果为零则ZF置1。第四步sub ax, 2000h。上一步AX0000h减去2000h结果会得到一个负数有符号视角。AX0E000hCF1因为无符号视角发生了借位SF1结果最高位为1ZF0OF0。这一条指令能同时看到CF、SF、ZF三个标志的联动变化。第五步add ax, ax。此时AX0E000h自加相当于乘以2。结果AX1C000h在16位寄存器里体现为0C000hCF1进位被丢出SF1最高位是1OF1或0取决于符号位变化情况。按16位内部行为推导这里的OF具体值在不同宽度设定下有差异读者可以在调试器里亲自确认。第六步sub ax, ax。这条指令执行后AX0CF0ZF1SF0OF0。这也是上一节提到的“自己减自己”清空法的底层原理。5.3 亲手验证标志位的最佳习惯上面标记“取决于环境”的地方不是在打太极而是想说清楚不同汇编器和调试器的默认数据宽度可能不同I/O窗口显示的寄存器位数也可能不同这会导致“同一条指令的数值结果”看起来不一样。遇到这种情况不要慌回到数据宽度上看如果采用16位寄存器视角把结果看成16位CF记录进位。如果采用32位寄存器视角结果自然不同CF是否置位也取决于最高位在哪。这是整个实操环节最大的坎。强烈建议在调试器里做一个固定动作每次单步执行完先看CF和ZF再看SF和OF。这四个标志位一起看能迅速判断出这条加法/减法指令是不是按预期工作的。6. 小练习项目标志位诊断器光看指令执行还不够真正把知识内化的方法是做一个完整的小项目。这里给出一套适合初学者的练习方案目标是写一段程序对一批无符号数和有符号数分别做加法、减法运算并在每次运算后把标志位状态“转换”成可读的提示信息也就是一个标志位诊断器。这一步会用到后面章节才正式讲解的跳转指令但作为练习可以先动手过一遍思路遇到不会的指令再翻资料。6.1 目标与模块拆分模块任务内容涉及的知识点数据准备定义若干8位/16位数据包括正数、负数、零、边界值DB/DW伪指令、数值补码表示加法模块对无符号数做加法并提取CF对有符号数做加法并提取OFADD、JNC/JC/JNO/JO等跳转思路减法模块类似加法模块但关注借位与有符号溢出SUB、SBB的配合、标志位读取结果输出用字符等可视方式打印“CF1”“OF1”等信息简单中断调用或输出函数6.2 数据设计思路建议准备这样的测试向量无符号边界0FFFFh 1检验CF有符号边界7FFFh 1检验OF减法借位0 - 1检验CF减法溢出7FFFh - 0FFFFh检验OF零结果1234h - 1234h检验ZF。这组数据覆盖了标志位的大部分关键行为而且每种情形都有明确的预期结果。把这些数据逐条执行并核对基本就能确认对ADD/SUB的理解没有偏差。6.3 移植到更高位宽如果用的是32位汇编或64位汇编只需要把寄存器从AX换成EAX或RAX数据定义从DW换成DD或DQ其余逻辑一致。这种做法能让这套练习的知识在今后工作中无缝迁移不会只停留在16位古董模式下。7. 常见问题与排查技巧实录这一节记录实际学习中最多被问到的问题以及对应的排查思路。这些问题都来自真实使用场景有些是逻辑错误有些是认知误区。7.1 明明结果是“对的”为什么CF或OF不对这种情况最常出现在“结果宽度”理解不到位的时候。举例mov al, 0FFh add al, 01h从8位寄存器AL的视角看结果是0CF1。但如果有人把AL理解成“总该是256吧”就会觉得CF应该为0因为“256没有溢出”。这种纠结的根源是AL只有8位根本存不下256。真正能存256的寄存器是AX但上面这段代码操作的是AL不是AX。寄存器宽度决定能表达的范围范围决定进位行为是否发生。排查办法算之前先想清楚目标寄存器是多少位的把结果按那个宽度截断再看最高位有没有进位/借位。不要按数学直觉判断进位。7.2 OF和CF到底怎么选这也是高发困惑。一个常见场景比较两个数谁大到底看CF还是OF一句话概括如果两个数被当成无符号数看CF如果被当成有符号数看OF。CMP指令之后无符号比较用JA/JB一类跳转内部看CF/ZF有符号比较用JG/JL一类跳转内部看OF/SF。编译器在生成代码时会依据数据类型帮你决定该看哪个标志。手写汇编时这个决定必须由你自己做。7.3 多字节加法高位的进位为什么老丢典型错误代码add ax, cx add bx, dx ; 错误忽略了低16位的进位正确写法应该是add ax, cx adc bx, dx如果现在还是觉得“ADD也能用”只能说明CF的传递价值还没真正吃透。多字节算术不是数值拼图进位必须一站一站传下去。SBB同理。7.4 减法结果正确但CF方向总搞反很多人会问既然SUB在硬件上是“加补码”那“借位”和“进位”到底怎么对应这件事情的直观解释是0 - 1 -1无符号视角不够减要向高位借位所以CF1。很多芯片文档里明确写的是“SUB的结果在无符号解释下若为负则CF1”。用“借位”这个词来描述减法里的进位标志虽然历史包袱很重但也算约定俗成。记住结论就好无符号减法发生借位时CF1。7.5 调试时看到的标志位和手册对不上先检查调试器是否开启了“标志位显示高级模式”有的调试器默认隐藏AF或者把OF显示在奇怪的位置。还有一个常见原因刚执行完一条MOV指令所有标志位维持原样有些人看到标志位没变就以为自己操作错了。MOV不变标志位是特性不是缺陷。判断一条指令是否影响标志位可以查指令集手册里的“Flags Affected”栏这栏才是权威。8. 避坑清单长期实战换来的提示以下内容是长时间折腾汇编之后总结出的经验供参考不要让ADD/SUB指令的两个操作数同时指向内存先加载到寄存器再运算。指令集不支持这种写法硬写只会让汇编器报错。千万不要用ADD指令来做“带进位的加法”除非你确实不想管低位进位。ADD和ADC的分工是不同的用错场合就会得到错误结果。使用立即数时注意宽度匹配。add al, 100h这类写法汇编器会在编译阶段就报警或截断完全不进入运行阶段。判断有符号数溢出一定要看OF不要看SF。SF只是符号位不能单独代表“结果错乱”。写多字节加减法时先写低位和后写高位顺序不能乱。低位指令影响CF高位指令消费CF顺序一旦颠倒数据全错。手册中的“无符号”和“有符号”不是CPU层面的概念而是程序层面的解释方式。CPU只按位操作和置标志怎么解释是程序员的事。9. 常见问题速查表问题原因解决方案加法结果无符号溢出但OF为0误用了有符号视角的判断判断无符号溢出时看CF不要看OF减法结果正确CF却为1无符号借位的正常体现理解SUB在硬件上等价于加补码ADD执行后标志位完全没变前面刚执行的是MOV/LEA等不影响标志位的指令核对指令手册的Flags Affected栏多字节加法少了一部分没用ADC进位丢失低位ADD高位ADC多字节减法结果错误没用SBB借位丢失低位SUB高位SBB负数相加结果看起来“错”没有检查OF看OF确认是否发生了有符号溢出调试器显示的结果与手算不一致寄存器宽度不同或立即数被截断统一按目标寄存器的宽度来分析10. 小结之后再往前走一步这部分内容还能怎么用累述一遍“ADD和SUB很重要”没有意义倒不如说说这篇文章覆盖的知识点接下来能引向哪里。后续章节会进入AND、OR、XOR、NOT、TEST等逻辑运算指令那些指令同样基于Flags机制工作。XOR清空寄存器的惯用法、TEST判断位状态都和这一节里讲的标志位思路完全一致。学会了ADD/SUB与Flags的关系逻辑运算指令基本是同一套方法论换皮。再往后乘除法指令对溢出行为的处理思路也与本文内容呼应。MUL和IMUL分别针对无符号与有符号数设置溢出标志如果CF/OF理解不到位乘除法调试会比加减法痛苦得多。如果打算做一个小型项目来巩固本文内容推荐实现一个“多精度十进制加法器”。这个项目会把ADD、ADC、AF标志、DAA调整指令全部串起来整体难度不大但足够训练“标志位驱动逻辑”的思维方式。我个人在实际操作中的体会是学汇编最忌讳的事就是“只看书不跟踪寄存器”。哪怕只是短短几行的ADD和SUB只要在调试器里单步走一遍标志位的直观印象就能建立起来而一旦建立起这种印象后面学什么指令都快。各位读者不妨从今天这份代码开始把每条指令执行前后的寄存器值和四个核心标志位抄在一张草稿纸上不再凭“感觉”写条件判断一切以调试器的实际状态为准。