ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Debug下所有年份都成闰年?汇编LOOP循环CX寄存器未初始化陷阱解析

Debug下所有年份都成闰年?汇编LOOP循环CX寄存器未初始化陷阱解析

简介:面向北京交通大学“汇编与接口”课程的一份Debug调试实验文档,以leapYear.exe闰年判断程序为例,完整呈现汇编程序从编译、链接到运行调试的流程。文档从整体逻辑与逐句逐段分析两条线展开,既说明闰年判定规则(能被4整除但不能被100整除,或能被400整除)的编程实现,又展示在Debug中观察是/非闰年输入时的寄存器变化与输出结果,并介绍D显示内存、T单步执行、C继续运行、P执行指令等常用调试命令。针对实验中可能出现的判断条件不准、输入处理不当等问题,还给出了定位思路与修改示范。资源以1个docx格式文档打包,大小78.94MB,目录涵盖编译运行、代码分析、Debug实践和错误修改四大部分,便于按步骤对照上机操作。目前已有521人在CSDN平台学习下载,适合作为微机接口课程实验的参考笔记及汇编调试入门资料。

1. Debug 一跑就翻车:leapYear.exe 直接运行全对,进 Debug 后所有年份都变成闰年

某高校的汇编与接口课程里,有一道经典的 Debug 调试实验:用 masm5 编译链接一个 leapYear.exe,判断输入的年份是不是闰年。程序在 DOS 下直接运行,输入 2020 输出闰年,输入 2026、2025、2100 都正确输出不是闰年,看起来完美收工。可一旦用 debug 加载它,再用 G 命令跑,怪事就来了:输入 2020 显示闰年还能接受,输入 2021 依然显示闰年,更离谱的是不管输什么四位数年份,结果全是闰年。这种“直接运行正常、调试环境才出错”的故障最折腾人,第一反应往往是调试器坏了,实际是一个寄存器初始化的隐含 bug。下面把整个定位过程拆开讲,正被 Debug 单步卡住的初学者和想搞懂 LOOP 与 CX 关系的朋友,都能照着查一遍。

2. 根因:MOV CL,[BUF+1] 只改低位,CH 残留让 LOOP 多循环了 0104H 次

2.1 先看懂 DATACATE 子程序:字符转数字的“乘十累加”

leapYear.exe 的输入输出逻辑不算复杂。主程序先输出 “Please input a year:”,调用 DOS 的 0AH 号功能把键盘输入的字符串读到缓冲区 BUF,然后调用 DATACATE 子程序,把字符串年份转成十六进制数存进 W,再调用 IFYEARS 子程序做闰年判断,最后根据 CF 标志位决定输出“是闰年”还是“不是闰年”。整个流程里,DATACATE 是最容易藏问题的一段。

缓冲区布局是:BUF 处放最大长度 8,BUF+1 放实际输入的字符个数,BUF+2 开始放字符本身。比如输入 2020,BUF+2 到 BUF+5 就是 “2”“0”“2”“0” 四个 ASCII 码。DATACATE 做的事,是每次取一个字符,减去 30H 得到数字,再把上一次的累加结果乘 10,加上新数字,循环四次得到 2020:

DATACATE PROC NEAR LEA SI, BUF+2 ; SI 指向输入的年份字符串 MOV BX, 0 ; BX 作为累加器,先清零 X3: MOV AL, [SI] ; 取一个字符的 ASCII 码 SUB AL, 30H ; ASCII 码减 30H,得到数字 0-9 MOV AH, 0 ; 把 AX 高位置 0,保证 AX 里是纯数字 XCHG AX, BX ; 交换,把累加值放进 AX 准备乘 10 MOV DX, 10 ; 乘数 10 MUL DX ; AX = AX * 10,结果存在 DX:AX XCHG AX, BX ; 乘完的结果换回 BX ADD BX, AX ; BX = BX * 10 + 新数字 INC SI ; 指针后移,处理下一位 LOOP X3 ; CX = CX - 1,CX 不为 0 则继续循环 MOV W, BX ; 最终年份送入 W RET DATACATE ENDP

这段代码的逻辑是标准的“乘十累加”:第一个数字进来时 BX 是 0,乘 10 还是 0,加上 2 得到 2;第二个数字进来,2 乘 10 加 0 得到 20,BX 里存的是 0014H;第三个数字进来,20 乘 10 加 2 得到 202;第四个数字进来,202 乘 10 加 0 得到 2020,也就是 07E4H。每一步都依赖上一次的累加结果,这也是后面排查时观察 BX 值变化的关键依据。

2.2 LOOP 的循环条件不是“低位为 0”,而是 CX 整体不为 0

问题就出在循环次数上。看主程序调用 DATACATE 之前的这一段:

MOV CL, [BUF+1] ; BUF+1 是实际输入的字符个数,送入 CL LEA DI, BUF+2 ; DI 指向缓冲区数据区 CALL DATACATE ; 调用子程序做字符转数字

这里只给 CL 赋值,没有给 CH 赋值。如果输入的是四位年份,CL 变成 04H,但 CH 保持进入程序之前的值。LOOP 指令的执行过程是:先把 CX 减 1,再判断 CX 是否等于 0,不等于 0 就跳转到目标标号。注意,这里的 CX 是完整的 16 位寄存器,不是只看 CL 低 8 位。哪怕 CL 已经减到 0,只要 CH 不是 0,CX 整体就不是 0,循环就会继续。

这是新手最容易踩的认知误区。很多人把 LOOP 理解成“CL 减到 0 就停”,看到 DEBUG 里 CL 变成 00 就以为循环结束了,实际上 LOOP 判断的是 16 位的 CX。DATACATE 的累加器 BX 是 16 位,乘 10 累加的结果一旦超过 FFFFH 就会溢出,只保留低 16 位。2020 的十六进制是 07E4H,如果继续按“20200、202000……”的节奏累加,BX 很快就会溢出丢失高位,最后得到一个看起来毫无规律的值。实验中观测到循环结束后 BX = 71B0H,就是这个溢出截断后的残留。

2.3 根因定位:直接运行时 CX 恰好为 0,Debug 下 CX 被调试器占用

为什么直接运行不出错,进 Debug 用 G 命令运行就出错?答案在 CX 的初始值上。DOS 加载一个普通的 .exe 程序时,会按程序头的设置初始化寄存器,CX 一般被置为 0,所以 MOV CL,[BUF+1] 之后 CX 高 8 位仍然是 0,CL 变成 04H,CX 整体就是 0004H,循环正好执行四次,结果正确。

而用 debug 加载 leapYear.exe 时,CX 存放的是文件长度等信息。实验中用 R 命令看到 CX 一开始就不是 0,IP 指向 0000,CS 段地址是 0C59H 这类和源程序里直接写的段地址完全不同的值。在这种环境下执行 MOV CL,[BUF+1],只会覆盖低 8 位,高 8 位保留着 debug 环境带来的残留值。假设 CH 是 01H,CL 是 04H,CX 整体就是 0104H,LOOP 要执行 0104H 次才停。DATACATE 里的乘十累加在得到正确的 2020 之后根本停不下来,继续循环 0104H 次,BX 值越过 FFFFH 溢出截断,最终定格在 71B0H。

整件事的根因就是一句话:代码在 DATACATE 循环前没有对 CX 高位清零,依赖了“CX 初始为 0”这个外部条件。直接运行时条件恰好满足,Debug 环境下条件被打破,于是同一个 .exe 表现出两种完全不同的行为。定位到这一步,修复方案已经很清楚了:在 MOV CL,[BUF+1] 之前加一条 MOV CH,0,保证 CX 的高位永远是 0。这个实验也提醒我们,汇编程序里所有寄存器的初始值都必须当作未知数处理,不能赌它恰好是 0。

3. Debug 实测:R/U/T/P/G/D/E 指令如何把 71B0H 之谜一步步拆穿

3.1 T 单步进入子程序:观察 AL 减 30H 的 ASCII 转数字过程

先用 debug leapYear.exe 加载程序,再用 R 命令查看寄存器,会发现 CX 一开始就不是 0。这说明 debug 环境确实改变了寄存器初值,和直接运行不是同一个起点。用 U 命令反汇编,能看到 CS:IP 指向的每一条指令地址,方便对照源程序。接下来输入 T 指令单步执行,T 指令的特点是遇到子程序会进入子程序内部,所以它能带我们走进 DATACATE 的每一行。

执行到 MOV AL,[SI] 时,AL 变成 32H,这是字符 “2” 的 ASCII 码。再执行 SUB AL,30H,AL 变成 02H,字符成功变成了数字。这个过程很直观:30H 是字符 ‘0’ 的 ASCII 码,任何数字字符减 30H 就得到它代表的数值。随后 MOV AH,0 把 AX 高位置 0,AX 变成 0002H,保证后续乘法和加法不会带入高位垃圾数据。通过 T 指令单步,这些细节全部暴露在寄存器变化里。

继续单步,能看到 XCHG AX,BX 交换累加值,MUL DX 做乘 10 运算,ADD BX,AX 完成累加。前两位处理后 BX 是 0014H,也就是 20;第三次循环后 BX 变成 00CAH,也就是 202;第四次循环后 BX 变成 07E4H,正好是 2020 的十六进制。到这里,DATACATE 的算法本身没有任何问题,问题只出在循环没有按时停止。

3.2 循环异常:BX 从 07E4H 一路涨到 71B0H

四次循环之后,按正常逻辑 LOOP 应该让 CX 减到 0 并跳出循环。但此时 CX 的高位 CH 还残留着 debug 环境带来的值,导致 CX 减了四次仍然不是 0。用 T 指令继续单步会陷入无限循环,按多少次都走不完。正确的做法是改用 P 指令,P 指令和 T 的区别在于,P 会一次性执行完当前指令,遇到 CALL、INT 等调用时不会进入内部,而是直接执行完整个调用返回。

用 P 指令跳出循环后发现,BX 的值已经变成了 71B0H,而不是正确年份。这时用 D 命令查看内存,能看到输入的数字已经放进缓冲区。实验里输入 2020 后,数据段偏移 0050-0053 的位置存着 32 30 32 30 一排 ASCII 码,说明输入环节没有问题,问题集中在 DATACATE 的循环次数上。71B0H 的十进制是 29104,这个数字和年份毫无关系,就是累加溢出后残留的低 16 位。

更关键的现象是:输入 2021,四次循环后 BX 是 07E5H,继续用 P 跳出循环,BX 还是 71B0H。用 2020 和 2021 两个不同的输入,最终得到同一个 BX 值,说明多出来的循环次数是固定的,由 CX 初值决定。年份的低 16 位在溢出之后被丢弃,所以不管输入是 2020 还是 2021,只要前几步计算一致,最终溢出的结果就一致。这就解释了 Debug 下“所有年份都显示闰年”:BX 都是 71B0H,IFYEARS 对同一个数做判断,结果当然一样。

3.3 E 命令篡改内存:证明 IFYEARS 判断逻辑本身没有错

为了确认问题只在 DATACATE、不在 IFYEARS,实验里用 E 命令直接把 W 的内容改成 07E4H。E 命令可以修改指定内存地址的数据,相当于人为给 W 写入了一个正确值。改完之后继续运行,程序正确输出 “This is a leap year!”。再把 W 改成 07E5H,也就是 2021 的十六进制表示,继续运行,程序正确输出 “This is not a leap year!”。

这一步验证非常重要:IFYEARS 子程序对 2020 判闰年、对 2021 判非闰年,行为完全正确。也就是说,闰年判断的算法没错,错的是喂给它的数据。通过 E 命令绕过 DATACATE、直接给 W 塞数据,我们把故障范围从整个程序缩小到 DATACATE 的循环控制,排查思路也变成了经典的二分定位法。Debug 的 E 命令在实际排错里非常有用,遇到可疑的子程序,先手动改掉它的输入看看后面是否正常,能快速圈定责任范围。

3.4 G 命令与 P 命令的分工:什么时候该用哪个

整个调试过程中用到的指令分工,值得单独记一下:

指令作用是否进入子程序/中断
R查看/修改寄存器否
U反汇编显示指令否
D查看内存数据否
E修改内存数据否
T单步执行进入
P单步执行不进入
G运行到指定地址不进入

T 和 P 的选择是一个常见困惑。需要进入子程序看内部每一行时用 T;需要一步跨过整个子程序时用 P。在 LOOP 循环里,如果确定问题出在循环次数上,用 T 在里头死磕反而浪费时间,用 P 一口气执行完循环,直接看跳出后的寄存器结果,效率更高。G 命令还可以配合地址参数使用,比如 G 0018 表示运行到偏移 0018H 处暂停,实验里就用它停在输出语句附近,确认跳转方向正确。

实验里除了这些,还把 F、A、H、Q 都过了一遍。F 可以把一串内存填成相同数据,A 可以直接在 DEBUG 里写一小段汇编,H 用于计算两个十六进制数的和与差,Q 退出调试器。这些指令单独练起来不难,但真正理解它们各自的服务场景,是在这种追一个 bug 追到寄存器层面的过程里。

4. 汇编 Debug 避坑:5 条新手最容易踩的调试误操作

4.1 T 指令进入 INT 21H 中断后卡死

现象:主程序显示字符串和读键盘都要调用 INT 21H。用 T 指令单步,一旦执行到 INT 21H,就进入了 DOS 中断处理,输入 T 之后没有任何反应,甚至没法继续调试,像是程序“卡死”了。

原因:INT 21H 是 DOS 系统功能调用,T 指令遇到 INT 会进入中断服务程序内部。中断内部有大量我们并不关心的系统代码,单步进去不仅没有信息量,还容易因为缓冲区等待等原因卡住。

解决:遇到 INT 21H、INT 20H 这类软中断,改用 P 指令。P 指令会把整个 INT 调用当做一个步骤执行完,直接返回调用处,不会进入中断内部。以后调试任何带 DOS 功能调用的程序,都记住一个习惯:看到 INT 就按 P,不要按 T。这条是这次实验里实打实踩出来的血泪经验。

4.2 P 指令不能进入子程序,想观察内部必须用 T

现象:想查看 DATACATE 子程序内部每一句的执行效果,用了 P 指令,结果程序一步就跳过了整个子程序,看不到 LEA SI、MOV BX,0 这些内部步骤。

原因:P 指令的设计语义就是“不进入调用”,CALL 一条指令执行完,直接停在 CALL 的下一条。它和 T 正好相反,T 会进入 CALL 指向的子程序,P 不会。

解决:看子程序内部用 T,跨过子程序用 P。两者没有谁更好,只有场景合不合适。判断当前用哪个,就看你想不想要子程序内部的细节。另外,从循环里跳出来时 P 很管用。实验里 LOOP 因为 CH 残留变成死循环,用 T 永远走不到头,用 P 一下跳出循环,立刻看到了溢出的 BX 值。

4.3 用 R 命令看寄存器,别只看低 8 位就下结论

现象:调试中看到 CX 显示 0104H 之类,某个瞬间 CL 变成了 00,于是以为循环条件已满足,可 LOOP 还在继续转。

原因:LOOP 判断的是 16 位 CX 寄存器整体是否为 0,CL 只是 CX 的低 8 位。调试器显示的 CX 是完整 16 位值,但人眼容易只盯住低位变化,忽略了高位残留。实验中 CX 高位不是 0,低位减到 0 后 CX 整体还是 0100H,循环继续执行。

解决:所有涉及计数器的循环,先用 R 命令把完整值记下来,再跟踪变化。看到 CX 显示类似 0104H,正确的解读是“循环还要执行 0104H 次”,而不是“CL 是 04H 所以四次”。如果发现循环次数不对,第一时间检查高位 CH,手动算一下 CX 初始值,别让“看着像 0”骗了你。

4.4 U 命令反汇编地址与源程序行号对不上

现象:用 U 命令反汇编,看到的地址偏移和源程序里标注的段地址、偏移对不上,怀疑自己看错了代码。

原因:DEBUG 加载 .exe 后,CS、DS 等段寄存器的值由程序加载时的内存布局决定,源程序里 ASSUME 伪指令只是告诉汇编器逻辑上怎么安排,实际地址以加载时为准。实验中 CS 从 0C59H 开始,IP 从 0000H 开始,和手工按段地址计算的绝对地址不是同一个数。

解决:不要试图把 U 命令的地址和源文件里的绝对地址一一对应,直接用 U 命令现场反汇编出来的指令序列做对照,看助记符和操作数是否一致。判断代码执行到哪一步,以反汇编显示的 CS:IP 为准。这个现象不是程序错误,是调试环境正常的内存重定位,理解之后就不会再被绕晕。

4.5 直接运行正常、Debug 才出错的程序,优先怀疑未初始化寄存器

现象:leapYear.exe 单独运行,2020、2026、2025、2100 全部判断正确;用 debug 加载后,用 G 命令运行,无论输入什么都输出闰年。

原因:这是调试环境与运行环境的寄存器初值不一致造成的。直接运行时 CX 恰好是 0,MOV CL,[BUF+1] 后 CX 高位还是 0,循环次数正确;debug 环境下 CX 被调试器占用,高位 CH 不为 0,循环失控。程序对未初始化高位的依赖,在正常运行时被偶然满足,在调试时暴露。

解决:遇到这类现象,第一反应应该是找哪个寄存器只写了部分。检查所有 MOV 到 8 位寄存器、而 16 位寄存器又没有先清零的指令。修复不复杂,在 MOV CL,[BUF+1] 前补一条 MOV CH,0 就彻底解决。从那以后我只要看到 8 位寄存器赋值用于循环计数,都会先确认另一半是否清过零,省掉很多无谓的怀疑。

5. 修复:加一条 MOV CH,0,重新编译链接后验证三组用例

5.1 最小改动:MOV CH,0 加在循环计数之前

定位到根因后,修改方案非常小。DATACATE 的循环由 CX 控制,而 CX 只有低 8 位被赋了值,高 8 位是环境残留。只要把高位清零,循环次数就完全由输入字符个数决定:

MOV CH, 0 ; 新增:将 CX 高位置 0,防止残留值干扰 LOOP MOV CL, [BUF+1] ; BUF+1 为实际输入字符个数 LEA DI, BUF+2 CALL DATACATE

这条 MOV CH,0 放在读 BUF+1 之前或之后都可以,只要在 LOOP 开始执行前完成高位清零即可。更稳妥的做法是直接用 MOV CX,0 先把整个 CX 清掉,再 MOV CL,[BUF+1],两条指令最直观。注意修改的是主程序中调用 DATACATE 之前的代码,而不是 DATACATE 内部。DATACATE 里的 LOOP 本身没有错,错的是它收到一个不干净的 CX。

这里也顺带解释一个常见疑问:为什么不直接改 LOOP 的判断条件?因为 LOOP 指令的语义在 8086 里是固定的,就是 CX 减 1、非零则跳转。它的设计前提是调用方必须保证 CX 是合法的循环次数,所以修复责任在调用方,不在 LOOP 本身。汇编程序里很多类似的坑,都是被调子程序假设调用方初始化到位,一旦假设被打破,行为就不可预测。

5.2 重新编译链接:masm 与 link 的完整流程

修改完源文件,需要重新走一遍编译链接流程。实验使用的是 masm5 工具包,先确认 masm5 文件夹在 C 盘根目录,然后进入该目录操作。编译用 masm 命令,汇编器读取 .asm,输出 .obj 目标文件;链接用 link 命令,链接器读取 .obj,输出 .exe 可执行文件:

cd C:\masm5 masm leapYear.asm link leapYear.obj

masm 过程中会提示是否生成列表文件、交叉索引文件等,按参考书要求生成 .lst、.crf 等文件,方便查看指令地址和符号对应关系。link 成功后,masm5 目录下会多出 leapYear.exe,之前的 .obj、.lst、.ref、.map、.crf 也都会保留。

提示:DEBUG 加载的是链接后的 .exe,不是 .asm 源文件。每次修改源文件后,必须重新编译和链接,再重新用 debug 加载新生成的 .exe,否则调试的仍是旧程序。

这里有一个调试实验特有的注意点:DEBUG 的 T、P、G 指令操作的是机器码层面的指令,和源文件的行号没有直接对应关系。所以修改完代码,确认生成新 exe 后,最好先用 U 命令看一眼第一条指令是否变化,避免调试时拿旧文件重复碰壁。

5.3 回归验证:闰年、非闰年、世纪年三组用例

修复后不能只看 2020 一个例子,要按闰年规则的边界条件做回归。闰年的完整规则是:能被 4 整除但不能被 100 整除,或者能被 400 整除。所以至少测三组:普通闰年如 2020,普通非闰年如 2021,以及能被 100 整除但不是 400 倍数的世纪年,比如 2100,再加上能被 400 整除的世纪年,比如 2000,这样四种情况全覆盖:

输入年份预期结果实际结果
2020闰年闰年
2021不是闰年不是闰年
2100不是闰年不是闰年
2000闰年闰年

在直接运行环境下,输入 2020 得到闰年,输入 2026 和 2025 得到不是闰年,输入 2100 得到不是闰年,结果全部正确。再回到 DEBUG 环境,重新加载修复后的 leapYear.exe,输入 2020、2021、2100,观察 LOOP 是否在四次后正常跳出。BX 的值应该精确停在 07E4H、07E5H 这些年份对应的十六进制数上,不会再往 71B0H 跑。调试环境和直接运行环境的结果一致,说明修复不依赖任何偶然的寄存器初值。

这里也补一句:E 命令修改内存的办法在回归测试里依然有效。如果怀疑 IFYEARS 对某个边界值的判断有问题,直接用 E 命令把 W 改成 1900H 这类值,快速验证,不必反复从键盘输入。把改内存和重新运行结合起来,能省下不少时间。

5.4 为什么直接运行正确也要修:防御性清零的习惯

有人可能会说:反正直接运行是对的,Debug 只是调试工具,不改也行吧。这个想法很危险。程序正确不能依赖于恰好没人用 Debug 跑它,更不能依赖 DOS 加载后 CX 恰好是 0。寄存器在程序入口处只有 CS:IP、SS:SP 等少数几个有确定含义,其余寄存器的值都属于未定义,不同操作系统、不同加载方式都可能不同。今天 Debug 环境下出问题,明天换了别的加载器可能又出问题。

所以防御性清零不是洁癖,是汇编编程的基本素养。所有用作计数、寻址、累加的寄存器和内存单元,使用前都要明确初始化。这个实验的精髓其实不在闰年判断算法,而在于展示一个隐蔽的初始化遗漏,如何让程序在特定环境下全线崩溃。修复后哪怕直接运行结果没有变化,也必须保留这条 MOV CH,0,因为它消除的是一个潜在的随机故障源。

6. 快速定位法:以后再遇到“只有 Debug 才出错”的程序,我这样查

6.1 先复现,再分辨是逻辑错误还是环境错误

遇到调试环境才出错的第一件事,不是改代码,而是把两种运行方式的现象都完整记录一遍:直接运行对不对、Debug 下哪个指令出错、出错时寄存器是什么值。如果两种环境结果不同,基本可以断定代码依赖了外部环境提供的某个初始值。这时候把注意力放在“环境给了什么、代码又没设什么”上,比盯着业务逻辑更有效。

6.2 寄存器快照比对:进调试器先看一遍完整值

我一般会在程序入口处先用 R 命令做一次寄存器快照,把 CX、DX、BX、SI、DI 五个寄存器抄下来。然后单步执行到可疑代码前,再做一次快照,两次对比,差值就是代码对环境初值的依赖量。leapYear 这个 bug 就是这样暴露的:入口处 CX 不是 0,DATACATE 之前只写了 CL,没动 CH,差异一眼就能看出来。

6.3 内存篡改验证:把可疑子程序“短路”掉

如果怀疑某个子程序产出的数据不对,用 E 命令直接修改它的输出,然后继续跑。改完后程序结果正确,责任就在这个子程序;改完后还是错,就继续往前查。实验里把 W 改对后 IFYEARS 判断全对,一下就把责任锁定在 DATACATE;再把 CX 用 R 命令强制改成 0000 再跑,同样得到正确答案,进一步确认了根因。这个办法比反复单步循环高效得多,尤其适合 LOOP 死循环这种没法用 T 指令走完的场景。

从那以后,我每次写汇编循环,都强制自己在 LOOP 前检查一遍 CX 高 8 位;每次进 Debug,第一件事就是 R 看寄存器快照。这两个习惯救了我不少次。这份实验材料把每一步寄存器变化都做了对照记录,跟着复现一遍,比死记指令效果好得多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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