总结
在训练营第一阶段中,我顺利完成了rustlings与rCore的五个实验,学习了Rust与操作系统知识。
Rust学习
因为之前有一些Rust的基础,加入训练营后,我首先用两天时间把rustlings做了一下,集中查漏补缺,之后按照不熟练的地方去细读了Rust参考手册与Rust标准库,加深了对Rust的理解。
RISCV学习
之前有了解过RISCV,这次重点看了特权级和MMU的相关知识。因为前面Rust花费了很多时间,RISCV上没敢用太多时间就赶紧开始rCore实验了。
rCore实验
时间还是非常紧的,每一章对我来说都有大量的新知识。
test0
- 操作系统远古阶段的 LibOS设计思路在当前云计算时代重新焕发青春,成为学术机构和各大互联网企业探索的新热点。
- 逐步进化的“小”操作系统:
- LibOS: 让APP与HW隔离,简化应用访问硬件的难度和复杂性
- BatchOS: 让APP与OS隔离,加强系统安全,提高执行效率
- multiprog&time-sharing OS: 让APP共享CPU资源
- Address Space OS: 隔离APP访问的内存地址空间,加强APP间的安全
- Process OS: 支持APP动态创建新进程,增强进程管理和资源管理能力
- Filesystem OS:支持APP对数据的持久保存
- IPC OS:支持多个APP进程间数据交互与事件通知
- Tread&Coroutine OS:支持线程和协程APP,简化切换与数据共享
- SyncMutex OS:在多线程APP中支持对共享资源的同步互斥访问
- Device OS:提高APP的I/O效率和人机交互能力,支持基于外设中断的串口/块设备/键盘/鼠标/显示设备
- 一个操作系统(OS)是一个软件,它帮助用户和应用程序使用和管理计算机的资源。我们的讨论将集中在通用操作系统上,因为它们需要的技术是嵌入式系统所需技术的超集,对操作系统原理、概念和技术的覆盖更加全面。
- 操作系统不能只提供面向单一编程语言的函数库的编程接口 (API, Application Programming Interface) ,它的接口需要考虑对基于各种编程语言的应用支持,以及访问安全等因素,使得应用软件不能像访问函数库一样的直接访问操作系统内部函数,更不能直接读写操作系统内部的地址空间。为此,操作系统设计了一套安全可靠的接口,我们称为系统调用接口 (System Call Interface)。系统调用接口通常面向应用程序提供了API的描述,但在具体实现上,还需要提供ABI的接口描述规范。在现代处理器的安全支持(特权级隔离,内存空间隔离等)下,应用程序就不能直接以函数调用的方式访问操作系统的函数,以及直接读写操作系统的数据变量。不同类型的应用程序可以通过符合操作系统规定的ABI规范的系统调用接口,发出系统调用请求,来获得操作系统的服务。操作系统提供完服务后,返回应用程序继续执行。
- 相对比较重要的操作系统接口或抽象:
- 进程(即程序运行过程)管理:复制创建进程 fork 、退出进程 exit 、执行进程 exec 等。
- 线程管理:线程(即程序的一个执行流)的创建、执行、调度切换等。
- 线程同步互斥的并发控制:互斥锁 mutex 、信号量 semaphore 、管程 monitor 、条件变量 condition variable 等。
- 进程间通信:管道 pipe 、信号 signal 、事件 event 等。
- 虚存管理:内存空间映射 mmap 、改变数据段地址空间大小 sbrk 、共享内存 shm 等。
- 文件I/O操作:对存储设备中的文件进行读 read 、写 write 、打开 open 、关闭 close 等操作。
- 外设I/O操作:外设包括键盘、显示器、串口、磁盘、时钟 … 但接口均采用了文件 I/O 操作的通用系统调用接口。
- 操作系统对计算机硬件重要组成的抽象和虚拟化,使得应用程序只需基于对简单的抽象概念的访问来到达对计算机系统资源的使用:
- 文件 (File) 是外设的一种抽象和虚拟化。特别对于存储外设而言,文件是持久存储的抽象。
- 地址空间 (Address Space) 是对内存的抽象和虚拟化。
- 进程 (Process) 是对计算机资源的抽象和虚拟化。而其中最核心的部分是对CPU的抽象与虚拟化。
- 对cpu来说,有普通控制流和异常控制流之分。这里我们把控制流在执行完某指令时的物理资源内容,即确保下一时刻能继续 正确 执行控制流指令的物理资源内容称为控制流的 上下文(Context) ,也可称为控制流所在执行环境的状态。操作系统有责任来保护应用程序中控制流的上下文,以让应用程序得以正确执行。
- 在操作系统中,需要处理三类异常控制流:外设中断 (Device Interrupt) 、陷入 (Trap) 和异常 (Exception,也称Fault Interrupt)。中断 (Interrupt) 由外部设备引起的外部 I/O 事件如时钟中断、控制台中断等。异常 (Exception) 是在处理器执行指令期间检测到不正常的或非法的内部事件(如除零错、地址访问越界)。陷入 (Trap) 是在程序中使用请求操作系统服务的系统调用而引发的有意事件。
- 在 RISC-V 的特权级规范文档中,异常指的是由于 CPU 当前指令执行而产生的异常控制流,中断指的是与 CPU 当前指令执行无关的异常控制流,中断和异常统称为陷入。
- 地址空间 (Address Space) 是对物理内存的虚拟化和抽象,也称虚存 (Virtual Memory)。它就是操作系统通过处理器中的内存管理单元 (MMU, Memory Management Unit) 硬件的支持而给应用程序和用户提供一个大的(可能超过计算机中的物理内存容量)、连续的(连续的地址空间编址)、私有的(其他应用程序无法破坏)的存储空间。操作系统中的虚存管理与处理器的 MMU 密切相关,在启动虚存机制后,软件通过 CPU 访问的每个虚拟地址都需要通过 CPU 中的 MMU 转换为一个物理地址来进行访问。
- 以磁盘为代表的持久存储介质的数据访问单位是一个扇区或一个块,而在内存中的数据访问单位是一个字节或一个字。这就需要操作系统通过文件来屏蔽磁盘与内存差异,尽量以内存的读写方式来处理持久存储的数据。
- 各种外设虽然差异很大,但也有基本的读写操作,可以通过文件来进行统一的抽象,并在操作系统内部实现中来隐藏对外设的具体访问过程,从而让用户可以以统一的文件操作来访问各种外设。
- 操作系统具有五个方面的特征:虚拟化 (Virtualization)、并发性 (Concurrency)、异步性、共享性和持久性 (Persistency)。
- 并行和并发:并行 (Parallel) 是指两个或者多个事件在同一时刻发生;并发 (Concurrent) 是指两个或多个事件在同一时间间隔内发生。
test1
- 本章展现了操作系统的一个基本目标LibOS:让应用与硬件隔离,简化了应用访问硬件的难度和复杂性。
- 无论用户态应用如何编写,是手写汇编代码,还是基于某种高级编程语言调用其标准库或三方库,某些功能总要直接或间接的通过内核/操作系统提供的 系统调用 (System Call) 来实现。内核作为用户态的执行环境,它不仅要提供系统调用接口,还需要对用户态应用的执行进行监控和管理。
- rustc –print target-list | grep riscv查看rustc支持的riscv目标三元组
- 从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的是,该下标通常不以 0 开头,而通常以一个常数,如 0x80000000 开头。简言之,CPU 可以通过物理地址来寻址,并 逐字节 地访问物理内存中保存的数据。
- 一个多位数的低位放在较小的地址处,高位放在较大的地址处,则称小端序(little-endian);反之则称大端序(big-endian)。常见的 x86、RISC-V 等架构采用的是小端序。
- 不同的段会被编译器放置在内存不同的位置上,这构成了程序的 内存布局 (Memory Layout)。
- 代码部分只有代码段 .text 一个段,存放程序的所有汇编代码。而数据部分则还可以继续细化:
- 已初始化数据段保存程序中那些已初始化的全局数据,分为 .rodata 和 .data 两部分。前者存放只读的全局数据,通常是一些常数或者是 常量字符串等;而后者存放可修改的全局数据。
- 未初始化数据段 .bss 保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,即将这块区域逐字节清零;
堆 (heap)区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长;
栈 (stack)区域不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内,它向低地址增长。
- 汇编器输出的每个目标文件都有一个独立的程序内存布局,它描述了目标文件内各段所在的位置。而链接器所做的事情是将所有输入的目标文件整合成一个整体的内存布局。
- rust编写内核态程序步骤:
关闭标准库#![no_std]->添加panic语言项绑定#[panic_handler]->关闭main函数#![no_main]->ld指定入口点->汇编在入口点分配并启用栈,跳转至rust入口->ld调整内存布局->rust-objcopy –strip-all修剪掉元数据 - 利用 rust-objcopy 工具可以删除掉 ELF 文件中的 所有 header 只保留各个段的实际数据得到一个没有任何符号的纯二进制镜像文件
- 其他控制流都只需要跳转到一个 编译期固定下来 的地址,而函数调用的返回跳转是跳转到一个 运行时确定 (确切地说是在函数调用发生的时候)的地址。
- 调用规范 (Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下内容:函数的输入参数和返回值如何传递;函数调用上下文中调用者/被调用者保存寄存器的划分;其他的在函数调用流程中对于寄存器的使用方法。
- 感觉各种高低级语言之间的交互主要还是靠符号来实现,这样的话#[no_mangle]是经常要用到的
- 使用内联汇编ecall来调用sbi
test2
- 本章构造一个包含操作系统内核和多个应用程序的单一执行程序BatchOS:多程序自动按顺序加载,使用特权级。
- 处理器设置两个不同安全等级的执行环境:用户态特权级的执行环境和内核态特权级的执行环境。且明确指出可能破坏计算机系统的内核态特权级指令子集,规定内核态特权级指令子集中的指令只能在内核态特权级的执行环境中执行。处理器在执行指令前会进行特权级安全检查,如果在用户态执行环境中执行这些内核态特权级指令,会产生异常。
- M 模式软件 SEE 和 S 模式的内核之间的接口被称为 监督模式二进制接口(Supervisor Binary Interface, SBI),而内核和 U 模式的应用程序之间的接口被称为 应用程序二进制接口 (Application Binary Interface, ABI),当然它有一个更加通俗的名字—— 系统调用 (syscall, System Call) 。
- 与特权级无关的一般的指令和通用寄存器 x0~ x31 在任何特权级都可以执行。而每个特权级都对应一些特殊指令和 控制状态寄存器(CSR, Control and Status Register) ,来控制该特权级的某些行为并描述其状态。当然特权指令不仅具有读写 CSR 的指令,还有其他功能的特权指令。
- 在 RISC-V 中,会有两类属于高特权级 S 模式的特权指令:
- 指令本身属于高特权级的指令,如 sret 指令(表示从 S 模式返回到 U 模式)。
- 指令访问了 S模式特权级下才能访问的寄存器 或内存,如表示S模式系统状态的 控制状态寄存器 sstatus 等。
- 应用放置采用“静态绑定”的方式,而操作系统加载应用则采用“动态加载”的方式
- 在执行操作系统的 Trap 处理过程(会修改通用寄存器)之前,我们需要在某个地方(某内存块或内核的栈)保存这些寄存器并在 Trap 处理结束后恢复这些寄存器。
- 当 CPU 执行完一条指令(如 ecall )并准备从用户特权级 陷入( Trap )到 S 特权级的时候,硬件会自动完成如下这些事情:
- sstatus 的 SPP 字段会被修改为 CPU 当前的特权级(U/S)。
- sepc 会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。
- scause/stval 分别会被修改成这次 Trap 的原因以及相关的附加信息。
- CPU 会跳转到 stvec 所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。
- 在 RV64 中, stvec 是一个 64 位的 CSR,在中断使能的情况下,保存了中断处理的入口地址。它有两个字段:
- MODE 位于 [1:0],长度为 2 bits;
- BASE 位于 [63:2],长度为 62 bits。
- 当 MODE 字段为 0 的时候, stvec 被设置为 Direct 模式,此时进入 S 模式的 Trap 无论原因如何,处理 Trap 的入口地址都是 BASE<<2 , CPU 会跳转到这个地方进行异常处理。本书中我们只会将 stvec 设置为 Direct 模式。而 stvec 还可以被设置为 Vectored 模式。
- 而当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 sret 来完成,这一条指令具体完成以下功能:
- CPU 会将当前的特权级按照 sstatus 的 SPP 字段设置为 U 或者 S ;
- CPU 会跳转到 sepc 寄存器指向的那条指令,然后继续执行。
- 在正式进入 S 特权级的 Trap 处理之前,上面 提到过我们必须保存原控制流的寄存器状态,这一般通过内核栈来保存。
- 特权级切换的核心是对Trap的管理。这主要涉及到如下一些内容:
- 应用程序通过 ecall 进入到内核状态时,操作系统保存被打断的应用程序的 Trap 上下文;
- 操作系统根据Trap相关的CSR寄存器内容,完成系统调用服务的分发与处理;
- 操作系统完成系统调用服务后,需要恢复被打断的应用程序的Trap 上下文,并通 sret让应用程序继续执行。
test3
- 本章是multiprog&time-sharing OS: 让APP共享CPU资源
- 我们可以把一个程序的一次完整执行过程称为一次 任务 (Task),把一个程序在一个时间片(Time Slice)上占用处理器执行的过程称为一个 任务片 (Task Slice)。
- 多个应用程序被一次性地加载到内存中,这样在切换到另外一个应用程序执行会很快,不像前一章介绍的操作系统,还要有清空前一个应用,然后加载当前应用的过程开销。
- 如何知道外设是否已经完成了请求呢?通常外设会提供一个可读的寄存器记录它目前的工作状态,于是 CPU 需要不断原地循环读取它直到它的结果显示设备已经将请求处理完毕了,才能继续执行(这就是 忙等 的含义)。
- 多道程序的思想在于:内核同时管理多个应用。如果外设处理 I/O 的时间足够长,那我们可以先进行任务切换去执行其他应用;在某次切换回来之后,应用再次读取设备寄存器,发现 I/O 请求已经处理完毕了,那么就可以根据返回的 I/O 结果继续向下执行了。
- 协作式调度(Cooperative Scheduling) ,因为它的特征是:只要一个应用不主动 yield 交出 CPU 使用权,它就会一直执行下去。与之相对, 抢占式调度(Preemptive Scheduling) 则是应用 随时 都有被内核切换出去的可能。可以从性能(主要是吞吐量和延迟两个指标)和 公平性(Fairness) 两个维度来评价调度算法,后者要求多个应用分到的时间片占比不应差距过大。
- 对于某个处理器核而言, 异常与当前 CPU 的指令执行是 同步 (Synchronous) 的,异常被触发的原因一定能够追溯到某条指令的执行;而中断则 异步 (Asynchronous) 于当前正在进行的指令,也就是说中断来自于哪个外设以及中断如何触发完全与处理器正在执行的当前指令无关。而对于中断,可以理解为发起中断的是一套与处理器执行指令无关的电路(从时钟中断来看就是简单的计数和比较器),这套电路仅通过一根导线接入处理器。当外设想要触发中断的时候则输入一个高电平或正边沿,处理器会在每执行完一条指令之后检查一下这根线,看情况决定是继续执行接下来的指令还是进入中断处理流程。也就是说,大多数情况下,指令执行的相关硬件单元和可能发起中断的电路是完全独立 并行 (Parallel) 运行的,它们中间只有一根导线相连。
- RISC-V 的中断可以分成三类:
- 软件中断 (Software Interrupt):由软件控制发出的中断
- 时钟中断 (Timer Interrupt):由时钟电路发出的中断
- 外部中断 (External Interrupt):由外设发出的中断
- 中断每一个都有 M/S 特权级两个版本。中断的特权级可以决定该中断是否会被屏蔽,以及需要 Trap 到 CPU 的哪个特权级进行处理。在判断中断是否会被屏蔽的时候,有以下规则:
- 如果中断的特权级低于 CPU 当前的特权级,则该中断会被屏蔽,不会被处理;
- 如果中断的特权级高于与 CPU 当前的特权级或相同,则需要通过相应的 CSR 判断该中断是否会被屏蔽。
- 如果中断没有被屏蔽,那么接下来就需要软件进行处理,而具体到哪个特权级进行处理与一些中断代理 CSR 的设置有关。默认情况下,所有的中断都需要到 M 特权级处理。而通过软件设置这些中断代理 CSR 之后,就可以到低特权级处理,但是 Trap 到的特权级不能低于中断的特权级。事实上所有的中断/异常默认也都是到 M 特权级处理的。
- 嵌套 Trap 则是指处理一个 Trap(可能是中断或异常)的过程中又再次发生 Trap ,嵌套中断是嵌套 Trap 的一个特例。在内核开发时我们需要仔细权衡哪些嵌套 Trap 应当被允许存在,哪些嵌套 Trap 又应该被禁止,这会关系到内核的执行模型。
- 由于软件(特别是操作系统)需要一种计时机制,RISC-V 架构要求处理器要有一个内置时钟,其频率一般低于 CPU 主频。此外,还有一个计数器用来统计处理器自上电以来经过了多少个内置时钟的时钟周期。在 RISC-V 64 架构上,该计数器保存在一个 64 位的 CSR mtime中,我们无需担心它的溢出问题,在内核运行全程可以认为它是一直递增的。
- 目前为了简单起见,我们的内核不会被 S 特权级中断所打断,这是因为 CPU 在 S 特权级时, sstatus.sie总为 0 。但这会造成内核对部分中断的响应不及时,因此一种较为合理的做法是允许内核在处理系统调用的时候被打断优先处理某些中断,这是一种允许 Trap 嵌套的设计。
test4
- 本章Address Space OS: 隔离APP访问的内存地址空间,加强APP间的安全
- 实现地址空间的第一步就是实现分页机制,建立好虚拟内存和物理内存的页映射关系。此过程需要硬件支持,硬件细节与具体CPU相关,涉及地址映射机制等,相对比较复杂。
- 操作系统如果要建立页表(构建虚实地址映射关系),首先要能管理整个系统的物理内存,这就需要知道整个计算机系统的物理内存空间的范围,物理内存中哪些区域是空闲可用的,哪些区域放置内核/应用的代码和数据。
- 操作系统内核能够以物理页帧为单位分配和回收物理内存,具体实现主要集中在 os/src/mm/frame_allocator.rs 中;也能在虚拟内存中以各种粒度大小来动态分配内存资源,具体实现主要集中在 os/src/mm/heap_allocator.rs 中。
- 一旦使能分页机制,CPU 访问到的地址都是虚拟地址了,那么内核中也将基于虚地址进行虚存访问。所以在给应用添加虚拟地址空间前,内核自己也会建立一个页表,把整块物理内存通过简单的恒等映射(即虚拟地址映射到对等的物理地址)映射到内核虚拟地址空间中。
- 在内核地址空间中执行的内核代码常常需要读写应用的地址空间中的数据,这无法简单的通过一次访存来解决,而是需要手动查用户态应用的地址空间的页表,知道用户态应用的虚地址对应的物理地址后,转换成对应的内核态的虚地址,才能访问应用地址空间中的数据。
- alloc 库需要我们提供给它一个 全局的动态内存分配器 ,它会利用该分配器来管理堆空间,从而使得与堆相关的智能指针或容器数据结构可以正常工作。
- 只需将我们的动态内存分配器类型实例化为一个全局变量,并使用 #[global_allocator] 语义项标记即可。
- 如果在地址转换过程中,无法找到物理地址或访问权限有误,则处理器产生非法访问内存的异常错误。
- 在每个应用程序的视角里,操作系统分配给应用程序一个地址范围受限(容量很大),独占的连续地址空间(其中有些地方被操作系统限制不能访问,如内核本身占用的虚地址空间等)
- 操作系统要达到地址空间抽象的设计目标,需要有计算机硬件的支持,这就是计算机组成原理课上讲到的 MMU 和 TLB 等硬件机制。
- 需要硬件提供一些寄存器,软件可以对它进行设置来控制 MMU 按照哪个应用的地址映射关系进行地址转换。
- 内核以页为单位进行物理内存管理。每个应用的地址空间可以被分成若干个(虚拟) 页面 (Page) ,而可用的物理内存也同样可以被分成若干个(物理) 页帧 (Frame) ,虚拟页面和物理页帧的大小相同。
- 为了方便实现虚拟页面到物理页帧的地址转换,我们给每个虚拟页面和物理页帧一个编号,分别称为 虚拟页号 (VPN, Virtual Page Number) 和 物理页号 (PPN, Physical Page Number) 。每个应用都有一个表示地址映射关系的 页表 (Page Table) ,里面记录了该应用地址空间中的每个虚拟页面映射到物理内存中的哪个物理页帧,即数据实际被内核放在哪里。
- 当 MMU 进行地址转换的时候,虚拟地址会分为两部分(虚拟页号,页内偏移),MMU首先找到虚拟地址所在虚拟页面的页号,然后查当前应用的页表,根据虚拟页号找到物理页号;最后按照虚拟地址的页内偏移,给物理页号对应的物理页帧的起始地址加上一个偏移量,这就得到了实际访问的物理地址。
- 在页表中,还针对虚拟页号设置了一组保护位,它限制了应用对转换得到的物理地址对应的内存的使用方式。最典型的如 rwx , r 表示当前应用可以读该内存; w 表示当前应用可以写该内存; x 则表示当前应用可以从该内存取指令用来执行。一旦违反了这种限制则会触发异常,并被内核捕获到。
- 默认情况下 MMU 未被使能,此时无论 CPU 位于哪个特权级,访存的地址都会作为一个物理地址交给对应的内存控制单元来直接访问物理内存。我们可以通过修改 S 特权级的一个名为 satp 的 CSR 来启用分页模式,在这之后 S 和 U 特权级的访存地址会被视为一个虚拟地址,它需要经过 MMU 的地址转换变为一个物理地址,再通过它来访问物理内存;而 M 特权级的访存地址,我们可设定是内存的物理地址。
- 我们采用分页管理,单个页面的大小设置为 4KiB ,每个虚拟页面和物理页帧都对齐到这个页面大小,也就是说虚拟/物理地址区间[0,4KiB) 为第 0 个虚拟页面/物理页帧,而 [4KiB,8KiB) 为第 1 个,以此类推。
- 4KiB需要用 12 位字节地址来表示,因此虚拟地址和物理地址都被分成两部分:它们的低 12 位,即 [11:0] 被称为 页内偏移 (Page Offset) ,它描述一个地址指向的字节在它所在页面中的相对位置。而虚拟地址的高 27 位,即 [38:12] 为它的虚拟页号 VPN,同理物理地址的高 44 位,即 [55:12] 为它的物理页号 PPN,页号可以用来定位一个虚拟/物理地址属于哪一个虚拟页面/物理页帧。
- 地址转换是以页为单位进行的,在地址转换的前后地址的页内偏移部分不变。可以认为 MMU 只是从虚拟地址中取出 27 位虚拟页号,在页表中查到其对应的物理页号(如果存在的话),最后将得到的44位的物理页号与虚拟地址的12位页内偏移依序拼接到一起就变成了56位的物理地址。
- SV39 分页模式规定 64 位虚拟地址的 [63:39] 这 25 位必须和第 38 位相同,否则 MMU 会直接认定它是一个不合法的虚拟地址。通过这个检查之后 MMU 再取出低 39 位尝试将其转化为一个 56 位的物理地址。
- 也就是说,所有 264 个虚拟地址中,只有最低的 256GiB (当第 38 位为 0 时)以及最高的 256GiB(当第 38 位为 1 时)是可能通过 MMU 检查的。当我们写软件代码的时候,一个地址的位宽毋庸置疑就是 64 位,我们要清楚可用的只有最高和最低这两部分
- 有更多的标志位,物理页号和全部的标志位以某种固定的格式保存在一个结构体中,它被称为 页表项 (PTE, Page Table Entry) ,是利用虚拟页号在页表中查到的结果。
- 页表项,其中 [53:10] 这 44 位是物理页号,最低的 8 位 [7:0] 则是标志位,它们的含义如下(请注意,为方便说明,下文我们用 页表项的对应虚拟页面 来表示索引到一个页表项的虚拟页号对应的虚拟页面):
- V(Valid):仅当位 V 为 1 时,页表项才是合法的;
- R(Read)/W(Write)/X(eXecute):分别控制索引到这个页表项的对应虚拟页面是否允许读/写/执行;
- U(User):控制索引到这个页表项的对应虚拟页面是否在 CPU 处于 U 特权级的情况下是否被允许访问;
- G:暂且不理会;
- A(Accessed):处理器记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被访问过;
- D(Dirty):处理器记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被修改过。
- 除了 G 外的上述位可以被操作系统设置,只有 A 位和 D 位会被处理器动态地直接设置为 1,表示对应的页被访问过或修过
- 27 位的虚拟页号可以看成一个长度 n=3 的字符串,字符集为 α={0,1,2,…,511} ,因为每一位字符都由 9 个比特组成。而我们也不再维护所谓字符串的计数,而是要找到字符串(虚拟页号)对应的页表项。因此,每个叶节点都需要保存 512 个 8 字节的页表项,一共正好 4KiB ,可以直接放在一个物理页帧内。这种页表实现被称为 多级页表 (Multi-Level Page-Table) 。由于 SV39 中虚拟页号被分为三级 页索引 (Page Index) ,因此这是一种三级页表。
- 非叶节点(页目录表,非末级页表)的表项标志位含义和叶节点(页表,末级页表)相比有一些不同:
- 当 V 为 0 的时候,代表当前指针是一个空指针,无法走向下一级节点,即该页表项对应的虚拟地址范围是无效的;
- 只有当 V 为1 且 R/W/X 均为 0 时,表示是一个合法的页目录表项,其包含的指针会指向下一级的页表;
- 注意: 当 V 为1 且 R/W/X 不全为 0 时,表示是一个合法的页表项,其包含了虚地址对应的物理页号。
test5
- Process OS: 支持APP动态创建新进程,增强进程管理和资源管理能力
- 在新增系统调用的时候,需要在 user/src/lib.rs 中新增一个 sys_* 的函数,它的作用是将对应的系统调用按照与内核约定的 ABI 在 syscall 中转化为一条用于触发系统调用的 ecall 的指令;还需要在用户库 user_lib 将 sys_* 进一步封装成一个应用可以直接调用的与系统调用同名的函数。
- 我们新增三个进程模型中核心的系统调用 fork/exec/waitpid ,一个查看进程 PID 的系统调用 getpid ,还有一个允许应用程序获取用户键盘输入的 read 系统调用。
- 两个特殊的应用程序:用户初始程序 initproc.rs 和 shell 程序 user_shell.rs ,可以认为它们位于内核和其他应用程序之间的中间层提供一些基础功能,但是它们仍处于用户态的应用层。前者会被内核唯一自动加载、也是最早加载并执行,后者则负责从键盘接收用户输入的应用名并执行对应的应用。
- 在本章之前,任务管理器 TaskManager 不仅负责管理所有的任务状态,还维护着 CPU 当前正在执行的任务。这种设计耦合度较高,我们将后一个功能分离到 os/src/task/processor.rs 中的处理器管理结构 Processor 中,它负责管理 CPU 上执行的任务和一些其他信息;而 os/src/task/manager.rs 中的任务管理器 TaskManager 仅负责管理所有任务。
- 针对新的进程模型,我们复用前面章节的任务控制块 TaskControlBlock 作为进程控制块来保存进程的一些信息,相比前面章节还要新增 PID 、内核栈、应用数据大小、父子进程、退出码等信息。它声明在 os/src/task/task.rs 中。
- 从本章开始,进程的 PID 将作为查找进程控制块的索引,这样就可以通过进程的 PID 来查找到进程的内核栈等各种进程相关信息。 同时我们还面向进程控制块提供相应的资源自动回收机制。
- 在本章之前,我们有 任务 的概念,即 正在执行的程序 ,主角是程序。而相比于 任务, 进程 (Process) 的含义是 在操作系统管理下的程序的一次执行过程。
- 协程(Coroutines,也称纤程(Fiber)),也是程序执行中一个单一的顺序控制流程,建立在线程之上(即一个线程上可以有多个协程),但又是比线程更加轻量级的处理器调度对象。协程一般是由用户态的协程管理库来进行管理和调度,这样操作系统是看不到协程的。而且多个协程共享同一线程的栈,这样协程在时间和空间的管理开销上,相对于线程又有很大的改善。在具体实现上,协程可以在用户态运行时库这一层面通过函数调用来实现;也可在语言级支持协程,比如 Rust 借鉴自其他语言的的 async 、 await关键字等,通过编译器和运行时库二者配合来简化程序员编程的负担并提高整体的性能。
- 进程模型有三个运行状态:就绪态、运行态和等待态;有基于独立页表的地址空间;可被操作系统调度来分时占用 CPU 执行;可以动态创建和退出;可通过系统调用获得操作系统的服务。
- 系统中同一时间存在的每个进程都被一个不同的 进程标识符 (PID, Process Identifier) 所标识。在内核初始化完毕之后会创建一个进程——即 用户初始进程 (Initial Process) ,它是目前在内核中以硬编码方式创建的唯一一个进程。其他所有的进程都是通过一个名为 fork的系统调用来创建的。
- 唯有用来保存 fork 系统调用返回值的 a0 寄存器(这是 RISC-V 64 的函数调用规范规定的函数返回值所用的寄存器)的值是不同的。这区分了两个进程:原进程的返回值为它新创建进程的 PID ,而新创建进程的返回值为 0 。由于新的进程是原进程主动调用 fork 衍生出来的,我们称新进程为原进程的 子进程 (Child Process) ,相对的原进程则被称为新进程的 父进程 (Parent Process) 。这样二者就建立了一种父子关系。
- 所有进程可以被组织成一颗树,其根节点正是代表用户初始程序——initproc,也即第一个用户态的初始进程。相比创建一个进程, fork 的另一个重要功能是建立一对新的父子关系。
- 一般情况下一个进程要负责通过 waitpid 系统调用来等待它 fork 出来的子进程结束并回收掉它们占据的资源,这也是父子进程间的一种同步手段。
- 如果一个进程先于它的子进程结束,在它退出的时候,它的所有子进程将成为进程树的根节点——用户初始进程的子进程,同时这些子进程的父进程也会转成用户初始进程。这之后,这些子进程的资源就由用户初始进程负责回收了,这也是用户初始进程很重要的一个用途。
- 如果仅有 fork 的话,那么所有的进程都只能和用户初始进程一样执行同样的代码段,这显然是远远不够的。于是我们还需要引入 exec系统调用来执行不同的可执行文件
- 同学可以在 user/src/syscall.rs 中看到以 sys_* 开头的系统调用的函数原型,它们后续还会在 user/src/lib.rs 中被封装成方便应用程序使用的形式。如 sys_fork 被封装成 fork,而 sys_exec 被封装成 exec
- sys_waitpid 被封装成两个不同的 API ,wait 表示等待任意一个子进程结束,根据 sys_waitpid 的约定它需要传的 pid 参数为 -1 ;而 waitpid 则等待一个进程标识符的值为pid 的子进程结束。
- 目前的实现风格是尽可能简化内核,因此 sys_waitpid 是立即返回的,即它的返回值只能给出返回这一时刻的状态。如果这一时刻要等待的子进程还尚未结束,那么也只能如实向应用报告这一结果。于是用户库 usr/src/lib.rs 就需要负责对返回状态进行持续的监控,因此它里面便需要进行循环检查。
- 在后续的实现中,我们会将 sys_waitpid的内核实现设计为 阻塞 的,即直到得到一个确切的结果之前,其对应的进程暂停(不再继续执行)在内核内;如果 sys_waitpid 需要的值能够得到,则它对应的进程会被内核唤醒继续执行,且内核返回给应用的结果可以直接使用。那时 wait 和 waitpid 两个 API 的实现便会更加简单。
- 同一时间存在的所有进程都有一个唯一的进程标识符,它们是互不相同的整数,这样才能表示表示进程的唯一性。这里我们使用 RAII 的思想,将其抽象为一个 PidHandle 类型,当它的生命周期结束后对应的整数会被编译器自动回收
- 类似之前的物理页帧分配器 FrameAllocator,我们实现一个同样使用简单栈式分配策略的进程标识符分配器 PidAllocator ,并将其全局实例化为 PID_ALLOCATOR
- 从本章开始,我们将应用编号替换为进程标识符。我们可以在内核栈 KernelStack 中保存着它所属进程的 PID
- 内核栈 KernelStack 也用到了 RAII 的思想,具体来说,实际保存它的物理页帧的生命周期与它绑定在一起,当 KernelStack 生命周期结束后,这些物理页帧也将会被编译器自动回收
- 任务控制块中包含两部分:
- 在初始化之后就不再变化的元数据:直接放在任务控制块中。这里将进程标识符 PidHandle 和内核栈 KernelStack 放在其中;
- 在运行过程中可能发生变化的元数据:则放在 TaskControlBlockInner 中,将它再包裹上一层 UPSafeCell
放在任务控制块中。这是因为在我们的设计中外层只能获取任务控制块的不可变引用,若想修改里面的部分内容的话这需要 UPSafeCell 所提供的内部可变性。
- 任务控制块:
- parent 指向当前进程的父进程(如果存在的话)。注意我们使用 Weak 而非 Arc 来包裹另一个任务控制块,因此这个智能指针将不会影响父进程的引用计数。
- children 则将当前进程的所有子进程的任务控制块以 Arc 智能指针的形式保存在一个向量中,这样才能够更方便的找到它们。
- 注意我们在维护父子进程关系的时候大量用到了引用计数 Arc/Weak 。进程控制块的本体是被放到内核堆上面的,对于它的一切访问都是通过智能指针 Arc/Weak 来进行的,这样是便于建立父子进程的双向链接关系(避免仅基于 Arc 形成环状链接关系)。当且仅当智能指针 Arc 的引用计数变为 0 的时候,进程控制块以及被绑定到它上面的各类资源才会被回收。子进程的进程控制块并不会被直接放到父进程控制块中,因为子进程完全有可能在父进程退出后仍然存在。
- 在前面的章节中,任务管理器 TaskManager 不仅负责管理所有的任务,还维护着 CPU 当前在执行哪个任务。由于这种设计不够灵活,不能拓展到后续的多核环境,我们需要将任务管理器对于 CPU 的监控职能拆分到下面即将介绍的处理器管理结构 Processor 中去,任务管理器自身仅负责管理所有任务。在这里,任务指的就是进程。
- TaskManager 将所有的任务控制块用引用计数 Arc 智能指针包裹后放在一个双端队列 VecDeque 中。正如之前介绍的那样,我们并不直接将任务控制块放到 TaskManager 里面,而是将它们放在内核堆上,在任务管理器中仅存放他们的引用计数智能指针,这也是任务管理器的操作单位。这样做的原因在于,任务控制块经常需要被放入/取出,如果直接移动任务控制块自身将会带来大量的数据拷贝开销,而对于智能指针进行移动则没有多少开销。其次,允许任务控制块的共享引用在某些情况下能够让我们的实现更加方便。
- TaskManager 提供 add/fetch 两个操作,前者表示将一个任务加入队尾,后者则表示从队头中取出一个任务来执行。从调度算法来看,这里用到的就是最简单的 RR 算法。
- 调度功能的主体是 run_tasks() 。它循环调用 fetch_task 直到顺利从任务管理器中取出一个任务,随后便准备通过任务切换的方式来执行
- 进程管理:
- 创建初始进程:创建第一个用户态进程 initproc;
- 进程调度机制:当进程主动调用 sys_yield 交出 CPU 使用权或者内核把本轮分配的时间片用尽的进程换出且换入下一个进程;
- 进程生成机制:介绍进程相关的两个重要系统调用 sys_fork/sys_exec 的实现;
- 进程资源回收机制:当进程调用 sys_exit 正常退出或者出错被内核终止之后如何保存其退出码,其父进程通过 sys_waitpid 系统调用收集该进程的信息并回收其资源。
- 字符输入机制:为了支持shell程序-user_shell获得字符输入,介绍 sys_read 系统调用的实现;
test6
- Filesystem OS:支持APP对数据的持久保存
- 在操作系统的管理下,应用程序不用理解持久存储设备的硬件细节,而只需对 文件 这种持久存储数据的抽象进行读写就可以了,由操作系统中的文件系统和存储设备驱动程序一起来完成繁琐的持久存储设备的管理与读写。
- 我们是参考经典的UNIX基于索引结构的文件系统,设计了一个简化的有一级目录并支持 open,read, write, close ,即创建/打开/读写/关闭文件一系列操作的文件系统。
- easyfs 文件系统的整体架构自下而上可分为五层:
- 磁盘块设备接口层:读写磁盘块设备的trait接口
- 块缓存层:位于内存的磁盘块数据缓存
- 磁盘数据结构层:表示磁盘文件系统的数据结构
- 磁盘块管理器层:实现对磁盘文件系统的管理
- 索引节点层:实现文件创建/文件打开/文件读写等操作
- 至于为什么块设备层位于 easy-fs 的最底层,那是因为文件系统仅仅是在块设备上存储的稍微复杂一点的数据。无论对文件系统的操作如何复杂,从块设备的角度看,这些操作终究可以被分解成若干次基本的块读写操作。
- 我们可以把easyfs文件系统看成是一个库,被应用程序调用。而 easy-fs-fuse 这个应用就通过调用easyfs文件系统库中各种函数,并作用在用Linux上的文件模拟的一个虚拟块设备,就可以在这个虚拟块设备上进行各种文件操作和文件系统操作,从而创建一个easyfs文件系统。
- 只要提供这个块设备驱动所需要的内存申请与释放以及虚实地址转换的4个函数就可以了。而我们之前操作系统中的虚存管理实现中,已经有这些函数,这使得块设备驱动程序很简单
- 对于内存直接通过一条指令即可直接读写内存相应的位置,而磁盘的话需要用软件的方式向磁盘发出请求来间接进行读写。
- 我们在easy-fs设计上,采用了松耦合模块化设计思路。easy-fs与底层设备驱动之间通过抽象接口 BlockDevice 来连接,避免了与设备驱动的绑定。easy-fs通过Rust提供的alloc crate来隔离了操作系统内核的内存管理,避免了直接调用内存管理的内核函数。在底层驱动上,采用的是轮询的方式访问 virtio_blk 虚拟磁盘设备,从而避免了访问外设中断的相关内核函数。easy-fs在设计中避免了直接访问进程相关的数据和函数,从而隔离了操作系统内核的进程管理。
- easy-fs crate 自下而上大致可以分成五个不同的层次:
- 磁盘块设备接口层:定义了以块大小为单位对磁盘块设备进行读写的trait接口
- 块缓存层:在内存中缓存磁盘块的数据,避免频繁读写磁盘
- 磁盘数据结构层:磁盘上的超级块、位图、索引节点、数据块、目录项等核心数据结构和相关处理
- 磁盘块管理器层:合并了上述核心数据结构和磁盘布局所形成的磁盘文件系统数据结构,以及基于这些结构的创建/打开文件系统的相关处理和磁盘块的分配和回收处理
- 索引节点层:管理索引节点(即文件控制块)数据结构,并实现文件创建/文件打开/文件读写等成员函数来向上支持文件操作相关的系统调用
- 块和扇区是两个不同的概念。 扇区 (Sector) 是块设备随机读写的数据单位,通常每个扇区为 512 字节。而块是文件系统存储文件时的数据单位,每个块的大小等同于一个或多个扇区。之前提到过 Linux 的Ext4文件系统的单个块大小默认为 4096 字节。在我们的 easy-fs 实现中一个块和一个扇区同为 512 字节
- 常见的手段是先通过 read_block 将一个块上的数据从磁盘读到内存中的一个缓冲区中,这个缓冲区中的内容是可以直接读写的,那么后续对这个数据块的大部分访问就可以在内存中完成了。如果缓冲区中的内容被修改了,那么后续还需要通过 write_block 将缓冲区中的内容写回到磁盘块中。
- 实际读写的时机完全交给块缓存层的全局管理器处理,上层子系统无需操心。全局管理器会尽可能将更多的块操作合并起来,并在必要的时机发起真正的块实际读写。
- 为了避免在块缓存上浪费过多内存,我们希望内存中同时只能驻留有限个磁盘块的缓冲区
- 块缓存全局管理器的功能是:当我们要对一个磁盘块进行读写时,首先看它是否已经被载入到内存缓存中了,如果已经被载入的话则直接返回,否则需要先读取磁盘块的数据到内存缓存中。此时,如果内存中驻留的磁盘块缓冲区的数量已满,则需要遵循某种缓存替换算法将某个块的缓存从内存中移除,再将刚刚读到的块数据加入到内存缓存中。
- 对于一个文件系统而言,最重要的功能是如何将一个逻辑上的文件目录树结构映射到磁盘上,决定磁盘上的每个块应该存储文件相关的哪些数据。为了更容易进行管理和更新,我们需要将磁盘上的数据组织为若干种不同的磁盘上数据结构,并合理安排它们在磁盘中的位置。
- 在 easy-fs 磁盘布局中,按照块编号从小到大顺序地分成 5 个不同属性的连续区域:
- 最开始的区域的长度为一个块,其内容是 easy-fs 超级块 (Super Block)。超级块内以魔数的形式提供了文件系统合法性检查功能,同时还可以定位其他连续区域的位置。
- 第二个区域是一个索引节点位图,长度为若干个块。它记录了后面的索引节点区域中有哪些索引节点已经被分配出去使用了,而哪些还尚未被分配出去。
- 第三个区域是索引节点区域,长度为若干个块。其中的每个块都存储了若干个索引节点。
- 第四个区域是一个数据块位图,长度为若干个块。它记录了后面的数据块区域中有哪些数据块已经被分配出去使用了,而哪些还尚未被分配出去。
- 最后的区域则是数据块区域,顾名思义,其中的每一个已经分配出去的块保存了文件或目录中的具体数据内容。
test7
- IPC OS:支持多个APP进程间数据交互与事件通知
- 除了键盘和屏幕这样的 标准 输入和 标准 输出之外,管道其实也可以看成是一种特殊的输入和输出,而前面讲解的 文件系统 中的对持久化存储数据的抽象 文件(file) 也是一种存储设备的输入和输出。所以,我们可以把这三种输入输出都统一在 文件(file) 这个抽象之中。这也体现了在 UNIX 操作系统中“ 一切皆文件 ” (Everything is a file) 的重要设计哲学。
- 为了统一表示 标准 输入、 标准 输出、管道和数据存储等,我们把支持 File trait 定义的接口的结构都称为文件。这样只要 标准 输入、 标准 输出、管道也基于统一的 File trait 接口实现自己的打开、关闭和读写等文件操作,就可以让进程来对自己进行管理了。
- 仅仅实现文件的统一抽象和支持进程间通信的管道机制,还不够灵活。因为这需要两个进程之间相互“知道”它们要通信,即它们不能独立存在。
- 进一步扩展进程动态管理的机制,来实现独立应用之间的I/O重定向,从而可以让独立的应用之间能够灵活组合完成复杂功能。
- 仅仅有支持数据传递的管道机制还不够便捷,进程间也需要更快捷的通知机制。而且操作系统也不仅仅是被动地接受来自进程的系统调用,它也需要有主动让进程响应它发出的通知的需求。这些都推动了一种 信号(Signal) 的事件通知机制的诞生。
- 简而言之,本章我们首先建立基于文件的统一I/O抽象,将标准输入/标准输出的访问改造为基于文件描述符,然后同样基于文件描述符实现一种父子进程之间的通信机制——管道,从而实现灵活的进程间通信,并基于文件抽象和管道支持不同的独立进程之间的动态组合,来实现复杂功能。而且通过实现信号机制,进程和操作系统可以主动发出信号来异步地通知相关事件给其它进程。
- 支持标准输入/输出文件
- 支持管道文件
- 支持对应用程序的命令行参数的解析和传递
- 实现标准 I/O 重定向功能
- 即进程打开一个文件的时候,内核总是会将文件分配到该进程文件描述符表中编号最小的 空闲位置。还需实现符合这个规则的新系统调用 sys_dup :复制文件描述符。这样就可以巧妙地实现标准 I/O 重定向功能了。具体思路是,在某应用进程执行之前,父进程(比如 user_shell进程)要对子进程的文件描述符表进行某种替换。以输出为例,父进程在创建子进程前,提前打开一个常规文件 A,然后 fork 子进程,在子进程的最初执行中,通过 sys_close 关闭 Stdout 文件描述符,用 sys_dup 复制常规文件 A 的文件描述符,这样 Stdout 文件描述符实际上指向的就是常规文件A了,这时再通过 sys_close 关闭常规文件 A 的文件描述符。至此,常规文件 A 替换掉了应用文件描述符表位置 1 处的标准输出文件,这就完成了所谓的 重定向 ,即完成了执行新应用前的准备工作。接下来,子进程调用 sys_exec 系统调用,创建并开始执行新应用。在重定向之后,新应用所在进程认为自己输出到 fd=1 的标准输出文件,但实际上是输出到父进程(比如 user_shell进程)指定的文件A中,从而实现了两个进程之间的信息传递。
- 应用程序访问的 文件 (File) 就是一系列的字节组合。操作系统管理文件,但操作系统不关心文件内容,只关心如何对文件按字节流进行读写的机制,这就意味着任何程序可以读写任何文件(即字节流),对文件具体内容的解析是应用程序的任务,操作系统对此不做任何干涉。
test8
- Tread&Coroutine OS:支持线程和协程APP,简化切换与数据共享
- 如果变量是只读的,多个线程读取该变量也不会有一致性问题。但是,当一个线程修改变量时,其他线程在读取这个变量时,可能会看到一个不一致的值,这就是数据不一致性的问题。
- 为什么会出现线程的数据不一致问题呢?其根本原因是 调度的不可控性 :即读写共享变量的代码片段会随时可能被操作系统调度和切换。
- 线程的数据一致性的定义:在单处理器(即只有一个核的CPU)下,如果某线程更新了一个可被其他线程读到的共享数据,那么后续其他线程都能读到这个最新被更新的共享数据。
- 并发相关术语
- 共享资源(shared resource):不同的线程/进程都能访问的变量或数据结构。
- 临界区(critical section):访问共享资源的一段代码。
- 竞态条件(race condition):多个线程/进程都进入临界区时,都试图更新共享的数据结构,导致产生了不期望的结果。
- 不确定性(indeterminate): 多个线程/进程在执行过程中出现了竞态条件,导致执行结果取决于哪些线程在何时运行,即执行结果不确定,而开发者期望得到的是确定的结果。
- 互斥(mutual exclusion):一种操作原语,能保证只有一个线程进入临界区,从而避免出现竞态,并产生确定的执行结果。
- 原子性(atomic):一系列操作要么全部完成,要么一个都没执行,不会看到中间状态。在数据库领域,具有原子性的一系列操作称为事务(transaction)。
- 同步(synchronization):多个并发执行的进程/线程在一些关键点上需要互相等待,这种相互制约的等待称为进程/线程同步。
- 死锁(dead lock):一个线程/进程集合里面的每个线程/进程都在等待只能由这个集合中的其他一个线程/进程(包括他自身)才能引发的事件,这种情况就是死锁。
- 饥饿(hungry):指一个可运行的线程/进程尽管能继续执行,但由于操作系统的调度而被无限期地忽视,导致不能执行的情况。
- 在用户态进行线程的创建,调度切换等,这就意味着我们不需要操作系统提供进一步的支持,即操作系统不需要感知到这种线程的存在。如果一个线程A想要运行,它只有等到目前正在运行的线程B主动交出处理器的使用权,从而让线程管理运行时库有机会得到处理器的使用权,且线程管理运行时库通过调度,选择了线程A,再完成线程B和线程A的线程上下文切换后,线程A才能占用处理器并运行。
- 在用户态进行线程管理,带了的一个潜在不足是没法让线程管理运行时直接切换线程,只能等当前运行的线程主动让出处理器使用权后,线程管理运行时才能切换检查。
基本的线程方法如下:
- spawn():创建一个新线程。
- join():把子线程加入主线程等待队列,等待子线程结束。