汇编语言一发入魂 0x01 - Hello World
目标:在屏幕上打印出Hello World
。
要在屏幕上打印字符就需要对显存进行操作。那么如何操作显存呢?对于汇编语言来说,这个问题的答案是很简单的。如上一篇文章所讲,在计算机启动时,显卡被初始化为文本模式,对应的显存也已经映射到了0xb8000
到0xbffff
这段物理地址空间。所以直接向这段内存写入数据,屏幕上就能够打印出对应的字符了。那么如何向内存写入数据呢?
向内存写入数据,首先需要知道内存对应的地址。对于8086
处理器来说,内存地址是以段基址
:段内偏移
的形式给出的。物理内存被划分为逻辑上的段,每个段最长为64KB
。这是有历史原因的,8086
具有20
位的地址线,寻址范围是1MB
,但是8086
的内部寄存器都是16
位的,最多只能访问64KB
的内存空间,无法完全利用这巨大的内存,真是太可惜了。天无绝人之路,Intel
的那帮巨佬们就想出了一个巧(鸡)妙(贼)的办法。16
位的寄存器左移4
位不就是20
位了吗?理想很丰满,但是这里有一个问题。因为采用了左移4
位的方法,所以无论地址是多少,最终计算得到的地址都是16
字节对齐的。举个栗子,0x1234
这个地址,左移4
位之后就变成了0x12340
,同理,0x1235
对应0x12350
,0x12340
到0x12350
之间的16
个字节是没法访问到的。这个就很好解决了,把0x1234
看作一个段,这样的话再加一个偏移量就可以访问到刚才无法访问到的空间了。栗如要访问0x1234f
这个位置,那么给个0xf
的偏移量就可以了。说干就干,于是他们马上设计了一个计算物理地址的电路,做的运算就是从段寄存器里取出来段地址,左移4
位,然后在加上一个16
位的偏移地址,形成20
位的物理地址。这个电路俗称地址加法器。
上面我们提到计算机启动时,显卡被初始化为文本模式。这个文本模式默认是80
行、25
列,可以显示2000
个字符。在该模式下,每个字符的显示占据两个字节的空间,低字节保存字符的ASCII
码,高字节保存字符的显示属性。下面给出ASCII
码表。
Oct Dec Hex Char Oct Dec Hex Char
────────────────────────────────────────────────────────────────────────
000 0 00 NUL '\0' (null character) 100 64 40 @
001 1 01 SOH (start of heading) 101 65 41 A
002 2 02 STX (start of text) 102 66 42 B
003 3 03 ETX (end of text) 103 67 43 C
004 4 04 EOT (end of transmission) 104 68 44 D
005 5 05 ENQ (enquiry) 105 69 45 E
006 6 06 ACK (acknowledge) 106 70 46 F
007 7 07 BEL '\a' (bell) 107 71 47 G
010 8 08 BS '\b' (backspace) 110 72 48 H
011 9 09 HT '\t' (horizontal tab) 111 73 49 I
012 10 0A LF '\n' (new line) 112 74 4A J
013 11 0B VT '\v' (vertical tab) 113 75 4B K
014 12 0C FF '\f' (form feed) 114 76 4C L
015 13 0D CR '\r' (carriage ret) 115 77 4D M
016 14 0E SO (shift out) 116 78 4E N
017 15 0F SI (shift in) 117 79 4F O
020 16 10 DLE (data link escape) 120 80 50 P
021 17 11 DC1 (device control 1) 121 81 51 Q
022 18 12 DC2 (device control 2) 122 82 52 R
023 19 13 DC3 (device control 3) 123 83 53 S
024 20 14 DC4 (device control 4) 124 84 54 T
025 21 15 NAK (negative ack.) 125 85 55 U
026 22 16 SYN (synchronous idle) 126 86 56 V
027 23 17 ETB (end of trans. blk) 127 87 57 W
030 24 18 CAN (cancel) 130 88 58 X
031 25 19 EM (end of medium) 131 89 59 Y
032 26 1A SUB (substitute) 132 90 5A Z
033 27 1B ESC (escape) 133 91 5B [
034 28 1C FS (file separator) 134 92 5C \ '\\'
035 29 1D GS (group separator) 135 93 5D ]
036 30 1E RS (record separator) 136 94 5E ^
037 31 1F US (unit separator) 137 95 5F _
040 32 20 SPACE 140 96 60 `
041 33 21 ! 141 97 61 a
042 34 22 " 142 98 62 b
043 35 23 # 143 99 63 c
044 36 24 $ 144 100 64 d
045 37 25 % 145 101 65 e
046 38 26 & 146 102 66 f
047 39 27 ' 147 103 67 g
050 40 28 ( 150 104 68 h
051 41 29 ) 151 105 69 i
052 42 2A * 152 106 6A j
053 43 2B + 153 107 6B k
054 44 2C , 154 108 6C l
055 45 2D - 155 109 6D m
056 46 2E . 156 110 6E n
057 47 2F / 157 111 6F o
060 48 30 0 160 112 70 p
061 49 31 1 161 113 71 q
062 50 32 2 162 114 72 r
063 51 33 3 163 115 73 s
064 52 34 4 164 116 74 t
065 53 35 5 165 117 75 u
066 54 36 6 166 118 76 v
067 55 37 7 167 119 77 w
070 56 38 8 170 120 78 x
071 57 39 9 171 121 79 y
072 58 3A : 172 122 7A z
073 59 3B ; 173 123 7B {
074 60 3C < 174 124 7C |
075 61 3D = 175 125 7D }
076 62 3E > 176 126 7E ~
077 63 3F ? 177 127 7F DEL
字符可以使用一个字节的数字来表示,那字符的显示属性又是如何表示的呢?
用于控制字符显示属性的字节中的每一位含义如下,其中RGB
代表红绿蓝,K
代表是否闪烁、I
代表是否高亮。
例如:0x0a
二进制为00001010
,我们翻译翻译,就是黑色背景,不闪烁,绿色前景,高亮显示,高亮的效果是最终显示的是浅绿色。
有了上面的这些基础知识,那在屏幕上打印字符就是手到擒来。具体来说,就是把字符的ASCII
码和字符的属性依次送入显存对应的内存即可。
下面给出我们的第一个汇编程序:
.code16
movw $0xb800, %ax
movw %ax, %es
movb $'H', %es:0
movb $0xa, %es:1
jmp .
.org 510
.word 0xAA55
依次解释一下每一行的含义。
第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
的顺序存放的。
接下来我们编译然后反编译,康康上面这段代码对应的二进制的指令究竟是什么。
$ as --32 boot.s -o boot.o
$ objdump -D -Mi8086,suffix boot.o
boot.o: 文件格式 elf32-i386
Disassembly of section .text:
00000000 <.text>:
0: b8 00 b8 movw $0xb800,%ax
3: 8e c0 movw %ax,%es
5: 26 c6 06 00 00 48 movb $0x48,%es:0x0
b: 26 c6 06 01 00 0a movb $0xa,%es:0x1
11: eb fe jmp 0x11
...
1fb: 00 00 addb %al,(%bx,%si)
1fd: 00 55 aa addb %dl,-0x56(%di)
第1
行使用as
编译生成目标文件。
第2
行使用objdump
反编译目标文件。-D
参数说明我们要反编译所有的section
。根据反编译的结果可以看出来,其实整个程序都被编译到.text
段中了。因为我们的源文件中并没有明确的指定代码或者数据所在的段,默认情况下as
会把这些代码、数据都放在.text
段里。-M
参数中指定了以16
位模式(i8086)
反汇编,同时指定在at&t
语法中显示指令后缀(suffix)
,这些选项以逗号分隔开。
第10
行第一个b8
是将16
位立即数转移到ax
寄存器的指令的二进制码,后面的00 b8
因为是小端序,所以它表示的数据实际上是b800
,很熟悉,对不对?
第12、13
行,仔细观察可以发现这两条指令前三个字节是相同的,对,它们就是将8
位立即数转移到以es
为段地址的内存中的指令的二进制码。还是因为是小端序,所以第12
行的00 00
实际是00 00
跟没说一样,第13
行的01 00
实际是00 01
,也就是我们在代码里指定的偏移地址。第12
行最后一个字节48
,就是'H'
的ASCII
码对应的16
进制表示,as
已经帮我们翻译过了,所以我们也就不需要花时间去记每一个字符的ASCII
码是多少了。
第14
行,我们在代码中写的明明是.
,到这里却变成了0x11
。还记得我们说过.
是位置计数器吗?代表了当前行所在的位置,看冒号前面的11
,它告诉了我们当前这条指令的位置,jmp 0x11
就是反复跳到自己这里执行喽。
中间的代码压根没有代码,因为我们是用.org
直接跳到510
这个位置的,所以从jmp
之后到510
都是0
,objdump
很贴心的没有给我们显示。0x1fb
处的这个00 00
不用管他,毕竟我们把所有东西都放在了代码(.text)
段里,objdump
也没那么聪明,这不,把我们可引导的标记0x55 0xaa
也翻译成了指令。
好了,代码也写好了,也编译成二进制的文件了。那么,放在虚拟机里启动康康效果吧。
且慢,让我们先康康boot.o
的大小,康康它是不是符合我们512
字节的要求。
$ ls -l boot.o
-rw-rw-r-- 1 laoli laoli 956 3月 6 21:19 boot.o
956
字节,显然胖了。出现这种结果的原因是as
生成的目标文件默认是elf
格式的,elf
格式的文件中除了二进制代码,还会附加一些头信息、段信息、链接信息、调试信息等等。对与我们这个程序来说,是用不到这些信息的,甚至连链接都不需要,直接把目标文件中的二进制代码复制出来就行了。这个操作我们使用objcopy
这个工具来完成。
$ objcopy -O binary -j .text boot.o boot.bin
-O binary
指定输出文件的格式为纯二进制格式,-j .text
指定只复制.text
段,输出的文件名为boot.bin
。我们再来看看boot.bin
文件的大小。
$ ls -l boot.bin
-rw-rw-r-- 1 laoli laoli 512 3月 6 21:19 boot.bin
刚刚好,512
字节。再来看看其中的内容。
$ xxd -a boot.bin
00000000: b800 b88e c026 c606 0000 4826 c606 0100 .....&....H&....
00000010: 0aeb fe00 0000 0000 0000 0000 0000 0000 ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
*
000001f0: 0000 0000 0000 0000 0000 0000 0000 55aa ..............U.
有兴趣的小朋友可以和上面反编译的结果对比一下,分毫不差。
好了,现在可以开机、启动了。
$ qemu-system-i386 boot.bin
这就完事儿了?不是说打印Hello World
吗?怎么打了个H
就完事儿了?
其实对于一个初学者来说,这章的内容还是蛮多的,需要消化消化。另外,H
都打印出来了,ello World
还是事儿吗?这个可以留个课后作业,小伙伴们自己试一试。在下一篇文章中老李会教大家一些其它的指令来完成Hello World
的打印。
最后我们来对学到的知识点做一个总结:
8086
处理器采用分段的模型来操作内存,由段基址
:段内偏移
组合给出物理地址,计算方式为段基址
左移4
位,与段内偏移
相加形成20
位的物理地址。- 计算机启动后,显卡默认初始化为
80 x 25
的文本模式,显存映射到内存的0xb8000
到0xbffff
这段物理地址空间。 - 文本模式下每个字符的显示由两个字节控制,低字节为该字符的
ASCII
码,高字节控制字符显示的颜色。 .code16
告诉编译器将代码编译成符合16
位处理器的格式。mov
指令用于转移数据。jmp
指令用于程序的跳转。.
位置计数器,表示当前位置,当然也可以通过给它赋值来改变当前位置。.org
伪指令,告诉编译器移动到操作数所指定的位置。.word
伪指令,用于写入一个字的数据,也可以写入多个一个字长的数据,用逗号分隔。- 剩下的就是我们用到的那些工具
as
、objdump
、objcopy
,回过头去结合工具执行后的结果,理解理解每一个参数的含义就ok
了。