002-在屏幕上打印Hello,World!
Contents
本文的目标是,构建一个引导扇区,并使用汇编语言,在屏幕上打印Hello World
。
经过学习,我们可以到一些基本的GNU
汇编语言的用法,同时也会了解在屏幕上打印字符的原理。
基础知识
要在屏幕上打印字符就需要对显存进行操作。那么如何操作显存呢?对于汇编语言来说,这个问题的答案是很简单的。如上一篇文章所讲,在计算机启动时,显卡被初始化为文本模式,对应的显存也已经映射到了0xb8000
到0xbffff
这段物理地址空间。所以直接向这段内存写入数据,屏幕上就能够打印出对应的字符了。
文本模式默认是80行、25列,可以显示2000个字符。在该模式下,每个字符的显示占据两个字节的空间,低字节保存字符的ASCII
码,高字节保存字符的显示属性。
字符可以使用一个字节的数字来表示,那字符的显示属性又是如何表示的呢?
用于控制字符显示属性的字节中的每一位含义如下,其中RGB
代表红绿蓝,K
代表是否闪烁、I
代表是否高亮。
例如:0x0a
二进制为00001010
,我们翻译翻译,就是黑色背景,不闪烁,绿色前景,高亮显示,高亮的效果是最终显示的是浅绿色。
有了上面的这些基础知识,那在屏幕上打印字符就非常简单了。具体来说,就是把字符的ASCII
码和字符的属性依次送入显存对应的内存即可。
在屏幕上打印字符H
如下汇编代码,会在屏幕上打印一个字符H:
|
|
- 第
1
行告诉编译器以16
位模式编译,因为BIOS
在加载并运行我们的代码时是处于16
位实地址模式的。 - 第
3
、4
行将附加数据段寄存器es
的内容设置为0xb800
。mov
是数据转移指令,mov
后面的w
表示操作数的宽度为一个word
,即16
位的数据。movw $0xb800
,%ax
表示把立即数0xb800
转移到寄存器ax
中。其中0xb800
是源操作数,ax
是目的操作数。根据at&t
的规范,立即数前需要加$
符,用来和内存地址区分。寄存器前需要加%
。这条指令执行完成之后ax
寄存器的内容为0xb800
,下一条指令又把ax
寄存器中的数据转移到es
中,完成段寄存器的设置。为啥不直接把0xb800
放到es
里?答案是段寄存器在程序运行中的职责比较重要,所以Intel
没有提供直接把立即数转移到段寄存器的指令。通过强制多加一个步骤,可以使操作者明白自己到底在做什么,是否真的需要修改段寄存器的值。 - 第
6
行我们先来分析一下目的操作数%es:0
,根据之前的内容我们知道这是以段基址:段内偏移的形式来给出内存地址。此时es
的内容为0xb800
,左移4
位再加上偏移地址0
,得到的物理地址为0xb8000
。再来看看源操作数'H'
,为啥这样写呢?得益于GNU as
编译器的支持,我们能够以这种方式表示一个ASCII
字符,编译器会帮我们把'H'
转换为0x48
。接下来看看mov
后面的b
,b
表示byte
,因为这次我们只操作一个字节的数据。 - 第
7
行和第6
行基本一致,只不过偏移地址为1
,最终的物理地址为0xb8001
,0x0a
表示浅绿色。 - 第
9
行是一条跳转指令,.
单独使用时是一个特殊的符号,作为位置计数器,表示当前所在行的位置。那么这条指令就表示跳转到当前位置,实现的效果就是死循环。 - 第
11
、12
行用了两条伪指令,伪指令是给编译器看的,并不是处理器最终会执行的指令。.org
伪指令指示编译器把位置计数器移动到操作数所指定的位置,这里是将位置计数器移动到510
处。.word
伪指令指示编译器在当前位置写入一个字大小的数据,当然,操作数也可以用逗号隔开,表示写入一组一个字大小的数据。这里要写入的数据是0xAA55
,何以是0xAA55
?上次不是才说过第一个扇区的最后两个字节要是0x55
、0xAA
才能被引导吗?怎么反过来了?这是因为Intel
处理器使用的是小端序,即数据的低字节存放在内存的低地址处,高字节存放在内存的高地址处。所以0xAA55
在内存中仍然是按照0x55
,0xAA
的顺序存放的。
编译:
|
|
我们先看看boot.o
的大小,它是不是符合我们512
字节的要求
|
|
956
字节,显然胖了。出现这种结果的原因是as
生成的目标文件默认是elf
格式的,elf
格式的文件中除了二进制代码,还会附加一些头信息、段信息、链接信息、调试信息等等。对于我们这个程序来说,是用不到这些信息的,甚至连链接都不需要,直接把目标文件中的二进制代码复制出来就行了。这个操作我们使用objcopy
这个工具来完成。
|
|
-O
:binary
指定输出文件的格式为纯二进制格式,-j
:.text
指定只复制.text
段
输出的文件名为boot.bin
。我们再来看看boot.bin
文件的大小。
|
|
刚刚好,512
字节。再来看看其中的内容。
|
|
启动一下:
|
|
H
字符打印出来,下一步我们来打印完整的Hello World
。
在屏幕上打印字符串Hello World!
本节我们用循环的方式让计算机把所有的字符都打印出来。代码如下:
|
|
-
第
3、4
行用于将数据段寄存器ds
的值设置为0x07c0
,这是为了访问数据的方便。这里的数据指的是message
和message_length
这两个符号所处位置的数据。为什么是0x07c0
呢?还记得我们在前面的文章中说的吗。BIOS
会把我们的程序,也就是磁盘0
柱面0
磁道的第1
个扇区加载到内存0x7c00
处执行。因为ds
是段寄存器,所以在寻址的时候左移4
位,0x07c0
转换为0x7c00
。 -
第
6, 7
行,将es
设置为显存映射的内存段,方便操控屏幕。 -
第
9
行使用异或指令xor
将源索引寄存器si
内容清空,即设置为0
。可以把源索引寄存器想象成数组的下标,结合message
标号处的数据,大概就能明白我们要做什么了吧?我们要通过这个索引来访问message
标号开始处的数据,si
每次增加1
,依次,我们就能通过循环的方式访问所有的数据。 -
第
10
行将内存地址message_length
处的数据转移到cx
寄存器。我们先来分析一下message_length
,message_length
定义在第33
行,后面跟了一个冒号,是一个标号(label
)。标号代表的就是所处行的位置,这个在稍后我们反编译的时候可以看到。我们在这个位置定义了一个word
的数据,数据的内容是message
内容的长度。计算的方式是用当前位置(用位置计数器.
表示)减去标号message
所表示的位置,中间的这一段数据就是我们要打印的字符及其显示属性。cx
是一个通用寄存器,但是通用寄存器也有特殊的用途。例如cx
,在串操作指令和loop
指令中作计数器用;在移位、循环移位指令中作移位次数计数器用。其中c
就是count
的意思。下面我们要用到loop
指令来依次打印每一个字符,所以在这里设置cx
。 -
第
13
行采用了寄存器相对寻址的方式将ds:(si + message)
处的一个字节的数据转移到bx
寄存器的低字节处bl
。这里先说明一下bl
,8086
中的4
个16
位数据寄存器ax
、bx
、cx
、dx
可以用来存放数据或地址,也可以将每个数据寄存器拆成两个独立的8
位寄存器使用。高8
位寄存器分别是ah
、bh
、ch
、dh
,低8
位寄存器分别是al
、bl
、cl
、dl
。因为这里我们要移动一个字节的数据,所以拆了一个8
位寄存器使用。 -
第
14
行采用了寄存器间接寻址的方式将bl
中的一字节数据转移到内存es:si
处,这里我们显示的指明了要使用es
作为段地址,因为在使用si
、di
、bx
做偏移量时默认使用的是ds
段寄存器。我们用bl
做了一个中转,把ds:(si + message)
处的一个字节的数据转移到了es:si
处。为什么需要用寄存器做一次中转呢?答案是8086
处理器不支持同时操作两个内存数。 -
第
15
行使用了加一指令inc
将si
的值加一,相当于高级语言中常用的i++
。 -
第
16
行使用了loop
指令来实现循环。loop
指令的功能是重复执行一段相同的代码,处理器在执行它的时候会顺序做两件事:-
- 将寄存器
cx
的内容减一;
- 将寄存器
-
- 如果
cx
的内容不为零,转移到指定的位置处执行,否则顺序执行后面的指令。
- 如果
-
-
第
20
行定义了标号message
,标号实际上表示一个位置,它的一个功能就是方便我们引用数据。例如这里,如果我们手动去数'H'
这个字符在内存中的第几个字节处,那这代码就没法写了,太痛苦了。但是当我们使用了标号时,编译器就会帮我们计算出'H'
所在的位置,并在我们的代码中把使用了message
的地方替换成实际的位置。 -
第
21
到31
行依次定义了每一个要显示的字符,每个数据的长度为一字节(byte
),每个字符由两个字节组成,ASCII
码和显示属性0xa
(浅绿色)。 -
第
33
行定义了标号message_length
,作为我们对要打印数据长度的引用。 -
第
34
行定义了要打印的数据的长度,占用一个字(word
)的存储空间,值为当前位置减去标号message
所表示的位置,结果即Hello World
及其显示属性所占据的内存空间的大小。
编译并运行
|
|
运行结果如下:
使用串操作指令打印Hello World!
含义:通过执行一条字符串操作指令,对存储器中某一个连续的内存中存放的一串字或字节均进行同样的操作,称为串操作。字符串操作指令简称为串操作指令。
所有的基本串操作指令都用寄存器si
间接寻址源操作数,且假定源操作数在当前的数据段中,即源操作数首地址的物理地址由ds:si
提供;而用寄存器di
间接寻址目的操作数,且假定目的操作数在当前的附加段中,即目的操作数首地址的物理地址由es:di
提供。显然,串操作指令的源操作数和目的操作数都在存储器中。
这两个地址的指针在每一个操作以后要自动修改,但按增量还是减量修改,取决于方向标志DF
(位于标志寄存器内):若DF=0
,则在每次操作后si
和di
作增量操作:字节操作加1
,字符操作加2
;若DF=1
,则在每次操作后si
和di
作减量操作:字节操作减1
,字符操作减2
。因此对于串操作,需要预先设置DF
的值。可以用std
或cld
指令分别置DF
为1
或0
。
若源串和目的串在同一段中,可使ds
和es
指向相同数据段,即ds=es
。
还可以在任一串操作指令前加一个指令前缀,构成重复前级指令,通过此指令来控制串操作指令的重复执行操作。
下面结合代码来看一下如何通过串操作指令打印Hello World
|
|
跟上一个打印Hello World
的代码不同之处主要在10
到14
行。
-
第
10
行,使用cld
指令将DF
标志位置为0
,表示每次操作后对si
和di
做增量操作。 -
第
11
行,将message
的地址赋值给si
。此时引导扇区整体被BIOS
加载到0x7c00
处,并且我们已经将数据段设置成了0x07c0
。message
代表数据的偏移量,该指令执行后ds:si
就指向了我们的数据首地址。 -
第
12
行,将di
置0
。此时es
内容为0xb800
,es:di
表示的物理地址为0xb8000
,即显存映射在内存中的首地址。 -
第
13
行,设置循环次数,循环的次数为数据串的长度。 -
第
14
行使用串传送指令movs
来完成数据传送的工作。该指令具体分为两条movsb
和movsw
,分别为把由si
作为指针的源操作数串中的一个字节或字,传送至由di
作为指针的目的操作数串中,且根据DF
修改各自的指针,使其指向各串中的下一单元。这里是把ds:si
处的一个字节传送到es:di
,并且把si
和di
分别加一。指令前缀rep
是重复前缀,其功能是重复执行rep
后紧跟着的一个串操作指令,直到cx
寄存器中的值为0
。执行时先检查cx
的值,若为0
则退出重复操作,执行以下其他指令;若不为0
,则将cx
的值减一;然后执行rep
右侧的串指令;重复上述操作。
通过组合rep
和movs
我们就可以批量的把数据从内存的一个区域转移到另一个区域。
编译并运行
|
|
运行结果如下:
总结
最后我们来对学到的知识点做一个总结:
8086
处理器采用分段的模型来操作内存,由** 段基址:段内偏移** 组合给出物理地址,计算方式为段基址
左移4
位,与段内偏移
相加形成20
位的物理地址。- 计算机启动后,显卡默认初始化为
80 x 25
的文本模式,显存映射到内存的0xb8000
到0xbffff
这段物理地址空间。 文本模式下每个字符的显示由两个字节控制,低字节为该字符的ASCII
码,高字节控制字符显示的颜色。 .code16
告诉编译器将代码编译成符合16
位处理器的格式。mov
指令用于转移数据。jmp
指令用于程序的跳转。.
位置计数器,表示当前位置,当然也可以通过给它赋值来改变当前位置。.org
伪指令,告诉编译器移动到操作数所指定的位置。.word
伪指令,用于写入一个字的数据,也可以写入多个一个字长的数据,用逗号分隔。xor
,异或指令,对源操作数和目的操作数做按位异或操作,结果保存在目的操作数中。inc
,加一指令,对操作数加一。loop
,循环指令,其功能是重复执行一段相同的代码,处理器在执行它的时候会顺序做两件事:- 将寄存器
cx
的内容减一。 - 如果
cx
的内容不为零,转移到指定的位置处执行,否则顺序执行后面的指令。
- 将寄存器
label
,即标号,如print
、message
、message_length
。它们表示当前所处行的位置,当编译完成之后,会被替换成实际的位置。.byte
,伪指令,用于定义一字节大小的数据,也可以同时指定一组一字节大小的数据,使用逗号分隔。- 剩下的就是我们用到的那些工具
as
、objdump
、objcopy
,回过头去结合工具执行后的结果,理解理解每一个参数的含义就ok了。