从零写OS内核 | 极简Bootloader——从磁盘第一个扇区到内核入口
金句:电脑开机后,CPU 从 0xFFFF0 执行的第一条指令,既不是 Windows,也不是 Linux——而是一段只有 446 字节的启动代码。它没有任何操作系统功能,唯一的任务就是:找到下一个可以被执行的程序,把它加载到内存,然后跳过去。
1. 问题:电脑开机后,谁是第一个跑的?
你按下电源键,CPU reset,寄存器全部清零。但问题是:内存里什么都没有,CPU 该执行谁?
这时硬件提供了一个契约:CPU 上电后,CS:IP 被强制设置为 0xF000:0xFFF0(x86 架构),这个地址映射到主板上的一块 ROM——BIOS ROM(现在叫 UEFI Flash)。ROM 里的代码做两件事:
- POST(Power-On Self-Test):检测内存、显卡、键盘等基本硬件
- 按顺序遍历启动设备(硬盘、U盘、光盘……),找到第一个可以启动的
找到之后,BIOS 读取该设备的第一个扇区(512 字节)到内存 0x7C00,然后跳过去执行。
这 512 字节,就是 Bootloader 的起点。
2. MBR:磁盘第一个扇区里藏了什么
硬盘的第一个扇区叫 MBR(Master Boot Record),它不是操作系统的代码,而是启动链的第一环。
MBR 的结构是固定的:
┌─────────────────────────────────────────────────────┐
│ 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 字节,能力有限。它的核心逻辑是:
- 从分区表里找到活动分区(flag = 0x80)
- 读取该分区的第一个扇区到内存(比如 0x7C00)
- 验证 0xAA55 标记
- 跳过去执行
典型的 MBR 引导代码(16 位实模式):
; 简化版 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:告诉汇编器,这段代码会被加载到0x7C00int 0x13:BIOS 提供的磁盘读写服务(16位实模式下唯一访问磁盘的方式)times 510 - ($ - $$):用 0 填充到第 510 字节dw 0xAA55:写入结束标记,BIOS 靠这个识别可启动盘
坑1:实模式地址 vs 保护模式地址
MBR 代码跑在 16 位实模式,地址是 段:偏移 形式。0x0000:0x7C00 和 0x07C0:0x0000 指向同一个物理地址 0x7C00,但写法不同。实模式地址转换规则:物理地址 = 段 × 16 + 偏移。代码里要小心这种混淆。
4. GRUB:更复杂的 Bootloader
MBR 代码只有 446 字节,功能太弱——它只能读取固定扇区、跳过去,没有文件系统的概念。
实际工作中,PC 上的 Linux 用的是 GRUB(Grand Unified Bootloader)。GRUB 是一套多阶段的引导器:
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:
; 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),需要实现:
; 最简 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):
; 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, . - _start7. Linux 实践:亲手编译和运行 Multiboot 内核
实验:构建并运行最小内核
前提:需要 as(汇编器)、ld(链接器)、qemu-system-i386(虚拟机):
# Ubuntu/Debian
sudo apt install binutils qemu-system-x86
# macOS
brew install binutils qemu步骤 1:准备源码
# 创建工作目录
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:编译
# 编译汇编(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:运行
# 直接用 QEMU 的 -kernel 选项(不需要 GRUB,不需要 ISO)
qemu-system-i386 -kernel kernel.elf -nographic输出:
=== 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 引导流程)
# 创建 ISO 目录结构
mkdir -p iso/boot/grub
cp kernel.elf iso/boot/
# 写入 GRUB 配置文件
cat > iso/boot/grub/menu.lst << 'EOF'
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.iso8. 动手环节:实现你的 Bootloader
今天的目标:修改 wandos 的 demos/multiboot/multiboot.S,添加一个自定义的启动信息打印。
-
下载 wandos:
git clone https://github.com/zhangfuwen/wandos.git cd wandos -
找到对应模块:
ls demos/multiboot/ # 看到 multiboot.S(入口点)和 main.c(内核主函数) -
实现作业:
- 在
main.c的kernel_main里,打印 wandos 的版本信息 - 读取 multiboot_info 里的 boot_device 字段,解析出启动盘信息
- 修改
multiboot.S,在跳转到 kernel_main 之前打印一行”Loading wandos…”
- 在
-
提示:
- VGA 文本模式显存地址:
0xB8000,格式是(attr << 8) | char boot_device字段的低 8 位是第一个驱动器号,0x80= 第一块硬盘- 字符串打印参考
kernel/core/下的串口驱动代码
- VGA 文本模式显存地址:
-
提交作业: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不着急」,回复”资料”获取内核学习路线图。
评论