简介:北京交通大学汇编与接口课程配套的Debug调试实验文档包,围绕leapYear.exe闰年判断程序,完整记录汇编语言从编译、链接到运行调试的全过程。文档逐一展开代码逻辑分析,演示Debug工具中设置断点、单步执行、查看寄存器与内存状态,详解D、T、C、P等常用调试指令在闰年判断场景下的实际应用;同时针对程序可能存在的判断条件或输入处理错误,给出定位思路与修改后重新编译链接的验证方法。资源为单一docx文档,整体78.94MB,内容结构化,包含编译链接、逻辑分析、Debug调试、错误修改四大部分,适合正在学习微机接口、汇编语言或准备上机实验的学生对照操作与复习。该文档目前已有521人学习下载,对想掌握汇编程序底层调试方法、提升排错能力的读者具有实用参考价值。
1. 为什么一个运行正确的 leapYear.exe,进 Debug 一跑就翻车
我先说结论:这份实验资源真正的价值,不在于它教会你怎么判断闰年,而在于它把一个典型的汇编调试事故完整地摊在了你面前。我拿到 leapYear.exe 的源码时,按部就班地编译、链接、运行,输入 2020、2021、2100,结果全部正确。可一旦进入 Debug 环境执行,同样的代码在循环里怎么都出不来——最后在 BX 寄存器里躺着的不是年份,而是相同的 71B0H。换句话说,直接运行程序时压根不出现的隐患,在调试器里漏出了马脚,而这个马脚恰恰是汇编初学者最该学会定位的那类错误:寄存器高位残留。这份实验资源覆盖了 masm5 编译链接、DATACATE 子程序分析、IFYEARS 闰年判断分支逻辑,以及 Debug 单步调试的全过程,适合正在学《汇编与接口》的在校生,也适合想补齐底层调试能力、被 Debug 指令搞晕的从业者。
2. 编译链接与程序骨架:先跑通,再谈调试
2.1 手工编译四件套:masm、link 与交叉索引文件
实验第一步用的是 masm5 工具包,这是汇编课程里最常见的一套工具。把 masm5 文件夹放到 C 盘根目录后,进入该目录,依次执行编译和链接。注意实验顺序:先 masm 生成目标文件,再 link 生成可执行文件,中间不要跨过汇编直接去找 .exe。
cd C:\masm5 masm leapYear.asm link leapYear.objmasm 命令执行后会询问是否生成列表文件、交叉引用文件,直接回车可以按默认名生成,也可以输入具体文件名。这里有个细节:汇编器会依次生成 .obj、.lst、.ref、.map、.crf 等文件。.lst 是列表文件,能同时看到源码和对应的机器码,调试时对照它看反汇编结果很有帮助;.crf 是交叉引用文件,记录符号被引用的位置;.map 文件用于链接阶段查看段地址分配。我第一次做实验时只盯着 .exe 有没有生成,后来才意识到 .lst 和 .map 是排查代码段地址对应关系的重要参照。
链接成功后,masm5 文件夹中会出现 leapYear.exe。运行阶段直接输入年份,程序会输出对应结果。这个程序本身比较直白,但它为后面的 Debug 调试提供了完整的 16 位汇编程序骨架:数据段、堆栈段、代码段三段齐全,子程序调用、标志位判断、循环结构都有了。
2.2 代码结构:三个段、两个子程序、一条标志位判断链
leapYear.asm 的代码结构可以拆成下面的层次。数据段里定义了 INFON、Y、N 三个字符串,分别对应输入提示、是闰年、不是闰年;W 是一个字变量,用来保存转换后的十六进制年份;BUF 是输入缓冲区,第一个字节放缓冲区容量 8,第二个字节留给实际输入字符数,后面 8 个字节存放输入字符——这是 INT 21H 的 0AH 号功能要求的缓冲区结构。堆栈段分配了 200 字节,对这个小程序来说完全够用。
代码段里主程序做的事情是:先初始化 DS,然后用 LEA DX, INFON 配合 AH=9 调用输出提示字符串。注意这里为什么不能用 MOV DX, INFON 直接赋值:INFON 是一个偏移地址,16 位汇编里 MOV 指令只能传立即数或寄存器,符号地址需要用 LEA 或 OFFSET 来取。输入阶段用 AH=10 的 INT 21H,它会把键盘输入的字符串放进 BUF,并在 BUF+1 处写入实际输入字符个数。
主流程的跳转判断集中在 JC A1 这一句:IFYEARS 子程序通过进位标志 CF 返回判断结果,CF=1 时表示闰年,跳转到 A1 输出是闰年语句;否则顺序执行输出不是闰年语句。这种用标志位作为子程序返回值的写法在 16 位汇编里非常常见,也恰恰是理解整个程序的关键:IF YEARS 内部的 LAB1、LAB2、LAB3 三个标签分别承担 CF 清零、CF 置 1、恢复寄存器并返回的任务。看懂这个结构,就明白了整个程序的核心数据流——输入字符串 → DATACATE 转成十六进制数 → IFYEARS 判断 → CF 决定输出分支。
2.3 为什么直接运行正确,Debug 环境却要单独对待
这部分是实验中反复出现的现象。leapYear.exe 直接运行完全正确,但进入 Debug 用 G 命令跑就会出错。根本原因要从 8086 的寄存器初始化说起:DOS 加载程序时,BX、CX、DX 的值来自程序执行前的现场快照,程序本身并没有对它们做完整初始化。主程序里 MOV CL, [BUF+1] 只修改了 CX 的低 8 位,高 8 位 CH 是什么值取决于进入程序前的环境。直接运行时命令行环境一般能保证 CH=0,所以循环正常结束;但 Debug 加载程序时,CX 会被调试器用来传递文件长度等信息——在实验里,CX 高字节不是 0,等到 DATACATE 循环结束时 CX 没有归零,LOOP 就继续转,最终把缓冲区和周边数据都读进去。
这类「运行对,调试错」的案例,本质是程序没有主动初始化用到的全部寄存器位。汇编不像 C 语言会在函数入口自动处理这些,所有的寄存器状态都要自己负责。实验里写的 MOV CL, [BUF+1] 就是典型的半初始化:它对 CL 负责,却放任 CH,这在直接运行时靠运气撑过去了,在 Debug 环境里立刻现形。
3. DATACATE 子程序:字符串转十六进制的乘十累加与隐患
3.1 逐行执行背后的状态变化
DATACATE 子程序完成的是从 ASCII 字符串到十六进制字变量的转换。实验原文里最核心的就是这几条指令的组合:
DATACATE PROC NEAR LEA SI, BUF+2 MOV BX, 0 X3: MOV AL, [SI] SUB AL, 30H MOV AH, 0 XCHG AX, BX MOV DX, 10D MUL DX XCHG AX, BX ADD BX, AX INC SI LOOP X3 MOV W, BX RET DATACATE ENDP这段代码的循环思路是经典的「乘十累加」:每读取一个字符,先减去 30H(即字符 '0' 的 ASCII 码 48),得到一个 0~9 的十六进制数字;然后将 BX 里已有的部分结果乘以 10,再把新数字加进去。以输入 "2020" 为例:第一个字符 '2' 转成 02H,BX=2;第二个字符 '0' 转成 00H,BX=2×10+0=20,即 14H;第三个字符 '2',BX=20×10+2=202,即 0CAH;第四个字符 '0',BX=202×10+0=2020,即 07E4H。四次循环后 W=07E4H,与 2020 的二进制表示完全一致。
代码里的 XCHG AX, BX 值得单独说明。它做的是让 AX 与 BX 的角色在「保存部分结果」和「参与乘法运算」之间切换。MUL DX 是无符号乘法,默认操作数是 AX,结果是 DX:AX,所以先把 BX 里的存量结果换到 AX 去乘 10,再用 XCHG 换回来,最后用 ADD BX, AX 把新数字加进去。这样设计的原因很简单:MUL 指令只有 AX 这一个隐含的乘数寄存器,如果直接把 BX 参加乘法,需要额外的 MOV 指令,换两次反而代码更紧凑。
3.2 LOOP 的循环计数:CL 与 CX 的陷阱
实验里 Debug 单步调试时,观察到的关键寄存器变化是:进入 DATACATE 前,CX 的内容并不是 0004H,而是 0104H——低位确实是 4(输入了 4 个字符),高位是 01H。也就是说,MOV CL, [BUF+1] 执行后,CL=04H,而 CH 没有改动,保持 01H,整个 CX 是 0104H。LOOP 指令的循环条件是 CX=CX-1,判断结果是否为 0,而不是只有 CL 是否为 0。当 CX 从 0104H 开始递减,要经过整整 0x104 次(260 次)循环才能归零——这远超 4 个字符的读取范围,SI 早就越过了缓冲区,继续向后读取内存中的其他数据。
单步调试时你会看到,当 CL 已经递减为 0 时循环还在继续,因为 CH 还没减到 0。这直接导致 BX 中累加的结果远远超过四位数年份应有的值,最终停留在 71B0H,换算成十进制是 29104。这个值跟输入的年份无关,因为 260 次循环读到的数据是固定的,所以无论输入 2020 还是 2021,Debug 下跑到最后 BX 都是 71B0H。程序看起来「永远输出闰年」,实际上是循环失控后读取了固定内存区域的固定数据,导致结果雷同。
提示:8086 的 LOOP 指令操作对象是完整的 CX,不是低 8 位 CL。只要循环体内没有主动清零 CH,循环次数就可能超出预期。调试时如果发现循环次数莫名其妙变大,第一反应应该看 CX 的高位有没有残留。
3.3 修复方案与寄存器初始化习惯
实验给出的修复是在循环计数设置处加一句 MOV CH, 0,把高位显式清零:
MOV CL, [BUF+1] MOV CH, 0 ; 关键是高位置零,确保 CX = 实际输入字符数 LEA DI, BUF+2 CALL DATACATE修改后 CX 的初值就是 0004H,LOOP 执行四次后 CX=0,循环准确退出。补上这一句之后,无论是直接运行还是进入 Debug 用 G 命令运行,2020、2021、2100 的验证结果都一致了。
这个失误暴露了一个更普遍的寄存器使用原则:在 8086 汇编中,凡是要用 CX、DX、BX 这类多字节寄存器的一部分时,应当先假定另外一部分是不可靠的。尤其是 CX 的高位,它可能是程序环境里遗留的中断计数、文件长度或其他数据。最稳妥的做法是在程序开头把所有要用的寄存器整体初始化,即使只用低 8 位,也顺手把高位清零。从那以后我写汇编循环,只要遇到 LOOP,第一件事就是确认 CX 初值是不是可控的。
4. IFYEARS 闰年判断:除法指令与 CF 标志位的联动逻辑
4.1 闰年规则的汇编表达
闰年的数学定义是:能被 4 整除且(不能被 100 整除或能被 400 整除)。这个逻辑用伪代码写很简单,但翻译成 8086 汇编需要借助除法指令和标志位。IFYEARS 子程序的做法是层层判断:
IFYEARS PROC NEAR PUSH BX PUSH CX PUSH DX MOV AX, W MOV CX, AX MOV DX, 0 MOV BX, 4 DIV BX ; AX / 4,商存 AX,余数存 DX CMP DX, 0 JNZ LAB1 ; 不能被 4 整除 → 非闰年,CF 清零 MOV AX, CX MOV BX, 100 DIV BX ; AX / 100 CMP DX, 0 JNZ LAB2 ; 不能被 100 整除 → 能整除 4 → 闰年,CF 置 1 MOV AX, CX MOV BX, 400 DIV BX ; AX / 400 CMP DX, 0 JZ LAB2 ; 能被 400 整除 → 闰年,CF 置 1 LAB1: CLC ; CF = 0 JMP LAB3 LAB2: STC ; CF = 1 LAB3: POP DX POP CX POP BX RET IFYEARS ENDP这段代码的精髓在于用 CMP DX, 0 配合 JNZ/JZ 实现分支。DIV BX 执行后 DX 是余数,CMP DX, 0 等价于判断「余数是否为 0」,JNZ 是「余数不为 0 则跳转」,JZ 是「余数为 0 则跳转」。第一次判断如果不能被 4 整除,直接走 LAB1,放弃后面的判断,因为已经能确定不是闰年;如果能被 4 整除,再判断能否被 100 整除,不能整除则直接认定是闰年;如果能被 100 整除,还要继续判断能否被 400 整除,这里只有能被 400 整除的世纪年才算闰年。2100 年因为能被 100 整除但不能被 400 整除,最终落到 LAB1,CF=0,输出「不是闰年」,完全正确。
4.2 除法前的寄存器保护:PUSH 与 POP 的时序
DATACATE 循环失控的调试过程里,单步执行到 IFYEARS 时,观察到的寄存器变化很典型:一次 DIV BX 之后,AX、DX 的内容全变了,AX 变成商,DX 变成余数。所以进入 IFYEARS 的第一件事就是把 BX、CX、DX 压栈保护,因为后面还要用 CX 保存原始年份值。LAB3 里统一的 POP DX、POP CX、POP BX 是标准的先进后出顺序,次序不能乱。
我在这里踩过一个坑:如果 PUSH 的顺序是 BX、CX、DX,那么 POP 的顺序必须是 DX、CX、BX,这跟堆栈的后进先出是天然对应的。我曾在另一个程序里把 POP 顺序写错,结果 CX 和 BX 被互换了,程序表面没报错,但之后的计算全部错误,而且这种错误非常难定位。汇编实验里这种整段子程序的寄存器现场保护,最好一进来就写好,收尾严格对称。
4.3 MUL 与 DIV 指令的隐藏操作数
DATACATE 里的 MUL DX 和 IFYEARS 里的 DIV BX,都会隐式使用 DX、AX。MUL DX 执行后,DX:AX 存放乘积,但 DATACATE 的循环里 DX 被赋值为 10D,所以乘积高 16 位进 DX。对 2020 这类四位年份,乘积不会超过 16 位,DX 就变成 0,否则 DX 会被改写。DIV BX 则是 32 位除以 16 位:被除数默认是 DX:AX,商到 AX,余数到 DX。所以在执行 DIV BX 之前,必须保证 DX 是 0,这就是 IFYEARS 里 MOV DX, 0 的含义——它处理的是被除数的高 16 位,不是随便一个清零。
这两条指令都属于「隐含操作数」类指令,代码读起来只有显式写的那个寄存器,实际上 AX 和 DX 全都会被改。我在调试时看到 AX、DX 的值莫名其妙变化,往往会先想到是不是碰上了 MUL 或 DIV。如果不用这两条指令,手动实现乘法和除法会多出十几行代码,但两条指令的副作用必须铭记。
5. 避坑记录:我顺着 Debug 的脚印踩进去的五个坑
5.1 T 指令单步走进 INT 21H 中断后出不来的坑
- 现象:用 T 指令单步调试,执行到 INT 21H 中断调用时,按一次 T 步进了中断内部,后续每条指令都是中断处理代码,怎么走都回不到自己的程序。
- 原因:T 指令会步入被调用过程,而 INT 21H 本身能进入 DOS 内部,中断内部的指令不是你的代码段,之后的行为不可控。
- 解决:改用 P 指令执行 INT 21H 调用,P 指令会完整执行完整个中断调用并返回到下一条指令。我后来根据这个经验定了一个习惯:凡是遇到 CALL 和 INT,默认先用 P 而不是 T,除非我明确想进子程序内部。
5.2 LOOP 循环跳不出来的坑
- 现象:输入 2020 后单步调试,执行完四次 LOOP 后程序没有继续往下走,而是继续循环,SI 已经超过缓冲区末尾,BX 的值一路累加变成 71B0H。
- 原因:MOV CL, [BUF+1] 只设置了 CX 的低字节,高字节 CH 仍保留之前的值,LOOP 判断的对象是整个 CX 而不是 CL。Debug 环境里 CH 的初值不为 0,所以循环次数不是 4,而是 0104H。
- 解决:在给 CL 赋值后补一句 MOV CH, 0,或在循环前统一初始化 CX。验证时用 R 命令查看 CX 是否等于 0004H 后再启动程序。
5.3 程序直接运行正常,Debug 里一跑就出错的坑
- 现象:直接运行 leapYear.exe 输入 2020 和 2021 全部正确,但用 Debug 的 G 命令运行同一程序,任何输入都显示是闰年。
- 原因:直接运行时环境寄存器状态恰好让 CX 高字节为 0,循环次数正确;Debug 加载程序时寄存器快照不同,CX 高位残留导致循环失控,读取了内存里固定区域的数据,所以无论输入什么都得到同一个错误结果。
- 解决:不要因为直接运行正常就跳过 Debug 验证。汇编程序的正确性必须以两种环境下的结果都正确为准,尤其是涉及循环计数和标志位时。
5.4 用 E 命令修改内存绕过陷阱的技巧
- 现象:单步调试到 DATACATE 循环时发现 BX 结果不对,想直接看 IFYEARS 判断逻辑是否正确,却总是被循环卡住。
- 原因:已经确认问题出在循环计数,此时再反复单步循环纯粹浪费时间。
- 解决:用 E 命令直接修改 W 变量的内存值。比如把 W 改成 07E4H(2020)或 07E5H(2021),跳过 DATACATE,直接测试 IFYEARS 的判断分支。这是 Debug 调试中非常高效的「绕过」手段,问题定位聚焦在哪一段,就先验证哪一段,不需要每次从入口一路走到尾。
5.5 反汇编看到地址对应不上源码的坑
- 现象:运行程序后从 CS:IP 开始用 U 命令反汇编,看到的指令和源码顺序对不上,直接怀疑代码是不是写错了。
- 原因:U 命令默认从 CS:IP 开始,但程序里伪指令(如 PROC、ENDP、SEGMENT)不产生机器码,反汇编只有 CODE 段的有效指令;另外 .lst 文件里能看到的偏移地址与反汇编输出存在映射关系,不能拿源码行号直接对照反汇编地址。
- 解决:对照 .lst 文件看每条汇编指令对应的段内偏移,再用 U 命令指定具体地址反汇编。15 到 16 位汇编的地址是段:偏移形式,比如 0C53:0050,偏移量才是代码段内的有效位置,CS 的值每次加载可能不同。
6. 把 Debug 从「踩坑工具」变成「检查习惯」:我的固定验证流程
Debug 里值得反复用的指令组合其实就那几个,但组合思路比单条指令本身重要。我的固定流程是:用 U 命令反汇编确认代码段偏移,用 G 命令带地址参数直接跳到关键位置(比如 CALL DATACATE 的下一条指令),避免每次都从头单步按几十下;用 T 命令步进子程序内部观察寄存器变化,用 P 命令跨过 INT 21H 调用;用 D 命令查看 BUF 缓冲区和 W 变量区域,确认输入字符串的 ASCII 码到底落在哪里;最后用 R 命令查看 CX、IP、标志位状态。
对于循环类问题,我在进入循环前先用 R 命令把 CX 强改成预期值。实验里试过一次把 CX 置为 0004H,后续执行完全正常,这比反复单步按十几次高效得多。另外,用 E 命令直接改 W 内存值来测试 IFYEARS 分支的方法,我在排查程序错误时用了很多次——它把「数据转换」和「逻辑判断」解耦成两个独立验证单元,各测各的,错误范围瞬间缩小一半。
Debug 实验的核心价值不在于学会某条指令,而在于建立「寄存器状态可观测」的意识。从那以后我每写一段 16 位汇编程序,都会花一分钟过一遍 Debug 流程:确认 CX 计数初值、确认标志位状态、确认子程序返回后寄存器恢复情况。这套动作看上去简单,确实能拦住大部分的隐性寄存器污染问题。希望这份经过完整 Debug 调试验证的过程记录,能帮你把汇编实验的每个坑都提前踩平。
本文还有配套的精品资源,点击获取