购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

5.4 在屏幕上显示文字

5.4.1 显卡和显存

本程序首先要做的事是在屏幕上显示一行文字。当然,要想在屏幕上显示文字,就需要先了解文字是如何显示在屏幕上的。

为了显示文字,通常需要两种硬件,一是显示器,二是显卡。显卡的职责是为显示器提供内容,并控制显示器的显示模式和状态,显示器的职责是将那些内容以视觉可见的方式呈现在屏幕上。

一般来说,显卡都是独立生产、销售的部件,需要插在主板上才能工作。当然,像处理器、内存这样的东西,也位于主板上。每台计算机都有主板,它就在机箱内部,有时间你可以打开机箱来观察一下。

当然,显卡未必一定是独立的插卡。为了节省使用者的成本,有的显卡会直接做在主板上,这样的显卡也有个名字,叫集成显卡。

显卡控制显示器的最小单位是像素,一个像素对应着屏幕上的一个点。屏幕上通常有数十万乃至更多的像素,通过控制每个像素的明暗和颜色,我们就能让这大量的像素形成文字和美丽的图像。

不过,一个很容易想到的问题是,如何来控制这些像素呢?

答案是显卡都有自己的存储器,因为它位于显卡上,故称显示存储器(Video RAM:VRAM),简称显存,要显示的内容都预先写入显存。和其他半导体存储器一样,显存并没有什么特殊的地方,也是一个按字节访问的存储器件。

对显示器来说,显示黑白图像是最简单的,因为只需要控制每个像素是亮,还是不亮。如果把不亮当成比特“0”,亮看成比特“1”,那就好办了。因为,只要将显存里的每个比特和显示器上的每个像素对应起来,就能实现这个目标。

如图5-1 所示,显存的第1 个字节对应着屏幕左上角连续的8 个像素;第2 个字节对应着屏幕上后续的8 个像素,后面的以此类推。

图5-1 显存内容和显示器内容之间的对应关系

显卡的工作是周期性地从显存中提取这些比特,并把它们按顺序显示在屏幕上。如果是比特“0”,则像素保持原来的状态不变,因为屏幕本来就是黑的;如果是比特“1”,则点亮对应的像素。

继续观察图5-1,假设显存中,第1 个字节的内容是11110000,第2 个字节的内容是11111111,其他所有的字节都是00000000。在这种情况下,屏幕左上角先是显示4 个亮点,再显示4 个黑点,然后再显示8 个亮点。因为像素是紧挨在一起的,所以我们看到的先是一条白短线,隔着一定距离(4 个像素)又是一条白长线。

黑色和白色只需要1 个比特就能表示,但要显示更多的颜色,1 个比特就不够了。现在最流行的,是用24 个比特,即3 个字节,来对应一个像素。因为224=16777216,所以在这种模式下,同屏可以显示16777216 种颜色,这称为真彩色。有关颜色的显示和它们与字长的关系,在《穿越计算机的迷雾》一书中有详细的介绍,这里不再赘述。

上面所讨论的,是人们常说的图形模式。图形模式是最容易理解的,同时对显示器来说也是最自然的模式。

现在是图形的时代,就连手机的屏幕都是五彩缤纷的。时光倒退到几十年前,在那个时代,真彩色还没有出现,显示器只能提供有限的色彩,处理器也不够强劲(以今天的眼光来看)。在这种情况下,人们不太可能认为图形显示技术有多么重要,因为他们不看高清电影,也没有数码相机,用计算机制作动画片更是不能想象的事。那个时候,人们的愿望很简单,只要能显示文字就行。

不管是显示图片,还是文字,对显示器来说没有什么不同,因为所有的内容都是由像素组成的,区别仅仅在于这些像素组成的是什么。有时候,人们会说,哦,显示的是一棵树;有时候,人们会说,哦,显示的是一个字母“H”。

问题是,操作显存里的比特,使得屏幕上能显示出字符的形状,是非常麻烦、非常繁重的工作,因为你必须计算该字符所对应的比特位于显存里的什么位置。

为了方便,工程师们想出了一个办法。就像一个二进制数既可以是一个普通的数,也可以代表一条处理器指令一样,他们认为每个字符也可以表示成一个数。比如,数字0x4C 就代表字符“L”,这个数被称为是字符“L”的ASCII 代码,后面会讲到。

如图5-2 所示,可以将字符的代码存放到显存里,第1 个代码对应着屏幕左上角第1 个字符,第2 个代码对应着屏幕左上角第2 个字符,后面的以此类推。剩下的工作是如何用代码来控制屏幕上的像素,使它们或明或暗以构成字符的轮廓,这是字符发生器和控制电路的事情。

传统上,这种专门用于显示字符的工作方式称为文本模式。文本模式和图形模式是显卡的两种基本工作模式,可以用指令访问显卡,设置它的显示模式。在不同的工作模式下,显卡对显存内容的解释是不同的。

图5-2 字符在屏幕上的显示原理

为了给出要显示的字符,处理器需要访问显存,把字符的ASCII 码写进去。但是,显存是位于显卡上的,访问显存需要和显卡这个外围设备打交道。同时,多一道手续自然是不好的,这当中最重要的考量是速度和效率。想想看,你让人传话给父母,和自己亲自往家里打电话,花费的时间是不一样的。为了实现一些快速的游戏动画效果,或者播放高码率的电影,不直接访问显存是办不到的。

为此,计算机系统的设计者们,这些敢想敢干的人,决定把显存映射到处理器可以直接访问的地址空间里,也就是内存空间里。

如图5-3 所示,我们知道,8086 可以访问1MB 内存。其中,0x00000~9FFFF 属于常规内存,由内存条提供;0xF0000~0xFFFFF 由主板上的一个芯片提供,即ROM-BIOS。

图5-3 文本模式下显存到内存的映射

这样一来,中间还有一个320KB 的空洞,即0xA0000~0xEFFFF。传统上,这段地址空间由特定的外围设备来提供,其中就包括显卡。因为显示功能对于现代计算机来说实在是太重要了。

由于历史的原因,所有在个人计算机上使用的显卡,在加电自检之后都会把自己初始化到80× 25 的文本模式。在这种模式下,屏幕上可以显示25 行,每行80 个字符,每屏总共2000 个字符。

所以,如图5-3 所示,一直以来,0xB8000~0xBFFFF 这段物理地址空间,是留给显卡的,由显卡来提供,用来显示文本。除非显卡出了毛病,否则这段空间总是可以访问的。如果显卡出了毛病怎么办呢?很简单,计算机一定不会通过加电自检过程,这就是传说中的严重错误,计算机是无法启动的,更不要说加载并执行主引导扇区的内容了。

5.4.2 初始化段寄存器

和访问主内存一样,为了访问显存,也需要使用逻辑地址,也就是采用“段地址:偏移地址”的形式,这是处理器的要求。考虑到文本模式下显存的起始物理地址是0xB8000,这块内存可以看成是段地址为0xB800,偏移地址从0x0000 延伸到0xFFFF 的区域,因此我们可以把段地址定为0xB800。

访问内存可以使用段寄存器DS,但这不是强制性的,也可以使用ES。因为DS 还有别的用处,所以在这里我们使用ES 来指向显存所在的段。

源程序第6、7 行,首先把立即数0xB800 传送到AX,然后再把AX 的值传送到ES。这样,附加段寄存器ES 就指向0xB800 段(段基地址为0xB800)。

你可能会想,为什么不直接这样写:

而要用寄存器AX 来中转呢?

原因是不存在这样的指令,Intel 的处理器不允许将一个立即数传送到段寄存器,它只允许这样的指令:

没有人能够说清楚这里面的原因,Intel 公司似乎也从没有提到过这件事,尽管从理论上,这是可行的。我们只能想,也许Intel 是出于好心,避免我们无意中犯错,毕竟,段地址一旦改变,后面对内存的访问都会受到影响。理论上,麻烦一点的方法,可以保证你确实知道自己在做什么。

5.4.3 显存的访问和ASCII 代码

一旦将显存映射到处理器的地址空间,那么,我们就可以使用普通的传送指令(mov)来读写它,这无疑是非常方便的,但需要首先将它作为一个段来看待,并将它的基地址传送到段寄存器。

为此,源程序的第10、11 行,我们把0xB800 作为段地址传送到附加段寄存器ES,以后就用ES 来读写显存。这样,段内偏移为0 的位置就对应着屏幕左上角的字符。

在计算机中,每个用来显示在屏幕上的字符,都有一个二进制代码。这些代码和普通的二进制数字没有什么不同,唯一的区别在于,发送这些数字的硬件和接收这些数字的硬件把它们解释为字符,而不是指令或者用于计算的数字。

这就是说,在计算机中,所有的东西都是无差别的数字,它们的意义,只取决于生成者和使用者之间的约定。为了在终端和大型主机,以及主机和打印机、显示器之间交换信息,1967 年,美国国家标准学会制定了美国信息交换标准代码(American Standard Code for Information Interchange,ASCII),如表5-1 所示。

表5-1 ASCII 表

在不同设备之间,或者在同一设备的不同模块之间有一个信息传递标准是非常必要的。想想看,当你用手机向朋友发送短消息时,这些文字当然被编码成二进制数字。如果对方的手机使用了不同的编码,那么他将无法正确还原这些消息,而很可能显示为乱码。

值得注意的是,ASCII 是7 位代码,只用了一个字节中的低7 比特,最高位通常置0。这意味着,ASCII 只包含128 个字符的编码。所以,在表中,水平方向给出了代码的高3 比特,而垂直方向给出了代码的低4 比特。比如字符“*”,它的代码是二进制数的010 1010,即0x2A。

ASCII 表中有相当一部分代码是不可打印和显示的,它们用于控制通信过程。比如,LF 是换行;CR 是回车;DEL 和BS 分别是删除和退格,在我们平时用的键盘上也是有的;BEL 是振铃(使远方的终端响铃,以引起注意);SOH 是文头;EOT 是文尾;ACK 是确认,等等。

注意,一定要遵从约定。比如,你在处理器上编写程序算了一道数学题2+3,你也希望把结果5 显示在屏幕上。这个时候,算出的结果是0000 0101,即0x05。但是,数字5 和字符5 是不同的,显卡在任何时候都认为你发送的是ASCII 码。所以,你不应该发送0x05,而应该发送0x35。

屏幕上的每个字符对应着显存中的两个连续字节,前一个是字符的ASCII 代码,后面是字符的显示属性,包括字符颜色(前景色)和底色(背景色)。如图5-4 所示,字符“H”的ASCII 代码是0x48,其显示属性是0x07;字符“e”的ASCII 代码是0x65,其显示属性是0x07。

如图5-4 所示,字符的显示属性(1 字节)分为两部分,低4 位定义的是前景色,高4 位定义的是背景色。色彩主要由R、G、B 这3 位决定,毕竟我们知道,可以由红(R)、绿(G)、蓝(B)三原色来配出其他所有颜色。K 是闪烁位,为0 时不闪烁,为1 时闪烁;I 是亮度位,为0时正常亮度,为1 时呈高亮。表5-2 给出了背景色和前景色的所有可能值。

图5-4 字符代码及字符属性示意图

表5-2 80×25 文本模式下的颜色表

从表5-2 来看,图5-4 中的字符属性0x07 可以解释为黑底白字,无闪烁,无加亮。

你可能觉得奇怪,当屏幕上一片漆黑,什么内容都没有的时候,显存里会是什么内容呢?

实际上,这个时候,屏幕上显示的全是黑底白字的空白字符,也叫空格字符(Space),ASCII代码是0x20,当你用大拇指按动键盘上最长的那个键时,就产生这个字符。因为它是空白,自然就无法在黑底上看到任何痕迹了。

5.4.4 显示字符

从源程序的第10 行开始,到第35 行,目的是显示一串字符“Label offset:”。为此,需要把它们每一个的ASCII 码顺序写到显存中。

为了方便,多数汇编语言编译器允许在指令中直接使用字符的字面值来代替数值形式的ASCII 码,比如源程序第10 行:

这等效于

尽管通过查表可以知道字符“L”的ASCII 代码是0x4C,但毕竟费事。不过,要在指令中使用字符的字面值,这个字符必须用引号围起来,就像上面一样。在源程序的编译阶段,汇编语言编译器会将它转换成ASCII 码的形式。

当前的mov 指令是将立即数传送到内存单元,目的操作数是内存单元,源操作数是立即数(ASCII 代码)。为了访问内存单元,需要给出段地址和偏移地址。在这条指令中,偏移地址0x00,段地址在哪里呢?一般情况下,如果没有附加任何指示,段地址默认在段寄存器DS 中。比如:

当执行这条指令后,处理器把段寄存器DS 的内容左移4 位(相当于乘以十进制数16 或者十六进制数0x10),加上这里的偏移地址0x00,就得到了物理地址。

但是实际上,在我们的程序中,显存的段地址位于段寄存器ES 中,我们希望使用ES 来访问内存。因此,这里使用了段超越前缀“es:”。这就是说,我们明确要求处理器在生成物理地址时,使用段寄存器ES,而不是默认情况下的DS。

因为指令中给出的偏移地址是0x00,且ES 的值已经在前面被设为0xB800,故它指向ES 段中,偏移地址为0 的内存单元,即0xB800:0x0000,也就是物理地址0xB8000,这个内存单元对应着屏幕左上角第一个字符的位置。

还需要注意的是,因为目的操作数给出的是一个内存地址,我们要用源操作数来修改这个地址里的内容,所以,目的操作数必须用方括号围起来,以表明它是一个地址,处理器应该用这个地址再次访问内存,将源操作数写进这个单元。实际上,这类似于高级语言里的指针。

最后,关键字“byte”用来修饰目的操作数,指出本次传送是以字节的方式进行的。在16 位的处理器上,单次操作的数据宽度可以是8 位,也可以是16 位。到底是8 位,还是16 位,可以根据目的操作数或者源操作数来判断。遗憾的是,在这里,目的操作数是偏移地址0x00,它可以是字节单元,也可以是字单元,到底是哪一种,无法判断;而源操作数呢,是立即数0x4C,它既可以解释为8 位的0x4C,也可以解释为16 位的0x004C。在这种情况下,编译器将无法搞懂你的真实意图,只能报告错误,所以必须用“byte”或者“word”进行修饰(明确指示)。于是,一旦目的操作数被指明是“byte”的,那么,源操作数的宽度也就明确了。相反地,下面的指令就不需要任何修饰:

因为屏幕上的一个字符对应着内存中的两个字节:ASCII 代码和属性,所以,源程序第11 行的功能是将属性值0x07 传送到下一个内存单元,即偏移地址0x01 处。这个属性可以解释为黑底白字,无闪烁,也无加亮,请参阅表5-2。

后面,从第12 行开始,到第35 行,用于向显示缓冲区填充剩余部分的字符。注意,在这个过程中,偏移地址一直是递增的。

5.4.5 MOV 指令的格式

到目前为止,我们已经多次接触了mov 指令。在处理器的整个指令集中,mov 指令是用得最多的一条。

mov 指令用于数据传送。既然是数据传送,那么,目的操作数的作用应该相当于一个“容器”,故必须是通用寄存器或者内存单元;源操作数呢,也可以是和目的操作数具有相同数据宽度的通用寄存器和内存单元,还可以是立即数。传送指令只影响目的操作数的内容,不改变源操作数的内容。比如:

以上,第一条指令的目的操作数和源操作数都是8 位寄存器,指令执行后,寄存器AH 的内容和BH 相同;第二条指令的目的操作数和源操作数都是16 位寄存器,指令执行后,寄存器AX的内容和DX 相同。但是,由于数据宽度不同,下面这条指令就是错误的:

再来看下面两条指令:

以上,第一条指令是把寄存器BL 中的内容传送到偏移地址为0x02 的8 位内存单元;第二条指令是把偏移地址为0x06 的16 位内存单元里的内容传送到寄存器AX 中。由于这两条指令中都有寄存器操作数,故不需要用“byte”或者“word”来修饰。

传送指令的源操作数也可以是立即数。比如:

以上,第一条指令是把立即数0x05 传送到寄存器AH 中,指令执行后,AH 中的内容为0x05;第二条指令是把立即数0xf000 传送到偏移地址为0x1c 的16 位内存单元中。因为上一节所说的原因,这里要用word 来修饰。

mov 指令的目的操作数不允许为立即数,而且,目的操作数和源操作数不允许同时为内存单元。因此,下面两条指令都是不正确的:

以上,说第一条指令是错误的,这很好理解。想想看,你把寄存器AL 中的内容传送给一个立即数,这是什么意思呢?于理不通。至于第二条指令为什么不正确,那是因为处理器不允许在两个内存单元之间直接进行传送操作。事实上,这条指令的功能可以用两条指令实现(假设传送的是一个字):

就算处理器支持在两个内存单元之间直接传送数据,那么,它依然是在内部按上面的两个步骤进行操作的。而且,支持这种直接传送操作还需要增加额外的电路。

不单单是mov 指令,其他指令都不支持在两个内存单元之间直接进行操作,包括加、减、乘、除和逻辑运算等指令。事情是明摆着的,既然增加了处理器的复杂性之后和用两条指令没什么区别,干脆就用两条指令好了。

检测点5.1

1. 在我们日常使用的个人计算机上,文本模式下的显示缓冲区被映射到物理内存地址空间,起始地址为( ),它对应的段地址为( )。在标准的80×25 文本模式下,要想在屏幕右下角显示一个绿底白字的字符“H”,那么,应当在该段内偏移量为( )的地方开始,连续写入两个字节( )和( )。

2. 以下指令中,哪些是正确的,不正确的原因是什么?

A.mov al,0x55aa      B.mov ds,0x6000     C.mov ds,al

D.mov [0x06],0x55aa     E.mov ds,bx       F.mov ax,0x02

G.mov word [0x0a],ax    H.mov es,cx       I.mov ax,bl

J.mov byte [0x00],’c’     K.mov [0x02],[0xf000]   L.mov ds,[0x03] JLujAp5CbyG5yTBGFRt6jLTvuMH9p+WCQ1YHUycdjIelLeAkaU/fz+LY7MYZU+x+

点击中间区域
呼出菜单
上一章
目录
下一章
×