从零写OS内核 | BIOS到内核的最后一跳——POST之后CPU在想什么

你按下电源键,屏幕亮了风扇转了,BIOS 自检完,GRUB 倒计时结束——然后呢?

内核是怎么真正开始跑的?CPU 此时此刻处于什么状态?GRUB 凭什么把自己的控制权交给了一个二进制的 .elf 文件?这一跳没跳好,后面什么都没得聊。

这就是今天要拆解的主题:从 BIOS/UEFI 把控制权交给操作系统内核的最后一跳,以及这背后 CPU 模式的切换、GRUB 的 Multiboot 协议、和一个内核是如何被正确地加载起来的。


1. 先搞清楚你在哪:实模式 vs 保护模式

写内核启动代码,最常踩的坑就是:地址到底用的是哪个?

x86 CPU 有两种基本运行模式:

实模式(Real Mode)—— CPU 刚上电时的默认状态。

Bash
┌─────────────────────────────────────────────────────┐
│  实模式 (上电后到保护模式切换前)                      │
│                                                      │
│  地址计算:段基址 × 16 + 偏移量                       │
│  最大寻址:20 bits → 1MB (00000h ~ FFFFFh)           │
│  段寄存器:存放段基址(直接是物理地址的高 16 位)      │
│  寄存器宽度:16 位(AX, BX, CX, DX, SI, DI, BP, SP) │
│  无权限级别:无,任何代码都可以做任何事                │
└─────────────────────────────────────────────────────┘

保护模式(Protected Mode)—— 现代操作系统真正运行的地方。

Bash
┌─────────────────────────────────────────────────────┐
│  保护模式 (现代 OS 真正运行的地方)                    │
│                                                      │
│  地址计算:段选择子 → 段描述符 → 线性地址              │
│  最大寻址:32 bits → 4GB(PAE 开启后可更多)          │
│  段寄存器:存放段选择子(Selector),不再是地址        │
│  寄存器宽度:32 位                                    │
│  特权级:Ring 0(内核)~ Ring 3(用户态)             │
│  分页:可选开启,将线性地址映射到物理地址              │
└─────────────────────────────────────────────────────┘

关键差异:实模式下地址是"所见即所得",段寄存器里直接放物理地址;保护模式下段寄存器放的是索引,要查 GDT(全局描述符表)才能找到真正的段基址。

为什么这个区别这么重要?因为 GRUB 把内核加载起来的时候,CPU 已经处于保护模式了。你在内核代码里写的每一个地址,背后都是一套查表机制,不再是简单的"段×16+偏移"。


2. POST 之后到 GRUB:固件层的交接

电脑按下电源到 GRUB 真正接管,这中间经历了什么?

Bash
电源按钮
   ↓
BSP (Boot Processor) 开始执行 Flash/ROM 中的固件代码
   ↓
POST (Power-On Self-Test) — 硬件自检
   ↓
固件枚举引导设备(磁盘、网络等)
   ↓
从磁盘第一个扇区(MBR)或 EFI 分区(ESP)读取引导程序
   ↓
控制权交给 Bootloader(GRUB / systemd-boot / Windows Boot Manager)

实模式的两大约束

  1. 只能访问 1MB 内存:地址线只有 20 根(A0-A19),超出 1MB 的地址会回绕(wrap around)
  2. 只有 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 不是一整块代码,它是一个分阶段的多级引导器。每一阶段都有明确职责:

Bash
┌──────────────────────────────────────────────────────────┐
│  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(全局描述符表)

Bash
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 = 132 位_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, Accessed

GRUB 的 GDT 建立了一个平坦模型(Flat Model):所有段基址都是 0x00000000,段界限都是 4GB(0xFFFFF × 4KB)。这样段选择子就不需要实际做地址偏移,直接等于线性地址。代码段和数据段各两套——Ring 0 给内核用,Ring 3 给用户程序用。

4.2 A20 地址线

实模式下只能访问 1MB 内存,因为第 21 根地址线(A20)被强制接地(grounded)。要访问 1MB 以外的空间,必须打开 A20。

Bash
打开 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 切换

Bash
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, %ss

ljmp $0x08, $flush 这行特别重要:它是一个段间跳转,CPU 执行这行时会强制从 GDT 里重新加载 CS(代码段寄存器)。这一步之后,CPU 才真正进入保护模式——之前的 movl %eax, %cr0 只是把标志位置了,但 CPU 的流水线里还缓存着旧的实模式状态,必须用跳转指令来清洗。

4.4 跳转到内核入口

切换完成后,GRUB 把以下状态交给内核:

Bash
寄存器约定(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 结构体:

Bash
┌──────────────────────────────────────────────────────────────┐
│  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 可回收等):

Bash
内存映射条目(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 编译

Bash
# 进入 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 linked

6.2 用 QEMU 运行(不需要 GRUB,直接用 -kernel)

Bash
# 直接用 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.iso

6.3 预期输出

Bash
=== 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 链路:

Bash
/tmp/wandos/
├── boot.asm (57行)   — BSP 入口,Multiboot header,调用 kernel_main
├── ap_boot.asm (99行) — AP 启动,含 16 位实模式代码和 GDT/保护模式切换
└── kernel/core/      — 内核主函数

boot.asm 里的 Multiboot Header(已实现)

Asm
; 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 握手,还需要

  1. GDT 建立(wandos 的 ap_boot.asm 中有 GDT 代码,但 boot.asm 直接从 GRUB 的 GDT 继承了平坦模型)
  2. 保护模式切换(wandos 的 AP 启动代码里有 ljmp $0x08, $label 和段寄存器设置,BP 启动直接依赖 GRUB 设置好的 GDT)
  3. 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 个),格式为:

Bash
[类型] 起始地址 - 结束地址 (大小 MB)

类型用文字标注:AVAILABLE / RESERVED / ACPI_RECLAIMABLE / NVS

任务 2:打印 GRUB 传递的模块信息

如果 flags & MB_FLAG_MODS 为真,遍历 mods_addr 指向的模块数组,在屏幕上打印每个模块的起始地址和结束地址。

验收标准

  • 编译无警告(-Wall -Wextra -m32
  • QEMU 运行输出完整内存映射(超过 4 个条目)
  • 如果有模块,模块信息正确显示

提示multiboot_info 结构的 mods_addr 指向一个模块数组,数组元素格式为:

C
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 到内核的最后一跳,本质上是三次交接

  1. 固件 → Bootloader:BIOS/UEFI 把控制权交给 GRUB,CPU 还在实模式
  2. Bootloader → 内核:GRUB 完成保护模式切换,把 eax=0x2BADB002ebx=boot_info 交给内核
  3. 内核接过完整机器状态:从 multiboot_info 里读取内存布局、启动设备、命令行参数

理解了这三次交接,你才能真正读懂 GRUB 的 multiboot 协议,才能写出一个”被别人正确加载、也能正确加载别人”的内核。

下篇预告:物理内存管理——伙伴系统(Buddy System),内核最核心的内存分配基础设施。


相关阅读


动手环节

想深入理解本文内容?动手实践是最好的方式:

今天的目标:下载 wandos 代码仓库,实现文中的 feature,对比和 Linux 原版的差距。

  1. 下载 wandos

    Bash
    git clone https://github.com/zhangfuwen/wandos.git
    cd wandos
  2. 找到对应模块:根据本文内容,找到 boot.asmap_boot.asm 里的启动代码

  3. 实现作业:根据文中”动手环节”章节的要求,完成代码编写

  4. 提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request

提示:wandos 采用 C++ + x86 汇编实现,参考 kernel/ 下的现有代码风格,注释清楚再提交。


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

最后修改: 2024年12月11日

作者

评论

发表评论

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