跳转到内容

Avatar OS 内核 · RISC-V 64 实现要点与避坑指南

本文基于本仓库 kernel/RISC-V64(RV64IMAC,Sv48 分页,S-mode) 的真实实现整理而成。每一章给出「实现要点」与「需要避开的坑」,方便按课动手时对照。

涉及的真实文件(仅 RISC-V64 部分): kernel/boot/riscv64/{boot.S,trap.S,exception.c,link.ld}kernel/mm/riscv64/{mmu.S,vm_early.c}kernel/kernel/{main.c,mm/pmm.c,task/*}kernel/driver/{uart, timer/riscv64, irq/riscv64}kernel/kernel/loader/elf_loader.c


0. 统一背景(先读这一段,所有章节共用)

运行环境:qemu-system-riscv64 -machine virt -m 128M -nographic -bios default,OpenSBI 跑在 M-mode,把控制权以 S-mode 移交内核 _start(加载地址 0x80200000,DRAM 基址 0x80000000)。

关键常量:

  • 内核高半区偏移 KERNEL_VMA_OFF = 0xFFFFFFFF00000000(即 VA = PA + 0xFFFFFFFF00000000)。
  • 早期页表用 2 级简化映射(root L0 + 1 GiB gigapage L1),但 satp 仍按 Sv48 模式make_satpMODE=9)。
  • UART MMIO 物理地址 0x10000000;PLIC 基址 0xC000000
  • 工具链 riscv64-linux-musl-gcc,编译选项 -ffreestanding -nostdlib -nostdinc(无标准库/头)。

贯穿全课程的三个最易错点(务必刻进肌肉记忆):

  1. 引导期/MMU 关闭时取地址用 lla(PC 相对),绝不用 lala 走 GOT,在低地址会取到 高半区 VMA 导致访问非法)。 gp 在本内核基本不用,靠 lla 寻址。
  2. stvec/satp 等 CSR 助记符在本工具链可直接用,无需写裸地址;但写入的地址值必须 正确、stvec 必须 4 字节对齐(否则落入 vectored 模式被硬件 WARL 改写)。
  3. 跨 ±2GB 的 PC 相对寻址(lla)无法到达高低 VA 两端:设置 stvec 这类高 VA 操作必须放到 MMU 开启后的高 VA 代码段(post_mmu)执行。

更多速查见 docs/RISC-V64-课前准备.md(CSR 表、sstatus 位域、scause 编码、指令速查)。


第一阶段:裸机基础(97–102)

Lesson 97 · Bare-Metal Hello(裸机第一声)

实现要点

  • 直接往 UART THR(0x10000000)写一个字节即可在串口看到字符,这是全部 kprintf 的源头。
  • RISC-V:用 sb a1, 0(a0) 写单字节;AArch64 用 strb

需要避开的坑

  • sb(单字节)而非 sw(4 字节)。MMIO 寄存器只认字节写入,写 4 字节会破坏相邻寄存器。
  • hang 循环用 wfi/wfe 低功耗等待,别用空转 b hang(浪费电、QEMU 下刷屏)。
  • 引导阶段栈尚未设置,不能在 _start 里调用 C 函数

构建系统:kernel/Makefile 编写要点(从 L97 起就要搭好,后续 24 课共用)

  • 交叉编译 + freestandingCROSS := riscv64-linux-musl-,统一 AS/CC/LD;编译选项 -ffreestanding -nostdlib -nostdinc -Iinclude(无标准库/头,头文件搜索路径显式列 include/driver/kernel)。
  • 源文件自动收集,新增文件零配置ASM_SRCS/C_SRCS := $(shell find . -name '*.S'/'*.c' -not -path './build/*' ...), 模式规则 $(BUILD_DIR)/%.o: %.c/%.S 把产物按子目录结构映射到 build/往正确目录丢新 .c/.S 即可被纳入,无需改 Makefile——这是本课程分阶段实现最省心的一点。
  • 架构选择ARCH ?= riscv64,可用 make ARCH= 或根目录 ../ARCH 覆盖;Makefile 内为 aarch64/riscv64 两套分支设置各自的交叉前缀、QEMUTEXT_BASE、链接脚本。
  • 链接入口:指定 LD_SCRIPT 时用 -T$(LD_SCRIPT),否则回退 -Ttext=$(TEXT_BASE)LDFLAGS-static -no-pie -nostdlib -Wl,-N-Wl,-N 关掉页对齐,避免 .text 被拉到 2MB 边界造成巨大空洞)。
  • 日志等级LOG ?= debug-DLOG_LEVEL=nLOG=none 额外 -DLOG_NONE 在编译期彻底剔除日志(零开销)。
  • L119 用户程序特殊对待apps/riscv64/welcome.c 单独用 musl 标准库 -static -no-pie 编成独立 ELF,再 objcopy -I binary 成 blob 嵌入内核,绝不能进内核源收集(否则链接报重复符号/误用 libc)。
  • 评测只做 make clean && make build,产物 build/kernel.elf;Makefile 暴露符号 _binary_*_welcome_elf_{start,end} 供加载器读。

Lesson 98 · Freestanding Types(独立类型系统)

实现要点

  • include/types.h 自行定义 uint64_t/size_t/vaddr_t/paddr_t/NULL, 以及类型安全的 MIN(用 __typeof__ + GCC statement expression 防双重求值)、 ALIGN_UP/IS_ALIGNED(位运算,要求对齐量是 2 的幂)。
  • vaddr_t/paddr_t 底层都是 uint64_t,但语义不同:MMU 开启前二者相同,开启后完全不同。

需要避开的坑

  • ALIGN_UP(x, a) 只能在 a 是 2 的幂时正确;否则位运算结果错误。
  • 天真的 #define MIN(a,b) ((a)<(b)?(a):(b)) 会让 a/b 被求值两次(i++ 类参数会自增两次)。
  • paddr_t 直接当指针解引用是内核最常见的 bug 之一;必须用 phys_to_virt() 转成虚拟地址。

Lesson 99 · Kernel Logging(内核日志)

实现要点

  • KLOG_* 宏 → kprintfkvprintfklog_putcharuart_putchar,调用链最底层就是 L97 的单字节写。
  • 5 级过滤(NONE<ERROR<WARN<INFO<DEBUG<TRACE)+ 64 位模块掩码;LOG=none 编译期彻底剔除日志(零开销)。
  • 宏用 do { ... } while(0) 包裹,保证在 if/else 中像单条语句。

需要避开的坑

  • 中断/异常上下文里调 KLOG_* 要谨慎:若 UART 驱动内部加了自旋锁,而在已持锁时又触发打印, 会自死锁。教学内核简单,但多核/中断路径要留心。
  • ##__VA_ARGS__## 用于可变参数为空时吃掉前面逗号,不要漏掉。

Lesson 100 · Linker Script(链接脚本)

实现要点

  • boot/riscv64/link.ld 在本阶段(L100)的真实作用:描述内核镜像的加载布局——入口 _start、 引导栈、BSS 区间、各段在 ELF 中的顺序与对齐,并导出 __bss_start/__bss_end/stack_top 等符号供汇编使用。
  • 此时 MMU 尚未开启、虚拟地址还没有实现(那是 L107/L108 的事):内核直接以物理地址0x80200000 起) 运行。链接脚本里出现的 KERNEL_VMA_OFF = 0xFFFFFFFF00000000 高半区 VMA 是为后续预埋的布局,本阶段并不 真正使用(宏只是被定义出来,引导代码仍在低地址跑)。
  • 常见的「双 VMA/LMA」写法(AT(ADDR(...) - KERNEL_VMA_OFF))也属于预埋:它把高 VMA 与低 LMA 解耦,等 L107 开 MMU、L108 落地 phys_to_virt 后,这套布局才会被真正「激活」。现在只需保证 LMA 连续、能被 QEMU -kernel 一段不落地加载到 0x80200000 即可。

需要避开的坑

  • 不清零 BSS 会死锁:本内核真实踩过——UART 的 spinlock 初始值是 DRAM 上电垃圾,一次加锁就永久自旋。 必须在 C 运行前(MMU 开或没开都行,只要地址对)用链接脚本导出的符号把 BSS 清 0。
  • 栈在链接脚本中分配,stack_top 是栈顶(高地址),栈向下生长;SP 必须 16 字节对齐(RISC-V 调用约定要求)。
  • .bss 放在 .text 之前会让生成的 ELF 大小惊人(文件要为 BSS 预留空间),且违背只读段在前的布局意图。
  • 别在本阶段去「验证高半区地址」或写 trampoline:那属于 L107/L108 的工作;L100 的评测只关注链接布局与 BSS 清零。

Lesson 101 · Boot Assembly(引导汇编)

实现要点

  • _start 四件事:① 关中断(csrw sie,zero/csrw sip,zero,S-mode 下避免无向量表时收中断崩溃) ② 设引导栈(lla sp, boot_stack_top)③ 建页表 + 开 MMU(均在低地址)④ trampoline 到高半区 → boot_check 验证栈可读写 → 清 BSS → 进 kernel_main
  • RISC-V 由 OpenSBI 移交:a0 = hart id,a1 = DTB 地址;入口是 S-mode 的 _start

需要避开的坑

  • 栈未设好就 call C 函数,会覆盖随机内存。
  • RISC-V 早期取低地址全局(如 boot_stack_topsecondary_boot_stacks)必须用 lla(PC 相对), 不能用 la(会取高半区 VMA,MMU 未开时非法)。
  • 保存 hartid/DTB 必须放在 post_mmu 之前(MMU 关闭、低地址)用 lla 写入 .bss.boot 全局。

Lesson 102 · Arch Abstraction(架构抽象层)

实现要点

  • include/arch.h:用编译器内置宏(__riscv + __riscv_xlen==64ARCH_RISCV64)统一命名, 避免直接依赖编译器行为。
  • 公共头(barrier.h/spinlock.h)定义接口;架构相关用 include/<arch>/*_impl.h 内联实现, 通过 #include 自动分派,Makefile 不需要 #ifdef 选源文件

需要避开的坑

  • 热路径(锁、屏障、开关中断)必须 static inline 内联,不能放 .c + 链接(每秒百万次调用, 函数调用开销不可接受)。static inline 在每个翻译单元内展开,不产生外部符号,不会重复定义。
  • 复杂函数(>100 行)不适合内联,应放 lib/

第二阶段:内存与同步(103–108)

Lesson 103 · Memory Barriers(内存屏障)

实现要点

  • RISC-V:fence rw,rw(全屏障,等价 AArch64 dsb sy/x86 mfence)、fence r,r/fence w,wfence.i(刷 I-cache)。
  • 封装 barrier_data()/barrier_data_read()/barrier_data_write()/barrier_acquire()/barrier_release()

需要避开的坑

  • x86 TSO 模型下偶然正确的代码,移植到 ARM/RISC-V 弱序模型常翻车:几乎任何重排都可能发生, 必须显式加屏障。比如改 sctlr_el1/satp 后要 isb/sfence.vma,否则后续指令可能用旧翻译。
  • 区分「编译器屏障」(零开销,只挡编译器重排)与「CPU 数据屏障」(排序点,有性能代价),别滥用。

Lesson 104 · Spinlocks(自旋锁)

实现要点

  • RISC-V 用 LR/SClr.w/sc.w)实现原子「检查-修改」;spin_lock 用 acquire 语义、 spin_unlock 用 release 语义(不需要额外 fence)。
  • 提供 spin_lock_irqsave/spin_unlock_irqrestore:先关中断再加锁,杜绝「同核拿锁→中断又拿同一把锁」的自死锁。

需要避开的坑

  • 单核系统上自旋锁的「自旋」无意义——单核 + 已关中断时没人会释放锁,会永久死等。
  • stxr/sc.w 返回失败不一定是别的核写了同一地址,也可能只是被别的独占访问打断;必须重试。
  • 凡在中断上下文可能分配/释放内存(如 PMM),就必须用关中断版本的锁,否则中断抢占到一半的临界区会损坏数据结构。

Lesson 105 · Physical Memory Manager(物理内存管理器)

实现要点

  • kernel/mm/pmm.c位图分配器bitmap_test/bitmap_set,每 bit 对应一个 4KB 页), 简单 O(n);pmm_alloc_page 返回内核虚拟地址(经 PHYS_TO_VIRT(pa))。
  • spin_lock_irqsave 保护(中断里也可能分配内存)。

需要避开的坑

  • PHYS_TO_VIRT/VIRT_TO_PHYS 只是 PA ± KERNEL_VMA_OFF 的常数加减,只适用于 direct-map 地址; 对任意虚拟映射的页表查询不能用它替代(这是 L98 的 PA/VA 混淆 bug 的延续)。
  • pmm_free_page 被调用两次(double free)会破坏位图;真实内核靠 bitmap 位状态检测,但自己实现时要小心。

Lesson 106 · Page Tables(页表结构)

实现要点

  • include/riscv64/mmu.h 定义 PTE 标志:V/R/W/X/U/G/A/DPTE_TABLEPTE_PAGE 数值相同, 由所在级别区分:L0–L2 指向下一级表,L3 才是页描述符)。
  • pte_table()/pte_leaf()make_satp();PPN 占 bits[53:10]。
  • Sv48 为 4 级页表,每级 512 项 × 8 字节 = 4KB = 恰好一页。

需要避开的坑

  • AttrIndx(MAIR 索引)默认 0 是 Device 属性!本课环境 index 0 = Device-nGnRnE,index 1 = Normal Write-Back。若省略 PTE_ATTRIDX,DRAM 会被错当成 Device(不缓存、不重排),性能/正确性问题。
  • PTE_TABLEPTE_PAGE 数值相同并非笔误(L0–L2 用 0b11 表示 table,L3 表示 page),别误改。
  • 内存属性:MMIO 必须标记为 Device;数据页应标记不可执行(防代码注入)。

Lesson 107 · MMU Enable(启用 MMU)

实现要点

  • mm/riscv64/mmu.Scsrw satp, a0(MODE=9, Sv48)+ sfence.vma zero,zero(全 TLB 刷新,同时充当屏障)。
  • mm/riscv64/vm_early.c:建恒等映射VA=PA,让 MMU 开启后低地址代码还能执行)+ 高半区映射 (0xFFFFFFFF80000000 → 0x80000000),再 trampoline 到高 VA。
  • 早期页表放在独立低地址 .pgtable 段(静态 4KB 对齐数组),不依赖 PMM,也不被 pmm_test() 回收。

需要避开的坑

  • MMU 开启是「惊险一步」satp.M=1 后,同一条低地址 PC 会作为 VA 经 TTBR0/satp 翻译。若页表没覆盖 当前 PC 所在物理地址 → 取指 Translation Fault → 死机。恒等映射就是为此存在。
  • trampoline:ldr/la x0, =1f(链接地址,高 VA)→ br 跳过去进入高半区;跳板之后才能拆掉启动期页表。
  • satp 后必须 sfence.vma:RISC-V 规范不保证旧翻译对后续访问可见,若同时换根表 PPN 不刷新会出问题。
  • 不要假设固件交接时 MMU 已经开启:本课程 QEMU 冷启动 S-mode 下 satp 初始为 0(Bare),直接用课程路径建页表即可; 若从 M-mode/EL2 移交且翻译已开,不能无条件照搬,要用适用于当前异常级别的 TLB 指令。

Lesson 108 · Kernel Address Space(内核地址空间)

实现要点

  • include/mm_vm.hphys_to_virt()/virt_to_phys() 实现 direct-mapVA = PA + KERNEL_VMA_OFF)。 这是 L100 预埋的高 VMA 布局真正被激活的一课:MMU 已在 L107 开启,内核代码从此都跑在 0xFFFFFFFF00000000 + PA 的高半区虚拟地址上。
  • kernel_main 反推自身 VA/PA 验证映射正确(评测会解析两个地址并验证 PA = VA - 0xFFFFFFFF00000000, 还会读 kernel.elf 符号表确认打印的是真实地址而非硬编码)。
  • 设备(RISC-V:UART 0x10000000、PLIC 0xC000000)也经 direct-map 映射到内核高半区,标 Device 属性 (PTE_ATTRIDX 选 Device,见 L106)。

需要避开的坑(本阶段踩坑最密集,核心都在 link.ld

  • link.ld 是整套高半区方案的总开关,本阶段几乎所有坑都源于它没写对:
    1. 双 VMA/LMA 的 AT() 偏移必须一致:每个高 VMA 段都要 AT(ADDR(段) - KERNEL_VMA_OFF),否则 LMA 不连续、 QEMU 加载错位,内核起来后取指/取数据全是错地址。
    2. .pgtable 必须放在「切换 VMA 之前」的低地址段、且 4K 对齐、NOLOAD:页表数组在 MMU 关闭时由 _start(低 VA)以物理地址直接写入;若误放进高 VMA 段,MMU 未开时根本写不到正确位置。还要避免与 .bss.boot 里的引导栈重叠(本实现用独立 .pgtable (NOLOAD) : ALIGN(4096) 段隔开)。
    3. 给低 VA 引导代码要用到的符号补 *_pa 别名post_mmu/stack_top/__bss_start/kernel_main 都是高 VMA 符号,但 _start 在 MMU 关闭时要用它们的物理地址。链接脚本用 PROVIDE(post_mmu_pa = post_mmu - KERNEL_VMA_OFF) 之类导出低地址别名,引导代码 lla*_pa 才能落到 真实物理位置。漏掉别名 → 引导期跳到错误的(高 VMA 当物理)地址 → 取指 fault。
    4. 引导栈(.bss.boot)必须本就在低地址:它不经过高 VMA,MMU 关闭时 lla boot_stack_top 直接可用; 若挪进高 VMA 段,设栈这一步就会崩。
  • 启动顺序铁律:低 VA 跑 _start → 建页表 + 开 MMU(仍在低 VA,靠恒等映射存活)→ trampoline la post_mmu 跳高半区 → 清 BSS(高 VA,靠高半区映射翻译回物理)→ 切高地址内核栈 la stack_top → 进 kernel_main。 任何一步在错误阶段用了高/低 VA 都会崩。
  • direct-map 只覆盖「内核线性区」PHYS_TO_VIRT/VA 只是常数加减,仅对 direct-map 地址有效;对任意虚拟映射 的页不能用它反推 PA(L98 的 PA/VA 混淆 bug 在此时最常复发)。
  • 1 GiB 的 L1 block 映射窗口 不等于机器真的装了那么多 RAM:评分环境 -m 128M,有效 RAM 是 0x400000000x47ffffff,映射到未由平台提供的物理地址不能当内存用。
  • kmalloc 应从 PMM 管理的真实 RAM 取页;页表中 block 映射推导不出固定「堆区」。
  • 内核/用户隔离最终靠 PTE 的 U/权限位 + satp,不是光有高半区地址(L117 才完整落地)。

第三阶段:中断与定时器(109–112)

Lesson 109 · Exception Vectors(异常向量表)

实现要点

  • RISC-V 只有一个 trap 入口 trap_vector(direct 模式),stvec 指向它;入口保存全部 32 个通用寄存器 (x1–x31,跳过 x0)+ sepc/scause/stval/sstatus288 字节 trap 帧,调 handle_exception, 返回前 sret
  • trap_frame_t 布局:x[32](256B) + sepc@256 + scause@264 + stval@272 + sstatus@280。

需要避开的坑此问题导致 L114 出现非常难 debug 的问题

  • stvec 必须 4 字节对齐(.align 4。否则 bit1:0=0b10 落入 vectored 模式,被硬件 WARL 改写, trap 跳到错误地址。
  • stvec 的地址值必须正确:本内核在 _start(低 VA)里用 lla t0, trap_vector 设置 stvec 时, trap_vector 在高 VMA、与低 VA 相距超 ±2GB,lla(PC 相对)取不到,被解析成 _start 自身 (0x80200000)。结果是每次 trap(页面错误、定时器中断)都跳回 _start 重新打印 Hello 并重启内核, 表现为「kernel_main 跑不完、末尾多出 Hello」。
    • 修复:把 stvec 设置挪到 post_mmu(高 VA),trap_vectorpost_mmu 同段且相距很近,lla 可正确解析; 并补 .align 4
    • 经验:当「写了一个 CSR 但读回来不是预期值」时,先反汇编确认机器码(本工具链 stvec0x105, 机器码相同,映射正确),再查「写入的值」是否正确(地址计算、PC 相对可达性、对齐、时机)。改用显式编号 通常是个空操作。
  • 区分 eret(AArch64)/ sret(RISC-V,同时恢复 PC + 特权级)与 ret(普通函数返回)。

Lesson 110 · Exception Handling(异常处理)

实现要点

  • handle_exception 解析 scause:最高位(bit63)=1 是中断,否则是同步异常;低 8 位是编号。
  • 中断路径按 irq = cause & 0xFF 分发到 timer/plic handler;同步异常按编号处理(如 scause=13/15 page fault 打印 stval 故障地址)。

需要避开的坑

  • 同步异常 sepc 指向触发异常的指令本身,必须 sepc += 4 跳过,否则 sret 回到同一条指令再次触发异常 → 死循环。 中断不需要(其 sepc 已指向下一条指令)。
  • 绝对不能在 ISR / 异常处理里直接做上下文切换:切换会破坏被中断任务的 trap 帧,正确做法是「ISR 只设标志, 真正切换放到返回路径」(见 L116)。
  • RISC-V scause 用最高位区分中断/异常;AArch64 是靠不同向量入口区分,不要混淆。

Lesson 111 · Timer Driver(定时器驱动)

实现要点

  • RISC-V 定时器由 SBI 管理:sbi_set_timer(stime_value)ecall 陷入 M-mode(OpenSBI)编程 mtimecmp; S-mode 只能读 time CSR。
  • 注册 irq_install(5, timer_handler)(scause=5 = Supervisor Timer);timer_handler 每次重设下一次中断, 并通过 timer_set_tick_cb 回调调度器(sched_tick)。
  • 两级使能:sie.STIE(bit5) 单独开关 + sstatus.SIE(bit1) 全局开关,两个都要置 1。

需要避开的坑L116 修复记录中确认的三个真实 timer bug

  • 必须用 legacy SBI set_timer(EID=0, FID=0),不能用 v0.2 TIME 扩展(EID=0x54494D45)。本环境 OpenSBI v1.3 + ACLINT 下 TIME 扩展虽返回成功,却不把中断转发给 S 态,导致 timer_handler 永不触发、 Timer tick 从不出现。改用 legacy 接口后 ACLINT 被正确编程、中断转发到 S 态。
  • timer_enable() 不要过早调用:在 kernel_main 早期开启会干扰 113/114/115 的协作式测试(频繁陷入变慢甚至超时)。 教学实现把它挪到 preempt_test() 内、注册 sched_tick 之后才调用(仅 L116 开启抢占)。
  • sbi_set_timer 的参数是绝对时间值,不是相对间隔;忘记重设下一次中断则定时器只触发一次。

Lesson 112 · Interrupt Controllers(中断控制器)

实现要点

  • RISC-V 用 PLIC(QEMU virt 基址 0x0c000000):claim/complete 机制——读 claim 寄存器得到中断号(同时 告知 PLIC「我开始处理」),处理完把中断号写回 complete(告知 PLIC「处理完了」)。
  • plic_init() 设 threshold=0、注册 external handler(irq_install(9, …)scause=9 = Supervisor External)、 使能 sie.SEIE(bit9)。UART 在 QEMU 中是 IRQ 10。

需要避开的坑

  • 忘记 complete(写回中断号)→ PLIC 认为你还在处理 → 同一中断源永不再次触发 → 设备卡死
  • 定时器中断不经过 PLIC(直接由 SBI/ACLINT 管理),别在 PLIC 里找 timer。
  • PLIC context 编号:M-mode=0,S-mode=1;涉及多核时 per-hart context 不同。
  • claim 读到 0 表示无中断(虚假中断),要直接返回。

第四阶段:多任务(113–116)

Lesson 113 · Task Control Block(任务控制块)

实现要点

  • task_tkernel/kernel/task/task.h):sp(保存的内核栈指针,必须是第一个成员,偏移 0,供汇编 切换代码直接访问)、stateidprioritynamestack_baseentryargrun_node(侵入式链表节点)。
  • task_create 从静态任务池 task_pool[TASK_MAX](默认 64)分配槽位,给每任务独立静态内核栈,用 setup_initial_stack 把栈「伪装」成刚被切换出去的样子(ra 指向 task_trampoline),入就绪队列。
  • container_ofrun_node 反推 task_t*(L98 宏的实际应用)。

需要避开的坑

  • task_t.sp 必须放第一个成员:汇编 arch_task_switch 直接用偏移 0 访问;挪动字段顺序会崩。
  • 栈必须用高半区 VA(静态数组 .bss 已是高 VA),不能拿 PMM 低地址物理页当栈而不做映射。

Lesson 114 · Context Switch(上下文切换)

实现要点

  • kernel/kernel/task/riscv64/switch.Sarch_task_switch(prev_sp, next_sp) 只保存 callee-savedra + s0–s11,13×8 = 104 字节),存入当前栈;把 SP 存入 prev->sp;从 next->sp 恢复 SP 与寄存器;ret
  • CNTPCT_EL0/计数器测切换耗时(评测要求输出 cycles/ns/switch 等)。

需要避开的坑L114 bug 的根因

  • 上文 §109 的 stvec 巨坑直接导致 task_switch_testkernel_main 不继续、末尾多出 Hello
  • 基准任务(bench_entry)结束后必须切回主任务上下文,否则内核永远停在该任务内,回不到 kernel_main (用 g_bench_main 保存主任务指针,arch_task_switch 切回)。
  • 新任务首次 retra = task_trampoline,须确保 setup_initial_stackra 正确设为 task_trampoline

Lesson 115 · Round-Robin Scheduler(轮转调度器)

实现要点

  • kernel/kernel/task/sched.csched_enqueue(尾部追加)、schedule(取队头、当前任务回队尾、调 arch_task_switch)、task_yield(= schedule)。就绪队列用侵入式链表,spin_lock_irqsave 保护。
  • 引入 idle 兜底任务:就绪队列空时切回 idle(把内核主上下文登记为 idle),解决「所有任务结束后如何安全返回」。
  • task_exit():入口返回后标 DEADschedule()DEAD 任务不会被重新入队。
  • 任务回收 task_release:只清 slot 位图,不释放静态栈,供后续 task_create 覆写复用。

需要避开的坑

  • task_release(回收槽位)绝不能在 task_exit() 里做task_exit() 此刻正运行在自己的内核栈上, 若此时另一路径 task_create 复用该 slot 并覆写 task_stack[id],会直接写崩正在用的栈。正确做法是放回 schedule()「即将切离 prev、CPU 仍停在 prev 栈上」的转折点,且只清位图不碰栈内存。
  • schedule() 持锁只做队列/状态更新,切换(arch_task_switch)要在 spin_unlock_irqrestore 之后, 避免切换路径长时间持锁、避免切换栈时锁状态被带错。
  • arch_task_switch 切换前显式 csrs sstatus, 0x2 打开 SIE:首次切入任务走 ret(非 sret),sstatus 会继承调用者(可能 SIE=0),导致新任务以关中断运行(见 L116 §7.3)。

Lesson 116 · Preemptive Scheduling(抢占式调度)

实现要点

  • 定时器中断 → sched_tick() 递减当前任务 time_slice,到期只置 need_resched 标志(不在中断上半部切换)。
  • 真正切换放到 handle_exception 末尾、正要 sret 之前的 sched_check_and_yield()延迟调度 / deferred scheduling)。
  • preempt_disable/enablepreempt_count 提供细粒度、与中断无关的抢占开关。

需要避开的坑L116 概率性卡死 + 定时器不触发

  • 两套切换帧布局不一致会概率性卡死(核心坑):arch_task_switch 只存 104 字节 callee 帧,而 trap.S 按 288 字节 trap 帧用 sret 恢复 sepc/sstatus。协作式让出的任务、首次运行的任务、被陷阱上下文切回的 idle 只有 104 字节帧,trap.S 会读到栈上残留垃圾(随机残留 → 偶尔「侥幸」通过、偶尔跳非法地址死循环)→ 概率性卡死。
    • 修复(xv6 式对称切换)task_yield() 改用 ebreak(cause=3)陷入,先经 trap.S 保存完整帧; setup_initial_stack() 构造「完整 288B trap 帧 + 104B callee 帧」;schedule() 在陷阱上下文且唯一可运行的是 idle 时跳过切换直接返回被中断任务(idle 无完整 trap 帧,经 sret 必崩)。
  • ebreak 当 yield 用要自描述:RISC-V ebreak 不区分来源,所有 cause==3 都进 handler。用 a7 携带 TASK_YIELD_MAGIC0x7969656c,"yiel")标记,只有命中才走 yield,否则按真实断点/调试陷阱停下,避免未来 新增 BUG()/kassert 被静默吞掉。用 ebreak 而非 ecall 是为了避开 SBI 的 ecall(cause=9)。
  • setup_initial_stack 漏设 SPIE → 任务以关中断运行sret 是把 sstatus.SPIE 复制到 SIE,不是用 SIE 位本身。只设 SIE=1SPIE=0,任务返回后 SIE=0——中断被永久关闭,定时器抢占永不触发。必须 tf->sstatus = (1<<8)|(1<<5)|(1<<1)(SPP=1 + SPIE=1 + SIE=1)。
  • 不要直接调用 schedule()sched_tick 只设 need_resched),否则会在中断上半部破坏被中断任务的 trap 帧。

第五阶段:用户态与多核(117–120)

Lesson 117 · User Mode(用户态)

实现要点

  • kernel/kernel/task/riscv64/user.Sarch_user_enter:保存内核 callee-saved 帧 → 设 sepc(用户入口)、 sstatus.SPP=0(回 U-mode)+ SPIE=1 + SUM=1sscratch←内核陷阱栈顶satp←pgd + sfence.vmasp←用户 spsret(同时切 PC 与特权级,等价 AArch64 eret)。
  • sscratch 约定:用户态运行时 sscratch = 内核陷阱栈顶;内核态 sscratch = 0trap_vector 入口 csrrw sp, sscratch, sp 据此区分陷入来源。
  • 用户地址空间 [1GiB, 2GiB)0x40000000),独立根页表 + PTE_U;W^X(代码 U+R+X,栈 U+R+W)。

需要避开的坑

  • post_mmu 阶段必须 csrw sscratch, zero:OpenSBI 移交时 sscratch 内容未定义,不清零则 trap_vector 的「sscratch==0 表示在内核态」约定不成立,陷入栈切换会乱。
  • 切换 satp 到用户页表后必须 sfence.vma(省略任一环节会导致用旧 TLB 翻译异常)。
  • 系统调用读用户缓冲区(如 writebuf)借 SUMM=1 让内核直接读用户页,避免临时映射;这是 118 读取参数的基础。
  • 本实现没有 page fault handler,依赖用户程序不访问内核地址;访问内核地址会触发 page fault 死循环。

Lesson 118 · System Calls(系统调用)

实现要点

  • exception.cscause==8(U-mode ecall)→ process_syscall(f)syscall_dispatcha7 = 调用号, a0–a5 = 参数,返回值走 a0(负值为错误)。sepc += 4 跳过 ecall 继续。
  • 遵循 Linux ABI(64=write93=exit),以便运行为 Linux 编译的程序。

需要避开的坑

  • ecall 后必须 sepc += 4,否则回到同一条 ecall 死循环(与 L110 的同步异常同理)。
  • 标准库(musl)程序不只调 write/exit,还会调 set_tid_address/ioctl/writev/exit_group(见 L119)—— 缺任一都会卡在启动/退出阶段。
  • writev 最容易被忽略:printf 在 stdout 非 tty 时全缓冲,fwrite 落到 writev;只实现 write 不实现 writev,输出永远留在缓冲区、看不到 Welcome

Lesson 119 · ELF Loader(ELF 加载器)

实现要点

  • 用户程序 welcome.criscv64-linux-musl-gcc -static -no-pie -Wl,-Ttext-segment=0x40000000 -Wl,-z,max-page-size=4096 编译成独立 ET_EXEC,objcopy -I binary 转 blob 链接进内核镜像。
  • kernel/kernel/loader/elf_loader.c:校验 ELF 头 → 遍历 PT_LOAD 段,按页拷贝(取文件交集、共享页合并 权限、新页清零覆盖 .bss)→ 构造符合 Linux ABI 的初始用户栈(argc/argv/envp/auxv,16 字节对齐)→ process_run 用返回的 entry/user_sp 进 U-mode。
  • 物理页来自内核 BSS 内静态池(elf_pages[192][4096],PMP 安全、可映射为用户页)。

需要避开的坑

  • musl 静态 ELF 段 p_vaddr 通常不页对齐,只与 p_offset 同余于页大小。按页遍历段覆盖区间,每页只拷文件 内容交集,页内剩余保持零(覆盖 .bss 语义);相邻段共享同一物理页时复用并 OR 合并权限。
  • 初始栈布局必须严格正确argv[0] → NULL → envp NULL → auxv → AT_NULL,16 字节对齐。旧实现把 argv[0] 与 NULL 终止符位置颠倒,musl 读取 argv 越界。auxv 至少提供 AT_PHDR/AT_PHENT/AT_PHNUM/AT_PAGESZ/AT_RANDOM/AT_NULL
  • 必须补 writev(见 L118);ioctl(TIOCGWINSZ) 返回 -ENOTTY 让 stdio 转全缓冲再走 writev
  • 评测脚本健壮性坑welcome.elf 输出变长后,grading/lib.shecho "$out" | grep -qEgrep 提前退出时 触发 SIGPIPE + pipefail 误判 FAIL。修复为 here-string grep -qE ... <<< "$output"tests/107.sh 同类竞态一并修。 这不影响内核,但会让评分假失败。

Lesson 120 · SMP Multicore(多核启动)

实现要点

  • BSP(_start,hart 0)建好内核页表与全部子系统;跑完用户/ELF 测试后调 smp_bringup(),对每个次级 hart 调 SBI HSM hart_start(EID="HSM"=0x48534D,FID=0),把「_secondary_start 物理地址 + BSP 的 satp」交给固件。
  • 次级核 _secondary_start.text.boot 低地址,MMU 关、S-mode):取 per-hart 启动栈 secondary_boot_stacks[N]csrw satp(复用 BSP 页表)+ sfence.vma、trampoline 到高半区 secondary_post_mmu、装 stvec/清 sscratchcall cpu_secondary_bootstrap(写 tp、标 online、打印 CPU N online、进 wfi idle 循环)。
  • per-CPU 数据 g_per_cpu[MAX_CPUS]tp(x4) 存本核指针,cpuid() 直接读 tp

需要避开的坑

  1. boot.S 早期取低地址全局(g_boot_hartid/secondary_boot_stacks)必须用 llala 会取高半区 VMA、MMU 未开时崩溃。
  2. add 立即数 4096 超出 12-bit 范围(addi 只接受 ±2048 内 signed 12-bit),需 li t2,4096 + add
  3. 多核串口输出必须整行加锁uart_puts 从「逐字符加锁」改为「整行持有 uart_lock」,否则并发打印字符穿插、 正则匹配失败(docs/L120-smp-multicore.md §2.4 把 kvprintf 一次性格式化整行再持有锁输出)。
  4. 次级核必须等内核页表就绪、且用户态测试已恢复 satp 之后才启动:次级核复用内核页表,若用户进程页表还挂着会乱。
  5. 次级核用独立启动栈(secondary_boot_stacks[hartid],每核 4KB),避免与 BSP 冲突。

跨章节通用避坑清单(速记)

主题正确做法错误/陷阱
引导期取地址lla sym(PC 相对)la sym(走 GOT,MMU 关时非法)
stvec.align 4 后写高 VA 地址(放 post_mmu在低 VA 用 lla 取高 VA 符号(±2GB 够不到)→ 跳回 _start
satp 切换写后 sfence.vma zero,zero省略刷新 → 旧翻译生效
STIE(bit5)/SEIE(bit9)li t0,(1<<5); csrs sie,t0csrsi sie, 0x20(立即数仅 5 位,超范围失效)
同步异常/ecall 返回sepc += 4不跳过 → 死循环
定时器编程legacy SBI set_timer(EID=0)v0.2 TIME 扩展(本环境不转发中断)
任务首帧 sstatus`SPP=1SPIE=1
上下文切换仅在返回路径(延迟调度)切换在 ISR 上半部 schedule() → 破坏 trap 帧
切换帧布局全任务统一 288B+104B(xv6 式)callee 帧与 trap 帧混用 → 概率性卡死
任务回收schedule() 切离时清位图task_exit() 自己的栈上清位图 → 并发覆写崩溃
多核打印整行加锁逐字符加锁 → 行被打散
用户栈/初始栈16 字节对齐 + 正确 auxvargv/NULL 顺序错 → musl 越界
标准库程序writev/ioctl/set_tid_address/exit_group只实现 write/exit → 无输出/卡退出

Released under the MIT License.