[rCore学习笔记 017]实现批处理操作系统
写在前面
本随笔是非常菜的菜鸡写的。如有问题请及时提出。
可以联系:[email protected]
GitHhub:https://github.com/WindDevil (目前啥也没有
本章目的
实现批处理操作系统,每当一个应用程序执行完毕,都需要将下一个要执行的应用的代码和数据加载到内存.
- 应用加载机制
- 在操作系统和应用程序需要被放置到同一个可执行文件的前提下,设计一种尽量简洁的应用放置和加载方式,使得操作系统容易找到应用被放置到的位置,从而在批处理操作系统和应用程序之间建立起联系的纽带。
- 具体而言,应用放置采用“静态绑定”的方式,而操作系统加载应用则采用“动态加载”的方式
- 静态绑定:通过一定的编程技巧,把多个应用程序代码和批处理操作系统代码“绑定”在一起。
- 动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到每个应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。
- 硬件相关且比较困难的地方
- 如何让在内核态的批处理操作系统启动应用程序'
- 且能让应用程序在用户态正常执行
将应用程序链接到内核
目的是将应用程序的二进制文件(也就是上一章执行make build
生成的.bin
文件),链接到内核之中,此时内核需要知道:
- 内含应用程序的数量
- 内含应用程序的位置
知道以上信息就可以在运行时对它们进行管理并且可以加载到物理内存.
这里直接给出结论:
- 需要汇编代码
.S
文件,里边规定应用程序的 开始和结束位置 ,保存应用程序的 数量 和 应用程序开始位置指针 . - 需要写出生成这个
.S
文件的代码.
这个.S
文件就规定名字为link_app.S
,使用build.rs
模块来进行生成.
先看生成的~/App/rCore-Tutorial-v3/os/src/link_app.S
:
.align 3
.section .data
.global _num_app
_num_app:
.quad 5
.quad app_0_start
.quad app_1_start
.quad app_2_start
.quad app_3_start
.quad app_4_start
.quad app_4_end
.section .data
.global app_0_start
.global app_0_end
app_0_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/00hello_world.bin"
app_0_end:
.section .data
.global app_1_start
.global app_1_end
app_1_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/01store_fault.bin"
app_1_end:
.section .data
.global app_2_start
.global app_2_end
app_2_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/02power.bin"
app_2_end:
.section .data
.global app_3_start
.global app_3_end
app_3_start:
.incbin "../user/target/riscv64gc-unknown-none-elf/release/03priv_inst.bin"
app_3_end:
.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:
可以看到:
.align
是是一个伪指令,用于指定编译器对随后的变量或标签进行特定的内存对齐,这里.align 3
则代表对齐到2^3
或者8
字节的边界..quad
是一个伪指令,用于定义一个 64 位(8 字节)的常量或变量.quad 5
代表此时有5
个应用程序- 以
.quad app_0_start
为例,意味着记录app_0_start
的指针
.section
是一个伪指令,用于定义或切换到一个新的段,.section .data 特别指定了数据段,这是程序中用于存储已初始化的全局变量和静态变量的区域..global
是一个伪指令,用于声明一个符号(通常是函数或变量)为全局可见.这意味着这个符号不仅在其定义的文件内部可用,而且在整个链接过程中都是可见的,也就是说,其他文件也可以引用它..global app_1_start
说明app_1_start
是全局的,作为连接过程中被链接器识别的标号,标志第一个app的开始位置.global app_1_end
说明app_1_end
是全局的,作为连接过程中被链接器识别的标号,标志第一个app的结束位置
.incbin
是汇编语言中的一个伪指令,用于将二进制文件的原始内容直接嵌入到当前的汇编文件中- 以
.incbin "../user/target/riscv64gc-unknown-none-elf/release/04priv_csr.bin"
为例,将编译出的04priv_csr.bin
嵌入到当前的汇编文件之中
- 以
因此,我们如果要生成这个文件,需要在_num_app
标号后的段中储存app的数目和app的开启指针,并且为每个app生成包含开始标号和结束标号的段,并且在其中引入app的二进制文件.
这里是~/App/rCore-Tutorial-v3/os/build.rs
的内容:
use std::fs::{read_dir, File};
use std::io::{Result, Write};
fn main() {
println!("cargo:rerun-if-changed=../user/src/");
println!("cargo:rerun-if-changed={}", TARGET_PATH);
insert_app_data().unwrap();
}
static TARGET_PATH: &str = "../user/target/riscv64gc-unknown-none-elf/release/";
fn insert_app_data() -> Result<()> {
let mut f = File::create("src/link_app.S").unwrap();
let mut apps: Vec<_> = read_dir("../user/src/bin")
.unwrap()
.into_iter()
.map(|dir_entry| {
let mut name_with_ext = dir_entry.unwrap().file_name().into_string().unwrap();
name_with_ext.drain(name_with_ext.find('.').unwrap()..name_with_ext.len());
name_with_ext
})
.collect();
apps.sort();
writeln!(
f,
r#"
.align 3
.section .data
.global _num_app
_num_app:
.quad {}"#,
apps.len()
)?;
for i in 0..apps.len() {
writeln!(f, r#" .quad app_{}_start"#, i)?;
}
writeln!(f, r#" .quad app_{}_end"#, apps.len() - 1)?;
for (idx, app) in apps.iter().enumerate() {
println!("app_{}: {}", idx, app);
writeln!(
f,
r#"
.section .data
.global app_{0}_start
.global app_{0}_end
app_{0}_start:
.incbin "{2}{1}.bin"
app_{0}_end:"#,
idx, app, TARGET_PATH
)?;
}
Ok(())
}
这里的代码有一些需要注意的点:
r#
和#
允许创建一个不解析转义序列的字符串,而且允许字符串跨越多行.Vec
是向量,Vec<_>
是一个类型推断的占位符,Rust编译器会根据上下文自动推断出向量元素的类型.- 这里有一些复杂,
read_dir
读取之后为了处理两种情况:Ok()
和Error()
这里要注意到Rust的枚举类型的特质,即一个结果有某几种可以被列举出来的不同类型,而其中包裹着结果,使用unwarp()
来获取其中包裹的内容,如果正确执行,那么返回的是一个ReadDir
迭代器,再通过into_iter
转换成迭代器. map
接收一个闭包(也称为匿名函数),并将这个闭包应用于迭代器的每一个元素,产生一个新的迭代器,其元素是原元素经过闭包转换后的结果.因此在map
中,分别完成把读取的文件类转化成文件名,并且删除其拓展名.
这样我看就可以轻松读懂这段代码,它通过读取TARGET_PATH
这个常量文件夹下的无后缀文件名,并且将其设置成asm
文件中的label
的名字,从而最后实现生成了link_app.S
文件.
但是这时候很容易产生一个疑问,就是关于为什么在进行make
的时候会先执行这个模块?
这时候尝试去看Makefile
,发现其中似乎没有提到关于build.rs
的内容,考虑到这个模块的名字被命名为build
也许是一个特殊设置,通过查询Cargo
的执行流程.得知,Cargo会按照以下步骤处理build.rs
:
- 检查依赖关系:Cargo首先检查项目的依赖树,确保所有依赖项都是最新的,并且已经下载了所有必要的源代码和预编译的二进制文件。
- 执行
build.rs
:如果项目根目录下存在build.rs
文件,Cargo会在构建任何其他目标之前先运行它。build.rs
应该包含有效的Rust代码,通常用于生成或修改将在实际构建过程中使用的源代码或元数据。Cargo会编译并运行build.rs
中的代码,这可能包括创建额外的源文件、生成绑定到外部库的代码、修改Cargo.toml
文件等。 - 构建目标:一旦
build.rs
执行完成,Cargo将继续正常的构建过程,编译项目中的Rust源代码,链接库和可执行文件,最终产生一个或多个输出文件,如.rlib
库文件或.exe
可执行文件。
这样我们就知道原理了.
找到并加载应用程序二进制码
实现一个能够 找到 并且 加载 应用程序二进制码的应用管理器AppManager
,通过在os
中实现一个batch
子模块获得:
- 保存应用数量和各自的位置信息,以及当前执行到第几个应用了。
- 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行。
看了这个功能感觉头上浮现出一个汗珠,感觉这个在功能上仍然和直接顺序执行裸机代码是一样的,但是可能后续引入分时操作和文件系统之后,这种可以寻找和加载App
的模式才是正道.
定义一个应用管理器的结构体AppManager
:
// os/src/batch.rs
struct AppManager {
num_app: usize,
current_app: usize,
app_start: [usize; MAX_APP_NUM + 1],
}
这里可以看到,这个结构体是一个保存app
的数量和当前的app
以及每个app
的开头指针位置的结构体,通过查看官方文档,可以看出在设计时,打算实例化一个AppManager
作为全局变量,从而可以然任何函数来直接访问,也就是一个开放的单例.这时候就要考虑Rust的变量的生命周期问题,也就是所有权问题.
这里存在一个问题,就是如果使用了static
修饰实例化的AppManager
,就会导致AppManager.current_app
是不可变的,但是使用static mut
来修饰实例化的AppManager
,则会导致对它的操作是unsafe
的.因此需要使用更好的方法来解决问题.
发现关于 Rust所有权模型和借用检查 的问题,发现在官方文档中有比Rust语言圣经(Rust Course)中更好的解释,更详细的部分一定要去看官方文档:
我们这里简单介绍一下 Rust 的所有权模型。它可以用一句话来概括: 值 (Value)在同一时间只能被绑定到一个 变量 (Variable)上。这里,“值”指的是储存在内存中固定位置,且格式属于某种特定类型的数据;而变量就是我们在 Rust 代码中通过 let
声明的局部变量或者函数的参数等,变量的类型与值的类型相匹配。在这种情况下,我们称值的 所有权 (Ownership)属于它被绑定到的变量,且变量可以作为访问/控制绑定到它上面的值的一个媒介。变量可以将它拥有的值的所有权转移给其他变量,或者当变量退出其作用域之后,它拥有的值也会被销毁,这意味着值占用的内存或其他资源会被回收。
对于Rust中的内部可变性涉及到了对于 可变借用 的 运行时借用检查 ,在官方文档也有表述:
相对的,对值的借用方式运行时可变的情况下,我们可以使用 Rust 内置的数据结构将借用检查推迟到运行时,这可以称为运行时借用检查,它的约束条件和编译期借用检查一致。当我们想要发起借用或终止借用时,只需调用对应数据结构提供的接口即可。值的借用状态会占用一部分额外内存,运行时还会有额外的代码对借用合法性进行检查,这是为满足借用方式的灵活性产生的必要开销。当无法通过借用检查时,将会产生一个不可恢复错误,导致程序打印错误信息并立即退出。具体来说,我们通常使用 RefCell
包裹可被借用的值,随后调用 borrow
和 borrow_mut
便可发起借用并获得一个对值的不可变/可变借用的标志,它们可以像引用一样使用。为了终止借用,我们只需手动销毁这些标志或者等待它们被自动销毁。 RefCell
的详细用法请参考 2 。
因此可以使用RefCell
来实现Rust的 内部可变性 , 也即在变量自身不可变或仅在不可变借用的情况下仍能修改绑定到变量上的值.
那么当前能不能声明一个全局变量的RefCell
呢?在workspace/homework
下创建ref_cell
工程:
cd ~/workspace/homework
cargo new ref_cell
在main.rs
中键入如下代码:
use std::cell::RefCell;
static A: RefCell<i32> = RefCell::new(3);
fn main() {
*A.borrow_mut() = 4;
println!("{}", A.borrow());
}
运行这个工程:
cd ~/workspace/homework/ref_cell
cargo run
出现报错:
error[E0277]: `RefCell<i32>` cannot be shared between threads safely
--> src/main.rs:2:11
|
2 | static A: RefCell<i32> = RefCell::new(3);
| ^^^^^^^^^^^^ `RefCell<i32>` cannot be shared between threads safely
|
= help: the trait `Sync` is not implemented for `RefCell<i32>`
= note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` instead
= note: shared static variables must have a type that implements `Sync`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `ref_cell` (bin "ref_cell") due to 1 previous error
根据提示,我们可以看出RefCell
不能在线程间共享,需要为它实现一个Sync
特性才可以.
这里官方文档提到了我们单纯循规蹈矩的时候不会想到的设计逻辑:
目前我们的内核仅支持单核,也就意味着只有单线程,那么我们可不可以使用局部变量来绕过这个错误呢?
很可惜,在这里和后面章节的很多场景中,有些变量无法作为局部变量使用。这是因为后面内核会并发执行多条控制流,这些控制流都会用到这些变量。如果我们最初将变量分配在某条控制流的栈上,那么我们就需要考虑如何将变量传递到其他控制流上,由于控制流的切换等操作并非常规的函数调用,我们很难将变量传递出去。因此最方便的做法是使用全局变量,这意味着在程序的任何地方均可随意访问它们,自然也包括这些控制流。
这里我们可以看到,如果我们在Rust中使用局部变量可以通过巧妙地设计变量的借用来解决问题.
那么可以 退一步 , 设计一个可以在单核上可以安全使用的可变全局变量.
在os/src
下创建sync
模块,而且使用文件夹的方式,这里刚好复习Rust的模块的另一种使用方式---寻找包名文件夹下的mod.rs
:
cd /os/src
mkdir sync
touch up.rs
touch mod.rs
这里除了mod.rs
以外还定义了up.rs
.
先看up.rs
内容:
// os/src/sync/up.rs
pub struct UPSafeCell<T> {
/// inner data
inner: RefCell<T>,
}
unsafe impl<T> Sync for UPSafeCell<T> {}
impl<T> UPSafeCell<T> {
/// User is responsible to guarantee that inner struct is only used in
/// uniprocessor.
pub unsafe fn new(value: T) -> Self {
Self { inner: RefCell::new(value) }
}
/// Panic if the data has been borrowed.
pub fn exclusive_access(&self) -> RefMut<'_, T> {
self.inner.borrow_mut()
}
}
这里实现了结构体UPSafeCell
得到一个内部变量inner
类型为RefCell
.并且为这个结构体实现一个Sync
特性.
为UPSafeCell
实现了两个方法,一个是用于初始化这个结构体的new
,而另一个则是比较重要的exclusive_access
,顾名思义,这个函数叫做独占访问.
exclusive_access
会返回RefCell
包裹的内容的可变借用,如果访问两次exclusive_access
则会调用两次RefCell
的borrow_mut
,就会导致出现panic
.
与RefCell
不同,如果我们需要两次读操作,只需要调用RefCell
的borrow
即可,但是经过这次封装,如果想要访问UPSafeCell
里包裹的RefCell
的借用,只能通过exclusive_access
访问RefCell
的borrow_mut
,这样就没办法访问非可变借用,因此相比 RefCell 它不再允许多个读操作同时存在.
这段代码里面出现了两个 unsafe
:
- 首先
new
被声明为一个unsafe
函数,是因为我们希望使用者在创建一个UPSafeCell
的时候保证在访问UPSafeCell
内包裹的数据的时候始终不违背上述模式:即访问之前调用exclusive_access
,访问之后销毁借用标记再进行下一次访问。这只能依靠使用者自己来保证,但我们提供了一个保底措施:当使用者违背了上述模式,比如访问之后忘记销毁就开启下一次访问时,程序会 panic 并退出。 - 另一方面,我们将
UPSafeCell
标记为Sync
使得它可以作为一个全局变量。这是 unsafe 行为,因为编译器无法确定我们的UPSafeCell
能否安全的在多线程间共享。而我们能够向编译器做出保证,第一个原因是目前我们内核仅运行在单核上,因此无需在意任何多核引发的数据竞争/同步问题;第二个原因则是它基于RefCell
提供了运行时借用检查功能,从而满足了 Rust 对于借用的基本约束进而保证了内存安全。
而mod.rs
则只是先用mod
引入了up.rs
,然后再声明UPSafeCell
是pub
的:
//! Synchronization and interior mutability primitives
mod up;
pub use up::UPSafeCell;
这时候就可以实现一个全局变量APP_MANAGER
,类型为UPSafeCell<AppManager>
,其泛型为AppManager
.
use crate::sync::UPSafeCell;
static APP_MANAGER: UPSafeCell<AppManager>;
但是Rust不允许我们这样定义一个变量,我们必须初始化它.
use crate::sync::UPSafeCell;
static APP_MANAGER: UPSafeCell<AppManager = unsafe{UPSafeCell::new(AppManager{num_app:0,current_app:0,app_start})}
由于我们需要在程序开始运行的时候才能读取到有多少个App
,以及得到这些App
的起始指针.
我们当然可以这样初始化,然后再通过一个必须要执行的函数初始化这个全局变量,但是官方提供的方法很明显是更符合高级语言设计逻辑的.
首先先在os/Cargo.toml
里引入lazy_static
这个库,lazy_static = { version = "1.4.0", features = ["spin_no_std"] }
:
[package]
name = "os"
version = "0.1.0"
edition = "2021"
[dependencies]
sbi-rt = { version = "0.0.2", features = ["legacy"] }
log = "0.4.22"
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }
随后需要引入lazy_static
这个包,并且编写初始化代码,包括读取App
数量和App
的起始指针:
lazy_static!{
static ref APP_MANAGER: UPSafeCell<AppManager> = unsafe
{
UPSafeCell::new
(
extern "C"
{
fn _num_app();
}
let num_app_ptr = _num_app as usize as *const usize;
let num_app = num_app_ptr.read_volatile();
let mut app_start: [usize;MAX_APP_NUM+1] = [0; MAX_APP_NUM + 1];
let app_start_raw:&[usize] = core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1);
app_start[..=num_app].copy_from_slice(app_start_raw);
AppManager
{
num_app,
current_app: 0,
app_start: [0; MAX_APP_NUM + 1],
}
)
};
}
这里需要特别注意的一点就是static ref
他和static
不同,是允许动态初始化的.
这里是我不成熟的见解,因为是"懒"静态变量,因此事实上是在程序运行时才创建了一个存在内存里的实例,也就是rust里提到的 值 , 这时候把这个值的所有权绑定到一个 类似于指针 的 ref
即引用之中去,这样就避免了拷贝.
而后再为AppManager
实现一些方法.
impl AppManager
{
pub fn print_app_info(&self)
{
println!("[kernel] num_app = {}", self.num_app);
for i in 0..self.num_app
{
println!("[kernel] app_{} [{:#x},{:#x})", i, self.app_start[i], self.app_start[i+1]);
}
}
pub fn get_current_app(&self) -> usize
{
self.current_app
}
pub fn move_to_next_app(&mut self)
{
self.current_app += 1;
}
unsafe fn load_app(&self, app_id: usize)
{
if app_id >= self.num_app
{
println!("All applications completed");
shutdown(false);
}
println!("[kernel] Loading app_{}",app_id);
// 清除app加载的位置
core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *const u8, APP_SIZE_LIMIT).fill(0);
let app_src = core::slice::from_raw_parts(self.app_start[app_id] as *const u8, self.app_start[app_id + 1] - self.app_start[app_id]);
let app_dst = core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *mut u8, app_src.len());
app_dst.copy_from_slice(app_src);
asm!("fence.i")
}
}
这里仍然只提到一些关键点:
- 关于
{:#x}
,实际上是一个占位符,把内容输出为十六进制.这里有关键的关于占位符的描述. - 这里注意
shutdown
是从sbi
中依赖得到的,要声明use crate::sbi::shutdown
. core::slice::from_raw_parts_mut
是一个unsafe
的方法,官方的描述是 Forms a slice from a pointer and a length ,也即通过一个 指针 和一个 长度 来构建一个 可变 切片.core::slice::from_raw_parts
同上,但是得到的是 不可变 的切片.copy_from_slice
则是把一个切片的内容拷贝到当前切片下,也就实现了 加载应用 .asm!("fence.i")
是一条奇怪的汇编指令,保证 在它之后的取指过程必须能够看到在它之前的所有对于取指内存区域的修改.
对于fence.i
官方文档有更详细的解释:
注意在第 21 行我们在加载完应用代码之后插入了一条奇怪的汇编指令 fence.i ,它起到什么作用呢?我们知道缓存是存储层级结构中提高访存速度的很重要一环。而 CPU 对物理内存所做的缓存又分成 数据缓存 (d-cache) 和 指令缓存 (i-cache) 两部分,分别在 CPU 访存和取指的时候使用。在取指的时候,对于一个指令地址, CPU 会先去 i-cache 里面看一下它是否在某个已缓存的缓存行内,如果在的话它就会直接从高速缓存中拿到指令而不是通过总线访问内存。通常情况下, CPU 会认为程序的代码段不会发生变化,因此 i-cache 是一种只读缓存。但在这里,OS 将修改会被 CPU 取指的内存区域,这会使得 i-cache 中含有与内存中不一致的内容。因此, OS 在这里必须使用取指屏障指令 fence.i ,它的功能是保证 在它之后的取指过程必须能够看到在它之前的所有对于取指内存区域的修改 ,这样才能保证 CPU 访问的应用代码是最新的而不是 i-cache 中过时的内容。至于硬件是如何实现 fence.i 这条指令的,这一点每个硬件的具体实现方式都可能不同,比如直接清空 i-cache 中所有内容或者标记其中某些内容不合法等等。
最后实现的batch.rs
如下:
use crate::sbi::shutdown;
use crate::sync::UPSafeCell;
use lazy_static::*;
const APP_BASE_ADDRESS: usize = 0x80400000;
const APP_SIZE_LIMIT: usize = 0x20000;
struct AppManager
{
num_app: usize,
current_app: usize,
app_start: [usize; MAX_APP_NUM + 1],
}
impl AppManager
{
pub fn print_app_info(&self)
{
println!("[kernel] num_app = {}", self.num_app);
for i in 0..self.num_app
{
println!("[kernel] app_{} [{:#x},{:#x})", i, self.app_start[i], self.app_start[i+1]);
}
}
pub fn get_current_app(&self) -> usize
{
self.current_app
}
pub fn move_to_next_app(&mut self)
{
self.current_app += 1;
}
unsafe fn load_app(&self, app_id: usize)
{
if app_id >= self.num_app
{
println!("All applications completed");
shutdown(false);
}
println!("[kernel] Loading app_{}",app_id);
// 清除app加载的位置
core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *const u8, APP_SIZE_LIMIT).fill(0);
let app_src = core::slice::from_raw_parts(self.app_start[app_id] as *const u8, self.app_start[app_id + 1] - self.app_start[app_id]);
let app_dst = core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *mut u8, app_src.len());
app_dst.copy_from_slice(app_src);
asm!("fence.i")
}
}
lazy_static!
{
static ref APP_MANAGER: UPSafeCell<AppManager> = unsafe
{
UPSafeCell::new
(
extern "C"
{
fn _num_app();
}
let num_app_ptr = _num_app as usize as *const usize;
let num_app = num_app_ptr.read_volatile();
let mut app_start: [usize;MAX_APP_NUM+1] = [0; MAX_APP_NUM + 1];
let app_start_raw:&[usize] = core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1);
app_start[..=num_app].copy_from_slice(app_start_raw);
AppManager
{
num_app,
current_app: 0,
app_start: [0; MAX_APP_NUM + 1],
}
)
};
}