1. 引言:为什么我们要自己写一个内核

现代操作系统庞大而复杂,Linux 内核源码已经超过三千万行,任何一个初学者直接扎进去都很容易迷失在无数宏定义、条件编译和架构适配代码中。但如果把问题缩小到一个最小可运行的内核,很多看似神秘的概念就会变得清晰起来:CPU 如何从开机地址开始执行第一条指令,实模式和保护模式到底有什么区别,C++ 这种看似“高级”的语言如何在不依赖操作系统的情况下运行,中断到底是如何触达我们写的处理函数的。

Cinux 就是一个为了回答这些问题而设计的最小内核项目。它的目标不是替代 Linux 或成为一个工业级操作系统,而是用最直接、最少依赖的方式,完整走通“按下电源键 → BIOS 自检 → Bootloader 加载内核 → 进入保护模式 → 跳转到 C++ 入口 → 运行我们的 C++ 代码”这一条链路。这篇文章会以这条链路为主线,逐层拆解每一个环节,并给出可以实际编译运行到 QEMU 里的代码。读完这篇文章,你至少能获得三样东西:一个可以自己编译启动的最小内核,一套关于 x86 启动流程的完整心象地图,以及后续继续扩展内存管理、进程调度、文件系统时的稳固地基。

在正式开始之前,先说清楚这篇文章的边界。我们只关注 x86 32 位保护模式,不涉及 64 位长模式,因为 32 位保护模式的启动路径更短、依赖更少,更适合第一次手写内核的人完整走通。我们使用 BIOS 启动而不是 UEFI,理由相同:BIOS 的启动路径几乎是“裸”的,从 0x7C00 开始执行引导扇区,没有复杂的固件接口、协议和驱动模型横在中间。我们选择 C++ 而不是纯 C,是因为内核开发中 C++ 的 RAII、模板、namespace 等特性在构建复杂数据结构时非常有用;同时,为了让 C++ 能在裸机环境运行,我们必须亲手解决全局对象构造、纯虚函数、异常禁用、内存分配等一系列运行时支撑问题,这本身就是一次极好的底层训练。

本文假设你具备基本的 C/C++ 语法知识,了解一点汇编更好,但完全不会汇编也能跟着走,因为每一行汇编我都会逐条解释。代码仓库可以在本地自行组织,按照文中的文件结构一步步搭建即可。

2. 整体架构:从按下电源到执行 main 函数

在写任何一行代码之前,我们先把整个启动流程在脑子里过一遍。这个过程可以分成七个阶段,每个阶段都对应一段可以独立验证的代码。

第一阶段是 BIOS 加电自检。x86 机器开机后,CPU 处于实模式,指令指针指向一个固定的复位向量地址。对 32 位 x86 来说,这个地址经过段基址和偏移的换算后落到物理内存的 0xFFFF0。这个位置存放的是 BIOS 固件的入口,BIOS 从那里开始执行,完成硬件自检、初始化基本外设、建立中断向量表等工作。

第二阶段是引导扇区加载。BIOS 在完成自检后,会根据启动顺序去扫描磁盘。对于传统 BIOS 启动,它会检查磁盘的第一个扇区,也就是 0 号柱面、0 号磁头、1 号扇区,看最后两个字节是不是 0x55 和 0xAA。如果是,BIOS 就把这一个 512 字节的扇区加载到物理内存 0x7C00,然后跳转过去执行。这个 512 字节就是我们的主引导记录,也是整条启动链中唯一由 BIOS 直接加载的代码。

第三阶段是 Bootloader 加载内核。512 字节太小,放不下任何像样的内核,所以主引导记录的任务非常明确:初始化段寄存器、设置栈、从磁盘读取更多扇区到内存、把内核映像整体加载到预定地址。这里可以把 Bootloader 做成两段式:第一段就是 512 字节的引导扇区,只负责加载第二段;第二段是真正的 Bootloader 主体,负责从磁盘读取 ELF 格式的内核文件,解析程序头,把各个段放到链接脚本规定的虚拟地址。

第四阶段是切换到保护模式。以内核加载到内存为分界点,之前的代码运行在 16 位实模式,只能访问 1MB 内存,没有任何内存保护。要从 C++ 内核的角度思考问题,必须切换到 32 位保护模式。切换的准备工作包括:关闭中断、加载全局描述符表、把 CR0 寄存器的保护模式使能位置 1、执行一个远跳转刷新指令流水线、重新加载段选择子。

第五阶段是跳转到内核入口。进入保护模式后,我们仍然身处汇编代码,但已经可以用 32 位地址访问完整内存空间。Bootloader 把栈切换到高位地址,然后跳转到链接脚本中定义的内核入口符号。这个符号通常由汇编文件或 C 运行时脚手架提供。

第六阶段是 C++ 运行时初始化与全局构造。如果内核入口直接是 C++ 函数,或者入口汇编调用了一个 C++ 函数,那么在这个函数真正获得控制权之前,必须先把全局对象构造出来。GCC 编译器会把所有需要运行时构造的全局对象登记到 .init_array 段,Bootloader 或内核启动汇编需要调用这个段里的构造数组,才能保证全局对象可用。同时还要提供纯虚函数桩、静态初始化相关函数、必要时提供 new/delete 等运行时支持。

第七阶段是 C++ 主逻辑运行。到这一步,我们已经有一个可用的 C++ 执行环境:保护模式已经启用,GDT 就位,栈已经设置好,全局对象已经构造。接下来就可以在 C++ 代码里初始化 IDT 和中断处理、实现屏幕输出、设置键盘输入、建立内存管理等,让内核真正“活”起来。

把这七个阶段画成表格,就是下面这张总览表。

阶段执行环境关键动作主要产物
BIOS 自检实模式 16 位硬件初始化、中断向量建立可用的 BIOS 中断服务
引导扇区加载实模式 16 位BIOS 读取 0 柱 0 头 1 扇区到 0x7C00512 字节 MBR
Bootloader 工作实模式 16 位读盘、解析 ELF、搬运段内核映像加载到目标地址
切换保护模式实模式到保护模式加载 GDT、置 CR0.PE、远跳转32 位寻址环境
跳转内核入口保护模式 32 位设置栈、跳转入口符号内核开始执行
C++ 运行时初始化保护模式 32 位执行 .init_array、处理纯虚函数桩全局对象可用的 C++ 环境
内核主逻辑保护模式 32 位IDT、中断、输出、内存管理具备基本交互能力的内核

这张总览表值得反复回看。后面每一节都对应其中某一阶段,所有代码的编排顺序也严格遵循这张表。接下来我们开始搭建环境。

3. 开发环境准备:交叉编译工具链与 QEMU

内核开发的第一步不是写 bootloader,而是准备一个能够生成裸机二进制文件的工具链。我们最终生成的内核不能依赖任何宿主操作系统的库,不能使用动态链接,不能调用 glibc 的函数。这意味着必须使用交叉编译器,让它只生成针对裸机目标平台的代码。

推荐使用 GCC 交叉编译工具链,目标平台选择 i686-elf,也就是 32 位 x86 裸机目标。这个目标平台不链接任何系统库,GCC 只会使用我们自己提供的启动文件和链接器脚本。在 Ubuntu/Debian 系系统上,可以安装 i686-elf-gcc 和 i686-elf-binutils,也可以用 GCC 内建的方式自行编译交叉工具链。为了减少环境带来的差异,后文统一使用 i686-elf-gcc、i686-elf-g++ 和 i686-elf-ld 这几个命令。

除了编译器,还需要两个辅助工具:NASM 用于编写 16 位和 32 位汇编,QEMU 用于模拟 x86 机器并运行我们编译出的磁盘镜像。NASM 的语法清晰、文档完善,特别适合写引导扇区这种需要精确控制二进制输出的场景。QEMU 支持直接从磁盘镜像启动,并提供了强大的调试能力,比如暂停执行、查看寄存器和内存,这对内核开发来说是救命级别的功能。

安装命令在不同发行版上略有差异,下面给出 Ubuntu 上的示例。如果使用 Arch、Fedora 或其他发行版,把包管理器换成对应的即可。

sudo apt update
sudo apt install nasm qemu-system-x86 build-essential
# 交叉编译器可以直接使用 OSDev 社区维护的预编译包,
# 或从源码构建 i686-elf-gcc,本文不展开工具链构建过程。

整个项目的目录结构如下。这个结构刻意保持最小化:boot 目录放汇编源码,kernel 目录放 C++ 源码,include 目录放头文件,build 目录放中间产物和最终镜像。

cinux/
├── boot/
│   ├── boot.asm        # 引导扇区 + Bootloader 主体
│   └── entry.asm       # 进入内核前的汇编入口
├── kernel/
│   ├── kernel.cpp      # C++ 主逻辑
│   ├── gdt.cpp         # GDT 初始化
│   ├── idt.cpp         # IDT 与中断处理
│   ├── terminal.cpp    # 终端输出
│   └── memory.cpp      # 内存管理
├── include/
│   ├── gdt.h
│   ├── idt.h
│   ├── terminal.h
│   └── memory.h
├── linker.ld           # 链接器脚本
├── Makefile
└── build/

接下来我们为整个项目建立一个统一的 Makefile。它负责编译汇编文件、编译 C++ 文件、链接出 ELF 内核,再把 ELF 中的可加载段提取出来放入磁盘镜像,最后生成一个可以用 QEMU 启动的完整磁盘映像。Makefile 的内容在示例代码块中给出,后续编译时直接执行 make run 就能一键启动。

ASM = nasm
CC  = i686-elf-g++
LD  = i686-elf-ld
QEMU = qemu-system-i386
CXXFLAGS = -ffreestanding -fno-exceptions -fno-rtti -nostdlib -Wall -Wextra -O2 -Iinclude
LDFLAGS  = -T linker.ld -nostdlib
BOOT_BIN  = build/boot.bin
KERNEL_ELF = build/kernel.elf
OS_IMAGE  = build/cinux.img
all: $(OS_IMAGE)
$(BOOT_BIN): boot/boot.asm boot/entry.asm
$(ASM) -f bin boot/boot.asm -o $(BOOT_BIN)
$(KERNEL_ELF): kernel/*.cpp boot/entry.o
$(CC) $(CXXFLAGS) -c kernel/kernel.cpp -o build/kernel.o
$(CC) $(CXXFLAGS) -c kernel/gdt.cpp -o build/gdt.o
$(CC) $(CXXFLAGS) -c kernel/idt.cpp -o build/idt.o
$(CC) $(CXXFLAGS) -c kernel/terminal.cpp -o build/terminal.o
$(CC) $(CXXFLAGS) -c kernel/memory.cpp -o build/memory.o
$(LD) $(LDFLAGS) build/kernel.o build/gdt.o build/idt.o build/terminal.o build/memory.o boot/entry.o -o $(KERNEL_ELF)
$(OS_IMAGE): $(BOOT_BIN) $(KERNEL_ELF)
dd if=/dev/zero of=$(OS_IMAGE) bs=512 count=2880
dd if=$(BOOT_BIN) of=$(OS_IMAGE) conv=notrunc
dd if=$(KERNEL_ELF) of=$(OS_IMAGE) bs=512 seek=1 conv=notrunc
run: $(OS_IMAGE)
$(QEMU) -fda $(OS_IMAGE)
clean:
rm -rf build/*

这个 Makefile 有一个需要注意的设计:我们把 bootloader 放在磁盘镜像的 0 号扇区,把 ELF 内核原始字节紧接着放在后面的扇区。这样 Bootloader 在运行时只需要从 1 号扇区开始顺序读取固定数量的扇区,把 ELF 文件读进内存,再解析程序头即可。这种方式省去了实现文件系统的复杂度,非常适合第一个最小内核。

确认环境和目录结构备齐后,我们就可以正式开始写引导扇区了。

4. 主引导记录:BIOS 启动的第一段代码

BIOS 把引导扇区加载到 0x7C00 之后,会跳转到这个地址执行。此时 CPU 处于实模式,默认的 CS:IP 可以有不同的组合指向 0x7C00,比如 0x0000:0x7C00 或 0x07C0:0x0000。为了让后续代码不依赖 BIOS 给出的具体 CS 值,引导扇区开头第一件事就是重新初始化段寄存器,并建立栈。

还需要注意一个重要事实:BIOS 在跳转到引导扇区之前,会把引导磁盘的驱动器号放在 DL 寄存器里。0x00 表示第一块软盘,0x80 表示第一块硬盘。我们的 Bootloader 在读取磁盘时需要使用这个值,所以进入引导扇区后应当立刻把 DL 保存起来,或者把它压栈保留。下面这段汇编就是完整的引导扇区第一版,它只做三件事:保存启动盘号、设置段和栈、打印一条消息。

[BITS 16]
[ORG 0x7C00]
start:
cli                         ; 关中断,稳定启动环境
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00              ; 栈向下生长,从 0x7C00 开始,不会覆盖引导扇区之前的区域
sti
mov [BOOT_DRIVE], dl        ; 保存 BIOS 提供的启动驱动器号
mov si, MSG_REAL_MODE
call print_string
; 暂时停机循环,待后续扩展读取内核逻辑
hang:
hlt
jmp hang
print_string:
lodsb
or al, al
jz .done
mov ah, 0x0E
int 0x10
jmp print_string
.done:
ret
BOOT_DRIVE db 0
MSG_REAL_MODE db 'Cinux bootloader: real mode OK', 13, 10, 0
times 510 - ($ - $$) db 0
dw 0xAA55

这段代码里 ORG 0x7C00 告诉 NASM,这个二进制文件内的所有标签偏移都基于 0x7C00 计算。因为 BIOS 一定会把它加载到 0x7C00,这样数据标签的地址才是正确的。times 510 - ($ - $$) db 0 则把剩余的引导扇区空间填零,使最后两个字节恰好落到 0x55 和 0xAA,BIOS 才会承认这是一个合法引导扇区。

print_string 函数使用了 BIOS 的 0x10 号中断,AH 为 0x0E 表示电传打字模式输出字符,AL 中是字符本身。在实模式下,BIOS 中断是我们仅有的硬件交互手段,它帮助我们验证引导扇区确实被执行了。运行后屏幕上会显示 “Cinux bootloader: real mode OK”,这标志着第一阶段的成功。

但光有输出还不够,引导扇区的真正使命是加载更多代码。512 字节只够做最基础的事情,所以接下来把 Bootloader 扩展成两段式:引导扇区从磁盘读取第二个扇区到某个固定内存地址,然后跳转过去;第二个扇区包含完整的磁盘读取逻辑和后续的保护模式切换代码。这个第二段 Bootloader 不再受 512 字节限制,可以按照需要继续读取更多扇区。

5. 实模式寻址与磁盘读取

在让 Bootloader 读取内核之前,必须先把实模式下的内存寻址模型和磁盘访问方式弄清楚。这正是很多 Bootloader 代码看起来费解的根本原因。

实模式使用“段基址 + 偏移”的方式生成 20 位物理地址。物理地址等于段寄存器的值乘以 16,再加上偏移。比如段寄存器 DS 等于 0x07C0,偏移 0x0000,得到的物理地址就是 0x7C00。同一个物理地址可以由多种段和偏移组合表达,这就是为什么前面要强调不要假设 CS 的具体值。在实模式下,16 位偏移只能覆盖 64KB,而 20 位地址线总计只能访问 1MB 内存,这是实模式最核心的两个限制。

显卡文本模式的内存缓冲区位于 0xB8000,这是实模式可访问的 1MB 范围内的一个重要区域。后面实现屏幕输出时,在保护模式下也会直接写 0xB8000。现在先把注意力放在读写磁盘上。BIOS 的 0x13 号中断提供了磁盘服务,其中 AH=0x02 是从磁盘读取扇区的功能。调用前需要设置:AL 为要读取的扇区数,CH 为柱面号低 8 位,CL 的低 6 位为扇区号、高 2 位为柱面号高 2 位,DH 为磁头号,DL 为驱动器号,ES:BX 指向存放读取数据的内存缓冲区。调用后如果 CF 清零表示成功,AH 中存放实际读取的扇区数。

这套 CHS 寻址方式非常繁琐,而且现代磁盘早已不再使用真实的柱面、磁头、扇区结构。对于 QEMU 中的软盘映像,BIOS 通常支持 LBA 扩展读取,也就是 AH=0x42。它使用一个磁盘地址包结构,把逻辑块地址直接传给 BIOS,由 BIOS 完成 LBA 到 CHS 的转换。下面实现一个通用的实模式磁盘读取函数,优先尝试 LBA 扩展,失败时回退到传统 CHS 方式。为了充分利用 BIOS 能力,我们把第二段 Bootloader 也一并加载。

LOAD_ADDR equ 0x1000        ; 第二段 Bootloader 的加载地址
[BITS 16]
[ORG 0x7C00]
start:
cli
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
sti
mov [BOOT_DRIVE], dl
mov bx, LOAD_ADDR
mov dh, 4               ; 读取 4 个扇区,第二段 Bootloader 足够放入
mov dl, [BOOT_DRIVE]
call disk_load
jmp LOAD_ADDR           ; 跳转到第二段 Bootloader
%include "disk.asm"
BOOT_DRIVE db 0
times 510 - ($ - $$) db 0
dw 0xAA55

为了保持代码可维护,把磁盘读取函数单独放在 boot/disk.asm 中,用 NASM 的 %include 指令包含进 boot.asm。disk.asm 的内容如下,它实现了完整的 LBA 和 CHS 两种读取路径。

; 输入: DL = 驱动器号, DH = 扇区数, ES:BX = 目标缓冲区
; 输出: CF = 0 成功, CF = 1 失败, AH = BIOS 错误码
disk_load:
    push dx
    mov si, DAP
    xor ax, ax
    mov ds, ax
    mov [DAP.segment], es
    mov [DAP.offset], bx
    mov [DAP.count], dh      ; DAP 中 count 为 16 位,高字节保持 0
    mov ah, 0x42
    int 0x13
    jnc .done
; LBA 失败则回退到 CHS
pop dx                   ; 恢复 DX
push dx
mov ah, 0x08
int 0x13                 ; 获取驱动器参数
jc .fail
and cx, 0x3F             ; 每磁道扇区数
mov [SECTORS_PER_TRACK], cx
movzx ax, dh
inc ax                   ; 磁头数
mov [NUM_HEADS], ax
pop dx
mov al, dh               ; 要读的扇区数
mov ah, 0x02
mov ch, 0                ; 初始柱面 0
mov cl, 2                ; 从 2 号扇区开始
mov dh, 0
int 0x13
jc .fail
jmp .done
.fail:
stc
ret
.done:
clc
ret
DAP:
.size      db 0x10
.zero      db 0
.count     dw 0
.offset    dw 0
.segment   dw 0
.lba       dq 1          ; 从 LBA 1 开始,LBA 0 是引导扇区本身
SECTORS_PER_TRACK dw 18
NUM_HEADS dw 2

这段代码中有一个潜在问题需要提前说明:从引导扇区跳转到 0x1000 之前,第二段 Bootloader 已经被读入内存,因此跳转后代码可以继续执行。第二段 Bootloader 的开头不再需要 ORG 0x7C00,因为它被加载到 0x1000,所以第二段源文件开头使用 ORG 0x1000 来对齐地址。为了简单,也可以把引导扇区和第二段 Bootloader 合并到同一个二进制文件里,引导扇区负责把剩余部分读入 0x1000,再跳转过去。但更推荐的做法是让引导扇区独立编译,第二段单独编译并在构建脚本中拼接到一起,这样职责更加清晰。

实模式阶段的最后一块拼图是内存映射。虽然我们暂时用不到 1MB 以上的内存,但提前获取物理内存拓扑有助于后面规划分页。由于实模式下裸机环境没有现成接口,调用 BIOS 的 E820 中断可以拿到内存区域描述表。这个信息可以保存在一个固定地址,内核初始化内存管理时直接读取。本文会在进入保护模式前调用它,并把结果放到 0x9000 附近,供后续使用。

6. 保护模式:打破 1MB 与 64KB 的限制

实模式虽然简单,但有两个致命限制:一是寻址上限只有 1MB,二是段机制只提供 16 位偏移,超过 64KB 的数据访问非常别扭。保护模式通过完全重构内存寻址模型解决了这两个问题,代价是需要我们自己建立描述符表并完成切换流程。

在保护模式下,段寄存器里装的不再是段基址,而是“段选择子”。段选择子是一个 16 位值,其中第 0 到 1 位是请求特权级,第 2 位是表指示标志,第 3 到 15 位是描述符索引。CPU 根据段选择子从全局描述符表或局部描述符表中取出 8 字节的段描述符,描述符里包含了真正的 32 位段基址、20 位段界限和各类访问权限。CPU 每次访问内存时都会用段基址加上 32 位偏移生成线性地址,因此偏移可以覆盖完整的 4GB 虚拟空间,段界限则提供内存保护。

这种“段选择子 → 描述符 → 基址 + 偏移”的间接寻址方式,是保护模式名字的由来:CPU 会在每次内存访问时检查权限和边界,任何越界或越权访问都会触发异常,而不是像实模式那样默默绕回。为了让所有段都覆盖完整的 4GB 空间,通常做法是建立平坦模型:代码段和数据段的基址都为 0,界限都设为 0xFFFFF,并启用 4KB 粒度,这样实际界限达到 4GB。

切换保护模式有一套标准流程,每条指令都不能随意调换。先关闭中断,防止切换过程中硬件中断进来捣乱;然后加载 GDTR,把全局描述符表的基址和大小告诉 CPU;接着把 CR0 的最低位置 1,启用保护模式;紧接其后是一条远跳转,其目的不是跳转到什么遥远的地方,而是用一个新的代码段选择子刷新 CS,并清空 CPU 预取的 16 位解码流水线;最后加载数据段选择子到 DS、ES、FS、GS、SS,完成整个切换。

下面给出第二段 Bootloader 的主体,它完成磁盘读取、GDT 加载和保护模式切换。为了能把内核加载到 1MB 以上,读取内核的阶段放在保护模式切换之后。

[BITS 16]
[ORG 0x1000]
second_stage:
; 保存启动盘号
mov [boot_drive], dl
; 读取内核的 ELF 文件到 0x8000
; 内核最大按 128 个扇区即 64KB 设计,后续可扩大
mov bx, 0x8000
mov dh, 128
mov dl, [boot_drive]
call disk_load
; 进入保护模式
cli
lgdt [gdt_descriptor]
mov eax, cr0
or eax, 1
mov cr0, eax
jmp CODE_SEG:protected_mode
[BITS 32]
protected_mode:
mov ax, DATA_SEG
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, 0x90000
call load_kernel
; 跳转到内核入口
mov eax, KERNEL_ENTRY
jmp eax

这段代码里 CODESEG、DATASEG 等常量定义在 GDT 段。需要特别说明的是,这个版本把内核读入内存 0x8000,但内核链接脚本中的入口地址一般位于 1MB 以上。汇编中的 load_kernel 子程序会解析 ELF 头,把程序段复制到链接脚本指定的地址。下一节会先补齐 GDT 的定义,再把 load_kernel 的完整逻辑展开。

7. GDT:全局描述符表

全局描述符表是保护模式的第一块基石。它通常至少包含三个条目:空描述符、代码段描述符和数据段描述符。空描述符是第一项,硬件要求它永远为 8 字节全零,任何尝试用它访问内存的操作都会触发异常,这为非法段选择子提供了兜底保护。

段描述符的 64 位布局非常紧凑,基址和界限被拆成多段,初看容易头晕。以平坦模型下的代码段描述符为例:基址 0,界限 0xFFFFF,访问字节 0x9A 表示“存在 + 特权级 0 + 代码段 + 可读 + 未访问”,粒度字节 0xCF 表示“4KB 粒度 + 32 位操作数大小”。数据段描述符的访问字节则是 0x92,表示“存在 + 特权级 0 + 数据段 + 可写 + 未访问”,其余相同。用 NASM 宏把这些位拼接起来,可以极大地提高可读性。

CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start
gdt_start:
dq 0x0000000000000000      ; 空描述符
gdt_code:
dw 0xFFFF                  ; 段界限 0-15
dw 0x0000                  ; 基址 0-15
db 0x00                    ; 基址 16-23
db 0x9A                    ; 访问字节
db 0xCF                    ; 粒度 + 段界限 16-19
db 0x00                    ; 基址 24-31
gdt_data:
dw 0xFFFF
dw 0x0000
db 0x00
db 0x92
db 0xCF
db 0x00
gdt_end:
gdt_descriptor:
dw gdt_end - gdt_start - 1
dd gdt_start

gdt_descriptor 的格式是“大小在前、基址在后”,这是 lgdt 指令要求的。大小是 GDT 总字节数减一,基址是 gdt_start 的物理地址。在实模式下,GDTR 里的基址是线性地址,因为我们尚未分页,线性地址等于物理地址。

进入保护模式后,代码段选择子 CODE_SEG 的值为 8,表示 GDT 中索引 1,RPL 为 0;数据段选择子 DATA_SEG 的值为 16,表示 GDT 中索引 2,RPL 为 0。索引 0 是空描述符,所以自然地把 8 和 16 作为前两个实际段的选择子。后面在 C++ 里设置 TSS 时,还会向 GDT 追加更多条目,现在先保持最小实现。

有了 GDT,保护模式切换代码就完整了。验证方法是在 QEMU 里单步执行到 jmp CODE_SEG:protected_mode 之后,查看寄存器 CS 是否变成 8,SS 是否是 16。如果执行顺利,屏幕上不会再有 BIOS 输出,因为此刻已经处在 32 位保护模式,BIOS 的 16 位中断服务已经无法使用。

8. 加载内核:解析 ELF 文件

把 ELF 文件读进内存只是完成了物理搬运,还不能直接执行。ELF 文件的段可能和目标内存地址不一致,也可能存在文件中不占空间的零填充段,需要按程序头描述逐个处理。Bootloader 的职责就是遍历 ELF 程序头,根据类型、虚拟地址、文件偏移和大小,把每个可加载段放到它该在的位置。

ELF 文件头的前 16 个字节是标识魔数和类型信息,从偏移 0x20 开始是 e_type,0x1C 是 e_phoff 即程序头表偏移,0x2C 是 e_phnum 即程序头个数。每个程序头条目长度 32 字节,其中 p_type 在偏移 0,p_offset 在偏移 4,p_vaddr 在偏移 8,p_filesz 在偏移 16,p_memsz 在偏移 20。需要加载的是 p_type 等于 1 的 PT_LOAD 段;对于 p_memsz 大于 p_filesz 的部分,要把多出来的内存清零,这部分通常是 .bss 段。

以下 load_kernel 子程序按上述解析流程把内核从临时缓冲区搬移到最终地址。内核 ELF 文件先前已被 disk_load 读到物理地址 0x8000。

[BITS 32]
load_kernel:
    mov esi, 0x8000          ; ELF 文件临时地址
    mov eax, [esi + 0x1C]    ; e_phoff
    add eax, esi             ; 程序头表绝对地址
    mov ecx, [esi + 0x2C]    ; e_phnum
    mov edx, eax
.process_phdr:
cmp dword [edx], 1       ; PT_LOAD
jne .next
; 源地址 = ELF 基址 + p_offset
mov ebx, [edx + 4]
add ebx, esi
; 目标地址 = p_vaddr
mov edi, [edx + 8]
; 复制 p_filesz 字节
mov eax, [edx + 16]
push ecx
mov ecx, eax
rep movsb
pop ecx
; 清零 p_memsz - p_filesz 字节
mov eax, [edx + 20]
sub eax, [edx + 16]
jbe .next
mov ecx, eax
xor al, al
rep stosb
.next:
add edx, 32
loop .process_phdr
ret

这里使用 rep movsb 和 rep stosb 完成字节搬运和清零,清晰直观。需要注意的是,ESI 指向 ELF 文件基址,而 p_offset 是相对于文件开头的偏移;EDI 是最终放置段的内存地址;p_vaddr 来自链接器脚本,它就是段在运行时应当所在的虚拟地址。在未开启分页的情况下,虚拟地址等于物理地址,所以直接写入即可。

加载完成后,KERNEL_ENTRY 指向内核入口符号。入口符号如何与链接脚本、C++ 入口函数对齐,是下一节要解决的核心问题。但在那之前,还要为 C++ 环境做好一件事:把内核入口的栈设置到高位,并在跳转前把一些启动信息传给内核,比如 E820 内存映射的首地址和长度。接下来在链接脚本中统一规划这些地址。

9. 链接器脚本:搭建 C++ 内核的骨架

链接器脚本决定内核二进制在内存中的布局。对于裸机程序而言,它同时约束着 Bootloader 该往哪里放段,以及 C++ 代码中符号的最终地址。一个精心设计的链接器脚本是 C++ 内核从“能编译”到“能运行”之间的桥梁。

我们的内核入口地址设为 0x100000,也就是 1MB 处。这样选择是为了避开实模式经常使用的低 1MB 区域,留下 BIOS 数据区、VGA 缓冲区和 Bootloader 临时区域。链接脚本首先定义入口符号,然后从 0x100000 开始依次放置 .text、.rodata、.data、.bss 等段。为了让全局构造数组能被可靠找到,脚本中还要显式提供 .init_array 的位置和起止边界符号。

ENTRY(kernel_entry)
OUTPUT_FORMAT(elf32-i386)
SECTIONS
{
. = 0x100000;
.text : ALIGN(4K)
{
    KEEP(*(.text.kernel_entry))
    *(.text*)
}
.rodata : ALIGN(4K)
{
(.rodata)
}
.data : ALIGN(4K)
{
(.data)
}
.init_array : ALIGN(4)
{
__init_array_start = .;
KEEP((.init_array))
__init_array_end = .;
}
.bss : ALIGN(4K)
{
(.bss)
*(COMMON)
}
__kernel_end = .;
}

这里特别把 .text.kernel_entry 放在代码段最前,并用 KEEP 保留它。ENTRY(kernel_entry) 声明的入口符号必须真实存在,否则链接会失败。入口符号由 boot/entry.asm 提供,它是一小段 32 位汇编,负责准备 C++ 运行时环境并调用真正的 C++ 主函数。

entry.asm 中最关键的动作是清空 .bss,然后处理 .init_array。.bss 段承载所有未初始化的全局变量,链接器只记录大小并不占用文件空间,因此进入 C++ 代码前必须手动清零,否则全局变量的初始值不可预测。清零时使用脚本提供的 __kernel_end 和 .bss 起始地址作为边界。对于 .init_array,每个条目是一个 4 字节函数指针,engine 部分逐个调用即可完成静态存储期对象的动态初始化。

[BITS 32]
[section .text.kernel_entry]
global kernel_entry
extern main
extern __init_array_start
extern __init_array_end
kernel_entry:
; 清空 .bss,具体边界在启动代码中通过链接符号计算,
; 这里以条目简化演示核心流程。
call init_runtime
; 调用 C++ 主函数
call main
; 内核主函数返回后停机
.halt:
hlt
jmp .halt
init_runtime:
; 遍历 .init_array 调用全局构造
mov esi, __init_array_start
mov edi, __init_array_end
.loop:
cmp esi, edi
jae .done
call [esi]
add esi, 4
jmp .loop
.done:
ret

这段汇编把控制权最终交到 C++ 的 main 函数。注意这里使用 extern main 而不是 extern kernel_main,是为了让代码示例更直观。C++ 的 main 函数签名必须是 extern "C",否则 name mangling 会让链接器找不到符号。在真实内核中,通常会定义 extern "C" void kernel_main(),入口汇编里跳转的就是这个符号。文本后续统一使用 kernel_main 作为 C++ 主入口名,避免与宿主环境 main 混淆。

链接脚本完成之后,内核的骨架已经成形。但要让 C++ 真正“跑起来”,只解决链接还不够,还要跨越一道更隐蔽的坎:C++ 运行时。

10. C++ 运行时:让裸露的 C++ 真正跑起来

很多人以为,只要用 freestanding 编译参数,C++ 就能像 C 一样直接在裸机上运行。这个认识只对了一半。C++ 语言有一些隐含的运行时依赖,比如全局对象的构造和析构、纯虚函数的缺省实现、new 和 delete 运算符、静态局部对象的线程安全初始化等。在 RTTI 和异常都禁用之后,大部分依赖消失了,但仍有一些必须由我们亲手补齐。

编译所有内核 C++ 文件时,使用如下核心参数:-ffreestanding 表示不使用宿主环境的标准库启动代码,-fno-exceptions 停用异常,-fno-rtti 停用运行时类型信息,-nostdlib 禁止链接标准库。这些参数共同将 C++ 降级到一个接近 C 的环境,同时保留 RAII、模板、命名空间、函数重载、类等编译期特性。

i686-elf-g++ -ffreestanding -fno-exceptions -fno-rtti -nostdlib \
  -Wall -Wextra -O2 -Iinclude -c kernel/kernel.cpp -o build/kernel.o

第一个必须提供的运行时符号是 pure virtual 相关函数。当一个类声明纯虚函数时,编译器会在其虚函数表中放置一个指向 __cxa_pure_virtual 的条目。如果代码路径意外调用了纯虚函数,相应的处理函数就会被调用。在裸机环境下,这个函数就是让内核停机并给出错误标识。

extern "C" void __cxa_pure_virtual() {
    // 纯虚函数被调用,说明程序逻辑有误,直接停机。
    asm volatile("cli; hlt");
    for (;;) {}
}

第二个问题是全局对象的构造。GCC 会把需要在进入 main 前执行的初始化函数指针放入 .init_array 段。前面 entry.asm 已经遍历并调用了这些指针,因此理论上全局对象可以正常构造。但要把这个机制真正用起来,还需要确认链接器没有因为 -nostdlib 而丢掉这些段。链接脚本中的 KEEP(*(.init_array*)) 就是为此服务。验证方式很简单:写一个全局类对象,让它把一段字符串写入显存,如果开机后能看到输出,就说明构造机制已经打通。

第三个问题是 new/delete。内核肯定需要动态内存分配,但 C++ 的运算符 new 最终要调用我们自己的分配器。为了在早期阶段就能使用 new,需要提供 new 和 delete 的全局重载。实现里可以先让它们调用我们稍后实现的内存分配接口,或者在内核尚未准备好堆分配时直接停机。

#include "memory.h"
void* operator new(size_t size) {
return kmalloc(size);
}
void* operator new[](size_t size) {
return kmalloc(size);
}
void operator delete(void* p) {
kfree(p);
}
void operator delete[](void* p) {
kfree(p);
}
void operator delete(void* p, size_t) {
kfree(p);
}
void operator delete[](void* p, size_t) {
kfree(p);
}

内存管理部分会在后续章节实现 kmalloc 和 kfree,这里先把 C++ 层面的接口打通。另一个容易踩坑的点是静态局部对象。C++11 标准要求局部静态对象初始化是线程安全的,GCC 会用 guard 变量和 __cxa_guard_acquire/__cxa_guard_release 实现。在我们的实现中可以使用 -fno-threadsafe-statics 关闭这个行为,或者提供这两个符号。选择前者更简单,因为单核无抢占的内核不需要线程安全初始化。

当这些运行时支撑全部到位之后,C++ 世界的大门才真正打开。接下来就可以在 kernel.cpp 里放心使用类、模板和命名空间来构建终端、中断和内存管理。

11. 内联汇编与端口 IO

进入保护模式后,BIOS 的中断服务全部失效,内核和硬件之间的一切通信都必须通过端口 IO 或内存映射 IO 完成。端口 IO 是 x86 特有的一套外设访问机制,使用独立的 16 位 IO 地址空间,通过 in 和 out 指令操作。汇编层面 in al, dx 表示从 DX 指定的端口读一个字节到 AL,out dx, al 则相反。

在 C++ 中无法直接写 in/out 指令,需要借助 GCC 内联汇编。端口操作可以抽象成四个内联函数:inb、inw、outb、outw。以内联函数形式放在头文件里,可以保持高效和类型安全。

#pragma once
#include <stdint.h>
static inline void outb(uint16_t port, uint8_t value) {
asm volatile ("outb %0, %1" : : "a"(value), "Nd"(port));
}
static inline uint8_t inb(uint16_t port) {
uint8_t ret;
asm volatile ("inb %1, %0" : "=a"(ret) : "Nd"(port));
return ret;
}
static inline void outw(uint16_t port, uint16_t value) {
asm volatile ("outw %0, %1" : : "a"(value), "Nd"(port));
}
static inline uint16_t inw(uint16_t port) {
uint16_t ret;
asm volatile ("inw %1, %0" : "=a"(ret) : "Nd"(port));
return ret;
}
static inline void io_wait() {
outb(0x80, 0);
}

内联汇编里的 "a" 表示使用 AL/AX 寄存器,约束 value 必须放在累加器中;"Nd" 表示端口参数必须是立即数或 DX 寄存器,符合 in/out 指令的操作数要求。"=a" 表示输出值存回累加器。io_wait 通过向很少使用的 0x80 端口写一个字节制造短暂延迟,常用于等待慢速外设响应。

端口操作是所有硬件驱动的基石:PIC 可编程中断控制器的命令和状态通过 0x20/0xA0 端口访问,PIT 定时器通过 0x40 到 0x43 端口编程,键盘控制器通过 0x60/0x64 端口交互。后面设置 IDT 和中断时,会大量使用 outb 指令来初始化 PIC。把端口操作收拢到独立头文件后,代码可读性会明显提升。

12. 终端输出:从显存到格式化字符串

没有输出的内核几乎无法调试。保护模式下实模式 BIOS 的 0x10 中断不能再用,但 VGA 文本模式的显存映射仍然有效。文本模式下,显存位于物理地址 0xB8000,共 80 列 25 行。每个字符占两个字节:低字节是 ASCII 码,高字节是属性。属性字节低 4 位定义前景色,高 4 位定义背景色,还包含闪烁位。最常见的前景色取值从 0 到 15,分别对应黑、蓝、绿、青、红、紫、棕、浅灰、深灰、亮蓝、亮绿、亮青、亮红、亮紫、黄、白。

终端驱动的核心是一个写字符函数。它维护当前行列位置,遇到换行符时把行号加一,遇到回车符时把列号清零,普通字符则写入显存对应位置。当光标超过屏幕底部时,需要把整个屏幕内容向上滚动一行,最后更新 VGA 光标寄存器。

#include "terminal.h"
#include "io.h"
#define VGA_WIDTH  80
#define VGA_HEIGHT 25
static uint16_t* const vga_buffer = reinterpret_cast<uint16_t*>(0xB8000);
static size_t terminal_row = 0;
static size_t terminal_column = 0;
static uint8_t terminal_color = 0x0F;
static inline uint8_t make_color(uint8_t fg, uint8_t bg) {
return fg | (bg << 4);
}
static inline uint16_t make_vga_entry(char c, uint8_t color) {
return static_cast<uint16_t>(c) | (static_cast<uint16_t>(color) << 8);
}
static void terminal_put_entry_at(char c, uint8_t color, size_t x, size_t y) {
vga_buffer[y * VGA_WIDTH + x] = make_vga_entry(c, color);
}
static void terminal_scroll() {
for (size_t y = 1; y < VGA_HEIGHT; ++y) {
for (size_t x = 0; x < VGA_WIDTH; ++x) {
vga_buffer[(y - 1) * VGA_WIDTH + x] = vga_buffer[y * VGA_WIDTH + x];
}
}
for (size_t x = 0; x < VGA_WIDTH; ++x) {
terminal_put_entry_at(' ', terminal_color, x, VGA_HEIGHT - 1);
}
if (terminal_row > 0) {
--terminal_row;
}
}
static void terminal_putchar(char c) {
if (c == '\n') {
terminal_column = 0;
++terminal_row;
} else if (c == '\r') {
terminal_column = 0;
} else {
terminal_put_entry_at(c, terminal_color, terminal_column, terminal_row);
++terminal_column;
}
if (terminal_column >= VGA_WIDTH) {
terminal_column = 0;
++terminal_row;
}
if (terminal_row >= VGA_HEIGHT) {
terminal_scroll();
}
update_cursor();
}
void terminal_write(const char* data, size_t size) {
for (size_t i = 0; i < size; ++i) {
terminal_putchar(data[i]);
}
}
void terminal_write_string(const char* str) {
terminal_write(str, strlen(str));
}

更新光标的函数直接操作 VGA 端口。VGA 有两个光标寄存器:0x3D4 作为索引寄存器,0x3D5 作为数据寄存器。光标位置是一个 16 位值,等于行号乘以 80 再加列号。写入时先把高字节送到寄存器 0x0E,再把低字节送到 0x0F。

static void update_cursor() {
    uint16_t pos = static_cast<uint16_t>(terminal_row * VGA_WIDTH + terminal_column);
    outb(0x3D4, 0x0F);
    outb(0x3D5, static_cast<uint8_t>(pos & 0xFF));
    outb(0x3D4, 0x0E);
    outb(0x3D5, static_cast<uint8_t>((pos >> 8) & 0xFF));
}

终端输出打通后,可以把 printf 风格的格式化输出作为一个后续扩展点。先实现一个支持字符串、整数、十六进制数和字符的格式化函数,再基于它构建 kprintf。格式化函数用可变参数即可,但为了简单,也可以用 C++ 模板做类型安全包装。无论如何实现,核心思路都是生成字符串后调用 terminal_write_string。

void kprintf(const char* format, ...) {
    va_list args;
    va_start(args, format);
    char buf[256];
    vsnprintf(buf, sizeof(buf), format, args);
    va_end(args);
    terminal_write_string(buf);
}

有了终端输出,后续每个模块初始化时都会在屏幕上打印状态,比如 “GDT initialized”、“IDT loaded”、“Paging enabled”。这些输出让整个启动过程可视化,也便于快速定位崩溃位置。

13. 中断与 IDT:让硬件和 CPU 都能找到我们

中断是内核与硬件交互的第二条通道,也是 CPU 切换到内核态执行我们代码的主要入口。保护模式下的中断处理机制围绕中断描述符表展开。IDT 的每一个条目对应一个中断向量,向量可以是 CPU 异常、外部硬件中断或软件触发的系统调用。和 GDT 类似,IDT 的位置和大小也要通过 lidt 指令加载到 CPU。

在进入 C++ 之前,Bootloader 并没有加载 IDT,因此进入保护模式后任何中断都会导致 CPU 崩溃。为了避免这种情况,进入保护模式前关闭中断是必须的。进入 C++ 后,第一件事就是重新加载 IDT 并打开中断。对于 x86 32 位保护模式,IDT 描述符长度 8 字节,其中偏移地址被拆成两段:高 16 位和低 16 位,中间是选择子、类型属性和保留位。最实用的类型属性是 0x8E,表示 32 位中断门、存在、特权级 0。

初始化 IDT 前还要处理 CPU 的 32 种异常。前 20 种异常对应除零错误、调试、NMI、断点、溢出、越界、无效操作码、设备不可用、双重故障、协处理器段越界、非法 TSS、段不存在、栈段故障、一般保护故障、页故障等,后 12 个为 Intel 保留。为了后续调试方便,为每个异常都注册一个通用处理函数,在发生异常时打印具体信息并停机。

IDT 初始化代码分两层:底层内联汇编用于加载 IDTR,上层 C++ 结构体用于填充描述符。下面先建立 IDT 数据结构。

#pragma once
#include <stdint.h>
struct IDTEntry {
uint16_t offset_low;
uint16_t selector;
uint8_t  zero;
uint8_t  type_attr;
uint16_t offset_high;
} attribute((packed));
struct IDTPointer {
uint16_t limit;
uint32_t base;
} attribute((packed));
void idt_init();

设置 IDT 条目的函数把中断处理函数的地址拆开,填充到对应字段。类型属性 0x8E 表示这是一个 32 位中断门,特权级 0,存在位为 1。

#include "idt.h"
#include "io.h"
extern "C" void isr0();
extern "C" void isr1();
extern "C" void isr2();
// ... 其余处理器异常入口
static IDTEntry idt[256];
static IDTPointer idt_ptr;
void idt_set_gate(uint8_t num, uint32_t base, uint16_t selector, uint8_t flags) {
idt[num].offset_low  = base & 0xFFFF;
idt[num].selector    = selector;
idt[num].zero        = 0;
idt[num].type_attr   = flags;
idt[num].offset_high = (base >> 16) & 0xFFFF;
}
void idt_init() {
idt_ptr.limit = sizeof(IDTEntry) * 256 - 1;
idt_ptr.base  = reinterpret_cast<uint32_t>(idt);
for (int i = 0; i &lt; 256; ++i) {
    idt_set_gate(i, 0, 0x08, 0x8E);
}
idt_set_gate(0,  reinterpret_cast&lt;uint32_t&gt;(isr0),  0x08, 0x8E);
idt_set_gate(1,  reinterpret_cast&lt;uint32_t&gt;(isr1),  0x08, 0x8E);
// ... 依次注册各异常处理入口
asm volatile("lidt (%0)" : : "r"(&amp;idt_ptr));
asm volatile("sti");
}

中断处理入口需要在汇编中编写,原因在于中断返回时使用的 iret 指令无法用 C++ 直接表达,而且异常处理通常需要保存和恢复所有通用寄存器。下面的 isr0 示例处理除零异常,其余异常的入口结构类似,区别仅在于压入不同的错误码或补 0 保持栈帧一致。

[BITS 32]
extern fault_handler
global isr0
isr0:
cli
push 0        ; 除零异常没有错误码,补 0 保持栈帧一致
push 0
jmp isr_common_stub
global isr13
isr13:
cli
push 13       ; 一般保护故障有错误码,直接使用
jmp isr_common_stub
isr_common_stub:
pusha
push ds
push es
push fs
push gs
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
call fault_handler
pop gs
pop fs
pop es
pop ds
popa
add esp, 8    ; 清理错误码和中断号
iret

fault_handler 是一个 C++ 函数,它接收中断号和错误码,在屏幕上打印异常信息后停机。实际使用中,可以用宏展开所有异常入口,避免手工重复 20 次。

IDT 就绪后,CPU 异常和软件中断都能触发我们的代码。但来自硬件的外部中断还处于关闭状态,因为 8259 PIC 还没有重新映射。下一步就是完成 PIC 重映射,让 IRQ0 到 IRQ15 对应中断向量 32 到 47,并接入时钟和键盘。

14. PIC、PIT 与键盘:让内核拥有时间感

计算机主板上的 8259 PIC 负责汇总外部硬件中断,最初 IRQ0 到 IRQ7 映射到中断向量 0 到 7,IRQ8 到 IRQ15 映射到向量 8 到 15。问题是这些向量与 CPU 前 32 个异常向量重叠,一旦硬件触发中断,CPU 会把它误认为异常。所以内核初始化时必须把 PIC 重新映射到 32 到 47 这个范围。

8259 有两个级联的控制器,主片端口为 0x20 和 0x21,从片端口为 0xA0 和 0xA1。重映射通过初始化命令字完成,标准序列是:发送 ICW1,设置 ICW2 为新的向量基址,设置 ICW3 表示级联关系,最后发送 ICW4 设置工作模式。

#include "io.h"
#define PIC1_COMMAND 0x20
#define PIC1_DATA    0x21
#define PIC2_COMMAND 0xA0
#define PIC2_DATA    0xA1
void pic_remap(int offset1, int offset2) {
outb(PIC1_COMMAND, 0x11);       // ICW1
io_wait();
outb(PIC2_COMMAND, 0x11);
io_wait();
outb(PIC1_DATA, offset1);       // ICW2:主片向量基址
io_wait();
outb(PIC2_DATA, offset2);
io_wait();
outb(PIC1_DATA, 0x04);          // ICW3:主片连接从片的 IRQ2
io_wait();
outb(PIC2_DATA, 0x02);
io_wait();
outb(PIC1_DATA, 0x01);          // ICW4:8086 模式
io_wait();
outb(PIC2_DATA, 0x01);
io_wait();
outb(PIC1_DATA, 0x00);          // 放开所有中断屏蔽
outb(PIC2_DATA, 0x00);
}

重置映射后,IRQ0 对应向量 32。IRQ0 来自可编程间隔定时器 PIT。PIT 的默认频率约为 1.1931816666 MHz,通过编程分频值可以产生周期性中断。分频值 1193182 / frequency 作为 16 位值写入 PIT 端口 0x40。选择 1000 Hz 可以让时钟中断每秒触发 1000 次,在时钟中断处理函数里递增系统 tick 计数,就拥有了最基础的时间感。

void pit_init(uint32_t frequency) {
    uint16_t divisor = static_cast<uint16_t>(1193182 / frequency);
    outb(0x43, 0x36);           // 通道 0,模式 3,方波,二进制
    outb(0x40, divisor & 0xFF);
    outb(0x40, (divisor >> 8) & 0xFF);
}
volatile uint64_t system_tick = 0;
extern "C" void irq0_handler() {
++system_tick;
outb(0x20, 0x20);           // 发送 EOI
}

键盘驱动则是另一个重要外设。键盘中断是 IRQ1,对应向量 33。键盘数据端口是 0x60,读入的是一个字节的扫描码。可以为键盘定义一个简单的环形缓冲区,中断处理函数中把扫描码存入缓冲区,主循环中取出并转换成对应字符打印到终端。

#define KEYBOARD_DATA_PORT 0x60
extern "C" void irq1_handler() {
uint8_t scan_code = inb(KEYBOARD_DATA_PORT);
keyboard_add_scancode(scan_code);
outb(0x20, 0x20);
}
void keyboard_add_scancode(uint8_t code) {
// 将扫描码放入环形缓冲区
if ((keyboard_head + 1) % KEYBOARD_BUFFER_SIZE != keyboard_tail) {
keyboard_buffer[keyboard_head] = code;
keyboard_head = (keyboard_head + 1) % KEYBOARD_BUFFER_SIZE;
}
}

有了时钟和键盘,内核已经具备基本交互能力。再往下,就进入真正的内存管理:分页。

15. 内存分页:从物理内存到虚拟地址

保护模式解决了段寻址问题,但所有内存访问仍然是物理地址,任何代码都能访问任何地址,错误一旦发生就是毁灭性的。分页机制把线性地址映射到物理地址,使内核能够建立页表、隔离内核空间与用户空间,并为后续的按需加载、写时复制、进程隔离奠定基础。

x86 32 位分页使用两级页表结构。线性地址的高 10 位是页目录索引,中间 10 位是页表索引,低 12 位是页内偏移。页目录共 1024 个条目,每个条目指向一个页表;每个页表也是 1024 个条目,每个条目指向 4KB 物理页。开启分页前,需要构造页目录并至少填充内核所在的条目,然后把页目录物理地址写入 CR3,最后把 CR0 的最高位置 1。

对于初版内核,采用最简单的方式:恒等映射前 4MB 内存,也就是虚拟地址等于物理地址。这样所有已有代码和显存映射都无需改动。页目录和页表本身必须放在 4KB 对齐的物理内存中。下面的示例在 0x100000 之后预留一块区域存放页目录和页表,当然正式实现应该从物理内存管理器中分配。

#include <stdint.h>
#include "terminal.h"
#define PAGE_SIZE 4096
static uint32_t page_directory[1024] attribute((aligned(PAGE_SIZE)));
static uint32_t first_page_table[1024] attribute((aligned(PAGE_SIZE)));
void paging_init() {
for (int i = 0; i < 1024; ++i) {
page_directory[i] = 0x00000002;      // 不存在、可写、特权级 0
}
for (int i = 0; i < 1024; ++i) {
first_page_table[i] = (i * PAGE_SIZE) | 0x03;  // 存在、可写
}
page_directory[0] = reinterpret_cast<uint32_t>(&first_page_table) | 0x03;
asm volatile("mov %0, %%cr3" : : "r"(page_directory));
uint32_t cr0;
asm volatile("mov %%cr0, %0" : "=r"(cr0));
cr0 |= 0x80000000;
asm volatile("mov %0, %%cr0" : : "r"(cr0));
}

启用分页后,所有线性地址都经过两级转换,硬件会自动完成,代码无需改变。但有一件事必须立即处理:从此以后任何非法线性地址访问都会触发页故障异常。这也是我们期望的效果,错误不再静默发生,而是陷入我们的 handler。

一个很好的后续练习是让内核从 4MB 恒等映射过渡到高半内核映射,也就是把页目录的第 768 项映射到 0 地址开始的物理内存,相应地把第 0 项取消映射。这样内核逻辑地址可以放到 0xC0000000 以上,为将来加载用户程序腾出低 3GB 空间。高半内核的切换需要额外注意入口跳转和栈重定位,是分页进阶的重要课题。

16. 物理内存管理器与内核堆

分页建立虚拟内存之后,需要一套物理内存管理器来记录哪些物理页空闲、哪些被占用。对于 x86,我们可以把物理内存按 4KB 页划分,用一个位图表示每个页的状态。位图的每一比特对应一页,1 表示已使用,0 表示空闲。4GB 内存对应 1048576 个页,位图只需 128KB,是可以接受的代价。

物理内存管理器的第一个工作是从 E820 内存映射或固定内存上限推导出可用页数,并标记内核已占用的区域。初版可以简化:固定假设机器有 32MB 内存,把 0 到 1MB 以及内核结束地址以下区域标记为已用,其余为可用。位图本身放置在链接脚本保证安全的位置。

#include <stdint.h>
#define MAX_MEMORY 0x02000000     // 32MB
#define PAGE_SIZE  0x1000
#define MAX_PAGES  (MAX_MEMORY / PAGE_SIZE)
static uint8_t bitmap[MAX_PAGES / 8];
void mm_init(uint32_t kernel_end) {
for (size_t i = 0; i < sizeof(bitmap); ++i) {
bitmap[i] = 0xFF;         // 初始标记全部占用
}
uint32_t used_pages = kernel_end / PAGE_SIZE + 1;
for (uint32_t i = used_pages; i < MAX_PAGES; ++i) {
bitmap[i / 8] &= ~(1 << (i % 8));   // 清除位图对应位,标记空闲
}
}
static void bitmap_set(uint32_t page) {
bitmap[page / 8] |= (1 << (page % 8));
}
static void bitmap_clear(uint32_t page) {
bitmap[page / 8] &= ~(1 << (page % 8));
}
uint32_t alloc_page() {
for (uint32_t i = 0; i < MAX_PAGES; ++i) {
uint8_t byte = bitmap[i / 8];
uint8_t mask = 1 << (i % 8);
if ((byte & mask) == 0) {
bitmap_set(i);
return i * PAGE_SIZE;
}
}
return 0;                    // 无可用内存
}
void free_page(uint32_t addr) {
uint32_t page = addr / PAGE_SIZE;
bitmap_clear(page);
}

有了 alloc_page/free_page,内核堆就水到渠成。堆分配器的核心是管理连续虚拟地址空间中的一系列已分配块和空闲块。经典做法是维护一个空闲链表,每个空闲块头部记录大小和下一块指针。分配时从链表找到第一个满足大小的空闲块,必要时拆分;释放时把相邻空闲块合并,减少碎片。

下面的 kmalloc/kfree 实现基于空闲链表。为了避免初始链表没有可用块,先用 alloc_page 分配一页作为堆的初始内存池,并逐步扩展。

#include <stdint.h>
#include "memory.h"
struct BlockHeader {
size_t size;
bool   free;
BlockHeader* next;
};
static BlockHeader* heap_start = nullptr;
static uint32_t heap_top = 0;
static uint32_t heap_limit = 0;
void kheap_init(uint32_t start) {
heap_start = reinterpret_cast<BlockHeader*>(start);
heap_top = start;
heap_limit = start;
}
void* kmalloc(size_t size) {
if (size == 0) return nullptr;
size = (size + sizeof(BlockHeader) + 15) & ~15;
if (heap_top + size &gt; heap_limit) {
    uint32_t needed = size + PAGE_SIZE;
    while (heap_limit - heap_top &lt; needed) {
        uint32_t page = alloc_page();
        if (page == 0) return nullptr;
        heap_limit += PAGE_SIZE;
    }
}
BlockHeader* header = reinterpret_cast&lt;BlockHeader*&gt;(heap_top);
header-&gt;size = size;
header-&gt;free = false;
heap_top += size;
return reinterpret_cast&lt;void*&gt;(header + 1);
}
void kfree(void* ptr) {
// 简化实现:不做释放,仅为了接口完整。
(void)ptr;
}

这个分配器是刻意简化的版本,没有做释放合并。完整的内存管理器会包含更精细的空闲链表管理和页级分配策略,但作为内核运行基础已经足够。内存管理器的实现务必配合页面边界对齐和错误检查,避免越界访问破坏位图本身。

17. 调试内核:从串口日志到 QEMU 单步

内核开发中调试能力的重要性甚至高于业务逻辑本身。有一台虚拟机允许你随时暂停、查看寄存器和内存,是手写内核最大的便利之一。QEMU 提供了 -s 参数在 1234 端口开启 GDB 调试接口,我们可以用 i686-elf-gdb 连接进去,配合符号文件实现源码级调试。

# 启动 QEMU 并开启调试接口
qemu-system-i386 -fda build/cinux.img -s -S
另一个终端里连接 GDB
i686-elf-gdb build/kernel.elf
(gdb) target remote :1234
(gdb) break kernel_main
(gdb) continue

GDB 连接后的调试能力包括断点、单步、查看寄存器、步过汇编指令、检查内存。对于启动早期,QEMU 还支持 -d 参数输出指令流、中断信息和 CPU 状态。排查引导扇区问题时,-d int 非常有用,能看到每次中断的调用细节。排查页故障时,-d cpu_reset,int 可以定位异常发生前后的寄存器快照。

另一个重要的调试手段是串口日志。QEMU 的 -serial stdio 参数可以把客户机串口映射到宿主机标准输出,日志不再依赖屏幕显示。结合我们写的 outb 端口操作,实现串口输出只需初始化 16550 UART 的几个寄存器,然后在 keyprintf 之外增加一个 serial_printf 函数。串口输出在中断关闭或屏幕初始化失败的条件下仍然可用,是早期调试的不二选择。

#include "io.h"
#define COM1 0x3F8
void serial_init() {
outb(COM1 + 1, 0x00);    // 关闭中断
outb(COM1 + 3, 0x80);    // 启用 DLAB
outb(COM1 + 0, 0x03);    // 波特率分频低字节
outb(COM1 + 1, 0x00);    // 波特率分频高字节
outb(COM1 + 3, 0x03);    // 8 位数据、无校验、1 停止位
outb(COM1 + 2, 0xC7);    // 启用 FIFO
outb(COM1 + 4, 0x0B);    // IRQ 使能
}
void serial_write_char(char c) {
while ((inb(COM1 + 5) & 0x20) == 0) {}
outb(COM1, c);
}
void serial_write_string(const char* str) {
while (*str) {
serial_write_char(*str++);
}
}

在 kernel_main 一开始就调用 serial_init,后面所有重要阶段都输出串口日志。这样即使 VGA 输出异常,仍然能通过宿主终端看到内核走到哪一步。串口日志配合 GDB 断点,基本能覆盖这个阶段所有问题。

18. 总结与下一步:从最小内核走向完整系统

到这里,我们已经完整走通了从 BIOS 到 C++ 内核的链路:引导扇区在 0x7C00 起身,第二段 Bootloader 从磁盘读入 ELF 内核、解析程序头并搬运到最终地址,GDT 将 CPU 送入 32 位保护模式,链接器脚本和 entry.asm 完成 C++ 运行时初始化、清空 BSS、调用全局构造,随后 C++ 代码接管控制权,依次建立终端输出、IDT 中断、PIC/PIT 和键盘交互、分页和物理内存管理。在这一刻,屏幕上的每一行输出都已经完全由我们自己的 C++ 代码驱动。

这个最小内核的完成是一次重要的范式转变。你不再需要在宿主操作系统的抽象之上理解系统调用如何工作,而是亲手构建了这些抽象之下的最底层。接下来可以沿着几条路线继续扩展。第一条是中断和异常处理的系统化,建立统一的 IRQ 注册接口、完善异常处治和内核 panic 框架。第二条是内存管理的深化,把物理内存位图、内核堆和虚拟内存映射整合起来,支持任意大小的物理内存和更细粒度的页分配。第三条是进程和调度,从多任务开始,保护用户态进程,引入系统调用和用户态库。第四条是持久化和文件系统,让内核能读写真实的磁盘或制作 ramdisk,逐步支持文件抽象。

无论选哪条路线,本文建立的这个最小内核都是可靠的起点。它足够小,可以完全装进大脑;又足够完整,所有关键机制一个不少。接下来把代码敲进编辑器,让 QEMU 的黑色窗口亮起你的第一行 C++ 输出吧。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐