我当年第一次上汇编实验课老师把“转移指令和循环指令”这一节讲完之后直接丢了个上机题目下来。我当时觉得JMP就是跳转LOOP就是循环概念一听就懂结果坐在DOSBox面前写代码的时候整个人的状态是代码看着没问题运行结果就是不对不是死循环就是跳错位置甚至编译都过不去。后来折磨了一下午翻书、查资料、用DEBUG单步跟踪才算是把这块真正弄明白。这篇就围绕“转移指令和循环指令”这个主题把我自己第二次上机的经验完整拆一遍。内容包括两类指令的原理、关键细节、完整可运行的代码示例、以及我踩过的一些坑。不管你是在校学生正在学汇编还是自学8086汇编想补一补程序控制流的知识这篇应该都能让你少走点弯路。不过先说好这篇文章的目标不是背指令表而是建立一种“执行流”的直觉CPU怎么决定下一条指令去哪循环凭什么能停下来标志位是怎么在背后起作用的。这些东西一旦想通转移指令和循环指令就算真正学会了。1. 先搞清楚转移指令和循环指令到底在解决什么问题1.1 没有跳跃的CPU只能“一条道走到黑”先做个最简单的实验假设有一段代码按顺序执行MOV AL, 1 MOV BL, 2 MOV CL, 3CPU执行完MOV AL, 1之后自动执行下一条MOV BL, 2再执行MOV CL, 3。这是CPU最自然的运行方式指令寄存器IPInstruction Pointer在执行完一条指令后自动增加指向内存中的下一条指令。换句话说如果没有转移指令程序就永远只能从上到下一条路走到底无法根据条件做出选择也无法回头重复做某件事。但现实中的程序哪有这么简单比如需求是“输入一个字符如果是数字就提示digit否则提示other”这必须要在程序里做一个判断又比如“把1加到100”总不能写100行加法语句。这时候就必须让CPU能够“跳”到指定的指令地址继续执行于是就有了转移指令而“重复做某件事”的需求就催生了循环指令。有一个理解角度很关键循环指令本质上就是转移指令的一种“包装”。LOOP循环无非是“先看看计数器减到0没有没减到0就跳回去继续执行”。CPU本身并不知道什么叫循环它只知道地址跳转。所以学习这两块内容时不要把它们割裂开而应该统一理解为“如何控制程序的执行流”。1.2 课程上机背景MASM、io.lib和DOSBox如果你正在学校上汇编课那么大概率会遇到这几个关键词MASM、io.lib、DOSBox。标题的热搜词里也有“汇编语言第二次上机 io.lib masm”这说明大家普遍在同一个环境里挣扎。MASM是微软的宏汇编器用来把汇编源码编译成OBJ文件再用LINK工具链接成EXE。很多教材用的就是MASM 5.0或MASM 6.11。而io.lib则是部分教材或学校提供的I/O库用来简化字符输入输出、十进制输入输出这类操作比如ReadDec、WriteDec、ReadString、WriteString这些过程避免学生一上来就跟INT 21H的中断细节死磕。不过不同学校的io.lib版本和过程名可能不完全一样我见过有的实验室用自定义的IO库有的直接用INT 21H所以如果你在课堂上发现函数名和网上对不上别慌以你课程提供的文档或老师PPT为准。至于DOSBox它是在现代操作系统上模拟DOS环境的软件因为MASM编译出的程序是16位的在Windows 10/11 64位系统上没法直接运行。DOSBox挂载汇编目录的命令一般是mount c: d:\asm c: masm test.asm; link test.obj; test.exe这里有个常见坑有些人编译的时候漏了分号导致MASM反复问你文件名有些人忘记LINK只生成OBJ就直接运行结果提示找不到程序。这些小问题我后面会集中整理。作为过来人我建议你务必在电脑上装好DOSBox和MASM因为这篇文章里的代码全部都需要实际跑一跑才能理解透彻。光看不练汇编是绝对学不会的。2. 转移指令JMP与条件转移的核心细节2.1 无条件转移JMP段内、段间、直接、间接无条件转移指令JMP意思是“不管什么条件直接跳”。它的分类方式有几种按跳转范围分有短转移SHORT、近转移NEAR、远转移FAR按操作数形式分有直接转移和间接转移。初学者最需要理解的是SHORT和NEAR的区别。SHORT是8位偏移量范围是当前IP之后的-128到127字节NEAR是16位偏移量范围是当前IP之后的-32768到32767字节。如果你写了一句JMP LABEL1而LABEL1离这条JMP指令很远超过了8位偏移量的范围编译时MASM通常会报错“Relative jump out of range”。解决办法是加NEAR或SHORT修饰或者改写成JMP NEAR PTR LABEL1不过这种报错多出现在比较大的程序里练习小程序一般不太会遇到。另一个容易忽略的点是间接转移。JMP后面不一定跟标号也可以跟寄存器或内存单元JMP AX ; 跳转到AX寄存器中存放的地址 JMP WORD PTR [SI] ; 从内存中取出一个16位地址作为跳转目标这种写法就是间接转移。它能实现类似C语言里函数指针的机制但初学者也用得少大致了解即可。真正希望大家记住的是JMP本身不影响任何标志位它只是把IP改成目标地址。2.2 条件转移看标志位下菜碟条件转移是汇编里最出彩的部分。它的助记符很多JZ、JNZ、JE、JNE、JC、JNC、JA、JB、JG、JL、JGE、JLE……光看名字容易晕但核心其实只有一个根据标志位的状态决定是否跳转。标志位里最常见的就是ZF零标志位、CF进位/借位标志位、SF符号标志位、OF溢出标志位。条件转移指令本质上就是在做“标志位判断”JZ / JEZF1时跳转。JZ和JE机器码完全相同只是语义不同一个强调“结果为0”一个强调“两数相等”。JNZ / JNEZF0时跳转。JCCF1时跳转JNCCF0时跳转。JS / JNS根据SF判断结果是否为负数。JO / JNO根据OF判断是否溢出。后面还有一组用于无符号数和有符号数大小比较的条件转移这是初学者最容易混乱的地方无符号数比较JA高于、JAE高于等于、JB低于、JBE低于等于。有符号数比较JG大于、JGE大于等于、JL小于、JLE小于等于。为什么会有两组因为同一个二进制数当作无符号数和有符号数时大小关系是不同的。比如FFH无符号数是255有符号数是-1。CMP指令执行完会影响CF、SF、OF、ZF等多个标志位至于用哪个标志位来判断大小取决于你把它当有符号还是无符号。JA/JB系列看CF和ZFJG/JL系列看SF、OF和ZF千万不能混用。实操里最常见的写法是CMP AL, 30H JB NEXT ; 如果AL小于30H十进制48ASCII 0就跳到NEXTCMP AL, 30H做的事情是计算AL - 30H但不保存结果只更新标志位。然后条件转移指令根据标志位来决定跳不跳。这种“比较指令条件转移指令”的组合就是汇编语言里最基础的分支结构。2.3 实战用一块字符分类程序把转移指令焊死在脑子里看再多的指令表不如亲手写一个小程序。我第二次上机时做的第一个练习就是“输入一个字符判断它是数字、大写字母、小写字母还是其他字符”。这个程序把无符号比较和条件转移全部用了一遍非常经典。完整代码如下放到MASM下可以直接编译运行DATA SEGMENT msg1 DB Please input a character: $ msg2 DB 0DH, 0AH, It is a digit., 0DH, 0AH, $ msg3 DB 0DH, 0AH, It is an uppercase letter., 0DH, 0AH, $ msg4 DB 0DH, 0AH, It is a lowercase letter., 0DH, 0AH, $ msg5 DB 0DH, 0AH, Other character., 0DH, 0AH, $ DATA ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA START: MOV AX, DATA MOV DS, AX MOV AH, 09H LEA DX, msg1 INT 21H MOV AH, 01H INT 21H MOV BL, AL CMP BL, 0 JB OTHER CMP BL, 9 JA CHECK_UPPER JMP DIGIT CHECK_UPPER: CMP BL, A JB OTHER CMP BL, Z JA CHECK_LOWER JMP UPPER CHECK_LOWER: CMP BL, a JB OTHER CMP BL, z JA OTHER JMP LOWER DIGIT: MOV AH, 09H LEA DX, msg2 INT 21H JMP EXIT UPPER: MOV AH, 09H LEA DX, msg3 INT 21H JMP EXIT LOWER: MOV AH, 09H LEA DX, msg4 INT 21H JMP EXIT OTHER: MOV AH, 09H LEA DX, msg5 INT 21H EXIT: MOV AH, 4CH INT 21H CODE ENDS END START这段代码的逻辑线是这样的读入一个字符存到BL然后依次检查它是否在数字区间、大写字母区间、小写字母区间最后落到对应的处理分支。重点看CMP BL, 0和JB OTHER的组合CMP BL, 0实际是在算BL - 0如果BL的ASCII码小于0比如是空格借位标志CF会置1JB就跳转到OTHER判断结束。如果BL大于等于0继续比较它是否小于等于9这里用的是JA CHECK_UPPER也就是说如果BL大于9说明不是数字有可能是字母所以跳到下一个检查区。有一个细节需要注意ASCII字符编码中数字、大写字母、小写字母的编码不是连续的。0到9对应30H到39HA到Z对应41H到5AHa到z对应61H到7AH。三个区间之间有不小的空隙所以必须分区间判断不能想着用一个范围覆盖。我当初第一次写这段代码犯的错是忘记在DIGIT、UPPER、LOWER分支末尾加JMP EXIT结果程序判断完数字之后没有跳到EXIT而是直接掉入下一个分支的代码把三条提示全部打印了一遍。这是汇编里非常经典的“掉入fall-through”问题——如果没有跳转指令CPU就会接着执行下一条指令哪怕这条指令是你认为已经结束的另一个分支。3. 循环指令LOOP系指令的原理与使用3.1 LOOP的工作机制先减一再判断别搞反LOOP指令是8086汇编里最常用的循环指令它的原理用一句话说就是CX CX - 1如果CX ! 0就跳转到目标标号继续循环否则顺序执行下一条指令。这句话听起来简单但放到具体例子里就有人绕晕。比如MOV CX, 5 L1: ; 循环体 LOOP L1这段代码循环体会被执行几次答案是5次。因为第一次进入L1时CX5执行完循环体后执行LOOPCX变成4不等于0跳回L1第二次LOOP时CX从4变成3……直到第5次执行LOOP时CX从1变成0不跳转循环结束。所以CX的初始值就是循环次数。但这里有一个极其容易出错的坑循环体里不能随便用CX。如果你在循环体内部改动了CX比如用CX做其他计数LOOP判断时就会出问题循环次数彻底不可控。我见过一个同学在循环里用CX存中间结果结果循环跑了上千次程序直接卡死。还有一个类似的坑LOOP指令本身不影响标志位但循环体里的MOV、ADD、CMP等指令会影响。这通常不会有问题因为LOOP只看CX不看ZF。但如果你用的是下面要讲的带条件的循环指令LOOPZ/LOOPNZ标志位就会掺和进来。最后补充一个细节LOOP的跳转范围和条件转移一样是8位短跳转范围是当前IP之后-128到127字节。所以如果循环体特别长编译时可能会报错“Relative jump out of range”。解决方法通常是把循环体里大段的代码抽成子程序用CALL调用。3.2 循环嵌套保存CX的两种方式遇到二维数组遍历或者其他需要嵌套循环的场景时问题就来了内存循环和外层循环都需要用CX计数但CPU只有一个CX寄存器怎么办最经典的做法是用栈保存外层CX。进入内层循环之前把CX压栈内层循环结束后再弹出恢复外层计数MOV CX, 4 ; 外层循环次数 OUTER: PUSH CX ; 保存外层CX MOV CX, 3 ; 内层循环次数 INNER: ; 内层循环体 LOOP INNER POP CX ; 恢复外层CX LOOP OUTER这段代码能正常工作是因为内层LOOP结束之后栈顶恰好保存着外层CX的值。只要你保证PUSH和POP配对不要漏掉嵌套循环就不会乱。另一种做法是换寄存器比如用SI或DI来辅助保存循环次数。比如外层循环用CX内层循环改用SI计数MOV CX, 4 OUTER: MOV SI, 3 INNER: ; 内层循环体 DEC SI JNZ INNER LOOP OUTER这种方式在某些场景下比压栈更直观因为避免了频繁入栈出栈的开销。但它要求你能熟练使用DEC和JNZ的组合本质上就是用“减一条件跳转”手动模拟LOOP。很多汇编老手其实更喜欢这种方式因为灵活性更高你可以在循环体里随意修改计数器的值而LOOP指令做不到。需要注意跳转标号不要重名。嵌套循环里如果外层和内层用同一个标号汇编器会弄混编译器也容易报错。3.3 LOOPZ/LOOPE与LOOPNZ/LOOPNE循环还能带条件LOOP指令只有一个判断维度CX是否为0。但有些场景需要“一边循环一边检查某个条件是否满足”比如在字符串中查找第一个等于某个字符的位置。这种情况用普通LOOP就不太方便于是有了带条件的循环指令。LOOPZ也写作LOOPECX先减1如果CX不等于0并且ZF1则跳转。也就是说循环继续的条件是“计数没到0且上一次运算结果为0或相等”。LOOPNZ也写作LOOPNECX先减1如果CX不等于0并且ZF0则跳转。循环继续的条件是“计数没到0且上一次运算结果不为0或不相等”。举个例子在字符串里跳过一个连续的字母AMOV SI, 0 MOV CX, 100 ; 假设字符串最多100字节 L_SCAN: CMP BYTE PTR [BUFSI], A JNE L_EXIT ; 不是A提前退出 INC SI LOOP L_SCAN L_EXIT: ; SI 就是最后一个连续A的下一个位置这里如果在CMP之后用LOOPE语义就变成了“如果相等且CX没减到0继续循环”等字符串当前字符不再是A或者CX减到0循环都会终止。这种写法在查找类算法里非常实用。不过凭心而论LOOPZ/LOOPNZ在课内作业里用得并不算多大部分循环用LOOP就够。但考试偶尔会考它们和ZF的关系所以我建议至少把这个机制理清楚执行顺序是先减CX再判断CX是否为0两个条件都满足才跳转。4. 综合实战求一组数据中的最大值和最小值4.1 需求与思路拆解前面把转移指令和循环指令分开讲了现在把它们合起来做一个完整的练习。题目很简单已知一组16位无符号数存放在数据段中求最大值和最小值并输出。这个题目的算法思路和高级语言没区别第一次取第一个数作为初始最大值和最小值然后遍历剩下的每个数如果比当前最大值大就更新最大值如果比当前最小值小就更新最小值。思路清晰之后关键就是如何把“遍历”和“比较分支”翻译成汇编。遍历用LOOP比较用CMP JA/JB更新用MOV。整个过程没有特别高深的东西但它能把转移指令、循环指令、内存寻址、子程序调用全部串起来非常适合练手。4.2 完整代码与关键段讲解DATA SEGMENT array DW 25, 72, 63, 98, 5, 41 count EQU 6 max_val DW 0 min_val DW 0 msg1 DB Max: $ msg2 DB 0DH, 0AH, Min: $ DATA ENDS STACK SEGMENT PARA STACK STACK DW 100H DUP(?) STACK ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA, SS:STACK START: MOV AX, DATA MOV DS, AX LEA SI, array MOV AX, [SI] MOV max_val, AX MOV min_val, AX ADD SI, 2 MOV CX, count - 1 NEXT: MOV AX, [SI] CMP AX, max_val JBE CHECK_MIN MOV max_val, AX JMP CONTINUE CHECK_MIN: CMP AX, min_val JAE CONTINUE MOV min_val, AX CONTINUE: ADD SI, 2 LOOP NEXT MOV AH, 09H LEA DX, msg1 INT 21H MOV AX, max_val CALL PRINT_DEC MOV AH, 09H LEA DX, msg2 INT 21H MOV AX, min_val CALL PRINT_DEC MOV AH, 4CH INT 21H PRINT_DEC PROC NEAR ; 入口AX为无符号16位数 PUSH AX PUSH BX PUSH CX PUSH DX MOV BX, 10 MOV CX, 0 L1: XOR DX, DX DIV BX PUSH DX INC CX TEST AX, AX JNZ L1 L2: POP DX ADD DL, 0 MOV AH, 02H INT 21H LOOP L2 POP DX POP CX POP BX POP AX RET PRINT_DEC ENDP CODE ENDS END START逐个说几个关键点。首先count EQU 6是宏定义编译期就会把count替换成6所以MOV CX, count - 1会被编译成MOV CX, 5。这样写的好处是数组长度只需改一处后面所有引用都会跟着变。其次取第一个数作为初始值后循环从第二个元素开始所以CX设为count - 1。如果你把CX设为count然后从第一个元素开始遍历也完全没问题只是多了一次无意义的比较。但要注意如果数组只有一个元素count - 1就等于0LOOP指令会直接跳过输出结果仍然是正确的——这就是“把第一个元素作为基准值”这个思路的优雅之处。然后是分支结构的写法。这里我对比AX, max_val用JBE CHECK_MIN。为什么是JBE而不是JB或JG因为当AX小于等于max_val时不需要更新最大值可能要去检查最小值只有当AX大于max_val时才更新最大值。CMP计算的是AX - max_val无符号数比较时如果AX小于或等于max_val则CF或ZF会置位JBE正好命中这个条件。更新完最大值后一定要加一个JMP CONTINUE跳过CHECK_MIN分支否则就会掉入更新最小值的逻辑产生错误结果。输出部分我用了一个十进制输出子程序PRINT_DEC。这个子程序的核心逻辑是不断用DIV BXBX里存10去除AX余数依次压栈商继续除以10直到商为0最后一次从栈顶弹出的是最高位数字加上字符0之后用INT 21H逐个输出。这种“除10取余逆序输出”的做法是汇编里最常用的十进制输出方案建议背下来。这里的TEST AX, AX是一条很有用的指令它做了一次按位与操作但不保存结果只更新标志位。JNZ L1根据ZF来判断AX是否为0等效于“如果AX不为0继续循环”。5. 上机常见问题与排查技巧实录5.1 常见问题速查表学汇编的人没有谁是不踩坑就直接通关的。这一节我把上机过程里最容易遇到的问题整理成表按经验概率排序现象常见原因解决办法MASM编译报错“Relative jump out of range”条件转移或LOOP跳转范围超过-128到127字节把跳转拆成两段或改用JMP NEAR PTR或把大段代码抽成子程序程序运行后死循环长时间不退出循环体内修改CX或LOOP目标标号位置错误检查循环体里是否有对CX的改动必要时用栈保存CX程序输出多了几行提示文本分支末尾忘记写JMP导致掉入下一个分支检查每个分支结束后是否有JMP跳出到公共出口比较结果完全相反判断反了条件转移指令选错把无符号数比较和有符号数比较混用明确数据是有符号还是无符号再选JA/JB或JG/JL编译提示“Operand types do not match”变量类型不匹配比如把一个字节量和一个字量做比较使用BYTE PTR或WORD PTR显式指定类型程序能编译能链接但运行退出后异常忘记定义栈段或栈空间不足定义STACK SEGMENT PARA STACK STACK并分配足够空间DEBUG里单步运行跳到奇怪地址使用了间接转移IP被内存或寄存器里的脏数据覆盖检查JMP寄存器/JMP内存前的数据来源这里我想特别强调第一条“Relative jump out of range”。初学者很少遇到但一旦程序写得稍微长一点就会碰到。原因前面说过条件转移和LOOP都是短跳转。如果编译器提示这个错误千万不要想着用什么高深的技巧最稳妥的解法是把大块的逻辑拆成子程序用CALL和RET组织代码。这不仅仅是为了满足编译器限制更是写出可读性更好的汇编程序的基本功。5.2 DEBUG单步跟踪把每条跳转都看穿我在学转移指令和循环指令时最大的转折点是学会了用DEBUG单步跟踪。DOSBox里运行DEBUG加载EXE程序后输入T命令单步执行U命令反汇编查看指令R命令查看寄存器状态D命令查看内存数据。这个工具堪称汇编学习神器。用DEBUG跟踪循环的体验非常直观。比如运行上面求最大值最小值的程序在DEBUG里可以看到每条指令执行后寄存器值的变化IP指针如何跳转CX如何递减标志位如何被CMP影响。看几次之后对“执行流”的理解会从“大概知道”变成“真的懂”。跟踪时的操作流程大致如下在DOSBox中进入汇编程序所在目录输入debug test.exe加载程序。输入U反汇编找到入口附近的指令地址记下来。输入T单步执行几条指令观察寄存器变化。如果想直接跳到某条指令先用U查出该指令所在的段地址和偏移量再用G 段地址:偏移量设置临时断点。循环部分用T一条条走重点看CX、IP、ZF、CF的变化。我第一次用DEBUG跟踪一个LOOP循环时看到CX从5到4到3逐渐减到0IP在循环体内来回跳顿时就明白了“循环就是有条件跳转”这句话。后面写任何循环结构脑子里都有这种“IP来回弹跳”的画面出错的概率大大下降。5.3 一个容易被忽视的细节指令对标志位的影响学习转移指令时很多人会忽略一个问题每一条指令对标志位的影响是不同的。比如MOV不影响标志位CMP会影响CF、ZF、SF、OF、AF、PFADD会影响CF、ZF、SF、OF、AF、PFINC会影响OF、SF、ZF、AF、PF但不影响CFTEST只影响ZF、SF、PF。这个细节在条件转移里非常关键。举个例子MOV AX, 0 DEC AX JZ ZERODEC AX执行后ZF1所以JZ会跳转这很合理。但如果写成MOV AX, 0 MOV AX, 1 JZ ZEROMOV不影响标志位所以ZF仍然是上一条指令留下的值。如果上一条指令是DEC AXZF1这里JZ就误跳了。这是新手很容易踩的隐形坑。所以记住一个原则条件转移指令前面紧跟着的那条影响标志位的指令才是它真正的判断依据。中间如果夹了MOV、LEA这类不影响标志位的指令那还好但如果夹了ADD、SUB这类影响标志位的指令判断结果就变了。写代码时要刻意控制“比较指令”和“条件转移指令”之间的距离。写在最后转移指令和循环指令这块说难不难说简单也不简单。难的地方在于它要求你用“执行流”的视角看程序而不是像高级语言那样写一个if或for就觉得完事了。但一旦你建立起“CPU逐条执行指令条件满足就跳转”的直觉汇编里很大一部分内容都会豁然开朗。我自己的体会是学这两块内容最有效的方式不是背指令助记符而是去DEBUG里单步跟踪一个完整的循环程序亲眼看着IP指针在代码里跳来跳去看着CX一点点减到0。这个过程比看十遍书都管用。最后再分享一个小技巧写汇编程序时多写注释并且每条跳转指令后面都注明“跳去干什么”。比如JZ EXIT ; 结果为0跳出循环 JMP NEXT ; 继续处理下一个元素这种注释习惯在程序只有几十行时看不出来一旦代码变长转移指令一多执行路线就会像蜘蛛网一样。没有注释的话过两天你自己都看不懂自己写的跳转逻辑。这是在汇编上机里吃过亏之后我最想提醒大家的一点。