Featured image of post Multiboot 极简示例

Multiboot 极简示例

Multiboot 规范,或许你没有听说过,但是让你的内核用上它一定是个好主意……

什么是 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 个必选的字段:

偏移大小名称描述
0x004magic魔数,必须是 0x1BADB002,bootloader 通过这个值寻找 Multiboot header。
0x044flags标志位,表示内核希望 bootloader 提供的功能。
0x084checksum校验和,要求 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 位被设置时才会被使用:

偏移大小名称描述
0x0c4header_addr内核被加载到内存后,Multiboot header 的物理地址。
0x104load_addr内核被加载到内存后,内核的起始物理地址。
0x144load_end_addr内核被加载到内存后,内核的结束物理地址。
0x184bss_end_addr内核被加载到内存后,BSS 段的结束物理地址。
0x1c4entry_addr内核的入口物理地址。

header_addr 决定了内核的加载位置。加载完成后,位于 Multiboot header 开头的魔数会位于 header_addr 指定的地址上。通过这个地址,就可以间接确定内核的加载位置。

不难发现,header_addr 这一个字段就足以确定内核的加载位置了,那为什么还需要 load_addrload_end_addr 呢?这是因为 bootloader 并不一定要将内核二进制文件的所有内容都加载到内存中(例如对于一个 ELF 格式的内核二进制文件,加载代码和数据等几个段就足够了,其余的段和 ELF Header 等可以不加载)。load_addrload_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 位的情况下,可以忽略它们。这里只列出了它们的偏移和大小:

偏移大小名称
0x204mode_type
0x244width
0x284height
0x2c4depth

有了符合要求的 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。
CR0PG 复位,PE 置位。即保护模式打开,但分页关闭。其他位不确定。
EFLAGSVM 复位,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 仅仅负责跳转到 entry32multiboot_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,欢迎使用。