显卡文本模式

 Max.C     2020-03-15   5079 words    & views

零、文字显示

如果我们要显示文字,我们需要的硬件是显示器和显卡,显卡用于为显示器提供显示内容,并控制显示器的显示模式和状态。

显卡控制显示器的显示内容,我们很容易想到通过像素控制,显卡通过提供这些像素的信息(灰度值、RGB值等),显示器将其显示出来。显卡中存储这些信息的地方称为显存,显示器解读这些信息,并显示在屏幕上。

显卡具有图形模式、文本模式两种基本工作模式,在不同的工作模式下,对显存内容的解读方式也不同。接下来介绍文本模式的显示原理——

一、文本模式显示原理

在8086结构中,显存位于内存地址0xB8000——0xBFFFF处,为了在屏幕上显示出文字,我们需要把必要的信息加载到内存以0xB8000起始的位置。

显卡初始化时,会形成一个25x80的文本模式(25行、80列),这部分内容的信息来自内存0xB8000——0xB8‭FA0‬,由于屏幕上的每一个字符需要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

这里用了上面数字显示的代码,利用inout对端口进行读写,并利用光标显示四位数字:

五、换行、回车、滚屏

上面我们说过,ASCII码中有一部分表示信息的代码是无法显示的,当我们向显存中写入这些值时,不会产生任何效果,我们需要手动为其做出解释。

在这些用以控制的ASCII码中,我们想要做出实现的是回车和换行,并以光标位置体现:

回车:0x0D,将光标移动至当前行首,将光标位置除以80得到行数,光标位置=行数x80;

换行:0x0a,将光标移动至下一行的当前位置,即光标位置+=80;

将两个操作统一起来,光标就会移动至下一行行首,这个过程称为回车换行CRLF。但光标位置最大只能是1999,当超过这一数值时,还需要进行滚屏:将2-25行的显示内容往上移动一行,并将最后一行清空,光标置于最后一行行首。

滚屏的实现思路如下:

  1. 判断光标位置是否>1999,如果是,进行滚屏操作;
  2. 使用指令movsw,将显存位置 80-1999 word的内容移动至 0-1919 word;
  3. 清除 1920-1999 word的内容;
  4. 设置光标位置至1920。

步骤2的数据移动代码如下:

mov ax,0xb800
mov ds,ax
mov es,ax
cld
mov si,0xa0
mov di,0x00
mov cx,1920
rep movsw