(翻译)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——它都会添加countsize个值为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

运行这个命令会产生一些相对不引人注意的事情:

img

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)。

当我们运行asld来得到更新后的引导程序后,QEMU显示如下:

img

我们甚至可以看到光标在下一个位置闪烁,所以这个功能应该很容易使用更长的消息,对吧?

我们最终的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”,我们消息文本的第一个字符。

我们可以通过下面的步骤来打印整个字符串:

  1. 将字符串(即msg)第一个字符的地址放到除ax外(因为我们使用ax来进行实际的打印)的其他任何寄存器中,比如使用cx
  2. 将寄存器cx里地址所指向的位置上的字节加载到al中。
  3. al中的值与0(字符串结尾,.asciz的特点)比较。
  4. 如果al中是0,转到程序的最后。
  5. 调用中断0x10
  6. cx中存储的地址增加1。
  7. 从步骤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:

img

通过不断地循环print_charjmp 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)做额外的步骤,所以它不会像asld一样在内存中移动我们的msg

然而,如果我们忘记设置我们代码的启动地址为0x7c00,二进制文件中使用的msg地址仍然会是错误的,因为nasm默认情况下会使用不同的起始地址。当我们明确设置起始地址为0x7c00(BIOS加载我们代码的地方),二进制文件中的地址就会被正确计算,并且代码也会和其他版本一样正常工作。

posted @ 2018-05-03 11:20  main_c  阅读(419)  评论(0编辑  收藏  举报