2026/10/23 23:02:42

8086汇编计数循环:LOOP指令与CX寄存器调试指南

8086汇编计数循环:LOOP指令与CX寄存器调试指南 汇编这门语言有个挺有意思的地方越是基础的指令越容易在实际调试时把人卡住。循环和CX 寄存器就是典型例子。看教程五分钟就能背下LOOP指令的语义但真正上手写程序第一次跑出来的结果十有八九是错的——要么死循环要么循环次数差一要么程序跑得好好的突然跳飞了。这篇内容围绕 8086 实模式下的计数循环展开把LOOP系列指令、CX 计数器的隐含用途、位移量的计算方式、嵌套循环的保护方案、DEBUG 单步验证的方法以及块复制和循环展开的效率取舍一路讲透。刚接触汇编、正在做课程设计或者想搞明白为什么我的循环多跑了一次的读者可以直接照着复现已经能写完整程序、但对指令编码和标志位影响没细究过的同行也能从中找到一些平时容易忽略的细节。文中所有代码都在 8086 指令集范围内用 MASM/TASM 风格书写配 DOSBox 或实体机都能跑。1. 先弄清楚 CX 在循环里到底扮演什么角色1.1 从一条 LOOP 指令说起LOOP这条指令短得离谱机器码只有两个字节格式是E2 cb其中cb是一个 8 位有符号位移量。可它内部干的事并不简单CPU 执行到LOOP时会一口气做完三步先把 CX 的内容减一结果写回 CX然后判断 CX 是不是等于零如果不等于零就把 IP 加上那个 8 位位移量跳到目标地址继续执行如果等于零就什么都不做顺序执行下一条指令。请注意顺序是先减、后判这个顺序决定了 CX 初值和实际循环次数之间的对应关系。CX 装 5循环体就执行 5 次最后一次执行完之后 CX 减到 0LOOP不再跳转循环结束。这个关系看起来很直观但正因为太直观很多人写代码的时候默认我传进来 0 就是一次都不循环结果程序在主频只有几兆赫兹的老机器上空转六万多次才停下来——这个问题后面会专门用一节讲。把LOOP拆开它其实等价于这么两条指令的组合DEC CX JNZ 标号区别在于LOOP只占 2 字节而DEC CX加JNZ rel8要占 3 字节更重要的是LOOP不修改任何标志位而DEC会实打实地改动 ZF、SF、PF、AF、OF。这个差异在循环体依赖标志位的场合是致命的后面 2.3 节会展开。1.2 CX 的四个兼职身份CX 在 8086 里被定名成计数寄存器这个名字不是随便起的。除了LOOP系列指令默认拿它当循环计数器它至少还有三份兼职写循环的时候很容易撞车。第一份兼职是移位次数。8086 的移位和循环移位指令SHL、SHR、SAL、SAR、ROL、ROR、RCL、RCR移位次数只允许两种形式写死常数 1或者用 CL 指定。注意是 CL也就是 CX 的低八位。这是硬件层面的限制8086 没有提供立即数移位的编码方式。所以只要你的循环体里出现了变量移位CX 的低字节就被动了如果不小心把计数器也放在 CX 上循环次数立刻失控。第二份兼职是串操作重复次数。REP、REPE、REPNE这些前缀指令本质上就是以 CX 为次数、每执行一次串操作自动减一的硬件循环。REP MOVSB干的事情和手写一个LOOP加MOVSB一模一样只是更快。第三份兼职是除法除数。DIV CX、IDIV CX会把 CX 当除数用这在循环体里做整数运算时也得留个心眼。一份兼职撞上另一份循环就乱了。我见过最典型的翻车现场是循环体里调了个子程序子程序内部为了字符串拷贝把 CX 设成了字符串长度返回之后外层的循环计数器已经被改成别的数了外层循环要么提前结束要么多跑几十次。提示写循环之前先扫一遍循环体看看里面有没有MOV CX、REP前缀、SHL AX, CL这类会碰到 CX 的指令。有的话要么换寄存器要么进循环体之前压栈保护。1.3 为什么不用 DX 或者 BX 来计数既然 CX 这么容易被别的指令抢走为什么不用 DX 或者 BX 当计数器原因有三个都很实际。第一个是指令长度。LOOP是专门为 CX 设计的别的寄存器没有对应的单指令循环。你要是用 DX只能写成DEC DX加JNZ 标号三条字节起步循环体稍微长一点的程序字节数累积起来就是可观的差异。在当年内存按 KB 计算的年代这个差异值得计较。第二个是可读性约定。汇编代码是给人看的不是只给机器看的。整个 8086 生态里CX 当计数器、SI 当源指针、DI 当目标指针、AX 当累加器、BX 当基址这是一套约定俗成的分工。你按这个分工写别人接手一眼就知道每行在干嘛你偏要用 BX 计数看代码的人每次都得回头确认一遍寄存器含义。第三个是和 REP 前缀的配合。如果你的循环体内核其实是串操作直接用REP前缀比手写LOOP快得多而REP只认 CX。用别的寄存器计数就没法享受这个加速。当然这不是死规矩。循环嵌套的时候 CX 只有一个内层循环我通常就用 DI 或 BX 配合DEC、JNZ来写省掉压栈开销。这个取舍后面 3.4 节会讲。2. LOOP 家族的指令细节与位移计算2.1 LOOP / LOOPE / LOOPNE / JCXZ 的差别LOOP不是孤零零一条指令它有一个家族四条指令各有各的适用场景。把它们的判断条件列清楚比背定义管用。指令机器码执行动作转移条件典型用途LOOPE2 cbCX 减一CX ≠ 0固定次数的计数循环LOOPE同LOOPZE1 cbCX 减一CX ≠ 0 且 ZF 1查找循环找到即停LOOPNE同LOOPNZE0 cbCX 减一CX ≠ 0 且 ZF 0查找循环没找到继续JCXZE3 cb不改变 CXCX 0循环入口前的空次数判断LOOPE和LOOPNE这两个带条件判断的版本很适合写在数组里找某个值这类逻辑。比如在一串字节里找第一个等于 0 的元素每轮比较完用CMP设置 ZF然后LOOPNE继续找找到的时候 ZF 1循环因为条件不满足而退出此时 CX 的值还能反推出元素的位置。这里有个容易被忽略的点LOOPE因为要同时看 ZF它的退出条件是或的关系——要么 CX 走到 0要么 ZF 不满足任意一个成立就退出。这意味着循环结束后你必须再判断一次到底是哪种情况退出的否则分不清找到了还是找完了没找到。老手通常会在循环之后跟一条条件跳转把两种结局分开处理。JCXZ则是另一个角色。它不修改 CX也不影响标志位只是单纯地看 CX 是不是零。因为它不递减 CX所以不能当循环用只能放在循环前面防止次数为 0 却还进循环体的意外。2.2 位移量是怎么算出来的LOOP的位移字段只有一个字节是有符号数取值范围 -128 到 127。这个数字不是标号的绝对地址而是相对下一条指令的偏移。CPU 执行LOOP时IP 已经指向了LOOP的下一条指令所以用下一条指令的地址 位移量才是跳转目标。拿一段实际的代码来算。假设代码段起始偏移是 0000H0000 B9 06 00 MOV CX, 6 0003 BE 00 00 MOV SI, 0 0006 B8 00 00 MOV AX, 0 0009 03 04 ADD AX, [SI] 000B 46 INC SI 000C 46 INC SI 000D E2 FA LOOP 0009LOOP指令本身位于 000DH占两个字节所以下一条指令的地址是 000FH。目标是 0009H。位移量 0009H - 000FH -6用 8 位补码表示就是 FAH。所以机器码是E2 FA跟上面写的对上了。反过来如果你在 DEBUG 里看到一条E2 F9怎么推目标地址F9H 作为有符号数是 -7假设这条指令在 000EH下一条就是 0010H0010H - 7 0009H。目标标号在 0009H。这套计算没什么技巧但必须会因为调试的时候没有源代码只有机器码你得能从字节反推出跳转目标才能判断跳飞了是跳到哪儿去了。至于 ±128 字节的限制这是 8 位位移字段的硬边界跟标号写在哪没关系。循环体写长了LOOP就够不着。这个坑后面 4.3 节有专门的改写方案。注意不同汇编器对LOOP短跳转越界的报错措辞不一样MASM 一般提示Jump out of rangeNASM 会说short jump out of range。看到这类报错先量一下循环体长度基本就定位了。2.3 LOOP 不碰标志位这件事有多重要前面提过LOOP和DEC CX加JNZ的等价性但有一处不等价标志位。LOOP做的事情只有两件CX 减一、判断是否为零全程不动标志寄存器。DEC CX则会更新 ZF、SF、PF、AF、OF。为什么不改标志这么重要举一个具体场景。循环体内部做比较比较结果保存在 ZF 里然后循环结束——如果你用DEC CX / JNZ收尾DEC会先把 ZF 覆盖掉你原来比较出来的结果就没了。而用LOOP收尾ZF 原封不动出了循环还能接着JE、JNE这就是差别。再比如循环体里有CMP [SI], AL配合LOOPE做查找如果LOOPE换成DEC CX加JNZ那DEC在LOOPE的位置上会先动标志LOOPE赖以判断的 ZF 就不可靠了。这类 bug 特别隐蔽程序不一定崩但结果不对且很可能在不同机器上表现不一样。所以我的经验是只要循环靠 CX 计数且循环体内有依赖标志位的比较运算一律优先用LOOP收尾。只有在循环体超长导致LOOP够不着或者需要换寄存器计数的时候才退而求其次用DEC加JNZ并且在循环出口处重新做一次判断不要指望标志位能活下来。3. 完整实操从求和程序到 DEBUG 单步验证3.1 环境与源程序骨架这一节用一个数组求和的完整程序把前面讲的点串起来。程序在 DOS 实模式下跑需要一个 8086 汇编器和 DOSBox 之类的兼容环境。我平时用的是 MASM 5.0 配合 DOSBox源文件后缀.asm编译链接之后生成.exe用 DEBUG 加载调试。程序需求很朴素内存里放六个 16 位整数用循环把它们加起来结果存到内存变量里。选这个例子是因为它结构干净循环体只有两条指令位移量小适合先把手感建立起来再上复杂的。DATA SEGMENT ARRAY DW 12, 34, 56, 78, 90, 11 COUNT EQU 6 TOTAL DW 0 DATA ENDS STACK1 SEGMENT STACK DW 64 DUP(0) STACK1 ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA, SS:STACK1 START: MOV AX, DATA MOV DS, AX MOV CX, COUNT ; 计数器初值 元素个数 LEA SI, ARRAY ; SI 指向数组首地址 XOR AX, AX ; 累加器清零同时清掉 CF NEXT: ADD AX, [SI] ; 累加当前元素 ADD SI, 2 ; 指向下一个字 LOOP NEXT ; CX 减一非零继续 MOV TOTAL, AX ; 结果落地 MOV AH, 4CH INT 21H ; 返回 DOS CODE ENDS END START程序骨架里有几个细节值得掰开说。MOV AX, DATA / MOV DS, AX这两行不能省也不能写成MOV DS, DATA——8086 不允许直接把段地址立即数送进段寄存器必须借道通用寄存器。这是实模式汇编的入门级坑第一节课基本都会踩一次。XOR AX, AX比MOV AX, 0短一个字节而且是公认的清零惯用法。这里还顺带把 CF 清了为后面可能的进位运算留下干净状态。ADD SI, 2这一行在纯 8086 上会被汇编成81 C6 02 00四个字节。因为 8086 的加法立即数指令只有 16 位立即数的编码形式80286 之后才引入了 8 位立即数的紧凑格式。如果你想让代码更短可以写成两条INC SI各占一字节省两个字节但执行时间反而更长——两条指令的取指和译码开销抵不过一次四字节指令。这个取舍在现代 CPU 上意义不大但在老机器上曾经是值得争论的话题。3.2 逐条拆解求和循环从NEXT标号开始的循环体只有三条指令把执行过程走一遍。初始状态CX 6SI 指向 ARRAY 首地址AX 0。第一轮ADD AX, [SI]把 12 加进 AXAX 变成 12ADD SI, 2让 SI 前进两个字LOOP把 CX 减到 5非零跳回NEXT。第二轮加 34AX 变成 46CX 减到 4继续。依此类推第六轮加 11AX 得到最终的和 281然后 CX 减到 0LOOP不跳转落到MOV TOTAL, AX。六个数字手算一遍12 34 46 56 102 78 180 90 270 11 281。程序跑出来的TOTAL应该是 0121H因为 281 的十进制转十六进制是 0119H——等一下281 256 25 100H 19H 119H。对0119H。这个数字在 DEBUG 里可以用D DS:0直接看数据段验证。数组占 12 个字节TOTAL紧跟在后面偏移是 000CH值是19 01小端序低字节在前。看到这个序列程序就对了。有个地方容易出问题ADD SI, 2的步长必须和数据类型匹配。数组是DW定义的每个元素两个字节所以步长是 2。如果定义成DB步长就得改成 1如果写错了读到的是相邻两个字节拼起来的数结果会莫名其妙。这类错误在调试时表现为结果看着像个正常数但就是不对而且往往前几个数还是对的后面才开始飘。提示累加类程序的经典自检方法是拿第一个元素单独跑一次——把 COUNT 改成 1看结果是不是等于第一个元素。如果是说明寻址和累加逻辑没问题再把 COUNT 改回去。这个手法能把逻辑错和步长错快速区分开。3.3 用 DEBUG 盯住 CX 的每一次变化代码写完编译通过不等于程序是对的。汇编这门东西静态看代码只能发现语法错误逻辑错误全靠调试器抓。DEBUG 的三个命令足够应付绝大多数循环问题R看寄存器T单步G带断点运行。先用T命令走几步看看 CX 的变化节奏-r AX0000 BX0000 CX0006 DX0000 SP0100 BP0000 SI0000 DI0000 DS0B2A ES0B2A SS0B37 CS0B2A IP0010 NV UP EI PL ZR NA PE NC 0B2A:0010 B90600 MOV CX,0006 -t AX0000 BX0000 CX0006 DX0000 SP0100 BP0000 SI0000 DI0000 0B2A:0013 BE0C00 MOV SI,000C -t -t AX0000 BX0000 CX0006 DX0000 SP0100 BP0000 SI000C DI0000 0B2A:0019 0304 ADD AX,[SI] -t AX000C BX0000 CX0006 DX0000 SP0100 BP0000 SI000C DI0000上面是示意输出具体段值随环境变化但观察点很明确MOV CX, 6执行后 CX 是 0006ADD AX,[SI]执行后 AX 变成 000CH也就是十进制的 12正好是第一个元素此时 CX 还是 0006因为还没执行到LOOP。继续T到LOOP那条指令再走一步CX 就会变成 0005IP 跳回NEXT。真正高效的调试方式是G加断点。在LOOP指令的地址上下断点反复G每停一次看一次 CX能在一分钟内确认循环次数对不对-g 001f AX002E BX0000 CX0006 DX0000 ... -g 001f AX005C BX0000 CX0005 DX0000 ...注意LOOP停在断点上时CX 还没被递减所以第一次停在断点看到的是 6第二次是 5。这个停在断点上看到的是递减前的值的细节第一次用断点调试循环的人几乎都会困惑一下。我在教别人调试循环的时候一般会让他们做一件事把 CX 的每次变化抄在纸上六次一轮和手算的累加过程对一遍。听起来笨但比对两三遍之后对先减后判的理解就从概念变成肌肉记忆了。3.4 嵌套循环里 CX 的保护单层循环用 CX 天经地义一进嵌套就麻烦——CX 只有一个内外两层都要计数怎么办三种方案各有适用场合。方案一压栈保护。外层进内层之前PUSH CX内层跑完POP CX再执行外层的LOOP。这是最直白、最不容易出错的做法。MOV CX, 5 ; 外层次数 OUTER: PUSH CX ; 保护外层计数 MOV CX, 4 ; 内层次数 INNER: ; 内层循环体 LOOP INNER POP CX ; 恢复外层计数 LOOP OUTER压栈的代价是两次内存访问配上栈操作本身的指令开销在紧凑的内层循环里不算便宜。但它的好处是绝对可靠不用担心内层代码有没有动过 CX。方案二换寄存器。外层用 CX 走LOOP内层用 DI 或 BX 配合DEC / JNZ两个计数器互不干扰。MOV CX, 5 ; 外层 OUTER: MOV DI, 4 ; 内层 INNER: ; 内层循环体 DEC DI JNZ INNER LOOP OUTER省掉了压栈弹栈但多了DEC的指令开销而且DEC DI会修改标志位。内层循环依赖标志位的场合要小心。方案三计数器放内存。用一个DW变量当内层计数器DEC加JNZ直接操作内存。速度最慢但寄存器压力最小适合内层循环体已经把寄存器用得七七八八的情况。三种方案怎么选我的判断标准是看内层循环体的长度和调用频率。内层循环体短、跑得次数多选方案二内层循环体长、逻辑复杂选方案一求稳寄存器实在不够用了才考虑方案三。嵌套层次超过两层的时候建议直接把计数器都放内存代码可读性比那点性能重要得多。三层嵌套的压栈弹栈顺序一旦写错出错现场会非常难查——程序不崩只是结果不对而且只在特定输入下才不对。4. 常见问题与排查技巧实录4.1 初值给 0跑出 65536 次这是LOOP最经典的坑没有之一。CX 初值为 0 的时候LOOP并不是一次都不循环而是循环 65536 次。原因在先减后判这四个字。CX 等于 0执行LOOP先减一0 - 1 在 16 位无符号数里等于 FFFFH也就是 65535不等于零于是跳转。接下来每次减一要走 65535 次才能回到 0。加上第一次总共 65536 次。这个坑在两种场景下最容易触发。第一种循环次数来自外部输入。比如从键盘读一个数字当循环次数用户输入 0程序就陷进去了。这种情况下必须用JCXZ在循环入口前拦一道MOV CX, [COUNT] JCXZ DONE ; 次数为 0直接跳过 NEXT: ; 循环体 LOOP NEXT DONE:第二种循环次数是算出来的差值。比如从地址 A 到地址 B 复制数据长度等于 B 减 A。如果 B 和 A 相等长度是 0LOOP就开始空转。这类运算的结果最好在进循环之前用CMP CX, 0加JE判一下。注意JCXZ只是在入口判一次如果 CX 是在循环体内部被改到 0它救不了你。所以这道防线只对初值为零有效。还有一种情况更隐蔽CX 的高位被别的东西污染了。比如你本来只想用 CL 存个值结果上面某条指令动了 CHCX 整体变成 0x0500 之类LOOP就会跑 1280 次。排查这类问题的方法是单步到LOOP之前用R命令看 CX 的完整 16 位值而不是只看低字节。4.2 循环体里偷吃 CX 的指令CX 被循环体内部的指令改掉是第二大高频问题。表现形式有两种一是循环次数变多程序耗时明显拉长二是循环次数变少结果只算了一部分。前面提过的三份兼职就是三大嫌疑人。REP前缀最狠。REP MOVSB、REP STOSB执行完之后CX 一定变成 0。如果你的外层循环计数器也在 CX 上内层一个REP就把外层归零了外层循环立刻结束程序看起来跑完了但结果只处理了第一趟。变量移位次之。MOV CL, 3加SHL AX, CL这种写法会把 CX 的低字节改掉。如果计数器只用 CL 的一部分且移位次数很小可能表现成循环多跑了几次不容易第一时间发现。除法最隐蔽。DIV CX执行之后CX 本身不变但整个运算把 AX 和 DX 都覆盖了。如果你的累加器在 AX 上除完就没了。虽然不直接改 CX但和 CX 相关的运算链一起被破坏症状同样表现为数算错了。排查这类问题我一般用二分法先把循环体里的指令注释掉一半看 CX 是否稳定稳定就说明问题在被注释的那一半然后继续二分。比逐条读代码快得多。4.3 跳超出范围LOOP 汇编不过循环体写长了LOOP的 8 位位移字段装不下汇编器直接报错。这个错误很好认换一种写法就能过去NEXT: ; 很长的循环体 DEC CX JNZ NEXTJNZ用的是 8 位位移跟LOOP一样有 ±128 的限制换成它不一定解决问题。要彻底摆脱距离限制得用JMP类的近跳转配合条件判断NEXT: ; 很长的循环体 DEC CX JZ EXIT JMP NEXT EXIT:JMP是近跳转16 位位移能覆盖整个段。缺点是每次循环多一条指令代码可读性也差一点。改写的时候要留意标志位。原来是LOOP不碰标志换成DEC CX之后标志被改了。如果循环体里最后一次比较的结果需要在循环外继续用得在DEC之前先把结果保存进变量或寄存器否则出了循环再判断就是错的。还有一种情况是循环体里嵌套了另一个LOOP内外层加起来超了 128 字节但每一层单独看都没超。这时候汇编器可能只报外层有问题实际是内外层共同的位移和超限。解决思路一样把内层改成DEC / JNZ先给外层腾出空间。4.4 问题速查表把上面这些踩过的坑整理成一张表出问题的时候按症状对号入座比从头读代码快。现象可能原因排查动作处理办法程序卡住不动CX 初值为 0跑 65536 次在循环入口前看 CX入口加JCXZ或先判零循环次数比预期多CX 低字节被CL移位改掉单步到LOOP前看完整 CX换寄存器存移位次数循环提前结束循环体内REP前缀消耗了 CX检查循环体里的串操作PUSH CX/POP CX保护累加结果只算了一部分循环体内子程序改了 CX检查CALL前后 CX 值调用前压栈返回后弹栈汇编报Jump out of range循环体超过 128 字节数一下标号到LOOP的距离改DEC CXJNZ或JMP循环后条件判断失效用了DEC收尾ZF 被覆盖看循环末尾的指令改回LOOP或保存标志嵌套循环内层次数乱内外层共用 CX 未保护看内层入口有没有PUSH CX压栈或换寄存器数组求和结果偏差指针步长与元素宽度不匹配检查ADD SI, n的 nDW用 2DB用 1这张表不是穷举但覆盖了我这些年实际遇到的八成场景。真正遇到表上没写的问题通用的排查顺序是先看 CX 初值再看循环体里有没有指令碰 CX最后看标志位有没有被意外覆盖。这三步走完问题基本就浮出来了。5. 进阶块复制、循环展开与 REP 前缀5.1 手写块复制循环数组求和是热身块复制才是循环真正的日常用途。需求简单把源地址的一片数据搬到目标地址。MOV CX, LENGTH ; 要搬的字节数 LEA SI, SRC LEA DI, DST COPY: MOV AL, [SI] MOV [DI], AL INC SI INC DI LOOP COPY四条指令的循环体跑一次搬一个字节。这段代码约十一个字节LOOP的位移量是 -9 左右在范围内能正常汇编。这里 SI 和 DI 的分工是约定俗成的SI 是源索引DI 是目标索引很多串操作指令默认就用这两个寄存器。现在手写循环用它们后面换成REP MOVSB的时候也就是把整个循环体换成一条指令不用改寄存器约定。块复制有个经典陷阱源和目的区域重叠。如果目标区域在源区域的低地址方向从前往后复制会把还没读的源数据覆盖掉。这时候必须改成从高地址往低地址复制也就是指针先指向末尾每次减一。这个方向问题在内存管理类的程序里出现频率不低而且症状很怪——复制出来的数据有一部分是对的另一部分是错的看着像随机损坏。5.2 REP 前缀把 CX 交给硬件REP MOVSB这条指令把上面整个循环体压缩成了一行而且速度更快原理就在 CX 身上。REP的本质是一个硬件级的循环控制器。它做的事检查 CX 是否为零为零就退出不为零就执行一次后面的串操作指令CX 减一然后重新检查。整个过程不需要 CPU 反复取指译码效率比软件循环高出一截。CLD ; 方向标志清零地址递增 MOV CX, LENGTH LEA SI, SRC LEA DI, DST REP MOVSB四条有效指令替代了七条机器码也短了。不过用REP有几个前提条件必须满足。第一方向标志要明确。CLD是地址递增STD是地址递减。如果之前某个操作把 DF 设成了 1而你又没清掉REP MOVSB就会从高地址往低地址搬结果和预期相反。我一般习惯在每条REP之前都写一句CLD虽然多数时候是多余的但省得排查。第二SI 和 DI 的初值要落在正确的段里。MOVSB默认用 DS:SI 做源、ES:DI 做目的ES 段寄存器必须提前设好否则会写到一段莫名其妙的内存里去。这一点跟手写循环不一样——手写循环用MOV [DI], AL的时候DI 默认也是走 DS除非显式加段前缀。这个默认段的前后差异是初学REP时最常撞的墙。第三CX 一定是次数。REP执行完 CX 归零做完之后想用原来的长度得提前备份。注意REP MOVSW一次搬一个字比MOVSB快一倍前提是长度是偶数、源和目标地址都是字对齐的。长度是奇数的时候得单独处理最后一个字节别图省事。5.3 循环展开与效率取舍循环展开是老派优化手段思路是把多次迭代的循环体并排写出来减少循环控制指令的开销。原来要执行 N 次LOOP展开四次之后只执行 N/4 次剩下的开销就是四份循环体。MOV CX, LENGTH SHR CX, 1 ; 每次搬两个字次数减半 LEA SI, SRC LEA DI, DST COPY: MOV AX, [SI] MOV [DI], AX ADD SI, 2 ADD DI, 2 LOOP COPY这个例子里循环体翻了一倍但控制指令的执行次数减半。在老机器上循环控制指令的取指译码开销占比不小这种改写能有实实在在的速度提升。但现在要不要这么做得看具体场景。现代处理器有分支预测和指令缓存LOOP这类短小的循环控制指令很可能被预测器提前处理掉展开带来的收益未必明显反过来代码体积变大会挤占指令缓存在某些场景下反而更慢。所以我的原则是除非实测发现这个循环是热点否则不主动展开。可读性的损失是实打实的收益却要打了问号。真要追求块复制的极致性能现代的做法是用单指令多数据扩展一次搬十六个甚至六十四个字节那已经是另一套指令体系了跟 8086 的LOOP不在一个层面上。就 8086 这个平台本身而言这几个方案的性能排序大致是REP MOVSW快于手写MOVSW循环手写MOVSW循环快于MOVSB循环LOOP收尾略快于DEC / JNZ收尾。差距在具体数字上不大但在紧密的内层循环里累积起来能看出来。我自己在写这类程序的时候习惯是先用最直白的LOOP版本把逻辑跑通确认结果正确再考虑要不要换REP或者展开。顺序不能反——先优化后正确等于给自己挖坑出了错都不知道是逻辑问题还是优化引入的问题。实际动手写下来我对LOOP和 CX 这套机制的体会是它们的设计非常经济一条两字节的指令承担了循环控制、计数递减、条件判断三件事代价是把计数器和一大堆指令绑在了一起。这个设计在资源紧张的年代是聪明的取舍放到今天就变成了要小心绕开的耦合。所以我现在写汇编只要循环体超过五行就先把 CX 的使用情况在纸上画一遍标出哪些指令会碰它、哪些子程序可能动它画完再动手。这个习惯帮我省掉的调试时间比任何优化技巧都多。真要说有什么值得反复强调的那就是初值为零这一条——JCXZ多写一行能省掉一下午。