0%

2025OS训练营三阶段记录

4.7-4.8

这两天看了石磊老师的unikernel讲解视频,完成了彩色print和hashmap的任务。

unikernel我是第一次听说,用组件化的方式来实现操作系统,可以对应用定制轻量化的运行环境,相当于操作系统与应用一体,比较适合嵌入式和轻量虚拟化场景。arceos就不像rcore那样是面向教学的了,而是更偏向于实际应用,我查看了toml,操作系统的组件几乎都是依赖导入,刚开始有些搞不清楚这些包的具体用途。

彩色print的实现很简单,在格式化前加入蓝色字符标注即可实现。

hashmap的实现我一开始考虑的是nostd环境是否还需要我来实现对内存分配器,但是后来仔细看过arceos里有实现默认的对内存分配。我的实现内存分配依赖的vec,用vec作为hashmap的底层数据结构,实现起来相对容易,对于通过测例来说还是足够的。

4.9-4.12

这几天在琢磨lab1的挑战题,不愧是挑战题,花了目前最久的时间。题目规范很好,只需要实现规定的部分就可以。内容是要实现一个字节内存分配器来让这个测试达到最大迭代次数。这个测试用例是重点,他是循环分配32+i到2的14次方+i的内存,这个i就是迭代次数,每次迭代会把每一轮偶数次数的分配空间给释放掉,所以每次都有一部分内存不会被释放,即这个挑战有理论最大值。用例的分配用的是vec,vec根据rust的实现每次分配在空间不够时都是要扩容当前一倍的空间的。挑战的分数是i的次数要大于170,因为170是算法tlsf的结果。我对tlsf、buddy算法的实现都手动实现测试了一遍,逐步理清了我的实现思路。

每次分配的偶数情况都会被释放,那直接对偶数情况的内存分配给固定的内存池,比如32 index为0,那分配32+Max的内存池,这个max就是最大i值,中修改max来得到i最大值。对于奇数的情况我使用了最简单的线性分配方法,应为当时想的是每次技术分配的都不会释放,所以不会有间隙,其实不是。这个探索的过程我发现分配的大小没有按照我的想法分配到对应的位置,于是我就打印了每次分配的内存的大小,我发现有几个固定值96,192,386这些数字不符合每次请求的内存大小,猜测应该是对其的要求分配的。所以对这个情况进行处理,直接计数进行跳过。但是效果到64又出现问题,分析发现32涨到96,与固定值96的分配打乱了奇偶计数的顺序,所以对全局分配96的大小进行计数,同样跳过一个固定位次的96分配。同理128在涨到192时也会触发,32在涨到192又会触发。对这些情况都做处理,得到了189的分数,折腾了三天,也总算有个结果,即便我知道这不是最佳答案。

其实问题在于我认为奇数的分配是没有空隙的,其实vec的分配策略,扩容的空间都是浪费了,如果要提升,就要在奇数合并块时,通过某种策略找出未使用的扩容区域,就可以真做到最大限度利用空间,达到无空隙。

这个题目刚开始觉得是个算法题,但其实也让我对内存分配的理解更加的深入。

4.13

今天实现的是bump内存分配策略,同时兼顾页分配和字节分配,通过上次挑战题的考验,这道题相当简单了。而且题目要求也很低,我的实现就是对分配器维护一个左右标签记录已经分配的区域,左为字节分配器的使用位置,右为页面分配器的使用位置。字节分配采用线性分配,每次分配查看左标签内还有没有空余,没有就移动左标签扩容。释放只处理与左标签邻近的地址,将左标签剪去分配的内存大小。右标签则是每次分配页面大小,维护是扩容则减,释放则加。

其实对与这道题的测试用例很松,维护好左右分配标签就可以过。

4.14

今天实现了rename,其实有个最简单的方法就是拷贝删除用来重新创建该文件。

正规做法需要先改下依赖的路径为本地,再将rename实现,arceos的文件系统依赖于虚拟文件系统,根据它的结构找到mata文件即可修改文件名。

4.15

mmap操作需要先理解静态分配,其实很简单,我们平时用到的动态分配就是请求了就立即分配内存空间给用户,静态则是在请求时返回成功,再用到时访问内存会触发pagefault,这个时候再分配内存。mmap需要做到文件映射,本来文件读入内存需要先经过内核空间再到用户空间,mmap在静态分配地址后,手动映射到用户空间,再将文件直接读取到这个物理地址,实现了无拷贝操作。

实现时检查参数有效性,转换传进来的flags格式,获取当前任务的地址空间即可转换给定的虚拟地址为物理地址,使用mapalloc对内存大小完成映射,注意flag要加上USER,因为是要映射到用户空间。通过fd找到文件节点后直接读入到得到的物理内存处,完成映射。

4.16

虚拟化这个题,看过了视频后,有了老师给的提示还是非常容易的。虚拟机在执行到某个位置触发了系统异常,那肯定要在traphandler里找,返汇编后,看到有非该特权级的指令执行出发了异常指令,那就将pc指针步进继续执行,然后代替处理复制操作,后面的错误访问内存也一样,代替执行赋值后设置pc继续执行内核。

总结

三阶段做的这些题远远不够理解arceos,所以在做完后我确定在虚拟化上下功夫,仔细研究这一块的代码。

2025OS训练营二阶段记录

3.18-3.21

这段时间是在开营前,我在用rcorebook学习,这几天完成了ch1-ch3的学习。

ch1

环境配置:我用的是ubuntu20.04,按照教程来配置还是相当容易的,没有碰到什么问题。

个人基础:我是在寒假接触自制操作系统的,看的一本书叫做《操作系统真象还原》,这本书用c语言和汇编实现了一个x86的32的操作系统。书的内容很多,对于基础盲区也讲解很全,作者从MBR到Bios、bootloader再到用显存来实现print,操作系统的必要组件。所以我学起来还没那么吃力。

riscv我没有接触过,有一些x86的汇编先不系统学习还是可以看得懂的,毕竟rcore没有涉及太多汇编内容。

rust的编译工具安装、编译方便在开始都让我非常惊喜。内核的第一条指令涉及到一个linker.ld,这个文件就是链接脚本,通过分析内容可以很容易理解程序的内存分布,首先是固定的内存入口,其次是 .data .bss .rodata等段的排布很清晰。

简单学习了下gdb调试和远程连接,反编译反汇编工具。

第一次接触rust的外部链接extern “C”,使用声明函数的方式来找到链接脚本确定的内存区域起始符号。

rustsbi这个东西真的很方便,当然也是riscv架构的标准。像打印字符这种函数都实现在里面,要不然需要用汇编来自己实现。对sbi进行封装后很容易就能实现write函数和print宏。

ch2

这一章是写一个批处理系统,系统在运行前就明确了所有要运行的app,这些app都按顺序执行直到最后一道,然后系统panic结束。应用的内存地址都是在运行前要手动计算确定的。riscv架构从大到小分为0,1,2,3四个特权级,还提供了ecall,eret用来切换特权级。这个批处理系统分成了用户态和内核态,ecall就是用来从用户态进入内核态,eret用来返回用户态。ecall属于riscv规定的一种异常,而操作系统属于在riscv的s模式特权级,在用户态ecall就会触发陷入机制进入s模式特权级,用sret来返回u特权级。进入s模式后会调用二进制接口,也称为系统调用。

系统调用的意义在于很多操作交给用户来做非常不安全,系统稳定性难以保证,所以需要划分出内核态用户态,内核根据需求做出安全的系统调用操作。

程序的入口准确来说是_start就是linker里的start符号位置,这里我们不能使用原生架构编译工具,而是使用交叉工具链,所以rust std内容都用不了,main函数也不能完成作为程序入口的作用。在rust声明外部链接符号就要在link操作的宏。

创建系统调用的触发接口,使用内敛汇编调用ecall传入系统调用号和参数,在操作系统就会捕捉异常,判断类型为ecall后根据对应系统调用号即可处理系统调用,完成系统调用后返回用户态。

这里的测试没有实现具体的系统调用,而是用了linux相同的系统调用好在linux环境进行测试,测试正常。

rcore写了build.rs用来构建linker.ld链接脚本,app需要使用路径方式进行导入,每个app的入口地址和执行顺序挂钩。使用的测试用例是在linux系统调用环境下编译的,编译的结果是elf的,需要使用工具来将elf转换成bin二进制文件。在设计上rcore创建了UnSafeCell用来在单核上可以安全使用全局可变借用。

load_app根据固定的标号入口地址来从内存里找到app的代码段,加载到APP_BASE_ADDR,这个地址是个固定值。

1
2
3
4
5
6
批处理操作系统为了建立好应用程序的执行环境,需要在执行应用程序之前进行一些初始化工作,并监控应用程序的执行,具体体现在:
当启动应用程序的时候,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序;
当应用程序发起系统调用(即发出 Trap)之后,需要到批处理操作系统中进行处理;
当应用程序执行出错的时候,需要到批处理操作系统中杀死该应用并加载运行下一个应用;
当应用程序执行结束的时候,需要到批处理操作系统中加载运行下一个应用(实际上也是通过系统调用 sys_exit 来实现的)。
这些处理都涉及到特权级切换,因此需要应用程序、操作系统和硬件一起协同,完成特权级切换机制。

系统调用和异常都涉及到了切换特权级,特权级的切换最重要的就是保存上下文。因为不管是trap还是异常都是停止执行当前的程序,转去执行系统调用或者异常处理,这时候就需要保存当前应用的寄存器环境,这个就是上下文,将这些寄存器按固定顺序保存到用户栈,在ret时再逆顺序恢复寄存器。

1
2
3
4
sstatus 的 SPP 字段会被修改为 CPU 当前的特权级(U/S)。
sepc 会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。
scause/stval 分别会被修改成这次 Trap 的原因以及相关的附加信息。
CPU 会跳转到 stvec 所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。

这些是riscv用到的硬件辅助寄存器。

rcore使用汇编实现了alltrap和restore,用来保存和恢复寄存器,需要注意sp寄存器,sp寄存器的值用到sscratch,这个寄存器用来交换内核栈sp值,还要注意处理当前执行的位置。

traphandler中根据scause的结果,对不同的异常进行处理,包括ecall,runnext等操作。

ch3

这一章要创建一个多道程序处理系统,需要实现分时调用就必须要将应用都加载到内存,这也意味着也要为当前每个应用都分配独立栈空间。所以不能像上节一样都加载到base地址,需要确保应用占用的空间不会重叠。切换任务必然也要保存上下文,与系统调用不同的是,任务切换的操作保存寄存器恢复寄存器都是在用户态。rcore根据sbi实现了gettime,设定计时器后,计时器到时触发异常,traphandler捕捉到后执行任务切换操作。

任务切换最重要的是switch操作,在执行之前已经将下个任务的栈顶地址保存起来,保存当前寄存器环境后,导入下个任务的寄存器环境。yeild操作为计时器中断触发切换任务的系统调用。

3.24-3.25

关于trace的系统调用实现,需要判断request来实现不同功能. 在request为0时:需要将id转换成const u8 再解引用,再转换为isize返回;在request为1时:要将id转换成mut u8,再将data转换成u8,复制给id转换后指针的解引用,返回0;

当request为2时: 想要获取每个任务的某个系统调用次数,并且考虑到批处理系统会进行任务切换,所以在切换到其他任务时也要保存着其他刮起的人物的调用次数,所以我就在syscall/mod.rs中实现了个全局变量SYSCALL_COUNT,类型为Mutex<BTreeMap<usize, BTreeMap<usize, usize>>>,Mutex包裹是因为他是全局变量(rust的要求? 按理说批处理单进程用不到锁,但是我编译没过就加上了.),外层BTree key为app_id,value为内层BTree,内层BTree为(sys_id, count).这样就可以通过app_id来得到所有系统调用的次数.syscall_count方法在syscall里用来计数,insert_syscall_count为当前任务初始化记录,已有记录则返回,delete_syscall_count在进程退出时删除记录,get_syscall_count就用来在sys_trace里获取当前任务的系统调用次数.

3.25-3.26

在sys_get_time和sys_trace实现最主要的是将虚拟地址转换成物理地址,因为内核不能访问应用的虚拟地址.sys_get_time首先获取应用虚拟页表,将ts转换成虚拟地址,记录偏移,通过页表转换后可以得到对应的物理页表项,将表项加上偏移就得到了对应ts的物理地址,然后转换成mut指针,将获取的timeval复制给该地址.判断跨页是要在ts地址上加上timeval长度得到截至地址,与start%Pagesize后判断是否相等,不相等则要处理跨页,还是计算出物理地址分别在对应部分拷贝该部分的数据.

sys_trace相比上次只需要处理0,1的情况,要想办法通过虚拟地址读取或写入,同样转换为物理地址就可以将其转换成指针.在获取物理页表项时要注意判断页表项的有效性 可读性 可写性.

mmap就是要为应用程序申请一块动态区域,这个就要在memset上处理,本来我是准备将内存加入areas的,但是在写unmap时判断vpn来删除有些困难,就考虑重新为mmap的内存重新建立映射结构.处理时就是要几个点注意,1:判断port是否全0或者无效位有非0, 2:判断start是否页表对齐,没有对齐就返回, 3:PTEFlags不仅要加上port的标志,还需要有效位V和用户位U, 4:计算虚拟页表起始地址,然后对每个页循环遍历, 5: 判断虚拟页表有效性,有效则返回(mapped), 6: 申请物理页建立映射.

unmap要判断虚拟页表无效性,然后循环遍历每个vpn在虚拟页表中被unmap,并移除mmap_set对应项.

3.26-3.28

关于spawn,相比fork就是不要复制父进程的内存布局,将传进来的字符串指针转换成string,通过这个名字获取应用elf_data,后面就对TCB进行初始化,和TCB::new一样,注意三点,父进程设置为当前进程;将该进程加入父进程的child列表;将该进程加入运行队列.

关于stride,我定义了两个常量BASE_STRIDE=91,BIG_STRIDE=99991.在TCBinner里加入两个量:pass=0,prio=1,封装updatepass:

pass += (BASE_STRIDE / prio) % BIG_STRIDE

留出相关接口.再到run_task调用updatepass, 在fatch里对ready_queue取出最小pass的TCB.

3.28-3.30

关于link,与linux的ln命令类似,对文件创建链接相当于在操作系统层面增加个INode,但是磁盘里共用统一片块,那相当于拥有相同的inode编号,我们先通过原文件名称获取编号,已知所有文件都在根目录。找到编号后就可以得到块位置id和offset,在创建inode时需要。接下来修改目录项,需要先扩展目录项的存储区域,后写入目录项。返回一个新的inode。

关于unlink,通过name找到根节点后计算目录项数目,循环读取根节点每个文件对比名称,找到index后,把末尾的目录项换到index处,减小目录大小。

stat就连两个项需要获取,ino就是blockid,nlink需要通过块位置来对目录里所有文件的块位置进行比较,相同计数加1.在返回时我和gettime的处理一样,将虚拟地址转换成物理地址后再转换成可变指针,将stat结构体指针放进去。

3.30-4.3

这节还挺费劲的。刚开始看了很久没发现是银行家算法,不知道如何来抽象这些资源。

首先就是清楚是要以每个进程为单位来进行资源管理,所以在pcb inner里添加了死锁检测模块。模块数据一维为线程tid,二维为资源rid,模块需要实现几个功能,isunsafe就是银行家算法用来查看当前状态是否安全,tryallocate在检查unsafe同时通过tid和rid处理减小available和need,增加allocation;setneed需要在设置资源前调用,在分配失败时会保留,在分配成功会减小。release在up时增加available和减小allocation;检测开关只需要设置enabled。在创建线程和创建资源时需要更新数据结构,在添加一个线程时会为二维结构添加资源数的全为0的向量,在添加mutex时avail则push 1,sem则push 初始化值。在down时首先判断是否打开了检测,打开了就首先setneed,然后判断unsafe如果不安全则直接返回dead,安全则尝试分配(会失败),然后调用down;up时判断检测打开之后就调用release,release判断了当前分配数是否为0。

Lab1

3.28

完成 Rust 语言练习

Lab2

4.7

risc-v知识欠缺,花费大量时间浏览 risc-v 手册。

完成 Lab1

学习 第四章 Rust 中的动态内存分配,地址空间, SV39 多级页面管理机制(上)。

成果:

初步了解risc-v多级分页机制。

4.10

完成 Lab2, 选做了 Lazy 分配策略。

大部分时间花在了 Lazy 策略初始化不分配内存上,通过查看多个函数的调试输出,完善了mmap申请的虚拟页表和物理页表映射结构上的设计。

4.11

开始 Lab3:spawn, 迁移 Lab2 中的工作。

spawn 功能非常简单,记得最后要添加到 manager 中。

4.15 总结

rcore 的文档很详细,通过读文档已经能对 rcore 很清晰的了解了,之后做实验也不存在太大的困难。选做题只做了 mmap lazy 策略。

Lab3

4.17

做完第二个小测验,发现没有安装 riscv-linux-musl-gcc, ArchLinux 中这个包在 AUR 中可以找到,riscv64-gnu-toolchain-musl-bin^AUR^, 也可以在 github 上找到相应的二进制文件 riscv-gnu-toolchain/releases

4.23

完成 Lab3, 选做题花费的时间多了些,主要是卡在了最后一个点上,费了一番功夫才完成了。现在回想起来感觉是因为是在晚上3,4点脑子不清醒,今天下午就顺利很多。
题目难度本身不大,第三阶段需要对项目的结构有一定的了解,不然会不知道要找的模块在哪里。

rustling

大部分练习没什么印象,都是很基础的练习。

唯一印象深刻的是对#[cfg(feature)]的考察,cargo在编译时会根据feature来选择编译字段、函数、源代码,私认为这是个很有用的特性。在编译大型项目时,就可以塞入很多功能,让用户能够选择feature来控制项目的编译。

rcore

之前做过mit 6.1810 操作系统实验课,是基于riscv的用c编写的操作系统。这次写rcore主要是体验两者在实验流程的设计差异,以及crust在编写内核上的特性与差异。

我个人觉得rcore内容上更丰厚更复杂,但是实验设计上有点太简单了,或者说为学生实现了太多。我觉得可以丰富实验内容,让学生体会到内核的调用流程,以及rust语言实现内核的优点。

  1. syscall实验中,可以增加几个系统调用函数,并让学生完成从用户态增加函数,到内核态具体实现

  2. syscall实验中,内核接收syscall_id,通过match分发到对应的函数。我个人觉得这里应该提供两个数组引导学生去填写新增的系统调用函数:

    1. index -> syscall_id: SYSCALL_MAP = [SYS_GETTIME, SYS_READ, SYS_WRITE, .. ]
    2. index -> syscall_func: SYSCALL_FUNC = [sys_gettime, sys_read, sys_write, ..]

    这样,很自然的就能想到trace系统调用应该怎么实现。我觉得syscall_id设置成64,93,124,...应该有rcore设计上的考量,教学项目是不是可以设置从0开始的连续自然数呢?

  3. virtual memory中,实验设计书上没有仔细讲从虚拟地址的39位怎么映射到物理地址的,而代码更是直接帮学生实现好了地址转换、地址映射、地址查找。我个人觉得这里可以划分几个实验让学生实现

  4. virtual memory中,增加一个中断实验,内核处理page_fault,进而进一步考察copy on write页表缺页的实验

  5. 接上一条,增加trap的处理实验

  6. 增加考察汇编代码的简单编写,比如在代码中插入汇编代码、在.S中编写汇编代码。并让学生体会为什么这么做:手动控制寄存器。在这个过程中,自然的就了解了编写的函数本质就是汇编代码中的符号,再通过汇编链接到一起

  7. 测试可以更丰富,有些测试过于简单了

  8. 用户端的shell代码应该捕获ctrl z, ctrl d, ctrl c之类的字符或者添加exit, quit来让用户退出

在这次实验中,我深刻体会到了rusttrait抽象的强大之处,在virtual memory实验中,VirtAddrPhyAddrstruct对相关trait的实现可以很方便让用户操作地址还不会混淆。

arceos

非常的复杂,内容也非常的多。我一直认为大型项目的价值在于项目的架构以及各个api的语义设计。

通过查看调用链了解到了是user -> axstd -> api -> modules/xxx。我觉得这个项目最有意思的地方在于组件化操作系统,通过feature来选择编译Unikernel、宏内核、虚拟机。如果组件化内核编译出的各种类型内核性能与原生内核差距不大的话,感觉会是很方便的内核开发方式。
郑友捷老师讲的组件化内核让我收益很多,我准备后续学习一下cargo的功能来了解这个项目是怎么组织不同功能的组件的。
我自己一直有个疑惑,编写内核的时候要不要用alloc::collections中的数据结构,以及为了内核稳定性是不是应该只用官方库和自己编写的库而少用第三方库。

这个项目也让我逐渐意识到一个事实:编译器提供c语言库,其他高级语言通过调用c语言库(汇编)来与硬件/操作系统交互。

阶段一

在原版的rustlings基础上加入了一些针对训练营需要的unsafe相关的知识和构建过程中build.rs的应用。属于比较基础的内容,跟着评测机一道道做过去就可以了。

阶段二

这个阶段主要是通过基于rust编写操作系统内核rcore完成几个实验,借助实验理解一个具有进程/线程管理、内存管理、文件系统、进程间通信和提供了一定同步机制的内核是如何构成并运作起来的。

比较印象深刻的有这么几个知识点:

  • 链接脚本与全局符号的使用
  • Rust的汇编嵌入
  • 第四章中,在使用分离内核空间的时候。通过设计跳板页来解决切换页表后指令执行的问题。跳板页
  • 第六章了解了文件系统,了解了块设备的概念,对文件系统的各个抽象层有了一定的了解。
  • 第七、八章了解了操作系统是如何为应用提供同步原语的

跳板

由于rcore使用了分离内核空间的设计,所以在Trap的时候需要切换页表。但在切换页表之后,pc寄存器还是忠实的在其原来的位置自加到下一条指令,如果内核内存空间程序内存空间对这段代码的映射不是在同一个位置的话,则会表现出来程序跳转到了别的地方执行的效果。因此需要设计一个跳板页,在虚存中将其映射到所有内存空间的最高页,确保在切换之后,也能正确运行下一条指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# trap.S
...
.section .text.trampoline
.globl __alltraps
.globl __restore
.align 2
__alltraps:
csrrw sp, sscratch, sp
...

# linker.ld
...
stext = .;
.text : {
*(.text.entry)
. = ALIGN(4K);
strampoline = .;
*(.text.trampoline);
. = ALIGN(4K);
*(.text .text.*)
}
...

在上面的汇编可以看到,我们给trap.S分配到了.text.trampoline段,并在链接脚本中定义了一个strampline符号来标记他的位置,这样我们可以在Rust中找到这个跳板页,映射到我们期望的位置。

但将跳板也映射到别的地方带来了新的问题,原来__alltraps中最后跳转到trap_handler使用的是call trap_handler。我们可以通过obj-dump看看编译得到的指令。

1
2
3
4
5
6
7
8
9
10
# obj-dump -Dx ...

...
80201056: 73 90 02 18 csrw satp, t0
8020105a: 73 00 00 12 sfence.vma
8020105e: 97 80 00 00 auipc ra, 0x8
80201062: e7 80 e0 0b jalr 0xbe(ra) <trap_handler> # pc+0x80be
...
000000008020911c g F .text 00000000000003b2 trap_handler
...

可以看到,这里用的是pc相对寻址,也就是基于当前指令的偏移找到trap_handler所在的位置。但是现在__alltraps已经在虚拟内存中被我们映射到最高页去了,也就是说我们实际运行代码的时候是在下面这一段内存中。

1
2
3
4
5
6
7
8
9
# gdb
>>> x /20i $pc-10
0xfffffffffffff054: ld sp,280(sp)
0xfffffffffffff056: csrw satp,t0
0xfffffffffffff05a: sfence.vma
=> 0xfffffffffffff05e: jr t1

>>> p /x $t1
$9 = 0x8020911c

很明显如果这里跳转到$pc+offset$的话,并不是跳到位于正常代码段的trap_handler。所以我们要将这里换成寄存器跳转,将trap_handler的地址放到寄存器t1中,这样才能顺利地调用到trap_handler

也就是指导书中所说的

跳转指令实际被执行时的虚拟地址和在编译器/汇编器/链接器进行后端代码生成和链接形成最终机器码时设置此指令的地址是不同的。

阶段三

这个阶段正式接触到组件化操作系统arceos

在调用路径上任意一个地方加入颜色代码就可以了,本身并不复杂,主要是了解arceos的结构。

support_hashmap

考虑实现一个hashmap比较麻烦,直接引入hashbrown,将里面HashMap包到collections里面也可以通过。

但是hashbrown默认依赖的hashfold库在no_std下所提供的RandomState是基于内存布局的,而非每次都随机
可能会带来一些安全性问题

alt_alloc

实验要求实现一个bump alloctor,是一个比较简单的分配器,在给定的接口下实现就可以了

ramfs_rename

要求在给定的文件系统中实现rename的功能。看了测例中的注释仅要求在同级下重命名,不涉及移动。

搞清楚了VfsOpsVfsNodeOps两个trait之后,在路径上把目录项的名字改掉就好了。

sys_map

要求实现系统调用mmap

利用task_extaspace提供的接口就可以完成。通过find_free_area找到空闲的区域并通过map_alloc分配,然后将给定fd的数据读进来就可以通过了。需要注意一些接口有检查传入参数是否有对齐。

simple_hv

按照提示将a0``a1寄存器设置好,并将pc寄存器偏移以跳过当前指令即可。

总结

阶段三的任务总体来说比阶段二的时候来得要更简单,感觉主要还是了解arceos的架构以及UnikernelMonolithic KernelHypervisor的不同。并体会在不同的内核需求中,arceos是如何将不同的组件组合起来以达成需求的。

一阶段

用rust其实也有快5年了所以一阶段不是什么特别难的事,因为23年已经做过一次,这次增加了一些数据结构的实现,其实不是特别难,整体数据结构实现对于之前刷过leetcode的人都会比较熟,所以轻松就过了。

二阶段

其实23年最早刷过一遍所以这个比较简单,沿用23年的一些总结,这次lab1和23年的lab1有一些不同,不过核心是不变的

  1. lab1

    其实是一个很简单的lab,系统调用的次数统计
    问答题是很好的问题,也帮助我回忆和加深了risc-v的寄存器的作用,包括trap的流程,这个很重要,直接以代码展现出来,没学rcore的时候平时听到系统调用,其实是很抽象的,并不知道系统调用是怎么从用户态切换到内核态的,而rcore非常精彩的给我解答了这个问题,并且以代码展现,不再抽象。只能说感谢开源!

  2. lab2

    mmap 和 munmap 匿名映射,对我来说其实也不难,不过反而是问答题让我再次加深了SV39的结构,页表,页表项等等这些其实理解很抽象,包括用户态是怎么用到MMU的,MMU和操作系统存储的页表这些是怎么结合的,在这一张再结合linux的一些代码就理解了。其实是riscv 使用 SATP 寄存器来保存 MMU 映射表的根地址

  3. lab3

    spawn和stride 调度算法,这个其实也不算特别复杂,在给予fork和exec代码中只需要理解 spawn和他们的区别,就很容易写出来,而stride调度算法用一个小顶堆实现即可。因为之前看了linux的task_struct的实现,所以比较轻松就能理解。

  4. lab4

    这个要求实现linkatunlinkat 这个加深了我对硬连接的理解,并且文件系统的这章让我对linux的vfs也更加理解。整体来说明显会感受到磁盘读取和内存有异曲同工之妙。

  5. lab5

    死锁检测,这个就非常考察细心了,主要就是资源的分配、分出、释放,需要格外注意,否则都无法通过,顺带这里也有一个坑,就是检测用了sleep,sleep用的是get_time,所以要实现这个api不然程序就会卡在那

三阶段

这次是新增的一个阶段,主要是为了让大家先熟悉arceos,在熟悉rcore以后其实再看arceos是比较轻松的,组件化操作系统的思想是一个很好的思想,同时也比较考验抽象能力如果做到高性能高抽象的同时又可以随意的扩展操作系统的个个组件,使用一个create引入开箱即用,个人认为是一个未来需要的方向。随着整体社会需求的发展大家对底层性能的要求越来越苛刻定制化需求也越来越多,对于操作系统也是百花齐放,同时在写一个新的操作系统的时候也总是需要重复造轮子,这个工作量其实也不算小,所以个人认为组件化操作系统在当前是一个很好的想法,高质量的组件化操作系统可以帮助个人和初创企业降低开发操作系统的难度还可以获得定制操作系统的优势来满足一些特殊的场景需要。

  1. print_color
    这个其实很简单,因为不想修改println的宏所以这里新增了一个print_color的宏来实现对颜色的打印,并再用log来包装print_color宏实现不同等级打印不同颜色的日志

    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
    #[macro_export]
    macro_rules! print_color {
    ($color:expr, $($arg:tt)*) => {{
    use axstd::io::Write;

    let mut out = $crate::io::stdout().lock();
    let _ = write!(out, "\x1B[{}m", $color);
    let _ = write!(out, $($arg)*);
    let _ = write!(out, "\x1B[0m");
    }};
    }
    pub enum LogLevel {
    Error,
    Warn,
    Info,
    Debug,
    }

    #[macro_export]
    macro_rules! log {
    (error, $($arg:tt)*) => {
    $crate::print_color!("31", concat!("[error] ", $($arg)*, "\n"));
    };
    (warn, $($arg:tt)*) => {
    $crate::print_color!("33", concat!("[warn] ", $($arg)*, "\n"));
    };
    (info, $($arg:tt)*) => {
    $crate::print_color!("32", concat!("[info] ", $($arg)*, "\n"));
    };
    (debug, $($arg:tt)*) => {
    $crate::print_color!("34", concat!("[debug] ", $($arg)*, "\n"));
    };
    }
  1. hashmap
    这个其实可以参考rust的std的rust实现,然后改一下就可以了,当然也可以从0自己实现一个最后别忘了这样才能使用std::map

    1
    2
    3
    4
    5
    6
    7
    mod map;

    #[cfg(feature = "alloc")]
    pub mod collections {
    pub use crate::map::HashMap;
    pub use alloc::collections::*;
    }
  1. bump_alloc
    这个主要需要了解什么是bump算法,在实现ByteAllocator和PageAllocator的时候需要注意b_pos和p_pos的验证,还有要注意对齐,还要理解一下align_pow2

    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
    fn alloc(&mut self, layout: Layout) -> AllocResult<NonNull<u8>> {
    let align = layout.align();
    let size = layout.size();
    let aligned = (self.b_pos + align - 1) & !(align - 1);
    let new_b_pos = aligned + size;

    if new_b_pos > self.p_pos {
    return Err(AllocError::NoMemory);
    }

    self.b_pos = new_b_pos;
    self.byte_alloc_count += 1;
    self.byte_alloc_total += size;
    Ok(NonNull::new(aligned as *mut u8).unwrap())
    }

    fn alloc(&mut self, layout: Layout) -> AllocResult<NonNull<u8>> {
    let align = layout.align();
    let size = layout.size();
    let aligned = (self.b_pos + align - 1) & !(align - 1);
    let new_b_pos = aligned + size;

    if new_b_pos > self.p_pos {
    return Err(AllocError::NoMemory);
    }

    self.b_pos = new_b_pos;
    self.byte_alloc_count += 1;
    self.byte_alloc_total += size;
    Ok(NonNull::new(aligned as *mut u8).unwrap())
    }
  1. rename
    这个需要修改一下axfs_ramfs组件大致思路是 获取当前节点(即当前目录)-> 查找要重命名的原始节点 old_node->拆解 new 路径,获得新文件名新父目录路径->获取根节点,并从中查找新父目录->从原目录中移除旧路径->将 old_node 插入新父目录,使用新文件名

  2. mmap file
    这个修改较多,在Backend新增了一个FileBacked

    1
    2
    3
    4
    5
    6
    /// File-backed mapping backend (lazy load).
    FileBacked {
    reader: ::alloc::sync::Arc<dyn crate::MmapReadFn>,
    file_offset: usize,
    area_start: VirtAddr,
    },

    在page_fault的时候进行实际内存申请

    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
    Self::FileBacked {
    reader,
    file_offset,
    area_start,
    } => {
    let va = vaddr.align_down(PAGE_SIZE_4K);
    let offset = file_offset + (va.as_usize() - area_start.as_usize());

    let vaddr = match global_allocator().alloc_pages(1, PAGE_SIZE_4K) {
    Ok(vaddr) => vaddr,
    Err(_) => return false,
    };

    let paddr = virt_to_phys(VirtAddr::from(vaddr));
    let buf = unsafe {
    core::slice::from_raw_parts_mut(axhal::mem::phys_to_virt(paddr).as_mut_ptr(), PAGE_SIZE_4K)
    };
    if !(reader)(offset, buf) {
    return false;
    }

    page_table
    .map_region(
    va,
    |_| paddr,
    PAGE_SIZE_4K,
    orig_flags,
    false,
    false,
    )
    .map(|tlb| tlb.ignore())
    .is_ok()
    }

    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
    fn sys_mmap(
    addr: *mut usize,
    length: usize,
    prot: i32,
    flags: i32,
    fd: i32,
    offset: isize,
    ) -> isize {
    let binding = current();

    let mut aspace = binding.task_ext().aspace.lock();

    let vaddr = match aspace.find_free_area(
    VirtAddr::from(addr as usize),
    length,
    VirtAddrRange::from_start_size(aspace.base(), aspace.size()),
    ) {
    Some(base) => base,
    None => return -1,
    };

    let prot_flags = MmapProt::from_bits_truncate(prot);
    let mut map_flags = MappingFlags::USER;

    if prot_flags.contains(MmapProt::PROT_READ) {
    map_flags |= MappingFlags::READ;
    }
    if prot_flags.contains(MmapProt::PROT_WRITE) {
    map_flags |= MappingFlags::WRITE;
    }
    if prot_flags.contains(MmapProt::PROT_EXEC) {
    map_flags |= MappingFlags::EXECUTE;
    }

    let aligned_len = (length + PAGE_SIZE_4K - 1) & !(PAGE_SIZE_4K - 1);
    let hint = if addr.is_null() {
    aspace.base()
    } else {
    VirtAddr::from(addr as usize)
    };

    let file_obj = match get_file_like(fd) {
    Ok(f) => f,
    Err(_) => return -1,
    };

    let reader = alloc::sync::Arc::new(move |_offset: usize, buf: &mut [u8]| {
    file_obj.read(buf).is_ok()
    });

    if let Err(e) = aspace.mmap_file(vaddr, aligned_len, map_flags, offset as usize, reader) {
    return -1;
    }

    vaddr.as_usize() as isize
    }
  3. simple_hv
    这个其实蛮有意思的可以体验到guest操作自己没有权限的指令时候的一个流程以及体验的到page_fault的流程,第一个需要自己在VM中模拟 CSR 访问

    1
    2
    3
    ctx.guest_regs.gprs.set_reg(A1, 0x1234);
    ctx.guest_regs.sepc += 4;
    return false;

    第二个可以直接写成成对应的值

    1
    2
    3
    ctx.guest_regs.gprs.set_reg(A0, 0x6688);
    ctx.guest_regs.sepc += 4;
    return false;

这个是对Hypervisor很好的一个体验也有了一个初步的认识,包括整个项目中的实验设计是非常好的,每一个新的功能都有一个简单的实验来上手体验,而且正是因为有了arceos也免去了很多最开始操作系统要处理的事情,直接进入体验Hypervisor代码量非常少,实验在risc-v指令集下,整个Hypervisor的体验很丝滑,代码结构很好,可以立马就对VM_ENTRY和VM_EXIT这个有点抽象的概念进行了具象

总结

新增的三阶段arceos是非常棒的,整体实验设计也很不错,在有了rcore的基础以后再看arceos是不困难的,一步一步的迈向抽象度更高的操作系统,大家在arceos里已经做了非常多的事情了,使得我们可以如此简单的启动一个os,并且体验到最小化的一个Hypervisor以及宏内核,也再次加深了对操作系统的理解,以及发现软硬协同的重要性。

ArceOS Record

ArceOS 的设计可以说优雅而不失健壮性,利用rust优秀的包管理机制和crates的特性组件化地搭建OS,将复杂的OS设计解耦,各个模块功能清晰、层次鲜明,

tutorial出于教学的目的,在modules引入了dependence crates;而在主线arceos中,将解耦做到了极致,形成了清晰的Unikernel层次:dependence crates -> kernel modules -> api -> ulib -> app,下为上提供功能,上到下形成层次鲜明的抽象,这种抽象又为异构内核的实现提供支持,以宏内核为例,其既可以使用api提供的功能,又可以复用kernel modules支持更多的功能,这种自由的复用和组织可以为定制化操作系统提供极大的便利和支持,方便基于需求实现特定OS

Read more »

训练营学习记录

这篇文章用来记录我在2025春夏季开源操作系统训练营的学习过程,之所以会参加本次训练营,是因为我想进一步学习操作系统以及学习操作系统以及通过完成rcore包括通过完成组件化操作系统进行进一步磨练自己。

训练营二阶段关于rcore实验完成记录

在第二阶段的学习过程中,我收获颇丰,深入理解了 Rust 语言和操作系统的核心概念。在学习 Rust 语言时,我全面掌握了其独特的所有权、借用和生命周期规则,这些特性为 Rust 提供了强大的内存安全保障。而在操作系统方面,我不再停留在浅显的层面,而是深入探讨了内核架构,从系统启动到各个模块的交互过程有了清晰的认知。特别是在进程管理方面,我了解了进程的创建、销毁及状态转换的原理,并深入分析了不同调度算法对 CPU 资源分配的影响。

在内存管理方面,我深入研究了物理内存分配与虚拟内存映射的机制,了解了页表机制在其中扮演的关键角色,惊叹于内存管理的复杂性与精巧性。在我的个人项目中,我将 Rust 和操作系统的知识结合,参与了从设计、实现到调试的全过程,解决了许多技术难题,这一过程让我不断成长和提升。

这一阶段的学习为我打开了全新的视野,未来我将继续深入探索,将所学的知识更好地应用于实践。

训练营三阶段关于arceos实验以及挑战实验

print_with_color
通过使用 ASCII 字符,实现了简单的控制台颜色输出。

support_hashmap
为了快速实现功能,引入了一个现成的库来处理哈希映射。

alt_alloc
由于测试用例较为简单,实现难度较低。严格按照要求实现后,我对是否完全正确也并没有特别的把握。

shell
在原有的 Shell 实现中,已经有了 rename 功能。为了简化,我直接调用了现有库来处理 rename,同时利用文件创建、复制文件内容和删除原文件的方式,模拟了 mv 命令的功能。

sys_map
通过使用 find_free_area 来找到合适的内存区域并进行数据读取,尽管 find_free_area 找到的内存地址并不完全符合 man mmap 的描述,但依旧能够实现所需的功能。

page_fault
难度适中,相比于原先的实现,这部分内容更多是基于 rcore 的基础进行了延伸与补充。

simple_hv
通过修改 guest 的 sepc 寄存器值,并设置 a0、a1 的值,成功实现了一个基础的 Hypervisor 操作。

通过第三阶段的学习,我理解了组件化操作系统内核的设计理念。相较于2阶段的rcore,这种组件化内核更像是可以随意拼接的积木,可以极大程度的根据自己的需求适配或灵活的扩展内核。开始时,我学习Unikernel这种内核结构,并阅读了如axhal、axruntime、axalloc等关键部分的代码,初步掌握acreos的运行逻辑和代码架构;尝试将arceos扩展为宏内核,也让我进一步体验到组件化内核的奇妙;同时根据PPT初步了解了虚拟化的原理和技术。Arceos的模块化内核很好的结合rust模块的特性,也让我思考模块化内核和微内核是否能够结合起来呢,这或许也是一个扩展方向吧。我第四阶段打算做rust异步运行时,希望能做成一个比较完备的项目!

一、前言

在过去两周,我学习了Unikernel, Monolithic Kernel, Hypervisor三种内核架构。经过学习,我对组件化操作系统有了初步的认识和掌握。以下是我对这两周学习过程的总结。

二、学习内容

  1. Unikernel

学习了Unikernel的基础与框架,包括如何从汇编代码进入到rust代码再进入到内核,并通过axhal -> axruntime -> arceos_api -> axstd 实现控制台的打印输出。

接下来引入了动态内存分配组件,以支持Rust Collections类型。通过引入axalloc模块,实现对内存的管理,并学习了动态内存分配的相关算法。通过这部分的学习,让我理解了rCore中为什么到后面的章节就可以使用Vec等集合类型。

之后引入任务数据结构并构建了通用调度框架,实现了抢占式调度。并实现了文件系统的初始化和文件操作。

实践作业:

实现带颜色的打印输出,理清控制台的打印输出的调用链即可, 可以在不同层次的组件上修改。

手写HashMap,我使用拉链法实现哈希表,并通过引入axhal提供的随机数增强鲁棒性。

实现bump分配算法,根据代码框架,实现EarlyAllocator的初始化和分配函数。

实现rename,首先是需要追踪是如何使用axfs_ramfa的,通过调试,可以发现底层实现是在DirNode,并且源数据结构其实就是btreemap,具体操作并不复杂。

2.Monolithic Kernel

在unikernel的基础上,引入用户态、系统调用等即可完成到宏内核的跨越,这一部分的学习让我更深刻的理解了组件化的优势,扩展task属性实现宏内核的进程管理以及分离调度属性和资源属性的策略更是让我眼前一亮。

实践作业:

实现sys_mmap系统调用,先使用fd读取源文件的内容,分配所需的内存空间,再查找用户态的页表得到相应的物理地址,将源文件内容写入即可。

3.Hypervisor

引入RISC-V H扩展,使原来的S态增强为HS态,并加入了VS态和VU态,通过对特权寄存器的修改,即可跨越到Hypervisor。

主要学习了VM-EXIT,由于Guest不存在M态,所以超出当前特权态的处理能力时会经历 VU -> VS -> (H)S -> M 的过程,本部分的作业也是和 VM-EXIT相关的,通过修改 vmexit_handler 函数以完成作业的要求。