零、文字显示
如果我们要显示文字,我们需要的硬件是显示器和显卡,显卡用于为显示器提供显示内容,并控制显示器的显示模式和状态。
显卡控制显示器的显示内容,我们很容易想到通过像素控制,显卡通过提供这些像素的信息(灰度值、RGB值等),显示器将其显示出来。显卡中存储这些信息的地方称为显存,显示器解读这些信息,并显示在屏幕上。
显卡具有图形模式、文本模式两种基本工作模式,在不同的工作模式下,对显存内容的解读方式也不同。接下来介绍文本模式的显示原理——
一、文本模式显示原理
在8086结构中,显存位于内存地址的0xB8000——0xBFFFF处,为了在屏幕上显示出文字,我们需要把必要的信息加载到内存以0xB8000
起始的位置。
显卡初始化时,会形成一个25x80的文本模式(25行、80列),这部分内容的信息来自内存0xB8000
——0xB8FA0
,由于屏幕上的每一个字符需要2个连续字节表示,一共25x80x2 = 4000B的空间。
两个连续字节包含的信息如下:
- 低字节:字符的ASCII码,由于ASCII码只有7bit,这一字节的最高位置为0;其中有一部分表示信息的代码是无法显示的。
- 高字节:字符的属性,表示为K R G B I R G B。低4位定义的是前景色,高4位定义的是背景色,K为闪烁位,I为亮度位。
在初始化的情况下,显存里存储的是黑底白字的空白字符。
我们用一段简单的汇编代码测试下上述原理:
mov ax,0
mov bx,ax ; bx = 0
mov ax,0x0B800 ; 文本窗口显存起始地址
mov es,ax ; ex = 0xB800
mov byte [es:bx],'A' ;字符编码
mov byte [es:bx+1],0x07 ;字符属性
add bx, 3998 ;文本窗口最后一个字符的位置
mov byte [es:bx],'Z'
mov byte [es:bx+1],0x07
jmp $
times 510-($-$$) db 0
dw 0xaa55
上述代码将文本窗口的第一个字符(左上角)设置为’A’,最后一个字符(右下角)设置为’Z’。
编译代码并写入主引导扇区,利用虚拟机打开,发现结果符合我们的预想。
二、确定行列位置
文本窗口是一个80x25的窗口,如果我们需要限定文字在第几行的第几个位置,每次都手动计算在显存中的偏移地址就比较麻烦了。
假设x和y表示列和行,那么偏移地址为$2\times (y\times80+x)$。我们修改上述汇编代码,用于显示给定行列的字符:
mov ax, 0x7c0
mov ds, ax
mov ax, 0x0B800 ; 文本窗口显存起始地址
mov es, ax
xor ax,ax ; 计算显存地址
mov ax,[y]
mov bx,80
mul bx
add ax,[x]
mov bx,2
mul bx
mov bx,ax ; bx = position
mov byte [es:bx],'A'
inc bx
mov byte [es:bx],0x07
end:
jmp $
data:
x dw 40 ;存储x坐标
y dw 12 ;存储y坐标
times 510-($-$$) db 0
dw 0xaa55
我们使用X、Y作为坐标,在11-18行根据坐标计算出在显存中的偏移地址,在屏幕正中央的位置打印出一个字符:
三、显示数字
显示一串数字是一个简单的操作,我们需要将放在内存中的值以十进制的方式一位一位显示在屏幕上:
mov ax, 0x7c0
mov ds, ax ; 数据段起始地址
mov ax, 0x0B800
mov es, ax ; 文本窗口显存起始地址
mov ax,[x] ;取得x中的数据
mov bx,10 ;十进制
;求个位上的数字
mov dx,0
div bx
mov [number+0x00],dl ;保存个位上的数字
;求十位上的数字
xor dx,dx
div bx
mov [number+0x01],dl ;保存十位上的数字
;求百位上的数字
xor dx,dx
div bx
mov [number+0x02],dl ;保存百位上的数字
;求千位上的数字
xor dx,dx
div bx
mov [number+0x03],dl ;保存千位上的数字
;求万位上的数字
xor dx,dx
div bx
mov [number+0x04],dl ;保存万位上的数字
;用十进制显示标号的偏移地址
mov bx,0
mov al,[number+0x04]
or al, 0x30
mov [es:bx],al
inc bx
mov byte [es:bx],0x04
mov al,[number+0x03]
or al, 0x30
inc bx
mov [es:bx],al
inc bx
mov byte [es:bx],0x04
mov al,[number+0x02]
or al, 0x30
inc bx
mov [es:bx],al
inc bx
mov byte [es:bx],0x04
mov al,[number+0x01]
or al, 0x30
inc bx
mov [es:bx],al
inc bx
mov byte [es:bx],0x04
mov al,[number+0x00]
or al, 0x30
inc bx
mov [es:bx],al
inc bx
mov byte [es:bx],0x04
end:
jmp $
data:
number db 0,0,0,0,0 ;用于存储计算结果
x dw 11111 ;存储需要打印出来的数值
times 510-($-$$) db 0
dw 0xaa55
第1-2行代码将数据段寄存器ds初始化为0x7c0
,这是因为主引导扇区的代码会被加载到内存的0x7c00
区域,段地址为0x7c0
,初始化后在访问74行代码后的数据才不会出错。
除法指令如果源操作数为16b,则计算为 ` dx:ax / 源操作数 = ax …… dx,将余数的低8位
dl取出,保存至
number`。
代码从数据段中读取x内存单元的数值,经过除法运算得到每一位的值,最终将结果存储到number内存单元中,并将其打印出来,运行结果如下:
四、光标显示
光标(Cursor)是在屏幕上有规律地闪动的一条小橫线,通常用于指示下一个要显示的字符位置。
光标在屏幕上的位置保存在显卡内部的两个光标寄存器中,每个寄存器是 8 位的,合起来形成一个 16 位的数值。比如,0 表示光标在屏幕上第 0 行第 0 列,80 表示它在第 1 行第 0 列,当光标在屏幕右下角时,该值为 25×80-1=1999。
光标寄存器是可读可写的。你可以从中读出光标的位置,也可以通过它设置光标的位置。为了读写光标寄存器,需要通过显卡的端口进行操作。
由于显卡内部寄存器极多,我们并不能直接指定光标寄存器作为端口进行读写,而是通过索引寄存器间接访问:
索引寄存器的端口号是0x3d4
,可以向它写入一个值,用来指定内部的某个寄存器。两个8位的光标寄存器的索引值分别是 14(0x0e)和 15(0x0f),分别用于提供光标位置的高8位和低8位。
指定了寄存器之后,可以通过数据端口 0x3d5
对其进行读写。
光标用于指示下一个要显示的字符位置,为了在光标处显示字符,只需要读取光标位置,将其乘以2,就得到了在显存中的偏移地址。
下面我们读取当前光标位置,将其显示出来,并将光标置于显示出来的字符之后:
mov ax, 0x7c0
mov ds, ax
mov ax, 0x0B800 ; 文本窗口显存起始地址
mov es, ax
;读取当前光标位置
mov dx,0x3d4
mov al,0x0e
out dx,al ;索引寄存器
mov dx,0x3d5
in al,dx ;读取高位
mov ah,al
mov dx,0x3d4
mov al,0x0f
out dx,al ;索引寄存器
mov dx,0x3d5
in al,dx ;读取低位
mov al,4
out dx,al
mov bx,10 ;十进制
;求个位上的数字
mov dx,0
div bx
mov [number+0x00],dl ;保存个位上的数字
;求十位上的数字
xor dx,dx
div bx
mov [number+0x01],dl ;保存十位上的数字
;求百位上的数字
xor dx,dx
div bx
mov [number+0x02],dl ;保存百位上的数字
;求千位上的数字
xor dx,dx
div bx
mov [number+0x03],dl ;保存千位上的数字
;用十进制显示修改前光标位置
mov bx,0
mov al,[number+0x03]
or al, 0x30
mov [es:bx],al
mov al,[number+0x02]
or al, 0x30
add bx,2
mov [es:bx],al
mov al,[number+0x01]
or al, 0x30
add bx,2
mov [es:bx],al
mov al,[number+0x00]
or al, 0x30
add bx,2
mov [es:bx],al
mov dx,0x3d4
mov al,0x0f
out dx,al ;索引寄存器
mov dx,0x3d5
mov al,4
out dx,al ;修改低位光标位置
end:
jmp $
data:
number db 0,0,0,0 ;用于存储计算结果
times 510-($-$$) db 0
dw 0xaa55
这里用了上面数字显示的代码,利用in
和out
对端口进行读写,并利用光标显示四位数字:
五、换行、回车、滚屏
上面我们说过,ASCII码中有一部分表示信息的代码是无法显示的,当我们向显存中写入这些值时,不会产生任何效果,我们需要手动为其做出解释。
在这些用以控制的ASCII码中,我们想要做出实现的是回车和换行,并以光标位置体现:
回车:0x0D
,将光标移动至当前行首,将光标位置除以80得到行数,光标位置=行数x80;
换行:0x0a
,将光标移动至下一行的当前位置,即光标位置+=80;
将两个操作统一起来,光标就会移动至下一行行首,这个过程称为回车换行CRLF。但光标位置最大只能是1999,当超过这一数值时,还需要进行滚屏:将2-25行的显示内容往上移动一行,并将最后一行清空,光标置于最后一行行首。
滚屏的实现思路如下:
- 判断光标位置是否>1999,如果是,进行滚屏操作;
- 使用指令
movsw
,将显存位置 80-1999 word的内容移动至 0-1919 word; - 清除 1920-1999 word的内容;
- 设置光标位置至1920。
步骤2的数据移动代码如下:
mov ax,0xb800
mov ds,ax
mov es,ax
cld
mov si,0xa0
mov di,0x00
mov cx,1920
rep movsw