从零写OS内核 | BIOS到内核的最后一跳——POST之后CPU在想什么
你按下电源键,屏幕亮了风扇转了,BIOS 自检完,GRUB 倒计时结束——然后呢?
内核是怎么真正开始跑的?CPU 此时此刻处于什么状态?GRUB 凭什么把自己的控制权交给了一个二进制的 .elf 文件?这一跳没跳好,后面什么都没得聊。
这就是今天要拆解的主题:从 BIOS/UEFI 把控制权交给操作系统内核的最后一跳,以及这背后 CPU 模式的切换、GRUB 的 Multiboot 协议、和一个内核是如何被正确地加载起来的。
1. 先搞清楚你在哪:实模式 vs 保护模式
写内核启动代码,最常踩的坑就是:地址到底用的是哪个?
x86 CPU 有两种基本运行模式:
实模式(Real Mode)—— CPU 刚上电时的默认状态。
┌─────────────────────────────────────────────────────┐
│ 实模式 (上电后到保护模式切换前) │
│ │
│ 地址计算:段基址 × 16 + 偏移量 │
│ 最大寻址:20 bits → 1MB (00000h ~ FFFFFh) │
│ 段寄存器:存放段基址(直接是物理地址的高 16 位) │
│ 寄存器宽度:16 位(AX, BX, CX, DX, SI, DI, BP, SP) │
│ 无权限级别:无,任何代码都可以做任何事 │
└─────────────────────────────────────────────────────┘保护模式(Protected Mode)—— 现代操作系统真正运行的地方。
┌─────────────────────────────────────────────────────┐
│ 保护模式 (现代 OS 真正运行的地方) │
│ │
│ 地址计算:段选择子 → 段描述符 → 线性地址 │
│ 最大寻址:32 bits → 4GB(PAE 开启后可更多) │
│ 段寄存器:存放段选择子(Selector),不再是地址 │
│ 寄存器宽度:32 位 │
│ 特权级:Ring 0(内核)~ Ring 3(用户态) │
│ 分页:可选开启,将线性地址映射到物理地址 │
└─────────────────────────────────────────────────────┘关键差异:实模式下地址是"所见即所得",段寄存器里直接放物理地址;保护模式下段寄存器放的是索引,要查 GDT(全局描述符表)才能找到真正的段基址。
为什么这个区别这么重要?因为 GRUB 把内核加载起来的时候,CPU 已经处于保护模式了。你在内核代码里写的每一个地址,背后都是一套查表机制,不再是简单的"段×16+偏移"。
2. POST 之后到 GRUB:固件层的交接
电脑按下电源到 GRUB 真正接管,这中间经历了什么?
电源按钮
↓
BSP (Boot Processor) 开始执行 Flash/ROM 中的固件代码
↓
POST (Power-On Self-Test) — 硬件自检
↓
固件枚举引导设备(磁盘、网络等)
↓
从磁盘第一个扇区(MBR)或 EFI 分区(ESP)读取引导程序
↓
控制权交给 Bootloader(GRUB / systemd-boot / Windows Boot Manager)实模式的两大约束:
- 只能访问 1MB 内存:地址线只有 20 根(A0-A19),超出 1MB 的地址会回绕(wrap around)
- 只有 16 位寄存器:没有足够的寄存器来寻址大内存
BIOS 固件在实模式下运行,它能做的事很有限:探测硬件、设置中断向量、往 0x0000~0xFFFF 这段低内存写数据、把磁盘第一个扇区加载到 0x7C00,然后 jmp 0x0000:0x7C00 跳过去执行。
MBR 就是这样被加载的——512 字节的磁盘第一个扇区,加载到物理地址 0x7C00,BIOS 跳过去执行它。MBR 里记录了分区表和活动分区信息,然后 MBR 再把控制权交给活动分区的引导扇区(或 GRUB Stage 1)。
3. GRUB 的三段加载:Stage 1 / Stage 1.5 / Stage 2
GRUB 不是一整块代码,它是一个分阶段的多级引导器。每一阶段都有明确职责:
┌──────────────────────────────────────────────────────────┐
│ Stage 1 (MBR 512字节) │
│ 位置:磁盘第一个扇区(MBR) │
│ 职责:找到 Stage 1.5 的扇区 │
│ 限制:512 字节,磁盘CHS寻址,不能识文件系统 │
└──────────────────────────────────────────────────────────┘
↓(Stage 1 读取活动分区第一个扇区)
┌──────────────────────────────────────────────────────────┐
│ Stage 1.5 (文件系统驱动) │
│ 位置:紧跟 MBR 之后的若干扇区,或文件系统特定位置 │
│ 职责:理解特定文件系统(ext2, fat, etc),读取 Stage 2 │
│ 限制:驱动代码嵌在 MBR 之后的空间里,大小受限 │
└──────────────────────────────────────────────────────────┘
↓(Stage 1.5 读取 Stage 2)
┌──────────────────────────────────────────────────────────┐
│ Stage 2 (GRUB 主程序) │
│ 位置:/boot/grub/ 目录下(通常是 ext2 分区) │
│ 职责:解析 menu.lst/grub.cfg,显示引导菜单, │
│ 加载内核镜像到内存,切换到保护模式 │
└──────────────────────────────────────────────────────────┘为什么分成三段?
MBR 只有 512 字节,塞不下文件系统驱动,只能用最原始的磁盘寻址方式;Stage 1.5 负责”理解文件系统”,有了它 Stage 2 才能放在正常的分区文件里而不需要固定在磁盘特定扇区。
GRUB 2(现在的版本)的关键变化:Stage 1.5 被拆成了多个模块化的”prefix”驱动,不再是一个固定大小的 blob,而是按需加载 ext2、xfs、fat 等文件系统驱动。
4. 保护模式切换:GRUB 帮内核做的事
Stage 2 加载完内核镜像之后,在跳转到内核入口之前,GRUB 要先完成 CPU 模式的切换——从实模式切换到保护模式。
这步是整个引导链里最核心的一跳,涉及以下几个关键动作:
4.1 建立 GDT(全局描述符表)
GDT(全局描述符表)结构
┌──────────┬──────────────┬────────────┬────────────┐
│ 选择子 │ 段基址 │ 段界限 │ 属性 │
├──────────┼──────────────┼────────────┼────────────┤
│ 0x00 │ (空描述符) │ — │ — │
│ 0x08 │ 0x00000000 │ 0xFFFFF │ 0xC09A │ ← 代码段(Ring 0)
│ 0x10 │ 0x00000000 │ 0xFFFFF │ 0xC092 │ ← 数据段(Ring 0)
│ 0x18 │ 0x00000000 │ 0xFFFFF │ 0xF09A │ ← 代码段(Ring 3)
│ 0x20 │ 0x00000000 │ 0xFFFFF │ 0xF092 │ ← 数据段(Ring 3)
└──────────┴──────────────┴────────────┴────────────┘
属性字段解释(0xC09A):
Bit 47: G (Granularity) = 1 → 段界限以 4KB 为单位
Bit 46: D/B = 1 → 32 位_operand
Bit 44: AVL → Available for OS
Bit 43-40: P/DPL/S → P=1(存在) DPL=00 S=1(非系统段)
Bit 39-38: Reserved
Bit 37: D → D bit
Bit 36: W → W bit
Bit 35: E → Expansion direction
Bit 34: AC → Accessed
Bit 33-32: Type=1010 → Code, Execute-Only, AccessedGRUB 的 GDT 建立了一个平坦模型(Flat Model):所有段基址都是 0x00000000,段界限都是 4GB(0xFFFFF × 4KB)。这样段选择子就不需要实际做地址偏移,直接等于线性地址。代码段和数据段各两套——Ring 0 给内核用,Ring 3 给用户程序用。
4.2 A20 地址线
实模式下只能访问 1MB 内存,因为第 21 根地址线(A20)被强制接地(grounded)。要访问 1MB 以外的空间,必须打开 A20。
打开 A20 的经典方法(端口 I/O):
inb $0x92, %al # 读端口 0x92(PS/2 控制器 A20 控制位)
orb $2, %al # Bit 1 = A20 enable
outb %al, $0x92 # 写回端口 0x92现在很多系统通过 BIOS 或 UEFI 自动开启 A20,但这行代码在 QEMU/GRUB 环境下是必要的防御性编程——有些 GRUB 版本不帮你开。
4.3 控制寄存器 CR0 切换
CR0 切换(保护模式开关):
# 设置 GDTR(指向 GDT)
lgdt gdt_descriptor
# 关闭中断
cli
# 打开 A20(如上)
# CR0 Bit 0 = PE (Protection Enable) → 开启保护模式
movl %cr0, %eax
orl $1, %eax
movl %eax, %cr0
# 远跳转,清空流水线,强制 CPU 用新的 CS 值加载段寄存器
ljmp $0x08, $flush
flush:
# 设置数据段寄存器(Ring 0 代码段选择子 0x08,数据段 0x10)
movw $0x10, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %gs
movw %ax, %ssljmp $0x08, $flush 这行特别重要:它是一个段间跳转,CPU 执行这行时会强制从 GDT 里重新加载 CS(代码段寄存器)。这一步之后,CPU 才真正进入保护模式——之前的 movl %eax, %cr0 只是把标志位置了,但 CPU 的流水线里还缓存着旧的实模式状态,必须用跳转指令来清洗。
4.4 跳转到内核入口
切换完成后,GRUB 把以下状态交给内核:
寄存器约定(GRUB → 内核):
┌──────────────────────────────────────────────────────────┐
│ eax = 0x2BADB002 (Multiboot Magic) │
│ ebx = multiboot_info 结构体物理地址 │
│ cs = 0x08 (Ring 0 代码段选择子) │
│ ds/es/fs/gs/ss = 0x10 (Ring 0 数据段选择子) │
│ esp = 内核栈顶(GRUB 分配,通常 0x90000 向下) │
│ eflags = 0x00000002 │
└──────────────────────────────────────────────────────────┘eax = 0x2BADB002 是 GRUB 和内核之间的握手信号:内核收到这个值,才知道自己是被 GRUB 加载的,而不是直接被 BIOS 或别的东西加载的。如果 eax != 0x2BADB002,内核应该立即 jmp $ 挂起,拒绝执行。
5. Multiboot Info:GRUB 传递给内核的数据包
GRUB 不只是把控制权交给内核,它还把机器的当前状态完整地传递给内核——这就是 multiboot_info 结构体:
┌──────────────────────────────────────────────────────────────┐
│ multiboot_info 结构(ebx 指向的物理地址) │
│ │
│ flags — 哪些字段有效(位域) │
│ mem_lower — 低端内存大小(KB),传统为 640KB │
│ mem_upper — 高端内存大小(KB),通常是 262144KB(256MB)│
│ boot_device — 启动设备编码 │
│ cmdline — 内核命令行字符串指针 │
│ mods_count — 加载的模块数量 │
│ mods_addr — 模块信息数组的地址 │
│ mmap_length — 内存映射表总长度 │
│ mmap_addr — 内存映射表地址(e820 格式) │
│ drives_length — 驱动器信息长度 │
│ drives_addr — 驱动器信息地址 │
│ ... │
└──────────────────────────────────────────────────────────────┘mmap_addr 指向的内存映射(e820)是最重要的字段之一——它记录了机器上每一段物理内存的范围和类型(可用/保留/ACPI 可回收等):
内存映射条目(e820 格式)格式:
┌────────────────────────────────────────────┐
│ size (uint32_t) — 本条目大小 │
│ addr (uint64_t) — 起始物理地址 │
│ len (uint64_t) — 长度(字节) │
│ type (uint32_t) — 1=可用 2=保留 3=ACPI.. │
└────────────────────────────────────────────┘内核拿到 e820 内存映射后,才能正确建立物理内存管理器——哪些地址是可用内存,哪些是被硬件保留的。
6. Linux 实践:亲手跑一个 Multiboot 内核
光说不练假把式。我们用 os-kernel-from-scratch 仓库里的 multiboot demo,动手编译并运行一个真实的最小化 Multiboot 内核。
6.1 编译
# 进入 demo 目录
cd /home/admin/wechat-articles/os-kernel-from-scratch/demos/multiboot
# 编译汇编部分(Multiboot Header + 入口点)
as --32 multiboot.S -o multiboot.o
# 编译 C 部分(kernel_main)
gcc -m32 -fno-pie -c main.c -o main.o
# 链接为 ELF32 执行文件(入口点 0x100000 = 1MB)
ld -m elf_i386 -Ttext 0x100000 -o kernel.elf multiboot.o main.o
# 确认文件格式(应该是 ELF)
file kernel.elf
# kernel.elf: ELF 32-bit LSB executable, Intel 80386, statically linked6.2 用 QEMU 运行(不需要 GRUB,直接用 -kernel)
# 直接用 QEMU 的 -kernel 选项加载(QEMU 会自动处理 Multiboot)
qemu-system-i386 -kernel kernel.elf -nographic
# 或者打包成 ISO 用 GRUB 引导
grub-mkrescue -o kernel.iso kernel.elf
qemu-system-i386 -cdrom kernel.iso6.3 预期输出
=== Multiboot Kernel Demo ===
Magic: 0x2BADB002 (OK)
Loader: GRUB 2.x
Cmdline: (内核命令行)
Low mem: 639 KB High: 262144 KB
Memory map:
[FREE] 0x0 - 0x9FC00 (0 MB)
[FREE] 0x100000 - 0x3FF00000 (1023 MB)
Modules: 0
Halted.观察:Magic 显示 0x2BADB002 (OK),说明 GRUB 确实把控制权交给我们了。Memory map 显示了 640KB 以下和 1MB 以上两段可用内存——这正是 x86 的经典内存布局。
7. 从零实现:wandos 怎么写这部分
⚠️ wandos 启动说明:wandos 目前没有完整的 GRUB/Multiboot 加载实现,以下展示的是 wandos 现有 boot 代码的能力边界,以及要实现完整 Multiboot 握手还需要补什么。
wandos 当前的 boot 链路:
/tmp/wandos/
├── boot.asm (57行) — BSP 入口,Multiboot header,调用 kernel_main
├── ap_boot.asm (99行) — AP 启动,含 16 位实模式代码和 GDT/保护模式切换
└── kernel/core/ — 内核主函数boot.asm 里的 Multiboot Header(已实现):
; boot.asm 关键片段
.section .multiboot
.align 4
.long 0x1BADB002 ; magic
.long 0x00010003 ; flags
.long -(0x1BADB002 + 0x00010003) ; checksum
.section .text
.global _start
.type _start, @function
_start:
; GRUB 把 magic 放 eax,boot_info 放 ebx
; 这里直接用 ebx 调用 kernel_main
pushl %ebx
call kernel_main
jmp .要完整实现 GRUB 握手,还需要:
- GDT 建立(wandos 的
ap_boot.asm中有 GDT 代码,但boot.asm直接从 GRUB 的 GDT 继承了平坦模型) - 保护模式切换(wandos 的 AP 启动代码里有
ljmp $0x08, $label和段寄存器设置,BP 启动直接依赖 GRUB 设置好的 GDT) - multiboot_info 解析(wandos 的 kernel_main 目前没有解析 ebx 中的 multiboot_info 结构,没有读取 e820 内存映射表)
wandos 当前的实现策略是依赖 GRUB 建立好的保护模式环境(GRUB 已经把 GDT、段寄存器、CR0 都设置好了),只要求 GRUB 传入 0x2BADB002 的 magic,然后直接用 ebx 调用 kernel_main。这对于学习目的足够,但要实现一个”不依赖 GRUB,自己能做完整的启动”的 OS,还需要把 AP 启动代码里的 GDT/保护模式切换逻辑移植到 BP 启动路径。
8. 动手环节:实现你的版本
今天的目标:修改 os-kernel-from-scratch/demos/multiboot/main.c,在屏幕上显示更多信息,并添加一个新的 GRUB 模块加载功能。
任务 1:解析并显示所有 e820 内存条目
当前代码只显示前 4 个条目。修改 kernel_main 函数,让它遍历并打印所有 e820 内存条目(不只是前 4 个),格式为:
[类型] 起始地址 - 结束地址 (大小 MB)类型用文字标注:AVAILABLE / RESERVED / ACPI_RECLAIMABLE / NVS
任务 2:打印 GRUB 传递的模块信息
如果 flags & MB_FLAG_MODS 为真,遍历 mods_addr 指向的模块数组,在屏幕上打印每个模块的起始地址和结束地址。
验收标准
- 编译无警告(
-Wall -Wextra -m32) - QEMU 运行输出完整内存映射(超过 4 个条目)
- 如果有模块,模块信息正确显示
提示:multiboot_info 结构的 mods_addr 指向一个模块数组,数组元素格式为:
struct module {
uint32_t mod_start;
uint32_t mod_end;
uint32_t string; // 命令行字符串
uint32_t reserved;
};9. 踩坑与注意事项
坑 1:Multiboot Header 不在文件前 8192 字节内
GRUB 只会搜索内核文件的前 8192 字节来找 Multiboot Header。如果你的内核镜像很大(超过 8KB),记得确保 .multiboot section 编译后在文件靠前位置。用 objdump -h kernel.elf 确认 section 排列,或在链接脚本里把 .multiboot 放在最前面。
坑 2:A20 地址线没开导致内存回绕
在实模式切换到保护模式时,如果 A20 没有被打开,访问 0x100000(1MB)以上的地址会回绕到低 1MB 区。在 QEMU + GRUB 环境里 GRUB 通常会自动开 A20,但在一些虚拟机或老硬件上会挂。防御性做法:在入口代码里显式开 A20。
坑 3:链接地址和加载地址不一致
ld -Ttext 0x100000 指定的是链接地址(link address)——代码里所有地址按这个地址生成。但 GRUB 实际把内核加载到的地址(load address)可能不同。如果不一致,访问全局变量或函数指针会错位。解决方案:确保链接地址 == 加载地址,或使用 PIC(位置无关代码)。
坑 4:GDT 选择子值写错了
代码段选择子通常是 0x08(GDT 第 1 个描述符),数据段是 0x10(第 2 个描述符)。但如果你的 GDT 顺序不同,这些值要相应调整。选错了选择子会导致保护异常(#GP)。
写在最后
BIOS/UEFI 到内核的最后一跳,本质上是三次交接:
- 固件 → Bootloader:BIOS/UEFI 把控制权交给 GRUB,CPU 还在实模式
- Bootloader → 内核:GRUB 完成保护模式切换,把
eax=0x2BADB002和ebx=boot_info交给内核 - 内核接过完整机器状态:从 multiboot_info 里读取内存布局、启动设备、命令行参数
理解了这三次交接,你才能真正读懂 GRUB 的 multiboot 协议,才能写出一个”被别人正确加载、也能正确加载别人”的内核。
下篇预告:物理内存管理——伙伴系统(Buddy System),内核最核心的内存分配基础设施。
相关阅读
- Linux Kernel Source:
arch/x86/boot/compressed/misc.c(内核解压) - Multiboot Specification: https://www.gnu.org/software/grub/manual/multiboot/
- GRUB 2 源码:
grub-core/boot/linux.c - 本文 Demo: https://github.com/golang12306/os-kernel-from-scratch (demos/multiboot/)
- https://github.com/zhangfuwen/wandos — Linux 内核教程
- 下一篇:《从零写OS内核 | 物理内存管理——Buddy System 伙伴系统》
动手环节
想深入理解本文内容?动手实践是最好的方式:
今天的目标:下载 wandos 代码仓库,实现文中的 feature,对比和 Linux 原版的差距。
-
下载 wandos:
git clone https://github.com/zhangfuwen/wandos.git cd wandos -
找到对应模块:根据本文内容,找到
boot.asm和ap_boot.asm里的启动代码 -
实现作业:根据文中”动手环节”章节的要求,完成代码编写
-
提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request
提示:wandos 采用 C++ + x86 汇编实现,参考 kernel/ 下的现有代码风格,注释清楚再提交。
评论