实验10:编写子程序
一. 子程序:显示字符串
实验要求:在屏幕的8行3列,用绿色显示data段中的字符串。
名称:show_str
功能:在指定的位置,用指定的颜色,显示一个用0结束的字符串。
参数:(dh)=行号(0-24取值范围);(dl)=列号(0-79取值范围);(cl)=颜色(是一个二进制排列组合的值);ds:si指向字符串的首地址。
实验目的:
1.熟练掌握在dos屏幕上输出字符的基本操作。掌握显示缓冲区范围。
2.为什么定义字符串用0来结尾?
3.熟练掌握从内存中读取字节单元内容和字单元内容;并将该内容写入我们期望的内存中。
4.掌握8位乘法和16位乘法的操作。
程序分析:
在实验九中我们可以知道一些基本信息:
命令提示符窗口或dos窗口,我们可以显示80X25的字符(我的机器行数多,命令提示符窗口,跟设置有关)。每行80个字符,一共是25行。它们在内存中是在一个内存段中存储的,这个内存区域叫做显示缓冲区。从物理地址B8000H~BFFFH这个32K的内存区域就是显示缓冲区。
在显示缓冲区中,偶数字节单元表示的是字符,奇数字节单元表示的是字符的属性(颜色、闪烁等)。也就是说在显示缓冲区中,每2个字节负责屏幕上一个字符的显示(包括显示的属性)。
在显示缓冲区内写入的字符,立即就显示在屏幕上。
因为每行要显示80个字符,故从0000H~009FH是显示的第一行(共160个字节)。每行可以类推。也就是说行的偏移量是160个字节。
为什么在定义字符串时候,结尾有个0?
讲解:因为在汇编和其它语言中,字符串存储在内存中,长度不一致,我们统一规定在每个字符串的结尾有个数字0,就代表了这个字符串结束了。规定(也是便于管理)
在写入显示缓冲区中时,我们为什么使用[bx+di+idata]的方式?
在子程序中,我们通过计算得出了在特定行和特定列(由主程序中的dl和dh参数传入)的基于b800:0000的偏移地址是(bx);(di)代表了从这个偏移地址开始,每个字符的偏移地址。(idata)代表了每个字符的二个字节(一个是字符本身,一个是字符的颜色属性)。
代码如下:
assume cs:code
data segment
db 'Welcome to masm!', 0 ;内存data段中定义一个字符串
data ends
code segment
main: ;字符串参数
mov dh, 8 ;屏幕的行数
mov dl, 3 ;所在行的列数
mov ch, 0 ;ch清零,防止高8位不为零。
mov cl, 2 ;颜色属性(此处应是二进制数0000 0010)
mov ax, data
mov ds, ax
mov si, 0 ;将ds:si指向字符串
call show_str
mov ax, 4c00H
int 21H
;show_str功能 :按行和列及字符属性显示字符串
;入口参数:dh-行数、dl-列数、cl-字符属性、ds:[si]指向字符串。
;返回值:无
show_str: push dx
push cx
push si ;将子程序用到的寄存器入栈
mov ax, 0b800H
mov es, ax ;设置显示缓冲区内存段
mov ax, 0 ;(ax)= 0,防止高位不为零
mov al, 160 ;0a0H- 160字节/行
mul dh ;相对于0b800:0000第dh行偏移量
mov bx, ax ;将第(dh)行的偏移地址送入bx,bx代表行偏移
mov ax, 0
mov al, 2 ;列的标准偏移量是2个字节
mul dl ;同一行列的偏移量,尽量使用乘法,(al)=列偏移
add bx, ax ;最终获得偏移地址(bx)=506H
mov di,0 ;将di作为每个字符的偏移量
mov al, cl ;将字符属性写入al中
mov ch, 0 ;将cx高8位设置为0
show: mov cl, ds:[si] ;将字符串单个字符读入cl中
jcxz ok ;判断字符串是否为零。
mov es:[bx+di+0], cl ;在显示缓冲区中写入字符
mov es:[bx+di+1], al ;在显示缓冲区中写入字符属性
add di, 2
inc si
jmp short show
ok: pop si ;字符串字符为0,结尾
pop dx
pop cx ;恢复寄存器
ret
code ends
end main
程序体会:
1).参数的传递.。此程序有3个参数,它们是:dh)=行号(0-24取值范围);(dl)=列号(0-79取值范围);(cl)=颜色。参数的传递方式是传递值。
通过修改这3个参数,我们可以方便的将data段中定义的字符串显示在我们需要的位置。
2).子程序的调用,我们可以多次调用该子程序,用于显示特定的字符串。只要知道字符串的地址,我们不必关心子程序内部是怎样运算的。也就是说只要我们指定ds:si的指向就可以了。那么我们使用实验10的代码,改写实验9的程序就轻松了。只需要在主程序中添加参数和再次call下就行了。
3).只要给出相关的参数值,调用子程序,我们就可以达到我们预期的目的(子程序设计的目的)
4).还是要熟悉从内存中读入一个字符,我们采用的是ds:[si] 方式;写入到显示缓冲区中(同样是内存,它们没有任何的区别,CPU把所有的设备都内存化了),我们采用了
es:[bx+di+idata]的方式。由于ds寄存器被data段占用了,目前只有es寄存器可用,只好把es当做了显存的段寄存器,bx代表了行偏移、di代表了列偏移、idata(值是0和1)代表了列的2个字节(2个字节代表一个字符的显示)的偏移。
5)这个程序是固定了行和列,我们也可以通过程序来提示行和列,做到人机交互,这个本章没有涉及。呵呵。
6)关于接口的问题,我们查找其他资料了解。
二. 解决除法溢出的问题
问题提出:
考虑下面代码1:
mov bh, 1
mov ax, 1000
div bh
程序分析:由于除数是(bh),故div是执行的8位除法,(ax)/(bh)=1000(结果);结果的商(1000)应该存放在al中,结果的余数(0)存放在ah中;从代码我们得知,它的结果的商是1000,al是8位寄存器,保存的数值(0~255如果按照无符号数运算)超出了存储范围。
考虑下面代码2:
mov ax, 1000H
mov dx, 1
mov bx, 1
div bx
程序分析:由于除数是(bx),故div是执行的16位除法,被除数应该是:(dx)高16位,与(ax)低16位组合在一起=11000H,故11000H/(bx)=11000H(结果)。结果的商(11000H)存入ax中,结果的余数(0)存入到dx中;由于ax寄存器不能存储11000H数值,导致溢出。
除法溢出:除法操作时,由于运算结果商的值过大,超出ax寄存器的存储范围,导致ax寄存器不能存储该值。CPU将引发一个内部错误:除法溢出。
展示下如何导致除法的溢出。(在debug中直接演示)
解决方法:编程一个子程序。
名称:divdw
功能:进行不会产生溢出的除法运算,被除数为dword型,除数为word型,结果为dword型。
参数:(ax)=dword型被除数的低16位
(dx)=dword型被除数的高16位
(cx)=除数
返回值:(ax)=dword型结果的低16位
(dx)=dword型结果的高16位
(cx)=除数
例子:计算1000000/10(F4240H/0AH)
mov ax, 4240H
mov dx, 000FH
mov cx, 0AH
call divdw
程序分析:
1)首先判断这个无符号数值的除法运算;1000000==F4240H这个数ax寄存器肯定存储不下,1000000/10==F4240H/0AH=186A0H(结果),结果ax也存储不下,结果的余数(0)dx倒是可以存储。这种情况CPU会发生内部错误:除法溢出。
2)我们将被除数(一个双字单元,4个字节)的高16位(000FH)存储在dx中,将低16位(4240H)存储在低16位中;除数(0AH)存储在cx中。
3)调用子程序divdw,子程序的返回值,高16位存储在dx中,将低16位存储在低16位中;除数依然不变存储在cx中。
4)考虑将被除数和除数定义在内存data段中,通过内存读入到寄存器中,这样符合设计思想。在此例子中,似乎王老师希望直接在寄存器赋值。
5)我们不必纠结这个公式的推算,这是数据结构中算法负责研究的事情,我们只管负责把这个推算的公式汇编语言代码化。
6)分析这个公式:
X是被除数:(范围[0~FFFFFFFFH]),也就是0F4240H
N是除数:(范围[0~FFFFH]),也就是0AH
H:高16位(范围[0~FFFFH]),对于被除数来说就是000FH
L:低16位(范围[0~FFFFH]),对于被除数来说就是4240H
int():取商,int(H/N)也就是求H/N结果的商。
rem():取余,rem(H/N)也就是求H/N结果的余数。
公式:X/N=int(H/N)*65536+[rem(H/N)*65536+L]/N
噢!MY GOD!你还能不能再把这个烂公式简单点???这个有点弯弯绕。大部分人看不懂这个公式。
解读这个公式(希望我们不要把时间花费在这个上面,这是数据结构的问题):
明确65536是什么东东?10000H;等价于左移16位,也就是说此例中代表了高位寄存器。
X/N代表,你懂的!!! X(被除数)代表一个dw类型的双字单元,N(除数)代表一个字单元。
int(H/N)*65536代表了X/N结果高16位
[rem(H/N)*65536+L]/N代表了X/N结果的低16位
+代表了将它们的组合一起,代表了一个dw类型的双字单元。
程序分析:
首先:我们先求H/N,这个有结果了,公式的二个部分都有结果了,商=int(H/N);
余数:rem(H/N)。
要使用div指令,div cx,最初:(ax) =4240H(低16位);(dx)=000FH(高16位);(cx)=0AH(除数)。
由于ax中目前有值,先将其压栈保存。
然后(ax)=(dx)(高16位的数值),(dx)=0000H,(cx)不变,也就是说0000 000FH/000AH。结果的商为0001H,存储在了ax中,此时(ax)=0001H,结果的余数存储在dx中了,此时(dx)=0005H。
然后我们求出int(H/N)*65536,结果的高16位:
H/N的结果有了,商是0001H(等价于int(H/N)),保存在了ax中。这个数我们另存储在一个寄存器中(由于后面需要ax参与运算)。此例中我们把这个值先保存在了bx中;
为毛*65536?表明了它是一个数据(2个字单元)的高16位。也就是说0001H代表了最终结果的高16位的数值。
其次我们求出[rem(H/N)*65536+L]/N ,运算结果商代表最终结果的低16位,运算结果的余数,代表最终结果的余数:
理解公式的后部分:同理:[rem(H/N)*65536+L]也代表一个数值:高16位是rem(H/N)*65536(使用这个表示),其实存储在高16位寄存器变量中的数值是rem(H/N);为什么*65536,表示它左移了16位,代表高16位。低16位是L,就是最初存储在ax中的那个数值。将它们二者组合后,形成一个新的数值,这个新的数值与N(始终保持不变的cx变量)做除法运算,运算结果的商=(ax),余数=(dx)。也就是说此时的(ax)就是低16位的数值。
通俗的讲就是将H/N的余数(等价于rem(H/N))作为高16位;将L作为低16位;
将它们组合成一个数值,与cx做除法运算。此时我们的H/N运算结果的余数存储在dx中了,是0005H,正好,就是我们需要的数值;L代表了低16位的值,这个值在栈中呢,弹栈到ax(把它恢复就可以了pop ax),那么(dx)=0005H,(ax)=4240H,(cx)=0AH,将它们组合后形成一个dw型的双字数值:0005H+4240H=00054240H。也就是00054240H/0AH;结果的商存储在ax中(此时ax=86A0H),余数是0存储在dx中。
最终结果的高16位的值是:0001H(我们把它存储在了bx中),把它送入dx中去;低16位就是ax值,除数cx值不变。也就是说最终结果是186A0H。
汇编代码如下:
assume cs:code
code segment
start:
mov ax, 4240H ;被除数,低16位
mov dx, 000FH ;被除数,高16位
mov cx, 0AH ;除数
call divdw ;调用divdw子程序,做不溢出的除法运算。
mov ax, 4c00H
int 21H
divdw: ;子程序开始
push ax ;将被除数低16位先压栈保存。
mov ax, dx ;(ax)=(dx)
mov dx, 0000H ;
div cx ;此时(dx)=0000H,(ax)=000FH,组合成0000000FH。
mov bx, ax ;将H/N结果的商先保存在bx中,(bx)=0001H
pop ax ;将L值弹栈到ax
div cx ;此时(dx)=0005H,(ax)=4240H,组合成54240H
mov cx, dx ;返回值(cx)等于最终结果的余数
mov dx, bx ;最终结果高16位值=(bx)
ret
code ends
end start
在debug中运行结果是:
AX=86A0 BX=0001 CX=0000 DX=0001 SP=FFFE BP=0000 SI=0000 DI=0000
DS=0B56 ES=0B56 SS=0B66 CS=0B66 IP=0022 NV UP EI PL NZ NA PO NC
结果分析:与F4240H/0AH=186A0H(商)结果一样,余数:(cx)=0000H
实验目的:
1)考察对于一个较大的数值的存储,无论是在寄存器中还是在内存中。
2)熟悉div除法指令的内涵,它的操作数存储的寄存器是那些?运算的结果又存储在那些寄存器中。
3)合理例如栈空间保存一些临时的数值。
改进程序:这个程序看着有点累,如果将这个被除数存储在内存中,代码就显得好理解些,有兴趣的自行修改。
三。数值显示
问题提出:
编程,将data段中定义的数据以十进制的方式显示出来(在计算机屏幕上)
data segment
dw 123, 12366, 1, 8 , 3, 38
data ends
编程分析:
1)确定123, 12366, 1, 8 , 3, 38这些数在内存中是以二进制形式存储的(二进制补码),2个字节存储一个数字。12366=(0011 0001 0111 1010B)=(317AH)
2)在计算机屏幕上显示的数字、字符、其他符号,一律按照字符方式显示,都是按照ASCII码来处理的。也就是说在屏幕上显示的1它不代表数值1,而是字符1。我们遇到的问题变成了怎样把二进制代码(补码方式存储的)变成ASCII码;
提示:0~9字符在ASCII码中是30H~39H,是否有规律?0=30H、1=30 H +1、……9=30H+9。
3)在底层显卡显示方面,由于我们知道了CPU只要在显卡的显存中写入期望的数据,它就显示在屏幕上,那么我们就可以利用实验10第一个子程序了。
4)怎样将一个十进制的数值转变为表示十进制数的字符串,并且字符串以0为结尾符号。例如:将数值12666转变成一个字符串“12666”,也就是说得到1,2,6,6, 6的ASCII码,他们分别是31H、32H、36H、36H,36H。怎样得到十进制的各个数字呢?我们可以使用将12666除以10,然后取余数,将余数倒序后,就得到了12666的各个位的数字了,对于12666搞个循环,5次,就将它们搞定了。这里注意余数的顺序。
怎样转换成ASCII码?字符0(ASCII码30H),同理,字符3就是30H+3,总结:30H+余数就是对应的ASCII码。
但是对于我们不知道的一个十进制数字,怎么判断各位的值求出来呢?只要保证结果的商是0,那么这个数除以10肯定结束了。这样我们可以使用jcxz指令判断(CX)是否为0(将结果的商每次送入到cx中),作为结束循环条件。
5)由于(ax)/10的求商(求各个位的数字)的顺序是倒序的(原理看书吧!)。怎样把它的顺序给倒过来?我们可以采用栈的结构,利用栈的先进后出的原理,弹栈时将栈顶的值(也就是数字的最高位的值)先写入内存data段中。这样就解决了字符顺序的问题。
也可以判断该数字一共有几个数字组成,然后在写入内存时,si的值是从大到小递减也可以。
还是利用系统给你的栈结构吧。那个是免费了,不用费事了!
6)编写子程序dtoc,功能是将ax中的存储十进制数值(传入参数(ax))转换成对应的ASCII码,并将这些字符按顺序写入到内存data段中。
7)调用子程序show_str,显示该data段的字符串。完成在计算机屏幕中显示12666这个字符串的功能。
汇编代码如下:
assume cs:code
data segment
db 10 dup (0) ;初始化10个字节,置零
data ends
code segment
start: mov ax, 12666 ;将显示的数字赋值给ax
mov bx, data
mov ds, bx
mov si, 0 ;将ds:si指向data内存段
call dtoc ;调用dtoc子程序
;为调用show_str做准备
mov dh, 8 ;屏幕的行数
mov dl, 3 ;所在行的列数
mov ch, 0 ;ch清零,防止高8位不为零。
mov cl, 2 ;颜色属性(此处应是二进制数0000 0010)
call show_str ;调用show_str子程序将字符串显示?
mov ax, 4c00H
int 21H
;-----
;dtoc功能:将一个数字转换成字符串,并写入data段中。
;入口参数:ax, ds
;返回值:无
;-----
dtoc: ;保护寄存器变量值,因为下面的变量子程序都用到。
push ax
push cx
push bx
push si
mov si, 0 ;偏移地址置零
mov bx, 10 ;除数=10
change: mov dx, 0 ;涉及到16位除法,先将存储余数的变量置零
div bx ;将(ax)/(bx)
mov cx, ax ;将除法运算结果的商赋值给cx,用于条件判断 jcxz last ;判断cx是否为0?或商为零?
add dx, 30H ;将每个位的数字转换成ASCII码值
push dx ;将ASCII码值压栈保存
inc si
jmp short change
last: ;最后一次除法,商为0,(dx)=余数时,没有转换并压栈。故。。。。。。
add dx, 30H ;将数字转换成ASCII码
push dx ;将字符值压栈
inc si ;最后一次也要转换并压栈
;将栈中数据倒序写入内存data段中
mov cx, si ;(si)=字符串共几个字符,设置循环计数器cx。
mov si, 0
s: pop ds:[si] ;弹栈,并写入data内存段。
inc si
loop s
exit: ;恢复寄存器,并返回主调程序。
pop si
pop bx
pop cx
pop ax
ret
;------
;show_str功能 :按行和列及字符属性显示字符串
;入口参数:dh-行数、dl-列数、cl-字符属性
;返回值:?
;------
show_str: push dx
push cx
push si ;将子程序用到的寄存器入栈
mov ax, 0b800H
mov es, ax ;设置显示缓冲区内存段
mov ax, 0 ;(ax)= 0,防止高位不为零
mov al, 160 ;0a0H- 160字节/行
mul dh ;相对于0b800:0000第dh行偏移量
mov bx, ax ;将第(dh)行的偏移地址送入bx,bx代表行偏移
mov ax, 0
mov al, 2 ;列的标准偏移量是2个字节
mul dl ;同一行列的偏移量,尽量使用乘法,(al)=列偏移
add bx, ax ;最终获得偏移地址(bx)=506H
mov di,0 ;将di作为每个字符的偏移量
mov al, cl ;将字符属性写入al中
mov ch, 0 ;将cx高8位设置为0
show: mov cl, ds:[si] ;将字符串单个字符读入cl中
jcxz ok ;判断字符串是否为零。
mov es:[bx+di+0], cl ;在显示缓冲区中写入字符
mov es:[bx+di+1], al ;在显示缓冲区中写入字符属性
add di, 2
inc si
jmp short show
ok: pop si ;字符串字符为0,结尾
pop dx
pop cx ;恢复寄存器
ret
code ends
end start
程序理解:
1)子程序show_str的代码没有任何改变,拿来直接用就可以了。注意在汇编语言编程中代码段的框架结构。
2)如果遇到的数字数值过大?可以考虑实验第二个子程序:divdw来解决问题。此时也应该考虑这个数字位数多,在data段中多初始化内存空间;
3)合理利用系统提供的栈结构,或程序员创建的栈结构,提高临时存储数据的效率。
4)有时间看看ASCII的有关资料。帮助你理解字符及字符串。
5)我们在写入data内存段时,结尾没有0,这个不必纠结,在我们初始化data时,都置零了,也就是说“12666”后面有零。字符串后面有0,为什么?我们以前介绍了。
C语言随想:看来我们还是怀念C,为了在屏幕上显示字符串,费劲太大了。C语言一个语句就搞定了。但在C中你看不到底层是怎么操作的,其实跟这个类似。