汇编程序调试实战:从leapYear.exe看Debug单步跟踪与修复
简介这是北京交通大学《汇编与接口》课程的一份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, BUF2 ; 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 得 2BX 变成 2第二次拿到 0先 BX2 乘 10 得 20再加 0 还是 20第三次拿到 220 乘 10 加 2 得 202第四次拿到 0202 乘 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 → 将 BUF1 处的实际输入字符数送入 CL → 调用 DATACATE 转换 → 调用 IFYEARS 判断 → 根据 CF 决定输出哪个字符串 → 调用 INT 21H 的 4CH 功能退出程序。这里有一个隐藏的地址计算问题。BUF 的定义是 DB 8, DB ?, DB 8 DUP(?)。第一个 8 表示缓冲区最大长度第二个字节是实际输入长度由 INT 21H 功能 10 自动填充后面的 8 个字节存放实际输入的字符。所以 BUF1 是输入长度BUF2 是输入内容的首地址。DATACATE 中 LEA SI, BUF2 正是利用了这个布局。4. Debug 实操用 R、U、T、P、G、D、E 逐条指令验证程序行为4.1 R 与 U查看寄存器状态和反汇编代码进入 Debug 后首先用 R 命令查看寄存器初始状态。这时会注意到 CX 一开始并不为 0这是一个伏笔后面会详细说。IP 指向 0000CS 是 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 指向 BUF2 地址 0050AX 低 8 位变成 32即字符 2 的 ASCII 码AL 减 30H 后变成 02AH 清零后 AX 中存储了数字 2。然后 AX 与 BX 交换DX 置 0MUL 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, [BUF1] 将输入字符数送入 CX 低位但 CX 高位 CH 没有被清零。进入 Debug 时 CX 初始值并不是 0而是环境遗留值。LOOP 指令的循环条件是 CX-1 且 CX≠0即使 CL 减到 0CH 不为 0 时 CX 整体仍然非 0循环就不会终止。实验中记录了 CL 变成 04 后继续循环的事实由于 CH 不是 0循环会一直持续到 CX 高位也减到 0总共执行 0104H 次也就是 260 次循环远超应有的 4 次。解决在 MOV CL, [BUF1] 后面加一条 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, [BUF1] 只修改低位不碰高位循环次数失控。这就是典型的「单独运行正常、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, [BUF1] 之后添加 MOV CH, 0。修改后的 DATACATE 调用前CX 完整地等于输入字符数LOOP X3 循环严格执行四次转换结果正确存入 W。MOV CL, [BUF1] ; BUF1 存的是实际输入字符数 MOV CH, 0 ; 显式清零高位避免环境遗留值干扰 LOOP LEA DI, BUF2 CALL DATACATE修复后建议按以下顺序验证第一单独运行 leapYear.exe输入 2020、2021、2100、2000前两者分别输出是闰年和不是闰年2100 输出不是闰年2000 输出是闰年能被 400 整除。四项全对说明闰年判断逻辑本身无误。第二进入 Debug用 T 单步跟踪到 DATACATE 子程序观察 CX 的值应该是 0004 而不是 0400 或其他值CL04、CH00。每走完一次循环 CX 自减第四次结束后 CX 变成 0LOOP 条件不满足正常跳出循环。第三用 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 高位或段寄存器初值而不是急着怀疑算法逻辑。这个问题不只在汇编里存在嵌入式和底层开发中寄存器初始值的假设错误也是排障时最容易绕远路的地方先把环境状态摸清楚再查逻辑能省下大半时间。希望这份实验拆解能帮你在汇编调试的路上少踩几个坑。本文还有配套的精品资源点击获取