汇编语言一发入魂 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了。
