目录
汇编语言的循环程序和高级语言程序类似,分为计数控制循环和条件控制循环(分别对应C语言中的for 和while循环)。而子程序由于汇编的特性一般需要手动进行一些堆栈和传参的工作,相对高级语言来说会复杂不少。了解汇编语言的循环程序和子程序,有利于我们了解计算机循环流程和堆栈函数调用的底层原理。
循环程序设计
循环程序设计按照控制方式分类,可以分类为计数控制循环和条件控制循环;按照层数分类,可以分类为单层循环和多重循环。先介绍单层循环。
计数控制循环
单层循环的计数控制循环有固定的步数。其中循环次数存储在CX中。当每次执行循环到LOOP指令时,都会将CX递减,然后判断CX是否为0,如果不为0,则跳转到循环开始的语句。否则退出循环顺序执行语句。即等价于:
dec cx
jnz startloop
下面提供一个单层计数控制循环程序的示例:通过循环完成从1加到100
.model small
.data
sum dw ?
.stack
.code
.startup
xor ax,ax ;清空AX
mov cx,100
startloop: add ax,cx
loop startloop ;等价于for (int i=100;i>0;i--) sum+=i;
mov sum,ax
条件控制循环
单层条件控制循环通过条件判断是否结束循环。通常使用JMP命令跳转到循环体开始的语句,并且在循环体中添加跳出循环体的JCC语句(类似于if() break;)根据高级语言,我们有while控制循环和do-while控制循环,而在汇编语言中是while还是do-while取决于JCC语句的位置。
下面提供一个单层条件控制循环程序的示例:通过循环实现lowercase()函数,将一个字符串的所有大写字母改为小写字母。
.model small
.data
string db 'Hello, Everybody!',0
.stack
.code
.startup
mov bx,offset string
aga:mov al,[bx] ;取一个字母
or al,al
jz done ;if (al==0) break
cmp al,'A'
jb next
多重循环
在多重循环中,如果内外循环没有关系,问题会比较容易处理;但是如果需要传递参数或者利用相同的数据,问题会相对比较复杂。
下面展示一个多重循环的示例:冒泡排序法是一种经典的排序算法,时间复杂度为O(n^2),嵌套了两层循环。
.model small
.data
array db 56h,23h,37h,78h,0ffh,0,12h,99h,64h,0b0h
count equ ($-array)/type array ;计算数据个数
.stack
.code
.startup
mov cx,count
dec cx ;cx是外循环循环次数
outlp:mov dx,cx ;dx是内循环循环次数
mov bx,offset array
inlp: mov al,[bx]
cmp al,[bx+1]
jna next ;不大于是不叫唤
xchg al,[bx+1]
mov [bx],al
next: inc bx ;下标递增
dec dx ;循环次数递减
jnz inlp
loop outlp
.exit
end
可以看到,由于避免CX被重复使用的情况,内部使用了JCC控制语句。因此,在面对多重(计数)循环时,我们可以将最外层的循环用LOOP控制,而内部拆解为JCC控制。当然,将CX压入堆栈也是一个选择,但是注意比较容易犯错。
串传送指令
当循环操作的目标是一个串而且操作是简单的MOV指令时,可以使用串传送指令来快速实现类似于strcpy()函数的功能。
串传送指令从SI寄存器指向地址(或者AX)向DI寄存器指向地址(或者AX)对齐进行传送。主要的串传输指令包含MOVS指令、STOS指令和LODS指令。
MOVS指令
MOVS指令分为MOVSB(按字节传送)和MOVSW(按字传送)指令,具体实现功能如下:
movsb ;es:[di]<-ds[si],然后si++(--),di++(--)
movsw ;es:[di]<-ds:[si],然后si+=(-=)2,di+=(-=)2
寻址方式:从DI和SI读取地址
标志影响:无。
STOS指令
同样地,STOS指令分为将AX(AL)中的数据存入ES:[DI]的STOSB指令和STOSW指令。
stosb ;
stosw ;
寻址方式:寄存器寻址和间接寻址。
标志影响:无。
LODS指令
同样地,LODS指令分为LODSB指令和LODSW指令。
lodsb ;al<-ds:[si],随后si++(--)
lodsw ;ax<-ds:[si],随后si+=(-=)2
寻址方式:寄存器寻址和间接寻址
标志影响:无。
另外,串传送指令常常和重复指令REP结合,REP的重复次数取决于CX。
同时,串传送指令MOVS也可以接受两个类型相同的串。
MOVS dststr,srcmsg
下面给出了一个strcpy()函数的实现。
.model small
.data
srcmsg db 'Try your best, why not.$'
dstmas db sizeof srcmsg dup(?)
.stack
.code
.startup
mov ax,ds
mov es,ax ;es<-ds
mov si,offset srcmsg
mov di,offset dstmsg
mov cx,lengthof secmsg ;设置循环次数
cld ;地址增量传送
rep movsb
mov dx,offset dstmsg
mov ah,9
int 21h
.exit
end
如果使用MOVS指令,则需要事先设置SI,DI,ES,DS,CX。同时,还要注意DF的设置。
下面是另外一个示例:实现清屏指令(相当于DOS的清屏命令CLS)
.model small
.code
.startup
mov dx,0b800h
mov es,dx
mov di,0 ;设置es:di = b800h:0000h
mov cx,25*80 ;设置cx=填充个数
mov ax,0720h ;设置ax=填充内容,0720h对应空格
cld
rep stosw
.exit 0
end
子程序设计
汇编的子程序和高级语言的函数相同,其作用在此不做赘述。下面介绍如何定义编写子程序。
过程定义伪指令
汇编语言的子程序框架一般如下:
func proc[near/far]
process
func endp
其中,func是子程序名,near和far属性决定该子程序只能被相同代码段其他程序调用还是可以被其他代码段的程序调用。
子程序的调用和返回由CALL指令和RET指令完成。当发生调用时,会将返回地址压入堆栈;而RET则将堆栈内的数据弹出传给IP。因此,当子程序中涉及堆栈调用时, 一定要注意维护堆栈以确保返回时能够正确将地址返回IP。同时,还要注意恢复所有寄存器的状态。一种解决方法是,在调用子程序时先将所有寄存器按照一定的顺序压入栈中,在返回后再以相反的顺序弹出返回寄存器。
子程序最好放置在主程序执行中止之后的位置(.exit后,伪指令end前),或者放在主程序开始执行(.startup)之前的位置。
子程序传参
在高级语言的函数传参中,存在按值传参和按引用传参的方式。同时,按存储器分,传参又有三种方式:寄存器传参、堆栈传参和变量传参。
寄存器传参
寄存器传参是最简单的一种传参方式,但是寄存器数量和自身大小都十分有限,这是寄存器传参的主要缺点。寄存器传参一般用于传递参数比较小且比较少的情况。例如,调用02号DOS功能是,传递的参数为DL的值。
下面是一个使用寄存器传参的示例:数组求和子程序。
.model small
.stack
.data
count equ 10
array db 12h,15h,25h,0fh,0a3h,3,68h,71h,0cah,0ffh,90h
result db ?
.code
.startup
mov bx,offset array
mov cx,count
call checksuma
mov result,al
.exit 0
checksuma proc
xor al,al ;清空AL
suma:add al,[bx]
inc bx
loop suma
ret
checksuma endp
end
;传参为BX,输出保存在AX中
注意,被选作输出参数的寄存器不能保护和恢复,但是传入参数可以。例如,上面示例的AX就无法保护。
变量传参
三种方式对应三种不同的存储方式,那么使用变量传递参数时,参数存储在主存中。如果调用程序和被调用程序在同一源代码文件中,只要设置好数据段DS,则子程序和主程序访问的变量方式就是相同的。当不在同一源文件中时,则需要public或者extern关键字。
下面使用变量传参的方式同样实现数组求和。
... ;数据段相同
.code
.startup
call checksumb
.exit 0
checksumb proc
push ax
push bx
push cx
xor al,al
mov bx,offset array
mov cx,count
sumb:add al,[bx]
inc bx
loop sumb
mov result,al
pop cx
pop bx
pop ax
ret
checksumb endp
end
可见,变量传递参数非常繁琐且不自然,但是这是保护性最高的方法。通常,在不同程序模块之间传输数据也主要使用全局变量。
堆栈传参
这种方式和高级语言传参的方式相同。堆栈传参可以使得参数非常容易寻得,弹出时可以顺序弹出,效率高,但是维护相对困难。这是因为子程序的调用和返回也是使用堆栈传输的,所以要时刻注意堆栈的分配情况。
同理,下面的代码实现了和上方相同的功能,但是使用堆栈传参。
...
.code
.startup
mov ax,offset arrayt ;设置入口参数
push ax
mov ax,count
push ax
call checksumc
add sp,4
mov result,al
.exit 0
;入口使用堆栈传参,出口使用寄存器传参,和高级语言相同
checksumc proc
push bp
mov bp,sp
push bx
push cx
mov bx,[bp+6]
mov cx,[bp+4]
xor al,al
sumc:add al,[bx]
inc bx
loop sumc
pop cx
pop bx
pop ax
pop bp ;恢复寄存器
ret
checksumc endp
end
可以看到,入口先利用堆栈保护了寄存器,在子程序中通过BP访问堆栈段中存储的数据,最后在即将返回时恢复了 寄存器。
子程序嵌套、递归和重入
子程序内调用其他的子程序叫做子程序嵌套;调用的子程序是自身时则为递归。嵌套子程序其实并没有其他特殊之处,只是若是程序中使用了堆栈段,需要注意堆栈段的分配情况,同时,太多的嵌套也有可能形成爆栈(这和高级语言相同)。
下面是一个递归计算8以内阶乘,使用递归的程序,注意其中子程序之间传参的部分。
.model small
.stack
.data
n dw 3 ;阶乘的是几
result dw ?
.code
.startup
mov bx,n
push bx
call fact
pop result
.exit
fact proc
push ax
push bp ;堆栈分配情况:bx,(ip,ax,bp)<-sp,括号表示递归压入
mov bp,sp
mov ax,[bp+6] ;ax<-n
cmp ax,0
jne fact1 ;(if n!=0) fact1
inc ax
jnp fact2 ;else 返回1
fact1: dec ax
push ax
call fact
pop ax
mul word ptr[bp+6]
fact2:mov [bp+6],ax
pop bp
pop ax 将得数返回上一个AX
ret
fact endp
总结
和高级语言函数不同,汇编语言在子程序调用和返回过程中需要手动传参,而在传参时需要注意传参的类别和传参的方式。尤其是在使用堆栈进行传递时,要注意维护堆栈。