编写我自己的引导加载程序
系列介绍
先决条件
引导加载程序的任务
组织代码库
编写引导加载程序
编写虚拟内核
把所有东西放在一起
系列介绍
我最近看了乔纳森·布洛的《防止文明崩溃》 ——精彩绝伦的演讲——我很好奇自己能否从零开始编写一个操作系统。于是我用谷歌搜索了一下,偶然发现了卡洛斯·费诺罗萨斯撰写的综合操作系统教程,它基于一篇写得非常精彩的讲座《从零开始编写一个简单的操作系统》。
所以我给自己定了一个目标:编写一个 x86 的 32 位操作系统。为了确保我真正理解了所有细节,我决定写一篇博客来记录我的进展。所以,这就是第一篇博文。
我们将从头开始编写一个简单的引导加载程序,使用 x86 汇编语言,并加载一个用 C 编写的极小的操作系统内核。为了简单起见,我们将使用BIOS而不是 UEFI。
这篇文章的结构如下。在深入细节之前,为了能够理解我的简要解释,最好先查阅一些资料。因此,接下来的部分包含一些关键词,您可以参考阅读。之后,我们将逐步编写引导加载程序。然后,我们将用 C 语言实现我们的极简内核。在最后一节,我们将把所有内容连接起来,启动我们自己的操作系统。
源代码可以在 GitHub 上找到。
先决条件
为了保持文章简短,我将重点讨论实现目标最重要的因素。这意味着有些内容将无法解释。但是,如果您在阅读本文的过程中花些时间更详细地了解这些内容,您应该能够顺利理解。
以下是需要了解/阅读以便理解本文内容的主题列表。
在工具方面,我们需要一个模拟器 ( QEMU ) 来运行我们的操作系统,一个 x86 汇编器 ( NASM ) 来编写我们的引导加载程序代码,以及一个 C 编译器 ( gcc ) 和链接器 ( ld ) 来创建可执行的操作系统内核。我们将使用GNU Make将所有这些连接起来。
引导加载程序的任务
在 x86 机器上,BIOS 会选择一个启动设备,然后将该设备的第一个扇区复制到位于内存地址 0x7C00 的物理内存中。在我们的例子中,这个所谓的启动扇区将包含 512 个字节。这 512 个字节包含引导加载程序代码、分区表、磁盘签名以及一个“魔数”,BIOS 会检查该魔数以避免意外加载不属于引导扇区的内容。然后,BIOS 指示 CPU 跳转到引导加载程序代码的开头,实际上是将控制权交给引导加载程序。
在本教程中,我们只关注引导加载程序代码,它将启动操作系统内核。这是必要的,因为我们无法将整个操作系统装入 512 字节的空间。为了启动内核,引导加载程序必须执行以下任务:
- 将内核从磁盘加载到内存中。
- 设置全局描述符表(GDT)。
- 从16 位实模式切换到 32 位保护模式并将控制权交给内核。
组织代码库
我们将使用 NASM 以 x86 汇编语言编写引导加载程序。内核将使用 C 语言编写。我们将代码组织成多个文件,以提高可读性和模块化。以下文件与最小设置相关:
mbr.asm
是定义主引导记录(512 字节引导扇区)的主文件disk.asm
包含使用 BIOS 从磁盘读取的代码gdt.asm
设置GDTswitch-to-32bit.asm
包含切换到 32 位保护模式的代码kernel-entry.asm
包含要交给我们主函数的汇编代码kernel.c
kernel.c
包含内核的主要功能Makefile
将编译器、链接器、汇编器和模拟器连接在一起,以便我们可以启动操作系统
下一节将重点介绍如何编写引导加载程序相关文件(mbr.asm
、disk.asm
、gdt.asm
和switch-to-32bit.asm
)。之后,我们将编写内核和入口文件。最后,我们将把所有内容一起写入并尝试启动。
编写引导加载程序
主引导记录文件
引导加载程序的主汇编文件包含主引导记录的定义,以及所有相关辅助模块的 include 语句。我们先来看一下整个文件,然后再分别讨论每个部分。
[bits 16]
[org 0x7c00]
; where to load the kernel to
KERNEL_OFFSET equ 0x1000
; BIOS sets boot drive in 'dl'; store for later use
mov [BOOT_DRIVE], dl
; setup stack
mov bp, 0x9000
mov sp, bp
call load_kernel
call switch_to_32bit
jmp $
%include "disk.asm"
%include "gdt.asm"
%include "switch-to-32bit.asm"
[bits 16]
load_kernel:
mov bx, KERNEL_OFFSET ; bx -> destination
mov dh, 2 ; dh -> num sectors
mov dl, [BOOT_DRIVE] ; dl -> disk
call disk_load
ret
[bits 32]
BEGIN_32BIT:
call KERNEL_OFFSET ; give control to the kernel
jmp $ ; loop in case kernel returns
; boot drive variable
BOOT_DRIVE db 0
; padding
times 510 - ($-$$) db 0
; magic number
dw 0xaa55
首先要注意的是,我们将在 16 位实模式和 32 位保护模式之间切换,因此我们需要告诉汇编器它应该生成 16 位还是 32 位指令。这可以分别使用[bits 16]
和[bits 32]
指令来完成。当 CPU 仍处于 16 位模式时,BIOS 会跳转到引导加载程序,因此我们一开始就使用 16 位指令。
在 NASM 中,该[org 0x7c00]
指令设置汇编器位置计数器。我们指定BIOS 放置引导加载程序的内存地址。这在使用标签时非常重要,因为在生成机器码时必须将它们转换为内存地址,而这些地址需要具有正确的偏移量。
该KERNEL_OFFSET equ 0x1000
语句定义了一个汇编程序常量,KERNEL_OFFSET
其值0x1000
稍后我们将在将内核加载到内存并跳转到其入口点时使用该值。
在引导加载程序调用之前,BIOS 会将选定的启动驱动器存储在dl
寄存器中。我们将这些信息存储在变量内的内存中,BOOT_DRIVE
这样我们就可以将该dl
寄存器用于其他用途,而不会有覆盖这些信息的风险。
在调用内核加载程序之前,我们需要通过设置堆栈指针寄存器sp
(栈顶,向下增长)和bp
(栈底)来设置堆栈。我们将栈底设置为 ,0x9000
以确保与其他引导加载程序相关的内存保持足够远的距离,从而避免冲突。堆栈将用于(例如)call
和ret
语句在执行汇编程序时跟踪内存地址。
现在该开始工作了!我们首先调用该load_kernel
过程,指示 BIOS 将内核从磁盘加载到内存中的以下KERNEL_OFFSET
地址。该过程load_kernel
使用了disk_load
我们稍后编写的程序。该过程接受三个输入参数:
- 读取的数据存放在哪个内存位置(
bx
) - 读取的扇区数(
dh
) - 要读取的磁盘(
dl
)
完成后,我们将返回下一条指令call switch_to_32bit
,该指令调用我们稍后编写的另一个辅助过程。它将准备切换到 32 位保护模式所需的一切,执行切换,并BEGIN_32BIT
在完成后跳转到标签,从而有效地将控制权移交给内核。
主引导加载程序代码到此结束。为了生成有效的主引导记录,我们需要在剩余空间中填充 0 字节times 510 - ($-$$) db 0
和一个魔法数字dw 0xaa55
。
接下来,让我们看看如何disk_load
定义该过程,以便我们可以从磁盘读取内核。
从磁盘读取
在 16 位模式下,从磁盘读取数据相当容易,因为我们可以通过发送中断来利用 BIOS 功能。如果没有 BIOS 的帮助,我们就必须直接与硬盘或软盘驱动器等 I/O 设备交互,这将使我们的引导加载程序变得更加复杂。
为了从磁盘读取数据,我们需要指定从哪里开始读取、读取多少以及在内存中存储数据的位置。然后,我们可以发送一个中断信号 ( int 0x13
),BIOS 就会开始工作,从相应的寄存器读取以下参数:
登记 | 范围 |
---|---|
ah |
模式(0x02 = 从磁盘读取) |
al |
要读取的扇区数 |
ch |
圆柱 |
cl |
部门 |
dh |
头 |
dl |
驾驶 |
es:bx |
要加载的内存地址(缓冲区地址指针) |
如果发生磁盘错误,BIOS 会设置进位位。在这种情况下,我们通常应该向用户显示一条错误消息,但由于我们没有介绍如何打印字符串,而且本文也不会介绍,所以我们就无限循环下去。
我们先来看看disk.asm
现在的内容。
disk_load:
pusha
push dx
mov ah, 0x02 ; read mode
mov al, dh ; read dh number of sectors
mov cl, 0x02 ; start from sector 2
; (as sector 1 is our boot sector)
mov ch, 0x00 ; cylinder 0
mov dh, 0x00 ; head 0
; dl = drive number is set as input to disk_load
; es:bx = buffer pointer is set as input as well
int 0x13 ; BIOS interrupt
jc disk_error ; check carry bit for error
pop dx ; get back original number of sectors to read
cmp al, dh ; BIOS sets 'al' to the # of sectors actually read
; compare it to 'dh' and error out if they are !=
jne sectors_error
popa
ret
disk_error:
jmp disk_loop
sectors_error:
jmp disk_loop
disk_loop:
jmp $
该文件的主要部分是disk_load
程序。回想一下我们在中设置的输入参数mbr.asm
:
- 读取的数据存放在哪个内存位置(
bx
) - 读取的扇区数(
dh
) - 要读取的磁盘(
dl
)
每个过程应该做的第一件事就是将所有通用寄存器(ax
,,,)推送到堆栈,以便我们可以在返回之前将它们弹出,以避免过程的副作用。bx
cx
dx
pusha
此外,我们将要读取的扇区数(存储在寄存器的高位dx
)推送到堆栈,因为我们需要dh
在发送 BIOS 中断信号之前设置磁头号,并且我们希望将预期读取的扇区数与 BIOS 报告的实际扇区数进行比较,以便在完成后检测错误。
现在我们可以在相应的寄存器中设置所有必需的输入参数并发送中断。请记住,bx
和dl
已被调用者正确设置。由于目标是读取磁盘上的下一个扇区(紧接着引导扇区),因此我们将从引导驱动器的 2 扇区、0 柱面、0 磁头开始读取。
执行完成后int 0x13
,我们的内核应该被加载到内存中。为了确保没有问题,我们应该检查两件事:首先,使用基于进位的条件跳转指令,检查是否存在磁盘错误(由进位指示)jc disk_error
。其次,使用比较指令,检查读取的扇区数(设置为中断的返回值al
)是否与我们尝试读取的扇区数(从堆栈弹出到dh
)是否匹配cmp al, dh
,如果不相等,则使用条件跳转指令jne sectors_error
。
一旦出现问题,我们就会陷入无限循环。如果一切顺利,我们将从程序返回到主函数。下一个任务是准备 GDT,以便切换到 32 位保护模式。
全局描述符表 (GDT)
一旦我们离开 16 位实模式,内存分段的工作方式就会略有不同。在保护模式下,内存段由段描述符定义,而段描述符是GDT的一部分。
对于我们的引导加载程序,我们将设置尽可能简单的 GDT,它类似于平面内存模型。代码段和数据段完全重叠,并跨越整个 4 GB 的可寻址内存。我们的 GDT 结构如下:
- 一个空段描述符(八个 0 字节)。这是一种安全机制,用于捕获代码中可能出现的错误,例如忘记选择内存段,从而导致无效的段被用作默认段。
- 4 GB 代码段描述符。
- 4 GB 数据段描述符。
段描述符是一种包含以下信息的数据结构:
- 基地址:段的 32 位起始内存地址。这将适用
0x0
于我们的两个段。 - 段限制:段长度为 20 位。这适用
0xfffff
于我们两个段。 - G(粒度):如果设置,则段限制将计为 4096 字节页面。这将适用
1
于我们的两个段,将页面限制转换0xfffff
为0xfffff000
字节数 = 4 GB。 1
D(默认操作数大小)/ B(大):如果设置,则对于我们的两个段来说,这是一个 32 位段,否则为 16 位。- L(长):如果设置,则这是一个 64 位段(并且 D 必须是
0
)。0
在我们的例子中,因为我们正在编写一个 32 位内核。 - AVL(可用):可用于任何我们喜欢的用途(例如调试),但我们只是将其设置为
0
。 - P(现在时):
0
此处的 A 基本上禁用了该段,阻止任何人引用它。1
显然,这适用于我们两个段。 - DPL(描述符特权级别):访问此描述符所需的保护环特权级别。
0
由于内核将访问这两个段,因此这两个段中均包含该特权级别。 - 类型:如果为
1
,则为代码段描述符。设置为0
则为数据段描述符。这是代码段描述符和数据段描述符之间唯一不同的标志。对于数据段,D 会被替换为 B,C 会被替换为 E,R 会被替换为 W。 - C(符合):此段中的代码可以从较低权限级别调用。我们设置此值是
0
为了保护内核内存。 - E(向下扩展):数据段是否从边界向下扩展到基址。仅与堆栈段相关,
0
在本例中设置为 。 - R(可读):如果代码段可读,则设置该值。否则,代码段只能执行。
1
在本例中,设置为 。 - W(可写):如果数据段可写入,则设置该值。否则,只能读取。
1
在本例中,设置为 。 - A(已访问):当访问该段时,硬件会设置此标志,这对于调试很有用。
遗憾的是,段描述符并非以线性方式包含这些值,而是分散在整个数据结构中。这使得用汇编语言定义 GDT 变得有些繁琐。请参阅下图,了解该数据结构的直观表示。
除了 GDT 本身,我们还需要设置一个 GDT 描述符。该描述符包含 GDT 的位置(内存地址)及其大小。
理论讲得够多了,让我们来看看代码!下面是我们的gdt.asm
,其中包含 GDT 描述符和两个段描述符的定义,以及两个汇编常量,以便我们知道代码段和数据段在 GDT 中的位置。
;;; gdt_start and gdt_end labels are used to compute size
; null segment descriptor
gdt_start:
dq 0x0
; code segment descriptor
gdt_code:
dw 0xffff ; segment length, bits 0-15
dw 0x0 ; segment base, bits 0-15
db 0x0 ; segment base, bits 16-23
db 10011010b ; flags (8 bits)
db 11001111b ; flags (4 bits) + segment length, bits 16-19
db 0x0 ; segment base, bits 24-31
; data segment descriptor
gdt_data:
dw 0xffff ; segment length, bits 0-15
dw 0x0 ; segment base, bits 0-15
db 0x0 ; segment base, bits 16-23
db 10010010b ; flags (8 bits)
db 11001111b ; flags (4 bits) + segment length, bits 16-19
db 0x0 ; segment base, bits 24-31
gdt_end:
; GDT descriptor
gdt_descriptor:
dw gdt_end - gdt_start - 1 ; size (16 bit)
dd gdt_start ; address (32 bit)
CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start
有了 GDT 和 GDT 描述符,我们最终可以编写执行切换到 32 位保护模式的代码。
切换到保护模式
为了切换到 32 位保护模式,以便我们可以将控制权移交给我们的 32 位内核,我们必须执行以下步骤:
- 使用指令禁用中断
cli
。 - 使用指令将 GDT 描述符加载到 GDT 寄存器中
lgdt
。 - 在控制寄存器中启用保护模式
cr0
。 - 使用 远跳转至我们的代码段
jmp
。这需要进行远跳转,以便刷新 CPU 流水线,清除其中残留的所有预取的 16 位指令。 - 设置所有段寄存器(
ds
,,,,,)以指向我们的单个 4 GB 数据段。ss
es
fs
gs
ebp
通过设置 32 位底部指针( )和堆栈指针( )来设置新堆栈esp
。- 通过调用我们的 32 位内核入口程序跳回内核
mbr.asm
并将控制权交给内核。
现在让我们将其翻译成汇编语言,这样我们就可以写switch-to-32bit.asm
:
[bits 16]
switch_to_32bit:
cli ; 1. disable interrupts
lgdt [gdt_descriptor] ; 2. load GDT descriptor
mov eax, cr0
or eax, 0x1 ; 3. enable protected mode
mov cr0, eax
jmp CODE_SEG:init_32bit ; 4. far jump
[bits 32]
init_32bit:
mov ax, DATA_SEG ; 5. update segment registers
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ebp, 0x90000 ; 6. setup stack
mov esp, ebp
call BEGIN_32BIT ; 7. move back to mbr.asm
切换模式后,我们就可以把控制权移交给内核了。下一节我们将实现一个虚拟内核。
编写虚拟内核
C 内核
基本引导加载程序功能启动并运行后,我们只需要用 C 语言创建一个小型的虚拟内核函数,以便从引导加载程序中调用。虽然离开 16 位实模式意味着我们将无法使用 BIOS,并且需要编写自己的 I/O 驱动程序,但现在我们可以用 C 语言等高级语言编写代码了!这意味着我们不再需要依赖汇编语言了。
目前,内核的任务是在屏幕左上角输出字母 X。为此,我们必须直接修改显存。对于启用了 VGA 文本模式的彩色显示器,显存起始于0xb8000
。
每个字符由两个字节组成:第一个字节表示 ASCII 编码字符,第二个字节包含颜色信息。下面是一个简单的main
函数,它会在屏幕左上角kernel.c
打印一个。X
void main() {
char* video_memory = (char*) 0xb8000;
*video_memory = 'X';
}
内核入口
当您回顾我们的时mbr.asm
,您会注意到我们仍然需要调用用 C 编写的主函数。为此,我们将创建一个小型汇编程序,该程序将KERNEL_OFFSET
在创建启动映像时放置在已编译的 C 内核前面的位置。
我们来看看内容kernel-entry.asm
:
[bits 32]
[extern main]
call main
jmp $
正如预期的那样,这里没什么可做的。我们只想调用我们的main
函数。为了避免汇编过程中的错误,我们需要将其声明main
为未在汇编文件中定义的外部过程。链接器的任务是解析内存地址,main
以便我们能够成功调用它。
需要注意的是,kernel-entry.asm
并不包含在我们的文件中mbr.asm
,但在下一节中会放在内核二进制文件的开头。所以,让我们看看如何组合我们构建的所有部分。
把所有东西放在一起
为了构建我们的操作系统映像,我们需要一些工具。我们需要nasm
处理汇编文件。我们需要gcc
编译 C 代码。我们需要将ld
编译后的内核目标文件和编译后的内核入口文件链接成一个二进制文件。我们将使用这个工具cat
将主引导记录和内核二进制文件合并成一个可引导的二进制映像。
但是,我们如何将这些巧妙的小工具组合在一起呢?幸运的是,还有另一个工具可以做到这一点:make
。因此,如下所示Makefile
:
# $@ = target file
# $< = first dependency
# $^ = all dependencies
# First rule is the one executed when no parameters are fed to the Makefile
all: run
kernel.bin: kernel-entry.o kernel.o
ld -m elf_i386 -o $@ -Ttext 0x1000 $^ --oformat binary
kernel-entry.o: kernel-entry.asm
nasm $< -f elf -o $@
kernel.o: kernel.c
gcc -m32 -ffreestanding -c $< -o $@
mbr.bin: mbr.asm
nasm $< -f bin -o $@
os-image.bin: mbr.bin kernel.bin
cat $^ > $@
run: os-image.bin
qemu-system-i386 -fda $<
clean:
$(RM) *.bin *.o *.dis
需要注意的是,为了能够编译并链接成独立的 x86 机器码,你可能需要进行交叉编译。我至少在我的 Mac 上做过这件事ld
。gcc
现在让我们编译、组装、链接、将我们的图像加载到qemu
,并查看X
屏幕左上角的美丽!
我们成功了!下一步是编写一些驱动程序,以便与 I/O 设备进行交互,不过这部分内容会在下一篇文章中讲解 :)
封面图片由Michael Dziedzic在Unsplash上提供。
如果您喜欢这篇文章,您可以在 ko-fi 上支持我。
文章来源:https://dev.to/frosnerd/writing-my-own-boot-loader-3mld