汇编语言做到实验十,其实已经到了一个很有意思的节点。前面几节课,你大概已经折腾过数据传送、算术运算、分支循环这些基本指令,写出来的程序都是从头到尾一顺溜的“线性代码”。但真正的程序,哪怕是几千行的真实工程,也不可能全挤在main逻辑里一路写到底,这时候就需要一个机制,把重复的、独立的、逻辑上能拆开的段落给抽出来——这就是子程序。
我自己的经验是,能不能把子程序写好,是判断一个人是不是真正上手汇编的分水岭。子程序背后牵扯的栈操作、现场保护、参数传递,本质上就是“调用约定”的雏形,这些思想往后的C语言函数调用、API设计、甚至操作系统里的过程调用,全都在用。这篇内容就按实验十的实际操作路径来聊,从机制原理讲到完整参考代码,再把调试中容易踩的坑一次性说透。
1. 实验定位:汇编子程序到底是什么
很多人在学这门课的时候都会想:子程序不就是高级语言里的函数吗?确实,功能上很像,但汇编的子程序有一个非常核心的区别——它没有任何语法层面的“参数表”和“返回值”概念。调用者和子程序之间靠什么沟通?答案是:靠约定。
这个约定,可以是大家都默认用哪个寄存器传参,可以是事先约定好一个内存地址存数据,也可以是把参数压进栈里让子程序自己去取。没有编译器帮你检查,也没有类型检查,写错了就是运行时的逻辑错误,程序不会在编译阶段拦住你。这也是为什么子程序实验的评分点往往不是“能不能跑通”,而是“寄存器有没有保护好”“栈是否平衡”这些平时不容易注意的细节。
1.1 这次实验解决的实际问题
你做前面的实验时,可能会遇到一个非常烦人的场景:程序里需要把一个二进制数转成十进制显示出来,你在主流程里写了一遍;过了没几行,又需要再显示一个数,你又写了一遍;再显示一次,再写一遍。代码越复制越长,改一处错就得改三四处。
子程序要解决的就是这个问题:把你需要重复使用的代码段,写成带名字的独立模块(比如DisplayNumber、ClearScreen、FindMax),主程序里只需要一句CALL就能调用它。好处有三点:
- 代码量显著减少,程序结构变得清晰,别人一眼就能看明白你的主流程干了什么。
- 修改和定位问题更方便,某个功能写错了,只需要改子程序内部,不用满世界找重复代码。
- 为以后的模块化、多人协作打基础,每个人负责几个子程序,最后拼起来就是完整程序。
1.2 前置技能自查清单
动手写子程序之前,我建议你先确认自己已经掌握了下面几个基础点,不然实验过程中很容易纠结:
- 堆栈段SS:SP和PUSH/POP指令的使用。子程序的返回值地址就存在栈里,栈看不懂,子程序就没法真正理解。
- 常用寄存器:AX、BX、CX、DX、SI、DI,特别是CX作为计数器配合LOOP的使用。
- 寻址方式:寄存器间接寻址[BX]、[SI]、[DI],以及OFFSET获取变量偏移地址。
- INT 21H的常用功能调用。其实你前面已经反复用过系统提供的“现成子程序”了,比如mov ah, 02h + int 21h显示字符,这本质上就是一个远调用。实验十只是从“用”变成“自己写”而已。
2. CALL/RET 的底层运行机制
这次实验虽然名字是“编写子程序”,但我认为真正的知识点核心其实是两个指令:CALL和RET。这两个指令的长度很短,可背后藏着一套栈的完整运作逻辑。好多同学第一次写子程序栽跟头,就是只记了“CALL去调用,RET就返回”,却没弄明白中间这趟栈上的旅程到底是怎么走的。
2.1 调用发生时CPU做了什么
当程序执行到CALL指令时,CPU要做的其实是两件事:
- 把CALL指令下一条指令的地址(也就是返回地址)压入堆栈。
- 然后修改IP,跳转到子程序的入口地址去执行。
注意这个顺序:先“留后路”,再“跳走”。这个后路,也就是返回地址,被存在了栈顶上。RET指令刚好相反,它从栈里弹出这个地址,送回IP,于是程序就回到CALL下一条指令继续执行。
用生活化的方式理解,就像你出门办一件事,出门前把“办完事要回家”这个地址写在纸条上,贴在门框上(压栈),然后放心出去办事(跳转),事办完看一眼纸条(弹栈),顺着地址走回家(RET)。要是你出门前没贴纸条,办完事就只能站在大街上发呆——程序就飞了。
这就是为什么在子程序里写PUSH和POP必须成对出现。你压了一个寄存器没弹回来,RET执行时弹出的就不是你当初存的返回地址,而是那个寄存器的值,程序直接跳到莫名其妙的地址去了,轻则乱码,重则死机。
2.2 段内调用与段间调用的差异
实验里大部分情况的子程序和主程序都在同一个代码段里,这是段内调用,也就是NEAR CALL。这种情况下,压入栈的就是IP一个字的偏移地址(2字节)。
但如果你用到了多个代码段,子程序定义时标了FAR属性,或者调用一个外部段里的子程序,这时CPU需要保存的除了IP还有CS,也就是4字节的返回地址,这种叫段间调用/FAR CALL。在程序里写CALL的时候不需要太纠结,只要记得子程序定义前后的属性要和调用的地方匹配就行。MASM里你写PROC时默认一般是NEAR,如果你的子程序要被别的段调用,就要写成类似:
PublicSub PROC FAR ... ret PublicSub ENDP不然可能会链接报错或者运行时返回地址错乱。这一点在处理大型实验作业的时候容易遇到,尤其是多个源文件链接的场景。
2.3 现场保护的本质是栈帧管理
子程序内部难免要用一些寄存器来做临时计算。问题是,调用者那边可能正在用AX存一个重要结果,你子程序里一上来就mov ax, 1234h,把人家数据冲掉了,返回之后后续计算结果全错。怎么办?约定俗成的做法是:子程序开头把要用到的寄存器统统压栈,返回之前再逆序弹出来。这个压栈的寄存器集合,加上返回地址,就构成了这个子程序的“栈帧”。
写的时候有个高频错误:PUSH了三个寄存器,收尾时只POP了两个,或者顺序写反了。记住PUSH和POP遵循严格的“后进先出”顺序,先PUSH的必须最后POP:
MySub PROC push ax push bx push cx ; ... 执行过程 ... pop cx pop bx pop ax ret MySub ENDP顺序一旦错,恢复出来的寄存器值就是乱的,返回地址也可能被弹错位置。另外要特别提醒,CALL和RET本身不会保存标志寄存器FLAGS,如果子程序里改了标志位而调用者还需要用它判断结果,那就要在子程序入口pushf、返回前popf,或者由调用者在调用前自己保存需要关注的那些标志位。类似ZF、CF这种,都比较容易踩坑。
3. 参数传递:寄存器、堆栈还是全局变量
子程序要干活,光有个空壳不行。比如你要让子程序计算最大值,那至少得告诉它“数据在哪、长度多少”。这就是参数传递。汇编里没有现成的形参实参语法,所以必须按约定来传。常用的有寄存器传参、堆栈传参、固定内存地址传参,各有各的适用场景。
3.1 寄存器传参:最直接也最常见
所谓寄存器传参,就是调用前把参数放进特定寄存器,子程序里去读这些寄存器。比如:
mov ax, 1234h ; 参数1:待显示的数 call DisplayNumber ; 子程序读AX完成显示这是最简单、速度最快、实验中最常用的方式。缺点也很明显:寄存器总数有限,如果参数太多(超过五六个),寄存器根本不够分配。还有一层风险是子程序容易“顺手”把传入的寄存器改掉,如果调用者后面还要用这个寄存器里的原值,就很被动。所以实际写法中,要么子程序开头就保护自己不打算返回的参数寄存器,要么调用者自己心里有数,调用完重新赋值。
我的习惯是,参数比较少(两三个以内)、又是单值运算的子程序,优先寄存器传参。比如把AX传进去当操作数,然后通过AX或DX把结果带出来。这样代码读起来最省事,调试时也一目了然。
3.2 堆栈传参:参数多起来时的正道
参数一多,寄存器的办法就不行了。这时可以把参数先PUSH进栈,再CALL子程序。子程序内部用BP来访问这些参数。这里有一个非常关键的“位置差”:你压完参数之后,CALL会压入返回地址,所以子程序进来时栈顶是返回地址,而参数在返回地址的下面。
为了让这个逻辑清晰,子程序第一步通常这样做:
StackParamSub PROC push bp ; 保存原有BP mov bp, sp ; BP指向当前栈顶 push ax push bx ; ... mov ax, [bp+4] ; 访问第一个压入的参数(16位模式,返回地址占2字节) mov bx, [bp+6] ; 访问第二个参数 ... pop bx pop ax pop bp ret 4 ; 返回并废弃4字节的参数(如果是C语言风格) StackParamSub ENDP这个写法里,[bp+4]这个偏移你得自行推理,很容易晕。我建议动手画一张栈变化图:从参数入栈开始,到CALL压入IP,到PUSH BP,再到PUSH AX之后,SP和BP的相对位置每一步都画出来。用偏移量访问时,只要记住BP始终指向保存的原BP值,往上走就是返回地址,再往上就是调用者压入的参数。
如果子程序使用的是RET n这种带操作数的返回指令,RET会顺便把栈顶上的n字节参数“清理”掉,这样调用者压了几个参数,子程序返回后栈就自动恢复平衡。这个对实验里的远调用、重定位传递很实用。
3.3 三种传参方式的选择
我自己在给学生讲、以及平时写汇编实验时,会给他们一个简单的取舍思路:
表格:
| 传参方式 | 实现成本 | 运行速度 | 可读性 | 适用场景 |
|---|---|---|---|---|
| 寄存器传参 | 最低 | 最快 | 最高 | 参数少,单值运算,比如数值显示/运算 |
| 堆栈传参 | 中高 | 稍慢 | 中 | 参数多,或者需要支持嵌套/递归 |
| 固定内存地址 | 最低 | 快 | 低 | 全局数据块、大块缓冲区,比如数组排序 |
固定内存地址传参通常就是定义一个全局变量,比如CountValue,调用前把数据放那儿,子程序直接访问变量名。简洁是简洁,但会导致子程序和全局数据耦合,可移植性差,多人协作容易互相覆盖。实验里做那种单文件作业还行,真实场景就别这么干。
4. 实验参考实现:经典子程序逐步拆解
下面我给出两个比较有代表性的子程序例程。一个是数值显示子程序,另一个是数组最大值查找子程序。这两个分别覆盖了“计算结果输出”和“批量数据遍历”的经典逻辑,实验题也经常拿它们当模板。
4.1 例1:把AX中的二进制数转十进制并显示
这个子程序相当经典。8086的INT 21H的02H功能一次只能显示一个字符,而且显示的是ASCII码。所以要把AX里的二进制数转成十进制显示,你必须把每一位数字逐位拆出来,加上30H变成字符,再一个个输出去。比如AX=1234H(十进制4660),你最终要输出的字符是'4'、'6'、'6'、'0'这4个字符。
参考代码(MASM/TASM风格):
;------------------------------------------------------------ ; 子程序名:DispNumber ; 功能:将AX中的16位无符号二进制数以十进制形式显示 ; 入口参数:AX = 待显示的数值(0~65535) ; 出口参数:无(但会改变DX、CX、AX、BX、DI) ;------------------------------------------------------------ DispNumber PROC push ax ; 保护调用者寄存器 push bx push cx push dx push di mov di, 10 ; 十进制基数 xor cx, cx ; 用CX当“位数计数器” ; 循环1:连续除10取余,把每一位的余数压入栈 ; 注意DIV指令的操作数:被除数在DX:AX,所以除法前必须先清零DX DigitLoop: xor dx, dx ; 关键!清DX,否则会自动当作被除数高16位 div di ; 32位(DX:AX) / 10 => AX=商,DX=余数 push dx ; 余数入栈(这是从低位到高位的顺序,栈会把它倒过来) inc cx ; 位数加1 cmp ax, 0 jne DigitLoop ; 循环2:从栈里逆序弹出每一位并显示 ShowLoop: pop dx add dl, 30h ; 余数0-9转换成ASCII码'0'-'9' mov ah, 02h int 21h loop ShowLoop pop di pop dx pop cx pop bx pop ax ret DispNumber ENDP这个子程序里值得品味的点很多,逐条说:
- 为什么用DIV前一定要xor dx, dx:因为DIV指令做的是“32位除以16位”,被除数默认是DX:AX拼起来的一个32位数。如果DX残留了之前的数据,除法结果就会莫名其妙的大错。这是做除法子程序最典型的低级坑。
- 为什么用压栈存余数而不是数组:除法得到的余数顺序是从个位开始的,输出却要从最高位开始,正好先进后出。用栈天然解决了这个问题,完全不用再开缓冲区。这个思路非常值得学习,很多“逆序输出”问题都可以用栈来反转。
- 为什么PUSH DX而不PUSH DX的某种转化值:因为DIV之后余数就在DX,十进制位其实就是0到9,压栈后再处理即可。
- LOOP的妙处:CX既是计数器,也是循环控制寄存器的关键。每次循环自动减一,减到0退出。注意第一个循环结束时,CX正好是位数。
4.2 例2:在字数组中查找最大值
再写一个带“数组处理”味道的子程序,这类子程序在后续实验(如排序、统计)里非常常用:
;------------------------------------------------------------ ; 子程序名:FindMax ; 功能:找出指定字数组中的最大值 ; 入口参数:SI = 数组首元素的段内偏移地址 ; CX = 数组元素个数(大于0) ; 出口参数:AX = 最大值 ; 破坏寄存器:AX,BX,CX,SI,DI ;------------------------------------------------------------ FindMax PROC push bx push si push di mov ax, [si] ; 先取第一个元素当初始最大值 mov bx, ax ; BX临时保存当前最大值 mov di, 0 ; 或者用 mov di, 2 来跳过第一个,也可以 ; 从第2个元素开始比较 add si, 2 dec cx ; 已经取了一个,剩下的循环次数是cx-1 jcxz Done ; 如果只有1个元素,直接返回 CompareLoop: mov ax, [si] ; 取当前元素 cmp ax, bx ; 与当前最大值比较 jle NotBigger ; 如果当前元素不大于最大值,跳过更新 mov bx, ax ; 更新最大值 NotBigger: add si, 2 ; 指向下一个字元素 loop CompareLoop Done: mov ax, bx ; 最终最大值放到AX返回 pop di pop si pop bx ret FindMax ENDP这个子程序同样埋了几个重要的练习点:
- 比较时用的是jle(带符号小于等于),如果你的数据是无符号的,比如内存地址或者颜色编码,应该改用jbe。带符号和无符号的比较跳转指令要严格区分,这是初学者最容易犯的错。
- 第一次取元素后记得dec cx或处理循环次数,不然会多比一轮,甚至数组越界。
- 子程序内部修改了SI,外面如果需要SI的原值,调用前自己保存或者在子程序里PUSH保护。这里我给了一个PUSH SI/POP SI的例子,实际开发中只保护自己会改、又不打算返回出去的寄存器就行。
4.3 子程序的代码规约
很多同学觉得实验只要功能正确就行,注释随便写两行,其实子程序的“约定”还应该包括文档化。在实验报告里,在程序头部写清入口参数、出口参数、破坏寄存器,这两个信息非常重要,你以后自己调试和复用都得靠它。时间久了,你自己都记不清某个子程序到底改没改BX。
一个通用模板:
;------------------------------------------------------------ ; 子程序名:... ; 功能:... ; 入口参数:... ; 出口参数:... ; 破坏寄存器:BX, CX(这些寄存器调用后不保证原有值) ;------------------------------------------------------------我习惯还加一行“调用示例”,把调用前需要准备什么样的寄存器写成一两行伪代码,下次用的时候看一眼就懂,省得再翻主程序。
5. 嵌套与递归调用
子程序里面调用另一个子程序,就叫嵌套调用。如果嵌套的是自己,就叫递归。这两个情况比单纯的“主程序调子程序”多一层复杂度,但对后续实验(尤其是排序、树操作这些题目)很重要。
5.1 嵌套调用与寄存器保护
嵌套调用的栈变化比较有意思:主程序CALL子程序A,A执行中又CALL子程序B,B返回,A继续执行,A返回,回到主程序。这个过程中,返回地址是一个套一个压进栈的,后调用的先返回,完全符合栈的结构。
这个结构天然适合嵌套,只要注意一点:A在调用B之前,如果A中有正在使用的寄存器值,必须先把它们保护起来,否则B执行时也会用寄存器,会把A的数据冲掉。我自己写嵌套子程序时养成了一个习惯:凡是跨调用边界还活着的数据,要么放在内存变量里,要么放在栈里,绝对不指望寄存器能“幸免于难”。
看一个简单例子,子程序ReadCharAndEcho内部要调用DOS显示字符功能:
ReadCharAndEcho PROC push ax push bx mov ah, 01h ; 读字符(AL返回字符) int 21h mov dl, al ; 准备好要显示的字符 mov ah, 02h int 21h ; 这里相当于嵌套调用系统子程序显示 pop bx pop ax ret ReadCharAndEcho ENDP看起来很简单,关键就是在调用INT 21H之前你自己得想清楚AL和AH这些寄存器,调完系统功能后哪些变量需要提前存走。大胆地保存,小心地恢复,宁可多PUSH一个,也别少PUSH。
5.2 递归子程序的编写与限制
递归是一个比较考验栈理解的玩法。这里给一个经典例子,用递归计算阶乘(只演示思路,假设输入N很小):
;------------------------------------------------------------ ; 子程序名:Factorial ; 功能:递归计算 N! ; 入口参数:AX = N(N必须为正整数且较小,比如小于8) ; 出口参数:AX = N! ; 破坏寄存器:AX, BX, CX, DX ;------------------------------------------------------------ Factorial PROC cmp ax, 1 jle BaseCase ; N <= 1 时,阶乘就是1 push ax ; 把当前的N压栈保存 dec ax ; 计算(N-1)! call Factorial pop bx ; 取出之前压栈的N放到BX mul bx ; AX = (N-1)! * N ret BaseCase: mov ax, 1 ret Factorial ENDP这个写法堪称递归的“标准答卷”:先检查终止条件,再缩小问题规模,调用自己,回来后再组合结果。压栈保存当前层数据,返回前恢复。
但递归写起来爽,跑起来不一定爽。8086环境下的栈是有限的,默认栈段大小一般也就64KB,你每次递归调用至少要压入返回地址2字节,再加上你自己PUSH的寄存器、局部变量,随随便便一次就是几十字节。递归深度几十层可能就爆栈了。所以实验题如果需要处理大数据,我不建议用递归,循环加自建栈通常更可控。
5.3 递归的栈开销估算
教学生时我常让他们做一道计算题:如果子程序每次递归压入一个BP、一个局部变量、一个返回地址,一共6字节,栈段大小有4KB(或者16KB),最多可以递归多少层?用这个来直观体会栈的容量限制。答案往往比你想象的小得多,这也解释了为什么有些递归程序跑到一半就莫名其妙“重置”了。碰到这种情况,先别去调试算法,优先看看是不是栈爆了。
6. 常见错误与调试实录
写子程序实验,几乎每个人都会踩到同几个坑。我把这几年看到的高频问题按症状整理成了一张速查表,再结合我实际调试过的经验,给几个排查思路。
6.1 子程序实验高频问题速查
| 现象 | 大概率原因 | 排查思路 |
|---|---|---|
| 程序一执行到CALL就卡死/自动重启 | 栈段太小或栈指针SP异常 | 检查PUSH/POP数量是否对称,检查栈段定义大小 |
| RET之后程序“飞”了,执行到乱码指令 | 返回地址被破坏,栈不平衡 | 数PUSH/POP次数,确认返回前SP和进入时一致 |
| 输出一串乱码数字 | 除10后的余数没有加30H,或者直接输出了二进制值 | 检查ASCII转换语句add dl, 30h |
| 除法溢出,触发INT 0(程序中断) | DIV前忘了xor dx, dx | 检查所有DIV/DIV指令前清零DX |
| 循环次数不对,少走或多走几轮 | LOOP用CX,但子程序里CX被别有用途覆盖 | 检查子程序是否保护/恢复CX |
| 两个子程序互相调用后数据全乱 | 跨调用边界没有保存寄存器 | 在CALL前保存需要的寄存器 |
| 数组比较结果异常 | 无符号数错用带符号跳转指令 | 检查使用JA/JAE还是JG/JGE |
6.2 排查思路与调试技巧
我调试子程序问题时,拿手的办法就是“加临时输出”。在可疑的步骤之后插入一段显示某寄存器值的代码,比如让DX显示一个特定字符:
mov dl, 'A' ; 标记位置A mov ah, 02h int 21h跑到哪个标记,就知道执行流到哪了。这个土办法虽然不优雅,但在没有高级调试器的环境下异常好用。另外,利用单步调试跟踪CALL前后的SP值也同样关键。记录进入子程序时SP的值,在RET之前再对照一次,如果SP没回到原位,回去找PUSH/POP之间的不对称。我当时带实验时,很多同学的子程序“飞了”,最后都是靠数SP和PUSH次数找到问题的。不要小看栈平衡,这是影响整个程序稳定性的核心要素。
调试时还有一个小经验:先用最小数据量测。比如写的显示子程序,先用AX=5测试,再用AX=66测试,逐步换成大数。一次测复杂数据,如果结果错了很难定位是哪一位数出了问题。
6.3 比较“隐蔽”的坑
有几个坑可能没那么显眼,但实际发生率不低:
第一个是RET n的写法。如果你用了RET 4来清理参数,一定要确认RET后面的这个数字就是调用者实际压入参数的字节数。多一个字节,返回地址就偏了;少一个字节,栈上残留参数,下次RET还会错乱。
第二个是远调用和返回不匹配。子程序定义成FAR,却用RET(近返回)从子程序返回,CS没有被恢复,程序跳回调用者那里时CS是错的,当场报废。这种错误你去看CALL和RET指令本身没有问题,但实际要检查PROC属性。
第三个是使用OFFSET和LEA时不小心取了变量的值而不是地址。你要传给子程序一个数组首地址,忘了写OFFSET,结果把数组第一个元素的值传过去了。数组处理子程序大概率立刻越界。凡是看到子程序里用[BX]或者[SI]遍历数据,回来就检查调用处是不是把地址正确传进去了。
第四个是堆栈段没初始化。写实验的时候如果你直接用了SP,却没有把SS:SP指向自己定义的栈段,系统就会用一个默认栈区,小数据量也许能正常跑,但子程序一调用,栈压得深了,马上就会覆盖到代码段或者数据段。定义栈段和初始化SS:SP这步,别省。
第五个也是学生最容易忽略的:子程序入口的PUSH序列里,如果有PUSHF和POPF,注意和别的PUSH/POP穿插的顺序。把FLAGS和寄存器混在一起压栈可以,但弹出时必须逆序。我在实际代码里见过多次,pushf放中间、popf放最前面,结果返回时标志位乱套,后续条件跳转全不对。
7. 实操中的体会与一个小技巧
最后分享一点经验。
我在做这类实验时,有一个自己的小原则:子程序应当尽量“无脑可调用”。什么意思呢?就是我希望调用者调用子程序前,只需要把参数放进约定位置,然后CALL,子程序返回后,该有的结果都在约定位置,其余寄存器尽量保证和调用前一样。为了达到这个效果,我通常会在子程序开头把除了出口参数外的寄存器全都PUSH一遍,返回前POP一遍。看起来多写了几行,但在大程序里能省下大量推算“子程序改了哪些寄存器”的时间。
另一个小技巧是写子程序时习惯于让“错误分支先返回”。就是说,如果子程序入口时发现参数不合法,我倾向于立刻RET,而不是在错误条件下继续执行,这样做能最大程度避免非法参数把栈搞乱。
如果你在实验里能沉下心把上面的内容逐一吃透,后面做综合性大作业,比如字符串处理、屏幕操作、简单计算器,都会顺很多。汇编里的子程序,就是模块化的基本功,别急着跳过去。