0%

第一次参加操作系统的训练营,确实学到了很多之前在书本上没有学到的知识,也锻炼了自己的动手能力。

rustlings

主要是语法学习,因为之前学过rust的基础语法,所以这次简单复习一遍就很容易通过了,不过增加的数据结构和算法题还是有一定难度,需要多多debug。

rcore

rcore其实和本科的教材讲的操作系统其实是一回事,只是用rust实现。所以看文档就感觉很亲切,练习题也比较简单。

arceos

由于之前没有接触过组件化操作系统,第一次看到还是有许多不理解的地方。不过教学视频里面的图示都很清晰,对照着老师的教学也很容易听懂。练习题主要是要多使用gdb,搞清楚各个功能的函数调用链,功能本身的实现倒不是很复杂。

挑战题目

目前刚好处在论文开题的阶段,由于时间原因还没来得及做,希望能在截至日前抽出时间尝试一下。

学习心得

二阶段 rcore

再一次参加了OS训练营,从第一次的跌跌撞撞到第二次的懵懵懂懂再到第三次的游刃有余,操作系统虽然很难,但也正是因为困难才有意思。

整个内核的调用关系在心里更清晰了,在实现需求的同时,也力求代码的整洁和可读性。

  1. ch3 user通过syscall调用内核,本质上就是对目标的物理地址进行赋值,把不安全的操作放到内核来做
  2. ch4 加入了MMU,user拿到的地址就不是物理地址了,需要进行一些转换。在分配和释放内存的时候也就是修改页表的一个过程,和修改数据库没什么区别。
  3. ch5 实现了简单的进程调度算法,从FIFO变成了stride 调度算法,也把fork 和 exec拼一起形成了spawn。
  4. ch6 对inode进行操作,实际上各种方法都被包装的很好了,调用关系也很清晰。
  5. ch8 这里弄清楚创建线程的位置和那些变量的增减时机一切都迎刃而解。

三阶段arceos

代码量总体更少,确定了实现范围后很快就可以完成。

在实验上

  1. [print_with_color] 可以很好的熟悉调用关系
  2. [alt_alloc_bump] 一个bump内存分配算法 如果有时间可以尝试一下挑战题
  3. [ramfs_rename] 相比于二阶段这里的node抽象程度更高,老节点挂在新路径上即可实现
  4. [hashmap] 实现collections中的hashmap的替换,可以自己新建一个collection,在里面实现hashmap
  5. [sys_map] 三阶段代码量最大的一道题,不过很多方法和调用都在main中用过了,先拿到用户栈的地址,在里面找一块内存,之后把程序读入缓冲区并写到目标位置
  6. [simple_hv] 处理一下未处理的异常,之后执行下一条命令

总结

操作系统是一个很神奇的东西,每一次学习都有新的收获,在里面能找到很多数据库,web开发共通的思想。操作系统导论也是一本很好的书,虽然我还没看完内存部分,但是通过结合实践,理解的更深刻了。

学习操作系统就像爬山,每一步都需要扎实,每一步都很困难,但每一步都离顶峰更近,踏一步就有一步的收获。

总结

rust编程语言

第一阶段由于原先有C/C++的编程基础,基础语法上手还是比较快的,但在rust语言在内存管理方面的特性还是需要适应一段时间。但学习rust的经历中,也是能学到很多,能看到很多其他编程语言的影子,但也有rust自身的特性,如内存管理方面。多方面多语言融会贯通,收获颇丰。

rcore

由于在操作系统方面是0基础,在进入第二阶段的学习之前,我先去学习了Mit的xv6课程,有了这方面的基础,后续的学习会简单很多。

在实验上第一次尝试用rust写内核,体验挺奇妙的,在内存管理上给人与C语言完全不同的体验。

  1. ch3 熟悉系统调用
  2. ch4 内存映射出问题,调试比较麻烦
  3. ch5 熟悉进程调度算法
  4. ch6 挺麻烦的要自己实现一些接口
  5. ch8 死锁检测,耗费时间比较多,走了一些弯路,好在最后调试发现问题所在

arceos

作业难度比上一阶段简单一些,但模块化的设计思路很巧妙,体验比较新奇。

在实验上

  1. [print_with_color] 仿照原有代码的color包装即可完成
  2. [alt_alloc_bump] 内存分配算法 这里的测试文件是不很全部
  3. [ramfs_rename] 动手实现一个处理函数
  4. [sys_map] 花费的时间比较多,后续调试才发现是访问权限的问题
  5. [simple_hv] 解析指令,修改上下文即可

综上

总的来说,训练营的学习收获还是挺多的,也尝试了用rust编写内核,这是我从未涉猎的领域,希望在后续的学习中能学的更多,后续希望能写出自己的内核,尝试在操作系统中加入自己的想法。

实验总结

UniKernel

实验1:print_with_color

在该实验中,我原来是在__print_impl中进行实现(因为不懂宏),后来在实验二中输出pass后测评脚本没找到support_hashmap pass,问ai是因为我的实现在行末有个换行导致没识别上,最后还是选择修改格式化输出宏。

实验2: support_hashmap

axstd中实现了collections并添加了HashMap。第一次的时候没有将原collections中的数据结构导入到新的collections中,卡了一小段时间。
最开始是使用递归形式写的,没有注意数据量,运行一半卡死了,最后修改成循环写法,修改后才顺利通过。

实验3:alt_alloc

自感难度不大。

实验4:ramfs_rename

在修改dependencies卡了半天,一直没有调用自己实际写的(对rust的依赖之类的还是不太熟悉,经常不知道该怎么导入)。最开始把问题想太简单了,后续修改才真正pass。

Monolithic Kernel

实验5:sys_mmap

最开始直接使用sys_read将内容写入用户内存空间的地址,没想到内核态没有实现处理这种缺页问题,看着Page Fault一脸懵逼,排查半天发现在read这一行,后续用ai排查才知道是这个原因,最后先写入缓冲区,再将缓冲区写入用户地址空间。

page_fault

自己写的时候开了个头就没想法了。(可能是因为答案就在边上)

Hypervisor

实验6:simple_hv

本来以为会很难,看视频的时候云里雾里的,实际写起来发现还好。

个人总结

自我感觉第三阶段的任务要比第二阶段简单一些,可能是第二阶段刚开始接触kernel,加上代码量比较大,当时一个实验要卡好久,第三阶段感觉主要是底部实现已经比较完善了,所以好写一些。

经过这两个阶段对kernel的学习,我感觉我在文件系统方面还是有一点云里雾里,可能是第二阶段学习的时候比较仓促,不太扎实。同时rust编程方面也不算非常熟悉,看懂没有太大的问题,但是实际上手写总是因为语法问题而有卡顿,同时在包,模块的引用,cargo.toml的编写修改还是不太熟悉(以前就简单学过c和cpp,不太关注这种问题)。

离训练营结束还有一个多月,希望可以解决这些问题,继续学习,拿下通过学员证书,尽量争取一下优秀学员证书。

第三阶段blog

总体上 我对arceos的认识可以概括为接口,框架,算法。

  • 接口关心下面的框架是什么,算法是什么,关心功能, 只需要调用框架提供的api。提供给用户程序
  • 框架就是为了实现这个组件而需要的各种数据结构和在这些数据结构上面实现的一些trait
  • 算法是对于框架的不同实现,通过指定不同的feature,对同一个框架采取不同的实现

通过在arceos/exercises/print_with_color/src/main.rsuse axstd::println, 并且在print_with_color中没有实现axstd.rs 或者是axstd/mod.rs于是便到arceos/print_with_color/Cargo.toml中寻找
发现这一行

1
axstd = { workspace = true, optional = true }

意思是当前目录的配置直接继承了根目录的配置, 于是便到arceos/Cargo.toml中去求证

members中,发现了以下这一行

1
"exercises/print_with_color",

说明确实继承了根目录的配置

因此直接在arceos/Cargo.toml中寻找axstd

发现了

1
axstd = { path = "ulib/axstd" }

于是便到ulib/axstd中寻找最后在arceos/ulib/axstd/src/macros.rs
中找到了

1
macro_rules! println

直接修改具体的实现即可

根据提示修改axhal能够修改ArceOS的颜色 到axruntime中找到打印符号的逻辑,使用的是ax_println那么直接修改该功能的实现,便实现了符号颜色的改变

support_hashmap

  • ulib/axstd/Cargo.toml的 `[dependencies]中加入
    1
    axhal = { workspace = true }
    实现对axhal模块中的random函数的调用
  • 哈希函数的选取方面在axstd/Cargo.toml中, 我加入了
    1
    2
    3
    [dependencies.xxhash-rust]
    version = "0.8.12"
    features = ["xxh3", "const_xxh3"]
    来导入该哈希函数
    哈希表的数据结构包括存储键值对和发生冲突时的步长。对内部存储键值对我直接用了数组来实现,存储的是Optino<(key, value)>键值对,全部初始化为None,表明下标处没有存储元素
    对于每一个插入操作,先通过将输入通过core::slice::from_raw_parts将输入的键hash得到一个下标,如果该下标对应的键值对是None
    那么就将键值对放入这个下标处,否则就将hash得到值不断加上步长,直到找到None为止。

我并没有实现删除和查找的功能,只是通过了测试用例,实现的并不完善。

alt_alloc

对一段连续的内存左侧用以字节分配,右侧用于页面分配即可实现
通过这个实验的学习,了解到了初始阶段的内存分配器可以通过直接操作物理地址来支持早期的库和函数

sys_map

通过find_free_area找到用户空闲的虚拟地址空间,建立地址映射,将通过sys_read得到的文件内容写入物理地址中,最后返回虚拟地址的开头。

simple_hv

执行

1
riscv64-linux-gnu-objdump -d ./target/riscv64gc-unknown-none-elf/release/skernel2
1
2
ffffffc080200000:       f14025f3                csrr    a1,mhartid
ffffffc080200004: 04003503 ld a0,64(zero) # 40 <_percpu_load_end+0x40>

通过这两行可得出指令csrr a1,mhartid长度为4

1
2
ffffffc080200004:       04003503                ld      a0,64(zero) # 40 <_percpu_load_end+0x40>
ffffffc080200008: 48a1 li a7,8

这两行可知指令ld a0,64(zero) 长度为4
因此需要在
vmexit_handler中对Exception::IllegalInstructionException::LoadGuestPageFault的处理中,将sepc的值加上4。
然后通过观察Exception::VirtualSupervisorEnvCall的处理分别设置寄存器a1和寄存器a0的数值即可。

通过对hypervisor 的初步学习 加深了程序就是状态机的理解,程序的状态有寄存器和内存,对最简单的Guest OS的初始化无非就是对寄存器和内存的初始化

ramfs_rename

折腾了半天发现rename一直不会是当前工作区的实现
于是就在arceos/exercises/ramfs_rename/Cargo.toml中将

1
axfs_ramfs = { version = "0.1", optional = true }

修改为

1
axfs_ramfs = { workspace = true, optional = true }

然后发现还是不行

发现由于rename是被axfs模块中的rename调用的,就将axfsCargo.toml也修改了

1
axfs_ramfs = { workspace = true, optional = true }

然后就可以了

实现上来说就是需要通过split_path分离出路径,必须在相同的路径下才能修改名字

项目基础阶段通过总结概述

春季营因为忙于实验室项目没有完成训练营全部内容,秋季一定要弥补遗憾,通过训练营,善始善终。

Rust 编程阶段

这个阶段时间比较短,自己只是匆匆过了遍语法基础就去刷rustlings,之后有时间还要常复习。
没接触系统内核类项目之前,对rust语言没有太深感觉,只知道它比c++多些安全检查以及借用等特性,但是当真正在实战中用到这些特性才发现其厉害之处,尤其是编译阶段检测,这个会让开发者避开很多写代码时难以发现的错误(内存泄漏),以及锁的诸多包装,所有权控制等等。

OS 设计与实现

  1. ch3就是熟悉一下相关接口,会调用,加个系统syscall计数就行
  2. ch4 难点在于检查是否有未映射的页以及页对齐问题
  3. ch5 Systrace跨页遍历,需要特殊处理。另外就是记得引用模块要注意是否public,否则需要自己写一个让外部访问的接口函数。
  4. ch6 文件系统处理,最难的一个,需要自己补充很多文件系统的调用,比如inode相关方法,文件链接建立。
  5. ch8 死锁检测,实在太繁琐了这个接口层层调用。

组件化操作系统

我在写我们实验室项目 AlloyStack 时曾研究过arceos和ruxos,对模块化操作系统还是比较熟悉的。不过自己从裸机实现hello world到unikernel组件层层编译实现再到支持虚拟化(unikernel+guestkernel)。过一遍这个流程还是很有收获的,形成了更加清晰的组件化操作系统开发思路(可惜实验都是打打补丁,实现部分接口,主要还是看ppt一步步实现)。

不过 Hypervisor实现在同一进程下还要在unikernel和guestkernel反复地址空间切换,感觉很影响性能,如果做到能统一编址(实现一个统一编址器)感觉会有很大性能提升。

总结

自我感觉操作系统内核之所以难写,是因为庞杂的系统调用以及层层嵌套的接口,要想在里面加些什么功能先要把调用逻辑链搞清楚,明白已知哪些信息,要用这些信息和手动实现算法组合出一个新模块,再像插插座一样把模块嵌入到这个内核当中。真的需要极大的耐心,幸运的是在agent时代,利用ai分析调用很方便,明确需求再让ai辅助去实现,轻松了不少,真的佩服那些真正一行行手搓内核的前辈,他们是计算机系统伟大的奠基人。

基础阶段-Rust编程

难点: 从自由的C转向Rust的学习是一个比较艰难的过程,尤其是所有权系统。对我来说,习惯了C语言写代码后,所有权是Rust的第一个难点,不能再用C这么自由的内存管理了,需要仔细处理所有权问题,也是我学Rust最艰难的一段时期——和编译器斗智斗勇。

但是Rust的所有权优点显而易见:

  • Rust会自动管理内存,防止一些C普遍遗留的内存泄漏
  • 在系统编程中使用Rust可以完全避免悬垂指针等问题
  • 性能好,不逊于C

Rust拥有好的工程管理工具Cargo,这对于系统开发有着减少大量工作量的作用,也利于实现之后的组件化操作系统Unikernel。

在完成Rustling的练习之后,我的Rust编程能力得到很大的提升,为专业阶段的OS设计与实现打下了坚实的基础。

专业阶段-OS设计与实现

到了最喜欢的阶段了,OS的每个模块都深深地吸引着我。我花了10天的时间学完并且完成了rCore-Tutorial-Guide-2025S、rCore-Tutorial-Book-v3的大多数实验。我将rCore-Tutorial-Book-v3当作理论知识教学书,rCore-Tutorial-Guide-2025S当作实验手册。

令我印象最深的: 地址空间。依靠MMU给应用创建了一个虚拟的地址空间,应用本身以为拥有全世界,其实尽在操作系统的掌控之内!

这个阶段也是我运用理论知识进行实践的一个开始。在完成大部分实验之后,我不满足在现有的框架内翻找API,并且希望更深一步地理解rCore。然后我打算学着rCore的思想自己写一个操作系统内核。那晚我开启了GitHub仓库,并且给我这个OS kernel命名为BlueStarOS! 在此贴上链接https://github.com/Dirinkbottle/BlueStarOS

我改装了rCore的Makefile快速搭建起了依赖和基础框架,基于RustSBI开始着手内核开发。在自己动手完成内核时,我才知道自己实现需要解决大量的问题。

碰到的最大难点: 调试困难,内核的小bug较难排查。

遇到的问题:

  • 在我建立内核地址空间时,我错误地把PageTable的页号×页大小直接当作页表指针(也可能是当时脑袋发昏),导致之后切换页表后无法翻译内核地址空间的数据
  • Rust中的编译优化导致的玄学问题,例如特意的占位符会被优化掉
  • 内存屏障问题…

在此期间,我学会了如何快速使用QEMU配合GDB进行调试,GDB帮了大忙。之后我一直开发这个内核,到了目前基本完成了:

  • 内存管理
  • 系统调用
  • 陷阱处理
  • 大部分任务调度
  • 内存文件系统

这些实现都是最小化的,利于我理解和学习。

项目基础阶段-组件化操作系统

得益于Rust高效便捷的包管理系统,组件化操作系统可以方便地实现。在这一阶段我主要学到了内核开发中组件化的思想。

组件化的优势:

  • 不用手动造轮子实现每个组件,例如内存管理模块,这部分内核之间基本通用
  • Unikernel的组件化赋予了它极其灵活的特性,既可以针对单个应用进行定制,让整个操作系统内核全部针对这个应用来设计
  • 不仅提高了应用的运行性能,而且还提升了应用的安全性
  • Unikernel可以最小化组建应用的执行环境和运行环境然后快速上线
  • 去除了冗余功能和不必要的模块,正如一些文章所说,”我都没有shell你拿啥反弹”
  • Unikernel也可以扩展为宏内核,拥有功能完备的操作系统所有该有的模块

这一阶段的实验难点: 项目文件比较大,系统结构较rCore有很大不同。我花了几天时间合理利用AI来帮助理解Unikernel的构建思想和分层以及一些小语法。在实验过程中我也对Unikernel的设计思想理解更深一步!

遇到的问题: 今天遇到了一个棘手的小问题,在simple-hv实验中完成实验后本地运行测试会卡住。这期间我修改了很多次代码依然如此。经过问题排查得知是QEMU的事件型驱动出的岔子,在用管道传递运行结果时大部分时间会卡住。调整了一下脚本就解决了问题。


马上将要进入真正的项目阶段,我满怀期待,希望在项目阶段能学到更多的操作系统的知识!

arceOS 学习总结

前言

arceOS 是一个用 Rust 编写的模块化操作系统,与 rcore 相比,arceOS 采用了更加模块化的设计思路。

这次学习 arceOS 的练习,我花了不少时间。虽然之前做过 rcore 的实验,但 arceOS 的模块化设计和实现方式还是让我遇到了不少挑战。特别是在虚拟化部分,两级地址转换机制让我困惑了很久,好在最后通过看源码和调试还有网站上的博客,终于理解了。

这篇博客主要记录我在完成这些练习过程中的学习体会、遇到的问题以及解决思路。内核的学习实际上最重要的部分可能更是在于解决问题,也就是在针对特定问题的排查思路上。

练习概览

本次学习完成了以下练习:

  1. print_with_color - 带颜色的控制台输出
  2. alt_alloc - 备用分配器测试
  3. ramfs_rename - 文件系统重命名功能
  4. support_hashmap - 内存管理测试
  5. sys_map - 系统调用映射实现
  6. simple_hv - 简单虚拟化器实现

其中 simple_hv 是最复杂的,花了我最多时间。其他练习相对简单,但每个练习都让我对 arceOS 的设计有了更深入的理解。


练习一:print_with_color

学习过程

这个练习看似简单,但一开始我也遇到了一些困惑。在无标准库(no_std)的环境下,如何实现带颜色的输出?我之前在 Linux 上用过 ANSI 转义序列,但在 Rust 的字符串字面量中如何表示 ESC 字符?

实现

最终实现的代码很简单:

1
2
3
4
5
6
7
8
9
10
11
#![cfg_attr(feature = "axstd", no_std)]
#![cfg_attr(feature = "axstd", no_main)]

#[cfg(feature = "axstd")]
use axstd::println;

#[cfg_attr(feature = "axstd", no_mangle)]
fn main() {
// ANSI color code: \u{1B}[32m for green
println!("\u{1B}[32m[WithColor]: Hello, Arceos!\u{1B}[m");
}

关键点:

  • 使用no_std特性,不依赖标准库
  • 使用axstd::println!宏而不是标准库的println!
  • ANSI 转义序列使用 Unicode 转义\u{1B}表示 ESC 字符

通过查看 arceOS 源码,我发现 arceOS 的打印系统(axlog模块)已经内置了对 ANSI 转义序列的支持,但with_color!宏是模块内部的私有宏,无法在外部直接使用。所以直接使用 ANSI 转义序列是合理的选择。

遇到的问题和解决方案

问题 1:Unicode 转义序列的写法

问题:最初尝试使用\x1B但编译失败。

原因:Rust 中字符串字面量需要使用 Unicode 转义序列\u{1B}而不是十六进制转义\x1B

解决:使用\u{1B}来表示 ESC 字符。

问题 2:颜色代码的理解

问题:不清楚不同颜色对应的 ANSI 代码。

学习

  • 30-37:标准颜色(黑、红、绿、黄、蓝、品红、青、白)
  • 90-97:亮色(高亮版本)
  • 32:绿色(本练习使用)

问题 3:终端是否支持颜色

问题:在某些终端中,ANSI 转义序列可能被过滤或不被支持。

解决:测试脚本会检查输出中是否实际包含 ANSI 转义序列,确保不仅输出了文本,还输出了颜色控制码。

学习感悟

这个练习虽然简单,但让我理解了 arceOS 的模块化设计。即使是简单的功能,也需要考虑底层实现。另外,在 Rust 中表示特殊字符需要使用 Unicode 转义,这也是一个小的知识点。


练习二:alt_alloc

学习过程

这个练习主要是测试备用分配器(bump allocator)的功能。虽然代码很简单,但让我理解了 arceOS 的模块化设计——可以通过 feature 标志在编译时选择不同的分配器实现。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#![cfg_attr(feature = "axstd", no_std)]
#![cfg_attr(feature = "axstd", no_main)]

#[macro_use]
#[cfg(feature = "axstd")]
extern crate axstd as std;
extern crate alloc;

use alloc::vec::Vec;

#[cfg_attr(feature = "axstd", no_mangle)]
fn main() {
println!("Running bump tests...");

const N: usize = 3_000_000;
let mut v = Vec::with_capacity(N);
for i in 0..N {
v.push(i);
}
v.sort();
for i in 0..N - 1 {
assert!(v[i] <= v[i + 1]);
}

println!("Bump tests run OK!");
}

遇到的问题和解决方案

问题:在分配 300 万个元素时,程序偶尔会崩溃。

原因:bump allocator 是线性分配器,在内存不足时会失败。

解决:通过查看 alt_axalloc 的源码,理解了 bump allocator 的工作原理。它从内存池的一端开始分配,只能向前推进,不能释放单个分配。这让我理解了为什么需要不同的分配器策略。

学习感悟

这个练习虽然简单,但让我看到了 arceOS 模块化设计的优势。通过 feature 标志,可以在编译时选择不同的实现,这种设计非常灵活。


练习三:ramfs_rename

学习过程

这个练习主要是实现文件系统重命名功能。一开始我以为会很简单,但实际做起来发现需要理解 arceOS 的文件系统抽象。特别是 ramfs.rs 的实现,让我理解了文件系统接口的设计。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
fn create_file(fname: &str, text: &str) -> io::Result<()> {
println!("Create '{}' and write [{}] ...", fname, text);
let mut file = File::create(fname)?;
file.write_all(text.as_bytes())
}

// Only support rename, NOT move.
fn rename_file(src: &str, dst: &str) -> io::Result<()> {
println!("Rename '{}' to '{}' ...", src, dst);
fs::rename(src, dst)
}

fn print_file(fname: &str) -> io::Result<()> {
let mut buf = [0; 1024];
let mut file = File::open(fname)?;
loop {
let n = file.read(&mut buf)?;
if n > 0 {
print!("Read '{}' content: [", fname);
io::stdout().write_all(&buf[..n])?;
println!("] ok!");
} else {
return Ok(());
}
}
}

fn process() -> io::Result<()> {
create_file("/tmp/f1", "hello")?;
// Just rename, NOT move.
// So this must happen in the same directory.
rename_file("/tmp/f1", "/tmp/f2")?;
print_file("/tmp/f2")
}

遇到的问题和解决方案

问题:最初不理解为什么需要 ramfs.rs 这个文件,以及它和主代码的关系。

原因:arceOS 使用 trait 来抽象文件系统,需要实现 MyFileSystemIf trait。

解决:通过查看 ramfs.rs 的代码,理解了文件系统接口的设计。它使用 crate_interface::impl_interface 来实现接口,这是 arceOS 模块化设计的一部分。

学习感悟

通过这个练习,我理解了 arceOS 文件系统的抽象设计。虽然实现简单,但背后的设计理念很清晰。在 no_std 环境下实现类似标准库的 API,这种设计很优雅。


练习四:support_hashmap

学习过程

这个练习主要是测试 arceOS 对集合类型(BTreeMap)的支持。虽然代码很简单,但让我理解了 arceOS 如何在 no_std 环境下提供标准库的替代实现。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn test_hashmap() {
const N: u32 = 50_000;
let mut m = BTreeMap::new();
for value in 0..N {
let key = format!("key_{value}");
m.insert(key, value);
}
for (k, v) in m.iter() {
if let Some(k) = k.strip_prefix("key_") {
assert_eq!(k.parse::<u32>().unwrap(), *v);
}
}
println!("test_hashmap() OK!");
}

遇到的问题和解决方案

问题:在插入大量数据时,程序运行时间较长。

原因:BTreeMap 使用 B 树实现,插入操作需要维护树结构。

解决:通过测试,理解了 BTreeMap 的性能特征。虽然插入较慢,但查找和遍历操作比较高效。

学习感悟

这个练习让我理解了 arceOS 如何提供标准库的替代实现。在 no_std 环境下,需要自己实现这些数据结构,但这种设计让系统更加可控。


练习五:sys_map

学习过程

这个练习比较复杂,涉及系统调用的实现。一开始我对用户程序加载和地址空间管理不太理解,特别是 sys_mmap 的实现,花了我不少时间。

实现

这个练习主要包括:

  1. 用户程序加载:从文件系统加载 ELF 文件到用户地址空间
  2. 系统调用处理:实现 mmapopenreadwriteexit 等系统调用
  3. 内存映射:实现 sys_mmap,支持匿名映射和文件映射

关键实现 - sys_mmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#[allow(unused_variables)]
fn sys_mmap(
addr: *mut usize,
length: usize,
prot: i32,
flags: i32,
fd: i32,
_offset: isize,
) -> isize {
syscall_body!(sys_mmap, {
// Get the user space address space
let curr = current();
let task_ext = curr.task_ext();
let mut aspace = task_ext.aspace.lock();

// Parse flags
let mmap_flags = MmapFlags::from_bits_truncate(flags);
let mmap_prot = MmapProt::from_bits_truncate(prot);

// Align address and size to page boundaries first
let aligned_length = (length + PAGE_SIZE_4K - 1) & !(PAGE_SIZE_4K - 1);

// Allocate virtual address if addr is NULL
let aligned_vaddr = if !addr.is_null() {
VirtAddr::from(addr as usize).align_down_4k()
} else {
// Find a suitable virtual address below stack
// Stack is at [aspace.end() - USER_STACK_SIZE, aspace.end())
// So we place mmap at aspace.end() - USER_STACK_SIZE - aligned_length
(aspace.end() - USER_STACK_SIZE - aligned_length).align_down_4k()
};

// Map the memory
let mapping_flags = MappingFlags::from(mmap_prot) | MappingFlags::USER;
aspace.map_alloc(aligned_vaddr, aligned_length, mapping_flags, true)?;

// If it's a file mapping, read the file and write to the mapped memory
if !mmap_flags.contains(MmapFlags::MAP_ANONYMOUS) && fd >= 0 {
// Read from file
let mut data = vec![0u8; aligned_length];
let mut read_count = 0;
while read_count < length {
let buf = &mut data[read_count..];
let n = api::sys_read(fd, buf.as_mut_ptr() as *mut c_void, buf.len());
if n <= 0 {
break;
}
read_count += n as usize;
}

// Write to mapped memory
aspace.write(aligned_vaddr, &data[..read_count])?;
}

Ok(aligned_vaddr.as_usize())
})
}

遇到的问题和解决方案

问题 1sys_mmap 的地址对齐问题

原因:内存映射需要按页对齐,但用户传入的地址和长度可能不对齐。

解决:使用 align_down_4k() 和按页对齐长度:(length + PAGE_SIZE_4K - 1) & !(PAGE_SIZE_4K - 1)

问题 2:文件映射的实现

问题:如何将文件内容映射到内存?

解决:先读取文件内容到缓冲区,然后使用 aspace.write() 写入映射的内存区域。

问题 3:虚拟地址分配

问题:当 addr 为 NULL 时,如何选择合适的虚拟地址?

解决:在栈下方分配,计算 aspace.end() - USER_STACK_SIZE - aligned_length,确保不与栈冲突。

学习感悟

这个练习让我深入理解了系统调用的实现机制。特别是地址空间管理和内存映射,让我对操作系统内核有了更深入的理解。在实现过程中,需要仔细处理地址对齐、权限等问题,这些都是系统编程中需要注意的细节。


练习六:simple_hv

学习过程

这个练习是最复杂的,花了我最多时间。一开始我对虚拟化的概念不太理解,特别是两级地址转换机制,让我困惑了很久。看了很多资料,包括 RISC-V 虚拟化扩展规范,才慢慢理解了。

后来在调试过程中,遇到了很多问题,比如 htinst 寄存器不可用、地址转换错误等。好在最后通过看源码和调试,终于理解了。

实现

这个练习涉及虚拟化技术的多个核心方面:

  1. 客户机镜像加载:从文件系统加载 ELF 或二进制格式的客户机镜像
  2. 地址转换:实现两级地址转换(GVA → GPA → HPA)
  3. 虚拟 CPU 管理:保存和恢复虚拟 CPU 的寄存器状态
  4. 陷阱处理:处理客户机产生的陷阱,包括非法指令、页面错误、环境调用等

关键点:

  • 使用身份映射(GPA = HPA)简化实现
  • 禁用 VSATP,让客户机直接使用 GPA
  • 通过指令模拟实现特权指令的功能

遇到的问题和解决方案

问题 1:地址转换的复杂性

问题:最初不理解两级地址转换的工作原理,特别是 VSATP 和 HGATP 的关系。

原因:RISC-V H 扩展的地址转换机制比较复杂,需要理解:

  • VSATP 将 GVA 转换为 GPA
  • HGATP 将 GPA 转换为 HPA
  • 两者需要配合工作

解决

  • 通过阅读 RISC-V 虚拟化扩展规范理解地址转换流程
  • 使用身份映射简化实现
  • 在代码中添加详细注释说明地址转换过程

问题 2:htinst 寄存器不可用

问题:当 VSATP 禁用时,htinst 寄存器可能为 0,无法获取导致非法指令异常的指令。

原因htinst 寄存器在某些情况下(如 VSATP 禁用)可能不包含有效信息。

解决

  • 检查 htinst 是否为 0
  • 如果为 0,从 sepc 指定的物理地址读取指令
  • 使用内核的直接映射将 GPA 转换为 KVA 后读取

问题 3:身份映射的必要性

问题:不理解为什么需要为 GPA 创建身份映射。

原因:HGATP 使用 GPA 作为虚拟地址来访问页表,因此需要将 GPA 映射到 HPA(在身份映射中,GPA = HPA)。

解决

  • 理解 HGATP 的地址转换机制
  • 为页表根和入口地址创建身份映射
  • 确保 HGATP 可以正确访问页表页面

学习感悟

通过这个练习,我深入理解了虚拟化技术的核心原理。特别是两级地址转换机制,让我对 RISC-V H 扩展有了更深入的理解。

在调试过程中,我学会了如何排查虚拟化相关的问题,包括地址转换错误、寄存器状态错误等。这些经验对我理解操作系统内核非常有帮助。

虽然这个练习很复杂,但完成后的成就感也很强。虚拟化是操作系统中最复杂的技术之一,通过这个练习,我对操作系统有了更深入的理解。


总体学习收获

通过完成这些练习,我学到了很多:

  1. 模块化设计:arceOS 的模块化设计让我印象深刻。通过 feature 标志,可以在编译时选择不同的实现,这种设计非常灵活。

  2. no_std 环境编程:理解了在无标准库环境下如何实现常用功能。虽然代码量增加了,但这种设计让系统更加可控。

  3. 底层系统编程:深入理解了操作系统核心机制,包括内存管理、进程管理、虚拟化等。这些知识对我理解操作系统内核非常有帮助。

  4. 问题解决能力:在调试过程中,我学会了如何排查系统级问题。特别是通过看源码和调试,理解了很多之前不理解的概念。

  5. Rust 系统编程:通过实践加深了对 Rust 在系统编程中的应用理解。Rust 的所有权系统和类型安全,让系统编程更加安全。

后续思考

arceOS 的模块化设计给我留下了深刻印象。即使是简单的功能,也需要考虑底层实现。这种设计使得每个组件都可以独立测试和替换,为系统开发和调试带来了极大的便利。

通过这次学习,我不仅掌握了操作系统的基本原理,还深入理解了 Rust 在系统编程中的优势。内核的学习实际上最重要的部分可能更是在于解决问题,也就是在针对特定问题的排查思路上。

很感谢这次训练营的主办方,给了这么好的学习机会。虽然学习过程中遇到了很多困难,但完成后的成就感也很强。未来如果有机会,我还会继续深入学习操作系统内核。

先说说为什么会参加这个训练营。

起因是在配置arch linux的过程中用到了很多rust写的工具,而且这些工具用起来非常顺手,引起了我对rust的兴趣。在看完《Rust 程序设计语言 中文版》后,我开始寻找一个rust相关的项目练手。正好在知乎上看到有人推荐rcore,加上之前写过xv6(s081),对操作系统有不小的兴趣,就这样开启了这段rocre之旅。

第一阶段

这一阶段主要是熟悉rust语言。

由于我有java、python的语言基础,这一阶段还是比较轻松的。
难点是在写链表时因为有所有权限制,要对node作多层包装。刚开始写时并不顺利,其他语言的基础成了障碍,总是会按python的写法往下写,导致所有权出问题。直到真正理解所有权的含义才写出正确的代码。

所有权是我第一次真切感受到rust和其他语言的不同,它把一些原来从未考虑的可能出现的数据竞争提前暴露出来,迫使编写者去思考数据关系,这种体验是从未有过的。

第二阶段

受益于xv6,第二阶段的实验基本没有卡点。这些实验让我深入理解了riscv的trap、页表等机制,重新梳理了操作系统的进程切换、文件系统等相关知识。

第三阶段

这一阶段的实验比第二阶段的实验要难一些,花费的时间也更多。

对于hashmap这个实验,我自己实现了一个固定buckets的hashmap,但看到blog中大家基本是引网上的包,感觉自己有些笨笨的,还是要多和群友交流。

后面在mmap这个实验卡了很久,一直在想怎么实现文件的lazy load和对应,最后是面向测例编程,直接文件内容读到内存中通过测例完事。

学习总结 - Blankke

rcore与arceOS可以理解成两种不同思路编写而来的内核,而内核基本原理上估计是不会有太大区别的。
抱着这样的想法,三阶段只给了3周的时间,我就直接上了。
因为内核赛的时候基本上是整个内核都写了一遍,除了文件系统是调ext4库了没有太管里面怎么实现的,其他的原理明白了做起来还是挺快的。
我不知道有什么荣誉准则要求,这话说起来其实挺不好的,但是我的准则是不管copy还是llm生成,只要我觉得下一次遇到一样的问题我能一眼看出来用这个方法解决,那我就觉得这个学习是有效的。
所以这次的arceOS学习我也是抱着这样的态度去做的,并且内核的学习实际上最重要的部分可能更是在于解决问题,也就是在针对特定问题的排查思路上。

rcore

rcore部分我本身没有记录很多,只记了一些学习rust的时候的笔记,毕竟rcore的代码量实在是太大了,想要全部理解需要花费大量时间。然后在完成练习的时候我还不知道要写blog,所以只有一点点感悟和笔记。

ch3

为什么在 TaskManager 中添加方法而不是直接返回 TaskControlBlock

这确实是 Rust 所有权系统的限制,与 C++ 有本质区别:
Rust 所有权问题:

1
2
3
4
5
6
// 这样的设计在 Rust 中是不可能的:
pub fn get_current_task_mut(&self) -> &mut TaskControlBlock {
let mut inner = self.inner.exclusive_access();
let current = inner.current_task;
&mut inner.tasks[current] // ❌ 编译错误!
}
  • inner 是一个临时变量,当函数返回时会被销毁
  • 返回的 &mut TaskControlBlock 引用了inner的内容
  • Rust 编译器检测到”悬垂引用”(dangling reference)问题

C++ vs Rust

1
2
3
4
5
// C++ 可以这样做(自己管理)
TaskControlBlock& TaskManager::getCurrentTask() {
auto lock = inner.lock();
return tasks[current_task]; // 返回引用,但锁可能已释放
}
1
2
3
4
5
6
7
8
9
// Rust 强制我们使用更安全的封装方法
impl TaskManager {
pub fn increment_current_syscall_count(&self, syscall_id: usize) {
let mut inner = self.inner.exclusive_access(); // 获取锁
let current = inner.current_task;
inner.tasks[current].increment_syscall_count(syscall_id);
// 锁在这里自动释放
}
}

Clone trait 和 new 方法的关系

  • Copy:浅拷贝,按位复制,用于简单类型(如整数)
  • Clone:深拷贝,可能涉及堆内存分配,用于复杂类型
    但添加 Vec<(usize, usize)> 后:
    1
    2
    3
    4
    5
    6
    pub struct TaskControlBlock {
    pub syscall_counts: Vec<(usize, usize)>, // Vec 不能实现 Copy
    }
    let vec1 = vec![1, 2, 3]; // 在堆上分配内存
    let vec2 = vec1; // 如果是 Copy,会有两个指针指向同一块内存
    // 当 vec1 和 vec2 都被销毁时,会导致 double free!
    原来的初始化方式不再适用:
    1
    2
    3
    4
    5
    // 旧代码 - 数组字面量初始化
    let tasks = [TaskControlBlock {
    task_cx: TaskContext::zero_init(),
    task_status: TaskStatus::UnInit,
    }; MAX_APP_NUM]; // ❌ 需要 Copy trait
    新的初始化方式:
    1
    2
    3
    4
    // 新代码 - 使用 core::array::from_fn
    let tasks: [TaskControlBlock; MAX_APP_NUM] = core::array::from_fn(|_| {
    TaskControlBlock::new(TaskContext::zero_init(), TaskStatus::UnInit)
    });

ch4

主要新建的函数有

1
2
3
4
5
6
7
8
9
/// Translate a user pointer to a mutable reference
pub fn translate_user_ptr<T>(ptr: *mut T) -> Option<&'static mut T> {
TASK_MANAGER.translate_user_ptr(ptr)
}

/// Translate a user pointer to a reference
pub fn translate_user_ptr_readonly<T>(ptr: *const T) -> Option<&'static T> {
TASK_MANAGER.translate_user_ptr_readonly(ptr)
}

内部使用页表进行翻译,获得的(可变)引用可以用unsafe的类指针操作直接修改内存。
mmap的实现与cpp的方法无异,只是对应的层级是task层,由taskmanager调用获取当前的task,当前的task使用mmap,所以mmap是task的类方法。

ArceOS

Unikernel

T1 print-with-color

这个看了一下,可以在log层打印,也可以直接改std。

1
2
3
4
5
6
arch = riscv64
platform = riscv64-qemu-virt
target = riscv64gc-unknown-none-elf
smp = 1
build_mode = release
log_level = warn

像这种信息就是在log层里打出来的,如果修改axlog模块的lib.rs,那么这些打印信息就会变色

1
2
3
4
5
6
7
8
9
10
11
12
13
/// axlog/lib.rs
/// Prints to the console, with a newline.
#[macro_export]
macro_rules! ax_println {
() => { $crate::ax_print!("\n") };
($($arg:tt)*) => {
$crate::__print_impl($crate::with_color!(
$crate::ColorCode::BrightGreen,
"{}\n",
format_args!($($arg)*)
));
}
}

但是根据题目要求,我们打印的那句话其实是axstd里面的,所以我其实只在这个macro.rs里添加了色号就可以了。

1
2
3
4
5
6
7
8
/// Prints to the standard output, with a newline.
#[macro_export]
macro_rules! println {
() => { $crate::print!("\n") };
($($arg:tt)*) => {
$crate::io::__print_impl(format_args!("\u{1B}[92m{}\u{1B}[m\n", format_args!($($arg)*)));
}
}

这是绿色

T2 support-hashmap

在axstd等组件中,支持collections::HashMap
先读了一下axstd,原本的情况是这样的

1
pub use alloc::{boxed, collections, format, string, vec};

这里有一个collections,是从alloc模块过来的,那么实际上是标准库里的(我认为就是内核环境不支持标准库的哈希表),所以要替换成一个自己实现的HashMap。
上网查了一下hashbrown是一个常用的哈希表实现(hashbrown n. 薯饼),所以添加了依赖,用这现成的模块。

1
2
axhal = { workspace = true }
hashbrown = { version = "0.14", default-features = false }

接下来就是在axstd/src里面添加一个collection.rs然后将对应使用过的函数都用hashbrown进行对应实现就可以了。注意new()一定需要有对应的实现否则报错找不到。

T3 bump-allocator

这个很简单。
当时内核赛的时候瞎装了一万个分配器到自己的内核中,经过痛苦的阅读代码后了解过buddy、slab、liballocator的分配原理,这个bump分配器简单看一下原理似乎是堆分配器。然后需要实现页分配以及细粒度的字节分配,也就是多层级的分配。那就跟linux的slab&buddy的做法差不多了。
代码中todo写的很明确,每一步需要干什么,不会漏掉隐秘的细节,不像当初写内核一样自己出一堆找不到的bug在后面回来找。

T4 rename-for-ramfs

ramfs就是一个最基础的文件系统,不需要回写,不需要驱动,基本上意思就是在内存里进行书写,关机后不会存下来,这个rename也不会再下次开机后保存下来。
学习正常的rename,以前从没看过底层的inode操作,都是直接调用ext_rename()就完工了,所以这次对照着加抄袭整了个版本。
明确这个操作是在ramfs模块下的就简单了,这是个结点操作,所以要在两个地方添加操作(这是我的做法),一个是impl VfsNodeOps for DirNode,一个是DirNode内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// Renames a node from `src_name` to `dst_name` in this directory.
pub fn rename_node(&self, src_name: &str, dst_name: &str) -> VfsResult {
let mut children = self.children.write();

let node = children.get(src_name).ok_or(VfsError::NotFound)?.clone();

if children.contains_key(dst_name) {
return Err(VfsError::AlreadyExists);
}

// Remove from old name and insert with new name
children.remove(src_name);
children.insert(dst_name.into(), node);

Ok(())
}

fn rename(&self, src_path: &str, dst_path: &str) -> VfsResult {中可以照着别的函数写法形成模板,最后一步调用上面的rename就好了

Macro

宏内核部分比较熟悉也简单,就略写了。

T1 Page-fault

这也是老朋友了,这个提示很明显,在axhal/trap里面,很多异常的处理方法都写在其中了。

T2 mmap

这更是老朋友,xv6就做过这个实验,rcore也是有。根据posix标准从堆内存找到空闲位置,扩大堆空间。
这里评测环境错误很久没过差点以为是我的问题,所以自己添加了一个get_brk()函数,结果又在本地爆了,这个实验似乎就是让我们使用find_free_area就可以了,并没有按照posix标准去处理那么多flag,也没有匿名映射。
后面所以我又改成了最简单的版本

1
2
3
4
5
6
7
8
9
10
11
12
13
let start_addr = if addr.is_null() {
// Use find_free_area to find a suitable address
let hint = VirtAddr::from(0x10000000usize);
let limit = VirtAddrRange::from_start_size(aspace.base(), aspace.size());
aspace.find_free_area(hint, aligned_length, limit)
.ok_or(LinuxError::ENOMEM)?
} else {
VirtAddr::from(addr as usize)
};

// Map memory in user address space
aspace.map_alloc(start_addr, aligned_length, mapping_flags, true)
.map_err(|_| LinuxError::ENOMEM)?;

这属于有点ltp后遗症,写了linux的标准错误号。然后后面其实也处理了fd是-1且不是MAP_ANONYMOUS的情况。

Hypervisor

这个虚拟化有点超纲了,以前确实没见过这种虚拟机的做法。我想理解成用户进程,这样的话自己有一个cpu对象,用户进程的地址空间也是连续的,但是在内核中不连续。

T1 simple-hv

这个有两个退出原因(据悉是这样):
IllegalInstruction (非法指令异常)和 LoadGuestPageFault (Guest 页面错误),需要在vm_exit的时候判断这几次错误并处理。实际上操作的方法有点像写cpu,直接对epc等寄存器进行+4这样。这个错误原因估计还要下到trap模块才能判断,就是csr寄存器里会记录错误原因。
为了调试这几种原因,我先添了几句调试输出,没想到其实本来它的打印就是有输出的。
可以读到代码中期望的输出就是这两个寄存器要放正确的值,而这之前就不要有vmexit

1
2
3
4
5
6
7
8
let a0 = ctx.guest_regs.gprs.reg(A0);
let a1 = ctx.guest_regs.gprs.reg(A1);
ax_println!("a0 = {:#x}, a1 = {:#x}", a0, a1);
assert_eq!(a0, 0x6688);
assert_eq!(a1, 0x1234);
ax_println!("Shutdown vm normally!");
ctx.guest_regs.sepc += 4;
return true;

所以就在对应的错误处理处改0x6688和0x1234就可以,对应指令sepc+4跳过

T2 pflash

从上面为止练习题其实就做完了。说来非常惭愧,我参加训练营有点面向做题的学习,从rcore开始都是学习的目标就是做完所有练习题就收工了,觉得解决问题才是做这个训练营的精华。这个练习也是个示例,不用我做自己就是好的。所以后面学起来有点没有动力。
分析了一下运行指令,其实是先编译了u_3_0的内核,然后把内核的bin文件写进了disk.img里,最后make一个虚拟机出来。运行之后里面也是显示了两次ArceOS(虚拟机里运行了内核),然后从guest里面试图去读host
很好看的一点是在make u_3_0的时候直接编译,而运行虚拟机h_2_0的时候可以把log开成info,这样的话就可以看到那句 Starting virtualization…,以及是如何装载虚拟机到虚拟地址的。
内核赛的时候很惊讶,因为听说第一名的Starry Mix可以在里面运行xv6,让我直接震惊了。后面了解到StarryOS就是在arceOS基础上改的,现在我才知道原来就是基于了这样的Hypervisor模式,真的长知识了。