感想 在接触这门课程之前,我正处于第 N 次入门 Rust 的途中,在之前其实就一直有过多次学习 Rust 的经历,我博客上关于 Rust 的学习笔记最早甚至是 18 年的,回顾我最早学习 Rust 的理由是我希望掌握不同的编程语言范式拓展自己的思维模型。
「学习过程更多的是知识迁移,如果是学习新的编程范式则会稍有难度,这不仅仅是学习新的概念,还需要在思维模式上做出改变,对于编程语言的学习,应当多去学习不同范式的语言,尽管现代编程语言都支持多范式,但各语言擅长的范式还是不一样的。」
但前几次入门 Rust 多因为工作没能用上而浅尝辄止,最近一次我是深刻意识到不实践是无法学会的,所以我一边用 Anki 制作学习笔记卡片,一边尝试用 Rust 写一些小工具,又在 Rust 微信群里看到了 Rust 操作系统的课程,这一下死去的记忆突然开始攻击我了,这不正是我想要的实践吗? 回想起大学自己自学过王爽的《汇编语言》还有于渊《一个操作系统的实现》,但止于汇编编写内核引导后输出 Hello World 的阶段,我被开篇的复杂 C 语言应用劝退了,上面两本书的作者和名字我时隔十年居然都能清晰回忆起来,这或许与我当时的热情有关吧,毕竟操作系统是三大浪漫之一。 于是我立了个 FLAG 想要弥补过去没学好的内容,另外也是为了真正知道自己在做什么,当我拿起自己习以为常且熟练的编程语言和计算机工具进行各种创作的时候,真正发生了什么,学完二阶段或许我不能说掌握了多少底层的细节,但是我有了一种清晰地感知,当我写下的代码在计算机中流动的时候发生了什么。
控制流 由代码编写的指令在编译成汇编代码后会形成一个执行序列,这个序列就是一个控制流,在正常没有中断和异常情况下,整个控制流都由代码生成的汇编控制,这个控制流是普通控制流。 但是一旦进行系统调用,程序就会脱离原本的控制流,进入到操作系统内核代码的控制流中了,比如接受 IO 中断响应,处理用户输入等。这种突变的控制流称之异常控制流 。(处理中断、异常、陷入) 这个过程中伴随着执行环境的变化,以及上下文的切换,在应用程序基于操作系统抽象(进程、线程等)的执行环境中,突然切换到操作系统内核基于硬件的执行环境中,同时上下文发生了变化(执行环境的相关参数)。 操作系统和应用程序需要协同硬件一起来保存和恢复这些上下文,使得程序能够正常运行。
异常控制流的保存和恢复由操作系统和 CPU 负责。(手动编写在栈上保存与恢复寄存器的指令)
对于函数转移这内控制流转移,由编译器负责。(编译器会自动生成栈上保存与恢复上下文的指令)
异常控制流 外设中断(Device Interrput):由外部设备引起的外部 I/O 设备事件。 异常(Exception):程序发生除零错误,内存访问越界等。 陷入(Trap):系统调用进入操作系统工作流。
三种东西都是一回事,都是应用程序的工作流被中断了,跑去执行别的地方的代码了,不过对三种中断的方式做了命名区分。
目标平台与目标三元组 通常一个 C 程序编译器的工作流程如下:
源代码 -> 预处理器进行宏展开
宏展开的源代码 -> 编译器编译生成汇编代码
汇编程序 -> 汇编器编译为目标机器代码
目标代码 -> 链接器链接为可执行文件
Rust 通过一个三元组来描述软件运行的目标平台,即 CPU、操作系统、运行时库等。
`$ rustc –version –verbose
1 2 3 4 5 6 7 rustc 1.82.0 (f6e511eec 2024-10-15) binary: rustc commit-hash: f6e511eec7342f59a25f7c0534f1dbea00d01b14 commit-date: 2024-10-15 host: aarch64-apple-darwin release: 1.82.0 LLVM version: 19.1.1
从 host 可以看出我们的 CPU 是 aarch64, apple 系统, darwin 运行时。
Rust std 库和 core 库区别? std 库依赖操作系统,提供线程等能力,core 库不依赖操作系统。
elf 格式分析rust-readobj -h target/riscv64gc-unknown-none-elf/debug/os
实现第一个裸机程序我们做了什么? Rust 编程:剥离标准库,使用 core 库提供的 trait 实现 panic_handle
1. 去掉 Rust 的标准库依赖,使得程序能够编译成功。
在 Rust 项目中使用 #[no_std]
标识不使用标准库,这样一来我们 println!()
也将无法使用需要自己实现。
panic 处理是必须的,但是 panic!宏也标准库实现,因此需要导入 core 库手动实现 panic_handle
方法
但程序需要一个的启动需要一个_start
语义项,语言的标准库作为程序的执行环境,在启动前也会做一些初始化工作才会跳转到由用户编写的程序入口处,但此时我们已经没有标准库依赖了,因此#[no_main]
声明没有通常意义的入口函数。
完成上面三项我们就可以编译通过了,但此时编译出来的是一个空壳应用,我们希望执行点什么。
2. 编译出能对接 Qemu 与 RustSBI 的应用。
源代码编译出的来程序字节码可以分为两部分即数据与代码,实际上我们还可以对这两部分进一步划分为更小的单位段(Section),整个这部分就构成了程序内存布局。
.bss: Block Started By Symbol 存储未初始化的全局变量和静态变量。 .data: 已经初始化的全局变量与静态变量。 .rodata: 存储只读的常量与字符串等。
Qemu 在加电启动后会从 0x80200000 开始执行,因此我们需要调整编译出的程序的内存布局,这将通过为编译器指定 linker.ld
配置文件来实现,但提交给 Qemu 的文件还要剥离元数据才能被 Qemu 正确寻址,这通过 rust-objcopy --strip-all
来实现。
使用#[no_mangle]
自定义的入口函数
编写 linker.ld 定义内存布局
编写汇编代码配置栈空间布局,设置栈顶指针然后 call 入口函数
1 2 3 4 5 6 7 8 9 10 11 12 13 # os/src/entry.asm .section .text.entry .globl _start _start: la sp, boot_stack_top call rust_main .section .bss.stack .globl boot_stack boot_stack: .space 4096 * 16 .globl boot_stack_top boot_stack_top:
3. 实现关键系统调用,实现 Println! 宏等。 通过系统调用实现退出机制,通过系统调用实现 print 方法以及宏。
如何实现批处理操作系统? 批处理操作系统主要解决几个问题:
加载所有应用程序
在执行应用程序前初始化,比如用户态栈的初始化,特权级别切换
处理应用程序的系统调用
处理应用程序的错误,在出错时执行下一个程序
因此首先要实现用户态的程序,剥离元数据转为 bin 格式,以静态绑定的方式载入内存,在使用时以动态加载的方式来加载。 具体实现方法是通过代码生成汇编码,将应用程序写入数据段,并标识每个程序的起始结束地址。
1 2 3 4 5 6 7 # 例子 .section .data .global app_4_start .global app_4_end app_4_start: .incbin "../user/target/riscv64gc-unknown-none-elf/release/04priv_csr.bin" app_4_end:
在内核中,实现应用程序的加载器、实现 Trap 的处理和特权级别切换,以便于在用户态程序进行系统调用时从用户态切换到内核态,并且在这种切换过程中实现上下文的保存和恢复。
CSR 是什么? 在计算机体系结构中,CSR 通常指的是 “Control and Status Register”(控制和状态寄存器)。这些寄存器用于存储处理器的控制信息和状态信息,允许操作系统和应用程序控制处理器的行为和获取处理器的状态。
多道任务与分时多任务 在这里我们主要为了隔离 CPU 资源的使用,让每个应用在特定时间内都能获得 CPU 的全部使用权,同时也可以主动让出 CPU 的使用。 为了让内核同时调度多个应用程序的执行,我们需要实现应用程序的加载机制和任务管理系统,实现任务的切换、暂停管理等能力。
协作式多任务执行 通过实现 yield 系统调用 我们可以让任务主动让出 CPU 使用权,从而让内核进行任务切换执行别的任务,这种由任务自己让出 CPU 使用权的系统叫做协作式 操作系统,但是任务下次获得 CPU 使用权的时间与内核调度策略以及其它任务相关,所以缺点也很明显,对于需要及时得到响应的任务而言这样协作式的方式会严重影响使用体验,协作式操作系统适用于所有应用都是可信的情况下。
分时多任务执行 通过时钟中断机制,我们可以实现时间片轮转调度机制,让 CPU 在每个时间片段内周而复始的轮转执行任务,这样可以确保每个任务都能公平的得到 CPU 的使用时间,这种机制叫做抢占式 调度策略。
地址空间 - 计算机空间的抽象 现在,我们学会了如何将如何将应用运行在裸机上所必要的知识与概念,即特权级别的切换与任务栈的切换,切换过程中伴随的寄存器的存储与恢复,接下来是操作系统最重要的事物,即构造各种抽象,提供环境约束与管理。 第一要构造的抽象是「地址空间 」,它给应用程序创造一个拥有全部内存的幻觉,隔离应用以及操作系统之间的内存访问,保证安全。
SV39 多级页表机制 RISC-V 中通过将 Satp 寄存器的高 8 位 Mode 设置为 8 则开启 SV39 页表机制,此时 U/S 模式下的地址访问都会被视作 39 位虚拟地址需要经过 MMU 转换为 56 位的物理地址,SV39 即特权模式下的 39 位虚拟地址,即在 SV39 模式下,64 位宽只有低 39 位是有意义的。
地址格式:
因为我们的页表是 4K 大小,因此需要 12 位来寻址,因此低 12 位为页内偏移,高 27 位则为页码,MMU 的转换就是扎到虚拟页码到物理页的映射,Offset 不变,拼接成 56 位物理地址。
页表格式:
页表存储: 如果按下面线性表的方式存储页表,即知道一个应用页表的 base*addr,对应的虚拟页页表 = base_addr + 8 * 虚拟页码,即可得到页表项从而找到物理地址。 考虑到虚拟地址有 27 位用于页表索引,因此如果完全存储 2^27 页页表需要 2^27 _ 8(64 位) = 1GB 的空间,这显然不现实。
因此我们需要按需分配的机制,即保存真正有效的虚拟页表。
我们可以通过类似前缀树的方式采用三级页表的方式来索引页表项目 PTE,将 39 位虚拟地址的高 27 位分为 3 段,每段 9 位,剩下 12 位为偏移,如下图所示,通过三级索引我们拿到最终的物理页表页码,加上 Offset 就可以得到最终的物理地址。 每一级页表的大小刚好 2^9 = 512 个 PTE,每个 PTE 大小 8Byte 刚好 4K 一个物理页帧,这样一来,12K 就足以存储页表了。
在地址空间下运行的应用 在地址空间下应用的程序与之前最大的区别在于,此时所有地址指令都需要经过多重的转换,期间有 TLB 加速了这个过程。除了之前提到的特权级别切换,我们还要切换相应的地址空间。 为了这种切换能够顺畅,我们需要构造跳板以及陷入上下文等数据结构,在应用的高位地址存储用户态陷入上下文以及映射陷入内核的跳板代码,这部分代码尽管在用户地址空间,但是 S 态才有的权限。
进程 - 计算器力量的抽象 计算是一种强大力量,但是在操作系统上可能同时运行着多个不同的应用程序,每个应用都可能需要持有计算的权柄,前面提到我们可以让程序自己出让计算权利的协作式多任务模式,也有由操作系统根据时间片切换的抢占式模式。 如何更好的管理这些任务的计算,我们需要更高阶的抽象,即进程,可以理解为比任务更高阶的概念,它不光包括了正在运行的任务,还包括了他拥有的资源,如地址空间等,下面这张图很好的描述了进程概念和任务的执行过程。
文件系统 - IO 的抽象 关于 Linux 系统有一句非常著名的话,那就是一切接「文件 」,这个概念如此的强大,以至于可以容纳一切外部设备,仔细想想,因为它只包含了最小的内涵,即「读、写 」两个操作,只要能读写?那就是文件,包括我们的硬盘。
准确的说,文件系统是建立在块设备上的一种抽象,下面这张图描述了块设备的内部布局,类似于页表,不过用于管理索引的结构叫做位图。
操作系统上的应用不应关注块设备的内部结构,而是需要关心它提供什么能力,如果每次数据的读写都需要从索引一路找到数据块岂不是很麻烦。 因此我们要为其实现目录结构的管理,这是一个内存中的数据对象,它完成了对块设备布局中存储的真正数据内容的映射。
并发 - 万物迸发 死锁检测 算法过程就是判断某一未结束线程 Finish[i] == false 它的需求是否小于操作系统所能提供的 Need[i, j] <= Work[j]
如果小于那就执行到完成, 然后更新操作系统能分配的资源 Work[j] = Work[j] + Allocation[i, j] Finish[i] = true
如果 Finish[0..n-1] 都为 true 即所有线程都有足够资源,否则就是不够,有死锁。