1. 预备知识
- call 标号 将当前 IP 的内容入栈,并且根据位移跳转到执行标号处;ret 将栈顶元素赋值为 IP 寄存器,二者都是段内转移。由此,call 和 ret 指令共同支持了汇编语言编程中的模块化设计,类似于高级语言中的函数机制。如计算 N 的 3 次方:
call cube ;调用子程序
...
cube:
mov ax,bx
mul bx
mul bx
ret ;返回
- 用寄存器来存储参数和结果是最常用的方法,对于存放参数的寄存器和存放结果的寄存器,调用者和子程序的读写操作相反:调用者将参数送入参数寄存器,从结果寄存器取返回值;子程序从参数寄存器取参数,将结果送入结果寄存器。
- 在使用寄存器时我们希望:调用子程序时不必关系程序使用了哪些寄存器,编写子程序时不必关心调用者使用了哪些寄存器,不会发生寄存器冲突。解决办法是在程序的开始将所有用到的寄存器中的内容使用栈保存,然后在子程序返回前恢复:
子程序开始:
子程序使用的寄存器入栈
子程序的内容
子程序使用的寄存器出栈
ret
- 汇编语言中的除法规则:如果除数为 8 位则被除数为 16 位,默认存放在 AX 中,且 AL 存放商、AH 存放余数;如果除数为 16 位则被除数为 32 位,且高 16 位存放在 DX 中、低 16 位存放在 AX 中,且 AX 存放商、DX 存放余数。
2. 实验任务 1:显示字符串
在指定的位置,用指定的颜色,显示一个用 0 结束的字符串。参数:(dh)=行号,(dl)=列号,(cl)=颜色,ds:si 指向字符串的首地址。如在屏幕的 8 行 3 列,用绿色显示 data 段中的字符串:
assume cs:code
data segment
db 'Welcome to masm!',0
data ends
code segment
start:
mov dh,8 ;行号
mov dl,3 ;列号
mov cl,2 ;颜色
mov ax,data
mov ds,ax ;段寄存器DS指向数据段data
mov si,0
call show_str ;调用子程序
mov 4c00h
int 21h
show_str:
... ;待完成部分
code ends
end start
- 由于需要显示有颜色的字符串,所以使用 80×25 彩色模式。每个字符占用 2 个字节,低位存储 ASCII 码、高位存储属性。一屏幕共占用 4000 字节,可存储 2000 个字符,共 25 行,每行 80 个字符。
- 由于写入位置的行号为 DH 的内容、列号为 DL 的内容,且每行的字节数为 160,所以可以使用乘法定位写入位置的起始位置(列的索引类似)。这里,使用两个 8 位寄存器做乘法:
mov al,160 ;8位寄存器乘法,一个存在AL中,另一个存在寄存器或内存单元(DH)中
mul dh ;定位行的偏移,乘法结果存放在AX中
mov bx,ax ;以防被下一个乘法覆盖,这里需要保存寄存器AX的内容,如BX
mov al,2
mul dl ;定位列的偏移,乘法结果存放在AX中
add bx,ax ;最终的偏移
- 由于事先不知道 data 段中字符串的长度,题目给出字符串的用 0 结束。jcxz 标号的功能是当寄存器 CX 的内容为 0 时则跳转到标号执行。所以,我们可以使用寄存器 CX 来接收字符串中的内容,并使用 jcxz 指令来判断是否到达字符串结尾。
- 由于不知道循环次数,这里使用 jmp 指令配合 jcxz 来完成循环功能:
help:
mov cl,data:[] ;将data段字符串依次写入CL中
jcxz 标号 ;一个退出标号,退出当前help子程序
... ;写入字符及其属性
... ;相关偏移
jmp short help ;转移实现类似循环的功能
- 根据预备知识,为防止寄存器冲突,在程序的开始将所有用到的寄存器中的内容使用栈保存,然后在子程序返回前恢复。最终整体程序为:
assume cs:code
data segment
db 'Welcome to masm!',0
data ends
code segment
start:
mov dh,8
mov dl,3
mov cl,2
mov ax,data
mov ds,ax
mov si,0 ;使ds:si指向字符串的字符
mov di,0 ;di用于索引写入时的位置
call show_str
mov ax,4c00h
int 21h
show_str:
push ax
push bx
push cx
push dx
push es
push si
push di ;将子程序用到的寄存器内容入栈
mov ax,0B800h
mov es,ax ;寄存器ES指向彩色模式段
mov al,160 ;8位寄存器乘法,一个存在AL中,另一个存在寄存器或内存单元中
mul dh ;定位行的偏移,乘法结果存放在AX中
mov bx,ax ;以防被下一个乘法覆盖,这里需要保存寄存器AX的内容,如BX
mov al,2
mul dl ;定位列的偏移,乘法结果存放在AX中
add bx,ax ;最终的偏移
mov al,cl ;将颜色属性存到AL中,因为后面的jcxz指令会用到CX
help:
mov cl,ds:[si] ;取字符串的字符
jcxz exit ;如果CX等于0则退出
mov es:[bx+di],cl ;低位写入字符的ASCII码
mov es:[bx+di+1],al ;高位写入字符的属性
inc si ;偏移1字节取字符
add di,2 ;偏移2字节写字符
jmp short help ;转移实现类似循环的功能
exit:
pop di
pop si
pop es
pop dx
pop cx
pop bx
pop ax ;将子程序用到的寄存器内容出栈
ret ;子程序返回
code ends
end start
程序运行结果:
3. 实验任务 2:解决除法溢出的问题
由此可见,被除数的位数比除数高,这就可能造成除法溢出的情况。如在除数为 8 位被除数为 16 位时,假设结果也为 16 位,则商 / 余数无法存放在 8 位寄存器(AH、AL)中。
在汇编语言中,如果商过大,超过了寄存器所能存储的范围,CPU 执行到 div 指令时会引发内部错误,即除法溢出。如,我们编写一个除法溢出的程序,然后用 debug 调试:
assume cs:code
code segment
start:
mov ax,1000H ;被除数
mov bl,1 ;除数
div bl ;这里除法结果为1000H,需要用16位寄存器存放,除法溢出
mov ax,4c00h
int 21h
code ends
end start
由结果可知,此时系统对其做了相关处理。编写子程序 divdw 来解决除法溢出问题:
名称:divdw
功能:进行不会产生溢出的除法运算,被除数为 dword 型、除数为 word 型,结果为 dword 型
参数:(ax)=dword 型数据的低 16 位、(dx)=dword 型数据的高 16 位、(cx)=除数
返回:(dx)=结果的高 16 位、(ax)=结果的低 16 位、(cx)=余数
如,计算 F4240H/0AH:
mov ax,4240h
mov dx,000Fh
mov cx,0Ah
call divdw
执行子程序调用后,(dx)=0001H、(ax)=86A0H、(cx)=0。此外,题目中给了一个针对可能出现的除法溢出问题的解决方法:
X:被除数,范围:[0, FFFFFFFF]
N:除数,范围:[0, FFFF]
H:X 高 16 位,范围:[0, FFFF]
L:X 低 16 位,范围:[0, FFFF]
int():取商,如:int(38/10)=3
rem():取余,如:rem(38/10)=8
公式:X/N=int(H/N)*65536+[rem(H/N)*65536+L]/N
这个公式将可能产生溢出的除法运算:X/N,转变为多个不会产生溢出的除法运算。公式中,等号右边的所有除法运算均不会产生溢出。先手动计算:
H: 000FH L: 4240H N: 000AH
H/N 的商为 1H、余数为 5H。所以,int(H/N)=1H、rem(H/N)=5H。
X/N=int(H/N)*65536+[rem(H/N)*65536+L]/N
=1H*10000H+[5H*10000H+4240]/000AH
=10000H+[54240H]/000AH
=10000H+86A0H(没有余数)
=186A0H
根据手动计算的过程可以得到:左边除法运算的商由两部分组成,即右边等式加号的左右两部分。第一部分的商乘以 65536 相当于将其存放在结果的高 16 位;同理,加号右边部分乘以 65536 相当于将余数放到高 16 位。
- H/N 表示被除数 X 的高 16 位除法。因为除数 N 是 16 位,所以被除数由高 16 位 DX 和 低 16 位 AX 组成。这里,需将 DX 设置为零:
mov bx,ax ;由于后面除法会用到AX,将其内容暂存到寄存器BX中
mov ax,dx ;针对X高16位的除法,将被除数X的高16位存放在AX中
mov dx,0 ;除法H/N中高16位设置为零
div cx ;执行除法H/N,此时结果的商存放在AX中、余数存放在DX中
此时,除法结果中的高位为最终结果的商的高 16 位。由于后面还有一个除法,会继续使用寄存器 AX,所以将其值存放到其他寄存器:
mov si,ax ;H/N运算结果的商
- 由前面的叙述可知,在第二个除法中,由于除数为 16 位,所以被除数为 32 位。且,被除数的高 16 位由 H/N 除法运算的余数组成,低 16 位由 L 组成:
mov dx,dx ;这里表示将H/N的余数(DX)放到下一次除法的高16位处(DX)
mov ax,bx ;将被除数低16位的内容放到AX中
div cx ;;执行除法[rem(H/N)*65536+L]/N,此时结果的商存放在AX中、余数存放在DX中
题目要求,(dx)=结果的高 16 位、(ax)=结果的低 16 位、(cx)=余数。高 16 位前面存放寄存器 SI 中了,低 16 位存放在 AX 中,余数存放在 DX 中:
mov cx,dx ;余数
mov dx,si ;高16位
mov ax,ax ;低16位
综合以上,完成程序为:
assume cs:code
code segment
start:
mov ax,4240h
mov dx,00Fh
mov cx,0Ah
call divdw
mov ax,4c00h
int 21h
divdw:
mov bx,ax ;由于后面除法会用到AX,将其内容暂存到寄存器BX中
mov ax,dx ;针对X高16位的除法,将被除数X的高16位存放在AX中
mov dx,0 ;除法H/N中高16位设置为零
div cx ;执行除法H/N,此时结果的商存放在AX中、余数存放在DX中
mov si,ax ;H/N运算结果的商
mov ax,bx ;将被除数低16位的内容放到AX中
div cx ;;执行除法[rem(H/N)*65536+L]/N,此时结果的商存放在AX中、余数存放在DX中
mov cx,dx ;余数
mov dx,si ;高16位
ret
code ends
end start
程序运行后各存放结果的寄存器:
4. 实验任务 3:数值显示
编程,将 data 段中的数据以十进制的形式显示出来。
data segment
dw 123,12666,1,8,3,38
data ends
这些数据在内存中以二进制的形式存放,如 12666(317AH)。而如果我们要在显示器上看到 12666,我们看到的应该是一串字符,计算机理解的内容是其 ASCII 码值:31H、32H、36H、36H、36H。
因此,要将数据以十进制形式显示在屏幕上,需进行两步工作:将用二进制信息存储的数据转变为十进制形式的字符串,显示十进制形式的字符串。
第二步可以使用实验 1 中的子程序完成,现将第一步定义为如下子程序:
名称:dtoc
功能:将 word 型数据转变为表示十进制数的字符串,字符串以 0 为结尾符
参数:(ax)=word 型数据,ds:si 指向字符串的首地址
返回:无
首先实现,将数据 12666 以十进制的形式在屏幕的 8 行 3 列用绿色显式出来。 程序如下:
assume cs:code
data segment
db dup 10(0)
data ends
code segment
start:
mov ax,12666
mov bx,data
mov ds,bx ;寄存器DS指向data段
call dtoc ;调用子程序完成转换
mov dh,8
mov dl,3
mov cl,2
call show_str ;调用子程序完成显示
mov ax,4c00h
int 21h
code ends
end start
-
要通过数字 12666 得到其字符串形式,首先要得到数字的每一位,然后将其转换成 16 进制的 ASCII 码值。第一步,可通过除 10 取余的操作从低到高依次得到数字的每一位;第二步,将得到的每一位加上 30H 即可得到对应的 ASCII 码值。
-
如何判断除 10 操作是否继续进行? 如果商为 0,则不继续进行除 10 操作,而当前余数为数字的最高位。解决办法是使用寄存器 CX 来存储商,再借助 jcxz 指令来实现相关功能。这里,除数 10 使用 BX 存储,则被除数低 16 位默认放在 AX 中。除法部分逻辑为:
mov bx,10 ;BL存放除数
mov ax,12666 ;AX存放被除数
div bx ;默认16位除法的商存放在AX中、余数存放在DX中
add dx,30h ;处理余数
mov cx,ax ;使用寄存器CX来存储商
jcxz ok ;基于jcxz跳转指令判断商是否为0
其次,由于从低到高取数字的每一位,与最终显式数字的每一位的顺序相反,这里利用栈先进后出的特点处理余数。同时,使用寄存器 DI 来记录共处理的元素数量以确定后续出栈的元素个数。结合实验任务 1 中的 show_ptr 子程序,完整程序为:
assume cs:code
data segment
db 10 dup (0)
data ends
code segment
start:
mov ax,12666
mov bx,data
mov ds,bx ;寄存器DS指向data段
mov si,0
call dtoc ;调用子程序完成转换
mov dh,8
mov dl,3
mov cl,2
call show_str ;调用子程序完成显示
mov ax,4c00h
int 21h
dtoc:
push ax
push bx
push cx
push dx
push si
push di ;将子程序用到的寄存器内容入栈
mov di,0 ;计数器清零
mov bx,10 ;BX存放余数
div_call:
mov dx,0 ;寄存器DX清零
div bx ;被除数在AX中,默认16位除法的商存放在AX中、余数存放在DX中
add dx,30h ;余数加上30H转换为对应ASCII码值
push dx ;入栈
inc di ;计数
mov cx,ax ;判断商是否为零
jcxz ok
jmp short div_call ;如果商不为零则继续执行除法运算
ok:
mov cx,di ;DI记录了栈中数据个数,将其赋值给CX作为后续循环次数
assign:
pop ax ;栈中数据依次出栈
mov ds:[si],al ;写入数据段,每次写入一个字节
inc si ;偏移1个字节写入下个元素
loop assign ;循环赋值
pop di
pop si
pop dx
pop cx
pop bx
pop ax ;将子程序用到的寄存器内容出栈
ret ;子程序返回
show_str:
push ax
push bx
push cx
push dx
push es
push si
push di ;将子程序用到的寄存器内容入栈
mov ax,0B800h
mov es,ax ;寄存器ES指向彩色模式段
mov al,160 ;8位寄存器乘法,一个存在AL中,另一个存在寄存器或内存单元中
mul dh ;定位行的偏移,乘法结果存放在AX中
mov bx,ax ;以防被下一个乘法覆盖,这里需要保存寄存器AX的内容,如BX
mov al,2
mul dl ;定位列的偏移,乘法结果存放在AX中
add bx,ax ;最终的偏移
mov al,cl ;将颜色属性存到AL中,因为后面的jcxz指令会用到CX
help:
mov cl,ds:[si] ;取字符串的字符
jcxz exit ;如果CX等于0则退出
mov es:[bx+di],cl ;低位写入字符的ASCII码
mov es:[bx+di+1],al ;高位写入字符的属性
inc si ;偏移1字节取字符
add di,2 ;偏移2字节写字符
jmp short help ;转移实现类似循环的功能
exit:
pop di
pop si
pop es
pop dx
pop cx
pop bx
pop ax ;将子程序用到的寄存器内容出栈
ret ;子程序返回
code ends
end start
程序运行结果如下:
现在,如果将待写入数据改为:
data segment
dw 123,12666,1,8,3,38
data ends
结合上述程序,需要额外定义一个段来存放余数:
remainder segment
db 100 dup (0)
remainder ends
由写入单个数字到写入多个数字需改进的地方:
- 最外层加一个循环,且循环次数为待写入数字的个数。
- 根据上一个程序,需要额外使用一个数据段来存储余数。同时,由于 show_ptr 中使用 0 作为结束标识符,在写完一个数字的余数后要在结尾处写入一个 0。
- 由于使用的子程序较多,子程序开始时保存所有用到的寄存器、结束时恢复寄存器。
5. 总结
注意,在多子程序调用时,在子程序开始前保存所所用的寄存器、结束时恢复寄存器。