01 第一个引导扇区

所谓引导扇区,其实就是一段可执行的代码而已,不过加入了一个小限制:编译后的总字节数不能超过 512,同时扇区(一个 512 字节的连续区域,一般在磁盘里)最后两个字节必须是 0x55 0xAA 。

虽然现在看来这个限制并不怎样,但一到后面再回过头来,您将会发现这是一个非常恶心的限制。不过没关系,对于现在的我们来说,这个限制并不大。

那么我们的目标就是用一些功能往屏幕上输出信息。现在这个阶段,除了我们之外,还活着的也就一个 BIOS 了。万幸的是,BIOS 提供了显示字符串的方法,具体用法如下:

向下列寄存器中依次存入:

AH=013h,表示输出信息

BH=页码(一般可以置0)

BL=属性(当al=0或1时才有用)

CX=字符串长度

(DH, DL):行和列

ES:BP:字符串地址

AL=输出方式

AL=0:仅含显示字符,字符属性(颜色等)位于 BL 中。显示后,光标位置不变。

AL=1:同 AL=0,但显示后光标位置改变。

AL=2:字符串中含有显示字符和显示属性。显示后,光标位置不变。

AL=3:同 AL=2,但显示后光标位置改变。

然后执行 int 10h

寄存器可以近似理解为变量,这里面的 AHBHBLDHDL 这些都是寄存器。怎么操作它们呢?且看待会的代码。

这里面有个东西叫 ES,它与其他寄存器不同,它是段寄存器。至于段寄存器是什么, ES:BP 又是什么意思,且看下文说明。

那么此次我们要使用的就是 AH=13h AL=01h 的显示方法,即显示字符串后光标移动。

知道怎么显示字符串,主体部分的代码除了汇编的语法以外就没有理解障碍了。鉴于是第一段代码,我们还是来做一个阅读理解吧:

代码 1-1 最简单的引导扇区(boot.asm)

    org 07c00h ; 告诉编译器程序将装载至0x7c00处

    mov ax, cs
    mov ds, ax
    mov es, ax ; 将ds es设置为cs的值(因为此时字符串存在代码段内)
    call DispStr ; 显示字符函数
    jmp $ ; 死循环

DispStr:
    mov ax, BootMessage
    mov bp, ax ; es前面设置过了,所以此处的bp就是串地址
    mov cx, 16 ; 字符串长度
    mov ax, 01301h ; 显示模式
    mov bx, 000ch ; 显示属性
    mov dl, 0 ; 显示坐标(这里只设置列因为行固定是0)
    int 10h ; 显示
    ret

BootMessage: db "Hello, OS world!"
times 510 - ($ - $$) db 0
db 0x55, 0xaa ; 确保最后两个字节是0x55AA

汇编语言大小写不敏感,因此我们把所有的指令和寄存器都搞成了小写。汇编语言也不存在 main 函数,会从第一行开始顺次往下执行(当然如果遇到跳转会跳走,这个流程类似 Python),因此我们也一行一行的看。

第一行,org 07c00h,意义已经写在注释里,但是为什么要这么做?这是因为,按照硬件规程(这个词汇后面还会出现多次),BIOS 在执行完自检等一系列操作以后,将执行位于 0x7c00 处的代码。07c00h,与 0x7c00 同义;同理,0(管你是啥)h0x(管你是啥) 也同义。这样,下面的代码才有被执行到的机会。由于它实际上不会产生任何机器码,因此它也被叫做伪指令

下面的 mov ax, cs,可以近似理解为 ax = cs,这里的 ax 也是寄存器,cs 也是寄存器,但这两者并不尽相同:ax 被称为通用寄存器,顾名思义可以随便用;而 cs 则是段寄存器,段与内存有莫大的关系,如果乱动将导致内存操作不合预期,这个 cs 更是和 code 有关,乱动会导致执行出故障,因此除了某些必然更改的方法以外,它一般都是只读的。

接下来的两个 mov 本身,我想读者可以自己引申理解。这其中,dses 也是段寄存器。段与内存有什么关系呢?在刚刚进入引导扇区的实模式下,我们认为一个段管理 64KB 内存。如果某个段寄存器的数值是 x,那么从 x * 16 开始的 64KB 就归它管,x 本身则代表一个段。这样的寻址方法,用 段寄存器:寻址寄存器 来表示。或许有人就要问了:

唉,这不对啊,那两个段难道不会重合么?

好问题,两个段还真会重合。那么重合部分的内存归谁管呢?段寄存器里是哪个段,这个内存就归谁管。

至于这个寻址寄存器又是什么东西,由于我们不会在实模式待太久(我是不是听到了“还有其他模式?”),所以就先不解释了。

这里之所以要把 dsescs 赋值,则又是因为这两者在 BIOS 执行期间可能还存着 BIOS 时期的段,如果不进行覆写,后面的 int 10h 会觉得我要从 BIOS 的某处取字符串,实际则应该从执行代码的某处读字符串,而后者是由 cs 进行表示的。

然后 call DispStr,这个可以近似理解为 DispStr();。至于具体发生了什么,由于本节教程(甚至可能一直到很后面的教程)都没有用到,所以先不解释,用到了再说。

最后这个 jmp $,相当于 while (1);。但是需要注意,jmp 并不是循环,它是一个跳转语句,和 goto 反而更为接近。$ 则表示这条指令的起始地址。这么一来,这条指令就相当于跳转到这条指令开始的位置,从而继续执行跳转,于是就起到了无限循环的作用。

然后是 DispStr:,它既可以表示 void DispStr(),也可以干脆作为 goto 的标签名,从后面的介绍还可以知道,它还能表示更多的意思,就先不说了。

下面 mov ax, BootMessage,相当于 ax = BootMessage。这个 BootMessage 又是从什么地方来的?仔细观察发现,原来就在下面,BootMessage: db "Hello, OS world!"。这个 db 也是个伪指令,作用是把后面的东西原样写进内存,不管它是一个数,一串数,或是一个字符串,只要它或它的每一个最小单元都在一个字节的范围内,就从头开始到最后,依次把这个数原样写在生成的文件里。大概相当于这样:

db 0x55, 0xaa -> char sth[] = {0x55, 0xaa};

db "Hello, OS World! -> char sth[] = "Hello, OS World!"

db 0x55 -> char sth[] = {0x55}

这个 db 其实也是一系列伪指令里的一个,还有 dwdd,分别是把那个数组的类型改成了 shortint。再往上还有更大尺度的,但是我们用不到。

把一个 BootMessage: 加在 db 前面,就相当于把这一串数组的名字给搞成了 BootMessage。也就是说,

BootMessage: db "Hello, OS world!" 等价于 char BootMessage[] = "Hello, OS world!"

因此这个 mov 代表的意思,就相当于是把 BootMessage 对应的内存地址赋值给了 ax

接下来 mov bp, ax,就是 bp = ax。或许有人要问:

那么为什么不直接 bp = BootMessage 呢,转写成汇编就是 mov bp, BootMessage?这样难道不是效率更高、指令更少吗?

这是因为,有的寄存器不能直接使用内存地址和数字(我们统称这俩为立即数,意思是可以立即知道数值的数)赋值,比如段寄存器。虽然 bp 不在此列,但为了保险的需要,还是使用 ax 进行中转。

接下来就是按照要求,依次对这些寄存器进行写入了。先是 mov cx, 16cx = 16),这是手动计算的下面字符串的长度;然后 mov ax, 01301hax = 0x1301)、mov bx, 000chbx = 0x000c),再之后是 mov dl, 0 设置在第 0 列显示。由注释可知,这是因为我们默认此时的 dh 是 0 的缘故。神奇的事情发生了,我们好像并没有对 ahalbhbl 赋值!这又是为什么呢?

如果您的观察比较敏锐,那么就会发现,ah 本该获得的 0x13,被放在了 ax 的高 8 位;al 本该获得的 0x01,被放在了 ax 的低 8 位。难道说……?

没错!ahal,其实就代表了 ax 的高8位和低8位(这也是它们 hl 的来源)。同理,bhbl 对应 bxchcl 对应 cxdhdl 对应 dx。其余的通用寄存器:disispbp,没有对应的 hl

接下来的 int 10h,相当于在调用库函数,上面的 ah 什么的都是参数。如果硬要类比,可能类似于这样:

sort(v.begin(), v.end(), cmp);int 10h 就类似 sort(只是角色,功能很不同),v.begin()v.end()cmp 作为参数则和那些寄存器类似(当然后面知道其实也很不同)。

最后的 ret,相当于 return ax;。这个返回值怎么处置,最终是由调用方说了算。

下面的 BootMessage 那一行已解释过,再往下比较有意思,times 510 - ($ - $$) db 0,这是在干什么,发刀乐么?

先说 timestimes xxx aaa,相当于做 xxxaaatimes 本身也是个伪指令。一般 times 都与 db 系列的伪指令配合使用,和其他的联合使用的,我是没见过例子。

下面的 $ 已经解释过,表示现在这个指令的起始内存地址,以此类推,其实伪指令的起始地址也可以用 $ 表示;$$ 则比较复杂,不过在这个语境下,可以默认它是 0。也就是说,写成 times 510 - $ db 0 也是没有问题的。

最后 db 0x55, 0xAA,是为了顺应硬件规程的需要,“扇区最后两个字节必须是 55 AA”。一个扇区一共 512 个字节,所以先把最后这一句一直到 510 字节填充成 0,然后写 55 AA,就能够保证这个二进制是一个符合硬件规程的扇区,从而能够被执行。

程序读完了,想必在这之前大家也写 好了,我们该怎么运行呢?首先编译一下:

nasm boot.asm -o boot.bin

对于 LinuxmacOS 用户而言,只需要下面两行命令就可以完成软盘映像的创建与写入:

dd if=/dev/zero of=a.img bs=512 count=2880
dd if=boot.bin of=a.img bs=512 count=1 conv=notrunc

如果您使用的是 Windows,那么需要执行 bximage。下面是使用 bximage 创建软盘映像的实例:

> bximage
========================================================================
                                bximage
                  Disk Image Creation Tool for Bochs
        $Id: bximage.c,v 1.34 2009/04/14 09:45:22 sshwarts Exp $
========================================================================

Do you want to create a floppy disk image or a hard disk image?
Please type hd or fd. [hd] fd

Choose the size of floppy disk image to create, in megabytes.
Please type 0.16, 0.18, 0.32, 0.36, 0.72, 1.2, 1.44, 1.68, 1.72, or 2.88. [1.44]

I will create a floppy image with
  heads=2
  sectors per track=18
  total sectors=2880
  total bytes=1474560

What should I name the image? [a.img]

Writing: [] Done.

I wrote 1474560 bytes to a.img.

The following line should appear in your bochsrc:
  floppya: image="a.img", status=inserted
(The line is stored in your windows clipborad, use CTRL-V to paste)

Press any key to continue
>

硬盘镜像制作完成之后,我们再执行一条写入命令:

dd if=boot.bin of=a.img bs=512 count=1

注意,Windows 下的 dd 不支持 conv 选项。

另外,如果您的 boot.bin 被报毒 KillMBR,请不要惊慌,因为它就是一个 MBR,因此被判为覆盖 MBR 的病毒非常正常,默认不做操作即可。

无论是上述哪种情况,在制作完成之后,直接执行一行命令来执行:

qemu-system-i386 -fda a.img

如果您的执行结果如下图,那么恭喜您,您的引导扇区成功执行了!

图 1-1 运行结果

(图 1-1 运行结果)

无论您使用的是哪种虚拟机,只要左上角出现 Hello, OS world! 就算是成功。