跳至主要內容

汇编语言一发入魂 0x01 - Hello World

未央大约 13 分钟汇编语言显卡显存

目标:在屏幕上打印出Hello World

要在屏幕上打印字符就需要对显存进行操作。那么如何操作显存呢?对于汇编语言来说,这个问题的答案是很简单的。如上一篇文章所讲,在计算机启动时,显卡被初始化为文本模式,对应的显存也已经映射到了0xb80000xbffff这段物理地址空间。所以直接向这段内存写入数据,屏幕上就能够打印出对应的字符了。那么如何向内存写入数据呢?

向内存写入数据,首先需要知道内存对应的地址。对于8086处理器来说,内存地址是以段基址:段内偏移的形式给出的。物理内存被划分为逻辑上的段,每个段最长为64KB。这是有历史原因的,8086具有20位的地址线,寻址范围是1MB,但是8086的内部寄存器都是16位的,最多只能访问64KB的内存空间,无法完全利用这巨大的内存,真是太可惜了。天无绝人之路,Intel的那帮巨佬们就想出了一个巧(鸡)妙(贼)的办法。16位的寄存器左移4位不就是20位了吗?理想很丰满,但是这里有一个问题。因为采用了左移4位的方法,所以无论地址是多少,最终计算得到的地址都是16字节对齐的。举个栗子,0x1234这个地址,左移4位之后就变成了0x12340,同理,0x1235对应0x123500x123400x12350之间的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的内容设置为0xb800mov是数据转移指令,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后面的bb表示byte,因为这次我们只操作一个字节的数据。

7行和第6行基本一致,只不过偏移地址为1,最终的物理地址为0xb80010x0a表示浅绿色。

9行是一条跳转指令,.单独使用时是一个特殊的符号,作为位置计数器,表示当前所在行的位置。那么这条指令就表示跳转到当前位置,实现的效果就是死循环

11、12行用了两条伪指令,伪指令是给编译器看的,并不是处理器最终会执行的指令。.org伪指令指示编译器把位置计数器移动到操作数所指定的位置,这里是将位置计数器移动到510处。.word伪指令指示编译器在当前位置写入一个字大小的数据,当然,操作数也可以用逗号隔开,表示写入一组一个字大小的数据。这里要写入的数据是0xAA55,何以是0xAA55?上次不是才说过第一个扇区的最后两个字节要是0x550xAA才能被引导吗?怎么反过来了?这是因为Intel处理器使用的是小端序,即数据的低字节存放在内存的低地址处,高字节存放在内存的高地址处。所以0xAA55在内存中仍然是按照0x550xAA的顺序存放的。

接下来我们编译然后反编译,康康上面这段代码对应的二进制的指令究竟是什么。

$ 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都是0objdump很贴心的没有给我们显示。0x1fb处的这个00 00不用管他,毕竟我们把所有东西都放在了代码(.text)段里,objdump也没那么聪明,这不,把我们可引导的标记0x55 0xaa也翻译成了指令。

好了,代码也写好了,也编译成二进制的文件了。那么,放在虚拟机里启动康康效果吧。

且慢,让我们先康康boot.o的大小,康康它是不是符合我们512字节的要求。

$ ls -l boot.o 
-rw-rw-r-- 1 laoli laoli 956 36 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 36 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
启动 H
启动 H

这就完事儿了?不是说打印Hello World吗?怎么打了个H就完事儿了?

其实对于一个初学者来说,这章的内容还是蛮多的,需要消化消化。另外,H都打印出来了,ello World还是事儿吗?这个可以留个课后作业,小伙伴们自己试一试。在下一篇文章中老李会教大家一些其它的指令来完成Hello World的打印。

最后我们来对学到的知识点做一个总结:

  • 8086 处理器采用分段的模型来操作内存,由 段基址:段内偏移 组合给出物理地址,计算方式为 段基址 左移 4 位,与 段内偏移 相加形成 20 位的物理地址。
  • 计算机启动后,显卡默认初始化为 80 x 25 的文本模式,显存映射到内存的 0xb80000xbffff 这段物理地址空间。
  • 文本模式下每个字符的显示由两个字节控制,低字节为该字符的 ASCII 码,高字节控制字符显示的颜色。
  • .code16 告诉编译器将代码编译成符合 16 位处理器的格式。
  • mov 指令用于转移数据。
  • jmp 指令用于程序的跳转。
  • . 位置计数器,表示当前位置,当然也可以通过给它赋值来改变当前位置。
  • .org 伪指令,告诉编译器移动到操作数所指定的位置。
  • .word 伪指令,用于写入一个字的数据,也可以写入多个一个字长的数据,用逗号分隔。
  • 剩下的就是我们用到的那些工具asobjdumpobjcopy,回过头去结合工具执行后的结果,理解理解每一个参数的含义就ok了。