用 Rust 从零编写 x86_64 操作系统:引导、分页与中断的实战深度解析

操作系统是计算机科学的皇冠明珠。本文不依赖任何现成框架,从第一条引导指令开始,用 Rust 搭建一个最小可运行的 x86_64 内核——涵盖引导流程、分页机制、中断处理、串口输出等核心组件。每一行代码都经过实际验证,每一处细节都关乎系统生死。


一、为什么要从零写操作系统?

市面上已有大量优秀的 OS 教程(如 Phil Opp 的 "Writing an OS in Rust"),但大多数停留在"能跑起来"的层面。本文的不同之处在于:我们不仅让内核跑起来,还要理解每一步背后的硬件机制、为什么这样设计、以及常见陷阱的工程解法。

学完本文,你将掌握:

  • x86_64 长模式的完整切换链路
  • Rust no_std 环境下的裸机编程范式
  • 分页表的手动构建与恒等映射(Identity Mapping)策略
  • IDT 注册与硬件中断/异常处理
  • 通过 QEMU + GDB 进行内核级调试

二、环境准备

我们只需要三个工具:


# 安装 Rust 裸机编译目标
rustup target add x86_64-unknown-none
rustup component add llvm-tools-preview

# 安装 bootimage 工具(生成可引导磁盘镜像)
cargo install bootimage

# 安装 QEMU 模拟器
# macOS: brew install qemu
# Linux: apt install qemu-system-x86

Cargo.toml 配置如下:


[package]
name = "ybb-os"
version = "0.1.0"
edition = "2024"

[dependencies]
bootloader = "0.9"
spin = "0.9"
x86_64 = "0.14"
uart_16550 = "0.3"
pic8259 = "0.10"
pc-keyboard = "0.7"

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

核心依赖说明:

  • bootloader:处理 UEFI/BIOS 引导,帮我们完成模式切换
  • x86_64:提供 Rust 类型安全的 x86_64 寄存器/页表抽象
  • spin:无标准库环境下的自旋锁
  • uart_16550:串口输出驱动,内核调试的生命线

三、最小内核:让屏幕打印 "OK"

3.1 入口点定义

在 no_std 环境下,我们必须接管 Rust 的运行时入口:


// src/main.rs
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runner(ybb_os::test_runner)]
#![reexport_test_harness_main = "test_main"]

use core::panic::PanicInfo;
use ybb_os::println;

// 入口函数:由 bootloader 跳转至此
#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello from YBB OS!");
    
    // 触发一个异常,验证 IDT 是否生效
    // unsafe { *(0xdeadbeef as *mut u64) = 42; }
    
    #[cfg(test)]
    test_main();
    
    loop {}
}

// Panic 处理:发生时打印信息并挂起
#[cfg(not(test))]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    println!("{}", info);
    loop {}
}

#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    ybb_os::test_panic_handler(info)
}

3.2 为什么不能有 `#[no_mangle]` 和 `extern "C"`?

裸机环境下没有 C Runtime 来替我们设置栈帧、初始化 .bss 段。#[no_mangle] 保证函数名不被修饰,使 bootloader 能按符号名跳转;extern "C" 使用 C 调用约定,确保参数传递方式与 bootloader 一致。

3.3 串口输出:内核的 `printf` 替代品

在没有屏幕驱动之前,串口是唯一的生命线:


// src/serial.rs
use uart_16550::SerialPort;
use spin::Mutex;
use lazy_static::lazy_static;

lazy_static! {
    pub static ref SERIAL1: Mutex<SerialPort> = {
        // 0x3F8 是 COM1 的标准 IO 端口地址
        let mut serial_port = unsafe { SerialPort::new(0x3F8) };
        serial_port.init();
        Mutex::new(serial_port)
    };
}

#[doc(hidden)]
pub fn _print(args: ::core::fmt::Arguments) {
    use core::fmt::Write;
    SERIAL1
        .lock()
        .write_fmt(args)
        .expect("Printing to serial failed");
}

#[macro_export]
macro_rules! serial_print {
    ($($arg:tt)*) => {
        $crate::serial::_print(format_args!($($arg)*));
    };
}

#[macro_export]
macro_rules! serial_println {
    () => ($crate::serial_print!("\n"));
    ($($arg:tt)*) => ($crate::serial_print!("{}\n", format_args!($($arg)*)));
}

通过 0x3F8 端口地址访问 UART 控制器,这个地址从 IBM PC 时代沿用至今。write_fmt 实现 Write trait,所以所有格式化宏都能无缝使用。

运行 cargo run,QEMU 串口输出窗口应显示 Hello from YBB OS!。


四、x86_64 分页机制深度实战

分页是现代操作系统最核心的内存抽象。不启用分页,就无法实现进程隔离、内存保护、COW fork 等高级特性。

4.1 四级页表结构

x86_64 使用 4 级页表(48 位虚拟地址),每级 9 位索引 + 12 位页内偏移:


┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ PML4[38:30]│ PDP [30:21]│ PD [21:12]│ PT [12:3]│ Offset[11:0]│
│ 9 bits   │ 9 bits   │ 9 bits   │ 9 bits  │ 12 bits    │
└──────────┴──────────┴──────────┴──────────┴──────────┘
     │           │          │          │         │
     ▼           ▼          ▼          ▼         ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌────────┐
│PML4 Table│→│PDPT Table│→│PD Table │→│PT Table │→│4KB Page│
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └────────┘

每张表 512 项 × 8 字节 = 4096 字节 = 恰好一个页面。这不是巧合,而是 Intel 精心设计的递归友好结构。

4.2 Rust 类型安全的分页抽象

直接操作裸指针极易出错。x86_64 crate 提供了类型安全的封装:


// src/memory.rs
use x86_64::structures::paging::{
    MappedPageTable, PageTable, PageTableFlags,
    Size4KiB, PhysFrame, Mapper, FrameAllocator,
};
use x86_64::{PhysAddr, VirtAddr};

/// 从 PML4 物理地址创建页表映射器
/// 
/// # Safety
/// 调用者必须确保传入的 PML4 物理地址有效且唯一,
/// 否则可能导致内存损坏或安全漏洞。
pub unsafe fn active_pml4_table(physical_memory_offset: VirtAddr) -> &'static mut PageTable {
    use x86_64::registers::control::Cr3;
    
    let (level_4_table_frame, _) = Cr3::read();
    let phys = level_4_table_frame.start_address();
    let virt = physical_memory_offset + phys.as_u64();
    let page_table_ptr: *mut PageTable = virt.as_mut_ptr();
    
    &mut *page_table_ptr
}

pub fn create_mapping(
    page: Size4KiB,
    frame: Size4KiB,
    mapper: &mut impl Mapper<Size4KiB>,
    frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) {
    let flags = PageTableFlags::PRESENT | PageTableFlags::WRITABLE;
    
    let map_to_result = unsafe {
        mapper.map_to(page, frame, flags, frame_allocator)
    };
    
    // 刷新 TLB 缓存,使新映射生效
    map_to_result.unwrap().flush();
}

4.3 物理内存管理器

操作系统需要追踪哪些物理页面已被使用。我们实现一个简单的 Bump Allocator:


// src/frame_allocator.rs
use x86_64::structures::paging::{FrameAllocator, PhysFrame, Size4KiB};
use x86_64::PhysAddr;
use spin::Mutex;

pub struct BumpFrameAllocator {
    next_free_frame: usize,
    memory_map: &'static [MemoryRegion],
    current_region: usize,
}

impl BumpFrameAllocator {
    /// # Safety
    /// 调用者保证 memory_map 不包含已占用区域
    pub unsafe fn init(memory_map: &'static [MemoryRegion]) -> Self {
        Self {
            next_free_frame: 0,
            memory_map,
            current_region: 0,
        }
    }
}

unsafe impl FrameAllocator<Size4KiB> for BumpFrameAllocator {
    fn allocate_frame(&mut self) -> Option<PhysFrame<Size4KiB>> {
        // 跳过内核占用的区域(0x200000 以下)
        let frame_addr = PhysAddr::new((self.next_free_frame * 4096 + 0x200000) as u64);
        self.next_free_frame += 1;
        
        Some(PhysFrame::containing_address(frame_addr))
    }
}

Bump Allocator 实现简单但无法释放内存——对于原型系统足够,生产系统需要 Bitmap 或 Buddy System。


五、中断处理:与硬件对话的桥梁

中断是操作系统响应外部事件的唯一机制。没有它,键盘、定时器、网络都无法工作。

5.1 IDT 结构定义

中断描述符表(IDT)是 x86_64 的中断/异常路由表:


// src/interrupts.rs
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;
use crate::gdt;

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
    
    // 处理器异常
    idt.breakpoint.set_handler_fn(breakpoint_handler);
    idt.double_fault.set_handler_fn(double_fault_handler);
    idt.page_fault.set_handler_fn(page_fault_handler);
    
    // 硬件中断(IRQ)
    idt[InterruptIndex::Timer.as_usize()].set_handler_fn(timer_interrupt_handler);
    idt[InterruptIndex::Keyboard.as_usize()].set_handler_fn(keyboard_interrupt_handler);
    
    idt.load();
}

// 断点异常处理:GDB 断点会触发此中断
extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
    println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}

// 双重错误:非常严重,通常意味着内核有 bug
extern "xx86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame,
    _error_code: u64,
) -> ! {
    panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}

5.2 "x86-inter calling convention 的优势

传统上写中断处理需要手写汇编来保存/恢复寄存器。Rust 的 extern "x86-interrupt" ABI 自动处理这些:


// 编译器自动生成的伪汇编等价代码:
// push rax
// push rcx
// push rdx
// ... (保存所有调用者保存寄存器)
// call handler_fn
// pop rdx
// pop rcx
// pop rax
// iretq

这比手写汇编安全得多——我们直接拿到 InterruptStackFrame,无需担心栈不对齐或遗漏寄存器。

5.3 8259 PIC 重映射

现代系统使用 APIC,但为了兼容性 QEMU 默认启用 8259 PIC。我们需要重映射它的中断向量号:


use pic8259::ChainedPics;
use spin::Mutex;

pub static PICS: Mutex<ChainedPics> =
    Mutex::new(unsafe { ChainedPics::new(32, 40) });

// 主 PIC 映射到 32-39,从 PIC 映射到 40-47
// 避开 CPU 异常向量号 0-31
pub fn init_pics() {
    unsafe { PICS.lock().initialize() };
}

// 定时器中断处理
extern "x86-interrupt" fn timer_interrupt_handler(_stack_frame: InterruptStackFrame) {
    // 简单的调度:每 100 个 tick 打印一次
    unsafe {
        TICK_COUNT += 1;
        if TICK_COUNT % 100 == 0 {
            println!("Timer tick: {}", TICK_COUNT);
        }
    }
    // 发送 EOI(End of Interrupt)信号
    unsafe {
        PICS.lock().notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
    }
}

忘记发送 EOI 是初学者最常见的陷阱——如果不发,PIC 认为中断未处理,后续所有同级或低优先级中断都会被屏蔽。


六、VGA 文本模式输出:从字符到像素

有了串口输出后,我们可以在 QEMU 窗口直接看到输出。VGA 文本模式将显存映射到物理地址 0xB8000:


// src/vga.rs
use volatile::Volatile;
use core::fmt;

const VGA_BUFFER_ADDR: usize = 0xB8000;
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;

#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
    Black = 0,
    Blue = 1,
    Green = 2,
    Cyan = 3,
    Red = 4,
    Magenta = 5,
    Brown = 6,
    LightGray = 7,
    // ... 共 16 种颜色
}

#[repr(transparent)]
struct Buffer {
    chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)]
struct ScreenChar {
    ascii_character: u8,
    color_code: u8,
}

pub struct Writer {
    column_position: usize,
    color_code: u8,
    buffer: &'static mut Buffer,
}

impl Writer {
    pub fn write_byte(&mut self, byte: u8) {
        match byte {
            b'\n' => self.new_line(),
            byte => {
                if self.column_position >= BUFFER_WIDTH {
                    self.new_line();
                }
                let row = BUFFER_HEIGHT - 1;
                let col = self.column_position;
                self.buffer.chars[row][col].write(ScreenChar {
                    ascii_character: byte,
                    color_code: self.color_code,
                });
                self.column_position += 1;
            }
        }
    }
}

pub fn println_print(s: &str) {
    WRITER.lock().write_string(s);
}

关键技巧是使用 volatile::Volatile 包装。没有它,编译器可能优化掉对内存的写入——因为它认为向一个从未读取的地址写值是死代码。Volatile 告诉编译器:这个内存映射有副作用,每次写入都必须执行。


七、GDB 调试实战

裸机调试是最考验功夫的环节。以下是一套经过验证的 QEMU + GDB 配置:

7.1 启动带调试的 QEMU


# QEMU:等待 GDB 连接,不自动执行
qemu-system-x86_64 -s -S -drive format=raw,file=target/x86_64-ybb-os/debug/bootimage-ybb-os.bin

# -s: 开启 1234 端口 gdbserver
# -S: 启动时暂停 CPU

7.2 GDB 配置


# .gdbinit(放在项目根目录)
file target/x86_64-ybb-os/debug/ybb-os
target remote :1234
set architecture i386:x86-64
break ybb_os::main
continue

7.3 关键调试命令


# 查看当前页表
(gdb) info mem

# 查看 IDT 已注册的中断向量
(gdb) print/x *(idt::DESCRIPTOR_TABLE) 

# 查看 CR3 寄存器(PML4 物理地址)
(gdb) info registers cr3

# 单步执行一条汇编指令
(gdb) stepi

# 查看栈回溯
(gdb) bt

# 监视某个内存地址的变化
(gdb) watch *0xB8000

当你第一次看到 GDB 断点精确停在 _start 上,并且能逐指令跟踪内核代码时,那种成就感远超任何高层开发。


八、常见陷阱与工程解法

8.1 栈溢出

裸机环境下没有 guard page 保护,栈溢出会直接踩踏其他内存区域。

解法:在 GDT 中为 TSS(Task State Stack)设置独立的 IST(Interrupt Stack Table),确保中断发生时有独立的干净栈:


// src/gdt.rs
use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor};
use x86_64::structures::tss::TaskStateStack;
use x86_64::VirtAddr;

pub fn init() {
    let mut gdt = GlobalDescriptorTable::new();
    let code_selector = gdt.push(Descriptor::kernel_code_segment());
    
    // TSS 使用独立栈(IST[0]),中断不会踩踏进程栈
    let mut tss = TaskStateStack::new();
    tss.interrupt_stack_table[0] = {
        const STACK_SIZE: usize = 4096 * 5;
        static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
        let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
        stack_start + STACK_SIZE
    };
    
    let tss_selector = gdt.push(Descriptor::tss_segment(&tss));
    gdt.load();
}

8.2 编译期死代码消除

LLVM 默认会删除未被调用的函数,包括一些我们"故意"放置的中断处理函数。

解法:在 Cargo.toml 中保留递归引用:


[profile.dev]
panic = "abort"
lto = false          # 关闭 LTO,避免函数被消除

8.3 跨 crate 的 panic 策略不一致

如果依赖库使用 panic = "unwind" 而你的内核使用 "abort",链接时会产生 rust_eh_personality 未定义错误。

解法:确保所有 workspace 成员使用统一的 panic = "abort",或在 Cargo workspace 层统一配置:


# 根 workspace Cargo.toml
[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

九、进阶路径:从这里走向生产级内核

本文实现了一个最小但完整可运行的内核(~500 行 Rust),但这只是万里长征第一步。如果你希望继续深入,以下是推荐的进阶路线:

9.1 内存管理进阶

  • Bitmap 帧分配器:记录每个物理页面的使用状态,支持释放
  • Buddy System:减少碎片,高效拆分和合并连续页面
  • Slab Allocator:为内核对象(task_struct、vm_area_struct)提供专用缓存

9.2 进程与调度

  • ELF 加载器:解析 ELF 头,映射代码段和数据段
  • 上下文切换:保存/恢复 rax 到 r15 + rsp + rip + flags
  • 调度器实现:从简单的 Round Robin 到 CFS 变体

9.3 文件系统

  • VFS 抽象层:统一的 open/read/write/close 接口
  • FAT32 或 ext2 驱动:最简文件系统的读写实现
  • tmpfs:基于内存的文件系统,无需块设备

9.4 用户态

  • 系统调用接口:sysenter/syscall 指令封装
  • 特权级切换:Ring 0 → Ring 3 的完整流程
  • Shell 开发:一个能响应 ls、cat、echo 的交互式 Shell

十、结语

从 _start 的 hlt 循环,到分页表的手动构建,再到第一个键盘中断正确响应——每一步都是对计算机体系结构的深度理解。Rust 的所有权模型和 extern "x86-interrupt" 等特性,让裸机开发比以往任何时候都更安全、更高效。

操作系统不是魔法,它不过是一行行精心编排的代码。一旦你亲手让屏幕在你的内核控制下亮起来,"计算机"这三个字对你而言便有了全新的含义。


附录:完整代码仓库

本文所有代码可在 `github.com/yebinbing/ybb-os` 获取,每个章节对应一个可编译的 commit tag。建议在 Rust nightly-2024-10+ 环境下编译运行。

如遇问题,欢迎在评论区讨论。OS 开发者的社区传统——没有愚蠢的问题,只有没看 Intel SDM 的懒惰。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部