0%

内容

  1. 所有权
  2. 基本数据结构
  3. 集合类型
  4. 生命周期
  5. 闭包
  6. 智能指针
  7. 面向对象编程

    遇到的困难

  8. 在第一阶段做110题时,每每完成一道题后,下一道题就会出现许多全新的知识点,这时只能再回到指导手册继续阅读。
  9. 变量的生命周期延长
  10. 智能指针的内存结构
  11. 集合的遍历处理.iter()、.enumerate()、.find()、.map()、.fold()等的组合使用。
  12. 使用rust库需要时间熟悉
  13. 闭包的使用,闭包是我遇到的全新的语法,难在分析它的输入、内部逻辑以及输出。

    收获

    使用rust时,需要非常关注变量的生命周期,进而要考虑其在内存程序内的位置。通过rust的使用让我对程序的组织结构有了清晰的了解。在掌握rust基本语法后,因为rust语言的可读性,阅读rust代码成为了一件相对简单的事情,但是需要格外小心变量的生命周期。要想熟练掌握rust语言,还需要对rust的各个特性进行深入研究。

2024年秋冬季开源操作系统训练营第一、二阶段总结报告

在开始正式报告之前,我想说作为一位三战老兵,三次参加开源操作系统训练营,从一开始的只能勉强完成rustlings,再到现在完全实现第二阶段五个lab的所有功能。一路走来,走到今天实属不易。遥想一年前的我,因为二进制漏洞挖掘与利用(pwn)接触到操作系统,并励志于弄清操作系统的运行原理,但是苦于实在没有学习路径而踌躇不前,是开源操作系统训练营给予我接下来奋斗努力的方向。在此也非常感谢这个项目。

另外这一路上结识的,给予我帮助的伙伴,我也非常的感激并庆幸认识了你们。尤其是23年春夏季的助教徐堃元老师和朱懿老师,没有你们的帮助我可能还卡在一些看上去非常令人费解而又苦恼的地方,你们在rcore与系统能力赛上的指导对我的启发很大,在这里向你们表示感谢。

我身边的朋友曾问我,为什么要做操作系统?操作系统在外人看来晦涩难懂,且在实际业务场景中似乎并不需要我们去细究它的实现与原理。每到这种时候,我就会想到登月前肯尼迪总统所做的讲话:We choose to go to the moon, not because it’s easy, but because it’s hard.

做难而正确的事情,至少目前为止,我都是这样干的。至于前程?但行好事,莫问前程。在我还有精力干的年纪,稍微干点事情吧。

2024.11.7 stone-san于寝室

第一阶段总结

第一阶段就是rustlings的通关。一开始我对rust语言一无所知,且光看一些书籍的话,学习的效果也不佳,所以使用rustlings来实操是有助于我理解rust语言的。在这个阶段我觉得rust不同于其他高级语言的一些地方一个就是所有权的概念,使用所有权,编译器在编译时会根据一系列规则进行检查,且这种检查只发生在编译期,因此对于程序运行期,不会有任何性能上的损失。我理解是说同一个作用域范围内,数据只能被一个变量所使用,如果说发生了赋值的情况,那么除非是这个数据类型本身支持自动拷贝之外,像String这样的类型则必须显式调用clone方法来进行拷贝。这样在内存安全上,rust从根本上杜绝了一些c语言里会有的问题,诸如悬垂指针等。

另外,训练营版本的rustlings本身也加了一些自己的内容。比如今年新增的数据结构与算法章节,就给rustlings通关带来了新的挑战。不过这对于接下来rcore是有好处的,因为rcore里全是数据结构相关的一些东西。

第二阶段总结

第二阶段是rcore,有五个lab,分别对应着多道程序、地址空间、进程管理、文件系统、并发同步这几个我认为操作系统里最基本的一些实现。

多道程序的实验主要是实现一个系统调用来获取当前任务信息。第一次做的时候并不清楚到底要干什么,但是随着多次的学习了解了,这个TaskInfo结构体的信息其实是要我们在任务结构体里先增加一些对应的字段,然后再通过实现方法来设置这些字段的内容或者是获取这些字段的信息,最后输出给用户。其实绝大多数后面的任务基本也就是对结构体字段的完善与实现方法的撰写。、

在地址空间的实验中,除了上一章的实验之外,还增加了内存分配mmap和munmap的实现。因为开启了虚拟内存,所以现在我们不能直接对任务结构体操作,因为会遇到数据存储在两个页上的情况,我们只能先根据token找到真实地址,然后再获取内容。至于mmap和munmap,那涉及到对页表和memory_set的更改,为此维护了一个BTreeMap,来对应内存地址与具体页的关系。

在进程管理里,先前的任务结构体被重新修改为了进程。并且要实现的系统调用也与进程的产生有关系。此外,对stride算法也有了一定的了解。本质上还是进程里添加了一个字段,然后在每次执行进程时都重新处理一次prio字段并更新记录,以决定下一次执行的进程。

文件系统是比较难啃的一章,这里首先文件系统我不是很了解,而且这一章里也大量使用了闭包,这都是要从零开始学习的地方。由于文件系统大改,sys_spawn需要适配上新的文件系统。这里我原本的写法是将这些操作全部放置于syscall/process.rs下,但是问题在于data会莫名其妙的数据置零。所以只能将这个任务扔进task中了。

而并发同步是我另一个不熟悉的领域,在此之前我对并发这里并没有过多的了解。通过这章的学习也了解了互斥锁和信号量的实现机制。总之还是非常有挑战性以及很有趣的。

之后……

至于之后要做什么?我不知道,我手上的活有很多。有很多的任务等待我去完成,比如三阶段的项目制实习,比如明年系统能力赛的内核实现,比如软件所的kernel开发任务。总之一直在路上。我想到了我小学时候读过的一本关于奥数的书,在那里,序言的最后写下一句话:

奔跑了,就别停下!

我想。应该是这样的。

第一阶段学习总结

学习心得

初学这门语言时总觉得是一门很繁琐的语言,需要和编译器,但是经过编译器和 clippy 方便的静态检查(折磨),发现这门语言的确是很严谨的,很多错误都是在编译期就能发现的,到后面就会发现这门语言有一种可以帮你减少错误的感觉。而且 rust 语言的创新性也是很吸引人的,下面是我认为 rust 语言的一些特色:

  1. 类型安全:默认不可变,默认私有。
  2. 内存安全:包括所有权系统(遇事不决用.clone()),借用概念,智能指针&RAII,生命周期等。
  3. 并发安全:多线程并发,异步并发。
  4. 零成本抽象:在编译期就能消除抽象的开销。对于泛型代码,rust 会在编译期将泛型代码展开,消除了泛型的开销。trait 又保证了代码的灵活性。
  5. 宏:rust 的宏是一种强大的元编程工具,可以指定匹配模式,并在编译器生成代码。

另外,rust 的迭代 API 也和其他语言不太一样,rust 的迭代器是惰性的,只有在需要的时候才会计算,这样可以避免一些不必要的计算。rust 中的迭代器处理同时满足了效率和代码的简短,是大部分 rust 代码中无处不在的。这些特性使得 rust 语言在系统编程中有着很大的优势。

在 rustling 过程中,有一些题目是编译器告诉我要这么做,然后就过了,虽然体验到了编译器的强大,但是对底层的原理还是似懂非懂。

Option和Result类型的使用感觉有点繁琐,各种.unwarp()。

最后在algo的题目被裸指针的转换弄得很头疼,也许链表Box::into_raw(node)可以用内部可变性和引用计数?

学习路径

rustlings 只是对 rust 中的一些基本特性做了一个简单的介绍,没有很深入的要求。对于很多特性还可以深挖,而且 rust 中还有很多有意思的特性。以下是我个人再看的一些资料:

A half-hour to learn Rust

rustlings + The Rust Programming Language - The Rust Programming Language

Introduction - Rust By Example

Effective Rust - Effective Rust

Table of Contents - Rust Cookbook

第二阶段学习总结

rCore 用比较现代的 RISCV ISA 和 rust 语言介绍并实践了 OS 相关的重要概念和基础功能,如线程调度、地址空间切换、文件系统、IPC和并发。

学习过程

整体思路:先看看每章的引言和题目,了解下一章将要学到什么。并且带着题目和测例中的疑问点看实现细节和代码。(面向测例编程)

前三章的知识更偏向ISA,riscv 还是比较简单直观的,没有很多复杂的指令和很绕的概念,只需要了解基本的 load/store 和算术运算即可。特权级相关的寄存器比较重要,需要重点记下。

lab2 从内核态复制一个结构体到用户态可以单独用一个函数封装一下,可以参考已经给出的 translated_byte_buffer 的实现,在实现 mmap 的时候要注意 SimpleRange 是左闭右开的区间,然后申请内存前枚举判断区间的相交就很显然了。

lab3 的 spawn 实现的一个坑点就是不能用 TaskControlBlock::new(),猜测是 stdin 等不能初始化多次,所以最好还是仿照 new 和 fork 在 TaskControlBlock 结构体中写一个 spawn。stride 算法听起来很吓人,但实现还是很简单的,只需要加入 stridepass,然后在调度时暴力计算就好了。

前面的代码还算直观,在 lab4 和 lab5 的代码就比较抽象了,为了方便实现参数乱传。

easy-fs 的层级太多了,代码量比较大,做实验时要重点看块管理器和 inode 部分。在 vfs 的 Inode 结构体中只存了 block_idblock_offset,再加一个 inode_id 会对 statlink 的实现比较方便,当然从block_idblock_offset 也可以直接反推出 inode_id。在 unlink 操作时,我获取了一下 nlink,即枚举这个 inode 有多少个硬链接,如果只有一个,就需要回收inode以及它对应的数据块。这个枚举还存在优化的空间。

ch8 的死锁检测算法需要在每次获取锁和释放锁时更新 Available 和 Need 矩阵,需要注意这两个矩阵的区别,什么时候用到不同的两个矩阵。同时新建线程和锁时要维护矩阵的大小。在 lock 和 down 操作执行前判断死锁。因为要更新矩阵中对应 tid 的一行,所以需要知道task在数组的位置。但是锁里面只能获得 task 对象,没法得到对应的下标。最后我用 get_trap_cx() 获取每个 task 的 trap_cx 比较。现在想想更科学的方法应该是往 TaskControlBlock 里面再加一个元素。

体会与总结

感觉 rCore 整体的代码量比较小,用用户程序测试操作系统的过程特别有意思,用户体验很友好,但是部分测例比较弱。

写完 lab 题目之后还是要尽快写总结,感觉过了几周已经忘了rustling干了啥了。

之前也曾做过os的实验,但是当时所用脚手架代码太多,掩盖了许多底层细节——尤其是内存空间相关的。所以决定做一下rcore,复习os相关知识。

可能因为有相关基础,rcore前四个lab都没怎么费力,只有最后一个lab理解上花了点功夫。究其原因,是因为当时学银行家算法时就没有理解他是怎么运作的。带着先入为主的错误的观点来看实验要求,就感觉百思不得其解了。
我最初以为银行家算法是静态对一个程序进行分析以判断该程序是否会产生死锁的——但实际上银行家算法也可以用于“动态申请🔒时,如果判断可能死锁,就返回错误值”的情况。(实际上对于图灵完备的语言,静态分析反而没办法判断是否会产生死锁)

此外从 rcore 中学到的东西有:

  1. 文件系统的多层设计
  2. os如何分配和填写页表
  3. 具体如何实现线程和进程

写到这里,第二阶段就要告一段落了。rCore的文档真的写的很棒,作为一名非科班的CS爱好者,终于弥补了自己本科时期的遗憾,系统的学完了操作系统,也对git项目开发和rust有所入门。

学完之后最大的感觉是补全了自己的计算机体系结构,之前一直在学一些语言层面的语法,思考代码主要是从逻辑层面,现在能思考一些比较深刻的东西,debug能力也有所增强。

lab1算是对操作系统的基本入门,了解了用户态程序是如何一步步进入到内核态代码进行处理的。前三章读完之后,感觉分时多任务和上个月学的rtos的工作原理有点像,属于同一类系统。也对如何从0开始构建一个裸机应用,后面想试试用rust编写stm32,应该也会很方便。

lab2加深了我对虚拟地址的一些理解,之前了解过可执行文件的地址空间,当时还在想一个应用有4G的地址空间,真的能存下这么多吗?这一章对页表机制讲得很深刻,解答了我之前所遇到的一些困惑。

lab3的编写,让我对进程管理的一些api及实现有了全新的认识,之前的我只是一个pthread的调包工程师,现在对进程的实现和相关流程有了更为本质的理解。

lab4实现的文件系统,来回看了好几遍,真的挺复杂的。最终完成lab,很有成就感,学习了rust的Trait实现,后面的项目可以参考这个架构。

lab5的各种同步机制,算是对Rust同步机制的巩固,在项目中多次用到了多线程,想想挺可笑的,这些没有锁导致的问题,自己在初学阶段真的都有犯过,后面才有一步步接触到了这些同步机制。

看完整个RCore教程,完成了第二阶段的实验,才发现自己浪费了太多时间在一些开发技术上面了。学完操作系统明白了,这些技术本质上都是相通的,无非是各个语言实现的不同,以后要花重点在这些基础上,做一些更偏底层的学习。

最后,感谢训练营,感谢清华大学能提供这次机会学习操作系统,学到了很多,希望下一个阶段自己能坚持走到最后。

一、前言

在过去两周,我学习了rCore操作系统。通过阅读实验指导书,我跟着操作系统的发展历程,学习了批处理系统、多道程序与分时多任务、虚拟地址空间、进程管理、文件系统、进程通信和并发等核心概念,并对rCore的实现有了更深入的认识。以下是我对这两周学习过程的总结。

二、学习内容

  1. 搭建执行环境:

    学习了平台与目标三元组,理解了应用程序执行环境。

  2. 批处理系统:

    学习了批处理系统的基本原理,包括作业调度、作业执行过程。

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

    掌握了多道程序设计的基本概念,以及如何实现分时多任务调度。

  4. 虚拟地址空间:

    理解了虚拟内存的概念,包括页表、地址映射。

  5. 进程管理:

    学习了进程的管理、调度,更加深入的理解了fork+exec。

  6. 文件系统:

    掌握了文件系统的基本结构,包括目录、文件。

  7. 进程通信:

    学习了父子进程之间的通信机制——管道。

  8. 并发:

    学习了线程管理机制的设计与实现,理解了同步互斥的实现,包括锁、信号量、条件变量。

三、学习心得

Rust语言的安全性和性能在rCore开发中得到了充分体现,经过阅读系统的源码以及作业的编写,对rust语言在内存安全和并发控制方面的优势有了更深的理解,第一阶段学习rust的很多疑问也得到了解答。

通过学习rCore,我对操作系统的原理有了更深入的理解,虽然考研的时候较为系统的学习了操作系统的知识,但是基本上还是停留在理论知识方面。这次rCore学习之旅,我获取PCB对进程进行操作、实现课本上学习过的系统调用、深入汇编代码理解什么是 '陷入' ,让我对操作系统的设计理念、计算机的体系结构有了具象化的认识。

在学习过程中,我也遇到了许多挑战,包括环境搭建遇到报错、Rust基础不够牢固导致代码编写举步维艰等,但通过不断解决这些问题,我的编程能力和问题解决能力得到了显著提升。

两周的rCore学习之旅让我受益匪浅。通过学习rCore,我对操作系统的设计和实现有了更深刻的认识,同时也提升了我的编程技能。我相信,这些知识和经验将对我未来的学习和职业发展产生积极影响。

一、前言

在过去两周,我学习了Rust编程语言的相关知识。通读了一遍《Rust程序设计语言》书籍并完成了训练营的Rustlings练习。经过两周的学习,我对Rust有了初步的认识和掌握。以下是我对这两周学习过程的总结。

二、学习内容

  1. Rust基础知识
  • Rust编程语言的基本语法,包括变量、数据类型、运算符、控制流等。
  • Rust的所有权系统,包括所有权、借用、生命周期等概念。
  1. Rustlings练习
  • 通过完成一系列练习,巩固对Rust基础知识的理解和应用。
  • 练习涵盖了Rust的所有权、借用、生命周期、错误处理、宏、模式匹配等方面的内容。

三、学习心得

非常感谢训练营的老师和助教帮助,让我能够在两周的时间快速入门rust这门优秀的语言。印象最深的还是最后的算法部份,尤其是前两道关于链表的题目,其实之前一直是使用c++做算法题,对链表也较为熟悉了,但是由于对rust的特性不熟悉以及对链表没有深刻理解,让我有一种有力使不出的感觉,后面通过阅读题目的框架,以及对书本知识的巩固,终于是对rust中的链表有了初步的认识,写完链表的题目后,后续的题目也很快完成了。rust语言的特性让我对编程和计算机有了更深的理解,尽管现在还是写得磕磕绊绊,和编译器打架,但是相信通过不断学习和时间,将来我也能够编写出优秀的rust代码。

碎碎念

拖了好久最后还是写完了,中间碰到些奇怪的问题和bug,卡了好一大气,最后发现问题在什么地方的时候真的血压升高,一整个气烂了。但是最后还是解决了,感觉自己的能力有所提升吧。

最后真的永远也习惯不了写各种报告(捂脸

学习总结

前三个lab

前三个lab的内容大致上还是比较简单,感谢各位老师和助教提供的框架,写的时候可以更注重于OS的机制以及riscv平台本身,不用关注太多的细节。报告里的思考题也挺有帮助的。

前四个lab要求需要整合前面lab实现的功能,在写这几个lab的过程当中,一些部分的实现也在一直调整,变得更加合理以及方便使用。也发现自己一些问题,有时候还是要先想清楚把代码读好再动手,不然会浪费很多时间。

第四个lab

在这个lab上花的时间最长。代码写好以后调试始终是不对。最后发现spawn跑过去之后整个文件系统一整个挂掉了,整个easy-fs乱成了一锅粥。调试了好久好久也搞不清楚什么原因,最后还是换了种实现spawn的方法,先过了测试。猜测可能是在exec的时候不小心写了什么地方?玄学问题以后有时间的时候再研究研究吧。如果真的是把fs的内容给覆写掉的了话,我也真厉害能写出这么恶心的代码。毕竟表面上看完全是不可能的

第五个lab

这个lab是要我们实现一个死锁检测的算法。这个算法之前在课上有学过,这下也算是见到本体了。觉得自己有时候对于并发编程的了解还是不够深入,可能如果加上一些实现并发的代码和lab会更好一些?希望能在后面的课程中学到更多的知识。

心得体会

rust

算是开始接触rust有一段时间了,和编译器斗争越来越熟练。感觉rust是一门很有趣的语言,有时候会出现写其他语言的时候没有的一些奇怪的问题,但是解决了之后能对一些东西有更好的了解,平时会更注意一些。

比如说rust的所有权系统,有时候会觉得很麻烦,但是这个系统也能帮助我们避免一些潜在的bug,也能让我们更好的理解内存和生命周期的管理。这些内容其实在写C/C++的时候也会遇到,但是表现的形式可能更加狂野一点。rust这样更加严格的检查让我们在写代码的时候,设计整个结构的时候更小心,考虑到更多的细节。之前在写C++的时候,发现C++11加入的std::movestd::unique_ptr等等,也是为了表达一些所有权等相关的语义,但是没有强制化的要求。rust的所有权系统更加严格,也更加直观。

希望以后能够继续学习rust。

riscv

riscv和x86比起来简直就是天堂(

riscv指令集更简单,更容易理解。在设计的时候就避免了很多x86遗留的历史设计问题,实现OS的时候更加方便。在学习riscv的过程中,也对计算机体系结构有了更深的了解。觉得riscv是一个很有潜力的平台,希望以后应用更普遍一些吧。

rCore

Chapter 1

目标三元组

目标三元组 (Target Triplet) 描述了目标平台的 CPU 指令集、操作系统类型和标准运行时库。

  • x86_64-unknown-linux-gnu:CPU 架构是 x86_64,CPU 厂商是 unknown,操作系统是 linux,运行时库是 gnu libc。
  • riscv64gc-unknown-none-elf:CPU 架构是 riscv64gc,厂商是 unknown,无操作系统, elf 表示没有标准的运行时库。没有任何系统调用的封装支持,但可以生成 ELF 格式的执行程序。

通过cargo配置文件指定目标平台。

1
2
3
# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"

移除std依赖

RUST默认使用了标准库std,std提供了许多高级功能,如文件系统访问、线程、网络等,这些功能依赖于操作系统的支持。因此,std只能在支持这些功能的平台上使用。对于不支持操作系统功能的平台(如裸机编程或嵌入式系统),需要显式指定使用core。core是std的一个子集,不依赖于操作系统,提供了Rust语言的核心功能。

  • #![no_std]:告诉 Rust 编译器不使用 Rust 标准库 std。
  • #![no_main]:告诉编译器我们没有一般意义上的 main 函数, 并将原来的 main 函数删除。这样编译器也就不需要考虑初始化工作了。

panic_handler

Rust编译器在编译程序时,处于安全性考虑,要求有 panic! 宏的具体实现。

#[panic_handler] 是一种编译指导属性,用于标记核心库core中的 panic! 宏要对接的函数(该函数实现对致命错误的具体处理)。该编译指导属性所标记的函数需要具有 fn(&PanicInfo) -> ! 函数签名,函数可通过 PanicInfo 数据结构获取致命错误的相关信息。

1
2
3
4
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

结合汇编代码

在裸机编程或嵌入式系统开发中,通常需要定义一个自定义的入口点函数。在Rust中,这个入口点函数通常被命名为_start。

在 fn 关键字之前增加 extern 关键字可以用来导入外部符号。
#[no_mangle] 注解来告诉 Rust 编译器不要 mangle 此函数的名称。Mangling 发生于当编译器将我们指定的函数名修改为不同的名称时,这会增加用于其他编译过程的额外信息,不过会使其名称更难以阅读。每一个编程语言的编译器都会以稍微不同的方式 mangle 函数名,所以为了使 Rust 函数能在汇编代码中使用,必须禁用 Rust 编译器的 name mangling。

Qemu启动流程

  1. 将必要文件载入Qemu模拟的物理内存后,PC被初始化为0x1000。然后执行一段指令后跳转到0x80000000。
  2. 运行位于0x80000000的bootloader(这里使用的是rustsbi-qemu.bin),RustSBI将下一阶段的入口地址设置为0x80200000。
  3. 运行0x80200000开始的程序。

调整内核的内存布局

链接器默认的内存布局并不能符合我们的要求,为了实现与 Qemu 正确对接,我们可以通过链接脚本(Linker Script)调整链接器的行为。

首先通过cargo配置文件指定链接脚本:

1
2
3
4
5
6
7
// os/.cargo/config
...
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
# 强制打开 fp 选项,避免 fp 相关指令被编译器优化掉
]

在链接脚本中指定起始地址为0x80200000,:

1
2
3
4
5
6
7
8
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80200000;
SECTIONS
{
. = BASE_ADDRESS;
...
}

elf文件中除了实际会被用到的代码和数据段之外还有一些多余的元数据,这些元数据无法被 Qemu 在加载文件时利用,且会使代码和数据段被加载到错误的位置。需要通过rust-objcopy --strip-all丢弃这些元数据。

函数调用栈

Chapter 2

配置环境一章给了 GDB 预编译二进制的下载链接,但版本不支持Python脚本。为了提高调试效率,需要自己编译新版本的 GDB。

查看汇编代码发现在rust_main函数中找不到对trap::init函数的调用,初始化CSR stvec的代码被直接内联汇编到了rust_main中。(相比与C语言,要将RUST源码与对应的汇编代码对应起来的难度明显更大,也可能是因为目前对RUST的机制还不太熟悉)

#[link_section = ".text.entry"]宏将编译后的汇编代码放在名为 .text.entry 的代码段中, 供链接脚本使用。

#![feature(linkage)]启用弱链接特性

#[linkage = "weak"]在有多个同名符号时优先使用其他符号

将应用程序链接到内核

在本章中,通过global_asm!(include_str!("link_app.S"));将应用程序的二进制镜像文件作为内核的数据段链接到内核里面。

UPSafeCell

有一些变量(例如本章中的APP_MANAGER)需要作为全局变量使用,但有需要在运行时修改部分成员变量。我们为这些变量将RefCell封装成了UPSafeCell,并为UPSafeCell添加了Sync标记,从而允许我们在单核上安全使用可变全局变量。

1
2
3
4
5
// os/src/sync/up.rs
pub struct UPSafeCell<T> {
inner: RefCell<T>,
}
unsafe impl<T> Sync for UPSafeCell<T> {}

lazy_static!

1
2
3
4
5
6
// os/src/batch.rs
lazy_static! {
static ref APP_MANAGER: UPSafeCell<AppManager> = unsafe { UPSafeCell::new({
...
})};
}

手动实现全局变量初始化有诸多不便之处,比如需要把这种全局变量声明为 static mut 并衍生出很多 unsafe 代码。在这里借助lazy_static!宏来简化代码。

刷新指令缓存(i-cache)

OS 在加载完应用代码后必须使用取指屏障指令 fence.i ,它的功能是保证 在它之后的取指过程必须能够看到在它之前的所有对于取指内存区域的修改 ,这样才能保证 CPU 访问的应用代码是最新的而不是 i-cache 中过时的内容。

特权级切换

RISC-V 提供了新的机器指令:执行环境调用指令(Execution Environment Call,简称 ecall )和一类执行环境返回(Execution Environment Return,简称 eret )指令。其中:

  • ecall 具有用户态到内核态的执行环境切换能力的函数调用指令;
  • sret :具有内核态到用户态(从Superivisor模式返回)的执行环境切换能力的函数返回指令。

操作系统需要负责特权级切换时的上下文保存和恢复。

相关的控制状态寄存器:

  • sstatus:SPP等字段给出切换前的特权级等信息
  • sepc:当Trap是异常时,记录Trap发生前的最后一条指令地址
  • scause:Trap原因
  • stval:Tral附加信息
  • stvec:Trap处理代码的入口地址

当 CPU 执行完一条指令并准备从用户特权级 陷入( Trap )到 S 特权级的时候,硬件会自动完成如下这些事情:

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

Trap上下文

与函数调用类似,在处理Trap之前,需要在某些地方(由操作系统决定)保存必要的寄存器,并在Trap处理结束后恢复这些寄存器。

Trap发生时需要保存的内容:

  • 32个通用寄存器的值(部分寄存器如x0可以不作保存,预留空间主要是为了方便后续编码)
  • sstatus
  • sepc
    scause/stval 的情况是:它总是在 Trap 处理的第一时间就被使用或者是在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。

Trap处理流程

  1. 通过__alltraps保存Trap上下文到内核栈上
  2. 跳转到trap_handler处理Trap
  3. 通过__restore恢复上下文
  4. sret返回

思考:sscratch 是何时被设置为内核栈顶的?
在第一次执行restore时,通过a0传入的参数实际是(KERNEL_STACK.get_sp() - core::mem::size_of::<TrapContext>()) as *mut TrapContext;,并将a0的值赋给了sp。在restore返回之前,先将sp+34*8即TrapContext的大小,然后交换sp和sscratch,从而设置为内核栈顶。

KERNEL_STACK栈顶的TrapContext中,x[1](ra)存储了第一个程序的入口地址,x[2](sp)存储了用户栈顶地址,在__restore执行过程中分别被加载到ra和sp寄存器中,因此当sret返回时就会进入用户态执行第一个程序。

Chapter 3

把应用程序的一次执行过程(也是一段控制流)称为一个 任务 ,把应用执行过程中的一个时间片段上的执行片段或空闲片段称为 “ 计算任务片 ” 或“ 空闲任务片 ” 。当应用程序的所有任务片都完成后,应用程序的一次任务也就完成了。从一个程序的任务切换到另外一个程序的任务称为 任务切换

Task切换与Trap切换主要有以下异同:

  • 与 Trap 切换不同,它不涉及特权级切换;
  • 与 Trap 切换不同,它的一部分是由编译器完成的(可以看作编译时插入额外的汇编代码);
  • 与 Trap 切换相同,它对应用是透明的。

Task上下文

Task切换时需要手动保存的内容:

  • s0~s11
  • ra
  • sp

Task处理流程

  1. 保存当前Task上下文
  2. 加载下一个Task上下文
  3. ret返回

每个任务的TaskContext中,保存的ra初始值都是__restore,因此在第一次通过__switch切换到该任务时,都会跳转到__restore,然后从该任务对应的初始TrapContext中读取程序入口(载入spec寄存器),从而开始运行。

相比于Chapter 2,这里的__restore中不需要mv sp, a0,因为在__switch之后,CPU从TaskContext中读取对应内核栈的栈顶地址,并加载到了sp寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// os/src/task/mod.rs
impl TaskManager {
fn run_next_task(&self) {
if let Some(next) = self.find_next_task() {
let mut inner = self.inner.exclusive_access();
let current = inner.current_task;
inner.tasks[next].task_status = TaskStatus::Running;
inner.current_task = next;
let current_task_cx_ptr = &mut inner.tasks[current].task_cx as *mut TaskContext;
let next_task_cx_ptr = &inner.tasks[next].task_cx as *const TaskContext;
drop(inner);
// before this, we should drop local variables that must be dropped manually
unsafe {
__switch(
current_task_cx_ptr,
next_task_cx_ptr,
);
}
// go back to user mode
} else {
panic!("All applications completed!");
}
}
}

在run_netx_task中,需要手动在__switch前drop获取的TaskManagerInner实例(实际上是通过RefCell的borrow_mut方法获取的TASK_MANAGER.inner的可变借用)。

因为编译器帮我们自动插入的drop会位于作用域的尾部,而在此之前我们就通过__switch切换到了其他任务的执行流中,而在处理其他任务时也可能需要获取TASK_MANAGER.inner的可变借用,因此需要提前调用drop。

中断

当中断产生并进入某个特权级之后,在中断处理的过程中同特权级的中断都会被屏蔽。

Chapter 4

资源获取即初始化 (RAII, Resource Acquisition Is Initialization,指将一个使用前必须获取的资源的生命周期绑定到一个变量上,变量释放时,对应的资源也一并释放。)

alloc 库需要提供给它一个 全局的动态内存分配器 来管理堆空间,从而使得与堆相关的智能指针或容器数据结构可以正常工作。动态内存分配器需要实现它提供的 GlobalAlloc Trait,这个 Trait 有两个必须实现的抽象接口:

1
2
3
// alloc::alloc::GlobalAlloc
pub unsafe fn alloc(&self, layout: Layout) -> *mut u8;
pub unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);

然后只需将我们的动态内存分配器类型实例化为一个全局变量,并使用 #[global_allocator] 语义项标记即可。由于该分配器的实现比较复杂,我们这里直接使用一个已有的伙伴分配器(buddy_system_allocator)实现。#[alloc_error_handler]标识内存分配失败时的处理函数。

分页内存管理

  • 可用的物理内存被分为若干个(物理)页帧(Frame),用物理页号 (PPN, Physical Page Number)标识
  • 每个应用的地址空间可以被分成若干个(虚拟) 页面 (Page) ,用 虚拟页号 (VPN, Virtual Page Number) 标识
  • 根据VPN可以在页表(Page Table)中查询到页表项 (PTE, Page Table Entry),satp寄存器记录了当前所用的一级页表的物理页
  • PTE记录了对应的PPN和相关的控制信息
  • 手动修改PTE后,需要使用 sfence.vma 指令刷新整个 TLB。注:可以在 sfence.vma 指令后面加上一个虚拟地址,这样 sfence.vma 只会刷新TLB中关于这个虚拟地址的单个映射项。

物理页帧管理

栈式物理页帧管理策略

1
2
3
4
5
6
// os/src/mm/frame_allocator.rs
pub struct StackFrameAllocator {
current: usize, //空闲内存的起始物理页号
end: usize, //空闲内存的结束物理页号
recycled: Vec<usize>,
}

物理页帧的生命周期绑定的一个FrameTracker实例上

1
2
3
4
5
6
7
8
9
10
// os/src/mm/frame_allocator.rs
pub struct FrameTracker {
pub ppn: PhysPageNum,
}

impl Drop for FrameTracker {
fn drop(&mut self) {
frame_dealloc(self.ppn);
}
}

这些 FrameTracker 的生命周期进一步绑定到 PageTable 下面。当 PageTable 生命周期结束后,向量 frames 里面的那些 FrameTracker 也会被回收,也就意味着存放多级页表节点的那些物理页帧被回收了。

1
2
3
4
5
// os/src/mm/page_table.rs
pub struct PageTable {
root_ppn: PhysPageNum,
frames: Vec<FrameTracker>,
}

PageTable提供了from_token 方法可以临时创建一个专用来手动查页表的 PageTable ,它仅有一个从传入的 satp token 中得到的多级页表根节点的物理页号,它的 frames 字段为空,也即不实际控制任何资源。from_token创建的PageTable不能用来修改地址映射

地址空间

PageTable 下挂着所有多级页表的节点所在的物理页帧,而每个 MapArea 下则挂着对应逻辑段中的数据所在的物理页帧,这两部分合在一起构成了一个地址空间所需的所有物理页帧。这同样是一种 RAII 风格,当一个地址空间 MemorySet 生命周期结束后,这些物理页帧都会被回收。

Frame->FrameTracker->PageTable/MapArea->MemorySet

应用地址空间:

跳板(Trapmpoline)和TrapContext位于用户栈栈顶,但是并不包含U标志位,只在地址空间切换时才会用到。因为有了分支机制,在Trap上下文保存和恢复时需要同时完成换栈和换页表

在内核代码和应用代码看来,trap.S中的汇编代码都放在最高虚拟页面中(也就是Trapmpoline中),因此进入trap处理流程时CPU指令能够连续执行。

Chapter 5

fork

实现fork时,需要复制父进程的当前状态创建新的TaskControlBlock,主要是复制进程空间、栈和寄存器数据。

exec

实现exec时,直接在当前进程的TaskControlBlock上进行修改。对于系统调用 sys_exec 来说,一旦调用它之后, trap_handler 原来上下文中的 cx 失效了——因为它是用来访问之前地址空间中 Trap 上下文被保存在的那个物理页帧的,而现在它已经被回收掉了。

spawn

实现spawn时,与fork类似的是创建了新的TaskControlBlock,但没有复制父进程的当前状态,而是类似exec从elf文件中读取信息来创建地址空间。

Chapter 6

块与扇区

扇区 (Sector) 是块设备随机读写的数据单位,通常每个扇区为 512 字节。而块是文件系统存储文件时的数据单位,每个块的大小等同于一个或多个扇区。Linux的Ext4文件系统的单个块大小默认为 4096 字节。在 easy-fs 实现中一个块和一个扇区同为 512 字节。

块缓存

  • 位于内存中的缓冲区
  • 内存中同时只能驻留有限个磁盘块的映射
  • 需要写回磁盘才能真正修改持久性存储的数据

easy-fs磁盘布局

  • 超级块 (Super Block)在内存中对应数据结构SuperBlock,SuperBlock与磁盘上的数据结构一致,没有额外的元数据。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // easy-fs/src/layout.rs
    #[repr(C)]
    pub struct SuperBlock {
    magic: u32,
    // 文件系统的总块数,包括非数据块
    pub total_blocks: u32,
    // 索引节点位图区域占据的块数
    pub inode_bitmap_blocks: u32,
    pub inode_area_blocks: u32,
    pub data_bitmap_blocks: u32,
    pub data_area_blocks: u32,
    }
  • 索引节点位图和数据块位图在内存中对应数据结构Bitmap,但Bitmap本是只存储了相关的元数据,即位图的起始块编号以及占据的块数。实际磁盘上的数据载入到内存中的BitmapBlock实例中。

    1
    2
    3
    4
    5
    6
    // easy-fs/src/bitmap.rs
    pub struct Bitmap {
    start_block_id: usize,
    blocks: usize,
    }
    type BitmapBlock = [u64; 64];
  • 索引节点在内存中对应数据结构DiskInode,SuperBlock与磁盘上的数据结构一致,没有额外的元数据。索引节点本身存储的是对应文件/目录的元数据,即大小、数据块索引、类型。DiskInode大小为128字节。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // easy-fs/src/layout.rs
    #[repr(C)]
    pub struct DiskInode {
    pub size: u32,
    pub direct: [u32; INODE_DIRECT_COUNT],
    pub indirect1: u32,
    pub indirect2: u32,
    type_: DiskInodeType,
    }
  • 数据块在内存中对应DataBlock,DataBlock与磁盘上的数据结构一致,没有额外的元数据。数据块中可能是文件的数据,也可能是目录项DirEntry。

    1
    2
    3
    4
    5
    6
    7
    // easy-fs/src/layout.rs
    type DataBlock = [u8; BLOCK_SZ];
    #[repr(C)]
    pub struct DirEntry {
    name: [u8; NAME_LENGTH_LIMIT + 1],
    inode_number: u32,
    }

    Inode 是放在内存中的记录文件索引节点信息的数据结构。block_id 和 block_offset 记录该 Inode 对应的 DiskInode 保存在磁盘上的具体位置方便我们后续对它进行访问。

1
2
3
4
5
6
7
8
// easy-fs/src/vfs.rs
pub struct Inode {
block_id: usize,
// 偏移量单位是字节
block_offset: usize,
fs: Arc<Mutex<EasyFileSystem>>,
block_device: Arc<dyn BlockDevice>,
}

文件描述符

文件描述符表 (File Descriptor Table) ,其中的每个 文件描述符 (File Descriptor) 代表了一个特定读写属性的I/O资源。

为简化操作系统设计实现,可以让每个进程都带有一个线性的 文件描述符表 ,记录该进程请求内核打开并读写的那些文件集合。而 文件描述符 (File Descriptor) 则是一个非负整数,表示文件描述符表中一个打开的 文件描述符 所处的位置(可理解为数组下标)。

Chapter 8

Appendix

RISC-V Notes

寄存器

RISC-V 的寄存器集由 32 个通用寄存器和一些特殊寄存器组成。

通用寄存器

寄存器 名称 用途
x0 zero 常数 0
x1 ra 返回地址寄存器
x2 sp 栈指针
x3 gp 全局指针
x4 tp 线程指针
x5 t0 临时寄存器 0
x6 t1 临时寄存器 1
x7 t2 临时寄存器 2
x8 s0/fp 保存寄存器 0 / 帧指针
x9 s1 保存寄存器 1
x10 a0 函数参数 / 返回值 0
x11 a1 函数参数 / 返回值 1
x12 a2 函数参数 2
x13 a3 函数参数 3
x14 a4 函数参数 4
x15 a5 函数参数 5
x16 a6 函数参数 6
x17 a7 函数参数 7
x18 s2 保存寄存器 2
x19 s3 保存寄存器 3
x20 s4 保存寄存器 4
x21 s5 保存寄存器 5
x22 s6 保存寄存器 6
x23 s7 保存寄存器 7
x24 s8 保存寄存器 8
x25 s9 保存寄存器 9
x26 s10 保存寄存器 10
x27 s11 保存寄存器 11
x28 t3 临时寄存器 3
x29 t4 临时寄存器 4
x30 t5 临时寄存器 5
x31 t6 临时寄存器 6

特殊寄存器

  • PC(程序计数器):保存当前执行指令的地址。
  • CSR(控制和状态寄存器):用于存储控制和状态信息,如异常处理、性能计数等。

寄存器分类

  • 零寄存器(x0):始终为零。
  • 返回地址寄存器(ra):用于保存函数返回地址。
  • 栈指针寄存器(sp):指向当前栈顶。
  • 全局指针寄存器(gp):指向全局变量区域。
  • 线程指针寄存器(tp):指向线程本地存储。
  • 临时寄存器(t0-t6):用于临时数据存储,不需要在函数调用间保存。
  • 保存寄存器(s0-s11):需要在函数调用间保存的数据。
  • 函数参数寄存器(a0-a7):用于传递函数参数和返回值。

C语言调用规范

  • a0a7( x10x17 )调用者保存,用来传递输入参数。其中的 a0 和 a1 还用来保存返回值。
  • t0t6( x5x7,x28~x31 )调用者保存,作为临时寄存器使用,在被调函数中可以随意使用无需保存。
  • s0s11( x8x9,x18~x27 )被调用者保存,作为临时寄存器使用,被调函数保存后才能在被调函数中使用。
  • ra( x1 ) 是被调用者保存的。
  • sp( x2 ) 是被调用者保存的。这个是之后就会提到的栈指针 (Stack Pointer) 寄存器,它指向下一个将要被存储的栈顶位置。
  • fp( s0 ),它既可作为s0临时寄存器,也可作为栈帧指针(Frame Pointer)寄存器,表示当前栈帧的起始位置,是一个被调用者保存寄存器。fp 指向的栈帧起始位置 和 sp 指向的栈帧的当前栈顶位置形成了所对应函数栈帧的空间范围。
  • gp( x3 ) 和 tp( x4 ) 在一个程序运行期间都不会变化,因此不必放在函数调用上下文中。

指令

  • Load Immediate: li x16, 0
  • Load Address: la rd, symbol
  • Store Word: sw value, dst_addr
  • Wait For Interrupt: wfi
  • Add Upper Immediate to PC: auipc rd, imm
  • Jump And Link Register: jalr rd, (imm[11:0])rs

Rust Notes

胖指针(Fat Pointer)

sys_write 使用了一个 &[u8] 切片类型来描述缓冲区,这是一个 胖指针 (Fat Pointer),里面既包含缓冲区的起始地址,还包含缓冲区的长度。可以分别通过 as_ptrlen 方法取出它们并独立地作为实际的系统调用参数。

#[derive]

通过 #[derive(...)] 可以让编译器为自定义类型提供一些 Trait 的默认实现。

  • 实现了 Clone Trait 之后就可以调用 clone 函数完成拷贝;
  • 实现了 PartialEq Trait 之后就可以使用 == 运算符比较该类型的两个实例。
  • Copy 是一个标记 Trait,决定该类型在按值传参/赋值的时候采用移动语义还是复制语义。

很早之前在翻一个朋友的 GitHub 的时候偶然看见了 rCore-Tutorial-V3 这个项目,今年 9 月便趁着空闲开始学习。因为 rCore-Tutorial-V3 中有不少的测试用例缺失,所以我便试着在 LearningOS 的仓库中找以前训练营的测试用例,然后偶然发现今年的训练营已经开营了,便毫不犹豫参加了。

因为很早就已经学过 Rust 并且运用在项目当中了,第一阶段算是对 Rust 的复习了,因此花了大改四个小时就完成了。

前三个实验总体来说还是比较容易的,只有在 Lab3 中需要为进程TCB实现 core::cmp::Ord 遇到了一点麻烦,通过查阅官方文档解决了。在第四个实验的时候就感觉有些不太理解了,因此我查阅了一些操作系统的教材和教学视频才能理解。而第五个实验中的算法,一开始我误认为是银行家算法,然后发现程序没有也无法提供最大需求资源 Max,因此稍稍转变思路成功完成了实验。

经过第二阶段的学习我学习了操作系统的基本概念,并且动手实践编写了一些操作系统功能,我觉得受益良多。