简介这份文档面向学习汇编语言与微机接口课程的高校学生尤其适合正在做北京交通大学相关实验、需要掌握程序调试方法的学习者。内容围绕一个判断输入年份是否为闰年的 leapYear.exe 程序展开覆盖编译链接与运行、代码整体逻辑与逐句逐段分析、Debug 调试过程中的闰年与非闰年两类输入演示、D、T、C、P 等常用指令的上机实践以及发现逻辑错误后的代码修改与重新编译验证形成从编写到排错的完整闭环。资源包共 1 个 docx 文档约 78.94MB以图文步骤记录实验过程便于对照复现。目前已有 521 人学习。读者可据此理解寄存器与内存状态观察方法、断点设置技巧和条件判断错误的定位思路也可作为实验报告撰写与上机复习的参照材料。1. 同一个闰年程序为什么双击运行正确、Debug 里跑就翻车同一个 leapYear.exe在命令行敲进 2020、2025、2100闰年判断一次没错搬到 Debug 里用 G 命令跑输入 2021 却照样蹦出This is a leap year!。这不是玄学程序没改改的只是它被启动时寄存器里残留的那些值。这份汇编与接口课程的调试实验真正值钱的地方不在背下几条 Debug 命令而在于它把一行MOV CL,[BUF1]的后果完整摊开给人看循环的边界是 CX 这整个 16 位寄存器不是你以为的那个 8 位 CL。写高级语言时循环次数由编译器和运行时兜底我们从来不关心高 8 位装了啥一旦落到裸机寄存器上CH 里那点来历不明的残留值就足够让一个判闰年的程序对所有输入都回答是。后面按顺序拆编译链接阶段产物各自是干什么的、键盘输入怎么变成内存里的二进制数、三次 DIV 怎么拼出闰年判定、以及 Debug 里那几步单步为什么能让错误现形。每一层都落到具体的寄存器和机器码上不讲抽象概念。2. 从 .asm 到 .exemasm 与 link 在背后补齐了哪些东西2.1 一次编译链接留下的六个文件各是什么角色把 masm5 整个目录放到 C 盘根下是为了让masm和link这两个可执行文件能被 PATH 或者当前目录找到省掉每次敲绝对路径。进入目录后两条命令就够cd C:\masm5 masm leapYear.asm; # 汇编源码 - 目标文件分号表示后面所有提问都用默认值回答 link leapYear.obj; # 链接目标文件 - 可执行文件同样用分号一路默认分号这个写法很多人不知道它的含义它让汇编器和链接器不再逐项询问要不要生成清单文件要不要生成映像文件全部按默认答案走。一旦交互式地回答多敲几个回车反而容易在某个提问上答错产物不全会让人以为编译失败。链接成功后目录里会多出.obj、.lst、.ref、.map、.crf、.exe这一串文件它们不是废料各管一件事。文件产生阶段内容什么时候真用得上.obj汇编机器码加尚未确定的地址重定位项链接器的输入单独没法运行.lst汇编源码与机器码逐行对照附段偏移算某条指令在第几字节定断点地址.crf/.ref汇编/链接标号被定义与被引用的行号清单找某个 LAB 到底从哪儿跳过来.map链接各段的起始地址、段长、程序总大小确认 DATA 段被放在哪个段基址.exe链接带重定位表的加载映像交给 DOS 装载执行.lst是这个实验里被严重低估的文件。Debug 里用 U 反汇编看到的地址是0C59:0000、0C59:0003这样的偏移想提前知道某条INT 21H落在哪个偏移上就得回头翻清单文件清单里每条指令前面的四位十六进制就是它在代码段内的偏移。没有它你只能靠一条条 T 单步数着走效率低还容易数错。2.2 MOV AX,DATA 这一圈绕的到底是什么程序开头永远是这个套路两行代码缺一不可START: MOV AX, DATA ; DATA 是段名链接后才确定的段基址先送通用寄存器过渡 MOV DS, AX ; 段寄存器只能从通用寄存器或内存装载不能接立即数为什么不能直接MOV DS, DATA因为 x86 的指令编码里段寄存器没有立即数到段寄存器这种形式只有寄存器/内存到段寄存器。而DATA在汇编阶段还不是一个确定数字——它只是段名真正的段基址要等链接器把所有段排好位置才定下来装载时再由 DOS 的重定位机制填进 exe 映像。所以汇编器生成的是B8 xx xxMOV AX, imm16后跟一个重定位项MOV DS,AX则是8E D8两个字节。这两行的机器码加起来 5 个字节一个都不能省。这也是为什么MOV AX,DATA里的 AX 只是过渡容器——它在这一刻不承载任何业务含义纯粹是因为段寄存器不接受立即数而临时借用的。很多初学者把MOV AX,DATA当成把数据段装进 AX然后在后面接着用 AX 算东西结果 DS 是对了AX 的初值却被这段样板代码污染了。我在别的程序里见过因此算错累加和的案例排查半天最后发现是这段几乎所有汇编课本都会抄的开头埋的雷。2.3 三段骨架与 ASSUME 的边界整个程序的结构是三个段DATA 放字符串和变量STACK 放栈空间CODE 放指令DATA SEGMENT INFON DB 0DH,0AH,Please input a year: $ W DW 0 BUF DB 8 DB ? DB 8 DUP(?) DATA ENDS STACK SEGMENT stack DB 200 DUP(0) STACK ENDS CODE SEGMENT ASSUME DS:DATA, SS:STACK, CS:CODEASSUME是纯伪指令不产生任何机器码。它的作用是在汇编阶段告诉汇编器遇到不带段前缀的内存操作数默认按 DS 指向 DATA 段来解释。比如后面MOV AL,[SI]这条指令机器码里根本没有段信息CPU 执行时一律用 DS 作为默认段。如果 ASSUME 写错但运行时 DS 装对了程序照样跑反过来 ASSUME 写对而 DS 没装载取到的就是垃圾。这种两边不一致也能跑/也会崩的耦合正是汇编调试里最需要建立的心智模型伪指令只影响汇编器的判断段寄存器才决定 CPU 的实际行为。栈段那 200 字节也不是随便写的。STACK SEGMENT stack里的stack是段类别名链接器据此把这段标记成栈段DOS 装载时把 SS:SP 指到这段的末尾SP 初值大约是 200。后面 IFYEARS 里连续三次 PUSH 会消耗 6 个字节200 字节的余量绰绰有余。如果这段忘了写或者太小PUSH 就会一路压到别的数据上去表现为程序跑着跑着输出乱码这种崩溃最难查因为它不在出错的那一行报错。3. 键盘到内存INT 21H 的两条功能号和 DATACATE 的累加套路3.1 AH09H$ 才是这段字符串的句号LEA DX, INFON ; DX 指向字符串首地址LEA 取的是偏移而不是内容 MOV AH, 9 ; 功能号 9显示以 $ 结尾的字符串 INT 21H这里有个在高级语言里完全不会遇到的约定字符串的结束标志是$ASCII 码 24H不是长度也不是 0。DOS 的 09H 功能从 DX 指向的地址开始逐字节往屏幕送一遇到$就停。所以字符串内容里绝对不能出现$否则输出会被截断——这个坑在输出金额、符号表之类的内容时特别容易踩。INFON的定义是0DH,0AH,Please input...前面两个字节是回车和换行0DH是 CR把光标送回行首0AH是 LF往下走一行。顺序不能反写成0AH,0DH在很多终端上会看到光标斜着往下跑。把这两个控制字符直接编进字符串而不是单独输出是因为 09H 一次调用就能把换行 提示语全部送出去少一次 INT这在一堆需要反复交互的程序里是实打实的性能考虑。3.2 AH0AHBUF 头两个字节是一份小型协议输入年份用的是 0AH 功能它的缓冲区结构是三段式LEA DX, BUF ; DX 指向缓冲区结构首地址 MOV AH, 10 ; 功能号 0AH带缓冲的键盘输入 INT 21HBUF定义的三个部分各司其职BUF0是调用前由程序填好的最多接受几个字符含回车这里是 8BUF1是调用返回后 DOS 写回的实际读到了几个字符不含回车BUF2开始才是真正的字符内容末尾会保留一个0DH。偏移谁写含义BUF0程序缓冲区容量上限含回车符BUF1DOS实际输入字符数不含回车BUF2起DOS输入内容末尾附0DH这个协议里最反直觉的一点是容量包含回车长度不包含回车。输入2020加回车一共 5 个字符所以容量至少要给 5返回值却是 4。如果按容量就是数字位数来理解给 4 就会在输入四位年份时被拒——DOS 只会收下前 3 个字符最后一个数字根本进不了缓冲区后面的转换自然全错。这种 off-by-one 在缓冲区结构里是常态血泪经验就是看到最大长度这个词先问一句含不含结束符。3.3 DATACATE把四个 ASCII 字节折叠成一个整数年份在内存里是32H 30H 32H 30H即 2020要变成07E4H才能参与除法。这个转换由 DATACATE 完成DATACATE PROC NEAR LEA SI, BUF2 ; SI 指向第一个数字字符 MOV BX, 0 ; BX 充当累加器 X3: MOV AL, [SI] ; 取出一个字符 SUB AL, 30H ; 减去 0 的 ASCII 码得到 0~9 的数字 MOV AH, 0 ; 高位清零把 AL 零扩展成 16 位 XCHG AX, BX ; 交换AX 拿到累计值BX 拿到当前数字 MOV DX, 10 ; 乘数 10 MUL DX ; DX:AX AX * 1016 位乘法结果放在 DX:AX XCHG AX, BX ; 再换回来BX 拿到累计值*10AX 拿回当前数字 ADD BX, AX ; BX 累计值*10 当前数字 INC SI ; 指针后移一位 LOOP X3 ; CX 减 1不为 0 就继续 MOV W, BX ; 结果存进变量 W RET DATACATE ENDP这段代码干的事用一句话概括就是 Horner 展开n ((d1*10 d2)*10 d3)*10 d4。不用四次幂运算只用四次乘十再加这是把十进制字符串转成二进制最省事的写法任何进制转换的底层实现都是这个套路。两次XCHG AX,BX是这段代码最绕的地方根源在于MUL指令的硬性约束16 位乘法只能对 AX 操作结果固定放在 DX:AX 里没有乘以某个寄存器再存回原处的形式。所以只能把累计值搬到 AX、算完再搬回去。反复交换看着低效但省下了额外的内存访问在 8086 这种寄存器稀少的架构上是很典型的取舍。参数上要记清楚入口时 SI 指向第一个数字字符CX 是循环次数也就是位数出口时 BX 就是转换结果同时已经写进了变量 W。这个过程会破坏 AX 和 DX调用方不能指望它们保持原值。3.4 一个位数被塞进 CL 的返回值在调 DATACATE 之前主程序先做了这么一件事MOV CL, [BUF1] ; 把输入位数4送进 CL LEA DI, BUF2 ; BUF2 送进存储器指针 CALL DATACATECL是CX的低 8 位MOV CL, x这种 8 位写操作在 x86 上只改低 8 位高 8 位原样保留。这是 x86 寄存器部分写入特性的直接体现也是整个 bug 的种子如果此刻 CH 里恰好是 0一切岁月静好如果 CH 里躺着上一位主人留下的01H那 CX 就是0104HLOOP 会忠实地循环 260 次而不是 4 次。为什么直接运行时 CH 总是 0因为 DOS 装载一个 exe 时会初始化一批寄存器CX 通常被置 0 或某个固定值。而 Debug 里用 G 命令运行时程序不是被装载器重新初始化过的寄存器里是上一次单步、上一次 INT 21H 调用后残留的状态。同一个 bin两套初始条件这就是双击运行正确、Debug 里错这种看似灵异现象的物理根源。4. 三次 DIV 一条判定链IFYEARS 怎么用 CF 把结果交回主程序4.1 闰年规则落到三次 DIV 上余数就是判据闰年的完整定义是三段式能被 4 整除但不能被 100 整除或者能被 400 整除。这段逻辑被翻译成三条DIV加一串条件跳转IFYEARS PROC NEAR PUSH BX ; 保护三个将被破坏的寄存器 PUSH CX PUSH DX MOV AX, W ; AX 年份 MOV CX, AX ; CX 留一份备份后面每次除法前重取 MOV DX, 0 ; 除法前必须清零 DX否则被当成 32 位被除数的高半部分 MOV BX, 4 ; 除数 4 DIV BX ; DX:AX / BX - 商在 AX余数在 DX CMP DX, 0 ; 余数为 0 说明能整除 JNZ LAB1 ; 不能被 4 整除直接判非闰年 MOV AX, CX MOV BX, 100 ; 第二次判 100 DIV BX CMP DX, 0 JNZ LAB2 ; 能被 4 整除但不能被 100 整除 - 是闰年 MOV AX, CX MOV BX, 400 ; 第三次判 400 DIV BX CMP DX, 0 JZ LAB2 ; 能被 400 整除 - 是闰年 LAB1: CLC ; 清进位标志表示不是闰年 JMP LAB3 LAB2: STC ; 置进位标志表示是闰年 LAB3: POP DX ; 恢复现场 POP CX POP BX RET IFYEARS ENDPDIV的行为是这里最需要钉死的细节16 位除法把 DX:AX 当成 32 位被除数除以 BX 后商放 AX、余数放 DX。也就是说 DX 在除法前是输入被除数的高 16 位除法后是输出余数一进一出角色完全不同。如果第一次除法前忘了MOV DX,0被除数就变成了DX:AX拼起来的几十亿除出来的商极可能超过 16 位能表示的范围CPU 直接触发除法溢出异常在 DOS 下表现为程序毫无征兆地中断、连错误提示都没有。这种崩溃比逻辑错误更让人摸不着头脑。三条 DIV 的顺序也是有讲究的先判 4是因为不能被 4 整除这一条直接排除率最高四分之三的年份都能被这条干掉先判能省两点后续计算。先判 100 再判 400是因为 400 的情况更罕见把它放到最后绝大多数年份根本走不到第三条 DIV。这就是最常见的情况先短路的典型写法虽然在这种一年跑一次的小程序里省不了什么但在判定链动辄七八层的场景里顺序几乎决定性能。4.2 NEAR 与 PUSH/POP裸机上最朴素的调用约定PROC NEAR里的 NEAR 决定了两件事调用方用CALL时只压入 IP2 字节不压 CS子程序返回时用RET弹回 2 字节。如果用 FAR压栈的就是 CS:IP 共 4 字节两个字节的差别在高频调用里也是成本。这个实验里所有子程序都是 NEAR因为它们和主程序分在同一个代码段。PUSH BX / CX / DX和结尾对应的三个 POP是 8086 时代最基本的现场保护方式。哪些寄存器该保护没有硬性规定全凭调用约定DATACATE 破坏了 AX、DX 和 SI而且没做任何保护因为调用它之前主程序对这些寄存器没有依赖IFYEARS 保护了 BX、CX、DX 但没保护 AX因为主程序在拿到 CF 之后只需要判断标志位AX 的内容已经不被关心。这种按需保护的做法在今天看很不规范但它揭示了一个事实调用约定不是语言强加的是调用双方商量的。没有编译器替你记账你就得自己清楚每一处 CALL 之后哪个寄存器还能用。判断标准也很朴素——谁需要跨过 CALL 继续使用某个值谁就负责保护它。4.3 拿 CF 当返回值的省事与代价这个程序最巧妙也最脆弱的设计是闰年判定结果不是放在 AX 或 AL 里返回的而是压在了进位标志 CF 上。主程序用一行JC A1拿走结果CALL IFYEARS JC A1 ; CF1 表示是闰年跳去输出是闰年这么写确实省事CF 天生只有 0/1 两种状态正好对应布尔量判定过程中顺手用STC/CLC就写完了不需要额外的指令去装 AL。但它同时引入了一个隐患——CF 是一个全局共享的状态位任何一条算术、移位、逻辑运算指令都会改写它。如果哪天有人在STC和RET之间插一句MOV AX,CX不影响标志位没事或者CMP AX,0会改写 CF出事返回值就神不知鬼不觉地变了。现代 ABI 之所以把返回值统一放在 AL/EAX 里就是因为它不会被中间指令随手改掉。用 CF 传返回值是 8086 时代的实用主义在那个寄存器比黄金还贵的年代是合理的但今天再看它更像是一个能跑但不好维护的典型样板。4.4 DX 一路没清零却没出事的原因这段代码里有个看起来像 bug 的地方只有第一次除法前写了MOV DX,0第二、第三次除法前都只有MOV AX,CX和MOV BX,xxxDX 没有重置。按前文说的DX 是 32 位被除数的高半部分DX 若不是 0除出来的结果就完全错了。可程序跑是对的。原因藏在控制流的路径约束里。第一次 DIV 之后 DX 是年份 mod 4的余数随后是一条CMP DX,0加JNZ LAB1——只要余数不为 0程序就已经跳走了根本走不到第二次除法。因此能走到第二次除法的那条路径上DX 必然等于 0。同理第二次 DIV 后 DX 是年份 mod 100JNZ LAB2会把非零的情况全部过滤掉能走到第三次除法的DX 又必然是 0。这是典型的路径约束保证不变量的手法不显式清零靠前面的判断保证到达时一定干净。这么写省了一条指令也把一个隐含假设每次进入除法前 DX0散布在三个不同的位置读代码的人必须把整条路径在脑子里跑一遍才能确认它安全。如果哪天有人把JNZ LAB1改成JMP LAB1之前先做点别的或者调整了判定顺序这里立刻翻车。5. 排查Debug 单步时最容易翻车的五个地方这几个坑的共同点是报告出来的现象和真正的原因往往隔了一层不建立起寄存器状态随着指令流转的直觉很容易在错误的地址上反复打转。5.1 T 一头扎进 INT 21H 里出不来现象用 T 单步执行到INT 21H这一行再按一次 T屏幕开始滚动输出一堆看不懂的机器码反汇编程序的执行流像是被劫持了想继续往下走怎么按都回不到自己的代码。原因T 指令的原理是给 CPU 置单步陷阱标志让每条指令执行完都触发一次单步中断。而INT 21H本身也是一条指令它执行完就跳进 DOS 内核的处理程序了接下来 T 停下来的位置是 DOS 内部的第一条指令不是你的下一条。也就是说你没有进错地方是这个工具就这么设计的——它把控制权交给了中断并且中断里那条指令也是一条指令也会被单步陷阱抓住。解决遇到INT 21H、CALL、LOOP这类会改变执行流的指令改用 P。P 的语义是执行到下一条同层指令它会把整个中断处理程序当作一条指令执行完停在你代码的下一行。经验法则是想看自己代码的逐步流转用 T想跨过系统调用和子程序用 P两者交替着用比硬按 T 高效得多。5.2 P 把整个子程序当一条指令吞了现象用 P 去执行CALL DATACATE一按下去BX 直接从 0 变成了07E4H中间四次循环、四次乘十加全都看不见想观察累加过程的愿望落空。原因P 对 CALL 的处理就是跑到子程序返回为止它在子程序末尾的 RET 上设了临时断点等于把整个子程序当成一个黑匣子。对 LOOP 指令也是如此P 会一直执行到 CX 归零、循环真正结束你想看每次循环后的 BX一次都看不到。解决观察子程序内部必须用 T而且要提前算好入口的偏移地址从LEA SI,BUF2那条开始单步。反过来如果只想验证调用后结果对不对而不关心过程P 更省事。两种指令不是替代关系是互补关系外层用 P 快速跳过已确认无误的部分可疑的那段用 T 逐条啃。5.3 只改 CL 不改 CH循环次数变成 256 倍现象输入 2020用 T 单步进入 DATACATE走到MOV CL,[BUF1]之后看寄存器CX 显示0004看起来一切正常。改用 P 跑完整个循环BX 却变成了71B0H而不是应有的07E4H。原因MOV CL,[BUF1]只写了低 8 位。在这个具体的运行环境里CH 恰好残留着01H于是 CX 实际是0104HLOOP 老老实实地循环了 260 次。260 次和 4 次之间差的不是次数是每一次都在BX BX*10 d上继续累积早就溢出了 16 位能表达的范围。这个时候如果只看 CL 的值永远发现不了问题——必须在 R 命令里看完整的 CX 16 位显示而不是只看低 8 位的那个数字。解决在MOV CL,[BUF1]后面补一句MOV CH,0或者干脆先用MOV CX,0再取位数。更稳妥的写法是MOVZX CX,BYTE PTR [BUF1]一条指令完成零扩展。这个问题的隐蔽性在于它取决于运行环境直接双击运行 CX 初值是干净的只有调试器里才会暴露属于典型的环境敏感型 bug。5.4 溢出截断把结果抹平成一个常数现象在 Debug 里不管输入 2020、2021 还是别的什么年份最终都输出This is a leap year!而且每次跑完看到的 BX 都是同一个71B0H。看起来像是输入根本没被读进去但用 D 命令查缓冲区32 30 32 31明明躺在那里。原因这轮循环次数是 260每一轮都在做BX BX*10 d结果被截断在 16 位以内。把它写成模运算就是BX_k ≡ (BX_{k-1}*10 d_k) mod 65536展开后BX_k ≡ Σ d_j · 10^(k-j) mod 65536。关键在于 10 的幂10^m 2^m · 5^m只要m ≥ 16这个乘积就含有 2 的 16 次方因子模 65536 之后恒为 0。换句话说第 k 轮之后只有最后 16 个输入数字还能影响结果前面输入的四位年份被彻底忘掉了。后面那 256 次循环读的是缓冲区之外的内存内容与年份无关于是所有输入都收敛到同一个常数。解决先修 5.3 那个循环次数的问题再谈结果对不对。这一步的价值在于它解释了为什么错误会表现得如此规整——不是随机乱码是一个稳定的错误值。凡是看到输入千变万化、输出恒定不变的现象第一反应就该是查溢出截断而不是怀疑输入没读到。5.5 U 反汇编里对不上的行数现象用 U 命令反汇编屏幕上列出来的指令条数明显比源文件的行数少SEGMENT、ENDS、ASSUME、PROC这些行一个都找不到导致没法按源码行号去对应地址。原因这些全是伪指令只在汇编阶段起作用不生成任何机器码。U 命令是拿内存里的机器码往回翻译机器码里没有的东西它自然显示不出来。所以反汇编看到的永远只是代码段里真实存在的字节而伪指令留下的唯一痕迹是它们造成的段布局和偏移变化。解决把.lst清单文件打开对照左边是源码、右边是机器码和偏移这才是源码与地址之间的权威对照表。另一种办法是用 U 的起止地址分段反汇编比如只反汇编DATACATE那一段把范围缩小到能一眼看完。养成看清单文件的习惯之后定断点、算跳转目标都会快很多。6. 一个 CH 没清零暴露的取舍现场保护、返回值与环境依赖修这个 bug 只要一行MOV CL, [BUF1] MOV CH, 0 ; 补这一行把循环计数器的高 8 位清干净 LEA DI, BUF2 CALL DATACATE但更值得琢磨的是三种修法各自的取舍补MOV CH,0最省事前提是调用方记得 CX 的语义MOV CX,4直接硬编码位数绕开了部分写入问题但把年份必须是四位这件事焊死在代码里MOVZX CX,BYTE PTR [BUF1]一条指令到位是现代汇编里更常见的零扩展写法代价是需要 386 以上的指令集。这个实验的机器和教材以 8086 为准所以第一种最合适。| 修法 | 指令数 | 前提 |本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?