什么是 Multiboot?
Multiboot 规范是一个开源的引导加载程序(bootloader)接口标准,它定义了操作系统内核和 bootloader 之间的交互方式。简单来说,如果你的内核符合 Multiboot 规范,那么它就可以:
- 被任何 Multiboot 兼容的 bootloader(主要是 GRUB)引导,也能被 QEMU 加载;
- 跳过实模式的初始化过程,直接进入保护模式执行;
- 从 bootloader 处接收一些关于硬件环境的信息,例如内存布局等;
对于内核开发者来说,Multiboot 规范简化了内核的引导过程,将一部分无趣又繁琐的工作交给了 bootloader 来完成,这对于专注于内核开发本身,减少重复造轮子是有益的。同时,Multiboot 规范也使内核更容易与 bootloader 兼容。因此,在编写内核时,遵循 Multiboot 规范是一个不错的选择。
这篇文章不仅详细介绍了 Multiboot 规范的基本内容,还给出了一个符合 Multiboot 规范的极简示例——启动后向串口打印「Hello, World!」,随后关机——整个示例不计注释仅包含约 40 行汇编代码,并可以在 QEMU 中运行。
如何使用 Multiboot?
如何编写符合 Multiboot 规范的内核?
Multiboot 规范的内容可以在这里找到。简单来说,要使内核符合 Multiboot 规范,需要在内核二进制文件的前 8192 字节中包含一个 Multiboot header,其格式如下:
首先是 3 个必选的字段:
偏移 | 大小 | 名称 | 描述 |
---|---|---|---|
0x00 | 4 | magic | 魔数,必须是 0x1BADB002 ,bootloader 通过这个值寻找 Multiboot header。 |
0x04 | 4 | flags | 标志位,表示内核希望 bootloader 提供的功能。 |
0x08 | 4 | checksum | 校验和,要求 magic + flags + checksum == 0 。 |
其中 flags
字段的定义如下:
位 | 描述 |
---|---|
0 | 要求 bootloader 将所有 boot module 加载到 4KiB 对齐的地址上。 |
1 | 要求 bootloader 提供内存布局信息。 |
2 | 要求 bootloader 提供显示输出信息。 |
3 ~ 15 | 保留。 |
16 | 要求 bootloader 根据 Multiboot header 中提供的地址加载内核到内存。 |
17 ~ 31 | 保留。 |
flags
的第 0、1、2 位和此处的示例无关,这里不展开介绍;第 3 ~ 15 位和第 17 ~ 31 位保留,内核中这些位的值必须为 0;这里只讲述第 16 位:如果第 16 位被设置,bootloader 会将内核加载到 Multiboot header 中以下字段指定的地址上(如果第 16 位没有被设置,则内核二进制文件必须是自身包含了加载地址信息的 ELF 格式,bootloader 会根据 ELF 文件中的信息来加载内核),这些字段只有在第 16 位被设置时才会被使用:
偏移 | 大小 | 名称 | 描述 |
---|---|---|---|
0x0c | 4 | header_addr | 内核被加载到内存后,Multiboot header 的物理地址。 |
0x10 | 4 | load_addr | 内核被加载到内存后,内核的起始物理地址。 |
0x14 | 4 | load_end_addr | 内核被加载到内存后,内核的结束物理地址。 |
0x18 | 4 | bss_end_addr | 内核被加载到内存后,BSS 段的结束物理地址。 |
0x1c | 4 | entry_addr | 内核的入口物理地址。 |
header_addr
决定了内核的加载位置。加载完成后,位于 Multiboot header 开头的魔数会位于 header_addr
指定的地址上。通过这个地址,就可以间接确定内核的加载位置。
不难发现,header_addr
这一个字段就足以确定内核的加载位置了,那为什么还需要 load_addr
和 load_end_addr
呢?这是因为 bootloader 并不一定要将内核二进制文件的所有内容都加载到内存中(例如对于一个 ELF 格式的内核二进制文件,加载代码和数据等几个段就足够了,其余的段和 ELF Header 等可以不加载)。load_addr
和 load_end_addr
正是圈定了内核二进制文件中需要加载的部分。详细地说:
load_addr
决定了内核加载的起始位置:bootloader 会从 Multiboot header 前header_addr - load_addr
字节处开始加载;load_end_addr
决定了内核加载的结束位置:bootloader 会到 Multiboot header 后load_end_addr - header_addr
字节处结束加载;如果load_end_addr
为 0,则表示加载到二进制文件的末尾;- 也就是说,bootloader 一共会加载
load_end_addr - load_addr
字节到物理内存的[load_addr, load_end_addr)
区间;并确保 Multiboot header 的魔数位于header_addr
处。
bss_end_addr
则决定了 BSS 段的结束位置,bootloader 会将 [load_end_addr, bss_end_addr)
区间的内存清零,内核可以使用这部分内存作为 BSS 段。如果不需要 BSS 段,可以将 bss_end_addr
设置为 0。
entry_addr
决定了内核的入口地址,加载完成后,bootloader 会将跳转到这个地址,将控制权交给内核。
Multiboot header 最后还有 4 个字段是和 flags
中的第 2 位相关的,不置位第 2 位的情况下,可以忽略它们。这里只列出了它们的偏移和大小:
偏移 | 大小 | 名称 |
---|---|---|
0x20 | 4 | mode_type |
0x24 | 4 | width |
0x28 | 4 | height |
0x2c | 4 | depth |
有了符合要求的 Multiboot header 之后,内核就可以被兼容的 bootloader 引导了。
从 bootloader 到内核
Multiboot 规范也规定了控制权交给内核后,整个系统的状态。简而言之,bootloader 会将系统初始化为一个最简的 32 位保护模式状态,内核可以运行,但需要自己完成进一步的初始化工作。具体来说,Multiboot 规范给出了如下保证:
项目 | 状态 |
---|---|
EAX | 包含魔数 0x2BADB002 ,表示是由 Multiboot 规范引导的。 |
EBX | 包含 Multiboot 信息表的物理地址。 |
ESP | 无效值,内核需要自己设置栈指针。 |
其他通用寄存器 | 无效值。 |
CS | 一个 32 位,可读可执行的段,基址为 0,大小为 4 GiB。 |
DS ~SS | 一个 32 位,可读可写的段,基址为 0,大小为 4 GiB。 |
GDTR | 无效值,即使以上的段是保证有效的,GDT 本身也可能是无效的,内核需要自己设置 GDT。 |
CR0 | PG 复位,PE 置位。即保护模式打开,但分页关闭。其他位不确定。 |
EFLAGS | VM 复位,IF 复位。即虚拟 8086 模式关闭,中断关闭。其他位不确定。 |
IDTR | 无效值,内核需要自己设置 IDT。 |
A20 门 | 已打开。 |
编写极简的 Multiboot 示例
编码
有了以上的知识储备,我们就可以开始编写一个符合 Multiboot 规范的极简内核了。为了简便起见,我们全程使用汇编语言,进入内核后不初始化栈,也不读取 Multiboot 信息表。仅仅向串口打印「Hello, World!」,然后通过 QEMU 的方式(向 0x604 端口写入 0x2000)关机。
首先,我们需要一个 Multiboot header:我们将 flags
的第 16 位置位,为了保留未来的扩展性,我们也置位第 1 位,即使我们并不读取内存信息;由于没有 BSS 段,我们将 bss_end_addr
设置为 0;由于整个二进制文件都需要加载,我们将 load_end_addr
设置为 0;header_addr
设置为 Multiboot header 自身的地址,入口设置在 _start
标签处:
.att_syntax
.equ MULTIBOOT_MAGIC, 0x1BADB002
.equ MULTIBOOT_FLAGS, 0x00010002 # bit 1 (meminfo) and bit 16 (load address header fields)
.equ MULTIBOOT_CHECKSUM, -(MULTIBOOT_MAGIC + MULTIBOOT_FLAGS)
# The entry point of the kernel
.section .text.boot
.code32
.global _start
_start:
mov %eax, %edi # The multiboot magic number
mov %ebx, %esi # The multiboot information structure
jmp entry32
.balign 4
.type multiboot_header, @object
multiboot_header:
.long MULTIBOOT_MAGIC # The magic number
.long MULTIBOOT_FLAGS # The flags
.long MULTIBOOT_CHECKSUM # The checksum
.long multiboot_header # The header address, the linear address where the magic number should be loaded
.long _start # The start address of the kernel image, same as _start in this demo
.long 0 # The end address of the data segment, 0 means the end of the kernel image
.long 0 # The end address of the bss segment, 0 means no bss segment
.long _start # The entry point of the kernel
# There may be other fields here if we set the bit 2 of the flags, we can safely ignore them here
.global entry32
entry32:
hlt
我们将 _start
标签放在整个内核的起始位置,这样可以为不同的加载方式保留最好的兼容性。同时由于 Multiboot header 需要尽量靠近起始位置,所以我们最小化了 _start
的大小,以 entry32
标签作为真正的代码入口,让 _start
仅仅负责跳转到 entry32
;multiboot_header
则紧紧跟在 _start
后面。
这里将两个参数放入
%edi
和%esi
则是遵循了 System V 规范,如果在此基础上扩展出一个 64 位内核,则可以让 C/C++/Rust 等语言的函数以参数的形式接收这两个参数,当然,那时需要修改以下串口输出的部分。
接下来,我们需要编写 entry32
的代码:输出「Hello, World!」,然后关机:
.global entry32
entry32:
# Print "Hello, World!" to the serial port COM1 (0x3F8)
mov $0x3F8, %dx # The serial port COM1
mov $message, %esi # The message to print
mov $(message_end - message), %ecx # The length of the message
rep outsb
# Shutdown QEMU
mov $0x604, %dx
mov $0x2000, %ax
out %ax, (%dx)
hlt
.section .data.boot
.type message, @object
message:
.asciz "\nHello, World!\n"
message_end:
这里使用了很罕见的 rep
前缀搭配 outsb
指令来输出字符串。outsb
指令会将 %ds:%esi
指向的内存中的字节输出到 %dx
指向的端口中。rep
前缀则会重复执行 outsb
指令,每次将 %ecx
中的字节数减 1,%esi
的值加 1,直到 %ecx
中的字节数减为 0 为止。这样就可以一次性输出整个字符串了。最后通过向 0x604 端口写入 0x2000 来关机,这个方法是 QEMU 特有的。
汇编和链接
我们直接使用 GCC 工具链完成汇编和链接的工作。首先,我们将以上的代码保存为 multiboot.asm
,并使用 as
汇编成目标文件:
$ as -o multiboot.o multiboot.asm
随后,我们使用 ld
链接成 ELF 格式的可执行文件,这里需要准备一份链接脚本:
OUTPUT_ARCH(i386:x86-64)
BASE_ADDRESS = 0x100000;
ENTRY(_start)
SECTIONS {
. = BASE_ADDRESS;
.text : ALIGN(0x1000) {
*(.text.boot)
}
.data : {
*(.data.boot)
}
}
即使全程使用 32 位环境,我们也使用了 64 位的 ELF 格式,这也是为了方便能在此基础上扩展成完整的内核。我们只有 .text
和 .data
两个段,起始地址设置在 0x100000(即 1 MiB)处。将链接脚本保存为 linker.lds
,然后使用 ld
链接:
$ ld -T linker.lds -o multiboot.elf multiboot.o
最后,我们使用 objcopy
将 ELF 格式的可执行文件转换为裸的二进制文件:
$ objcopy --strip-all -O binary multiboot.elf multiboot.bin
运行
我们可以使用 QEMU 来运行这个内核,只需要将 multiboot.bin
作为内核传入 QEMU 即可,如果一切顺利的话,QEMU 会在串口输出「Hello, World!」,然后关机:
$ qemu-system-x86_64 -m 256M -smp 1 -nographic -kernel multiboot.bin
SeaBIOS (version rel-1.16.3-0-ga6ed6b701f0a-prebuilt.qemu.org)
iPXE (http://ipxe.org) 00:03.0 CA00 PCI2.10 PnP PMM+0EFD0E60+0EF30E60 CA00
Booting from ROM..
Hello, World!
至此,我们的 Multiboot 极简示例就完成了。我们可以在此基础上扩展更多的功能,例如读取 Multiboot 信息表,初始化栈,设置 GDT 和 IDT 等等,直至成为一个完整的内核。
以上代码已经放在这个仓库中,并附上了功能更加强大的 Makefile,欢迎使用。