汇编子程序实战:参数传递、寄存器保护与call/ret控制权交接 1. 实验十的本质不是“写函数”而是理解控制权交接很多人第一次拿到“编写子程序”这个实验第一反应是“这不就是高级语言里的函数吗”。这个直觉没有错但用高级语言的思维去做汇编子程序往往会摔得很惨。高级语言里你只需要关心函数名和参数列表编译器悄悄帮你处理好了调用、传参、寄存器保护、返回地址保存这些琐事而汇编里没有编译器替你兜底你必须手工控制每一件事其中最核心的一件事就是“控制权怎么交出去又怎么交回来”。子程序的运行机制本质上是一次精确的“借东西”过程。主程序执行到call指令时相当于把当前正在读的书签页下一条指令的地址压进堆栈然后跳转到子程序入口子程序执行到ret指令时再从堆栈里把书签页弹出来回到主程序继续往下走。这个过程极其简单但正是这个“压入返回地址-跳转-弹出返回地址-跳回”的循环构成了所有函数调用的物理基础。1.1 一个最朴素的子程序label与ret先看一个最简版本如果要在屏幕上显示一个字符通常可以调int 21h的02h功能但实验中更常见的是直接写显存。假设我们想写一个子程序在指定位置显示一个字符。; 子程序在屏幕(3,5)位置显示字符A show_a: mov ax, 0B800h mov es, ax mov bx, 3 * 160 5 * 2 ; 第3行第5列对应的显存偏移 mov byte ptr es:[bx], A mov byte ptr es:[bx1], 07h ; 属性黑底白字 ret主程序要使用它只需call show_a这段代码执行完毕后控制权回到call的下一条指令。注意子程序内部没有使用任何栈操作call和ret是成对自动工作的call压入返回地址ret弹出返回地址。这里没有“函数名”“作用域”的概念子程序入口就是一个普通的label返回就是一条ret。实验十的第一个目标就是让你彻底接受这个“裸奔”的调用方式。1.2 子程序要解决的三个问题先别急着写代码一个合格的子程序必须回答三个问题第一入口怎么约定。调用者要告诉子程序“做什么、对谁做、做成什么样”这就是参数传递。实验里最常见的做法是用寄存器调用者把数据放进ax、bx、cx、dx子程序从这些寄存器里取值算完后再把结果放回寄存器。第二哪些寄存器会被改动。子程序为了完成工作不可避免要用某些寄存器暂时存数据。如果它把调用者辛辛苦苦放进cx的循环次数改了调用者回来一用就出错。所以必须约定子程序用到的寄存器进去前要先保存在堆栈里返回前再恢复做到“用完像没用过一样”。第三返回结果放哪里。子程序执行完结果要有一个固定位置让调用者拿。通常也是寄存器比如ax放数值结果dx放高位结果或者通过内存缓冲区返回。实验十的经典题目往往包含三个子程序字符串显示、除法溢出处理、数值转字符串。它们正好覆盖这三种能力第一个考验参数传递和显存操作第二个考验对除法指令误区的理解第三个考验循环和栈的综合运用。接下来说说这三个题到底在考什么。2. 经典实验题拆解显示字符串、除法溢出与数值转换我见过很多同学一上来就拿着三个子程序的题目硬啃啃到最后也没明白为什么要这么设计。实际上这三道小题是一条逻辑链你要把内存里的一串数据变成屏幕上用户可以读到的文字就需要先把数字转成字符串再把字符串写到显存而数字转换过程中又很容易遇到除法溢出的问题。把它们放在同一个实验里就是让你体验一次完整的“数据到显示”流程。2.1 show_str按指定行列输出字符串题目通常这样描述在屏幕的第row行、第col列用指定颜色显示一个以0结尾的字符串。字符串起始地址在ds:si行列号和颜色属性通过寄存器传入。这里的技术难点有两个显存地址换算8086的文本模式显存从0B800h段开始每一行占160字节前两个字节对应一个字符低位字节是ASCII码高位字节是颜色属性。第row行第col列的位置就是row * 160 col * 2。循环终止条件字符串以0结尾所以每次都读一个字符判断是否为0是则停止否则写入显存并继续。一个可用的实现长这样; 参数dh行号(0~24), dl列号(0~79), cl颜色属性, ds:si字符串首地址 show_str: push ax push bx push cx push dx push si push es mov ax, 0B800h mov es, ax mov al, 160 mul dh ; ax 行号 * 160 mov bx, ax mov al, 2 mul dl ; ax 列号 * 2 add bx, ax ; bx 显存偏移 mov ah, cl ; ah 颜色属性 show_str_loop: mov al, [si] ; 取一个字符 cmp al, 0 je show_str_end mov es:[bx], ax ; 写入ASCII和属性 inc si add bx, 2 jmp show_str_loop show_str_end: pop es pop si pop dx pop cx pop bx pop ax ret注意这里不能用mov byte ptr es:[bx], al之后单独写属性吗可以但用mov es:[bx], ax一次写入两个字节会更紧凑。前提是长度和属性要一次性算好ah放颜色al放字符。这个细节实验里很多人忽略了结果屏幕上出现“花字”。2.2 divdw让32位除法不再溢出第二个题目的背景是div指令做除法时如果被除数的高位大于等于除数商会溢出触发int 0中断。默认情况下这个中断没有处理程序程序会直接崩溃。但在真实业务里我们经常要处理“一个大数除以一个小数”的场景比如计算总字节数除以扇区大小。实验要求写一个子程序divdw用16位除法模拟32位除以16位把结果正确算出来而不溢出。原理并不神秘核心是拆开分步除。设被除数是一个32位数X 高16位 * 65536 低16位除以16位的N。我们可以借助这样两步高16位 / N 得到商的高16位余数R 将 R 作为高位拼上低16位组成一个32位中间数再除以 N 得到商的低16位和最终余数这里的数学依据是小学就学过的长除法每一步除完余数留给下一位继续除。8086的div虽然只能做16位除以16位但我们可以让每步的商都不超过65535从而避免溢出。具体实现; 参数dx高16位, ax低16位, cx除数N ; 返回dx商的高16位, ax商的低16位, cx余数 divdw: push bx push ax ; 保存低16位后面再用 mov ax, dx ; 先算高16位部分 mov dx, 0 div cx ; ax 高16位的商dx 高16位的余数 mov bx, ax ; 暂存商的高16位 pop ax ; 取回被除数的低16位 ; 此时 dx 是上一步余数作为高16位与 ax 一起做第二次除法 div cx ; ax 商的低16位dx 最终余数 mov cx, dx ; 余数放到 cx mov dx, bx ; 商的高16位放到 dx pop bx ret这段代码看下来你会发现它没有一句多余的话第一次除完dx自然变成了余数第二次除法直接以这个dx作为被除数的高16位完美衔接。如果把pop ax放在div cx之前顺序错了结果就全乱了。这个实验的精髓就在于这种“步骤间的数据接力”。2.3 dtoc把二进制数转成ASCII字符第三个子程序要求把ax里的16位二进制数转换成十进制字符串放到缓冲区最后配合show_str显示在屏幕上。原理是反复除10取余数每次dx存余数把数字0~9加上30h变成ASCII字符。典型的“反了”版本是这样的第一轮除10得到最低位先变成字符放进缓冲区第二轮得到十位依次往后。最后缓冲区里是“个十百千”倒序的需要逆序之后才能正着显示。很多实验指导书上会建议用栈来做逆序每得到一位就压栈全部除完后弹出到缓冲区。; 参数ax待转换数值, ds:si缓冲区地址 dtoc: push ax push bx push dx push si mov bx, 10 loop_dtoc: mov dx, 0 div bx ; 除10dx各位数字(0~9)ax商 add dl, 30h ; 转成ASCII字符 push dx ; 暂时压栈 cmp ax, 0 jne loop_dtoc ; 到这里所有字符都在栈里栈顶是最多位 pop_dtoc: pop dx mov [si], dl inc si ; 判断栈是否弹空这里需要记录压栈次数 ; 可以通过loop方式也可以压一个结束标记 ; 简化写法在进入循环前数出数字位数并用cx记录 loop pop_dtoc mov byte ptr [si], 0 ; 字符串结尾 pop si pop dx pop bx pop ax ret注意这里我省略了一个环节压栈时要用cx记录总共压了多少位弹出时再用loop控制次数。如果压入的数量和弹出的数量对不上要么显示乱码要么留下一堆栈垃圾直接影响后面的子程序调用。3. 参数传递的取舍寄存器、内存还是堆栈前面三个子程序我统一使用了寄存器传参。这是实验里最方便的做法但绝不代表它是唯一选择。实际编程中参数传递有三大流派各有利弊搞清楚了才算真正掌握子程序。传递方式优点缺点适用场景寄存器传参速度快代码短寄存器数量有限嵌套调用时容易冲突参数不超过三四个的简单子程序内存传参不占用寄存器可传大数据块必须约定内存地址容易和内部临时缓冲区混在一起字符串、数组等批量数据堆栈传参几乎无限容量调用前后主程序不用特意保护取参数麻烦代码可读性下降参数很多或子程序要递归调用时3.1 寄存器传递快但容易脏寄存器传递的好处是直观dh行、dl列、cl颜色、ds:si字符串看一眼调用处就知道传了什么。坏处也明显8086一共就那么几个通用寄存器如果子程序内部还要用ax做乘法、用bx做偏移、用cx做循环计数、用dx存余数很快就发现“寄存器不够用”。举例来说divdw内部要同时暂存商、余数、被除数低16位如果调用者把参数放在dx:ax里子程序一进来就必须用push把低16位保存起来否则第二次div前会丢掉。这种“偷寄存器修为”的做法不写注释的话看代码的人很容易一头雾水。3.2 堆栈传递麻烦但干净堆栈传参的做法是调用者把参数逐个push进栈然后call子程序子程序通过bp访问栈里的参数。8086里bp默认和ss段配合它不像sp那样会自动变化所以常用bp做栈帧定位。一个典型例子把字符串起始地址和长度通过栈传给子程序。调用者这样写push ds push si push cx call show_str_stack add sp, 6 ; 调用后恢复栈顶子程序内部这样取show_str_stack: push bp mov bp, sp ; 现在栈布局为 ; bp2 返回地址 ; bp4 cx ; bp6 si ; bp8 ds ; 取参数时要用 [bp...]这种方式的优点是参数随时可以压更多不担心寄存器冲突缺点是一旦call把返回地址压进来所有参数的偏移量都要往后挪一位写错一个偏移量整个程序就崩。我个人的建议是实验阶段先用寄存器传参把逻辑跑通等学有余力再改成堆栈传参体会两者差异。3.3 我的建议实验阶段按题目约定来大多数实验十的评分标准其实已经把参数约定写死了比如“dh行号、dl列号、cl颜色、ds:si字符串地址”。这时候纠结用哪种传参方式没有意义跟着题目约定走最省事。但你自己要清楚这些约定本质上是“调用者和子程序之间的合同”你完全可以在自己的项目里重新设计合同。理解这个思想比记住某一种写法更重要。4. 寄存器保护不坑调用者的第一课子程序刚写好的时候很多同学会犯同一个错误主程序里用cx控制某个循环循环体里call了一个子程序结果子程序内部也用cx做了另一个循环。主程序的循环次数直接变成垃圾数据程序要么死循环要么提前退出。这个错误的根源就是子程序“污染”了调用者的寄存器。4.1 哪些寄存器必须保护8086环境下通常约定ax、bx、cx、dx、si、di这些通用寄存器只要子程序用到就必须在入口push、出口pop。ds、es这类段寄存器如果子程序里改了更是必须恢复否则调用者后续访存的位置全变了。只有两种情况可以不保护子程序的返回值本身就在这个寄存器里。比如divdw约定返回结果在dx:ax那返回前当然不能把ax恢复原样否则结果就被覆盖了。调用者和子程序事先约定某些寄存器是“自由寄存器”。比如某个项目统一规定ax和dx是可以随便改的调用者不会依赖它们的原值。这种约定在大型项目里很常见但实验阶段不建议自己搞老老实实全保护。4.2 一个典型的错误现场我见过一份学生代码主程序里用cx做外层循环显示三行字符串子程序里用cx做内层循环逐个输出字符。结果第一行显示完cx已经变成了0外层循环直接结束后两行字符串根本显示不出来。他的代码大致长这样mov cx, 3 next_line: ; 设置行号、列号、字符串地址 call show_str inc dh loop next_line而show_str内部有类似mov cx, 100的循环也没有保护。修复方式很简单在show_str入口把所有用到的寄存器压栈出口再按相反顺序弹回来。注意push和pop顺序必须对称先压谁就后弹谁不然寄存器恢复就会错位。show_str: push ax push bx push cx push dx push si push es ; ... 主体逻辑 ... pop es pop si pop dx pop cx pop bx pop ax ret这个习惯越早养成越好。你写一个子程序可能只要半天但调试“寄存器被莫名改掉”的问题可能要花好几天。与其事后追查不如一开始就把保护写全。5. 一步步实现与测试完整代码和踩坑记录把三个子程序串起来通常会做一个综合演示把内存中的几个数字转换成十进制字符串再按指定行列显示到屏幕上。完整流程分三步定义数据段存放待显示的数字、缓冲区、字符串。主程序依次调用dtoc转换数字再用show_str输出。调试过程中处理好divdw与dtoc的协作关系因为dtoc除10也可能溢出吗不会只要被除数本身小于65535就不会溢出。但如果ax的值很大循环累计的除法不会导致溢出因为每次div bx的商都小于等于上一步的商。这个可以放心。一个最小可运行框架assume cs:code, ds:data, ss:stack data segment num dw 12345 buf db 8 dup (0) str_end db 0 data ends stack segment db 16 dup (0) stack ends code segment start: mov ax, data mov ds, ax mov ax, stack mov ss, ax mov sp, 16 ; 转换数字 mov ax, num mov si, offset buf call dtoc ; 显示字符串 mov dh, 5 mov dl, 10 mov cl, 02h ; 绿色 mov si, offset buf call show_str mov ax, 4c00h int 21h ; 包含三个子程序 ; dtoc、show_str、divdw code ends end start5.1 主程序怎么调用三个子程序注意上面框架中dtoc和show_str的参数不会冲突dtoc用ax和sishow_str用dh、dl、cl、si。这里si都在传地址恰好一致很多同学会因此觉得巧合很自然。但如果dtoc内部把si移动到了缓冲区末尾show_str再读si就会从错误位置开始。所以主程序在两次调用之间要重新给si赋值这一点最容易漏。5.2 测试用例设计边界值必须覆盖实验报告里只写“运行成功”没有说服力。我会建议至少测试这些数0转换结果应该是字符“0”循环一次就结束。9只有一位数字。10验证进位是否正常。65535最大值验证多次除10后商和高位是否清零。32768最高位为1的负数如果实验要求按无符号数处理应该显示32768而不是-32768。把每个测试数存到num里反复改、反复看屏幕输出基本能把dtoc的隐藏问题全暴露出来。5.3 三个经典翻车现场我调试这份实验代码时最常遇到三种问题分享一下排查思路第一字符串显示成一片雪花。现象是屏幕上出现大量彩色马赛克通常是显存写入的属性字节错了或者循环里add bx, 2忘了写导致后续字符覆盖了第一个字符的位置。重启程序看第一行是否只有第一个字符正确如果是多半是偏移没递增。第二divdw返回结果不对。比如输入0FFFFh * 10000 1234h除以10期望结果是4294967290除以10得到的429496729但程序返回了一个奇怪的小数。这时候我建议人工用计算器算一遍然后设置断点检查第一次div后的ax和dx。第一步商如果大于65535说明你用了dx:ax里残留的高位记得先把dx清零再除。第三栈不平衡导致返回地址错乱。如果ret之后程序飞了要么是ret前多了pop要么是push和pop数量不匹配。排查时看sp的值如果在子程序入口和出口要保持一致可以在入口先mov bp, sp返回前再cmp sp, bp验证。实战中我用过“先写注释再写代码”的办法在每段push下面标一个计数注释里写明当前栈深度这样基本不会错。6. 子程序的嵌套与递归实验之外的延伸实验十通常不强制要求写递归但只要你理解了call/ret的机制递归就是自然延伸。递归子程序在每次调用自己之前必须先把当前的参数和局部状态压栈否则下一次调用会覆盖上一次的数据。一个简单的递归示例计算ax的阶乘不讨论溢出。factorial: cmp ax, 1 jbe factorial_done ; 如果ax 1返回1 push ax dec ax call factorial pop ax mul cx ; 这里假设结果放在cx乘上当前ax ret factorial_done: mov cx, 1 ret注意mul cx会默认使用ax作为被乘数结果在dx:ax这里如果不在意高位溢出只取ax。递归的每一层都在栈上保存了当前的ax返回后再逐层相乘。这个模式和dtoc里用栈保存字符是同一个思想栈就是子程序的“草稿纸”随用随取用完即弃。如果你把实验十的三个子程序和递归小节合并起来看会发现一个共同主线所有看起来复杂的汇编程序本质都是“数据搬运控制权跳转”的组合。子程序只是把跳转变得可复用、有契约入口地址固定、参数约定明确、寄存器使用有承诺。所谓“接口设计”在汇编里就是这么直白地摆在你面前。最后分享一个我调试这类程序时的小技巧别怕在关键位置加临时的显示调试信息。写一个极简的show_char子程序只显示一个固定位置的字符然后在怀疑的代码前后各调一次分别写入不同字符。如果call完子程序字符不对说明子程序有问题如果调用前字符就不对说明主程序参数传递出错。这种二分定位法比盯着屏幕猜快得多。汇编调试本质上是“眼见为实”看不到的寄存器状态都是猜测只有真正显示出来的才算数。