☰
Debug下闰年判断为何出错?汇编寄存器初值与LOOP陷阱
2026/10/17 16:00:38 网站建设 项目流程

简介:这是面向汇编语言与微机接口初学者的Debug调试实验文档,源于北京交通大学相关课程,以leapYear.exe闰年判断程序为主线,完整覆盖编译、链接、运行、代码逻辑拆解、Debug单步跟踪和错误修复等环节。文档先说明汇编源程序经汇编器、链接器生成可执行文件的过程,再逐句分析闰年判定规则,并给出是闰年与非闰年两类输入的Debug运行现象,同时介绍D、T、P等常用调试指令的用途。针对实验中出现判断条件不准、输入处理不当等问题,文档还展示了修改源码、重新编译链接并验证结果的完整思路。压缩包内仅含1个docx文档,体积约78.94MB,内容按实验流程分节编写,目录清晰,便于按步骤对照上机。已有521人浏览学习,适合汇编初学者在课程实验前预习或课后复盘时使用。

1. Debug 下的「闰年魔咒」:为什么直接运行正确、进调试器却必然误判

这个实验程序本身平淡无奇:一个判断闰年的汇编程序,直接运行完全正常,2020 输出「是闰年」,2026、2100 输出「不是闰年」,判断全对。可一旦把它放进 Debug 里加载执行,无论输入哪一年,结果都稳稳停在「This is a leap year!」,连 2100 这种明显不是闰年的年份也判成闰年。同一个可执行文件,同样的机器码,为什么直接运行和 Debug 运行结果不一样?这不是玄学,而是一个被调试环境暴露出来的计数器 bug:MOV CL, [BUF+1]只改了 CX 的低 8 位,而 Debug 恰恰在 CX 里残留了与文件加载有关的现场值。要讲透这个现象,得把 ASCII 转数字、乘除法溢出回绕、LOOP 计数机制和 Debug 各命令的真实行为串起来看,这正是这份汇编与接口实验报告最有价值的地方。

2. DATACATE 逐位转换:减 30H 与乘 10 累加,字符串如何变成 0x07E4

2.1 缓冲区的前两字节:BUF+1 是长度,BUF+2 才是字符

先看输入是怎么收进来的。数据段里BUF的定义是三个连着的量:

BUF DB 8 ; 缓冲区最大容量:8 个字符 DB ? ; 实际输入字符数,由 INT 21H 填入 DB 8 DUP(?) ; 输入字符的存放区,从 BUF+2 开始

这是 DOS 的AH=10缓冲输入的标准布局。调用LEA DX,BUF; MOV AH,10; INT 21H之后,系统把用户敲入的字符逐个写入BUF+2往后,并在BUF+1回填实际读入的字符数。所以程序接下来写:

MOV CL, [BUF+1] ; 把实际输入长度送进 CL,比如 2020 就是 04 LEA DI, BUF+2 ; DI 指向第一个输入字符 CALL DATACATE

这里就埋下了整份实验最大的雷:MOV CL, [BUF+1]只写了 CX 的低字节。CL 确实是 04,但 CH 是什么状态,程序完全没管。当时 Debug 的 R 命令显示 CX 初值不为 0,后面 LOOP 恰好以整个 CX 为计数器,问题就从这里长出来了。

2.2 减 30H:ASCII 数字到二进制数字的第一次变身

DATACATE的转换核心只有三条指令:

MOV AL, [SI] ; 取一个字符,例如 '2' 的 ASCII 码是 32H SUB AL, 30H ; 减去 '0' 的 ASCII 码,得到数字 2 MOV AH, 0 ; 清空 AH,让 AX 变成干净的 0002H

ASCII 码表里 0 到 9 是连续的 30H 到 39H,减掉 30H 就得到真实的数字 0 到 9。这是汇编里把「字符数字」变成「数值数字」最经典的一条减法,所有手写 atoi 的底层都是这一下。

注意一个容易绕晕的点:内存里0C53:0050处存的是32 30 32 30,这是 2020 的 ASCII 形态,不是 2020 的数值。DATACATE 做的事情是把这段「文本 2020」转换成 16 位二进制整数,结果写成十六进制是07E4H,它恰好就是十进制 2020。有人会问:为什么不直接拼成2020H?因为由 4 个字符转变来的每一位数字只是「数字」,它们要按十进制位权加权求和,最终得到的是一个可参与算术运算的整数,而不是把四个字节排起来。

2.3 把 10 藏进 DX:MUL 与 XCHG 的寄存器倒腾

核心循环一共八条指令,很有 8086 时代的风格:

X3: MOV AL, [SI] ; 取一个 ASCII 字符 SUB AL, 30H ; 转成 0~9 MOV AH, 0 ; AX = 当前位数字 XCHG AX, BX ; 累加值 BX 换进 AX,让 MUL 能用 MOV DX, 10D ; 乘数 10 暂存 DX MUL DX ; AX = AX * 10,结果高 16 位进 DX XCHG AX, BX ; 乘完的结果换回 BX ADD BX, AX ; BX = BX + 当前位数字 INC SI ; 指向下一个字符 LOOP X3 ; CX-1,CX 不为 0 就继续 MOV W, BX ; 转换结果存入字变量 W

逐轮推演输入 2020:第一轮BX = 0*10 + 2 = 2;第二轮BX = 2*10 + 0 = 20;第三轮BX = 20*10 + 2 = 202;第四轮BX = 202*10 + 0 = 2020,即07E4H。这就是「每轮先乘 10 再加新位」的十进制左移累加。

为什么程序要 XCHG 两次,而不是直接MUL BX?因为MUL的隐式被乘数是 AX,结果固定落在DX:AX,AX 被占死了。累加值恰好存在 BX 里,所以必须先把 BX 换进 AX,乘完 10 再换回去,把腾出来的 AX 让给当前位的数字。乘数选 DX 也很有讲究:此刻 DX 空闲,MUL DX把 AX 乘上 10,高 16 位暂时扔进 DX,下一轮MOV DX, 10D会把它覆盖掉。

提示:MUL DX产生的是 32 位乘积DX:AX。DATACATE 只关心低 16 位的 AX,高位 DX 被直接丢弃。四位年份最大值 9999,远小于 65535,所以正循环里丢弃高位是安全的。

2.4 四位年份恰好塞进 16 位:为什么不需要考虑进位丢弃

W被定义为DW(字),16 位寄存器能表达的数值范围是 0 到 65535,容纳 0 到 9999 的四位年份绰绰有余。所以 DATACATE 选 16 位累加是经过考量的:位宽够用,又不必引入 32 位中间变量。真正危险的不是正常年份,而是第 5 章那个「循环多跑了 256 轮」的场景——16 位溢出回绕会把一切正常设计变成不可预期的结果。

3. IFYEARS 判断链:DIV 余数、CF 标志位与子程序返回协议

3.1 闰年规则的分支结构:先除 4,再看 100,最后求证 400

闰年的定义是「能被 4 整除,且要么不能被 100 整除、要么能被 400 整除」。IFYEARS 把判断拆成三段:先除 4,除不尽直接否定;能除尽再除 100,除不尽直接肯定;能整除 100 的,最后去求证能不能整除 400。这个判断链和高级语言里(year%4==0 && year%100!=0) || year%400==0是等价的两条分支路径,只是汇编里用跳转和标志位来表示。

IFYEARS PROC NEAR PUSH BX PUSH CX PUSH DX ; 进入子程序,先保存调用者现场 MOV AX, W ; 年份送入 AX MOV CX, AX ; 副本存进 CX,后面几轮除法还要再用年份 MOV DX, 0 ; DX 清零,为 16 位除法准备高字 MOV BX, 4 DIV BX ; AX = DX:AX / 4,DX = 余数 CMP DX, 0 JNZ LAB1 ; 余数非 0:不能被 4 整除,CF 清零后退出 MOV AX, CX MOV BX, 100 DIV BX ; AX = 年份 / 100,DX = 余数 CMP DX, 0 JNZ LAB2 ; 余数非 0:能被 4 整除但不能被 100 整除,是闰年 MOV AX, CX MOV BX, 400 DIV BX ; AX = 年份 / 400,DX = 余数 CMP DX, 0 JZ LAB2 ; 余数为 0:能被 400 整除,是闰年 LAB1: CLC ; CF = 0,表示不是闰年 JMP LAB3 LAB2: STC ; CF = 1,表示是闰年 LAB3: POP DX POP CX POP BX ; 恢复调用者现场 RET IFYEARS ENDP

3.2 DIV 的陷阱:DX:AX 是被除数,商与余数各归其位

DIV BX这条指令的语义常被初学者忽略:16 位除法拿DX:AX拼成一个 32 位被除数,除数是 BX,商回写到 AX,余数回写到 DX。所以每次做除法之前必须有MOV DX, 0,否则 DX 里残留的任意值都会让被除数变成一个巨大数字,商可能直接溢出。

三段除法里,程序每轮都把年份从CX里重新装回AX,因为上一轮 DIV 已经把 AX 变成了商,原始的W被破坏了。这里CX扮演的角色是「年份暂存器」:它最初在 PUSH 时保存的是调用者的 CX,进入子程序后先MOV CX, AX把年份副本放进去,退出前再 POP 换回调用者的值。寄存器在 8086 时代没有硬性的保存约定,子程序自己 PUSH 自己 POP,是当时最朴素的契约。

判断逻辑只需要看余数:CMP DX,0比较余数和 0,JNZ在「ZF=0」时跳转,JZ在「ZF=1」时跳转。对 4 取余非 0 → 否定;对 100 取余非 0 → 肯定;对 400 取余为 0 → 肯定。三条路径干净利落,没有嵌套的复杂分支。

3.3 CLC/STC 与三个 LAB 标号:把布尔结果塞进位标志位

这个子程序最值得琢磨的是它的返回方式:不用 AX 返回 0/1,不用内存变量传递,而是直接把进位标志 CF 当作返回值。LAB1 执行CLC把 CF 清成 0,LAB2 执行STC把 CF 置成 1。两个分支最终都汇到 LAB3,统一做 POP 恢复现场,再RET返回。

注意 LAB2 里没有JMP LAB3,而是写完整条STC之后自然落入 LAB3。这种「坠落」写法是汇编里的常见技巧:既然 LAB3 就是下一条指令,何必多一次跳转。但代价是 LAB2 与 LAB3 之间不能再插任何会改变 CF 的指令,否则返回值就被破坏了。POP 和 RET 不影响标志位,所以这里安全。

用 CF 传布尔值是有讲究的。CF 是 ALU 天然产出的状态位,一条 CLC 或 STC 就能赋值,成本极低;调用方只要一条JC就能按「CF=1 跳转」消费这个结果。对比用 AL 传 0/1,虽然也没多贵,但标志位方案省掉了一个寄存器,在当时寄存器极稀缺的环境里值得。这个设计取舍最后还要再展开。

3.4 主程序拿 CF 开奖:JC 跳转与字符串输出

主程序侧只有四句:

CALL IFYEARS JC A1 ; CF=1,跳到「是闰年」输出 LEA DX, N ; CF=0,加载「不是闰年」字符串 MOV AH, 9 INT 21H JMP EXIT A1: LEA DX, Y MOV AH, 9 INT 21H EXIT: MOV AH, 4CH INT 21H

JC A1是整条判断链的出口:CF 被 IFYEARS 设置好之后,主程序只读一位。LEA DX, 字符串配合AH=9的 DOS 中断输出以$结尾的字符串,这是 16 位时代最基础的 I/O。整个程序的数据流非常紧凑:输入字符串 → DATACATE 转成数值 → IFYEARS 判断 → CF 带回结果 → JC 分流输出。

4. Debug 的 T、P、G、D、E:单步、跳过、断点与内存手术的底层语义

4.1 T 与 P:步入中断的黑匣子,步过循环的边界

实验里最折磨人的操作就是单步调试时按 T 还是按 P。T 是 Trace,严格逐指令执行:遇到INT 21H就真的进入 DOS 的中断服务程序,屏幕瞬间变成一堆看不懂的中断处理汇编,再也回不到用户程序;遇到CALL DATACATE就钻进子程序内部;遇到LOOP只执行这一条指令,CX 不为 0 立刻又跳回循环体,看起来像「卡住出不来」。

实验记录里写得很直白:「中断后,输入 T 会进入中断,不能继续调试,而改用 P 指令不会进入中断,可以直接输出」「T 指令可以进入子程序,但是 P 指令不能进入子程序」。这正是 P 的本质:P 是 Proceed,语义是「把 CALL、INT、LOOP 这类整体当作一步执行完」。现代调试器里 Step Into 和 Step Over 的区分,就是从这对原始命令继承下来的。

输入年份那一步用了INT 21H,要读键盘、回显字符、等回车,内部流程极长,用 T 跟进去就是一头扎进黑匣子;用 P 则直接把整个中断调用走完,停在下一条指令。DATACATE 调用处同理,T 会带你进入逐句转数字的循环,P 则一口气执行完整个子程序,停到 CALL 的下一句。

4.2 R 与 U:先看清楚「现场」再动手

调试的第一步永远是 R 命令打印寄存器现场。实验里 R 显示的信息透露了两个关键事实:IP 指在 0000,代码段从 0C59 开始,而 CX 一开始并不为 0。后者就是陷阱所在——进入程序前,CX 已经被 Debug 加载器放入了与文件长度有关的信息,而不是像直接运行时那样是一个「干净」的初值。

U 命令用来反汇编,把内存里的机器码翻译成带地址的汇编指令。每一条指令的地址都被列出,这就为 G 命令提供了断点坐标。调试流程通常是:R 看现场 → U 反汇编定位 → T/P 逐句执行 → D 看数据。

4.3 D 与 E:内存的读与写

D 命令按段基址:偏移显示内存内容,右侧同时输出可显示的 ASCII 字符。实验里用D 0C53:0050看到32 30 32 30,正是「2020」的 ASCII 码被完整存储。这个地址的由头是:LEA DI, BUF+2之后 DI 变 0050,所以 BUF 起始在 004E,004E 存最大长度 08,004F 存实际长度 04,0050 开始才是输入的字符。地址与数据一一对应,这是汇编调试最基础的「眼见为实」。

E 命令则是内存手术刀:直接向指定地址写入你想要的值。实验里为验证 IFYEARS 后半段逻辑,用E把 W 的内容改成07E4H(2020 的十六进制)或07E5H(2021 的十六进制),绕开 DATACATE 直接跑判断链。这是一种极其高效的隔离手段:当怀疑某段逻辑时,把它的输入直接钉死成已知量,就能确认故障出在上游还是下游。

注意:E 改的是内存里的运行镜像,不是磁盘上的源文件。Quit 之后再进,修改消失。所以实验里可以放心做破坏性修改来验证猜想。

4.4 G 与发现现场:为什么「调试才出错」本身是线索

G 命令有两种用法:不带参数直接跑完整个程序,或者带地址G 地址运行到指定指令处暂停。实验里「使用 G 命令再进行一次循环」,就是配合 U 反汇编得到的地址,精确停在 LOOP 指令的返回处。

整个实验最重要的转折点是这句话:通过 Debug 的 G 指令运行程序出错,但只单纯运行该程序不会出错。同一个字节码,两种加载方式,行为不同。这逼出了一个结论:程序正确性依赖了「进入程序那一刻的寄存器初值」,而 Debug 恰恰改变了这个初值。现代软件工程里也有同样的场景——在调试器里跑得好好的、脱离调试器就崩,或者反过来;这类问题十有八九出在「未初始化状态」上。

5. 避坑:只清了 CL 没清 CH,LOOP 为什么跑了 260 次还收不住

5.1 2020 调试全程复盘:四次循环后,BX 为何从 07E4 变成 71B0

把 2020 放进 Debug 单步走,前四次循环完全正常:第一次循环后 BX=02,第二次后 BX=0014H(20),第三次后 BX=00CAH(202),第四次后 BX=07E4H(2020)。数值全部正确,但LOOP没有停。

T 单步按下去,观察 CX 的变化:CL 确实是 04,但每次LOOP是让整个 CX 减一,而 CH 还是 1。所以四次之后 CX 从0104H变成0100H,离 0 还很远。继续执行,SI 已经越过了输入的四个字符,开始读缓冲区预留的 8 个字节以及后面数据段的残留数据。这些数据是没有意义的垃圾字节,但 DATACATE 的循环体不管这些,照样「减 30H、乘 10、累加」。

用 P 步过整个循环后,BX 停在了71B0H。换成输入 2021,前四次得到的是07E5H,跑完整个循环,BX 居然也是71B0H。同一段程序,两个不同输入,收敛到了同一个终值,所以 CF 永远是 1,程序永远输出「是闰年」。

5.2 数学解释:10 的 256 次方把输入差异清零

这个收敛不是偶然。0104H等于 260,也就是说MOV CL, 04之后 CX 仍然是0104H,LOOP 要转 260 次。前 4 次处理真实输入,后 256 次全部在处理残留字节。16 位寄存器做加法、乘法,超出 65535 的部分自动回绕,等价于每一步都在做模 65536 运算。

把循环视作迭代式B(n+1) = 10 * B(n) + 字节值。两个不同年份在第 4 轮有微小差异 Δ,之后每一轮差分都放大 10 倍:Δ(n) = 10^(n-4) * Δ0。第 260 轮时,差分因子是10^256。因为10^256 = 2^256 * 5^256,而2^256显然被2^16整除,所以10^256模 65536 等于 0。也就是说,经过 256 轮之后,两个输入的初始差异被 16 位回绕彻底「抹平」,无论你输入什么年份,都会落到同一个终态71B0H。

这解释了实验里那句「对于四位的年份都相同」:后面的 256 轮迭代把前四位携带的信息全部清洗掉了。

5.3 直接运行没翻车,只是运气好:未初始化寄存器是潜伏雷

为什么直接运行 leapYear.exe 时一切正常?因为 DOS 直接加载并运行时,CX 在进入程序那一刻的残留值恰好没有拖累 LOOP——高字节为 0 或者路径上被某个环节清掉了。而 Debug 加载时,CX 放的是与文件加载有关的信息,MOV CL, 04之后 CX 仍然不是 4。

本质问题是:DATACATE 在把CL当成循环次数时,默认了CH一定是 0。这在高级语言里就是「使用了未初始化的局部变量」——语法上没错,行为全凭运行时环境赏脸。直接运行的「正确」掩盖了 bug,Debug 改写了环境,bug 才现形。这也是调试器的价值:它不只是定位问题,它还能用不同的初始状态把潜伏 bug 逼出来。

5.4 修复与验证:一行 MOV CH,0

修复只需要一条指令,在MOV CL, [BUF+1]之后补上MOV CH, 0,把 CX 的高字节显式清零。这样 CX 就等于 4,LOOP 恰好执行 4 次。更严谨的写法是直接用MOV CX, 0配合MOV CL, [BUF+1],或者干脆XOR CH, CH。实验报告选择保留原有逻辑、只修高位,体现了「最小改动」原则。

修改后重新编译、链接、运行:直接运行正常,Debug 里用 G 运行也正常,输入 2020 是闰年、2021 不是闰年。再用 E 命令把 W 改成07E4H或07E5H验证,判断链输出与预期一致,整个链路就此闭合。

5.5 排查速查:三条 Debug 时代的血泪经验

  1. 现象:Debug 下 G 运行结果与直接运行不一致。原因:程序依赖了寄存器初值,而调试器加载时改写了寄存器现场。解决:进入子程序前,把将要使用的寄存器完整初始化成 16 位全量,不要依赖环境「恰好干净」。

  2. 现象:LOOP 循环次数异常大、死活不退出。原因:只给 CL 或只给计数器的低字节赋值,高字节残留旧值,CX 实际是一个几百甚至几万的数。解决:用MOV CX, 次数或先XOR CX, CX再赋值,确保整个 16 位一致。

  3. 现象:T 单步跟到INT 21H后就再也回不来了。原因:T 会步入中断处理程序,而不是停在用户程序的下一条。解决:系统调用、子程序、循环用 P 跳过,T 只用于逐条查看普通指令。

6. 寄存器就是接口:从 CF 传布尔值到半寄存器更新的设计取舍

6.1 为什么选 CF 当返回值:一位就够,代价是把整个子程序绑死在标志位上

IFYEARS 没有把「是闰年」写成 AL 的 0/1,而是放在了 CF 上。这在今天看来有些反直觉,但在 8086 的资源约束下是合理选择:寄存器一共只有四个 16 位通用寄存器,传参和运算都在抢它们;而 CF 是一位状态位,由 CLC/STC 赋值,一条指令就能写入,调用方一条 JC 就能消费,完全不占用通用寄存器。

代价同样明显:CF 会被几乎所有算术指令修改,所以「置位之后到 RET 之前」这段路径必须严格控制,不能插入任何算术、比较、移位指令。LAB2 落到 LAB3 的设计就是为此服务的——STC 之后紧跟着的只有 POP 和 RET,它们不碰标志位。这相当于一种隐式的调用约定:子程序返回的不只是「值」,还有「值在何时有效、在哪一位有效」的隐含语义。写汇编的人必须自己维护这套约定,没有任何编译器帮你检查。

6.2 半寄存器更新的别名陷阱:CL 与 CX 的边界

这次 bug 的根源是一个更底层的机制:8086 刻意把 16 位寄存器设计成两个可独立访问的 8 位半部。MOV CL, 04合法、高效、只有一个字节的立即数,但它只改了低半部,高半部 CH 保持着进入这条指令前的任何值。高级语言里char和short是不同类型,编译器会根据语义生成完整赋值;而在汇编层面,"CL" 就是「CX 的低 8 位」的别名,不存在所谓「类型安全」。

这批寄存器原本是为了兼容早期 8 位处理器而保留的半体访问能力,是 x86 长期向后兼容的活化石。它给开发者提供了自由度,也埋下了「局部赋值污染整体状态」的陷阱。凡是写过这种代码的人,迟早会在某个调试器的 R 窗口里看到 CX=0104H 这种事——不是程序写错了,是程序对「寄存器的另一半是谁在管」做了假设。

从那以后,我在汇编里写任何半寄存器赋值之前,都会强制先读一遍该寄存器的完整当前值,在注释里写清「高字节当前由谁负责、为何安全」;遇到「只有调试环境才出现的现象」,也不再先怀疑玄学,而是先 R 一下寄存器现场。如果你手头也有一份这样的实验报告,别只把它当作业——跟着它的反汇编、寄存器快照和修改前后对照,在 Debug 里完整重跑一遍,你对 LOOP、DIV、MUL 和标志位的理解会真正落地。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询