操作系统内核分析
本书使用 rCore 与 uCore 的 2026A 参考实现,分析内核从启动到线程同步的主要机制。源码链接固定到各章对应的提交;运行报告可用于查看函数调用和系统事件。
章节
报告目录还收录 xv6-riscv、ArceOS 和 StarryOS 的运行记录。
使用 NodeFusion 分析其他内核时,可参考添加内核支持和Manifest 语法参考。
启动与基本执行环境
在已有操作系统上运行程序时,装载器和运行库会准备内存、栈以及输入输出接口。本章的程序直接运行在 QEMU 模拟的 RISC-V 机器上。这些准备工作由启动固件、入口汇编和内核代码共同完成。
从固件进入内核
本章使用 QEMU virt 机器及其 OpenSBI 固件。固件完成机器级初始化后,将执行权交给位于 0x80200000 的内核。内核入口处在监督者模式,后续通过 SBI 调用使用固件提供的服务。
两套实现都把入口汇编放在 .text.entry,链接脚本将它排在代码段的开头。入口先把 sp 设为启动栈顶,再调用 Rust 或 C 的主函数。顺序很重要:编译器生成的函数可能立即调整栈指针、保存寄存器或存放局部变量。
链接地址与内存初始状态
链接脚本决定代码和数据在内存中的位置,并生成供程序引用的边界符号。
| 区域 | 内容 | 初始化方式 |
|---|---|---|
.text | 入口和函数指令 | 随内核映像装载 |
.rodata | 字符串等只读常量 | 随内核映像装载 |
.data | 具有非零初值的可写全局数据 | 随内核映像装载 |
| 普通 BSS(不含启动栈) | 零初始化的静态存储 | 本章入口代码显式清零 |
| 启动栈 | 函数调用使用的存储 | 入口汇编预留空间并设置 sp |
两套链接脚本都把启动栈的 .bss.stack 放在普通 BSS 之前。清零起点位于栈顶之后,清零过程不会覆盖当前正在使用的调用栈。区间采用左闭右开的表示法:起点属于区间,终点不属于区间。
ELF 保存段、符号和调试信息;裸二进制保存装载所需的字节。NodeFusion 根据 ELF 中的符号和调试信息,将运行中的指令地址关联到函数和源码。
从函数调用到字符输出
RISC-V 调用约定使用 ra 保存返回地址,sp 指向当前栈,a0 等寄存器传递参数。入口的 call 与后续函数调用遵循同一约定。栈向低地址增长,栈顶是预留区域的高地址边界。
两套控制台都将格式化后的字符传给 SBI 输出接口。在本章,内核执行 ecall 向机器模式固件请求服务。第二章中用户程序执行 ecall 时,则由监督者模式内核处理系统调用。
日志级别决定哪些消息输出,低于配置级别的日志会被过滤。
沿运行记录检查启动
按以下顺序比较源码、链接结果和运行记录:
- 在入口汇编中找到设置
sp和调用主函数的指令。 - 从 ELF 读取入口、启动栈与 BSS 的边界。
- 检查主函数入口记录中的
sp是否等于启动栈顶。 - 查看清零函数的入口及其清零区间。
- 沿控制台输出与终止路径查看调用关系。
在 rCore 实现和 uCore 实现中,可以对照入口、栈和 BSS 边界。关于目标平台、调用约定和 SBI,可继续阅读 rCore 教程第一章。
rCore 实现
第一章的内核从汇编入口进入 Rust 主函数,完成全局变量清零、控制台输出和关机。以下代码取自 2026A 第一章参考实现。
入口与启动栈
处理器从 _start 开始执行。entry.asm 先把 sp 设为 boot_stack_top,再调用 rust_main。启动栈位于 .bss.stack,大小为 64 KiB。
linker.ld 把内核入口放在 0x80200000,依次排列代码、只读数据、可写数据和 BSS。启动栈位于 BSS 的最前面;sbss 标记其后的清零起点。这一布局使内核能够在使用启动栈的同时初始化其余 BSS。
清零与主函数
clear_bss 逐字节清零 [sbss, ebss)。rust_main 在访问全局状态之前调用它,随后初始化日志,输出问候语和数组 [1, 2, 3, 4, 5] 的求和结果。
内核通过链接脚本提供的符号打印各段边界,例如 stext as usize 取得代码段的起始地址。代码、只读数据、可写数据、启动栈和 BSS 分别使用 trace、debug、info、warn、error 级别。
输出与终止
格式化输出最终通过 SBI 写到控制台。主函数调用 QEMU_EXIT_HANDLE.exit_success() 向 QEMU 测试设备写入成功退出值。rust_main 的返回类型为 !;程序不会回到 _start 中的 call 之后。
运行观察
运行报告中,rust_main 入口的 sp 为 0x80214000,正好是启动栈顶。进入 clear_bss 时,sp 已降至 0x80213e80;这段空间属于主函数的调用栈。
清零区间为 [0x80214000, 0x80215000),位于启动栈上方。clear_bss 从这里向高地址清零,函数调用使用的栈则向低地址增长。
uCore 实现
从 _entry 到 main,内核只需要一块可用的栈;完成输出与关机则依靠 SBI。以下代码来自 uCore 2026A 第一章。
入口与启动栈
entry.S 的 _entry 先把 sp 设为 boot_stack_top,再调用 main。boot_stack 与栈顶之间预留 64 KiB。
kernel.ld 将内核起始地址设为 0x80200000,各段按页对齐。启动栈位于普通 BSS 之前,因而清零 BSS 不会抹掉正在使用的栈。
清零与主函数
main.c 的 clean_bss 清零 [s_bss, e_bss)。随后 main 调用 console_init 并输出各段边界。本章的 console_init 为空,字符输出由 SBI 完成。
最后,main 执行 panic("ALL DONE"),由 panic 调用关机函数。本章的 threadid() 固定返回 0;进程与线程管理要到后续章节才引入。
输出与终止
printf.c 逐个处理格式字符,通过 consputc 输出。指针格式 %p 打印十六进制地址。
sbi.c 将服务号放入 a7、参数放入 a0 等寄存器,再执行 ecall。关机服务号为 8。沿着 printf → consputc → SBI 和 panic → shutdown → SBI 两条路径,可以看到同一套固件接口如何承担输出与关机。
运行观察
运行报告中的 ELF 各段边界如下:
| 区域 | 地址区间 |
|---|---|
| 代码 | [0x80200000, 0x80201000) |
| 只读数据 | [0x80201000, 0x80202000) |
| 可写数据边界 | s_data = e_data = 0x80202000 |
| 启动栈 | [0x80202000, 0x80212000) |
| 清零区间 | s_bss = e_bss = 0x80212000 |
s_bss 与 e_bss 相等,因此 clean_bss 的循环执行零次。进入 main 时 sp = 0x80212000,正好指向栈顶;调用 clean_bss 后,栈指针向低地址移动,为函数调用留出空间。
第二章:批处理与系统调用
一个应用输出结果后退出,内核接着装载下一个应用。若应用执行了非法指令,内核也需要取得控制权,结束该应用并继续批次。
本章把应用放在用户态执行,将装载、输出和退出交给监督者模式的内核。应用执行 ecall 请求服务;异常入口保存应用的寄存器,内核处理请求后恢复这些寄存器,再从用户程序的继续执行位置运行。
从一个程序到一批应用
第一章的内核已经能够使用栈、输出字符和结束运行。第二章在内核映像中加入用户应用的二进制及其位置表。装载器根据表中相邻的两个地址找到应用,将它复制到固定执行地址 0x80400000。
同一时刻只有一个应用执行。退出或发生本章处理的异常后,下一应用覆盖这一执行区域,再使用重新建立的用户上下文启动。第三章将保留多个应用的代码和执行状态,使它们能够暂停后继续运行。
图中的普通系统调用沿右侧路径返回原应用;退出和应用异常沿下方路径装载下一应用。两条路径对上下文的处理不同:前者保留当前寄存器,后者建立新应用的初始寄存器。
硬件入口与软件保存
用户态执行 ecall 后,处理器记录异常指令地址与原因,切换到监督者模式,并跳转到 stvec 指定的入口。入口汇编再保存通用寄存器,准备内核栈,调用 Rust 或 C 的处理函数。
| 状态 | 用途 |
|---|---|
sepc | 异常指令地址,也是 sret 的跳转目标 |
scause | 区分用户系统调用、非法指令等原因 |
stval | 为相应异常提供地址或指令信息 |
sstatus.SPP | 指定异常返回后的特权级 |
sscratch | 供入口汇编交换用户寄存器或取得上下文地址 |
处理器不会自动把全部通用寄存器压入内存,也不会替软件准备内核调用栈。寄存器保存区与栈必须有足够空间,并在处理请求期间保持有效。两套实现分别把用户上下文放在内核栈顶和独立的 trap_page 中。
本章使用裸地址运行,satp 为零。特权级限制了用户程序可执行的指令,内核与应用之间的普通内存访问权限还没有通过独立页表划分。第四章将加入地址转换与页级权限。
一次 write 如何返回
用户程序把系统调用号 64 放入 a7,将文件描述符、缓冲区地址和长度放入 a0、a1、a2,再执行 ecall。异常入口保存的寄存器就是内核分发请求的依据。
内核将保存的 sepc 增加 4,调用写接口,把返回值写回保存的 a0。异常返回恢复通用寄存器和控制状态,执行 sret。用户程序继续执行 ecall 后面的指令,并在 a0 中取得结果。这里的 4 对应 ecall 的指令长度。
系统调用使用的地址来自用户寄存器。文件描述符、用户地址和字符编码由不同的代码处理;两套实现的检查路径分别见 rCore 实现和 uCore 实现。
退出与应用异常
系统调用 93 表示退出。退出路径装载下一应用,为它建立入口地址、用户栈指针和用户态返回状态。原应用的退出调用不会返回。
访问异常与非法指令也会使执行流离开用户程序。内核依据异常原因决定处理方式。两套参考实现的具体分支不同,阅读时可以沿 rCore 的异常分发和 uCore 的异常分发查看。
沿运行记录检查上下文
先选择一条用户系统调用,再按以下顺序查看相邻事件:
- 在
ecall记录中读取a7、参数及sepc。 - 查看异常处理函数的入口参数和
sp,确定用户上下文与内核栈的位置。 - 在返回前的上下文中检查
sepc和a0。 - 对退出路径查看下一应用的装载与初始上下文。
关于特权级和异常上下文,可继续阅读 rCore 教程第二章。
rCore 实现
第二章把多个应用依次装入同一段内存。应用通过系统调用请求输出或退出;发生异常时,内核结束当前应用并运行下一个。源码采用 2026A 第二章参考实现。
批次状态与装载
AppManager 保存应用数量、下一应用序号和各应用的起始地址。地址表比应用数多一项;相邻地址确定一个应用二进制的范围。
run_next_app 读取 current_app,调用 load_app,再把序号加一。load_app 清空从 0x80400000 开始的 128 KiB 区域,将应用复制进去,并执行 fence.i。应用代码刚由数据写入指令内存,执行 fence.i 后,处理器取指才会看到新内容。
管理器保存的是下一次要装载的序号。应用 0 正在运行时,current_app 已经是 1。所有应用执行完毕后,最后一次 run_next_app 通过 QEMU 退出设备关机。
首次进入用户态
TrapContext::app_init_context 准备应用入口 0x80400000、用户栈顶和返回用户态所需的 sstatus。run_next_app 把这份上下文放在内核栈上,随后直接进入 __restore。第一次进入用户态也沿用异常返回的汇编路径。
异常入口与上下文
用户态发生异常后,__alltraps 交换 sp 与 sscratch,切到内核栈。汇编在栈上保存用户寄存器、sstatus 和 sepc,把得到的 TrapContext 地址交给 trap_handler。
返回路径由 __restore 从同一份上下文恢复寄存器,最后执行 sret。用户栈指针保存在上下文的 x[2] 中;处理异常期间的函数调用使用内核栈。
系统调用与返回
用户程序执行 ecall 时,trap_handler 从 a7 读取调用号,从 a0—a2 读取参数,并把返回值写回上下文的 a0。处理前先将 sepc 加 4,返回用户态后便从 ecall 的下一条指令继续。
本章的 sys_write 接收标准输出描述符 1,将给定缓冲区作为 UTF-8 文本输出,返回写入长度。sys_exit 不再返回当前应用,而是调用 run_next_app 装载下一项。
退出与应用异常
写地址零、执行无效指令等应用错误由异常处理器识别。StoreFault、StorePageFault 和 IllegalInstruction 会结束当前应用并运行下一项;内核不再恢复出错的上下文。第二章的七个应用中有三个故意触发异常,其余四个通过 exit 结束。
运行观察
报告中的第一条 write 先进入 trap_handler,随后沿 syscall → sys_write 输出文本。继续查看 run_next_app,可以看到每次退出或应用异常后,下一份应用二进制被装入同一地址。七个应用结束后,内核输出 All applications completed!。
uCore 实现
第二章的内核每次只运行一个用户程序。程序退出或发生异常后,内核装入下一个程序。这里使用 uCore 2026A 第二章源码。
批次状态与装载
loader.c 用 app_num 记录应用数,app_cur 记录当前应用的序号。初值 -1 让第一次 run_next_app 将它推进到 0。应用的位置保存在内核映像中的地址表里;run_next_app 每次推进表指针,再调用 load_app。
load_app 先清零从 0x80400000 开始的 0x20000 字节,再把应用复制到这块固定的执行区域。随后内核清零 trap_page,为新应用设置入口地址和用户栈。其余应用仍在内核映像中等待装载。
首次进入用户态
user_stack 和 trap_page 各占一页。run_next_app 将 trap_page 解释为 struct trapframe,写入用户入口 0x80400000 和用户栈顶,再调用 usertrapret。
usertrapret 准备 sepc、sstatus 和下次进入内核所需的栈地址,随后由 userret 恢复用户寄存器。最终的 sret 将处理器切换到用户态,从 sepc 开始执行应用。
异常入口与上下文
uservec 先用 sscratch 取得 trapframe 地址,再保存用户寄存器和 sepc。原来的用户 a0 也写入 trapframe,随后入口读取 kernel_sp、kernel_trap 等字段,切换到内核栈并调用 usertrap。
返回时,userret 从同一份 trapframe 恢复寄存器,再执行 sret。本章尚未启用分页:汇编中切换 satp 和执行 sfence.vma 的代码被注释。
系统调用与返回
用户程序执行 ecall 后,usertrap 把保存的 epc 增加 4,使返回地址越过这条指令。syscall 从 trap_page 读取 a7 中的调用号和参数,把返回值写回保存的 a0。
sys_write 只接受描述符 1,逐字节输出后返回长度;其他描述符和未知调用号返回 -1。这个版本尚无用户页表,缓冲区地址也没有经过地址空间检查。
退出与应用异常
sys_exit 调用 run_next_app,把执行区域交给下一个应用。若批次结束,则输出 ALL DONE 并关机。非法指令、未对齐访问和页异常也会进入异常处理路径;报告异常后,内核继续装载下一应用。源码中的 core dumped. 只是诊断文字。
栈地址与上下文页
参考实现给 usertrapret 传入 boot_stack_top,函数内部又计算:
trapframe->kernel_sp = kstack + PGSIZE;
这次构建的 boot_stack_top 为 0x80219000,而 trap_page 正好占据 [0x80219000, 0x8021a000)。因此异常入口使用的栈顶是 0x8021a000;栈向低地址增长,处理函数实际在 trap_page 内使用栈。第一个应用退出后,run_next_app 清零这整页,也覆盖了当前调用栈上的返回地址。
独立修正构建将赋值改为 trapframe->kernel_sp = kstack;。处理函数回到启动栈,三个普通应用依次退出,退出码为 1234、0、0。这个问题可以用 sp、trap_page 边界和 memset 的目标地址直接定位。
运行观察
| 运行 | 结果 | usertrap 次数 |
|---|---|---|
| 原参考构建,三个普通应用 | 第一个应用退出后停滞 | 1 |
| 栈地址修正版,同一批次 | 三个应用结束 | 16 |
| 栈地址修正版,三个异常应用 | 三次异常后结束批次 | 3 |
普通批次的 16 次异常都是系统调用,包括 13 次 write 和 3 次 exit。另一批次依次执行地址零写入、sret 和读取 sstatus,可沿异常入口观察应用切换。
原参考构建 · 栈地址修正版 · 异常批次。原始记录、构建差异和命令见对应的 录制信息。
第三章:任务切换与调度
设两个程序分别输出字符 A 和 B。一个程序输出后主动让出处理器,另一个程序随后执行。下一次轮到第一个程序时,它需要继续原来的循环,保留循环变量和函数调用的进度。
操作系统为每个程序保存执行状态,并在程序之间分配处理器。本章分析上下文切换和调度过程。
本章在内核中的位置
第二章已经建立用户程序进入内核、处理请求和返回用户态的路径。第三章在这条路径中加入任务切换:处理系统调用或时钟中断时,当前程序可以暂停,内核转而恢复另一个程序。程序的代码与静态栈空间在启动时准备好,页表与独立地址空间将在第四章讨论。
保存哪些状态
程序进入内核时,异常入口保存用户寄存器和返回地址。这些信息用于恢复用户态执行,rCore 将其放在 TrapContext 中,uCore 将其放在 trapframe 中。
处理请求的内核代码也会调用函数并使用栈。切换到另一个任务之前,还需要保存当前内核调用的继续执行位置。两套实现的切换上下文都包含 ra、sp 和 s0—s11。其他寄存器由调用约定以及异常入口的保存过程共同处理。
图中左侧表示程序进入内核时保存的用户状态;右侧表示内核暂停这条执行流时保存的切换状态。恢复内核上下文后,内核先完成原有处理过程,再通过异常返回恢复用户程序。
上下文切换汇编最后执行 ret。此时 ra 已从目标上下文恢复,ret 跳转到目标任务的继续执行位置;sp 也指向目标任务的内核栈。一个切换调用可以隔着其他任务的执行,稍后才返回。
任务状态与调度资格
一个准备运行的任务处于就绪状态。被选中后进入运行状态。主动让出或被时钟抢占时,它重新进入就绪状态,等待后续调度。退出后则失去运行资格。
两套内核使用不同的枚举名称:
| 含义 | rCore | uCore |
|---|---|---|
| 未使用 | UnInit | UNUSED |
| 已分配,尚未加载完成 | 初始化过程内完成 | USED |
| 就绪 | Ready | RUNNABLE |
| 运行 | Running | RUNNING |
| 已退出 | Exited | 本章恢复为 UNUSED |
uCore 的枚举还包含 SLEEPING 和 ZOMBIE,第三章的这些路径没有使用它们。两套实现都在切换前更新调度状态,随后保存与恢复寄存器。
主动让出与时钟抢占
主动让出从用户程序的系统调用开始。内核保存用户上下文,分发让出请求,将当前任务设为就绪,再执行调度。时钟抢占由中断触发,内核设置下一次定时器,然后进入同一调度接口。
异常原因区分主动让出与时钟抢占。两条路径随后汇合到 yield 或 suspend_current_and_run_next,执行相同的调度过程。
两种切换路径
rCore 在当前任务的内核执行流中选择下一个任务,然后直接切换。uCore 先保存当前进程的内核上下文,恢复调度器上下文;调度器继续扫描进程表,再切换到选中的进程。
逐步查看切换过程:选择主动让出、时钟抢占、退出或仅一个任务可运行的场景,观察任务状态和执行位置的变化。
切换时需要保持的条件
- 目标任务已就绪,其上下文和栈存储仍然有效。
- 恢复用户态执行时,当前任务标识与实际程序一致。
- 暂停任务保留原有上下文,退出任务不再被选中。
- rCore 在调用
__switch前释放任务管理器的动态借用,使其他任务能够访问管理状态。
rCore 的任务管理器直接选择并切换任务;uCore 的调度循环先返回调度器,再选择下一进程。
rCore 实现
第三章让多个应用同时留在内存中。任务各有用户栈、内核栈和执行上下文;调度器在它们之间切换。源码采用 2026A 第三章参考实现。
任务数组与静态资源
TaskManager 使用固定数组保存任务控制块。应用编号就是数组下标;只有 0..num_app 范围内的任务参与调度。每个控制块保存状态、TaskContext 和系统调用计数。
应用代码分别装入 0x80400000 + app_id × 0x20000。初始化时,任务状态设为 Ready,内核栈顶放入初始用户上下文。首次执行该任务时,预设的返回地址指向 __restore,从那里恢复寄存器并执行 sret。
首次运行
run_first_task 将任务 0 设为 Running,以启动栈上的临时上下文为保存位置,调用 __switch。切换汇编保存启动状态后,恢复任务 0 的预设上下文。启动上下文此后不再参加调度。
让出与选择下一个任务
sys_yield 调用 suspend_current_and_run_next,先把当前任务改为 Ready。run_next_task 从当前编号的下一项开始循环查找 Ready 任务;扫描一圈后也会检查当前任务。
选中其他任务时,调度器取出两个 TaskContext 地址,释放对任务管理器的借用,再调用 __switch。下一个任务恢复执行后也会访问任务管理器,因此切换时不能保留这次动态借用。
若只有当前任务就绪,它会重新变成 Running,run_next_task 直接返回。此时没有发生上下文切换,用户的 yield 调用仍会正常结束。
切换后从哪里继续
__switch 保存当前任务的 ra、sp 和 s0—s11,再恢复目标任务的相同寄存器。已经运行过的任务,其 ra 指向上次调用 __switch 之后;恢复后从原来的内核调用链继续,最终返回用户态。
新任务的 ra 由初始化代码设为 __restore。这使首次运行与后续恢复共用一套切换汇编,而用户态入口由各自的上下文决定。
时钟抢占与退出
时钟中断分支先设置下一次触发时间,再调用 suspend_current_and_run_next。主动 yield 与时钟抢占经过同一条调度路径。
任务退出时,exit_current_and_run_next 将状态设为 Exited。调度器只选择 Ready 任务;所有应用结束后,内核关机。
运行观察
报告中一次主动让出的调用链为 sys_yield → suspend_current_and_run_next → run_next_task → __switch。这次切换从任务槽位 7 到槽位 8。用户程序输出的 A、B、C 字符行交错出现,对应三个程序轮流运行。
ch3_sleep 使用 get_time() 和 yield_() 等待时间到达。其他任务退出后,它仍不断让出;此时调度器再次选中它自身。函数视图中的 sys_yield 次数会继续增加,__switch 次数却不会同步增加。
uCore 实现
第三章开始让多个应用共享处理器。内核先把应用分别装入内存,再由调度器决定运行哪一个。以下代码来自 uCore 2026A 第三章。
进程表与上下文
proc.c 的 pool 有 16 个槽。每个槽关联内核栈、用户栈和异常上下文。allocproc 取得空槽、分配 PID,将首次运行时的返回地址设为 usertrapret,栈指针设为该进程的内核栈顶。
run_all_app 装入应用、设置用户入口和栈,再把进程设为 RUNNABLE。应用依次放在 0x80400000 + i × 0x20000。PID 标识进程,调度器扫描的则是 pool 槽位;两种编号不能混用。
调度器选择进程
scheduler 从第一个槽位开始扫描 pool。找到 RUNNABLE 进程后,它把状态改为 RUNNING,更新 current_proc,再调用 swtch(&idle.context, &p->context)。
swtch 保存调度器的寄存器上下文,恢复进程的上下文。第一次切入新进程时,执行从 usertrapret 开始,最终通过 sret 进入用户态。进程以后再切回调度器,调度器从这次 swtch 的下一条指令继续扫描。因此,槽位顺序也决定了一轮扫描中的选择顺序。
让出处理器
yield 将当前进程改为 RUNNABLE,然后调用 sched。sched 以 swtch(&p->context, &idle.context) 保存进程、恢复调度器。调度器随后选择下一进程,再执行一次 swtch。即使最后选中的还是原进程,也必须先经过调度器。
用户程序通过系统调用 124 主动让出。时钟中断也调用 yield,但会先设置下一次定时器。两条路径会合后,进程都从原来 sched 的返回位置继续执行。
这里的 current_proc 在调度器恢复后仍指向刚刚离开的进程,直到下一个进程被选中。判断哪段上下文正在执行,应看 swtch 的两个参数以及调度器所在的位置。
退出与批次结束
exit 将进程设为 UNUSED,调用 finished 记录一个应用完成,再切回调度器。状态已不是 RUNNABLE,以后扫描不会选中这个槽。
最后一个应用退出时,finished 输出 all apps over 并结束运行;这一次不会再执行后面的 sched。这与前面各应用正常返回调度器的路径不同。
运行观察
九个课程程序同时装入后,三个输出程序的第一轮结果为:
AAAAAAAAAA [1/5]
CCCCCCCCCC [1/5]
BBBBBBBBBB [1/5]
每输出一行,程序就主动让出;调度器沿进程表继续寻找下一项。在报告中选择 PID 4 的一次系统调用 124,可以依次看到 yield → sched → swtch,随后调度器又以一次 swtch 切入 PID 5。前一次保存 PID 4,后一次恢复 PID 5,两次切换都经过 idle.context。
时钟中断也走同一条 yield 路径。选择 interrupt.timer,再查看前后的 sched.switch,可以把异常处理、状态改变和两次上下文切换连起来。
第四章:地址空间与页表
两个程序都访问 0x4000,读到的内容可以不同。处理器使用当前页表,将这个虚拟地址转换成各自的物理地址。程序能够访问哪些页、能否修改或执行其中的内容,也由页表项中的权限位决定。
第三章保存和恢复程序的执行状态。本章进一步为每个程序建立地址空间,使切换后的程序在自己的内存中继续执行。
地址空间在内核中的位置
任务控制块关联用户页表、用户栈和异常上下文。异常入口在用户页表下保存寄存器,随后切换到内核页表。返回用户态时,内核选择当前任务的页表,恢复寄存器并执行 sret。
一次完整访问涉及三类对象:虚拟地址范围说明内存的用途,页表建立虚拟页到物理页的对应关系,物理页分配器提供实际存储。撤销页表项和归还物理页分别改变后两类对象。
Sv39 地址转换
本章两套实现都使用三级页表和 4 KiB 页。虚拟地址的低 12 位是页内偏移,三个 9 位字段依次索引根页表、中间页表和末级页表。每张页表占一个物理页,包含 512 个 8 字节页表项。
以 0x4123 为例,三级索引为 0、0、4,页内偏移为 0x123。末级页表项若指向物理页 0x87fb9000,转换结果为 0x87fb9123。这个对应关系来自 uCore 的独立页表实验。
satp 保存地址转换模式与根页表物理页号。写入新的 satp 后,两套异常返回汇编执行 sfence.vma,使后续访问按新的页表转换。
页表项与访问权限
| 位 | 含义 | 本章用途 |
|---|---|---|
V | 有效 | 区分页表项是否存在 |
R、W、X | 读、写、执行 | 约束对末级映射的访问 |
U | 用户态可访问 | 区分用户页与内核专用页 |
A、D | 已访问、已修改 | 由地址转换机制检查或更新 |
中间页表项设置 V,指向下一层页表。末级映射还包含访问权限和目标物理页号。创建映射时需要防止覆盖已有映射;查找地址时则无需分配新的页表页。
Sv39 的有效虚拟地址要求第 63 至 39 位与第 38 位相同。rCore 的地址类型按这一规则处理高地址;uCore 本章的 walk 将地址限制在 MAXVA 以下。
用户页表与内核页表
用户程序进入异常处理时,处理器已经切换到 S 模式,页表仍然属于用户程序。异常入口必须先在这张页表中执行,保存用户寄存器,再切换到内核地址空间。
两套内核将跳板代码映射到用户页表与内核页表中的同一个虚拟地址。切换 satp 后,下一条指令仍然能从这个地址取到。异常上下文页设置读写权限而不设置 U,供 S 模式入口保存寄存器;用户程序无法直接访问它。
内核页表还包含内核代码、数据和可用物理内存的恒等映射。内核将用户虚拟地址转换为物理地址后,可以通过这些映射访问用户缓冲区。
建立与撤销映射
建立映射时,内核取得数据页和必要的页表页,再设置页表项。撤销映射后,是否归还数据页取决于该页的所有权。同一物理页可以被多张页表使用,归还前需要确保原有访问都已结束;本章的共享跳板就在各用户页表中保留同一个物理页。
页表本身也占用物理页。撤销一个末级映射通常保留中间页表,完整销毁地址空间时才逐层释放这些结构。数据页的归属与页表页的归属需要分别管理。
rCore 实现按内存区域组织这些关系;uCore 实现使用页表函数和显式释放参数。两套源码的具体权限检查、装载方式和回收路径见各自小节。
rCore 实现
第四章为每个应用建立独立的虚拟地址空间。页表负责地址转换,MemorySet 负责组织代码、数据、栈和堆所在的区域。源码采用 2026A 第四章参考实现。
页表与物理页
PageTable 保存根页表页号。建立映射时,find_pte_create 沿 Sv39 的多级页表向下查找,按需分配中间页表页,map 再写入末级页表项。unmap 清除末级页表项;数据页由上层的内存区域管理。
物理页分配器返回 FrameTracker。新分配的页会清零,FrameTracker 析构时归还页号。页表持有自己的页表页,Framed 类型的内存区域持有应用数据页。两类页各由相应的所有者负责释放。
装载与地址空间
MemorySet::from_elf 遍历 ELF 中非空的 PT_LOAD 段,按段的读、写、执行标志建立用户映射,再将文件内容复制到段的虚拟地址。新页已经清零,段在文件内容之后的内存部分保持为零。
最高的装载段之后留出一个未映射页,再建立用户栈。栈顶也是初始堆边界。内核还把异常上下文映射到跳板下方一页,把跳板代码映射到固定的高地址;这两处映射没有用户访问权限。
异常入口与页表切换
异常发生时,处理器仍使用用户页表。跳板中的 __alltraps 先把用户寄存器保存到异常上下文,从中取得内核页表令牌、内核栈指针和异常处理函数地址,然后切换 satp,进入 Rust 处理代码。
返回用户态时,__restore 在跳板中切回用户页表,执行 sfence.vma,恢复寄存器,最后执行 sret。跳板在两个地址空间中映射到相同的虚拟地址,页表切换前后的指令流因而能连续执行。
用户地址检查
translate_user 检查地址是否符合 Sv39 格式,再检查页表项的有效位、用户位和请求的读写权限。返回的物理地址保留页内偏移。
sys_trace 读取用户字节时请求读权限,写入时请求写权限。sys_get_time 先检查整个输出范围,再逐页写入 TimeVal。跨越页边界的用户缓冲区需要分别检查每个页表项。
映射与回收
sys_mmap 检查起始地址、长度和权限,建立用户可访问的 Framed 区域。sys_munmap 调用 remove_framed_area;这一版实现按区域的起止页号查找,要求一次撤销完整区域。找到区域后,内核清除页表项并归还其数据页。
sys_sbrk 改变堆边界。边界跨过页时才需要增加或撤销数据页;同一页内的变化只更新逻辑边界。
运行观察
报告中可沿 sys_munmap → munmap_current_task → remove_framed_area → MapArea::unmap 查看一次完整区域撤销。页表项清除与 FrameTracker 归还随后发生。sbrk 缩小堆时也会出现单页撤销,但调用链从堆区域的 shrink_to 开始。
运行的 21 个应用还包括故意访问未映射地址的程序。它们触发的缺页异常进入异常处理路径;成功的 mmap 和 munmap 则沿系统调用路径返回用户态。
uCore 实现
第四章为每个进程建立独立页表。用户程序看到自己的虚拟地址空间;异常入口和返回汇编则负责在用户页表与内核页表之间切换。以下代码来自 uCore 2026A 第四章。
页表与物理页
walk 根据 Sv39 的三级索引查找页表项。中间页表不存在时,alloc = 1 会分配并清零一页;alloc = 0 则返回空指针。mappages 逐页取得末级页表项,写入物理页号和权限。
物理页分配器是空闲页链表。kalloc 取出一页并填入字节 5,kfree 填入字节 1 后归还。新页表页必须由调用者清零,才能把所有页表项初始化为无效。
装载与地址空间
bin_loader 为进程创建用户页表,将嵌在内核映像中的应用映射到 BASE_ADDRESS,权限为 R | W | X | U。代码之后留一页空隙,再分配用户栈。
内核还把异常上下文页映射到 TRAPFRAME,权限为 R | W;把跳板映射到 TRAMPOLINE,权限为 R | X。这两处映射没有 U,用户态不能直接访问。各进程拥有自己的页表根,跳板的物理代码则由它们共享。
异常入口与页表切换
uservec 先在用户页表下保存寄存器,再从异常上下文取出内核页表和内核栈。写入 satp、执行 sfence.vma 后,才跳转到内核中的异常处理函数。
返回汇编 userret 先切回用户页表,再恢复用户寄存器,最后执行 sret。跳板同时映射在两套页表中,使切换 satp 前后的汇编指令能够继续执行。
用户地址检查
walkaddr 要求虚拟地址低于 MAXVA,页表项有效且带有 U 标志;useraddr 再加上页内偏移。copyin、copyout 按页界分段,每到下一页都重新查找映射。
本章的 walkaddr 没有分别检查 R 与 W。因此,这两个复制函数只验证页面可由用户访问,尚未按复制方向验证读写权限。
映射与回收
uvmunmap 清除末级页表项。参数 do_free = 0 只解除映射,物理页仍由调用者持有;do_free = 1 还调用 kfree。freewalk 递归释放页表页,要求叶子映射此前已经解除。
这个提交已实现 sys_sbrk,但 mmap、munmap 和 trace 仍留待后续实现。freeproc 中的 uvmfree 调用被注释:退出进程会恢复表项状态,却不会在这条路径上释放其整个地址空间。这一点可与下章的回收代码对照。
运行观察
页表实验将虚拟地址 0x4000 映射到物理页 0x87fb9000,写入并读回页内偏移 0x123 的字节 0x5a。接着分别以两种 do_free 参数撤销同一数据页的映射:
| 操作 | 物理页的处理 |
|---|---|
uvmunmap(..., 0x4000, 1, 0) | 只清除页表项 |
重新映射后 uvmunmap(..., 0x4000, 1, 1) | 清除页表项并归还数据页 |
实验还以 do_free = 0 解除跳板映射,因为跳板代码不是这张临时页表独占的物理页。实验结束时,临时页表页和数据页均已归还,随后六个基础应用继续运行。
第五章:进程创建与回收
一个用户程序执行 fork 后,父进程和子进程都从这次系统调用之后继续运行。父进程得到子进程的 PID,子进程得到零。两者最初拥有相同的用户寄存器和内存内容,随后可以分别修改自己的数据、执行不同程序。
第四章建立独立地址空间。本章将地址空间、执行状态和进程关系结合起来,使用户程序能够在运行期间创建和回收进程。
进程的执行环境
进程控制块保存 PID、状态、地址空间、用户寄存器、内核执行上下文以及父子关系。调度器从就绪进程中选择下一次运行的对象。进程进入系统调用后使用自己的内核栈;暂停时,任务上下文记录内核执行位置,异常上下文保留用户执行位置。
进程切换沿用第三章的上下文切换机制;数据页、页表页和异常上下文页构成第四章介绍的地址空间。创建或回收进程时,内核同时更新这些执行状态、内存资源和父子关系。
fork 的两次返回
异常处理器先将保存的用户 PC 移到 ecall 的下一条指令,再处理系统调用。fork 复制这个异常上下文,因此子进程首次返回用户态时也从下一条指令执行。内核将子进程保存的 a0 设为零,并向父进程返回子进程 PID。
两套参考实现都为子进程分配独立的数据页,将父进程的内容复制过去。复制后,同一个用户虚拟地址在两张页表中指向不同物理页。共享跳板继续使用原有物理页。
子进程还需要自己的内核栈和初始任务上下文。两个进程分别保存内核执行状态。
exec 装载新程序
exec 在现有进程中装载目标程序,重新设置入口地址和用户栈。PID 和父子关系保持不变,接下来的异常返回进入新程序。
本章的程序保存在内核镜像中,装载器按名字查找。shell 可以先 fork 创建子进程,让子进程 exec 目标程序,自己等待子进程结束。这样,目标程序退出后,shell 的地址空间和执行位置仍然保留。
rCore 本章还提供 spawn,直接用目标 ELF 建立子进程。它与 fork 后 exec 的资源分配顺序不同,具体实现见 rCore 分析。
退出与等待
进程退出后,调度器不再选择它。退出状态仍需交给父进程,因此内核保留 PID、退出码和父子关系,供 waitpid 查询。
资源在退出和等待两个阶段分别变化。rCore 先释放用户数据页,父进程等待成功时再释放子进程控制块、页表页、PID 和内核栈。uCore 在退出时释放用户数据页和页表页,等待成功后将进程表项恢复为 UNUSED;它的内核栈和异常上下文使用静态数组。
僵尸状态保存的是已经结束的进程信息。父进程先退出时,两套实现处理子进程的方式也不同:rCore 将子进程交给初始进程,uCore 清空子进程的父指针,并清理已经退出的子进程表项。
调度与进程关系
创建进程只建立执行环境,加入就绪队列后才可能获得处理器。进程主动让出或被时钟抢占时,内核保存任务上下文,回到调度器,再选择就绪进程。
本章 rCore 使用 stride 调度,根据优先级计算每次选择后的增量;uCore 使用先进先出的就绪队列。等待子进程的路径也不同:rCore 的用户库在子进程尚未退出时让出并重试,uCore 在内核的 wait 循环中重新入队并切换。
沿一次 fork、exec、exit 和 waitpid,可以在 rCore 实现与 uCore 实现中对照进程的建立、调度和回收。进程接口的概念说明还可参考 rCore 教程第五章。
rCore 实现
第五章引入进程:每个进程拥有独立地址空间和 PID,调度器通过就绪队列选择运行对象。以下分析对应 2026A 第五章参考实现。
进程与调度器
TaskControlBlock 保存 PID、内核栈和可变的进程状态。状态中包括地址空间、异常上下文、任务上下文、调度参数及父子关系。父进程以 Arc 持有子进程;子进程用 Weak 指回父进程。
就绪队列保存等待运行的进程。调度器取出一个进程,将它设为 Running,再从处理器的空闲上下文切换过去。进程让出处理器时重新入队,并切回空闲上下文。初始用户进程 INITPROC 拥有 PID 0;空闲上下文是调度器执行时使用的内核上下文。
fork 与地址空间
fork 为子进程分配 PID、内核栈和新的用户地址空间。MemorySet::from_existed_user 遍历父进程的区域,为子进程分配物理页并复制内容。子进程的异常上下文也由此复制,再把其中的内核栈指针改为子进程自己的栈顶。
父进程把新控制块加入 children。sys_fork 将子进程保存的 a0 设为 0,加入就绪队列,并把子 PID 返回给父进程。两者从同一条用户指令之后继续执行,通过返回值区分自己的身份。
exec 与程序装载
sys_exec 根据用户传入的名字查找程序。找到 ELF 后,exec 建立新地址空间并替换旧地址空间,重置用户入口、用户栈和堆边界。PID、内核栈及父子关系保持不变。
spawn 直接根据目标 ELF 创建子进程。它分配新进程并装载目标程序,随后建立父子关系、加入就绪队列。与 fork 后再 exec 相比,这条路径省去了复制父进程旧地址空间的步骤。
waitpid 与退出
进程退出时,内核记录退出码,状态改为 Zombie,释放用户数据页。控制块仍由父进程持有,以便随后读取退出码。退出进程的子进程转交给 INITPROC。
waitpid 查找指定 PID 的子进程;-1 表示任意子进程。没有匹配项返回 -1,匹配项尚未退出返回 -2,找到僵尸进程则移除它并返回 PID 与退出码。用户库遇到 -2 会让出处理器后重试。控制块的最后一个强引用释放后,剩余页表页、PID 和内核栈随对象析构。
调度策略
TaskManager::fetch 选择 stride 最小的就绪进程,同值时按入队顺序选择。每选中一次,便将该进程的 stride 增加 65536 / prio。
例如优先级 4 的增量是 16384,优先级 8 的增量是 8192。两者都持续就绪时,后者的 stride 增长较慢,会更频繁地得到处理器。set_priority 更改优先级时保留已经累积的 stride。
运行观察
基础报告中,PID 0 首先通过 fork 创建 PID 1,PID 1 随后执行 exec,成为 shell。shell 再创建测试进程。沿 fork → exec → waitpid 查看同一组 PID,可以把进程创建、程序替换和退出后的回收连起来。
扩展报告包含 spawn 和优先级测试。函数视图中选择 TaskManager::fetch,结合就绪进程的 stride 与 prio,可以核对每次选择后只更新被选中的进程。
uCore 实现
第五章引入 fork、exec 和 wait,进程不再只能由启动阶段批量装载。下面沿一次“父进程创建子进程、子进程换程序、父进程等待”的过程阅读 uCore 2026A 第五章源码。
进程与调度器
pool 是含 512 个表项的静态进程表。每个表项关联一块静态内核栈和异常上下文页,并记录 PID、状态、页表和父进程。allocproc 取得空表项,分配 PID,创建用户页表,初始化首次切入进程所需的内核上下文。
就绪进程的表项下标放入 task_queue。调度器从队头取出表项,将进程设为 RUNNING,再从 idle.context 切入。进程主动让出或被时钟中断抢占时重新排到队尾。
fork 与地址空间
fork 先为子进程创建页表,再用 uvmcopy 复制父进程的用户页。每个有效映射对应一块新物理页,内容和权限从父进程复制;原地址空间中的空洞保持为空洞。
子进程还继承用户栈位置、堆边界和异常上下文。内核把子进程保存的 a0 改为 0,而父进程从 fork 得到新 PID。两者的用户 PC 都已经越过 ecall,下一次进入用户态后便从同一调用点的后面分别继续执行。子进程被设为 RUNNABLE 并加入队列。
exec 与程序装载
exec 根据名字查找内嵌程序。找到后,先撤销旧程序的低地址用户映射并释放数据页,再由 bin_loader 装入新程序。装载器把原始二进制复制到新分配的页,从 BASE_ADDRESS 开始映射;代码与栈之间留一页空隙。
这次替换保留 PID、父进程关系、根页表、内核栈和异常上下文的存储。装载器重新设置用户入口、栈和堆边界。exec 返回内核异常处理路径后,新程序从新入口开始运行。
waitpid 与退出
wait 在进程表中寻找当前进程的子进程。指定 PID 时只匹配该子进程;pid <= 0 匹配任意子进程。子进程仍在运行,父进程就重新入队并切回调度器,下一次获得处理器时继续扫描。
子进程退出时,freeproc 解除用户映射、释放数据页和页表页,随后保留一个 ZOMBIE 表项供父进程读取退出码。wait 找到它后,把表项改为 UNUSED 并返回 PID。等待期间,父进程保持 RUNNABLE,通过调度器轮换后再次检查。
调度策略
task_queue 按先进先出顺序保存进程表下标。主动让出、时钟抢占和等待中的父进程都调用 add_task 排到队尾;fetch_task 由队头取出。
sched 只负责把进程上下文切回 idle.context。调度器恢复后再取下一项,因此一次进程间轮换会经过两次 swtch,中间运行的是调度器。这个实现没有在队列中按优先级排序;sys_set_priority 尚未实现。
运行观察
课程批次从 ch5b_usertest 启动,运行了 fork、退出、等待等 12 项基础测试。第一次 fork 中,PID 1 创建 PID 2;PID 2 执行 exec 后退出,父进程在 wait 中取得它的退出状态。
在运行报告中筛选 PID 1 和 PID 2,可以顺着 fork → exec → exit → wait 看进程关系;再看 sched 与 swtch,可见父进程等待期间怎样把处理器交给子进程。录制信息保存源码版本、运行命令与构建记录。
第六章:文件系统
前一章的装载器从内核镜像中取出用户程序。本章把程序和普通文件放到磁盘上,内核通过文件系统按名字查找、读取和修改它们。文件系统把文件的字节偏移转换成磁盘块号,并在内存中缓存经常访问的块。
文件名与文件描述符
应用先用文件名打开文件,得到一个整数文件描述符。后续 read、write 和 close 使用这个描述符。内核在进程的描述符表中查到文件对象,再通过索引节点访问文件内容。
文件对象保存这次打开的读写位置。两次独立的 open 得到两个文件对象,各自从文件开头读写;fork 复制已有对象的引用,父子进程因而共享读写位置。索引节点保存文件大小与数据块地址,同一个文件的不同打开对象都访问它。
关闭一个描述符会释放它持有的文件对象引用。目录项保存文件名到索引节点编号的对应关系。rCore 删除文件名时修改目录项;关闭描述符则释放进程持有的引用。
从字节到磁盘块
设文件系统块大小为 B,文件偏移为 offset。offset / B 给出文件内的逻辑块号,offset % B 给出块内偏移。索引节点中的直接地址或间接索引把这个逻辑块号转换成磁盘块号。
直接地址足够保存小文件。文件增长到直接地址容量以外时,需要额外的索引块,保存更多数据块地址。索引块自身也占用磁盘空间;截断文件时,内核需要回收数据块和索引块。
打开偏移计算图。输入文件偏移,查看需要访问的直接地址或间接块地址项;两套布局可以在图中直接切换。
用户缓冲区还可能跨越页边界。文件内容按磁盘块读取,用户地址按页表映射复制;两种边界可以落在请求的不同位置。
缓存与设备请求
目录查找、读取文件和分配磁盘空间都需要访问磁盘块。块缓存按磁盘块号保存内容。命中已有缓存时,内核直接访问内存;没有相应缓存时,块设备驱动发出读取请求。
写操作还涉及内存与磁盘内容的一致性。rCore 的缓存记录脏标志,由同步操作或缓存析构写回。uCore 的文件系统在修改块之后显式调用 bwrite。两套实现都直接更新数据、索引节点与分配位图,没有日志事务层。
块设备通过 VirtIO 与 QEMU 中的磁盘交互。rCore 的本章驱动轮询请求完成状态;uCore 发布请求后等待设备中断更新完成标志。沿一次读写,可以分别找到文件操作、缓存访问和实际设备请求。
文件系统与进程
exec 通过文件系统取得目标程序,再建立新的用户执行环境。原进程的 PID、父子关系和文件描述符保留。进程退出时关闭描述符,释放文件对象引用。
文件的内容格式由使用它的程序解释。rCore 的装载器读取 ELF 并按段建立映射;uCore 的装载器读取平坦二进制文件,按固定起始地址装入用户内存。两者在文件系统中都以普通文件保存程序。
源码阅读
从系统调用入口开始,先找到描述符如何选中一个文件对象,以及偏移在哪里更新。随后查看目录查找、逻辑块到磁盘块的转换、块缓存和设备驱动。最后回到 fork、exec 与退出路径,检查描述符表中的引用如何保留或释放。
rCore 实现和 uCore 实现分别说明文件对象、块索引与缓存。文件接口的概念还可参考 rCore 教程的文件系统接口。
rCore 实现
第六章把文件接口接入进程的描述符表,并用 easy-fs 在块设备上保存目录和文件。以下代码取自 2026A 第六章参考实现。
文件对象与描述符
OSInode 把磁盘 inode 包装为进程可使用的文件对象,保存访问权限和当前偏移。open_file 每次成功打开都会创建一个新的 OSInode,所以两次打开同一文件有各自的偏移。fork 克隆描述符表中的 Arc,父子进程则共享原来的文件对象及偏移。
sys_read 和 sys_write 从描述符表取出文件对象,检查权限,随后调用 File 接口。用户缓冲区按页拆分;文件对象根据实际读写的字节数推进偏移。关闭描述符只移除该槽中的引用,其他描述符仍可继续使用同一对象。
目录与索引节点
easy-fs 的 Inode 根据 inode 所在块号和块内偏移访问磁盘元数据。目录内容由固定大小的 DirEntry 构成,每项占 32 字节,保存文件名和 inode 编号。
创建文件时,文件系统分配 inode,并在目录中加入名字。link 为已有文件增加目录项和链接数。unlink 移除目录项、减少链接数;最后一个链接消失时回收该文件的数据块和 inode。截断文件会清空数据块,目录项和 inode 编号继续保留。
数据块与磁盘布局
DiskInode 保存 27 个直接块地址、一个一级间接块地址和一个二级间接块地址。块大小为 512 字节,间接块可以存放 128 个块地址。
| 文件内的块号 | 查找方式 |
|---|---|
| 0–26 | 直接地址 |
| 27–154 | 一级间接块 |
| 155 起 | 二级间接块,再进入对应的一级间接块 |
文件偏移达到 79,360 字节时开始使用二级间接块。索引块本身也占用磁盘块;文件增长时分配,截断时一并回收。DiskInode::read_at 以 512 字节块为界处理数据,系统调用层另以 4096 字节页为界处理用户缓冲区。
块缓存与写回
BlockCacheManager 最多保存 16 个缓存块。命中时直接返回缓存对象;缓存满时,管理器从队列中寻找没有被外部持有的块并替换。
修改缓存块会把它标为脏块。sync 将脏块写回设备,文件写入、截断和目录操作结束时调用全局同步。一次文件操作可能更新位图、索引块、数据块和 inode;沿这些写回位置可以理解磁盘状态如何形成。
磁盘请求
VirtIOBlock 把 read_block、write_block 转交给 VirtIO 块设备驱动。缓存命中时无需发起设备请求;缓存未命中时,调用链才会到达设备读入。
驱动使用 MMIO 寄存器和 VirtIO 队列提交请求,并轮询完成队列。设备访问的是物理地址,队列和数据缓冲区所需的地址由内核的地址转换与页分配接口提供。
程序装载与资源释放
初始进程和 exec 从文件系统读取 ELF。新打开的 OSInode 偏移为零,read_all 从文件开头读到结尾;MemorySet::from_elf 再建立程序的用户映射。
fork 复制地址空间并共享已有文件对象。exec 替换地址空间,保留描述符表。进程退出时清空描述符表,用户数据页也随退出路径回收。
运行观察
报告可从 Inode::read_at 沿 DiskInode::read_at → get_block_id 追到文件内块号的查找。再选择 get_block_cache,可以区分缓存命中与到达 VirtIOBlock::read_block 的请求。
扩展测试中的 ch6_file3 每轮写入 145,000 字节,越过二级间接块的起点;关闭并删除文件后,目录中不再保留 fname3。这段运行适合连贯查看数据块分配、缓存写回和文件删除。
uCore 实现
第六章把程序和普通文件放到磁盘上。一次 read 从进程的文件描述符出发,经过文件对象、inode、块缓存,最后才可能向 VirtIO 设备发起请求。以下代码来自 uCore 2026A 第六章。
文件对象与描述符
每个进程有 16 个文件描述符槽,槽中保存指向全局 filepool 的指针。文件对象保存引用数、读写权限、当前偏移和 inode 指针。两次独立打开同一文件得到两个文件对象,各有自己的偏移;fork 则复制指针并增加引用数,父子进程共享偏移。
inoderead、inodewrite 根据传输的字节数推进文件对象中的偏移。fileclose 在最后一份引用关闭时释放文件对象对 inode 的引用,磁盘上的目录项和文件内容继续保存。
本章 sys_read、sys_write 检查描述符是否有效,并根据文件类型进入控制台或 inode 路径;代码尚未用文件对象的 readable、writable 字段限制这两个调用。O_TRUNC 则通过 itrunc 清空文件内容并回收块。
目录与索引节点
磁盘上的 dinode 保存文件类型、大小和块地址;内存中的 inode 另有设备号、inode 编号和引用数。iget 在内存表中查找或取得一个槽,ivalid 首次使用时从磁盘载入,iupdate 把改动写回。
根目录的 inode 编号是 1。每个目录项占 16 字节,前 2 字节是 inode 编号,后 14 字节是名字。dirlookup 按目录项遍历,dirlink 写入空项或追加新项。此时 namei 直接在根目录查找文件名,还没有逐级解析路径。
内存 inode 的引用数表示当前有多少内核对象持有它,fileclose → iput 会减少这个计数。本章未实现磁盘文件链接数,linkat、unlinkat 和 fstat 系统调用返回 -1。
数据块与磁盘布局
文件系统块大小为 1024 字节。一个磁盘 inode 有 12 个直接块地址,以及一个一级间接块地址;间接块可保存 256 个地址。文件内逻辑块号 0–11 直接查 inode,12–267 则在间接块中查找。最大文件长度为 268 个块。
bmap 把文件内的逻辑块号转换为磁盘块号,缺块时调用 balloc。writei 按块写入并更新 inode 的大小和块地址;readi 按文件大小截断读取范围,再通过 bread 取得相应块。
本次镜像有 1000 个块:块 0 为引导块,块 1 为超级块,块 2–14 存放 inode,块 15 为位图,数据区从块 16 开始。位图管理磁盘块,内存 inode 表则管理当前载入的 inode;两者容量不同。
块缓存与写回
bcache 有 30 个缓存项。bget 先查找相同设备号和块号;未命中时,从链表尾部挑选无人引用的缓存项。bread 只在缓存尚无有效内容时读取设备;bwrite 把缓存内容写回设备;brelse 释放引用并调整链表次序。
目录、位图、inode 和文件数据都经过这套缓存。itrunc 依次释放直接数据块、间接数据块与间接索引块,再更新 inode。磁盘写入按块发生,这个文件系统没有日志事务。
磁盘请求
virtio_disk_rw 用三个描述符组成一次请求:请求头、数据缓冲区和完成状态。文件系统块为 1024 字节,设备扇区为 512 字节,因此扇区号是文件系统块号的两倍。
驱动将请求放入 available ring,通知设备,然后等待完成。设备中断进入 virtio_disk_intr;该函数读取 used ring、检查状态并标记请求完成。由 bread 进入驱动的是读请求,由 bwrite 进入驱动的是写请求。缓存命中时,bread 不会再次访问设备。
程序装载与资源释放
bin_loader 从磁盘读取平坦二进制文件,从用户地址 0x1000 起逐页装入。程序之后留一页未映射空间,再建立用户栈。exec 换掉旧用户映射并装入新程序,进程已打开的文件仍在描述符表中。
进程退出时,freeproc 释放用户页表并逐个关闭文件。父进程以后从僵尸进程表项取回退出码;磁盘文件不因进程退出而消失。
运行观察
课程批次以 ch6b_usertest 为初始程序。测试创建文件 filea,写入 Hello, world! 和换行;运行后磁盘上的文件大小为 14 字节。
在运行报告中依次查看 fileopen → readi → bread → virtio_disk_rw,可以区分文件名查找、文件内偏移、块缓存和设备请求。virtio_disk_intr 则对应请求完成。另一条 sys_exec → exec → namei → dirlookup → readi 调用链展示了程序如何从磁盘文件变成用户地址空间中的页面。录制信息保存构建和磁盘镜像记录。
第七章:进程间通信
上一章用文件描述符访问磁盘文件。本章沿用这个接口,在内存中建立管道,让一个进程写入的字节被另一个进程读到。管道把进程之间的数据传递、调度和资源释放联系起来。
父子进程共享管道
pipe 创建一个读端和一个写端,把两个描述符交给调用者。两端连接同一个缓冲区。应用通过写端把字节放到队尾,通过读端取走队首字节,按 FIFO 顺序传递数据。
父进程先创建管道,再调用 fork。子进程继承描述符表中的引用,因而可以访问同一管道。若父进程负责写、子进程负责读,父进程关闭自己的读端,子进程关闭自己的写端;双方只保留后续使用的端点。
一个管道传递一个方向的数据。双向通信需要两个管道,分别连接两个方向的写端与读端。rCore 的大管道用例先由父进程发送 3000 字节,再由子进程通过另一管道送回校验结果。
读写和缓冲区
缓冲区大小固定,读取和写入分别推进数组中的位置。位置到达末尾后从零继续,形成环形缓冲区。rCore 的容量为 32 字节,uCore 的容量为 512 字节。
一次请求可以超过缓冲区容量。写进程填满缓冲区后,需要让读进程取走一部分数据,再继续写入。读进程遇到空缓冲区时,也需要等写进程产生数据。本章两套实现通过主动让出处理器并重查条件完成等待,进程仍保留在可调度状态。
读取返回的时机由具体实现决定。rCore 尽量读满请求长度,写端全部关闭后才允许不足长度的返回。uCore 等到至少有数据之后,读取当前可用字节并返回,应用需要循环处理短读。
打开管道交互图。选择“超过缓冲区容量”,交替运行写进程与读进程,查看请求进度、数组下标和缓冲区占用。再选择“短读与等待”,比较两个读取函数何时返回。
关闭与通信结束
关闭描述符释放它持有的文件对象引用。fork 后,父子进程可能分别持有同一端点;rCore 的 dup 也会增加引用。只有所有写端引用都释放,读进程才能判断后续不会再有数据。
写端关闭时,缓冲区可能还留有未读字节。读进程先取走这些字节,再处理空缓冲区上的结束条件。两端均释放后,内核回收共享缓冲区。rCore 在读空且写端全部关闭时返回已读长度,uCore 在空管道上返回 -1。
描述符的生命周期会影响程序能否结束。例如子进程忘记关闭自己继承的写端,即使父进程已经关闭写端,子进程仍可能等待自己不会再写入的数据。
源码阅读
先沿 sys_pipe 找到端点、缓冲区和描述符的创建顺序,再阅读缓冲区中的下标更新与读写循环。随后追踪 fork、close 和退出路径,检查端点最后由谁释放。用户缓冲区的页表转换继续使用第四章的地址空间机制。
rCore 实现还分析 dup、shell 重定向和信号处理;uCore 实现结合独立边界实验,分析跨页复制、短读与描述符分配失败后的回滚。管道接口的基本使用也可参考 rCore 教程的管道一节。
rCore 实现
第七章通过描述符把管道接入文件接口,进程由此可以传递字节流。dup 和 exec 又使 shell 能够重定向标准输入输出。本节对应 2026A 第七章参考实现。
管道对象与描述符
make_pipe 创建一个环形缓冲区和两个 Pipe 对象。读端只允许读取,写端只允许写入;两个端点通过 Arc 共享缓冲区。
sys_pipe 为两个端点分配描述符,并把编号写回用户数组。sys_dup 给同一个文件对象增加一个描述符。fork 克隆描述符表后,父子进程仍引用同一组管道端点,写入的一方和读取的一方因而能够共享缓冲区。
环形缓冲区
PipeRingBuffer 有 32 个字节槽。head 指向下一次读取的位置,tail 指向下一次写入的位置,两者到达数组末尾后从 0 继续。
当 head == tail 时,缓冲区可能为空,也可能已满。status 用 Empty、Normal、Full 区分这三种状态。满状态下 32 个槽都能保存数据。例如 head = 28、tail = 4 且状态为 Normal 时,未读数据跨过数组末尾,共有 8 字节。
读写与调度
Pipe::write 把用户缓冲区的字节依次写入环形缓冲区。空间用尽时,它释放缓冲区的动态借用,调用 suspend_current_and_run_next,恢复后再检查可写空间。已经写入的字节数保存在本次调用的局部变量中。
Pipe::read 在没有数据、写端仍存在时以同样方式让出处理器。写端全部关闭后,读端先取完缓冲区中剩余的数据,再以短读或 0 表示文件结束。零长度的读写直接返回 0。
这里的等待使用任务让出:任务保持就绪,调度器以后还会再次选择它。管道读写函数从原来的循环位置继续,而不是重新进入系统调用。
端点关闭与资源释放
sys_close 移除描述符中的引用。缓冲区保存指向写端的 Weak 引用;最后一个写端强引用释放后,all_write_ends_closed 才会返回真。父子进程或重复描述符保留的写端都计入端点的生命周期。
这版 Pipe::write 只检查剩余空间,没有检测读端是否全部关闭。读端消失后,写端仍会填满现有空间;此后的写入继续等待可写空间。代码中没有发送 SIGPIPE 的路径。
用户地址与失败处理
sys_read 和 sys_write 从描述符表取出端点,检查读写权限,再把用户缓冲区按页转换为切片。管道接口按切片顺序处理字节,环形缓冲区的下标与用户页边界各自独立推进。
描述符越界、空槽或读写方向不符时,系统调用返回 -1。sys_pipe 将两个描述符编号分别写到用户提供的两个 usize 位置;调用者需要提供有效的结果地址。
程序执行与通信
标准输入输出重定向
exec 替换地址空间时保留描述符表。shell 可以先在子进程中打开输出文件,关闭描述符 1,再用 dup 把文件对象放入这个最小空槽,然后执行目标程序。目标程序写标准输出时,字节便进入该文件。输入重定向对描述符 0 使用相同的办法。
信号处理
kill 把信号加入目标进程的待处理集合。异常处理路径检查信号及屏蔽状态;执行用户处理函数前,内核备份当前异常上下文,把 sepc 改成处理函数入口、把 a0 改成信号编号。用户处理函数通过 sigreturn 恢复备份的上下文。
SIGSTOP 使进程反复让出处理器,SIGCONT 解除停止状态。fork 复制屏蔽位和处理动作表,子进程从空的待处理集合开始。
运行观察
基础报告包含一次 3000 字节的管道通信。32 字节缓冲区会多次写满、读空;沿 write_byte、read_byte 和 suspend_current_and_run_next 的调用链,可以找到两个进程交替推进读写的位置。
重定向记录运行 ch2b_hello_world > nfout,程序输出写入磁盘文件 nfout。信号记录则可沿 call_user_signal_handler → sys_sigreturn 查看进入用户处理函数和恢复上下文的过程。
管道报告 · 跨进程信号 · 信号处理 · 重定向报告 · 录制数据
uCore 实现
管道让两个进程经内核中的缓冲区传递字节。父进程先创建管道,再 fork;父子进程各保留一端,便能在不同地址空间之间通信。以下代码来自 uCore 2026A 第七章。
管道对象与描述符
pipealloc 分配一页保存 struct pipe。管道内部有 512 字节缓冲区、读写计数器和两端是否打开的标志。sys_pipe 创建分别用于读、写的两个文件对象,再把它们放进当前进程的描述符表。
内核将两个描述符写回用户数组。该接口按两个 uint64 写入,共 16 字节;调用者应使用课程用户库约定的数组类型。fork 之后,父子进程的描述符指向同一对文件对象,因此也指向同一管道。
环形缓冲区
nread、nwrite 记录累计读取和写入的字节数。下一次访问的数组下标分别是 nread % 512 与 nwrite % 512。两者相等表示缓冲区为空;nwrite == nread + 512 表示已满。
复制时,piperead、pipewrite 都会在数组末尾截断本段长度。假设下一写入位置是 508,写入 12 字节,前 4 字节落在 508–511,后 8 字节从下标 0 开始。用户缓冲区若跨越页边界,copyin 或 copyout 还会按用户页再次分段。
读写与调度
写端在缓冲区满时调用 yield,获得处理器后重新检查空间;读端在空缓冲区上等待也采用同一办法。进程被切走时,内核栈保存着 pipewrite 或 piperead 的局部进度,恢复后继续原来的循环。这里没有专门的管道等待队列。
pipewrite 尝试写完请求的全部字节。piperead 有数据后最多读到当前可用量,缓冲区一旦变空就返回,所以一次读取可能短于请求长度。调用者需要按返回的字节数累计结果。
端点关闭与资源释放
fork 增加文件对象的引用数。只有读端文件对象的最后一份引用关闭,readopen 才变为 0;写端同理。写端关闭后,读端仍可取走剩余数据。缓存读空后再次读取,在本实现中返回 -1;读端关闭后,写入也返回 -1。
两端都关闭时,pipeclose 归还管道页。关闭父进程自己的描述符不会影响子进程仍持有的引用;进程退出时逐个关闭文件,也可能完成管道的最终回收。
用户地址与失败处理
pipewrite 从调用进程的用户页表经 copyin 取字节,piperead 经 copyout 写回。两个进程只通过内核管道中的字节交换数据,各自使用自己的用户页表转换缓冲区地址。
sys_pipe 依次取得文件对象、分配管道页、分配两个描述符、写回用户数组。中途失败会关闭已经取得的端点,并清空已占用的描述符槽。两个端点都关闭后,管道页也被释放。读写请求长度小于等于零则触发 panic;描述符无效时,系统调用返回 -1。
程序执行与通信
课程程序 ch7b_pipetest 创建管道后调用 fork。父进程关闭读端,写入 hello pipe!,关闭写端;子进程关闭写端,从读端取得内容并校验。父进程随后调用 wait。这条路径同时用到了共享文件对象、独立用户地址空间和进程调度。
exec 换掉用户程序时仍保留描述符表。因此,已打开的管道端点也可以交给新程序使用。
运行观察
边界实验让父进程写入 3000 字节,子进程每次请求 137 字节,并逐字节核对内容。写入量大于 512 字节的管道缓冲区,写端必然要等待读端腾出空间。实验还把用户缓冲区放在页尾附近,经过跨页的 copyin、copyout。
子进程分 24 次取得全部 3000 字节,其中有短读;写端关闭且缓冲区读空后,再读返回 -1。实验还填满描述符表,使第二个描述符分配失败,然后关闭已有管道并重新创建。沿 sys_pipe → fdalloc → fileclose → pipeclose 能看到失败后的资源清理。
第八章:线程与同步
第五章的进程各有地址空间。一个进程也可以包含多个线程:它们使用同一地址空间和文件描述符表,各自保存执行位置、栈和调度状态。第七章用管道传递数据;本章的线程可以直接访问同一变量,因而需要约定谁可以在什么时候修改它。
共享资源与线程状态
两个线程同时执行 counter = counter + 1,可能都先读到旧值,最后只留下其中一次写入。互斥锁把读取、计算、写回放在同一临界区。信号量用于限制并发访问数量,也能表示一次事件是否已经发生。条件变量让线程等待共享条件发生变化。
线程进入内核后仍属于原进程。进程控制块管理地址空间、文件与同步对象;线程控制块保存自己的上下文和栈。线程局部编号 TID 与进程编号 PID 需要一起看,同一个 TID 可以出现在不同进程中。
创建与回收
thread_create 指定新线程的入口函数与参数。内核分配线程标识和执行资源,设置新线程的用户态入口与栈,再将它放入就绪队列。新线程与创建者执行的先后顺序取决于调度。
线程退出后,waittid 取得退出结果并完成余下的回收。两个实现对 TID 的复用时机、线程槽位和错误参数有不同安排。rCore 实现与 uCore 实现沿创建、退出、等待三条路径逐项追踪资源。
等待与唤醒
自旋互斥锁遇到竞争时让出处理器,下一次运行再检查锁。阻塞互斥锁把当前线程放入等待队列,直到解锁方将它唤醒。解锁时,如果队列中已有等待者,锁直接交给队首线程;若无人等待,锁才变为空闲。
条件变量的 signal 唤醒一个等待者。等待者恢复运行后仍需重新获得互斥锁,才从 wait 返回。通知者在发出通知时可能还持有锁,所以“已唤醒”和“已进入临界区”是两个不同的时刻。
打开锁与条件变量交互图。逐步执行两个线程,观察等待队列、锁持有者和就绪状态如何变化。
源码阅读
先看线程控制块与进程控制块的引用关系,再沿 thread_create、调度入口、退出和 waittid 找到资源的分配与回收位置。随后用互斥锁的等待队列解释一次竞争,继续追踪条件变量等待函数释放锁、阻塞、重新加锁的顺序。
两套参考实现都提供自旋锁、阻塞锁、信号量和条件变量。rCore 还实现了资源分配检查。两者的结构与运行过程分别见 rCore 实现和 uCore 实现。
rCore 实现
第八章将进程内的执行流拆分为线程,并提供互斥锁、信号量和条件变量。线程共享地址空间,各自保存栈与执行上下文。源码采用 2026A 第八章参考实现。
进程与线程
ProcessControlBlock 持有地址空间、文件描述符表、线程列表和同步对象。TaskControlBlock 保存单个线程的内核栈、任务上下文、状态、退出码及用户资源。
每个线程有自己的用户栈和异常上下文页。用户栈大小为 8 KiB,相邻栈之间留 4 KiB 空隙。同一进程的线程使用同一张用户页表,因此可以访问相同的全局变量和堆数据。
创建、退出与等待
sys_thread_create 分配 TID 与线程资源,设置新线程的入口、用户栈和 a0 参数,再把线程加入就绪队列。返回值是进程内的线程编号。
线程退出时,内核释放它的用户栈和异常上下文页,记录退出码。waittid 查询线程状态:自身或空槽返回 -1,尚未退出返回 -2,已退出则取走线程槽并返回退出码。TID 的回收发生在用户资源释放时,分析连续创建的线程时需要同时看创建顺序和控制块。
调度、阻塞与唤醒
就绪队列按 FIFO 取线程。主动让出时,当前线程回到就绪队列;等待同步对象时,block_current_and_run_next 将它设为 Blocked,调度器暂时不再选择它。同步对象调用 wakeup_task 后,线程重新进入就绪队列。
上下文切换经过处理器的空闲上下文:先保存当前线程,再选择下一个线程恢复。函数调用报告中的一次阻塞和唤醒,可以沿线程对象、进程 PID 及两段切换记录连接起来。
互斥锁
MutexSpin 遇到已占用的锁时调用 suspend_current_and_run_next,恢复运行后再次检查锁。等待者仍是就绪线程。
MutexBlocking 则把等待者放入 FIFO 队列并阻塞。解锁时若队列非空,内核将锁交给队首线程并唤醒它;locked 仍为 true。被唤醒的线程从原来的 lock 调用继续,无需再次竞争。没有等待者时,解锁才把 locked 清为 false。
信号量与条件变量
Semaphore 使用有符号的 count。down 先减一;结果小于零时,线程进入等待队列并阻塞。up 加一;结果仍小于等于零时,把许可交给队首线程并唤醒它。负数的绝对值对应队列中等待的线程数。
Condvar::wait 释放调用者持有的互斥锁,将当前线程加入条件变量队列,然后阻塞。signal 唤醒队首线程;线程恢复后先重新取得互斥锁,wait 才返回。用户程序用循环检查共享条件,因为再次取得锁时条件可能已经改变。
资源检查与接口参数
进程分别为互斥锁和信号量维护一个 DeadlockDetector。它记录当前可用的资源、各线程已持有的资源,以及正在等待的一个资源。
is_safe 用一份可用资源副本模拟:找到当前请求可以得到满足的线程,把该线程已持有的资源归还到副本中,再寻找下一个。若所有线程都能依次完成,请求通过;否则,启用检查的系统调用返回 -0xDEAD,不会把这次请求加入同步对象的计数或队列。
运行观察
基础测试中的 ch8b_race_adder_mutex_spin 创建 16 个工作线程,每个线程完成 1000 次共享计数器更新。沿 MutexSpin::lock → MutexSpin::unlock 查看临界区,可将最终结果 16000 与更新次数对应起来。
条件变量测试中,等待线程从 Condvar::wait 阻塞,通知线程调用 signal,等待线程被唤醒后再次取得互斥锁。扩展报告还运行互斥锁、信号量的资源检查用例;沿 sys_enable_deadlock_detect 与 is_safe 可查看检查何时开启、何时判断请求。
uCore 实现
第八章把调度单位从进程改为线程。同一进程中的线程共享用户地址空间和文件,却各自拥有执行栈与寄存器上下文。以下代码来自 uCore 2026A 第八章。
进程与线程
struct proc 保存页表、文件描述符表、同步对象池和 16 个线程槽。struct thread 保存 TID、状态、调度上下文、内核栈和异常上下文。内核栈及异常上下文使用按进程槽和线程槽排列的静态数组;用户栈映射在所属进程的页表中。
线程之间共享同一地址空间,因而能直接访问相同的用户数据。切换线程时,swtch 更换的是线程自己的内核执行上下文;共享的页表和文件对象不因切换而复制。
创建、退出与等待
sys_thread_create 调用 allocthread 寻找空线程槽,为新线程映射用户栈与异常上下文页,设置入口地址和参数 a0,再把它加入就绪队列。16 个槽中已有一个供主线程使用,因此测试程序可以再创建 15 个工作线程。
线程调用 exit 时,freethread 解除该线程的用户栈和异常上下文映射,退出码保存在槽中,状态改为 EXITED。waittid 不阻塞:目标未退出时返回 -2;已退出时读取退出码、清空线程槽,使 TID 可以复用。编号无效、槽位未占用或等待自身时返回 -1。
fork 创建子进程后只建立主线程,固定从父进程的 threads[0] 复制异常上下文。其他线程调用 fork 时,这条路径也会复制主线程的上下文。
调度、阻塞与唤醒
调度器从就绪队列取出 RUNNABLE 线程。yield 将当前线程放回队尾;阻塞同步原语则把线程设为 SLEEPING,调用 sched 切回调度器。解锁或发出通知的线程把等待者改回 RUNNABLE 并入队。
线程 A 让出后到线程 B 开始运行,经过两次 swtch:一次保存 A 并恢复调度器,一次保存调度器并恢复 B。A 以后被选中时,从原来调用 sched 的地方继续。
互斥锁
mutex_lock 在锁空闲时直接设 locked = 1。若锁已占用,自旋型互斥锁反复调用 yield;阻塞型互斥锁把线程放入 FIFO 等待队列并设为 SLEEPING。
阻塞型互斥锁解锁时,若有人等待,就让队首线程运行,同时保持 locked = 1。锁的持有权直接交给这个线程;它从 mutex_lock 的睡眠位置恢复后即可返回。队列为空时,解锁才把 locked 清零。
信号量与条件变量
semaphore_down 先将 count 减一。结果小于零,线程便入队睡眠。semaphore_up 将 count 加一;结果仍小于等于零时,它唤醒一个等待者。被唤醒的线程从原来的 down 继续,不再减少一次计数。
cond_wait 先释放互斥锁,然后进入条件变量的等待队列;恢复后重新取得互斥锁。cond_signal 唤醒队首等待者。若队列为空,本次通知不会留给后来的线程,所以使用者需要在持锁状态下检查共享条件,并在条件不成立时继续等待。
资源检查与接口参数
一个进程最多创建 8 个互斥锁、8 个信号量和 8 个条件变量;创建接口按数组位置返回编号,资源用尽时返回 -1。等待队列容量为 16。系统调用在访问对象前检查编号范围。
源码保留了死锁检测的实现位置,但系统调用分发没有启用相应分支。当前互斥锁和信号量按上述等待、唤醒规则运行。
运行观察
课程批次以 ch8b_usertest 启动,包含 23 项测试。ch8b_mut_race 让 15 个工作线程各为共享计数器加 100,最后检查 1500。这一结果可以和阻塞锁的等待队列、解锁时的直接交接对照。
报告中还能沿 sys_thread_create → allocthread 看新线程入队,沿 semaphore_down、cond_wait 查看线程睡眠,再由解锁或通知回到可运行状态。目标线程尚未退出时,waittid 返回 -2,调用者继续运行并重试。
运行报告
2026A 课程实验
| 内核 | 内容 | 报告 |
|---|---|---|
| rCore | 第一章:启动与基本执行环境 | 打开 · 分析 |
| uCore | 第一章:启动与基本执行环境 | 打开 · 分析 |
| rCore | 第二章:批处理与系统调用 | 打开 · 分析 |
| uCore | 第二章:原参考构建 | 打开 · 分析 |
| uCore | 第二章:栈地址修正 | 打开 · 分析 |
| uCore | 第二章:异常批次 | 打开 · 分析 |
| rCore | 第三章:任务切换与调度 | 打开 · 分析 |
| uCore | 第三章:任务切换与调度 | 打开 · 分析 |
| rCore | 第四章:内存映射与页表 | 分析 · 打开 |
| uCore | 第四章:应用装载与页表 | 分析 · 打开 |
| uCore | 第四章:映射、解除映射与页表回收 | 分析 · 打开 |
| rCore | 第五章:进程基础测试 | 分析 · 打开 |
| rCore | 第五章:spawn 与 stride 调度 | 分析 · 打开 |
| uCore | 第五章:进程创建与回收 | 分析 · 打开 |
| rCore | 第六章:文件系统基础测试 | 分析 · 打开 |
| rCore | 第六章:硬链接与大量文件读写 | 分析 · 打开 |
| uCore | 第六章:文件与设备请求 | 分析 · 打开 |
| rCore | 第七章:管道与基础测试 | 分析 · 打开 |
| rCore | 第七章:跨进程信号 | 分析 · 打开 |
| rCore | 第七章:信号处理测试 | 分析 · 打开 |
| rCore | 第七章:输出重定向 | 分析 · 打开 |
| uCore | 第七章:管道与课程测试 | 分析 · 打开 |
| uCore | 第七章:管道边界实验 | 分析 · 打开 |
| rCore | 第八章:线程与同步基础测试 | 分析 · 打开 |
| rCore | 第八章:资源分配检查 | 分析 · 打开 |
| uCore | 第八章:线程与同步课程测试 | 分析 · 打开 |
已有实验
| 内核 | 内容 | 报告 |
|---|---|---|
| xv6-riscv | 内核启动 | 打开 |
| xv6-riscv | 写时复制 | 打开 |
| xv6-riscv | 写时复制运行对比 | 打开 |
| xv6-riscv | 延迟分配 | 打开 |
| rCore | 第六章:文件系统分配 | 打开 |
| rCore | 第六章:内核 panic | 打开 |
| ArceOS | Hello World | 打开 |
| ArceOS | 用户态程序 | 打开 |
| ArceOS | 延迟映射 | 打开 |
| ArceOS | 综合实验 | 打开 |
| StarryOS | 综合实验 | 打开 |
| StarryOS | 进程创建 | 打开 |
| StarryOS | 文件读写 | 打开 |
添加内核支持
NodeFusion 通过两条通道观察内核。QEMU 插件在函数入口记录事件和参数寄存器,并按一定间隔保存内存快照;内核中的 nftrace 探针补充函数入口无法表达的结果值、提交时刻和调度决定。manifest 把目标内核的 ELF/DWARF、内存结构和这两条通道接到统一的数据模型上。
每个内核在 nodefusion/manifests/ 下有一份 TOML 文件。支持同一内核的多个教学章节或编译 feature 时,仍然使用一份 manifest,通过 DWARF 条件选择各自的数据结构和事件映射。
下面以 mykernel 为例。完整字段表见《Manifest 语法参考》。已有的 xv6.toml、rcore.toml、arceos.toml、starry.toml 和 ucore.toml 可以作为实际样例。
准备内核构建
先选定一份能够重复构建的源码。记录仓库地址、提交号、构建命令和工具链版本。若一个内核需要覆盖多个章节或配置,每一种形态都要保留一份可供验证的 ELF。
git -C /path/to/kernel rev-parse HEAD
rustc --version
qemu-system-riscv64 --version
编译时保留 DWARF 调试信息。Rust release 构建可以使用:
CARGO_PROFILE_RELEASE_DEBUG=2 make build
C/C++ 工程通常在编译参数中加入 -g。最终交给 NodeFusion 的文件应当是未经 strip 的 ELF。QEMU 启动时使用的扁平镜像可以是另一份文件。
检查 ELF 的架构、调试段和符号表:
file /path/to/kernel.elf
readelf -h /path/to/kernel.elf
readelf -S /path/to/kernel.elf | rg 'debug_info|debug_line|symtab'
shasum -a 256 /path/to/kernel.elf
debug_info 提供类型和字段,debug_line 提供源码位置,symtab 提供静态量和函数地址。少掉其中一项时,相关规则会在 probe 或观察点选择阶段报告原因。
接着从源码中找出内核对象的保存位置和主要入口:
rg -n 'struct (Proc|Process|Thread|Task)|enum .*State' /path/to/kernel
rg -n 'fn (fork|clone|spawn|exit|schedule)|void (fork|exit|scheduler)' /path/to/kernel
rg -n 'PageTable|MemorySet|frame_alloc|kalloc|journal|commit' /path/to/kernel
源码用于确认语义,ELF 用于确认本次构建中实际存在的名字和布局。后面的每个类型、字段和函数名都应当能追溯到这份源码与 ELF。
建立覆盖清单
先列出需要支持的构建形态和测试程序。例如逐章增加功能的教学内核:
| 构建 | 主要结构 | 测试程序 | 预期通道 |
|---|---|---|---|
| ch1 | 无进程结构 | 内核启动 | 控制台、函数事件 |
| ch2 | 批处理程序 | 顺序执行多个程序 | 装载、异常与系统调用 |
| ch3 | 固定任务数组 | 多任务示例 | 任务快照、调度事件 |
| ch4 | 虚拟内存与页分配器 | 映射、解除映射 | 页表、页分配事件 |
| ch5 | 进程生命周期 | fork/exec/wait | 进程关系、生命周期事件 |
| ch6 | 文件系统 | 文件读写 | inode、块缓存、磁盘事件 |
| ch7 | 管道 | 管道读写 | pipe 事件 |
| ch8 | 同步与线程 | 线程同步测试 | 线程、锁与调度事件 |
同一形态在多种架构上构建时,每个架构单列。SMP、文件系统、网络等 feature 会改变结构或事件语义时,也各占一行。后续验证按这张表逐项进行。
写入内核身份
在 nodefusion/manifests/ 中新建 mykernel.toml:
[kernel]
name = "mykernel"
family = "mykernel"
arches = ["riscv64"]
detect = { any_type = ["mykernel::task::Task"], any_symbol = ["INIT_TASK"] }
name 是 manifest 的唯一名称,也是 --kernel-kind mykernel 的取值。family 表示内核家族。arches 使用 NodeFusion 的 ELF 架构名。
这里的 detect 用于读取 ELF 后识别内核。表中的各组条件同时成立时才算命中;每组数组内部按该组的规则判断。上例要求 Task 类型和 INIT_TASK 静态量都存在。
目录级自动识别由 [profile].detect_files 完成。两处检测发生在不同阶段:录制命令先根据源码目录选择 profile,分析阶段再根据 ELF/DWARF 选择 manifest。两处都要验证。
检测条件宜选用长期存在、足以区分内核的核心类型或静态量。Rust 类型使用完整模块路径。派生内核可能同时命中底层 manifest,可以声明继承关系:
[kernel]
name = "mykernel"
family = "basekernel"
builds_on = "basekernel"
arches = ["riscv64"]
detect = { any_type = ["mykernel::task::Process"] }
当 mykernel 和 basekernel 同时命中时,检测器让 mykernel 生效。两份 manifest 各自保存完整配置。
新增文件后先运行解析和检测相关测试:
python -m pytest \
nodefusion/tests/test_manifest_lint.py \
nodefusion/tests/test_manifest_vocabulary.py \
nodefusion/tests/test_profile_from_manifest.py -q
还要拿每一份目标 ELF 运行一次检测,确认只命中预期的 manifest。可在 Python 中直接查看检测轨迹:
python - <<'PY'
from nodefusion.model.dwarfsrc import DwarfSource
from nodefusion.model.manifest import load_dir
from nodefusion.model.probe import detect
dw = DwarfSource('/path/to/kernel.elf')
kind, trace = detect(load_dir(), dw)
print('result:', kind)
print(*trace, sep='\n')
PY
描述实体
实体是内存快照中可以枚举的内核对象,例如进程、线程、缓冲块、打开文件和 inode。每个实体先声明名称、DWARF 类型和语义角色:
[[entity]]
name = "process"
role = "process"
type = "mykernel::task::Process"
label = "进程"
name 在本份 manifest 内唯一。type 对应 DWARF 类型。role = "process" 让进程视图和调度归属能够找到这种实体。
找到枚举根
实体需要一条从静态量走到对象集合的路径。假设 INIT_PROCESS 保存初始进程,每个进程的 children 字段保存子进程,可以写成:
[[entity.source]]
kind = "tree"
completeness = "reachable_only"
reason = "从初始进程沿 children 枚举;脱离该树的临时对象不会出现。"
steps = [
{ static = "mykernel::task::INIT_PROCESS" },
{ unwrap = ["Lazy", "Arc"] },
{ walk = { children = "inner.children", via = ["SpinLock", "Vec", "Arc"] } },
]
执行过程从 INIT_PROCESS 的地址开始,解开 Lazy 和 Arc,再对每个进程读取 inner.children。Vec 的字段偏移、元素大小以及 Arc 的数据偏移从 DWARF 取得。
completeness 说明这条来源能覆盖哪些对象。固定全局表通常为 total;树遍历为 reachable_only;就绪队列为 ready_only;合并若干仍不完备的来源时使用 partial。取值会进入报告和覆盖率结果。
固定数组的写法较短:
[[entity.source]]
kind = "table"
completeness = "total"
steps = [
{ static = "PROCESS_POOL" },
{ iter = "array" },
]
静态量外面带锁或 cell 时,按照内存中的层次写出 unwrap:
[[entity.source]]
kind = "registry"
completeness = "total"
steps = [
{ static = "TASK_MANAGER" },
{ unwrap = ["Lazy", "SpinLock"] },
{ field = "tasks" },
{ iter = "vec", via = ["Vec", "Option", "Arc"] },
]
via 从容器层开始写到元素层。上例最终生成 vec<option<arc>> reader,空的 Option 槽会被略过。
同一内核家族可能在不同章节采用不同结构。把具体条件放在前面,通用来源放在后面:
[[entity.source]]
kind = "tree"
completeness = "reachable_only"
when = { field_exists = { type = "mykernel::task::Process", field = "children" } }
steps = [
{ static = "INIT_PROCESS" },
{ unwrap = ["Lazy", "Arc"] },
{ walk = { children = "children", via = ["SpinLock", "Vec", "Arc"] } },
]
[[entity.source]]
kind = "table"
completeness = "total"
steps = [
{ static = "PROCESS_POOL" },
{ iter = "array" },
]
默认的 source_combine = "first" 选择第一条条件成立的来源。就绪队列、当前任务和退出队列需要一起枚举时,在 [[entity]] 中加入:
source_combine = "union"
所有成立的来源会依次执行,对象按地址去重。每条来源仍需填写自己的 completeness 和 reason。
某个章节尚未出现这种实体时,可以使用受条件保护的空来源:
[[entity.source]]
kind = "batch"
completeness = "none"
when = { type_exists = "mykernel::console::Console" }
reason = "该章节只运行单个内核入口,尚未引入任务结构。"
steps = []
空 steps 只允许用于 kind = "batch" 且 completeness = "none" 的来源。
读取字段
字段路径相对于实体的 type。进程号、状态和调度上下文可以写成:
[entity.fields]
pid = { path = "pid", reader = "newtype<usize>", role = "id" }
state = { path = "inner.state", reader = "enum", enum = "TaskState", role = "state" }
context = { path = "inner.context", reader = "struct", role = "sched_context" }
path 只描述内联字段下潜。跨越 Arc、裸指针等地址边界时,由 reader 明确解引用方式。reader 可以逐层组合,例如:
[entity.fields]
name = { path = "inner.name", reader = "spinlock<string>", role = "name" }
parent = { path = "inner.parent", reader = "option<weak>", role = "id" }
root = { path = "memory", reader = "arc<mutex<field<root_paddr, usize>>>", role = "address_space_root" }
需要在所有目标 ELF 中出现的字段放在 [entity.fields]。随章节或 feature 出现的字段放在 [entity.optional_fields]:
[entity.optional_fields]
exit_code = { path = "inner.exit_code", reader = "i32", role = "exit_code", feature = "process" }
可选字段在当前 ELF 中不存在时会记录为缺席。必需字段缺失时会记录为无法解码,并阻断严格覆盖率。
实体间的引用放在 relations 中:
[entity.relations]
parent = { path = "inner.parent", reader = "option<weak>", links_to = "process", inverse = "children" }
读取出的地址会在当前快照的实体索引中查找。links_to 指向另一种实体的 name,inverse 在目标实体上建立反向边。目标对象没有被 source 枚举到时,快照会记录 dangling relation。
固定表中的空槽使用 liveness 过滤:
[entity.liveness]
skip_when = { field = "state", equals = "UNUSED" }
用于判定的字段应当已经声明并能够稳定读取。字段无法解码时,对象会保留在结果中。
显示资源表
缓冲块、文件和 inode 等实体可以直接生成资源表:
[entity.table]
show_when_any = ["refcnt", "valid"]
empty = "当前没有被占用或有效的缓冲块。"
[[entity.table.column]]
key = "slot"
label = "槽位"
format = "num"
synthetic = true
[[entity.table.column]]
key = "block_id"
label = "块号"
format = "hex"
[[entity.table.column]]
key = "valid"
label = "有效"
format = "bool"
optional = true
普通列的 key 引用已经声明的字段或关系。synthetic = true 的列使用枚举下标。show_when_any 只影响显示的行,实体枚举和覆盖率仍使用完整结果。
选择函数观察点
[[watch]] 依据 DWARF 函数、源码文件和符号表选择函数入口:
[[watch]]
subsystem = "task"
match = { module = "mykernel::task", fn = ["fork", "exit", "schedule"] }
args = 3
snapshot = "event"
module 匹配完整模块及其后代。file 匹配 C/C++ 的声明文件路径或路径后缀。fn 使用大小写敏感的 shell 通配符,匹配函数短名和已知别名。多个条件写在同一个 match 中时全部需要成立。
观察规则按书写顺序处理。一个地址被前面的规则选中或排除后,后面的规则不再接管。函数级规则放在前面,子模块规则随后,宽模块规则放在最后。例如先排除高频自旋函数:
[[watch]]
subsystem = "sync"
match = { module = "mykernel::sync", fn = ["spin_loop", "cpu_relax"] }
skip = true
[[watch]]
subsystem = "sync"
match = { module = "mykernel::sync" }
args = 2
用目标 ELF 检查实际选择结果:
python -m nodefusion.tools.crosscheck_watchsel \
/path/to/kernel.elf nodefusion/manifests/mykernel.toml
empty_rules 表示整条规则没有匹配对象;no_address 表示 DWARF 中存在函数信息,但当前 ELF 没有可设置观察点的入口地址。优化导致函数只剩内联实例时,可以针对必要规则设置 inlined = true,检查生成的地址是否对应目标操作。
高频入口会增加轨迹体积。先录制一趟不抽样的短 workload,统计各入口的命中次数,再决定 skip 或 throttle。throttle = N 用于 snapshot = "none" 的普通观察点,每 N 次命中保留一条事件。相关计数会标为 sampled,依赖这些事件的指标也不会显示为精确值。带 always 或 event 快照的观察点使用各自的快照策略,不应用这项事件抽样。
映射统一事件
[event] 把函数映射到 nodefusion/model/kinds.py 登记的统一事件:
[event]
"mykernel::task::fork" = { kind = "proc.fork", args = ["parent", "flags"], resource = "process" }
"mykernel::task::exit" = "proc.exit"
"mykernel::task::schedule" = "sched.enter"
事件键使用观察点选择后的函数路径。Rust 函数通常写完整路径,C 函数写符号名。args 依次命名入口参数寄存器;RISC-V 插件记录 a0 至 a7。需要使用第七个参数时,watch 的 args 至少为 7。
统一事件表示可跨内核比较的语义。函数名称只能作为线索。映射前应读取实现,确认调用阶段、对象粒度、成功条件和参数含义。例如逐页写 PTE 的函数适合 pagetable.map,一次映射整个区域的函数适合 vm.map。
同一入口可由参数位区分两种事件时,使用 classify:
[event]
"mykernel::task::clone" = { kind = "proc.fork", args = ["flags"], classify = { arg = "flags", mask = 65536, set = "thread.create" } }
mask 来自目标内核 ABI。位被设置时产生 thread.create,清零时产生默认的 proc.fork。需要先确认 flags 在函数入口确实位于对应寄存器。
一个函数在不同构建中具有不同含义时,可以根据 DWARF 形态选择映射:
[event]
"mykernel::task::spawn" = [
{ kind = "thread.create", when = { type_exists = "mykernel::task::Thread" } },
{ kind = "proc.create" },
]
候选项按顺序判断,最后一项必须是无条件项。
优化构建有时会内联薄包装函数,只保留被调用的方法。源码和目标 ELF 已确认两个入口覆盖
同一批调用时,可以给它们设置相同的 coverage_group:
[event]
"mykernel::cache::lookup" = { kind = "bcache.get", coverage_group = "cache.lookup" }
"mykernel::cache::get" = { kind = "bcache.get", coverage_group = "cache.lookup" }
严格审计按观察点核对。组内已有入口时,缺失的内联别名可以由该组覆盖;未分组的同类事件 仍分别检查。
函数入口看不到返回值,也无法确认函数最终成功。分配结果、提交完成、真实调度切换和 clone 成功后的进程/线程类别适合由 nftrace 记录。线协议和类型编号见 nodefusion/spec/event-stream.md,已有补丁和应用命令见 nodefusion/integrations/README.md。
新增 nftrace 事件时需要同步完成以下工作:
- 在内核完成目标操作后写入记录,避开持锁区和分配路径;
- 在
event-stream.md登记固定的记录类型和字段; - 更新插件、解析器和分析器;
- 处理函数入口产生的重复事件;
- 添加 wire format、截断输入和真实补丁内容测试;
- 在补丁针对的源码提交上运行
git apply --check; - 用能够触发成功、失败和边界路径的 workload 录制验证。
观察补丁应只加入观测逻辑。为了让教学内核在新工具链上构建或修复其功能缺陷的改动,应放在单独补丁中。
声明不适用的事件
某项机制在目标内核中不存在时,可以在 [absent] 中记录:
[absent]
"log.commit" = { why = "feature", evidence = "文件系统直接写回缓存块;源码中的 block_cache_sync_all() 只遍历并写回脏块,没有日志、事务或恢复记录。" }
why = "feature" 表示该内核没有对应机制。证据应给出源码位置、主要调用链和搜索过的概念。
内核具有相关行为,当前可寻址入口无法达到统一事件所需粒度时,使用 granularity:
[absent]
"vm.unmap" = { why = "granularity", evidence = "地址空间销毁入口一次清空全部区域;ELF 中没有逐区域调用的函数,入口参数也不含单个区域的起止地址。" }
多个章节共用 manifest 时,可以让早期章节的 feature absence 取决于本次 ELF 的类型:
[absent]
"bcache.read" = { why = "feature", when = { type_missing = "mykernel::block_cache::BlockCacheManager" }, evidence = "早期章节没有块缓存管理器;文件系统章节引入该类型和块读取路径。" }
先用各章节的 DWARF 核对类型:没有该类型的构建将此项计为不适用,具有该类型的构建继续使用 [event] 映射。条件 absent 仅在 DWARF 成功读取后生效。无条件的 absent 类别不能与事件映射并存;新增探针后,删除已经过时的 absent 项。
配置构建和录制
[profile] 保存从源码目录构建并启动内核所需的信息:
[profile]
kernel_elf = "build/{crate}"
kernel_image = "build/{crate}.bin"
build = "make build {mv}"
detect_files = ["Makefile", "Cargo.toml"]
required_tools = ["qemu-system-riscv64", "rustc"]
machine_opts = ["-machine", "virt", "-m", "512M", "-bios", "default", "-kernel", "build/{crate}.bin"]
interactive = true
prompt = ">> "
ready_markers = ["Boot complete"]
done_markers = ["All tests passed"]
panic_markers = ["panicked at", "kernel panic"]
exit_marker = 'exit_code=(?P<code>-?[0-9]+)'
halt_markers = ["Power down"]
protect = ["fs.img"]
路径相对于 --kernel 指向的源码目录。{crate} 从根目录 Cargo.toml 的 [package].name 读取。{mv} 展开为 --lab-stage 和 --make-var 传入的构建参数;使用这些命令行参数时,build 中需要保留 {mv}。
录制器在构建前移走旧产物,构建结束后检查 ELF、镜像和必需设备,避免把旧文件当作本次结果。protect 与设备文件会在校准运行后恢复。
可选设备使用数组表:
[[profile.device]]
file = "fs.img"
opts = ["-drive", "file=fs.img,if=none,format=raw,id=x0"]
built_when = { file = "Makefile", contains = "fs.img:" }
设备文件存在时才附加 opts。built_when 用来判断本次构建是否应当生成该文件,构建后的产物检查会使用这个判断。
一个内核家族的后期章节可能增加 shell。可以按源码中的稳定标识切换 profile:
[profile.shell_probe]
dir = "os/src"
ident = "run_shell"
匹配成功后,interactive 会设为 true,并使用 shell_prompt 和 shell_ready_marker。完整的 marker 判定顺序见profile 参考。
先运行环境检查,再进行录制:
python -m nodefusion.host.cli doctor
python -m nodefusion.host.cli record \
--kernel /path/to/kernel \
--kernel-kind mykernel \
--program smoke_test \
--name mykernel-smoke
长时间 workload 可以先限定观察子系统:
python -m nodefusion.host.cli record \
--kernel /path/to/kernel \
--kernel-kind mykernel \
--program fs_test \
--watch-subsystem syscall \
--watch-subsystem inode \
--name mykernel-fs
子系统验证启用所选观察点;完整验证启用覆盖清单中的全部适用观察点。
验证每一种构建
先运行静态测试:
python -m pytest \
nodefusion/tests/test_manifest_lint.py \
nodefusion/tests/test_manifest_vocabulary.py \
nodefusion/tests/test_event_kind_registry.py \
nodefusion/tests/test_event_when_branches.py \
nodefusion/tests/test_profile_from_manifest.py \
nodefusion/tests/test_kernel_integrations.py -q
用目标 ELF 检查 DWARF 读取和观察点选择:
python -m nodefusion.tools.crosscheck_dwarf /path/to/kernel.elf
python -m nodefusion.tools.crosscheck_watchsel \
/path/to/kernel.elf nodefusion/manifests/mykernel.toml
crosscheck_dwarf 对比两套 DWARF 读取路径。没有共同结构体、字段布局不一致或出现悬空类型引用时,命令会退出非零。
每一行覆盖清单至少录制一趟。测试程序应当主动触发该构建中适用的实体、关系和事件。例如文件系统构建需要覆盖创建、读写、关闭、inode 查找、块缓存和磁盘 I/O;进程构建需要覆盖创建、exec、退出、等待和调度切换。
录制完成后重新渲染,并生成机器可读的覆盖率结果:
python -m nodefusion.host.cli render \
--run mykernel-smoke \
--event-stream never
python -m nodefusion.host.cli audit \
--run mykernel-smoke \
--json > /tmp/mykernel-smoke.coverage.json
audit 检查本次运行的实体、关系和事件。检查通过时退出码为零,JSON 中的 complete 为 true。
再运行报告数据、产物写入和覆盖证据测试:
python -m pytest \
nodefusion/tests/test_bundle_shape.py \
nodefusion/tests/test_artifact_output_safety.py \
nodefusion/tests/test_coverage_evidence.py -q
检查运行目录中的材料:
manifest.json中的kernel_kind、ELF SHA-256、构建信息和观察点选择与本次运行一致;trace.nfb正常收尾,报告中没有 trace incomplete 提示;- 进程、线程和资源表中的对象数量能够由 workload 解释;
- 必需字段没有
undecodable,关系没有意外的 dangling 目标; - 事件类型、参数、资源和先后顺序与源码路径一致;
- 经过 throttle 的事件和派生指标显示为 sampled;
- absent 项在报告元数据中保留其原因和证据;
- HTML 能够离线打开,各页签和时间线正常渲染。
浏览器检查可以从运行目录启动一个本地服务器:
python -m http.server 8000 --directory nodefusion/runs/mykernel-smoke
打开 http://127.0.0.1:8000/mykernel-smoke.html,依次检查概览、进程、资源和事件页面,并查看浏览器控制台。报告应当在断网状态下工作,切换时间点后表格与时间线同步更新。
需要长期保存本次验证时,对覆盖 JSON、ELF、二进制轨迹和 HTML 计算校验和:
shasum -a 256 \
/tmp/mykernel-smoke.coverage.json \
/path/to/kernel.elf \
nodefusion/runs/mykernel-smoke/trace.nfb \
nodefusion/runs/mykernel-smoke/mykernel-smoke.html
需要导出完整 events.jsonl 时使用:
python -m nodefusion.host.cli render \
--run mykernel-smoke \
--event-stream always
auto 模式会在预计文件超过 256 MiB 或磁盘余量不足时跳过 JSONL。HTML 抽样显示事件时,bundle 会记录原始数、保留数和抽样方法;事件总数和统计指标使用完整轨迹计算。
本机已有多份运行记录时,可以执行 corpus 测试:
python -m pytest nodefusion/tests --run-corpus
默认只发现不超过 256 MiB 的轨迹。归档机上穷举全部记录使用:
NF_CORPUS_MAX_TRACE_MIB=0 \
python -m pytest nodefusion/tests --run-corpus
完成内核支持
覆盖清单中的每一种构建都应满足以下条件:
- 源码提交、构建命令、工具链和 ELF SHA-256 已记录;
- 目录检测和 ELF 检测均唯一命中;
- 所有适用实体都有能够执行的 source;
- 必需字段和关系能够从该 ELF 解码;
- source 的完整性与内核实际保存的对象集合一致;
- 所有适用子系统都有 workload 和观察点;
- 每个统一事件都经过源码语义核对;
- 函数入口缺少的结果语义由
nftrace提供,或在 absent 中留下可复查证据; - profile 能够从干净源码目录构建并启动该内核;
- 补丁在指定提交上通过
git apply --check; - 实际录制正常收尾,严格 audit 通过;
- HTML、可选 JSONL 和运行元数据相互一致;
- 保存的覆盖 JSON、ELF、轨迹和 HTML 校验和能够重新核对;
- 普通测试与 corpus 测试通过。
一份 manifest 支持多个章节时,逐章保存 audit JSON;支持多种 feature 或架构时,逐配置保存。覆盖清单记录各构建的检查结果。
排查常见问题
找不到类型或字段
先在目标 ELF 上运行 crosscheck_dwarf。Rust 泛型类型使用完整路径;短名遇到同名类型时会产生歧义。字段路径只穿过内联包装,跨指针需要 reader 或 source 中的 unwrap、deref。
source 编译成功,枚举结果为空
查看 source trace 和运行期 problems。常见原因包括静态根尚未初始化、容器布局未能从 DWARF 解析、裸指针为空、per-CPU 布局尚未安装,以及 liveness 将所有槽位过滤掉。
watch 规则没有选中函数
查看 crosscheck_watchsel 的 empty_rules 与 no_address。C 文件名按路径后缀匹配;Rust trait 实现会先转换为实现类型路径;fn 通配符匹配短名。内联实例需要显式启用 inlined。
一个入口同时创建进程和线程
入口参数中有稳定 ABI 标志时使用 classify。成功结果由函数内部才能确定时加入 nftrace 探针,并让分析器消除入口事件的重复计数。
报告中的计数标为 sampled
检查命中该事件的 watch 是否设置了 throttle,以及派生指标是否依赖该事件。需要精确计数时,缩短 workload 或缩小观察子系统后取消 throttle,重新录制。
audit 没有达到完整状态
读取 JSON 中的 blockers。常见项包括必需指标无数据、字段无法解码、物理页绝对计数缺失、轨迹没有正常收尾,以及某个已声明观察点在本次 workload 中没有产生证据。修复后重新录制;旧轨迹不会获得新加入的观察数据。
Manifest 语法参考
NodeFusion 从 nodefusion/manifests/*.toml 读取内核描述。实际添加过程见《添加内核支持》。解析入口在 nodefusion/model/manifest.py,source 的编译和执行分别位于 nodefusion/model/plan.py 与 nodefusion/model/exec.py。
文件结构
一份 manifest 可以包含以下部分:
[kernel]
[[entity]]
[[entity.source]]
[entity.fields]
[entity.optional_fields]
[entity.relations]
[entity.optional_relations]
[entity.liveness]
[entity.table]
[[entity.table.column]]
[[watch]]
[event]
[absent]
[profile]
[[profile.device]]
[profile.shell_probe]
[syscalls]
[kernel] 必须存在。实体、观察点和录制 profile 可以分阶段加入。没有 [profile] 的 manifest 能够用于分析已有轨迹,不能由 nodefusion record 启动。
TOML 行内表必须写在同一行。各节会拒绝未知键,并在错误信息中给出相近拼写。
[kernel]
[kernel]
name = "mykernel"
family = "mykernel"
arches = ["riscv64", "x86_64"]
detect = { any_type = ["mykernel::task::Task"], all_symbol = ["INIT_TASK"] }
builds_on = "basekernel"
derive_feature_absence_from_symbols = true
cache_hit_derivation = "one_to_one"
clone_completion_channel = "nftrace"
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
name | 字符串 | — | manifest 唯一名称;必需 |
family | 字符串 | "" | 内核家族名称 |
arches | 字符串数组 | [] | 支持的 ELF 架构 |
detect | 表 | {} | ELF/DWARF 检测条件 |
builds_on | 字符串 | "" | 当前 manifest 命中时遮住的基础 manifest |
derive_feature_absence_from_symbols | 布尔值 | false | 按当前 ELF 中的实现符号推导 build-local feature absence |
cache_hit_derivation | unverified / one_to_one / semantic | unverified | 缓存命中数的来源;semantic 使用内核上报的逐请求结果 |
clone_completion_channel | entry / nftrace | entry | fork/clone 的计数来源;成功后上报结果记录时使用 nftrace |
当前架构名包括 riscv64、x86_64、aarch64 和 loongarch64。arches 与 ELF 不符时,probe 产生警告。
detect 接受三种条件:
| 键 | 判定 |
|---|---|
any_type | 数组中至少一个 DWARF 类型存在 |
any_symbol | 数组中至少一个 DWARF 变量存在 |
all_symbol | 数组中的 DWARF 变量全部存在 |
同一张 detect 表中的条件同时成立才会命中。数组必须为非空字符串数组。多个无继承关系的 manifest 同时命中时,检测结果为歧义。
derive_feature_absence_from_symbols 适合跨章节或 feature 共用的 manifest。某个已声明事件类别的全部实现符号在当前 ELF 中均不存在时,该构建会把它视为 feature-level N/A。ELF 中存在实现符号而观察点没有选中时,仍然属于覆盖缺口。
只有逐条核对源码、确认缓存未命中与观测到的设备读取一一对应,才填 cache_hit_derivation = "one_to_one"。例如设备层合并相邻块读取,或还有绕过缓存的设备读取,此时保留默认值;命中数和命中率显示未知,严格 audit 会报告缺口。
内核在每次缓存读取后上报 NFT_CACHE_READ 时,填 cache_hit_derivation = "semantic"。主机检查结果记录数与缓存读取观察点命中数一致,再计算命中数;旧轨迹缺少这个通道时仍显示未知。
fork 或 clone 的函数入口只表示一次尝试。内核在创建成功后上报 NFT_PROC_FORK 或 NFT_THREAD_CREATE 时,填 clone_completion_channel = "nftrace"。分析器只统计完成记录;整趟运行没有成功创建时,结果为零,也不会退回入口计数。
[[entity]]
[[entity]]
name = "process"
type = "mykernel::task::Process"
label = "进程"
role = "process"
source_combine = "first"
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
name | 字符串 | — | manifest 内唯一的实体名;必需 |
type | 字符串 | — | DWARF 类型名;必需 |
label | 字符串 | "" | 报告中的显示名称 |
role | 字符串 | "" | 实体的跨内核角色 |
source_combine | first / union | first | source 选择方式 |
source | 数组表 | [] | 枚举来源 |
fields | 表 | {} | 必需字段 |
optional_fields | 表 | {} | 可选字段 |
relations | 表 | {} | 必需关系 |
optional_relations | 表 | {} | 可选关系 |
liveness | 表 | — | 固定槽位的存活条件 |
table | 表 | — | 资源表显示配置 |
当前实体角色由消费端识别 process 和 thread。没有角色的实体仍可枚举并生成资源表。
source_combine = "first" 按声明顺序选取第一条 when 成立的 source。union 执行全部成立的 source,对象按地址去重。合并后的完整性按 source 声明计算:其中有 total 时为 total;没有 total 且含 partial 时为 partial;其余不同取值组合为 partial。
[[entity.source]]
[[entity.source]]
kind = "tree"
completeness = "reachable_only"
reason = "从初始进程沿 children 枚举。"
when = { type_exists = "mykernel::task::Process" }
steps = [
{ static = "INIT_PROCESS" },
{ unwrap = ["Lazy", "Arc"] },
{ walk = { children = "children", via = ["SpinLock", "Vec", "Arc"] } },
]
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
kind | 字符串 | "" | 来源形态说明;空计划判断也会使用它 |
completeness | 字符串 | unknown | 枚举范围 |
steps | 表数组 | — | 从根到实体的执行步骤 |
when | 表 | 无条件 | 当前 ELF 的选择条件 |
reason | 字符串 | "" | 完整性或来源限制的说明 |
completeness 的取值如下:
| 值 | 含义 |
|---|---|
total | 来源覆盖内核保存的全部此类对象 |
partial | 来源只覆盖一部分对象,范围由 reason 说明 |
reachable_only | 来源覆盖某个根可达的对象 |
ready_only | 来源覆盖处于 ready 集合的对象 |
none | 当前内核形态中没有此类对象 |
unknown | 尚未确认来源范围 |
空 steps 仅在 kind = "batch" 且 completeness = "none" 时编译为明确的空计划。其他空 source 会报告编译失败。
when
source 和事件候选共用下列 DWARF 条件:
type_case = { type_exists = "mykernel::task::Process" }
field_case = { field_exists = { type = "mykernel::task::Process", field = "children" } }
prefix_case = { field_type = { type = "mykernel::task::Manager", field = "tasks", starts_with = "alloc::vec::Vec<" } }
exact_case = { field_type = { type = "mykernel::task::Manager", field = "tasks", equals = "[Task; 16]" } }
上面的键名只是为了让示例成为合法 TOML;实际使用时把右侧表写到 when = ...。
type_exists 查询类型。field_exists 先查询类型,再查询其直接字段。field_type 读取直接字段的类型名,并使用 starts_with 或 equals 判断。一个 when 只写一种谓词。
实体来源的 when 还支持 symbol_exists、symbol_missing 和 all。符号谓词查询 ELF .symtab 中已定义的原始符号名;缺少符号表时,两种谓词均不成立。all 中的条件必须全部成立。例如,早期批处理内核可以同时检查进程类型、进程池和调度入口均未引入,再选择 kind = "batch"、completeness = "none" 的空来源。
source steps
每个 step 表只能有一个步骤名。可用的附加参数为 via、key、children、link、next、terminator 和 layout;各步骤只读取下面列出的参数。
static
steps = [
{ static = "mykernel::task::TASK_MANAGER" },
]
从静态量开始。解析器先查询 DWARF 变量,并结合 ELF 符号解析 lazy_static 等只在符号表中出现的名字。找不到符号时 source 编译失败。后续步骤使用该变量的 DWARF 类型。
field
steps = [
{ static = "BCACHE" },
{ field = "inner.buffers" },
]
沿内联字段路径增加偏移。当前类型已知时,偏移在 source 编译阶段解析。编译阶段无法解析的字段会变成运行期动态步骤;执行器没有运行期类型信息,这条路径会报告 unavailable。因此目标 ELF 上的验证需要确认 trace 中没有 field_dyn。
deref
steps = [
{ static = "CURRENT_TASK" },
{ deref = true },
]
按目标架构的指针宽度读取地址。空指针产生空结果;越界或明显无效的地址产生问题记录。等号右侧仅作为 TOML 步骤值,执行过程使用当前 DWARF 类型推导 pointee。
unwrap
steps = [
{ static = "TASK_MANAGER" },
{ unwrap = ["Lazy", "SpinLock"] },
]
依次剥开包装层。名称不区分大小写,也可以写完整泛型类型的外层名称。可用包装层为:
- 地址边界:
Arc、Weak、Ptr、NonNull; - 内联包装:
UnsafeCell、MaybeDangling、MaybeUninit、UPSafeCell; - 锁:
SpinLock、BaseSpinLock、Mutex、SpinRwLock; - 延迟初始化:
LazyInit、Lazy、LazyLock。
各层的 payload 偏移从 DWARF 取得。LazyInit 和 Lazy 会检查初始化状态。Arc 与 Weak 会检查指针对齐和可用性。
descend_to
steps = [
{ static = "WRAPPED_TASK" },
{ descend_to = "mykernel::task::TaskInner" },
]
在当前结构的直接字段中寻找目标类型,并增加该字段偏移。目标类型为短名或完整名均可。没有匹配字段或出现多个匹配字段时,source 编译失败;后一种情况改用 field 明确字段名。
iter
数组、Vec、VecDeque、BTreeMap 和 intrusive list 使用 iter。
固定数组:
steps = [
{ static = "PROCESS_POOL" },
{ iter = "array" },
]
数组的元素数和元素大小来自 DWARF。展开数量受运行时 max_items 限制,超过上限会标记 truncated。
Rust 容器:
steps = [
{ static = "TASKS" },
{ unwrap = ["Lazy", "SpinLock"] },
{ iter = "vec", via = ["Vec", "Option", "Arc"] },
]
iter 的值必须与 via 中负责展开的容器一致。via 从容器层写到元素层;上例生成 vec<option<arc>>。Vec 与 VecDeque 的 buffer、length、capacity 和 head 偏移来自 DWARF。
BTreeMap 需要指定键 reader:
steps = [
{ static = "PROCESS_MAP" },
{ iter = "btreemap", key = "newtype<u32>", via = ["BTreeMap", "Arc"] },
]
结果保留 [key, value],遍历轨迹使用 key 标识节点。映射的 root、length、节点 keys、values 和 edges 布局由 DWARF 解析。
intrusive list 需要给出链字段、next 字段和终止约定:
steps = [
{ static = "READY_QUEUE" },
{ iter = "list", link = "links", next = "next", terminator = "circular_headed" },
]
terminator 可以是 null、circular、circular_headed 或整数哨兵。link 缺省为 links,next 缺省为 next。链表偏移由 DWARF 解析。
walk
steps = [
{ static = "INIT_PROCESS" },
{ unwrap = ["Lazy", "Arc"] },
{ walk = { children = "inner.children", via = ["SpinLock", "Vec", "Arc"] } },
]
walk 把当前对象作为根,读取每个对象的 child 容器并进行广度优先遍历。边可以写 children 或 next。via 的组合规则与 iter 相同;BTreeMap 边还要写 key。遍历按地址去重,回边计入 cycles,达到 max_items 时标记 truncated。
边路径和容器布局应当能在编译阶段从 DWARF 取得。边偏移缺失时,执行器保留根对象并记录问题。
percpu
链接脚本提供 per-CPU 区边界时:
steps = [
{ percpu = "mykernel::run_queue::RUN_QUEUE" },
]
默认路径查询 _percpu_start、_percpu_end、_percpu_load_start 和 _percpu_load_end,计算模板内偏移、stride 和 CPU 数。未加 __PERCPU_ 前缀的名字会同时查询同模块下的 __PERCPU_ 兄弟符号。
运行期安装 per-CPU 布局时:
steps = [
{ percpu = "mykernel::run_queue::RUN_QUEUE", layout = "mykernel::percpu::INSTALLED_LAYOUT" },
]
layout 对象需要能够下潜到 region.runtime_base、region.area_stride、region.area_count 和 template_base。这些值在每一帧快照中读取。布局尚未安装时,此帧没有结果并带有原因。
index
steps = [
{ static = "TASK_POINTERS" },
{ index = 2 },
{ deref = true },
]
把当前地址增加 index * ptr_size。这一实现适用于指针槽数组。数组元素不是指针宽度时,使用 iter = "array"。
from
steps = [
{ from = "process.threads" },
]
当前解析器会接受并建立实体依赖,执行器尚未把已解析关系注入 from reader。这一步不能用于新的工作中。需要从父实体枚举子对象时,先用独立静态根、walk 或 source_combine = "union" 表达;缺少可用表达时再扩展通用执行器。
字段和关系
四个字段表使用同一种格式:
[entity.fields]
pid = { path = "inner.pid", reader = "newtype<usize>", role = "id" }
[entity.optional_fields]
name = { path = "inner.name", reader = "string", role = "name", feature = "task-name" }
[entity.relations]
parent = { path = "inner.parent", reader = "option<weak>", links_to = "process", inverse = "children" }
[entity.optional_relations]
owner = { path = "owner", reader = "option<struct>", links_to = "process" }
| 键 | 类型 | 含义 |
|---|---|---|
path | 字符串 | 相对于实体类型的字段路径;必需 |
reader | 字符串 | 内存编码的 reader;省略后字段无法解码 |
role | 字符串 | 跨内核字段角色 |
enum | 字符串 | reader = "enum" 使用的枚举类型名 |
links_to | 字符串 | 关系指向的实体名 |
inverse | 字符串 | 在目标实体上建立的反向关系名 |
feature | 字符串 | 可选字段所属的编译 feature |
必需字段不存在时状态为 undecodable;可选字段不存在时状态为 absent。字段存在而 reader 不能构造或访存失败时,两类字段都会记录无法解码。
inverse 依赖 links_to。关系 reader 返回单个地址或地址数组;目标地址在本帧没有对应实体时计为 dangling。
字段 role 为闭集:
| role | 用途 |
|---|---|
id | pid、tid、VM ID 等对象标识 |
name | 显示名称 |
state | 调度或生命周期状态 |
address_space_root | 页表根物理地址或 satp 值 |
priority | 调度优先级 |
exit_code | 退出码 |
trap_context | 用户态寄存器保存区地址 |
sched_context | 内核态调度上下文地址 |
添加新的跨内核角色时,先更新 nodefusion/model/roles.py 及消费端测试。
verified 仍是解析器接受的兼容字段,运行时不读取它。验证状态应当由必需/可选字段、测试和覆盖率结果表达。
readers
reader 名不区分大小写,尖括号表示组合。spinlock<vec<arc>> 会先定位锁内数据,再展开 Vec,最后把每项作为 Arc 读取。布局参数由字段的 DWARF 类型逐层生成。
整数、布尔和枚举
| reader | 结果 |
|---|---|
u8、u16、u32、u64 | 对应宽度的无符号整数 |
i8、i16、i32、i64 | 对应宽度的有符号整数 |
usize、isize | 按 ELF 指针宽度读取 |
ppn | 读取 usize 后左移页大小位数,得到物理地址 |
bool | 读取 Rust bool;只接受 0 和 1 |
enum | 按 DWARF 枚举表把判别值转换为变体名 |
atomic<T> | 按内层 reader 读取原子值 |
newtype<T> | 读取单字段包装的 __0 |
bitfield | 读取指定 offset 和 width 的位段;manifest 当前无法传入这两个参数 |
unit | 返回空值,常用于只关心 key 的 map |
enum 优先使用字段表中的 enum,省略时使用字段自身的 DWARF 类型。未知判别值保留为整数。
指针和引用计数
| reader | 结果 |
|---|---|
ptr、nonnull | 读取裸指针;可继续写 ptr<T> |
arc、arc<T> | 读取 ArcInner.data 地址或继续读取 payload |
weak、weak<T> | 读取仍存活的 weak payload;悬垂值返回空 |
option<arc> | Rust Option<Arc<_>> 的 niche 布局 |
option<weak> | Rust Option<Weak<_>> 的 niche 布局 |
option<struct>、option<ptr> | 以空指针表示 None 的结构/指针布局 |
struct | 返回当前内嵌结构的地址,不访存 |
option<T> 当前只支持表中四种内层。其他 Rust enum 布局使用 variant 明确判别值和载荷。
内联包装
unsafecell<T>、maybedangling<T>、maybeuninit<T>、upsafecell<T>、spinlock<T>、basespinlock<T>、mutex<T> 和 spinrwlock<T> 根据 DWARF 中的字段定位 payload。
lazyinit<T>、lazy<T> 和 lazylock<T> 还会读取初始化状态。未初始化、初始化进行中或初始化失败的对象返回 unavailable。
这些 reader 不假定 Rust 字段顺序。目标 DWARF 中找不到所需 payload 字段时,字段无法解码。
容器和字符串
| reader | 条件与结果 |
|---|---|
array<T> | 元素数和元素大小来自 DWARF |
vec<T> | 展开 Rust Vec<T> |
vecdeque<T> | 按 head、length 和 capacity 展开 VecDeque<T> |
btreemap<K,V> | 遍历 Rust BTreeMap,每项返回 [key, value] |
string | 读取 Rust UTF-8 String,长度上限 4096 字节 |
cstr | 读取固定数组长度范围内的 NUL 结尾 UTF-8 字符串 |
cstr 的 maxlen 来自字段的 DWARF 数组上界,适用于 char name[N]。指针形式的 C 字符串没有可由 manifest 提供的独立长度参数,当前 reader 无法安全构造。
容器会检查 length/capacity、地址、对齐和 UTF-8。展开数量达到 ReadCtx.max_items 时,结果带有 truncated 记录。
取字段、变体和类型视图
| reader | 写法 | 用途 |
|---|---|---|
field | field<root_paddr, usize> | 从当前结构的指定字段继续读取 |
variant | variant<Some, field<__0, ptr>> | 只读取指定枚举变体的载荷 |
astype | astype<mykernel::Thread, field<owner, arc>> | 用指定 DWARF 类型解释当前位置 |
这些 reader 可以嵌套。逗号按最外层尖括号解析,因此完整 Rust 类型名和内层 reader 可以同时出现。
当前 reader 注册表共有以下 43 个名称:
arc array astype atomic basespinlock bitfield bool btreemap cstr enum field
i16 i32 i64 i8 isize lazy lazyinit lazylock maybedangling maybeuninit mutex
newtype nonnull option ppn ptr spinlock spinrwlock string struct u16 u32 u64 u8
unit unsafecell upsafecell usize variant vec vecdeque weak
[entity.liveness]
[entity.liveness]
skip_when = { field = "state", equals = "UNUSED" }
field 指定判定字段,equals 指定单个空槽值。不同版本使用不同名称时,可以用 one_of 列出这些值:
skip_when = { field = "state", one_of = ["UNUSED", "P_UNUSED"] }
字段读取成功且值匹配时排除该实体。字段缺失、为空或无法解码时保留实体。
[entity.table]
[entity.table]
show_when_any = ["refcnt", "valid"]
empty = "当前没有被占用或有效的缓冲块。"
[[entity.table.column]]
key = "slot"
label = "槽位"
format = "num"
synthetic = true
[[entity.table.column]]
key = "valid"
label = "有效"
format = "bool"
optional = true
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
show_when_any | 字符串数组 | [] | 其中至少一项为真或非空时显示该行 |
empty | 字符串 | "" | 筛选后没有行时的提示 |
column | 数组表 | — | 列定义;至少一列 |
列的字段如下:
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
key | 字符串 | — | 字段/关系名,或 synthetic 列名;必需 |
label | 字符串 | key | 列标题 |
format | 字符串 | "" | bool、hex、num、enum 或普通字符串 |
values | 字符串数组 | [] | format = "enum" 时按整数下标映射文本 |
optional | 布尔值 | false | 字段不可用时隐藏此列并附说明 |
synthetic | 布尔值 | false | 使用实体枚举下标,无需同名字段 |
普通列必须引用已声明字段或关系。必需列无法解码时,整张表标记为 unavailable。show_when_any 只筛选前端行;快照仍保存全部实体。
address_space 是 [[entity]] 中保留的兼容键。加载器当前不构造地址空间配置,运行时也没有消费者。页表根使用字段 role address_space_root;新的地址翻译能力需要先扩展通用模型。
[[watch]]
[[watch]]
subsystem = "task"
match = { module = "mykernel::task", fn = ["fork", "exit", "schedule*"] }
args = 3
throttle = 0
snapshot = "event"
skip = false
inlined = false
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
subsystem | 字符串 | "" | 观察点分组和 --watch-subsystem 的值 |
match | 表 | — | 函数选择条件;至少一个条件 |
args | 整数 | 2 | 保存的入口参数寄存器数量 |
throttle | 整数 | 0 | 插件侧命中抽样参数 |
snapshot | none / always / event | none | 入口是否请求快照 |
skip | 布尔值 | false | 匹配并占用地址,不加入观察点 |
inlined | 布尔值 | false | 同时选择 DWARF inline site |
when | 表 | 无条件 | 按 DWARF 类型存在与否启用规则;接受 type_exists 或 type_missing |
match 的四个键都接受字符串或字符串数组:
| 条件 | 匹配方式 |
|---|---|
module | 路径等于该模块,或以 module:: 开头 |
module_prefix | 路径以该字符串开头;subsystem = "*" 时用前缀后的第一段作为分组 |
file | DWARF 声明文件等于给定路径,或以 /给定路径 结尾 |
fn | 对函数短名和别名执行大小写敏感的 shell 通配符匹配 |
同一 match 中的条件同时成立才会命中。候选函数来自 DWARF 与 ELF 符号表;同地址的符号会合并别名。Rust trait 实现路径会转换为实现类型路径。
规则按声明顺序执行。匹配到的地址立即加入已处理集合,后续规则不能改变 subsystem、参数数、节流或快照策略。skip 同样占用地址,适合放在宽规则之前。
snapshot = "always" 在每次命中时请求快照。event 请求受限流保护的事件快照;profile 的 event_snapshot_min_insns 可以限制两次事件快照之间的最小指令数。skip = true 只能与 snapshot = "none" 一起使用。
throttle = N 只作用于 snapshot = "none" 的普通观察点,每 N 次命中写入一条事件。N <= 1 表示不抽样。运行元数据记录抽样观察点,覆盖率把相关计数和依赖指标标为 sampled。带 always 或 event 快照的观察点不会写入 @rN,其数值 throttle 不生效。
[event]
键为观察点的完整函数路径或选点后生成的唯一短名,值可以是事件类别字符串、事件表或候选表数组。构建 watchlist 时先查完整路径,再查短名:
[event]
"mykernel::task::exit" = "proc.exit"
"mykernel::task::fork" = { kind = "proc.fork", args = ["parent", "flags"], resource = "process" }
"mykernel::task::spawn" = [
{ kind = "thread.create", when = { type_exists = "mykernel::task::Thread" } },
{ kind = "proc.create" },
]
事件表字段如下:
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
kind | 字符串 | — | nodefusion/model/kinds.py 中登记的事件类别;必需 |
args | 字符串数组 | [] | 入口参数寄存器的名称 |
resource | 字符串 | "" | 事件关联的资源类别 |
operation | read / write | "" | disk.io 的方向 |
coverage_group | 字符串 | "" | 同一路径上互相替代的观测点组 |
when | 表 | 无条件 | 候选项的 DWARF 条件 |
classify | 表 | — | 按入口参数位掩码改变事件类别 |
没有事件映射的观察点使用 func.<观察点名称>。这些事件保留函数调用信息,不参与统一事件指标。
args 的位置对应插件保存的入口寄存器。RISC-V 当前最多记录 a0 至 a7。显式名称也用于 classify.arg。DWARF 形参名不会替代这里的 ABI 位置声明。
resource 随事件进入分析结果,用于标识 process、inode、pagetable 等关联对象。空值使用该 watch 的 subsystem。它不读取对象地址;需要对象身份时,还要在 args 或 nftrace 记录中提供相应值。
operation 只允许用于 kind = "disk.io"。
编译器可能把薄包装函数内联掉,只留下它调用的方法。确认两个入口覆盖同一批调用后,可以为它们填写相同的 coverage_group。严格覆盖只会在该组已有观测点时接受缺失的别名;事件类别相同本身不构成替代关系。
候选列表按顺序选择第一条 when 成立的项。最后一项必须无条件;此前每一项必须带条件。
classify 的格式如下:
[event]
"mykernel::task::clone" = { kind = "proc.fork", args = ["flags"], classify = { arg = "flags", mask = 65536, set = "thread.create" } }
arg 必须出现在同一事件的显式 args 中,且位于前八项。mask 为正整数,set 为另一个已登记事件类别。命中位掩码时使用 set,其余情况使用 kind。
事件类别注册表位于 nodefusion/model/kinds.py。新增类别需要定义跨内核语义、更新消费端并添加注册表测试。
[absent]
[absent]
"log.commit" = { why = "feature", evidence = "文件系统直接写回缓存块;源码中没有 journal、transaction、recovery 路径,ELF 中也没有提交入口。" }
每个键是已登记事件类别。值接受 why、evidence,以及可选的 when:
why | 含义 |
|---|---|
feature | 目标内核没有该机制 |
granularity | 内核有相关行为,当前可寻址入口达不到统一事件粒度 |
evidence 去除首尾空白后至少 24 个字符。内容应当能指向源码位置、调用路径、符号检查或实际测量。无条件的 absent 类别不能同时出现在 [event] 中。
跨构建的 manifest 可用 when = { type_missing = "完整 DWARF 类型名" } 声明某个构建缺少该机制。例如早期章节尚无块缓存:
[absent]
"bcache.read" = { why = "feature", when = { type_missing = "mykernel::block_cache::BlockCacheManager" }, evidence = "早期构建没有块缓存管理器,文件系统章节才引入该类型及读取路径。" }
分析器成功读取本次 ELF 的 DWARF,且找不到指定类型时,这条 absent 才生效。同一类别可在 [event] 中为后续构建保留映射。没有可用的 DWARF 时,条件 absent 不会生效。
[absent] 描述内核或观测边界。某次 workload 没有触发一个已支持事件时,保留事件映射,并为该事件增加测试 workload。
[profile]
[profile] 让录制器从源码目录构建并启动内核。kernel_elf、kernel_image 和 build 必须同时出现。
[profile]
kernel_elf = "target/riscv64/release/{crate}"
kernel_image = "target/riscv64/release/{crate}.bin"
build = "make build {mv}"
detect_files = ["Makefile", "Cargo.toml"]
required_tools = ["qemu-system-riscv64"]
machine_opts = ["-machine", "virt", "-m", "512M", "-kernel", "target/riscv64/release/{crate}.bin"]
interactive = false
ready_markers = ["Booting applications"]
done_markers = ["All applications completed"]
panic_markers = ["Panicked at"]
unsupported = ["program", "stdin_after"]
build_deviation = "启用 DWARF 和 nodefusion-trace feature。"
protect = ["fs.img"]
stdin_preload = false
stdin_pad = ""
event_snapshot_min_insns = 500
构建字段
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
kernel_elf | 字符串 | — | 带 DWARF 的 ELF 相对路径 |
kernel_image | 字符串 | — | QEMU 加载的镜像相对路径 |
build | 字符串 | — | 在内核目录执行的构建命令 |
detect_files | 字符串数组 | [] | 目录级自动识别要求同时存在的文件 |
required_tools | 字符串数组 | [] | 录制前环境检查使用的命令 |
build_deviation | 字符串 | "" | 可观测构建相对上游的构建差异 |
protect | 字符串数组 | [] | 构建与校准期间保护、恢复的文件 |
{crate} 会在以上路径、构建命令、机器参数、保护路径和设备路径中展开为根 Cargo.toml 的 package name。{mv} 只在 build 中由 --lab-stage 与 --make-var 展开。
构建前,录制器暂存旧 ELF、镜像、保护文件和设备文件。构建结束后必须出现新 ELF、镜像以及 required_after_build 判定为必需的文件。
QEMU 与输入字段
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
machine_opts | 字符串数组 | [] | 追加到 QEMU 命令的机器、固件和镜像参数 |
interactive | 布尔值 | false | 使用 guest shell 驱动 |
prompt | 字符串 | "" | shell 提示符;空值使用内置提示符 |
unsupported | 字符串数组 | [] | 此 profile 忽略的 RunConfig 选项名 |
stdin_preload | 布尔值 | false | 在等待提示符前发送 workload |
stdin_pad | 字符串 | "" | preload workload 前写入的字符 |
event_snapshot_min_insns | 整数 | 0 | 两次 event snapshot 之间的最小指令数 |
录制器从 machine_opts 的 -m 解析 guest RAM 大小,并传给插件。支持 -m 512M 和 -m size=512M,...。解析失败时会产生警告,插件使用自己的默认值。
unsupported 中常用 program 和 stdin_after。无 shell 的教学内核自行顺序运行应用时,录制器会忽略这些命令行选项并写入 warning。
控制台 marker
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
ready_markers | 字符串数组 | [] | headless 模式的启动完成标记 |
done_markers | 字符串数组 | [] | workload 完成标记 |
panic_markers | 字符串数组 | ["panic"] | panic 标记 |
exit_marker | 字符串 | "" | shell workload 的完成正则 |
halt_markers | 字符串数组 | [] | guest 停机标记;空数组时使用 panic markers |
headless 模式先等待任一 ready marker。没有 ready marker 时立即视为已启动。运行阶段先检查 done,再检查 panic;QEMU 提前退出时也按完整控制台先判断 done 与 panic。
shell 模式先等待 prompt 或第一个 panic marker。workload 开始后,结局按 exit_marker、done、panic、新 prompt、halt 的顺序判断。exit_marker 可以包含 (?P<code>...) 捕获退出码。prompt 与 halt 同时出现且没有 exit marker 时,运行记为 completed,并附带 outcome ambiguous 说明。
shell probe
[profile]
kernel_elf = "target/{crate}"
kernel_image = "target/{crate}.bin"
build = "make build"
interactive = false
unsupported = ["program", "stdin_after"]
shell_prompt = "$ "
shell_ready_marker = "shell ready"
[profile.shell_probe]
dir = "os/src"
ident = "run_shell"
shell_probe 必须同时包含 dir 和 ident。录制器在 dir 下递归读取 *.rs,任一文件含有 ident 时,把 profile 切换为 interactive,使用 shell_prompt,并用单个 shell_ready_marker 替换 ready markers。program 和 stdin_after 会从 unsupported 中移除。
device 设备
[[profile.device]]
file = "fs.img"
opts = ["-drive", "file=fs.img,if=none,format=raw,id=x0"]
built_when = { file = "Makefile", contains = "fs.img:" }
| 键 | 类型 | 缺省值 | 含义 |
|---|---|---|---|
file | 字符串 | — | 设备文件相对路径;必需 |
opts | 字符串或字符串数组 | [] | 文件存在时追加的 QEMU 参数 |
built_when | 表 | — | 构建是否应生成该设备的源码条件 |
built_when 必须同时给出 file 和 contains。录制器读取该文件并查找字符串;命中时设备在构建后必需。文件无法读取时按必需处理。设备无论是否为本构建必需,都会进入保护集合。
[syscalls]
[syscalls]
files = ["kernel/syscall.h", "include/syscall*.h"]
pattern = '''#define\s+SYS_(?P<name>[A-Za-z0-9_]+)\s+(?P<num>[0-9]+)'''
files 是相对内核目录的非空 glob 数组。pattern 是 Python 正则,必须包含 (?P<num>...) 和 (?P<name>...) 两个具名组。分析器用它建立系统调用号到名称的映射。
当前兼容字段
下列语法由解析器接受,尚未形成可用的端到端能力:
| 位置 | 字段 | 当前状态 |
|---|---|---|
[[entity]] | address_space | 加载时丢弃,没有运行时消费者 |
[[entity.source]] | nested | 保存到 SourceSpec,source 编译器不读取 |
| 字段/关系 | verified | 保存到 FieldSpec,probe 与覆盖率不读取 |
| reader | bitfield | reader 需要 offset 和 width,字段 schema 尚无参数入口 |
| source step | from | 建立依赖并编译,执行器没有关系注入 reader |
新 manifest 不使用这些字段。若某个内核确实需要相应能力,应先补齐数据模型、执行路径、错误报告和测试,再更新本页。
扩展通用模型
下列改动属于通用引擎扩展:
- 新的内存编码需要 reader;
- 新的枚举方式需要 source step;
- 新的跨内核字段概念需要 role;
- 新的跨内核事件语义需要 event kind;
- 返回值、提交后状态或内核内部决策需要
nftrace记录。
新增 reader 时要覆盖正确布局、缺失 DWARF、越界内存、空值、损坏值和截断。新增 step 时要覆盖计划编译、执行、循环和数量上限。新增事件或 nftrace 记录时要覆盖注册表、wire 编解码、截断输入、重复事件处理、覆盖率和 HTML 数据形状。