汇编调试翻车记:CX寄存器高位未清零引发的闰年判断bug

📅 发布时间:2026/10/17 20:48:26
汇编调试翻车记:CX寄存器高位未清零引发的闰年判断bug
简介围绕北京交通大学汇编与接口课程的Debug调试实验这是一份面向汇编语言初学者与微机接口实验学习者的操作文档。资源以leapYear.exe闰年判断程序为案例系统梳理了汇编源程序的编译、链接与运行流程并深入讲解如何在Debug工具中逐句分析代码逻辑、设置断点观察寄存器与内存状态使用D、T、C、P等指令单步执行程序。文档同时记录了闰年与非闰年两类输入的调试输出过程并针对代码中的典型逻辑错误给出修改与重新编译运行的操作指引能够帮助读者掌握汇编程序排错思路与底层执行细节。资源包为1个docx文档大小78.94MB目录结构完整内容覆盖编译链接、代码分析、Debug实操与错误修改四个模块。已有521人学习/下载适合需要完成同类上机实验或想借助Debug工具提升汇编调试能力的同学参考。1. 程序能跑但一调试就错leapYear.exe 的 CX 寄存器之谜写汇编实验时最常见的翻车现场不是编译不过而是程序单独运行一切正常一进 Debug 就行为诡异。这次要复盘的是某高校汇编与接口课程里的闰年判断实验程序 leapYear.exe它用 masm 编译、链接一路顺利直接运行输入 2020、2026、2025、2100 结果全对可一旦用 debug.exe 的 G 命令运行不管输入什么年份输出都变成闰年。折腾到最后才发现问题出在一个看似无害的寄存器——CX 的高位 CH 没有清零。这个坑在汇编与接口的调试实验里几乎人人都会踩一次也特别适合用来理解 R、T、P、G、E 这些命令到底在干什么。本文写给正在做上机实验、被“运行正确但调试出错”折磨的开发者。2. 编译链接全通过进了 Debug 就翻车先分清直接运行与 G 命令运行2.1 masm 编译到链接leapYear.exe 是怎么生成的这个实验从编译环境准备开始。先把 masm5 文件夹放到 C 盘根目录打开命令行进入对应目录用 masm 命令编译源文件。编译完成后按参考书的步骤建立交叉索引文件再执行 link 进行链接。cd C:\masm5 masm leapYear.asm link leapYear.obj编译的作用是把汇编源码翻译成机器码目标文件链接则把目标文件里的段和符号整合成可执行文件。链接成功后masm5 文件夹里会生成一批后缀不同的文件它们各自承担不同的角色。生成文件含义与用途leapYear.obj目标文件汇编器输出链接器的输入leapYear.lst列表文件源程序与机器码的对照表leapYear.ref / .crf交叉索引文件记录符号与交叉引用关系leapYear.map映像文件记录段地址与符号分布leapYear.exe最终可执行文件很多人拿到 .exe 就直接运行忽略了 .lst 和 .map。但在排查地址类问题时这两个文件能帮你把 Debug 里的反汇编地址与源码行对应起来。我一般会把 .lst 保留到整个实验结束后面用 U 命令反汇编时对照着看能省去不少数地址的精力。这是微机接口类课程里非常基础但容易被跳过的一步。2.2 直接运行四组年份2020、2026、2025、2100 全对先不碰调试器直接运行 leapYear.exe。输入 2020屏幕输出 This is a leap year!输入 2026输出 This is not a leap year!输入 2025同样不是闰年再输入 2100依然不是闰年。2100 这个用例是有讲究的。按“四年一闰”的直觉2100 能被 4 整除很多人会以为它是闰年。但闰年的准确定义是能被 4 整除且不能被 100 整除或者能被 400 整除。2100 能被 100 整除但不能被 400 整除所以它不是闰年。程序在 2100 上给出正确结果说明它内部的判断逻辑覆盖了这个边界条件。程序开头的数据段定义了几个关键变量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(?)INFON、Y、N 是三条提示字符串前缀的 0DH、0AH 分别代表回车和换行保证输出自带换行。BUF 的布局很典型第一个字节 8 表示缓冲区最大能收 8 个字符第二个字节 ? 由系统在接收输入时填充实际字符数后面 8 DUP(?) 是预留存储区。输入 2020 后BUF1 位置被填成 04BUF2 开始依次存放 2、0、2、0 的 ASCII 码。这些内存布局在后面用 D 命令查看时能直接对应上。主程序流程先输出提示语再接收输入存入缓冲区然后调用 DATACATE 把字符串年份转成十六进制存入 W 变量调用 IFYEARS 判断是否闰年最后根据 CF 标志位决定输出哪条语句。MOV AX, DATA MOV DS, AX ; 数据段寄存器指向 DATA 段 LEA DX, INFON ; 提示语有效地址送入 DX MOV AH, 9 INT 21H ; 显示提示字符串 LEA DX, BUF MOV AH, 10 INT 21H ; 接收键盘输入到缓冲区 MOV CL, [BUF1] ; 实际输入字符个数送入 CL LEA DI, BUF2 CALL DATACATE ; 字符串转数字结果存 W CALL IFYEARS ; 判断是否闰年结果用 CF 表示 JC A1 ; CF1 则跳转到输出闰年语句有一个细节容易忽略MOV AX, DATA不是把立即数传给 AXDATA 是段地址符号必须先经 AX 中转再传给 DS因为段寄存器不能直接接收内存数。LEA DX, INFON是把 INFON 的有效地址装入 DX配合MOV AH,9 / INT 21H显示字符串。此时整个程序给人的印象是逻辑清晰行为正确。2.3 翻车现场Debug 的 G 命令运行后所有年份都显示闰年按照实验要求进入 Debug 调试先用 R 命令查看寄存器现场。此时能看到 CX 一开始并不是 0IP 指向 0000代码段从某个段地址开始CS:IP 共同指定了当前要执行的指令位置。随后用 U 命令反汇编可以看到每一条汇编指令对应的内存地址。这一步非常关键在 Debug 里地址是 CS:偏移 的二维结构不像高级语言调试器那样有源码行号。用 U 反汇编后你才知道 IP 下一步会停在哪也才能在 T 单步时看懂跳转方向和循环入口。问题出现得很隐蔽。直接运行完全正常的程序用 G 命令一次性跑完输入 2021结果还是 This is a leap year!。继续测试 2020、2026同样都是闰年。所有输入得到相同输出这不是判断逻辑该有的表现。我把现象总结成一句话直接运行的命运和 Debug 下的命运不一样说明程序对环境有隐藏依赖。顺着这个方向查最先怀疑的就是寄存器初始状态。R 命令看到的 CX 非 0 是一个重要线索但当时还没把它和 LOOP 循环联系起来。直到用 T 命令单步跟踪 DATACATE 子程序看到 71B0H 这个诡异的最终值问题才真正浮出水面。3. DATACATE 循环里的黑匣子T 命令单步看清字符串转数字3.1 ASCII 转数字SUB AL,30H 的换码思路DATACATE 的职责是把缓冲区里的年份字符串转成十六进制数。数字字符和数值之间有一个固定偏移0 的 ASCII 码是 30H1 是 31H依此类推。因此SUB AL,30H就能把字符码转换成真正的数字。但单字符转换只是基础多位数的整串转换要靠循环累加。DATACATE PROC NEAR LEA SI, BUF2 ; SI 指向第一个年份字符 MOV BX, 0 ; BX 作为累加器初始为 0 X3: MOV AL, [SI] ; 取当前字符 SUB AL, 30H ; 减去 30HASCII 转数字 MOV AH, 0 ; AX 当作 16 位数高位补 0 XCHG AX, BX ; 交换AX 里变成旧累加值 MOV DX, 10D MUL DX ; AX 旧累加值乘以 10 XCHG AX, BX ; 换回AX 为乘 10 结果BX 为当前数字 ADD BX, AX ; BX BX * 10 当前数字 INC SI ; 指向下一位字符 LOOP X3 ; CX-1非 0 则继续循环 MOV W, BX ; 保存转换后的年份 RET DATACATE ENDP代码里有两个容易看晕的地方。第一个是 XCHG 交换指令第一次 XCHG 把 AX 里的当前数字和 BX 里的累加值互换目的是让 MUL DX 能用 AX 做被乘数。第二个是 MUL DX 的 16 位乘法语义结果存放在 DX:AX 双字中因为年份乘 10 后仍远小于 65536DX 恒为 0乘积只看 AX 就行。第二次 XCHG 把累加值换回 BXADD 完成 旧值×10新数字 的累加。为什么要用 MUL 而不是加法模拟乘法因为 8086 的 MUL 指令本身就是为这类计算设计的配合交换指令恰好能实现迭代公式。这套写法在汇编里属于非常典型的字符串转整数套路实验报告里不少同学试图用 ADD 累加 10 次代替乘法结果又引入新的进位问题反而更难调。3.2 四位年份的累加过程BX 从 0002H 到 07E4H用 T 命令单步执行可以直观看到 BX 的变化。T 指令一次执行一条指令然后停下来显示寄存器当前值。以输入 2020 为例循环次数当前字符转换后数字BX 累加结果十六进制12220002H200200014H32220200CAH400202007E4H第一次循环时AL 先变成 32H2 的 ASCIISUB AL,30H 后 AL2AH0AX0002H。经过交换、乘 10、再交换、累加BX 变成 0002H。第二次循环取到 0转换后 AX0BX 先被换进去乘 10 变成 0014H加上 0 仍是 0014H。第三、第四次循环重复相同过程最终 BX 变成 07E4H即 2020 的十六进制表示。T 指令在进入 DATACATE 之前正常可用它会跟着 CALL 进入子程序内部所以你能看到每一句的执行。注意一个前提——T 能进入子程序P 不能。后面排查循环问题时正是利用了这个区别来确认循环到底执行了多少次。3.3 71B0H 之谜CL04 但 CH 非零LOOP 多跑了 260 圈问题发生在循环控制上。程序里只写了MOV CL,[BUF1]把输入字符个数 04 放进 CL。CL 是 CX 的低八位CH 是高八位。如果进入子程序前 CH 恰好是 0那么 CX0004HLOOP 正好执行 4 次但如果 CH 不是 0比如 CX 是 0104H那么 LOOP 就要执行 0104H 次也就是 260 次循环。Debug 加载程序时CX 保存的是调试器现场的残留值并不保证是 0。用 R 命令查看初始寄存器时就看到了非 0 的 CX但当时没把它和循环计数联系起来。等到 T 单步走进 DATACATE发现 CL 已经变成 04循环跑了 4 次之后并没有停LOOP X3 还在把 IP 拉回 X3。继续观察BX 的值一路疯涨最后稳定在 71B0H。71B0H 的十进制是 29104。这个结果对四位年份几乎一样因为每次循环 BX BX*10 当前数字超过 260 次循环后乘法和加法的溢出不断把 BX 高位信息抹掉只剩低 16 位最终收敛到同一个 71B0H。无论输入的是 2020 还是 2021转换结果都一样后端的闰年判断自然给出相同结论。71B0H 再经过 IFYEARS 的除法判断恰好让 CF 被置 1于是所有输入都输出闰年。要验证这个判断有个非常高效的实验进入 Debug 后用 R 命令把 CX 改成 0000再重新运行。结果程序马上恢复正常2021 正确输出 不是闰年。这说明 DATACATE 的算法本身没有问题唯一的问题就在 CX 的高位没有初始化。这是典型的环境依赖型 bug——直接运行时进程环境的 CX 恰好为 0Debug 环境下的 CX 不巧不是 0于是程序在调试器里翻车。4. IFYEARS 闰年判断用 E 命令改内存验证 CF 标志位分支4.1 三级判断除 4、除 100、除 400 的边界覆盖IFYEARS 子程序接收 W 中的年份通过三条除法指令完成闰年规则。每次除法产生的余数放进 DXCMP DX,0 比较余数是否为 0再决定跳转到 LAB1非闰年出口还是 LAB2闰年出口。IFYEARS PROC NEAR PUSH BX PUSH CX PUSH DX ; 保存现场返回前恢复 MOV AX, W ; 年份送入 AX MOV CX, AX ; 备份年份DIV 会破坏 AX 和 DX MOV DX, 0 MOV BX, 4 DIV BX ; AX / 4商入 AX余数入 DX CMP DX, 0 JNZ LAB1 ; 余数非 0必不是闰年跳 LAB1 MOV AX, CX ; 恢复年份 MOV BX, 100 DIV BX ; AX / 100 CMP DX, 0 JNZ LAB2 ; 能被 4 整除但不能被 100 整除是闰年 MOV AX, CX MOV BX, 400 DIV BX ; AX / 400 CMP DX, 0 JZ LAB2 ; 能被 400 整除是闰年 LAB1: CLC ; 非闰年CF 清零 JMP LAB3 LAB2: STC ; 闰年CF 置 1向下落入 LAB3 LAB3: POP DX POP CX POP BX ; 恢复现场 RET IFYEARS ENDP这个判断顺序完整覆盖了闰年规则能被 4 整除且不能被 100 整除是闰年能被 100 整除的年份只有同时能被 400 整除才是闰年。例如 2000 能被 400 整除是闰年1900 能被 100 整除但不能被 400 整除不是闰年2024 能被 4 整除但不能被 100 整除是闰年。有三条细节需要特别注意DIV BX 执行的是无符号除法。年份 W 必须视为无符号数四位年份最大 9999远小于 65536不会产生除法溢出。DIV 之后 AX 是商、DX 是余数原来的年份被破坏所以每次判断前都要从 CX 备份里恢复 AX。CMP DX,0 只影响 ZF 标志位JNZ 根据 ZF 决定跳转。这里没有比较大小只判断余数是否 0。4.2 主程序的最终分支JC A1 与输出语句切换IFYEARS 返回之后主程序用 JC 指令读取 CF 标志。JC 是有进位则跳转CF1 跳到 A1 标签输出闰年语句CF0 则顺序执行输出非闰年语句。JC A1 LEA DX, N ; 非闰年字符串 MOV AH, 9 INT 21H JMP EXIT ; 跳过 A1直接退出 A1: LEA DX, Y ; 闰年字符串 MOV AH, 9 INT 21H EXIT: MOV AH, 4CH INT 21H ; 返回 DOS在 Debug 里跟踪这段代码能看到标志位的变化过程。输入 2020 时2020 除以 4 余数为 0ZF1JNZ 不跳转继续除以 100余数 20ZF0JNZ 跳到 LAB2STC 之后 CF 从 NC无进位变成 CY有进位。主程序的 JC A1 看到 CY跳到闰年输出。对于 2021除以 4 余数为 1JNZ 直接跳到 LAB1CLC 清掉 CFJMP LAB3 跳过 STC最终 CF0输出非闰年。进子程序时 PUSH 了 BX、CX、DX返回前 POP 恢复。这个现场保护不是可有可无的——主程序后续还要用寄存器处理数据如果子程序改了 BX 不恢复主程序继续跑就会踩坑。我见过不少实验报告里漏掉 POP导致程序第一次调用正常、第二次调用结果全乱。4.3 E 命令改内存隔离法把 W 改成 07E4H / 07E5H为了坐实问题只在 DATACATE、不在 IFYEARS实验里常用 E 命令直接修改内存中的 W 变量。转换正确时 2020 对应 07E4H。既然 DATACATE 被循环溢出搞乱了那就绕过它用 E 命令把 W 改成已知值-e 0C53:0050 07 E4这个例子里的地址需要根据 R 命令看到的 DS 段地址来确定实际调试时以你自己的 DS:偏移为准。改完后继续运行程序输出 This is a leap year!与 2020 的真实预期一致。再把 W 改成 07E5H也就是 2021运行后输出 This is not a leap year!。这个实验至少证实了两件事。第一IFYEARS 的闰年判断逻辑完全正确无论是否能被 4 整除的分类、能被 100 整除的剔除还是能被 400 整除的恢复分支方向都对。第二真正出错的是 DATACATE 的循环控制。E 命令这种改内存跳过嫌疑代码的思路在汇编调试里非常实用不重编译、不重启程序直接改变程序要处理的数据观察行为变化定位速度比通读代码快一个数量级。5. Debug 调试避坑记录寄存器状态、INT 21H 中断与 LOOP 循环这一章把实验中反复踩过的坑集中记录下来。每个坑都按现象 → 原因 → 解决的顺序写方便你对照自己的调试过程。5.1 现象T 指令步入 INT 21H 中断后无法继续从 START 开始用 T 单步执行到MOV AH,9 / INT 21H这句时T 会一头扎进中断处理程序内部屏幕出现大量系统代码你自己的程序反而没得看了。原因INT 21H 是 DOS 系统调用指令它会把 IP 引导到中断向量表指向的中断服务程序。T 的语义是执行一条指令然后停下它不知道这条指令是否是系统调用于是把中断服务程序也逐条执行了。解决遇到 INT、CALL 这类指令改用 P。P 同样只执行一步但它把 INT/CALL 当作一个整体执行完整个调用后停在下一句。习惯用法是想观察自己的子程序内部细节时用 T想跨过系统中断时用 P。这个区分在整轮调试里反复用到。5.2 现象P 指令跨过 LOOP 后 BX 变成 71B0H用 T 在 LOOP X3 这句上反复按每次循环体都会重新执行想快速跑完循环很累。改用 P 跨过循环后以为能直接看到 BX07E4H结果却是 71B0H。原因P 能跨过循环但它只是把剩余迭代一并执行完并不能修复错误的循环条件。LOOP 的退出条件是 CX0而 CX 初值高位不是 0。即使 CL 已经到 0CX 整体还是 CH:00 的值非零于是继续循环直到执行完 0104H 次才停下。P 跨过循环只是把这次多跑的循环一次性执行完结果自然是错。解决不要用 P 跨循环来挽救错误的计数器。先在循环入口查看 CX 的完整值用 R 命令确认 CH 是否为 0如果不为 0要么用 R 命令手动修正后继续调试要么回源码加MOV CH,0重新编译。这里得到的经验是只要 LOOP 的循环次数不对先查 CX 的十六位整体而不是只盯 CL。5.3 现象G 命令运行出错而直接运行正常这是整个实验最反直觉的现象。leapYear.exe 不经过调试器直接运行四个测试年份全部正确一旦用 debug.exe 加载G 命令跑完输出全乱。原因Debug 加载程序时寄存器现场来自调试器环境。CX 在这里保存了与文件加载有关的长度信息或者其他调试器临时值并非 0。源程序只写了MOV CL,[BUF1]没有对 CH 置 0于是 CX 高位残留了调试器的值。直接运行时DOS 加载程序后的初始 CX 恰好为 0所以侥幸正确。解决分两个层次。第一层在代码层面彻底修复给 CL 赋值后补一条MOV CH,0保证 CX 在任何环境下都是确定的。修改后的代码段如下MOV CL, [BUF1] ; 取输入字符个数例如 04 MOV CH, 0 ; 修复CX 高位置 0保证 LOOP 只循环 4 次 LEA DI, BUF2 CALL DATACATE修改后重新用 masm 编译、link 链接、运行。这次分别在直接运行和 Debug 环境下输入 2020、2021、1900、2100结果完全一致。第二层在调试层面建立习惯进入 Debug 后第一步用 R 命令查看所有相关寄存器的初始值尤其是代码里要用的 CX、DX、SI、DI再决定要不要先修正。不能假设任何寄存器在程序入口有特定值这是汇编程序的基本素养也是这次实验最大的收获。5.4 现象R 命令改完寄存器没有继续运行修改被还原在验证把 CX 改成 0000 程序就正常这个结论时有一个操作顺序的坑。用 R 命令修改 CX 后如果直接按 Q 退出或者重新加载程序修改值不会保留。R 命令只修改当前调试现场的寄存器内容一旦程序重新加载寄存器又会变成加载初值。正确做法是R 命令修改后用 G 或 T 命令在当前现场继续执行让修改后的值参与后续运算。例如把 CH 改成 0 后输入年份再 G 命令跑到输出才能看到修复效果。5.5 Debug 命令速查本实验用到的全部指令这轮调试几乎把 Debug 的常用命令用了一遍整理成一张速查表做汇编实验时可以直接对照命令功能本实验中的用途R查看/修改寄存器查看 CX 初始值修改 CH 验证修复U反汇编将机器码转为汇编指令定位代码地址T单步执行进入子程序/中断跟踪 DATACATE 循环内部P单步执行跨过子程序/中断跨过 INT 21H快速跳过一次循环G连续运行可带断点地址运行整个程序或跑到指定地址停下D查看内存查看 BUF 中的字符和 W 变量的值E修改内存修改 W 为 07E4H / 07E5H 做隔离验证F填充内存将一串连续内存单元填成相同数据A汇编写入在内存中直接写入机器码指令H十六进制计算计算两个十六制数的和与差Q退出退出 debug.exeG 命令可以带断点地址比如G 0018程序会运行到 0018 地址停下。这个技巧在定位输出分支时很好用可以跳过整段输入转换直接看结果。整个闰年问题的排查从头到尾都是这几个命令的组合。6. 快速定位寄存器初始化问题三步排查法这类直接运行正常、Debug 下错乱的问题绝大多数根因是寄存器状态假设错误。现在我遇到类似现象不会再漫无目的地翻代码而是固定走三步。第一步观察寄存器现场。程序加载到 Debug 后立即用 R 命令记录 CS、IP、AX、BX、CX、DX 的初始值。接着用 U 命令反汇编从上到下数出所有在代码中出现过的寄存器。对每一个寄存器问自己它进入这段代码前是否被完整赋值了特别是那种只赋低位的写法比如MOV CL,[BUF1]要立刻意识到高位存在隐患。验证方法很简单用 R 命令把 CH 改成 0重新运行如果结果恢复正常就证明 CX 高位是唯一变量。第二步控制单步粒度。用 T 一步步走进循环体观察 LOOP 指令的跳转行为。正常的四次循环按四次 TIP 会从 X3 走完循环体后往下继续。如果按了四次 TIP 还在 X3说明循环次数偏多。这时候用 R 查看 CXCH 非 0 就可以实锤。想快速跳过剩余循环在 LOOP 上按 P想进入子程序内部看细节在 CALL 上按 T。这两种指令的分工就是为这种场景设计的。第三步用 E 命令做数据层排除。当问题锁定在 A 模块和 B 模块之间时用 E 命令直接修改内存里的关键变量跳过可疑模块看后续模块是否正常。在这个实验里我把 W 改成 07E4H、07E5H分别得到闰年和非闰年的正确输出从而把 IFYEARS 完全排除最后集中精力查 DATACATE 的循环控制。核心思想是用修改数据的方式把嫌疑模块和无辜模块切开而不是反复整体重启调试。修复验证不能只测一组数据。我建议准备一份边界用例表每个用例对应一条独立分支年份判定规则期望结果2000能被 400 整除闰年2020能被 4 整除且不能被 100 整除闰年2021不能被 4 整除非闰年1900能被 100 整除但不能被 400 整除非闰年2100能被 100 整除但不能被 400 整除非闰年直接运行和 Debug 运行各测一遍五组全过才算修复彻底。只用 2020 和 2026 两组很容易漏掉 1900、2100 这种边界分支换个环境可能又翻车。从那以后我每次写含 LOOP 的汇编程序都会在循环前面强制检查一遍完整的寄存器赋值CX 要确认十六位都在预期值DI、SI 要确认指向的缓冲区有效BX 要确认后续不会被子程序改坏。Debug 工具的宝贵之处就是把这些平时看不见的寄存器状态暴露出来前提是你愿意先看现场再下结论。希望这篇复盘能帮你在下次遇到运行正常、调试翻车时少绕几个弯子。本文还有配套的精品资源点击获取