资讯动态

汇编循环结构详解:LOOP、条件跳转与嵌套循环实战

发布时间:2026/9/9 21:26:11 来源:尧图企业网站定制
学汇编的流程控制前期最轻松的是顺序结构因为一条条指令往下执行就行脑子基本不用转弯。一旦到流程控制的第二部分——循环结构画风就完全变了你会看到一堆 LOOP、CMP、JNZ、JMP 混在一起逻辑环环相扣稍不留神就掉进死循环出不来了。但恰恰是循环结构才是真正让你从“会写指令”进阶到“会写程序”的分水岭。不管是计算累加、遍历数组还是做延时、排序背后翻来覆去都是循环。这篇文章就针对 2.3 节循环结构把底层原理、三种常用实现方式、完整代码示例以及调试排错的经验一次讲清楚适合刚接触汇编、正在啃流程控制这一章的读者也适合那些学过一遍但总觉得循环用不熟的同学拿来查漏补缺。1. 循环结构到底是什么从生活场景到寄存器语义1.1 循环三要素初始化、条件判断、循环体更新先聊一个稍微“形而上”一点的问题循环的本质是什么如果让我用一句话概括循环就是“在满足某个条件的前提下反复执行同一段代码”。这句话放在高级语言里很好理解Python 的while、C 的for都是这个逻辑。但放到汇编里你需要把这句话拆成更细的动作因为 CPU 不认识“反复”这个词寄存器也不认识什么 while 从句。一个标准的循环结构无论用什么语言实现都离不开三个要素初始化、条件判断、循环体更新。拿最常见的“从 1 加到 100”来说初始化累加器清零计数器从 1 开始条件判断当前计数器是否已经超过 100如果超过就退出循环体更新把计数器加到累加器中计数器自增 1然后回到条件判断。高级语言把这三件事封装成了语法糖你写一个for i in range(1, 101)就完事了。但汇编没有语法糖它只有指令。所以你在汇编里看到的循环本质上是“一段代码的执行流通过跳转指令反复回到某个地址”而“条件判断”则是靠比较指令配合标志寄存器来实现的。这个区别很重要。很多人写汇编循环容易晕就是因为脑子里还带着高级语言的思维方式总觉得循环应该是个“结构”可实际上汇编里根本没有结构只有跳转。1.2 汇编循环和高级语言循环的底层关系既然汇编没有循环结构它是怎么表达循环的答案是循环只是跳转指令的一种使用模式。你可以用JMP无条件跳转实现一个死循环也可以用LOOP指令实现一个计数循环还可以用CMP加条件跳转指令实现一个 while 循环。换句话说高级语言里的for、while、do-while最终编译成机器码时都会变成跳转指令。你在汇编里手动写的循环其实就是编译器在背后帮你做的事情。理解了这一层你就不会再对“汇编为什么没有 for 循环”感到困惑了。举个直观的例子C 语言里常见的for(i 0; i 100; i)翻译成汇编的思想大概是初始化MOV CX, 0或者用别的寄存器装计数器条件判断CMP CX, 100如果满足退出条件就跳到循环结束循环体执行你要反复做的操作更新INC CX然后JMP跳回条件判断处。当然这是最朴素的翻译思路实际上为了效率可以用 LOOP 指令进一步简化后面会展开。2. 三种常用实现手段LOOP、条件跳转、JCXZ2.1 LOOP 指令最偷懒但最受限制的循环大多数汇编教材讲循环结构第一个登场的就是LOOP指令原因很简单它用起来最像高级语言的for一个指令就能搞定计数器递减和条件跳转。LOOP指令的完整语义是这样的先把 CX 寄存器的值减 1然后判断如果 CX 不等于 0就跳转到指令后面跟着的标号如果 CX 等于 0就顺序执行下一条指令。也就是说LOOP的循环次数完全靠 CX 寄存器来当计数器你在循环开始之前必须先把 CX 设置成你想要循环的次数。我见过很多新手在这个地方踩坑因为LOOP是“先减 1 再判断”。如果你把 CX 初始化为 0按直觉理解应该是循环 0 次对吧但实际上不是CX 减 1 后会变成 0FFFFh也就是 65535不等于 0于是程序会悲惨地循环 65535 次。这是LOOP指令最容易踩的第一个坑几乎每次教循环都会有人中招。还有一点要特别注意LOOP的跳转范围是 8 位位移也就是只有在当前位置 -128 到 127 字节范围内的标号才能直接跳转。如果你的循环体特别长超过了这个范围汇编器会报错。这种时候有两种解决办法一是简化循环体把部分逻辑拆到子程序里二是手动改用DEC CX加JNZ的写法因为JNZ如果目标太远同样受限制那就得用JMP中转。不过这是后话入门阶段先把短循环写好就够了。2.2 CMP 加条件跳转万能循环的真相LOOP虽然方便但它只能做“固定次数”的循环。现实中的循环往往不是这么简单你可能需要在某个值大于阈值时退出可能需要在遇到某个标志时提前结束甚至可能循环次数在程序运行过程中动态变化。这时候就需要第二个手段CMP加条件跳转指令。CMP指令的本质是“做一次减法但不保存结果”它会把两个操作数相减然后根据结果更新标志寄存器中的 ZF、SF、CF、OF 等标志位。比如CMP AX, 100它计算 AX 减 100如果结果等于 0ZF 就置 1如果 AX 比 100 小CF 就置 1具体要看无符号还是有符号细节不在这里展开。条件跳转指令则根据标志位来决定是否跳转。常用的有JE/JZ等于跳转、JNE/JNZ不等跳转、JG/JGE大于/大于等于有符号、JL/JLE小于/小于等于有符号、JA/JAE高于/高于等于无符号、JB/JBE低于/低于等于无符号等等。这些指令配合CMP几乎可以实现任何你想要的条件判断。举个例子你想写一个“当 AX 大于 0 时继续循环”的循环WHILE_LOOP: CMP AX, 0 JLE END_LOOP ; 如果 AX 0则退出循环 ; 循环体代码 ... JMP WHILE_LOOP END_LOOP:这段代码就是典型的 while 语义先判断条件是否满足不满足就跳出满足就执行循环体执行完后跳回去重新判断。它比LOOP灵活得多任何你能想到的退出条件都可以写出来。但灵活也带来了一个代价你需要自己维护计数器。LOOP帮你把 CX 减 1 和判断都做了而用CMP加跳转的方式你要自己决定用哪个寄存器当计数器、在哪里增加、在哪里判断。这听起来麻烦但实际上是锻炼“汇编思维”的好机会因为编译器最终生成的代码往往就是这个形态。2.3 JCXZ循环前的哨兵判断JCXZ指令的全称是 Jump if CX is Zero也就是“如果 CX 为 0 则跳转”。它不像LOOP那样自带计数器递减功能也不像CMP那样需要配条件跳转指令。它的作用很简单在执行循环之前先判断 CX 是不是 0如果是就跳走跳过整个循环。你可能会问这个指令有什么实际用场答案是它正好用来规避LOOP指令那个“CX 为 0 时循环 65535 次”的坑。如果你不确定调用方的 CX 会不会是 0那在进入LOOP循环之前先写一句JCXZ SKIP_LOOP这样如果 CX 本来就是 0程序直接跳过循环不会进入那个可怕的 65535 次循环。这个技巧在写子程序时特别有用因为子程序可能被不同地方的代码调用你没法保证每次传进来的 CX 都是非零值。另外一个常见用途是处理字符串和数组。比如用REP前缀配合MOVSB或STOSB做块操作时CX 是重复次数如果 CX 为 0 就应该什么都不做。这种情况下加一个JCXZ保护是很稳妥的写法。3. 完整实操从零写一个循环累加程序3.1 需求分析与算法设计聊完理论来点实在的。我们写一个经典的入门级循环程序计算 1 到 100 的累加和。这个需求用数学公式可以直接算出结果是 5050但作为编程题目它的意义在于训练循环结构的搭建。我们先把算法在脑海中过一遍第一步初始化。需要一个寄存器保存当前的累加和初始为 0需要一个寄存器保存当前要加的数初始为 1还需要一个计数器来控制循环次数初始为 100。第二步循环体。把当前要加的数累加到和中然后把当前数加 1。第三步循环控制。循环 100 次后退出。这里有个很微妙的点循环次数设为 100但当前数从 1 开始每次加 1循环 100 次之后最后一次加进去的数正好是 100。因为LOOP是“先减 1 再判断”第一次进入循环体时 CX 还是 100执行完循环体后LOOP把 CX 变成 99发现不等于 0继续跳回循环体循环体再次执行。等到第 100 次执行完循环体LOOP把 CX 从 1 变成 0这才退出。所以循环体一共执行了 100 次完美匹配。3.2 第一版代码用 LOOP 实现 1 加到 100下面是完整的 MASM/TASM 风格代码; 功能计算 1 2 3 ... 100 ; 结果保存在 AX 寄存器中 CODE SEGMENT ASSUME CS:CODE, DS:CODE START: MOV AX, CODE MOV DS, AX XOR AX, AX ; AX 清零用来保存累加和 MOV CX, 100 ; CX 作为循环计数器循环 100 次 MOV DX, 1 ; DX 用来保存当前要加的数从 1 开始 L_ADD: ADD AX, DX ; 累加AX AX DX INC DX ; 当前数加 1 LOOP L_ADD ; CX CX - 1若 CX ! 0 则跳回 L_ADD ; 到这里AX 5050 MOV AH, 4CH INT 21H ; 程序结束返回 DOS CODE ENDS END START这段代码的运行过程可以这样理解AX 先清零CX 设为 100DX 设为 1。进入L_ADD标号后第一句把 DX 的值加到 AX 上此时 AX 变成 1第二句把 DX 变成 2第三句LOOP L_ADDCX 减 1 变成 99不为 0跳回。第二次进来AX 加上 2变成 3DX 变成 3CX 变成 98……如此反复一直到第 100 次时 AX 加上 100DX 变成 101CX 从 1 减到 0循环结束。这里你会发现DX 的值在循环结束时已经变成了 101多跑了一步。这不算 bug因为我们的循环次数是跟着 CX 走的DX 只是被 INC 多了一次而已。但如果后面代码还要用到 DX就得小心这个边界情况。在集成开发环境或命令行下用 MASM 编译链接后单步调试就能在寄存器窗口看到 AX 从 0 逐步涨到 5050 的过程。第一次看到这个数字出来的瞬间你会觉得循环的魔力确实有点东西。3.3 升级版用条件跳转实现 while 语义LOOP版本简单但不够通用。假如现在需求改成“从 1 加到 100但加到总和超过 1000 就提前停止”这就没法用固定次数循环来做了因为退出条件依赖循环过程中计算出来的值。这时候就得用CMP加条件跳转的 while 风格。看下面的代码; 功能计算 1 2 3 ... 直到和超过 1000 为止 ; 结果保存在 AX 中 CODE SEGMENT ASSUME CS:CODE, DS:CODE START: MOV AX, CODE MOV DS, AX XOR AX, AX ; AX 保存累加和 MOV DX, 1 ; DX 保存当前数 MOV BX, 1000 ; BX 保存阈值 L_WHILE: ADD AX, DX ; 累加 CMP AX, BX ; 比较累加和与阈值 JG L_END ; 如果 AX 1000退出循环 INC DX ; 当前数加 1 JMP L_WHILE ; 跳回重新判断 L_END: ; 此时 AX 是第一个超过 1000 的累加和 MOV AH, 4CH INT 21H CODE ENDS END START这段代码的跳转逻辑是标准的 while 循环先执行循环体再判断条件条件不满足就跳回循环体开头。注意我这里把INC DX放在JMP L_WHILE之前意味着每当条件不满足时DX 都会继续递增直到累加和超过阈值才停止。实际上1 加到 44 的和是 9901 加到 45 的和是 1035所以这个程序会在 DX 变成 46多递增了一次后停止AX 的值是 1035。你如果在调试器里单步跟会看到最后一次循环进来时 AX 已经变成 1035然后CMP AX, BX发现 1035 大于 1000JG L_END直接跳走。这种写法的好处是非常直观任何有高级语言基础的人都能看懂它的逻辑。缺点是你得把所有控制逻辑都写出来不能像LOOP那样一行搞定。实际项目中while 风格的循环其实比固定次数循环更常见所以我建议你重点掌握这一种。3.4 数组遍历循环真正实用的循环累加求和只能算热身实际编程中循环最常见的场景是遍历数组。假设有一个 10 个字的数组我们要把数组所有元素累加到一个变量里。DATA SEGMENT ARRAY DW 10 DUP(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) SUM DW 0 DATA ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA START: MOV AX, DATA MOV DS, AX LEA SI, ARRAY ; SI 指向数组首地址 MOV CX, 10 ; 数组长度 10 XOR AX, AX ; 累加器清零 L_SUM_ARRAY: ADD AX, [SI] ; 累加当前元素 ADD SI, 2 ; SI 后移一个字2 字节 LOOP L_SUM_ARRAY ; CX 减 1不为 0 则继续 MOV SUM, AX MOV AH, 4CH INT 21H CODE ENDS END START这里有一个很重要的知识点为什么用ADD SI, 2而不是ADD SI, 1因为数组类型是DWdefine word字一个字在内存中占 2 个字节。SI 的偏移量单位是字节所以要跳过一个字就得让 SI 加 2。如果你定义的是DB字节那 SI 每次加 1 就够了。当数组元素需要放进 CX 的循环计数时你要注意数组长度不能超过 65535否则一个寄存器存不下。对于入门练习来说这个限制基本不会造成困扰。还有一个更优雅的写法是综合SI和[SIBX]这样的变址寻址在需要同时遍历两个数组时特别有用。比如你想把 A 数组和 B 数组对应元素相加存到 C 数组就可以用一个循环外加 SI、DI 两个指针同时推进。4. 嵌套循环与寄存器保护4.1 为什么嵌套循环必须保护 CX前面讲的都是单层循环但实际需求里循环套循环太常见了比如排序、矩阵遍历、输出乘法表都需要嵌套循环。如果你用LOOP指令写嵌套循环会立刻撞上一个问题内层循环和外层循环都需要用 CX 来计数。当内层循环开始后它会把 CX 覆盖成自己的循环次数内层循环结束时CX 已经变成 0外层循环的计数信息早就丢了。解决办法很简单在进入内层循环之前把外层的 CX 压栈保存内层循环结束后再从栈里弹出来恢复。典型代码如下MOV CX, 5 ; 外层循环 5 次 OUTER_LOOP: PUSH CX ; 保存外层 CX MOV CX, 3 ; 内层循环 3 次 INNER_LOOP: ; 内层循环体 LOOP INNER_LOOP POP CX ; 恢复外层 CX ; 外层循环体 LOOP OUTER_LOOP这里有几个细节值得注意。第一PUSH CX和POP CX必须严格配对不能出现内层循环用了 PUSH 但 POP 写在外层循环结束之后的情况否则栈会失衡。第二如果内层循环里还有其他 PUSH 操作PUSH 和 POP 的顺序是后进先出要保证恢复时顺序正确。第三内层循环深度越大越要小心栈空间8086 默认栈段大小有限嵌套太深可能会导致栈溢出但一般教科书规模的程序不会触及这个上限。另外如果内层循环的计数器不需要保留你也可以换一个寄存器实现内层循环比如用 DL、SI 等。不过最省心的还是“压栈保护”这个思路它能让你在不改寄存器分配的情况下直接套娃。4.2 用嵌套循环写冒泡排序简化版嵌套循环最经典的实战案例就是冒泡排序。虽然真正的排序库函数早就写好了但用汇编手写一遍对理解循环、内存寻址和交换操作非常有帮助。下面是一个简化版的冒泡排序代码框架数组长度假设为 6DATA SEGMENT ARR DW 6, 3, 8, 1, 9, 2 N EQU 6 ; 数组长度 DATA ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA START: MOV AX, DATA MOV DS, AX MOV CX, N-1 ; 外层循环次数 N - 1 OUTER: PUSH CX LEA SI, ARR ; SI 指向数组开头 INNER: MOV AX, [SI] ; 取当前元素 CMP AX, [SI2] ; 和下一个元素比较 JLE NO_SWAP ; 如果当前元素 下一个不交换 ; 交换两个元素 MOV DX, [SI2] MOV [SI2], AX MOV [SI], DX NO_SWAP: ADD SI, 2 ; 移动到下一个元素 LOOP INNER ; 内层循环 POP CX LOOP OUTER MOV AH, 4CH INT 21H CODE ENDS END START注意几个关键点数组是DW所以指针 SI 每次加 2比较当前元素和下一个元素时下一个元素的地址是[SI2]当需要交换时用 AX 和 DX 作临时寄存器。内层循环从第一个元素比较到倒数第二个这样每轮外层循环都能把当前最大值“冒泡”到数组末尾。这个版本不是最优的冒泡排序因为它每轮都固定比较 N-1 次中间几轮即使后面已经有序了也会重复比较。但作为教学示例它的逻辑足够清晰也足够暴露嵌套循环的寄存器问题。4.3 循环体长度对跳转范围的影响前面提过LOOP和大部分条件跳转指令的跳转范围是 8 位位移-128 到 127 字节。这意味着循环体如果写得太长代码超过了这个范围汇编器就会报错提示跳转越界。怎么解决一般有这几种思路。第一精简循环体。把循环里特别长的逻辑抽成子程序循环体只负责调用子程序这样循环体代码量就小了。这是最推荐的做法不仅让跳转范围问题消失还能让代码更可读。第二把短跳转改成近跳转。如果你用的是JZ、JNZ这类条件跳转可以改成反逻辑的条件跳转加一个JMP中转。比如原来JZ LABEL跳不过去就改成JNZ SKIP加JMP LABEL但JMP的近跳转范围更大可以覆盖 ±32K。不过这样会引入额外指令而且改起来有点绕不如第一种方案清爽。第三在代码段里适当调整标号位置。有时候把一些初始化代码挪到循环外面循环体就能短上一截刚好能放进短跳转范围。我见过有些初学者遇到“Jump out of range”就直接把循环拆了重写其实没那么严重先想想能不能把循环体瘦身往往才是正解。5. 实操中的坑常见问题与排查思路5.1 常见错误速查表循环结构的错误十有八九集中在“计数器”和“跳转条件”这两个地方。我把这些年带新人时最常见的几个坑整理成了表格方便你对照排查。错误表现根本原因解决办法死循环程序跑不完退出条件永远不满足或忘记修改循环变量检查 CMP 条件方向确认循环体里有没有必要的 INC/DECCX 初始为 0却循环了 65535 次不理解 LOOP 是“先减 1 再判断”进入循环前加 JCXZ 保护或确保 CX 初始值非 0内层循环把外层 CX 破坏了嵌套循环共用 CX 寄存器内层循环前 PUSH CX结束 POP CX数组遍历结果不对指针每次加的字节数不对DW 数组用 ADD SI, 2DB 数组用 ADD SI, 1跳转标号越界编译报错循环体太长超出 8 位跳转范围精简循环体或抽子程序LOOP 少循环一次或多循环一次对“先减 1 再判断”的理解出现偏差单步调试跟踪 CX 变化用具体数据验证边界条件跳转方向写反提前退出Jcc 助记符混淆比如 JG 和 JL 用反先画逻辑框图再编码确认“满足条件退出”还是“不满足退出”这些坑我基本都踩过有些甚至不止一次。尤其是LOOP的“65535 次”问题几乎每个接触汇编的人都要经历一次刻骨铭心的死循环体验才会真正记住。5.2 如何用调试器观察循环过程写循环程序最怕的就是逻辑看不到全貌。好在汇编有 Debug 一整套调试工具只要你会用循环过程就是透明的。在 DOS 环境或者模拟器里用R命令查看寄存器状态。你可以直接看到 CX 当前值是多少、AX 累加到哪里了、IP 指针停在哪条指令上。每执行一次T单步跟踪观察一条指令执行完后 CX 和 AX 的变化循环的逻辑就慢慢清晰了。如果遇到死循环可以在怀疑的标号处设置断点然后用G命令运行到断点看执行到断点时各个寄存器的值是否符合预期。再用D命令查看内存区域确认数组数据有没有被正确读写。这套组合拳打下来绝大多数循环问题都能定位到具体指令。我还养成了一个习惯在循环体开头放一条特定指令然后断点观察。比如在数组遍历程序里我会观察 SI 的偏移量变化确认它在循环结束时恰好越过最后一个元素不多不少。这种“用数据说话”的调试习惯在汇编阶段建立起来后面学任何语言都受用。5.3 性能与代码可读性的取舍最后聊一个稍微进阶一点的话题汇编循环的性能和可读性如何平衡。从执行效率来说LOOP指令并不比DEC CX JNZ更快甚至在部分 CPU 上还有额外开销。在某些优化场景里直接展开循环loop unrolling会比让 CPU 反复跳转更快因为跳转会清空流水线。但对于入门阶段来说这些优化意义不大更重要的是先用清晰的代码把正确逻辑跑通。可读性方面我的建议是先写最容易理解的CMP加跳转版本因为它的每一步控制逻辑都显式写在代码里出了问题和代码能一一对上。等你确实弄懂了循环的底层机制再去用LOOP指令简化代码那时候你才会真正明白LOOP帮你做掉了什么工作而不只是复制粘贴一个指令模板。我见过不少初学者一上来就背LOOP的用法结果遇到稍微复杂点的 while 循环就傻眼了。反而是那些先写条件跳转、再对比LOOP的同学对循环的理解更扎实。所以如果你时间还算充裕建议把两种写法都写一遍再观察两种写法的差异。根据我用汇编做教材配套实验的体会循环结构这一节想要真正过关光看教程是不够的一定要自己在机器上跑几个程序哪怕只是把书上的例子敲一遍再改改参数收获都比反复看十遍文字大。另外一个小技巧遇到循环题目先别急着写代码拿张纸把初始值、循环次数和退出条件列出来算清楚边界再动手敲指令。提前把这个问题想明白你写循环时会少走很多弯路。最后再分享一个检查循环边界的小方法把循环次数改小一点比如从 1 加到 3单步跟踪看每一轮 CX 和累加值的状态确认循环体执行的次数恰好等于预期。确认逻辑没问题之后再把次数改回 100。这个“小数据验证”的习惯是我写汇编以来最实用的经验尤其是面对循环嵌套、数组遍历这类代码时它能帮你把错误拦截在最小范围内。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价