实验5 子程序设计
汇编中的子程序等价于C语言的函数。在编写功能较复杂的程序时,需要将它分解为若干比较小的、易于实现的子程序来实现。在主程序运行过程中,需要执行某个功能时,就调用相应的子程序。子程序执行完毕后,返回到主程序。
5.1 子程序的定义和调用
1. 子程序的定义
伪指令PROC和ENDP用来定义子程序。其格式如下:
子程序名 proc
…
…
ret
子程序名 endp
其中,PROC表示子程序定义开始,ENDP表示子程序定义结束。子程序名的命名规则和变量相同,不能使用数字开头。
子程序结束时,用RET指令返回主程序。
2. 子程序的调用
在主程序中,使用CALL指令来调用子程序。格式如下:
…
call 子程序名
…
在下面的示例程序中,包含了两个子程序AddProc1和AddProc2。AddProc1使用ESI和EDI作为加数,做完加法后把和放在EAX中,AddProc2使用X和Y作为加数,做完加法后把和放在Z中。主程序先后调用两个子程序,最后将结果显示出来。
在AddProc2中用到了EAX,所以要先将EAX保存在堆栈中,返回时再恢复EAX的值。否则EAX中的值会被破坏。
;程序清单:callret.asm(子程序的调用与返回)
.386
.model flat,stdcall
option casemap:none
includelib msvcrt.lib
printf PROTO C :dword,:vararg
.data
szFmt byte '%d + %d = %d', 0ah, 0 ;输出结果格式字符串
X dword ?
Y dword ?
Z dword ?
.code
AddProc1 proc ; 使用寄存器作为参数
mov eax, esi ; EAX = ESI + EDI
add eax, edi
ret
AddProc1 endp
AddProc2 proc ; 使用变量作为参数
push eax ; 保存EAX的值
mov eax, X
add eax, Y
mov Z, eax ; Z = X + Y
pop eax ; 恢复EAX的值
ret
AddProc2 endp
start:
mov esi, 10 ;
mov edi, 20 ; 为子程序准备参数
call AddProc1 ; 调用子程序
; 结果在EAX中
mov X, 50 ;
mov Y, 60 ; 为子程序准备参数
call AddProc2 ; 调用子程序
; 结果在Z中
invoke printf, offset szFmt,
esi, edi, eax ; 显示第1次加法结果
invoke printf, offset szFmt,
X, Y, Z ; 显示第2次加法结果
ret
end start
子程序AddProc1执行完毕后,RET指令要返回到主程序的“mov X, 50 ” 处继续执行。子程序AddProc2执行完毕后,RET指令要返回到主程序的“invoke”处继续执行。每个子程序的最后,执行的RET指令都返回到主程序。
3. 返回地址
从子程序返回后,主程序继续执行的指令的地址称为“返回地址”。或者说,返回地址就是主程序中CALL指令后面一条指令的地址。
CALL指令执行时,它首先把返回地址作为一个双字压栈,再进入子程序执行。子程序最后执行的RET指令从堆栈中取出返回地址,返回到主程序。所以,CALL指令和RET指令执行是必须依赖于堆栈的。
用以下命令编译callret.asm,生成callret.exe。
ml /Zi /coff callret.asm /link /subsystem:console
在VC 6.0中,执行菜单File→Open,指定callret.exe,再按F11。按F10键,执行第1条跳转指令后,显示的内容如图5-1所示。
图5-1 察看callret.asm的反汇编代码
从图5-1的反汇编代码中可以看出。程序从0040103CH处开始执行,第1条“call AddProc 1 ” 指令的返回地址为0040104BH;第2条“call AddProc 2 ” 指令的返回地址为00401064H。
在Memory窗口处Address后面的编辑框内,输入ESP-10和回车,再在内存显示部分点鼠标右键,选择“Long Hex Format”。如果屏幕上没有Memory窗口,可使用菜单View→Debug Windows→Memory打开。
按三次F11键,执行图5-1中黄色箭头指向的3条指令,进入AddProc1子程序。这时,返回地址0040104B被保存到堆栈中,ESP的值从0012FFC4变为0012FFC0。
执行后面的RET指令时,CPU从堆栈中弹出返回地址,ESP=0012FFC0H。返回到主程序的0040104B处。
必须注意,在子程序中必须保持堆栈的平衡。在子程序中,压入堆栈的操作数必须在子程序返回前出栈,而不能留在主程序中再出栈。例如,下面的程序是错误的:
ErrorProc PROC
PUSH EAX
…
RET
ErrorProc ENDP
…
CALL ErrorProc
POP EAX
图5-2 返回地址被保存在堆栈中
5.2 参数传递规则
在C/C++以及其他高级语言中,函数的参数是通过堆栈来传递的。C语言中的库函数,以及Windows API等也都使用堆栈方式来传递参数。例如,前面程序已经使用过的printf、scanf属于C的库函数,而MessageBox属于Windows API。
1. cdecl方式和stdcall方式
常见的有2种参数传递方式是cdecl方式和stdcall方式。
(1) cdecl方式
cdecl方式是C函数的默认方式,在C/C++程序中,函数缺省使用cdecl调用规则。
主程序按从右向左的顺序将参数逐个压栈。最后一个参数先入栈。每一个参数压栈一次,在堆栈中占4字节。
在子程序中,使用[EBP+x]的方式来访问参数。x=8代表第1个参数;x=12代表第2个参数,依次类推。子程序用RET指令返回,子程序的返回值放在EAX中。
由主程序执行“ADD ESP, n”指令调整ESP,达到堆栈平衡。n等于参数个数乘以4。
(2) stdcall方式
stdcall方式也是使用堆栈传递参数,使用从右向左的顺序将参数入栈。与cdecl方式的区别,堆栈的平衡是由子程序来完成的。子程序使用“RET n”指令,在返回主程序的同时调整ESP的值。子程序的返回值也保存在EAX中。
Windows API采用的stdcall调用规则。例如,lstrcmpA( )的函数原型为:
WINBASEAPI int WINAPI lstrcmpA(LPCSTR lpStr1, LPCSTR lpStr2);
其中的WINAPI定义为:
#define WINAPI __stdcall
2. invoke伪指令
前面的程序中多次用到invoke伪指令,它对函数、子程序的调用方式与C程序类似。
在汇编语言中,定义子程序时,可以在后面说明,是使用cdecl规则还是stdcall规则,以及各参数的名称。在调用子程序时,使用invoke伪指令,后面跟子程序名和各个参数的取值即可。
;程序清单:invoke.asm(invoke伪指令)
.386
.model flat,stdcall
includelib msvcrt.lib
printf PROTO C :dword,:vararg
.data
szFmt byte '%d - %d = %d', 0ah, 0 ;输出结果格式字符串
.code
SubProc1 proc c a:dword, b:dword ; 使用堆栈传递参数, C规则
mov eax, a ; 取出第1个参数
sub eax, b ; 取出第2个参数
ret ;
SubProc1 endp
SubProc2 proc stdcall a:dword, b:dword; 使用堆栈传递参数, stdcall规则
mov eax, a ; 取出第1个参数
sub eax, b ; 取出第2个参数
ret ;
SubProc2 endp
start:
invoke SubProc1, 100, 40 ; 调用SubProc1
invoke printf, offset szFmt,
100, 40, eax ; 显示第1次减法结果
invoke SubProc2, 200, 5 ; 调用SubProc2
invoke printf, offset szFmt,
200, 5, eax ; 显示第2次减法结果
ret
end startt
程序invoke.asm对应的机器指令如下所示。可以看到,在编译invoke.asm时,MASM根据子程序定义的参数个数和调用规则,自动地处理了参数转换和堆栈平衡等烦琐问题。
00401000 push ebp
00401001 mov ebp,esp
00401003 mov eax,dword ptr [ebp+8]
00401006 sub eax,dword ptr [ebp+0Ch]
00401009 leave
0040100A ret
0040100B push ebp
0040100C mov ebp,esp
0040100E mov eax,dword ptr [ebp+8]
00401011 sub eax,dword ptr [ebp+0Ch]
00401014 leave
00401015 ret 8
00401018 push 28h
0040101A push 64h
0040101C call 00401000
00401021 add esp,8
00401024 push eax
00401025 push 28h
00401027 push 64h
00401029 push 403000h
0040102E call 00401058
00401033 add esp,10h
00401036 push 5
00401038 push 0C 8h
0040103D call 0040100B
00401042 push eax
00401043 push 5
00401045 push 0C 8h
0040104A push 403000h
0040104F call 00401058
00401054 add esp,10h
00401057 ret
对照invoke.asm和机器指令列表,可以观察到以下几点:
(1) 自动加入的指令
子程序的进入、退出代码,如“push ebp”、“mov ebp, esp”指令,以及“leave”指令。
(2)参数的替换
参数a用[ebp+8]替换,参数b用[ebp+12]替换。在编程时采用a、b的参数形式更方便易懂。
(3)返回指令的区别
SubProc1采用c规则,用“ret”返回;返回后,在主程序中有“add esp, 8 ” 指令。SubProc2采用stdcall规则,用“ret 8 ” 返回。
(4)invoke语句转换为CALL指令
invoke后面跟的参数被逐一压入堆栈,再跟上一条CALL指令。
5.3 局部变量
局部变量仅仅在子程序内部使用,使用局部变量能提高程序的模块化程度。局部变量也被称为自动变量。
MASM提供了local伪指令,可以在子程序中方便地定义局部变量。在swap子程序中,使用这两条伪指令在堆栈中保留了8字节的局部空间。如:
local temp1,temp2:dword
局部变量的地址不能使用offset操作符,而必须用addr操作符。
;程序清单:local.asm(局部变量)