0%

专业阶段总结

四个抽象

  • 执行环境
  • 进程
  • 地址空间
  • 文件

    五个特征

  • 虚拟化
    • 内存地址虚拟化
    • 内存大小虚拟化:通过引入硬盘空间、
    • CPU虚拟化:通过分时以及快速切换
  • 并发
  • 异步
  • 共享
  • 持久

程序编译流程

  • 预处理:宏展开
  • 编译器: ——> 汇编语言
  • 汇编器:——> 目标代码
  • 链接器:——> 可执行文件

不使用标准库

#![no_std]添加在文件开头

禁用默认的main

#![no_main]

qemu启动流程

1
2
3
4
5
qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
  • virt 硬件平台上,物理内存的起始物理地址为 0x80000000 ,物理内存的默认大小为 128MiB,所以物理地址空间为[0x80000000,0x88000000)
  • 启动时先将sbi文件加载到0x8000 0000, 将os.bin 加载到0x8020 0000

计算机启动的三个阶段:

  1. CPU固有的一些程序,对应于QEMU就是其自带的一段程序,该程序第一条指令在0x1000处,最后一条指令就是跳转到0x8000 0000 执行bootloader
  2. bootloader 负责一些计算初始化工作后,再跳转到第三程序,对于RUSTSBI第三阶段程序的入口地址固定为0x8020 0000,实际的应用中bootloader会负责将操作系统程序从硬盘加载到该地址
  3. 执行os的代码

GDB调试

查看内存

x/nfu addr

  • n:表示要显示的内存单元的数量。
  • f:格式,可以是 b(byte)、h(halfword,2字节)、w(word,4字节)、g(giant,8字节)等。
  • u:显示的格式,可以是 x(十六进制)、d(十进制)、o(八进制)、t(二进制)等。
  • addr:内存地址。

    打印寄存器

    p/x $xx

特权级切换

S模式下的控制状态寄存器(CSR)

  • sstatus:SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息
  • sepc:当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址
  • scause: Trap的原因
  • stval: Trap的附加信息 (例如缺页异常时的虚拟地址)
  • stvec: Trap处理程序的入口地址

硬件

当执行ecall时,硬件的工作:

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

sret

  • CPU 会将当前的特权级按照 sstatus 的 SPP 字段设置为 U 或者 S ;
  • CPU 会跳转到 sepc 寄存器指向的那条指令,然后继续执行。

    软件(操作系统)

    在内存中存在两个全局变量,分别表示用户栈和内核栈,位于操作系统程序的.bss段
    从用户栈切换到内核栈,只需要将寄存器sp值进行更改即可

虚拟内存

当程序所需空间大于物理内存时怎么办。在没有虚拟内存前一种方法是,程序员对程序手工分段,依次将每段程序装入内存中运行。
虚拟存储器与此类似,只是由操作系统每次将正在使用的一部分放入内存,其余存在硬盘上。

  • 虚拟存储器大小由处理器位数决定,对32位处理器来说地址范围为$0 \sim FFFF\quad FFFF$,其中某一地址就叫做虚拟地址(VA)
  • 没有使用虚拟地址的系统,其处理器输出的地址直接就是物理地址(PA),而如果使用了虚拟地址,则处理器输出的就是虚拟地址了,其需要通过内存管理单元(MMU)转换成物理地址
  • 优点:使用虚拟地址的一大优点,对于需要同时运行多个程序的系统来说,每个系统都可以随意使用地址空间,不需要考虑地址的限制。以及保护(同一虚拟地址可以转换为不同物理地址)和共享(不同虚拟地址可以转换为统一物理地址)

    地址转换

    目前虚拟存储器通常使用请求分页(demand page)的方式实现,将虚拟内存按页划分,典型的是4KB,物理地址空间按同样大小划分,称为frame。
    对4KB大小的页,页内索引需要12位,即VA[11:0],称为page offset,剩余部分可以看做是页编号VPN(virtual page number)。相应的PA[11:0]是frame offset,剩余部分PA[:11]是PFN(physical frame number)。从虚拟地址到物理地址的转换时间上就是VPN到PFN的转换。

    单级页表

    当要访问的page,没有与物理内存中的某个frame形成映射关系时,即需要的内容不在物理内存中,此时发生缺页异常(page fault),需要将内容从硬盘加载到内存中。
    操作系统如何知道page和frame的对应关系?目前普遍使用表格存储VPN到PFN的映射,称为页表(page table,PT),一般存放在物理内存中。
  • 每个程序都有一个张页表,页表在物理内存中的首地址存放在页表寄存器中
  • 页表与cache不同,其包含了所有VPN的映射关系
  • 操作系统会在不同进程之间进行切换,在当前进程被替换掉之前,需要保存其状态,对于页表来说只需要保存页表寄存器就好
  • 页表存在于物理内存中,因此一次取数据需要访问两次内存,第一次是访问页表完成VPN到PFN的转换,第二次是用物理地址来访问内存

一张页表存储了所有VPN的映射因此,对32位处理器,其虚拟空间为4GB,假设页大小为4KB,则VPN编号为VA[31:12]共20位,则一个页表有$2^{20}$项,假设每项占32位,则页表大小为4MB。而对64位处理器,一张页表达到了PB大小,这已经超出了物理内存的大小。而且大多数程序也不会用光虚拟空间,只会使用一小部分,页表项大多都是空的。解决方法就是多级页表

多级页表

通过引入多级页表,页表也无需占用一块连续空间。页表中的一项称为PTE(page table entry)
多级页表的引入,使得访问数据需要的访存次数增加

缺页异常

缺页异常通过是由软件来处理

  • 这是因为发生缺页是需要将数据从磁盘搬运到内存,读磁盘所需时间对异常处理程序来说绰绰有余。
  • 处理过程中可能需要进行页替换,使用软件来处理更加灵活,可以按需选择合适的替换策略

发生缺页异常时,如何知道虚拟地址中的一个页在硬盘哪个位置?

  • 操作系统会为每个进程的所有页在硬盘中开辟一块空间(swap空间),同时建立一张表保存每个页在硬盘中的位置
    缺页处理可能会发生页替换,为了保证一致性,对页表项添加一个脏位,此外为了实现LRU替换策略,添加一个使用位,当使用位为1说明该页最近访问过,操作系统会周期性将使用位置0.
    缺页异常的硬件支持:
  • 缺页发生时,硬件会产生相应的异常,并跳转值异常处理程序
  • 脏位
  • 使用位

除此之外还可以通过在PTE中添加AP来实现访问控制,添加cacheable来表示是否允许被缓存

引入TLB和cache

TLB

多级页表节省了页表的存储空间,但是也增加了防存次数。因此引入缓存思想,对最近访问过的PTE(页表项)进行缓存,这个缓存被称为TLB,其本质就是页表的cache。不过其只有时间相关性。
现代处理器通常采用二级TLB,第一级是哈佛结构即分为指令TLB和数据TLB,一般全相连,容量不大,第二级则是组相连模式。

TLB缺失

TLB是页表的一个子集

  1. 虚拟地址对应的页不在物理内存中,此时页表中对应的PTE是空的,所以TLB中也一定不会有相关信息
  2. 虚拟地址对应的页已经在物理内存中,则页表中对应PTE是有效的,但可能该PTE不在TLB中。

要解决TLB缺失,本质是从页表中找到相应PTE,将其写入TLB中,这个过程叫Page Table Walk。该过程可以用硬件实现,也可以用软件实现。

  1. 软件实现:发生缺失时,硬件将产生缺失的虚拟地址保存到一个特定寄存器中,同时产生一个TLB miss 异常,然后执行异常处理程序,这个过程中可能还会出现缺页异常。软件的优点就是灵活,可以调整TLB写入换出的算法。软件实现中,当发生TLB miss时,流水线中的指令需要清空用于执行异常处理程序,有一个流水线清空和恢复的操作,流水线越深,清空的指令越多。
  2. 硬件实现:一般由内存管理单元MMU实现。好处就是不需要清空恢复流水线

一种LRU近似算法:假设TLB使用全相连,使用一个计数器每周期加一,计数器宽度为TLB表项数目,当TLB表中的项目需要被替换时,就以此时计数器中的数作为要被换出的表项编号。

TLB的写入

TLB与页表的一致性。
在对物理内存进行页替换时,需要检查被替换页PTE的脏位,如果是1,需要将被替换页内存写回硬盘,如果是0可以直接替换。
而引入了TLB后,就产生了TLB中表项与PTE的一致性问题。load和store命令都会先去查看TLB,如果存在对应的项,则会将use bit置1,此外store指令会将脏位变为1,这时TLB与页表就产生了不一致。如果使用写回策略,那么知道该TLB表项被替换才会更新页表,在此之间如果系统发生了页替换,系统可能以为该内存页没有更新而直接覆盖。

解决方法:页替换发生在page fault时,在进行页替换前,先将TLB写回页表,然后再执行替换

进程调度

批处理系统的调度

特点:计算为主,少量I/O

约束条件

  1. 每个进程同时到达。
  2. 进程的执行时间是已知的。
  3. 进程在整个执行过程期间很少执行I/O操作。
  4. 进程在执行过程中不会被抢占。

性能指标

周转时间(turn around),即进程完成时间(completion)与进程到达时间(arrival)的差值:
$T_{turn} = T_{complete} - T_{arrive}$
平均周转:
$T_{avg} = \frac{1}{n}\sum T_{turn}$

先来先服务FIFO

通常使用队列来实现:

  • 就绪队列
  • 阻塞队列

优点:

  • 实现简单
  • 公平

缺点:

  • 如果长任务先到,那么短任务会被迫等待很长时间,使得平均周转时间变长

最短作业优先SJF

在约束条件下,如果把平均周转时间作为唯一的性能指标,那么SJF是一个最优调度算法。

交互式系统的调度

约束条件

  1. 每个进程可不同时间到达。
  2. 每个进程的执行时间不同。
  3. 进程的执行时间是已知的。
  4. 进程在整个执行过程期间会执行I/O操作。
  5. 进程在执行过程中会被抢占。

性能指标

目标是提高用户的交互性体验和减少I/O响应时间
响应时间(response time),即从程序到达到第一次被执行的时间

最短完成时间优先(STCF)

  • 在支持抢占式的系统上,可以改善周转时间
  • 但对于响应时间,FIFO/SJF/STCF都没用特别好的效果

    基于时间片的轮转

  • 每个任务轮流占用CPU一段时间
  • 时间片越短,任务得到响应越快,但存在进行切换开销
  • 任务越多,轮到下一次执行的时间越长,系统平均周转时间也会变长

通用计算机系统的调度

约束条件

  1. 每个进程可不同时间到达。
  2. 每个进程的执行时间不同。
  3. 进程的启动时间和执行时间是未知的。
  4. 进程在整个执行过程期间会执行I/O操作。
  5. 进程在执行过程中会被抢占。

    固定优先级的多级无反馈队列Multi-level Feedback Queue

    一般CPU密集型优先级低,I/O密集型优先级高
  6. 如果进程PA的优先级 > PB的优先级,抢占并运行PA。
  7. 如果进程PA的优先级 = PB的优先级,轮转运行PA和PB。

难点:

  • 无法预估程序的类型
  • I/O密集程度多样

    可降低优先级的多级反馈队列

  • 动态调整进程的优先级
  • 操作系统首先需要以优先级的数量来建立多个队列。当然这个数量是一个经验值,比如Linux操作系统设置了140个优先级。
  • 优先级的变化是单向的,一个进程的优先级只会持平/降低
  • 低优先级可能会一直无法拿到CPU,产生饥饿现象

如何提升优先级

  • 定期统计进程在就绪态/阻塞态的等待时间,等待时间越长,其优先级的提升度就越高

语言基础

编译

rustc **.rs

项目构建cargo

cargo build默认根据代码创建一个可执行文件
cargo run 运行程序
cargo check检查语法和类型错误

模块

crate
一个crate相当于C++中的翻译单元

  • binary crate 可以被编译成可执行程序,其必须有一个main函数作为程序入口
  • library crate 常说的库文件,用于供其他程序调用

package

包是crate的集合,由一个Cargo.toml文件定义,可以有多个binary crate 但最多有一个库
在使用cargo new创建一个包后,默认包含一个以src/main.rs为入口的同名binary crate;
如果还有一个src/lib.rs文件,则包还包含了一个同名的library crate

modules

module 是关于在一个crate内如何组织代码
编译期在编译时,首先从src/main.rs开始查找要编译的代码

模块声明

可以通过mod xxx {} 来直接在文件内定义一个模块。

  • 一个module内的成员默认是模块私有的,如果想要在模块外使用,必须声明为pub

子模块

模块允许嵌套,子模块的成员要想被最外层的访问,其每一层都需要pub关键字

字符串

字面量的类型是&str

1
let s = "hello, world";

带所有权的String,分配在堆上,底层是一个Vec,注意结尾没有null

1
2
let s = String::with_capacity(32);
s.push_str("Hello world");

这里会在堆上申请32字节的内存,在栈上创建指针s(p, 11, 32)

String 和 &str

&str to String

  • to_owned:从借用类型创建拥有所有权的副本,适用于任何实现了 ToOwned 特性的类型。
  • to_string:将实现了 Display 特性的类型转换为 String。
  • String::from 和 .into():将 &str 转换为 String,分别适用于显式和隐式转换。

字符串拼接

使用+号进行拼接时,左侧必须是一个String类型,而右操作数则必须是&str,其中&String可以自动转成&str

Unwrap 和 ?

  1. unwrap 是一个方法,通常用于在你确定一个 Result 或 Option 包含值时获取该值。如果 Result 或 Option 包含 Err 或 None,调用 unwrap 会导致程序崩溃,并显示一个错误信息。因此,使用 unwrap 时,你应该确保被操作的 Result 或 Option 不会为 Err 或 None。
  2. ? 是一个在函数中处理错误的语法糖,它可以帮助你编写更简洁的代码。当你在一个返回 Result 或 Option 的函数中使用 ? 时,如果结果为 Err 或 None,? 会立即返回该错误,否则它会解包 Ok 或 Some 中的值。这种方式允许你在遇到错误时将错误传播给调用者,而不需要显式地处理每一个可能的错误情况。

traits

在 Rust 中,实现 trait 时,不能直接在 trait 的函数签名中给参数添加 mut、&mut、& 等符号。这些符号只能出现在具体实现(impl)中,
而不是在 trait 定义本身。

lifetime

生命周期与引用是紧密相关的。

  • 为编译期在编译期提供一种验证指针有效性的的方式
  • 生命周期是指定的代码区域,引用必须在这些区域内有效

    ‘xxx

  • 生命周期的标记只是描述多个引用之间的生命周期关系,不影响实际对象的真实生命周期

    标记消除

    为了减少编码量,当引用满足如下规则时,不需要标记引用的生命周期
  • 针对输入参数,编译器会给每个参数不同的生命周期标记
  • 对于返回值,如果函数只有一个输入参数,那么引用类型的返回值的生命周期标记与输入相同
  • 如果第一个参数是&self或者&mut self那么输出的生命周期与其一致

迭代器

map的闭包参数时迭代器元素本身,而filter的闭包参数则是迭代器元素的引用,所以filter的引用符号比map多一个

该阶段练习rust语法。一百道题目够大家堪堪入门,数量正好。当然还有很多用法是后面在第二阶段慢慢掌握的。rust这门语言特色比较鲜明,所有权机制、生命周期管理等等,都挺有意思的,而且要和rustc斗智斗勇,很难忘。

本实验难度基本和os课程大作业难度一致,可能稍微容易一点。出于时间原因和个人能力不足,代码写得比较丑陋,健壮性不足。

第二阶段很好的带着大家窥探了os的几个基本部分。进程管理、内存管理、通信、文件系统等等比较好的串联起来了,能够让大家有连贯的感觉,知道os是怎么跑起来的,从教学代码来说,算是比较成功的,环环入扣。

第一阶段的总结

  1. 初识 Rust:语法与基础
    Rust 的语法相比其他系统编程语言更为严谨,并且采用了一些新颖的设计,比如所有权(Ownership)和借用(Borrowing)模型。通过学习变量、函数、控制流、数据结构等基础内容,我逐步熟悉了 Rust 的编程风格。同时,Rust 强制规定的编译检查(如变量不可变性、类型检查)帮助我在早期阶段就养成了良好的编程习惯。

  2. 内存管理与所有权
    Rust 的核心设计之一是其所有权系统,这也是区别于其他语言的最显著特性之一。在 Rust 中,每个变量都有一个所有权(Owner),变量的作用域结束时会自动释放资源,无需手动管理内存,这有效地避免了悬空指针和内存泄漏问题。在学习过程中,我对以下几个概念有了深刻理解:

所有权(Ownership):每个数据在同一时间只能有一个所有者。
借用(Borrowing):通过引用传递数据,允许在不改变所有权的情况下访问数据。
生命周期(Lifetimes):通过生命周期标注来管理数据的引用,使得引用的使用更加安全。
所有权系统让 Rust 在没有垃圾回收的情况下也能有效管理内存,学习这些概念后,我对资源管理的意识有了显著提升。

  1. 错误处理
    Rust 的错误处理也与众不同。相比传统语言使用的异常机制,Rust 使用 Result 和 Option 枚举来处理错误和可选值。这种显式的错误处理方式让我更加清楚地理解了每个操作的潜在风险,并培养了防御性编程的习惯。此外,通过使用 ? 操作符,代码变得简洁易读,让错误处理不再显得冗长。

  2. 并发编程
    Rust 在并发编程上的独特设计使其特别适合编写高性能的并发应用。Rust 的所有权模型通过在编译期检查数据竞争,有效防止了多线程中的常见问题,比如数据竞争、死锁等。Rust 提供的 std::thread、async/await 异步编程模型等工具使并发编程更加安全且易于管理。在学习中,我掌握了创建线程、共享数据的方式,并学会使用 Mutex 和 Arc 等工具来处理复杂的多线程情况。

  3. Cargo 和生态系统
    Rust 的包管理器和构建工具 Cargo 是 Rust 生态系统的重要组成部分。在学习中,我逐步熟悉了如何使用 Cargo 创建项目、管理依赖、构建和测试代码。Rust 拥有活跃的社区和丰富的库资源,通过使用第三方库,可以快速实现许多功能,这大大提升了我的开发效率。

  4. 实践中的收获
    理论学习之后,我通过一些实际项目(如简单的 Web 服务、命令行工具)将所学知识应用于实践。Rust 对类型和内存的严格检查在初期可能显得繁琐,但这些设计让我更清楚地理解代码结构,减少了运行时的错误。实践过程中,我逐渐感受到 Rust 的优势:高性能、可靠性和低级控制,使得 Rust 成为编写高效、安全应用的理想选择。

    第二阶段总结

在第二阶段的学习中,我逐步深入理解了操作系统中各个功能模块的实现。这一阶段的主要任务是通过阅读和理解代码的层次结构,来掌握系统的整体设计。在初期,我对项目代码结构的理解较为模糊,但随着长时间的代码阅读和实践,我总结出了一些行之有效的阅读和理解方法:

  1. 分层次理解代码:我发现从高层抽象入手去理解代码结构更为高效。首先理清最高层的抽象层次,理解其核心功能,再逐渐深入到底层实现的细节。在实验中,掌握和利用高层抽象往往能够帮助节省大量时间。

实验总结

实验一:基础系统调用实现

实验一的难度较低,因为此阶段还未实现页表管理,内核可以直接访问用户态的地址空间。实验中,系统调用的返回值可以直接使用内核中的数据。此外,通过高精度的 get_time_us 函数获取时间,能够确保后续实验中通过测试。为实现系统调用计数,可以在 syscall 函数调用时更新计数,而首次调用时间则需在 run_first_taskrun_next_task 中进行设置。

实验二:引入页表管理

在实验二中,由于实现了页表管理,系统调用设计需要改写。内核在处理传入参数时需找到实际的物理地址,并在该地址上读写数据。同时,本次实验引入了两个新的系统调用 mmapmunmap。因为两者涉及一段连续地址,因此在设计中需确保连续地址的映射状态(已映射或未映射)。最后,mmapmunmap 分别负责插入和删除页面。

实验三:进程管理与调度算法实现

实验三的任务是实现 spawn 系统调用和 stride 调度算法。spawn 不能简单地视作 fork + exec 的组合。在实现前需理解 spawn 的语义,然后从 fork + exec 中提取相关代码,使得 TCB 数据结构达到所需的状态。stride 是一种进程调度算法,实现较为简单,按照教程提供的步骤逐步操作即可。

实验四:兼容性与文件系统操作

实验四相较之前难度有所提升,需要保证现有代码的兼容性。一开始 spawn 存在兼容性问题,导致错误频发,最终通过重新实现 spawn 解决了兼容问题。此外,死锁处理是本实验的重点之一。由于某些函数(如 findcreate)对文件系统上了锁,因此这些函数不可直接调用,但可以通过拼接部分代码来实现所需的功能。unlink 目录项的删除操作则需更新目录项,实验中借鉴了其他同学的思路,通过删除原内容并复制新内容实现目录项更新。

实验五:死锁检测算法实现

实验五主要任务是实现死锁检测算法,该算法类似于银行家算法。教程中的描述较为简略,许多细节需要在实现中仔细推敲。semmutex 的实现大致相同。此外,sys_get_time 必须实现,否则某些实例可能会引发死锁。


整体总结

本阶段实验的概念和难度相对基础,更多的挑战在于使用 Rust 编写操作系统。对于首次接触 Rust 的我来说,这种体验充满新奇,同时也加深了我对 Rust 的理解。Rust 强大的内存安全和所有权模型,在系统级编程中尤为适用

来跟大伙唠会

你们好,我是清朝老兵。

我是catme0w,你可能已经在排行榜见过我的名字了。

去年这个时候,我已经来过一次了。为什么我会再来一次?马上揭晓答案。

在继续之前,你可以先看看我上一次留下的记录:https://rcore-os.cn/blog/2023/11/14/2023开源操作系统训练营第二阶段总结报告-CatMe0w/

和上次一样,我的日志里不会有太多关于技术的流水账。如果你想看一个不错的故事,那你来对地方了;如果你还在rCore中挣扎,正在寻找一些攻略,那么我也不会让你白跑一趟,接好了:

写在最前,太长不看攻略:你可能需要的建议

  1. 强烈建议关闭Copilot或Codeium等代码补全器,它们完全无法理解内核,对于系统编程几乎一无是处;它们产生出的代码会浪费你非常多的时间。
  2. 小黄鸭调试法或许会有帮助。
  3. 在你100%理解代码库之前,你是做不出题目的。相信我。
  4. 完全理解代码库之后,先构思好你准备要做的东西,否则出场即大改。
  5. 做好因方向错误而大改的准备。
  6. 小心#[repr(C)]。
  7. rustsbi-qemu可能非常不稳定,遇到灵异现象?换个QEMU版本试试;如果你在用Apple Silicon,也换台机器试试。
  8. gdb或许很低效,多插trace!()。

未讲述的往事

如果你看了上边我参加去年训练营时的记录,你应该已经了解到,当时,我在ch5就停滞下来然后退出了。我其实内心是有些后悔的,觉得自己当时并没有看起来的那么忙,是自己的懒惰造成不得不退出的结局。

纵使rCore从不是什么大事,我还是觉得“抬不起头”,之后也从未和别人再提起这个精致的小操作系统,就好像未曾发生过。

我会自己写一个操作系统吗?也许永远不会吧。就当是一场梦。我这么想。

次年夏天。

在一个非常非常偶然的机会,我了解到我的一位朋友参加了今年春季的训练营。而我们之间最大的不同,便是我自始至终都只抱着“60分万岁”一般的态度,指望着能混到晋级就是胜利,而他从一开始的目标就是榜一。

我仿佛挨了一记响亮的耳光:做不到,只是因为不想。

所幸,训练营从不禁止清朝老兵(而且现在还越来越多了!),我有机会为rCore重新补上一个完美结局。

rustlings II

我也是Rust老兵了,这100题,不是把我去年的补丁直接合过来便是?

然而从今年开始,rustlings多加了十道算法题,成了110题。清朝老兵遇到了它的第一个障碍!

我了解Rust的脾气,知道什么数据结构得开unsafe,只是我已经很长时间没有摸过算法题了,更没有用过Rust写算法题,一打开,满屏的泛型让我虎躯一震。

这需要一点时间……不过,仍然只是小插曲。让我们直面恐惧,走向rCore吧。

rCore II

实不相瞒,我去年压根没看懂rCore的教程,我的确是一路完成到了ch5,可除了ch3以外,我自己都不知道我写了什么东西,我的代码以一种连我自己都无法理解的方式运行起来,大概纯粹是碰对了测例而已。

往好处想,将功补过的机会来了;往坏处想,这相当于一点经验都没有了!

ch3

没有什么困难,凭借着清朝时期的记忆,我还记得ch3要在哪里加上什么。迅速结束战斗。

ch4

我至今都认为,ch4内存管理这一章是整个rCore难度最大的部分,至少在知识上是如此!题目本身反而是不那么难的,代码量不大,理解内存管理的情况下应该很快能找到正确的路;可如果对内存模型有哪怕一丢丢一知半解,你都会在ch4的题目上拥有一段不愉快的时光。

嗯,我承认我是后者……

ch5

ch5做的是什么来着?我又给忘了。它就是如此没有存在感,前后的ch4和ch6都是令人大叫的程度,相比之下,ch5就有些难以给人留下深刻印象了。

哦想起来了是spawn。好像还真没有太多可以说的,在经历过前两章的毒打之后,你应该能很快处理完这一章。

ch6

我抵达了我之前没能征服的地方,硬骨头来了。至此,rCore才真正展现出它的威力,BOSS进入二阶段了!

ch6在知识上稍轻松些,但题目的难度却和教材文本完全不成比例。题目的难点在于非常的绕,为了改动某个功能,你可能需要前后在许多不同的地方修改一连串代码,更不必提这个在内核之外的模块很难调试。

到这里,有件事我一定要骂:用Rust编程,有时真的很让人沮丧。

人们总是说“编译期拦住错误比事后debug更好”,可是只有自己才知道,用Arc啊,Mutex啊那些东西的时候有多令人不快:它明明就在这里,就是这么直观,但就是不让你用。

当天中午11点,我已经几乎完成了ch6的功能实现,我想着,ch6通过就去吃午饭。

你能猜到这样立flag将会如何发展。

晚上11点的我:😅

ch8

就快结束了!最开始看到ch8教程的时候,我心中窃喜:这么简单啊!就好像回到了ch3一样!

题目要求也很简单,甚至详尽地描述了要实现的每个细节。

上机开写,很快就通过了互斥锁的测例,信号量的第一个测例也很快通过了。只剩最后一个测例了,马上就可以结局了!

然后我就在这“最后一个测例上”卡了十多个小时。🌚

要么是第一个测试不过,要么是第二个,两个测例呈现出完全不可理解的结果。可是无论我怎么检查自己的代码,在能插的地方都插满了log,发现每个步骤确实都与预期中的相符,可为什么结果就是不对呢?我也没有理解错题意啊?

“我也没有理解错题意”……等等。

会不会真是我把题目弄错了?我会不会实现了一个错误的算法?

走投无路之下,我开始直接写这个算法的证明,很快发现,(按我理解的)这个算法根本无法检测任何死锁!

题目在算法的描述上,确实有些混乱和容易误导人。我意识到我该寻求外部资料了,到此时,我才终于知道这就是Dijkstra的银行家算法。

在查看了这个算法的精确描述后,我才终于弄明白我出问题的地方:need矩阵,只减不增,更绝非我最初理解的从0开始增长。

终于,所有测例都能通过了,ch8结束了,可是我却有些哭笑不得,因为这么愚蠢和缺乏技术含量的原因卡住,感觉自己有些像是被戏弄了。

rCore EnCore

感谢你还有耐心看到这里。也许你已经留意到,我在今年的排行榜上是榜一。

二阶段classroom放出之后,我使用的是补丁方式提交,因而在补丁里还留有当时的实际完成时间:9月21日。

在我完成rustlings之后,也就是今年报名刚开的那个周末,我把整个周末的全部时间都投入到速通rCore上了。

不为别的,我就想给去年的自己一个耳光。

尾声

做rCore实验的过程,前半程就是一筹莫展:你看了半天文档,感觉自己好像会了,一打开代码却发现两眼一抹黑,根本不知道在干什么。

然后你花了很长时间终于大致摸清楚代码结构了,一准备开始写,发现还是一筹莫展,都不知道要写什么东西。

过了不知道多久,终于你有了一个差不多的思路,可以开始写了,此时,你来到了后半程。

你可能用了不太长的时间就写出来,也可能很久,但一定是跑不起来的。

接下来你会用几十个小时的时间去调试,gdb或者插满trace!()。

然而即使用了gdb也没有太大帮助,因为错误往往出现在你想象不到的地方。

有的时候你会发现测试用例能过,甚至是随机性可以过,有的时候会发现QEMU直接一动不动——rustsbi-qemu并不稳定。

当你第一次发现测试能过的时候,哪怕只是概率性成功,你以为自己离胜利近了,但你很快,或者很久以后才发现,方向完全错了,你必须全部推倒重来。

这种事将会反复发生。

最后,你离最终的目标越来越近,但每次都总差那么一点,你不禁怀疑自己是否真的在接近终点,还是又一次搞错了方向。

到了最后,你可能都记不清这中间到底推翻了多少次,改了多少个细节。你不再期待一蹴而就,而是学会了享受每次解决一个小问题的成就感。

不管失败多少次,推倒多少次,你已经比一开始那个两眼一抹黑的自己强太多了。也许方向错了,代码推倒重来,但至少,你知道自己在进步,哪怕每一步都很艰难。最终你会发现,这些反复挣扎的瞬间,才是整个过程里最有价值的部分。

尾声之后

“我会自己写一个操作系统吗?”

会的。

我回想起一件事,是在我很小的时候发生过的,我已经很难分辨究竟是什么时候了。

人们在讨论《30天自制操作系统》这本书。

当时的我想,啊,这竟然是可能的吗?这本书一定很厚,很难懂……

在那时的我眼里,除开最终呈现给用户的应用程序,其余关于计算机的一切都是魔法烟雾构成的黑盒——麻瓜弄不明白。

但在这么多年之后,我终于明白,做不到,只是因为不想。

我们都可以。

训练营只是一个开始,在这之后,还有更广袤的世界等待着探索。

https://github.com/CatMe0w/attune

一、前言

毕业之后一直工作都是在做c嵌入式驱动开发,是和同事闲聊的时候了解这个项目的就开始学下去了 从2024春季就已经开始学了但是因为一些事情耽误了就没有完成
对我个人来说主要是好奇想看看经常看到的所谓写操作系统是做什么,以及我在工作中也会看到rust的一些内容对这个语言也很好奇

二、基础阶段

错误记录

在基础学习的时候我认为这样的错误记录是很有必要的
因为我基础并不好经常对于书上看到的方法看到就忘了 实际写起来我最大的感受就是
1、如果有多个数据结构嵌套我很容易就搞不清哪里要复制哪里不复制
2、闭包按照我理解就是一个函数指针 在传递闭包的时候一开始没理解总是搞不清楚到底是怎么回事

以及在完成一阶段之后我才安装了rust-analyer也算是踩坑了把 有这个插件才发现可以很快就发现错误不用在一次次编译

三、OS专业阶段

专业阶段在整个学习的过程中对我而言最困难的是第四章和第八章
在第四章 我觉得内存的边界条件卡住了很久 主要还是理解不够 明白了 地址空间 应用空间 需要转换
也看明白了虚拟地址的寻址方法 但是真的感觉新的文档没有图我一开始没理解的就是 他三级页表怎么寻址的过程
后面才真的理解上一级页表的值代表的是下一级要去找的page 再再下一级page上找到对应页表项 才实现了多级页表的寻址
三级页表

整体来说我觉得这个专业阶段 首先文档需要图
并且我发现只看一遍效果很差 需要边看边记 并且对于代码讲解最好是能够记录并且依次添加注释 明白每一步再做什么不是只看文档中的讲解就过一遍就结束
结构体关系
以及我觉得再实验过程中最重要的是搞清楚各个结构体所代表的实际意义
并且可以集中在图上展示 不然我在做ch6一开始总是错就是 没梳理清楚 disk_inode inode OS_inode还有目录项分别都是些什么 导致ch6经常找错
于是我就在草稿纸上根据理解写了我对于这些数据结构的理解和关联很快就解决了

实验的时候还有一点 有时候我总喜欢通过结构体之间的关系来找对应的数据 但是在rust里这样做我觉的很复杂
最好的方式就是在容易得到的结构体中保存需要的数据

第一阶段

第一个阶段是通过 Rustlings 练习 Rust 语言的基础知识,包括变量、函数、所有权等等。在这一阶段, 我更加深入地了解了 Rust 的一些基础概念,比如所有权、生命周期等等。在这一阶段的学习中, 我发现通过 debug 的方式来学习一门编程语言和熟悉其语法是非常有效的。这能够让我更深入的去思考一些语言设计的原因,而不是简单地记住一些规则。

在学习 Rust 的过程中,我发现 Rust 的所有权机制和生命周期机制是 Rust 的核心特性,也是 Rust 与其他语言最大的不同之处。这也是我认为 Rust 是一门非常有意思的语言的原因之一。所有权机制能够让我将每一个值看作绑定, 而非变量, 这种思维方式让我感觉非常新鲜, 也让我能够更多地将注意力放在程序的逻辑上, 而非内存管理上。生命周期机制则是 Rust 的一种保证, 保证了程序的安全性, 同时也能有效的避免 C/C++ 中的一些内存问题, 比如野指针、内存泄漏等等。

在后续的日常开发过程中,我应该会尝试使用 Rust 做为我的首选语言,因为我觉得 Rust 的所有权机制和生命周期机制能够让我写出更加安全、高效的代码。

第二阶段

第二个阶段是通过为 rcore 添加一些简单的 feature 来熟悉操作系统的基本概念和相关实现。在这一阶段,我更加深入地了解了操作系统的一些基本概念,比如中断、虚拟内存、系统调用、文件系统等等。在这一阶段的学习中,我发现通过实践来学习操作系统是非常有效的。这能够让我更加深入地理解操作系统的一些基本概念。对于模糊的概念,我可以直接看到它们的实现,这让我能够更加直观地理解这些概念,也能更深入的思考这些设计的原因。

在这一阶段,我的感受是操作系统是一个非常庞大的系统,其中涉及到的知识点和细节非常多,但同时操作系统对各组件的抽象也非常清晰。只要理解了这些抽象,将各组件串联起来也并不是一件非常困难的事情。

lab1总结

实验一相对简单。由于尚未实现页表,内核地址空间可以直接访问用户态地址空间,因此系统调用的实现只需直接返回用户态地址即可。此外,为了确保后续实验中的测试通过,建议使用高精度的 get_time_us 获取时间,避免因时间精度不足导致错误。至于统计系统调用次数,可以在 syscall 函数执行时进行更新。若需获取系统第一次调用的时间,则需修改 run_first_task 和 run_next_task。

lab2总结

与实验一不同的是,实验二中需要实现页表。因此,之前的系统调用逻辑需要重构。内核必须找到传递参数的实际物理地址,并在该地址进行读写操作。

此外,实验要求实现两个新的系统调用:mmap 和 munmap。这两个调用涉及一段连续地址,因此需要检查该地址范围是否完全未映射或已映射。mmap 负责插入页面,而 munmap 则用于删除页面。

lab3总结

在实验三中,首先需要将之前实验的代码引入本次实验。如果之前的代码有问题,本次实验的测试通常也无法通过。

本次实验的核心是实现 spawn 和 stride 调度算法。spawn 并非简单的 fork + exec,因此在实现前需充分理解其语义。可以借助 fork + exec 的部分代码来调整 TCB 数据结构至目标状态。

stride 是一种进程调度算法,由于本次实验示例较简单,按照教程步骤实现即可完成。

lab4总结

相比之前的实验,实验四的难度有所增加。首先,需要确保兼容之前的代码。一开始 spawn 的兼容性问题导致多次报错,最终通过重新实现 spawn 才解决。

此外,还需注意可能出现的死锁问题。由于某些函数(如 find 和 create)对文件系统加锁,不能直接调用它们,而是需要将其实现逻辑拷贝并进行调整。

最后是 unlink 的目录项删除操作。在找到目标目录项后,需要更新内容。采用了群里分享的方法:删除原目录项后,将内容重新拷贝过去。

lab5总结

实验五的任务是实现一个死锁检测算法,类似于银行家算法。但教程中的描述较为模糊,许多细节未明确说明。因此,需要仔细分析各个细节。sem 和 mutex 的实现思路基本相同。另外,sys_get_time 的实现必不可少,否则某些测试实例会出现死锁。

总结
总体来说,这些实验内容并不复杂,涉及的概念较为基础。但由于是第一次接触 Rust 并使用它编写操作系统,对我而言是一次新奇的体验。实验的主要难点在于 Rust 的使用上。此外,教程的部分内容并不完善,例如死锁检测算法的部分解释得较为模糊。若不是群里有人提到类似银行家算法,我很可能难以理解。因此,希望教程能进一步完善文档内容,帮助更多初学者。