简介:这份 Debug 调试实验文档对应北京交通大学汇编与接口课程的上机环节,面向正在学习汇编语言与微机接口、需要熟悉调试工具的学生。内容以判断闰年的 leapYear.exe 为主线,串联编译链接与运行、代码整体逻辑与逐段分析、Debug 实操以及错误修改四个部分:既讲清闰年判定条件的判断方式,也在调试器中通过断点与单步执行观察寄存器、内存与程序流程,对比“是闰年”与“不是闰年”两类输入的差异,并整理 D、T、C、P 等常用上机指令的用法,最后落到定位逻辑错误、修改源码并重新编译链接验证的完整流程。压缩包共 1 个 docx 文件,约 78.94MB,以文档形式完整记录实验过程与关键环节。目前已有 521 人学习,适合课前预习调试流程、课上对照操作、课后复盘排错思路,帮助读者把程序执行细节真正看懂。
1. 判闰年的汇编 Debug 实验:从一份寄存器快照看 8086 程序的“看不见”部分
一个判断闰年的汇编程序,逻辑只有十来行,可真把它放进 Debug 里单步走一遍,很多人才第一次发现:源程序写得没问题、直接双击运行也没问题,一进调试器就全错。这份实验记录的价值就在这里——它没有停在“能跑出结果”,而是用 R、U、D、T、P、G、E 这一组命令,把leapYear.exe从 DOS 载入内存开始,一路拍到 CF 标志位被置起来的那一刻,中间还逮到一个只在 Debug 环境下才发作的隐式错误:循环计数器 CX 的高位 CH 没清零。对正在上汇编与微机接口类课程、要交一份带寄存器现场截图的调试报告的人来说,这是一份可以直接照着复现的流程;对已经写过 16 位代码、但说不清LOOP、DIV、标志位返回约定怎么配合的人来说,它补的是那块黑匣子。它不是一份能直接编译的完整工程,而是一份把踩坑过程摊开的调试笔记。
2. MASM5 工具链与编译链接:把 .asm 变成一个能被 Debug 载入的 exe
2.1 为什么这门课还在用 MASM5,而不是 NASM 或现代汇编器
先讲清楚选型,不然后面的命令会显得莫名其妙。这个实验的目标不是写出高效代码,而是观察 8086 实模式下的寄存器行为:AX/BX/CX/DX 四个 16 位通用寄存器、段地址加偏移的寻址方式、INT 21H这类 DOS 中断调用、以及 CF/ZF 这些标志位在指令执行后的变化。这套东西只有在 16 位环境下才成立。MASM5 是老一代 8086 宏汇编器,输出的.obj经过 LINK 打包成带 MZ 头的.exe,DOS 加载后 CS:IP 指向入口、SS:SP 由栈段自动初始化,Debug 才能把整个内存映像摆在你面前让你逐条看。
换成 32 位或 64 位工具链,寄存器变成 EAX/RAX,中断调用被系统调用取代,DIV BX的语义和异常行为都不一样,调试器看到的是另一套世界。所以工具链选型在这里不是怀旧,而是实验对象本身就是 16 位实模式。
要注意的是运行环境。64 位 Windows 已经移除了 NTVDM,16 位 exe 双击是没反应的,常见做法是用 DOSBox 挂一个目录当 C 盘,或者开一台 32 位虚拟机。DOSBox 里挂载的原则是“路径短、无中文、无空格”,因为 DOS 的 8.3 文件名和长路径处理都很脆。我的习惯是源码放一个目录、工具放另一个目录,而不是把所有文件堆在根目录:
REM 在 DOSBox 里先把宿主机目录挂成 C 盘,然后进入工具目录 mount c d:\asm-lab c: cd \masm5 dirmount是 DOSBox 自己的命令,不是 DOS 命令,只在挂载时用一次;c:切盘符;cd \masm5进工具目录。把 masm5(含masm.exe、link.exe、debug.exe)和源码分开放,好处是编译产物和工具不会混在一起,dir的时候一眼能看出哪些是刚生成的中间文件。实验记录里把 masm5 直接放 C 盘根目录,是为了少打几个字符,能跑通,但清理的时候会比较难受。
提示:DOSBox 里
\是路径分隔符,和 Windows 的习惯一致;但目录名超过 8 个字符会被截断成MASM5~1这种形式,命令行里要按截断后的名字进。
2.2 从 .asm 到 .obj、.lst、.map、.exe 的完整命令序列
编译和链接是两条独立命令,中间产物各有用途,不是随手加的参数。MASM 的语法是masm 源文件,目标文件,列表文件,交叉引用文件,参数之间用逗号分隔,最后加分号表示后面全部用默认值。只写masm leapYear;也能过,但那样不会生成.lst,你也就拿不到“源码行 + 目标代码 + 偏移地址”三列对照的清单——而这份清单恰恰是后面在 Debug 里定位地址的依据。
REM 编译:生成 obj、列表文件 lst、交叉引用文件 crf masm leapYear,leapYear.obj,leapYear.lst,leapYear.crf; REM 链接:生成 exe、映射文件 map link leapYear,leapYear.exe,leapYear.map; REM 用 cref 把 crf 处理成可阅读的 ref 交叉引用表 cref leapYear.crf;第一条命令里,leapYear是源文件名(扩展名.asm可省略),第二个参数指定目标文件,第三个参数是列表文件,第四个是交叉引用文件。第二条命令里 LINK 的参数顺序是目标文件,可执行文件,映射文件,库文件,这里不链接库所以第四个参数省掉。第三条cref是可选的,它把.crf转成.ref,里面列出每个符号在哪些行被引用,程序一大就很有用。
跑完之后目录里会多出一堆文件,各自的用途值得记一下,因为调试时经常要回头查它们:
| 文件 | 由谁生成 | 用途 |
|---|---|---|
.obj | masm | 目标模块,LINK 的输入,改代码后必须重新生成 |
.lst | masm | 汇编清单,含源码行、目标码、偏移地址,查地址最直接 |
.crf/.ref | masm / cref | 交叉引用表,查符号被哪些行使用 |
.map | link | 段布局与各段长度,确认代码段起始位置 |
.exe | link | 可执行文件,Debug 载入的对象 |
.map的价值常被低估。Debug 载入 exe 之后CS:IP是被 exe 头改写的,你在源码里看到的偏移和实际运行时的段地址不是一回事,.map能告诉你各段被排到了哪个顺序、代码段占了多大,配合U反汇编就能对上号。实验记录里提到“代码段从 0C59 开始”,这个段地址就是加载时由 DOS 分配的,每次运行可能不同,所以不要把它写死在脚本里。
链接成功后会提示警告数量,常见的警告是 "no stack segment"——说明栈段定义没被识别,这种情况多半是段定义写了STACK SEGMENT但漏了组合类型'stack'。这个警告不能忽略,后面讲数据段的时候会再提到。
2.3 数据段与输入缓冲区:BUF 那三个部分到底怎么分工
程序的输入输出全靠数据段里这几个定义撑着,看懂了它们,后面BUF+1、BUF+2这种写法才不会是玄学:
DATA SEGMENT INFON DB 0DH,0AH,'Please input a year: $' Y DB 0DH,0AH,'This is a leap year! $' N DB 0DH,0AH,'This is not a leap year! $' W DW 0 BUF DB 8 DB ? DB 8 DUP(?) DATA ENDS STACK SEGMENT 'stack' DB 200 DUP(0) STACK ENDS0DH,0AH是回车加换行,放在字符串开头,作用是“先把光标挪到下一行再输出”,这样提示语不会和上一条输出挤在一起。结尾的$是INT 21H的 09H 功能约定的字符串结束符,没有它 DOS 会一直往后打印直到碰上随便哪个$或者干脆打飞。
W DW 0是用来存转换后年份的 16 位字,DW决定它占两个字节、按小端存放——低字节在前、高字节在后,所以在 Debug 里D出来看到的是E4 07而不是07 E4,第一次见容易懵。
BUF那三行是 DOS 缓冲输入的固定格式:第一个字节是允许输入的最大字符数(含回车),这里写 8,意味着最多敲 7 个数字;第二个字节?是占位,DOS 读完后会填实际输入长度(不含回车);从第三个字节开始才是字符本身,末尾 DOS 会自动补一个 0DH。所以[BUF+1]是长度、[BUF+2]是第一个字符,源码里MOV CL,[BUF+1]和LEA SI,BUF+2就是取这两样东西。
栈段那句STACK SEGMENT 'stack',引号里的'stack'是组合类型,LINK 靠它认出这是栈段并自动把 SS:SP 指向段尾。如果写成不带引号的STACK SEGMENT,链接器只当它是普通数据段,SS:SP 不初始化,程序一调用子程序或者一进中断就可能跑飞——这是新手最常翻的车之一,而且症状是随机的,很难查。
主程序开头那三行也是必考点:
START: MOV AX,DATA MOV DS,AX LEA DX,INFON MOV AH,9 INT 21H为什么不能直接MOV DS,DATA?因为 8086 不允许把立即数送进段寄存器,DATA在汇编时只是个重定位用的段地址常量,必须先经通用寄存器中转。MOV AX,DATA取到的是段基址,MOV DS,AX再送进去,这两步是固定套路,少了任何一步 DS 就指向错误的地方,后面所有变量访问全错位。
3. 逐段拆解 DATACATE 与 IFYEARS:一个做进制转换,一个把结论塞进 CF
3.1 DATACATE:四次 LOOP 里做的是“乘十加本位”
这个子程序干的事很朴素:把缓冲区里的 ASCII 数字串转成 16 位二进制数。但它的写法用到了 8086 的几个隐式约定,值得一句一句过:
DATACATE PROC NEAR LEA SI,BUF+2 ; SI 指向第一个数字字符 MOV BX,0 ; BX 作累加器,初值 0 X3: MOV AL,[SI] ; 取一个字符 SUB AL,30H ; '0' 的 ASCII 是 30H,减完得 0~9 MOV AH,0 ; 清高字节,AX 现在是纯数字 XCHG AX,BX ; 累加值换到 AX(MUL 的被乘数必须在 AX) MOV DX,10D ; 乘数 10 MUL DX ; DX:AX = AX * 10 XCHG AX,BX ; 乘积回 BX,本位数字换回 AX ADD BX,AX ; BX = BX*10 + 本位 INC SI ; 指针后移一位 LOOP X3 ; CX 减一,不为 0 就继续 MOV W,BX ; 结果写进 W RET DATACATE ENDP核心就一句数学:v = v * 10 + d,也就是 Horner 法。输入 2020 时,循环四次的中间值是 2 → 20 → 202 → 2020,每一轮都把已有的部分整体左移一位十进制再补上新的一位。
两次XCHG是这段代码最绕的地方,绕的原因在MUL。8086 的 16 位无符号乘法规定被乘数必须在 AX、结果落在 DX:AX,而累加值存在 BX 里,所以必须先换进 AX,乘完再换回来。为什么不干脆把累加器放 AX?因为 AX 还要用来放当前位的数字,两边打架,只能用交换绕开。
MOV AH,0这一句不能省。SUB AL,30H只改低 8 位,AH 里可能还残留上一轮的值,不清零的话 AX 就不是 0 到 9 而是 0x??0? 这种怪数,乘出来全乱。
循环次数来自主程序里的MOV CL,[BUF+1],LOOP指令的语义是“CX 减一,CX 不为 0 则跳转”——注意它看的是整个 CX,不是 CL。这一点是整个实验的命门,第 5 章会专门拆。
顺带说一句,主程序里那句LEA DI,BUF+2其实是冗余的,子程序内部又用LEA SI,BUF+2重新算了一遍,DI 从头到尾没被读。写实验报告时可以把它当“可删指令”标出来,比单纯抄代码显得明白。
3.2 IFYEARS:DIV 的余数怎么变成一个 bit 的返回值
判断逻辑本身是小学知识:能被 4 整除、且不能被 100 整除,或者能被 400 整除,就是闰年。难点在于怎么把结论传给调用者。这段代码没用返回值寄存器,而是用了标志位:
IFYEARS PROC NEAR PUSH BX PUSH CX PUSH DX MOV AX,W ; 取年份 MOV CX,AX ; CX 备份一份原值 MOV DX,0 ; 清高 16 位,DIV 要求 DX:AX 是被除数 MOV BX,4 DIV BX ; AX=商,DX=余数 CMP DX,0 JNZ LAB1 ; 不能被 4 整除,直接判非闰年 MOV AX,CX MOV BX,100 DIV BX CMP DX,0 JNZ LAB2 ; 能被 4 整除但不能被 100 整除,判闰年 MOV AX,CX MOV BX,400 DIV BX CMP DX,0 JZ LAB2 ; 能被 400 整除,判闰年 LAB1: CLC ; CF=0,非闰年 JMP LAB3 LAB2: STC ; CF=1,闰年 LAB3: POP DX POP CX POP BX RET IFYEARS ENDPDIV BX是 16 位无符号除法,被除数是 DX:AX 拼起来的 32 位,商进 AX、余数进 DX。判断整除就是判断余数是不是 0,所以每次除完紧跟一条CMP DX,0。这也是为什么每次除法前 DX 必须是 0——它代表被除数的高 16 位,不清零等于凭空给被除数加了 65536 的若干倍。
三档判定的顺序是有讲究的。先判 4,不满足直接出局,这一步挡掉了大多数年份;再判 100,不满足就是闰年,直接出结果;最后判 400,只有“能被 100 整除”的少数年份才走到这里。这个顺序让最坏情况也只做三次除法,而且代码是顺序下落的——第三次判断如果余数不为 0,JZ LAB2不跳,自然落到紧挨着的LAB1: CLC,不需要额外写跳转。这种“靠代码布局省一条 JMP”的写法在老汇编里很常见,读的时候要在脑子里补出 fallthrough 的路径,否则会以为漏了分支。
返回值的传递方式值得单独说。CALL IFYEARS之后主程序立刻JC A1,也就是看 CF 是不是 1。这意味着子程序和调用者之间有一个隐式契约:CF 就是返回值。这种约定在 8086 时代很普遍(INT调用也大量这么干),但它的代价是任何一条会影响 CF 的指令都会破坏返回值,所以LAB1里必须先CLC再JMP,不能指望 CF 自己变成 0。
三条PUSH保护 BX、CX、DX 也是必须的。DIV会改 AX 和 DX,CMP会改标志位,如果不保护,调用者原来的寄存器值就被吃掉了。子程序改完寄存器要还原,这是汇编里的基本礼貌,也是这份代码能反复调用的前提。
3.3 输出分支与 INT 21H 的两处用法
判断完就该输出了,这段逻辑短但有两个细节:
CALL IFYEARS JC A1 ; CF=1 走闰年分支 LEA DX,N ; 否则输出“不是闰年” MOV AH,9 INT 21H JMP EXIT A1: LEA DX,Y MOV AH,9 INT 21H EXIT: MOV AH,4CH INT 21HINT 21H的 09H 功能是“输出 DS:DX 指向的以$结尾的字符串”,所以每次输出前都要把字符串首地址LEA进 DX,然后把功能号送 AH。这里 AH 每次都被重新赋值,不能省,因为上一次调用可能已经把它改成了别的功能号。
MOV AH,4CH / INT 21H是带返回码退出,4CH 功能会把 AL 里的值作为 ERRORLEVEL 交还给 DOS。程序里没设 AL,退回去的是个不确定值,严格的写法应该在退出前MOV AL,0。教学实验一般不追究,但如果你的程序要被批处理脚本调用,这个细节会影响判断。
程序里一共用了两次INT 21H:09H 输出提示和结论,0AH 读入字符串。0AH 的缓冲区格式前面讲过,值得一提的是它读完后不会自动换行,光标停在输入行的末尾,所以 Y 和 N 两个字符串开头都放了0DH,0AH,靠输出时先换行把版面拉回来。这种“把格式控制塞进字符串常量”的做法在 DOS 程序里是标准操作,比单独调用换行省事。
3.4 那段“忘了清 DX 却恰好正确”的代码
把 IFYEARS 的除法部分单独拎出来看,会发现一个可疑之处:只有第一次DIV之前有MOV DX,0,后面两次除法前都没清。
按 8086 的规则,DIV BX的被除数是 DX:AX。第二次除法前 DX 里装的是第一次除法的余数,第三次除法前装的是第二次的余数,听起来应该出错。但它确实能跑对,原因是控制流:
- 第一次除完,只有当余数为 0(能被 4 整除)才会继续往下走,此时 DX 恰好是 0;
- 第二次除完,只有当余数为 0(能被 100 整除)才会走到第三次除法,此时 DX 又是 0。
也就是说,能走到下一次除法的路径上,DX 必然已经被上一次的“余数为 0”重置过了。逻辑自洽,但极其脆弱——只要有人把某个JNZ改成JMP,或者在两次除法之间插一条读 DX 的指令,整个判定就崩,而且崩得很隐蔽。
我的习惯是每次DIV前都显式XOR DX,DX,多一条指令换一份安心。这不是学院派的洁癖,是血泪经验:靠上下文推断出来的“恰好为 0”,在别人改代码的时候一文不值。
4. Debug 实操:单步、断点与寄存器快照怎么配合
4.1 先用 R、U、D 把现场拍成三张快照
载入程序和真正开始调试之间,应该先做一次“现场记录”,不然等程序跑起来,很多初始状态就再也看不到了:
debug leapYear.exe -r -u -d ds:0000R显示所有寄存器和标志位。这里要看三样:CS:IP 指向哪里(程序入口)、SS:SP 指向哪里(栈顶)、CX 是多少。这份实验里 CX 载入后并不为 0,高字节 CH 里带着一个非零值,这个值就是后面所有麻烦的来源。
U反汇编,从 CS:IP 开始列出每条指令的段:偏移、机器码和助记符。它的作用是给你一张地址地图,之后G设断点、T单步都要靠这些偏移来定位。反汇编出来的地址和.lst清单里的偏移能对上,两个工具互相印证,比单看一个靠谱。
D DS:0000按十六进制加 ASCII 双栏显示内存,右边那栏能看到Please input a year:这些字符串。用D确认数据段被正确加载,是排查“为什么输出乱码”最快的一步——如果右侧 ASCII 栏的字符是对的,说明段地址没错,问题在输出逻辑;如果字符本身就是乱的,说明 DS 没指对。
这三条命令不动程序状态,纯粹是读取,可以放心多跑几次。真正开始执行从下一条命令算起。
4.2 T 与 P 的分界线:进了 INT 21H 就出不来
T是单步执行一条指令,遇CALL会跟进去,遇INT会跳进中断服务程序。问题就在这儿:INT 21H的处理程序在 DOS 内核里,你跟着T进去,看到的是一堆和你的程序毫无关系的系统代码,想退出来只能靠P或者干脆重启调试会话。实验记录里那句“输入 T 会进入中断,不能继续调试,而改用 P 指令不会进入中断”,说的就是这个。
P的语义是“执行完当前这条指令,包括它引发的子程序调用或中断,然后停在下一条”。所以:
- 想看
DATACATE内部怎么一步步算,用T,它会进子程序; - 只想看子程序执行完的结果,用
P,它一口气跑完整个子程序停在下一条; - 遇到
INT 21H,一律用P,不要犹豫; - 遇到
LOOP,P会把整个循环跑完(这也是它和T最大的行为差异之一)。
一个实用技巧:在CALL之前先用P观察一次整体行为,发现结果不对,再回退到T逐条看细节。上来就T容易在中断里迷路,上来就P又看不到过程,两种交替用才是正常节奏。
注意:
T进入子程序后,返回地址被压在栈顶,此时用D SS:SP能看到它。如果栈段没正确初始化(比如'stack'组合类型漏了),这里读出来的就是垃圾,RET会跳到不知道哪里去。
4.3 用 G 跳过循环、用 E 改内存做对照实验
G是带断点的运行,语法是G 段:偏移,程序会一直跑到指定地址停下。在循环里一条条T太慢,直接用G跳到循环之后的下一条指令,几秒钟就能拿到最终结果:
REM 跑到循环结束后的位置(地址从 U 反汇编里读) -g 0c59:0018 -rR再看一次寄存器,此时 BX 里应该是累加结果、CX 应该是 0。如果 CX 不为 0 而程序已经跳出了循环,说明你给的断点地址不对,或者循环根本没按预期结束。
E是直接改内存,这是做对照实验的利器。想单独验证 IFYEARS 的正确性,可以跳过整个输入和转换流程,直接把年份写进 W:
REM 把 W 改成 07E4H(十进制 2020),小端存放,低字节在前 -e ds:000c e4 07 -d ds:000c这里000c只是示意偏移,实际地址要用U或.lst确认W在数据段里的位置。改完之后用G直接跑到CALL IFYEARS之后,看 CF 是不是 1,就能把“输入转换”和“闰年判定”两段逻辑彻底隔离——哪一段有问题一目了然。这种“绕开前半段、单独测后半段”的思路,比从头单步到结尾效率高一个数量级。
同一组命令里还有几个值得顺手练的:
| 命令 | 用途 | 典型场景 |
|---|---|---|
A | 直接汇编一小段指令到内存 | 临时验证ADD、MOV的行为 |
F | 用指定值填充一段内存 | 清空缓冲区,避免残留数据干扰 |
H | 十六进制加减,显示和与差 | 快速核对地址偏移、算段长 |
Q | 退出 Debug,不保存 | 结束会话 |
A命令在实验里被用来验证“AX 存 10H、BX 存 5H、求和存回 AX”,本质上是把 Debug 当成一个可以即时求值的计算器。这个用法看着简陋,但在确认某条指令到底怎么影响标志位的时候非常高效——比改源码、重新编译、重新链接快得多。
5. 避坑与排查:五个在这份实验里反复翻车的点
5.1 现象:T单步走到INT 21H之后就卡住,或者一路跑飞
原因:T会跟进中断服务程序,而INT 21H的处理代码在 DOS 内核里,那一段没有你程序的符号,也没有返回你程序的自然出口,继续T下去只会在系统代码里越陷越深。
解决:凡是有INT的地方一律用P。如果确实想观察中断前后的寄存器变化,在INT前一条指令用T,INT本身用P,之后再T。另一个办法是在INT之后那条指令上设G断点,让程序自己跑过去。
5.2 现象:直接运行 exe 结果全对,用 Debug 载入后不管输入什么年份,都输出“是闰年”
原因:主程序用MOV CL,[BUF+1]设置循环次数,只写了 CL,没管 CH。直接运行时 CX 恰好是 0,所以LOOP的次数等于 CL;但 Debug 载入 exe 后 CX 里带着文件长度之类的残留值,CH 非零,LOOP判断的是整个 CX,于是循环次数变成CH*256 + CL,远远超出预期。多出来的循环继续读缓冲区后面的内存字节,累加结果在 16 位里反复溢出,最后稳定停在一个固定值上——实验记录里这个值是71B0H,十进制 29104,它模 4 余 0、模 100 余 4,正好被判成闰年,于是无论输入什么都输出“是闰年”。
解决:在MOV CL,[BUF+1]之前加一条MOV CH,0,或者干脆先MOV CX,0再取长度。修完之后 Debug 下的行为和直接运行一致。这也是整个实验最值得写进报告的一条:一个只在调试器里出现的错误,根源往往是被调试器改变的初始状态。
5.3 现象:输入 5 位以上的年份,结果完全不对,甚至输出乱码
原因:两处限制叠加。W是DW,只能存 0 到 65535,而DATACATE用MUL DX做 16 位乘法,结果落在 DX:AX,代码只取了 AX,高 16 位被直接丢弃。处理到第 5 位时,前四位最大 9999,9999*10+9已经超过 65535,溢出必然发生。另一方面BUF的第一个字节是 8,意味着最多允许输入 7 个字符,程序本身却没有对长度做任何校验。
解决:在调用DATACATE之前检查[BUF+1]是否等于 4(或者把 BUF 的最大长度改成 5 以容纳回车),不合法就直接输出提示并退出。如果是课程实验,报告里写清“本程序仅支持 0 到 65535 的年份”比硬撑着支持更大范围更诚实。
5.4 现象:什么都不输、直接敲回车,程序要么长时间不返回,要么输出一片乱码
原因:直接回车时[BUF+1]是 0,MOV CL,0之后 CX 为 0。LOOP的判定是“先减一,再判断是否为 0”,CX=0 时减一变成 0FFFFH,于是循环 65536 次,把整段内存当成数字串往 BX 里乘加。
解决:取完长度后加一条CMP CL,0 / JE EXIT之类的判空分支,或者统一在入口处校验长度范围。这类边界输入在实验报告里往往不写,但它恰恰是“程序健壮性”和“能跑通”的分界线。
5.5 现象:改了源码里的一行,重新运行 Debug,行为还是老样子
原因:只改了.asm,没重新masm和link,Debug 载入的还是上一次生成的 exe。MASM 不会自动增量构建,LINK 也不会检查源文件时间戳。
解决:养成“改源码 → masm → link → debug”四步连打的习惯,或者写一个批处理把这四步串起来,中间任何一步报错就停下来。判断有没有真的重新生成,看dir里.obj和.exe的时间戳,比凭记忆可靠。
REM 一键重建,任一步失败就中止 masm leapYear,leapYear.obj,leapYear.lst,leapYear.crf; || goto :fail link leapYear,leapYear.exe,leapYear.map; || goto :fail debug leapYear.exe goto :eof :fail echo build failed这段批处理里的||是“前一条命令返回非零才执行后面”,用来在编译或链接失败时中断流程,避免拿着旧的 exe 继续调试。goto :eof是批处理里的正常退出写法。
6. 把修复验证做扎实:一次对照实验比十次“看起来对了”都管用
修完MOV CH,0之后,光看到输出正确还不够,得证明“错的原因确实是我以为的那个原因”。我一般会做一轮反向验证:故意把 CH 污染成已知值,看能不能复现出原来那个71B0H;再清掉,看现象是否消失。能复现、能消除,因果关系才算成立。
; 修复片段:在取长度之前把整个 CX 清零 MOV CX,0 MOV CL,[BUF+1] ; 现在 CH 一定是 0,LOOP 次数等于输入位数如果只想做最小改动,MOV CH,0一条就够;MOV CX,0更保险,因为它顺带把可能残留的其他位也清了。两条指令的差别在于前者假设 CL 会马上被覆盖、后者不假设任何东西,我倾向后者。
验证用的输入建议覆盖四类边界,这四组能同时打到“能被 4 整除”“能被 100 整除”“能被 400 整除”三条路径:
| 输入年份 | 期望输出 | 修复前(Debug 下运行) | 修复后 |
|---|---|---|---|
| 2020 | 是闰年 | 是闰年(巧合,溢出值 71B0H 恰好判为闰年) | 是闰年 |
| 2021 | 不是闰年 | 是闰年(错误) | 不是闰年 |
| 1900 | 不是闰年 | 是闰年(错误) | 不是闰年 |
| 2000 | 是闰年 | 是闰年(巧合) | 是闰年 |
| 2100 | 不是闰年 | 是闰年(错误) | 不是闰年 |
这张表最有价值的一列是“修复前”。2020 和 2000 在修复前也输出“是闰年”,如果只测这两个数,你会以为程序是对的;只有把 2021、1900、2100 这三个期望为“不是”的年份加进去,错误才暴露。这就是为什么验证用例要挑“期望结果为否”的样本——正确结果往往是巧合,错误结果才是证据。
反向验证的具体做法是:在 Debug 里用R命令直接把 CX 改成0C04(或者其他让高位非零的值),然后G跑完全程,观察输出是否又变回“永远是闰年”。如果复现了,说明你对根因的判断成立;如果没复现,说明还有别的因素在起作用,得回头重查。这一步多做五分钟,能省掉后面被追问“你怎么确定是 CH 没清零”的尴尬。
还有一个习惯值得固化:每修一个 bug,就在源码旁边留一行注释写清“为什么这么改”,而不是只留结果。汇编代码的可读性本来就差,三个月后回头看MOV CX,0这种指令,没有注释根本想不起它防的是什么。
从那以后我每次在 Debug 里跑任何带LOOP的程序,都会先R看一眼 CX 的完整值,再决定要不要在循环前手动清零——这个动作花两秒钟,但能挡掉一整类“只在调试器里发作”的怪问题。希望帮到你。
本文还有配套的精品资源,点击获取