Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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、UserContextsepc 未 +4
3 多任务交替执行、主动让出内核栈、sscratch任务、调度器、yieldTaskContext、Arc<SpinLock<TCB>>切换时栈没换
4 抢占公平、交互时钟中断、STIE时间片、tickAtomicUsize、Timer忘了重设 mtimecmp
5 地址空间隔离、按需分配Sv39、satp、TLB页表、MapArea、MemorySetPageTable、MapPermissionPTE 的 V/R/W/X 配错
6 进程fork/exec/wait页表切换、COWPCB、pid、父子关系Arc<Mutex<Process>>、WeakArc 循环、死锁
7 文件系统持久化、命名块设备、virtioinode、目录项、fd 表Arc<Inode>、dyn File目录项越界、块号错
8 IPC/并发协作、共享IPI、原子指令pipe、signal、线程、锁Arc<Semaphore>、Condvar竞态、丢失唤醒
  1. 应用怎么用:它看到的接口是什么(系统调用、文件、信号)?
  2. 硬件给什么:特权级、CSR、页表、中断、原子指令中的哪一件?
  3. 内核存什么:新结构体的字段、谁分配、谁释放、什么时候改?
  4. Rust 怎么表达所有权:用 &mut、Arc、Weak、UnsafeCell,还是只能 unsafe?
  5. 怎么验证:QEMU 输出、GDB 寄存器、objdump 反汇编、/proc 或自写测试,哪一个是证据?

rCore 的章节与我们的落点对应如下(只对照组织,不复制正文/代码):

rCore 章节主题我们的落点本章保留的核心概念
chapter1裸机、移除 std、SBI1.19.2入口、链接、栈、SBI
chapter2特权级、批处理、trap1.19.2U/S、ecall、应用加载
chapter3多道程序、任务切换、分时1.19.3TaskContext、__switch
chapter4动态分配、Sv39、地址空间1.19.4页表、MapArea
chapter5进程、fork/exec/wait、调度1.19.5PCB、pid、调度
chapter6easy-fs、inode、块缓存1.19.6磁盘布局、fd
chapter7fd、pipe、参数、重定向、信号1.19.7File 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:

步骤代码作用
1copy_nonoverlapping(src, APP_BASE, len)把应用放到约定地址
2csrw stvec, trap_entry装好 U→S 的入口
3csrw sscratch, sp保存内核栈顶,trap 时交换
4sstatus.SPP=0, SPIE=1, SIE=0返回目标是 U,返回后开中断
5csrw sepc, APP_BASE返回地址 = 应用入口
6sret降级到 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 可能在中断上下文里触发分配器,语义不清晰。

阶段 1–2 的四个坑
  1. sepc 没 +4:ecall 返回后再次执行,应用永远退不出。
  2. U 态没有独立栈:sret 后 sp 仍是内核栈,应用一压栈就改内核数据。
  3. sscratch 没设:第一次 trap 时 csrrw sp, sscratch, sp 把 sp 换成 0,随后 load fault。
  4. 应用镜像超过预留区间: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)为什么保存
ra0返回地址,决定 __switch 返回到哪
sp8内核栈指针,换任务就是换栈
s0–s1116–104callee-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 也是这个思路。

例 1.19.1 · 一次切换的栈变化

任务 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:60MODE8=Sv39,0=Bare,9=Sv48,10=Sv57
59:44ASID地址空间标识,减少 TLB 刷新
43:0PPN根页表物理页号(PA >> 12)
VA 位级别页表索引页大小
38:30level 2VPN[2]1 GiB
29:21level 1VPN[1]2 MiB
20:12level 0VPN[0]4 KiB
11:0—页内偏移—

PTE 的低 10 位是权限,高 44 位是物理页号:

位名称含义
0V有效
1R可读
2W可写
3X可执行
4U用户可访问
5G全局映射
6A已访问(硬件可能不自动置位)
7D已脏
53:10PPN物理页号

一个合法的叶子 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 用同样的分层,但字段命名独立:

结构字段谁改什么时候
MapAreavpn_range、perm、map_type创建时from_elf、mmap
MemorySetareas、page_tablepush/removefork、exec、mmap
PageTableroot、framesmap/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 里到底有什么

字段类型作用生命周期
pidusize进程标识创建到回收
stateReady/Running/Zombie调度状态随调度变化
trap_cxTrapFrame用户寄存器现场每次 trap 更新
kstackKernelStack内核栈创建时分配
memory_setMemorySet地址空间fork 复制、exec 替换
fd_tableVec<Option<Arc<dyn File>>>打开的文件open/close 变化
parentWeak<Mutex<Process>>父进程创建时设置
childrenVec<Arc<Mutex<Process>>>子进程fork/wait 变化
exit_codei32退出码exit 写入、wait 读出

这张表里有两个 Rust 特有的设计点:

  1. parent 用 Weak:父进程可能先于子进程退出,子进程不能阻止父进程释放;用 Weak 避免循环引用。
  2. 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 都可以,但检查条件必须在持锁时进行,否则会丢唤醒。

阶段 5–6 的五个坑
  1. satp 改了但没 sfence.vma:旧 TLB 项仍然有效,用户可能访问到已撤销的映射。
  2. fork 复制页表但没标 COW:父子写同一物理页,互相覆盖;或者复制了物理页但没改 PTE,写时仍然共享。
  3. exec 先释放旧地址空间再解析新 ELF:解析失败时进程没有地址空间,只能被杀。
  4. parent 用 Arc:父子循环引用,进程永远不释放;用 Weak。
  5. wait 在锁外检查条件:检查完到睡眠之间子进程退出,唤醒丢失,父进程永久睡眠。

1.19.6 阶段 7:文件系统——从块设备到路径

应用需求:程序能按名字打开文件、读写字、列目录,重启后数据还在。硬件机制:块设备(virtio-blk / SD / NVMe)。内核抽象:超级块、inode、目录项、文件描述符、块缓存。

一个最小 FS 的磁盘布局

myfs 把磁盘按 512 字节分块,前几块是元数据:

块号内容关键字段
0超级块magic、inode_count、data_start
1inode 位图每个 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_blocktrait BlockDeviceblock_device_operations
块缓存读入、标记脏、回写Arc<Mutex<BlockCache>>address_space/page cache
inoderead_at/write_at/findArc<Inode>struct inode
目录项名字 → inodeDirEntrystruct 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栈空间不足、对齐
4sepc=handler,a0=sig返回地址要指向 trampoline
5handler ret 到 trampolinetrampoline 执行 sigreturn
6sigreturn 恢复现场恢复被中断的 syscall 返回值

1.19.8 一次 read 的完整路径

把前面的抽象串起来:用户调用 read(fd, buf, len),内核里到底经过哪些层?

层代码/函数关键动作失败
1用户 ecalla7=63(read),a0=fd,a1=buf,a2=len—
2__s_trap_entry换栈、保存 32 寄存器、scause=8栈非法
3syscall()按 a7 分发到 sys_read未知 syscall
4sys_read检查 fd 范围、buf 用户地址合法EBADF/EFAULT
5FdTable[fd]Arc<dyn File> 克隆None
6File::read普通文件/pipe/stdio 多态EINTR/EPIPE
7Inode::read_at文件偏移 → 块号越界
8BlockCache::get命中返回;未命中发 I/O超时
9BlockDevice::read_blockvirtio queue + wfiDMA 错误
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.8myrcore(本章)
调度简单 RR/优先级CFS/EEVDF + RT + deadlineRR + tick
地址空间MemorySet/MapAreamm_struct/vmaMemorySet/MapArea
页表Sv39 三级四级/五级,pgd/p4d/pud/pmd/pteSv39 三级
进程TaskControlBlocktask_structProcess/Thread
文件easy-fs + File traitVFS + ext4/xfs/btrfsmyfs + File trait
同步自旋锁/信号量/条件变量spinlock/mutex/rwsem/RCUSpinLock/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
为什么内核代码里 `unsafe` 不可避免?

因为内核要表达「这块物理页同时被页表、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 陷阱清单与出口检查

本章 12 条陷阱
  1. sepc 不 +4:系统调用返回后死循环。
  2. sscratch 没设:第一次 trap 时 sp=0,load fault。
  3. __switch 保存了 caller-saved:多余且可能破坏编译器假设;只保存 ra/sp/s0..s11。
  4. 切换任务不换 sp:两个任务共用一个内核栈,返回地址互相覆盖。
  5. satp 改了不 sfence.vma:旧 TLB 项让用户访问到已撤销的页。
  6. PTE 的 W=1 但 R=0:保留组合,硬件可能触发 page fault。
  7. 大页翻译丢高位偏移:mask 写成 0xfff,2 MiB 页只保留 4 KiB 偏移。
  8. fork 后父子共享 fd 但复制了 file offset:POSIX 要求共享 offset;复制会导致读位置错乱。
  9. parent 用 Arc:父子循环引用,进程永不释放。
  10. wait 在锁外检查条件:丢失唤醒,父进程永久睡眠。
  11. pipe 写端全关仍阻塞:应返回 EPIPE 并发送 SIGPIPE。
  12. 持自旋锁睡眠:中断关闭 + 调度器无法运行,系统死锁。

出口检查

检查项达标线没达标去哪
八阶段说出每阶段的应用需求、硬件机制、内核抽象1.19.1
任务切换画出 TaskContext 14 个字段与 __switch1.19.3
页表手算一个 VA 的三级索引与 PTE1.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 批处理/trap1.19.2概念对照,代码独立
rCore 教程chapter3 多道程序/任务切换、chapter4 Sv391.19.3–1.19.4概念对照,页表程序自写
rCore 教程chapter5 进程、chapter6 easy-fs1.19.5–1.19.6概念对照,FS 布局简化
rCore 教程chapter7 fd/pipe/信号、chapter8 线程/锁1.19.7概念对照,同步原语自写
RISC-V 特权级规范Sv39、satp、PTE、sfence.vma1.19.4以规范为准
Linux 6.8task_struct、mm_struct、VFS、struct file1.19.5–1.19.8第 3 篇展开

下一站:回到 第 3 篇 Linux 内核,把 myrcore 的 Process/MemorySet/File 对照到 Linux 6.8 的 task_struct/mm_struct/file。你会发现:教学内核删掉的是工程复杂度,不是机制本身;读懂机制之后,Linux 源码只是「同一件事的工业级实现」。