1. 前言:为什么要手搓操作系统

很多人在学习计算机时,最先接触的是应用开发:写 Python 脚本、做 Web 后端、调数据库接口。时间一长,很容易产生一种错觉——程序就是「调库、传参、拿结果」。但真正的计算机系统并不是这么运行的。操作系统隐藏了 CPU 模式切换、中断、内存分页、进程调度等大量底层细节,而这些细节恰恰决定了程序的性能、稳定性和可移植性。

手搓操作系统,并不是为了让你在真实服务器上替换 Linux 或 Windows,而是通过亲手实现一个能启动、能打印、能响应键盘、能切换任务的小内核,真正理解以下问题:CPU 上电后第一条指令从哪来?为什么需要实模式和保护模式?什么是 GDT、IDT?虚拟内存的页表到底做了什么?一个进程从「代码文件」到「被 CPU 执行」中间发生了什么?

本教程采用 x86 架构,从零开始一步步实现一个极简操作系统。我们会先写出能在虚拟机里打印欢迎信息的引导扇区,再逐步加入保护模式、中断系统、分页、内存管理、进程切换和系统调用,最终形成一个有内核态、用户态和任务调度能力的微内核骨架。教程中的代码都可以在 QEMU 虚拟机里直接运行,每一步都保持完整可编译。

提示:本教程默认读者具备 C 语言基础,了解一些汇编语法会更好,但即使汇编基础薄弱也可以跟着实现。所有示例以 NASM 汇编和 GCC 交叉编译为主,运行环境建议使用 Linux 或 WSL。

2. 整体路线图与学习目标

手写操作系统是一个由浅入深的过程,不宜一上来就写完整内核。我们按照硬件依赖顺序把任务拆成多个可运行的里程碑,每个里程碑都能在屏幕上看到反馈。

  • 阶段一:裸机启动。理解 BIOS 启动流程,编写第一个引导扇区,学会用 QEMU 运行和调试。
  • 阶段二:从实模式到保护模式。建立 GDT,切换 32 位保护模式,让 CPU 从 16 位汇编代码进入 32 位环境。
  • 阶段三:中断系统。重映射 8259A PIC,建立 IDT,处理键盘中断、时钟中断,让内核具备对外部事件的响应能力。
  • 阶段四:内存管理。开启分页,建立页目录和页表,实现物理内存分配器,理解虚拟地址到物理地址的映射。
  • 阶段五:内核 C 语言化。编写链接脚本和 C 入口,把启动代码与 C 内核连接起来,形成可扩展的内核结构。
  • 阶段六:多任务与系统调用。实现上下文切换、进程控制块、系统调用接口,再尝试进入用户态执行简单程序。

学完本教程后,你应该能够独立写出一个包含引导、内存管理、中断和简单进程调度的微内核,并具备继续阅读 Linux 0.11、Minix、xv6 等源码的能力。更重要的是,遇到「段错误」「页面错误」「内存映射」等概念时,不再觉得抽象。

3. 开发环境搭建

首先要准备完整的交叉编译和模拟运行工具链。我们推荐在 Ubuntu 20.04/22.04 或 WSL2 中进行,安装以下软件包:

sudo apt update
sudo apt install build-essential nasm qemu-system-x86 gcc-multilib g++-multilib mtools xorriso -y

nasm 用于汇编引导扇区和启动相关代码,qemu-system-x86 是 x86 模拟器,gcc-multilib 是为了编译 32 位代码,必要时还可以安装 gdb 做远程调试。

为了编写真正的 32 位内核,推荐安装 i686 交叉编译工具链,避免使用宿主机默认编译器带来的链接问题。可以从源码编译 binutils 和 gcc,也可以直接安装发行版提供的目标包。安装完成后,验证工具是否可用:

nasm -v
qemu-system-i386 --version
i686-elf-gcc --version

我们还需要一个通用的项目目录结构。建议按以下方式组织:

myos/
├── boot/
│   ├── boot.asm
│   └── boot2.asm
├── kernel/
│   ├── kernel.c
│   ├── gdt.c
│   ├── idt.c
│   ├── isr.c
│   ├── memory.c
│   └── ...
├── include/
├── link.ld
└── Makefile

其中 boot/ 放第一、第二阶段启动代码,kernel/ 放 C 内核,link.ld 是链接脚本,Makefile 负责从源码生成可启动的镜像文件。后续章节会逐步填充这些文件。

4. 计算机启动流程详解

理解启动流程是手写操作系统的第一步。现代 x86 计算机按下电源键后,经历的过程大致如下:

  1. 加电与复位:主板向 CPU 发送复位信号,CPU 将各寄存器设置为已知的初始状态。其中 CS 寄存器被设置为 0xF000,IP 寄存器被设置为 0xFFF0,因此 CPU 第一条指令的物理地址是 0xFFFF0。
  2. 执行 BIOS:0xFFFF0 位于 BIOS ROM 区域。BIOS 首先执行上电自检,即 POST,检查内存、键盘、显示器等硬件是否正常。
  3. 查找启动设备:BIOS 按照配置的启动顺序检查软盘、硬盘、U盘等设备。对硬盘而言,BIOS 读取其第一个扇区,即 MBR,并检查该扇区最后两个字节是否为 0x55 和 0xAA。
  4. 加载引导扇区:如果签名正确,BIOS 把 512 字节的 MBR 加载到物理内存 0x7C00 处,然后跳转到 0x7C00 开始执行。从这一刻起,控制权就交给了我们自己的代码。

这里有几个关键点需要记住:引导扇区固定为 512 字节,以 0x55、0xAA 结尾;代码加载地址是 0x7C00;CPU 初始处于 16 位实模式,寻址方式为 段地址 × 16 + 偏移地址,最大可寻址 1MB 物理内存。

0x7C00 这个地址虽然看起来很奇怪,但它是历史兼容约定。BIOS 会把我们的代码放到这里,所以引导程序必须使用 org 0x7C00 来保证标号地址与真实加载地址一致。

5. 第一个引导扇区:打印 Hello World

先写一个最简单的引导程序,让它启动后在屏幕上显示文字。创建 boot/boot.asm,内容如下:

[bits 16]
[org 0x7C00]

start:
    cli
    xor ax, ax
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0x7C00

    mov si, msg
    call print_string

hang:
    hlt
    jmp hang

print_string:
    mov ah, 0x0E
.loop:
    lodsb
    test al, al
    jz .done
    int 0x10
    jmp .loop
.done:
    ret

msg db "Hello, My OS!", 0

times 510 - ($ - $$) db 0
dw 0xAA55

逐行分析这段代码。[bits 16] 声明生成 16 位代码;[org 0x7C00] 告诉汇编器所有标号的地址都以 0x7C00 为基准偏移。cli 先关闭中断,防止引导阶段被意外打断。随后把 dsesss 都清零,并把栈指针 sp 指向 0x7C00。

print_string 是打印函数。它通过 BIOS 中断 int 0x10 的 0x0E 号功能逐字符输出,lodsbsi 指向的内存读取一个字节到 al 并自动递增 si;当读到 0 时表示字符串结束。最后 times 510 - ($ - $$) db 0 用 0 填充到 510 字节,再写入 0xAA55 作为引导签名。

接下来编写运行脚本。创建 Makefile

boot.bin: boot/boot.asm
	nasm -f bin boot/boot.asm -o boot.bin

os.img: boot.bin
	truncate -s 1440K os.img
	dd if=boot.bin of=os.img conv=notrunc

run: os.img
	qemu-system-i386 -drive file=os.img,format=raw

clean:
	rm -f boot.bin os.img

执行 make run,QEMU 会打开一个窗口,屏幕上出现 Hello, My OS!。如果看到这行字,说明你已经成功让裸机执行了自己的第一段程序。这一步虽然简单,但它是整个内核的起点。

如果程序没有输出,首先检查两个最容易出错的地方:一是镜像是否真的从第一个字节写入了 boot.bin;二是程序结尾是否为 0xAA55,即低字节 0x55、高字节 0xAA。可以用 xxd os.img | head 查看镜像头部和结尾。

6. 使用 BIOS 中断与磁盘读写

实模式阶段,我们还不能直接操作硬件,最方便的方式是借助 BIOS 提供的中断服务。BIOS 像一个小型操作系统,替我们封装了屏幕输出、键盘读取、磁盘读写等功能。常用中断如下:

中断号功能常用参数
int 0x10视频服务AH=0x0E 打印字符;AH=0x00 设置显示模式
int 0x13磁盘服务AH=0x02 读扇区;AH=0x03 写扇区
int 0x16键盘服务AH=0x00 读取按键
int 0x15获取内存信息AH=0xE820 获取内存映射

我们的第二阶段引导加载器就需要使用 int 0x13 从磁盘读取更多数据。例如,从硬盘读取 5 个扇区到内存 0x1000 处:

read_disk:
    mov ah, 0x02
    mov al, 5
    mov ch, 0
    mov cl, 2
    mov dh, 0
    mov dl, 0x80
    mov bx, 0x1000
    mov es, bx
    mov bx, 0x0000
    int 0x13
    jc read_disk
    ret

参数含义如下:ah=0x02 表示读扇区;al 是要读的扇区数;ch 是柱面号低 8 位;cl 低 6 位是起始扇区号;dh 是磁头号;dl 是驱动器编号,0x80 表示第一块硬盘;es:bx 是目标缓冲区地址。调用后如果发生错误,CF 标志位会被置位,需要重试。

BIOS 的 CHS 寻址方式比较繁琐,但它在引导阶段足够使用。进入保护模式后,我们会逐步摆脱对 BIOS 的依赖,使用 I/O 端口和硬件控制器直接驱动设备。

7. 从实模式切换到保护模式

实模式最大的限制是只能访问 1MB 内存,且没有内存保护、权限隔离和分页机制。现代操作系统内核都必须运行在 32 位或 64 位保护模式下。切换到保护模式的完整流程通常包括以下步骤:

  1. 关闭中断,防止切换过程中被打断。
  2. 使用 lgdt 指令加载全局描述符表 GDT。
  3. 设置 CR0 寄存器的最低位 PE 位为 1,开启保护模式。
  4. 通过一个远跳转 jmp selector:offset 刷新 CS 寄存器,让 CPU 真正进入 32 位代码段。
  5. 更新 DS、ES、SS 等段寄存器,建立 32 位栈。

下面是一段最精简的切换代码:

[bits 16]
start:
    cli
    lgdt [gdt_descriptor]

    mov eax, cr0
    or eax, 1
    mov cr0, eax

    jmp 0x08:protected_mode

[bits 32]
protected_mode:
    mov ax, 0x10
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
    mov esp, 0x90000

    mov byte [0xB8000], 'P'
    mov byte [0xB8001], 0x0F
    jmp $

gdt_start:
    dq 0x0000000000000000
gdt_code:
    dw 0xFFFF
    dw 0x0000
    db 0x00
    db 10011010b
    db 11001111b
    db 0x00
gdt_data:
    dw 0xFFFF
    dw 0x0000
    db 0x00
    db 10010010b
    db 11001111b
    db 0x00
gdt_end:

gdt_descriptor:
    dw gdt_end - gdt_start - 1
    dd gdt_start

times 510 - ($ - $$) db 0
dw 0xAA55

代码中 mov cr0, eax 之后 CPU 已经进入保护模式,但此时 CS 仍指向旧的实模式代码段,必须用远跳转来刷新。0x08 是 GDT 中的代码段选择子,表示「GDT 表中的第 1 项,请求特权级为 0」。跳转完成以后,程序进入 32 位代码段,随后我们给各数据段寄存器装载 0x10,这是数据段选择子,并将栈顶设置为 0x90000。

程序最后直接向显存地址 0xB8000 写入字符 P,这是 VGA 文本模式下的显存基地址。保护模式下我们不能再用 int 0x10 打印,因此直接写显存是最简单的自检方式。第一个字节是字符,第二个字节是字符属性,0x0F 表示白色字符、黑色背景。

8. 深入理解全局描述符表 GDT

GDT 是保护模式内存管理的核心数据结构之一。它保存了多个「段描述符」,每个段描述符描述一段内存的基址、长度、访问权限和属性。CPU 通过段寄存器中的选择子查找 GDT,从而确定该段可以访问的内存范围。

一个段描述符占 8 字节,结构如下:

字段含义
段限长低 16 位0-15段长度的低 16 位
基地址低 16 位16-31段起始地址的低 16 位
基地址中间 8 位32-39段起始地址的中间 8 位
Access Byte40-47段存在位、特权级、类型等
限长高 4 位 + 标志48-55G 位、D/B 位、段限长高 4 位
基地址高 8 位56-63段起始地址的高 8 位

Access Byte 中的关键位包括:P 位表示段是否存在;DPL 表示描述符特权级;S 位表示是系统段还是代码/数据段;Type 表示可读、可写或可执行。对于大多数简化内核,我们通常创建一个从 0 开始、长度 4GB 的平坦代码段和一个同样 4GB 的平坦数据段,让虚拟地址与物理地址一一对应。

上面代码中的两个描述符都是「基址 0、限长 0xFFFFF、G 位置 1」。G 位为 1 时,段限长以 4KB 为单位,因此 0xFFFFF 乘以 4KB 正好覆盖 4GB。这样的平坦模型大大简化了后续的寻址和分页设计。

在 C 语言内核中,我们通常也会定义描述符结构,并通过内联汇编执行 lgdt。例如:

typedef struct {
    uint16_t limit_low;
    uint16_t base_low;
    uint8_t base_middle;
    uint8_t access;
    uint8_t granularity;
    uint8_t base_high;
} __attribute__((packed)) gdt_entry_t;

typedef struct {
    uint16_t limit;
    uint32_t base;
} __attribute__((packed)) gdt_ptr_t;

这段代码使用 __attribute__((packed)) 关闭结构体对齐,确保内存布局与硬件要求完全一致。初始化 GDT 时,向描述符中分别填入存取权限字节和粒度字节即可。

9. 中断描述符表 IDT 与中断处理

CPU 无法预先知道按键什么时候到来、时钟什么时候触发。中断机制让硬件能够异步通知 CPU。在保护模式下,中断通过中断描述符表 IDT 进行管理。IDT 与 GDT 类似,也是一张描述符表,但描述符类型不同——一个 IDT 描述符对应一个中断门或陷阱门,指向中断处理程序的入口地址。

进入保护模式后,CPU 遇到中断会做如下事情:

  1. 根据中断号从 IDT 获取门描述符。
  2. 检查当前特权级是否允许执行该门。
  3. 切换到门描述符指定的目标代码段和偏移地址。
  4. 如果发生特权级变化,保存旧的 SS 和 ESP。
  5. 保存 EFLAGS、CS、EIP 等现场,然后跳转到中断处理程序。

处理程序执行完后使用 iret 返回。在编写 32 位内核时,需要先为中断准备统一的汇编入口,因为 C 函数约定与中断现场保存并不完全相同。一个精简的键盘中断入口可以这样写:

extern isr_handler

global isr33
isr33:
    cli
    push 0
    push 33
    jmp isr_common

isr_common:
    pusha
    push ds
    push es
    push fs
    push gs
    mov ax, 0x10
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    push esp
    call isr_handler
    add esp, 4
    pop gs
    pop fs
    pop es
    pop ds
    popa
    add esp, 8
    iret

这里 push 0 压入错误码占位,push 33 压入中断号,然后跳转到公共处理流程。pusha 保存所有通用寄存器,接着保存各段寄存器;为了避免内核使用错误的用户态段寄存器,先将 DS、ES、FS、GS 设置为内核数据段选择子 0x10。随后把栈指针压栈,作为参数传给 C 函数 isr_handler。函数返回后按相反顺序恢复现场,再用 iret 返回被中断的程序。

真正响应硬件中断前,还需要重映射 8259A 可编程中断控制器 PIC。isr33 说明键盘中断号已经被重映射到了 33。为了让操作更统一,我们通常在初始化阶段执行 PIC 重映射,把主片映射到 32~39,从片映射到 40~47。

void pic_remap(uint8_t offset1, uint8_t offset2) {
    uint8_t mask1 = inb(0x21);
    uint8_t mask2 = inb(0xA1);

    outb(0x20, 0x11);
    outb(0xA0, 0x11);
    outb(0x21, offset1);
    outb(0xA1, offset2);
    outb(0x21, 0x04);
    outb(0xA1, 0x02);
    outb(0x21, 0x01);
    outb(0xA1, 0x01);

    outb(0x21, mask1);
    outb(0xA1, mask2);
}

完成重映射后,键盘中断对应 33 号,时钟中断对应 40 号。初始化 IDT 时,需要为这些中断号注册处理函数,并调用 lidt 加载 IDT,最后执行 sti 开启 CPU 硬件中断。键盘中断处理程序还必须读取 0x60 端口清除硬件中断状态,否则键盘会继续处于等待状态。

10. 内存分页机制

分段机制解决了保护模式和权限问题,但仍以段为单位,粒度较粗。分页机制把内存划分为固定大小的页,通常为 4KB,为每个进程提供独立的虚拟地址空间,是实现内存隔离和交换的基础。

x86 32 位保护模式使用两级页表:页目录和页表。虚拟地址被拆分为三部分:高 10 位是页目录索引,中间 10 位是页表索引,低 12 位是页内偏移。CPU 先根据 CR3 找到页目录,再通过页目录项找到页表,最后从页表项得到物理页基址,加上页内偏移得到最终物理地址。

页目录项和页表项都是 32 位,格式关键位如下:

位名称含义
P存在位,1 表示该页在物理内存中
R/W是否可写,0 表示只读
U/S用户态是否可访问,0 表示仅内核可访问
A访问位,由 CPU 自动设置
D脏位,表示该页被写过

开启分页需要先将页目录物理地址写入 CR3,再设置 CR0 的 PG 位。开启之后,所有内存地址都会经过页表翻译。为了让内核在开启分页前后都正常工作,最简单的做法是建立恒等映射,也就是让前几兆字节的虚拟地址与物理地址相同。下面是一段开启分页的汇编函数:

global load_page_directory
load_page_directory:
    push ebp
    mov ebp, esp
    mov eax, [ebp + 8]
    mov cr3, eax
    mov eax, cr0
    or eax, 0x80000000
    mov cr0, eax
    pop ebp
    ret

在 C 语言中,页目录和页表都以 1024 项数组的形式组织。初始化时首先把整个数组清零,再为每一页建立映射。一个常用的恒等映射函数如下:

void paging_init(void) {
    for (int i = 0; i < 1024; i++) {
        page_directory[i] = 0x00000002;
        low_page_table[i] = (i * 0x1000) | 0x3;
    }

    page_directory[0] = ((uint32_t)low_page_table) | 0x3;

    load_page_directory((uint32_t)page_directory);
}

这里 0x3 表示「存在 + 可写」;0x2 表示「存在但只读」,用于尚未映射的页目录项。设置完 4MB 恒等映射后调用汇编函数开启分页,整个内核就能继续以相同的地址运行,后续再逐步按需分配和映射页面。

11. 加载 C 语言内核与链接脚本

引导扇区只有 512 字节,不可能容纳完整内核。通常做法是:第一阶段引导扇区加载第二阶段加载器,第二阶段加载器再读取真正的内核文件并跳转到 C 入口。为了简化运行,也可以在引导阶段直接把编译链接好的内核二进制写入镜像固定扇区,再通过磁盘读取到内存。

C 内核的编译目标与普通应用程序不同。内核没有 C 运行库,运行地址也由我们自己决定。链接脚本用于指定内核的加载地址、代码段、数据段和 BSS 段在最终 ELF 或二进制文件中的排布。

创建一个简单的 link.ld

ENTRY(_start)

SECTIONS {
    . = 0x100000;

    .text : ALIGN(4K) {
        *(.text)
    }

    .rodata : ALIGN(4K) {
        *(.rodata)
    }

    .data : ALIGN(4K) {
        *(.data)
    }

    .bss : ALIGN(4K) {
        *(COMMON)
        *(.bss)
    }

    . = ALIGN(4K);
    end = .;
}

链接脚本把内核加载地址设置为 1MB,即 0x100000。这个地址避开了低端内存中 BIOS 数据、VGA 显存等区域,是保护模式内核常用的起始位置。_start 是入口符号,通常由一个小型汇编启动文件提供,该文件负责设置栈并调用 C 函数 kernel_main

[bits 32]
global _start

section .text

_start:
    mov esp, kernel_stack_top
    extern kernel_main
    call kernel_main
.halt:
    hlt
    jmp .halt

section .bss
align 16
kernel_stack_bottom:
    resb 16384
kernel_stack_top:

内核主函数可以这样开始:

void kernel_main(void) {
    gdt_init();
    idt_init();
    pic_remap(0x20, 0x28);
    keyboard_init();
    timer_init();
    sti();

    printk("Kernel loaded successfully!\n");
    for (;;) {
        asm volatile ("hlt");
    }
}

编译时,需要先用 NASM 汇编启动文件,再用 i686-elf-gcc 编译各 C 文件,最后用链接器按 link.ld 生成内核二进制。如果链接过程出现未定义符号 __stack_chk_fail,可以在编译参数中加入 -fno-stack-protector;出现 64 位与 32 位不兼容问题,则确认使用了 i686-elf-gcc 并且带了 -ffreestanding -m32 参数。

12. VGA 文本模式与基础库

内核不能直接使用 printf,需要自己实现 printk。在 VGA 文本模式下,屏幕通常为 80×25 字符,显存基地址是 0xB8000。每个字符占 2 字节:低字节是 ASCII 码,高字节是颜色属性。属性字节高 4 位是背景色,低 4 位是前景色,常见组合如黑底白字 0x0F、黑底绿字 0x0A。

维护两个全局变量:光标横坐标 column 和纵坐标 row。打印字符时先处理换行、退格等特殊字符,再把字符写入对应显存位置。以下代码展示了字符打印与屏幕滚动的实现思路:

static uint8_t column;
static uint8_t row;
static uint16_t *video_memory = (uint16_t *)0xB8000;

static void scroll(void) {
    for (int y = 1; y < 25; y++) {
        for (int x = 0; x < 80; x++) {
            video_memory[(y - 1) * 80 + x] = video_memory[y * 80 + x];
        }
    }
    for (int x = 0; x < 80; x++) {
        video_memory[24 * 80 + x] = make_vga_entry(' ', 0x0F);
    }
}

static void putchar(char c) {
    if (c == '\n') {
        column = 0;
        row++;
    } else if (c == '\r') {
        column = 0;
    } else {
        video_memory[row * 80 + column] = make_vga_entry(c, 0x0F);
        column++;
    }

    if (column >= 80) {
        column = 0;
        row++;
    }
    if (row >= 25) {
        scroll();
        row = 24;
        column = 0;
    }
}

为了支持十六进制输出和格式化字符串,还需要实现 print_hexprint_int 等函数。它们通常由 printk 内部按格式符逐项处理。基础库还需要提供内存操作函数,例如 memsetmemcpystrlenstrcmp,这些函数在后续内存管理和字符串处理中会反复用到。由于内核是 freestanding 环境,标准库头文件不可用,必须自己声明并实现。

显存操作还有一个重要提示:在开启分页之后,0xB8000 仍然需要被映射到物理显存。很多人在分页开启后突然发现屏幕不输出了,就是因为只映射了 1MB~2MB 的内核内存,却没有映射 VGA 显存区域。

13. 物理内存管理

物理内存管理负责追踪每一页物理内存的使用情况,为内核、页表和进程分配空闲页。最直观的实现方式是位图分配器:用一个比特表示一页物理内存,1 表示已使用,0 表示空闲。假设要管理 128MB 内存,每页 4KB,则共有 32768 页,位图大小为 4KB。

位图分配器的核心函数如下:

static uint8_t mem_bitmap[BITMAP_SIZE];

void set_frame(uint32_t addr) {
    uint32_t frame = addr / PAGE_SIZE;
    mem_bitmap[frame / 8] |= (1 << (frame % 8));
}

void clear_frame(uint32_t addr) {
    uint32_t frame = addr / PAGE_SIZE;
    mem_bitmap[frame / 8] &= ~(1 << (frame % 8));
}

bool test_frame(uint32_t addr) {
    uint32_t frame = addr / PAGE_SIZE;
    return mem_bitmap[frame / 8] & (1 << (frame % 8));
}

uint32_t alloc_frame(void) {
    for (uint32_t i = 0; i < TOTAL_FRAMES; i++) {
        if (!(mem_bitmap[i / 8] & (1 << (i % 8)))) {
            mem_bitmap[i / 8] |= (1 << (i % 8));
            return i * PAGE_SIZE;
        }
    }
    return 0;
}

位图分配器实现简单,但每次分配都需要从头扫描,性能一般。更好的方式是使用空闲页链表或伙伴系统,但作为教学内核,位图已经足够直观。无论采用哪种分配器,都必须处理以下边界:保留内核自身占用的内存、保留页目录页表和位图自身所在页,避免分配器把自己的内存又分配出去。

初始化时,需要根据 BIOS 提供的 E820 内存映射确定可用物理内存范围。也可以先固定假设可用内存上限,例如 64MB,再把 0~64MB 全部纳入位图管理,并把内核占用区域提前标记为已使用。后续分配到的物理页通常作为用户进程的代码页、数据页或栈页,由虚拟内存管理器建立映射。

14. 虚拟内存管理初探

物理内存分配器只能返回物理地址,而进程看到的是虚拟地址。虚拟内存管理器负责维护页目录和页表,把虚拟页映射到物理页。每个进程拥有独立的页目录,从而拥有独立的地址空间。内核空间通常采用共享映射,让所有进程的 3GB 以上区域都指向同一份内核页表。

32 位系统常见的地址空间划分是:0~3GB 为用户空间,3GB~4GB 为内核空间。Linux 0.11 等早期系统也是这样设计。简化内核可以暂时只给用户态分配 1GB 空间。为进程创建地址空间时,需要申请一个新的页目录,并把内核部分对应的页目录项复制进去。

我们把页表操作封装成以下函数:

void map_page(page_directory_t *pdir, uint32_t vaddr, uint32_t paddr, uint32_t flags);
void unmap_page(page_directory_t *pdir, uint32_t vaddr);
uint32_t get_page(page_directory_t *pdir, uint32_t vaddr, uint32_t *paddr);

map_page 首先从虚拟地址取出页目录索引和页表索引,检查对应页目录项是否存在;不存在则分配一个新的页表。随后把物理地址与标志位写入页表项。图方便的话,可以先为内核创建恒等映射,用户进程再单独建立映射。这样内核通过自己的地址访问用户内存时,需要临时切换页目录或把用户页表也映射到内核地址空间。

发生缺页时,CPU 会产生 14 号中断。缺页处理程序可以从 CR2 寄存器读取引发异常的虚拟地址,再根据错误码判断缺页原因:0 表示页面不存在,1 表示保护违例,2 表示写只读页,4 表示用户态访问。简单的处理方式是打印错误信息后挂起系统;完整的操作系统则会在此时换入页面、写时复制或终止进程。

15. 进程与任务切换

有了内存管理和中断之后,就可以实现多任务了。进程是资源分配单位,这里我们把进程简化为一个包含地址空间、打开文件和运行上下文的实体。进程控制块 PCB 保存进程 ID、页目录、内核栈指针、状态和上下文等信息。任务切换的本质是保存当前进程的 CPU 寄存器现场,再恢复另一个进程的现场。

一个最小化的上下文结构可以表示为:

typedef struct {
    uint32_t edi;
    uint32_t esi;
    uint32_t ebp;
    uint32_t ebx;
    uint32_t edx;
    uint32_t ecx;
    uint32_t eax;
    uint32_t eip;
} context_t;

之所以不必保存全部段寄存器,是因为内核态下这些寄存器通常是固定的。上下文切换由汇编函数完成:把当前寄存器压入当前进程的内核栈,取出目标进程的内核栈,再从目标栈中弹出寄存器并跳转到其 EIP。大致实现如下:

global switch_context
switch_context:
    mov eax, [esp + 4]
    mov [eax], edi
    mov [eax + 4], esi
    mov [eax + 8], ebp
    mov [eax + 12], ebx
    mov [eax + 16], edx
    mov [eax + 20], ecx
    mov [eax + 24], eax
    mov ecx, [esp]
    mov [eax + 28], ecx
    ret

这段代码把当前任务的上下文保存到第一个参数指向的结构体,并通过修改栈来恢复目标任务的现场。首次创建任务时,需要手工构造上下文,把 EIP 设为任务入口函数,栈指向分配给该任务的内核栈。

真实任务切换通常与时钟中断结合:每当时钟中断到来,调度器判断当前进程的时间片是否用完,如果用完就调用 switch_context 切换到下一个就绪进程。这样多个任务就能轮流占用 CPU。

16. 系统调用

用户态程序不能直接访问内核内存,也不能读写硬件端口。用户程序需要请求内核服务时,必须通过系统调用。在 x86 上常见方式是使用软件中断 int 0x80。用户程序把系统调用号存入 EAX,参数存入 EBX、ECX、EDX 等寄存器,然后执行 int 0x80。内核在 IDT 中注册 0x80 号中断,从寄存器中取出参数并执行相应服务。

内核端处理函数的大致结构如下:

uint32_t syscall_handler(uint32_t eax, uint32_t ebx, uint32_t ecx, uint32_t edx) {
    switch (eax) {
        case SYS_WRITE:
            return sys_write(ebx, (const void *)ecx, edx);
        case SYS_FORK:
            return sys_fork();
        case SYS_EXIT:
            sys_exit(ebx);
            break;
        default:
            return -1;
    }
    return 0;
}

系统调用的难点在于特权级切换。当用户态程序执行 int 0x80 时,CPU 通过中断门切换到内核态,并自动保存用户态的 SS、ESP、EFLAGS、CS、EIP。内核处理完成后执行 iret 返回用户态。入口汇编代码需要保证使用内核栈,并正确传递用户参数。

为了验证系统调用,用户空间可以写一小段代码:把 4 放入 EAX 表示 SYS_WRITE,然后把文件描述符、字符串指针和长度分别放入 EBX、ECX、EDX,最后执行 int 0x80。一旦屏幕上出现来自用户态进程的字符串,就说明用户态、内核态和系统调用链路已经打通。

17. 用户态程序与 ELF 加载

拥有系统调用后,就可以尝试让用户态程序真正运行起来。用户程序由编译器生成 ELF 可执行文件,内核加载器负责读取 ELF 头、解析程序头表,把各段内容拷贝到用户地址空间,设置入口地址,最后通过中断返回或栈构造切换到用户态。

创建用户进程的基本流程如下:

  1. 为进程申请新的页目录和内核栈,创建 PCB。
  2. 将用户程序各段加载到用户虚拟地址空间,按程序头的权限设置页表项。
  3. 创建用户栈,并把程序参数、环境变量等内容写入。
  4. 构造中断返回栈帧,使 iret 返回后进入用户态的代码入口。
  5. 把进程加入就绪队列,等待调度器选择执行。

进入用户态的关键是栈上内容。为了从内核态直接跳转到用户态,我们需要在栈上依次放入用户数据段选择子、用户栈指针、用户态 EFLAGS、用户代码段选择子和入口地址,然后执行 iret。CPU 会检测当前特权级发生变化,并从相应栈帧恢复用户态环境。

; 假设 esp 指向构造好的栈帧
iret

为了保证用户态不能破坏内核,用户代码段和数据段必须使用 DPL=3 的描述符,而内核段使用 DPL=0。页表项也要设置 U/S 位,允许用户态访问用户页面。内核空间的页表项则保持 U/S=0,当用户态访问这些地址时,CPU 会产生页错误或一般保护错误。

如果 ELF 文件较大或格式复杂,可以先从加载扁平的二进制文件开始,等流程跑通后再实现完整的 ELF 解析。这也是很多教学内核采用的渐进策略。

18. 简单文件系统设计

文件系统让数据和程序能够持久化存储。完整实现文件系统非常复杂,但我们可以先设计一个极简的只读文件系统用于加载用户程序。常见教学方案有两种:一种是直接把程序编译后链接到内核里;另一种是在磁盘镜像中放置一个简单的表结构和文件数据区。

一个最小文件系统可以包含:超级块、文件表和数据区。超级块记录文件系统类型、文件数量和数据区起始位置;文件表记录每个文件名、长度、数据偏移;数据区连续存放文件内容。内核启动时从磁盘读取超级块和文件表,访问文件时根据名字查找表项,再读取对应数据。

typedef struct {
    char name[16];
    uint32_t size;
    uint32_t offset;
} file_entry_t;

typedef struct {
    uint32_t magic;
    uint32_t num_files;
    file_entry_t entries[64];
} fs_header_t;

这种结构思路清晰,缺点是文件表固定、无法动态读写。如果希望支持创建和修改文件,就需要引入 FAT 或 inode 结构。FAT 文件系统相对简单,适合作为下一步学习目标;而 inode 结构对理解 Unix 文件系统非常有帮助。

磁盘读写在内核中通过 I/O 端口完成。传统 ATA 硬盘可以使用 PIO 模式读取 LBA 扇区,代码需要依次向 0x1F2~0x1F7 端口写入扇区数、LBA 地址和读写命令,再通过 0x1F0 端口读取 16 位数据循环 256 次,得到一个扇区的 512 字节。由于端口操作依赖具体硬件,开发时最好先抽象出 disk_read_sector 接口,后续再替换为更强壮的驱动。

19. 常见坑与调试技巧

手写操作系统中最耗时的往往不是编写功能代码,而是排查那些难以复现的启动失败。下面列出几个高频问题。

问题一:QEMU 提示不是可启动设备。 这通常是因为镜像不是 512 字节的整数倍,或者引导扇区末尾没有 0x55AA。可以先检查镜像大小,再用 hexdump -C os.img | tail 查看最后几个字节。

问题二:进入保护模式后重启。 常见原因包括:忘记在设置 PE 位后用远跳转刷新 CS;GDT 描述符定义错误;段寄存器装载了越界的选择子;或者栈没有被正确设置。可以在关键代码前后向显存写不同字符,以此判断程序执行到了哪一步。

问题三:链接后符号未定义。 例如 undefined reference to __stack_chk_fail,原因是编译器自动插入了栈保护代码。解决办法是编译时加 -fno-stack-protector -fno-builtin -ffreestanding

问题四:分页后屏幕不再输出。 几乎都是因为没有映射 VGA 显存或页目录、页表位置错误。可以临时用 print_hex 输出 CR3 和控制寄存器值。

调试工具方面,QEMU 自带的 -d int-d cpu_reset 可以打印中断和 CPU 复位日志;-s -S 可以让 QEMU 暂停并监听 GDB 远程调试端口。配合 GDB 的 target remote localhost:1234,可以设置断点、查看寄存器和单步执行,这对定位保护模式切换和缺页问题非常有帮助。

建议养成小步快跑的习惯:每加入一个功能就及时在 QEMU 中验证,不要等几千行代码写完再一次性调试。每次构建后都保留一个可正常启动的版本,出问题时用二分法快速缩小范围。

20. 总结与进阶方向

到这里,我们已经完成了一条完整的操作系统构建路径:从 BIOS 启动、引导扇区、实模式、保护模式、GDT、IDT、分页、内存管理,到多任务、系统调用和用户态。虽然这个内核还非常简陋,但它已经具备了操作系统最核心的骨架。理解这些机制后,再去阅读 Linux 0.11 或 xv6 的源码,会发现很多熟悉的概念。

如果希望继续深入,可以从以下几个方向展开:

  • 更完善的进程模型:实现 fork、exec、wait 等系统调用,让用户进程可以创建子进程和替换程序映像。
  • 完整的内存管理:加入页表按需映射、写时复制、内存回收和内核堆管理器。
  • 块设备与文件系统:实现 ATA 驱动、FAT 或 inode 文件系统,支持文件的读、写、创建和删除。
  • 图形界面:从 VGA 模式切换到更高分辨率,实现简单的窗口和输入系统。
  • 网络协议栈:驱动网卡,实现以太网帧、ARP、IP、TCP 等协议。
  • 移交给 64 位:在 x86-64 架构上重建启动流程、GDT/IDT 和分页映射,理解长模式。

手搓操作系统的价值,在于把「操作系统原理」从概念变成可运行、可观察的代码。每一个难以理解的抽象,最终都会在一次缺页、一次保护错误、一次任务切换中变得具体。保持耐心,小步验证,你会逐渐体会到从裸机到系统的乐趣。

本文的示例代码为教学用途而做了充分简化,重在说明核心流程。真实生产系统需要考虑同步、安全、性能、异常恢复和大量硬件兼容问题,但原理与本文一脉相承。

Logo

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

更多推荐