☰
汇编程序调试实战:从leapYear.exe看Debug单步跟踪与修复
2026/10/29 8:13:29 网站建设 项目流程

简介:这是北京交通大学《汇编与接口》课程的一份Debug调试实验文档,面向正在学习微机接口或需要掌握汇编程序调试方法的读者。内容围绕leapYear.exe闰年判断程序,完整记录汇编源程序从编译、链接到生成可执行文件的流程,并逐步讲解Debug工具下的断点设置、单步执行、寄存器查看等核心操作。实验报告以docx格式封装,压缩包内共1份文档,整体约78.94MB,目录按编译运行、代码逻辑分析、Debug截屏记录、代码错误修改四部分组织,便于对照上机步骤逐段查阅。文档中不仅给出“能被4整除且不能被100整除,或能被400整除”的闰年判定逻辑的逐句分解,还覆盖是闰年与非闰年两类输入在Debug中的调试截屏,以及D、T、P等常用指令的用法说明。同时包含典型逻辑错误从定位、修改到重新编译运行复测的完整排错思路。学生可借此理解程序执行路径、寄存器状态变化与汇编调试技巧,也可直接参考报告结构完成同类实验。目前已有520人学习下载。

1. Debug 调试实验:从 leapYear.exe 看汇编程序的编译、调试与修复

这是北京交通大学汇编与接口课程里一个非常典型的实验,围绕一个判断闰年的汇编程序 leapYear.exe 展开。实验内容分四块:用 masm5 完成编译链接、分析 DATACATE 和 IFYEARS 两个子程序的逻辑、用 Debug 工具单步跟踪寄存器与内存变化、最后定位并修复一个隐藏的代码错误。这个实验的含金量不在于闰年判断本身,而在于它完整展示了汇编程序从源代码到可执行文件再到 Debug 逐指令验证的全流程,以及一个「单独运行正常、进 Debug 就出错」的经典翻车案例。适合正在学微机接口或汇编课程的学生,也适合想补上底层调试功底的从业者。

2. 环境搭建与编译链接:masm5 下的完整工具链流程

2.1 为什么选 masm5 而不是其他汇编器

汇编语言的上机实验,工具链选择直接决定后续 Debug 能不能顺利走通。常见方案有 MASM、TASM、NASM,还有现在的 Visual Studio 内嵌汇编或在线模拟器,但这类课程实验用 masm5 最合适。原因是 Debug 工具与 MASM 生成的 EXE 兼容性最好,T、P、G、U 这些调试指令对 MASM 生成的目标文件解析最稳定。

masm5 是微软宏汇编器,严格说是 MASM 5.0 版本,生成的是 16 位实模式下的 OBJ 文件,配合 LINK 生成 EXE,适用于 8086 指令集教学。实验里用的就是这套流程。如果你用的是 32 位或 64 位系统,可以直接把 masm5 文件夹放到 C 盘根目录,通过命令行调用,也可以配 DOSBox 做虚拟环境。

2.2 编译链接的完整命令与文件产物

实验的第一步是把 masm5 文件夹放进 C 盘根目录,然后进入命令行。以下是标准操作:

cd C:\masm5 masm leapYear.asm

执行后 MASM 会提示是否生成列表文件和交叉索引文件,可以直接回车跳过,也可以按实验要求生成。目标文件 leapYear.obj 会出现在当前目录,然后执行链接:

link leapYear.obj

链接成功后生成 leapYear.exe。整套流程中 masm5 文件夹里会出现多个文件,它们的用途如下:

文件后缀全称作用
.asm汇编源代码程序员编写的汇编语言源文件
.obj目标文件汇编器生成的机器码模块,尚未链接
.lst列表文件源代码与机器码对照的清单,调试时查地址很方便
.ref交叉引用文件符号与引用位置的对应关系
.map链接映射文件EXE 中各段的内存布局
.crf交叉参考文件供交叉引用工具读取的数据文件
.exe可执行文件最终可以运行的程序

这些中间文件在后续 Debug 时非常有用。比如 .lst 文件能直接看到每条指令对应的偏移地址,配合 Debug 的 U 命令反汇编结果对照分析,定位速度会快很多。

2.3 运行确认程序基本正确

程序生成后先直接运行一遍确认基础功能没问题。输入 2020 会输出 "This is a leap year!",输入 2026 和 2025 会输出 "This is not a leap year!"。注意 2100 这个年份也输出了不是闰年,说明程序正确处理了「能被 100 整除但不能被 400 整除不算闰年」这条规则。这部分验证很重要,因为后续 Debug 中发现的问题并不会影响单独运行的结果。

3. 代码结构与核心算法:DATACATE 与 IFYEARS 子程序拆解

3.1 数据段与代码段的内存布局

源码整体是一个标准的 8086 汇编程序结构,数据段里定义了提示字符串和缓冲区,代码段里定义了主程序流程和两个子程序。数据段中 INFON、Y、N 三个字符串分别对应输入提示、是闰年、不是闰年的输出文本,W 是存放转换后年份数值的变量,BUF 是输入缓冲区,前两个字节分别存放缓冲区大小和实际输入字符数。

这段代码值得注意的一个点是 ASSUME 伪指令的使用。ASSUME 只是告诉汇编器各段寄存器的默认指向关系,并不会真的给段寄存器赋值,所以主程序开头必须有 MOV AX, DATA 和 MOV DS, AX 这两条指令把数据段地址真正装入 DS。一开始学汇编的人很容易在这里困惑,觉得为什么数据段不能直接 MOV DS, DATA,原因是立即数无法直接送到段寄存器。

3.2 DATACATE 子程序:字符串转数值的乘十累加算法

DATACATE 子程序的核心任务是把用户输入的年份字符串转换成十六进制数值存到 W 变量里。整体逻辑是:从字符串第一个字符开始,逐个取出字符,减去 30H 得到对应的数字('0' 的 ASCII 码就是 30H),然后把之前累加的结果乘 10 再加上当前数字,循环处理完每一位。

DATACATE PROC NEAR LEA SI, BUF+2 ; SI 指向输入字符串的首字符 MOV BX, 0 ; BX 作为累加器,初始为 0 X3: MOV AL, [SI] ; 取当前字符的 ASCII 码 SUB AL, 30H ; 减去 0 的 ASCII 码,得到数字本身 MOV AH, 0 ; 把 AX 高位清零 XCHG AX, BX ; 交换 AX 和 BX MOV DX, 10D ; DX = 10 MUL DX ; AX = AX * DX XCHG AX, BX ; 交换回来 ADD BX, AX ; BX = BX + AX,累加当前数字 INC SI ; 指向下一个字符 LOOP X3 ; CX-1,不为 0 则继续循环 MOV W, BX ; 将最终结果存入 W RET DATACATE ENDP

参数说明:输入是 BUF 缓冲区中存储的 ASCII 码字符串,输出是 W 变量中的十六进制数值。逻辑核心是 XCHG AX, BX 和 MUL DX 的组合。举个例子,输入 "2020" 时,第一次循环拿到 '2' 减 30H 得 2,BX 变成 2;第二次拿到 '0',先 BX=2 乘 10 得 20,再加 0 还是 20;第三次拿到 '2',20 乘 10 加 2 得 202;第四次拿到 '0',202 乘 10 加 0 得 2020,也就是 07E4H。

这里有一个容易忽略的细节:MUL 是乘法指令,在 8086 中执行的是 AX * 操作数,结果放在 DX:AX 双字中。当 BX 里的值乘 10 时,如果结果超过 16 位就会溢出到 DX,但这个实验只考虑四位年份,最大 9999,乘 10 后是 99990,已经超过 16 位上限 65535。不过实验里的测试年份都在 2100 以内,所以没触发溢出问题,但理论上这个子程序对超过 6553 的年份输入会产生错误结果。

3.3 IFYEARS 子程序:基于除法的闰年判定逻辑

IFYEARS 子程序的判断规则是:能被 4 整除且不能被 100 整除的年份为闰年,能被 400 整除的年份也是闰年。整体流程先判断能否被 4 整除,不行直接清零 CF 退出;能被 4 整除再看能否被 100 整除,不能就置位 CF 退出;能被 100 整除再看能否被 400 整除,能则置位 CF。

IFYEARS PROC NEAR PUSH BX ; 保存现场,BX 入栈 PUSH CX ; CX 入栈 PUSH DX ; DX 入栈 MOV AX, W ; 年份装入 AX MOV CX, AX ; 备份年份到 CX MOV DX, 0 ; 被除数高 16 位清零 MOV BX, 4 ; 除数 = 4 DIV BX ; AX ÷ BX,商在 AX,余数在 DX CMP DX, 0 ; 比较余数和 0 JNZ LAB1 ; 余数不为 0,跳转 LAB1 直接清零 CF MOV AX, CX ; 恢复年份 MOV BX, 100 ; 除数 = 100 DIV BX ; AX ÷ BX CMP DX, 0 ; 比较余数 JNZ LAB2 ; 不能被 100 整除,跳转 LAB2 置位 CF MOV AX, CX ; 恢复年份 MOV BX, 400 ; 除数 = 400 DIV BX ; AX ÷ BX CMP DX, 0 ; 比较余数 JZ LAB2 ; 能被 400 整除,跳转 LAB2 置位 CF LAB1: CLC ; CF 清零,不是闰年 JMP LAB3 LAB2: STC ; CF 置 1,是闰年 LAB3: POP DX ; 恢复现场,DX 出栈 POP CX ; CX 出栈 POP BX ; BX 出栈 RET IFYEARS ENDP

这段逻辑的巧妙之处在于用 CF 标志位作为返回值,而不是通过 AX 或 DX 传递结果。主程序在调用 IFYEARS 之后直接用 JC 指令判断 CF 是否为 1,如果是就跳转到输出「是闰年」的代码段。这种用标志位传递布尔结果的方式在汇编里很常见,因为不需要额外分配寄存器或内存空间。

三个子程序 LAB1、LAB2、LAB3 的作用分别是:LAB1 负责把 CF 清零并跳转到收尾,LAB2 负责把 CF 置位,LAB3 负责恢复寄存器并返回。注意 LAB2 执行完后没有跳转直接落入 LAB3,是顺序执行,所以 LAB1 必须显式 JMP 到 LAB3。

3.4 主程序的整体控制流

主程序流程是:显示提示字符串 → 调用 INT 21H 的 10 号功能读取键盘输入到 BUF → 将 BUF+1 处的实际输入字符数送入 CL → 调用 DATACATE 转换 → 调用 IFYEARS 判断 → 根据 CF 决定输出哪个字符串 → 调用 INT 21H 的 4CH 功能退出程序。

这里有一个隐藏的地址计算问题。BUF 的定义是 DB 8, DB ?, DB 8 DUP(?)。第一个 8 表示缓冲区最大长度,第二个字节是实际输入长度,由 INT 21H 功能 10 自动填充,后面的 8 个字节存放实际输入的字符。所以 BUF+1 是输入长度,BUF+2 是输入内容的首地址。DATACATE 中 LEA SI, BUF+2 正是利用了这个布局。

4. Debug 实操:用 R、U、T、P、G、D、E 逐条指令验证程序行为

4.1 R 与 U:查看寄存器状态和反汇编代码

进入 Debug 后首先用 R 命令查看寄存器初始状态。这时会注意到 CX 一开始并不为 0,这是一个伏笔,后面会详细说。IP 指向 0000,CS 是 0C59,说明代码段从这里开始。用 U 命令反汇编可以看到每一条指令的地址、机器码和汇编助记符,对照源码和 .lst 文件能确认编译结果是否符合预期。

debug leapYear.exe -r -u

参数说明:U 命令如果不带地址,会从 CS:IP 当前位置开始反汇编。如果带地址如 U 0C59:0100,则从指定位置开始。这里 R 命令显示的寄存器值是进入程序时的初始状态,还没有执行任何用户代码。

4.2 T 与 P:单步执行中关于中断和子程序的关键差异

单步调试的核心是 T 和 P 两条指令。T 会执行当前 CS:IP 指向的一条指令并停下来显示所有寄存器内容,同时会进入 INT 21H 中断内部,导致调试器陷入中断处理程序,无法继续跟踪用户代码。P 则不会进入中断,遇到 CALL 指令时会直接执行完整个子程序再停下。

实验中的具体记录是:用 T 单步走到 INT 21H 时,输入 T 会进入中断,后续无法正常调试,改用 P 直接输出并等待输入,成功跳过中断。这个血泪经验很关键,遇到 INT 21H 或 CALL 子程序时优先考虑 P,如果要进入子程序内部逐条检查才用 T。

观察 DATACATE 子程序的执行过程,T 可以进入子程序内部,P 不行。进入后能看到 SI 指向 BUF+2 地址 0050,AX 低 8 位变成 32,即字符 '2' 的 ASCII 码,AL 减 30H 后变成 02,AH 清零后 AX 中存储了数字 2。然后 AX 与 BX 交换,DX 置 0,MUL DX 后 AX 变为 0,再交换回来,ADD BX, AX 后 BX 累加为 2。接着 SI 自增到 0051,循环处理下一位字符。

4.3 D 与 E:查看和修改内存数据

D 命令可以查看指定地址的内存内容,右侧显示对应的 ASCII 字符。实验中用 D 命令验证了输入年份 2020 的 ASCII 码存储在 0C53:0050-0053 地址处,四位字符分别是 32 30 32 30。

E 命令用来修改内存中的值。这个命令在实验里非常有价值,当发现 DATACATE 子程序执行结果不正确时,用 E 命令直接把 W 变量所在地址的内容改为正确的十六进制年份值,从而跳过有问题的转换环节,继续验证 IFYEARS 子程序本身是否正确。注意 E 命令修改的是内存中的字节,如果 W 是 DW 类型(双字节),需要连续修改两个地址。

4.4 G 命令:直接运行到指定地址

G 命令用于从当前 CS:IP 直接运行到某个断点地址,中间不暂停。实验中发现单独运行程序不会出错,但用 G 命令运行就会出错。原因是 G 命令在运行过程中不会像 T 一样重置 CX 的值,而是保留当前环境状态。

4.5 其他实践指令:F、A、H 与 Q

实验还覆盖了 F(填充内存)、A(汇编指令到内存)、H(十六进制加减计算)、Q(退出 Debug)等指令。F 命令可以把一段连续内存区域填充为相同数据,适合初始化缓冲区或清零内存段。A 命令可以直接在 Debug 中输入汇编指令,比如 MOV AX, 10H、MOV BX, 5H、ADD AX, BX 后运行验证结果。H 命令可以计算两个十六进制数的和与差,用于快速验算。

5. 避坑指南:Debug 中常见的四个典型问题与解决办法

5.1 CX 高位未清零导致循环次数异常

现象:用 Debug 调试 leapYear.exe 时,输入 2020 后循环没有按预期四次结束,而是继续执行,最终 BX 中存储的值为 71B0H,和输入年份无关。

原因:主程序用 MOV CL, [BUF+1] 将输入字符数送入 CX 低位,但 CX 高位 CH 没有被清零。进入 Debug 时 CX 初始值并不是 0,而是环境遗留值。LOOP 指令的循环条件是 CX-1 且 CX≠0,即使 CL 减到 0,CH 不为 0 时 CX 整体仍然非 0,循环就不会终止。实验中记录了 CL 变成 04 后继续循环的事实,由于 CH 不是 0,循环会一直持续到 CX 高位也减到 0,总共执行 0104H 次,也就是 260 次循环,远超应有的 4 次。

解决:在 MOV CL, [BUF+1] 后面加一条 MOV CH, 0,确保 CX 完整等于输入长度。

5.2 Debug 中 T 指令进入中断导致调试中断

现象:用 T 指令单步执行到 INT 21H 时,无法继续跟踪用户代码,调试器陷入中断内部。

原因:INT 21H 是 DOS 系统调用中断,T 指令会进入中断处理程序,中断内部还有更多指令需要执行,但这部分不是用户程序的逻辑,直接看会迷失在系统代码里。

解决:遇到 INT 21H 或 CALL 指令时,如果要跳过中断或子程序内部,用 P 指令而不是 T。P 会执行完整个调用或中断再停下,T 则逐条进入。这也是实际调试中需要频繁切换 T 和 P 的原因。

5.3 E 命令修改 W 变量时的字节顺序问题

现象:用 E 命令修改 W 变量内容后,程序判断结果不对,无法得到预期输出。

原因:W 是 DW 类型(双字节),8086 是小端存储,低位字节存放在低地址,高位字节存放在高地址。如果直接按 07E4H 从高位往低位写,实际内存中存储的值就变成了 E407H,十进制是 58375,明显不是 2020。

解决:E 命令写入时需要倒序。比如要把 W 修改为 07E4H,需要先写低字节 04,再写高字节 07。或者用 E 1F4 这样的格式,Debug 会按字节顺序连续写入。注意 W 变量在数据段中的偏移地址需要先用 R 命令查看 DS 段值,再根据数据段定义计算出 W 的偏移。

5.4 单独运行正常但 Debug 中出错的现象

现象:不进入 Debug,直接运行 leapYear.exe,输入任何年份结果都正确。但用 Debug 的 G 命令运行,结果全变成闰年。

原因:单独运行时程序的段寄存器、CX 等寄存器初始值由 DOS 加载 EXE 时确定,通常会初始化为 0 或一个确定值。而进入 Debug 时,CX 高位保存的是 EXE 文件大小等环境信息,加上 MOV CL, [BUF+1] 只修改低位不碰高位,循环次数失控。这就是典型的「单独运行正常、Debug 中出错」的案例。

解决:在代码中显式对 CH 清零,不依赖环境寄存器初始值。这也是一个核心教训——汇编程序不能假设寄存器初始为 0,关键寄存器必须显式初始化。

5.5 Debug 输入 2021 时 BX 值相同的原因分析

现象:输入 2020 和 2021 时,Debug 中 BX 最终都变成 71B0H,看起来像是程序丢失了输入差异。

原因:循环执行了 0104H 次,远超 4 次。DATACATE 的循环体执行 0104H 次后,结果已经溢出 16 位寄存器 FFFFH 的边界,只保留低 16 位。对于任意四位年份,经过相同的超量循环后留下的低 16 位结果都相同,都是 71B0H。

解决:修复 CX 高位清零问题后,循环只执行 4 次,转换结果必然与输入一一对应。

6. 从 Debug 到修复:把循环次数修正写成可复用的验证方法

修复代码的最终方案是在 MOV CL, [BUF+1] 之后添加 MOV CH, 0。修改后的 DATACATE 调用前,CX 完整地等于输入字符数,LOOP X3 循环严格执行四次,转换结果正确存入 W。

MOV CL, [BUF+1] ; BUF+1 存的是实际输入字符数 MOV CH, 0 ; 显式清零高位,避免环境遗留值干扰 LOOP LEA DI, BUF+2 CALL DATACATE

修复后建议按以下顺序验证:

第一,单独运行 leapYear.exe,输入 2020、2021、2100、2000,前两者分别输出是闰年和不是闰年,2100 输出不是闰年,2000 输出是闰年(能被 400 整除)。四项全对说明闰年判断逻辑本身无误。

第二,进入 Debug,用 T 单步跟踪到 DATACATE 子程序,观察 CX 的值应该是 0004 而不是 0400 或其他值,CL=04、CH=00。每走完一次循环 CX 自减,第四次结束后 CX 变成 0,LOOP 条件不满足,正常跳出循环。

第三,用 P 指令跳过子程序后查看 BX 的值。输入 2020 时应该是 07E4H,输入 2021 时应该是 07E5H。如果看到这两个值,说明字符串转数值完全正确。

第四,用 E 命令直接修改 W 的值来验证 IFYEARS。比如把 W 改成 07E4H(注意字节序),然后继续运行,程序应该输出 "This is a leap year!";改成 07E5H 应该输出 "This is not a leap year!"。跳过 DATACATE 直接测 IFYEARS,能更快定位问题在转换环节还是判断环节。

这一个跳过环节的验证方法是我在调试其他汇编程序时也一直在用的。从那以后,我每次写汇编程序,凡是涉及 LOOP 或串操作指令,都会先确认计数寄存器完整赋值,尤其注意 CL 和 CH 的拆分使用;凡是发现单独运行正常但 Debug 表现不一致的情况,第一反应就是检查 CX 高位或段寄存器初值,而不是急着怀疑算法逻辑。这个问题不只在汇编里存在,嵌入式和底层开发中,寄存器初始值的假设错误也是排障时最容易绕远路的地方,先把环境状态摸清楚再查逻辑,能省下大半时间。希望这份实验拆解能帮你在汇编调试的路上少踩几个坑。

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

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

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

立即咨询