0%

各阶段总结

阶段一

rustling,对rust语言的引入,很不错的一个仓库,很久没用了,边写边复习,如果题目更多一点就更好了

阶段二

lab1

对系统调用的计数,遇到了比较神奇的bug,调用数组设置太大导致获取时间的调用出现异常,卡了挺久,本来是挺简单的,如果遇到类似bug的可以试试限制调用数组的长度

lab2

引入了虚存,工作主要集中在memory_set部分,在实现采用了RAIL机制,加上rust的所有权及drop trait自动实现页表项的释放

lab3

迁移三个函数,主要变化就是taskmanager多分出了一个processor,关于spawn,结合TaskControlBlock的fork和exec方法,从中选出直接spawn应该如何构造TaskContext和TrapContext
值得一提的是stride调度算法在筛选task的时候其实可以用优先级队列筛的,然而没时间了只能用个暴力遍历,对自己手搓的能力感到无奈

lab4

简单的文件系统,主要是理解inode的相关机制吧,首先对于创建硬链接来说,得先在diskinode加一个nlink用于引用计数,创建硬链接就先find_inode_id,然后modify_disk_inode为根增加direntry,name就是链接名,然后就创建完成,在通过目标文件的inode用modify增加引用计数nlink即可

unlinkat类似,先删direntry然后找到对应inode,引用计数-1,加一个判断如果nlink为0了就删文件回收inode和数据块

对sys_fstat来说,在Inode中增加get_inode_id、get_mode、get_nlink等函数,从fd_table中我们能拿到OSInode,OSInode中能拿到Inode,从而可以调用增加的函数获取文件信息

lab5

死锁检测,在每一次 sys_mutex_lock 以及 sys_semaphore_down时判断,会死锁则 return -0xdead;

对于 mutex
遍历一遍有没有别的 thread 正在拿这个 mutex 就行
其实就相当于 semaphore 里面的可用资源只有 1 个
主要思路还是遍历并逐个判断

阶段三

exercise1

没有太多好说的,主要也是偷懒了涉及axhal层的不想修改,直接在axstd层次做一个ANSI转义

exercise2

支持hashmap类型,看exercise文件可以知道要在axstd创建个collections文件夹模块,内部一个hashmap.rs和一个mod.rs把hashmap公布出去,hashmap的具体实现偷懒抄了下hashbrown,拿来简化了一下,再次对自己的手搓能力感到无奈,以后有时间再好好实现一次

exercise3

实现新的内存算法bump,把arceos/modules/bump_allocator/src/lib.rs中的EarlyAllocator类实现即可。

实际上还是偷懒了,对于字节分配,不去记录已分配的字节区域的开始位置及长度,仅仅记录一个分配次数count,alloc时count + 1,dealloc时count - 1,当count为0时才回收所有ByteAllocator的区域(b_pos收到start那里);

exercise4

支持ramfs的rename操作,主要注意一下把cargo里的包换成arceos下面的(不然文件系统的包永远是网上拉下来的)(搞了挺久,恼),找到对traitVfsOpsVfsNodeOps的实现之后,搞清楚记录文件的数据结构,实现rename就可以了。

exercise5

为宏内核支持sys_mmap系统调用,实现对文件的映射,参考 arceos/tour/m_2_0/src/main.rs handle_page_fault() 和 arceos/tour/m_2_0/src/loader.rs,除此之外,注意一下musl-gcc的环境变量

exercise6

实现最简单的Hypervisor,响应VM_EXIT常见情况,感觉像是面向测例的编程,csrr a1, mhartid 和 ld a0, 64(zero) 触发异常后,手动完成其功能

希望后续实习能增强动手能力,还有很多底层细节实现感觉不熟悉,调试代码的能力也不足,经常被一些很基础的问题卡住(笑)

2025 春季开源操作系统训练营前三阶段总结报告

学习背景

在进行网络编程的时候,如果要提高程序的并发性能会用到 io 多路复用和协程,很多框架和语言都会有自己的实现,在逐渐深入的过程中,就会提出一些问题:

  • io 多路复用 select、epoll、io_uring 是如何做到可以提高并发性的
  • 阻塞和非阻塞又是如何做到的
  • 到底什么是同步什么是异步
  • 协程底层是如何实现的

这些问题虽然网上有很多资料,但如果自己不亲自去写一些 case 去验证,感觉是很难理解透彻的。而这些技术的实现都是直接或者间接的依赖操作系统才可以做到,也就是说要理解这些知识点,那就必须要学操作系统的知识。

正好我也在模仿着想实现一个协程框架(有栈的),发现要用到汇编知识,发现写操作系统也需要汇编知识,于是顺手查了下比较好的操作系统课程:

  • 南京大学的操作系统
  • 清华大学操作系统 - ucore & rcore
  • MIT xv6 操作系统

在视频网站上看到有人分享参加了开源操作系统训练营,于是便在今年 1 月份就定计划要专供下操作系统的知识,来参与下开源操作系统训练营了,也算是补上十多年前没学好的专业课吧(出来混,早晚都要还的)

另外,也想通过这次的学习,寻找一些志同道合的朋友,一起进步。

学习仓库

跟着老师和同学们一起学习用的代码仓库,里面包含了每次作业的提交内容

各阶段学习内容总结

第一阶段 rustling

rustling 很久之前曾经刷过一遍了,这次是温故知新,复习了以下知识点:

  • rust 的基本语法
  • 模式匹配
  • 所有权转移
  • 容器智能指针
  • 包管理机制
  • 使用 async await

第二阶段 rcore

在学习 rCore 的过程中,首先通读了一遍 rCore-Tutorial-Guide, 发现其中大部分章节只是罗列了代码的改动,对于基础知识原理,并没有过多的介绍。
又开始阅读学习 rCore-Book,后续配合陈渝老师导学阶段的视频(2022年教学视频),才逐渐走上了学习的正轨。学到了以下知识:

构建操作系统的步骤

  • env setup - 模拟器搭建裸机运行环境
  • libos - 在内核上跑一个最简单的程序
  • batchos - 支持批量运行多个程序 - 引入特权级机制
  • taskos - 实现分时多任务执行
  • asos - 地址空间以及映射
  • pos - 进程 - 从 TCB 到 PCB 的进化
  • fos - 支持文件系统 - 从文件读取程序执行
  • ipcos - 进程间通信 - shell 的方式进行进程切换
  • tcos - 线程、协程 - 进程对应资源,线程对应调度

操作系统内核核心组件

  • 地址空间
  • 进程管理和切换
  • 文件系统

学到的具体知识点

  • 程序的内存布局结构、如何通过 linker.ld 去修改程序布局
  • 页表机制以及虚拟地址到物理地址的映射方式
  • 进程的协作式调度和抢占式调度以及不同的调度算法的历史演进
  • 文件系统 inode 的结构组织方式,逐层递进的调用方式,为深入理解一切皆 io 打下基础
  • 从进程发展的角度去理解 fork exec spawn
  • 进程间通信 pipe 和 shell
  • 从操作系统源码层面深入的理解进程、线程、绿色线程以及它们的异同
  • 理解互斥、同步的概念,锁、信号量、条件变量

帮助理解的知识点

  • 操作系统的启动流程
  • 中断和崩溃信号量机制的实现
  • 内存分配和管理机制
  • IO 多路复用技术
  • 协程以及调度实现
  • 各种锁的实现原理
  • RingBuffer
  • LRU

通过上述知识的学习,终于明白了操作系统是为上层应用开发提供硬件抽象的一种系统软件实现这句话,也为我揭开了操作系统的神秘面纱

第三阶段 arceos

arceos 这部分主要是把老师的上课视频给走了一遍,个人理解就是把 rCore 中的各部分代码用 rust 的包管理机制打散,最终做到通过拼装组件就可以形成操作系统的效果,类似应用开发中的模块化和组件化。

学到的知识点

  • unikernel 调用流程,如何从应用层通过系统调用调用内核的实现
  • 如何拼装新的模块到 unikernel 中
  • 理解 unikernel 到 宏内核的转变过程 - 解决特权级切换(用户态到内核态的切换)、地址空间的问题是关键
  • 能够运行标准 linux 二进制 - 这块还是挺感兴趣的
  • hypervisor 这块牵扯到在虚拟环境中运行 rCore 操作系统 - 个人理解相当于在操作系统中再次嵌套操作系统 - 学的有点吃力了

总结学习方法

渐进式的学习

  • what - 某个技术是什么,有个初步的认识
  • when - 什么时候用,在什么情况下或者说上下文下使用这种技术
  • how - 如何用这种技术,如何写代码
  • why - 为什么要用,为什么要使用这种技术
  • build - 内部是如何实现的,自己写一个

如何高效学习

就像面试自己一样,不断地向自己提问题是最好的学习方式,这样才能加深理解,才算是真正理解

遗憾之处

  1. 时间上的原因,并没有把自己提问的问题以及理解,全部深入的汇总出来
  2. 我们目前是学的 Risc-V 架构的操作系统,但是目前 x86 架构依然是主流,之前 uCore 的操作系统实现就是基于 x86 的,后续还要学习下 uCore 的实现。
  3. 在参加这次训练营的过程中,只是走马观花的看和了解了一些基础知识和概念,虽然也做了课后作业,但是跟自己实际上手去实现还是差着十万八千里,对于一些用到的库,基本都是拿来主义,也没有深入的去探索其内部的实现原理,如:内存分配算法,进程调度算法、rust-sbi,希望自己以后还是能脚踏实地的从头跟着 rCore-Book 手搓一个真正自己的操作系统。

各阶段总结

第一阶段

rust的基本筛查,没什么说的。rust的诸多库日后再学,目前就系统库使用比较多。

第二阶段

这里是一个简单的小操作系统

好的,这是参照您提供的原文,并进行书面化、精炼化处理后的中文版本:

Chapter 1: 应用程序与基本执行环境

本章旨在阐明操作系统作为系统软件与应用层软件在执行环境层面存在的本质差异,而非对操作系统自身的复杂理论进行深入剖析。
同时,通过具体的代码范例,演示了在操作系统此种独特的执行环境下构建可执行程序的途径。

用户态程序的运行普遍依赖于特定的语言运行时环境及其附带的标准库。所谓运行时,通常包含预先编译的代码片段,用以实现诸如数组边界检查等基础功能;
标准库则对底层系统调用进行了封装,例如,常见的打印函数其内部便是通过write系统调用将数据输出至显示设备。
操作系统亦承担了程序运行的准备与善后职责,涵盖虚拟内存空间的辟配、程序代码的加载以及退出时的资源清理等环节。
但是,操作系统是直接运行于硬件之上的,并无现成的系统调用或预置软件可供利用,一切功能从零开始编码实现。

Chapter 2: 批处理系统

本章的核心议题集中于系统调用的具体实现过程,此过程涉及较多工程实践层面的细节。从上下文保存与恢复的视角来看,系统调用与常规的函数调用具有相似性:
两者均要求在调用发生前保存当前的执行上下文(主要指寄存器状态),并在调用返回时予以恢复,
这与编译原理中函数调用的序言(prologue)和尾声(epilogue)机制异曲同工。

其主要差异体现在以下两个方面:

  1. 操作系统必须负责保存所有的通用寄存器(因为用户态的调用约定对此并无保障)以及一系列关键的控制状态寄存器(CSRs),例如sstatus(记录CPU当前的特权级别)、sepc(记录发生trap时的指令地址,即trap返回后应继续执行的地址)、scause/stval(分别记录trap发生的原因及相关的附加信息)。这些CSRs对于用户态程序而言通常是透明的。
  2. 出于安全性的考量,操作系统必须为内核态的执行维护一个独立的内核栈,而非在用户栈的基础上进行扩展。RISC-V架构为此提供了sscratch寄存器,可用于保存内核栈的地址。当陷入(trap)发生时,操作系统可以从sscratch寄存器中获取内核栈的起始地址,并切换至内核栈上开始后续处理。

另外,在此阶段,所有的内存操作均基于物理地址进行,尚未实现内核态与用户态之间的内存隔离。
理论上,应用程序能够访问包括内核栈在内的所有物理内存区域,这具有极大的潜在风险。此内存隔离问题将在后续实现虚拟内存机制时得到解决。

Chapter 3: 多道程序与分时多任务

第三章引入了抢占式(preemptive)机制。这使得操作系统能够以分时复用的方式交替运行多个程序,从而显著提升了系统的交互体验。

实现分时系统的关键技术在于引入了时钟(timer)中断。定时器会周期性地产生中断信号,使操作系统得以重新获得CPU的控制权,
并依据预设的调度策略来决定是否需要切换到另一个应用程序执行。这种应用程序间的切换过程,与系统调用的处理流程相似,均涉及到中断当前应用的执行并在稍后某个时刻恢复其运行,
因此同样需要对应用程序的运行上下文进行细致的保存与恢复。

在本章中,应用程序被抽象为“任务”(Task)的实体,并通过“任务控制块”(Task Control Block, TCB)的数据结构来进行统一的调度与管理。
实验任务之一是实现trace系统调用。一个值得注意的实现细节是,若实验要求分别统计每个应用程序的系统调用次数,这通常需要借助特定的数据结构
(例如二维数组)来实现。在设计实现时,若将此统计结构直接置于TaskControlBlock内部,可能会因TaskControlBlock自身内存布局或大小的限制而引发内核崩溃。
因此,将此类统计结构设计在更高层级的TaskManager中,通常是更为稳妥和健壮的方案。

Chapter 4: 地址空间

本章的核心内容围绕虚拟内存的实现展开。通过引入虚拟内存机制,每个应用程序都能拥有各自独立的、私有的地址空间,从而无需关心其在物理内存中的实际存储位置。
虚拟内存的管理主要依赖于诸如MemorySetMapArea(原文提及MemoryMap,但在教程实践中MapArea更为常用)等精心设计的数据结构,
其核心设计思想与Linux等成熟操作系统中的虚拟内存管理机制存在共通之处。

硬件层面,内存管理单元(MMU)承担了虚拟地址到物理地址的自动翻译工作。操作系统仅需将页表的根节点指针(例如在RISC-V架构中,
此指针存放于satp寄存器)加载到特定的控制寄存器中,即可启用地址翻译功能。

虚拟内存机制的引入,对内核的整体结构进行了一次较大规模的重构。其主要原因在于,先前在系统调用处理和程序切换过程中所保存的上下文内容,
由于地址空间的根本性变革,必须进行相应的调整以适应新的内存模型。此过程涉及到若干复杂的技术细节:
例如,在启用虚拟内存之后,当程序从用户态陷入(trap)到内核态时,内核需要借助应用态的页表(或预设的内核自身映射关系)来访问并保存用户程序的上下文信息。
此外,由于内核与应用程序的地址空间通常是不同的(或者说,它们各自拥有不同部分的内存映射),在从用户态陷入内核态之前,
往往需要借助一个位于固定虚拟地址的“跳板页”(trampoline page)来实现上下文的平滑、安全切换。这些具体的设计考量与实现细节,
可能是在先前纯理论学习阶段未曾深入探究的。

本章实验中的一个难点可能在于sys_trace函数的移植。当需要向目标地址写入数据时,
一个常见的理解误区涉及对物理页号(Physical Page Number, PPN)的认知:PPN本身并非一个带有零填充的完整物理地址,它仅仅是物理页帧的一个编号。
正确的物理地址应通过将PPN左移页大小对应的位数(PAGE_SIZE,通常为12位,即乘以$2^{12}$),再加上页内偏移(offset)来计算得到。

Chapter 5: 进程

本章正式引入了“进程”(Process)这一核心概念。进程可以被视为前四章所学知识(例如内存管理、任务调度等)的进一步抽象、封装与整合。
进程抽象使得操作系统能够支持更为丰富和高级的语义,例如实现更为灵活多样的进程调度策略,
以及通过fork()exec()等经典的系统调用来创建新进程和加载执行新程序。本章还通过实现一个简易的Shell,使得该实验性操作系统在功能完备性上又迈进了一步。

本章内容主要在于如何有机地结合前述章节所介绍的技术手段来实现进程这一核心概念,并补充了进程管理相关的诸多细节,
例如初始进程(init process)的启动流程、进程正常或异常退出后的资源回收(reap)机制等。

本章的实验内容可能包括:

  • spawn系统调用:其实现可视为forkexec系统调用的某种组合。但其区别在于,spawn通常用于创建一个全新的进程来执行一个指定的程序,
  • 它会为新进程创建全新的地址空间(MemorySet)并加载目标程序,而不是像fork那样完全复制父进程的地址空间。
  • stride调度算法:其实现主要是在调度器(例如TaskManager)的数据结构中为每个进程维护其步长(stride)和优先级(priority)等属性,
  • 并依据这些属性进行调度决策。在透彻理解相关数据结构之后,该算法的实现过程相对较为清晰。

Chapter 6: 文件系统

本章介绍了文件系统的具体实现,这一机制使得程序和数据能够持久化地存储在块设备(如磁盘)之上,而不仅仅是暂时驻留在易失性的内存之中。
尽管文件系统的基本理论可能已在其他课程中有所涉猎,但通过本章的实际动手实现,学习者可以深入了解到诸多实践层面的细节,
例如操作系统是如何与磁盘驱动程序进行交互的。由于磁盘驱动程序通常已经提供了一层硬件抽象(例如通过BlockDevice trait定义接口),
文件系统的实现便主要集中在遵循这些预定义的接口规范,并构建文件系统自身的逻辑结构(例如inode、dentry、superblock等核心概念的实现)。

在实验中实现硬链接(hard link)功能时,正确的处理方式是在目录条目(directory entry)中将新的文件名链接到目标文件的inode之上,
而不是为每一个硬链接都创建一个全新的inode副本。

实验过程中可能的问题:在向DiskInode等核心数据结构中添加过多字段时,有时可能会偶发性地触发与虚拟内存相关的LoadPageError或类似的错误,
且这种错误行为可能表现出一定的随机性或平台依赖性。其深层原因可能与数据结构在内存中的对齐方式、大小变化触及了某些边界条件,
或是与底层内存管理模块之间存在一些微妙的交互问题有关,这类问题通常具有较高的调试难度。

此外,由于引入了标准输入(stdin)和标准输出(stdout)的概念(它们通常作为文件描述符0和1存在),在通过spawn或类似机制创建新进程时,
必须确保正确初始化其文件描述符表,特别是stdinstdout的指向,
否则可能导致新创建的进程无法正常进行输入输出操作(例如,测试程序可能无法打印任何内容到控制台)。

Chapter 7: 进程间通信

本章内容有助于加深对Shell命令执行机制的理解,特别是关于管道(pipe)和重定向(redirection)的处理方式。例如,对于形如a | b的管道命令,
以及包含输入/输出重定向的复杂命令(如cmd xx > yy << zz),
其解析和执行流程并非简单的串行化处理,而是由Shell进行统一的调度与编排,从而在不同程序(进程)之间建立起正确的数据流。

本章的核心技术在于,通过为不同类型的IPC(Inter-Process Communication,进程间通信)资源(例如管道Pipe)实现一个统一的File
trait(可理解为接口或特性),使得进程可以使用与文件操作相一致的文件描述符来管理这些IPC资源。举例来说,标准输出可以被视为一个实现了File
trait的对象,对其执行write操作最终会将数据导向屏幕显示或传递给管道的下一级。

I/O重定向(例如使用><符号)也遵循相似的原理:操作系统层面通过修改目标进程文件描述符表中的相应条目,
使其指向特定的文件或其他I/O端点,从而实现数据流向的改变。

对于其他形式的进程间通信机制,例如基于环形缓冲区(ring buffer)实现的管道,同样可以为其封装readwrite等文件操作接口。
当进程调用这些接口时,数据被读入或写入共享的环形缓冲区,以此方式实现了进程间的数据交换。

Chapter 8: 并发

本章探讨了操作系统与并发编程中的若干经典并发控制机制,例如锁(包括互斥锁Mutex、自旋锁SpinLock)、信号量(Semaphore)以及条件变量(Condition Variable)。

本章关于线程(Thread)管理的具体实现尤为值得关注。在此阶段,rCore的调度模型进一步向类Linux系统演进:线程成为基本的调度单位,
而进程(Process)则更多地扮演资源容器的角色,负责管理诸如文件描述符表(fd_table)、地址空间(MemorySet)等由同一进程内所有线程共享的资源。

为了支持多线程,进程需要为每一个在其内部运行的线程维护独立的运行上下文(Trap Context)和内核栈(Kernel Stack)。
由于同一进程内的所有线程共享相同的地址空间,它们的线程栈(用户态栈)通常被统一分配和管理在进程用户地址空间内的特定区域
(或者由内核统一管理,但对用户而言是透明的)。线程切换的机制,在保存和恢复上下文方面,与早期章节中描述的任务切换机制基本一致。
若发生跨进程的线程切换(即从一个进程中的线程切换到另一个不同进程中的线程),switch函数除了需要切换线程的上下文外,
还必须负责切换地址空间(即更换当前生效的页表),以确保线程能够在新的目标进程的地址空间中正确运行。

本章的实验任务之一是实现银行家算法(Banker’s Algorithm),这是一个经典的用于避免死锁的算法。

第三阶段

arcore 阶段,这是另一个更严谨也更复杂一点的系统

组件化基础 - Unikernel内核

课堂

组件化内核设计,即通过选择和组合可复用的软件模块来构建内核。这种方法使得构建各种类型的内核(从简单的 Unikernel
到更复杂的宏内核或 Hypervisor)具有灵活性,可以通过增量添加或扩展组件来实现。系统的启动过程也正体现了这一理念:
首先由特定于体系结构的硬件抽象层 (axhal) 进行早期设置(如 MMU 和栈初始化),然后转换到通用运行时环境 (axruntime),
后者会初始化日志、内存分配器和调度器等更多系统,并最终运行主应用程序。通过features可以实现定制化,允许开发者编译仅包含特定应用所需组件的内核。

之后我们讲了内存管理。

内存管理 是一个关键方面,包含两个核心领域:

分页 (paging) 是分阶段实现的。这类似于现代操作系统(如 Linux 或 Windows)中的虚拟内存机制。
一个强制的初始阶段建立基本的内存映射(例如,内核代码和数据的直接映射);
一个可选的第二阶段则重建更完整、更细粒度的虚拟到物理地址映射 ,这对于管理更大的地址空间(如整个物理内存上限)和设备 MMIO(内存映射 I/O,例如 UART、VirtIO 插槽的物理地址 0x1000_0000 等被映射到虚拟地址 0xffff_ffc0_1000_0000 )至关重要。

通过 axalloc 组件引入了 动态内存分配,它提供了一个 GLOBAL_ALLOCATOR 。这与 C 语言中的 malloc/free 或 C++ 中的 new/delete 作用类似。该分配器支持字节和页分配,并采用了多种算法(如文稿中提到的 TLSF、Buddy、Slab 算法 ),从而能够支持像 Rust 的 Vec(动态数组)或 HashMap(哈希表)这类需要动态调整大小的数据结构 。

任务管理与调度对于实现并发至关重要。系统定义了任务状态(如图中所示的运行、就绪、阻塞、退出 ),并使用 TaskContext(保存了如 ra 返回地址、sp 栈指针以及 s0-s11等被调用者保存寄存器 )在上下文切换期间保存和恢复任务状态。探讨了多种调度算法:

协作式 FIFO调度:任务会一直运行,直到完成或主动调用 yield_now 放弃 CPU。这与早期单任务操作系统或者一些需要应用明确出让控制权的系统(如早期的 Windows)相似。
但是,如果一个任务陷入死循环或长时间不调用 yield_now,其他任务将得不到执行机会,造成系统“假死”。

抢占式轮询 (Round Robin, RR) 调度:为任务分配时间片(如 MAX_TIME_Slice ),当定时器中断发生,发现当前任务时间片耗尽时,调度器可以抢占该任务,并将其移至就绪队列的末尾 。这常见于简单的实时操作系统或分时系统。
注意时间片设置过短会导致频繁的上下文切换,增加系统开销;设置过长则会降低系统的响应速度。
完全公平调度器 (Completely Fair Scheduler, CFS):这种抢占式算法使用一个名为 vruntime (虚拟运行时间) 的概念来确定任务优先级 。vruntime 最小的任务将下一个运行,而 vruntime 的值受任务实际运行时间和其 ‘nice’ 值(优先级)的影响 。Linux 当前的默认调度器就是 CFS 的一种实现。

最后是设备交互与文件系统:

设备管理框架 (AllDevices) 为访问不同类型的设备(如块设备、网络设备和显示设备 )提供统一途径,类似于 Windows 中的设备管理器或 Unix 系统中的 /dev 目录。例如,当需要访问某个 VirtIO 块设备时,上层会通过 AllDevices 查找对应的驱动实例。
注意,设备驱动在 probe 阶段(如 probe_bus_devices 或针对 QEMU VirtIO MMIO 的地址范围探测 )如果未能正确识别硬件或初始化失败,设备将无法使用。我在做题时因为漏了一个参数卡了半天(真半天)

VirtIO 模型被用作设备驱动程序的示例,突出了 vring(一种共享内存环形缓冲区)在客户机和主机之间高效通信以及中断处理方面的应用 。这是一种在虚拟化环境中(如 QEMU )常用的半虚拟化设备接口。

题目

大部分题都在这一阶段,print_color更改下宏的定义就可以。hashmap我参考了hashbrown

hashbrown

hashbrown 之所以高效,主要归功于其精妙的SwissTable算法实现,该算法源自 Google。其核心优势在于缓存友好 (cache-friendliness) 和SIMD (Single Instruction, Multiple Data) 指令集的有效利用。

下面是一些核心要点:

开放寻址 (Open Addressing):与链式哈希表(separate chaining)不同,hashbrown 使用开放寻址策略,将所有条目直接存储在主哈希表数组中。这减少了指针间接寻址带来的开销,并提高了缓存局部性。当发生哈希冲突时,它会探测(probe)数组中的其他位置,直到找到一个空槽。

二次探测:简单易懂,就是遇到冲突后再次探测

元数据与主数据分离存储: hashbrown 将每个槽的元数据(例如哈希值的高位和控制字节)与实际的键值对分开存储。元数据通常更小,可以更紧凑地排列。

SIMD 优化查找: hashbrown 利用 SIMD 指令并行地检查多个元数据槽。它将哈希值的一部分(通常是高7位)存储在控制字节中。在查找、插入或删除操作时,可以加载一组控制字节(例如16个字节),并使用单个 SIMD 指令与目标哈希值的高位进行比较。这极大地加速了匹配过程,尤其是在哈希表密集的情况下。

特殊控制字节 (Special Control Bytes):控制字节不仅存储部分哈希值,还用于标记槽的状态,例如:

EMPTY: 槽为空。
DELETED (或称 TOMBSTONE): 槽之前被占用但现在已删除。这对于开放寻址的正确探测至关重要。
FULL: 槽已占用,并存储了部分哈希值。
低开销的元数据: 每个条目仅需要1字节的元数据开销(控制字节),这比许多其他哈希表实现要低得多,从而提高了内存效率。

默认使用快速的非加密哈希算法: hashbrown (以及现在 Rust 标准库中的 HashMap)通常默认使用像 FxHash 或类似的快速非加密哈希算法。这类哈希算法对于整数等简单键类型非常快,但可能不适用于需要抵抗哈希洪水攻击 (HashDoS) 的场景。

bump内存算法
这里我们的bump_allocator是一个双端分配器,一边是零散的小字节分配,一边是整页分配。只要注意好内存检查(插入内存有没有侵入已分配的这端的,以及
如果分配的特别多会不会影响到另一端的),然后两个标记指明当前内存分配位置即可。

Monolithic Kernel宏内核

课堂

异构内核扩展

核心目标是在保证可扩展性和高性能的前提下,让不同内核架构(如Unikernel、宏内核、Hypervisor)能够复用核心组件(称为”Backbone”)和系统服务 。

任务(Task)单元的扩展 是关键,因为任务是内核资源的集合体 。Unikernel的任务可能仅包含基本的上下文信息,而宏内核任务需要页表信息和文件描述符,Hypervisor任务则需要VCPU状态等 。
为了优雅地处理这些差异,而不直接修改核心Task结构(这会降低可读性和异构扩展性 ),或者避免像旧版Starry那样通过TaskID索引外部结构(这会带来性能开销 ),ArceOS引入了 extension域机制 。
这类似于在C语言结构体中预留一个void*指针,具体类型由使用者定义。具体做法是在Task结构中增加一个extension指针,外部模块可以预先定义好特定于某种内核模式的扩展对象(例如,宏内核的进程控制块),
然后在创建任务时在堆上分配内存给这个扩展对象,并将extension指针指向它 。这样,访问扩展域的开销接近于普通结构体成员访存,同时保证了不同内核模式按需扩展的灵活性 。
例如,宏内核的扩展可以包含指向其独立地址空间(页表)的引用和文件描述符表。未来的工作甚至设想了扩展的泛型化,使得不同类型的任务(Unikernel任务、宏内核进程、自定义任务)
可以携带不同的扩展,并在同一套调度、文件系统、网络框架下运行,例如在众核处理器上,某些核心运行Unikernel任务进行高性能计算,另一些核心运行宏内核任务负责管理和交互 。

系统服务的复用 也是一个重要议题,特别是如何让Unikernel已经提供的服务(如文件描述符表 Fd_table、虚拟内存管理、API处理器)方便地被其他架构(如宏内核)复用 。
挑战在于资源的隔离与共享:Unikernel中资源通常是全局唯一的,而宏内核中资源则归属于进程,并通过clone等机制控制共享 。
一个具体的复用目标是arceos_posix_api,它原本为Unikernel提供POSIX接口适配,但在宏内核场景下,可以直接复用其复杂的语义检查逻辑作为SYSCALL层的一部分,而无需为宏内核重新实现一套,从而减少冗余和开发精力 。
为了实现这种复用,引入了Resource(定义资源,使用Arc指针管理以支持共享)和NameSpace(保存所有Resource的集合)的概念 。对于Unikernel,NameSpace是全局唯一的;对于宏内核,每个任务(进程)拥有一份NameSpace,可以动态分配 。
Global namespace的布局可以在编译期通过link_section将所有全局Resource集中在特定段(如axns_resource段)来确定 。
当宏内核需要独有的NameSpace时,可以在堆上分配空间并将全局NameSpace拷贝过去;需要共享时,则利用Arc指针指向全局或父进程的Resource,
这样修改对共享的线程间可见,同时避免了不必要的拷贝开销 。这种设计使得新功能(如新的系统调用或资源类型)更容易接入,并与组件化思想联动。

总结来说,面对未来多样化的应用场景(如智能家居中需要高性能Unikernel的边缘节点和需要安全性的微内核或宏内核的控制中枢 ),组件化为异构内核的快速构建提供了有力支持,
它通过Unikernel基座和精心设计的扩展机制(如Task extension和Namespace),在最大化组件复用和灵活性的同时,兼顾了不同内核模式的特性需求 。
宏内核演进

核心在于引入用户特权级、独立的用户地址空间以及系统调用机制,最终目标是能够直接运行原始的Linux应用程序(二进制)。

从未经修改的Linux应用(通常使用glibc或musl-libc编译)的角度看,它期望运行在一个与内核隔离的用户地址空间,并通过标准的系统调用(syscall)接口与内核交互 。
为了实现这种兼容性,组件化宏内核需要在几个层面进行适配:

用户地址空间的创建与管理:与Unikernel共享单一地址空间不同,宏内核为每个用户应用(进程)创建独立的页表和地址空间(AddrSpace对象)。
通常,页表的高端部分映射内核空间(所有进程共享),低端部分则映射独立的用户应用空间 。这意味着加载应用时,
内核会将ELF文件的代码段和数据段加载到用户地址空间的预定虚拟地址 。

ELF格式应用的加载:大多数应用编译为ELF格式。内核需要解析ELF头获取入口点(Entry point)以及各个段(Segment)的信息,
特别是类型为LOAD的段(代码段和数据段)。需要注意,ELF文件中段的偏移(Offset)和文件大小(FileSiz)可能与加载到内存后的虚拟地址(VirtAddr)
和内存大小(MemSiz)不同,尤其是BSS段(未初始化数据段),它在ELF文件中不占空间,但加载时需要在内存中预留并清零 。

用户栈的初始化:Linux应用(尤其是使用标准C库的应用)的main函数执行前,C库的启动代码会检查用户栈上的参数,如argc(参数个数)、
argv(参数指针数组)和envp(环境变量指针数组),甚至auxv(辅助向量)。因此,内核在切换到用户态执行应用首条指令前,
必须在用户栈上按照约定的布局准备好这些信息,并正确设置用户栈指针(SP寄存器)。
特权级切换与系统调用处理:应用通过特定的指令(如RISC-V的ecall )陷入内核态发起系统调用。内核需要在异常处理流程中识别出系统调用,
并根据调用号分发到相应的处理函数 。例如,示例m_3_0中涉及的set_tid_address (96), ioctl (29), writev (66), exit_group (94)等都是具体的系统调用 。
对于从内核态首次启动用户任务,由于多数架构没有直接的“切换到用户态”指令,一种常见的做法是在内核态伪造一个来自用户态的异常上下文现场(包括用户态的PC、SP、状态寄存器等),
然后通过异常返回指令(如RISC-V的sret)“返回”到用户态开始执行应用 。

任务属性扩展:为了在复用Unikernel调度机制的同时支持宏内核的进程概念,TaskInner结构通过ext成员进行了扩展 。
这个ext可以是一个空结构体(对于Unikernel),也可以是一个包含进程特有信息(如指向其用户地址空间AddrSpace的引用、进程ID等)的结构体(对于宏内核)。
这使得调度器只关注通用的任务属性,而将资源管理等模式相关的特性封装在扩展中 。

为了支持更高级的内存管理特性,如 缺页加载(Lazy Loading)和内存映射(mmap),内核需要处理页错误(Page Fault)异常 。
当应用访问一个尚未建立有效物理映射的虚拟地址时(例如,通过init_user_stack(&mut uspace, false)设置用户栈为Lazy映射 ),会触发页错误。
内核的页错误处理函数(如handle_page_fault)会介入,它通常会查找该虚拟地址所属的MemoryArea(内存区域)。每个MemoryArea关联一个Backend,
负责具体的映射操作 。Backend可以有两种主要类型 :

Linear Backend:用于已存在的、连续的物理内存区域的直接映射,例如设备MMIO或共享内存。

Alloc Backend:用于按需分配物理页帧并建立映射。在缺页异常发生时,Alloc后端会申请物理页帧,然后在页表中补齐映射 。
sys_mmap系统调用的实现也与此类似:如果populate参数为true,则立即分配并映射;如果为false,则仅建立空映射,
等待后续访问时通过缺页异常来实际分配物理页面 。

此外,为了更好地兼容Linux生态,还需要支持像ProcFS、SysFS这样的 伪文件系统,它们提供内核和进程信息的接口(如/proc)或暴露设备信息(如/sys)。
在ArceOS中,这些可以通过axfs_ramfs的实例或专门的axfs_devfs组件来实现 。

题目

page_fault

缺页相应,这里其实系统里已经有不少实现了,我们只需要获取当前用户空间,然后调用find_free_area和map_alloc就可以了

ramfs_rename

这里首先要修正依赖,让测试依赖本地的axramfs(笑),这里依赖本地的库之后,在dir.rs中添加rename的函数,这里底层是用了BTreeMap来保存
已经申请过的文件,然后值对应着在内存中的物理节点。更改BTreeMap就可以了,字符串处理仿照其他实现。

rustling 阶段总结

先来一份 C++ 转 Rust 急救指南(

/ Mutable Borrow & take ownership

rust 中函数传递参数有三种方式,不可变借用,可变借用以及获取所有权。这三种方式贯穿了 rust 的设计思想。
它们的定义对应了 &T, &mut T, T 三种类型。与 C++ 不同,rust 中值传递的默认行为是 move 而非 copy,对于 copy 需要实现 Copy 的 trait。 且类型一旦实现 Copy trait 之后,默认行为就变为 copy

Borrow &

rust 中的 &T 应等同于 C++ 中的指针,只是 rust 对于 &T 实现了隐式 Deref,在大部分情况下避免了显式 * / &
迭代器有三种类型:iter(), iter_mut(), into_iter(),分别对应了 &T, &mut T, T 三种类型。

Iterator

rust 中迭代器的成员函数被分为两类,Non-Consuming Adaptors 返回对应的迭代器,是惰性的,Consuming Adaptors 返回结果,会消耗迭代器的所有权,在使用后无法再次使用该迭代器。

Index

[] 应返回 &T&mut T,不应获取所有权,这是为了避免通过 index 访问而隐式获得所有权的情况。

Deref

1
2
3
4
pub trait Deref {
type Target: ?Sized; // 关联类型,表示解引用后的目标类型
fn deref(&self) -> &Self::Target;
}

这是 rust 中关于 Deref 的定义,通过在 impl 的时候指定相关的类型,就可以无需像泛型定义那样显式指定类型。

1
2
3
4
5
6
impl Deref for String {
type Target = str; // 指定解引用目标为 `str`
fn deref(&self) -> &str {
unsafe { str::from_utf8_unchecked(&self.as_bytes()) }
}
}

由此我们也知道了,对于一个 Deref 实现来说,它所返回的类型是 single 的,并不会出现解引用可以返回不同类型的情况。对 &String 解引用可以得到 &str,是因为 Target = str
正因如此,编译器可以通过 T, U 类型推断这两个类型是否可以通过 Deref 转换。

Drop

Drop::drop() 会在变量离开作用域后由编译器隐式调用,不应显式调用该函数。
Drop trait 被实现用于回收资源,若需要显式调用,可使用 std::mem::drop(x) 通过消耗 x 的所有权来提前触发析构。

RefCell

在编译时允许同时存在多个可变引用,但运行时仍会检查是否存在多个可变引用,通过 borrow(), borrow_mut() 中的引用计数来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct RefCell<T> {
value: T,
borrow_count: usize,
is_mut_borrowed: bool,
}

impl<T> RefCell<T> {
fn borrow(&self) -> Ref<T> {
if self.is_mut_borrowed {
panic!("Already mutably borrowed!");
}
self.borrow_count += 1;
Ref { /* ... */ }
}

fn borrow_mut(&self) -> RefMut<T> {
if self.borrow_count > 0 || self.is_mut_borrowed {
panic!("Already borrowed!");
}
self.is_mut_borrowed = true;
RefMut { /* ... */ }
}
}

RefCell<T> 会返回 Ref<T> 或者 RefMut<T>Ref<T> 的所有权检查发生在运行时,且可以通过 map 来拆分借用,更加灵活。
可以通过 RefCell 来实现内部可变性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::cell::RefCell;

struct Cache {
data: RefCell<Option<String>>,
}

impl Cache {
fn get_data(&self) -> String {
// 检查缓存
let mut cache = self.data.borrow_mut();
if cache.is_none() {
*cache = Some("Expensive result".to_string());
}
cache.as_ref().unwrap().clone()
}
}

fn main() {
let cache = Cache { data: RefCell::new(None) };
println!("Data: {}", cache.get_data()); // 计算并缓存
println!("Cached: {}", cache.get_data()); // 直接读取缓存
}

使用 borrow_mut() 时,由于变量的生命周期与临时值不同,应谨慎与变量进行绑定。

Lifetime

rust 的临时值会在表达式结束后析构,因此允许存在这样的情况。在 clone2.borrow_mut() 语句执行的时候,clone1.borrow_mut() 已经被析构,所以 borrow_mut() 不会因为引用计数 > 1 而崩溃。

1
2
3
4
5
let shared_data = Rc::new(RefCell::new(42)); // 克隆 Rc(共享所有权) 
let clone1 = Rc::clone(&shared_data);
let clone2 = Rc::clone(&shared_data);
*clone1.borrow_mut() += 10; // 步骤1:获取可变借用,修改后立即释放
*clone2.borrow_mut() *= 2; // 步骤2:前一个可变借用已释放,安全获取新借用

作为对比,如果改成

1
2
3
4
5
6
let shared_data = Rc::new(RefCell::new(42)); // 克隆 Rc(共享所有权) 
let clone1 = Rc::clone(&shared_data);
let clone2 = Rc::clone(&shared_data);
let longer_lifetime = clone1.borrow_mut();
*longer_lifetime += 10;
*clone2.borrow_mut() *= 2; // 前一个可变借用未释放,程序崩溃

就无法正常运行,因为 longer_lifetime 作为 RefCellNLL 的交互,其行为表现为它的生命周期持续到该作用域结束。

Non-Lexical Lifetime

Non-Lexical Lifetimes (NLL) 是 Rust 编译器的一项核心特性,通过更精细的生命周期分析,允许变量在其最后一次使用后立即释放(而非必须等到词法作用域结束),但与某些类型的交互具有其他的表现。

Copy & Clone

Copy trait 是面向编译器实现的,Clone trait 是面向程序员实现的。一旦实现 Copy trait 默认行为将变为 copy 而非 move。在语义上,如果实现了 Copy,实现的 Clone 应当与之相容,可以通过调用 [#derived(Copy, Clone)] 获取默认实现。
并没有对于 Clone trait 实现的硬性约束。

这些是我觉得印象比较深刻的点,除此之外感觉似乎不太难。rust 唯一能折磨我的数据结构只有 Box<T>,但归根到底还是没有完全学会语言底层逻辑,没有完全学会用 rust 的方式写代码。

rcore 阶段总结

进入到这个阶段之后意识到,之前学的 xv6 还是有些太玩具了,尽管 rcore-os 也是一个教学用途的操作系统,但它的深度似乎是远大于 xv6 的,你可以在这里面接触到链接器是如何编写的,如何用自己的一套交叉编译工具链来编译并且运行 kernel 以及裸机程序。

Chapter1 给我的印象很深刻,上来就告诉我们要写一个裸机程序,要抛弃 std 库,但彼时的我还是连 rustling 都没有写明白的完全的 rust 萌新,看到这些东西未免觉得有些难以下手,好在 rcore-os 提供的简明 Tutorial 十分清晰,静下心来读基本上没有不理解的地方。

整个 rcore 阶段最困扰我也是让我思考了最久的一个问题:

为什么一个物理页不会同时被分配为页表和供内存使用?

因为在创建页表的过程中会使用 PageTable.map(),该函数会调用 PageTable().find_pte_create(),在这一创建多层页表的过程中会使用 frame_alloc()
而在分配内存的部分,MemorySetFramed 映射会调用 frame_alloc() 分配新的物理页供其使用,这是除了创建页表之外我们唯一能使用到 [ekernel, MEMORY_END) 这段内存的部分。
而在 MemorySet 里,主要是 KERNEL_SPACE 会产生一个恒等映射 [ekernel, MEMORY_END)。这部分恒等映射被用于寻找页表。
即使开启虚拟内存映射后,我们在 PhysPageNum.get_pte_array() 处也可以直接将 pa 转为 *mut PageTableEntry,就是因为此处的恒等映射会直接将 pa 对应的虚拟地址映射为物理地址。

我觉得愿意去弄明白这些,不给自己留有太多的疑惑,这个阶段的收获基本是不小的。

rcore-os 阶段让我印象最深刻的 lab 还是 filesystem 相关的部分,要求我们试着给文件维护一个引用计数。这个引用计数维护起来并不困难,但对于这样持久化的数据来说,在什么抽象层来维护十分重要,如果在视图层维护,那么是无法维护的。具体来说 OSInodeInode 便是对于 DiskInode 的视图,而看明白这一点,需要对整个代码的结构有比较好的了解。

arceos 阶段总结

这个阶段是我过的比较急的一个阶段。但其实这个阶段的代码质量远高于 rcore-os 阶段,因为是接近工业级别的代码,解耦合等各种设计思想也能学到不少。

lab2 的实现 HashMap 是我耗时最长的一个 lab,因为对于 Box 的特性不太熟悉,而且在不可变引用迭代器中,又很容易涉及到 second borrow 的问题。

感觉这个阶段的 lab 都是很贴近实际工程环境的 lab,但难度相较于 rcore-os 阶段来说其实下降了一些,因为通过 demo 并不要求效率,也不考虑 corner case。

前言

大家好,我是ACrazyShark(李卓恒)。今年2月,我和志同道合的同学们组队报名了全国大学生系统能力赛操作系统内核赛道,开启了操作系统开发的探索之旅。备赛过程中,我们有幸接触到由清华大学等高校打造的rcore训练营——这个以Rust语言重写Linux内核的实践项目,不仅帮助我们构建了从理论到落地的完整知识体系,更让我们在”通过开源学习开源”的理念中感受到系统级编程的魅力。

Read more »

阶段一

rustlings的练习还是比较基础的,实在想不起来某个知识点还可以去rust圣经翻翻文档。不过貌似后面的dsa部分对于rcore的练习基本没啥用处。

阶段二

之前有过自己搓内核(没有文件系统),以及做xv6 for riscv的lab的经验,实际到ch6为止都十分顺畅,ch6的部分实现参考了2023秋训练营的代码,ch8则是在线程的粒度上实现银行家算法,码量相对较少。

阶段三

配置环境的时候遇到点小插曲,qemu.mk指定bios为default,结果没有正确加载固件,后面指定固件加载路径解决了这个问题。

第三阶段的题目普遍简单,可能是框架基本搭好了所以实际工作量也减少了。此外arceos因为是unikernel架构,各个组件功能耦合较低,对应练习需要修改的地方也一目了然。

前言

本人没有 408 基础,之前仅仅是有过 CRUD 的经验,但是对各种底层很感兴趣,最近考试结束正好有时间参加训练营。索性接受了大佬的邀约(毕竟以后也是要做的)。

Read more »

第一阶段

凭着已有的rust知识速通了,都是基础填空题没啥好说的。

第二阶段

应用填空题,多看几遍就会了,没什么工作量,主要是看。详细报告见二阶段仓库。
https://github.com/LearningOS/2025s-rcore-Vinci-hit

第三阶段

主要感觉还是重在看,只要多看几遍就知道怎么做了,而且有提示,还是填空题。

第一题要求实现HashMap,只需要实现一个HashMap即可,有些坑会自动出现。

第二题要求实现内存分配算法,但其实都已经实现好了只需要学会看懂后调用api即可。

之后的每一题几乎都是只要看懂了会调用api就可以了,代码量都很少。

第一阶段:Rustling 与 Rust 基础练习

这个阶段我主要进行了 Rustling 题目的练习和 Rust 编程语言的基础训练。因为我本身有一定编程基础,所以整体难度对我来说并不高,主要是为了熟悉 Rust 的语言风格和核心概念,比如所有权、借用、模式匹配等。为后面更深入的系统开发打下了语言基础。


第二阶段:操作系统内核功能实现

到了第二阶段,框架已经搭建好,任务主要是完成其中某些具体功能模块的实现。虽然刚开始面对内核设计和系统调用这样的内容有些抽象,但随着理解的深入,实际的编码和调试也渐渐变得顺利起来。
具体详情见:https://github.com/LearningOS/2025s-rcore-Tecto-Wang


第三阶段:借助资料进行模块化理解

第三阶段的学习方式更为系统。我通过 PPT 和视频资料来逐步学习实现细节,并通过留空的方式聚焦于具体模块的实现。这样的方式可以集中注意力专注于每一个点,然后再逐渐拼凑出整个系统的全貌。相比第二阶段,这种学习方式更轻松、更具条理,也帮助我对整个系统架构的整体理解。


个人碎碎念

了解到rCore训练营是一位大佬拉我一起参加的,之前没有参加过类似的训练营,没有学习过rust,对os的理解也只停留在408里面片面的os(实际做下来跟应试学习的完全是两个东西)。回想起来当时在做之前应该先去看一下除提供的文档之外的os知识,很多时候也缺少走读代码和理解代码的能力,拖团队后腿了。
通过这次训练营,意识到自己很菜,一开始还是蛮焦虑的(虽然现在也是),跟不上大家,后来焦虑多了也在心态上有一些变化,开始慢慢走读现有的代码,结合训练营提供的资料一步一步理解任务,再根据自己的理解完成任务,不懂的就在团队的小群上问群友,也是比较艰辛的把三个阶段给完成了。
也是感谢几位群友,在我拖后腿的时候都会比较耐心的等我慢慢做,本来队伍还打算拿优胜队长的奖励的hhh。

第一阶段

由于是第一次接触rust,之前只接触过java,写起来非常割裂,并且在一些java可以的地方,rust是禁止的,就比如在类型检查上。rust的match以及一些闭包函数是蛮好用的(这里也要小小的吐槽一下rust实现链表太麻烦了。

第二阶段

这个阶段主要是了解系统内核,学习rCore指导书并且完成五个实验,这个阶段有大半时间都处在焦虑状态,没有好好的理解指导书和代码内容,结果就是一些实验需要靠群友帮助才能完成。但还是学习到了os底层的运作方式,多任务系统,虚拟地址空间映射,进程的管理调度,文件系统,锁的实现。印象最深的就是在写sys_linkat的时候由于没有走读理解现有的代码,愣是卡了一周才完成。

第三阶段

这个阶段原以为是在阶段二的基础上继续添加完善os系统,结果并不是,有一些实验写下来感觉不如阶段二,不过正因为这个阶段没有那么难,算是比较顺利的在预期时间就做完了,这个阶段开始慢慢的看懂框架代码,随后根据提示完成任务,算是进步比较大的阶段,还需要持续学习,接下来打算试试参加阶段四,拿一个结营证书,同时要好好反思自己,努力学习提升代码能力和计算机基础。

Read more »