2024春季开源操作系统训练营第一阶段总结报告-charain
报告编写时间好像有点晚
第一阶段总结报告
大概是寒假的时候,在网络上寻找一些学习操作系统的资料时,接触到了rcore lab和这个训练营,然后看到有一起学习的群友报名了这个训练营之后,立马顺着链接跟过来了。
在此之前并没有编写过类似的操作系统,了解学习过一些操作系统的知识,而且也对rust不是很熟悉。
rust语言
在参加这个训练营之前,是尝试学习过rust的,当时rust吸引我的原因不是因为它在内存安全方面的独特特性,而是它是一门现代的,系统级编程语言。而我确实也需要这样的语言(尝试为c语言寻找更加现代的替代)。不过当时由于rust特有的难以入门的特性和陡峭的学习曲线,随后便暂时放弃了这个语言的学习。
在参加了这个训练营之后,重新捡起来rust语言,发现也并不是想象中的那么可怕,其语言特性的设计理念,也可以理解。而且从这里,也进一步的体验到了其作为一门现代语言的好处。例如包管理器(安装新的工具链太方便啦),更好的lsp服务器(感觉比clangd强),以及语言提供的恰到好处的抽象设计,都让人感到很舒心。而且作为一门系统级语言,也很方便的可以在之后帮助我编写运行在一些裸机上的应用程序。
rustlings
这个练习前半部分基本都是和语言特性相关的内容,做起来还是很迅速的,基本上没有花费很长的时间便完成了这一部分的内容。但是对于后面的算法题部分,便开始困难起来了。倒不是这些数据结构和算法不清楚,如果使用c语言实现它们,那么将易如反掌,但是对于自己不熟悉,且时时刻刻强调内存安全的rust语言而言,便显得很困难。好在在查阅资料和不断的尝试下,还是将这一部分内容给完成了。
第二阶段总结报告
第二阶段的前三个实验,基本上是连着几天完成的,最后两个实验拖慢了几天。
总的来说,对于这一部分,还是学到了很多知识。
首先说一说riscv方面吧,对于这一方面相对更加熟悉,之前也编写过riscv的处理器和模拟器,并且也使用c语言编写过裸机应用,对于riscv相关的知识方面,还是很顺滑的便掌握了。
对于rust语言,写到这个阶段已经比第一阶段顺手多了,不过在使用这个语言时使用的很多设计理念,例如资源获取即初始化,等等设计方面的考量,还是学习到了很多。或许在完成这一部分内容之后,可以试着使用rust去重构之前写过的一些c语言项目了(乐)。
随后是对于操作系统相关的知识,之前学习过一些操作系统的概念和知识,但是对于真正的将操作系统实现出来,还是第一次尝试做出来。以前学习操作系统时还尝试看过一些linux内核设计相关的书籍,最后的结果是看的比较稀里糊涂。通过这次操作系统设计之后,或许之后入门linux能够更加顺利一些。
2024春夏季训练营第一阶段总结-qiuanran
前言
在去年秋天听闻了这个训练营,机缘巧合之下结识了Rust和rcore。 也许是由于rust这门语言入门门槛比较高,加之当时没有其他更modern语言的学习经验,只做完Rustlings就已经打算放弃了。从去年秋天到现在一直在写Rust,也了解了许多系统方面的知识,就打算在这次训练营中弥补一些遗憾。
第一阶段
心得
第一阶段更多的是教初学者通过查手册、book或者是问AI来逐渐了解Rust的各种特性,如所有权机制(一个value只能对应一个所有者)、RAII机制(自动回收)、智能指针(个人认为智能指针的学习更多的应该落实于应用场景:即它为什么而存在的问题)、生命周期(我认为它的存在更多的是帮助编译器编译)等,这些是Rust比较有意思的机制,也是大大保障了代码安全性的由来。
体验
有了去年以来一直积累的Rust经验,做Rustlings倒是非常快了。最后出的十道算法题非常有意思,大大加深了我对于智能指针的认识,虽然都是入门的算法题倒算是个很新奇的体验。
资料
由训练营给出的学习资料,同时也推荐Stanford的CS110L。
第二阶段
学习过程
rcore这个类Unix内核对于了解各种OS的各种核心内容而言是非常有帮助的,实际上,若不是有了Rust的一些经验,很难理清由理论转换为具体实现的过程。
对于rcore的学习,刚开始我十分痛苦,看着后面的实验要求一时间无计可施,只能由各种已经实现的syscall一个个去trace他们的调用关系,才有了一点点的大致思路。但具体实现起来又有其他的问题,这时候我的目光就放在了具体的数据结构上,需要彻底弄明白每一个数据结构的具体功能、与其他数据结构的关联,才能够说很好的掌握了整个流程。
rcore总结
- Chapter1-2:需要关注于环境如何一步步搭建,如何切换特权级
- Chapter3:难点在于理解任务切换时候的流程:trap上下文的保存。应用在内核中处理时, 其 Trap 控制流调用__switch函数,这个函数非常重要,里面就是任务的上下文寄存器。
- Chapter4:对于我而言,虚拟内存几乎是最难的一个章节了,以至于看到实验要求的时候说注意数据被切分成两个不同的页表中时我甚至不知道它想表达什么,反复观看、查找资料之后才能艰难完成。
- Chapter5:关键在于理解有关进程的每一个数据结构,以及它的调度机制,重点需要弄明白suspend_current_and_run_next这个函数以及整个task的工作流程:take_current_task 来取出当前正在执行的任务,修改其进程控制块内的状态变成Running,随后将这个任务放入任务管理器的队尾。接着调用 schedule 函数来触发调度并切换任务。
- Chapter6:文件系统相比起前面的章节有些异常庞大了,需要抓住核心抽象以及它的层次结构。
- Chapter7:了解进程之间如何通信,关注于各种不同的通信方式。
- Chapter8:并发是OS绕不开的一个话题,掌握各种锁+线程的概念对于理解并发有奇效,但其实Rust的很受欢迎的异步并发并没有提及,想来是语言特性而不属于OS教学层面上的。推荐一个了解Rust异步并发非常好的读物:Async in depth | Tokio - An asynchronous Rust runtime
一些心得
我觉得最重要的,也是支撑我艰难走过rcore这道难关的就是一个观念,在南京大学的OS课程上反复强调的:操作系统就是一个应用程序。这也是我在进行每一个lab后最大的感触。在接管了trap之后,所有的一切都是逻辑上的开发,与不同的应用程序没有什么区别。
资料
- 南京大学OS课程
- MIT6.S081: xv6 book
- CSAPP
opencamp-24sp-第一二阶段总结
DAY0-DAY?
0 前言
本篇内容为基础阶段 - Rust编程
的总结,分为环境配置
及语言学习
两个板块。
因为语言学习涉及内容较为零散,故将DAY0-DAY?汇聚成一篇。
1 环境配置
1.0 前言
环境配置部分主要流程就是根据教程内容一步步安装,同时需要注意以下几点:
1.2 配置rust环境
按照LearningOS仓库中readme指定的教程一步步操作即可
注意没有curl
需要先通过如下命令安装(其他同理):
1 | sudo apt install curl |
1.3 配置git+github ssh并clone代码
之前配置过,故省略
注意:配置ssh后,git push到github时若仍然提示输入密码,可修改.git/config文件中的url,从HTTPS https://github.com/achintski/opencamp-24sp-daily.git 更新为SSH git@github.com:achintski/opencamp-24sp-daily.git
2 rust语法学习及rustlings
2.0 前言
- 初学一门新语言,通常可以采用孟岩老师提倡的“快速掌握一个语言最常用的50%”方法,快速入门
- 但注意到rust语言的特殊性以及接下来rCore的开发的复杂性,我们必须还要通过阅读系统而权威的大部头书籍来全面深入地学习
- 同时,一些特别细节的点在入门资料和书籍中未必涉及,需要我们查阅官方文档
因此我穿插查阅/参考了如下资料:
- 快速入门:
- 菜鸟教程
- 《Rust语言圣经》(最推荐,整合了很多其他的经典资料)
- 官方文档:
- Slides:
- Books:
并通过穿插完成rustlings
作业进行巩固练习
2.1 rust语法
2.1.0 前言
注意:第一次学一个概念时一定要打好基础,不要为了追求进度而忽略质量
2.1.1 常见内容
HelloWorld
类型
*原生类型* 布尔 bool:两个值 true/false。 * 字符 char:用单引号,例如 'R'、'计',是 Unicode 的。 * 数值:分为整数和浮点数,有不同的大小和符号属性。 * i8、i16、i32、i64、i128、isize * u8、u16、u32、u64、u128、usize * f32、f64 * 其中 isize 和 usize 是指针大小的整数,因此它们的大小与机器架构相关。 * 字面值 (literals) 写为 10i8、10u16、10.0f32、10usize 等。 * 字面值如果不指定类型,则默认整数为 i32,浮点数为 f64。 * 数组 (arrays)、切片 (slices)、str 字符串 (strings)、元组 (tuples) * 指针 & 引用 * 函数 * 闭包
- 组合类型
- 结构体(逻辑与)
- 标签联合(逻辑或)
- 组合类型
条件
循环
结构化数据
IO
2.1.2 特殊内容
Q:任何行为背后都有动机,Rust特性这样设计的动机是什么呢?
变量绑定–
let
不可变变量 vs 常量
语句 vs 表达式
所有权 & 生命周期
- 高级语言
Python/Java
等往往会弱化堆栈的概念,但是要用好C/C++/Rust
,就必须对堆栈有深入的了解,原因是两者的内存管理方式不同:前者有GC
垃圾回收机制,因此无需你去关心内存的细节。 - 在所有权模型下:堆内存的生命周期,和创建它的栈内存的生命周期保持一致
copy
/move
borrow
(借用)- 借用
&
与可变借用&mut
- 借用规则
- 借用
- 函数的参数和返回值与变量绑定的规则相同
- 高级语言
2.2 rustlings
大致步骤:终端输入命令rustlings watch
、修改代码、删掉注释并自动跳转下一题
注意:后两个资料中概念介绍顺序和习题涉及概念顺序一致
技巧:学会分析编译器提示,有的题目是语法错误,有的是考虑不周导致测试过不去
vec2
enums3
strings3&strings4
into()
hashmaps2
HashMap
的get()
方法只能返回值的引用- 解引用操作
*
也需要转移所有权
quiz2
- match中模式绑定的值,如:
Command::Append(n) => {}
中的n是&usize类型,可以使用for i in 0..*n
完成遍历 - 对于
不可变引用的string
,要想修改需要先将其clone
给一个可变变量
- match中模式绑定的值,如:
options1
- 可用
match
,也可以用if
- 可用
options2
- 注意
Options枚举
的本质目的:解决Rust
中变量是否有值的问题,因此第二个任务中需要两层嵌套的Some
if let
/while let
本质上是match
match
中匹配后的绑定
- 注意
options3
- 在
Some(p) => println!("Co-ordinates are {},{} ", p.x, p.y)
处,value partially moved here;而在最后返回值y
处,value used here after partial move - 因此需要:borrow this binding in the pattern to avoid moving the value
- 区分
ref
和&
- 在
errors2
- 对于返回结果是
Result
的函数,一定要显式进行处理 ?
操作符(本质是宏,同时可以链式调用)- 作用:提前传播错误
- 场合:返回值是 Result 或者 Option 函数中
- 对于 Result 类型,
- 如果是 Err 则提前返回,当前函数立即返回该错误。
- 否则,从 Ok 中取出返回值作为 ? 操作符的结果。
- 对于 Option 类型,
- 如果是 None 则提前返回,当前函数立即返回 None。
- 否则,从 Some 中取出返回值作为 ? 操作符的结果。
- 本题既可以使用
?
操作符,也可以使用match
- 对于返回结果是
errors3
?
操作符只能在返回值为Result
/Option
的函数中使用- 需要修改三处:
use std::error::Error;
- 给
main
函数增加返回值-> Result<(), Box<dyn Error>>
- 末尾返回
Ok(())
- 也可以用
match
/if else
errors4
- 法一:利用
match guard
- 法二:
if
&else
- 法一:利用
errors6
- impl
- rust中对象定义和方法定义是分开的
- 一个/多个
impl
模块 self
、&self
和&mut self
及所有权self
表示实现方法的结构体类型的实例
的所有权转移到该方法中,这种形式用的较少&self
表示该方法对实现方法的结构体类型的实例
的不可变借用&mut self
表示可变借用
- 闭包
- 可以看做匿名函数
- ||中间放参数
- map_err()
- 用来处理Err类型的变量
- 参数是函数/闭包
PositiveNonzeroInteger::new(x).map_err(ParsePosNonzeroError::from_creation)
仅可以用来处理x<=0的情况,而x非数字的情况无法处理?
操作符:Err
/None
类型直接立即结束,提前返回;否则从Ok
/Some
中取出返回值作为?
操作符的结果- 或者用match平替
- impl
generics1
&str
vsString
- 也可以用
_
来让编译器自动推断
generics2
- 泛型可以类比多态
- 结构体中的泛型 & 方法中使用泛型
- 例如(from Rust Course):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}- 使用泛型参数前,依然需要提前声明:
impl<T>
- 上述代码中,
Point<T>
不再是泛型声明,而是一个完整的结构体类型,因为我们定义的结构体就是Point<T>
而不再是Point
- 除了结构体中的泛型参数,我们还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样
- 对于
Point<T>
类型,你不仅能定义基于T
的方法,还能针对特定的具体类型,进行方法定义
- 使用泛型参数前,依然需要提前声明:
trait(特征)
impl Trait for Type
:为Type类型实现Trait特征- 特征定义与实现的位置(孤儿规则)
- 方法的默认实现 vs 重载
impl Trait
- 作为函数参数
impl Trait
此时为语法糖,可用特征约束
写出完整版多重约束
:impl T1 + T2
- 作为函数返回值(只能返回某一种类型)
- 作为函数参数
quiz3
- restricting type parameter
T
:impl<T: std::fmt::Display> ReportCard<T> {...}
(根据编译器help提示)
- restricting type parameter
lifetims1
- 标记的生命周期只是为了取悦编译器,告诉编译器多个引用之间的关系,当不满足此约束条件时,就拒绝编译通过;并不会改变任何引用的实际作用域
- 和泛型一样,使用生命周期参数,需要先声明
<'a>
- 本题中:把具体的引用传给
longest
时,那生命周期'a
的大小就是x
和y
的作用域的重合部分,换句话说,'a
的大小将等于x
和y
中较小的那个 - 编译器提示:
1
2
3
4help: consider introducing a named lifetime parameter
|
13 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
lifetimes2
- 在
longest
函数中,string2
的生命周期也是'a
,由此说明string2
也必须活到 println! 处,可是string2
在代码中实际上只能活到内部语句块的花括号处}
,小于它应该具备的生命周期'a
,因此编译出错(编译器没法知道返回值没有用到string2
) - hint:
Remember that the generic lifetime ‘a will get the concrete lifetime that is equal to the smaller of the lifetimes of x and y.
You can take at least two paths to achieve the desired result while keeping the inner block:- Move the string2 declaration to make it live as long as string1 (how is result declared?)
- Move println! into the inner block
- 在
lifetimes3
- 结构体中生命周期标注:
- 对结构体本身类似泛型:
<'a>
- 对结构体成员:
&'a
- 对结构体本身类似泛型:
- 结构体成员引用的生命周期要大于等于结构体
- 结构体中生命周期标注:
lifetime others
- 输入生命周期 & 输出生命周期
- 生命周期的消除
- 方法中的生命周期(类似泛型参数语法)
impl
中必须使用结构体的完整名称,包括<'a>
,因为生命周期标注也是结构体类型的一部分!- 方法签名中,往往不需要标注生命周期,得益于生命周期消除的第一和第三规则
- 静态生命周期
tests4
#[should_panic]
:The test passes if the code inside the function panics; the test fails if the code inside the function doesn’t panic.
<!– * tests5(自动进行时这个被跳过了,直接到了迭代器)- unsafe函数:
- 使用
unsafe fn
来进行定义 - 这种定义方式是为了告诉调用者:当调用此函数时,你需要注意它的相关需求,因为Rust无法担保调用者在使用该函数时能满足它所需的一切需求
- 因此在使用
unsafe
声明的函数时,一定要看看相关的文档,确定自己没有遗漏什么 –>
- 使用
iterators1
- Rust的迭代器是指实现了
Iterator trait
的类型 - 最主要的一个方法:
next()
- 对迭代器的遍历是消耗性的
- 返回的是
Option<Item>
类型,当有值时返回Some(Item)
,无值时返回None
- 手动迭代必须将迭代器声明为
mut
可变,因为调用next
会改变迭代器其中的状态数据(当前遍历的位置等),而for
循环去迭代则无需标注mut
,因为它会帮我们自动完成
- Rust的迭代器是指实现了
iterators2
iter()
Iterator adapters
(迭代器适配器)- Adapters operate on an iterator and return a new iterator
- 是惰性接口:iterators are lazy and do nothing unless consumed
- 常见的有:
map()
、filter()
、take()
、zip()
、rev()
- 需要一个消费器来收尾,例如:
collect()
、sum()
、any()
- 注:如果集合里的类型是非
Copy
类型,消费者在取得每个值后,在迭代器被清除后,集合里的元素也会被清除。集合会只剩“空壳”,当然剩下的“空壳”也会被清除 - 迭代器是可组合的
一个例子:
1
2let v1: Vec<i32> = vec![1, 2, 3];
v1.iter().map(|x| x + 1);map 函数的闭包并没有获得迭代器的所有权。具体解释如下:
v1.iter()
创建了一个针对v1
中元素的迭代器。这个迭代器是对v1
的不可变引用,也就是说,它拥有对v1
中元素的借用权,但并不拥有所有权。map(|x| x + 1)
是对上述迭代器应用的一个闭包。闭包内部的|x| x + 1
表示对迭代器产生的每个元素x
加上 1。在这个过程中,闭包接收的是x
的不可变引用,同样没有获取x
或迭代器的所有权。综上所述,闭包并未获得迭代器的所有权。它只是在
map
函数执行期间,对迭代器提供的每个元素借用并进行计算。一旦map
函数结束,闭包对元素的借用也随之结束,不会影响到v1
或其迭代器的所有权状态。
…
如果您想让闭包返回的新值形成一个新的集合(如 Vec),您需要调用 collect() 方法来完成这一过程: 1
let incremented_values: Vec<i32> = v1.iter().map(|x| x + 1).collect();
在这里,
collect()
方法会消费map
返回的迭代器,并将其内容收集到一个新的Vec<i32>
中。然而,即使如此,闭包本身仍然没有获得迭代器的所有权,而是collect()
函数在处理过程中获取了所有权并完成了数据的转移。
iterators3
- 从容器创造出迭代器一般有三种方法:
iter()
takes elements by reference.iter_mut()
takes mutable reference to elements.into_iter()
takes ownership of the values and consumes the actual type once iterated completely. The original collection can no longer be accessed.
collect()
会根据函数返回值自动调整格式
- 从容器创造出迭代器一般有三种方法:
iterators4
- 这不让用那不让用,那自然是得用自带的工具咯
- (1..=num).product()
iterators5
- 一点思考:
1
2
3
4
5
6
7
8
9
10
11fn count_for(map: &HashMap<String, Progress>, value: Progress) -> usize {
let mut count = 0;
for val in map.values() {
// 此处为什么不是*val == value呢?
// 下面这中方式中,实际上是在比较两者对应的实体是否相同
if val == &value {
count += 1;
}
}
count
} - 扁平化(Flatten)
1
2
3
4
5
6fn count_collection_iterator(collection: &[HashMap<String, Progress>], value: Progress) -> usize {
collection.iter() // Iterate over the slice of hashmaps
.flat_map(|map| map.values()) // Flatten the values of each hashmap into a single iterator
.filter(|val| **val == value) // Filter values equal to the target value
.count() // Count the filtered values
} - 在上述实现中:
- 首先使用
collection.iter()
创建一个迭代器,它遍历collection
中的每一个HashMap
引用。 - 然后对每个
HashMap
应用flat_map(|map| map.values())
,将每个HashMap
的值迭代器扁平化为单个包含所有HashMap
值的迭代器。
接着使用filter(|val| *val == value)
,筛选出与目标value
相同的Progress
枚举值。 - 最后,通过
count()
方法计算筛选后的元素数量,即符合条件的Progress
枚举值的总数,返回这个计数值作为函数结果。
- 首先使用
- 一点思考:
smart_pointers(智能指针)
- 前言
- 相比其它语言,Rust 堆上对象还有一个特殊之处—它们都拥有一个所有者,因此受所有权规则的限制:当赋值时,发生的是所有权的转移(只需浅拷贝栈上的引用或智能指针即可)
- 例如以下代码:
1
2
3
4
5
6
7
8
9fn main() {
let b = foo("world");
println!("{}", b);
}
fn foo(x: &str) -> String {
let a = "Hello, ".to_string() + x;
a
} - 在
foo
函数中,a
是String
类型,它其实是一个智能指针结构体,该智能指针存储在函数栈中,指向堆上的字符串数据。当被从foo
函数转移给main
中的b
变量时,栈上的智能指针被复制一份赋予给b
,而底层数据无需发生改变,这样就完成了所有权从foo
函数内部到b
的转移。
- 在
Rust
中,凡是需要做资源回收的数据结构,且实现了Deref
/DerefMut
/Drop
,都是智能指针
- 前言
arc1
- 使用
let shared_numbers = Arc::new(numbers);
:将numbers
向量封装在一个Arc<Vec<u32>>
中。 Arc
允许多个线程同时拥有对同一数据的访问权,且其内部的引用计数机制确保数据在所有持有者都不再需要时会被正确释放。这样,numbers
可以在多个线程间共享而无需复制整个向量,既节省了内存,又保证了线程安全性- 使用
let child_numbers = Arc::clone(&shared_numbers);
:创建shared_numbers
的克隆(实际上是增加其引用计数)。每个线程都获得一个指向相同底层数据的独立Arc
thread::spawn
创建一个线程move
关键字:指示闭包在捕获外部变量时采取“所有权转移”策略,而非默认的借用策略join()
方法会阻塞当前线程,直到指定的线程完成其任务。unwrap()
处理join()
返回的Result
,在没有错误时提取出结果(这里没有错误处理,因为假设所有线程都能成功完成)。这样,main()
函数会等待所有子线程计算完毕后再继续执行
- 使用
cow1
threads1
- 如果你想创建新线程,可以使用
thread::spawn
函数并传递一个闭包,在闭包中包含要在新线程 执行的代码。 - 默认情况下,当主线程执行结束,所有子线程也会立即结束,且不管子线程中的代码是否执行完毕。极端情况下,如果主线程在创建子线程后就立即结束,子线程可能根本没机会开始执行。
- 为避免上述情况的发生,我们可以通过阻塞主线程来等待子线程执行完毕。这里所说的阻塞线程,是指阻止该线程工作或退出。
Rust
标准库提供的thread::JoinHandle
结构体的join
方法,可用于把子线程加入主线程等待队列,这样主线程会等待子线程执行完毕后退出。unwrap()
- 如果你想创建新线程,可以使用
threads2
- Mutex确保在某一时刻只有一个thread可以更新jobs_completed
- thread闭包中,使用lock()上锁
- 注意: 如果放到循环内,就是输出10次
1
println!("jobs completed {}", status.lock().unwrap().jobs_completed);
jobs completed x
,x
的值有时候全是10,有时候有一部分是10;放到循环外就是一次jobs completed 10
threads3
- 报错内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16|
29 | fn send_tx(q: Queue, tx: mpsc::Sender<u32>) -> () {
| -- move occurs because `tx` has type `Sender<u32>`, which does not implement the `Copy` trait
...
34 | thread::spawn(move || {
| ------- value moved into closure here
...
37 | tx.send(*val).unwrap();
| -- variable moved due to use in closure
...
42 | thread::spawn(move || {
| ^^^^^^^ value used here after move
...
45 | tx.send(*val).unwrap();
| -- use occurs due to use in closure
error: aborting due to 1 previous error - 分析:
tx
变量move
到第一个闭包后,已经无法在该闭包外获取了,而在第二次进程创建仍然尝试move
。通过为每个变量创建tx
的clone
,我们确保了每个闭包都拥有其独立的sender
,从而避免了use after move
错误
- 报错内容:
macros4
- 每个macro规则后面加;
clippy1
- 根据编译器提示修改
- const PI: f32 = std::f32::consts::PI;
clippy3
- 已经通过
is_none()
检查确保了my_option
是None
,因此接下来不应该再尝试调用unwrap()
Vec::resize
方法用于改变已有向量的长度,如果新的长度大于当前长度,则填充指定的默认值。- 但是,你的用法存在一些问题。首先,你试图创建一个空向量(通过将大小更改为0),但直接使用
resize(0, 5)
并不是正确做法,因为这会让人们以为你要填充默认值5到一个空向量中,而实际上你是从一个非空向量开始的。 - 如果你想从
vec![1, 2, 3, 4, 5]
起始,然后得到一个空向量,你应该直接使用clear
方法,而不是resize
。
- 但是,你的用法存在一些问题。首先,你试图创建一个空向量(通过将大小更改为0),但直接使用
- 已经通过
as_ref_mut
- 根据注释完成
using_as
- 报错内容:
1
2
3
4
5
6
7
8
9
10
11--> exercises/conversions/using_as.rs:17:11
|
17 | total / values.len()
| ^ no implementation for `f64 / usize`
|
= help: the trait `Div<usize>` is not implemented for `f64`
= help: the following other types implement trait `Div<Rhs>`:
<&'a f64 as Div<f64>>
<&f64 as Div<&f64>>
<f64 as Div<&f64>>
<f64 as Div> - 分析:错误提示指出
f64
类型没有实现Div<usize>
trait,因此无法执行除法操作。为了解决这个问题,我们可以将values.len()
转换为f64
类型,以便进行除法运算。
在修改后的代码中,我们使用values.len() as f64
将values.len()
的结果转换为f64
类型,以便与total
执行除法操作。
- 报错内容:
from_into
- 为
Person
结构实现From<&str>
trait
- 为
from_str
- 需要实现
FromStr
trait 来将字符串解析为Person
结构体,并返回相应的错误类型ParsePersonError
- 需要实现
tests5
这段代码中,
modify_by_address
函数使用了unsafe
关键字来标记它的不安全性。根据注释,我们需要在函数的文档注释中提供有关函数行为和约定的描述。以下是修改后的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14/// # Safety
///
/// The `address` must be a valid memory address that points to a mutable `u32` value.
///
/// It is the caller's responsibility to ensure that the `address` is a valid memory address
/// and that it points to a mutable `u32` value. Failing to meet these requirements may lead
/// to undefined behavior, such as memory corruption or crashes.
unsafe fn modify_by_address(address: usize) {
// SAFETY: The caller must ensure that `address` is a valid memory address
// and that it points to a mutable `u32` value.
let value_ptr = address as *mut u32;
let value = &mut *value_ptr;
*value = 0xAABBCCDD;
}在函数的(文档)注释中,我们明确了对
address
参数的要求,即它必须是一个有效的内存地址,并指向一个可变的u32
值。我们还提醒调用者必须满足这些要求,并在不满足要求时可能会导致的未定义行为。在函数内部,我们使用了
address
参数将其转换为一个可变的u32
指针value_ptr
,然后通过解引用该指针获取可变引用value
。最后,我们将value
设置为0xAABBCCDD
。
test6
- 这段代码中,我们需要实现一个
raw_pointer_to_box
函数,它将一个原始指针转换为一个拥有所有权的Box<Foo>
。我们需要根据给定的指针重新构建一个Box<Foo>
对象。
- 这段代码中,我们需要实现一个
test7&test8
- 在
tests7
部分,我们设置了一个名为TEST_FOO
的环境变量,并使用rustc-env
指令将其传递给Cargo。 - 在
tests8
部分,我们使用rustc-cfg
指令启用了名为pass
的特性。
- 在
test9
algorithm1
- Note that we use the
Ord
andClone
traits for the generic typeT
to enable value comparisons and cloning, respectively.
- Note that we use the
algorithm2
定义两个指针
current
和prev
,分别指向当前节点和上一个节点。初始时current
指向链表的头节点,prev
为None
。进入循环,在每次迭代中:
- 获取当前节点的可变引用
node
。 - 将
current
移动到下一个节点。 - 将当前节点的
next
指针指向上一个节点prev
。 - 如果
prev
不为None
,则更新上一个节点的prev
指针指向当前节点。 - 如果
prev
为None
,说明当前节点是新的尾节点,更新self.end
为当前节点。 - 将
prev
更新为当前节点。
- 获取当前节点的可变引用
循环结束后,将
self.start
更新为最后一个节点,即反转后的新头节点。
algorithm9
- 注意:在
next
中,对vec
的处理除了要把最后一个元素拷贝到下标为1
处,还需要把尾部元素用pop()
删除。可以合起来写(后面加一个?
),也可以分开。1
2
3
4
5
6
7
8
9
10
11fn next(&mut self) -> Option<T> {
//TODO
if self.is_empty() {
return None;
}
let result = self.items[1].clone();
self.items[1] = self.items.pop().clone()?;
self.count -= 1;
self.heapify_down(1);
Some(result)
}
- 注意:在
本地通过但autograde没通过:可以在actions-GitHub Classroon Workflow-最新的记录-Autograding complete的第二个日志中查看
rCore-Tutorial-Guide-2024S通关札记
前言
之前在学习操作系统的文件系统部分时,被国内的部分课本深深“恶心”到了,有幸阅读OSTEP以及一些其他的国外著作(如Unix Internals)并走了许多弯路后我发现一个问题:对于初学者来说,操作系统这种非常“工程类”的学科,(入门/初级阶段)应该采取的正确学习思路是用“增量法”,具体来说:就是从一个具有最基础功能的操作系统出发,不断分析现有问题、解决问题从而实现功能上的完善(即增量),这也是符合操作系统发展的历史脉络的。好巧不巧的是,rcore采取的正是这种教学策略:
要是能早点遇到该多好☹…
文档
rcore的文档非常之详细(对比xv6/pintos等),初学者很容易陷入到细节中去,因此在阅读文档/代码前建议先看一下每章的引言(相当于论文的abstract),明确每章要干什么以及代码的大致框架
环境配置
我自己采用的是(之前别的实验就配置好的)wsl2+ubuntu18.04+vscode+git,具体操作步骤文档内容写的很详细,网上也有很多相关教程
18.04安装qemu7.0.0
根据文档一步步操作至:出现报错
sudo apt-get install ninja-build
随后继续按照文档操作
最终:
在bashrc文件中配置路径:
其余部分根据指导书操作即可
rust语言
不建议花费太多时间,个人感觉比较靠谱的策略就是“迅速掌握一门语言的50%”,剩下的内容现学现用(按需调用)
重点:所有权和生命周期、类型系统
推荐资料:rust语言圣经、清华rust课程ppt
ci-user本地测试
在确定好思路后一定要看一下测试代码再动手coding,有些测试并没有覆盖所有情况,因此一些特别繁琐的情况可以忽略掉(不是本次实验的重点)
技能点
锻炼自己快速上手工程类代码的能力
ch3-lab1
1 | struct TaskInfo { |
任务:新增系统调用sys_task_info
分析:sys_task_info本质上是对参数中用户程序传入的指针指向的结构体赋值,因此本次实验的核心围绕这些值展开,需要思考的点就是:这些值要存放在什么地方?什么时候初始化?什么时候更新?为了获取这些值需要设计几个函数,这些函数哪些只能内部使用哪些是开放接口?
具体思维过程:
- 只存储最原始的数据,也就是在
tcb
中存储syscall_times
以及进程首次被调度的时间start_time
,在需要时再将他们拼装成TaskInfo
- 变量初始化时机:
tcb
初始化 - 变量更新时机:所有系统调用中,更新对应的
syscall_times[syscall_id]
用户程序(及first task
)对应的tcb第一次被调用时,更新start_time
(这就需要思考:负责进行任务调度的功能在哪里实现?怎么判断是否为第一次?) - 函数:
- 将重复多次的操作封装成函数
- 将私有变量/函数的操作封装成pub接口暴露给其他模块
- 其他细节:空指针
ch4-lab2
任务:重写sys_get_time
和sys_task_info
,增加sys_mma
p和sys_munmap
分析:因为引入了虚拟内存机制,因此sys_get_time
和sys_task_info
系统调用传入的参数中,指针指向的地址是属于用户地址空间的,无法直接在内核地址空间中使用,需要进行转换。sys_mmap
和sys_munmap
手册里描述的有点模糊,实际上通过阅读样例可知,该系统调用的功能就是以页为单位进行分配/回收,而且不会出现复杂的情况(比如跨越area
,部分可回收部分不可…),关键点就是边界条件、port
以及每次分配/回收都要同时操作pagetable
和areas
,需要注意的点就是接口的设计问题(没有暴露出来的内容,需要用接口传参数进去在本地处理)
具体思维过程:
- sys_get_time和sys_task_info可以参考:
我们平常编写用户程序的代码时,对指向某一类型变量的指针(虚拟地址)使用解引用,可以获得对应类型的变量,这是因为mmu自动帮我们完成了地址转换功能
ch5-lab3
需要移植(兼容lab1/2)的地方:
start_time和syscall_times:tcb中添加,new中初始化,run_task切换进程中更新时间,各个系统调用(位于process.rs和fs.rs)中更新次数
task_info:new定义,更新
mmap和munmap:
系统调用次数更新:update_syscall_times
page_table.rs和memory_set.rs和frame_allocator.rs中:一些用于检查的函数
注:
- 第2-4都位于processor.rs(用于处理当前进程相关的内容)中
- 注意实现细节的变化
- 注意crate管理
- 注意注释标出新增功能,以及impl需要文档注释
需要新增的功能:
- sys_spawn:
- 分析参数路径是否合法(参考exec)
- 返回值是pid/-1
- tcb impl中,实现spawn
- spawn中:(参考new+fork+exec)核心就是分配并初始化tcb,然后更新父进程内容,最后切换
- tcb及其字段的分配参考new(除了tcbinner的parent)
- 父进程更新父子关系、状态、最后加入taskmanager
- 修改processor中的current
- 销毁exclusive变量并进行__switch
- stride:(注意stride scheduling论文中的pass和stride的含义和本实验中相反,这里我们采用的是论文中的定义)
- 变量:
- tcb中新增stride、prio、pass
- 全局变量新增BIG_STRIDE
- 变量初始化:
- prio初始16,pass初始0,stride初始BIG_STRIDE/16
- 变量更新:
- 每次调度后,更新pass+= stride(在run_task中)
- 每次set_prio后,更新stride= BIG_STRIDE/new_prio
- 变量:
一些提交时未考虑到的细节:
- 切换前更新task_inner.start_time
- syscall_read没有更新syscall_times
- sys_spawn的trace中not implement忘记删除
ch6-lab4
框架分析:思考读一个文件时发生了什么?
easy-fs/src
easyfs 文件系统的整体架构自下而上可分为五层:
- 磁盘块设备接口层:/block_dev.rs
- 归根结底是在块设备上以块为单位读写,
- 读写磁盘块设备的trait接口– BlockDevice trait(仅需read_block 和 write_block)
- 块缓存层:/block_cache.rs
- 缓冲区是块设备的上一层,以块为单位管理对应的块缓存
- BlockCache:创建时会触发read_block
- BlockManager:以类似FIFO方式管理BlockCache,被换出时可能触发write_block
- get_block_cache 接口:通过block_id和block_dev参数,在BlockManager中查询对应的BlockCache,如果存在则直接使用,否则加载(核心是new中的block_device.read_block函数,将编号为 block_id 的块从磁盘读入内存中的缓冲区 buf)进BlockManger
- 磁盘数据结构层:/layout.rs /bitmap.rs
- 典型unix布局:超级块+inode位图+data位图+inode分区+data分区
- 表示磁盘文件系统的数据结构:SuperBlock、Bitmap、BlockInode、DirEntry、DataBlock
- 注意:
- 一个BlockCache块缓存对应一个块512B,而一个块中有4个BlockInode
- 对BlockInode添加新的元数据字段需要修改一级索引的长度,以保证总大小为128B
- DiskInode 方法:
- get_block_id:数据块索引功能
- read_at:将dkinode对应的文件从offset字节开始读到buf中(需要先通过get_block_id及索引定位到块号,然后用get_block_cache读入到内存中)
- 磁盘块管理器层:/efs.rs
- 管理磁盘数据结构的控制逻辑
- EasyFileSystem
- 注意从这一层开始,所有的数据结构就都放在内存上了
- 重要方法:
- get_disk_inode_pos
- get_data_block_id
- 索引节点层:/vfs.rs
- 对单个文件的管理和读写的控制逻辑
- Inode(why/how对应DiskInode):通过用efs的get_disk_inode_pos方法和BlockInode的inode_id可以算出该BlockInode所在block的block_id以及磁盘内偏移block_offset,而用get_block_cache接口和block_id以及block_device可以获得对应Block的BlockCache,使用BlockCache的read/modify方法就可以读/写Inode对应BlockInode对应的块缓存中的区域。因此,总的来说定位一个BlockInode需要block_id、block_offset、block_device、fs四个要素,这也正是vfs Inode的组成
- 重要方法:
- read/modify_disk_inode:读/写Inode对应的DiskInode对应的BlockCache区域
easy-fs-fuse
在(linux上的文件模拟出来的)一个虚拟块设备上创建并初始化文件系统
操作系统中对接easy-fs文件系统的各种结构
块设备驱动层
将平台上的块设备驱动起来并实现 easy-fs 所需的 BlockDevice Trait
easy-fs层
借助一个块设备BlockDevice,打开EasyFileSystem文件系统,进而获取Inode数据结构,从而进行各种操作
内核索引节点层
- 将 easy-fs 提供的 Inode 进一步封装成 OSInode
- OSInode 中要维护一些额外的信息
- 文件描述符层
- 常规文件对应的 OSInode 是文件的内核内部表示
- 需要为它实现 File Trait
- 系统调用层
ch8-lab5
任务:sys_enable_deadlock_detect
分析:银行家算法(即通过对已知数据的计算完成死锁判断)很好实现,关键是在何时/以什么形式记录/更新数据
具体思维过程:
- 把银行家算法涉及的所有数组封装成结构体,把相关的操作封装成对应的impl中的函数,并放到os/src/sync下一个新建的rs文件中
- 注意到测试数据中仅考虑了单个用户程序中的死锁问题,并不需要(实际上也无法,因为没有跨用户程序的接口实现锁/信号量/条件变量)考虑用户程序间的死锁,因此我们要以用户程序即pcb为单位进行死锁检测。为此我们要为pcb新增一个字段来容纳死锁检测结构体实例(如果是所有用户程序之间也需要检测,我们可以用
lazy_static
来实现),这涉及了初始化及更新的问题。对于数组的下标,我们用task_id
和sem_id/mutex_id
来区分即可保证唯一性 - 死锁检测结构体初始化需要在
impl
中实现一个new
函数,把数组初始化为0/1,并在tcb
初始化时调用;每次检测后(检测前也可以),我们需要将finish
和work
数组单独初始化一次 - 更新主要围绕
available
、allocation
和need
数组,其中回收时(sem_up/mutex_unlock
)available+1
、allocation-1
,分配时(sem_down/mutex_lock
),若不能分配,则need+1
,若能分配则available-1
、allocation-=1
。所有情况都需要考虑死锁检测,若检测成功则继续,若检测不成功则返回-0xdead(这里我们的实现不够优雅,需要在检测不成功时对数组恢复,实际上优雅的做法是把数组操作放在检测之后进行)。我们必须在最底层函数(sem_up/mutex_unlock/sem_down/mutex_lock
)中实现,因为一但检测失败我们需要停止后续操作并立即返回。 - 一些细节:比如
sem_id/mutex_id
需要用参数传递进去,以及为此需要修改trait
…
2024开源操作系统训练营第二阶段总结-squanchhhhh
1.学习心得
在参加本次课程之前,我正在做MIT6.081的实验,在谁演的过程中,我发现自己对操作系统的了解还不是特别深刻,于是去b站上寻找相关的视频,无意中发现了rcore的课程,便报名参加了,相比起xv6直接给出完整的代码,这个课程的设计可谓亲切许多,通过由浅入深,由表及里的设计,让学生对操作系统的了解更为深刻。
2.学习成果
2.1 gdb
本次实验的成果之一就是对gdb的了解更加深刻,从页表开始,调试程序就变得困难起来,因为不能像第一个实验那样直接顺序执行代码,在切换程序时,会同时切换程序的页表,刚开始也不知道该怎么调试这样的代码,后来发现gdb可以直接将断点设置为*0x0,由于每个用户程序的开始地址都是*0x0,这样当切换进程的时候,就可以很方便的进入用户空间。
2.2 makefile
如果引入本地测试的话,测试程序会修改makefile,这样每次都需要重新编写debug的makefile。
2.3 进程
在学习操作系统理论知识的时候,对进程控制块的理解不是很深刻,知道是一个很重要的组件,但是没有见到它的真实面貌,在实验中,为了给操作系统添加各种功能,也是为进程控制块添加了不少字段,理解更为深刻。
3.总结
在计算机的学习中,动手往往比理论更加重要,做一个操作系统往往效果比学习操作系统理论要好得多。
2024开源操作系统训练营第二阶段总结-陈载熺
第一阶段
rustlings
19号开始,把之前写过的rustlings合并,24号把补充的私货题目写完。
对rust的一些认识
函数式思想
使用中用到函数式思想最多的是迭代器。不同于cpp的迭代器,rust的迭代器更“函数式“一点。比如对迭代器的修饰、map、collect等都很方便。根据the book,rust的闭包抽象成本比较低,“零成本”。
外部符号与二进制文件
补充题中的与Cargo.toml等的联动及外部函数、不混淆函数名宏的使用挺扩展我的认知的。rust提供的相当方便的core的宏(#[repr(c)]等)来控制二进制文件的形式。
所有权和生命周期
之前一直有些不理解,但是看到了一句话“所有权是释放对象的责任”,感觉懂了一点。
rust的宏
很多功能,很黑魔法。翻了下宏的小册子,是在AST上替换的,很有趣,期待看到更多它的功能。
其他材料
- 看了下rCore的发展历史。
- riscv特权寄存器的文档。
- 示例文档中一位学长提到的《深入理解linux内核》中的i386分段部分
总结
初窥门径。感觉rust有很多简洁高效的地方,期待进一步的学习。
第二阶段
实验时间
放完51假期,7号开始写lab,属于是开始的比较迟(都看到有答疑了,没有去看),超了ddl。但是想学不嫌晚,还是写份总结。
印象
总共的5个实验中我印象最深的是fs的实验,让我分开来了Data,Inode,DiskInode,OSInode以及各种数据位图之间的区别,diskinode是存放在硬盘上的索引,inode根据diskinode索引值存在于vfs中的对象,在VFS中本身只持有几个属性。做的时候改了diskInode,加了link,其中对块缓存的读取和写入都是传入函数闭包实现的,很有趣。
还想说下mm的实验,双页表的实现使用恒等映射,将高地址的一定段映射到各个进程及os相同的逻辑地址。这样在切换页表进入内核时,可以得到一个过渡区域来处理上下文。
当然,刚接触ch1和ch2时,读linkld是最初震撼,可惜没有实验,现在有些细节模糊了,还是多做总结。
总结
- 从代码中得到方法。在实现spawn中,仿照fork和exec的实现来照猫画虎,捏了个spawn(id)->tcb来,感触挺大,代码是详细的准确的教材。
- 曳光弹。之前看《程序员修炼之道》时看到的概念,是指开发时优先穿透各个层次直接看到效果。我觉得看代码也可以用这种方式快速把握具体的流程,由功能到达自己熟悉的代码结构。来快速认识代码功能。
- 互操作。灌了很多汇编、二进制、和rust代码的联动。二进制文件的布局、符号等,原来看csapp了解的悬空的“二进制文件”逐渐有了些实际的感觉。
2024开源操作系统训练营第一阶段总结-刘梓陆
2024 开源操作系统训练营第二阶段总结-刘梓陆
写在前面
真是一段很长的旅程啊,怎么说呢,这一阶段的任务我基本上都是“挣扎”着完成的,一开始感觉以我对 Rust 和 OS 的理解,应该此阶段的任务不会非常困难。后来发现还得是“纸上得来终觉浅,绝知此事要躬行”,有些东西在课上学习理论知识和实际使用代码进行实现完全是不一样的感觉,而且怎么说呢,Rust 的运用程度实际上对编写操作系统的帮助也不是特别大,就好像你要和亚里士多德探讨哲学问题,重点是这个哲学问题,而不是古希腊语,当然你得先对古希腊语有一个大致的了解,但是古希腊语本身对理解这个哲学问题是没有什么帮助的。
反正总之由于种种原因,我感觉我在这方面的经验根本就是 0,导致本阶段的任务超出截止时间一周差不多才完成。
但是怎么说呢,就好比是 DND 里面的等级系统一样,在等级为 1 的情况下,升到 2 级所需要的经验值是很少的,所以这个训练营给我的提升感觉也特别大(OS 子职等级),了解到了很多之前根本没有机会接触的知识领域,最重要的,更是加深了我对 os 的理解。
我在第一阶段的总结之中也提到了我非常崇拜 Linux,现在感觉我离他好像又进一步了,感到非常开心!
欢迎交流;-)
在写这篇博客时,本人东北大学大三在读,前两年东学西学,Rust 就是之一,说实话,在本学期开始时,我实在找不到什么东西来做,准备先买一本《趣读 Linux 源码》来看,感谢我学弟翊嘉,是他给我推荐了这个操作系统训练营,这样直接上手操作系统的效果比看书要好太多了,真的十分感谢!
我对 Rust 的理解还是比较少,future、unsafe 的内容更是知之甚少,所以大佬求带!看到我这篇总结报告之中有什么不足也请指出!
- github ID: destinyFvcker
- 邮箱:destinyfunk@163.com
- 微信号:wxid_r3se52q2d4pg22
OS 大概是个什么东西捏?我在完成了这个阶段的任务之后的理解
最近在看 Linus 的自传,他在里面讲了一段关于 OS 的话感觉说得特别好:
计算机上的所有功能要起作用,都得以操作系统为基础。于是,创造一个操作系统就成了终极挑战。你创造操作系统的时候,相当于给所有在这部电脑上跑的程序创造了一个全新的生存环境—从根本上说,其实就是在制定这个世界的规则:什么事可以接受、可以做,什么事不可以接受、不可以做。其实所有的程序都是在制定规则,只不过操作系统制定的是最根本的规则。创造一个操作系统,就相当于自己创造的一方土地制定宪法,而在电脑上跑的程序则相当于各式各样的普通法律。
这段话形象地表达了操作系统的功能、作用、以及它对上层应用程序强大的控制能力,但是实际上感觉在实现了一些东西之后,好像也不完全是这样。
os 下面还有一个 sbi?
原来就是认为是下面这个层级关系:
- 计算机硬件 -> os -> user application
但是在使用 RISC-V 实际上应该是这样的!
- 计算机硬件 -> sbi -> os -> user application
实际上操作系统也要向下请求 sbi 提供的功能!在本次训练营之中一个比较常用的功能——往屏幕上输出字符就是通过 sbi 完成的。实际上完全不止于此,SBI 还会在计算机启动的时候进行它所负责的环境初始化工作,并将计算机控制权移交给内核,操作系统的关闭也是由 SBI 控制的。
从 RISC-V 特权级架构的视角来看,我们编写的 OS 内核位于 Supervisor 特权级,而 RustSBI 位于 Machine 特权级,也是最高的特权级。
说实话,我对 SBI 的理解就到此为止了,在代码之中,很贴心地向我们提供了 SBI 服务调用的接口,我们直接使用就可以了。
这部分我感觉还不是很清楚的内容:
SBI 的内容我感觉真的后续还有必要继续学习一下。
毫无疑问就是一个应用程序
在本阶段的相关代码项目之中,除了一些关键的处理 Trap 和上下文切换的逻辑是使用汇编进行编写的之外,其他都是使用 Rust 编写的、和普通的用户应用程序并没有什么不同。
在实现了地址空间之后,基本上就完全停留在逻辑上的开发了,不再关心操作系统的实现细节,实际上我在实现了进程和文件系统的相关功能之后回头一看都感觉地址空间之中的一些实现细节都开始遗忘了。
这部分我感觉还不是很清楚的内容:
Rust 编译器是如何将这个汇编代码和 Rust 代码链接在一起的?
而且在 Rust 之中是如何调用在汇编代码之中定义的过程的?
通过寄存器和 pc 的指令流
而且我发现真的一个应用程序实际上非常简单,就是一堆指令,pc 拿到之后一条条执行,然后会有一个跳转指令跳转到另外一条指令进行执行,然后指令的操作数都是从寄存器拿出来的,线程或者进程实际上就是一个包装了指令流的上下文,在里面进行了一些簿记工作,记录了当前指令进行到了哪里,然后一些相关寄存器的状态。
二阶段总结报告--c2h4moe
狂赶一周,二阶段也算是终于做完了,这里就说说实验中令我印象比较深的部分吧。
地址空间
操作系统为每个应用程序制造了一种假象,仿佛它们能够独占并随意的使用巨大的内存空间,但实际上,这是硬件分页机制和操作系统维护页表共同营造的幻觉,在加载ELF文件时,操作系统会把程序加载到物理地址中一段空闲的区间,但会让ELF文件中指定的加载地址对应的页表指向真正的物理地址。
我觉得这个实验需要注意很多细节,比如,页表切换是一条指令完成的,如何保证切换完之后依然能够按照我们所期望的执行流继续执行呢?我们开辟了一个“跳板页”,让所有地址空间的最高一页都指向一段相同的物理地址,这样就可以保证页表切换是“平滑的”。
还有,之前想了很久为什么用户态的上下文是存在用户的地址空间而非内核的,后来看实验指导书才明白页表切换和内核栈切换是两步操作,但只有一个sscratch寄存器,无法在不破坏通用寄存器的条件下切换到内核空间并在内核栈保存上下文。
这章还有很多技术上的优化技巧,比如,如果为每个应用都建立一个能够映射全部空间的页表,会占有过大的物理空间,所以,我们不再对每一个地址都映射,而是根据ELF文件的程序头一段段来映射,并且引入类似“字典树”的数据结构来建立多级页表,有效的节省了空间。
并发
实现互斥锁可以有三种方式,一种是在纯软件形式在用户态通过类Peterson算法
实现锁,还有是在硬件的支持下通过原子指令来实现,还可以通过操作系统支持(假设内核进程不被打断)。
这章给我印象比较深的部分是引入互斥锁时举了一个计数器的例子,根据例子运行的异常提供了3个分析思路:是不是编译优化造成的?是不是OS调度造成的?是不是CPU造成的?并且逐一分析,最终找到根本原因,我认为这给我了一个关于系统编程方法论的启发。并且在分析错误原因时通过状态机和合法状态来分析,也是一个很深刻的想法。
总结
除了上面说的一些底层的特性(页表,地址空间切换等等),操作系统大部分也只不过是个普通的应用程序。在看框架代码时,我也学习到了一些编程的技巧和方法,比如,通过drop trait来实现RAII,通过这个rust机制有效的减少了内存分配相关的错误。
总体来说,不管是对OS的理解,还是对rust的掌握,我都进步了很多。
<2024开源操作系统训练营总结报告——Air-Suck>
2024春夏季开源操作系统训练营总结报告
第一阶段总结报告
前言
作为一名大二在读生,在听同学说有一门语言很炫酷的时候,就算是面对着巨大的课业压力,我还是选择来看看rust到底是什么样的一门语言。
Rustlings总结
刚刚接触rust语言时,我并没有意识到rust的强大之处,认为它只是一门新的语言罢了。但是当我跟着文档一步步去接触rust中全新的概念时,我才发现这门语言与其他的编程语言有多大的区别。
从rust变量的所有权与借用规则,再到后面的智能指针和生命周期,每一个特性都是那么晦涩难懂。我甚至在刚刚接触这些特性时,认为这些特性就是在束缚我,让我没法自由自在的编程。我甚至真的想过直接给我的所有rust代码套上一个unsafe。
但是没有束缚的自由也确实不是真正的自由。随着学校课程,尤其是操作系统课程的推进,让我知道了rust的所有权、借用规则以及智能指针,都是为了从语言层面提高整个计算机系统的性能,包括但不限于防止内存泄漏、不让指针乱飞、实现共享区互斥访问等。让我不用像写C语言一样,总是要考虑堆上变量有没有free,指针到底是几级指针,到底指的是什么玩意。不得不承认,经过rustlings110道练习的磨炼,我意识到,rust在通过它的特性,让我更自由的编程。
从头开始学习一门语言是不容易的,尤其是学习像rust这样特性贼多的语言。我在这里要感谢一位之前一直被我忽视的一位朋友——编译器。在之前编程时,不论是写C还是写Java,又或者是写Go,我总是认为编译器只不过是在检查我的语句是不是合法罢了。但是在写rust时,我就深深感受到,没有rust编译器,单单靠我一团浆糊的脑子和厚厚的文档,我是学不会rust的,甚至说都难以写出符合rust语法的程序。每次的报错都让我能够更深地了解rust的语言特性,每次的help,都能让我精确无误的修改错误。编译器好像一直在对我说:“嘿,跟着哥,哥带你学rust。”
希望我能够在接下来的阶段乃至之后与rust同行的时间里,跟着rust编译器,不断加深对rust的理解。
嘿,把rust当做母语真的很酷好吗。
第二阶段总结报告
前言
进入到第二阶段的同时,学校其实也开始进入了期末考试的阶段,每天我说的最多的一句话就是:汗流浃背了。但是好在,我还是走完了这一艰难但是有意义的阶段。
1-2 应用程序基本执行环境与批处理系统
虽然第一章和第二章并没有让我动手实践,但是看着文档一步步从搭建最小环境,再到实现自己的print,再到实现riscv特权级的切换。很难想象这是我在两章里面能学到的东西。
3 多道程序与分时多任务
从这里开始,我终于能上手碰碰操作系统了。
这个实验主要是要让我通过rust实现一个任务状态查询的系统调用。我最自然的想法是,任务状态一定是跟任务绑定的,所以我直接在TCB中新增了一个计算系统调用的成员变量。实现下来也是非常自然顺畅的。但是我知道这个方法应该不是最好的方法,毕竟要在TCB中创建一个挺大的数组,而TCB是属于操作系统内核的,并且操作系统中可能有许多的任务,这样就会导致操作系统内核比较臃肿。
4 地址空间
这一章就涉及到了我之前很少接触到的部分——虚拟地址空间。
从sv39多级页表机制到内核与应用的地址空间,这些内容都挺让我头大的。虽然在进入第四章时走了点弯路,编写代码的时候甚至没有涉及到逻辑段。当后来发现其实框架代码中有一些函数接口可以直接调用(如:shrink_to、append_to、insert_framed_area等),我就开始吐槽之前浪费了太多的时间。但是现在想想,这一段小插曲让我对虚拟地址空间有了更深的了解:从分配物理页帧,到建立虚拟地址和物理地址之间的映射,再到用户程序逻辑段的构建。艰难但有意义。
5 进程及进程管理
本章主要实现操作系统中一个重要的系统调用——spawn。此外还涉及到了一种调度算法——stride调度算法
spawn与fork+exec的最大的区别就是,spawn并不需要像fork一样完全复制父进程的地址空间,并依此再创建一个TCB,然后再通过exec将TCB重写为需要执行的用户程序的TCB。它是直接根据新的用户程序创建一个TCB,省去了重写TCB的开销。所以如果要实现一个spawn系统调用,就只需要模仿fork来实现就好了。
而stride调度算法就是在TCB中增加两个成员stride和priority,在不考虑性能的情况下,只需要遍历就绪队列并执行 stride最小的任务即可。但是受于双端队列的限制,就只能从队列中取出一个TCB然后进行比较,如果stride比当前已经选出的TCBstride还大的话就重新将其放回就绪队列,这样可行,但是会导致更大的系统开销。
一些注意点
- 由于系统终止一个进程的时候是根据TCB的Arc指针强引用数量来判断的,所以很多地方就不能让编译器帮我drop变量,而是需要进行显式的变量drop。
6 文件系统与I/O重定向
在这一章的实验中我对操作系统中的另一个重要部分——文件系统有了基本的认识
从最底层的块设备、缓存,再到上层的文件系统以及操作系统相关的系统调用,学习下来我的感觉就是——多且杂,搞不明白为什么要分这么多层。但是现在回想一下这一切都是有意义的,这些分层让文件系统的不同层的代码高度解耦合,提高了文件系统的可移植性。
实验要求实现建立硬链接、释放硬链接以及查询文件状态系统调用。对于建立硬链接,我就按照文档上的提示按部就班为相同的磁盘索引块再创建了一个目录项。但是对于释放硬链接需要考虑的东西就多了:当一个文件在释放当前硬链接之后还存在硬链接的话,就只需要将磁盘上的某个目录项删除;当一个文件在释放当前硬链接之后没有硬链接了,那就需要将其在磁盘上所有的空间回收(虽然有同学说就算没有回收也能过测试用例)。而对于查询文件状态,我就是将需要查询的字段作为成员放在磁盘索引节点中,这样就能实现就算系统断电,文件的状态也不会丢失(由于是存储在磁盘块上而不是在内存中)
一些注意点
- 删除目录项的时候按道理来说不能仅仅是将该目录项清零,而是应该将后面的目录项移动到前面来,并且如果移动后刚好空出了一个磁盘块,还需要去回收该磁盘块。但是在我的代码中仅仅实现到了将后面的目录项移动到前面,之后有时间可以尝试去改进一下。
- 在给磁盘索引节点增加成员的时候一定要减少直接索引的数量以保证一个磁盘块的大小是128字节。
7-8 进程间通信与并发
这两章的实验主要实现了一个重要的算法——死锁检测算法
这个算法其实在学校的课程中学过,是一个非常类似于银行家算法的算法,但是银行家算法是为了避免死锁。
这个算法中我觉得最让我摸不着头脑的就是need矩阵到底应该如何初始化。Available矩阵可以根据剩余的信号量或者锁是否被占用来实现;Allocation可以根据查询当前哪些线程在占据资源来确定。而need呢?显然不能单从信号量和锁的阻塞队列来确定。
从结果来看,其实应该是我的理解错了,我将死锁检测算法和银行家算法混淆了。我之前一直以为死锁检测算法中的need是一个线程在全局角度上对资源的需求量。而实际上need矩阵只是系统在当前状态下各个线程对资源的需求量,在每次需要分配资源时都需要调用死锁检测算法。所以死锁检测算法中的need矩阵是由信号量或者锁的阻塞队列以及当前请求资源的线程决定的。接下来只需要按照算法描述进行编码问题就迎刃而解了。
虽然第三阶段的时间与学校期末考试的时间完美重合,但是从第二阶段的学习中我真正感受到了操作系统的魅力,冲就完了。
2024开源操作系统训练营第二阶段总结-OSFantasy
第二阶段 - OS设计实现
0 前言
由于我是二刷了,所以一阶段很快就搞完了,然后提前了一个月左右弄二阶段。最后赶在了五一假期前做完了二阶段的Lab。(PS:不得不说,群友都好强,Orz)
然后我五一假期过后,训练营学习基本上就有些摆了(除了上课就没干啥了)。一方面原因是学校课程忽然要考试了,二是不得不重视英语学习了,三是自制力下降了很多。
虽然是二刷训练营了,但是这次是第一次参与到二阶段的学习中(上次二阶段还没开始就没搞了)。不得不说二阶段真的学到了好多关于OS相关的知识,同时代码的实操非常有用。(PS:Tutorial-Book-V3的step-by-step真的很棒)
我本人是非计算机专业的,对于本次二阶段来说,基本上算是零基础了(没学过计组、操作系统概论、CSAPP、计算机体系结构等)。不过好在的是我对于单片机的开发还是比较熟悉的,在二阶段的学习中我也发现了OS开发和单片机开发的很多相似之处,比如:STM32有个东西叫HAL库,OS中有个东西叫HAL层(也就是SBI),它两都是对硬件的一层抽象。
1 环境与工具软件等
RustRover
还是强推RustRover。毕竟rCore的代码量可不小,用vim在多个文件间切换太麻烦了。而且RR可以方便的查看函数的使用和trait的impl。
另外提一下,我在使用Ubuntu22上的RR2024.3时遇到了闪退问题。换回到RR2023后解决了。
qemu
这个主要是用来模拟一个risc-v64的机器在我们的x86_64的电脑上。
在安装的时候可能会遇到一个坑,就是在执行:
1 | # 安装编译所需的依赖包 |
而后,可能会报错说缺少某个东西。这是因为第一步操作可能少了个需要的依赖。按照提示执行
1 | sudo apt install <缺少的某个依赖> |
2 OS知识
操作系统概述
站在应用程序的角度来看,我们可以发现常见的应用程序其实是运行在由硬件、操作系统内核、运行时库、图形界面支持库等所包起来的一个 执行环境 (Execution Environment) 中,如下图所示。
异常控制流
在操作系统中,需要处理三类异常控制流:外设中断 (Device Interrupt) 、陷入 (Trap) 和异常 (Exception,也称Fault Interrupt)。
陷入 (Trap) 是程序在执行过程中由于要通过系统调用请求操作系统服务而有意引发的事件。产生陷入后,操作系统需要执行系统调用服务来响应系统调用请求,这会破坏陷入前应用程序的控制流上下文,所以操作系统要保存与恢复陷入前应用程序的控制流上下文。
RISC-V 特权级架构
RISC-V 架构中一共定义了 4 种特权级:
特权级 | 编码 | 名称 | 描述 |
---|---|---|---|
0 | 00 | 用户/应用模式 (U) | 用于运行普通的用户应用程序。在这个模式下,应用程序不能执行特权指令,也不能直接访问硬件资源。 |
1 | 01 | 监督模式 (S) | 通常用于运行操作系统内核。在这个模式下,操作系统可以执行特权指令来管理进程、内存和其他系统资源,但是它不能直接访问所有的硬件资源。 |
2 | 10 | 虚拟监督模式 (H) | 用于运行虚拟化管理程序(Hypervisor),它可以在物理硬件上管理多个虚拟机监视器(VMM)。Hypervisor模式可以执行一些特定的管理操作,但不是所有的机器级指令都是可用的。 |
3 | 11 | 机器模式 (M) | 用于运行固件和操作系统内核。机器模式可以执行所有的指令,并且可以直接访问所有的硬件资源。通常,机器模式下的代码负责硬件管理和启动时的引导。 |
其中,级别的数值越大,特权级越高,掌控硬件的能力越强。从表中可以看出, M 模式处在最高的特权级,而 U 模式处于最低的特权级。在CPU硬件层面,除了M模式必须存在外,其它模式可以不存在。
从特权级架构的角度,去分析支持应用程序运行的执行环境栈,如下图所示:
其中,白色块表示一层执行环境,黑色块表示相邻两层执行环境之间的接口。这张图片给出了能够支持运行 Unix 这类复杂系统的软件栈。其中操作系统内核代码运行在 S 模式上;应用程序运行在 U 模式上。运行在 M 模式上的软件被称为 监督模式执行环境 (SEE, Supervisor Execution Environment),如在操作系统运行前负责加载操作系统的 Bootloader – RustSBI。站在运行在 S 模式上的软件视角来看,它的下面也需要一层执行环境支撑,因此被命名为 SEE,它需要在相比 S 模式更高的特权级下运行,一般情况下 SEE 在 M 模式上运行。
文件系统
在操作系统的管理下,应用程序不用理解持久存储设备的硬件细节,而只需对 文件 这种持久存储数据的抽象进行读写就可以了,由操作系统中的文件系统和存储设备驱动程序一起来完成繁琐的持久存储设备的管理与读写。
移植FATFS
之前移植FATFS文件系统到FeatOS(其实就是照着rCore写的,改了一点)中时,学习FATFS结构打的草稿。
最后移植成功后,能够读取并运行FAT32文件系统镜像中的elf文件。
3 总结
这次总算是圆满完成第二阶段了,同时也取得了不错的成绩。文件系统和并发让我印象深刻,打算后面完成第三阶段后再回来详细的搞搞ch6后面的东西。
感激社区提供了这样一个学习平台,它为我打开了一扇探索操作系统奥秘的大门。希望后续的学习我还能够坚持下去吧!