0%

前言

之前听说并且尝试过rust,但是,因为其陡峭的学习曲线,第一次学习失败!在这第一次的时候连蒙带混地走了一遍rustlings,但是依旧对很多语法细节不理解。

对于risc-v,因为他的开源性质,对他很感兴趣,赞同他是指令集中的Linux。并且其简单优雅的设计,让我不必忍受x86的各种复杂和历史包袱也能入门学习OS。

对于OS,之前只是看过理论教材,但是对于具体的实现并不清楚,想通过一门实践课程来丰富自己的实践经验,让理解落地。我认为,学习理解了操作系统之后,计算机便祛魅了很多(除了硬件层面),思考问题可以一眼想到底能够让设计更加灵活,思维更加创新。

过程

这次再次学习rust,重点去理解了生命周期的概念,主要是通过官方的the rust programming language以及查阅std docs来学习。

rust有很多现代语言的特性,并且融合了很多函数式语言的特点,之前热衷于lisp语言,因此对于rust这种做法有好感。

但是,尽管做完了rustlings,因为缺乏更多的工程实践,我认为自己的rust灵活运用能力仍然欠妥,对于rust的所有权机制仍然会有所不适应,仍然以c的眼光来思考编程。

此外,我觉得rust有些过于繁琐,这种保证内存安全的方式真的是最好的吗?对于我来说,这种限制极大影响了我目前的编程思路(可能来自c的编程思路本身就是不内存安全的),
以及其语法,我觉得有些丑陋, 每每看到生命周期就会头昏眼花, 在这里希望zig语言早日能发布1.0(跑

第一阶段做完之后的几周,因为课业问题,迟迟没有开始第二阶段的实践,希望自己能够在此后的过程中调整好自己的时间安排,不慌不乱,有条不紊,最终完成整个训练营。

最后

这次参加本次训练营,有很大的偶然成分,偶然间从某个微信公众号看到消息,但惧于高校的威名,一开始我是害怕报名的(对未知事物本能的惧怕),但是看到开源的字号,想着开源的含义,open to everyone,
以前一直在屏幕后作为观察着的我,一个热爱自由软件,开放共享精神的学生,应该踏出实践的一步,从这样一个课程开始,参与到知识共享的实践中去了(从受益者到贡献者)。

而且,我也很期待自己能够写出自己的一个OS,他可能不够完善,唯一的优点就是能跑,但当我有了这个技术资本,就可以参与到其他的项目(如:Linux)中去了。
也希望能够在这样一个项目中结识到志同道合的朋友,找到属于自己的圈子,提高自己的社交能力。

目录

前言

本着学习 Rust 和 OS 底层相关的想法加入了这一届的训练营。

  • 关于 Rust: 对 Rust 这个语言之前仅仅是略有耳闻, 听说学习曲线陡峭, 也一直没真的了解一下, 所以借此机会学习并了解一下。
  • 关于 OS: 一个方面是虽然写了几年代码, 但是其实对 OS 了解不多, 浅浅的知道一些模糊概念, 另外是对 thu 的 ucore 早有耳闻, 而这个 rcore 同出一源, 因此决定借此机会来学习一下。

由于第一阶段并不涉及 OS 的部分, 因此主要是从 Rust 的学习角度来总结一下第一阶段的内容。

第一阶段使用 rustlings 为评价标准, 提供提示、参考教程、直播讲解、线上答疑等方式组合推进。 我本人没有参与太多直播讲解和线上答疑,主要使用 hint 提示和相关文档、LLM 完成了第一阶段的回答。 因此主要从三个角度来分析所学:

  1. 所有权与类型系统: 这是我觉得 Rust 与其他语言最为迥异的地方, 所以在这里简单阐述一下我的理解。
  2. 做题感受: 对 Rustlings 和 训练营一阶段的一些感受。
  3. 个人反思: 对自己参与一阶段训练营和学习 Rust 的一些反思。

所有权与类型系统

Rust 不进行自动的 GC(内存回收), 也不需要开发者手动释放内存, 而是通过所有权来管理内存, 这是一个非常有趣的特点。 其次, RUst 有着与 Java、 C++、 Python等不同的类型系统, 这一点不仅仅体现在一个字符串就会有 String, str, &String 等多个类型上, 也体现在 sturctimplwhere 上。

所有权

所有权是指当前变量值所占据的内存, 归哪个变量所管理(这一句是个人总结)。 这也就意味着, 当一个变量丧失了对值的拥有, 它就不再能使用这个值。

与 Java 和 Swift 使用的引用计数不同, 引用计数需要管理复杂的引用关系, 同一个值可能被非常多的变量所引用, 这导致内存在很多时候难以回收, 甚至在某些时候无法判断 OOM(堆内存溢出) 的具体位置。 –这个是本人亲身经历的一次问题。

虽然由于同一时刻一个值只能有一个所有者这一个概念及编程环境的实际需要带来了很多的额外的复杂内容, 比如借用引用, 可变借用不可变借用等等。 但是不可否认的是, 这让开发者更深刻的了解了堆、栈等程序等基础概念, 也有利于开发者从思维上管理自己程序的运行时。

类型系统

类型系统第一次感觉奇特是在于 structimpl 的分离。 与常见语言中每个 class 与他的 method 耦合在一起不同, Rust 中方法是对 struct 使用 impl 构建的。

其次是对于 Stringstr 的类型, 让人区分堆与栈, 又或者说, 当明白了一个结构体会被分布在堆还是栈上的时候, 也就不会再纠结于到底是 String 还是 str 了。

做题感受

由于 Rustlings 主要是通过做题来驱动, 因此通过第一阶段训练营意味着完成所有的 Rustlings 练习题。 这样操作的好处是, 可以了解 Rust 的基本语法和编译器, 学会阅读 Rust 的文档和教程等内容。 因此夏令营的学习还是依赖于自我的驱动(当然, 学习编程可能没有那么多的技术热情, 但是确实应该学会自我驱动来跟上新的技术, 跟上时代的脚步)。

但是 Rustlings 存在一个很大的弊端, 似乎是通过对应名称的文件能否顺利执行来判断是否通过的, 也就是可以通过修改不应该修改的代码来使得练习通过。 在练习一开始的时候, 我按照编程习惯和编译器提示懵懵懂懂的过了十来道题, 中间可能修改了 assetequals 等内容。 同理, 如果只是想速通、进入第二阶段等, 完全有很多稀奇古怪的方法可以在不完成解题的情况下进行。

而要解题, 其实也并不需要一定看教学视频(当然这里的含义并非视频或直播不好), 而是 Rust 的编译器可以完成一些基础的提示, 其次是通过 hintReadme 和 代码中的注释可以获取到很多关于对应知识点的信息, 能完成接近于 90% 的问题, 因此靠自己也同样能完成很好的 Rust 学习, 并不需要等待直播开启。

当然, 依然有约 10% 的问题可能难以解决, 这时候利用诸如 ChatGPT、 ChatGLM、 BaichuanAI 等 LLM 可以解决剩下的9% 的问题。 通过提示词优化可以解决几乎 Rustlings 中 100% 的问题。

因此总结下来, 第一阶段使用 Rustlings 来作为评价标准, 是因为 Rustlings 更多的是一个强自驱的学习过程, 需要对自己的学习质量和学习内容有着一定的自我要求。

个人反思

尽管在几天内完成了全部的 Rustlings, 比不过训练营当天秒通的那些人, 但是鉴于我本人并没有 Rust 基础, 所以倒也没什么气馁或难受。

在学习过程中还是对 Rust 有了一点点浅薄的了解, 对 Rust 的代码不再是一知半解或者完全看不懂的情况, 这是一阶段学习后最欣慰的事情。

但是 Rustlings 并不能算是一个全面的 Rust 学习指南, 更多的是一个学习入门的指引, 对于很多的概念比如智能指针过程宏都是浅尝辄止, 需要自行补充更多的学习内容来进行增强。

其次是对于 AI 的使用也会导致个人思维的怠惰, 对于部分题只要求解出来而忽视了解题的思路(比如算法题链表和堆)。 这种只能适用于一时的情况, 不利于长期的学习。

总结

整体总结下来, 训练营选择 Rustlings 还是一个非常不错的决定, 不像是传统课程一般强制直播那样干涸, 也不会是纯练习那样放飞。 而且也成功的体会到了 Rust 的好玩之处, 和他的陡峭艰难, 期待二阶段可以顺利的使用 Rust 写出 Rcore, 对自己交出一份满意的答卷。

花了大约一周的时间基本看完了rust圣经,两天刷完rustling,发现rust真是把各种安全考虑(内存,数据…)直接暴露给用户了。

😯rust三大支柱:

  • 没有垃圾回收的内存安全(内存安全)
  • 没有数据争用的并发(数据安全)
  • 没有开销的抽象(性能)

[1]😯无GC: 随时回收内存啊,这不c++ RAII么,NLL比RAII更强!

[2]😯所有权、借用:最有特色的一章。这是直接把读写锁加在语言层了是吧。

[3]生命周期:最难理解的一章。NLL还好理解,给参数和返回值手动标生命周期怎么突然魔幻起来了🥵,好在编译器会帮我们做标注。

[4]泛型、trait:最惊艳的一章。泛型?大伙都有啊;trait? 这不Java的接口(c++虚函数)么,但是确实优化掉了继承,难道这就是组合优于继承么🧐;

😯零开销抽象!!

组合:用”has-a”(有什么或用什么)去替代”is-a”(是什么)

零开销抽象:极度强调运行时性能,把所有解释抽象的工作都放在编译时

[5]函数式编程:这不所有权+借用版本的c++迭代器和lambda表达式么

[6]Box智能指针、循环引用:这不所有权+借用版本的c++ unique_ptr,shared_ptr,weak_ptr的么

[7]unsafe:c++裸指针,但仍然无法逃离所有权和借用

[8]多线程与并发:大伙都有,但是更严格了啊,还得时刻考虑数据的位置和所有权转移


1.变量解构

(a, mut b): (bool,bool)
1
2
3
4
5
6
7
8

2.数值

```61_f32```表示f32类型的61

3.as 转类型

例如```'a' as u8

4.range

和 ```1..
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

5.```字符串```通过硬编码,而```String类型```在堆上

6.申请堆内存后,通过```栈中的堆指针```进行访问

7.一个值只能拥有一个所有者

> **变量在离开作用域后,就自动释放其占用的内存**

> 可以多个常量引用,但是只能有一个可变引用
>
> 可变引用和常量引用不能同时存在
>
> 引用的作用域在最后一次使用后时结束(编译器的优化)

```rust
let s1 = String::from("hello");
let s2 = s1;
//s1将所有权移交s2

8.字符串String 和 &str

str是内置类型,String是标准库类型

&str字符串切片

中文3个字节

9.尽量使用迭代方式访问数组 使用下标访问每次有越界检查

i in &v{}```
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

和 for i in v{} 的区别?

> 如果不使用引用的话,所有权会被转移(move)到 `for` 语句块中,后面就无法再使用这个集合了:(

> 对于实现了 `copy` 特征的数组(例如 [i32; 10] )而言, `for item in arr` 并不会把 `arr` 的所有权转移,而是直接对其进行了拷贝,因此循环之后仍然可以使用 `arr` 。



10.模式匹配

```rust
match some_u8_value {
1 => println!("one"),
3 => println!("three"),
5 => println!("five"),
7 => println!("seven"),
_ => (),
}

() 表示返回单元类型与所有分支返回值的类型相同,所以当匹配到 _ 后,什么也不会发生。

11.函数参数的三种传入方式:转移所有权、可变借用、不可变借用

12.只要闭包捕获的类型都实现了Copy特征的话,这个闭包就会默认实现Copy特征。

1
2
3
4
5
6
7
8
9
10
11
12
// 拿所有权
let s = String::new();
let update_string = move || println!("{}", s);

exec(update_string);
// exec2(update_string); // 不能再用了

// 可变引用
let mut s = String::new();
let mut update_string = || s.push_str("hello");
exec(update_string);
// exec1(update_string); // 不能再用了

😎仅实现 FnOnce 特征的闭包在调用时会转移所有权

13.调用第一个参数为self的成员函数会移交所有权,实例不能再使用

1
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {    // --snip-- }

14.数据内存布局

VecStringHashMap都是固定大小的类型,都是对堆上数据的引用,引用的大小是固定的,即栈上的引用类型是固定大小的

DST:不定长类型,在代码中直接使用 DST 类型,将无法通过编译。

Rust 中常见的 DST 类型有: str[T]dyn Trait它们都无法单独被使用,必须要通过引用或者 Box 来间接使用

dyn Trait表示它所指定的 Trait,确切的原始类型被删除,补全 Trait 对象指针所需的信息是 vtable 指针,被指向的对象的运行时的大小可以从 vtable 中动态地获取。

1
2
3
4
struct MySuperSliceable<T: ?Sized> {
info: u32,
data: T,
}

?Sized 是一个特殊的 trait bound,表示泛型类型 T 可以是非固定大小类型。

T 既可以是普通的固定大小类型(如 i32, u64),也可以是动态大小类型(如 str 或者 dyn Trait

#[repr(c)] 表示字段的顺序、大小和对齐方式与你在 C 或 C++ 中期望的完全一样。

通过 FFI (Foreign Function Interface,不同语言交互)边界的类型都应该有repr(C),因为 C 是编程世界的语言框架。

15.堆栈

  • 小型数据,在栈上的分配性能和读取性能都要比堆上高
  • 中型数据,栈上分配性能高,但是读取性能和堆上并无区别,因为无法利用寄存器或 CPU 高速缓存,最终还是要经过一次内存寻址
  • 大型数据,只建议在堆上分配和使用

栈的分配速度肯定比堆上快,但是读取速度往往取决于你的数据能不能放入寄存器或 CPU 高速缓存

将一个简单的值分配到堆上并没有太大的意义。将其分配在栈上,由于寄存器、CPU 缓存的原因,它的性能将更好.

16.智能指针往往都实现了 DerefDrop 特征

Box可当智能指针用

17.Deref

  • 把智能指针(比如在库中定义的,Box、Rc、Arc、Cow 等)从结构体脱壳为内部的引用类型,也就是转成结构体内部的 &v
  • 把多重&,例如 &&&&&&&v,归一成 &v
1
2
// 由于 String 实现了 Deref<Target=str>    
let owned = "Hello".to_string(); // str
  • T: Deref<Target=U>,可以将 &T 转换成 &U,也就是我们之前看到的例子
  • T: DerefMut<Target=U>,可以将 &mut T 转换成 &mut U
    • T: Deref<Target=U>,可以将 &mut T 转换成 &U

18.

Rc 只能用于同一线程内部,想要用于线程之间的对象共享,你需要使用 Arc

Arc 是线程安全的,Atomic Reference Count

Arc并不允许直接修改其中的数据,应该在Arc内部包装一个mutex


RefCell 实际上并没有解决可变引用和引用可以共存的问题,只是将报错从编译期推迟到运行时,从编译器错误变成了 panic 异常

borrow() borrow_mut()

  • RefCell 适用于编译期误报或者一个引用被在多处代码使用、修改以至于难于管理借用关系时

Cell对于实现了Copy的类型。如 Cell 使用.get()获取一个copy后的数据,使用.set()修改原来的数据 => 进而实现了可变借用和不可变借用同时存在。


Weak

使用Weak来解决循环引用导致的内存泄漏问题,也可直接使用unsafe 裸指针解决 裸指针没有所有权转移

通过RC创建Weak,或者直接创建Weak


NonNull [一个非空,协变的裸指针]

19.unsafe

创建原生指针是安全的行为,而解引用原生指针才是不安全的行为

unsafe代码块加在解引用裸指针的周围

将引用转化为裸指针是一种😎再借用

一旦开始使用裸指针,就要尝试着只使用它,不然就会突破rust的引用规则和栈借用规则。产生不好的后果

内部可变性

一个不可变引用 &UnsafeCell<T> 指向一个可以改变的数据,这就是内部可变性。

20.再借用
f

21.Pin

自引用最麻烦的就是创建引用的同时,值的所有权会被转移

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct SelfRef<'a> {
value: String,

// 该引用指向上面的value
pointer_to_value: &'a str,
}

fn main(){
let s = "aaa".to_string();
let v = SelfRef {
value: s, // 所有权转移
pointer_to_value: &s // 所有权已经被转移,不能再借用
};
}

22.线程安全

  • 实现Send的类型可以在线程间安全的传递其所有权
  • 实现Sync的类型可以在线程间安全的共享(通过引用)

23.常量可以在任意作用域进行定义,其生命周期贯穿整个程序的生命周期

Rust 要求必须使用unsafe语句块才能访问和修改static变量

lazy_static懒初始化静态变量,之前的静态变量都是在编译期初始化的,因此无法使用函数调用进行赋值,而lazy_static允许我们在运行期初始化静态变量!但是定义的静态变量都是不可变引用

Rust为我们提供了Box::leak方法,它可以将一个变量从内存中泄漏(听上去怪怪的,竟然做主动内存泄漏),然后将其变为'static生命周期,最终该变量将和程序活得一样久,因此可以赋值给全局静态变量CONFIG

24.unwarp通过unwarp() 取出Result<i32,ParseIntError>中的i32(潜在panic)

使用?方法后接收option或result时,遇到err,None直接返回

25.iterator

三种迭代器类型

IntoIter 类型迭代器的 next 方法会拿走被迭代值的所有权,IterMut 是可变借用, Iter 是不可变借用。

🥵26.链表

不能直接将head的所有权直接移交给新节点的next。take将head偷出,填入None,返回head

加泛型后head为Option<Box<Node>>


不使用as_ref会将head的所有权移入map函数内(map的函数签名为self,转移所有权),这样&node.elem就是返回map函数内的局部变量的引用。

使用as_ref将Option<Box<Node>> 转为 Option<&Box<Node>>解决问题

27.逆变协变

一个variable被拆分成‘读’跟‘写’,其中读的部分是协变,写的部分是逆变,可读可写的var则是不变

协变:需要动物的地方给一个猫

逆变:需要猫的地方给一个动物

不变:不能转换

rust中函数返回的是协变的,参数是逆变的

rust中生命周期和泛型T都有协变逆变性

*mut T 对于T是不变的 &’a mut T 对于T是不变的

引入NonNUll包裹T,这时T是协变的

在构建list时,使用*mut Node 会导致不能使用其协变性。用NonNull引入协变性

例子:

1
2
3
4
5
6
7
8
9
10
11
12
fn assign<T>(input: &mut T, val: T) {
*input = val;
}
fn main() {
let mut hello: &'static str = "hello";
{
let world = String::from("world");
assign(&mut hello, &world);
}
println!("{hello}");
}
// 这里传入assign的T为hello,hello是一个&'static str类型,即T是&'static str,它是不变的,不能用&'world str类型的val(第二个参数)来

NonNull<T> 是关于 T 协变的。这意味着如果你有一个 NonNull<T>,并且 UT 的子类型,那么你可以将 NonNull<T> 转换为 NonNull<U>。这种属性在泛型编程中非常有用,尤其是在处理继承或类型转换时。

子类型:

  1. 引用的子类型关系:在 Rust 中,引用 &T 是其指向的类型 T 的子类型。这意味着,如果有一个函数期望接受类型 T 的参数,你可以传递类型为 &T 的参数给它,而编译器会自动进行引用的解引用操作。这样的设计避免了数据的不必要的拷贝,并且使得代码更加灵活。
  2. 安全的子类型关系:在 Rust 中,如果一个类型 B 实现了 trait A,那么 B 就是 A 的子类型。这种子类型关系通过 trait 来实现。比如,如果类型 String 实现了 trait AsRef<str>,那么 String 就是 str 的子类型。这种关系允许你在期望 AsRef<str> 类型参数的地方传递 String 类型的值。
  3. Enum 的子类型关系:在 Rust 中,Enum 可以有多个变体(variants),这些变体可以拥有不同的数据类型。对于一个枚举类型来说,它的每个变体都可以被认为是它本身的子类型。这种设计在模式匹配和组合类型时非常有用。

28.module

当使用 mod 语句导入模块时,Rust 自动 为它创建一个模块命名空间

mod logging; 可以使用logging::xxx访问该module下的内容

extern

1.调用外部代码

1
2
3
extern "C" {
fn ctanf(z: Complex) -> Complex;
}

2.外部调用rust代码

1
pub extern "C" fn rustfn1() { }

29.多态

  • 泛型是一种 编译期多态 (Static Polymorphism),在编译一个泛型函数的时候,编译器会对于所有可能用到的类型进行实例化并对应生成一个版本的汇编代码,在编译期就能知道选取哪个版本并确定函数地址,这可能会导致生成的二进制文件体积较大;

  • 而 Trait 对象(也即上面提到的 dyn 语法)是一种 运行时多态 (Dynamic Polymorphism),需要在运行时查一种类似于 C++ 中的 虚表 (Virtual Table) 才能找到实际类型对于抽象接口实现的函数地址并进行调用,这样会带来一定的运行时开销,但是更为灵活。

第一阶段前面做Rustlings的时候忘记写了,知道最后第一阶段快结束的这几天才想起来。故没法清晰的记录每天的学习情况。

其实之前就有想学rust的打算了,所以看过一点rust圣经也加了一点群。这个训练营也正是Rust群里看到的项目。

之前是走C++方向的,自从了解了Rust的之后,个人认为这才是modern C++。Rust保留了C++绝大部分的特性的同时,还提供了丰富的标准库、强大的编译器和方便构建的包管理器、强大的纠错机制。C++就像高冷的冰山学姐一样给你Segmental Default,报错还贼难看懂。Rust就像温柔的小学妹一样帮你Debug。

总的来说第一阶段的收获挺大的,之前没有用rustlings学习,知识看了点语法就去leetcode写题。却不知道有rustlings这种好东西。

期待后面训练营的OS课。

前言

當年在學校學操作系統的回憶痛苦而蒼白,還記得剛上了兩堂課就知道這位老師的課上了也沒意義,估計他都不曉得自己在說什麼,整個學期下來,我連操作系統該有什麼樣的功能都很含糊,這個現象在同學之間普遍存在,一間高等學校好幾屆學生的系統知識就被一個毫無素養的老師給毀了,我每每回想起來感到很悲哀。

還很印象很深刻的是,他說操作系統太過複雜,要在課堂上寫一個是很不可能的。但過了幾年,我瞭解到了國外知名的 xv6 項目,明白自己完全被糊弄了。雖然離開校園已經好幾年,在 rust.cc 論壇上看到 rcore 訓練營的消息,我非常興奮地報了名。

第一階段總結

Rust 語言我還算是有些基礎,用它寫過一些簡單的程序。前面幾十道題過得還挺快,但當初學習沒能覆蓋到方方面面,在生命週期及 unsafe 操作裸指針上還是卡了一些關。這實在是 rust 真正獨創的地方,而操作記憶體在 OS 開發相必是重中之重,也期待在接下來的課程能進一步熟悉。

期許

下班後能利用的時間實在有限, rustlings 居然花了一個禮拜才搞定,接下來的第二階段必定挑戰更大,需要做好時間安排,潛心專研。

第二階段總結

第二階段的難度果然遠高於第一階段,我幾乎是壓線才能完成任務,但這堂課真的接近我理想中操作系統課程該有的樣子。rCore-Tutorial-Guide 2024 有些太簡略了,我第三章之後就改看 rCore-Tutorial-Book 第三版 了,內容十分翔實,可以自學,美中不足的是敘述好用長句,有些語言西化,若能再稍微雕琢文句就更好了。

還有三週實在是太短了,我沒辦法逐章逐節的閱讀完教材,只能先看 lab 問題來選讀章節,要寫 lab 其實不需要用到所有教材內容,例如 condvar 我就沒搞懂了。問答題是個很好的補充,迫使我多讀了一些源碼才能作答,對加深理解很有幫助。為了寫 lab 我讀懂了五成代碼,再寫問答題又多懂了一兩成吧,是希望三週能延長為五週六週,再增加 lab 及問答題的廣度。

期許

二階段已經如此艱辛,三階段更接近實戰任務,想必更具挑戰,我打算選擇 hypervisor ,當年學過的一點虛擬機知識要再撿起來了。

前言

作为一个长期依靠自学的科班选手,目前只系统学习过 Berkeley CS61A/61B,参与过炼丹项目,系统编程最近才起步(xv6)。加入训练营主要是借助 ddl 逼迫自己学习 Rust 以及 OS,拓展技术栈,同时也在为明年暑期实习和后续可能的 PL 研究增加知识储备。(能够遇到志同道合的朋友就更好了^^)

心得

之前早已听说 Rust 语言层面的安全性,但在实际上手之后才真正理解了这一点:严格的所有权、类型、生命周期、借用等机制,不断优化的编译时检查和对人类友好的报错信息/修改建议,包管理、配置管理的先进,以及零成本抽象的构建逻辑等,形成了区别于其他主流语言的独特风格。更加难得可贵的是 Rust 高质量的官方教程,讲解深入浅出,配合实际案例,一般路过萌新也能够顺利入门。

在 Rust 语法学习以及 Rustlings 练习中,主要难点有:读写/所有权机制、泛型/生命周期、智能指针、并发,以及一些 Safe Rust 目前难以解决的问题及,例如链表、自引用和循环引用。

Rust 不同于 C++ 等默认 copy semantics(复制语义) 的语言,转而默认对未实现 Copy trait 的类型 move semantics(移动语义),这一点使得很多初学者望而却步:即使编写简单逻辑程序,仍然需要对 Rust 的类型以及内存模型有一定了解,控制引用生命周期,同时明确程序每一处的逻辑,才能在 Safe Rust 的约束下通过编译。Rust 生命周期机制显式实现了对引用的有效期规定,从而有效控制借用范围。Rust 经过多次版本迭代,也引入了一系列语法糖,例如 Deref coersion、? match Result 等,一定程度降低了心智负担,不必成为“语言律师”才能上手开发。

总的来说,Rust 是一门高度现代化,注重内存安全和性能的系统级开源编程语言,同时具有逐渐完善的编译器和第三方库生态,可谓未来可期。

学习目标

第二阶段开始前,通过 Berkeley CS61C 以及 xv6 lab 提升对 RISC-V 指令集以及 OS 基本运行逻辑的理解。

尽可能在预计时间内完成第二阶段任务,同时备战期末(

契机

事情的起因是在威神群里看到的清华操作系统训练营。一个教你用rust写os的训练营!

受到了炸药哥、cjj等多位体系结构大佬的影响,且本身自己就有一个想把cs和ai结合起来的梦想。而且了解到开源操作系统研究组研究方向涉及高性能计算,于是果断报名。

其实我自己也不知道是否对操作系统感冒,对rust的兴趣也不及cpp。我只是喜欢了解计算机科学的底层,加之想找hpc的暑期实习但简历却石沉大海(悲),且也想扎实自己os基础便来了这里。

rust学习曲线真的好陡峭(痛苦面具)

从rust零基础到完成rustlings大概用了两周时间。

看过rust圣经,rust程序设计语言,做过rust最佳实践的练习,在b站看过网课,还尝试做过两个项目: 用rust实现基本数据结构和用rust实现lua解释器。 真的想说一句:”我xxx!” (请读者自行想象)

其实依旧是有很多不明白的东西,比如链表的布局其实我是似懂非懂的,只是照搬了它的布局。但为何这样布局当时并没有太清楚。
这样的地方还有很多,就且留到以后慢慢体会。毕竟我是来学os的(嘿嘿),我始终坚信一点:所有的疑问都会在不断学习的过程中找到答案!

幻想成为英雄

南京大学jyy在他的操作系统课上曾说过一句话:”这个世界需要英雄!”

我真的真的很喜欢这句话,在这个浮躁的时代,越来越多的人选择卷绩点,水科创,水竞赛,学前后端狠赚一笔()…

但这样不酷,真的不酷。
我要打下最扎实的计算机科学基础,我要实现真正顶层和底层的结合,我要做ai system!

那么就从认真完成这次操作系统训练营开始吧,第二阶段见!

我们,一定未来可期!

在第一阶段的学习中,主要对rust的所有权以及指针等方面进行了着重学习,下面对相关知识点进行简要总结

所有权相关规则

一个值只能被一个变量所拥有,这个变量被称为所有者

一个值同一时刻只能有一个所有者,即不能有两个变量拥有相同的值

当所有者离开作用域,其拥有的值被丢弃,内存得到释放。

当出现所有权冲突时,可以通过调用 data.clone() 把 data 复制一份出来给 data1,这样做可以创建 data 的深拷贝,避免出现所有权问题

Move 语义:当一个值被赋值给另一个变量时,它的所有权会转移,原始变量将不再有效,默认情况下,大部分自定义类型都是具有 Move 语义(优点:可以避免使用拷贝操作,提高性能)

Copy 语义:不希望值的所有权被转移,赋值或者传参时,值会自动按位拷贝(浅拷贝),两个变量都拥有独立的内存空间。 Copy 语义通常适用于简单的基本类型,如整数、浮点数、布尔值等

Borrow 语义:不希望值的所有权被转移,又无法使用 Copy 语义,可以“借用”数据,其允许在不转移所有权的情况下借用值的引用,包括不可变引用(&T)和可变引用(&mut T),允许同时存在多个不可变引用,但不允许同时存在可变引用和不可变引用(优点:使得代码更加安全,因为它在编译时进行所有权检查,防止了一些常见的错误,如悬垂指针和数据竞争)

一个值给多个所有者

  • Rc

对一个 Rc 结构进行 clone(),不会将其内部的数据复制,只会增加引用计数,当引用计数为0时,内存释放

Arc 与 Rc 类似,但是使用原子操作来保证引用计数的线程安全性,支持线程共享数据。

(如果不用跨线程访问,可以用效率非常高的 Rc。如果要跨线程访问,那么必须用 Arc)

use std::rc::Rc;
  • RefCell
    允许在不可变引用的情况下修改数据,采用borrow_mut(可变)、borrow(不可变)

Mutex 和 RwLock 都用在多线程环境下,当需要多线程时,可直接替换RefCell

use std::cell::RefCell;

智能指针Box

Rust 中,凡是需要做资源回收的数据结构,且实现了 Deref/DerefMut/Drop,都是智能指针。

允许将数据分配在堆上,当 Box 离开作用域时,它指向的堆内存会被自动清理。常用于: 在编译时大小未知的数据;大型数据结构,以避免栈溢出;拥有数据,确保只有一个所有者。

Box<dyn Trait> 表示一个指向实现了指定 trait 的类型的堆上分配的指针。
Trait 可以是任何 trait,它定义了一组行为或方法,而具体的类型则实现了这些方法。
通过 Box 将其放置在堆上,可以在运行时动态确定对象的具体类型,并通过指针进行访问。
运行时动态派发(动态调用)是通过虚函数表来实现的,这意味着在运行时确定调用的具体方法。

trait Animal {
    fn sound(&self);
}

struct Dog;
impl Animal for Dog {
    fn sound(&self) {
        println!("The dog barks!");
    }
}

struct Cat;
impl Animal for Cat {
    fn sound(&self) {
        println!("The cat meows!");
    }
}

fn main() {
    let dog: Box<dyn Animal> = Box::new(Dog);
    let cat: Box<dyn Animal> = Box::new(Cat);

    make_sound(&dog);
    make_sound(&cat);
}

fn make_sound(animal: &Box<dyn Animal>) {
    animal.sound();
}

unsafe代码块

unsafe绕过了 Rust 的安全检查,错误使用可能导致内存不安全、数据竞态等问题。

  • 解引用裸指针
let mut x = 10;
let ptr = &mut x as *mut i32;
unsafe {
    *ptr += 1;
}
  • 访问或修改静态变量:在 Rust 中,修改静态变量是不安全的操作。
static mut COUNT: i32 = 0;
unsafe {
    COUNT += 1;
}
  • 实现不安全的 trait,如 Send 和 Sync

  • 进行内存布局的低级操作:如结构体的字段重叠或内存对齐。

类型之间的相互转换

  • as 运算符:as 运算符用于类型转换,可以用于将一个值从一种类型转换为另一种类型。例如,将一个 u32 转换为 u64。
  • into 和 from 方法:这些方法是 From 和 Into trait 的一部分,用于在不同类型之间进行转换。这些方法通常会涉及类型的所有权转移。
  • try_into 和 try_from 方法:这些方法是 TryFrom 和 TryInto trait 的一部分,用于尝试在不同类型之间进行转换。这些方法在转换失败时会返回一个错误(Err)
  • cast 方法:在特定场景下,尤其是与裸指针相关的操作中,cast 方法可以用于将一个指针转换为另一种类型的指针。
let ptr: *const i32 = &10 as *const i32;
let ptr_void: *const std::ffi::c_void = ptr.cast();
  • transmute 方法:transmute 是一个不安全的操作,它可以在不同类型之间进行任意转换。这是一个高级的转换函数,使用时必须非常小心。
use std::mem::transmute;

let x: u32 = 42;
let y: f32 = unsafe { transmute(x) };

as用于显式转换,所有权不变;into/from用于隐式转换,所有权转移;try_into/try_from和into/from不同的地方是,转换失败会返回Err

以上是一些本阶段学习的知识点总结,通过第一阶段的训练,整体对rust的语法与编写有了初步地认识,不过在代码调试,多线程、链表操作方面还有所欠缺,将在后续空闲时间不断学习。

第一阶段

两年前就有听说过rust了,但是只是简单的看了下the rust book,并没有实际使用rust进行编程。这次打算借着rcore进一步实践rust。

目前的感受是rust的设计给了很多的限制,同时rust似乎也和cpp一样算是多范式的语言?就比如迭代一个列表既可以写成下面的形式

1
2
3
for ... in something.iter() {

}

写可以写成

1
something.iter().sum();

这样的形式。说实话,我不是很喜欢多范式的编程语言。因为选择太多了,对于我来说阅读别人的代码就会很麻烦。所以这里我更喜欢go的设计。

此外我用rust刷了一些leetcode上的简单的算法题,感觉挺头疼的。需要关注特别多的细节,而且leetcode特别喜欢给i32类型数据,而rust中访问容器用的下标都是usize类型的。导致代码写起来一点也不美观。有特别多的xx as usize

此外,感觉rust的标准库看起来是提供了很多方法来代替本来可用指针操作的方法。比如vector的swap,一般情况下其它语言里可能是这样写

1
a,b = b,a

rust对数据类型还特别敏感,只要有越界就会报错,必须得用饱和减法saturating_sub之类的方式来处理。

总之,我感觉rust的限制太多了,这让我对写好一个rust程序来说感觉心智负担很重。对于不依靠编辑器写完整程序没有信心。

希望接下来的rcore能改变我当前对rust的看法。

第二阶段

第二阶段的难度算是渐进式的吧。第二阶段的lab里,我印象最深的部分分别是内存的地址空间,文件系统Inode组织,死锁检测。

因此,下面我就对这三部分进行一些总结好了。

死锁检测

首先聊一下死锁检测吧,因为这个是最近做过的,所以印象比较深。这部分我之前一直只知道有一个银行家算法,所以一开始看到死锁检测这部分的算法跟银行家算法一样记录allocation和need的时候感觉优点懵。因为,在我原来的理解里,银行家算法应该是需要提前知道线程需要的资源的。

后来动手实现后,才发现死锁检测算法和银行家算法是有区别的,银行家算法其实是死锁避免算法。死锁检测算法感觉主要针对的就是信号量上的资源分配,然后在每一次申请的时候检查,是否分配这次资源后,能够有一个序列走得通,如果有,就算无死锁,否则算有死锁。整体上的时间复杂度应该是 $O(nm)$ 的其中 $n$ 是线程数量, $m$ 是资源数量。

平常写其它语言的时候并没有感觉到死锁检测的存在,可能就是因为这个时间复杂度随着资源数量和线程数量线性增长,所以不采用吧?

文件系统

下面来聊一下文件系统,这部分实验的学习中,我感觉复杂的地方主要在于存在很多的抽象。文件系统本身存储diskInode,然后虚拟文件系统上抽象了Inode,OSInode,最后到操作系统中,它使用的是File trait抽象。

抽象层数很多,因此让我挺晕的。这部分代码上面,感觉比较关键的是Inode的组织。超级快->Inode位图->数据位图->Inode区域->数据块区域。

地址空间

这部分我觉得是非常有收获的一部分,让我感觉算是彻底理解了多级页表,段表的区别。在此之前一直不知道页表是怎样存储的,总是想着它是存到一个特殊的内存里。现在算是明白了页表不过是存储在普通的内存里的一个结构,然后CPU依靠token来区分当前的页表。

SV39页表中,偏移为4KB,所以使用了后12位,剩余的 $39-12=27$刚刚好分成3部分,每部分占了 $27/3=9$ 位,也就是512个页表项,每个页表项占8字节,刚刚好可以将一个页存储满。

然后是操作系统内核采用恒等映射,用户程序除了跳板页,其它的采用Framed映射。这样使得操作系统访问的虚拟地址就是物理地址,而用户程序访问的是虚拟地址。这种区分使得操作系统可以访问到用户的地址,用户程序无法访问操作系统的地址。实现了空间上的隔离。

第三阶段

第三阶段我选择的是项目一:ArceOS单内核Unikernel。该实习项目主要分为两部分:熟悉ArceOS阶段与完成实习任务阶段。

熟悉ArceOS阶段

这一阶段主要是在ArceOS仓库下,完成一些简单的任务。分了两周来实现,第一周主要是尝试为ArceOS添加一些简单的功能。第二周则是练习从外部加载应用到arceos中,通过func call的方式使得外部应用可以打印字符。

第一周

  • 练习1:支持彩色打印 println! 通过在输出的时候,在前后添加特殊符号,使得输出的颜色可以改变。
  • 练习2:支持HashMap数据类型 这一步我参考rust的HashMap实现,调用了相同的库完成。
  • 练习3:为内存分配器实现新的内存算法 这一步我主要是利用了现有的算法完成页分配与字节分配
  • 练习4:解析dtb并打印 这一步我觉得主要难点是dtb的解析,因为不清楚dtb长什么样的,学习后,调用一个crate完成了该任务。
  • 练习5:抢占式调度算法 这一步我记得我是参考了现有的抢占式调度算法,然后做了一个奇偶性的判断,每次出队前,如果这次出队是奇数,就放到队头,否则正常放到队尾。

第二周

本周主要是我的收获主要是对qemu的启动参数有了一些了解,然后就是了解了rust中手写asm的方法。

实习任务阶段

第一周 rt_helloworld与rt_memtest与系统调用新增

我主要是在熟悉lkmodel,完成了原来arceos下的rt_helloworld与rt_memtest两个unikernel的实现。这一部分根据要求,必须要有axstd。因此我一开始的实现是将arceos下的axstd库直接拷贝过来,并且把它需要用到的库也进行了靠北,然后修改一些代码,使得可以编译通过。这个的结果在dev_task1_brute_force分支下。不过后来从群里得知,要尽量从lkmodel已经有的库开始,所以就重新实现了这部分,主要的方式是基于axlog2这个库,做了一些包装和宏的重新导出。

本周的问题是

  • 在实现rt_memtest的时候,出现的全局分配器不存在的问题。
    是为了解决这个问题,我发现是需要引用axalloc这个crate,同时还需要进行初始化。因此,出于实现方便,我在axstd文件夹下同时创建了一个axruntime crate,在其中编写了rust_main函数,在其中完成全局分配器的初始化。

  • 同时在周的结尾,我遇到的问题是宏内核在我操作系统上无法成功运行btp下的测试,通过在微信群与老师交流,我得知主要的原因是glibc在不同操作系统下编译C文件的结果,使用到的系统调用是不同的。就比如init.c,在我的操作系统下编译出的结果需要使用到stat系统调用,而该系统调用在当前lkmodel中是未实现的,因此我尝试在lkmodel中添加该系统调用,主要实现在axsyscall crate中添加该系统调用的函数,然后再fileops中添加fstat函数,该函数的实现参考fstatat函数,这个函数的功能是在目录中获取文件信息。

通过对系统调用新增实现,成功让我的宏内核可以在我的操作系统中运行btp下的init.c程序。我的感受就是了解了不同指令集,linux给他们分配的系统调用号码是不一样的。同时不同操作系统版本的下的glibc生成的文件,使用的系统调用也不一样。

第二、三周 rt loader

我在第二、三周完成了lkmodel下运行glibc静态编译的hello world的c应用。

这一部分我主要是参考思路3PPT的实现,首先就是对opensbi与sbi-rt的修改,这一步我是clone了opensbi代码然后按照ppt的方法进行修改。对于sbi-rt的修改则是fork其仓库,修改后提交到自己的仓库下。最后在cargo.toml中添加依赖即可。

剩下的就是修改qemu.mk脚本,向其中增加Pflash参数与bios参数。
如下所示,让其指向payload文件夹以此加载程序内容。

1
2
3
4
5
6
qemu_args-riscv64 := \
-machine virt \
-drive if=pflash,file=$(CURDIR)/payload/apps.bin,format=raw,unit=1 \
-bios ~/opensbi/build/platform/generic/firmware/fw_jump.bin \
-kernel $(OUT_BIN)
# -bios default

此处payload我是复制org_arceos下的payload,运行mk.sh脚本即可生成apps.bin。其中包含了两个hello world程序,不过由于我没有实现多线程,所以我只能跑一个hello world程序,跑完后发出的exit调用会导致我的程序直接退出。如果实现多线程的话就只会线程退出,然后回到rt_loader。

地址空间管理问题

使用rt_loader时,主要加载的是glibc静态编译的的elf程序,elf程序中存在两个数据段,一个是.text段,一个是.data段。程序想要运行就必须将这两个段搬运到对应的虚拟地址上去,否则程序无法运行。

因此为了完成虚拟地址映射,需要进行地址空间的管理。主要是创建SV39页表,目前lkmodel中的paging完成了这个页表的管理,因此我一开始是参照这个crate与org_arceos的实现,在其中创建一个静态变量来存储用户程序的页表。并编写了map_region。

但是这样做不太好,因为这样一次只能运行一个程序了,而且接下来我在运行程序的时候,它进行系统调用时,获取访问页表的手段是通过TCB中mm_struct来获取的。因此我对此进行了修改。

主要是在我的axstd下添加了map_region函数,它的功能是获取当前的task,然后获取一个可变引用,然后在task中我添加了一个map_region方法,这个map_region方法最终会对task的mm成员变量进行map_region操作。大致的调用关系如下

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
/// rt_loader中的程序调用map_region方法
vm::map_region(va, pa, num_pages << PAGE_SHIFT, flags);

/// axstd的vm模块下的map_region
pub fn map_region(va: usize, pa: usize, len: usize, flags: usize) {
let mut task = task::current();
// 向当前任务的mm中进行map_region
task.as_task_mut().map_region(va, pa, len, flags)
}
/// task的map_region方法
pub fn map_region(&mut self,va: usize, pa: usize, len: usize, flags: usize) {
self.mm.as_mut().map(|mm| {
let locked_mm = mm.lock();
locked_mm.map_region(va, pa, len, flags);
use mm::VmAreaStruct;
let vma = VmAreaStruct::new(va, va + len, 0, None, 0);
mm.lock().vmas.insert(va, vma);
});
}

/// mm的map_region方法
pub fn map_region(&self, va: usize, pa: usize, len: usize, _uflags: usize) -> PagingResult {
let flags =
MappingFlags::READ | MappingFlags::WRITE | MappingFlags::EXECUTE ;
self.pgd
.lock()
.map_region(va.into(), pa.into(), len, flags, true)
}

此处我实现的一个额外点在于调用map_region方法的时候,我同时会向mm struct中的vmas添加vma,也就是让程序知道这个地址空间被分段使用了。

task_ctx问题

在我的实现中,因为使用task记录了页表,因此我在我的axruntime库上添加了对axtask的初始化。同时我还初始化了axtrap,用于系统调用。

最后存在的一个问题是,在lkmodel的系统调用处理过程中,它经常需要访问task_ctx,从中获取当前的task,然后进行相关的操作,比如mmap,mprotect,brk系统调用等。但是因为获取task_ctx失败,所以我一直卡在此处。

听了符同学的提醒,我最终使用静态变量的方式将记录task_ctx,如下所示

1
2
3
4
5
6
7
8
9
pub static mut CURRENT_TASKCTX_PTR:Option<* const SchedInfo> = None;

// 然后在所有初始化task_ctx的位置
// 将 axhal::cpu::set_current_task_ptr(ptr);
// 修改为 CURRENT_TASKCTX_PTR = Some(ptr);

// 在所有获取task_ctx的位置
// 将 axhal::cpu::current_task_ptr();
// 修改为if let Some(ptr) = CURRENT_TASKCTX_PTR {...}

brk问题

解决完了上述问题后,我还遇到的问题就是系统调用分配堆空间的问题。我一开始没有设置brk,因此它总是从默认的位置0开始向后分配堆空间,而这部分空间与程序运行的代码段产生了冲突。

即代码段在0x10000-0x7e0000之间,而brk申请了0x1000-0x220000之间的空间。这导致了空间上的冲突,因此我后来在运行linux app之前,首先设置了brk的地址,将它设置为了数据段之后的位置。通过这样设置,就不会产生冲突了。

总结

总体来说,我完成了lkmodel下运行glibc静态编译的hello world的c应用。这让我对elf结构,qemu.log的调试,地址空间与系统调用都有了更进一步的了解。

lkmodel的组件划分让我觉得非常地棘手,因为虽然是将组件进行了分类,但是感觉想要实现特定功能的话,我依然是需要理解每个组件之间的依赖关系,有时还需要理解每个组件的内部实现。这是我认为相比rcore-os来说,主要的难点。

仓库链接是lkmodel

rust的学习

因为之前学过一些c++,有一定的基础,对rust的学习真的帮助挺大的。rust里的所有权,智能指针,移动语义都能在c++里找到对应的东西,rust学起来还是挺轻松的。

相较于c++,rust使用起来真的太舒服了。使用match进行流程控制十分优雅,宏也比c++的宏更加强大,rust泛型编程的类型萃取使用起来也比c++方便,更不用说cargo包管理对c++简直就是降为打击。

代价是rust的unsafe用起来有点丑陋。

rustlings实验

前一百道题通过看 Rust 程序设计rust官方api文档 和问gpt都能够轻松的完成。

后十道数据结构题目就遇到麻烦了,主要是对 rust unsafe 操作的不熟悉,写起来真的折磨。在认真学习了解rust 指针操作后,有算法基础数据结构实现还是比较好实现的。

总结

rust入门可以认真看完 《Rust 程序设计》,不理解的地方问gpt和百度。再写完rustlings, 最后在github上找个练手项目去理解代码尝试自己实现。