1.19 从 rCore 看内核:任务、地址空间与文件系统
一个「应用需求驱动」的 OS 是怎样一步步长出来的?从裸机打印到批处理、多任务、地址空间、进程、文件系统,每一步新增了什么硬件机制、什么内核数据结构、什么 Rust 类型?rCore 的 chapter1–8 主线如何对应我们第 3 篇的 Linux 实现?
画出 rCore 的启动→trap→任务→页表→进程→FS 主线;解释每个阶段的「应用需求」;把 rCore 的概念映射到 Linux 的 `task_struct/mm/vfs`;知道下一步读哪些源码。
本章参考清华 rCore 教程 chapter1–8 的组织方式,不摘录正文、不复制代码;rCore 为 GPLv3,完整实现请到原仓库学习。本章用我们自己的 myrcore 叙事与独立示例。
1.19.1 一条主线:八个阶段与五个问题
rCore 教程最有价值的地方不是某个数据结构,而是顺序:它把「应用需要什么」放在每一章的开头,然后才引入硬件机制和内核抽象。我们把它压缩成八个阶段,并换成自己的 myrcore 叙事:
| 阶段 | 应用需求 | 新增硬件机制 | 新增内核抽象 | myrcore 的 Rust 类型 | 典型失败 |
|---|---|---|---|---|---|
| 1 裸机 | 打印、计算、关机 | SBI、UART、CLINT | 入口、栈、trap 向量 | TrapFrame、&'static [u8] | 链接地址错、栈未设 |
| 2 批处理 | 应用不能改内核 | U/S 特权级、ecall | 应用加载器、系统调用 | AppRef、UserContext | sepc 未 +4 |
| 3 多任务 | 交替执行、主动让出 | 内核栈、sscratch | 任务、调度器、yield | TaskContext、Arc<SpinLock<TCB>> | 切换时栈没换 |
| 4 抢占 | 公平、交互 | 时钟中断、STIE | 时间片、tick | AtomicUsize、Timer | 忘了重设 mtimecmp |
| 5 地址空间 | 隔离、按需分配 | Sv39、satp、TLB | 页表、MapArea、MemorySet | PageTable、MapPermission | PTE 的 V/R/W/X 配错 |
| 6 进程 | fork/exec/wait | 页表切换、COW | PCB、pid、父子关系 | Arc<Mutex<Process>>、Weak | Arc 循环、死锁 |
| 7 文件系统 | 持久化、命名 | 块设备、virtio | inode、目录项、fd 表 | Arc<Inode>、dyn File | 目录项越界、块号错 |
| 8 IPC/并发 | 协作、共享 | IPI、原子指令 | pipe、signal、线程、锁 | Arc<Semaphore>、Condvar | 竞态、丢失唤醒 |
- 应用怎么用:它看到的接口是什么(系统调用、文件、信号)?
- 硬件给什么:特权级、CSR、页表、中断、原子指令中的哪一件?
- 内核存什么:新结构体的字段、谁分配、谁释放、什么时候改?
- Rust 怎么表达所有权:用
&mut、Arc、Weak、UnsafeCell,还是只能unsafe? - 怎么验证:QEMU 输出、GDB 寄存器、
objdump反汇编、/proc或自写测试,哪一个是证据?
rCore 的章节与我们的落点对应如下(只对照组织,不复制正文/代码):
| rCore 章节 | 主题 | 我们的落点 | 本章保留的核心概念 |
|---|---|---|---|
| chapter1 | 裸机、移除 std、SBI | 1.19.2 | 入口、链接、栈、SBI |
| chapter2 | 特权级、批处理、trap | 1.19.2 | U/S、ecall、应用加载 |
| chapter3 | 多道程序、任务切换、分时 | 1.19.3 | TaskContext、__switch |
| chapter4 | 动态分配、Sv39、地址空间 | 1.19.4 | 页表、MapArea |
| chapter5 | 进程、fork/exec/wait、调度 | 1.19.5 | PCB、pid、调度 |
| chapter6 | easy-fs、inode、块缓存 | 1.19.6 | 磁盘布局、fd |
| chapter7 | fd、pipe、参数、重定向、信号 | 1.19.7 | File trait、pipe |
| chapter8 | 线程、锁、信号量、条件变量 | 1.19.7 | 同步原语 |
1.19.2 阶段 1–2:从裸机到批处理
应用需求:把一段用户代码放到内存里跑,它不能改内核,做完要能退出。硬件机制:U/S 两个特权级 + ecall + sret。内核抽象:应用镜像、加载器、系统调用分发。
应用镜像放在哪
批处理内核通常把每个应用链接到固定虚拟地址,例如 0x8040_0000,彼此不重叠;内核在 0x8020_0000。应用 ELF 的入口就是它的第一条指令,内核只需要把 .text/.data 复制到目标地址,然后 sret 到入口。
0x8020_0000 内核 .text/.rodata/.data/.bss 0x8040_0000 app0 的 .text/.rodata/.data/.bss 0x8060_0000 app1 的 .text/.rodata/.data/.bss
应用链接脚本把 BASE_ADDRESS 设成 0x8040_0000,入口 _start 里只做一件事:把系统调用号放进 a7,参数放进 a0–a5,ecall。它不需要知道内核在哪,也不需要链接 libc。
加载器与第一次 sret
内核启动后,先把应用字节复制到 APP_BASE,再设置 sepc 和 sstatus:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 1 | copy_nonoverlapping(src, APP_BASE, len) | 把应用放到约定地址 |
| 2 | csrw stvec, trap_entry | 装好 U→S 的入口 |
| 3 | csrw sscratch, sp | 保存内核栈顶,trap 时交换 |
| 4 | sstatus.SPP=0, SPIE=1, SIE=0 | 返回目标是 U,返回后开中断 |
| 5 | csrw sepc, APP_BASE | 返回地址 = 应用入口 |
| 6 | sret | 降级到 U,开始跑应用 |
这六步里最容易错的是第 3 步。sret 到 U 后,sp 还是内核栈;如果 U 态代码一用栈,就会踩内核数据。正确做法是给 U 态准备独立栈,并在 sret 前把 sp 换过去;trap 入口再用 sscratch 换回内核栈。rCore 在 chapter2 用「每个应用一个内核栈 + 用户栈」解决这个问题,我们沿用同样的思路,但类型和命名独立设计。
完整程序:myrcore 批处理内核
这个程序把一段 8 字节的应用(li a7,2; ecall)加载到 0x8040_0000,在 U 态执行,ecall 回到 S 态后打印并关机。它包含 main.rs、entry.asm、trap.asm、linker.ld 四个文件,可以直接 cargo build。
#![allow(unused)]
fn main() {
// src/main.rs:myrcore 批处理内核(阶段 2)
#![no_std]
#![no_main]
use core::arch::{asm, global_asm};
use core::fmt::{self, Write};
use core::panic::PanicInfo;
global_asm!(include_str!("entry.asm"));
global_asm!(include_str!("trap.asm"));
const APP_BASE: usize = 0x8040_0000;
const SBI_PUTCHAR: usize = 0x01;
const SBI_SRST: usize = 0x5352_5354;
// 应用机器码:addi a7, zero, 2; ecall
static APP: [u8; 8] = [
0x93, 0x08, 0x20, 0x00, // addi a7, zero, 2
0x73, 0x00, 0x00, 0x00, // ecall
];
extern "C" {
fn __s_trap_entry();
}
#[inline(always)]
fn sbi_call(eid: usize, fid: usize, a0: usize, a1: usize) -> (isize, usize) {
let (err, val): (isize, usize);
unsafe {
asm!("ecall", in("a7") eid, in("a6") fid,
inlateout("a0") a0 => err, inlateout("a1") a1 => val,
options(nostack));
}
(err, val)
}
struct Console;
impl Write for Console {
fn write_str(&mut self, s: &str) -> fmt::Result {
for b in s.bytes() { let _ = sbi_call(SBI_PUTCHAR, 0, b as usize, 0); }
Ok(())
}
}
pub fn print_fmt(args: fmt::Arguments) {
let mut c = Console;
c.write_fmt(args).unwrap();
}
#[macro_export]
macro_rules! print {
($($arg:tt)*) => { $crate::print_fmt(format_args!($($arg)*)) };
}
#[macro_export]
macro_rules! println {
() => { $crate::print!("\n") };
($($arg:tt)*) => { $crate::print!("{}\n", format_args!($($arg)*)) };
}
fn shutdown(failure: bool) -> ! {
let _ = sbi_call(SBI_SRST, 0, 0, failure as usize);
loop { core::hint::spin_loop(); }
}
#[repr(C)]
pub struct TrapFrame { pub x: [usize; 32], pub sepc: usize, pub sstatus: usize }
#[no_mangle]
pub extern "C" fn rust_main() -> ! {
println!("batch: load app at {:#x}", APP_BASE);
unsafe {
// 1) 复制应用
core::ptr::copy_nonoverlapping(APP.as_ptr(), APP_BASE as *mut u8, APP.len());
// 2) 装 trap 入口
asm!("csrw stvec, {}", in(reg) __s_trap_entry as usize);
// 3) 保存内核栈顶,trap 时与用户 sp 交换
asm!("csrw sscratch, sp");
// 4) sstatus: SPP=U, SPIE=1, SIE=0
let mut ss: usize;
asm!("csrr {}, sstatus", out(reg) ss);
ss &= !(1 << 1);
ss = (ss & !(1 << 8)) | (1 << 5);
asm!("csrw sstatus, {}", in(reg) ss);
// 5) 返回地址 = 应用入口
asm!("csrw sepc, {}", in(reg) APP_BASE);
// 6) 降级到 U
asm!("sret", options(noreturn));
}
}
#[no_mangle]
pub extern "C" fn s_trap_handler(tf: &mut TrapFrame) {
let scause: usize;
unsafe { asm!("csrr {}, scause", out(reg) scause) };
if scause == 8 {
match tf.x[17] { // a7
1 => { let _ = sbi_call(SBI_PUTCHAR, 0, tf.x[10], 0); }
2 => { println!("batch: app exit"); shutdown(false); }
_ => println!("batch: unknown syscall {}", tf.x[17]),
}
tf.sepc += 4;
} else {
println!("batch: trap {:#x} at {:#x}", scause, tf.sepc);
shutdown(true);
}
}
#[panic_handler]
fn panic(_: &PanicInfo) -> ! { shutdown(true) }
}
entry.asm 与 1.18 的入口相同:清 .bss、设 sp、call rust_main;trap.asm 是 1.18.7 的 __s_trap_entry。链接脚本把 BASE_ADDRESS 设成 0x8020_0000,并定义 sbss/ebss。编译与运行:
cargo build --release
qemu-system-riscv64 -machine virt -nographic -bios default \
-kernel target/riscv64gc-unknown-none-elf/release/myrcore
预期输出:
batch: load app at 0x80400000
batch: app exit
这里有一个关键设计:应用是字节数组,不是链接进来的函数。一旦它成为「外部镜像」,内核就不能用 Rust 的函数调用去调它;只能用 sret 把 CPU 交给它。这是「用户态」和「内核态」的分界线,也是后面所有隔离的前提。
同一阶段的 C 视角
C 内核通常把应用镜像编译成独立的 ELF,再用 objcopy -O binary 转成 .bin,由构建脚本嵌入内核。数据结构和 Rust 版一一对应:
C 版本没有 Drop,所以 task 的栈、页表、fd 表都要显式释放;Rust 版本可以把「释放页表」写进 Drop,但内核里通常仍然用显式的 free,因为 Drop 可能在中断上下文里触发分配器,语义不清晰。
sepc没 +4:ecall返回后再次执行,应用永远退不出。- U 态没有独立栈:
sret后sp仍是内核栈,应用一压栈就改内核数据。 sscratch没设:第一次 trap 时csrrw sp, sscratch, sp把sp换成 0,随后 load fault。- 应用镜像超过预留区间:
0x8040_0000开始的应用覆盖到下一个应用或内核数据;链接脚本必须限制大小。
1.19.3 阶段 3–4:多任务与抢占
应用需求:两个程序都要跑,不能一个死循环卡死系统。硬件机制:内核栈 + 时钟中断。内核抽象:任务(task)、上下文(context)、调度器(scheduler)。
任务切换只保存 callee-saved
RISC-V 的调用约定把寄存器分成 caller-saved(ra、t0–t6、a0–a7)与 callee-saved(sp、s0–s11)。任务切换发生在内核里,编译器已经按 ABI 把 caller-saved 保存在当前任务的内核栈上;__switch 只需要保存「换栈之后仍然要恢复」的那部分:
| 字段 | 偏移(RV64) | 为什么保存 |
|---|---|---|
ra | 0 | 返回地址,决定 __switch 返回到哪 |
sp | 8 | 内核栈指针,换任务就是换栈 |
s0–s11 | 16–104 | callee-saved,被调用者必须保持 |
TaskContext 是 #[repr(C)] 的 14 个 usize,__switch(prev, next) 把当前寄存器存进 prev,再从 next 恢复。切换完成后,ret 会返回到 next 上次调用 __switch 的下一条指令——看起来像「从没切换过」,但已经在另一个任务的内核栈上。
# __switch(prev: *mut TaskContext, next: *const TaskContext)
# a0 = prev, a1 = next
.section .text
.globl __switch
__switch:
sd ra, 0(a0)
sd sp, 8(a0)
sd s0, 16(a0)
sd s1, 24(a0)
sd s2, 32(a0)
sd s3, 40(a0)
sd s4, 48(a0)
sd s5, 56(a0)
sd s6, 64(a0)
sd s7, 72(a0)
sd s8, 80(a0)
sd s9, 88(a0)
sd s10, 96(a0)
sd s11, 104(a0)
ld ra, 0(a1)
ld sp, 8(a1)
ld s0, 16(a1)
ld s1, 24(a1)
ld s2, 32(a1)
ld s3, 40(a1)
ld s4, 48(a1)
ld s5, 56(a1)
ld s6, 64(a1)
ld s7, 72(a1)
ld s8, 80(a1)
ld s9, 88(a1)
ld s10, 96(a1)
ld s11, 104(a1)
ret
# __switch(prev: *mut TaskContext, next: *const TaskContext)
# rdi = prev, rsi = next;布局:rsp, rbx, rbp, r12..r15
__switch:
movq %rsp, 0(%rdi)
movq %rbx, 8(%rdi)
movq %rbp, 16(%rdi)
movq %r12, 24(%rdi)
movq %r13, 32(%rdi)
movq %r14, 40(%rdi)
movq %r15, 48(%rdi)
movq 0(%rsi), %rsp
movq 8(%rsi), %rbx
movq 16(%rsi), %rbp
movq 24(%rsi), %r12
movq 32(%rsi), %r13
movq 40(%rsi), %r14
movq 48(%rsi), %r15
ret
// __switch(prev: *mut TaskContext, next: *const TaskContext)
// x0 = prev, x1 = next;布局:sp, lr, x19..x28, x29
__switch:
mov x9, sp
str x9, [x0, #0]
str x30, [x0, #8]
stp x19, x20, [x0, #16]
stp x21, x22, [x0, #32]
stp x23, x24, [x0, #48]
stp x25, x26, [x0, #64]
stp x27, x28, [x0, #80]
str x29, [x0, #96]
ldr x9, [x1, #0]
mov sp, x9
ldr x30, [x1, #8]
ldp x19, x20, [x1, #16]
ldp x21, x22, [x1, #32]
ldp x23, x24, [x1, #48]
ldp x25, x26, [x1, #64]
ldp x27, x28, [x1, #80]
ldr x29, [x1, #96]
ret
三个版本的结构完全一样:保存「换栈后还要用」的寄存器,切换 sp,恢复,ret。x86-64 没有 s0–s11,对应的是 rbx/rbp/r12–r15;ARM64 是 x19–x28 加 x29(FP)和 x30(LR)。这就是「同一机制,不同 ISA 的寄存器名字」。
Rust 的任务控制块与调度器
#![allow(unused)]
fn main() {
use core::sync::atomic::{AtomicUsize, Ordering};
use alloc::sync::Arc;
use spin::Mutex;
#[repr(C)]
#[derive(Clone, Copy, Default)]
pub struct TaskContext {
pub ra: usize,
pub sp: usize,
pub s: [usize; 12], // s0..s11
}
pub enum TaskState { Ready, Running, Zombie }
pub struct Task {
pub pid: usize,
pub state: TaskState,
pub ctx: TaskContext,
pub kstack: usize,
pub trap_cx: usize, // 用户 trap frame 的内核地址
}
pub struct Scheduler {
pub current: Option<Arc<Mutex<Task>>>,
pub ready: alloc::collections::VecDeque<Arc<Mutex<Task>>>,
pub next_pid: AtomicUsize,
}
impl Scheduler {
pub fn push(&mut self, task: Arc<Mutex<Task>>) {
self.ready.push_back(task);
}
/// 取出下一个任务;调用者负责在合适的时候 __switch
pub fn pick(&mut self) -> Option<Arc<Mutex<Task>>> {
self.ready.pop_front()
}
}
}
这里出现本章第一个「Rust 逼你面对所有权」的地方:current 和 ready 都持有 Arc<Mutex<Task>>,而任务自己可能又持有「父任务」或「等待队列」的引用。如果全用 Arc,会形成循环引用,任务永远不会释放;正确做法是所有权方向用 Arc,反向引用用 Weak。Linux 用 task_struct 的 parent 裸指针 + children 链表,靠锁和引用计数手工维护;Rust 把这件事变成类型系统的一部分。
调度器的核心循环只有三行:pick 一个任务、__switch 过去、任务返回后把它放回队列。真正难的是「什么时候切换」:协作式靠 yield 系统调用,抢占式靠时钟中断。
时钟中断把「让出」变成「抢占」
1.18.8 已经搭好 stvec、sie.STIE、sstatus.SIE 和 SBI set_timer。在调度器里,tick handler 只需要做三件事:
| 动作 | 代码 | 注意 |
|---|---|---|
| 重新装定时器 | set_next_timer() | 不装就只有一次 tick |
| 增加 tick 计数 | TICKS.fetch_add(1, Relaxed) | 用原子,避免多 hart 竞争 |
| 标记需要调度 | need_resched.store(true, Relaxed) | 真正切换在 trap 返回前 |
不要在中断 handler 里直接 __switch:此时当前任务还在中断上下文,栈上有 trap frame;正确做法是设置 need_resched,在 sret 之前检查并切换。Linux 的 preempt_schedule_irq 也是这个思路。
任务 A 在 __switch(&mut a.ctx, &b.ctx) 里:sd ra,0(a0) 保存的是「A 调用 __switch 的下一条指令」,sd sp,8(a0) 保存 A 的内核栈。恢复 B 时,ld sp,8(a1) 把 sp 换成 B 的内核栈,ret 跳到 B 上次 __switch 的返回地址。于是 A 的栈上还留着 __switch 的返回地址,B 的栈上也留着;两个任务各自「以为」自己刚从中断/系统调用返回。
1.19.4 阶段 5:地址空间与 Sv39
应用需求:每个应用都以为自己独占内存,且内核不能被应用改写。硬件机制:Sv39 三级页表 + satp + TLB。内核抽象:地址空间(MemorySet)、映射区(MapArea)、页表(PageTable)。
Sv39 的地址拆分
Sv39 使用 39 位虚拟地址:低 12 位页内偏移,往上每 9 位一级,共三级(level 2/1/0)。satp 的 MODE=8 表示 Sv39,PPN 指向根页表(level 2)的物理页号。
| 位 | 字段 | 用途 |
|---|---|---|
63:60 | MODE | 8=Sv39,0=Bare,9=Sv48,10=Sv57 |
59:44 | ASID | 地址空间标识,减少 TLB 刷新 |
43:0 | PPN | 根页表物理页号(PA >> 12) |
| VA 位 | 级别 | 页表索引 | 页大小 |
|---|---|---|---|
38:30 | level 2 | VPN[2] | 1 GiB |
29:21 | level 1 | VPN[1] | 2 MiB |
20:12 | level 0 | VPN[0] | 4 KiB |
11:0 | — | 页内偏移 | — |
PTE 的低 10 位是权限,高 44 位是物理页号:
| 位 | 名称 | 含义 |
|---|---|---|
| 0 | V | 有效 |
| 1 | R | 可读 |
| 2 | W | 可写 |
| 3 | X | 可执行 |
| 4 | U | 用户可访问 |
| 5 | G | 全局映射 |
| 6 | A | 已访问(硬件可能不自动置位) |
| 7 | D | 已脏 |
53:10 | PPN | 物理页号 |
一个合法的叶子 PTE 必须满足:V=1,且 R=1 或 X=1(W=1 而 R=0 的组合保留)。如果非叶子 PTE 的 R/W/X 任何一位为 1,它就是大页叶子;否则它指向下一级页表。这个规则是 Sv39 遍历算法的核心,写错会导致「把页表项当大页」或「把大页当页表」。
Rust 的页表类型:谁拥有物理页
页表不是一个「数组」,而是一棵由物理页组成的树。Rust 里可以用 Vec<PageTableEntry> 或裸指针 + 分配器;关键问题是谁负责释放子页表。myrcore 的做法是:
#![allow(unused)]
fn main() {
use core::ptr::NonNull;
pub struct PageTable {
root: NonNull<PageTableEntry>, // 指向 512 项的根页
frames: alloc::vec::Vec<NonNull<PageTableEntry>>, // 拥有所有子页
}
impl Drop for PageTable {
fn drop(&mut self) {
// 先释放子页,再释放根页;顺序反了会 use-after-free
for frame in self.frames.drain(..) {
unsafe { frame.drop_in_place() };
}
}
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PageTableEntry(pub usize);
impl PageTableEntry {
pub fn is_valid(&self) -> bool { self.0 & 1 != 0 }
pub fn is_leaf(&self) -> bool { self.0 & 0b1110 != 0 }
pub fn ppn(&self) -> usize { (self.0 >> 10) & ((1 << 44) - 1) }
}
}
Drop 里必须小心:页表项可能指向「共享页」(例如 COW 的物理页),不能见到 PTE 就释放。frames 只拥有页表页,不拥有映射的数据页;数据页由 FrameAllocator 或 Arc<Frame> 管理。这是 Rust 类型系统帮不上忙的地方——它不知道 PTE 的语义,只能靠你写对。
完整程序:一个可在主机上跑的 Sv39 遍历器
下面这个程序不依赖 QEMU,用 Vec<[u64;512]> 模拟物理页,构造三级页表,映射一个 4 KiB 页和一个 2 MiB 大页,然后翻译地址。它可以在主机上 rustc 直接跑,输出每一级的索引和 PTE。
// sv39.rs:主机上的 Sv39 页表构造/遍历器
const PTE_V: u64 = 1 << 0;
const PTE_R: u64 = 1 << 1;
const PTE_W: u64 = 1 << 2;
const PTE_X: u64 = 1 << 3;
const PTE_U: u64 = 1 << 4;
struct Sv39 {
frames: Vec<[u64; 512]>, // 每个元素是一个 4 KiB 页表页
root: usize,
}
impl Sv39 {
fn new() -> Self {
let mut frames = Vec::new();
frames.push([0u64; 512]);
Self { frames, root: 0 }
}
fn alloc(&mut self) -> usize {
self.frames.push([0u64; 512]);
self.frames.len() - 1
}
fn index(va: usize, level: usize) -> usize {
(va >> (12 + 9 * level)) & 0x1ff
}
/// 确保 va 在 level 上有一个子页表,返回子页在 frames 中的下标
fn ensure(&mut self, va: usize, level: usize) -> usize {
let mut frame = self.root;
for l in (level + 1..=2).rev() {
let i = Self::index(va, l);
let pte = self.frames[frame][i];
if pte & PTE_V == 0 {
let child = self.alloc();
self.frames[frame][i] = ((child as u64) << 10) | PTE_V;
frame = child;
} else {
frame = (pte >> 10) as usize;
}
}
frame
}
/// 映射 4 KiB 页(level 0)
fn map4k(&mut self, va: usize, pa: usize, flags: u64) {
let frame = self.ensure(va, 0);
let i = Self::index(va, 0);
self.frames[frame][i] = ((pa as u64 >> 12) << 10) | flags | PTE_V;
}
/// 映射 2 MiB 大页(level 1)
fn map2m(&mut self, va: usize, pa: usize, flags: u64) {
let frame = self.ensure(va, 1);
let i = Self::index(va, 1);
self.frames[frame][i] = ((pa as u64 >> 12) << 10) | flags | PTE_V;
}
/// 返回 (物理地址, 命中的 PTE, 页大小)
fn translate(&self, va: usize) -> Option<(usize, u64, usize)> {
let mut frame = self.root;
for level in (0..=2).rev() {
let i = Self::index(va, level);
let pte = self.frames[frame][i];
if pte & PTE_V == 0 {
return None;
}
if pte & (PTE_R | PTE_W | PTE_X) != 0 {
// 叶子:大页要保留低位的偏移
let mask = (1usize << (12 + 9 * level)) - 1;
let pa = (((pte >> 10) as usize) << 12) | (va & mask);
return Some((pa, pte, 1 << (12 + 9 * level)));
}
frame = (pte >> 10) as usize;
}
None
}
}
fn main() {
let mut pt = Sv39::new();
// 4 KiB 页:VA 0x1000 -> PA 0x8000_1000,可读写
pt.map4k(0x1000, 0x8000_1000, PTE_R | PTE_W);
// 2 MiB 大页:VA 0x4000_0000 -> PA 0x8000_0000,可读写执行
pt.map2m(0x4000_0000, 0x8000_0000, PTE_R | PTE_W | PTE_X);
for va in [0x1000usize, 0x1abc, 0x4000_0000, 0x4012_3456, 0x5000] {
match pt.translate(va) {
Some((pa, pte, size)) => println!(
"VA {:#010x} -> PA {:#010x} PTE {:#x} page {} KiB",
va, pa, pte, size / 1024
),
None => println!("VA {:#010x} -> unmapped", va),
}
}
println!("root frame = {}, frames = {}", pt.root, pt.frames.len());
}
运行:
rustc -O sv39.rs -o sv39 && ./sv39
预期输出:
VA 0x00001000 -> PA 0x80001000 PTE 0x20000407 page 4 KiB
VA 0x00001abc -> PA 0x80001abc PTE 0x20000407 page 4 KiB
VA 0x40000000 -> PA 0x80000000 PTE 0x2000000f page 2048 KiB
VA 0x40123456 -> PA 0x80123456 PTE 0x2000000f page 2048 KiB
VA 0x00005000 -> unmapped
注意 4 KiB 页的翻译保留了页内偏移(0x1abc),2 MiB 大页保留了 21 位偏移(0x12_3456)。如果你把 mask 写成固定的 0xfff,大页翻译会丢掉高位偏移,这是页表代码最常见的 bug。
内核里的地址空间:MapArea 与 MemorySet
rCore 用 MapArea 表示「一段连续的虚拟地址 + 权限 + 映射类型」,MemorySet 是 Vec<MapArea> 加根页表。myrcore 用同样的分层,但字段命名独立:
| 结构 | 字段 | 谁改 | 什么时候 |
|---|---|---|---|
MapArea | vpn_range、perm、map_type | 创建时 | from_elf、mmap |
MemorySet | areas、page_table | push/remove | fork、exec、mmap |
PageTable | root、frames | map/unmap | 页表操作 |
FrameAllocator | 空闲物理页 | 分配/释放 | 缺页、fork、栈增长 |
Rust 的所有权在这里体现为「MemorySet 拥有 PageTable,PageTable 拥有页表页,数据页由 Arc<Frame> 共享」。fork 时复制 MemorySet,把数据页标成 COW;写缺页时再分配新页并复制。Arc 的引用计数让「最后一个持有者释放物理页」变得可证明,但 COW 的 PTE 权限修改仍然要 unsafe。
1.19.5 阶段 6:进程、fork/exec/wait
应用需求:一个程序能创建另一个程序、等它结束、把它的退出码拿回来。硬件机制:页表切换、satp、sscratch。内核抽象:进程控制块(PCB)、pid、父子关系、fd 表。
PCB 里到底有什么
| 字段 | 类型 | 作用 | 生命周期 |
|---|---|---|---|
pid | usize | 进程标识 | 创建到回收 |
state | Ready/Running/Zombie | 调度状态 | 随调度变化 |
trap_cx | TrapFrame | 用户寄存器现场 | 每次 trap 更新 |
kstack | KernelStack | 内核栈 | 创建时分配 |
memory_set | MemorySet | 地址空间 | fork 复制、exec 替换 |
fd_table | Vec<Option<Arc<dyn File>>> | 打开的文件 | open/close 变化 |
parent | Weak<Mutex<Process>> | 父进程 | 创建时设置 |
children | Vec<Arc<Mutex<Process>>> | 子进程 | fork/wait 变化 |
exit_code | i32 | 退出码 | exit 写入、wait 读出 |
这张表里有两个 Rust 特有的设计点:
parent用Weak:父进程可能先于子进程退出,子进程不能阻止父进程释放;用Weak避免循环引用。children用Arc:父进程要能访问子进程的退出码,子进程在变成 zombie 后仍然存在,直到父进程wait。
Linux 的 task_struct 用 parent 裸指针 + children 链表 + real_parent,并靠 tasklist_lock/RCU 保护;Rust 用 Arc<Mutex<...>> 把锁和引用计数写进类型,但代价是每个字段访问都要加锁,而且容易在持锁时调用会阻塞的函数,造成死锁。
fork:复制地址空间,复制 fd 表
fork 的语义是「复制当前进程的用户态现场,子进程从 fork 返回 0,父进程返回子 pid」。内核要做:
| 步骤 | 做什么 | 失败怎么办 |
|---|---|---|
| 1 | 分配 pid、PCB、内核栈 | 回收已分配资源 |
| 2 | 复制 MemorySet(COW) | 释放页表 |
| 3 | 复制 TrapFrame | 同上 |
| 4 | 复制 fd 表(Arc 克隆,共享 file offset) | 关闭已复制 fd |
| 5 | 把子进程加入父进程 children | 解除父子关系 |
| 6 | 子进程 a0=0,父进程 a0=pid | — |
| 7 | 两者都从 trap 返回 | — |
关键细节:fd 表是「复制表,共享打开文件」。Arc<dyn File> 的克隆让父子共享同一个 file offset;如果 fork 后子进程 read 了 10 字节,父进程的 offset 也会前进。POSIX 要求如此,Linux 用 struct file 的引用计数实现;Rust 用 Arc 正好对应。
exec:换掉地址空间,保留 pid 和 fd
exec 不创建新进程,它把当前进程的 MemorySet 换成新 ELF 的地址空间,重置 TrapFrame,然后返回用户态。失败时(例如 ELF 格式错)必须保持原地址空间不变,否则进程会「半死」。这就是为什么 exec 要先解析 ELF、分配新页表,全部成功后才替换旧 MemorySet。
#![allow(unused)]
fn main() {
pub fn sys_fork(parent: &Arc<Mutex<Process>>) -> Result<usize, SysError> {
let mut parent = parent.lock();
let child = Process::new_child(&parent)?; // 分配 PCB/pid
let child = Arc::new(Mutex::new(child));
// 复制地址空间;失败时 child 的 Drop 会释放已分配页
child.lock().memory_set = parent.memory_set.fork()?;
// 复制 fd 表:Arc 克隆,共享 file offset
child.lock().fd_table = parent.fd_table.clone();
// 子进程从 fork 返回 0
child.lock().trap_cx.x[10] = 0;
// 父进程返回子 pid
parent.trap_cx.x[10] = child.lock().pid;
parent.children.push(child.clone());
Ok(child.lock().pid)
}
pub fn sys_exec(proc: &Arc<Mutex<Process>>, path: &str) -> Result<(), SysError> {
let elf = load_elf(path)?; // 先在用户内存里解析
let mut new_set = MemorySet::from_elf(&elf)?; // 新页表,失败不影响旧地址空间
let entry = elf.entry;
let mut p = proc.lock();
p.memory_set = new_set; // 原子替换;旧 MemorySet 在这里 Drop
p.trap_cx.sepc = entry;
p.trap_cx.sstatus = (p.trap_cx.sstatus & !(1 << 8)) | (1 << 5);
Ok(())
}
}
sys_exec 里 p.memory_set = new_set; 这一行是 Rust 所有权的高光时刻:旧 MemorySet 在赋值后立即 Drop,页表页和数据页按 Drop 顺序释放。C 版本必须显式 free_page_table(old),而且很容易在「新地址空间已生效、旧页表还没释放」的窗口里出 bug。
wait:回收 zombie
wait(pid) 做三件事:找子进程、如果它还活着就睡眠、如果它是 zombie 就取退出码并释放 PCB。Rust 里「睡眠」需要把当前进程从运行队列移到等待队列,并调用 schedule();醒来后要重新检查条件(防止虚假唤醒)。Condvar 或 WaitQueue 都可以,但检查条件必须在持锁时进行,否则会丢唤醒。
satp改了但没sfence.vma:旧 TLB 项仍然有效,用户可能访问到已撤销的映射。fork复制页表但没标 COW:父子写同一物理页,互相覆盖;或者复制了物理页但没改 PTE,写时仍然共享。exec先释放旧地址空间再解析新 ELF:解析失败时进程没有地址空间,只能被杀。parent用Arc:父子循环引用,进程永远不释放;用Weak。wait在锁外检查条件:检查完到睡眠之间子进程退出,唤醒丢失,父进程永久睡眠。
1.19.6 阶段 7:文件系统——从块设备到路径
应用需求:程序能按名字打开文件、读写字、列目录,重启后数据还在。硬件机制:块设备(virtio-blk / SD / NVMe)。内核抽象:超级块、inode、目录项、文件描述符、块缓存。
一个最小 FS 的磁盘布局
myfs 把磁盘按 512 字节分块,前几块是元数据:
| 块号 | 内容 | 关键字段 |
|---|---|---|
| 0 | 超级块 | magic、inode_count、data_start |
| 1 | inode 位图 | 每个 bit 表示一个 inode 是否已用 |
| 2 | 数据位图 | 每个 bit 表示一个数据块是否已用 |
| 3 … | inode 区 | 每个 inode 32 字节:mode、size、direct[4] |
data_start … | 数据区 | 文件内容、目录项数组 |
inode 的 direct[4] 是四个直接块号,够教学用;生产 FS 会用多级间接块、extent、B+ 树。目录也是一种文件:它的数据区是一组 DirEntry { inode, name },按名字线性查找。
| 对象 | 关键操作 | Rust 类型 | Linux 对应 |
|---|---|---|---|
| 块设备 | read_block/write_block | trait BlockDevice | block_device_operations |
| 块缓存 | 读入、标记脏、回写 | Arc<Mutex<BlockCache>> | address_space/page cache |
| inode | read_at/write_at/find | Arc<Inode> | struct inode |
| 目录项 | 名字 → inode | DirEntry | struct dentry |
| fd 表 | fd → 打开文件 | Vec<Option<Arc<dyn File>>> | files_struct |
File trait:一切皆文件
rCore 用 File trait 把普通文件、标准输入输出、管道统一成 read/write。myrcore 用同样的抽象,但把 lseek 也放进 trait:
#![allow(unused)]
fn main() {
pub trait File: Send + Sync {
fn read(&self, buf: &mut [u8]) -> Result<usize, SysError>;
fn write(&self, buf: &[u8]) -> Result<usize, SysError>;
fn seek(&self, pos: SeekFrom) -> Result<usize, SysError>;
}
pub struct FdTable {
pub fds: Vec<Option<Arc<dyn File>>>,
}
impl FdTable {
pub fn alloc(&mut self, file: Arc<dyn File>) -> usize {
if let Some(fd) = self.fds.iter().position(|f| f.is_none()) {
self.fds[fd] = Some(file);
return fd;
}
self.fds.push(Some(file));
self.fds.len() - 1
}
}
}
Arc<dyn File> 是 trait object + 引用计数:fd 表、fork 后的子进程、dup 出来的 fd 都可以共享同一个打开文件。Rust 的 Send + Sync 约束强制你思考「这个文件对象能不能跨线程/跨 hart 共享」;管道需要内部可变性,所以要用 Mutex 或 UnsafeCell + 自旋锁。
完整程序:构造并读回一个 myfs 镜像
下面的程序在内存里构造一个 8 块的镜像,写入超级块、根目录(.、hello.txt)和文件内容,然后解析回来列目录、读文件。它可以直接在主机上跑,也可以把 Vec<u8> 换成 BlockDevice 写进 virtio 盘。
// myfs.rs:最小文件系统镜像的构造与解析
use std::convert::TryInto;
const BLOCK: usize = 512;
const MAGIC: u32 = 0x4d59_4653; // "MYFS"
const INODE_SIZE: usize = 32;
const DIRENT_SIZE: usize = 32;
#[derive(Clone, Copy)]
struct Inode {
mode: u16, // 1=file, 2=dir
size: u32,
direct: [u32; 4],
}
#[derive(Clone, Copy)]
struct DirEntry {
inode: u32,
name: [u8; 28],
}
fn put_u32(buf: &mut [u8], off: usize, v: u32) {
buf[off..off + 4].copy_from_slice(&v.to_le_bytes());
}
fn get_u32(buf: &[u8], off: usize) -> u32 {
u32::from_le_bytes(buf[off..off + 4].try_into().unwrap())
}
fn write_inode(img: &mut [u8], idx: usize, ino: &Inode) {
let off = 3 * BLOCK + idx * INODE_SIZE;
img[off..off + 2].copy_from_slice(&ino.mode.to_le_bytes());
put_u32(img, off + 4, ino.size);
for (i, b) in ino.direct.iter().enumerate() {
put_u32(img, off + 8 + i * 4, *b);
}
}
fn read_inode(img: &[u8], idx: usize) -> Inode {
let off = 3 * BLOCK + idx * INODE_SIZE;
let mut direct = [0u32; 4];
for i in 0..4 {
direct[i] = get_u32(img, off + 8 + i * 4);
}
Inode {
mode: u16::from_le_bytes(img[off..off + 2].try_into().unwrap()),
size: get_u32(img, off + 4),
direct,
}
}
fn write_dirent(img: &mut [u8], block: usize, slot: usize, e: &DirEntry) {
let off = block * BLOCK + slot * DIRENT_SIZE;
put_u32(img, off, e.inode);
img[off + 4..off + 32].copy_from_slice(&e.name);
}
fn main() {
let data_start = 4u32;
let mut img = vec![0u8; 8 * BLOCK];
// 超级块
put_u32(&mut img, 0, MAGIC);
put_u32(&mut img, 4, 2); // inode 数
put_u32(&mut img, 8, data_start); // 数据区起始块
// inode 0 = 根目录,inode 1 = hello.txt
write_inode(&mut img, 0, &Inode { mode: 2, size: 64, direct: [data_start, 0, 0, 0] });
write_inode(&mut img, 1, &Inode { mode: 1, size: 13, direct: [data_start + 1, 0, 0, 0] });
// 根目录的两个目录项
let mut dot = DirEntry { inode: 0, name: [0; 28] };
dot.name[0] = b'.';
write_dirent(&mut img, data_start as usize, 0, &dot);
let mut hello = DirEntry { inode: 1, name: [0; 28] };
hello.name[..9].copy_from_slice(b"hello.txt");
write_dirent(&mut img, data_start as usize, 1, &hello);
// 文件内容
let content = b"hello, myfs!\n";
let off = (data_start as usize + 1) * BLOCK;
img[off..off + content.len()].copy_from_slice(content);
// 解析回来
assert_eq!(get_u32(&img, 0), MAGIC);
let root = read_inode(&img, 0);
println!("root: mode={} size={} direct={:?}", root.mode, root.size, root.direct);
for slot in 0..2 {
let eoff = root.direct[0] as usize * BLOCK + slot * DIRENT_SIZE;
let ino = get_u32(&img, eoff);
let name = &img[eoff + 4..eoff + 32];
let end = name.iter().position(|&b| b == 0).unwrap_or(28);
println!("dir[{}]: inode={} name={}", slot, ino, String::from_utf8_lossy(&name[..end]));
}
let file = read_inode(&img, 1);
let foff = file.direct[0] as usize * BLOCK;
println!("hello.txt: {}", String::from_utf8_lossy(&img[foff..foff + file.size as usize]));
println!("image bytes = {}", img.len());
}
运行:
rustc -O myfs.rs -o myfs && ./myfs
预期输出:
root: mode=2 size=64 direct=[4, 0, 0, 0]
dir[0]: inode=0 name=.
dir[1]: inode=1 name=hello.txt
hello.txt: hello, myfs!
image bytes = 4096
这个程序把「磁盘布局」变成了可打印的字节,你可以用 hexdump -C 对照每个字段的偏移。真正接入内核时,只需把 Vec<u8> 换成 BlockDevice::read_block/write_block,把 main 换成系统调用;布局不变。
1.19.7 阶段 8:IPC、线程与同步
应用需求:进程之间传数据、共享资源;一个进程内多个执行流。硬件机制:原子指令(amoswap/lr/sc)、IPI、fence。内核抽象:pipe、signal、线程、mutex、semaphore、condvar。
pipe:内核里的一块环形缓冲区
pipe 有两个 fd:读端和写端,共享一个 Pipe 对象。Pipe 内部是环形缓冲区 + 读写位置 + 等待队列:
#![allow(unused)]
fn main() {
pub struct Pipe {
buf: [u8; 4096],
read: usize,
write: usize,
len: usize,
readers: usize,
writers: usize,
}
impl Pipe {
pub fn read(&mut self, out: &mut [u8]) -> usize {
let n = out.len().min(self.len);
for i in 0..n {
out[i] = self.buf[(self.read + i) % self.buf.len()];
}
self.read = (self.read + n) % self.buf.len();
self.len -= n;
n
}
pub fn write(&mut self, data: &[u8]) -> usize {
let n = data.len().min(self.buf.len() - self.len);
for i in 0..n {
self.buf[(self.write + i) % self.buf.len()] = data[i];
}
self.write = (self.write + n) % self.buf.len();
self.len += n;
n
}
}
}
pipe 的语义细节:读空 pipe 要睡眠(除非写端全关,返回 0);写满要睡眠(除非读端全关,返回 EPIPE + SIGPIPE)。readers/writers 计数决定「返回 0」还是「阻塞」。Rust 的借用检查器不允许你在持 &mut Pipe 时睡眠再唤醒(锁 guard 跨 await/阻塞点),所以内核里通常用 Mutex<Pipe> + Condvar,在循环里重新检查条件。
线程:共享地址空间,独立内核栈
线程与进程的区别只在「是否共享地址空间」:
| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 MemorySet | 共享 Arc<MemorySet> |
| 内核栈 | 独立 | 独立 |
| trap frame | 独立 | 独立 |
| fd 表 | fork 复制 | 共享 Arc<FdTable> |
| 调度单位 | 进程/线程 | 线程 |
myrcore 用 Thread 表示调度实体,Process 持有 Arc<MemorySet> 和 Arc<FdTable>;clone 系统调用根据 flags 决定共享哪些资源。Linux 用 clone(CLONE_VM|CLONE_FILES|...) 表达同一件事,task_struct 既是进程也是线程。
完整程序:自旋锁、信号量与条件变量
下面的主机程序实现三个同步原语,用 4 个线程各加 10000 次,验证结果;再用信号量控制顺序。它可以直接 rustc 跑。
/* 内核自旋锁:单 hart 下的最小实现;多 hart 需要 amoswap 或 lr/sc */
#include <stdatomic.h>
typedef struct { atomic_flag flag; } spinlock_t;
static inline void spin_lock(spinlock_t *lk)
{
while (atomic_flag_test_and_set_explicit(&lk->flag, memory_order_acquire))
__asm__ volatile("pause");
}
static inline void spin_unlock(spinlock_t *lk)
{
atomic_flag_clear_explicit(&lk->flag, memory_order_release);
}
运行:
预期输出:
counter = 40000
order = ["main", "worker"]
真正的难点是内存序:lock 用 Acquire、unlock 用 Release,否则临界区里的写可能被重排到锁外。C11 的 atomic_* 同理;Relaxed 在 x86-64 上可能「碰巧对」,在 RISC-V 的弱内存模型上会出错。
信号:异步的通知
signal 是「软件中断」:内核在 trap 返回用户态前检查 pending 位图,若有未阻塞的信号,就把用户栈改造成「信号处理函数 + siginfo + 返回地址」,然后 sret 到 handler。handler 返回时执行 sigreturn,恢复原现场。Linux 用 rt_sigframe,rCore chapter7 用简化版。
| 步骤 | 做什么 | 陷阱 |
|---|---|---|
| 1 | 设置 pending 位 | 多线程共享/不共享 pending |
| 2 | 检查 blocked 掩码 | SIGKILL/SIGSTOP 不可阻塞 |
| 3 | 在用户栈构造 frame | 栈空间不足、对齐 |
| 4 | sepc=handler,a0=sig | 返回地址要指向 trampoline |
| 5 | handler ret 到 trampoline | trampoline 执行 sigreturn |
| 6 | sigreturn 恢复现场 | 恢复被中断的 syscall 返回值 |
1.19.8 一次 read 的完整路径
把前面的抽象串起来:用户调用 read(fd, buf, len),内核里到底经过哪些层?
| 层 | 代码/函数 | 关键动作 | 失败 |
|---|---|---|---|
| 1 | 用户 ecall | a7=63(read),a0=fd,a1=buf,a2=len | — |
| 2 | __s_trap_entry | 换栈、保存 32 寄存器、scause=8 | 栈非法 |
| 3 | syscall() | 按 a7 分发到 sys_read | 未知 syscall |
| 4 | sys_read | 检查 fd 范围、buf 用户地址合法 | EBADF/EFAULT |
| 5 | FdTable[fd] | Arc<dyn File> 克隆 | None |
| 6 | File::read | 普通文件/pipe/stdio 多态 | EINTR/EPIPE |
| 7 | Inode::read_at | 文件偏移 → 块号 | 越界 |
| 8 | BlockCache::get | 命中返回;未命中发 I/O | 超时 |
| 9 | BlockDevice::read_block | virtio queue + wfi | DMA 错误 |
| 10 | 中断 handler | 唤醒等待者、标记完成 | 丢失中断 |
| 11 | 返回用户 | 写 a0=读取字节数,sepc+=4 | — |
这条路径可以用 GDB 一站一站验证:
gdb-multiarch target/riscv64gc-unknown-none-elf/release/myrcore \
-ex 'target remote :1234' \
-ex 'b sys_read' -ex 'b myfs::Inode::read_at' \
-ex 'b virtio_blk::read_block' \
-ex 'c'
# 命中后:
# p/x $a0 fd
# p/x $a1 用户 buf
# p/x $a2 len
# bt 看调用链
# info registers satp scause sepc
1.19.9 设计对照:rCore、Linux 与 myrcore
| 维度 | rCore(教学) | Linux 6.8 | myrcore(本章) |
|---|---|---|---|
| 调度 | 简单 RR/优先级 | CFS/EEVDF + RT + deadline | RR + tick |
| 地址空间 | MemorySet/MapArea | mm_struct/vma | MemorySet/MapArea |
| 页表 | Sv39 三级 | 四级/五级,pgd/p4d/pud/pmd/pte | Sv39 三级 |
| 进程 | TaskControlBlock | task_struct | Process/Thread |
| 文件 | easy-fs + File trait | VFS + ext4/xfs/btrfs | myfs + File trait |
| 同步 | 自旋锁/信号量/条件变量 | spinlock/mutex/rwsem/RCU | SpinLock/Semaphore |
这张表说明:rCore 不是「小 Linux」,它把 Linux 的机制抽出来,去掉了工程复杂度(RCU、cgroup、namespace、日志、多核负载均衡)。读 rCore 的正确姿势是「看它如何用最小代码实现一个机制」,而不是「背它的结构体名」。
Rust 所有权在五个地方帮了你,在三个地方逼你写 unsafe
| 场景 | Rust 的帮助 | 代价 |
|---|---|---|
| 页表释放 | Drop 保证不泄漏 | 共享页要手动区分,Drop 不能无脑释放 |
| fd 表共享 | Arc 引用计数 | 循环引用要用 Weak |
| 进程父子 | Weak 表达非拥有关系 | 每次 upgrade 要处理 None |
| 锁 | Mutex<T> 把数据和锁绑定 | 持锁跨阻塞点会死锁 |
| 缓冲区 | 切片长度检查 | 用户指针仍是 unsafe |
| 页表 PTE | 类型区分 PTE/PA | 位操作全靠 unsafe |
| 中断上下文 | Send/Sync 约束 | 自旋锁不能睡眠 |
| DMA | &mut [u8] 独占 | 设备写内存要 unsafe/volatile |
因为内核要表达「这块物理页同时被页表、DMA、用户映射三方引用」这种硬件事实,而 Rust 的类型系统只知道「谁持有 &mut」。UnsafeCell、Arc、Mutex 都是把硬件协议翻译成类型协议的工具;翻译不了的部分,就必须用 unsafe + 注释说明不变量。好的内核 Rust 代码不是「没有 unsafe」,而是「每个 unsafe 都能说清前提」。
1.19.10 实验:QEMU、GDB 与 objdump
实验一:批处理内核与系统调用
cargo build --release
qemu-system-riscv64 -machine virt -nographic -bios default \
-kernel target/riscv64gc-unknown-none-elf/release/myrcore -s -S
# 另一个终端
gdb-multiarch target/riscv64gc-unknown-none-elf/release/myrcore \
-ex 'target remote :1234' \
-ex 'b rust_main' -ex 'b s_trap_handler' -ex 'c'
在 s_trap_handler 断点处检查:p/x $scause 应为 8,p/x $sepc 应指向应用里的 ecall,p/x $a7 应为 2。继续后应看到 batch: app exit。
实验二:页表与 satp
将 1.19.4 的 Sv39 移植到内核后,在 satp 写入处下断点:
gdb-multiarch ... -ex 'b MemorySet::activate' -ex 'c'
# 命中后
p/x $satp # 应为 0x8000000000000000 | root_pa >> 12
p/x *(unsigned long*)$satp # 根页表第一项
x/8gx root_page_table
用 1.19.4 的主机程序算出同一个 VA 的三级索引,和 GDB 里的 PTE 对照;如果对不上,先检查 satp 的 MODE 是否为 8。
实验三:文件系统镜像
rustc -O myfs.rs -o myfs && ./myfs
# 把输出写到文件后检查
./myfs > /tmp/myfs.txt
hexdump -C /tmp/myfs.img 2>/dev/null | head
# 如果没有生成镜像文件,可在程序里加 fs::write("myfs.img", &img)
1.19.11 陷阱清单与出口检查
sepc不 +4:系统调用返回后死循环。sscratch没设:第一次 trap 时sp=0,load fault。__switch保存了 caller-saved:多余且可能破坏编译器假设;只保存ra/sp/s0..s11。- 切换任务不换
sp:两个任务共用一个内核栈,返回地址互相覆盖。 satp改了不sfence.vma:旧 TLB 项让用户访问到已撤销的页。- PTE 的
W=1但R=0:保留组合,硬件可能触发 page fault。 - 大页翻译丢高位偏移:
mask写成0xfff,2 MiB 页只保留 4 KiB 偏移。 fork后父子共享 fd 但复制了 file offset:POSIX 要求共享 offset;复制会导致读位置错乱。parent用Arc:父子循环引用,进程永不释放。wait在锁外检查条件:丢失唤醒,父进程永久睡眠。- pipe 写端全关仍阻塞:应返回
EPIPE并发送SIGPIPE。 - 持自旋锁睡眠:中断关闭 + 调度器无法运行,系统死锁。
出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 八阶段 | 说出每阶段的应用需求、硬件机制、内核抽象 | 1.19.1 |
| 任务切换 | 画出 TaskContext 14 个字段与 __switch | 1.19.3 |
| 页表 | 手算一个 VA 的三级索引与 PTE | 1.19.4 |
| 进程 | 说清 fork/exec/wait 的资源所有权 | 1.19.5 |
| 文件系统 | 画出 superblock/inode/bitmap/数据区 | 1.19.6 |
| 同步 | 解释 Acquire/Release 与丢失唤醒 | 1.19.7 |
| 路径 | 从 read 追到块设备中断 | 1.19.8 |
母本对应
| 母本 | 章节/小节 | 本书落点 | 程度 |
|---|---|---|---|
| rCore 教程 | chapter1 裸机、chapter2 批处理/trap | 1.19.2 | 概念对照,代码独立 |
| rCore 教程 | chapter3 多道程序/任务切换、chapter4 Sv39 | 1.19.3–1.19.4 | 概念对照,页表程序自写 |
| rCore 教程 | chapter5 进程、chapter6 easy-fs | 1.19.5–1.19.6 | 概念对照,FS 布局简化 |
| rCore 教程 | chapter7 fd/pipe/信号、chapter8 线程/锁 | 1.19.7 | 概念对照,同步原语自写 |
| RISC-V 特权级规范 | Sv39、satp、PTE、sfence.vma | 1.19.4 | 以规范为准 |
| Linux 6.8 | task_struct、mm_struct、VFS、struct file | 1.19.5–1.19.8 | 第 3 篇展开 |
下一站:回到 第 3 篇 Linux 内核,把
myrcore的Process/MemorySet/File对照到 Linux 6.8 的task_struct/mm_struct/file。你会发现:教学内核删掉的是工程复杂度,不是机制本身;读懂机制之后,Linux 源码只是「同一件事的工业级实现」。