从零写OS内核 | 极简Bootloader——从磁盘第一个扇区到内核入口

金句:电脑开机后,CPU 从 0xFFFF0 执行的第一条指令,既不是 Windows,也不是 Linux——而是一段只有 446 字节的启动代码。它没有任何操作系统功能,唯一的任务就是:找到下一个可以被执行的程序,把它加载到内存,然后跳过去。


1. 问题:电脑开机后,谁是第一个跑的?

你按下电源键,CPU reset,寄存器全部清零。但问题是:内存里什么都没有,CPU 该执行谁?

这时硬件提供了一个契约:CPU 上电后,CS:IP 被强制设置为 0xF000:0xFFF0(x86 架构),这个地址映射到主板上的一块 ROM——BIOS ROM(现在叫 UEFI Flash)。ROM 里的代码做两件事:

  1. POST(Power-On Self-Test):检测内存、显卡、键盘等基本硬件
  2. 按顺序遍历启动设备(硬盘、U盘、光盘……),找到第一个可以启动的

找到之后,BIOS 读取该设备的第一个扇区(512 字节)到内存 0x7C00,然后跳过去执行。

这 512 字节,就是 Bootloader 的起点。


2. MBR:磁盘第一个扇区里藏了什么

硬盘的第一个扇区叫 MBR(Master Boot Record),它不是操作系统的代码,而是启动链的第一环

MBR 的结构是固定的:

Bash
┌─────────────────────────────────────────────────────┐
│  0x0000 ~ 0x01BD  (446 字节)   ← 主引导代码          │
│  0x01BE ~ 0x01CD  (16 字节)    ← 分区条目 1          │
│  0x01CE ~ 0x01DD  (16 字节)    ← 分区条目 2          │
│  0x01DE ~ 0x01ED  (16 字节)    ← 分区条目 3          │
│  0x01EE ~ 0x01FD  (16 字节)    ← 分区条目 4          │
│  0x01FE ~ 0x01FF  (2 字节)     ← 0x55AA(结束标记)   │
└─────────────────────────────────────────────────────┘
           总计:512 字节

分区条目(每个 16 字节)的结构:

偏移 大小 含义
0x00 1 活动标志(0x80 = 可启动,0x00 = 不可)
0x01 3 CHS 起始地址(老式柱面/磁头/扇区)
0x04 1 分区类型(0x07=NTFS,0x0C=FAT32,0x83=Linux ext2…)
0x05 3 CHS 结束地址
0x08 4 起始扇区(LBA)
0x0C 4 分区大小(扇区数)

结束标记 0xAA55 是关键:BIOS 读取 512 字节后,会检查最后两个字节是不是 0x55AA。如果不是,就认为这个盘不可启动,继续找下一个设备。


3. 主引导代码到底做了什么

MBR 的主引导代码只有 446 字节,能力有限。它的核心逻辑是:

  1. 从分区表里找到活动分区(flag = 0x80)
  2. 读取该分区的第一个扇区到内存(比如 0x7C00)
  3. 验证 0xAA55 标记
  4. 跳过去执行

典型的 MBR 引导代码(16 位实模式):

Asm
; 简化版 MBR 引导代码(16位实模式)
org 0x7C00        ; BIOS 会把第一个扇区加载到这里

start:
    mov ax, 0x07C0    ; 设置数据段
    mov ds, ax
    mov es, ax

    ; 读取活动分区的第一个扇区到 0x8000
    mov bx, 0x8000    ; 缓冲区地址
    mov dl, [boot_device]  ; BIOS 传入的启动盘号
    mov dh, 0         ; 磁头号
    mov ch, [cylinder]    ; 柱面号
    mov cl, 2         ; 从第2扇区开始读(扇区1是MBR自己)
    mov ah, 0x02      ; BIOS 中断 13h:读磁盘
    mov al, 63        ; 读取扇区数(根据分区大小)
    int 0x13          ; 触发 BIOS 磁盘服务

    jc read_error     ; 如果进位标志=1,说明读取失败

    ; 跳转到活动分区引导扇区
    jmp 0x0000:0x8000

read_error:
    mov si, err_msg
    call print_string
    jmp $

err_msg db 'Boot error!', 0

; 填充剩余空间
times 510 - ($ - $$) db 0
; 结束标记
dw 0xAA55

解释

  • org 0x7C00:告诉汇编器,这段代码会被加载到 0x7C00
  • int 0x13:BIOS 提供的磁盘读写服务(16位实模式下唯一访问磁盘的方式)
  • times 510 - ($ - $$):用 0 填充到第 510 字节
  • dw 0xAA55:写入结束标记,BIOS 靠这个识别可启动盘

坑1:实模式地址 vs 保护模式地址

MBR 代码跑在 16 位实模式,地址是 段:偏移 形式。0x0000:0x7C000x07C0:0x0000 指向同一个物理地址 0x7C00,但写法不同。实模式地址转换规则:物理地址 = 段 × 16 + 偏移。代码里要小心这种混淆。


4. GRUB:更复杂的 Bootloader

MBR 代码只有 446 字节,功能太弱——它只能读取固定扇区、跳过去,没有文件系统的概念。

实际工作中,PC 上的 Linux 用的是 GRUB(Grand Unified Bootloader)。GRUB 是一套多阶段的引导器:

Bash
Stage 1 (MBR 内): 446字节,跳转到 Stage 1.5
  ↓
Stage 1.5 (MBR 后紧邻): 识别文件系统(FAT、ext2、xfs…)
  ↓
Stage 2 (/boot/grub/): 显示菜单、读配置文件、加载内核

GRUB 的核心能力是:理解文件系统,直接从 /boot 目录读取内核文件

而且 GRUB 实现了 Multiboot 规范:它把内核加载到内存 0x100000,设置好寄存器状态,然后跳转。内核不需要知道 GRUB 的实现细节,只需要符合 Multiboot header 即可。


5. Multiboot Header:内核怎么告诉 GRUB”我是可以被加载的”

想让 GRUB 加载你的内核,内核文件的前 8192 字节内必须有一个 Multiboot Header

Asm
; Multiboot Header - 必须位于内核前 8192 字节内,4字节对齐
.section .multiboot
.align 4

/* GRUB 认识这些字段 */
.long 0x1BADB002          ; magic: 固定值 0x1BADB002
.long 0x00010003          ; flags: PF_ALIGN(1) + PF_MEMINFO(2) + PF_GRAPHICS(4096)
.long -(0x1BADB002 + 0x00010003)  ; checksum:必须等于 -(magic + flags)

/* 下面这些字段在 flags 开启后才需要 */
.long 0            ; header_addr
.long 0            ; load_addr
.long 0            ; load_end_addr
.long 0            ; bss_end_addr
.long 0            ; entry_addr  ← 内核入口地址

解释

  • magic = 0x1BADB002:GRUB 用这个 magic 识别 Multiboot 兼容内核
  • flags:告诉 GRUB 哪些特性内核支持(比如需要内存信息、图形模式)
  • checksum:校验和,必须使 magic + flags + checksum = 0

GRUB 加载内核后,会设置好寄存器:

  • eax = 0x2BADB002(magic,确认被 GRUB 加载)
  • ebx = multiboot_info 结构地址(包含内存映射、启动设备等信息)
  • CS/DS/ES/SS = flat 32-bit selector
  • CR0 的 PG 位 = 0(保护模式,未分页)

6. 从零实现:wandos 的 Bootloader 现状

⚠️ wandos 说明:wandos 当前 boot/ 目录为空(没有 boot.asm 或 boot.S)。wandos 依赖 GRUB(通过 Multiboot Header)来加载,自身没有实现自定义 Bootloader。本节说明如果要实现一个自定义 Bootloader,应该怎么做。

6.1 实现最小 MBR Bootloader

如果要写一个真正的自定义 Bootloader(不依赖 GRUB),需要实现:

Asm
; 最简 Bootloader:读取内核到内存,跳转执行
; 编译:as --32 -o boot.o boot.S
;      ld -m elf_i386 -Ttext 0x7C00 --oformat binary -o boot.bin boot.o
;      dd if=boot.bin of=/dev/sda bs=446 count=1  (写入 MBR)

.code16
.text
.global _start
_start:
    jmp short main        ; 跳过数据区

boot_device:
    .byte 0x80            ; 默认从第一块硬盘启动

main:
    ; 设置段寄存器
    xor ax, ax
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

    ; 设置栈(从 0x90000 向下生长)
    mov ax, 0x9000
    mov ss, ax
    mov sp, 0xFFFE

    ; 读取内核(假设在第2-63扇区,共62个扇区)
    mov bx, 0x10000      ; 内核加载到 0x10000
    mov dl, [boot_device]
    mov dh, 0             ; 磁头 0
    mov ch, 0            ; 柱面 0
    mov cl, 2            ; 从扇区 2 开始(第1扇区是 MBR 自己)
    mov ah, 0x02         ; BIOS 读磁盘
    mov al, 62           ; 读 62 个扇区
    int 0x13

    jc disk_error

    ; 关闭软盘马达
    mov ax, 0x0D
    int 0x13

    ; 切换到保护模式
    cli
    lgdt[gdt_descriptor]

    mov eax, cr0
    or al, 0x01
    mov cr0, eax

    jmp 0x08:protected_mode_entry

disk_error:
    mov si, err_msg
    call print_string
    jmp $

err_msg: .asciz "Disk read error!rn"

print_string:
    lodsb
    or al, al
    jz done
    mov ah, 0x0E
    int 0x10
    jmp print_string
done:
    ret

; GDT 表
gdt_start:
    ; NULL descriptor
    .word 0, 0
    .byte 0, 0, 0, 0

    ; 代码段 descriptor (base=0, limit=0xFFFFF, type=0x9A)
    .word 0xFFFF, 0x0000
    .byte 0x00, 0x9A, 0xCF, 0x00

    ; 数据段 descriptor (base=0, limit=0xFFFFF, type=0x92)
    .word 0xFFFF, 0x0000
    .byte 0x00, 0x92, 0xCF, 0x00

gdt_end:

gdt_descriptor:
    .word gdt_end - gdt_start - 1
    .long gdt_start

times 510 - ($ - $$) db 0
.word 0xAA55

; 后面放内核代码(链接时合并)

wandos 差距:wandos 没有自定义 Bootloader,目前通过 GRUB 的 Multiboot Header 加载。如果要实现独立的 Bootloader,需要像上面这样实现 16 位实模式的磁盘读取 + 保护模式切换 + GDT 设置。

6.2 GRUB 环境下的 Multiboot 内核

wandos 实际走的是 GRUB 路线(demos/multiboot/multiboot.S):

Asm
; wandos 使用的 Multiboot 入口(简化版)
.section .multiboot
.align 4
.long 0x1BADB002
.long 0x00010003
.long -(0x1BADB002 + 0x00010003)

.section .text
.global _start
.type _start, @function

_start:
    ; GRUB 已经把 CPU 设置在 32 位保护模式
    ; 设置栈指针
    movl $0x90000, %ebp
    movl %ebp, %esp

    ; 调用 kernel_main(ebx = multiboot_info*)
    pushl %ebx
    call kernel_main

    ; 永不返回
    jmp .

.size _start, . - _start

7. Linux 实践:亲手编译和运行 Multiboot 内核

实验:构建并运行最小内核

前提:需要 as(汇编器)、ld(链接器)、qemu-system-i386(虚拟机):

Bash
# Ubuntu/Debian
sudo apt install binutils qemu-system-x86

# macOS
brew install binutils qemu

步骤 1:准备源码

Bash
# 创建工作目录
mkdir -p ~/boot-demo
cd ~/boot-demo

# 写入 multiboot.S(入口点 + Multiboot header)
cat > multiboot.S << 'EOF'
.section .multiboot
.align 4
.long 0x1BADB002
.long 0x00010003
.long -(0x1BADB002 + 0x00010003)

.section .text
.global _start
.type _start, @function

_start:
    movw $0x10, %ax
    movw %ax, %ds
    movw %ax, %es
    movw %ax, %fs
    movw %ax, %gs
    movw %ax, %ss

    movl $0x90000, %ebp
    movl %ebp, %esp

    cli
    inb $0x92, %al
    orb $2, %al
    outb %al, $0x92

    pushl %ebx
    call kernel_main

    jmp .

.size _start, . - _start
EOF

# 写入 main.c(内核主函数)
cat > main.c << 'EOF'
#include <stdint.h>

typedef unsigned int uint32_t;
typedef unsigned char uint8_t;

#define MULTIBOOT_MAGIC 0x2BADB002

#pragma pack(push, 1)
struct multiboot_info {
    uint32_t flags;
    uint32_t mem_lower;
    uint32_t mem_upper;
    uint32_t boot_device;
    uint32_t cmdline;
    uint32_t mods_count;
    uint32_t mods_addr;
    uint32_t mmap_length;
    uint32_t mmap_addr;
};
#pragma pack(pop)

static inline void vga_putchar(int *x, int *y, char c, uint8_t attr) {
    volatile uint16_t *vga = (uint16_t *)0xB8000;
    int cols = 80;
    if (c == 'n') { *y = (*y + 1); *x = 0; }
    else {
        vga[*y * cols + *x] = (attr << 8) | c;
        *x = (*x + 1);
        if (*x >= cols) { *x = 0; *y = (*y + 1); }
    }
    if (*y >= 25) { *y = 24; }
}

void print_str(int *x, int *y, const char *s, uint8_t attr) {
    while (*s) vga_putchar(x, y, *s++, attr);
}

void kernel_main(unsigned long magic, struct multiboot_info *info) {
    int x = 0, y = 0;
    volatile uint16_t *vga = (volatile uint16_t *)0xB8000;
    for (int i = 0; i < 80 * 25; i++) vga[i] = (7 << 8) | ' ';

    print_str(&x, &y, "=== OS from scratch ===", 0x0F); y++; x = 0;
    print_str(&x, &y, "Magic: ", 0x07);
    print_str(&x, &y, magic == MULTIBOOT_MAGIC ? "OK" : "BAD", 0x0A);
    y++; x = 0;

    if (info->flags & 1) {
        print_str(&x, &y, "Low mem: ", 0x07);
        char buf[16]; int i = 0;
        uint32_t m = info->mem_lower;
        if (m == 0) { vga_putchar(&x, &y, '0', 0x0A); }
        else { while (m > 0) { buf[i++] = '0' + (m % 10); m /= 10; } while (i--) vga_putchar(&x, &y, buf[i], 0x0A); }
        print_str(&x, &y, " KB", 0x07);
        y++; x = 0;
    }

    print_str(&x, &y, "Bootloader ready!", 0x0A);
    while (1) __asm__ volatile ("hlt");
}
EOF

步骤 2:编译

Bash
# 编译汇编(32位)
as --32 multiboot.S -o multiboot.o

# 编译 C 代码(32位)
gcc -m32 -fno-pie -c main.c -o main.o

# 链接到 0x100000(内核标准加载地址)
ld -m elf_i386 -Ttext 0x100000 -o kernel.elf multiboot.o main.o

步骤 3:运行

Bash
# 直接用 QEMU 的 -kernel 选项(不需要 GRUB,不需要 ISO)
qemu-system-i386 -kernel kernel.elf -nographic

输出:

Bash
=== OS from scratch ===
Magic: OK
Low mem: 639 KB
Bootloader ready!

解释

  • -kernel kernel.elf:QEMU 直接加载 ELF 到 0x100000 并跳转执行(绕过了 GRUB)
  • kernel_main 接收到 GRUB 规范约定的参数:eax = magic,ebx = boot_info
  • 我们的内核打印了内存信息,说明 BIOS 已经探测到了 640KB 低端内存

步骤 4:构建 ISO(体验完整的 GRUB 引导流程)

Bash
# 创建 ISO 目录结构
mkdir -p iso/boot/grub
cp kernel.elf iso/boot/

# 写入 GRUB 配置文件
cat &gt; iso/boot/grub/menu.lst &lt;&lt; &#039;EOF&#039;
title OS from scratch
    kernel /boot/kernel.elf
EOF

# 用 grub-mkrescue 打包(需要 grub-pc-bin 包)
grub-mkrescue -o kernel.iso iso

# 用 ISO 启动(完整 GRUB 引导流程)
qemu-system-i386 -cdrom kernel.iso

8. 动手环节:实现你的 Bootloader

今天的目标:修改 wandos 的 demos/multiboot/multiboot.S,添加一个自定义的启动信息打印。

  1. 下载 wandos

    Bash
    git clone https://github.com/zhangfuwen/wandos.git
    cd wandos
  2. 找到对应模块

    Bash
    ls demos/multiboot/
    # 看到 multiboot.S(入口点)和 main.c(内核主函数)
  3. 实现作业

    • main.ckernel_main 里,打印 wandos 的版本信息
    • 读取 multiboot_info 里的 boot_device 字段,解析出启动盘信息
    • 修改 multiboot.S,在跳转到 kernel_main 之前打印一行”Loading wandos…”
  4. 提示

    • VGA 文本模式显存地址:0xB8000,格式是 (attr << 8) | char
    • boot_device 字段的低 8 位是第一个驱动器号,0x80 = 第一块硬盘
    • 字符串打印参考 kernel/core/ 下的串口驱动代码
  5. 提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request


9. 踩坑与注意事项

坑1:Multiboot Header 不在文件前 8192 字节内

GRUB 只搜索内核文件的前 8192 字节找 Multiboot Header。如果 Header 被链接器放到了后面,内核就无法被 GRUB 识别。解决方法:确保 Header 在 .multiboot section 内,且该 section 排在最前面(用链接脚本强制)。

坑2:栈地址设置错误

保护模式切换后,栈指针必须指向有效内存。如果把 ESP 设为 0,内核一调用函数就会崩溃。正确做法:在 _start 里立即设置 movl $0x90000, %esp(低端内存的最后 64KB)。

坑3:忘记关闭软盘马达导致启动卡死

某些 BIOS 实现会在 Bootloader 执行时保持软盘马达旋转,如果不及时关闭(发送 int 0x13, ah=0x0D),后续的磁盘操作可能失败。这是老机器上常见的卡死原因。


10. 总结

|| 知识点 | 关键结论 |
|——–|———|———|
| MBR 结构 | 512 字节 = 446 字节引导代码 + 4×16 字节分区表 + 0xAA55 | BIOS 靠 0xAA55 判断盘是否可启动 |
| 实模式 | 16 位地址,段×16+偏移的物理地址转换 | MBR 代码只能用 BIOS 中断访问硬件 |
| GRUB 多阶段 | Stage 1 → 1.5 → 2,支持文件系统 | 理解文件系统才能读 /boot 里的内核 |
| Multiboot Header | magic=0x1BADB002,内核告诉 GRUB 加载参数 | GRUB 加载到 0x100000,设置 eax/ebx 后跳转 |
| wandos 现状 | 无自定义 Bootloader,依赖 GRUB Multiboot | 要做独立 Bootloader 需实现 16 位实模式磁盘读写+GDT |


下篇预告(367):内存管理——Buddy System 伙伴系统。物理内存分配最经典的算法,从 2 的幂次阶开始,内核怎么把物理内存切成一块块分配出去。


关注公众号「AI不着急」,回复”资料”获取内核学习路线图。

仓库https://github.com/golang12306/os-kernel-from-scratch

最后修改: 2024年5月13日

作者

评论

发表评论

您的邮箱地址不会被公开。