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_satp置MODE=9)。 - UART MMIO 物理地址
0x10000000;PLIC 基址0xC000000。 - 工具链
riscv64-linux-musl-gcc,编译选项-ffreestanding -nostdlib -nostdinc(无标准库/头)。
贯穿全课程的三个最易错点(务必刻进肌肉记忆):
- 引导期/MMU 关闭时取地址用
lla(PC 相对),绝不用la(la走 GOT,在低地址会取到 高半区 VMA 导致访问非法)。gp在本内核基本不用,靠lla寻址。 stvec/satp等 CSR 助记符在本工具链可直接用,无需写裸地址;但写入的地址值必须 正确、stvec必须 4 字节对齐(否则落入 vectored 模式被硬件 WARL 改写)。- 跨 ±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 课共用)
- 交叉编译 + freestanding:
CROSS := 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两套分支设置各自的交叉前缀、QEMU、TEXT_BASE、链接脚本。 - 链接入口:指定
LD_SCRIPT时用-T$(LD_SCRIPT),否则回退-Ttext=$(TEXT_BASE);LDFLAGS带-static -no-pie -nostdlib -Wl,-N(-Wl,-N关掉页对齐,避免.text被拉到 2MB 边界造成巨大空洞)。 - 日志等级:
LOG ?= debug→-DLOG_LEVEL=n;LOG=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_*宏 →kprintf→kvprintf→klog_putchar→uart_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。
需要避开的坑
- 栈未设好就
callC 函数,会覆盖随机内存。 - RISC-V 早期取低地址全局(如
boot_stack_top、secondary_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==64→ARCH_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(全屏障,等价 AArch64dsb sy/x86 mfence)、fence r,r/fence w,w、fence.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/SC(
lr.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/D(PTE_TABLE与PTE_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_TABLE和PTE_PAGE数值相同并非笔误(L0–L2 用0b11表示 table,L3 表示 page),别误改。- 内存属性:MMIO 必须标记为 Device;数据页应标记不可执行(防代码注入)。
Lesson 107 · MMU Enable(启用 MMU)
实现要点
mm/riscv64/mmu.S:csrw 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.h:phys_to_virt()/virt_to_phys()实现 direct-map(VA = PA + KERNEL_VMA_OFF)。 这是 L100 预埋的高 VMA 布局真正被激活的一课:MMU 已在 L107 开启,内核代码从此都跑在0xFFFFFFFF00000000 + PA的高半区虚拟地址上。kernel_main反推自身VA/PA验证映射正确(评测会解析两个地址并验证PA = VA - 0xFFFFFFFF00000000, 还会读kernel.elf符号表确认打印的是真实地址而非硬编码)。- 设备(RISC-V:UART
0x10000000、PLIC0xC000000)也经 direct-map 映射到内核高半区,标 Device 属性 (PTE_ATTRIDX选 Device,见 L106)。
需要避开的坑(本阶段踩坑最密集,核心都在 link.ld)
link.ld是整套高半区方案的总开关,本阶段几乎所有坑都源于它没写对:- 双 VMA/LMA 的
AT()偏移必须一致:每个高 VMA 段都要AT(ADDR(段) - KERNEL_VMA_OFF),否则 LMA 不连续、 QEMU 加载错位,内核起来后取指/取数据全是错地址。 .pgtable必须放在「切换 VMA 之前」的低地址段、且 4K 对齐、NOLOAD:页表数组在 MMU 关闭时由_start(低 VA)以物理地址直接写入;若误放进高 VMA 段,MMU 未开时根本写不到正确位置。还要避免与.bss.boot里的引导栈重叠(本实现用独立.pgtable (NOLOAD) : ALIGN(4096)段隔开)。- 给低 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。 - 引导栈(
.bss.boot)必须本就在低地址:它不经过高 VMA,MMU 关闭时lla boot_stack_top直接可用; 若挪进高 VMA 段,设栈这一步就会崩。
- 双 VMA/LMA 的
- 启动顺序铁律:低 VA 跑
_start→ 建页表 + 开 MMU(仍在低 VA,靠恒等映射存活)→ trampolinela 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 是0x40000000~0x47ffffff,映射到未由平台提供的物理地址不能当内存用。 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/sstatus共 288 字节 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_vector与post_mmu同段且相距很近,lla可正确解析; 并补.align 4。 - 经验:当「写了一个 CSR 但读回来不是预期值」时,先反汇编确认机器码(本工具链
stvec≡0x105, 机器码相同,映射正确),再查「写入的值」是否正确(地址计算、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/15page 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 只能读timeCSR。 - 注册
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_t(kernel/kernel/task/task.h):sp(保存的内核栈指针,必须是第一个成员,偏移 0,供汇编 切换代码直接访问)、state、id、priority、name、stack_base、entry、arg、run_node(侵入式链表节点)。task_create从静态任务池task_pool[TASK_MAX](默认 64)分配槽位,给每任务独立静态内核栈,用setup_initial_stack把栈「伪装」成刚被切换出去的样子(ra指向task_trampoline),入就绪队列。- 用
container_of从run_node反推task_t*(L98 宏的实际应用)。
需要避开的坑
task_t.sp必须放第一个成员:汇编arch_task_switch直接用偏移 0 访问;挪动字段顺序会崩。- 栈必须用高半区 VA(静态数组
.bss已是高 VA),不能拿 PMM 低地址物理页当栈而不做映射。
Lesson 114 · Context Switch(上下文切换)
实现要点
kernel/kernel/task/riscv64/switch.S:arch_task_switch(prev_sp, next_sp)只保存 callee-saved (ra+s0–s11,13×8 = 104 字节),存入当前栈;把 SP 存入prev->sp;从next->sp恢复 SP 与寄存器;ret。- 用
CNTPCT_EL0/计数器测切换耗时(评测要求输出cycles/ns/switch等)。
需要避开的坑(L114 bug 的根因)
- 上文 §109 的
stvec巨坑直接导致task_switch_test后kernel_main不继续、末尾多出Hello。 - 基准任务(
bench_entry)结束后必须切回主任务上下文,否则内核永远停在该任务内,回不到kernel_main(用g_bench_main保存主任务指针,arch_task_switch切回)。 - 新任务首次
ret时ra = task_trampoline,须确保setup_initial_stack把ra正确设为task_trampoline。
Lesson 115 · Round-Robin Scheduler(轮转调度器)
实现要点
kernel/kernel/task/sched.c:sched_enqueue(尾部追加)、schedule(取队头、当前任务回队尾、调arch_task_switch)、task_yield(=schedule)。就绪队列用侵入式链表,spin_lock_irqsave保护。- 引入 idle 兜底任务:就绪队列空时切回 idle(把内核主上下文登记为 idle),解决「所有任务结束后如何安全返回」。
task_exit():入口返回后标DEAD并schedule(),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/enable用preempt_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必崩)。
- 修复(xv6 式对称切换):
ebreak当 yield 用要自描述:RISC-Vebreak不区分来源,所有cause==3都进 handler。用a7携带TASK_YIELD_MAGIC(0x7969656c,"yiel")标记,只有命中才走 yield,否则按真实断点/调试陷阱停下,避免未来 新增BUG()/kassert被静默吞掉。用ebreak而非ecall是为了避开 SBI 的ecall(cause=9)。setup_initial_stack漏设SPIE→ 任务以关中断运行:sret是把sstatus.SPIE复制到SIE,不是用SIE位本身。只设SIE=1而SPIE=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.S的arch_user_enter:保存内核 callee-saved 帧 → 设sepc(用户入口)、sstatus.SPP=0(回 U-mode)+SPIE=1+SUM=1、sscratch←内核陷阱栈顶、satp←pgd+sfence.vma、sp←用户 sp、sret(同时切 PC 与特权级,等价 AArch64eret)。sscratch约定:用户态运行时sscratch= 内核陷阱栈顶;内核态sscratch = 0。trap_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 翻译异常)。 - 系统调用读用户缓冲区(如
write的buf)借SUMM=1让内核直接读用户页,避免临时映射;这是 118 读取参数的基础。 - 本实现没有 page fault handler,依赖用户程序不访问内核地址;访问内核地址会触发 page fault 死循环。
Lesson 118 · System Calls(系统调用)
实现要点
exception.c:scause==8(U-modeecall)→process_syscall(f)→syscall_dispatch,a7= 调用号,a0–a5= 参数,返回值走a0(负值为错误)。sepc += 4跳过ecall继续。- 遵循 Linux ABI(
64=write、93=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.c用riscv64-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.sh的echo "$out" | grep -qE在grep提前退出时 触发SIGPIPE+pipefail误判 FAIL。修复为 here-stringgrep -qE ... <<< "$output";tests/107.sh同类竞态一并修。 这不影响内核,但会让评分假失败。
Lesson 120 · SMP Multicore(多核启动)
实现要点
- BSP(
_start,hart 0)建好内核页表与全部子系统;跑完用户/ELF 测试后调smp_bringup(),对每个次级 hart 调 SBI HSMhart_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/清sscratch、call cpu_secondary_bootstrap(写tp、标 online、打印CPU N online、进wfiidle 循环)。 - per-CPU 数据
g_per_cpu[MAX_CPUS],tp(x4) 存本核指针,cpuid()直接读tp。
需要避开的坑
boot.S早期取低地址全局(g_boot_hartid/secondary_boot_stacks)必须用lla,la会取高半区 VMA、MMU 未开时崩溃。add立即数 4096 超出 12-bit 范围(addi只接受 ±2048 内 signed 12-bit),需li t2,4096+add。- 多核串口输出必须整行加锁:
uart_puts从「逐字符加锁」改为「整行持有uart_lock」,否则并发打印字符穿插、 正则匹配失败(docs/L120-smp-multicore.md§2.4 把kvprintf一次性格式化整行再持有锁输出)。 - 次级核必须等内核页表就绪、且用户态测试已恢复
satp之后才启动:次级核复用内核页表,若用户进程页表还挂着会乱。 - 次级核用独立启动栈(
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,t0 | csrsi sie, 0x20(立即数仅 5 位,超范围失效) |
| 同步异常/ecall 返回 | sepc += 4 | 不跳过 → 死循环 |
| 定时器编程 | legacy SBI set_timer(EID=0) | v0.2 TIME 扩展(本环境不转发中断) |
任务首帧 sstatus | `SPP=1 | SPIE=1 |
| 上下文切换 | 仅在返回路径(延迟调度)切换 | 在 ISR 上半部 schedule() → 破坏 trap 帧 |
| 切换帧布局 | 全任务统一 288B+104B(xv6 式) | callee 帧与 trap 帧混用 → 概率性卡死 |
| 任务回收 | schedule() 切离时清位图 | task_exit() 自己的栈上清位图 → 并发覆写崩溃 |
| 多核打印 | 整行加锁 | 逐字符加锁 → 行被打散 |
| 用户栈/初始栈 | 16 字节对齐 + 正确 auxv | argv/NULL 顺序错 → musl 越界 |
| 标准库程序 | 补 writev/ioctl/set_tid_address/exit_group | 只实现 write/exit → 无输出/卡退出 |