1. 实验目标与三个子程序任务拆解汇编语言学到实验十基本就进入了一个分水岭前面的章节一直在教你怎么用指令、怎么访问内存、怎么写分支和循环但程序稍微一长代码就乱成一锅粥。这个实验的核心就一个字——结构化。用子程序把重复的逻辑封装起来让主程序变得简洁、可读、可复用。王爽教材的实验十要求编写三个子程序显示字符串、解决除法溢出问题、数值显示。三个任务难度是递进的第一个偏“套路”第二个偏“数学”第三个偏“综合”。把它们吃透你对call/ret这对跳转指令的理解以及对栈的运用能力会上一个台阶。先说下我的环境Windows 下用 masm 5.0 编译连接debug 调试如果你用 DOSBox 加 masm或者用其他汇编环境逻辑完全一样只是编译命令略有差异。下文给的都是完整可运行的思路目的不是让你抄代码而是让你搞清楚每一步为什么这么写。提示本文覆盖三个子程序的实现细节、背后的数学原理、调用约定设计以及我实际调试时踩过的坑。建议你手里备着教材边看边对照。2. 子程序的调用机制与参数传递约定2.1 call 和 ret 的底层行为很多初学者写子程序最大的障碍是搞不懂call和ret到底做了什么。其实就两句话call先把返回地址也就是 call 指令下一条指令的地址压入栈中然后跳转到子程序的标号处执行。ret从栈中弹出这个地址跳转回去。因为栈是“后进先出”的所以支持嵌套调用A 调用 BB 调用 C返回时先回 B再回 A。这个机制是所有高级语言函数调用的底层基础。这里有个隐含问题既然call和ret依赖于栈顶那么子程序内部用栈时必须保证返回前栈顶恢复到入栈时的位置。很多初学者在子程序里用push保存寄存器却在某些分支路径上忘记pop导致ret读到错误的返回地址程序直接飞掉。这是实验十最常见的崩溃原因之一。2.2 寄存器传递参数的约定这个实验三个子程序都需要主程序传参数进去。8086 汇编没有高级语言的“形式参数”机制约定俗成的做法是用寄存器传参。教材也是这么设计的show_str用dh传行号0~24dl传列号0~79cl传颜色ds:si指向字符串首地址。divdw用ax传被除数低 16 位dx传高 16 位cx传除数返回时ax存商低 16 位dx存商高 16 位cx存余数。dtoc用ax传要转换的数值ds:si指向存放字符的首地址转换结果以 0 结尾。这些约定不是硬性规定但一定要在子程序开头用注释写清楚否则过两周你自己都忘了哪个寄存器里存的什么。我见过很多同学调试时盯着寄存器窗口发呆就是因为传参约定没记牢。2.3 哪些寄存器需要保护子程序内部通常会用到一些寄存器而这些寄存器的值对主程序可能很重要。所以要做到“不影响调用者”凡是在子程序里被修改、而调用者可能还在用的寄存器都要在子程序开头push保存返回前pop恢复。这个实验里我的原则是除了专门用来返回结果的寄存器以及显式传参的寄存器其他的全保存。比如show_str里会用ax、bx、cx、dx、si其中部分本来就是入口参数但像cx、si这类如果确定不需要保留可以不恢复为了安全起见我通常统一保存。代价是多几条指令但调试时能少掉一大半的麻烦。3. 任务一显示字符串避开“回车换行”的坑3.1 显存映射与光标位置计算show_str的功能在屏幕指定位置用指定颜色显示一个以 0 结尾的字符串。这是三个任务中最简单的一个但涉及一个关键知识点——彩色文本模式下显存地址的计算。显存从B8000H开始每个字符占用 2 字节第一个字节是字符的 ASCII 码第二个字节是颜色属性。80 列 x 25 行所以第dh行、第dl列的字符偏移量是偏移量 (dh * 80 dl) * 2我们要用es:di指向显存目标位置把字符和颜色逐个写入。汇编里乘法不直观所以一般用移位和加法dh * 80因为 80 64 16可以mov al, 0A0hmul dh注意mul的操作数不能直接是内存/寄存器吗可以mul dh表示ax al * dh但要小心mul的结果是 16 位的放在ax里。再加上dl再乘以 2就是最终偏移量。有个更稳的做法用bx保存行偏移si初始化为 0在循环里用mov al, [si]读字符mov es:[bxsi*2]写显存。这样不用每次循环都重新计算偏移量效率高也不会算错。3.2 字符串结束判断与循环退出条件子程序没有显式传入字符串长度所以必须以 0 结尾来检测结束。这也是教材设计成“以 0 结尾”的原因。注意一点判断 0 时一定要用jcxz或者cmp al, 0je不能在循环里把al为 0 时的字符也写进显存。很多同学写成先复制、后判断结果 0 也被写到屏幕上。正确的结构是先取字符、判断是否为 0、非 0 才写入然后loop或jmp继续。如果用loop要先把循环次数和地址都提前算好如果不知道长度用jmp $形式死循环加判断退出更自然。3.3 颜色属性快速记忆cl传颜色属性例如02H是黑底绿字、07H是黑底白字、44H红底红字等。属性字节的低 4 位是前景色高 4 位是背景色。数字可以心算0 黑、1 蓝、2 绿、3 青、4 红、5 品红、6 棕、7 白。我调试时习惯用07H黑白因为白色字在黑色终端上最清晰。等逻辑全通了再换彩色的验证显示效果。3.4 一个容易忽略的细节es 的初始化show_str里要把es指向0B800H。但注意mov es, 0B800H是合法的因为 0B800H 是立即数而mov es, ax也可以。不要写成mov es, 0B8000H——显存段地址是B800H偏移从 0 开始物理地址才拼成B8000H。这个“段地址偏移”的概念不牢后面写显存类程序会经常出 bug。我给出核心代码框架标号注释写得很清楚你可以对照自己的实现检查show_str: push ax push bx push cx push dx push si push di mov ax, 0B800H mov es, ax mov al, 160 ; 每行字节数 80*2 mul dh ; 16位乘法ax 行号 * 160 mov bx, ax ; bx 存行偏移 mov al, 2 mul dl ; ax 列号 * 2 add bx, ax ; bx 最终偏移 mov di, 0 ; 字符串索引源地址从 ds:si 取 mov si, 0 ; 或者直接用 si 做源 show_str_loop: mov al, ds:[si] ; 取字符 cmp al, 0 je show_str_ok mov es:[bxdi*2], al ; 写字符 mov es:[bxdi*21], cl ; 写颜色 inc si inc di jmp show_str_loop show_str_ok: pop di pop si pop dx pop cx pop bx pop ax ret注意es:[bxdi*21]这种寻址方式在 8086 里是不允许的8086 的基址变址寻址不支持“基址变址*比例常量”这种形式只有 386 以上才支持比例因子。所以上面的框架代码只是示意——真实写法要么直接用es:[di]写字符再加要么用两个寄存器分别维护。实在要用 386 扩展寻址就得换addr32但实验环境一般还是 8086别在课堂上这么写。具体的正确的循环体写法我在下面给出。3.5 正确写法用两个指针分别走源和目标我的建议是分开维护源索引si和目标偏移di每次循环源索引加 1、目标偏移加 2。这样既简单又能避开 8086 寻址限制show_str_loop: mov al, ds:[si] cmp al, 0 je show_str_ok mov es:[bxdi], al ; 写入字符 mov es:[bxdi1], cl ; 写入颜色 inc si add di, 2 jmp show_str_loop这里es:[bxdi]是合法的基址加变址寻址。加 1 写属性不要写成es:[bxdi1]之外的形式因为 8086 支持 8 位位移量1没问题但不支持*2。调试这个函数时我建议先用dh12, dl30这样的中间位置测试。如果字符串只显示了一半大概率是循环退出条件写错如果连字符都没有先查es是不是B800H。4. 任务二解决除法溢出核心是数学推导4.1 div 溢出发生的条件8086 的div指令无符号除法格式是被除数默认在dx:ax32 位除数是操作数商放在ax余数放在dx。如果商太大超过ax的 16 位范围大于 65535就会触发除法溢出中断程序直接终止。教材给的数据是1000000 / 10。1000000十进制 0F4240H所以被除数高 16 位dx 000FH低 16 位ax 4240H。如果直接div cxcx 10商是100000超过 16 位最大 65535必溢出。这个实验的意义就在于此div指令本身处理不了“商超过 16 位”的情况但我们可以把除法拆成多次 16 位除法每步的商都控制在 16 位以内。4.2 公式拆解高位数除法 低位修正教材给出的推导思路大家一般称为“溢出除法公式”是这样的设被除数为X X_H * 65536 X_L除数为N16 位。我们想求X / N。第一步用高 16 位去除X_H / N Q_H 余 R_H这里Q_H是 16 位的商高位R_H是余数必然小于 N16 位能装下。第二步把余数并入低位原商的低位部分应该是(X_L R_H * 65536) / N注意现在被除数是(R_H 放在 dxX_L 放在 ax)组成的 32 位数正好可以直接用一次div。商就是最终结果的低 16 位余数就是整个除法的最终余数。所以完整结果商 Q_H * 65536 Q_L余数 R4.3 用实例算一遍1000000 / 10用具体数据走一遍被除数高 16 位dx 000FH低 16 位ax 4240H。mov cx, 10先mov ax, dx把高位移到axxor dx, dx然后div cx。这条除法是0000F4240H?不对——要先把ax dx高 16 位dx 0所以第一步除的是0 000FH / 10商ax 0001H余数dx 0005H。此时ax 1这是高 16 位商最终结果的商高位dx 5。把dx5和原来的低 16 位4240H组成 32 位被除数dx 0005Hax 4240H。再div cx。第二步除法0005 4240H 344064除以 10商ax 8613H十进制 34406余数dx 0004H。最终商 高 16 位0001H放dx低 16 位8613H放ax即0001 8613H 100000余数 4。验证1000000 / 10 100000 余 0等等我算错了1000000 / 10 整除余数应该是 0。问题出在哪第二步的 342? 重新算x 1000000N 10。第一步X_H dx 0F H 1515 / 10商 1 余 5。这一步ax 1, dx 5。第二步把被除数变成dx 5, ax 4240H也就是5 * 65536 16960 327680 16960 344640。344640 / 10 34464余数 0。34464 十六进制是86A0H。那为什么我刚才算出8613H因为0x4240 16960不是 16960 吗对344640 / 10 3446434464 的十六进制是86A0H不是8613H。这里我不能再犯计算错误所以代码注释里最好直接用div指令别手工算错。但这里有一个关键点dx:ax第二步的除法中dx 0005H, ax 4240H商一定小于 65536 吗不一定极端情况下可能接近 65535 以上。教材的场景里因为第一次除法的余数R_H N所以R_H * 65536 X_L最大也就是(N-1) * 65536 65535除以 N 最大 65535 65535/N可能略大于 65535 导致溢出。所以第二步也可能溢出吗测试一下N 65535X_H 65534X_L 65535。第一步65534 / 65535 0 余 65534第二步被除数 65534 * 65536 65535 4294901761? 这除以 65535 接近 65536 可能溢出。所以公式并非绝对安全教材的 divdw 例子里给的公式其实要求第二步被除数也满足商 65536让我细想。其实书上的公式是这样的divdw用三次div而不是两次回忆王爽《汇编语言》实验十的 divdw 方案是x / n, x 为 32 位n 为 16 位 结果商 32 位余数 16 位 公式 int(H/N) * 65536 [ (rem(H/N) * 65536 L) / N ]其中int为整数商rem为余数[ ... ]里的除法还是一步 32 位除以 16 位商低 16 位可能出现溢出吗 书中给的思路是由于rem(H/N) N所以rem(H/N)*65536 L的最大值是(N-1)*65536 65535当 N 足够大时除以 N 可能超过 65535。但书中此处的隐含条件不是 N 任意大而是我们希望最终商高 16 位存 dx且此算法只是“利用两次除法”如果第二步溢出说明原始商本来超过 32 位也不对16 位除数除 32 位数商最多 32 位不会超过 32 位。再算一下极限N 65535rem(H/N) 65534则分子的上限约 4294901760除以 65535 65536.? 计算一下65535 * 65536 4294901760加 65535 4294967295? 实际上最大值 (N-1)6553665535 N65536 -1 4294967295除以 N 65536 - 1/N 65535.999。这小于 65536所以商低 16 位最大 65535不会溢出我搞错了(N-1)*65536 65535 65536*N - 1除以 N 略小于 65536商为 65535余 N-1不会溢出。所以第二步的商严格小于 65536安全。之前忘了这个代数恒等式这里算清楚很关键。也就是说这个公式是严谨的因为rem * 65536 low最大是N*65536-1所以除以 N 的商最大是 65535余数最大 65534或 N-1反正商 65536绝不会溢出。这样两次div就是安全的。把这个数学关系写进博客里理解记忆会深刻很多。4.4 divdw 子程序的标准写法根据上面的思路代码可以这样组织divdw: push bx push di ; 保存会用到的寄存器 mov bx, ax ; bx 暂存低 16 位 mov ax, dx ; 高 16 位移到 ax mov dx, 0 ; 第一步被除数高位置 0 div cx ; ax int(H/N), dx rem(H/N) push ax ; 保存商高位 QH mov ax, bx ; 恢复低 16 位 ; 注意 dx 已经是第一步的余数正好作为第二步被除数的高 16 位 div cx ; 32 位被除数 dx:ax 除以 cx mov cx, dx ; 余数放入 cx pop dx ; 商高位放入 dx剩下 ax 是商低位 pop di pop bx ret这里几个细节要说清楚第一步div cx之前必须保证被除数dx:ax是H本身也就是dx 0, ax X_H。因为X_H本身是 16 位被除数就是高 16 位那个数自己。第二步的div指令会自动使用dx:ax作为被除数。如果第一步结束后没有修改dx那么dx正好就是第一步的余数拿mov ax, bx恢复低 16 位后dx:ax正好是rem*65536 low直接div cx就行。返回约定是dx存商高位、ax存商低位、cx存余数这也是教材指定的。我代码里用push ax暂存商高位是因为第二步除法必须用ax恢复低 16 位等第二步完事再从栈里把商高弹回dx。这个“栈里转一圈”的技巧非常典型理解栈的用途就看这里。4.5 验证与自查写完divdw后建议跑三个用例1000000 / 10期望商100000余数0。1000000 / 100期望商10000余数0。0FFFFH / 1期望商65535余数0。这个用例能验证第一步商高位为0、第二步商低位直接等于原始低 16 位的情形成立。如果第三个用例显示结果不对多半是第二步之前dx被污染了。这种边界用例比正常数据更能暴露问题。5. 任务三数值显示综合考验栈与循环5.1 转换思路除 10 取余从低位到高位dtoc的功能是把一个 16 位无符号数转换成十进制字符串。十进制转换的标准思路是“除 10 取余”每次用 10 去除当前数得到的余数加上30H就是对应数字字符的 ASCII 码商继续循环直到商为 0。问题是第一次取到的余数是最低位比如1234第一次余数是4我们想存成1,2,3,4,0这样的顺序。如果在内存里从前往后写顺序就反了。所以教材的提示是把每次得到的余数依次压栈全部转换完后再依次弹出并写入目的字符串。栈的后进先出特性天然解决了这个问题。这也是实验设计的高明之处——不是让你写一个复杂的反转逻辑而是借助栈这个现成的“倒序缓冲”。5.2 循环嵌套与寄存器分配的冲突dtoc里有两个循环外层循环处理每一位每次除 10内层循环把栈里的字符弹出并写入ds:si指向的缓冲区。两个循环都用cx必须小心冲突。我的建议是外层循环不用loop用jmp加判断内层循环记录栈里压了多少个字符用一个计数变量比如bx或bp来管理然后用loop或者继续jmp。具体流程dtoc: push ax push bx push cx push dx push si push di mov bx, 0 ; bx 记录压栈字符个数 mov di, 0 ; di 记录写入目标字符串的偏移 dtoc_loop: mov dx, 0 ; 被除数高 16 位清零 mov cx, 10 div cx ; ax 商, dx 余数 add dx, 30H ; 转 ASCII push dx ; 压栈保存 inc bx cmp ax, 0 jne dtoc_loop ; 商不为 0 继续 dtoc_pop: pop dx mov ds:[sidi], dl ; 弹出字符写入目标 inc di dec bx jnz dtoc_pop mov byte ptr ds:[sidi], 0 ; 补结尾 0 pop di pop si pop dx pop cx pop bx pop ax ret有几个易错点div cx前dx必须清零否则被除数会带着残留的高位结果完全不对。add dx, 30H是用dl转 ASCII但push dx会把整个dx压进去不干净。更稳的是mov dl, xxx后push dx或者先push dx再处理确保多余高 8 位不影响后续。我建议压栈前把dh清一下mov dh, 0。弹栈写字符时ds:[sidi]这种寻址也是合法的sidi是基址变址。mov ds:[sidi], dl没问题但注意这条指令不能写成mov ds:[sidi], dh之外的形式而忘记只传dl。最后一定要在字符串末尾写上0否则show_str会读到垃圾内存显示出一堆乱码。5.3 主程序串联三个子程序三个子程序平时可以单独测最终要合在同一个程序里跑。建议主程序流程main: ; 显示字符串验证 mov dh, 12 mov dl, 30 mov cl, 07H mov si, offset msg_hello call show_str ; 除法溢出验证 mov ax, 4240H mov dx, 000FH mov cx, 10 call divdw ; 此时 dx 商高位, ax 商低位, cx 余数 ; 可以再调用 dtoc 把结果显示出来注意要组装成 32 位或先用 16 位版本 ; 数值显示验证 mov ax, 12666 mov si, offset buf_num call dtoc mov dh, 13 mov dl, 30 mov cl, 07H mov si, offset buf_num ; 显示转换结果 call show_str mov ax, 4C00H int 21H注意show_str要求ds:si指向字符串而dtoc也是借助ds:si写结果两者可以串联。建议测试时先只测前两个确认没问题后再把结果显示出来否则容易出现“子程序本身对了但显示函数有问题”的排查混乱。5.4 显示除法结果的 32 位数难点教材要求的 divdw 返回 32 位商但 dtoc 接收的是 16 位ax。如果想完整显示商就需要额外处理把dx作为高 16 位、ax作为低 16 位再用“除 10 取余”的 32 位除法循环输出。这在后续扩展里是个很好的练习但实验本身一般只要求数值小于 65535 时的显示所以我建议分两步先用验证数据确认 divdw 结果存在寄存器里是对的再单独用 16 位数测 dtoc 显示。等两个都稳了再去打通 32 位显示。我后来自己扩展过一个版本每次除 10 时直接调用 divdwx当前值N10余数就是一位比单独处理 32 位除法简单得多。有兴趣的可以试试循环里始终维护 32 位被除数每次调用 divdw把余数cx压栈商继续当被除数。这样 divdw 就成了一个通用的“32 位除以 10”工具代码也很顺。6. 常见问题与调试技巧实录6.1 编译连接与运行环境的坑写汇编最典型的报错是error A2006: undefined symbol我遇到过最多的原因是标号名打错show_str和showstr是两个不同标号。大小写不一致masm 默认不区分大小写但某些版本配置不同。数据段里定义的变量名和子程序标号重复。连接时如果报LINK : fatal error L1101通常是end后还留了多余代码或者没有指定入口。检查一下程序的end标号是否正确。运行阶段如果程序一跑就死机或无响应没有别的招就是用 debug 逐行单步。重点看call进子程序前寄存器值是否符合预期ret回来后ip是否正常。6.2 寄存器污染导致的“灵异现象”我调试divdw时遇到一次单独测试子程序没问题但放到完整程序里结果就错。后来才发现是show_str里我用cx传颜色而divdw返回余数也放在cx主程序调用顺序没理清导致颜色被改掉。解决办法是明确约定每个子程序入口前主程序负责把所有入口参数按约定填好调用返回后如果要继续使用之前的寄存器先保存。子程序内部随意用寄存器没关系但返回时必须保证除了出口参数外其他可能被调用者使用的寄存器恢复原样。这看起来像是“各管各的”但真正写汇编的人都知道约定就是纪律。没有纪律程序越小越容易乱。6.3 显存里出现乱码的排查方法显示乱码通常有三个来源字符串没以0结尾越界读到显存或者别的数据。颜色属性写成字节还是字的问题导致显存错位。写入位置算错把字符串写到屏幕边缘看起来像没显示。排查时可以先用debug查看显存对应地址的内容比如目标在0B800H 偏移用d命令看有没有期望的 ASCII 码。如果能看见字符数据但屏幕没显示多半是颜色属性为00H——黑底黑字肉眼完全看不出来。我踩过这个坑当时还以为是显存地址算错了查了半小时。6.4 快速验证写一个小型的“单元测试”汇编没有现成的单测框架但你可以自己写一个简单的“断言语段”每次子程序返回后比较关键寄存器值如果不对就直接退出并显示错误标记。比如cmp cx, 0 jne test_failed ; 继续其他断言当然实验阶段更多是用 debug 手动看寄存器。我的建议是每完成一个子程序立刻用边界数据测试不要等到三个全写完再统一调试。三个 bug 同时出现时你根本分不清是谁的问题。6.5 几个让我印象深刻的小坑mul和div都是隐式使用ax/dx的写之前一定要确认这两个寄存器里没有你还需要的值。push的次数必须和pop的次数严格相等。我见过有人子程序里条件分支只pop了一半返回地址被当数据弹出程序跳到一个垃圾地址。offset在数据段内取标号偏移没问题但如果你把字符串定义在代码段里ds不一定指向它。字符串数据一定要放在data segment并正确初始化ds。7. 调试实录一个典型的 bug 修复全过程分享一个我最近带别人做实验十时遇到的实际问题。他的divdw在输入4240H低和0000H高除以0AH时正常但换到1000000 / 10就崩溃。我单步跟进去看发现他第一步除法前忘了把dx清零而是直接用了ax里的值当高 16 位导致真正计算的是0F4240H / 10而不是0FH / 10。这就是“除法溢出”没被解决但以为自己解决了。修好后他第二步又出了问题他把第一步的商和余数顺序搞反了写的代码是先把商存起来再拿余数去当低 16 位的一部分——逻辑完全反了。后来我让他把每一步用笔在纸上算一遍把寄存器值写出来再对着代码查才找到错在哪。这种错误在汇编实验里极其常见。原因就是div指令的隐式操作数太多不像高级语言一个变量名写清楚就完了。所以我建议你调试时在关键位置插入几条mov指令把寄存器值搬运到显存或者直接暂停观察不要只盯着 debug 寄存器窗口。如果你是新手我强烈建议把这一步当成一个“用纸笔模拟 CPU 执行”的练习。拿实验十的数据手动算一遍每条指令执行后寄存器ax、dx、cx、sp、ip和栈顶的值写满一张纸。等你推完一遍call/ret和div就再也不会成为障碍了。8. 三个子程序的综合对比与进一步扩展对这三个子程序做一个横向对比可以帮助你理解它们各自的难点和彼此之间的依赖关系子程序核心知识点主要难点经典坑show_str显存映射、字符与颜色属性地址计算忘记写颜色属性导致黑底黑字divdwdiv 溢出机制、高地位拆分数学公式推导忘记清零 dx 导致溢出未解决dtoc除 10 取余、栈后进先出倒序问题忘记补结尾 0 导致乱码实验十的巧妙之处在于三个子程序环环相扣dtoc转换出的字符串可以用show_str显示divdw的 32 位商又可能需要dtoc来转成十进制字符串。实际工作中“格式化输出”和“大数除法”就是这么组合使用的。扩展方向上你可以尝试把show_str改为支持透明背景、闪烁效果甚至做一个简单的文本滚动。把divdw推广为 32 位除以 32 位的通用除法思路是把 64 位被除数拆成更多段多次div。把dtoc改成支持有符号数、16 进制或二进制的转换输出前自己控制样式。这些扩展都不难但每一个都能把你对 8086 的寄存器、内存和栈的理解再压实一层。很多人问学 8086 汇编有没有用我的答案是它虽然指令集老但“子程序调用栈管理内存布局异常处理”这套思想是理解现代处理器和操作系统的基础。你能把实验十完整做对再看高级语言的函数调用栈、递归和系统调用都会有“原来如此”的通透感。最后给一个实用建议不要在代码里把三个子程序全都写完再调试。先写show_str用固定字符串验证再写divdw用两个边界用例验证最后写dtoc把它和前面两个串联起来。每次只改一个变量出了问题立刻能定位。这个“最小可验证单元”的习惯做实验和写真实项目都适用。
阅读完成 · 觉得有帮助?