(翻译)Writing an x86 "Hello world" bootloader with assembly
原文出处:http://50linesofco.de/post/2018-02-28-writing-an-x86-hello-world-bootloader-with-assembly
摘要
(TL;DR 可以是 Too long; Didn’t read(太长,所以没有看)。也可以是 Too long; Don’t read (太长,请不要看),常作为一篇很长的文章的摘要标题。)
计算机通电后,计算机的BIOS从启动设备上读取512bytes,如果其在这些512bytes末尾检测到一个2-byte的“magic number”,便将这512bytes的数据当成指令并运行这些指令。
这种指令被叫做“引导程序”(或者“引导扇区”)(”bootloader” (or “boot sector”)),我们打算写一些汇编代码来让一个虚拟机启动并显示“Hello world”。引导程序也是启动系统的第一个阶段。
x86计算机启动时所发生的事
你或许想知道当你按下你计算机的电源键时所发生的事。好了,没有太多的细节——在硬件准备好后,初始化BIOS代码来读取设置并检查系统,BIOS开始查看配置的潜在引导设备以执行某些操作。
它通过读取引导设备中的第一个512字节并检查这些512字节中的最后两个是否包含一个magic number(0x55AA)。如果这就是最后两个字节,BIOS将这512个字节移到内存地址0x7C00,并将这512字节开始处的任何字节当作代码,即所谓的引导加载程序。在本文中,我们将编写这样一段代码,让它打印文本“Hello World!”然后进入无限循环。
真正的引导程序通常将实际操作系统代码加载到内存中,将CPU更改为保护模式(protected mode),并运行实际操作系统代码。
用GNU汇编程序编写X86汇编程序
为了更加容易更加有趣,我们选择x86汇编语言来写我们的引导程序。这篇文章将使用GNU 汇编器来从我们的代码中创建二进制可执行文件,其中GNU 汇编器使用“AT&T语法“而不是流传甚广的”Intel语法“。在文章最后,我会用Intel语法重写例子。
准备我们的代码
目前为止我们知道:我们需要创建一个以0X55AA为结尾的512bytes二进制文件。同时,值得我们注意的是:无论你的x86处理器时32位还是64位的,在启动时都会运行在16bit实模式下,因此我们的程序需要处理这种情况。
让我们为我们的汇编源代码创建boot.s
文件,并告诉GNU汇编器我们将使用16位:
.code16 # 告诉汇编器我们使用16 bit模式
下一步,我们应该给我们的程序一个起始点,使链接器能够链接得到:
.code16
.global init # 使得我们的标签"init"为外面所知(makes our label "init" available to the outside)
init: # 这是之后我们二进制文件的起始点(this is the beginning of our binary later.)
jmp init # 跳转到"init"(jump to "init")
注意 你可以为你的标签取任何名字。标准做法是_start
但是我选择init
就是为了说明我们确实可以取任意的名字。
好,现在我们甚至得到了一个无限循环,因为我们一直跳到标签,然后跳到标签再次…
通过运行GNU汇编程序(as
)将代码转换为二进制代码,看看我们得到了什么:
as -o boot.o boot.s
ls -lh .
784 boot.o
152 boot.s
哇,坚持住!我们的输出已经是784字节?但是我们的引导装载器只有512个字节!
嗯,大多数时候开发人员感兴趣的可能是为他们的目标操作系统创建一个可执行文件,即一个exe
文件(Windows)和elf
文件(Unix)。这些文件有一个头(附加的、前置字节)并且通常加载几个系统库来访问操作系统功能。
我们的情况是不同的:我们不需要这些,只需要在引导时让BIOS执行的二进制代码。
通常,汇编器会产生一个可以运行的ELF或EXE文件,但是我们需要一个附加的步骤来从这些文件中删除不需要的附加数据。我们可以利用链接器(GNU的链接器被称为ld
)来实现该步骤。
链接器通常用于将各种库和从编译器或汇编器等其他工具产生的二进制可执行文件组合到一个最终文件中。在我们这种情况下,我们需要一个“普通二进制文件(plain binary file)”,所以我们会在我们运行ld
时给其传递--oformat binary
参数。我们还想指定我们的程序从何处开始,所以通过使用-e init
标志来告诉链接器在我们的代码中使用开始标签(我称之为init
)作为程序的入口点。
当我们运行时,我们得到了更好的结果:
as -o boot.o boot.s
ld -o boot.bin --oformat binary -e init boot.o
ls -lh .
3 boot.bin
784 boot.o
152 boot.s
现在3字节听起来好多了,但是这还不能启动,因为它缺少在字节511和512的二进制数字0x55AA
…
使之成为可引导的
幸运的是,我们可以用零来填充我们的二进制文件,并在末尾加上magic number。
让我们添加零直到我们的二进制文件是510字节长(因为最后两字节将是个magic number)。
我们可以使用as
的预处理指令.fill
来实现同样的效果。语法是.fill, count,size,value
——它都会添加count
乘size
个值为value
的字节到boot.s
里汇编代码中我们写这个指令的位置。
但是我们如何知道需要填充多少字节呢?方便的是,汇编器再次帮助我们。我们需要总共510个字节,所以我们将用零填充510-(我们的代码字节大写)个字节。但是,“代码的字节大小”是什么?幸运的是as
有一个帮手来告诉我们在生成的二进制文件当前字节位置:.
——并且我们还能够得到标签的位置。所以,我们的代码大小就是当前位置.
在我们代码中的位置减去第一条语句在我们的代码中的位置(它是init
的位置)。所以,.-init
返回在最终二进制文件中生成代码的字节数。
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
jmp init # jump to "init"
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
as -o boot.o boot.s
ld -o boot.bin --oformat binary -e init boot.s
ls -lh .
510 boot.bin
1.3k boot.o
176 boot.s
我们就要达到目标了——仍然缺少最后两个字节的magic number:
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
jmp init # jump to "init"
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
哦等等…如果magic number是0x55aa
,为什么我们在这里交换它们?这是因为x86
是小端的,所以字节在内存中交换。
现在,如果我们生成一个更新过的二进制文件,它是512字节长。
引导我们的引导程序
理论上,你可以把这个二进制写在USB驱动器、软盘或你电脑上愿意启动的任何512个字节上,但是我们可以使用一个简单的x86仿真器(它就像一个虚拟机)来替代。
我将使用x86系统架构的QEMU来实现这一点:
qemu-system-x86_64 boot.bin
运行这个命令会产生一些相对不引人注意的事情:
QEMU停止寻找可引导设备意味着我们的引导加载程序工作了——但是它并没有做任何事情!
为了证明这一点,我们可以重新启动循环,而不是无限循环,通过将程序集代码更改为:
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
ljmpw $0xFFFF, $0 # jumps to the "reset vector", doing a reboot
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
这条新的指令ljmpw $0xFFFF, $0
跳转到所谓的复位向量(Reset vector)。
他实际上意味着在系统重新启动之后,在没有实际重新启动的情况下重新执行第一条指令。它有时被称为“热重启”。
使用BIOS打印文本
好的,让我们从打印一个字符开始。
我们没有任何可用的操作系统和库,所以我们不能仅仅调用printf
或者它的friends来完成工作。
幸运的是,我们的BIOS还是可用使用的,所以我们可以使用它的功能。通过所谓的中断(interrupts)这些功能(伴随着不同硬件提供的一系列功能)可以为我们使用。
在 Ralf Brown’s interrupt list这个网站上,我们能够找到视频中断0x10
。
通常,一个中断可以通过设置AX寄存器为特定值来实现不同的功能。在我们这种情况下,function “Teletype” 听起来是一个不错的选择——它打印在al
中给出的字符并自动推进光标。漂亮!我们可以通过将ah
设置为0xe
来选择该函数,将要打印的ASCII代码放入al
中,然后调用int 0x10
:
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
mov $0x0e41, %ax # sets AH to 0xe (function teletype) and al to 0x41 (ASCII "A")
int $0x10 # call the function in ah from interrupt 0x10
hlt # stops executing
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
现在我们将必要的值加载到ax
寄存器中,调用中断0x10
并停止执行(使用hlt
)。
当我们运行as
和ld
来得到更新后的引导程序后,QEMU
显示如下:
我们甚至可以看到光标在下一个位置闪烁,所以这个功能应该很容易使用更长的消息,对吧?
我们最终的hello-word-bootloader
要得到一个完整的信息来显示,我们需要一种方法来将这些信息存储在我们的二进制文件中。我们可以就像我们在二进制文件的末尾存储magic word一样,但是我们将使用一个与.byte
不同的指令来存储一个完整的字符串。幸运的是,as
有.ascii
和.asciz
用于字符串。它们之间的区别是,.asciz
自动添加另一个被设置为零的字节。这一会就派上用场了,所以为我们的数据选择asciz
指令。
另外,我们会使用一个标签来访问地址:
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
mov $0x0e, %ah # sets AH to 0xe (function teletype)
mov $msg, %bx # sets BX to the address of the first byte of our message
mov (%bx), %al # sets AL to the first byte of our message
int $0x10 # call the function in ah from interrupt 0x10
hlt # stops executing
msg: .asciz "Hello world!" # stores the string (plus a byte with value "0") and gives us access via $msg
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
我们在这儿有了新的功能:
mov $msg, %bx
mov (%bx), %al
第一行将第一个字节的地址加载到寄存器bx
(我们使用整个寄存器因为地址长度是16位)。
第二行将在bx
寄存器里的地址所指向的位置上的值加载到寄存器al
中,所以消息的第一个字节最终进入到al
中,因为bx
指向它的地址。
但是现在当我们运行ld
时发生一个错误:
as -o boot.o boot.s
ld -o boot.bin --oformat binary -e init -o boot.bin boot.o
boot.o: In function `init':
(.text+0x3): relocation truncated to fit: R_X86_64_16 against `.text'+a
这些是什么意思呢?
这说明,在我们的16位地址空间中,msg
在ELF文件(boot.o
)中移动的地址不适合。可以通过告诉ld
我们的程序应该从哪里开始从而解决这个问题。BIOS会在地址0x7c00
处加载我们的代码,所以我们可以在调用链接器时通过-Ttext 0x7c00
来设置我们的开始地址。
as -o boot.o boot.s
ld -o boot.bin --oformat binary -e init -Ttext 0x7c00 -o boot.bin boot.o
QEMU
现在会打印出”H”,我们消息文本的第一个字符。
我们可以通过下面的步骤来打印整个字符串:
- 将字符串(即
msg
)第一个字符的地址放到除ax
外(因为我们使用ax
来进行实际的打印)的其他任何寄存器中,比如使用cx
。 - 将寄存器
cx
里地址所指向的位置上的字节加载到al
中。 - 将
al
中的值与0(字符串结尾,.asciz
的特点)比较。 - 如果
al
中是0,转到程序的最后。 - 调用中断
0x10
。 - 将
cx
中存储的地址增加1。 - 从步骤2开始重复。
同样有用的是,x86有一个特殊的寄存器和一组特殊的指令来处理字符串。
为了使用这些指令,我们将把字符串(msg
)的地址加载到特殊寄存器si
,该寄存器允许我们lodsb
指令来把si
指向的地址上的字节加载到al
并同时将si
里的地址增加1。
让我们把这些放在一起:
.code16 # use 16 bits
.global init
init:
mov $msg, %si # loads the address of msg into si
mov $0xe, %ah # loads 0xe (function number for int 0x10) into ah
print_char:
lodsb # loads the byte from the address in si into al and increments si
cmp $0, %al # compares content in AL with zero
je done # if al == 0, go to "done"
int $0x10 # prints the character in al to screen
jmp print_char # repeat with next byte
done:
hlt # stop execution
msg: .asciz "Hello world!"
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
Let’s look at this new code in QEmu:
通过不断地循环print_char
到jmp print_char
直到si
寄存器中为0(在我们消息的最后一个字符的后面),它打印了完整的消息。一旦找到0字节,我们跳转到done
并停止程序。
Intel语法的版本和nasm
前面说过,我会告诉你如何用nasm
代替GNU汇编器来实现同样的事情。
首先:nasm
可以自己生成一个原始二进制文件,并且使用英特尔语法:
operation target, source——
——我记得“W,T,F”的顺序:“What,To,From”;-)
下面是nasm
兼容版本的代码:
[bits 16] ; use 16 bits
[org 0x7c00] ; sets the start address
init:
mov si, msg ; loads the address of "msg" into SI register
mov ah, 0x0e ; sets AH to 0xe (function teletype)
print_char:
lodsb ; loads the current byte from SI into AL and increments the address in SI
cmp al, 0 ; compares AL to zero
je done ; if AL == 0, jump to "done"
int 0x10 ; print to screen using function 0xe of interrupt 0x10
jmp print_char ; repeat with next byte
done:
hlt ; stop execution
msg: db "Hello world!", 0 ; we need to explicitely put the zero byte here
times 510-($-$$) db 0 ; fill the output file with zeroes until 510 bytes are full
dw 0xaa55 ; magic number that tells the BIOS this is bootable
将它命名为boot.asm
后,可以通过运行 nasm -o boot2.bin boot.asm
编译。
注意,cmp
参数的顺序和as
中参数的顺序相反,并且[org]
在nasm
中和as
中的.org
是不一样的。
nasm
不会通过ELF文件(boot.o
)做额外的步骤,所以它不会像as
和ld
一样在内存中移动我们的msg
。
然而,如果我们忘记设置我们代码的启动地址为0x7c00
,二进制文件中使用的msg
地址仍然会是错误的,因为nasm
默认情况下会使用不同的起始地址。当我们明确设置起始地址为0x7c00
(BIOS加载我们代码的地方),二进制文件中的地址就会被正确计算,并且代码也会和其他版本一样正常工作。