ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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 减到 0,LOOP不再跳转,循环结束。这个关系看起来很直观,但正因为太直观,很多人写代码的时候默认"我传进来 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(同LOOPZ)E1 cbCX 减一CX ≠ 0 且 ZF = 1查找循环,找到即停
LOOPNE(同LOOPNZ)E0 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的下一条指令,所以用"下一条指令的地址 + 位移量"才是跳转目标。

拿一段实际的代码来算。假设代码段起始偏移是 0000H:

0000 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 0009

LOOP指令本身位于 000DH,占两个字节,所以下一条指令的地址是 000FH。目标是 0009H。位移量 = 0009H - 000FH = -6,用 8 位补码表示就是 FAH。所以机器码是E2 FA,跟上面写的对上了。

反过来,如果你在 DEBUG 里看到一条E2 F9,怎么推目标地址?F9H 作为有符号数是 -7,假设这条指令在 000EH,下一条就是 0010H,0010H - 7 = 0009H。目标标号在 0009H。

这套计算没什么技巧,但必须会,因为调试的时候没有源代码,只有机器码,你得能从字节反推出跳转目标,才能判断"跳飞了"是跳到哪儿去了。

至于 ±128 字节的限制,这是 8 位位移字段的硬边界,跟标号写在哪没关系。循环体写长了,LOOP就够不着。这个坑后面 4.3 节有专门的改写方案。

注意:不同汇编器对LOOP短跳转越界的报错措辞不一样,MASM 一般提示Jump out of range,NASM 会说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 = 6,SI 指向 ARRAY 首地址,AX = 0。第一轮,ADD AX, [SI]把 12 加进 AX,AX 变成 12;ADD SI, 2让 SI 前进两个字;LOOP把 CX 减到 5,非零,跳回NEXT。第二轮加 34,AX 变成 46,CX 减到 4,继续。依此类推,第六轮加 11,AX 得到最终的和 281,然后 CX 减到 0,LOOP不跳转,落到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 AX=0000 BX=0000 CX=0006 DX=0000 SP=0100 BP=0000 SI=0000 DI=0000 DS=0B2A ES=0B2A SS=0B37 CS=0B2A IP=0010 NV UP EI PL ZR NA PE NC 0B2A:0010 B90600 MOV CX,0006 -t AX=0000 BX=0000 CX=0006 DX=0000 SP=0100 BP=0000 SI=0000 DI=0000 0B2A:0013 BE0C00 MOV SI,000C -t -t AX=0000 BX=0000 CX=0006 DX=0000 SP=0100 BP=0000 SI=000C DI=0000 0B2A:0019 0304 ADD AX,[SI] -t AX=000C BX=0000 CX=0006 DX=0000 SP=0100 BP=0000 SI=000C DI=0000

上面是示意输出,具体段值随环境变化,但观察点很明确:MOV CX, 6执行后 CX 是 0006;ADD AX,[SI]执行后 AX 变成 000CH,也就是十进制的 12,正好是第一个元素;此时 CX 还是 0006,因为还没执行到LOOP。继续T到LOOP那条指令,再走一步,CX 就会变成 0005,IP 跳回NEXT。

真正高效的调试方式是G加断点。在LOOP指令的地址上下断点,反复G,每停一次看一次 CX,能在一分钟内确认循环次数对不对:

-g 001f AX=002E BX=0000 CX=0006 DX=0000 ... -g 001f AX=005C BX=0000 CX=0005 DX=0000 ...

注意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 相等,长度是 0,LOOP就开始空转。这类运算的结果最好在进循环之前用CMP CX, 0加JE判一下。

注意JCXZ只是在入口判一次,如果 CX 是在循环体内部被改到 0,它救不了你。所以这道防线只对"初值为零"有效。

还有一种情况更隐蔽:CX 的高位被别的东西污染了。比如你本来只想用 CL 存个值,结果上面某条指令动了 CH,CX 整体变成 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 NEXT

JNZ用的是 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 CX+JNZ或JMP
循环后条件判断失效用了DEC收尾,ZF 被覆盖看循环末尾的指令改回LOOP,或保存标志
嵌套循环内层次数乱内外层共用 CX 未保护看内层入口有没有PUSH CX压栈或换寄存器
数组求和结果偏差指针步长与元素宽度不匹配检查ADD SI, n的 nDW用 2,DB用 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多写一行,能省掉一下午。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进