简明 zCore 教程
自己动手山寨操作系统:自顶向下方法
zCore 是用 Rust 语言重写的 Zircon 微内核,它是 Google 正在开发的 Fuchsia OS 中的底层内核。
本教程基于 zCore 的真实开发历史,还原其开发过程。带领读者一步一步用 Rust 实现自己的 Zircon 内核,最终能够运行原生的 shell 程序。 在此过程中我们将体会 Zircon 微内核的设计理念,感受如何用 Rust 语言以一种现代的方式编写系统软件,在项目中实现理论与实践的融合。
与传统操作系统开发不同的是,zCore 使用一种自顶向下的方法:首先基于宿主系统已有的功能,在用户态实现一个能够工作的 libOS,然后再逐步替换底层实现, "移植"回裸机环境中运行。因此我们更关注系统的整体设计,从高层视角看待 OS 如何为用户提供服务,而不纠结于底层硬件细节。
鉴于此,本教程假设读者了解操作系统基本概念和原理,具有常用的 Linux 系统使用经验,并且会使用 Rust 语言编写简单程序。 如果读者不熟悉操作系统和 Rust 语言,希望以自底向上方法从零构建操作系统,rCore Tutorial 可能是更好的选择。
如果你准备好了,让我们开始吧!
zCore 整体结构和设计模式
首先,从 Rust语言操作系统的设计与实现,王润基本科毕设论文,2019 和 zCore操作系统内核的设计与实现,潘庆霖本科毕设论文,2020 可以了解到从 rCore 的设计到 zCore 的设计过程的全貌。
zCore 的整体结构
zCore 的整体结构/项目设计图如下:
zCore的设计主要有两个出发点:
- 内核对象的封装:将内核对象代码封装为一个库,保证可重用
- 硬件接口的设计:使硬件与内核对象的设计相对独立,只向上提供统一、抽象的API接口
项目设计从上到下,上层更远离硬件,下层更接近硬件。
zCore 设计的顶层是上层操作系统,比如 zCore、rCore、Zircon LibOS 和 Linux LibOS。在项目架构中,各版本的操作系统有部分公用代码。与 zCore 微内核设计实现相关的部分则主要是图中左侧蓝色线部分。
第二层,是 ELF 程序加载层(ELF Program Loader),包括 zircon-loader 和 linux-loader,其中封装了初始化内核对象、部分硬件相关的初始化、设定系统调用接口、运行首个用户态程序等逻辑,并形成一个库函数。zCore 在顶层通过调用 zircon-loader 库中的初始化逻辑,进入第一个用户态程序执行。
第三层,是系统调用实现层(Syscall Implementation),包括 zircon-syscall 和 linux-syscall,这一层将所有的系统调用处理例程封装为一个系统调用库,供上方操作系统使用。
第四层,利用硬件抽象层提供的虚拟硬件 API 进行内核对象(Kernel Objects)的实现,并且基于实现的各类内核对象,实现第三层各个系统调用接口所需要的具体处理例程。
第五层,是硬件抽象层(HAL,Hardware Abstraction Layer),这里对应的是 kernel-hal 模块。kernel-hal 将向上提供所有操作硬件需要的接口,从而使得硬件环境对上层操作系统透明化。
第六层,是对直接操作硬件的代码进行一层封装,对应模块为 kernel-hal-bare 和 kernel-hal-unix。kernel-hal 系列库仅仅负责接口定义,即将底层硬件/宿主操作系统的操作翻译为上层操作系统可以使用的形式。在这里,kernel-hal-bare 负责翻译裸机的硬件功能,而 kernel-hal-unix 则负责翻译类 Unix 系统的系统调用。
最底层是底层运行环境,包括 Bare Metal(裸机),Linux / macOS 操作系统。Bare Metal可以认为是硬件架构上的寄存器等硬件接口。
zCore 内核组件
zCore 内核运行时组件层次概况如下:

在zCore启动过程中,会初始化物理页帧分配器、堆分配器、线程调度器等各个组成部分。并委托 zircon-loader 进行内核对象的初始化创建过程,然后进入用户态的启动过程开始执行。每当用户态触发系统调用进入内核态,系统调用处理例程将会通过已实现的内核对象的功能来对服务请求进行处理;而对应的内核对象的内部实现所需要的各种底层操作,则是通过 HAL 层接口由各个内核组件负责提供。
其中,VDSO(Virtual dynamic shared object)是一个映射到用户空间的 so 文件,可以在不陷入内核的情况下执行一些简单的系统调用。在设计中,所有中断都需要经过 VDSO 拦截进行处理,因此重写 VDSO 便可以实现自定义的对下层系统调用(syscall)的支持。Executor 是 zCore 中基于 Rust 的 async 机制的协程调度器。
在HAL接口层的设计上,还借助了 Rust 的能够指定函数链接过程的特性。即,在 kernel-hal 中规定了所有可供 zircon-object 库及 zircon-syscall 库调用的虚拟硬件接口,以函数 API 的形式给出,但是内部均为未实现状态,并设置函数为弱引用链接状态。在 kernel-hal-bare 中才给出裸机环境下的硬件接口具体实现,编译 zCore 项目时、链接的过程中将会替换/覆盖 kernel-hal 中未实现的同名接口,从而达到能够在编译时灵活选择 HAL 层的效果。
Fuchsia OS 和 Zircon 微内核
内核对象
Zircon 是一个基于内核对象的系统。系统的功能被划分到若干组内核对象中。
作为一切的开始,本章首先构造了一个内核对象框架,作为后面实现的基础。
然后我们实现第一个内核对象 —— Process,它是所有对象的容器,也是将来我们操作对象的入口点。
最后会实现一个稍微复杂但是极其重要的对象 Channel,它是进程间通信(IPC)的基础设施,也是传送对象的唯一管道。
初识内核对象
内核对象简介
在动手编写我们的代码之前,需要首先进行调研和学习,对目标对象有一个全面系统的了解。 而了解一个项目设计的最好方式就是阅读官方提供的手册和文档。
让我们先来阅读一下 Fuchsia 官方文档:内核对象。这个链接是社区翻译的中文版,已经有些年头了。如果读者能够科学上网,推荐直接阅读官方英文版。
通过阅读文档,我们了解到与内核对象相关的三个重要概念:对象(Object),句柄(Handle),权限(Rights)。它们在 Zircon 内核中的角色和关系如下图所示:

这三个重要概念的定义如下:
- 对象(Object): 具备属性和行为的客体。客体之间可有各种联系。从简单的整数到复杂的操作系统进程等都可看做对象,它不仅仅表示具体的事物,还能表示抽象的规则、计划或事件。
- 句柄(Handle):标识对象的符号,也可看成是一种指向对象的变量(也可称为标识符、引用、ID等)。
- 权限(Rights):是指对象的访问者被允许在对象上执行的操作,即对象的访问权限。当对象访问者打开对象的句柄,该句柄具有对其对象的访问权限的某种组合。
对于Zircon与对象、句柄、权限的关系,可简单地表述为:
- Zircon是一个基于对象的内核,内核资源被抽象封装在不同的 对象 中。
- 用户程序通过 句柄 与内核交互。句柄是对某一对象的引用,并且附加了特定的 权限。
- 对象通过 引用计数 管理生命周期。对于大多数对象,当指向它的最后一个句柄关闭时,对象随之销毁,或进入无法挽回的最终状态。
此外在内核对象的文档中,还列举了一些常用对象。点击链接进去就能查看到这个对象的具体描述,在页面最下方还列举了与这个对象相关的全部系统调用。 进一步查看系统调用的 API 定义,以及它的行为描述,我们就能更深入地了解用户程序操作内核对象的一些细节:
-
创建:每一种内核对象都存在一个系统调用来创建它,例如
zx_channel_create。 创建对象时一般需要传入一个参数选项options,若创建成功则内核会将一个新句柄写入用户指定的内存中。 -
使用:获得对象句柄后可以通过若干系统调用对它进行操作,例如
zx_channel_write。 这类系统调用一般需要传入句柄handle作为第一个参数,内核首先对其进行检查,如果句柄非法或者对象类型与系统调用不匹配就会报错。 接下来内核会检查句柄的权限是否满足操作的要求,例如write操作一般要求句柄具有WRITE权限,如果权限不满足就会继续报错。 -
关闭:当用户程序不再使用对象时,会调用
zx_handle_close关闭句柄。当用户进程退出时,仍处于打开状态的句柄也都会自动关闭。
我们还发现,有一类 Object 系统调用是对所有内核对象都适用的。 这表明所有内核对象都有一些公共属性,例如 ID、名称等等。每一种内核对象也会有自己特有的属性。
其中一些 Object 系统调用和信号相关。Zircon 每个内核对象都附带有 32 个 信号(Signals),它们代表了不同类型的事件。 与传统 Unix 系统的信号不同,它不能异步地打断用户程序运行,而只能由用户程序主动地阻塞等待在某个对象的某些信号上面。 信号是 Zircon 内核中很重要的机制,不过这部分在前期不会涉及,我们留到第五章再具体实现。
以上我们了解了 Zircon 内核对象的相关概念和使用方式。接下来在这一节中,我们将用 Rust 实现内核对象的基本框架,以方便后续快速实现各种具体类型的内核对象。 从传统面向对象语言的视角看,我们只是在实现一个基类。但由于 Rust 语言模型的限制,这件事情需要用到一些特殊的技巧。
建立项目
首先我们需要安装 Rust 工具链。在 Linux 或 macOS 系统下,只需要用一个命令下载安装 rustup 即可:
$ curl https://sh.rustup.rs -sSf | sh
具体安装方法可以参考官方文档。
接下来我们用 cargo 创建一个 Rust 库项目:
$ cargo new --lib zcore
$ cd zcore
我们将在这个 crate 中实现所有的内核对象,以库(lib)而不是可执行文件(bin)的形式组织代码,后面我们会依赖单元测试保证代码的正确性。
由于我们会用到一些不稳定(unstable)的语言特性,需要使用 nightly 版本的工具链。在项目根目录下创建一个 rust-toolchain 文件,指明使用的工具链版本:
{{#include ../../code/ch01-01/rust-toolchain}}
这个程序库目前是在你的 Linux 或 macOS 上运行,但有朝一日它会成为一个真正的 OS 在裸机上运行。
为此我们需要移除对标准库的依赖,使其成为一个不依赖当前 OS 功能的库。在 lib.rs 的第一行添加声明:
// src/lib.rs
#![no_std]
extern crate alloc;
现在我们可以尝试运行一下自带的单元测试,编译器可能会自动下载并安装工具链:
$ cargo test
Finished test [unoptimized + debuginfo] target(s) in 0.52s
Running target/debug/deps/zcore-dc6d43637bc5df7a
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
实现 KernelObject 接口
所有的内核对象有一系列共同的属性和方法,我们称对象的方法为对象的公共接口(Interface)。 同一种方法在不同类型的对象中可能会有不同的行为,在面向对象语言中我们称其为多态(Polymorphism)。
Rust 是一门部分面向对象的语言,我们通常用它的 trait 实现接口和多态。
首先创建一个 KernelObject trait 作为内核对象的公共接口:
use alloc::string::String;
// src/object/mod.rs
/// 内核对象公共接口
pub trait KernelObject: Send + Sync {
/// 获取对象 ID
fn id(&self) -> KoID;
/// 获取对象类型名
fn type_name(&self) -> &str;
/// 获取对象名称
fn name(&self) -> String;
/// 设置对象名称
fn set_name(&self, name: &str);
}
/// 对象 ID 类型
pub type KoID = u64;
这里的 Send + Sync 是一个约束所有 KernelObject 都要满足的前提条件,即它必须是一个并发对象。
所谓并发对象指的是可以安全地被多线程共享访问。事实上我们的内核本身就是一个共享地址空间的多线程程序,在裸机上每个 CPU 核都可以被视为一个并发执行的线程。
由于内核对象可能被多个线程同时访问,因此它必须是并发对象。
实现一个空对象
接下来我们实现一个最简单的空对象 DummyObject,并为它实现 KernelObject 接口:
// src/object/object.rs
use spin::Mutex;
/// 空对象
#[derive(Debug)]
pub struct DummyObject {
id: KoID,
inner: Mutex<DummyObjectInner>,
}
/// `DummyObject` 的内部可变部分
#[derive(Default, Debug)]
struct DummyObjectInner {
name: String,
}
为了有效地支持操作系统中的并行和并发处理,我们这里采用了一种内部可变性的设计模式:将对象的所有可变的部分封装到一个内部对象 DummyObjectInner 中,并在原对象中用可保证互斥访问的自旋锁 Mutex 把它包起来,剩下的其它字段都是不可变的。
Mutex 会用最简单的方式帮我们处理好并发访问问题:如果有其他人正在访问,我就在这里忙等。
数据被 Mutex 包起来之后需要首先使用 lock() 拿到锁之后才能访问。此时并发访问已经安全,因此被包起来的结构自动具有了 Send + Sync 特性。
使用自旋锁引入了新的依赖库 spin ,需要在 Cargo.toml 中加入以下声明:
[dependencies]
spin = "0.7"
然后我们为新对象实现构造函数:
// src/object/object.rs
use alloc::sync::Arc;
use core::sync::atomic::*;
impl DummyObject {
/// 创建一个新 `DummyObject`
pub fn new() -> Arc<Self> {
Arc::new(DummyObject {
id: Self::new_koid(),
inner: Default::default(),
})
}
/// 生成一个唯一的 ID
fn new_koid() -> KoID {
static NEXT_KOID: AtomicU64 = AtomicU64::new(1024);
NEXT_KOID.fetch_add(1, Ordering::SeqCst)
}
}
根据文档描述,每个内核对象都有唯一的 ID。为此我们需要实现一个全局的 ID 分配方法。这里采用的方法是用一个静态变量存放下一个待分配 ID 值,每次分配就原子地 加1。
ID 类型使用 u64,保证了数值空间足够大,在有生之年都不用担心溢出问题。在 Zircon 中 ID 从 1024 开始分配,1024 以下保留作内核内部使用。
另外注意这里 new 函数返回类型不是 Self 而是 Arc<Self>,这是的 Arc 为了以后方便并行处理而做的统一约定。
最后我们为它实现 KernelObject 的基本接口:
// src/object/object.rs
impl KernelObject for DummyObject {
fn id(&self) -> KoID {
self.id
}
fn type_name(&self) -> &str {
"DummyObject"
}
fn name(&self) -> String {
self.inner.lock().name.clone()
}
fn set_name(&self, name: &str) {
self.inner.lock().name = String::from(name);
}
}
到此为止,我们已经迈出了万里长征第一步,实现了一个最简单的功能。有实现,就要有测试!即使最简单的代码也要保证它的行为符合我们预期。 只有对现有代码进行充分测试,在未来做添加和修改的时候,我们才有信心不会把事情搞砸。俗话讲"万丈高楼平地起",把地基打好才能盖摩天大楼。
为了证明上面代码的正确性,我们写一个简单的单元测试,替换掉自带的 it_works 函数:
// src/object/object.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dummy_object() {
let o1 = DummyObject::new();
let o2 = DummyObject::new();
assert_ne!(o1.id(), o2.id());
assert_eq!(o1.type_name(), "DummyObject");
assert_eq!(o1.name(), "");
o1.set_name("object1");
assert_eq!(o1.name(), "object1");
}
}
$ cargo test
Finished test [unoptimized + debuginfo] target(s) in 0.53s
Running target/debug/deps/zcore-ae1be84852989b13
running 1 test
test tests::dummy_object ... ok
大功告成!让我们用 cargo fmt 命令格式化一下代码,然后记得 git commit 及时保存进展。
实现接口到具体类型的向下转换
在系统调用中,用户进程会传入一个内核对象的句柄,然后内核会根据系统调用的类型,尝试将其转换成特定类型的对象。
于是这里产生了一个很重要的需求:将接口 Arc<dyn KernelObject> 转换成具体类型的结构 Arc<T> where T: KernelObject。
这种操作在面向对象语言中称为向下转换(downcast)。
在大部分编程语言中,向下转换都是一件非常轻松的事情。例如在 C/C++ 中,我们可以这样写:
struct KernelObject {...};
struct DummyObject: KernelObject {...};
KernelObject *base = ...;
// C 风格:强制类型转换
DummyObject *dummy = (DummyObject*)(base);
// C++ 风格:动态类型转换
DummyObject *dummy = dynamic_cast<DummyObject*>(base);
但在 Rust 中,由于其 trait 模型的限制,向下转换并不是一件容易的事情。
虽然标准库中提供了 Any trait,部分实现了动态类型的功能,但实际操作起来却困难重重。
不信邪的同学可以自己折腾一下:
use std::any::Any; use std::sync::Arc; fn main() {} trait KernelObject: Any + Send + Sync {} fn downcast_v1<T: KernelObject>(object: Arc<dyn KernelObject>) -> Arc<T> { object.downcast::<T>().unwrap() } fn downcast_v2<T: KernelObject>(object: Arc<dyn KernelObject>) -> Arc<T> { let object: Arc<dyn Any + Send + Sync + 'static> = object; object.downcast::<T>().unwrap() }
当然这个问题也困扰了 Rust 社区中的很多人。目前已经有人提出了一套不错的解决方案,就是我们接下来要引入的 downcast-rs 库:
[dependencies]
downcast-rs = { version = "1.2.0", default-features = false }
(题外话:这个库原来是不支持 no_std 的,zCore 有这个需求,于是就顺便帮他实现了一把)
按照downcast-rs 文档的描述,我们要为自己的接口实现向下转换,只需以下修改:
// src/object/mod.rs
use core::fmt::Debug;
use downcast_rs::{impl_downcast, DowncastSync};
pub trait KernelObject: DowncastSync + Debug {...}
impl_downcast!(sync KernelObject);
其中 DowncastSync 代替了原来的 Send + Sync,Debug 用于出错时输出调试信息。
impl_downcast! 宏用来帮我们自动生成转换函数,然后就可以用 downcast_arc 来对 Arc 做向下转换了。我们直接来测试一把:
// src/object/object.rs
#[test]
fn downcast() {
let dummy = DummyObject::new();
let object: Arc<dyn KernelObject> = dummy;
let _result: Arc<DummyObject> = object.downcast_arc::<DummyObject>().unwrap();
}
$ cargo test
Finished test [unoptimized + debuginfo] target(s) in 0.47s
Running target/debug/deps/zcore-ae1be84852989b13
running 2 tests
test object::downcast ... ok
test object::tests::dummy_object ... ok
模拟继承:用宏自动生成接口实现代码
上面我们已经完整实现了一个内核对象,代码看起来很简洁。但当我们要实现更多对象的时候,就会发现一个问题: 这些对象拥有一些公共属性,接口方法也有共同的实现。 在传统 OOP 语言中,我们通常使用 继承(inheritance) 来复用这些公共代码:子类 B 可以继承父类 A,然后自动拥有父类的所有字段和方法。
继承是一个很强大的功能,但在长期实践中人们也逐渐发现了它的弊端。有兴趣的读者可以看一看知乎上的探讨:面向对象编程的弊端是什么?。
经典著作《设计模式》中就鼓励大家使用组合代替继承。而一些现代的编程语言,如 Go 和 Rust,甚至直接抛弃了继承。在 Rust 中,通常使用组合结构和 Deref trait 来部分模拟继承。
继承野蛮,trait 文明。 —— 某 Rust 爱好者
接下来我们模仿 downcast-rs 库的做法,使用一种基于宏的代码生成方案,来实现 KernelObject 的继承。
当然这只是抛砖引玉,如果读者自己实现了,或者了解到社区中有更好的解决方案,也欢迎指出。
具体做法是这样的:
- 使用一个 struct 来提供所有的公共属性和方法,作为所有子类的第一个成员。
- 为子类实现 trait 接口,所有方法直接委托给内部 struct 完成。这部分使用宏来自动生成模板代码。
而所谓的内部 struct,其实就是我们上面实现的 DummyObject。为了更好地体现它的功能,我们给他改个名叫 KObjectBase:
// src/object/mod.rs
/// 内核对象核心结构
pub struct KObjectBase {
/// 对象 ID
pub id: KoID,
inner: Mutex<KObjectBaseInner>,
}
/// `KObjectBase` 的内部可变部分
#[derive(Default)]
struct KObjectBaseInner {
name: String,
}
接下来我们把它的构造函数改为实现 Default trait,并且公共属性和方法都指定为 pub:
// src/object/mod.rs
impl Default for KObjectBase {
/// 创建一个新 `KObjectBase`
fn default() -> Self {
KObjectBase {
id: Self::new_koid(),
inner: Default::default(),
}
}
}
impl KObjectBase {
/// 生成一个唯一的 ID
fn new_koid() -> KoID {...}
/// 获取对象名称
pub fn name(&self) -> String {...}
/// 设置对象名称
pub fn set_name(&self, name: &str) {...}
}
最后来写一个魔法的宏!
// src/object/mod.rs
/// 为内核对象 struct 自动实现 `KernelObject` trait 的宏。
#[macro_export] // 导出宏,可在 crate 外部使用
macro_rules! impl_kobject {
// 匹配类型名,并可以提供函数覆盖默认实现
($class:ident $( $fn:tt )*) => {
// 为对象实现 KernelObject trait,方法直接转发到内部 struct
impl KernelObject for $class {
fn id(&self) -> KoID {
// 直接访问内部的 pub 属性
self.base.id
}
fn type_name(&self) -> &str {
// 用 stringify! 宏将输入转成字符串
stringify!($class)
}
// 注意宏里面的类型要写完整路径,例如:alloc::string::String
fn name(&self) -> alloc::string::String {
self.base.name()
}
fn set_name(&self, name: &str){
// 直接访问内部的 pub 方法
self.base.set_name(name)
}
// 可以传入任意数量的函数,覆盖 trait 的默认实现
$( $fn )*
}
// 为对象实现 Debug trait
impl core::fmt::Debug for $class {
fn fmt(
&self,
f: &mut core::fmt::Formatter<'_>,
) -> core::result::Result<(), core::fmt::Error> {
// 输出对象类型、ID 和名称
f.debug_tuple(&stringify!($class))
.field(&self.id())
.field(&self.name())
.finish()
}
}
};
}
轮子已经造好了!让我们看看如何用它方便地实现一个内核对象,仍以 DummyObject 为例:
// src/object/mod.rs
/// 空对象
pub struct DummyObject {
// 其中必须包含一个名为 `base` 的 `KObjectBase`
base: KObjectBase,
}
// 使用刚才的宏,声明其为内核对象,自动生成必要的代码
impl_kobject!(DummyObject);
impl DummyObject {
/// 创建一个新 `DummyObject`
pub fn new() -> Arc<Self> {
Arc::new(DummyObject {
base: KObjectBase::default(),
})
}
}
是不是方便了很多?最后按照惯例,用单元测试检验实现的正确性:
// src/object/mod.rs
#[test]
fn impl_kobject() {
use alloc::format;
let dummy = DummyObject::new();
let object: Arc<dyn KernelObject> = dummy;
assert_eq!(object.type_name(), "DummyObject");
assert_eq!(object.name(), "");
object.set_name("dummy");
assert_eq!(object.name(), "dummy");
assert_eq!(
format!("{:?}", object),
format!("DummyObject({}, \"dummy\")", object.id())
);
let _result: Arc<DummyObject> = object.downcast_arc::<DummyObject>().unwrap();
}
有兴趣的读者可以继续探索使用功能更强大的 过程宏(proc_macro),进一步简化实现新内核对象所需的模板代码。 如果能把上面的代码块缩小成下面这两行,就更加完美了:
#[KernelObject]
pub struct DummyObject;
总结
在这一节中我们用 Rust 语言实现了 Zircon 最核心的内核对象概念。在此过程中涉及到 Rust 的一系列语言特性和设计模式:
- 使用 trait 实现接口
- 使用 内部可变性 模式实现并发对象
- 基于社区解决方案实现 trait 到 struct 的 向下转换
- 使用组合模拟继承,并使用 宏 实现模板代码的自动生成
由于 Rust 独特的面向对象编程特性,我们在实现内核对象的过程中遇到了一定的挑战。 不过万事开头难,解决这些问题为整个项目打下了坚实基础,后面实现新的内核对象就会变得简单很多。
在下一节中,我们将介绍内核对象相关的另外两个概念:句柄和权限,并实现内核对象的存储和访问。
对象管理器:Process 对象
权限
内核对象的“权限”指定允许对内核对象进行哪些操作。权限与句柄相关联,并传达对关联句柄或与句柄关联的对象执行操作的特权。单个进程可能对具有不同权限的同一个内核对象有两个不同的句柄。
句柄
句柄是允许用户程序引用内核对象引用的一种内核结构,它可以被认为是与特定内核对象的会话或连接。
通常情况下,多个进程通过不同的句柄同时访问同一个对象。对象可能有多个句柄(在一个或多个进程中)引用它们。但单个句柄只能绑定到单个进程或绑定到内核。
当句柄绑定到内核时,我们说它是“在传输中”('in-transit')。
在用户模式下,句柄只是某个系统调用返回的特定数字。只有“不在传输中”的句柄对用户模式可见。
代表句柄的整数只对其所属的那个进程有意义。另一个进程中的相同数字可能不会映射到任何句柄,或者它可能映射到指向完全不同的内核对象的句柄。
句柄的整数值是任何 32 位数字,但对应于ZX_HANDLE_INVALID的值将始终为 0。除此之外,有效句柄的整数值将始终具有句柄集的两个最低有效位. 可以使用ZX_HANDLE_FIXED_BITS_MASK访问代表这些位的掩码。
句柄可以从一个进程移动到另一个进程,方法是将它们写入通道(使用channel_write()),或者使用 process_start()传递一个句柄作为新进程中第一个线程的参数。对于几乎所有的对象,当最后一个打开的引用对象的句柄关闭时,对象要么被销毁,要么被置于可能无法撤消的最终状态。
在 Cargo.toml 中加入 bitflags 库:
[dependencies]
bitflags = "1.2"
在 object 模块下定义两个子模块:
// src/object/mod.rs
mod handle;
mod rights;
pub use self::handle::*;
pub use self::rights::*;
定义权限:
// src/object/rights.rs
use bitflags::bitflags;
bitflags! {
/// 句柄权限
pub struct Rights: u32 {
const DUPLICATE = 1 << 0;
const TRANSFER = 1 << 1;
const READ = 1 << 2;
const WRITE = 1 << 3;
const EXECUTE = 1 << 4;
const MAP = 1 << 5;
const GET_PROPERTY = 1 << 6;
const SET_PROPERTY = 1 << 7;
const ENUMERATE = 1 << 8;
const DESTROY = 1 << 9;
const SET_POLICY = 1 << 10;
const GET_POLICY = 1 << 11;
const SIGNAL = 1 << 12;
const SIGNAL_PEER = 1 << 13;
const WAIT = 1 << 14;
const INSPECT = 1 << 15;
const MANAGE_JOB = 1 << 16;
const MANAGE_PROCESS = 1 << 17;
const MANAGE_THREAD = 1 << 18;
const APPLY_PROFILE = 1 << 19;
const SAME_RIGHTS = 1 << 31;
const BASIC = Self::TRANSFER.bits | Self::DUPLICATE.bits | Self::WAIT.bits | Self::INSPECT.bits;
const IO = Self::READ.bits | Self::WRITE.bits;
const DEFAULT_CHANNEL = Self::BASIC.bits & !Self::DUPLICATE.bits | Self::IO.bits | Self::SIGNAL.bits | Self::SIGNAL_PEER.bits;
/// GET_PROPERTY | SET_PROPERTY
const PROPERTY = Self::GET_PROPERTY.bits | Self::SET_PROPERTY.bits;
/// BASIC | IO | PROPERTY | ENUMERATE | DESTROY | SIGNAL | MANAGE_PROCESS | MANAGE_THREAD
const DEFAULT_PROCESS = Self::BASIC.bits | Self::IO.bits | Self::PROPERTY.bits | Self::ENUMERATE.bits | Self::DESTROY.bits | Self::SIGNAL.bits | Self::MANAGE_PROCESS.bits | Self::MANAGE_THREAD.bits;
}
}
定义句柄:
// src/object/handle.rs
use super::{DummyObject, KernelObject, Rights};
use alloc::sync::Arc;
/// 内核对象句柄
#[derive(Clone)]
pub struct Handle {
pub object: Arc<dyn KernelObject>,
pub rights: Rights,
}
impl Handle {
/// 创建一个新句柄
pub fn new(object: Arc<dyn KernelObject>, rights: Rights) -> Self {
Handle { object, rights }
}
}
存储内核对象句柄
添加成员变量 handles: BTreeMap<HandleValue, Handle>
实现 create,add_handle,remove_handle 函数
使用上一节的方法,实现一个空的 Process 对象:
// src/task/process.rs
/// 进程对象
pub struct Process {
base: KObjectBase,
inner: Mutex<ProcessInner>,
}
impl_kobject!(Process);
struct ProcessInner {
handles: BTreeMap<HandleValue, Handle>,
}
pub type HandleValue = u32;
impl Process {
/// 创建一个新的进程对象
pub fn new() -> Arc<Self> {
Arc::new(Process {
base: KObjectBase::default(),
inner: Mutex::new(ProcessInner {
handles: BTreeMap::default(),
}),
})
}
}
插入、删除句柄函数:
// src/task/process.rs
impl Process {
/// 添加一个新的对象句柄
pub fn add_handle(&self, handle: Handle) -> HandleValue {
let mut inner = self.inner.lock();
let value = (0 as HandleValue..)
.find(|idx| !inner.handles.contains_key(idx))
.unwrap();
inner.handles.insert(value, handle);
value
}
/// 删除一个对象句柄
pub fn remove_handle(&self, handle_value: HandleValue) {
self.inner.lock().handles.remove(&handle_value);
}
}
定义内核错误及 Result 类型
// src/error.rs
/// Zircon statuses are signed 32 bit integers. The space of values is
/// divided as follows:
/// - The zero value is for the OK status.
/// - Negative values are defined by the system, in this file.
/// - Positive values are reserved for protocol-specific error values,
/// and will never be defined by the system.
#[allow(non_camel_case_types, dead_code)]
#[repr(i32)]
#[derive(Debug, Clone, Copy)]
pub enum ZxError {
OK = 0,
// ======= Internal failures =======
/// The system encountered an otherwise unspecified error
/// while performing the operation.
INTERNAL = -1,
/// The operation is not implemented, supported,
/// or enabled.
NOT_SUPPORTED = -2,
// ......
/// Connection was aborted.
CONNECTION_ABORTED = -76,
}
// src/error.rs
///
pub type ZxResult<T> = Result<T, ZxError>;
根据句柄查找内核对象
实现 get_object_with_rights 等其它相关函数
实现 handle 单元测试
// src/task/process.rs
impl Process {
/// 根据句柄值查找内核对象,并检查权限
pub fn get_object_with_rights<T: KernelObject>(
&self,
handle_value: HandleValue,
desired_rights: Rights,
) -> ZxResult<Arc<T>> {
let handle = self
.inner
.lock()
.handles
.get(&handle_value)
.ok_or(ZxError::BAD_HANDLE)?
.clone();
// check type before rights
let object = handle
.object
.downcast_arc::<T>()
.map_err(|_| ZxError::WRONG_TYPE)?;
if !handle.rights.contains(desired_rights) {
return Err(ZxError::ACCESS_DENIED);
}
Ok(object)
}
}
对象传送器:Channel 对象
概要
通道(Channel)是由一定数量的字节数据和一定数量的句柄组成的双向消息传输。
描述
通道有两个端点(endpoints)。从逻辑上讲,每个端点都维护要读取的有序消息队列。写入一个端点会将消息排入另一个端点的队列中。当端点的最后一个句柄关闭时,该端点队列中的未读消息将被销毁。因为销毁消息会关闭消息包含的所有句柄,关闭通道端点可能会产生递归效果(例如,通道包含一条消息,它包含一个通道,它包含一条消息,等等)。
关闭通道的最后一个句柄对先前写入该通道的消息的生命周期没有影响。这为通道提供了“即发即忘”的语义。
一条消息由一定数量的数据和一定数量的句柄组成。调用channel_write()使一条消息入队,调用channel_read() 使一条消息出列(如果有队列)。线程可以阻塞,直到消息通过object_wait_one()或其他等待机制挂起。
或者,调用channel_call()在通道的一个方向上将消息入队,等待相应的响应,然后将响应消息出队。在调用模式(call mode)下,相应的响应通过消息的前 4 个字节标识,称为事务 ID(transaction ID)。内核使用channel_call(),为消息提供唯一的事务 ID.
通过通道发送消息的过程有两个步骤。第一步是原子地将数据写入通道并将消息中所有句柄的所有权移到此通道中。此操作始终消耗句柄:在调用结束时,所有句柄要么全部在通道中,要么全部丢弃。第二步操作,通道读取(channel read),与第一步类似:成功后,下一条消息中的所有句柄都被原子地移动到接收进程的句柄表中。失败时,通道将保留所有权,然后它们将被删除。
与许多其他内核对象类型不同,通道是不可复制的。因此,只有一个句柄与通道端点相关联,持有该句柄的进程被视为所有者(owner)。只有所有者可以读取或写入消息或将通道端点发送到另一个进程。
当通道端点的所有权从一个进程转移到另一个进程时,即使消息正在进行写入,消息也不会被重新排序或截断。转移事件之前的消息属于以前的所有者,转移之后的消息属于新的所有者。如果在传输端点时,正在进行消息读取,则之前描述的所有权转移方式同样适用。
即使最后剩余的句柄被剥夺了DUPLICATE权限,也不为其他内核对象提供上述顺序保证。
创建一对内核对象
创建通道
创建 Channel 将返回两个句柄,一个指向对象的每个端点。
实现 Channel::create
讲一下互相持有对方 Weak 指针的目的,这里有不可避免的 unsafe
实现数据传输
当句柄被写入通道时,它们会从发送进程中删除。当从通道读取带有句柄的消息时,句柄被添加到接收进程中。在这两个事件之间,句柄继续存在(确保它们所指的对象继续存在),除非它们写入的通道的末端关闭——此时发送到该端点的消息被丢弃并且它们包含的任何句柄都已关闭。
实现 read, write 函数,read_write 单元测试
任务管理
本章我们来实现第一类内核对象:任务管理(Tasks)。
任务对象主要包括:线程 Thread,进程 Process,作业 Job。以及一些辅助性的对象,例如负责暂停任务执行的 SuspendToken 和负责处理异常的 Exception。
为了能够真实表现线程对象的行为,我们使用 Rust async 运行时 async_std 中的用户态协程来模拟内核线程。
这样就可以在用户态的单元测试中检验实现的正确性。
考虑到未来这个 OS 会跑在裸机环境中,将会有不同的内核线程的实现,我们创建一个特殊的硬件抽象层(Hardware Abstraction Layer,HAL),来屏蔽底层平台的差异,对上提供一个统一的接口。
这个 HAL 的接口未来会根据需要进行扩充。
本章中我们只会实现运行一个程序所必需的最小功能子集,剩下的部分则留到跑起用户程序之后再按需实现。
Zircon 任务管理体系
线程(Thread)表示包含进程(Proess)所拥有的地址空间中的多个执行控制流(CPU寄存器,堆栈等)。进程属于作业(Job),作业定义了各种资源限制。作业一直由父级作业(parent Jobs)拥有,一直到根作业(Root Job)为止,根作业是内核在启动时创建并传递给userboot(第一个开始执行的用户进程)。
如果没有作业句柄(Job Handle),则进程中的线程无法创建另一个进程或另一个作业。
程序加载由内核层以上的用户空间工具和协议提供。
一些相关的系统调用:
zx_process_create(), zx_process_start(), zx_thread_create(), zx_thread_start()
进程管理:Process 与 Job 对象
介绍 Process 与 Job 的整体设计
实现 Process 和 Job 对象的基本框架,支持树状结构
作业Job
概要
作业是一组进程,可能还包括其他(子)作业。作业用于跟踪执行内核操作的特权(即使用各种选项进行各种syscall),以及跟踪和限制基本资源(例如内存,CPU)的消耗。每个进程都属于一个作业。作业也可以嵌套,并且除根作业外的每个作业都属于一个(父)作业。
描述
作业是包含以下内容的对象:
- 对父作业的引用
- 一组子作业(每个子作业的父作业既是这个作业)
- 一组成员进程
- 一套策略(Policy)
由多个进程组成的“应用程序”可作为单个实体,被作业基于一套策略进行控制。
作业策略Job Policy
策略policy 可在Kernel运行时动态修改系统的各种配置(setting)。作业策略主要涉及作业安全性和资源使用的条件(Condition)限制。
策略的行为PolicyAction
策略的行为包括:
- Allow 允许条件
- Deny 拒绝条件
- AllowException 通过 debugt port 生成异常,异常处理完毕后可恢复执行且运行条件
- DenyException 通过 debugt port 生成异常,异常处理完毕后可恢复执行
- Kill 杀死进程
应用策略时的条件 PolicyCondition
应用策略时的条件包括:
- BadHandle: 此作业下的某个进程正在尝试发出带有无效句柄的syscall。在这种情况下,
PolicyAction::Allow并且PolicyAction::Deny是等效的:如果syscall返回,它将始终返回错误ZX_ERR_BAD_HANDLE。 - WrongObject:此作业下的某个进程正在尝试发出带有不支持该操作的句柄的syscall。
- VmarWx:此作业下的进程正在尝试映射具有写执行访问权限的地址区域。
- NewAny:代表上述所有ZX_NEW条件的特殊条件,例如NEW_VMO,NEW_CHANNEL,NEW_EVENT,NEW_EVENTPAIR,NEW_PORT,NEW_SOCKET,NEW_FIFO和任何将来的ZX_NEW策略。这将包括不需要父对象来创建的所有新内核对象。
- NewVMO:此作业下的某个进程正在尝试创建新的vm对象。
- NewChannel:此作业下的某个进程正在尝试创建新通道。
- NewEvent:此作业下的一个进程正在尝试创建一个新事件。
- NewEventPair:此作业下的某个进程正在尝试创建新的事件对。
- NewPort:此作业下的进程正在尝试创建新端口。
- NewSocket:此作业下的进程正在尝试创建新的套接字。
- NewFIFO:此工作下的一个进程正在尝试创建一个新的FIFO。
- NewTimer:此作业下的某个进程正在尝试创建新的计时器。
- NewProcess:此作业下的进程正在尝试创建新进程。
- NewProfile:此作业下的一个进程正在尝试创建新的配置文件。
- AmbientMarkVMOExec:此作业下的某个进程正在尝试使用带有ZX_HANDLE_INVALID的zx_vmo_replace_as_executable()作为第二个参数,而不是有效的ZX_RSRC_KIND_VMEX。
进程Process
进程是传统意义上程序的一个运行实例,包含一组指令和数据,这些指令将由一个或多个线程执行,并拥有一组资源。在具体实现上,进程包括如下内容:
- Handles :大部分是进程用到的资源对象的句柄
- Virtual Memory Address Regions:进程所在的内存地址空间
- Threads:进程包含的线程组
进程包含在作业(Job)的管理范畴之中。从资源和权限限制以及生命周期控制的角度来看,允许将由多个进程组成的应用程序视为一个实体(即作业)。
生命周期(lifetime)
进程有自己的生命周期,从开始创建到直到被强制终止或程序退出为止。可通过调用Process::create()创建一个进程,并调用Process::start()开始执行 。该进程在以下情况下停止执行:
- 最后一个线程终止或退出
- 进程调用
Process::exit() - 父作业(parent job)终止了该过程
- 父作业(parent job)被销毁(destroied)
注:Process::start()不能被调用两次。新线程不能被添加到已启动的进程。
线程管理:Thread 对象
线程对象是代表分时CPU的执行上下文的一种结构。线程对象与特定的进程对象相关联,该进程对象为线程对象执行中涉及的I/O和计算提供提供必要的内存和其他对象的句柄。
生命期
线程是通过调用Thread::create()创建的,但只有在调用Thread::create()或Process::start()时才开始执行。这两个系统调用的参数都是要执行的初始例程的入口。
传递给Process::start()的线程应该是在一个进程上开始执行的第一个线程。
下列情况都可导致一个线程终止执行:
- 通过调用
CurrentThread::exit() - 当父进程终止时
- 通过调用
Task::kill() - 在生成没有处理程序或处理程序决定终止线程的异常之后。
从入口例程返回并不终止执行。入口点的最后一个动作应该是调用CurrentThread::exit()。
关闭一个线程的最后一个句柄并不终止执行。为了强行杀死一个没有可用句柄的线程,可以使用KernelObject::get_child()来获得该线程的句柄。但这种方法是非常不可取的。杀死一个正在执行的线程可能会使进程处于损坏的状态。
本地线程总是分离的(detached)。也就是说,不需要join()操作来做一个干净的终止(clean termination)。但一些内核之上的运行系统,如C11或POSIX可能需要线程被连接(be joined)。
信号
线程提供以下信号:
- THREAD_TERMINATED
- THREAD_SUSPENDED
- THREAD_RUNNING
当一个线程启动执行时,THREAD_RUNNING被设定。当它被暂停时,THREAD_RUNNING被取消,THREAD_SUSPENDED被设定。当线程恢复时,THREAD_SUSPENDED被取消,THREAD_RUNNING被设定。当线程终止时,THREAD_RUNNING和THREAD_SUSPENDED都被置位,THREAD_TERMINATED也被置位。
注意,信号经过“或”运算后进入KernelObject::wait_signal()函数系列所保持的状态 ,因此当它们返回时,你可能会看到所要求的信号的任何组合。
线程状态(ThreadState)
状态转移:创建 -> 运行 -> 暂停 -> 退出,最好有个状态机的图
实现 ThreadState,最好能加一个单元测试来验证转移过程
#![allow(unused)] fn main() { pub enum ThreadState { New, \\该线程已经创建,但还没有开始运行 Running, \\该线程正在正常运行用户代码 Suspended, \\由于zx_task_suspend()而暂停 Blocked, \\在一个系统调用中或处理一个异常而阻塞 Dying, \\线程正在被终止的过程中,但还没有停止运行 Dead, \\该线程已停止运行 BlockedException, \\该线程在一个异常中被阻塞 BlockedSleeping, \\该线程在zx_nanosleep()中被阻塞 BlockedFutex, \\该线程在zx_futex_wait()中被阻塞 BlockedPort, \\该线程在zx_port_wait()中被被阻塞 BlockedChannel, \\该线程在zx_channel_call()中被阻塞 BlockedWaitOne, \\该线程在zx_object_wait_one()中被阻塞 BlockedWaitMany, \\该线程在zx_object_wait_many()中被阻塞 BlockedInterrupt, \\该线程在zx_interrupt_wait()中被阻塞 BlockedPager, \\被Pager阻塞 (目前没用到???) } }
线程寄存器上下文
定义 ThreadState,实现 read_state,write_state
Async 运行时和 HAL 硬件抽象层
简单介绍 async-std 的异步机制
介绍 HAL 的实现方法:弱链接
实现 hal_thread_spawn
线程启动
将 HAL 接入 Thread::start,编写单元测试验证能启动多线程
内存管理
Zircon 内存管理模型
物理内存:VMO 对象
VMO 简介
根据文档梳理 VMO 的主要特性
虚拟拟内存对象(Virtual Memory Objects, VMO)代表一组物理内存页面,或 潜在的页面(将根据需要延迟创建/填充)。
它们可以通过 zx_vmar_map()被映射到一个进程(Process)的地址空间,也可通过 zx_vmar_unmap()来解除映射。可以使用zx_vmar_protect()来调整映射页面的权限。
也可以直接使用zx_vmo_read()来读取VMO和通过使用 zx_vmo_write()来写入 VMO。因此,通过诸如“创建 VMO,将数据集写入其中,然后将其交给另一个进程使用”等一次性(one-shot )操作,可以避免将它们映射到地址空间的开销。
实现 VMO 对象框架
实现 VmObject 结构,其中定义 VmObjectTrait 接口,并提供三个具体实现 Paged, Physical, Slice
HAL:用文件模拟物理内存
初步介绍 mmap,引出用文件模拟物理内存的思想
创建文件并用 mmap 线性映射到进程地址空间
实现 pmem_read, pmem_write
实现物理内存 VMO
用 HAL 实现 VmObjectPhysical 的方法,并做单元测试
实现切片 VMO
实现 VmObjectSlice,并做单元测试
物理内存:按页分配的 VMO
简介
说明一下:Zircon 的官方实现中为了高效支持写时复制,使用了复杂精巧的树状数据结构,但它同时也引入了复杂性和各种 Bug。 我们在这里只实现一个简单版本,完整实现留给读者自行探索。
介绍 commit 操作的意义和作用
HAL:物理内存管理
在 HAL 中实现 PhysFrame 和最简单的分配器
辅助结构:BlockRange 迭代器
实现 BlockRange
实现按页分配的 VMO
实现 for_each_page, commit, read, write 函数
VMO 复制
实现 create_child 函数
虚拟内存:VMAR 对象
VMAR 简介
虚拟内存地址区域(Virtual Memory Address Regions ,VMARs)为管理进程的地址空间提供了一种抽象。在进程创建时,将Root VMAR 的句柄提供给进程创建者。该句柄指的是跨越整个地址空间的 VMAR。这个空间可以通过zx_vmar_map()和 zx_vmar_allocate()接口来划分 。 zx_vmar_allocate()可用于生成新的 VMAR(称为子区域或子区域),可用于将地址空间的各个部分组合在一起。
实现 VMAR 对象框架
定义 VmAddressRange,VmMapping
实现 create_child, map, unmap, destroy 函数,并做单元测试验证地址空间分配
HAL:用 mmap 模拟页表
实现页表接口 map, unmap, protect
实现内存映射
用 HAL 实现上面 VMAR 留空的部分,并做单元测试验证内存映射
用户程序
Zircon 用户程序
用户态启动流程
kernel -> userboot -> bootsvc -> component_manager -> sh / device_manager
ZBI 与 bootfs:ZBI 中包含初始文件系统 bootfs,内核将 ZBI 完整传递给 userboot,由它负责解析并对其它进程提供文件服务
用户程序的组成
内核不直接参与用户程序的加载工作(第一个进程除外)
用户程序强制使用 PIC 和 PIE(位置无关代码)
内存地址空间组成:Program, Stack, vDSO, Dylibs
通过 Channel 传递启动信息和句柄
加载 ELF 文件
简单介绍 ELF 文件的组成结构
实现 VmarExt::load_from_elf 函数
系统调用的跳板:vDSO
介绍 vDSO 的作用
如何修改 vDSO 源码(libzircon)将 syscall 改为函数调用
加载 vDSO 时修改 vDSO 代码段,填入跳转地址
第一个用户程序:userboot
实现 zircon-loader 中的 run_userboot 函数
能够进入用户态并在第一个系统调用时跳转回来
上下文切换
本节介绍 trapframe-rs 中 fncall.rs 的魔法实现
保存和恢复通用寄存器
定义 UserContext 结构体
保存 callee-saved 寄存器到栈上,恢复 UserContext 寄存器,进入用户态,反之亦然
找回内核上下文:线程局部存储 与 FS 寄存器
在用户程序跳转回内核代码的那一刻,如何在不破坏用户寄存器的情况下切换回内核栈?
进入用户态前,将内核栈指针保存在内核 glibc 的 TLS 区域中。为此我们需要查看 glibc 源码,找到一个空闲位置。
Linux 和 macOS 下如何分别通过系统调用设置 fsbase / gsbase
测试
编写单元测试验证上述过程
macOS 的麻烦:动态二进制修改
由于 macOS 用户程序无法修改 fs 寄存器,当运行相关指令时会访问非法内存地址触发段错误。
我们需要实现段错误信号处理函数,并在其中动态修改用户程序指令,将 fs 改为 gs。
系统调用
获取系统调用参数
从寄存器中获取参数
系统调用上下文与处理函数
定义 Syscall 结构体,实现 syscall 函数
实现第一个系统调用
实现 sys_channel_read 和 sys_debuglog_write
信号和等待
信号
对象可能有多达 32 个信号(由 zx_signals t 类型和 ZX SIGNAL 定义表示),它们表示有关其当前状态的一条信息。例如,通道和套接字可能是 READABLE 或 WRITABLE 的。进程或线程可能会被终止。等等。
线程可以等待信号在一个或多个对象上变为活动状态。
等待
线程可用于zx_object_wait_one() 等待单个句柄上的信号处于活动状态或 zx_object_wait_many()等待多个句柄上的信号。两个调用都允许超时,即使没有信号挂起,它们也会返回。
超时可能会偏离指定的截止时间,具体取决于计时器的余量。
如果线程要等待大量句柄,使用端口(Port)会更有效,它是一个对象,其他对象可能会绑定到这样的对象,当信号在它们上被断言时,端口会收到一个包含信息的数据包关于未决信号。
事件与事件对
事件(Event)是最简单的对象,除了它的活动信号集合之外没有其他状态。
事件对(Event Pair)是可以相互发出信号的一对事件中的一个。事件对的一个有用属性是,当一对的一侧消失时(它的所有句柄都已关闭),PEER_CLOSED 信号在另一侧被断言。
见:zx_event_create(), 和zx_eventpair_create()。
等待内核对象的信号
信号与等待机制简介
在内核对象中加入信号
定义 Signal 结构体
在 KObjectBase 中加入 signal 和 callbacks 变量,实现 signal 系列函数,并做单元测试
实现信号等待 Future
实现 wait_signal 函数,并做单元测试
利用 select 组合子实现多对象等待
实现 wait_signal_many 函数,并做单元测试
同时等待多个信号:Port 对象
Port 对象简介
同时提及一下 Linux 的 epoll 机制作为对比
实现 Port 对象框架
定义 Port 和 PortPacket 结构体
实现事件推送和等待
实现 KernelObject::send_signal_to_port 和 Port::wait 函数,并做单元测试
实现更多:EventPair, Timer 对象
Event 对象
EventPair 对象
HAL:定时器
实现 timer_now, timer_set,在此基础上实现 SleepFuture
Timer 对象
用户态同步互斥:Futex 对象
Futex 机制简介
Futex 是现代 OS 中用户态同步互斥的唯一底层设施
为什么快:利用共享内存中的原子变量,避免进入内核
Futexes 是内核原语,与用户空间原子操作一起使用以实现高效的同步原语(如Mutexes, Condition Variables等),它只需要在竞争情况(contended case)下才进行系统调用。通常它们实现在标准库中。
实现基础元语:wait 和 wake
实现 wait 和 wake 函数,并做单元测试
实现高级操作
实现 Zircon 中定义的复杂 API
硬件抽象层
zCore 的用户态运行支持
libos 版 zCore(简称uzCore) 的开发与裸机版 zCore (简称bzCore)同步进行,两个版本的 zCore 共用除了HAL 层之外的所有代码。为了支持 uzCore 的正常运行,zCore 在地址空间划分方面对 Zircon /Linux的原有设计进行了一定的修改,并为此对 Fuchsia 的源码进行了简单的修改、重新编译;另外,uzCore 需要的硬件相关层(HAL)将完全由宿主 OS 提供支持,一个合理的 HAL 层接口划分也是为支持 uzCore做出的重要考虑。
HAL 层接口设计
HAL 层的设计是在bzCore 和 uzCore 的开发过程中逐渐演进形成的,在开发过程中将硬件实现相关的接口,比如页表、物理内存分配等进行封装,暴露给上层的内核对象层使用。在 kernel-hal 模块中,给出空的弱链接实现,由 bzCore 或 uzCore 的开发者对相应的接口进行相应的实现,并用设定函数链接名称的方式,替换掉预设的弱链接的空函数。在整个开发过程中,不断对 HAL 层提出需求并实现,目前形成了第一版 HAL 层接口,在设计上能够满足现有的内核对象实现所需要的功能。
对内核对象层而言,所依赖的硬件环境不再是真实硬件环境中能够看到的物理内存、CPU、MMU 等,而是 HAL 暴露给上层的一整套接口。这一点从设计上来说,是 zCore 与 Zircon 存在差异的一点。Zircon 将 x86_64 、ARM64 的硬件架构进行底层封装,但是没有给出一套统一的硬件 API 供上层的内核对象直接使用,在部分内核对象的实现中,仍然需要通过宏等手段对代码进行条件编译,从而支持同时面向两套硬件架构进行开发。而在 zCore 的内核对象层实现中,可以完全不考虑底层硬件接口的实现,使一套内核对象的模块代码可以同时在 bzCore和 uzCore 上运行,之后如果 zCore 进一步支持 RISC-V 64 架构(已初步实现),只需要新增一套 HAL的实现,无需修改上层代码。下面将列出目前的uzCore的HAL层,即kernel-hal-unix的接口。
HAL接口名称 功能描述
- 线程相关
- hal_thread_spawn Thread::spawn创建一个新线程并加入调度
- hal_thread_set_tid Thread::set_tid 设定当前线程的 id
- hal_thread_get_tid Thread::get_tid 获取当前线程的 id
- future
- yield_now暂时让出 CPU,回到async runtime中
- sleep_until 休眠直到定时到达
- YieldFuture 放弃执行的future
- SleepFuture 睡眠且等待被唤醒的future
- SerialFuture 通过serial_read获得字符的future
- 上下文切换相关
- VectorRegs x86相关
- hal_context_run context_run 进入“用户态”运行
- 用户指针相关
- UserPtr 对用户指针的操作:读/写/解引用/访问数组/访问字符串
- IoVec 非连续buffer集合(Vec结构):读/写
- 页表相关
- hal_pt_currentPageTable::current 获取当前页表
- hal_pt_newPageTable::new 新建一个页表
- hal_pt_map PageTable::map 将一个物理页帧映射到一个虚拟地址中
- hal_pt_unmap PageTable::unmap 解映射某个虚拟地址
- hal_pt_protect PageTable::protect 修改vaddr对应的页表项的flags
- hal_pt_query PageTable::query 查询某个虚拟地址对应的页表项状态
- hal_pt_table_phys PageTable::table_phys 获取对应页表的根目录表物理地址
- hal_pt_activate PageTable::activate 激活当前页表
- PageTable::map_many 同时映射多个物理页帧到连续虚拟内存空间
- PageTable::map_cont 同时映射连续的多个物理页帧到虚拟内存空间
- hal_pt_unmap_cont PageTable::unmap_cont 解映射某个虚拟地址开始的一片范围
- MMUFlags 页表项的属性位
- 物理页帧相关
- hal_frame_alloc PhysFrame::alloc 分配一个物理页帧
- hal_frame_alloc_contiguous PhysFrame::alloc_contiguous_base 分配一块连续的物理内存
- PhysFrame::addr 返回物理页帧对应的物理地址
- PhysFrame::alloc_contiguous 分配一块连续的物理内存
- PhysFrame::zero_frame_addr 返回零页的物理地址(一个特殊页,内容永远为全0)
- PhysFrame::drop Drop trait 回收该物理页帧
- hal_pmem_read pmem_read 读取某特定物理页帧的内容到缓冲区
- hal_pmem_write pmem_write 将缓冲区中的内容写入某特定物理页帧
- hal_frame_copy frame_copy 复制物理页帧的内容
- hal_frame_zero frame_zero_in_range 物理页帧清零
- hal_frame_flush frame_flush将物理页帧的数据从 Cache 刷回内存
- 基本I/O外设
- hal_serial_read serial_read 字符串输入
- hal_serial_write serial_write 字符串输出
- hal_timer_now timer_now 获取当前时间
- hal_timer_set timer_set 设置一个时钟,当到达deadline时,会调用 callback 函数
- hal_timer_set_next timer_set_next 设置下一个时钟
- hal_timer_tick timer_tick当时钟中断产生时会调用的时钟函数,触发所有已到时间的 callback
- 中断处理
- hal_irq_handle handle 中断处理例程
- hal_ioapic_set_handle set_ioapic_handle x86相关,对高级中断控制器设置处理例程
- hal_irq_add_handle add_handle 对某中断添加中断处理例程
- hal_ioapic_reset_handle reset_ioapic_handle 重置级中断控制器并设置处理例程
- hal_irq_remove_handle remove_handle 移除某中断的中断处理例程
- hal_irq_allocate_block allocate_block 给某中断分配连续区域
- hal_irq_free_block free_block 给某中断释放连续区域
- hal_irq_overwrite_handler overwrite_handler 覆盖某中断的中断处理例程
- hal_irq_enable enable 使能某中断
- hal_irq_disable disable 屏蔽某中断
- hal_irq_maxinstr maxinstr x86相关,获得IOAPIC的maxinstr???
- hal_irq_configure configure 对某中断进行配置???
- hal_irq_isvalid is_valid 查询某中断是否有效
- 硬件平台相关
- hal_vdso_constants vdso_constants 得到平台相关常量参数
- struct VdsoConstants 平台相关常量:
- hal_vdso_constants vdso_constants 得到平台相关常量参数
max_num_cpus features dcache_line_size ticks_per_second ticks_to_mono_numerator ticks_to_mono_denominator physmem version_string_len version_string
* fetch_fault_vaddr fetch_fault_vaddr 取得出错的地址 ???好像缺了hal_*
* fetch_trap_num fetch_trap_num 取得中断号
* hal_pc_firmware_tables pc_firmware_tables x86相关,取得`acpi_rsdp` 和 `smbios` 的物理地址
* hal_acpi_table get_acpi_table 得到acpi table
* hal_outpd outpd x86相关,对IO Port进行写访问
* hal_inpd inpd x86相关,对IO Port进行读访问
* hal_apic_local_id apic_local_id 得到本地(local) APIC ID
* fill_random 产生随机数,并写入到buffer中
在上述“线程相关”的列表中,列出了 HAL 层的部分接口设计,覆盖线程调度方面。在线程调度方面,Thread 结构体相关的接口主要用于将一个线程加入调度等基本操作。在 zCore 的相关实现中,线程调度的各接口使用 naive-executor 给出的接口以及 trapframe 给出的接口来进行实现,二者都是我们为裸机环境的协程调度与上下文切换所封装的 Rust 库。uzCore 中,线程调度的相关接口依赖于 Rust 的用户态协程支持以及 uzCore 开发者实现的用户态上下文切换。
在内存管理方面,HAL 层将内存管理分为页表操作与物理页帧管理两方面,并以此设计接口。在 zCore 实现中,物理页帧的分配与回收由于需要设计物理页帧分配器,且可分配范围大小与内核刚启动时的内存探测密切相关,我们将其直接在总控模块 zCore 中进行实现。而在 uzCore 中,页表对应操作依赖 mmap 进行模拟,物理页帧的相关操作则直接使用用户态物理内存分配器进行模拟。
在 Zircon 的设计中,内存的初始状态应该设置为全 0,为了在内核对象层满足该要求,我们为 HAL 层设计了零页接口,要求 HAL 层保留一个内容为全 0 的物理页帧,供上层使用。上层负责保证该零页内容不被修改。
修改 VDSO
VDSO 是由内核提供、并只读映射到用户态的动态链接库,以函数接口形式提供系统调用接口。原始的 VDSO 中将会最终使用 syscall 指令从用户态进入内核态。但在 uzCore 环境下,内核和用户程序都运行在用户态,因此需要将 syscall 指令修改为函数调用,也就是将 sysall 指令修改为 call 指令。为此我们修改了 VDSO 汇编代码,将其中的 syscall 替换为 call,提供给 uzCore 使用。在 uzCore 内核初始化环节中,向其中填入 call 指令要跳转的目标地址,重定向到内核中处理 syscall 的特定函数,从而实现模拟系统调用的效果。
调整地址空间范围
在 uzCore 中,使用 mmap 来模拟页表,所有进程共用一个 64 位地址空间。因此,从地址空间范围这一角度来说,运行在 uzCore 上的用户程序所在的用户进程地址空间无法像 Zircon 要求的一样大。对于这一点,我们在为每一个用户进程设置地址空间时,手动进行分配,规定每一个用户进程地址空间的大小为 0x100_0000_0000,从 0x2_0000_0000 开始依次排布。0x0 开始至 0x2_0000_0000 规定为 uzCore 内核所在地址空间,不用于 mmap。图 3.3给出了 uzCore 在运行时若干个用户进程的地址空间分布。
与 uzCore 兼容,zCore 对于用户进程的地址空间划分也遵循同样的设计,但在裸机环境下,一定程度上摆脱了限制,能够将不同用户地址空间分隔在不同的页表中。如图 3.4所示,zCore 中将三个用户进程的地址空间在不同的页表中映射,但是为了兼容 uzCore 的运行,每一个用户进程地址空间中用户程序能够真正访问到的部分都仅有 0x100_0000_0000 大小。
LibOS源代码分析记录
zCore on riscv64的LibOS支持
- LibOS unix模式的入口在linux-loader main.rs:main()
初始化包括kernel_hal_unix,Host文件系统,其中载入elf应用程序的过程与zcore bare模式一样;
重点工作应该在kernel_hal_unix中的内核态与用户态互相切换的处理。
kernel_hal_unix初始化主要包括了,构建Segmentation Fault时SIGSEGV信号的处理函数,当代码尝试使用fs寄存器时会触发信号;
- 为什么要注册这个信号处理函数呢?
根据wrj的说明:由于 macOS 用户程序无法修改 fs 寄存器,当运行相关指令时会访问非法内存地址触发Segmentation Fault。故实现段错误信号处理函数,并在其中动态修改用户程序指令,将 fs 改为 gs
kernel_hal_unix还构造了进入用户态所需的run_fncall() -> syscall_fn_return();
而用户程序需要调用syscall_fn_entry()来返回内核态;
Linux-x86_64平台运行时,用户态和内核态之间的切换运用了 fs base 寄存器;
- Linux 和 macOS 下如何分别通过系统调用设置 fsbase / gsbase 。
这个转换过程调用到了trapframe库,x86_64和aarch64有对应实现,而riscv则需要自己手动实现;
- 关于fs寄存器
查找了下,fs寄存器一般会用于寻址TLS,每个线程有它自己的fs base地址;
fs寄存器被glibc定义为存放tls信息,结构体tcbhead_t就是用来描述tls;
进入用户态前,将内核栈指针保存在内核 glibc 的 TLS 区域中。
可参考一个运行时程序的代码转换工具:https://github.com/DynamoRIO/dynamorio/issues/1568#issuecomment-239819506
- LibOS内核态与用户态的切换
Linux x86_64中,fs寄存器是用户态程序无法设置的,只能通过系统调用进行设置;
例如clone系统调用,通过arch_prctl来设置fs寄存器;指向的struct pthread,glibc中,其中的首个结构是tcbhead_t
计算tls结构体偏移:
经过试验,x86_64平台,int型:4节,指针类型:8节,无符号长整型:8节;
riscv64平台,int型: 4节,指针类型:8节,无符号长整型:8节;
计算tls偏移量时,注意下,在musl中,aarch64和riscv64架构有#define TLS_ABOVE_TP,而x86_64无此定义
- 关于Linux user mode (UML)
"No, UML works only on x86 and x86_64."
https://sourceforge.net/p/user-mode-linux/mailman/message/32782012/