0%

2026春夏季开源操作系统训练营总结

操作系统训练营复盘

序言

我参加过不止一次操作系统训练营,但只有这次真正进入了专业阶段。回头看,这段学习给我最大的提升不是“又多懂了一些概念”,而是理解方式变了:操作系统不再是各种模块的拼装,而更像是对 CPU、内存、设备等物理资源的重新组织与分配——向上管理应用,向下管理资源。

这次训练营的跨度也很大:从 OS 理论与 Rust 基础,到搭建纯粹的 no_std 环境;再到特权级切换、调度、页表;最后在专业阶段落到并发控制、VFS、系统调用等更复杂的系统能力上。


导学阶段

导学阶段我主要跟着课程录像把操作系统体系重新过了一遍,并结合训练营官方指导与 OSTEP 做补充。这个阶段我最重要的收获是:很多章节并不是在讲“技巧”,而是在解释 OS 如何把有限的物理资源做成一种“多路复用的错觉”,让每个进程都觉得自己独占资源。理解到这一层之后,再看内核架构为什么这么演进,会更顺。

第一次在 no_std/裸机环境里做实验时,一些在标准库里理所当然的操作突然变得“不显然”。我印象很深的是终端彩色输出:我原本习惯用 {:?} 快速打印结构体/变量状态,但当我尝试直接写字节序列输出 ANSI 颜色控制码时,屏幕却是一堆乱码。

后来定位到原因:Debug 格式化会把 ANSI 转义字符 \x1b 强制转义成字面量文本(真实字符 \x1b),控制码语义被破坏,终端自然无法解析颜色指令。解决方式很朴素:不要用 {:?},改用 {}(Display),保证原始 0x1B 字节无损进入串口发送寄存器。


基础阶段

基础阶段的任务密度明显上来了:总共需要实现 6 个模块、23 个测试,涉及 no_std 基础设施、并发、特权级切换与调度、页表等多个节点。我这里挑几个对我影响最大的点写一下。

1)no_std 基础设施:内存原语、ecall、堆分配

在没有标准库的情况下,一些基础能力需要自己补齐。我用汇编与内联 Rust 代码手动实现了 memcpymemset,并封装了 ecall 系统调用接口。

动态内存分配方面,我实现了基于 CAS 无锁算法与侵入式链表的 Free-List 分配器,为内核态提供稳定的堆内存支持。

2)特权级切换与调度:上下文切换变得“可触摸”

在特权级切换与进程调度模块里,我更深刻地理解了上下文切换的本质:我利用 RISC‑V 的裸函数属性,手动保存与恢复 32 个通用寄存器以及核心 CSR。在此基础上,我不仅实现了传统线程上下文切换,还进一步实现了运行在用户态的“绿色线程”,用协作式调度与状态机探索轻量级协程的性能边界。

3)和同期同学对照后的一个结论:底层问题放大效应很强

我会把自己的 Debug 记录和同期同学的PR对照着看,感受很明显:应用层的“坏习惯”到了 OS 底层会被成倍放大。比如只顾设计模式而忽略数据流正确性、缺少错误路径测试、试图用大量 unsafe 绕过 Rust 检查器等——在 OS 里往往会直接演变成系统级灾难。

我印象比较深的几类问题与我的处理方式大致是这样:

  • 内存分配与回收 :早期简单 Bump 分配器缺乏回收机制,遇到 Vec 扩容或动态字符串处理很容易 OOM。针对早期内核初始化,我做过一种折中:设计“双端内存分配”,前端分配小块字节、后端分配整页,并引入一个基于引用计数的全局追踪器;当活动对象计数归零时,直接把字节分配指针重置回基址(b_pos = start)。
  • 并发与生命周期 :我踩过一个很隐蔽的“临时值生命周期”坑:链式调用 current().task_ext().aspace.lock() 会让 current() 生成的临时任务引用在语句结束时被 Drop,但 lock() 得到的 Guard 仍试图借用它,导致编译错误。最终我回到所有权本质:用显式变量绑定 let curr = current(); 延长引用存活期,理清锁与借用对象的层次关系。
  • 中断与特权级相关陷阱 :在异常处理里,若不推进 sepc,可能会反复回到同一条触发异常的指令。我的处理里会在必要时手动 sepc += 4 跳过当前 32 位指令。在 simple_hv 虚拟机管理程序中,我还遇到过一个规范相关的细节:从 VS 态访问某些 M 级 CSR(如 mhartid)触发的并非预期的 VirtualInstruction(异常码 22),而是 IllegalInstruction(异常码 2)。最后我重写了非法指令处理器:软件解码指令、模拟返回伪造的 CSR 值,并精细管理 sepc 的演进流。
  • no_std 生态缺失 :为了支持更复杂的数据结构,我把 hashbrown 引入到自己的 axstd 模块中,重建哈希集合支持。过程中还触发过 nightly-2024-09-03 的编译器 ICE:常量泛型参数 SIZE 与 Trait 关联常量 PAGE_SIZE 底层符号解析冲突。我通过重命名泛型变量(如改为 PAGESZ)从侧面规避了前端缺陷。

SV39 分页 与 TLB“幽灵状态”

如果要选一个最能体现“软硬件协同”且最折磨人的点,我会选虚拟内存分页系统及其与 TLB 的交互。

RISC‑V 的 SV39 使用三级 Radix Tree:虚拟地址拆成 VPN2/VPN1/VPN0+ 12 位页内偏移,MMU 会遍历三级页表完成映射。静态结构很好理解,但真正难的是运行时的硬件状态同步。

我在处理动态内存映射时,逻辑上已经“看起来完美”:分配物理页帧、更新 PTE、设置 V/R/W/X 权限位,然后 sret 返回继续执行。按常理应该成功,但 CPU 会再次抛出同样的缺页异常。

后来我意识到这是典型的 TLB staleness :页表在内存里更新了,但 CPU 内部的 TLB 仍缓存着旧的“该地址无效”记录。解决必须落到指令层:修改页表后,显式向处理器发出同步屏障。在我的逻辑里,只要重写了 hgatp 或更新了关键页表条目,就需要紧接着执行 hfence.gvma,强制刷新相关地址空间的 TLB 项。


专业阶段

专业阶段的变化很明显:复杂度与状态空间开始爆炸。文件系统架构、复杂并发控制、IPC 等问题,逼着我从“写功能”转向“做工程治理与架构取舍”。

在 rCore-Tutorial 的 Chapter 6,我围绕 easy-fs 的文件系统实现做了较多工作,并对其分层有了更清晰的认识:

  1. 块设备抽象层(easy-fs/efs.rs :磁盘布局、inode 位置计算、位图分配与回收等底层逻辑
  2. VFS 层(easy-fs/vfs.rs :目录树遍历、链接、inode 生命周期管理等核心抽象
  3. 系统调用边界(os/src/fs/ :将内核对象包装成用户态可用的文件抽象(如文件描述符)

我主要补全了 sys_fstatsys_linkatsys_unlinkat。以 linkat 为例,其控制流非常“手术式”:路径遍历 → 校验目标实体/链接名→ 锁定源 inode,对 nlink 原子 +1 并写回磁盘 → 在目标目录追加新的 DirEntry 并强刷缓存。

unlinkat 的反向流程同样复杂,尤其是“删除目录项不产生空间碎片”的问题。我采用的策略是:找到待删除目录项后,将其与目录中最后一个有效目录项交换,然后缩减目录文件 size,保证磁盘空间紧凑;随后递减目标 inode 引用计数,只有当 nlink 降至 0 且系统内无文件描述符占用时,才触发底层数据簇回收。

另外我也处理了一些边界:例如 Stdin/Stdout 属于流式字符设备,缺乏普通磁盘文件的块状元数据;为了避免生成“虚假的 Stat”,我的实现会直接拦截这类请求并 panic!,拒绝伪造元数据。


反思

经历导学、基础、专业三个阶段之后,我最明显的变化是:对“底层原理”的认知从抽象概念变成了可推导的具体原理,我会去思考寄存器怎么变、异常为什么回到同一条指令、TLB 为什么缓存旧结论、并发原语为何在不同调度模型下表现截然不同。

我对 AI 辅助编码的用法

随着AI的迅猛发展,在本次训练营中我也结合AI辅助OS编码,但是我也发现了OS 底层容错率太低,使得我对 AI 的用法也变得更审慎:它可以当手术刀处理局部实现与重复劳动,但不该在宏观架构上自由发挥。我的做法是把它严格约束在可控范围内:

  1. 全局上下文对齐 :先规定技术边界与可改文件/不可碰结构(唯一事实来源)
  2. 原子化拆解 :把大需求拆成 3–5 个闭环步骤,每一步都是 MVP,爆炸半径小
  3. 确定性验证标准 :每一步必须给出明确可验证的测试/日志/行为变化,通过后再做下一步

这套策略在专业阶段帮我避免了“AI 幻觉导致底层数据结构被悄悄破坏”的风险,也提高了推进效率,这也是我在本次训练营中领悟出来的使用技巧。从代码的写作转到审查代码,把控架构,也许我感觉这是能否在Vibe Coding的冲击中站稳脚跟的关键,以操作系统的设计为例,后者更考验对整个复杂系统的掌握,这就驱使着我们要加速脚步增加对知识的汲取。AI Coding 的效率不取决于模型的能力,而是取决于所使用的架构是否支持多工作树低冲突合并;AI 的能力边界也不取决于模型的能力,而是取决于工程过程可被 token 化的范围边界在哪里。