编写带有汇编的x86“ Hello world”引导加载程序

在按下计算机上的“ ON”按钮后,计算机的BIOS从引导设备读取512个字节,如果在这512个字节的末尾检测到两个字节的“幻数”,则从这512个字节中加载数据字节作为代码并运行它。

这类代码称为“引导加载程序”(或“引导扇区”),我们正在编写少量汇编代码,以使虚拟机运行我们的代码并显示“ Hello world”。

引导加载程序也是启动任何操作系统的第一步。

x86计算机启动时会发生什么

您可能想知道按计算机上的“电源”按钮会发生什么。 好了,无需太详细介绍-在准备好硬件并启动了初始BIOS代码以读取设置并检查系统之后,BIOS开始查看已配置的潜在启动设备以执行某些操作。

它通过从引导设备读取前512个字节并检查这512个字节中的后两个字节是否包含幻数( 0x55AA )来实现此目的。 如果这就是最后两个字节,那么BIOS将512字节移至内存地址0x7c00 ,并将512字节开头的内容视为代码,即所谓的引导加载程序 。 在本文中,我们将编写这样的代码,并将其打印为“ Hello World!”文本。 然后进入无限循环
实际的引导加载程序通常将实际的操作系统代码加载到内存中,将CPU更改为所谓的保护模式,然后运行实际的操作系统代码。

简而言之:

  1. 电源为所有必需的组件提供稳定的低压电源
  2. 计算机运行名为POST的自检程序
  3. CPU开始在计算机上称为BIOS的小型只读存储器上执行代码
  4. BIOS从配置为可引导的每个设备读取512字节
  5. 一旦找到以两个特定数字结尾的512字节,它将512字节视为引导加载程序并将其作为程序运行。
  6. 然后,此引导加载程序通常会加载实际的操作系统并运行它,以及其他一些管理工作。

在本文中,我们将编写自己的引导加载程序,该引导加载程序将打印“ hello world”,然后停止执行。 这并不是一个十亿美元的程序,而是可以成为您自己的操作系统的开始!

使用GNU汇编程序的x86汇编入门

为了使我们的生活更轻松(更简单!)并使之更加有趣,我们将在引导加载程序中使用x86汇编语言。 本文将使用GNU汇编器从我们的代码创建二进制可执行文件,并且GNU汇编器使用“ AT&T语法”而不是广泛使用的“英特尔语法”。 我将在本文结尾处以Intel语法重复该示例。

对于熟悉x86汇编语言和/或GNU汇编器的那些人,我创建了此描述,它说明了足够的汇编知识,可以使您快速上手本文的其余部分。 本文中的汇编代码也将被注释,因此您应该能够在不了解汇编详细信息的情况下浏览代码片段。

准备好我们的代码

好的,到目前为止,我们知道:我们需要创建一个512字节的二进制文件,该文件的末尾包含0x55AA 。 还值得一提的是,无论您使用的是32位还是64位x86处理器,在启动时该处理器都将以16位实模式运行 ,因此我们的程序需要对此进行处理。

让我们为汇编源代码创建boot.s文件,并告诉GNU汇编器我们将使用16位:

 .code16 # tell the assembler that we're using 16 bit mode 

啊,这太棒了! 接下来,我们应该为我们的程序提供一个起点,并将其提供给链接器(稍后再介绍):

 .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"

注意您可以随意命名标签。 标准是_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 )。

链接器通常用于将各种库和来自其他工具(例如编译器或汇编器)的二进制可执行文件组合到一个最终文件中。 在我们的例子中,我们要生成一个“普通二进制文件”,因此在运行它时,我们会将--oformat binary文件传递给ld 。 我们还想指定程序的开始位置,因此我们通过-e init标志告诉链接器将代码中的开始标签(我称为init )用作程序的入口点

运行该命令时,会得到更好的结果:

 $ as -o boot.o boot.s 
$ ld -o boot.bin --oformat binary -e init boot.s
$ ls -lh .
3 boot.bin
784 boot.o
152 boot.s

好的,三个字节听起来要好得多,但这不会启动,因为它在二进制文件的511和512字节上缺少幻数0x55AA

使它可启动

幸运的是,我们只需要用一堆零填充二进制文件,然后在数据末尾添加幻数即可。
让我们开始添加零,直到我们的二进制文件长510个字节(因为最后两个字节将是幻数)。

我们可以使用预处理器指令.fill from来做到这一点。 语法为.fill, count,size,value –在我们将这个指令写入boot.s的汇编代码中的任何位置时,它将count乘以size字节与value value

但是我们如何知道需要填写多少个字节? 方便地,汇编程序再次帮助了我们。 我们总共需要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

我们到达那里-仍然缺少我们的魔语的最后两个字节:

 .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 

哦,等等……如果魔术字节为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跳转到所谓的复位向量
这实际上意味着在系统重新引导后实际上不重新引导就重新执行第一条指令。 有时称为“热重启”。

使用BIOS打印文本

好吧,让我们从打印单个字符开始。
我们没有任何可用的操作系统或库,因此我们不能仅仅打电话给printf或它的一个朋友来完成。

幸运的是,我们的BIOS仍然可用并且可以访问,因此我们可以利用其功能。 这些功能(以及不同硬件提供的一系列功能)可以通过所谓的中断来使用。

在Ralf Brown的中断列表中,我们可以找到视频中断0x10。

单个中断可以执行许多不同的功能,通常可以通过将AX寄存器设置为特定值来选择这些功能。 在我们的例子中,“ 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向我们显示了这一点:

我们甚至可以看到光标在下一个位置闪烁,因此该功能应该易于用于较长的消息,对吗?

为了显示完整的消息,我们需要一种将这些信息存储在二进制文件中的方法。 我们可以像在二进制末尾存储魔术字那样进行类似的操作,但是由于要存储完整的字符串,因此将使用与.byte不同的指令。 幸运的是, .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 

堂,那是什么意思?

事实证明,在ELF文件( boot.o )中msg的移动地址不适合我们的16位地址空间。 我们可以通过告诉ld我们的程序存储器应该从哪里开始来解决此问题。 BIOS将在地址0x7c00加载我们的代码,因此在调用链接-Ttext 0x7c00时,通过指定-Ttext 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之外的任何寄存器中(因为我们将其用于实际打印),例如,我们使用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同时。

让我们放在一起:

 .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 

让我们看看QEmu中的这段新代码:

🎉耶! 🎉

它通过循环从print_charjmp print_char print_char来打印消息,直到我们在si命中零字节( jmp print_char消息的最后一个字符之后)为止。 找到零字节后,我们跳至done并停止执行。

英特尔语法版本和nasm

按照承诺,我还将向您展示使用nasm代替GNU汇编器的另一种方法。

首先, nasm可以自己生成原始二进制文件,并且使用Intel语法:

operation target, source –我记得带“ W,T,F”的顺序–“是什么,到,从”

因此,这是先前代码的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.asmboot.asm ,可以通过运行nasm -o boot2.bin boot.asm进行编译。

请注意, cmp的参数顺序与nasm和.org中使用的[org][org]使用的顺序相反!

nasm不会通过ELF文件( boot.o )执行额外的步骤,因此不会像ld那样在内存中移动msg

但是,如果我们忘记将代码的起始地址设置为0x7c00 ,则二进制文件用于msg的地址仍将是错误的,因为nasm默认情况下会采用其他起始地址。 当我们将其显式设置为0x7c00 (BIOS加载代码的位置)时,地址将在二进制文件中正确计算,并且代码的工作方式与其他版本相同。