文章目录
之后在知乎更新了: 操作系统学习之路
—
^^
—
资料汇总
- 2021课程主页
- 源码
- Rust宏编程学习指南
- rCore学习指南
- 指导书
- 老版指导书 - 有些新版没有的知识
- 视频
- 如何自学Rust & RISC-V
- Rust 写OS
- 计算机系统学习小组
- NJU 计算机系统基础(PA)
- CS61c 计算机组成与设计(软硬件接口RISC-V版) 对应课程
实验
进度
- 2021年08月02日: 大概在六月份的时候完成了前四个Ch,基本看了一遍代码,但是因为之前懒得写实验3&4的总结,所以好像都忘的差不多了. 准备把前四个Ch涉及到的知识,前四个Ch的内容基本涵盖了一个功能相对完善的内核抽象所需的所有硬件机制,后面的几个Ch都是偏向软件层面了
- 2021年08月10日: 完成了lab1 + lab2[60%]的总结
- 2021年08月13日: 完成了lab2 的总结
- 2021年08月15日: 完成了lab3 除代码部分的总结
- 2021年08月17日: 完成了lab3 代码总结
- 2021年08月19日: 更新了lab3 实验总结
- 2021年08月24日: 基本重看了一遍lab4指导书,温故而知新,接下来整理lab4的实验代码和整个内核工作的逻辑
实验总结
对于实验: 实现可能存在未知的bug,但是都通过了正确性测试中的所有case
Ch1
实验1主要是如何开发操作系统,操作系统的开发不能依赖编译器的运行时,不能依赖os的sys call.在实验1中就实现了这样一个简单的,没有任何依赖的(除了运行在M态的RUST-SBI) 简易OS. 实现的过程如下:
- 编译的过程中指定目标平台riscv64gc-unknown-none-elf, 生成面相riscv64gc的没有os依赖的elf可执行程序,此时我们的println!就不会编译通过了,因为这个宏依赖标准库.所以在开发过程中,使用不依赖std的core库
- 接下来需要移除println, 自实现panic_handler, 自指定start入口(即修改main函数之前的运行时初始化部分), 实现exit的sys call, 通过这几部分就能够编译出一个无依赖的可执行RV64-elf程序
- 基于以上的思路,我们再添加对于shutdown的sysCall, 然后将链接时的内存布局调整为Bootloader_RustSBI所要求的格式(如设置栈,清空.bss,设置_start入口),这样我们就能够基于模拟器qemu将这个简易的内核启动起来了
Ch2
在Ch1中只是实现了单个程序在裸机上的运行,没有对多道程序进行支持. 在Ch2中实现的功能是批处理, 通过特权级实现保护, & 通过多道程序加载运行实现批处理
特权级机制
简单来讲,RISC-V一般共分为3个特权级(MSU),通过ecall & sret完成高特权级和低特权级的切换,通过 指令 & 内存 & 寄存器 三者共同的特权级机制完成对系统的保护
当CPU从U态trap到S态的时候,会完成以下动作(具体的实现还是需要看trap.S,基本思路是依次保存各个可能在处理trap的过程中可能改变的寄存器,在内存中手动组装出trap_handler的参数,这样就能够在rust中实现trap的处理了):
- sstatus 的 SPP 字段会被修改为 CPU 当前的特权级(U/S)。
- sepc 会被修改为 Trap 回来之后默认会执行的下一条指令的地址。当 Trap 是一个异常的时候,它实际会被修改成 Trap 之前执行的最后一条指令的地址。
- scause/stval 分别会被修改成这次 Trap 的原因以及相关的附加信息。
- CPU 会跳转到 stvec(CSR,目前只用到direct的模式,完成从id到具体处理函数的映射)所设置的 Trap 处理入口地址,并将当前特权级设置为 S,然后开始向下执行。
当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 sret 来完成,这一条指令具体完成以下功能:
- CPU 会将当前的特权级按照 sstatus 的 SPP 字段设置为 U 或者 S
- CPU 会跳转到 sepc 寄存器指向的那条指令,然后开始向下执行
多道程序加载运行的实现
要实现多道应用程序的顺序执行, 首先就要实现S态的多个ABI, 在Ch2中首先实现了sys_write sys_exit(与基于RISC-V的linux发行版的接口完全一致,这样就可以利用qemu运行用户态程序测试), 保证用户态可以向串口(即屏幕)输出字符,并且可以在任务完成时退出
实现了系统调用之后, 将这些ABI封装成user_lib, 应用程序use这个库之后就可以利用ABI编写程序了, 多个用户态应用在编译为可执行elf文件之后,通过objcopy提取出可执行二进制部分,这三部分将直接被链接进内核. 链接的方式有静态和动态,
静态编码:通过一定的编程技巧,把应用程序代码和批处理操作系统代码“绑定”在一起。
动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。
在实现了文件系统(Ch7)之后就不会有这么重的耦合了
rust中的ABI调用没法直接通过函数实现,只能是通过llvm_asm的方式进行调用
在实现了用户态的各个程序之后,此时os内核的部分其实还未实现,这时可以使用qemu-riscv64(支持模拟riscv64的内核)来先行测试
批处理的实现
需要实现的就是一个批量任务管理器,主要完成
-
保存应用数量和各自的位置信息,以及当前执行到第几个应用了
通过读link_app.S里的内容来确定app的数量和位置,并保存到AppManager中供调度时使用 -
根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行
load一个app的步骤大概是先清空以0x80400000为起点的一块内存,然后将app的二进制部分copy到此处执行.
因为目前还没有实现分时的功能,所以每个app都是一直运行到结束调度器才会将下一个app加载
每次新加载的时候数据缓存 (d-cache) 和 指令缓存 (i-cache) 都要清空
代码
因为ch1的代码比较少,所以代码整理的部分从ch2开始,ch2的内核的功能主要有:
- print! 的实现
可以看到,基本上print! 是在做对参数的解析,具体的每个字符的输出都由底层SBI的接口完成
struct Stdout;
impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
for c in s.chars() {
console_putchar(c as usize);
}
Ok(())
}
}
pub fn print(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}
#[macro_export]
macro_rules! print {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!($fmt $(, $($arg)+)?));
}
}
#[macro_export]
macro_rules! println {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?));
}
}
- write & exit 的系统调用的实现
// 因为目前只支持输出到stdout=1,所以write过程就是把数据从内存读出来然后print!
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
match fd {
FD_STDOUT => {
// unsafe {println!("#{:#x} {:#x} #", buf as usize , USER_STACK.get_sp() - USER_STACK_SIZE);}
if (((buf as usize) >= USER_STACK.get_sp() - USER_STACK_SIZE) && ((buf as usize) + len <= USER_STACK.get_sp()))
|| (((buf as usize) + len <= APP_SIZE_LIMIT + APP_BASE_ADDRESS) && ((buf as usize) >= APP_BASE_ADDRESS)){
let slice = unsafe { core::slice::from_raw_parts(buf, len) };
let str = core::str::from_utf8(slice).unwrap();
print!("{}", str);
len as isize
}else{
-1 as isize
}
},
_ => {
-1 as isize
//panic!("Unsupported fd in sys_write!");
}
}
}
// 退出状态,并开始下一个app的执行
pub fn sys_exit(exit_code: i32) -> ! {
println!("[kernel] Application exited with code {}", exit_code);
run_next_app()
}
- 多道用户程序的加载和执行
//使用AppManager + lazy_static! 的方式实现safe的lazy全局实例化,在运行过程中完成app的调度管理
struct AppManager {
inner: RefCell<AppManagerInner>,
}
struct AppManagerInner {
num_app: usize,
current_app: usize,
app_start: [usize; MAX_APP_NUM + 1],
}
lazy_static! {
static ref APP_MANAGER: AppManager = AppManager {
inner: RefCell::new({
extern "C" { fn _num_app(); }
let num_app_ptr = _num_app as usize as *const usize;
let num_app = unsafe { num_app_ptr.read_volatile() };
let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
let app_start_raw: &[usize] = unsafe {
core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1)
};
app_start[..=num_app].copy_from_slice(app_start_raw);
AppManagerInner {
num_app,
current_app: 0,
app_start,
}
}),
};
}
//APP的加载: 将app的内容加载到以0x80400000作为起始的地方,具体过程是:清理缓存 -> 清理0x80400000作为起点的这块内存区域 -> 拷贝
unsafe fn load_app(&self, app_id: usize) {
if app_id >= self.num_app {
panic!("All applications completed!");
}
println!("[kernel] Loading app_{}", app_id);
// clear icache
llvm_asm!("fence.i" :::: "volatile");
// clear app area
(APP_BASE_ADDRESS..APP_BASE_ADDRESS + APP_SIZE_LIMIT).for_each(|addr| {
(addr as *mut u8).write_volatile(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);
}
// APP的切换: 比较简单, 只要load之后,组装出一个TrapContext,然后从这个地方__restore就能从S态切换到U态开始执行,并且寄存器的初始状态和TrapContext设置的一致
pub fn run_next_app() -> ! {
let current_app = APP_MANAGER.inner.borrow().get_current_app();
unsafe {
APP_MANAGER.inner.borrow().load_app(current_app);
}
APP_MANAGER.inner.borrow_mut().move_to_next_app();
extern "C" { fn __restore(cx_addr: usize); }
unsafe {
__restore(KERNEL_STACK.push_context(
TrapContext::app_init_context(APP_BASE_ADDRESS, USER_STACK.get_sp())
) as *const _ as usize);
}
panic!("Unreachable in batch::run_current_app!");
}
- Trap类(系统调用)和Fault类指令的转发处理
这部分内容是软硬协同完成的,硬件负责接受,软件负责收到后如何处理
//对于Trap, CPU会完成特权级切换,状态保存和恢复RUST语义实现不了,通过汇编实现这部分,然后call rust函数完成剩余部分. 将__alltraps写入stvec,这样每次发生的时候都会按照__alltraps的内容进行trap处理
//Trap.s:
.altmacro
.macro SAVE_GP n
sd x\n, \n*8(sp)
.endm
.macro LOAD_GP n
ld x\n, \n*8(sp)
.endm
.section .text
.globl __alltraps
.globl __restore
.align 2
__alltraps:
csrrw sp, sscratch, sp
# now sp->kernel stack, sscratch->user stack
# allocate a TrapContext on kernel stack
addi sp, sp, -34*8
# save general-purpose registers
sd x1, 1*8(sp)
# skip sp(x2), we will save it later
sd x3, 3*8(sp)
# skip tp(x4), application does not use it
# save x5~x31
.set n, 5
.rept 27
SAVE_GP %n
.set n, n+1
.endr
# we can use t0/t1/t2 freely, because they were saved on kernel stack
csrr t0, sstatus
csrr t1, sepc
sd t0, 32*8(sp)
sd t1, 33*8(sp)
# read user stack from sscratch and save it on the kernel stack
csrr t2, sscratch
sd t2, 2*8(sp)
# set input argument of trap_handler(cx: &mut TrapContext)
mv a0, sp
call trap_handler
__restore:
# case1: start running app by __restore
# case2: back to U after handling trap
mv sp, a0
# now sp->kernel stack(after allocated), sscratch->user stack
# restore sstatus/sepc
ld t0, 32*8(sp)
ld t1, 33*8(sp)
ld t2, 2*8(sp)
csrw sstatus, t0
csrw sepc, t1
csrw sscratch, t2
# restore general-purpuse registers except sp/tp
ld x1, 1*8(sp)
ld x3, 3*8(sp)
.set n, 5
.rept 27
LOAD_GP %n
.set n, n+1
.endr
# release TrapContext on kernel stack
addi sp, sp, 34*8
# now sp->kernel stack, sscratch->user stack
csrrw sp, sscratch, sp
sret
Ch3
ch3主要完成了分时多任务的功能,支持多道程序同时存在内存,通过时钟中断和程序主动让出CPU,实现多个任务在宏观上并行
多道程序的放置和加载
在ch2中,只有所有的任务都是被copy到同一内存区域去执行的,在ch3中每个应用都需要按照它的编号被分别放置并加载到内存中不同的位置.
具体实现方法:通过链接时指定每个应用程序的起始地址. 在内核运行时能够正确获取到这个地址,并将应用代码放置到这个地址起始的位置.
多道程序的切换原理
任务: 把应用程序的一个计算阶段的执行过程(也是一段执行流)称为一个 任务
任务切换: 从一个程序的任务切换到另外一个程序的任务称为 任务切换
任务上下文: 任务切换和恢复时相关的寄存器
因为每个应用都有自己的内核栈,所以任务状态的保存和恢复都可以通过在内核栈上进行task_context的压入和弹出的操作来实现,关于__switch的实现在最后代码部分总结
阶段 [1]:在 Trap 执行流 A 调用 __switch 之前,A 的内核栈上只有 Trap 上下文和 Trap 处理的调用栈信息,而 B 是之前被切换出去的,它的栈顶还有额外的一个任务上下文;
阶段 [2]:A 在自身的内核栈上分配一块任务上下文的空间在里面保存 CPU 当前的寄存器快照。随后,我们更新 A 的 task_cx_ptr,只需写入指向它的指针 task_cx_ptr2 指向的内存即可;
阶段 [3]:这一步极为关键。这里读取 B 的 task_cx_ptr 或者说 task_cx_ptr2 指向的那块内存获取到 B 的内核栈栈顶位置,并复制给 sp 寄存器来换到 B 的内核栈。由于内核栈保存着它迄今为止的执行历史记录,可以说 换栈也就实现了执行流的切换 。正是因为这一步, __switch 才能做到一个函数跨两条执行流执行。
阶段 [4]:CPU 从 B 的内核栈栈顶取出任务上下文并恢复寄存器状态,在这之后还要进行退栈操作。
阶段 [5]:对于 B 而言, __switch 函数返回,可以从调用 __switch 的位置继续向下执行。
多道程序的调度原理
抢占式调度效率增加原理
CPU 会将请求和一些附加的参数写入外设,待外设处理完毕之后, CPU 便可以从外设读到请求的处理结果。比如在从作为外部存储的磁盘上读取数据的时候,CPU 将要读取的扇区的编号以及读到的数据放到的物理地址传给磁盘,在磁盘对请求进行调度并完成数据拷贝之后,就能在物理内存中看到要读取的数据。
在一个应用对外设发出了请求之后,它不能立即向下执行,而是要等待外设将请求处理完毕并拿到完整的处理结果之后才能继续。那么如何知道外设是否已经完成了请求呢?通常外设会提供一个可读的寄存器记录它目前的工作状态,于是 CPU 需要不断原地循环读取它直到它的结果显示设备已经将请求处理完毕了,才能向下执行。然而,外设的计算速度和 CPU 相比可能慢了几个数量级,这就导致 CPU 有大量时间浪费在等待外设这件事情上,这段时间它几乎没有做任何事情,也在一定程度上造成了 CPU 的利用率不够理想。
状态的管理和转移
内核中对于多个task的状态通过统一全局static变量维护, 保存各个task的状态以及内核栈的sp位置(即task_context的地址),供__switch时使用.
rcore中task的状态转移如下所示,具体的实现在代码部分解释
第一次进入用户态的变化
在ch2中只需在内核栈上压入构造好的 Trap 上下文,然后 __restore 即可。本章的思路大致相同,但是有一些变化。
除了压入Trap_context 还需要压入task_context(ra设置为__restore的地址),然后直接 __switch—> __restore,就完成了应用首次进入用户态开始执行代码的过程
需要注意的是, __restore 的实现需要做出变化:它 不再需要 在开头 mv sp, a0 了。因为在 __switch 之后,sp 就已经正确指向了我们需要的 Trap 上下文地址。
分时抢占时多任务系统实现原理
RISC-V 架构中的中断
中断 (Interrupt) 和我们第二章中介绍的 用于系统调用的 陷入 Trap 一样都是异常 ,但是它们被触发的原因确是不同的。对于某个处理器核而言, 陷入 与发起 陷入 的指令执行是 同步 (Synchronous) 的, 陷入 被触发的原因一定能够追溯到某条指令的执行;而中断则 异步 (Asynchronous) 于当前正在进行的指令,也就是说中断来自于哪个外设以及中断如何触发完全与处理器正在执行的当前指令无关。
软件中断 (Software Interrupt) 时钟中断 (Timer Interrupt) 外部中断 (External Interrupt)
在判断中断是否会被屏蔽的时候,有以下规则:
- 如果中断的特权级低于 CPU 当前的特权级,则该中断会被屏蔽,不会被处理;
- 如果中断的特权级高于与 CPU 当前的特权级或相同,则需要通过相应的 CSR 判断该中断是否会被屏蔽。
时钟中断与计时器
由于需要一种计时机制,RISC-V 架构要求处理器要有一个内置时钟,其频率一般低于 CPU 主频。此外,还有一个计数器统计处理器自上电以来经过了多少个内置时钟的时钟周期。在 RV64 架构上,该计数器保存在一个 64 位的 CSR mtime 中,我们无需担心它的溢出问题,在内核运行全程可以认为它是一直递增的。
另外一个 64 位的 CSR mtimecmp 的作用是:一旦计数器 mtime 的值超过了 mtimecmp,就会触发一次时钟中断。这使得我们可以方便的通过设置 mtimecmp 的值来决定下一次时钟中断何时触发。
这样只需要计算出比如10ms内递增的内置时钟周期,然后set_timer就能保证每10ms触发一次时钟中断
抢占式调度
有了时钟中断和计时器,抢占式调度就很容易实现了:只需在 trap_handler 函数下新增一个分支,当发现触发了一个 S 特权级时钟中断的时候,首先重新设置一个 10ms 的计时器,然后调用上一小节提到的 suspend_current_and_run_next 函数暂停当前应用并切换到下一个。
match scause.cause() {
Trap::Interrupt(Interrupt::SupervisorTimer) => {
set_next_trigger();
suspend_current_and_run_next();
}
}
代码总结
main
从main开始,内核依次完成了trap_init,load_app,使能timer_interrupt并设置下一次触发中断的间隔,然后从第一个task开始运行
#[no_mangle]
pub fn rust_main() -> ! {
clear_bss();
println!("[kernel] Hello, world!");
comlog::init();
info!("[kernel] init comlog OK");
trap::init();
loader::load_apps();
trap::enable_timer_interrupt();
timer::set_next_trigger();
task::run_first_task();
panic!("Unreachable in rust_main!");
}
Timer_Interrupt的处理
trap的初始化和使能与ch2中并无太大变化,主要是加了S特权级的时钟中断的处理
Trap::Interrupt(Interrupt::SupervisorTimer) => {
set_next_trigger();//设置下次中断发生的时间
suspend_current_and_run_next();//暂停当前的app,切换下一个
}
下次中断发生时间的设置是根据CPU的计时器的频率计算出来的
pub fn get_time_ms() -> usize {
time::read() / (CLOCK_FREQ / MSEC_PER_SEC)
}
pub fn set_next_trigger() {
set_timer(get_time() + CLOCK_FREQ / TICKS_PER_SEC);
}
可以看到suspend_current_and_run_next 完成了暂停当前进程并调起下一个就绪进程的任务.
其中mark_current_suspended完成的比较简单,主要是通过全局TASK_MANAGER将当前task_status设置为Ready
pub fn suspend_current_and_run_next() {
mark_current_suspended();
run_next_task();
}
基于stride算法的优先级调度策略实现
在run_next_task中完成了基于stride算法(调度次数正比于其优先级)的调度策略.
run_next_task的主要内容比较简单,基本就是获取待交换双方的task_cx_ptr2,然后调用__switch完成任务切换,其中next是根据stride算法中的权重概念按照优先级获取的
fn run_next_task() {
// TASK_MANAGER.run_next_task();
TASK_MANAGER.run_next_task_stride();
}
fn find_next_task_stride(&self) -> Option<usize> {
let inner = self.inner.borrow();
let current = inner.current_task;
let mut ans: Option<usize> = None;
let mut min_stride = isize::MAX;
for index in (0..self.num_app) {
if inner.tasks[index].task_status == TaskStatus::Ready {
if inner.tasks[index].stride < min_stride {
ans = Some(index);
min_stride = inner.tasks[index].stride;
}
}
}
ans
}
fn run_next_task_stride(&self) {
if let Some(next) = self.find_next_task_stride() {
let mut inner = self.inner.borrow_mut();
let current = inner.current_task;
inner.tasks[current].stride += inner.tasks[current].pass;
inner.tasks[next].task_status = TaskStatus::Running;
inner.current_task = next;
let current_task_cx_ptr2 = inner.tasks[current].get_task_cx_ptr2();
let next_task_cx_ptr2 = inner.tasks[next].get_task_cx_ptr2();
core::mem::drop(inner);
unsafe {
__switch(
current_task_cx_ptr2,
next_task_cx_ptr2,
);
}
} else {
panic!("All applications completed!");
}
}
下面重点介绍任务切换依赖的两部分:①TASK_MANAGER ②__switch
TASK_MANAGER
TASK_MANAGER是全局的task管理器,所有的TaskControlBlock都在内核初始化时被读入.
比较复杂的部分在于task_cx_ptr的获取: 在初始化(加载)各个app的过程中, 会通过init_app_cx(i) 向第i个app对应的内核栈上依次压入trapContext和taskContext,返回参数的地址就是task_cx_ptr(taskContext的起始地址)
pub fn init_app_cx(app_id: usize) -> &'static TaskContext {
KERNEL_STACK[app_id].push_context(
TrapContext::app_init_context(get_base_i(app_id), USER_STACK[app_id].get_sp()),
TaskContext::goto_restore(),
)
}
pub struct TaskControlBlock {
pub task_cx_ptr: usize,
pub task_status: TaskStatus,
pub stride: isize,
pub pass: isize,
}
pub struct TaskManager {
num_app: usize,
inner: RefCell<TaskManagerInner>,
}
struct TaskManagerInner {
tasks: [TaskControlBlock; MAX_APP_NUM],
current_task: usize,
}
unsafe impl Sync for TaskManager {}
lazy_static! {
pub static ref TASK_MANAGER: TaskManager = {
let num_app = get_num_app();
let mut tasks = [
TaskControlBlock { task_cx_ptr: 0, task_status: TaskStatus::UnInit, stride: (DEFAULT_STRIDE as isize), pass: (DEFAULT_PASS as isize)};
MAX_APP_NUM
];
for i in 0..num_app {
tasks[i].task_cx_ptr = init_app_cx(i) as * const _ as usize;
tasks[i].task_status = TaskStatus::Ready;
}
TaskManager {
num_app,
inner: RefCell::new(TaskManagerInner {
tasks,
current_task: 0,
}),
}
};
}
__switch
初始化完成之后,最关键的就是任务的切换了,因为__switch解释为了一个rust函数,所以调用者保存寄存器会自动保存,需要自己保存的只有ra和s[0~11]. switch.s的实现比较简单:就是从a0保存的位置保存一个taskContext,然后从a1保存的位置读出一个taskContext, 然后函数ret
global_asm!(include_str!("switch.S"));
extern "C" {
pub fn __switch(
current_task_cx_ptr2: *const usize,
next_task_cx_ptr2: *const usize
);
}
//switch.S:
.altmacro
.macro SAVE_SN n
sd s\n, (\n+1)*8(sp)
.endm
.macro LOAD_SN n
ld s\n, (\n+1)*8(sp)
.endm
.section .text
.globl __switch
__switch:
# __switch(
# current_task_cx_ptr2: &*const TaskContext,
# next_task_cx_ptr2: &*const TaskContext
# )
# push TaskContext to current sp and save its address to where a0 points to
addi sp, sp, -13*8
sd sp, 0(a0)
# fill TaskContext with ra & s0-s11
sd ra, 0(sp)
.set n, 0
.rept 12
SAVE_SN %n
.set n, n + 1
.endr
# ready for loading TaskContext a1 points to
ld sp, 0(a1)
# load registers in the TaskContext
ld ra, 0(sp)
.set n, 0
.rept 12
LOAD_SN %n
.set n, n + 1
.endr
# pop TaskContext
addi sp, sp, 13*8
ret
ch4
- 4.3: 内存分配策略(查找开销,合并开销,碎片大小):
最先匹配: 第一个满足的大小的内存分区,实现简单,有外碎片
最优匹配: 比目标大并且最小的内存分区,分区列表按照大小排序,外碎片小,释放时比较复杂(和地址相邻的空闲分区合并),小碎片多
最差匹配: 直接找最大的,释放时比较复杂(和地址相邻的空闲分区合并),小碎片少 - 4.4: 碎片整理
碎片合并: 在应用程序可以动态重定位时合并外碎片
分区对换: 将等待状态的进程交换到外存,以增大内存空间(类似unix swap分区) - 4.5: 伙伴系统(一种连续内存分配算法)
每块的大小都是2的幂的大小,在空闲块过大的时候切一个刚好大于当前需求大小的块出来
合并时必须:1.地址相邻 2.大小相同 3. 起始地址必须是待合并地址的两倍大小的整数倍 - 4.6: SLAB分配器
内核中可能存在占用内存很小的块(远小于4k)
分配回收高效,不用清零 - 5.3 页式存储管理
- 5.6 RISC-V 对页表的硬件支持
特权级0 0 0 : 代表是指向下一级页表的页表项
如果在三级页表下,在第一级页表中指定特权级非 0 0 0 ,那么就是一个大页,2^30 大小的页(剩余两级页表占的地址+offset) - 6.1 虚拟存储的背景
- 6.3 局部性原理
时间局部性:一条指令或者一条数据的两次访问通常都集中在较短时间内
空间局部性:当前要指令和接下来要执行的指令,当前数据和接下来要访问的数据通常都集中在较小的区域内
分支局部性:一条指令的两次执行很可能跳转到相同位置 - 6.4 虚拟存储的概念
思路:不常用的内存块放在外存
原理:只将必要的部分加入内存,访问到不存在的页面时再调入内存,将之后不在需要的换出到外存 - 6.5 虚拟页式存储
驻留位,访问位,修改位,保护位
Rust中的动态内存分配
静态与动态内存分配
- 静态: 在编译的时候编译器已经知道它们类型的字节大小, 于是给它们分配一块等大的内存将它们存储其中,这块内存在变量所属函数的栈帧/数据段中的位置也已经被固定了下来; 不够灵活
- 动态:
动态分配就是指应用不仅在自己的地址空间放置那些 自编译期开始就大小固定、用于静态内存分配的逻辑段(如全局数据段、栈段),还另外放置一个大小可以随着应用的运行动态增减 的逻辑段,它的名字叫做堆;
在程序运行过程中再进行内存的分配,其实这就是一个连续内存分配问题,OS的连续内存分配算法都可以使用;
它背后运行着连续内存分配算法,相比静态分配会带来一些额外的开销。如果动态分配非常频繁,可能会产生很多无法使用的空闲空间碎片,甚至可能会成为应用的性能瓶颈
动态内存分配原理
动态内存分配的本质就是在堆段上进行连续内存分配的管理,当需要动态数据结构的时候从堆上分配一块出来使用
地址空间
操作系统为了更好地管理内存,并给应用程序提供统一的访问接口,即应用程序不需要了解虚拟内存和物理内存的区别的,操作系统提出了 地址空间 Address Space 抽象,并在内核中建立虚实地址空间的映射机制,给应用程序提供一个虚拟的内存环境
虚拟地址与地址空间
地址虚拟化出现之前只能通过覆盖或替换的方式缓解内存限制
地址空间的抽象应该至少解决下面三个问题:
- 透明 :应用开发者可以不必了解底层真实物理内存的硬件细节,且在非必要时也不必关心内核的实现策略, 最小化他们的心智负担;
- 高效 :这层抽象至少在大多数情况下不应带来过大的额外开销;
- 安全 :这层抽象应该有效检测并阻止应用读写其他应用或内核的代码、数据等一系列恶意行为。
当应用取指或者执行 一条访存指令的时候,它都是在以虚拟地址为索引读写自己的地址空间。此时,CPU 中的 内存管理单元 (MMU, Memory Management Unit) 自动将这个虚拟地址进行 地址转换 (Address Translation) 变为一个物理地址, 也就是物理内存上这个应用的数据真实被存放的位置
实现 SV39 多级页表机制
截止目前来看,基于sv39的多级页表虚拟内存机制实现基本上是整个课程中最复杂,最不显然的部分了,比如其中中的跳板机制等. 需要好好整理理解.
虚拟地址和物理地址
我们可以通过修改 S 特权级的一个名为 satp 的 CSR 来启用分页模式,在这之后 S 和 U 特权级的访存 地址会被视为一个虚拟地址,它需要经过 MMU 的地址转换变为一个物理地址,再通过它来访问物理内存;而 M 特权级的访存地址,我们可设定是内存的物理地址。
mode控制分页模式,8代表sv39,ppn代表页表起始地址
地址格式与组成
- 每页的起始位置都4kb对齐
- 虚拟地址的高 27 位,即 为 它的虚拟页号 VPN,同理物理地址的高 44 位,即 为它的物理页号 PPN,页号可以用来定位一个虚拟/物理地址 属于哪一个虚拟页面/物理页帧
为何只有高256GB和低256GB的空间是可用的:
页表项
上图为 SV39 分页模式下的页表项,其中 这 位是物理页号,最低的 位 则是标志位,它们的含义如下(请注意,为方便说明,下文我们用 页表项的对应虚拟页面 来表示索引到 一个页表项的虚拟页号对应的虚拟页面):
仅当 V(Valid) 位为 1 时,页表项才是合法的;
- R/W/X 分别控制索引到这个页表项的对应虚拟页面是否允许读/写/取指;
- U 控制索引到这个页表项的对应虚拟页面是否在 CPU 处于 U 特权级的情况下是否被允许访问;
- G 我们暂且不理会;
- A(Accessed) 记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被访问过;
- D(Dirty) 则记录自从页表项上的这一位被清零之后,页表项的对应虚拟页表是否被修改过。
多级页表的原理
如果只有一级页表的话,页表在内存中的分布会如下图所示,这种情况下我们只要知道第一个页表项(对应虚拟页号 0)被放在的物理地址 ,就能 直接计算出每个输入的虚拟页号对应的页表项所在的位置.
然而遗憾的是,这远远超出了我们的物理内存限制。由于虚拟页号有 2^27 种,每个虚拟页号对应一个 8 字节的页表项,则每个页表都需要消耗掉 1GB 内存!应用的数据还需要保存在内存的其他位置,这就使得每个应用要吃掉 1GB 以上的内存。作为对比, 我们的 K210 开发板目前只有 的内存,因此从空间占用角度来说,这种线性表实现是完全不可行的.
那么如何进行优化呢?核心思想就在于 按需分配 ,也就是说:一开始我们页表是空的,随着程序的不断加载,不断会有新的对应页表建立,通过这样的过程,就完成了按需初始化页表,设某个应用地址空间实际用到的区域总大小为 S 字节,则地址空间对应的多级页表消耗内存为 S/ 512左右
在三级页表中非叶节点的页表项标志位含义和叶节点相比有一些不同:
- 当 V 为 0 的时候,代表当前指针是一个空指针,无法走向下一级节点,即该页表项对应的虚拟地址范围是无效的;
- 只有当V 为1 且 R/W/X 均为 0 时,表示是一个合法的页目录表项,其包含的指针会指向下一级的页表。
- 注意: 当V 为1 且 R/W/X 不全为 0 时,表示是一个合法的页表项,其包含了虚地址对应的物理页号。
物理页帧管理
从前面的介绍可以看出物理页帧的重要性:它既可以用来实际存放应用的数据,也能够用来存储某个应用多级页表中的一个节点。 目前的物理内存上在使能虚拟内存之前就已经有一部分用于放置内核的代码和数据,我们需要将剩下可用的部分以单个物理页帧为单位管理起来, 当需要存放应用数据或是应用的多级页表需要一个新节点的时候分配一个物理页帧,并在应用出错或退出的时候回收它占有 的所有物理页帧。
内核与应用的地址空间
内核地址空间
地址空间抽象的重要意义在于 隔离 (Isolation) ,当我们 在执行每个应用的代码的时候,内核需要控制 MMU 使用这个应用地址空间的多级页表进行地址转换。由于每个应用地址空间在创建 的时候也顺带设置好了多级页表使得只有那些存放了它的数据的物理页帧能够通过该多级页表被映射到,这样它就只能访问自己的数据 而无法触及其他应用或是内核的数据。
下图是软件看到的 64 位地址空间在 SV39 分页模式下实际可能通过 MMU 检查的最高 256GB
注意相邻两个内核栈之间会预留一个 保护页面 (Guard Page) ,它是内核地址空间中的空洞,多级页表中并不存在与它相关的映射。 它的意义在于当内核栈空间不足(如调用层数过多或死递归)的时候,代码会尝试访问 空洞区域内的虚拟地址,然而它无法在多级页表中找到映射,便会触发异常,此时控制权会交给 trap handler 对这种情况进行 处理。由于编译器会对访存顺序和局部变量在栈帧中的位置进行优化,我们难以确定一个已经溢出的栈帧中的哪些位置会先被访问, 但总的来说,空洞区域被设置的越大,我们就能越早捕获到这一错误并避免它覆盖其他重要数据。由于我们的内核非常简单且内核栈 的大小设置比较宽裕,在当前的设计中我们仅将空洞区域的大小设置为单个页面。
下面则给出了内核地址空间的低 256GB 的布局:
- 四个逻辑段的 U 标志位均未被设置,使得 CPU 只能在处于 S 特权级(或以上)时访问它们;
- 代码段 .text 不允许被修改;
- 只读数据段 .rodata 不允许被修改,也不允许从它上面取指;
- .data/.bss 均允许被读写,但是不允许从它上面取指。
应用地址空间
现在,借助地址空间的抽象,我们终于可以让所有应用程序都使用同样的起始地址,这也意味着所有应用可以使用同一个链接脚本了:
在 .text 和 .rodata 中间以及 .rodata 和 .data 中间我们进行了页面对齐,因为前后两个逻辑段的访问方式限制是不同的,由于我们只能以页为单位对这个限制进行设置, 因此就只能将下一个逻辑段对齐到下一个页面开始放置。相对的, .data 和 .bss 两个逻辑段由于限制相同,它们中间 则无需进行页面对齐
1/* user/src/linker.ld */
2
3OUTPUT_ARCH(riscv)
4ENTRY(_start)
5
6BASE_ADDRESS = 0x0;
7
8SECTIONS
9{
10 . = BASE_ADDRESS;
11 .text : {
12 *(.text.entry)
13 *(.text .text.*)
14 }
15 . = ALIGN(4K);
16 .rodata : {
17 *(.rodata .rodata.*)
18 }
19 . = ALIGN(4K);
20 .data : {
21 *(.data .data.*)
22 }
23 .bss : {
24 *(.bss .bss.*)
25 }
26 /DISCARD/ : {
27 *(.eh_frame)
28 *(.debug*)
29 }
30}
下图展示了应用地址空间的布局:
右侧给出了最高的 , 可以看出它只是和内核地址空间一样将跳板放置在最高页,还将 Trap 上下文放置在次高页中。这两个虚拟页面虽然位于应用地址空间, 但是它们并不包含 U 标志位,事实上它们在地址空间切换的时候才会发挥作用
基于地址空间的分时多任务
在ch4的最后,基于virtual memory的分时多任务系统将会被实现
建立并开启基于分页模式的虚拟地址空间
从未开启虚拟内存的状态到开启状态的转化是在内核初始化的过程中完成的.
- 创建内核地址空间:
- 内存管理子系统初始化:最先进行了全局动态内存分配器的初始化,因为接下来马上就要用到 Rust 的堆数据结构。接下来我们初始化物理页帧 管理器(内含堆数据结构 Vec )使能可用物理页帧的分配和回收能力。最后我们创建内核地址空间并让 CPU 开启分页模式
跳板的实现
跳板机制的实现基本上算是本节中最不显然的一部分了,这里面涉及到了三方面:软件,硬件,设计技巧,如果能很好的理解了跳板机制的作用, 那么本章的虚拟内存的内容算是基本掌握了.
- 为什么需要跳板:
当一个应用 Trap 到内核的时候, sscratch 已经指出了该应用内核栈的栈顶,我们用一条指令即可从用户栈切换到内核栈,然后直接将 Trap 上下文压入内核栈 栈顶。当 Trap 处理完毕返回用户态的时候,将 Trap 上下文中的内容恢复到寄存器上,最后将保存着应用用户栈顶的 sscratch 与 sp 进行交换,也就从内核栈切换回了用户栈。在这个过程中, sscratch 起到了非常关键的作用,它使得我们可以在不破坏 任何通用寄存器的情况下完成用户栈和内核栈顶的 Trap 上下文这两个工作区域之间的切换。
然而,一旦使能了分页机制,一切就并没有这么简单了,我们必须在这个过程中同时完成地址空间的切换。 具体来说,当 __alltraps 保存 Trap 上下文的时候,我们必须通过修改 satp 从应用地址空间切换到内核地址空间, 因为 trap handler 只有在内核地址空间中才能访问; 同理,在 __restore 恢复 Trap 上下文的时候,我们也必须从内核地址空间切换回应用地址空间,因为应用的代码和 数据只能在它自己的地址空间中才能访问,内核地址空间是看不到的。 进而,地址空间的切换不能影响指令的连续执行,这就要求应用和内核地址空间在切换地址空间指令附近是平滑的。
我们为何将应用的 Trap 上下文放到应用地址空间的次高页面而不是内核地址空间中的内核栈中呢?原因在于,假如我们将其放在内核栈 中,在保存 Trap 上下文之前我们必须先切换到内核地址空间,这就需要我们将内核地址空间的 token 写入 satp 寄存器,之后我们 还需要有一个通用寄存器保存内核栈栈顶的位置,这样才能以它为基址保存 Trap 上下文。在保存 Trap 上下文之前我们必须完成这 两项工作。然而,我们无法在不破坏任何一个通用寄存器的情况下做到这一点。因为事实上我们需要用到内核的两条信息:内核地址空间 的 token 还有应用内核栈顶的位置,硬件却只提供一个 sscratch 可以用来进行周转。所以,我们不得不将 Trap 上下文保存在 应用地址空间的一个虚拟页面中以避免切换到内核地址空间才能保存。
因为只有唯一的内核地址空间,所以在每个trapContext都保存以下三个变量,这样在汇编代码中跳转比较好实现
kernel_satp 表示内核地址空间的 token
kernel_sp 表示当前应用在内核地址空间中的内核栈栈顶的虚拟地址
trap_handler 表示内核中 trap handler 入口点的虚拟地址
Trap.s这段汇编代码放在一个物理页帧中,且 __alltraps 恰好位于这个物理页帧的开头,其物理地址被外部符号 strampoline 标记。在开启分页模式之后,内核和应用代码都只能看到各自的虚拟地址空间,而在它们的视角中,这段汇编代码 被放在它们地址空间的最高虚拟页面上,由于这段汇编代码在执行的时候涉及到地址空间切换,故而被称为跳板页面
rcore 内核代码解读
lab4主要增加了:
- 在内核中使用动态内存分配
- 增加了虚拟内存的机制,实现了隔离 & 共享 & 安全,逻辑上增大了内存,简化了编译器的工作
- task,trap机制适配动态内存而做出的相关改变
这一切,都是从mm::init()展开的.
pub fn init() {
heap_allocator::init_heap();
frame_allocator::init_frame_allocator();
KERNEL_SPACE.lock().activate();
}
address.rs
在介绍具体的内存管理部分的代码之前,先整理下address.rs中封装的各个单元类
//1. PhysAddr & VirtAddr [两者几乎无差别,都是usize的包装]
// 主要实现了, 与usize之间的转化
pub struct PhysAddr(pub usize);
impl From<usize> for PhysAddr {
fn from(v: usize) -> Self { Self(v) }
}
impl From<PhysAddr> for usize {
fn from(v: PhysAddr) -> Self { v.0 }
}
impl PhysAddr {
pub fn floor(&self) -> PhysPageNum { PhysPageNum(self.0 / PAGE_SIZE) }
pub fn ceil(&self) -> PhysPageNum { PhysPageNum((self.0 - 1 + PAGE_SIZE) / PAGE_SIZE) }
// 页内偏移
pub fn page_offset(&self) -> usize { self.0 & (PAGE_SIZE - 1) }
// 页内偏移 == 0 代表 PAGE_SIZE 对齐
pub fn aligned(&self) -> bool { self.page_offset() == 0 }
}
impl From<PhysAddr> for PhysPageNum {
fn from(v: PhysAddr) -> Self {
assert_eq!(v.page_offset(), 0);
v.floor()
}
}
impl From<PhysPageNum> for PhysAddr {
fn from(v: PhysPageNum) -> Self { Self(v.0 << PAGE_SIZE_BITS) }
}
//2. PhysPageNum & VirtPageNum
pub struct PhysPageNum(pub usize);
impl From<PhysPageNum> for usize {
fn from(v: PhysPageNum) -> Self { v.0 }
}
impl From<usize> for VirtPageNum {
fn from(v: usize) -> Self { Self(v) }
}
//对于虚拟页号,分别取出三级页表内的各级偏移
impl VirtPageNum {
pub fn indexes(&self) -> [usize; 3] {
let mut vpn = self.0;
let mut idx = [0usize; 3];
for i in (0..3).rev() {
idx[i] = vpn & 511;
vpn >>= 9;
}
idx
}
}
//对于物理页号, 我们可以从这个位置开始读取数据, 格式可能有多种, 在传出的时候需要标记生命周期
impl PhysPageNum {
pub fn get_pte_array(&self) -> &'static mut [PageTableEntry] {
let pa: PhysAddr = self.clone().into();
unsafe {
core::slice::from_raw_parts_mut(pa.0 as *mut PageTableEntry, 512)
}
}
pub fn get_bytes_array(&self) -> &'static mut [u8] {
let pa: PhysAddr = self.clone().into();
unsafe {
core::slice::from_raw_parts_mut(pa.0 as *mut u8, 4096)
}
}
pub fn get_mut<T>(&self) -> &'static mut T {
let pa: PhysAddr = self.clone().into();
unsafe {
(pa.0 as *mut T).as_mut().unwrap()
}
}
}
heap_allocator::init_heap()
通过buddy_system_allocator + 用一块 硬编码在内核全局数据段中的空间 作为内核堆,来初始化#[global_allocator]修饰的HEAP_ALLOCATOR . 完成初始化之后,内核的代码就根据伙伴内存管理算法来进行动态内存分配和管理
//heap_allocator.rs
#[global_allocator]
static HEAP_ALLOCATOR: LockedHeap = LockedHeap::empty();
#[alloc_error_handler]
pub fn handle_alloc_error(layout: core::alloc::Layout) -> ! {
panic!("Heap allocation error, layout = {:?}", layout);
}
static mut HEAP_SPACE: [u8; KERNEL_HEAP_SIZE] = [0; KERNEL_HEAP_SIZE];
pub fn init_heap() {
unsafe {
HEAP_ALLOCATOR
.lock()
.init(HEAP_SPACE.as_ptr() as usize, KERNEL_HEAP_SIZE);
}
}
frame_allocator::init_frame_allocator()
物理页管理器,完成全局物理页管理器的初始化(可用范围是内核占用的上界,一直到MEMORY_END: usize = 0x80800000,所以总共能分配的内存共有16GB(含内核占的空间))
lazy_static! {
pub static ref FRAME_ALLOCATOR: Mutex<FrameAllocatorImpl> =
Mutex::new(FrameAllocatorImpl::new());
}
pub fn init_frame_allocator() {
extern "C" {
fn ekernel();
}
FRAME_ALLOCATOR
.lock()
.init(PhysAddr::from(ekernel as usize).ceil(), PhysAddr::from(MEMORY_END).floor());
}
frame_allocator管理的最小单位就是一个物理页, 为了利用rust RAII的自动回收机制, 将一个PhysPageNum [pub struct PhysPageNum(pub usize);] 包装成一个FrameTracker然后实现drop方法,就能在FrameTracker生命周期结束的时候,自动完成页回收
pub struct FrameTracker {
pub ppn: PhysPageNum,
}
impl FrameTracker {
pub fn new(ppn: PhysPageNum) -> Self {
// page cleaning
let bytes_array = ppn.get_bytes_array();
for i in bytes_array {
*i = 0;
}
Self { ppn }
}
}
impl Drop for FrameTracker {
fn drop(&mut self) {
frame_dealloc(self.ppn);
}
}
--------
//实现了简单的栈式内存页管理
trait FrameAllocator {
fn new() -> Self;
fn alloc(&mut self) -> Option<PhysPageNum>;
fn dealloc(&mut self, ppn: PhysPageNum);
}
pub struct StackFrameAllocator {
current: usize,
end: usize,
recycled: Vec<usize>,
}
impl StackFrameAllocator {
pub fn init(&mut self, l: PhysPageNum, r: PhysPageNum) {
self.current = l.0;
self.end = r.0;
}
}
impl FrameAllocator for StackFrameAllocator {
fn new() -> Self {
Self {
current: 0,
end: 0,
recycled: Vec::new(),
}
}
fn alloc(&mut self) -> Option<PhysPageNum> {
if let Some(ppn) = self.recycled.pop() {
Some(ppn.into())
} else {
if self.current == self.end {
None
} else {
self.current += 1;
Some((self.current - 1).into())
}
}
}
fn dealloc(&mut self, ppn: PhysPageNum) {
let ppn = ppn.0;
// validity check
if ppn >= self.current || self.recycled
.iter()
.find(|&v| {*v == ppn})
.is_some() {
panic!("Frame ppn={:#x} has not been allocated!", ppn);
}
// recycle
self.recycled.push(ppn);
}
}
type FrameAllocatorImpl = StackFrameAllocator;
KERNEL_SPACE.lock().activate()
在实现了内核动态内存分配 & 栈式物理页管理之后, 利用这些和页表一起,就能组成完整的地址空间.
在rcore的实现中,内核拥有单独的地址空间,每个app都有自己的用户地址空间,每个地址空间包括多个逻辑段(权限管理)
pub struct MemorySet {
page_table: PageTable,
areas: Vec<MapArea>,
}
因为MapArea的建立依赖pageTable,所以首先看下pageTable的实现,关于三级页表的原理可以查看指导书,这里只总结代码部分:
//因为 pageTable 本身的页表项也是需要占内存的,所以需要维护一个frames: Vec<FrameTracker>, 利用RAII 的思想,在页表释放时回收物理内存
pub struct PageTable {
root_ppn: PhysPageNum,
frames: Vec<FrameTracker>,
}
// 页表项的每一项我们会组织成一个PageTableEntry,实际上是一个usize的封装,占1字节.我们的页表每一页是512字节,所以每一个物理页上面可以放置512个PageTableEntry. 刚好三级页表的每一级页表最多有2^9=512个页表项,一个物理页刚好放置一张页表.
// 在页表项上按照sv39模式的解释,可以获取页表项的权限,可以取出对应的物理页号(下一级页表 或 管理的物理页)
#[derive(Copy, Clone)]
#[repr(C)]
pub struct PageTableEntry {
pub bits: usize,
}
impl PageTableEntry {
pub fn new(ppn: PhysPageNum, flags: PTEFlags) -> Self {
PageTableEntry {
bits: ppn.0 << 10 | flags.bits as usize,
}
}
pub fn empty() -> Self {
PageTableEntry {
bits: 0,
}
}
pub fn ppn(&self) -> PhysPageNum {
(self.bits >> 10 & ((1usize << 44) - 1)).into()
}
pub fn flags(&self) -> PTEFlags {
PTEFlags::from_bits(self.bits as u8).unwrap()
}
pub fn is_valid(&self) -> bool {
(self.flags() & PTEFlags::V) != PTEFlags::empty()
}
pub fn readable(&self) -> bool {
(self.flags() & PTEFlags::R) != PTEFlags::empty()
}
pub fn writable(&self) -> bool {
(self.flags() & PTEFlags::W) != PTEFlags::empty()
}
pub fn executable(&self) -> bool {
(self.flags() & PTEFlags::X) != PTEFlags::empty()
}
}
// pageTable 维护根节点地址用来区分不同页表,通过frames保存物理页的所有权
impl PageTable {
pub fn new() -> Self {
let frame = frame_alloc().unwrap();
PageTable {
root_ppn: frame.ppn,
frames: vec![frame],
}
}
/// Temporarily used to get arguments from user space.
pub fn from_token(satp: usize) -> Self {
Self {
root_ppn: PhysPageNum::from(satp & ((1usize << 44) - 1)),
frames: Vec::new(),
}
}
// 根据虚拟页号查询对应的根节点PageTableEntry,如果中间查询走不通,则新建中间页表
fn find_pte_create(&mut self, vpn: VirtPageNum) -> Option<&mut PageTableEntry> {
// if vpn.0 == 65537 as usize {
// println!("Create!!!");
// }
let idxs = vpn.indexes();
let mut ppn = self.root_ppn;
let mut result: Option<&mut PageTableEntry> = None;
for i in 0..3 {
let pte = &mut ppn.get_pte_array()[idxs[i]];
if i == 2 {
result = Some(pte);
break;
}
if !pte.is_valid() {
let frame = frame_alloc().unwrap();
*pte = PageTableEntry::new(frame.ppn, PTEFlags::V);
self.frames.push(frame);
}
ppn = pte.ppn();
}
result
}
// 根据虚拟页号仅查询对应的根节点PageTableEntry
fn find_pte(&self, vpn: VirtPageNum) -> Option<&PageTableEntry> {
let idxs = vpn.indexes();
let mut ppn = self.root_ppn;
let mut result: Option<&PageTableEntry> = None;
for i in 0..3 {
let pte = &ppn.get_pte_array()[idxs[i]];
if i == 2 {
result = Some(pte);
break;
}
if !pte.is_valid() {
return None;
}
ppn = pte.ppn();
}
result
}
// 映射一个VirtPageNum 到 PhysPageNum
#[allow(unused)]
pub fn map(&mut self, vpn: VirtPageNum, ppn: PhysPageNum, flags: PTEFlags) {
// println!("map_pte {}", vpn.0);
let pte = self.find_pte_create(vpn).unwrap();
assert!(!pte.is_valid(), "vpn {:?} is mapped before mapping", vpn);
*pte = PageTableEntry::new(ppn, flags | PTEFlags::V);
}
// 取消一个VirtPageNum 的 映射
#[allow(unused)]
pub fn unmap(&mut self, vpn: VirtPageNum) {
let pte = self.find_pte_create(vpn).unwrap();
assert!(pte.is_valid(), "vpn {:?} is invalid before unmapping", vpn);
*pte = PageTableEntry::empty();
}
// 根据虚拟页号仅查询对应的根节点PageTableEntry, 并clone
pub fn translate(&self, vpn: VirtPageNum) -> Option<PageTableEntry> {
self.find_pte(vpn)
.map(|pte| {pte.clone()})
}
pub fn find_vpn(&self, vpn: VirtPageNum) -> bool {
match self.find_pte(vpn) {
None => false,
Some(x) => x.is_valid(),
}
}
pub fn token(&self) -> usize {
8usize << 60 | self.root_ppn.0
}
}
接下来看MapArea的实现,MapArea是逻辑段的组织单位,数据段,代码段,栈段,都会创建自己的MapArea
//BTreeMap 管理的是实际使用的物理页帧,map_type控制映射方式,MapPermission控制逻辑段权限
pub struct MapArea {
vpn_range: VPNRange,
data_frames: BTreeMap<VirtPageNum, FrameTracker>,
map_type: MapType,
map_perm: MapPermission,
}
impl MapArea {
pub fn new(
start_va: VirtAddr,
end_va: VirtAddr,
map_type: MapType,
map_perm: MapPermission
) -> Self {
let start_vpn: VirtPageNum = start_va.floor();
let end_vpn: VirtPageNum = end_va.ceil();
Self {
vpn_range: VPNRange::new(start_vpn, end_vpn),
data_frames: BTreeMap::new(),
map_type,
map_perm,
}
}
// 恒等映射用在内核地址空间中,所以不需要新分配物理页,重复的insert会结束之前的ppn的生命周期,所以不会有内存浪费
pub fn map_one(&mut self, page_table: &mut PageTable, vpn: VirtPageNum) {
let ppn: PhysPageNum;
match self.map_type {
MapType::Identical => {
ppn = PhysPageNum(vpn.0);
}
MapType::Framed => {
let frame = frame_alloc().unwrap();
ppn = frame.ppn;
self.data_frames.insert(vpn, frame);
}
}
let pte_flags = PTEFlags::from_bits(self.map_perm.bits).unwrap();
page_table.map(vpn, ppn, pte_flags);
}
#[allow(unused)]
pub fn unmap_one(&mut self, page_table: &mut PageTable, vpn: VirtPageNum) {
match self.map_type {
MapType::Framed => {
self.data_frames.remove(&vpn);
}
_ => {}
}
page_table.unmap(vpn);
}
pub fn map(&mut self, page_table: &mut PageTable) {
for vpn in self.vpn_range {
self.map_one(page_table, vpn);
}
}
#[allow(unused)]
pub fn unmap(&mut self, page_table: &mut PageTable) {
for vpn in self.vpn_range {
self.unmap_one(page_table, vpn);
}
}
/// data: start-aligned but maybe with shorter length
/// assume that all frames were cleared before
pub fn copy_data(&mut self, page_table: &mut PageTable, data: &[u8]) {
assert_eq!(self.map_type, MapType::Framed);
let mut start: usize = 0;
let mut current_vpn = self.vpn_range.get_start();
let len = data.len();
loop {
let src = &data[start..len.min(start + PAGE_SIZE)];
let dst = &mut page_table
.translate(current_vpn)
.unwrap()
.ppn()
.get_bytes_array()[..src.len()];
dst.copy_from_slice(src);
start += PAGE_SIZE;
if start >= len {
break;
}
current_vpn.step();
}
}
}
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum MapType {
Identical,
Framed,
}
bitflags! {
pub struct MapPermission: u8 {
const R = 1 << 1;
const W = 1 << 2;
const X = 1 << 3;
const U = 1 << 4;
}
}
最后,来看一下地址空间的组织,用户和内核的地址空间都由多个段和其对应的页表组成
impl MemorySet {
pub fn new_bare() -> Self {
Self {
page_table: PageTable::new(),
areas: Vec::new(),
}
}
pub fn token(&self) -> usize {
self.page_table.token()
}
/// Assume that no conflicts. 向地址空间添加一个逻辑段(并分配页面)
pub fn insert_framed_area(&mut self, start_va: VirtAddr, end_va: VirtAddr, permission: MapPermission) {
self.push(MapArea::new(
start_va,
end_va,
MapType::Framed,
permission,
), None);
}
fn push(&mut self, mut map_area: MapArea, data: Option<&[u8]>) {
map_area.map(&mut self.page_table);
if let Some(data) = data {
map_area.copy_data(&mut self.page_table, data);
}
self.areas.push(map_area);
}
/// Mention that trampoline is not collected by areas.
// 所有地址空间的最高位置都是跳板的映射,保证了在用户&内核地址空间都从同样的虚拟地址能访问trap的入口代码
fn map_trampoline(&mut self) {
self.page_table.map(
VirtAddr::from(TRAMPOLINE).into(),
PhysAddr::from(strampoline as usize).into(),
PTEFlags::R | PTEFlags::X,
);
}
/// Without kernel stacks.
pub fn new_kernel() -> Self {
let mut memory_set = Self::new_bare();
// map trampoline
memory_set.map_trampoline();
// map kernel sections
println!(".text [{:#x}, {:#x})", stext as usize, etext as usize);
println!(".rodata [{:#x}, {:#x})", srodata as usize, erodata as usize);
println!(".data [{:#x}, {:#x})", sdata as usize, edata as usize);
println!(".bss [{:#x}, {:#x})", sbss_with_stack as usize, ebss as usize);
println!("mapping .text section");
memory_set.push(MapArea::new(
(stext as usize).into(),
(etext as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::X,
), None);
println!("mapping .rodata section");
memory_set.push(MapArea::new(
(srodata as usize).into(),
(erodata as usize).into(),
MapType::Identical,
MapPermission::R,
), None);
println!("mapping .data section");
memory_set.push(MapArea::new(
(sdata as usize).into(),
(edata as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
), None);
println!("mapping .bss section");
memory_set.push(MapArea::new(
(sbss_with_stack as usize).into(),
(ebss as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
), None);
println!("mapping physical memory");
memory_set.push(MapArea::new(
(ekernel as usize).into(),
MEMORY_END.into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
), None);
memory_set
}
/// Include sections in elf and trampoline and TrapContext and user stack,
/// also returns user_sp and entry point.
pub fn from_elf(elf_data: &[u8]) -> (Self, usize, usize) {
let mut memory_set = Self::new_bare();
// map trampoline
memory_set.map_trampoline();
// map program headers of elf, with U flag
let elf = xmas_elf::ElfFile::new(elf_data).unwrap();
let elf_header = elf.header;
let magic = elf_header.pt1.magic;
assert_eq!(magic, [0x7f, 0x45, 0x4c, 0x46], "invalid elf!");
let ph_count = elf_header.pt2.ph_count();
let mut max_end_vpn = VirtPageNum(0);
for i in 0..ph_count {
let ph = elf.program_header(i).unwrap();
if ph.get_type().unwrap() == xmas_elf::program::Type::Load {
let start_va: VirtAddr = (ph.virtual_addr() as usize).into();
let end_va: VirtAddr = ((ph.virtual_addr() + ph.mem_size()) as usize).into();
let mut map_perm = MapPermission::U;
let ph_flags = ph.flags();
if ph_flags.is_read() { map_perm |= MapPermission::R; }
if ph_flags.is_write() { map_perm |= MapPermission::W; }
if ph_flags.is_execute() { map_perm |= MapPermission::X; }
let map_area = MapArea::new(
start_va,
end_va,
MapType::Framed,
map_perm,
);
max_end_vpn = map_area.vpn_range.get_end();
// println!("{:#X} ~ {:#X}", start_va.0 , end_va.0);
memory_set.push(
map_area,
Some(&elf.input[ph.offset() as usize..(ph.offset() + ph.file_size()) as usize])
);
}
}
// map user stack with U flags
let max_end_va: VirtAddr = max_end_vpn.into();
let mut user_stack_bottom: usize = max_end_va.into();
// guard page
user_stack_bottom += PAGE_SIZE;
let user_stack_top = user_stack_bottom + USER_STACK_SIZE;
memory_set.push(MapArea::new(
user_stack_bottom.into(),
user_stack_top.into(),
MapType::Framed,
MapPermission::R | MapPermission::W | MapPermission::U,
), None);
// println!("{:#X} ~ {:#X}", user_stack_bottom, user_stack_top);
// map TrapContext
memory_set.push(MapArea::new(
TRAP_CONTEXT.into(),
TRAMPOLINE.into(),
MapType::Framed,
MapPermission::R | MapPermission::W,
), None);
// println!("{:#X} ~ {:#X}", TRAP_CONTEXT , TRAMPOLINE);
(memory_set, user_stack_top, elf.header.pt2.entry_point() as usize)
}
pub fn activate(&self) {
let satp = self.page_table.token();
unsafe {
satp::write(satp);
llvm_asm!("sfence.vma" :::: "volatile");
}
}
pub fn translate(&self, vpn: VirtPageNum) -> Option<PageTableEntry> {
self.page_table.translate(vpn)
}
pub fn find_vpn(&self, vpn: VirtPageNum) -> bool {
self.page_table.find_vpn(vpn)
}
pub fn munmap(&mut self, vpn: VirtPageNum){
//为了简单,参数错误时不考虑内存的恢复和回收。
// for i in 0..(self.areas.len()) {
// self.areas[i].unmap_one(&mut self.page_table, vpn);
// }
self.areas[0].unmap_one(&mut self.page_table, vpn);
}
}
build & trap & task 的变化
最后,build & trap & task 都因为虚拟内存的出现而或多或少需要改变实现方式
-
build
在链接的时候可以保存完整ELF格式了
在最终重定位的时候,可以起始地址设为0了 -
trap的变化
U2S:因为没法在不破坏寄存器的情况下切换到内核地址空间并完成trap的保存,所以必须在用户地址空间完成trapContext的保存,所以trap.s就需要在用户和内核地址空间都能访问,所以就需要都两种地址空间都有页面映射到trap.S
在trap.S执行的过程中,指令必须平滑,也就是在切换地址空间之后,下一条待执行指令的虚拟地址在用户地址空间和内核地址空间指向的物理地址都是一致的.所以我们将跳板在用户 & 内核地址空间中都放置在最高位置,保证虚拟地址相同. 这样一来trap.s和trap_handle函数的相对位置就不和链接时一样了, 所以这里就不能用call指令,只能使用 jr 跳转了
S2U:__restore需要两个参数:第一个是 Trap 上下文在应用 地址空间中的位置,这个对于所有的应用来说都是相同的,由调用规范在 a0 寄存器中传递;第二个则是即将回到的应用的地址空间 的 token ,在 a1 寄存器中传递
.altmacro
.macro SAVE_GP n
sd x\n, \n*8(sp)
.endm
.macro LOAD_GP n
ld x\n, \n*8(sp)
.endm
.section .text.trampoline
.globl __alltraps
.globl __restore
.align 2
__alltraps:
csrrw sp, sscratch, sp
# now sp->*TrapContext in user space, sscratch->user stack
# save other general purpose registers
sd x1, 1*8(sp)
# skip sp(x2), we will save it later
sd x3, 3*8(sp)
# skip tp(x4), application does not use it
# save x5~x31
.set n, 5
.rept 27
SAVE_GP %n
.set n, n+1
.endr
# we can use t0/t1/t2 freely, because they have been saved in TrapContext
csrr t0, sstatus
csrr t1, sepc
sd t0, 32*8(sp)
sd t1, 33*8(sp)
# read user stack from sscratch and save it in TrapContext
csrr t2, sscratch
sd t2, 2*8(sp)
# load kernel_satp into t0
ld t0, 34*8(sp)
# load trap_handler into t1
ld t1, 36*8(sp)
# move to kernel_sp
ld sp, 35*8(sp)
# switch to kernel space
csrw satp, t0
sfence.vma
# jump to trap_handler
jr t1
__restore:
# a0: *TrapContext in user space(Constant); a1: user space token
# switch to user space
csrw satp, a1
sfence.vma
csrw sscratch, a0
mv sp, a0
# now sp points to TrapContext in user space, start restoring based on it
# restore sstatus/sepc
ld t0, 32*8(sp)
ld t1, 33*8(sp)
csrw sstatus, t0
csrw sepc, t1
# restore general purpose registers except x0/sp/tp
ld x1, 1*8(sp)
ld x3, 3*8(sp)
.set n, 5
.rept 27
LOAD_GP %n
.set n, n+1
.endr
# back to user stack
ld sp, 2*8(sp)
sret
- task
现在需要在TaskControlBlock维护更多信息,供trap,和task切换时使用
pub struct TaskControlBlock {
pub task_cx_ptr: usize,
pub task_status: TaskStatus,
pub memory_set: MemorySet,
pub trap_cx_ppn: PhysPageNum,
pub base_size: usize,
}