用 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 的懒惰。

发表评论 取消回复