添加两篇文章

This commit is contained in:
luojia65 2021-05-19 22:23:37 +08:00
parent b063a875e0
commit 0b1b161b5d
3 changed files with 242 additions and 1 deletions

View File

@ -3,7 +3,7 @@ layout: post
title: '异步内核的设计与实现'
subtitle: '一种新的内核开发思想:在操作系统层面提供协程'
date: 2021-04-23
categories: 技术
categories: 内核设计
tags: 异步内核 Rust RISC-V
---

View File

@ -0,0 +1,56 @@
---
layout: post
title: '地址空间与进程'
subtitle: '提供一种异步内核对时空资源的划分方法'
date: 2021-05-01
categories: 内核设计
tags: Rust 生成器 RISC-V
---
# 地址空间与进程
传统操作系统内核里,地址空间和进程是一一对应的关系。很多情况下两者的概念杂凑在一起,不能得到很好的区分。
在飓风内核的实现中,我们尝试引入新的划分方法,区分“地址空间”和“进程”,体现出它们各自的作用,以供实现内核的核心思想。
## 1 地址空间
地址空间是一组地址映射关系的集合。
这里的地址映射关系是指虚拟地址到物理地址的映射关系。软件,包括 SBI 运行时,操作系统内核,用户程序,所面对的地址都属于虚拟地址,这个虚拟地址通过称为 MMU内粗管理单元的硬件实现会以某种映射关系转换成物理地址物理地址是访问真实存储硬件包括高速缓存和内存所使用的地址。而这种虚拟地址到物理地址的映射关系是由操作系统内核所设置的。
具体到指令集层面,在 RISC-V 指令中我们可以认为一个 satp 寄存器和它对应的页表对应一个地址空间。在这个概念的基础上,切换地址空间就可以描述为“切换 satp 寄存器并刷新 TLB”。
地址空间提供安全性隔离的作用,一个地址空间内的进程不能访问另一个地址空间的数据,可能在传统操作系统概念里面起到安全性隔离作用的是进程,但我们认为不是。**我们觉得是地址空间起到了安全性隔离的作用,进程不能做到这点**。
RISC-V 指令集里面针对地址空间会有一些优化点,比如 sfence 指令,可以指定某个地址空间编号,达到只刷新 TLB 中特定地址空间的页表项的效果。这种优化点是否存在取决于具体的硬件实现。
地址空间编号是什么RISC-V 指令集里面的 satp 寄存器,第 22 到第 30 位是 ASID 位,也就是**地址空间编号**,它唯一地标识了一个地址空间。
基于上面的思考,我们觉得地址空间这一概念是与进程分开的,在飓风内核的设计中,我们通过地址空间隔离来进行安全性隔离。
## 2 进程
我们认为,传统意义上的进程定义有三个组成部分:
+ 与地址空间的一一对应关系
+ 资源占用和释放的单位
+ 共同承担错误的单位
在飓风内核的设计与实现中,我们把传统意义上的这三个特点分割开来,分别单独去思考它们。
首先对于第一个特点,进程与地址空间是一一对应的关系,我们考虑新的设计,分别是一个进程对应多个地址空间和一个地址空间对应多个进程。
对于一个进程对应多个地址空间,我们暂时还没找到应用场景,因此在飓风内核的实现中暂时不考虑这部分。
一个地址空间对应多个进程,我们想到了一些应用场景,那就是资源相关性强的两个进程放到同一个地址空间中,相互之间的数据访问会比较快,因为这时候不需要切换地址空间,比如块设备驱动和文件系统。
对于这种设计,优点是在某些特定场景,同一个进程之间的数据访问会比较快(具体快在哪里需要我们后面去尝试实现这种设计之后才能比较好地归纳),但同时牺牲了一些安全性,因为这时候不同的进程处于同一个地址空间,可以访问彼此的数据。
对于第二个特点,资源占用和释放的单位,我们暂时沿用这个设计。
举个例子:一个进程打开一个文件,那么另外的进程就不能再次打开这个文件,因为这个文件资源已经被某个进程占用了。当一个进程退出之后,需要释放这个进程占用的所有资源,然后这些资源就可以被其他的进程占用。
对于第三个特点,共同承担错误的单位,我们也会沿用这个设计。
当一个任务的运行中遇到了不可恢复的错误,该任务所在的进程需要整个退出。也就是说,对于一个不可恢复的错误,该错误发生所在的任务所在的进程需要全部退出,另外的进程不受影响,这个错误由整个进程负责而不是由单个任务负责。
## todo补充

View File

@ -0,0 +1,185 @@
---
layout: post
title: '执行器与生成语义'
subtitle: '使用Rust语言的全新语义包装中断减少内核开发者的工作负担'
date: 2021-05-01
categories: 内核设计
tags: Rust 生成器 RISC-V
---
# 执行器与生成语义
操作系统为处理多应用而生,应当包含执行应用、中断处理这些模块。
传统的方法需要较多不会返回的函数,某种程度上,需要开发者加以更多的注意。
我们基于逐渐成型的“生成器语义”,提出一种新的方法来编写它们。
要搭建生成器语义的执行器,我们使用一个“恢复”函数运行用户应用,“恢复”函数将返回产生的中断;
这样可以以一种编程语言常见的形式,来隐含切换到应用和处理中断的流程。另外,应用切换到内核也可使用相似的形式。
这种方法能包装了应用执行和中断处理的逻辑,降低了编写难度,增加内核开发者的编程效率。
## 1 生成器与“让出”操作
以Rust语言为例我们来看“生成器”是如何的一种概念。生成器存在特殊的“让出”操作允许在运行“让出”操作时
打断当前的执行流程,回到调用生成器的环境。“让出”操作可以带一个返回值,允许环境做一些需要的操作。
我们看一段例子代码([来源:知乎@MashPlant](https://zhuanlan.zhihu.com/p/157496421)
```rust
use std::ops::{Generator, GeneratorState};
use std::pin::Pin;
fn main() {
let mut g = || {
yield 1;
return "foo"
};
assert_eq!(Pin::new(&mut g).resume(()), GeneratorState::Yielded(1));
assert_eq!(Pin::new(&mut g).resume(()), GeneratorState::Complete("foo"));
}
```
这里的变量“g”定义比较像普通的闭包但里面出现了“yield”让出关键字所以变量“g”变成了一个生成器。
生成器拥有一个“恢复”即“resume”函数只要调用了恢复函数生成器就会继续执行直到下一个“让出”操作或者“返回”。
出现“让出”操作时生成器内的代码将会暂停运行让出到它的调用者即“main”函数它将拿到让出操作的让出值。
使用让出值执行一些操作,可以打印出来,或者也可以做一些随后的处理,这里判断它和预期的值是否相等。
这之后,可以继续执行“恢复”函数,直到生成器运行结束。
所以简单来说,生成器是一个执行可以暂停,并不断产生值的一种变量。拥有一个生成器时,我们可以不断执行它,
处理它产生的中间值,直到它的执行过程完成。
## 2 内核执行器的新编程方法
传统内核中隐约地包含了“执行器”的思想:它运行当前的线程,中断发生时暂停线程,转入调度程序,得到下一个要运行的线程。
编写传统内核的调度逻辑,开发者必须完整地了解上下文切换、中断处理的流程。
思考内核的编写方法。应用的执行可以暂停,它可以不断地产生中断的原因和上下文。咦,这是不是和生成器的思路非常相似?
于是,我们可以把内核的运行环境看作是一个生成器。内核不断执行“恢复”函数,继续运行用户代码。
每当中断发生,将会产生中断的原因和上下文,这就是生成器生成的让出值。
内核前去处理让出值,在这期间,可以决定下一个要运行的应用,切换继续运行的应用,继续“恢复”它们,直到应用执行完成。
在传统内核中,假设运行环境是一个产生中断的生成器,我们可以编写下面的伪代码:
```rust
let mut runtime = Runtime::new(); // Runtime是一个生成器实现了trait Generator
runtime.prepare_first_thread();
loop { // 不断执行恢复操作
match Pin::new(&mut runtime).resume(()) { // 判断产生的中断是哪种类型
Yielded(ResumeResult::Syscall()) => {
syscall_process(&mut runtime);
// runtime包含用户上下文系统调用函数会读取其中的参数将返回值填写回runtime里。
// 随后下一个循环resume函数会继续执行当前的线程
// 如果这个系统调用将增加一个线程它会操作runtime的值。
}
Yielded(ResumeResult::TimerInterrupt()) => {
runtime.prepare_next_thread();
// 准备完毕后下一个循环的resume函数将会执行下一个线程
}
Yielded(ResumeResult::IllegalInstruction(_insn_addr)) => {
core_dump();
runtime.destroy_current_thread();
runtime.prepare_next_thread();
// 当前的线程会被销毁然后下一个线程会在下一个resume函数被运行
}
Complete(()) => break // 如果没有线程了,执行器运行结束
}
}
```
这样的编程方法遵守了高级语言的思路。在Rust语言中它没有使用全局变量便于控制生命周期
能提高开发者的编程效率和代码的安全性。只要在启动代码最后使用这段代码,就可以不断运行线程了。
接下来就是生成器要如何实现了。和普通应用的生成器不同,内核的中断和恢复是比较复杂的。
我们需要整理传统的上下文切换方法,实现内核运行用户的生成器。
## 3 实现生成器
对不同的架构来说,传统内核的上下文切换,需要保存和读取通用的寄存器,也需要地址空间的切换操作。
不同的是,当前执行器的上下文将将被保存,因为后续仍然需要运行执行器。
以常用于嵌入式开发的RTOS为例我们需要存取通用的寄存器。
在恢复函数执行时,首先保存当前执行器的上下文,只需要保存被调用者保存的寄存器,因为调用者保存的寄存器已经在调用函数之前保存了。
在RISC-V中需要保存所有的s0-s11寄存器。因为用户程序不会为我们保存返回地址所以我们还需要保存ra寄存器。
然后,恢复用户的上下文,这里应当保存用户所有的寄存器。
在RISC-V架构中需要x1一直到x31寄存器如果内核也可能发生中断为了支持嵌套中断还需要保存sstatus寄存器。
用户和内核都可能修改gp、tp寄存器。为了隔离数据、增加安全性两个过程都需要保存gp、tp寄存器。
产生中断时首先保存用户的上下文随后跳转到中断处理函数。中断处理函数将保存s0-s11寄存器返回值保存到a0和a1寄存器。
最后当中断处理函数返回它将跳转到另一段代码它将恢复内核的ra、s0-s11寄存器最终通过ra寄存器返回到内核的生成器中。
这个生成器绕了一圈终于接收到了中断处理函数传来的a0和a1寄存器作为返回值说明收到了中断。
生成器的函数终于可以返回了,它将返回到运行它的执行环境中,以等待下一步的操作。
我们用伪代码来说明这个过程:
```rust
impl Generator for Runtime {
type Yield = IsaSpecificTrap;
type Return = ();
fn resume(mut self: Pin<&mut Self>, _arg: ()) -> GeneratorState<IsaSpecificTrap, ()> {
do_resume(&mut self.context);
// 到这里用户程序已经开始执行。发生中断时跳转到处理函数interrupt。
// ← 当interrupt_return函数运行结束它将返回到这里
return IsaSpecificCauseRegister::read().into()
}
}
fn do_resume(ctx: &mut UserContext) {
asm!("save executor context", "load user context `ctx`", "jump to user application")
}
fn interrupt() -> ! {
asm!("save user context", "jump to `interrupt_return`")
}
fn interrupt_return() -> ! {
asm!("load executor context", "jump to return address register")
// 这段代码已经完成了中断上下文的操作它会返回到resume函数中 →
}
```
可以看到,恢复函数跳转到了用户程序中。但是,中断处理函数给恢复函数填写了返回值。
从用户看来,虽然经过很多过程的包装,但生成器函数竟然能够返回,拿到中断的类型和上下文。
至此,最重要的恢复操作已经实现,生成器可以投入使用了。
这段代码是伪代码具体能运行的实现代码已经提交到luojia-os-labs中[生成器](https://github.com/HUST-OS/luojia-os-labs/blob/main/01b-magic-return-kern/kernel/src/executor.rs)、[使用方法](https://github.com/HUST-OS/luojia-os-labs/blob/b3876866f2b6e2b6ad7bd1eba286fbaa9a6cca8a/01b-magic-return-kern/kernel/src/main.rs#L37)。
![内核执行器的恢复操作](内核执行器的恢复操作.png)
需要做的一些说明是具体实现中参数的填写方式和二进制接口有关。如果返回值的长度超过两个usize长度RISC-V下参数会从a1寄存器开始填写
a0寄存器将被用作寄存器相对寻址来保存真正的返回值参数将不会a0寄存器开始保存。
根据中断处理函数的返回值类型来决定上下文参数要放在a0还是a1中。一般为了简便中断处理函数的返回值都很小
就假设它很小总是填在a0里面就可以了。如果写错了参数的保存方法代码会出现很多意料之外的情况。
## 4 生成语义与“相对性”
经过以上的讨论,我们的内核可以创建一个生成器,不断执行它的恢复操作,运行所有的应用程序。我们有一个很大胆的想法。
首先我们来做一个思维实验。有一个人在地球上画一个圈,然后他站在圈里面。
如果从地球上其他人来看,是这个人被圈圈在里面。如果从这个人的角度看,是地球上的其他人都被圈在圈里面。
那么,如果内核能不断用生成器使用用户,用户能不能也把内核看成生成器,不断使用内核呢?
好像想得通啊,是不是有些头皮发麻了?不急,我们看看怎么回事。
每当发生一个硬件中断,它不是由内核产生的,也不是由用户产生的。
如果我们有一个不断运行任务的应用,把所有的任务看作是相对于内核的用户,它将运行所有的任务,不断产生“运行结束”的提示。
或者,如果一个任务超时了,将产生“已经超时”的提示。
如果是运行结束,用户的执行器可以复用原有的栈,拿出下一个任务,继续运行。
如果生成了“已经超时”,上一个任务的栈将会保留,执行器将创建一个新的栈,来运行下一个任务。
那么如何编写这个生成器呢?如果产生了中断,永远是内核在处理,轮不到用户去处理,于是用户没有机会知道中断发生了。
除非,用户层也有一个中断委托机制。即使内核能切换到其它的用户,如果内核在切换到这个用户时,提示用户,发生了一次上下文切换,
这时候用户就能包装这个提示,作为“已经超时”,作为生成器实现的一部分了。
如果在有信号量的系统,我们可以使用信号量,完成上下文切换提示的过程。如果硬件提供了用户层中断,我们也可以将它作为一个提示,
用生成语义实现用户层的执行器。
在这种比较初步的想法下,内核和用户都能接收到中断,他们是相对的。在未来的架构中,我们可以尝试使用生成语义,
在用户层更高效地开发的执行器和运行环境。
## 一些记录
Rust语言有一个还没稳定的执行器语法我提了一个[issue评论](https://github.com/rust-lang/rust/issues/43122#issuecomment-830573558)不知道社区的各位会怎么看。就记在这里以免找不到issue。