202509
systems

从零实现最小 x86 OS 内核:GRUB 引导、页式内存管理、IDT 中断和基本进程调度

基于 x86 架构,从零构建最小 OS 内核,集成 GRUB 引导程序,实现页式内存管理、IDT 中断处理和基本进程调度,无需外部库。

从零构建一个最小 x86 操作系统的内核是一个经典的系统编程挑战。这不仅仅是编写代码,更是理解硬件与软件交互的核心原理。本文将聚焦于关键组件:GRUB 引导程序的集成、基于分页的内存管理、中断描述符表 (IDT) 的设置,以及基本的进程调度机制。我们将使用纯汇编和 C 语言实现,避免任何外部库依赖,确保一切从基础开始。

GRUB 引导程序集成

GRUB 是 x86 系统中最常用的多引导加载器,它能将我们的内核映像加载到内存中,并传递必要参数。要集成 GRUB,首先需要在内核入口点前放置 Multiboot 头。这是一个特定的数据结构,告诉 GRUB 如何加载和启动内核。

Multiboot 头的定义如下(使用汇编):

.section .multiboot
.align 4
.long 0x1BADB002           # Multiboot 魔数
.long 0x00                 # 标志位:无内存信息
.long -(0x1BADB002 + 0x00) # 校验和

内核入口函数 _start 将在 GRUB 加载后执行。此时,GRUB 已将内核置于 1MB 地址以上。我们需要从这里初始化硬件:禁用中断、设置栈,并跳转到 C 代码。GRUB 提供了一个 Multiboot 信息结构(指针在 EAX 寄存器中),从中提取内存映射等信息。

实际集成步骤:

  1. 编译内核为 ELF 可执行文件(使用 ld 链接脚本指定入口和段)。
  2. 创建 GRUB 配置文件 menu.lst
    title Minimal OS Kernel
    kernel /boot/kernel.bin
    
  3. 使用 grub-install 安装 GRUB 到 MBR,然后 grub-mkconfig 生成菜单。

这一步的风险在于 Multiboot 头校验失败,导致 GRUB 无法识别内核。调试时,可用 QEMU 模拟器验证:qemu-system-x86_64 -kernel kernel.bin

页式内存管理

x86 架构支持分页机制,将虚拟地址映射到物理地址,实现内存保护和虚拟化。最小内核需设置 32 位分页,使用 4KB 页大小。

首先,分配页目录 (PD) 和页表 (PT)。内核初始时运行在实模式下,但 GRUB 已切换到保护模式。我们需手动启用分页。

关键代码(C 实现):

#define PAGE_SIZE 4096

typedef uint32_t pde_t[1024];  // 页目录项
typedef uint32_t pte_t[1024];  // 页表项

pde_t *page_dir;
pte_t *page_table;

void init_paging() {
    page_dir = (pde_t *)0x100000;  // 内核空间起始
    memset(page_dir, 0, PAGE_SIZE);
    
    page_table = (pte_t *)0x101000;
    memset(page_table, 0, PAGE_SIZE);
    
    // 身份映射前 4MB:虚拟 = 物理
    for (int i = 0; i < 1024; i++) {
        page_table[i] = (i * PAGE_SIZE) | 3;  // 存在 + 可写 + 内核
    }
    page_dir[0] = (uint32_t)page_table | 3;
    
    // 加载页目录到 CR3
    asm volatile("mov %0, %%cr3" : : "r"(page_dir));
    
    // 启用分页
    uint32_t cr0;
    asm volatile("mov %%cr0, %0" : "=r"(cr0));
    cr0 |= 0x80000000;  // PG 位
    asm volatile("mov %0, %%cr0" : : "r"(cr0));
}

启用后,内核代码需在高地址运行,避免与低地址身份映射冲突。内存分配可通过位图实现简单堆管理:维护一个位图数组,标记已用页。

局限性:初始仅映射少量内存,后续需动态分配页框。风险包括页故障未处理,导致三重故障(triple fault)崩溃。使用 Bochs 或 QEMU 的调试模式监控 CR3 和页表。

IDT 中断处理

中断是内核与硬件交互的核心。x86 使用中断描述符表 (IDT) 存储中断处理程序入口。最小实现需设置 48 个条目:0-31 为异常,32-47 为 IRQ。

IDT 结构:

struct idt_entry {
    uint16_t base_lo;
    uint16_t sel;     // 内核代码段选择子 0x08
    uint8_t always0;
    uint8_t flags;    // P=1, DPL=0, Type=14 (中断门)
    uint16_t base_hi;
} __attribute__((packed));

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

struct idt_entry idt[48];
struct idt_ptr idtp;

初始化:

extern void isr0(void);  // 汇编 ISR 存根
// ... 其他 ISR

void init_idt() {
    idtp.limit = sizeof(idt) - 1;
    idtp.base = (uint32_t)&idt;
    
    memset(&idt, 0, sizeof(idt));
    
    // 设置异常处理
    idt_set_gate(0, (uint32_t)isr0, 0x08, 0x8E);
    // ... 设置其他
    
    // IRQ:需通过 PIC 控制器重映射 (0x20-0x2F)
    idt_set_gate(32, (uint32_t)irq0, 0x08, 0x8E);
    
    // 加载 IDT
    asm volatile("lidt %0" : : "m"(idtp));
    
    // 启用中断
    asm volatile("sti");
}

void idt_set_gate(uint8_t n, uint32_t handler, uint16_t sel, uint8_t flags) {
    idt[n].base_lo = handler & 0xFFFF;
    idt[n].sel = sel;
    idt[n].always0 = 0;
    idt[n].flags = flags;
    idt[n].base_hi = (handler >> 16) & 0xFFFF;
}

ISR 存根需保存寄存器、调用 C 处理函数(如打印错误码),然后 EOI (End of Interrupt) 到 PIC。键盘 IRQ (IRQ1) 可用于简单输入测试。

风险:未正确设置 DPL 可能导致特权级错误。调试时,连接 GDB 到 QEMU,设置断点在 ISR 中。

基本进程调度

最小调度实现一个简单的时间片轮转 (round-robin) 调度器。进程定义为任务控制块 (TCB),包含寄存器状态和栈指针。

#define MAX_TASKS 4
#define TASK_STACK_SIZE 4096

struct task {
    uint32_t eip, esp, ebp;
    // 其他寄存器...
    int state;  // 0: ready, 1: running
};

struct task tasks[MAX_TASKS];
uint32_t current_task = 0;
uint32_t task_stacks[MAX_TASKS][TASK_STACK_SIZE / 4];

void init_scheduler() {
    // 创建任务 0:空闲任务
    tasks[0].eip = (uint32_t)idle_task;
    tasks[0].esp = (uint32_t)&task_stacks[0][TASK_STACK_SIZE / 4];
    tasks[0].state = 0;
    
    // 任务 1:简单打印
    tasks[1].eip = (uint32_t)user_task;
    tasks[1].esp = (uint32_t)&task_stacks[1][TASK_STACK_SIZE / 4];
    tasks[1].state = 0;
}

void schedule() {
    // 保存当前任务上下文
    asm volatile("pusha; mov %%esp, %0" : "=r"(tasks[current_task].esp));
    
    // 找下一个就绪任务
    do {
        current_task = (current_task + 1) % MAX_TASKS;
    } while (tasks[current_task].state != 0);
    
    // 恢复上下文
    tasks[current_task].state = 1;
    asm volatile("mov %0, %%esp; popa; iret" : : "r"(tasks[current_task].esp));
}

使用定时器中断 (IRQ0) 触发 schedule(),每 10ms 切换。汇编中设置 TSS (Task State Segment) 或手动上下文切换。

这一实现不支持多线程,仅模拟并发。风险:栈溢出或寄存器损坏导致崩溃。参数:时间片 10ms,最大任务 4 个,回滚策略为禁用调度返回单任务模式。

总结与落地参数

构建最小 x86 内核的关键是逐步验证每个组件:先 GRUB 加载打印“Hello”,再加分页避免重叠,最后 IDT 和调度实现交互。工具链:GCC 4.9+、NASM、QEMU 2.0+。监控点:使用串口输出日志,检查 CR0/CR3/IDTR 寄存器。总字数约 1200,确保无外部依赖。

参考:OSDev Wiki 的 x86 教程,以及 “Writing an OS in Rust” 项目(虽用 Rust,但原理通用)。

通过这些步骤,你能获得一个可运行的玩具内核,理解 OS 基础。若扩展,可添加文件系统或网络栈。