第十一章 子程序及参数传递
本章先讲述子程序设计的方法,然后介绍在子程序调用中参数的四种传递方法。
11.1 子程序设计
在前面的示例和练习中,会发现程序中会有一些反复使用到的代码片段。我们将程序中反复出现的程序片段设计成子程序,这样可以有效缩短程序长度,节约存储空间,大大减轻程序设计工作量。
在设计子程序时,需要注意到子程序的通用性、共享性和相对独立,方便阅读和调试修改。
在80x86系列汇编语言中,子程序常常以过程的形式出现。
本节内容:子程序的设计方法及举例。
■过程调用和返回:过程调用和返回指令属于程序控制指令。过程调用指令CALL用于主程序转子程序,过程返回指令RET用于子程序转主程序。
■过程定义语句:
过程名 PROC [NEAR | FAR]
.
.
.
过程名 ENDP
■子程序举例:示例代码t11-1.asm~ t11-4.asm。
■子程序说明信息:
为了能正确地使用子程序,在定义子程序代码时需要做必要的说明。子程序的说明信息一般由以下几部分组成:
- 子程序名。
- 功能描述。
- 入口参数和出口参数。
- 使用的寄存器和存储单元。
- 使用的算法和重要的性能指标。
- 其他调用注意事项和说明。
- 调用实例。
定义子程序时,可以根据实际情况给出具体包含的信息。
示例代码:t11-5.asm
■寄存器的保护与恢复:在子程序的代码中不可避免要使用一些寄存器或者存储单元存放内容。这样就会导致修改这些寄存器或存储单元原有的内容,当子程序返回主程序后,主程序将无法再使用这些寄存器或存储单元内原有的内容。
●存储单元的保护:可以将数据备份到另一个地址处。
●寄存器的保护有两种方法:
第一种方法是主程序中将寄存器PUSH入栈保护,待子程序调用返回后再将寄存器POP出栈。
第二种方法是在子程序中将寄存器PUSH入栈,子程序返回前将寄存器POP出栈。
哪些寄存器需要做保护呢?凡是子程序中使用到的寄存器都需要做保护,但是作为出口参数的寄存器绝对不可以作保护。
11.1.1 过程调用和返回指令
过程调用和返回指令属于程序控制指令。过程调用指令用于主程序转子程序,过程返回指令用于子程序转主程序。由于程序的代码段可以是多个段组成,所以过程调用和过程返回指令又分为段间远调用、远返回指令和段内近调用、近返回指令。接下来我们介绍这两组指令的使用方法。
●过程调用指令
(1)段内直接调用
CALL 过程名
CALL subi ;subi近过程
CALL toascii ;toascii近过程
CALL指令分解:
SP <= SP-2 ;分配2个字节堆栈空间
[SP] <= IP ;压入偏移
IP <= IP +DISP (地址差)
Disp 范围-32768~32767
11-1 段内调用
subi和toascii为子程序名,子程序名就是其在代码段内的地址标号。近过程就是子程序和主程序在同一个代码段内,编译器编译时将子程序名替换为代码段内的偏移地址。
调用call指令时,需要先动态分配堆栈空间,将call指令后的下一条指令的16位偏移地址(称为返回地址)压入堆栈,因此栈顶指针寄存器SP-2,并且存入返回地址。
接着就是修改IP指令指针寄存器的值为子程序的偏移地址,实现主程序到子程序的跳转。因为16位汇编中,最大段为64KB,所以段内跳转的范围是-32768~32767。跳转的地址差值为:目的地址-返回地址。
(2)段内间接调用
CALL oprd
SP <= SP-2 ;分配2个字节堆栈空间
[SP] <= IP ;压入偏移
IP <= (OPRD)
oprd是16位通用寄存器或字存储器操作数,
存储子程序在段内的偏移地址。
注意指令分解执行的顺序。
(3)段间直接调用
CALL 过程名
SP <= SP-2 ;分配2个字节堆栈空间
[SP] <= CS ;压入返回地址的段值
SP <= SP-2 ;分配2个字节堆栈空间
[SP] <= IP ;压入返回地址的偏移
IP <= 跳转后子程序的偏移
CS <= 跳转后子程序的段值
11-1 段间调用
注意指令分解执行的顺序。
与无条件间接转移相似,机器指令中含有转移目标地址。
CALL FAR PTR SUBRO ;设SUBRO是远过程
CALL SUBRO ;设SUBRO是远过程
MASM汇编器是根据过程名所指定的被调用过程的类型决定段内还是段间调用。TASM汇编器是根据实际是否在同一段内判断的。
因为是跨段跳转,所以子程序名SUBRO表示为另一个代码段内的地址标号,该地址标号应该表示为段值:偏移,同理,返回地址也应表示为段值:偏移。
执行CALL指令调用时,首先需要动态分配4个字节的堆栈空间,先压入当前段的16位段值CS,再压入16位偏移,作为子程序的返回地址。
接着修改CS段寄存器和IP指令指针寄存器为远过程的段值和偏移,实现跨段跳转。
(4)段间间接调用
CALL OPRD
OPRD是双字存储器操作数,存储远过程调用的CS:IP。
SP <= SP-2 ;分配2个字节堆栈空间
[SP] <= CS ;压入返回地址的段值
SP <= SP-2 ;分配2个字节堆栈空间
[SP] <= IP ;压入返回地址的偏移
IP <= OPRD之低字值
CS <= OPRD之高字值
;注意指令分解执行的顺序
CALL DWORD PTR [BX]
CALL VARD ;设VARD是双字变量
可以看出近调用和远调用的差异在于是否处理段寄存器。因为近调用发生在一个代码内的调用,因此不需要向栈中压入和切换代码段。而远调用因为发生在不同的代码段间,因此需要保存和切换代码段。但对于32位的windows,因为使用了平坦内存,在同一进程内的代码都是在一个大的4GB段中,因此不必再考虑段的差异,普通的应用程序使用的都是近调用,我们将在第三部分32位汇编中详细讲述。
●过程返回指令
返回指令将返回地址从堆栈弹出到IP或CS:IP,从而实现子程序到主程序的跳转,主程序可以继续执行CALL指令后的下一条指令。返回指令也分为段内返回指令和段间返回指令。
(1)段内返回指令RET
RET指令分解:
IP <= [SP] ;将返回地址偏移POP到IP寄存器
SP <= SP+2 ;释放返回地址偏移在栈内存储空间
(2)段间返回指令RET
RET指令分解:
IP <= [SP] ;将返回地址偏移POP到IP寄存器
SP <= SP+2 ;释放返回地址偏移在栈内存储空间
CS <= [SP] ;将返回地址段值POP到CS寄存器
SP <= SP+2 ;释放返回地址段值在栈内存储空间
虽然段内和段间返回指令助记符相同,但是它们的机器码是不同的。汇编器MASM根据RET指令所在过程的类型决定段间还是段内,TASM汇编器除此之外,还提供了专门的助记符RETF。
(3)段内带立即数返回指令
RET 表达式
指令分解:
IP <= [SP] ;将返回地址偏移POP到IP寄存器
SP <=SP+2 ;释放返回地址偏移在栈内存储空间
SP <= SP+data ;移动栈顶指针寄存器data个字节
由于堆栈正常操作都是以字为单位,所以Data及表达式的结果一般是偶数,如:RET 4。
(4)段间带立即数返回指令
RET 表达式
IP <= [SP] ;将返回地址偏移POP到IP寄存器
SP <= SP+2 ;释放返回地址偏移在栈内存储空间
CS <= [SP] ;将返回地址段值POP到CS寄存器
SP <= SP+2 ;释放返回地址段值在栈内存储空间
SP <= SP+data ;移动栈顶指针寄存器data个字节
例:RET 4
11.1.2 过程定义语句
●子程序格式
过程名 PROC [NEAR | FAR]
.
.
.
过程名 ENDP
PROC 指明该地址标号是过程名,即子程序。
NEAR表示该过程为近过程调用,FAR表示该过程为远过程调用。如果不指定过程类型,默认为NEAR型。
ENDP表示子程序定义结束。
●示例
; 过程调用
;子程序名:htoasc
;功能:将一位十六进制数转换为对应的ASCII码子程序
;--------------------------------------------------------------------------
;设欲转换的十六进制数在AL的低4位
;转换得到的ASCII码在AL中
htoasc proc near
and al,0fh
add al,30h ;先转换为ASCII字符
cmp al,39h ;再判断是否大于‘9’
jbe htoasc1
add al,7h
htoasc1:
ret ;调用返回
htoasc endp
;换一种写法
htoasc proc near
and al,0fh
cmp al,9 ;先判断是否大于9
jbe htoasc1
add al,37h ;’A’~’F’
ret
htoasc1:
add al,30h
ret
htoasc endp
;还可以写成
htoasc proc near
and al,0fh
cmp al,9
jbe htoasc1
add al,37h
htoasc2:
ret
htoasc1:
add al,30h
jmp htoasc2
htoasc endp
上述示例代码片段中可以包含一条ret返回指令,也可以包含多条ret返回指令。
11.1.3 子程序举例
●例1:两位ASCII码十进制数转换为数值
动手实验78:写一个程序将两位十进制数ASCII码转换为对应的十进制的子程序调用。
在理解下面示例程序的基础上,自己独立编写源程序。编译完成后,在debug调试器中单步跟踪调试,以验证程序的正确性。
数据定义:
data segment
num db 31h,30h
val db ?
data ends
算法分析:
设X为十位数,Y为个位数,计算10X+Y。
示例代码51:
;程序名:t11-1.asm
;例1:写一个程序将两位十进制数ASCII码转换为对应的十进制的子程序调用
;转换的算法为:设X为十位数,Y为个位数,计算10X+Y
;子程序名:subr
;-----------------------------------------------------
assume cs:code,ds:data
data segment
num db 31h,30h
val db ?
data ends
;
code segment
start:
mov ax,data
mov ds,ax
;入口参数
mov dh,num[0]
mov dl,num[1]
;子程序调用
call subr
mov val,al ;保存返回值
;
mov ax,4c00h
int 21h
;------------------------------------------------------
;子程序名:subr
;功能:两位ASCII码十进制数转换为十进制
;入口参数:dh高位,dl低位
;出口参数:al
;其他说明:
subr proc
;保护寄存器
PUSHF
PUSH DX
;10X
mov al,dh
and al,0fh
mov ah,10
mul ah
;+Y
mov ah,dl
and ah,0fh
add al,ah
;恢复寄存器
POP DX
POPF
ret ;返回
subr endp
code ends
end start
;子程序可以换一种写法
subr proc
mov al,dh
sub al,30h
mov ah,10
mul ah
mov ah,dl
sub ah,30h
add al,ah
ret
subr endp
注意
1.上述程序未判断dh,dl是否是十进制数的ASCII码。
2.子程序参数传递方式为寄存器传参,将在“本章11.2节”详细讲述。
3.子程序说明信息包括子程序名、子程序功能、入口参数、出口参数和其他信息。
4.子程序中可能会改变FLAG和DX寄存器的值,因此使用PUSHF、PUSH DX、POP DX、POPF四条语句保护这两个寄存器,注意入栈和出栈的顺序(后进先出)。
5.子程序的定义必须包含在代码段内,即code ends伪指令之前。
●例2:16位二进制数转换为4位十六进制ASCII码
动手实验79:写一个把16位二进制数转换为4位十六进制数ASCII码的子程序调用。
在理解下面示例程序的基础上,自己独立编写源程序。编译完成后,在debug调试器中单步跟踪调试,以验证程序的正确性。
数据定义:
data segment
num dw 1234h
buff db ?,?,?,?,0dh,0ah,'$'
data ends
算法分析:
把16位二进制数向左循环移位四次,使高四位变为低4位,析出低四位调用子程序htoasc转换1位十六进制ASCII码,循环四次。
示例代码52:
;程序名:t11-2.asm
;例2:写一个把16位二进制数转换为4位十六进制数ASCII码的子程序
;转换方法:把16位二进制数向左循环移位四次,使高四位变为低4位
;析出低四位调用子程序htoasc转换1位十六进制ASCII码,循环四次
;子程序名:htascs
;=================================================================
assume cs:code,ds:data
data segment
num dw 1234h
buff db ?,?,?,?,0dh,0ah,'$';0dh,0ah为回车换行的ASCII值
data ends
code segment
start:
mov ax,data
mov ds,ax
;入口参数
mov dx,num
mov bx,offset buff
;
call htascs
;显示转换后的ASCII字符串
mov dx,offset buff
mov ah,9
int 21h
;
mov ax,4c00h
int 21h
;====================================================================
;子程序名:htascs
;功能:16位二进制数转换为4位十六进制数ASCII码
;入口参数:dx=欲转换的二进制数
;ds:bx=存放转换得到的ASCII码串的缓冲区首地址
;出口参数:十六进制数ASCII码串按高位到低位依次存放在指定的缓冲区中
htascs proc
mov cx,4
htascs1:
push cx
mov cl,4
rol dx,cl
;也可以使用下面4条语句实现移位
;rol dx,1
;rol dx,1
;rol dx,1
;rol dx,1
mov al,dl
call htoasc
mov [bx],al
inc bx
pop cx
loop htascs1
ret
htascs endp
;---------------------------------------
;子程序名:htoasc
;功能:一位十六进制数转换为ASCII
;入口参数:al=待转换的十六进制数
;出口参数:al=转换后的ASCII
htoasc proc near
and al,0fh
add al,30h
cmp al,39h
jbe htoasc1
add al,7h
htoasc1:
ret
htoasc endp
;-----------------------------------------
code ends
end start
上述示例入口参数为dx、bx寄存器,出口参数为指定的buff缓冲区。num为1234h,需要分别转换4个十六进制数,采用ROL循环移位的方法,从左往右析出每个十六进制数。接下来调用嵌套的子程序htoasc将其转换为十六进制数ASCII码。转换算法为:如果十六进制数在0~9之间,加30H,如果在A~F之间,再加7H。
注意:对于循环计数器CX的保护,因为子程序中“mov cl,4”语句会改变循环计数值,所以在改变前入栈保护,循环移位后再恢复cx的值,否则会导致死循环。
示例代码53:
;程序名:T11-3.asm
;功能:利用上述子程序htascs按十六进制数形式显示地址为B000:0000H的字单元内容
;调用子程序htascs,htoasc
;------------------------------------
assume cs:code,ds:data
data segment
buff db 4 dup(0) ;存放4位十六进制数的ASCII码串
db 'h',0dh,0ah,'$' ;形成以$结尾的串
data ends
code segment
start:
mov ax,data
mov ds,ax
; 准备入口参数
mov ax,0B000h
mov es,ax
mov dx,es:[0] ;取B000:0000h地址的内容
mov bx,offset buff ;准备入口参数
call htascs ;调用子程序,转换4位十六进制数的ASCII码
;输出结果
mov dx,offset buff
mov ah,9
int 21h
;
mov ax,4c00h
int 21h;
;-------------------------------------------
;子程序名:htascs
;功能:16位二进制数转换为4位十六进制数ASCII码
;入口参数:dx=欲转换的二进制数
;ds:bx=存放转换得到的ASCII码串的缓冲区首地址
;出口参数:十六进制数ASCII码串按高位到低位依次存放在指定的缓冲区中
htascs proc
mov cx,4 ;循环计数
htascs1:
rol dx,1 ;循环左移4位
rol dx,1
rol dx,1
rol dx,1
mov al,dl ;保存移位后dl的低4位值
call htoasc ;调用子程序
mov [bx],al ;保存到缓冲区
inc bx
loop htascs1 ;重复循环4次
ret
htascs endp
;-----------------------------------------
;子程序名:htoasc
;功能:一位十六进制数转换为ASCII
;入口参数:al=待转换的十六进制数
;出口参数:al=转换后的ASCII
htoasc proc near
and al,0fh
add al,30h
cmp al,39h
jbe htoasc1
add al,7h
htoasc1:
ret
htoasc endp
code ends
end start
上述示例在调用子程序前,先准备好参数,dx =欲转换的二进制数, ds:bx=存放转换得到的ASCII码串的缓冲区首地址。出口参数为指定的buff缓冲区。获取指定地址B000:0000H的字单元内容的方法为es=B000H,然后mov dx,es:[0]。转换十六进制ASCII码的方法与例2相同,不再赘述。
●例3:把16位二进制数转换为5位十进制数ASCII码的子程序
动手实验80:写一个把16位二进制数转换为5位十进制数ASCII码的子程序,为了简单,二进制数为无符号数。
在理解下面示例程序的基础上,自己独立编写源程序。编译完成后,在debug调试器中单步跟踪调试,以验证程序的正确性。
数据定义:
data segment
num dw 1234h
buff db 5 dup(?),24h
data ends
算法分析:
算法1:把16位二进制先转换为5位十进制BCD码,除以(10000,1000,100,10,1)然后再转换为ASCII码。
算法2:把16位二进制数除以10余数为个位数的BCD码,商再除以10,余数为十位数的BCD码,循环5次。
示例代码54:
;程序名:t11-4.asm
;例3:写一个把16位二进制数转换为5位十进制数ASCII码的子程序,为了简单,二进制数为无符号数
;子程序名:btoasc
;=================================================================
assume cs:code,ds:data
data segment
num dw 1234h
buff db 5 dup(?),24h
data ends
code segment
start:
mov ax,data
mov ds,ax
;入口参数
mov ax,num
mov bx,offset buff
;子程序调用
call btoasc
;输出结果
mov dx,offset buff
mov ah,9
int 21h
;
mov ax,4c00h
int 21h
;=================================================================
;子程序名:btoasc
;入口参数:AX=欲转换的二进制数
;DS:BX=存放转换所得ASCII码串的缓冲区首地址
;出口参数:十进制数ASCII码串按万位到个位的顺序依次存放在指定的缓冲区
btoasc proc
push si
push cx
push dx
push ax
push bx
mov si,5 ;循环次数
mov cx,10 ;除数10
btoasc1:
xor dx,dx ;被除数扩展成32位
div cx
add dl,30h ;余数为BCD码,转换为ASCII码
dec si ;循环次数减一
mov [bx][si],dl ;ASCII码存入缓冲区
or si,si ;判断si是否为0
jnz btoasc1 ;否,继续
pop bx
pop ax
pop dx
pop cx
pop si
ret
btoasc endp
;SI即作为计数器,又作为变址指针使用,也可以不使用,inc bx实现变址
code ends
end start
注意
示例代码54中使用的是算法2实现的。请读者独立实现算法1—将示例代码41封装为子程序。
11.1.4 子程序说明信息
为了能正确地使用子程序,在定义子程序代码时需要做必要的说明。子程序的说明信息一般由以下几部分组成:
1.子程序名。
2.功能描述。
3.入口参数和出口参数。
4.使用的寄存器和存储单元。
5.使用的算法和重要的性能指标。
6.其他调用注意事项和说明。
7.调用实例。
定义子程序时,可以根据实际情况给出具体包含的信息。
●例4:把8位二进制数转换为2位十六进制数的ASCII码
动手实验81:写一个把8位二进制数转换为2位十六进制数的ASCII码的子程序,二进制数为无符号数。
在理解下面示例程序的基础上,自己独立编写源程序。编译完成后,在debug调试器中单步跟踪调试,以验证程序的正确性。
数据定义:
data segment
num db 12h
buff db 2 dup(?)
data ends
算法分析:
分别析出8位二进制数的高4位和低4位,并转换为十六进制数的ASCII码。
示例代码55:
;程序名:t11-5.asm
;功能:把8位二进制数转换为2位十六进制数的ASCII码
;=================================================================
assume cs:code,ds:data
data segment
num db 12h
buff db 2 dup(?)
data ends
code segment
start:
mov ax,data
mov ds,ax
;入口参数
mov al,num
mov bx,offset buff
;子程序调用
call ahtoasc
;保存结果
mov buff,ah
mov buff+1,al
;
mov ax,4c00h
int 21h
;=================================================================
;子程序名:ahtoasc
;功能:把8位二进制数转换为2位十六进制数的ASCII码
;入口参数:AL=欲转换的8位二进制数
;出口参数:AH=十六进制数高位的ASCII码
;AL=十六进制数低位的ASCII码
;其他说明:1.近过程,2.除AX寄存器外,不影响其他寄存器3.调用了htoasc实现十六进制到ASCII码转换
ahtoasc proc
mov ah,al
shr al,1
shr al,1
shr al,1
shr al,1
call htoasc ;高4位
xchg ah,al
call htoasc ;低4位
ret
ahtoasc endp
;---------------------------------------
;子程序名:htoasc
;功能:一位十六进制数转换为ASCII
;入口参数:al=待转换的十六进制数
;ds:bx=存放转换得到的ASCII码串的缓冲区首地址
;出口参数:al=转换后的ASCII
htoasc proc near
and al,0fh
add al,30h
cmp al,39h
jbe htoasc1
add al,7h
htoasc1:
ret
htoasc endp
;-----------------------------------------
code ends
end start
11.1.5 寄存器的保护与恢复
在子程序的代码中不可避免要使用一些寄存器或者存储单元存放内容。这样就会导致修改这些寄存器或存储单元原有的内容,当子程序返回主程序后,主程序将无法再使用这些寄存器或存储单元内原有的内容。这就需要我们在定义子程序时保护和恢复子程序中使用到的寄存器或存储单元存放的内容。
存储单元数据保护方法可以将数据备份到另外一个地址处作为副本使用。
寄存器的保护和恢复则有两种方法:
第一种方法:在主程序中将需要保护的寄存器push入栈,待子程序调用返回时,再pop出栈,恢复原值。
优点:只需要把寄存器压栈出栈。
缺点:不易理解,容易忘记,多次调用子程序会累赘。
第二种方法:在子程序中一开始就把子程序中要改变的寄存器压入堆栈,返回之前恢复。
优点:主程序调用时不需要考虑寄存器的恢复。特别是当子程序为第三方提供时,通常调用者并不清楚需要对哪些寄存器做保护。
接下来我们以子程序btoasc为例。
举例
;子程序名:btoasc
;功能:一个16位二进制数转换为5位十进制数ASCII码串
;算法2:把16位二进制数除以10余数为个位数的BCD码,商再除以10,余数为十位数的BCD码,循环5次
;入口参数:AX=欲转换的二进制数
;DS:BX=存放转换所得ASCII码串的缓冲区首地址
; 出口参数:十进制数ASCII码串按万位到个位的顺序依次存放在指定的缓冲区
btoasc proc
push ax
push cx
push dx
push si
mov si,5 ;循环次数
mov cx,10 ;除数10
btoasc1:
xor dx,dx ;被除数扩展成32位
div cx
add dl,30h ;余数为BCD码,转换为ASCII码
dec si ;循环次数减一
mov [bx][si],dl ;ASCII码存入缓冲区
or si,si ;判断si是否为0
jnz btoasc1 ;否,继续
pop si
pop dx
pop cx
pop ax
ret
btoasc endp
注意
1.上述子程序实际破坏了flag寄存器的部分标志,可以用pushf和popf指令保护和恢复标志寄存器,但是一般不在子程序中操作。
2.注意堆栈先进后出的特性。
3.有时为了简单,并不保护含有入口参数的寄存器,是否需要保护根据实际情况而定。
4.可以像寄存器一样通过入栈和出栈保护和恢复有关的存储单元的内容。但在子程序中尽量避免这样操做。子程序可以利用堆栈元素作为临时变量。
5.如果出口参数为寄存器传参,则不应对作为出口参数的寄存器做保护。
练习
1、怎样把程序片段封装为子程序?
2、请比较指令call与无条件转移指令jmp的异同。
3、段内调用指令是否可以调用远过程?如果可以,请举例说明。
4、是否可用过程返回指令ret调用某个过程?如果可能,请写出实例。
5、子程序说明信息应包含哪些内容?
6、比较寄存器保护和恢复的两种方法优缺点。
本文摘自编程达人系列教材《X86汇编语言基础教程》。