0%

第三阶段:R4L&跨内核驱动框架

只完成了前三个练习,后面各种大作业+期末考试没时间完成最后一个练习,还是比较可惜。

整个阶段3下来收获还是挺大的,在学校的操作系统大作业里只写过简单的字符设备驱动,在这里才真正把嵌入式、外设等结合进来。

  • 了解了跨内核驱动框架的含义和意义。
  • 了解了linux内核模块,设备树,设备IO(内存映射)等。
  • 阅读树莓派数据手册,完成了跨内核框架pure driver层和adapter层关于bcm2711的gpio驱动编写。
  • 对驱动和嵌入式有了更深入的了解。

继我的训练营第 1-2 阶段总结之后,终于结束了第 3 阶段。

这里的内容与我的幻灯片《阶段 3 学习总结 + 实现 rCore 异步定时器驱动》基本一致。

参加训练营以来的所有笔记,都在 https://zjp-cn.github.io/os-notes

每周任务(均完成)

开发日志

学习笔记

其他工作

致谢

向老师是我遇见的最好的老师!感谢向老师在这 6 周时间里倾囊相授,尤其对我充满了耐心、热情和鼓励。与您所交流的一切,远远大于我在训练营里学习的所有知识,也是我所得到的最具价值的收获。

在最后一周,我其实在笔记本里,用笔写了很多很多页想告诉向老师的话,但最终没有开口。

我是一个很容易受到鼓励而逐渐喜欢一个知识领域的人。十四年前,我向英语老师请教的第一个问题,打开了我对英语的兴趣大门,在之后的八年直到人生再无英语课堂,我一直是英语课堂上最跃跃欲试的学生,也常常从第一节晚自习缠着英语老师请教各种问题,直到晚自习放学而不知疲惫。七年前,我发现自己能够自学编程,随后在参加各种数学建模比赛中,负责编程,在收获名次的喜悦中,发展起对编程的爱好,而编程这个兴趣持续到现在。我只是凭借自己的努力而缓慢地学习着,所以,当向老师您每周热情地给予我宝贵的学习机会,我内心充满了感激。我甚至在第三阶段的前几周里,就不止一次地梦见您成为了我的老师,当梦醒来,我就已经知足了。但即便我有时非常希望请教您,我依然不得不拒绝这些机会:我不想麻烦任何人,尤其不想浪费向老师的时间和精力 —— 费心去教一个操作系统领域之外的人,是不值得的事情;此外,我害怕因为向老师而发展起对这个领域的兴趣,维持现有的兴趣已经让我支付了巨大的机会成本和沉没成本。


感谢陶要仲同学将异步运行时放置到时间中断处理函数里面,这是完成向老师所提的第二个步骤的最重要的一环;也谢谢你在最后一周、连续三个晚上倾听我喋喋不休,最终我们愉快地合作成功。


感谢操作系统训练营里的每位老师!感谢第三阶段杨杰老师、赵方亮老师对我的帮助。感谢 rCore 教程的作者陈渝老师、吴一凡老师(如果我了解错误或者仍有遗漏,请告诉我 ta 的名字)。rCore 教程是我阅读操作系统领域的第一本书,它带我领略到操作系统环环相扣的精巧设计,是我能够完成第二、三阶段非常重要的基础。


感谢全球的、开放的、包容的 Rust 社区!我所有的 Rust 知识来自 Rust 社区,来自我所阅读的每一篇文档、每一个博客、每一条帖子、每一则帮助、每一处讨论……是社区的力量,让我发现了新的兴趣,培养了新的习惯。没有积累充分的 Rust 知识储备,我不可能参加这个训练营,也不可能轻松阅读每一行 rCore、embassy 里的 Rust 代码,更不可能在训练营里与各位结识,最终走到现在。

第三阶段总结

第三阶段我参加的是向勇老师的项目6:异步操作系统的实现。

总的来说,6周的项目实训过程中,读了很多代码和文档,做了很多事情,学到了很多的东西。

在参加训练营之前,我对异步的了解其实也就是局限于说我们IO要异步,让出控制权,仅此而已。

但是在训练营中,我真正的接触到了何谓真正的异步,以及如何去实现它,受益匪浅。

同时我也将ArceOS的宏内核版本,将其进行了异步化,使整个内核都使用rust的async和await异步编程实现。

同时它也是无栈协程的切换方式,且对协程采用多栈复用的抢占。

第三阶段成果

在成果上,实现了对ArceOS家族系的Starry的异步化改造,使用async和await等rust提供的异步编程编写内核,已实现核心主体框架;同时对通用操作系统内核下协程的不合理性做调整,实现内核栈池,通过少量堆栈的复用实现对诸如键盘输入等高优先协程处理的抢占机制。

为后来者留下的东西,首先是代码产出:有充足的代码仓库参考,我将各个小步骤的代码都保存了下来。从原始版本 -> task实现 -> 同步无栈协程与显式执行流 -> 异步无栈协程 -> 多栈复用版本。

同时留下了若干个人的文档心得:考虑到训练营很多同学对从同步到异步的过程一头雾水,其实我本人最开始也是这样的,我个人认为更好的办法是看别人的代码,看看别人是怎么实现了,跟着别人学,先模仿,再谈能不能超越,因此留下文档如下:

  1. 我个人参考的两个内核的阅读和解析心得(去年OS内核唯二的异步内核)。链接:https://www.jensei.cn/?p=217
  2. 以Starry为基准的,整个改造过程是如何实现的,包含了从一个ArceOS宏内核(或者说rCore)如何一步步改为无栈异步内核。链接:https://www.jensenwei.cn/?p=221
  3. 我个人对其中一些机制的理解,比如async+await,poll等。链接:https://www.jensenwei.cn/?p=220

当然,更多的中间过程结果在项目六,向老师的文档中有所记录,这里不再列出。

第三阶段感受和体会

总的来说,第三阶段也算有一定的产出,这么长时间也算没白干,身为一个寄算机人,一切都要落实到代码产出上,光写文档吹牛逼不是一个合格的程序猿。

对我这个异步小白来说,理解和实现异步,再到利用异步的过程,是比较复杂且困难的,而且所面对的是如此大的一个内核,其藕断丝连的耦合性会对改造造成相当大的麻烦。尤其是我们对整个调度和进程管理的部分进行一次彻底的重构,这种挑战相当之大。

但是挑战之大,也是挺过来了,在改造整体框架下,大概前前后后推倒重来了十多次,这也是为什么我那么注重保存每一个小步骤的代码。

总的来说,训练营中我给自己列了一个很高的挑战,也尽力的去完成它了,最后的结果我个人是非常满意的,达到了自己对自己的要求。

第三阶段总结报告

相关进度

前期学习

项目实践

  • rcore 中整合 embassy, 首先遇到的问题就是在 executor 代码中里调试程序出错,后来发现了是因为在 poll 之后的一致性锁服务中加入了M态不是S态,将配置文件中的riscv加上一个s-mode的特征属性就好了。
  • 最后一学期在整合 embassy 提供的时钟中断服务时,因为没有找到关于 Driver trait 的具体实现,加上自身实力不济,并未能成功实现相关功能,是周积萍同学帮忙写的,然后我们测试了在单任务,多任务下的时钟中断服务,发现了各种各样的bug,例如任务的阻塞、无限嵌套时钟中断、忽然打出满屏的#号等等(按理来说地址越界是有相应的处理的,不知道这个为什么会直接不处理,可能不是地址越界造成的问题),有些是 embassy循环时的机制问题,有些则还未能解决,但并不妨碍我们去重构timer.rs中的相关定时器代码。
  • 最后在将代码放入 timer.rs 的定时器中并加以重构之后,我们测试了相关用例,发现符合我们的需求,并且能够正常运行。

感谢

  • 很感谢周积萍同学帮忙讲解 embassy 相关的运行机制,以及帮我解决 embassy 中的相关问题。
  • 也很感谢向老师每周周会时提出的建议,让我对之后的学习有了更加明确的方向。
  • 感谢夏令营的各位老师和助教们的帮助和支持,同时还提供了一个这么好的平台供我们学习成长。

内容

三阶段的项目我选择了Rust for Linux驱动开发方向。

具体内容是用Rust重写PL011串口驱动,并能通过跨内核驱动框架,使驱动在arceos和linux运行

这次的项目基于Raspi4b or Raspi3b in Qemu进行开发。

我使用的开发的仓库地址是(R4L_DRV)[https://github.com/Oveln/R4L_DRV]

练习部分

内容

练习部分分三个

  • 用Rust for Linux写一个简单的杂项设备驱动,能做到输入和输出
  • 用跨内核驱动框架,实现一个设备驱动,能控制Raspi4b上的某个引脚,从而控制LED的亮灭

遇到的困难

此前我完全没有接触过驱动的编写,对硬件的原理知之甚少。经过很长一段时间的RTFM,以及了解硬件编程相关的知识。

同时对Linux设备驱动相关的内容也进行了比较充足的了解。

收获

练习部分的收获主要是

  • 第一次接触了Linux的设备驱动,对文件树中的/dev/xxx的设备文件有了认知
  • 更加明白了什么叫Every thing is File
  • 稍微看的懂电路原理图了x
  • 对很长的文档的恐惧逐渐消退

实战部分:使用Rust重写PL011驱动

问题一

串口驱动的内容相较于简单的GPIO繁琐且复杂,需要我进一步的了解Linux的串口子系统tty-uart_driver-uart_port相关的注册关系。

在经过一些努力

  • RTFS
  • 找网上的资料学习(特别感谢野火的文档教程

这个问题逐渐的被解决了

问题二

Rust for Linux经过数年的发展,在某些方面有了完备的抽象。但这次项目所涉及的uart_driver、uart_port、console等结构体没有被支持。

这个问题没什么简单的办法,在Rust for Linux社区中也没有找到好的实现。只能自己做。

如何抽象函数指针

比如,在uart_port中有很多void*类型的函数指针,如何对这些函数指针进行良好的Rust抽象就成了一大问题。

我在R4L的代码中找到的解决方案是

1
2
3
4
struct ops {
void* print_int(int)
void* print_char(char)
}

以上代码可以通过Rust进行如下封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
pub struct OperationsVtable<T>(marker::PhantomData<T>);
pub trait Ops {
fn print_int(x: i32);
fn print_char(x: char);
}
impl<T: Ops> OperationsVtable<T> {
unsafe extern "C" fn print_int(x: c_int) {
T::print_int(x);
}
unsafe extern "C" fn print_char(x: c_char) {
T::print_char(x);
}

const VTABLE: bindings::ops = bindings::ops {
print_int: Some(Self::print_int),
print_char: Some(Self::print_char)
};

pub(crate) unsafe fn build() -> *const bindings::ops {
&Self::VTABLE as *const _
}
}

// 在定义了上面三段后就可以定义一个自己的Ops

struct MyOps;

impl Ops for MyOps {
...
}

// 之后想要得到MyOps的对应的C的ops结构体只需要

let ops = OperationsVtable<MyOps>::build();

现在的成果和收获

对Linux的C代码中的serial_core.h中的uart_driver和uart_port以及console做了比较完整的抽象。

我也能够比较好的使用Rust中的Pin使数据固定在内存上的某个地方了。

第三阶段总结

前期实验

第三阶段前期老师带领我们基于Arceos做了两周的实验

  • 练习1:支持彩色打印 println!
    较为简单,且有多种解法,可以在项目不同的层次进行修改来支持彩色打印
  • 练习2:支持HashMap数据类型
    在官网找到HashMap的源码然后进行一定增删就可以实现,因为初学rust,有些高级用法很生疏,改起来有点费劲。
  • 练习3:为内存分配器实现新的内存算法
    我的实现比较粗暴,有些内存空隙直接不要了
  • 练习4:解析dtb并打印
    在crate.io中有现成的解析dtb的crate,但是需要进行一定修改才能满足任务要求
  • 练习5:抢占式调度算法
    这个任务比较简单,改一下crates/scheduler/src/fifo.rs就好了

第二周的五个实验如下:

  • 实验一:从外部加载应用
  • 实验2:把应用拷贝到执行区域并执行
  • 实验3:通过 ABI 调用 ArceOS 功能
  • 实验4:正式在 App 中调用 ABI
  • 实验5:支持内核和应用分离的地址空间及切换

对于第二周的五个实验总体来说就是把外部应用APP写入PFLASH中,然后在内核中写一个APP加载器,为APP初始好寄存器后跳转到APP执行,同时支持多地址空间以及给APP传递内核API_TABLE的地址。第二周让我印象深刻的是跳转到APP执行前对寄存器的初始化很重要,如果初始化不到位可能会导致运行APP的时候出现各种错误。同时实验4也让我学到了如何以通过传参的方式支持APP调用内核API。

之后就是进入真正的项目阶段了,项目阶段是在lkmodel上开发,lkmodel相比Arceos我个人感觉难度还是有一定提升,且前两周刚熟悉完Arceos的代码和模块(T_T),一开始还是挺畏难的。不过最后在做的过程中慢慢熟悉lkmodel后也就不那么害怕了。

进入真正的项目阶段

musl入手

由于musl编译出来的app系统调用要比glibc简单的多且少得多,因此我先尝试支持用musl-gcc编译的hello_world程序。
经历大概如下:

  • 支持加载hello_world.bin程序并执行
  • 支持对elf的解析
  • 支持对hello_world.elf程序的执行

支持glibc

要支持glibc要实现和空实现一些syscall,让我印象比较深的是writev_syscall
原本lkmodel的writev_syscall实现如下:

1
2
3
4
5
6
7
8
9
10
pub fn writev(fd: usize, iov_array: &[iovec]) -> usize {
assert!(fd == 1 || fd == 2);
for iov in iov_array {
debug!("iov: {:#X} {:#X}", iov.iov_base, iov.iov_len);
let bytes = unsafe { core::slice::from_raw_parts(iov.iov_base as *const _, iov.iov_len) };
let s = String::from_utf8(bytes.into());
error!("{}", s.unwrap());
}
iov_array.len()
}

但是运行的时候发现没有输出,于是我简单的在writev里面加了一个early_console::write_bytes(bytes);。但发现输出的时候遇到了奇怪的现象,例如输出一个Hello_world,他会进行多次输出,并且输出的字符个数逐渐减少。如:

1
修改后:

pub fn writev(fd: usize, iov_array: &[iovec]) -> usize {
assert!(fd == 1 || fd == 2);
let mut total_bytes_written = 0;
for iov in iov_array {
//debug!(“iov: {:#X} {:#X}”, iov.iov_base, iov.iov_len);
let bytes = unsafe { core::slice::from_raw_parts(iov.iov_base as *const _, iov.iov_len) };
let s = String::from_utf8(bytes.into());
early_console::write_bytes(bytes);
total_bytes_written += iov.iov_len;
}
total_bytes_written
}

1
2
3
4
5
6

#### 发现的问题及解决思路
1. 在支持glibc的时候由于glibc调用了一些task相关的syscall,需要拿到当前的task_sched_info,但是每次都拿不到(None),就很奇怪,明明也进行了task::init(),为什么拿不到呢?后面一直跟踪拿task_sched_info的源码才发现是通过读gp寄存器拿的,即项目中的```pub fn gen_read_current_raw(symbol: &Ident, ty: &Type) -> proc_macro2::TokenStream ```存也是把task_sched_info的地址存在gp寄存器上```pub fn gen_write_current_raw(symbol: &Ident, val: &Ident, ty: &Type) -> proc_macro2::TokenStream ```。然后我就怀疑gp寄存器在app运行时被改变了,于是查看qemu.log,果然一开始就动了gp寄存器。于是我采用一个全局静态变量来存当前的task_sched_info,这样就可以顺利拿到了。
2. 一开始没有清理.bss,导致运行时各种奇怪的问题。其实一开始没清理.bss是因为我在做payload的时候是创建了一个32M的空文件(全0),然后把app.elf文件写进去,我心想写的时候是全0,我就不用清理了吧,但在debug的时候发现利用lkmodel下的elf crate来对app.elf进行解析的时候竟然把.bss那块儿区域写了一些数据,就因为偷了个懒,导致又浪费一大把时间debug。
3. 在支持vfork时,vfork的子进程由于要copy父进程的进入trap_handler时的寄存器信息以正确返回。在子进程返回时发现其trap上下文的sepc寄存器一直是0,且其他寄存器的值也不正常,经过调试发现是copy父进程的trap上下文时出现了问题,最后发现问题如下:
pt_regs_addr()函数(即拿到trap上下地址的函数)原来是
pub fn pt_regs_addr(&self) -> usize {
    self.kstack.as_ref().unwrap().top() - align_down(TRAPFRAME_SIZE, STACK_ALIGN)
}
1
2
3
但是由于align_down导致减去的值不是TRAPFRAME_SIZE
但在汇编中却是用self.kstack.as_ref().unwrap().top() 直接减去TRAPFRAME_SIZE
如下:

.Ltrap_entry_s:
addi sp, sp, -{trapframe_size}
SAVE_REGS 0
mv a0, sp
auipc a1, 0 # Load the upper 20 bits of the PC into a1
addi a1, a1, 12
call riscv_trap_handler
RESTORE_REGS 0
sret

.Ltrap_entry_u:
addi sp, sp, -{trapframe_size}
SAVE_REGS 1
mv a0, sp
li a1, 1
call riscv_trap_handler
addi t0, sp, {trapframe_size} // put supervisor sp to scratch
csrw sscratch, t0
RESTORE_REGS 1
sret

1
2
3
因此在rust中是用pt_regs_addr()获取trap_frame地址然后进行读写操作是和汇编里面的存储位置不匹配。
4. 不知道是不是因为静态链接的原因,我这边argc,argv,env在栈上的排布和ppt不一样,用ppt的方式排布app拿不到argv
下面是我在其他地方看到的另一种排布方式,用此方式排布app可以正确拿到argv参数

高地址

0

envp[1]
envp[0]
0

argv[1]
argv[0]
argc

低地址

```

  1. 由于要支持多地址空间,因此我需要给每个app重新分配一个页表,并在这个基础上为app分配内存。同时我为了复用lkmodel下的代码,我采用user_mode_thread来创建一个新的进程,因此我需要对user_mode_thread代码进行修改,最重要的是修改地址空间那块,我需要为app准备一个新的地址空间,而不是和内核共享同一个地址空间。于是我考虑更改mm_copy模块,但后面想到不能直接改这里,因为mm_copy这个函数会被多个模块调用,我改了可能其他模块就跑不通了。于是我决定在user_mode_thread外面为app的task.mm重新赋值(即建立新的地址空间),但神奇的事情发生了,赋值后会导致内核的页表(即之前task.mm指向的区域)被回收(Arc指针计数并不为0),之后为了验证是否是我修改了其他模块导致该问题,于是我重新git clone下来了原仓库并在原仓库基础上进行测试,发现还是会出现相同的问题。最后我通过其他手段绕过了该问题。

    总结

    这一个月的项目实习使我debug能力又有了提升同时更加深刻的理解了musl-gcc,glib-gcc的区别,同时也更加深刻的了解了Unikernel。同时也跟同学老师们学到了很多东西。

仓库链接

https://github.com/xhyf77/lkmodel/tree/dev

报告编写时间好像有点晚

第一阶段总结报告

大概是寒假的时候,在网络上寻找一些学习操作系统的资料时,接触到了rcore lab和这个训练营,然后看到有一起学习的群友报名了这个训练营之后,立马顺着链接跟过来了。

在此之前并没有编写过类似的操作系统,了解学习过一些操作系统的知识,而且也对rust不是很熟悉。

rust语言

在参加这个训练营之前,是尝试学习过rust的,当时rust吸引我的原因不是因为它在内存安全方面的独特特性,而是它是一门现代的,系统级编程语言。而我确实也需要这样的语言(尝试为c语言寻找更加现代的替代)。不过当时由于rust特有的难以入门的特性和陡峭的学习曲线,随后便暂时放弃了这个语言的学习。

在参加了这个训练营之后,重新捡起来rust语言,发现也并不是想象中的那么可怕,其语言特性的设计理念,也可以理解。而且从这里,也进一步的体验到了其作为一门现代语言的好处。例如包管理器(安装新的工具链太方便啦),更好的lsp服务器(感觉比clangd强),以及语言提供的恰到好处的抽象设计,都让人感到很舒心。而且作为一门系统级语言,也很方便的可以在之后帮助我编写运行在一些裸机上的应用程序。

rustlings

这个练习前半部分基本都是和语言特性相关的内容,做起来还是很迅速的,基本上没有花费很长的时间便完成了这一部分的内容。但是对于后面的算法题部分,便开始困难起来了。倒不是这些数据结构和算法不清楚,如果使用c语言实现它们,那么将易如反掌,但是对于自己不熟悉,且时时刻刻强调内存安全的rust语言而言,便显得很困难。好在在查阅资料和不断的尝试下,还是将这一部分内容给完成了。

第二阶段总结报告

第二阶段的前三个实验,基本上是连着几天完成的,最后两个实验拖慢了几天。

总的来说,对于这一部分,还是学到了很多知识。

首先说一说riscv方面吧,对于这一方面相对更加熟悉,之前也编写过riscv的处理器和模拟器,并且也使用c语言编写过裸机应用,对于riscv相关的知识方面,还是很顺滑的便掌握了。

对于rust语言,写到这个阶段已经比第一阶段顺手多了,不过在使用这个语言时使用的很多设计理念,例如资源获取即初始化,等等设计方面的考量,还是学习到了很多。或许在完成这一部分内容之后,可以试着使用rust去重构之前写过的一些c语言项目了(乐)。

随后是对于操作系统相关的知识,之前学习过一些操作系统的概念和知识,但是对于真正的将操作系统实现出来,还是第一次尝试做出来。以前学习操作系统时还尝试看过一些linux内核设计相关的书籍,最后的结果是看的比较稀里糊涂。通过这次操作系统设计之后,或许之后入门linux能够更加顺利一些。

前言

在去年秋天听闻了这个训练营,机缘巧合之下结识了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

DAY0-DAY?

0 前言

本篇内容为基础阶段 - Rust编程的总结,分为环境配置语言学习两个板块。

因为语言学习涉及内容较为零散,故将DAY0-DAY?汇聚成一篇。

1 环境配置

1.0 前言

环境配置部分主要流程就是根据教程内容一步步安装,同时需要注意以下几点:

  • 学会分析报错内容
  • 学会利用搜索引擎/gpt解决报错问题

    1.1 wsl+ubuntu22.04+vscode

    之前在完成pintos时配置过,故省略

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的开发的复杂性,我们必须还要通过阅读系统而权威的大部头书籍来全面深入地学习
  • 同时,一些特别细节的点在入门资料和书籍中未必涉及,需要我们查阅官方文档

因此我穿插查阅/参考了如下资料:

并通过穿插完成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

    • HashMapget()方法只能返回值的引用
    • 解引用操作*也需要转移所有权
  • quiz2

    • match中模式绑定的值,如:Command::Append(n) => {} 中的n是&usize类型,可以使用for i in 0..*n完成遍历
    • 对于不可变引用的string,要想修改需要先将其clone给一个可变变量
  • 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平替
  • generics1

    • &str vs String
    • 也可以用_来让编译器自动推断
  • generics2

    • 泛型可以类比多态
    • 结构体中的泛型 & 方法中使用泛型
    • 例如(from Rust Course):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      struct 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 Timpl<T: std::fmt::Display> ReportCard<T> {...}(根据编译器help提示)
  • lifetims1

    • 标记的生命周期只是为了取悦编译器,告诉编译器多个引用之间的关系,当不满足此约束条件时,就拒绝编译通过;并不会改变任何引用的实际作用域
    • 和泛型一样,使用生命周期参数,需要先声明 <'a>
    • 本题中:把具体的引用传给longest时,那生命周期'a的大小就是xy的作用域的重合部分,换句话说,'a的大小将等于xy中较小的那个
    • 编译器提示:
      1
      2
      3
      4
          help: 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:
      1. Move the string2 declaration to make it live as long as string1 (how is result declared?)
      2. 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,因为它会帮我们自动完成
  • 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
      2
      let 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
      11
      fn 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
      6
      fn 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
        9
        fn main() {
        let b = foo("world");
        println!("{}", b);
        }

        fn foo(x: &str) -> String {
        let a = "Hello, ".to_string() + x;
        a
        }
      • foo 函数中,aString 类型,它其实是一个智能指针结构体,该智能指针存储在函数栈中,指向堆上的字符串数据。当被从 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()上锁
    • 注意:
      1
      println!("jobs completed {}", status.lock().unwrap().jobs_completed);
      如果放到循环内,就是输出10次jobs completed xx的值有时候全是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。通过为每个变量创建txclone,我们确保了每个闭包都拥有其独立的sender,从而避免了use after move错误
  • macros4

    • 每个macro规则后面加;
  • clippy1

    • 根据编译器提示修改
    • const PI: f32 = std::f32::consts::PI;
  • clippy3

    • 已经通过 is_none() 检查确保了 my_optionNone,因此接下来不应该再尝试调用 unwrap()
    • Vec::resize 方法用于改变已有向量的长度,如果新的长度大于当前长度,则填充指定的默认值。
      • 但是,你的用法存在一些问题。首先,你试图创建一个空向量(通过将大小更改为0),但直接使用 resize(0, 5) 并不是正确做法,因为这会让人们以为你要填充默认值5到一个空向量中,而实际上你是从一个非空向量开始的。
      • 如果你想从 vec![1, 2, 3, 4, 5] 起始,然后得到一个空向量,你应该直接使用 clear 方法,而不是 resize
  • 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 f64values.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 and Clone traits for the generic type T to enable value comparisons and cloning, respectively.
  • algorithm2

    1. 定义两个指针 currentprev,分别指向当前节点和上一个节点。初始时 current 指向链表的头节点,prevNone

    2. 进入循环,在每次迭代中:

      • 获取当前节点的可变引用 node
      • current 移动到下一个节点。
      • 将当前节点的 next 指针指向上一个节点 prev
      • 如果 prev 不为 None,则更新上一个节点的 prev 指针指向当前节点。
      • 如果 prevNone,说明当前节点是新的尾节点,更新 self.end 为当前节点。
      • prev 更新为当前节点。
    3. 循环结束后,将 self.start 更新为最后一个节点,即反转后的新头节点。

  • algorithm9

    • 注意:在next中,对vec的处理除了要把最后一个元素拷贝到下标为1处,还需要把尾部元素用pop()删除。可以合起来写(后面加一个?),也可以分开。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      fn 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采取的正是这种教学策略:
Alt text
要是能早点遇到该多好☹…

文档

rcore的文档非常之详细(对比xv6/pintos等),初学者很容易陷入到细节中去,因此在阅读文档/代码前建议先看一下每章的引言(相当于论文的abstract),明确每章要干什么以及代码的大致框架

环境配置

我自己采用的是(之前别的实验就配置好的)wsl2+ubuntu18.04+vscode+git,具体操作步骤文档内容写的很详细,网上也有很多相关教程

18.04安装qemu7.0.0

根据文档一步步操作至:出现报错
Alt text
Alt text

sudo apt-get install ninja-build
随后继续按照文档操作
最终:
Alt text
在bashrc文件中配置路径:
Alt text
Alt text

其余部分根据指导书操作即可

rust语言

不建议花费太多时间,个人感觉比较靠谱的策略就是“迅速掌握一门语言的50%”,剩下的内容现学现用(按需调用)
重点:所有权和生命周期、类型系统
推荐资料:rust语言圣经、清华rust课程ppt

ci-user本地测试

在确定好思路后一定要看一下测试代码再动手coding,有些测试并没有覆盖所有情况,因此一些特别繁琐的情况可以忽略掉(不是本次实验的重点)

技能点

锻炼自己快速上手工程类代码的能力

ch3-lab1

1
2
3
4
5
struct TaskInfo {
status: TaskStatus,
syscall_times: [u32; MAX_SYSCALL_NUM],
time: usize
}

任务:新增系统调用sys_task_info

分析:sys_task_info本质上是对参数中用户程序传入的指针指向的结构体赋值,因此本次实验的核心围绕这些值展开,需要思考的点就是:这些值要存放在什么地方?什么时候初始化?什么时候更新?为了获取这些值需要设计几个函数,这些函数哪些只能内部使用哪些是开放接口?

具体思维过程:

  1. 只存储最原始的数据,也就是在tcb中存储syscall_times以及进程首次被调度的时间start_time,在需要时再将他们拼装成TaskInfo
  2. 变量初始化时机:tcb初始化
  3. 变量更新时机:所有系统调用中,更新对应的syscall_times[syscall_id]用户程序(及first task)对应的tcb第一次被调用时,更新start_time(这就需要思考:负责进行任务调度的功能在哪里实现?怎么判断是否为第一次?)
  4. 函数:
    • 将重复多次的操作封装成函数
    • 将私有变量/函数的操作封装成pub接口暴露给其他模块
  5. 其他细节:空指针

ch4-lab2

任务:重写sys_get_timesys_task_info,增加sys_mmap和sys_munmap

分析:因为引入了虚拟内存机制,因此sys_get_timesys_task_info系统调用传入的参数中,指针指向的地址是属于用户地址空间的,无法直接在内核地址空间中使用,需要进行转换。sys_mmapsys_munmap手册里描述的有点模糊,实际上通过阅读样例可知,该系统调用的功能就是以页为单位进行分配/回收,而且不会出现复杂的情况(比如跨越area,部分可回收部分不可…),关键点就是边界条件、port以及每次分配/回收都要同时操作pagetableareas,需要注意的点就是接口的设计问题(没有暴露出来的内容,需要用接口传参数进去在本地处理)

具体思维过程:

  1. sys_get_time和sys_task_info可以参考:
    Alt text
    我们平常编写用户程序的代码时,对指向某一类型变量的指针(虚拟地址)使用解引用,可以获得对应类型的变量,这是因为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:
    1. 分析参数路径是否合法(参考exec)
    2. 返回值是pid/-1
    3. tcb impl中,实现spawn
    4. 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 文件系统的整体架构自下而上可分为五层:

  1. 磁盘块设备接口层:/block_dev.rs
  • 归根结底是在块设备上以块为单位读写,
  • 读写磁盘块设备的trait接口– BlockDevice trait(仅需read_block 和 write_block)
  1. 块缓存层:/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
  1. 磁盘数据结构层:/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读入到内存中)
  1. 磁盘块管理器层:/efs.rs
  • 管理磁盘数据结构的控制逻辑
  • EasyFileSystem
  • 注意从这一层开始,所有的数据结构就都放在内存上了
  • 重要方法:
    • get_disk_inode_pos
    • get_data_block_id
  1. 索引节点层:/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文件系统的各种结构

  1. 块设备驱动层

    将平台上的块设备驱动起来并实现 easy-fs 所需的 BlockDevice Trait

  2. easy-fs层

    借助一个块设备BlockDevice,打开EasyFileSystem文件系统,进而获取Inode数据结构,从而进行各种操作

  3. 内核索引节点层

  • 将 easy-fs 提供的 Inode 进一步封装成 OSInode
  • OSInode 中要维护一些额外的信息
  1. 文件描述符层
  • 常规文件对应的 OSInode 是文件的内核内部表示
  • 需要为它实现 File Trait
  1. 系统调用层

ch8-lab5

任务:sys_enable_deadlock_detect

分析:银行家算法(即通过对已知数据的计算完成死锁判断)很好实现,关键是在何时/以什么形式记录/更新数据

具体思维过程:

  1. 把银行家算法涉及的所有数组封装成结构体,把相关的操作封装成对应的impl中的函数,并放到os/src/sync下一个新建的rs文件中
  2. 注意到测试数据中仅考虑了单个用户程序中的死锁问题,并不需要(实际上也无法,因为没有跨用户程序的接口实现锁/信号量/条件变量)考虑用户程序间的死锁,因此我们要以用户程序即pcb为单位进行死锁检测。为此我们要为pcb新增一个字段来容纳死锁检测结构体实例(如果是所有用户程序之间也需要检测,我们可以用lazy_static来实现),这涉及了初始化及更新的问题。对于数组的下标,我们用task_idsem_id/mutex_id来区分即可保证唯一性
  3. 死锁检测结构体初始化需要在impl中实现一个new函数,把数组初始化为0/1,并在tcb初始化时调用;每次检测后(检测前也可以),我们需要将finishwork数组单独初始化一次
  4. 更新主要围绕availableallocationneed数组,其中回收时(sem_up/mutex_unlockavailable+1allocation-1,分配时(sem_down/mutex_lock),若不能分配,则need+1,若能分配则available-1allocation-=1。所有情况都需要考虑死锁检测,若检测成功则继续,若检测不成功则返回-0xdead(这里我们的实现不够优雅,需要在检测不成功时对数组恢复,实际上优雅的做法是把数组操作放在检测之后进行)。我们必须在最底层函数(sem_up/mutex_unlock/sem_down/mutex_lock)中实现,因为一但检测失败我们需要停止后续操作并立即返回。
  5. 一些细节:比如sem_id/mutex_id需要用参数传递进去,以及为此需要修改trait