现在是我第5次参加操作系统训练营了。我第一次接触Rust和参加操作系统训练营是2024年初。经过漫长时间的学习和消化,我意识到了自己接受新事物的速度还是比较慢的。在学习的旅途中,我遇到了很多志同道合之人——有些人擅长理论推导,有些人喜欢折腾底层汇编,还有些人总能在我卡住的时候扔来一个关键链接。这种氛围让我觉得,慢一点也没关系,只要还在往前走。
以下是我参加训练营的经历、感悟和收获。
Rust and Programming Language
在此之前,除了汇编语言,我只用过C系列的编程语言(C, C++ 和 C#)编写项目和代码。按照原理来说,很多编程语言都可以实现操作系统,至少只要实现了LLVM的前端,就能得到对应的后端二进制代码。但是近年来,使用Rust进行操作系统和嵌入式开发变成了新的趋势。
最初,我无法想象“内存安全性”会是怎样的情况。毕竟C++的智能指针已经宣称解决了大部分问题,可实际项目里引用计数循环、悬垂指针照样防不胜防。但接触了Rust之后,我发现Rust不仅仅是一种简单的语言。Rust在编译时就能检查代码,考虑到多线程的使用、内存泄漏以及整体的优化等问题。它不是在运行时给你抛出一个段错误,而是在你保存文件的那一刻就告诉你:“这里有问题,而且我连怎么改都建议好了。”这种安全感,让人安心。在语法上,Rust的一些设计和Scala、C#非常相像,比如模式匹配、闭包的写法,还有那种“表达式无处不在”的风格,一开始不太习惯,但写了几周之后回头看C++的?:三目运算符,总觉得少了点灵活。
第一点、Rust的标准库非常好。与标准C/C++库相比,Rust的标准库应用更加简洁,对字符串的支持非常省心。C等传统语言对Unicode编码的支持并不友好,也缺少统一的标准,而Rust则很好地解决了这个问题。我印象很深的一次经历:在做训练营的某个文件系统实验时,需要解析一个包含中文路径的用户输入。用C写的话,我得纠结是UTF-8还是GBK,还得手动处理字节长度;而在Rust里,std::path::Path和String直接就把这些事情理得清清楚楚。这种“不用自己造轮子,而且轮子还是圆的”的体验,大大降低了编写操作系统外围工具的心智负担。
第二点、Rust在类型上有创新。例如切片和引用,切片不仅包含起始地址,还有数据长度,这让Rust函数在很多时候可以简化传参,例如,其他语言传递基址还需要包含一个尺寸量,像memset、memcpy这类函数,但是包含数据长度可以少传一个参数,直接当成“Array”使用。这样做有一个特点(不能说是缺点),就是Rust的字符串与C互操作时需要在末尾添加NULL终止符,否则无法正确解析ASCIZ字符串。我在写内核的串口驱动时,就经常需要做这种转换——Rust的&str传到C的printf风格函数之前,得手动as_ptr()再加个\0,稍微有点啰嗦,但习惯了也就变成了肌肉记忆。默认,Rust变量和引用都是不可变的,这让我在写代码时更加注重变量的可写性。对于很大部分人而言,写代码的时候很少注意变量的可写性,导致以后想给一个接口的参数换为const的时候需要付出很多的修改。采用不可变性这一特性促使我在编写代码时声明更多的变量,从而使代码更具可读性。以前写C++的时候,我习惯把一个变量反复重用——一个int i既能当循环下标,又能存中间结果,最后还得存个标志位。现在回过头看,那简直就是“变量的一生被反复蹂躏”。Rust逼着我给每个新状态起一个新名字,代码反而变得像流水账一样清晰。
第三点、unsafe关键字。与C#一样,Rust也有一个“unsafe”关键字,用于标记那些无法由编译器保证安全性的代码。这使我们能够插入特殊代码,解决可能存在的执行问题。在做操作系统训练营的过程中,我发现unsafe其实是一个很好的设计——它把“危险的角落”用高亮笔圈了出来。比如直接操作页表、读写外设寄存器、或者调用一段手写的汇编启动代码,这些必须放进unsafe块里。我在写内核的内存分配器时,整个模块只有三处unsafe:一处是操作裸指针,一处是调用内联汇编刷TLB,还有一处是绕过借用检查器来初始化静态变量。剩下的几百行代码都享受着编译器的保护。相比C++,Rust的unsafe就像是给你一张地图,上面标注了“此处有地雷”,你只要小心这几块区域,其他地方的草可以放心踩。
第四点、Rust 有一种非常独特的所有权系统。与 C 系列语言有所不同。所有权系统能帮助我们避免内存泄漏或多重引用的问题。在其他语言中,我们可能会忘记考虑变量使用的相关机制。在内核的编写中,野指针是非常致命的存在。在长期的C++开发中,我已经屡次领教过野指针带来的问题,往往是忘记使用 memset 或者没有使用构造函数导致的问题。
第五点、Rust 有着非常独特的trait。这种特性可以使代码更加灵活且可复用。我对这个特性的第一感觉是没有C++的虚表和继承的功能更方便。但是Rust提供的 impl 机制可以比较自由地增加方法,并且可以指定方法在哪个特性中实现。比C#中的partial 机制还要更美妙一些。受到Rust的影响,我在写C++代码时也用trait来命名一些公共接口。
第六点、使用panic和Result处理异常。在C++的世界里,异常处理一直是个让人又爱又恨的话题,这个机制需要专门的开关支持,在内核编程基本没人用。noexcept又不够精细,导致你永远不知道一个函数到底会冒出什么幺蛾子。我见过不少C++项目,最后干脆禁用异常(-fno-exceptions),所有函数返回错误码,然后调用方用一堆if去判断。Rust的做法让我眼前一亮:它把“可能出错的操作”分成了两类。一类是“NMI型问题”,用panic!;另一类是“小问题,调用方可以处理”,用Result<T, E>。
Architecture
我尝试在 x86, RISCV, ARM 架构上实现裸机程序。
- x86 算是现代计算机架构的鼻祖,也是目前使用最为广泛的架构。从我们身边的台式机、笔记本,到数据中心的服务器,x86 几乎无处不在。它的历史包袱很重,从16位的8086一路延伸到64位的x86_64,里面塞满了各种兼容模式、分段机制、中断描述符表的复杂规则。这是我从小接触的架构。
- RISCV 是一种开源的指令集架构,目前很多开源项目都在使用RISCV架构。
- ARM 是一种商业的指令集架构,在嵌入式单片机、移动设备手机平板等电子设备上使用广泛。
我发现了RISCV有以下优势:
第一、RISCV 是一种开源的指令集架构,注定了生产使用的成本更低。x86和ARM的授权费用高,而且还有各种专利壁垒。这种低成本不仅仅体现在钱上,更体现在试错成本上。我们可以在模拟器里随便改指令集,完全不用考虑商业兼容性。这对于我们这些写操作系统的人来说,意味着底层的行为是完全透明、可预期的。
第二、RISC-V的32位与64位指令集架构高度相似。这一点,x86与ARM均未实现。RISC-V的32位(RV32)与64位(RV64)在分页机制上仅存在细微差异,而ARM的不同运行模式(如32位与64位)之间的分页技术则差异显著。x86的32位与64位架构在分段模型、寻址方式及指令使用等方面均表现出较大差异,导致跨位宽兼容的实现复杂度显著上升。这种相似性不仅体现在分页技术上,还贯穿于异常处理、控制状态寄存器(CSR)布局以及原子操作指令中。对于操作系统开发者而言,这意味着同一套内核代码可以通过条件编译(如根据 __riscv_xlen 宏)平滑地适配两种位宽,无需重写核心的页表遍历、上下文切换或系统调用入口逻辑。反观x86-64,其兼容32位模式时需要启用兼容子模式(compatibility mode),并且段寄存器、门描述符格式以及中断栈帧布局均与纯64位模式存在结构性差异,这迫使许多操作系统(如Linux)在x86上维护两套独立的低级入口代码。RISC-V通过设计上的统一性,显著降低了多平台内核的维护成本。
第三、RISCV的M态陷入默认是关闭分页的。这一点设计真的很好,可以直接使用恒等映射,这样在内核态和用户态切换时,不需要做任何的映射操作,直接使用恒等映射即可。而x86和ARM则需要在用户态和内核态切换时想办法转到内核页面。但是,RISCV的S态陷入默认是开启分页的。M态关闭分页的特性使得最低特权级的固件(如引导加载程序或安全监视器)能够完全绕过虚拟地址转换,直接以物理地址访问内存。这一设计简化了启动阶段的早期初始化流程:在设置页表之前,M态代码即可完成内存检测、设备树解析或安全服务调用,无需预先建立任何地址映射。x86架构则缺乏类似的干净抽象——即使是在系统管理模式(SMM)下,处理器仍然处于实模式或保护模式的分页语境中,开发者必须小心处理段基址与分页的相互作用,稍有不慎便会引发三重故障。与之相对,RISC-V的S态默认开启分页则是为了保障常规操作系统的运行效率:内核与用户进程共享统一的虚拟地址空间,通过页表隔离权限。但这一设计也带来了额外的约束——在S态下直接访问未映射的物理地址需要临时切换页表或使用恒等映射区域,否则会触发页错误。
Experience and Another Design
今年的训练营,给我的最大挑战的是ArceOS环境的配置。我试过调整Toolchain的版本,也试过修改单个依赖的版本,都失败了。最终,我决定采用最新的工具版本,针对其中的一些版本适配问题,我会将一些依赖的内容固定到仓库中。
与之前几期相比,Rustlings 后又增加一个阶段。主要涉及了多线程下的Rust应用,功能很实用。rCore 的前几个章节有略微的改动。
print_with_color作业:
一开始要实现一个彩色打印功能,与rCore一开始实现的一样。原理都是 Linux 上面的终端支持。我实现的是绿色的,用这个颜色的原因是其他例程pass时也是用绿色输出的。
这个终端颜色的原理也让我将其用作其他程序的Logging输出。
sys_mmap作业:
这个作业让我对内存管理有了更深的理解。将文件内容映射为内存页,这个设计很好。让我掌握到了匿名映射,按需分配等概念,以后可以自己从头实现试一试。
support_hashmap, ramfs_rename作业:
要注意到修改Cargo.toml文件里依赖和版本
我使用 C++ 设计中另一个类组件化的微内核(mecocoa)。设计思路和经历如下
IO系统
IO 系统是我最早着手实现的部分之一,也是经历重构次数最多的模块。最初只是想做一个能显示字符的文本控制台,后来逐步加入了图形界面、TTY 虚拟终端、键盘鼠标输入以及窗口管理。
系统支持多个虚拟终端(VTTY),每个终端拥有自己的输入输出队列。当一个进程调用 INNC 等待键盘输入时,它的 PID 会被加入等待序列。主循环 serv_cons_loop 会轮询所有阻塞进程对应的 TTY 输入队列,一旦有字符到达就唤醒该进程并发送消息。这个设计简单直观,但轮询效率不高,而且没有优先级区分。
键盘中断服务程序将扫描码转换成 keyboard_event_t 结构,然后调用 hand_kboard 放入当前焦点 TTY 的输入队列。鼠标消息同样经过 hand_mouse 处理。这种消息化设计使得输入源与消费端解耦,后续若要支持触摸屏或远程输入,只需增加对应的消息产生者即可。
当前的消息轮询模型效率低下,未来要解决这些问题。
图形处理
图形处理模块是 IO 系统的上层延伸,负责鼠标光标绘制、窗口图层管理、基本绘图原语以及双缓冲刷新。其设计围绕图层管理器与底层图形输出接口展开,实现了从像素绘制到窗口交互的初步功能。针对不同显存布局,实现了两种主要的图形输出类,分别处理 ARGB 与 ABGR 两种字节序。基本绘图接口(画点、画矩形、批量画点)直接操作显存地址,通过帧缓冲基址加坐标偏移计算目标位置。对于 24bpp RGB 格式也有实现。矩形绘制中尝试了批量写入优化,例如在地址对齐时使用 32 位宽赋值代替逐字节写入。
图形子系统的图层管理。 LayerManager 管理多个 SheetTrait 对象,每个窗口或控件都是一个图层。我实现了双缓冲(enable_2buffer),通过后缓冲 sheet_buffer 减少显存直接操作带来的撕裂和闪烁。光标 Cursor 作为独立图层,可以响应鼠标移动事件并更新位置。此外还实现了简单的 Form 和控件(Label、TextBox),虽然功能简陋,但至少证明了窗口模型的可行性。双缓冲开启后,所有绘图操作先写入后缓冲(系统内存中的颜色数组),定期刷新函数检查“是否需要刷新”标志和“脏矩形”区域,将受影响的矩形区域通过真实的图形输出接口复制到显存。刷新频率由系统时钟控制。脏矩形坐标在刷新前会进行裁剪,防止越界。
图形服务函数运行在单独的任务中,从图形消息队列中取出消息,目前只处理鼠标消息并调用上述鼠标处理函数。这种设计将图形输入与主控制台服务解耦。
目前的局限性有:
- 光标形状是固定尺寸的字符数组,无法动态加载不同光标样式(如箭头、手型)。
- 每次鼠标移动或点击都遍历所有图层进行坐标命中测试,图层数量增加时开销线性增长。
- 层管理器的事件回调仅实现了鼠标拖拽窗口(检测到左键按下时设置移动标志),但未处理窗口释放、边界限制、与其他控件的交互(如按钮点击)
任务管理与调度系统
调度系统是内核的核心组件之一,负责管理进程与线程的生命周期、优先级调度、同步与通信。经过多次重构,当前版本实现了一个 O(1) 优先级的混合调度器,据说是Linux 2.x用的是这个方案,区分实时任务与分时任务,并支持多核基础架构(多核部分尚未启用)。优势是基于位图的快速队列选择: 每个优先级对应一个就绪队列(双向链表)。调度器维护一个 32 位位图,每一位表示对应优先级队列是否非空。选择下一个任务时,通过 __builtin_ctz(数尾零指令)直接找到最高优先级的非空队列,然后取出队首线程。
系统区分 ProcessBlock(进程)与 ThreadBlock(线程)。每个进程至少拥有一个主线程,可额外创建更多线程。进程拥有独立的页表、文件描述符表、堆区边界、TTY 焦点及图形窗口引用;线程则持有运行上下文、栈空间、优先级、时间片及阻塞原因。这种分离使得资源管理更加清晰,也为未来引入多线程应用打下了基础。
系统提供了基于消息的进程间通信原语(msg_send / msg_recv),支持阻塞发送与接收。每个线程可记录等待的发送目标或接收来源,并挂起直到消息到达。
关于M态会不会发生抢占,这个与架构有关。x86是可以的,但是 riscv 的 ecall 好像会自动关闭中断,对此我决定手动打开中断。所以M态是会发生抢占的。
内存管理
内存管理模块承担了物理页分配、内核堆管理、页表映射及分段机制初始化等职责。该模块需要适配多种引导环境(BIOS、GRUB、UEFI)和不同架构(x86 32位、x86_64、RISC‑V),是系统底层的基础设施之一。
系统预留一个覆盖 0 至 4G 物理地址空间的位图.位图结构支持标记页的占用与释放、查询连续空闲页以及设置可用范围。然后实现了一个简单内存池,内核启动后从物理页分配器获取一块连续内存(如 16KB),将其加入内存池作为初始堆空间。后续的 new/malloc 调用均通过默认分配器转发至该内存池。这种设计将内核自身的动态内存需求与物理页管理解耦。
尽管当前内存管理模块尚不完善,它已经成功支持了内核启动、多进程创建、图形缓冲区分配等基本功能。能够正确解析 GRUB 的内存映射并分配出可用的页帧。
用户堆功能当前尚未实现。
消息机制
消息机制是进程间通信与硬件事件通知的核心基础设施。该模块分为两个层次:一是全局系统消息队列,用于处理来自中断的异步事件(键盘、鼠标、定时器、xHCI、帧缓冲刷新);二是基于线程阻塞的同步进程间通信(IPC),支持任意进程间的消息发送与接收,并能够将硬件中断转化为特定线程的消息。
系统维护一个全局的环形队列,专门存放由中断处理程序产生的硬件事件(如鼠标、键盘、定时器、xHCI、刷新)。独立的任务不断从此队列中取出消息并分发给对应的驱动或图形子系统。进程间的普通 IPC 则通过发送与接收系统调用实现,使用另一套基于线程控制块的阻塞队列机制。这种分离保证了硬件事件不会被进程间通信阻塞,降低了中断响应延迟。
同步 IPC 基于线程阻塞与唤醒。 发送线程调用发送接口时,若接收线程当前正阻塞在接收接口上且匹配发送者或允许任意来源,则立即复制消息数据并唤醒接收线程;否则发送线程自身被阻塞,挂入接收线程的发送等待队列,同时标记阻塞原因为“等待发送”,并记录目标线程。接收线程调用接收接口时,若其发送队列非空且与指定的发送者匹配,则取出队首消息,复制数据后唤醒发送线程;否则接收线程阻塞,标记“等待接收”,记录期望的发送者。这种设计实现了内核中的同步消息传递,无需额外的共享内存。由于发送方和接收方可能位于不同的进程(拥有不同的页表),消息数据不能直接用指针访问。模块提供了跨页表复制函数,根据两个进程的页表将源虚拟地址转换为物理地址(或直接映射地址),再复制到目标虚拟地址。该函数在 RISC-V 与 x86 上分别适配了分页机制,并支持降级到内核页表。
发送接口在阻塞前会检测发送等待链中是否存在循环等待(例如 A 等待 B,B 等待 C,C 等待 A)。若检测到潜在死锁,函数返回错误而不阻塞,避免了系统挂死。虽然检测算法简单(仅遍历单链),但对于单核场景下有限的通信深度已足够。
文件管理与虚拟文件系统
文件管理模块构建了一个虚拟文件系统(VFS)层,用于统一管理不同底层文件系统,并为进程提供标准的文件操作接口(打开、读写、关闭、删除等)。
系统维护一个全局根目录项,所有文件和子目录均以双向链表形式组织在父目录项之下。路径解析时通过线性查找子目录项,命中则直接返回,未命中时触发底层文件系统的搜索回调,并在返回后动态创建新的目录项和索引节点加入缓存。这种“按需建立”的缓存策略减少了内存占用。
系统实现了一个特殊的设备文件系统,并默认挂载在 /dev 目录下。该文件系统在初始化时自动创建 /dev/tty0 至 /dev/tty3 四个设备节点,其内部处理字段存储 TTY 索引号。读写操作通过 IPC 消息转发给对应的 TTY 服务进程,将文件接口转换为进程间通信。
目前只适配了 FAT12/16/32 几种文件系统。
系统调用与处理程序
采用了一种通用简单的设计方案,限制是每个系统调用的参数数量不能大于3.
支持 INT/GATE/SYSCALL/ECALL 指令导致的系统调用。
End
我的操作系统开发之旅才刚刚开始,未来还有很长的路要走。从最初对Rust内存安全性的懵懂,到如今能在三种架构间对比分页与陷入行为,这五次训练营的经历让我逐渐摸到了系统编程的门槛。然而,每一次深入都揭示出更多未知的领域——异步运行时在内核中的整合、多核间的缓存一致性协议、实时调度算法的确定性验证,这些课题仍在远方等待。RISC-V的开源特性降低了实验门槛,但生态的成熟度尚需时日;x86的庞大兼容层虽令人敬畏,却也暴露出历史设计的沉重代价。前方的道路并非坦途,但正因如此,每一次编译通过、每一个页表正确遍历、每一次异常被精准捕获,都成为继续前行的理由。或许,操作系统的魅力就在于它永远无法被彻底“完成”。
Dosconio,
21, April 2026.