保障系统安全和多应用支持是操作系统的两个核心目标,本章从这两个目标出发,思考如何设计应用程序,并进一步展现操作系统的一系列新功能:

  • 构造包含操作系统内核和多个应用程序的单一执行程序
  • 通过批处理支持多个程序的自动加载和运行
  • 操作系统利用硬件特权级机制,实现对操作系统自身的保护
  • 实现特权级的跨越
  • 支持跨特权级的系统调用功能

批处理系统(Batch System),它可以用来管理无需或仅需少量用户交互即可运行的程序,在资源允许的情况下它可以自动安排程序的执行,这被称为批处理作业,此名词源自二十世纪60年代的大型机时代。批处理系统的核心思想是:将多个程序打包到一起输入计算机,当一个程序运行结束后,计算机自动加载下一个程序到内存并执行。

本片代码:

特权级机制

为了保护我们的批处理系统不受到出错应用程序的影响并全程稳定工作,单凭软件实现是很难做到的,而是需要CPU提供一种特权级隔离机制,使CPU在执行应用程序和操作系统内核的指令时处于不同的特权级。

特权级的软硬件协同设计

实现特权级机制的根本原因是应用程序运行的安全性不可充分信任。由于操作系统和应用程序两者通过编译器形成一个单一执行程序来执行,导致即使是应用本身的问题,也会牵连操作系统,导致整个计算机系统出现问题。

所以,计算机科学家和工程师想出了一个办法:让相对安全可靠的操作系统运行在一个硬件保护的安全执行环境中,不受应用程序的破坏;而让应用程序运行在另外一个无法破坏操作系统的受限执行环境中。

为了确保操作系统的安全,对应用程序而言,需要限制的主要有两个方面:

  • 应用程序不能访问任意的地址空间
  • 应用程序不能执行某些可能破坏计算机系统的指令

除此之外,还需要确保应用程序能够得到操作系统的服务,即应用程序和操作系统还需要有交互的手段。使得低特权级机制只能做高特权级软件允许它做的,且超出低特权级能力的功能必须寻求高特权级软件的帮助。

为了实现这样的特权级机制,需要进行软硬件协同设计。一个比较简洁的方法就是,处理器设置两个不同安全等级的执行环境:

  • 用户态特权级的执行环境
  • 内核态特权级的执行环境

且明确指出可能破坏计算机系统的内核态特权级指令子集,内核态特权级指令子集只能在内核态特权级的执行环境中执行。处理器在执行指令前会进行特权级安全检查,如果在用户态执行环境中执行内核态特权级指令,会产生异常。

为了让应用程序获得操作系统的函数服务,采用传统的函数调用方(即通常的callret指令或指令组合)将会绕过硬件的特权级保护检查。所以可以设计新的机器指令:执行环境调用(Execution Environment Call 简称 ecall)和执行环境返回(Excution Environment Return 简称 eret)

  • ecall:具有用户态到内核态到执行环境切换能力的函数调用指令
  • eret:具有内核态到用户态的执行环境切换能力的函数返回指令

硬件具有了这样的机制后,还需要操作系统的配合才能最终完成对操作系统自身的保护。

  • 首先,操作系统需要提供相应的功能代码,能在执行eret前准备和恢复用户态执行应用程序的上下文。
  • 其次,在应用程序调用ecall指令后,能够检查应用程序的系统调用参数,确保参数不会破坏操作系统。

RISC-V特权级架构

RISC-V架构一共定义了4种特权级:

级别编码名称
000用户/应用模式(U, User/Application)
101监督模式(S, Supervistor)
210虚拟监督模式(H, Hypervistor)
311机器模式(M, Machine)

级别数值越大,特权级越高,掌控硬件的能力越强。

在CPU硬件层面,除了M模式必须存在外,其他模式可以不存在。RISC-V架构中,只有M模式是必须实现的,剩下的特权级则可以根据跑在CPU上应用的实际需求进行调整:

  • 简单的嵌入式应用只需要实现M模式
  • 带有一定保护能力的嵌入式系统需要实现M/U模式
  • 复杂的多任务系统则需要实现M/S/U模式
  • 到目前为止,H模式特权规范还没有完全制定好

从特权级架构的角度看待执行环境栈:

PrivilegeStack

其中,白色块表示一层执行环境,黑色块表示相邻两层执行环境之间的接口。其中操作系统内核代码运行在S模式上;应用程序运行在U模式上。

运行在M模式上的软件被称为监督模式执行环境(SEE, Supervistor Execution Environment),比如在操作系统运行前负责加载操作系统的Bootloader-RustSBI。站在运行在S模式上的软件视角来看,它的下面也需要一层执行环境支撑,因此被命名为SEE,它需要在相比S模式更高的特权级下运行,一般情况下SEE在M模式上执行。

执行环境的功能之一是在它支持的上层软件执行之前进行一些初始化工作。之前提到的引导加载程序会在加电之后对整个系统进行初始化,它实际上就是SEE功能的一部分。也就是说在RISC-V架构上的引导加载程序一般运行在M模式上。

在上一节中,实现了简单的操作系统,它和应用程序全程运行在S模式下,应用程序很容易破坏没有任何保护的执行环境-操作系统。在之后,我们会涉及RISC-V的M/S/U三种特权级:

  • 应用程序和用户态支持库运行在U模式的最低特权级
  • 操作系统内核运行在S模式特权级,形成支撑应用程序和用户态支持的执行环境
  • 在之前提到的bootloader-RurstSBI实际上是运行在更底层的M模式特权级下的软件,是操作系统内核的执行环境。

执行环境的另一种功能是对上层软件的执行进行监控管理。可以理解为,当上层软件执行出现了一些异常或者特殊情况时,导致需要用到执行环境中提供的功能,因此需要暂停上层软件的执行,转而运行执行环境的代码。

由于上层软件和执行环境被设计为运行在不同的特权级,这个过程也往往**(不一定)伴随着CPU的特权级切换**。当执行环境的代码运行结束后,我们需要回到上层软件暂停的位置继续执行。在RISC-V架构中,这种与常规控制流不同的异常控制流(ECF, Exception Control Flow)被称为异常(Exception),在RISC-V语境下的Trap种类之一。

用户态应用直接触发从用户态到内核态的异常的原因总体上可以分为两种:

  • 用户态软件为获得内核态操作系统的服务功能而执行特殊指令
  • 在执行某条指令期间发生了错误并被CPU检测到,例如执行了用户态不允许执行的指令或者其他错误

下表是RISC-V特权级规范定义的可能会导致从低特权级到高特权级到各种异常

InterruptException CodeDescription
00Instruction address misaligned
01Instruction access fault
02Illegal instruction
03Breakpoint
04Load address misaligned
05Load access fault
06Stroe/AMO address misaligned
07Store/AMO access fault
08Environment call from U-mode
09Environment call from S-mode
011Environment call from M-mode
012Instruction page fault
013Load page fault
015Stroe/AMO page fault

其中,断点(Breakpoint)执行环境调用(Enviroment call)两种异常(这种有意而为之的指令称为陷入trap类指令)是通常在上层软件中执行一条特定的指令触发的:执行ebreak这条指令之后就会触发断点陷入异常;而执行ecall这条指令之后则会随着CPU当前所处特权级而触发不同的异常。

执行环境调用ecall,这是一种很特殊的陷入类的指令,在之前从特权级架构看待执行环境栈这张图中,相邻两特权级软件之间的接口正是基于这种陷入机制实现的。M模式软件SEE和S模式的内核之间的接口被称为监督模式二进制接口(Supervistor Binary Interface, SBI),而内核和U模式的应用程序之间的接口被称为应用程序二进制接口(Application Binary Interface, ABI),它还有一个更加通俗的名字:系统调用(syscall ,System Cal)。而之所以叫做二进制接口,是因为它与高级编程语言的内部调用接口不同,是机器/汇编指令级的一种接口。

事实上M/S/U三个特权级的软件可分别由不同的编程语言实现。即使是用同一种汇编语言实现,其调用也不是普通的函数调用,而是陷入异常控制流,在该过程中切换CPU特权级。因此只有将接口下降到机器/汇编指令级才能满足其跨高级语言的通用性和灵活性。

可以看到,在这样的架构之下,每层特权级的软件都只能做高特权级软件允许它做的,并且不会产生什么撼动高特权级软件的情况,一旦低特权级软件的要求超出了其能力范围,就必须寻求高特权级软件的帮助,否则就是一种异常行为了。因此,在软件执行过程中,我们经常可以看到特权级切换:

EnvironmentCallFlow

其他的异常则一般是在执行某一条指令的时候发生了某种错误,例如除零、无效地址访问、无效指令等;或处理器认为处于当前特权级下执行等当前指令是高特权级指令或会访问不应该访问的高特权级的资源(可能危害系统)。碰到这种情况,就需要将控制权转交给高特权级的软件来处理:

  • 当错误/异常恢复后,则重新回到低优先级软件去执行
  • 如不能恢复错误/异常,那高特权级软件可以杀死和清除低特权级软件,避免破坏整个执行环境

RISC-V的特权指令

与特权级无关的一般的指令和通用寄存器x0~x31在任何特权级都可以执行。而每个特权级都对应一些特殊指令和控制状态寄存器(Control and Status Register, CSR),来控制该特权级的某些行为并描述其状态。当然特权指令不仅具有读写CSR的指令,还有其他功能的特权指令。

如果处于低特权级状态的处理器执行了高特权级的指令,会产生非法指令错误的异常。这样,位于高特权级的执行环境能够得知低特权级的软件出现了错误,这个错误一般是不可恢复的,此时执行环境将低特权级的软件终止,这在某种程度上体现了特权级保护机制的作用。

在RISC-V中,会有两类属于高特权级S模式的特权指令:

  • 指令本身属于高特权级的指令,例如sret,表示从S模式返回到U模式
  • 指令访问了S模式特权级下才能访问的寄存器或内存,例如表示S模式系统状态的控制状态寄存器sstatus

RISC-V S模式特权指令:

指令含义
sret从S模式返回U模式:在U模式下执行会产生非法指令异常
wfi处理器在空闲时进入低功耗状态等待中断:在U模式下执行会产生非法指令异常
sfence.vma刷新TLB缓存:在U模式下执行会产生非法指令异常
访问S模式CSR的指令通过访问sepc/stvec/scause/sscartch/stval/sstatus/satp等CSR来改变系统状态:在U模式下执行会产生非法指令异常

实现应用程序

接下来将设计实现被批处理系统逐个加载并运行的应用程序。应用程序的设计实现要点是:

  • 应用程序的内存布局
  • 应用程序发出的系统调用

从某种程度上讲,这里设计的应用程序与第一章中的最小用户态执行环境有很多相同的地方。即设计一个应用程序和基本的支持的功能库,这样应用程序在用户态通过操作系统提供的服务完成自身的任务。

应用程序设计

应用程序、用户库(由入口函数、初始化函数、I/O函数和系统调用接口等多个rust文件组成)放在根目录的user目录下,它和上一篇的裸机应用不同之处主要在项目的目录文件结构和内存布局上:

  • user/src/bin/*.rs:应用程序
  • user/src/*.rs:用户库
  • user/src/linker.ld:应用程序的内存布局说明

项目结构

user/src/bin目录下有多个文件,每个文件是一个应用程序,分别是:

  • 00hello_wordl:在屏幕上打印一行Hello, world!
  • 01store_fault:访问一个非法的物理地址,测试批处理系统是否会被该错误影响
  • 02power:不断在计算操作和打印字符串操作之间进行特权级切换
  • 03priv_inst:尝试在用户态执行内核态的特权级指令sret
  • 04priv_csr:尝试在用户态修改内核态CSR sstatus
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// user/src/bin/00hello_world.rs
#![no_std]
#![no_main]

#[macro_use]
extern crate user_lib;

#[no_mangle]
fn main() -> i32 {
    println!("Hello, world!");
    0
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// user/src/bin/01store_fault.rs
#![no_std]
#![no_main]

#[macro_use]
extern crate user_lib;

#[no_mangle]
fn main() -> i32 {
    println!("Into Test store_fault, we will insert an invalid store operation...");
    println!("Kernel should kill this application!");
    unsafe {
        core::ptr::null_mut::<u8>().write_volatile(0);
    }
    0
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// user/src/bin/02power.rs
#![no_std]
#![no_main]

#[macro_use]
extern crate user_lib;

const SIZE: usize = 10;
const P: u32 = 3;
const STEP: usize = 100000;
const MOD: u32 = 10007;

#[no_mangle]
fn main() -> i32 {
    let mut pow = [0u32; SIZE];
    let mut index: usize = 0;
    pow[index] = 1;
    for i in 1..=STEP {
        let last = pow[index];
        index = (index + 1) % SIZE;
        pow[index] = last * P % MOD;
        if i % 10000 == 0 {
            println!("{}^{}={}(MOD {})", P, i, pow[index], MOD);
        }
    }
    println!("Test power OK!");
    0
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// user/src/bin/03priv_inst.rs
#![no_std]
#![no_main]

#[macro_use]
extern crate user_lib;

use core::arch::asm;

#[no_mangle]
fn main() -> i32 {
    println!("Try to execute privileged instruction in U Mode");
    println!("Kernel should kill this application!");
    unsafe {
        asm!("sret");
    }
    0
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// user/src/bin/04priv_csr.rs
#![no_std]
#![no_main]

#[macro_use]
extern crate user_lib;

use riscv::register::sstatus::{self, SPP};

#[no_mangle]
fn main() -> i32 {
    println!("Try to access privileged CSR in U Mode");
    println!("Kernel should kill this application!");
    unsafe {
        sstatus::set_spp(SPP::User);
    }
    0
}

批处理系统会按照文件名开头的数字编号从小到大的顺序加载并运行它们。

每个应用程序的实现都在对应的单个文件中。打开其中一个文件,会看到只有一个main函数和若干相关函数所形成的整个应用程序逻辑。

user/src/lib.rs中定义了用户库的入口点_start

1
2
3
4
5
6
7
8
// user/src/lib.rs
#[no_mangle]
#[link_section = ".text.entry"]
pub extern "C" fn _start() -> ! {
    clear_bss();
    exit(main());
    panic!("unreachable after sys_exit!");
}

第3行使用Rust宏,将_start这段代码编译后的汇编代码放在一个名为.text.entry的代码段中,方便我们在后续链接的时候调整它的位置使得它能够作为用户库的入口。

从第4行开始,进入用户库入口之后,与上一篇一样,手动清空需要零初始化的.bss段;然后调用main函数得到一个类型为i32的返回值,最后调用用户库提供的exit接口退出应用程序,并将main函数的返回值告知批处理系统。

lib.rs中可以看到另一个main

1
2
3
4
5
6
// user/src/lib.rs
#[linkage = "weak"]
#[no_mangle]
fn main() -> i32 {
    panic!("Cannot find main!");
}

第2行,我们使用Rust的宏将其函数main标志为弱链接。这样最后链接的时候,虽然在lib.rs和应用程序的文件中都会有main符号,但由于lib.rs中的main符号是弱链接,链接器会使用应用程序的main。这里主要是进行某种程度上的保护,如果应用程序的文件中找不到任何main,那么编译也能够通过,但在运行时会报错。

为了支持上述的链接操作,需要引入:

1
2
// user/src/lib.rs
#![feature(linkage)]

内存布局

user/.cargo/config中,我们和第一章一样设置链接时使用链接脚本user/src/linker.ld

linker.ld中,我们做的重要的事情是:

  • 将程序起始物理地址调整为0x80400000,上述五个应用程序都会被加载到这个物理地址上运行
  • _start所在的.text.entry放在整个程序的开头,也就是说批处理系统只要在加载之后跳转到0x80400000就已经进入了用户库的入口点,并会在初始化之后跳转到应用程序主逻辑
  • 提供了最终生成可执行文件的.bss段的起始和终止地址,方便clear_bss函数调用
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// user/src/linker.ld
OUTPUT_ARCH(riscv)
ENTRY(_start)

BASE_ADDRESS = 0x80400000;

SECTIONS
{
    . = BASE_ADDRESS;
    .text : {
        *(.text.entry)
        *(.text .text.*)
    }
    .rodata : {
        *(.rodata .rodata.*)
        *(.srodata .srodata.*)
    }
    .data : {
        *(.data .data.*)
        *(.sdata .sdata.*)
    }
    .bss : {
        start_bss = .;
        *(.bss .bss.*)
        *(.sbss .sbss.*)
        end_bss = .;
    }
    /DISCARD/ : {
        *(.eh_frame)
        *(.debug*)
    }
}

系统调用

在子模块syscall中,应用通过ecall调用批处理系统提供的接口,由于应用程序运行在用户态,ecall指令会触发执行环境调用异常,并Trap进入S模式执行批处理系统针对这个异常特别提供的服务代码。由于这个接口处于S模式的批处理系统和U模式的应用程序之间,这个接口可以被称为ABI或者系统调用。

在本篇中,应用程序和批处理系统之间按照API的结构,约定如下两个系统调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 功能:将内存中缓冲区中的数据写入文件
// 参数: fd表示待写入文件的文件描述符
//		 buf表示内存中缓冲区的起始地址
//		 len表示内存中缓冲区的长度
// 返回值:返回成功写入的长度
// syscall ID:64
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize;

// 功能:退出应用程序并返回值告知批处理系统
// 参数: xstate表示应用程序的返回值
// 返回值:该系统调用无需返回
// syscall ID:93
pub fn sys_exit(exit_code: i32) -> isize;

系统调用实际上是汇编指令级的二进制接口,在实际调用的时候,我们需要按照RISC-V调用规范(即ABI格式)在合适的寄存器中放置系统调用的参数,然后执行ecall指令出发Trap。在Trap回到U模式的应用程序代码之后,会从ecall的下一条指令继续执行,同时我们能够按照调用规范在合适的寄存器中读取返回值。

RISC-V寄存器编号从0~31,表示为x0~x31。其中

  • x10~x17:对应a0~a7
  • x1:对应ra

在RISC-V调用规范中,和函数调用的ABI情形类似,约定寄存器a0~a6保存系统调用的参数,a0保存系统调用的返回值。有些许不同的是寄存器a7用来传递syscall ID,这是因为所有的syscall都是通过ecall指令触发的,除了各输入参数之外我们还额外需要一个寄存器来保存要请求那个系统调用。由于这超出了Rust语言的表达能力,我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和ecall指令的插入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// user/src/syscall.rs
use core::arch::asm;

fn syscall(id: usize, args: [usize; 3]) -> isize {
    let mut ret: isize;
    unsafe {
        asm!(
            "ecall",
            inlateout("x10") args[0] => ret,
            in("x11") args[1],
            in("x12") args[2],
            in("x17") id
        );
    }
    ret
}

我们将所有的系统调用都封装成了syscall函数,可以看到它支持传入syscall ID和3个参数。在 syscall中,从第6行开始的asm!宏嵌入ecall指令来触发系统调用。

从RISC-V调用规范来看,就像函数有着输入参数和返回值一样,ecall指令同样有着输入和输出寄存器:a0~a2a7作为输入寄存器分别表示系统调用参数和系统调用ID,而当系统调用返回后,a0作为输出寄存器保存系统调用的返回值。在函数上下文中,输入参数数组args和变量id保存系统输调用参数和系统调用ID,而变量ret保存系统调用返回值,它也是函数syscall的输出/返回值。

那么如何将变量绑定到寄存器则成了一个难题:比如,在ecall指令被执行之前,我们需要将寄存器a7的值设置为变量id的值,那么我们首先需要知道目前变量id的值保存在哪里,它可能在栈上也有可能在某个寄存器中。作为程序员我们并不知道这些只有编译器才知道的信息,因此我们只能在编译器的帮助下完成变量到寄存器的绑定。

现在来看asm!宏的格式:首先在第8行是我们要插入的汇编代码段本身,这里我们只插入一行ecall指令,不过它可以支持同时插入多条指令。从第9行开始我们在编译器的帮助下将输入/输出变量绑定到寄存器。例如第10行的in("x11") args[1]表示将输入参数args[1]绑定到ecall的输入寄存器x11a1中,编译器自动插入相关指令并保证在ecall指令被执行之前寄存器a1的值与args[1]的值相同。输入参数arg[2]id到输入寄存器的绑定也是同样的方式,但是这里比较特殊的是a0寄存器,它同时作为输入和输出,因此我们将in改成inlateout,并在行末到变量部分使用{in_var} => {out_var}的格式,其中{in_var}{out_var}分别表示上下文中的输入变量和输出变量。

有些时候不必将变量绑定到固定的寄存器,此时asm!宏可以自动完成寄存器分配。某些汇编代码段还会带来一些编译器无法预知的副作用,这种情况下需要asm!中通过options告知寄存器这些可能的副作用,这样可以帮助编译器在避免出错的情况下更高效的分配寄存器。

对于sys_writesys_exit只需将syscall进行封装:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// user/src/syscall.rs
const SYSCALL_WRITE: usize = 64;
const SYSCALL_EXIT: usize = 93;

pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
    syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}

pub fn sys_exit(exit_code: i32) -> isize {
    syscall(SYSCALL_EXIT, [exit_code as usize, 0, 0])
}

注意sys_write使用一个&[u8]切片类型来描述缓冲区,这是一个胖指针(Fat Pointer),里面既包含缓冲区的起始地址,还包含缓冲区的长度。

我们将上述两个系统调用在用户库user_lib中进一步封装,从而更加接近在Linux等平台下的实际系统调用接口:

1
2
3
4
5
6
7
8
9
// user/src/lib.rs
use syscall::*;

pub fn write(fd: usize, buf: &[u8]) -> isize {
    sys_write(fd, buf)
}
pub fn exit(exit_code: i32) -> isize {
    sys_exit(exit_code)
}

我们将console子模块中的Stdout::write_str改成基于write的实现,且传入的fd参数设置为1,它代表标准输出,也就是输出到屏幕。目前不需要考虑其他的fd选取情况。这样,应用程序的println!宏借助系统调用变得可以用了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// user/src/console.rs
use super::write;
use core::fmt::{self, Write};

struct Stdout;

const STDOUT: usize = 1;

impl Write for Stdout {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        write(STDOUT, s.as_bytes());
        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)+)?));
    }
}

exit接口则在用户库中的_start内使用,当应用程序主逻辑main返回之后,使用它退出应用并将返回值告知底层的批处理系统。

编译生成应用程序二进制码

简单介绍一下user/Makefile

  • 对于src/bin下的每个应用程序,在target/riscv64gc-unknown-none-elf/release目录下生成一个同名的ELF可执行文件
  • 使用objcopy二进制工具,将上一步生成的ELF文件删除所有ELF header和符号得到.bin后缀的纯二进制镜像文件。它们将被链接进内核并由内核在合适的时机加载到内存

实现批处理操作系统

在批处理操作系统中,每当一个应用执行完毕,我们需要将下一个要执行的应用的代码和数据加载到内存。

将应用程序链接到内核

我们需要将应用程序的二进制镜像文件作为内核的数据段链接到内核里面,因此内核需要知道包含的应用程序的数量和他们的位置,这样才能够在运行时对他们进行管理并能够加载到物理内存:

1
2
// os/src/main.rs
global_asm!(include_str!("link_app.S"));

这里我们引入了一段汇编代码link_app.S,它一开始并不存在,而是在构建操作系统时自动生成的。这里我们需要增加一个构建脚本,在项目根目录添加一个build.rs文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 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(())
}

Cargo会先编译和执行该构建脚本,然后再去构建整个项目。使用make build构建内核时,上述的汇编代码link_app.S就生成了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 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"
a

找到并加载应用程序二进制码

我们在osbatch子模块中实现一个应用管理器,它的主要功能是:

  • 保存应用数量和各自的位置信息,以及当前执行到第几个应用
  • 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行

应用管理器AppManager结构体定义如下:

1
2
3
4
5
6
7
8
9
// os/src/batch.rs

const MAX_APP_NUM: usize = 16;

struct AppManeger {
    num_app: usize,
    current_app: usize,
    app_start: [usize; MAX_APP_NUM + 1],
}

在这里,应用管理器需要保存和维护的信息都在AppManager里面。这样设计的原因在于:我们希望将AppManager实例化为一个全局变量,使得任何函数都可以访问。然后AppManager中的current_app字段表示当前执行的第几个应用,它是一个可修改的变量,会在系统运行期间发生变。因此在声明全局变量时,采用static mut是一种比较自然的方法,但是在Rust中,任何对于static mut变量的访问控制都是unsafe的,而我们要在编程中尽量避免使用unsafe,这样才能让编译器负责更多的安全性检查。

因此我们需要考虑如何在尽量避免触及unsafe的情况下仍能声明并使用可变的全局变量。如果单独使用static而去掉mut的话,我们可以声明一个初始化之后就不可变的全局变量,但是我们需要AppManager里面的内容在运行时发生变化。这就涉及到了Rust中

内部可变性(Interior Mutability),即在变量自身不可变或仅在不可变借用的情况下仍能修改绑定到变量上的值。

我们可以使用RefCell包裹AppManager,这样RefCell无需被声明为mut,同时被包裹的AppManager也可变。但是RefCell并未被标记为Sync,因此Rust编译器认为它不能被安全的在线程间共享,也就不能作为全局变量使用。所以我们需要在RefCell的基础上,再封装一个UPSafeCell,它名字的含义是:允许我们在单核上安全使用可变全局变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// os/src/sync/up.rs

use core::cell::{RefCell, RefMut};

pub struct UPSafeCell<T> {
    inner: RefCell<T>,
}

unsafe impl<T> Sync for UPSafeCell<T> {}

impl<T> UPSafeCell<T> {
    pub unsafe fn new(value: T) -> Self {
        Self {
            inner: RefCell::new(value),
        }
    }

    pub fn exclusive_access(&self) -> RefMut<'_, T> {
        self.inner.borrow_mut()
    }
}
1
2
3
4
5
// os/src/sync/mod.rs

mod up;

pub use up::UPSafeCell;

UPSafeCel对于RefCell简单进行封装,它和RefCell一样提供内部可变性和运行时借用检查,只是更加严格:调用exclusive_access可以得到它包裹的数据的独占访问权。因此当我们要访问数据时,需要首先调用exclusive_access获得数据的可变借用标记,通过它可以完成数据的读写,在操作完成之后我们需要销毁这个标记,此后才能开始对该数据的下一次访问。相比RefCell它不再允许多个读操作同时存在。

up.rs的这段代码出现了两个unsafe

  • 首先new被声明为一个unsafe函数,是因为我们希望使用者在创建一个UPSafeCell时保证在访问UPSafeell内包裹的数据时始终不违背上述模式:即访问之前调用exclusive_access,访问之后销毁借用标记再进行下一次访问。
  • 另外,将UPSafeCell标记为Sync使得它可以作为一个全局变量。这是unsafe行为,因为编译器无法确定我们的UPSafeCell能否安全的再多线程共享。

接下来,初始化AppManager的全局实例APP_MANAGER

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// os/src/batch.rs
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,
            }
        })
    };
}

初始化的逻辑很简单,就是找到link_app.S中提供的符号_num_app,并从这里解析出应用数量以及各个应用的起始地址。

这里使用了外部库lazy_static提供的lazy_static!宏。引入这个外部库,需要加入依赖:

1
2
3
4
// os/Cargo.toml

[dependencies]
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }

lazy_static!宏提供了全局变量的运行时初始化功能。一般情况下,全局变量必须在编译时设置一个初始值,但是有些全局变量依赖与运行期间才能得到的数据作为初始值。这导致这些全局变量需要在运行时发生变化,即需要重新设置初始值之后才能使用。如果我们手动实现,需要把这种全局变量声明为static mut并衍生出很多unsafe代码。这里借助lazy_static!声明一个AppManager结构的名为APP_NAMAGER的全局实例,且只有在它第一次被使用到的时候,才会进行实际的初始化工作。

为了满足我们的需求,我们要实现一些AppManager的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// os/src/batch.rs

const APP_BASE_ADDRESS: usize = 0x80400000;
const APP_SIZE_LIMIT: usize = 0x20000;

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 {
            panic!("All applications completed!")
        }

        println!("[kernel] Loading app_{}", app_id);
        // clear icache
        asm!("fence.i");
        //clear app area
        core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *mut 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);
    }
}

load_app方法,负责将参数app_id对应的应用程序的二进制镜像加载到物理内存以0x80400000起始的位置,这个位置是批处理操作系统和应用程序之间约定的常数地址,在之前我们也调整应用程序的内存布局以同一个地址开头。第36行开始,我们首先将一块内存清空,然后找到待加载应用二进制镜像的位置,并将它复制到正确的位置。它的本质就是将数据从一块内存复制到另一块内存,而从批处理操作系统的角度来看,是将操作系统数据段的一部分数据复制到了一个可以执行代码的内存区域。体现了冯诺伊曼计算机的代码即数据的特征。

第34行插入了一条汇编指令fence.i,它是用来清除i-cache的。我们知道缓存是存储级结构中提高访存速度很重要的一环。而CPU对物理内存所做的缓存有分为数据缓存(d-cache)和指令缓存(i-cache)两部分,分别在CPU访存和取指时使用。在取指时,对于一个指令地址,CPU会先去i-cache里面查看它是否在某个已缓存的缓存行内,如果在的话它就会直接从高速缓存中拿到指令而不是通过总线访问内存。通常情况下,CPU 会认为程序的代码段不会发生变化,因此 i-cache 是一种只读缓存。但在这里,OS将修改会被 CPU 取指的内存区域,这会使得 i-cache 中含有与内存中不一致的内容。因此OS在这里必须使用fence.i指令手动清空i-cache,让里面所有的内容全部失效,才能够保证CPU访问内存数据和代码的正确性。

实现特权级的切换

由于处理器具有硬件级的特权级机制,应用程序在用户态特权级运行时,是无法直接通过函数调用访问处于内核态特权级的批处理操作系统内核中的函数。但应用程序又需要得到操作系统提供的服务,所以应用程序和操作系统需要通过某种合作机制完成特权级之间的切换,使得用户态应用程序可以得到内核态操作系统函数的服务。接下来将在RISC-V64处理器提供的U/S特权级下,解决批处理操作系统和应用程序的相互配合,完成特权级切换。

RISC-V特权级切换

特权级切换的起因

批处理操作系统被设计为运行在内核态特权级,这是作为SEE的RustSBI保证的。而应用程序被设计为运行在用户态特权级,被操作系统为核心的执行环境监督起来。在本篇中,应用程序的执行环境则是批处理系统提供的AEE(Application Execution Environment)。批处理操作系统为了建立好应用程序的执行环境,需要在执行应用之前进行一些初始化工作,并监控应用程序的执行,具体体现在:

  • 当应用程序被启动时,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序
  • 当应用程序发起系统调用之后,需要到批处理操作系统中进行处理
  • 当应用程序执行出错时,需要到批处理系统中杀死该应用并加载运行下一个应用
  • 当应用程序执行结束时,需要到批处理操作系统中加载运行下一个应用

这些处理都涉及到特权级切换,因此需要应用程序、操作系统和硬件一起协同,完成特权级切换机制。

特权级切换相关的控制状态寄存器

当从一般意义上讨论RISC-V架构的Trap机制时,通常需要注意两点:

  • 在触发Trap之前CPU运行在哪个特权级
  • CPU需要切换到哪个特权级来处理该Trap,并在处理完成之后返回原特权级

在本篇中,我们仅考虑如下流程:当CPU在用户态特权级运行应用程序,执行到Trap,切换到内核态特权级,批处理操作系统的对应代码相应Trap,并执行系统调用服务,处理完毕后,从内核态返回到用户态应用程序继续执行后续指令。

在RISC-V架构中,关于Trap有一条重要规则:在Trap前的特权级不会高于Trap后的特权级。因此如果触发Trap之后切换到S特权级,说明Trap发生之前CPU只能运行在S/U特权级。但无论如何,只要Trap到S特权级,操作系统就会使用S特权级中与Trap相关的控制状态寄存器(CSR)来辅助Trap处理。进入S特权级Trap的相关CSR:

CSR名该CSR与Trap相关的功能
sstatusspp等字段给出Trap发生之前CPU处在哪个特权级等信息
sepc当Trap是一个异常时,记录Trap发生之前执行的最后一条指令的地址
scause描述Trap的原因
stval给出Trap附加信息
stvec控制Trap处理代码的入口地址

特权级切换

当执行一条Trap类指令,如ecall时,CPU发现触发了一个异常并需要进行特殊处理,这涉及到执行环境切换。应用程序被切换回来之后需要从发出系统调用请求的执行位置恢复应用程序上下文并继续执行,这需要在切换前后维持应用程序的上下文保持不变。应用程序的上下文包括通用寄存器和栈两个主要部分。由于CPU在不同特权级下共享一套通用寄存器,所以在运行操作系统的Trap操作过程中,操作系统也会用到这些寄存器,这会改变应用程序的上下文。因此,与函数调用需要保存函数调用上下文/活动记录一样,在执行操作系统的Trap处理过程之前,我们需要在某个地方保存这些寄存器并在Trap处理结束后恢复这些寄存器。

除了通用寄存器之外还有一些可能在处理Trap过程中会被修改的CSR,比如CPU所在的特权级。我们要保证它们的变化在我们的预期之内。比如,对特权级转换而言,应该是Trap之前在U特权级,处理Trap的时候在S特权级,返回之后又需要回到U特权级。而对于栈问题则相对简单,只要两个应用程序执行过程中用来记录执行历史的栈所对应的内存区域不想交,就不会产生令我们头痛的覆盖问题和数据破坏问题,也就无需进行保存/恢复。

特权级切换的具体过程一部分由硬件直接完成,另一部分则需要由操作系统来实现。

特权级切换的硬件控制机制

当CPU执行完一条指令(例如:ecall)并准备从用户特权级Trap到S特权级时,硬件会自动完成以下事情:

  • sstatusSPP字段会被修改为CPU当前特权级
  • spec会被修改为Trap处理完成后默认会执行的下一条指令的地址
  • scause/stval分别会被修改成这次Trap的原因以及相关附加信息
  • CPU会跳转到stvec所设置的Trap处理入口地址,并将当前特权级设置为S,然后从Trap处理入口地址开始执行

而当CPU完成Trap处理准备返回的时候,需要通过一条S特权级的特权指令sret来完成,这一条指令具体完成以下功能:

  • CPU会将当前的特权级按照sstatusSPP字段设置为U或者S
  • CPU会跳转到spec寄存器指向那条指令,然后继续执行

用户栈与内存栈

在Trap触发的一瞬间,CPU就会切换到S特权级并跳转到stvec 所指示的位置。但是在正式进入S特权级的Trap之前,上面提到过我们必须保存原控制流的寄存器转台,这一般通过内核栈来保存。注意,我们需要用专门为操作系统准备的内核栈,而非应用程序运行时用到的用户栈。

使用两个不同的栈主要是为了安全性:如果两个控制流使用同一个栈,在返回之后应用程序就能读到Trap控制流的历史信息,比如内核一些函数的地址,这样会带来安全隐患。于是,我们要做的事,在批处理操作系统中添加一段汇编代码,实现从用户栈切换到内核栈,并在内核栈上保存应用程序控制流的寄存器状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// os/src/batch.rs
const KERNEL_STACK_SIZE: usize = 4096 * 2;
const USER_STACK_SIZE: usize = 4096 * 2;

static KERNEL_STACK: KernelStack = KernelStack {
    data: [0; KERNEL_STACK_SIZE],
};

static USER_STACK: UserStack = UserStack {
    data: [0; USER_STACK_SIZE],
};

#[repr(align(4096))]
struct KernelStack {
    data: [u8; KERNEL_STACK_SIZE],
}

#[repr(align(4096))]
struct UserStack {
    data: [u8; USER_STACK_SIZE],
}

KERNEL_STACK_SIZEUSER_STACK_SIZE指出内核栈和用户栈道大小分别为$8KiB$。两个类型是以全局变量的形式实例化在批处理操作系统的.bss段中的。

我们为两个类型实现了get_sp方法来获取栈顶地址。由于RISC-V中栈是向下增长的,我们只需返回包裹的数组的结尾地址:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// os/src/batch.rs
impl KernelStack {
    fn get_sp(&self) -> usize {
        self.data.as_ptr() as usize + KERNEL_STACK_SIZE
    }
}


impl UserStack {
    fn get_sp(&self) -> usize {
        self.data.as_ptr() as usize + USER_STACK_SIZE
    }
}

于是换栈是非常简单的,只需将sp寄存器的值修改为get_sp的返回值即可。

接下来是Trap上下文,类似前面提到的函数调用上下文,即在Trap发生时需要保存的物力资源内容,并将其放在一个名为TrapContext的类型中,定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// os/src/trap/context.rs

use riscv::register::sstatus::Sstatus;

#[repr(C)]
pub struct TrapContext {
    pub x: [usize; 32],
    pub sstatus: Sstatus,
    pub sepc: usize,
}

可以看到里面包含所有的通用寄存器x0~x31,还有sstatucspec。为什么保存它们呢?

  • 对于通用寄存器而言,两条控制流运行在不同的特权级,所属的软件也可能由不同的编程语言编写,虽然在Trap控制流中只是会执行Trap处理相关的代码,但依然可以直接或间接调用很多模块,因此很难甚至不可能找出哪些寄存器无需保存,既然如此只能全部保存。但也有一些例外,x0被硬编码成0,它自然不会有变化,还有tp(x4)寄存器,除非我们手动处于一些特殊用途使用它,否则一般也不会被用到。它们虽然无需被保存,但我们仍然为其预留空间,主要是为了后续的实现方便。
  • 对于CSR而言,我们知道进入Trap的时候,硬件会立即覆盖掉scause/stval/sstatus/sepc的全部或是其中一部分。scause/stval的情况是:它总是被Trap处理的第一时间就被使用或者在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。而对于sstatus/sepc而言,它们会在Trap处理的全程有意义(在Trap控制流最后sret的时候还用到了它们),而且确实会出现Trap嵌套的情况使得它们的值被覆盖掉。所以我们需要将它们保存下来,并在sret之前恢复原样。

Trap管理

特权级切换的核心是对Trap的管理。主要涉及如下一些内容:

  • 应用程序通过ecall进入到内核状态时,操作系统保存被打断的应用程序的Trap上下文
  • 操作系统根据Trap相关的CSR寄存器内容,完成系统调用服务的分发与处理
  • 操作系统完成系统调用服务后,需要恢复被打断的应用程序的Trap上下文,并通过sret让应用程序继续执行

Trap上下文的保存与恢复

首先是具体实现Trap上下文保存和恢复的汇编代码,在批处理操作系统初始化的时候,我们需要修改stvec寄存器来指向正确的Trap处理入口点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// os/src/trap/mod.rs
use core::arch::global_asm;
use riscv::register::{mtvec::TrapMode, stvec};

global_asm!(include_str!("trap.S"));

pub fn init() {
    extern "C" {
        fn __alltraps();
    }
    unsafe {
        stvec::write(__alltraps as usize, TrapMode::Direct);
    }
}

这里我们引入了一个外部符号__alltraps,并将stvec设置为Direct模式指向它的地址。我们在os/src/trap/trap.S中实现Trap上下文保存/恢复的汇编代码,分别用外部符号__alltraps__restore标记为函数,并通过global_asm!宏将这段汇编代码插入进来。

Trap处理的总体流程如下:首先通过__alltraps将Trap上下文保存在内核栈上,然后跳转到使用Rust编写的trap_handler函数完成Trap分发及处理。当trap_handler返回之后,使用__restore从保存在内核栈上的Trap上下文恢复寄存器。最后通过sret指令回到应用程序执行。

首先是保存Trap上下文的__alltraps的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# os/src/trap/trap.S

.macro SAVE_GP n
    ld x\n, \n*8(sp)
.endm

.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
  • 第7行我们使用.align__alltraps的地址4字节对齐,这是RISC-V特权级规范的要求
  • 第9行的csrrw原型是csrrw rd, csr, rs,可以将CSR当前的值读到通用寄存器rd中,然后将通用寄存器rs的值写入该CSR。因此这里起到的是交换sscratchsp的效果。在这一行之前sp指向用户栈,sscratch指向内核栈,之后sp指向内核栈,sscratch指向用户栈
  • 第12行,我们准备在内核栈上保存Trap上下文,于是预先分配$34 \times 8$字节的栈帧,这里改动的是sp,说明确实是在内核栈上
  • 第13~24行,保存Trap上下文的通用寄存器x0~x31,跳过x0tp(x4),原因之前已经说明。在这里也无需保存sp(x2),因为我们要基于它来找到每个寄存器应该被保存到的正确的位置。实际上,在栈帧分配之后,我们可用于保存Trap上下文的地址区间为$[sp, sp+8 \times 34)$,按照TrapContext结构体的内存布局,基于内核栈道位置(sp所指向的地址)来从低地址到高地址分别按顺序放置x0~x31这些通用寄存器,最后是sstatussepc。因此通用寄存器xn应该被保存在地址区间$[sp+8n,sp+8(n+1))$。为了简化代码,x5~x31这27个通用寄存器我们通过类似循环的.rept每次使用SAVE_GP宏来保存,其实质是相同的。注意我们需要在trap.S开头加上.altmacro才能正确使用.rept命令
  • 第25~28行,将CSR sstatussepc的值分别读到寄存器t0t1中然后保存到内核栈对应的位置上。指令csrr rd, csr功能就是将CSR的值读到寄存器rd
  • 第30~31行专门处理sp的问题。首先将sscratch的值读取到寄存器t2并保存到内核栈上。注意:此时sscratch指向用户栈,sp指向内核栈
  • 第33行令a0<-sp,让寄存器a0指向内核栈的栈指针也就是我们刚刚保存的Trap上下文的地址,这是由于我们接下来调用trap_handler进行Trap处理,它的第一个参数cx由调用规范要从a0中获取。而Trap处理函数trap_handler需要Trap上下文的原因在于:它需要知道其中某些寄存器的值,比如在系统调用的时候应用程序传过来的syscall ID和对应参数。

trap_handler返回之后会从调用trap_handler的下一条指令开始执行,也就是从栈上的Trap上下文恢复的__restore

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# os/src/trap/trap.S

.macro LOAD_GP n
    ld x\n, \n*8(sp)
.endm

__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
  • 第13~26行负责从内核栈顶的Trap上下文恢复通用寄存器和CSR。我们要先恢复CSR再恢复通用寄存器,这样我们使用的三个临时寄存器才能被正确恢复
  • 在第28行之前,sp指向保存了Trap上下文之后的内核栈栈顶,sscratch指向用户栈栈顶。在第28行内核栈上回收Trap上下文所占用的内存,回归进入Trap之前的内核栈栈顶。第30行,再次交换sscratchsp,现在sp重新只想用户栈栈顶,sscratch也依然保存进入Trap之前的状态并指向内核栈栈顶
  • 在应用程序控制流状态会还原之后,第31行使用sret指令回到U特权级继续运行应用程序控制流

Trap分发与处理

Trap在使用Rust实现的trap_handler函数中完成分发和处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// os/src/trap/mod.rs

#[no_mangle]
pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext {
    let scause = scause::read();
    let stval = stval::read();
    match scause.cause() {
        Trap::Exception(Exception::UserEnvCall) => {
            cx.sepc += 4;
            cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize;
        }
        Trap::Exception(Exception::StoreFault) | Trap::Exception(Exception::StorePageFault) => {
            println!("[kernel] PageFault in application, kernel killed it.");
            run_next_app();
        }
        Trap::Exception(Exception::IllegalInstruction) => {
            println!("[kernel] IllegalInstruction in application, kernel killed it.");
            run_next_app();
        }
        _ => {
            panic!(
                "Unsupported trap {:?}, stval = {:#x}",
                scause.cause(),
                stval
            );
        }
    }
    cx
}
  • 第4行声明返回值为&mut TrapContext并在第28行将传入的Trap上下文cx原样返回,因此在__restore的时候a0寄存器在调用trap_handler前后并没有发生变化,仍然指向分配Trap上下文之后的内核栈栈顶,和此时sp的值相同,这里的sp<-a0并不会有问题

  • 第7行根据scause寄存器所保存的Trap的原因进行分发处理。这里我们无须手动操作这些CSR,而是使用Rust的riscv库来更加方便的操作。引入riscv库,需要在os/Cargo.toml中添加:

    1
    2
    3
    4
    
    # os/Cargo.toml
    
    [dependencies]
    riscv = { git = "https://github.com/rcore-os/riscv", features = ["inline-asm"] }
    
  • 第8~11行,发现触发Trap的原因是来自于U特权级的Environment Call,也就是系统调用。这里我们首先修改保存在内核栈上的Trap上下文里面sepc,让其增加4。这是因为我们知道这是一个由ecall指令触发的系统调用,在进入Trap的时候,硬件会将sepc设置为这条ecall指令所在的地址。而在Trap返回之后,我们希望应用程序控制流从ecall的下一条指令开始执行。因此我们只需修改Trap上下文里面的sepc,让他增加ecall指令的码长,即4字节。这样在__restore的时候sepc在恢复之后就会指向ecall的下一条指令,并在sret之后从这里开始执行。

    用来保存系统调用返回值的a0寄存器也会同样发生变化。我们从Trap上下文取出作为syscall ID的a7和系统调用的三个参数`

  • 第12~19行,分别处理应用程序出现访存错误和非法指令错误的情况。此时需要打印错误信息并调用run_next_app直接切换并运行下一个应用程序。

  • 第20行开始,当遇到目前还不支持的Trap类型的时候,批处理操作系统整个panic报错退出。

实现系统调用功能

对于系统调用而言,syscall函数并不会实际处理系统调用,而只是根据syscall ID分发到具体的处理函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// os/src/syscall/mod.rs

const SYSCALL_WRITE: usize = 64;
const SYSCALL_EXIT: usize = 93;

pub fn syscall(syscall_id: usize, args: [usize; 3]) -> isize {
    match syscall_id {
        SYSCALL_WRITE => sys_write(args[0], args[1] as *const u8, args[2]),
        SYSCALL_EXIT => sys_exit(args[0] as i32),
        _ => panic!("Unsupported syscall_id: {}", syscall_id),
    }
}

这里我们会将传进来的参数args转化成能够被具体的系统调用处理函数接受的类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// os/src/syscall/fs.rs

const FD_STDOUT: usize = 1;

pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
    match fd {
        FD_STDOUT => {
            let slice = unsafe { core::slice::from_raw_parts(buf, len) };
            let str = core::str::from_utf8(slice).unwrap();
            print!("{}", str);
            len as isize
        }
        _ => {
            panic!("Unsupported fd in sys_write!");
        }
    }
}
1
2
3
4
5
6
// os/src/syscall/process/rs

pub fn sys_exit(exit_code: i32) -> ! {
    println!("[kernel] Application exited with code {}", exit_code);
    run_next_app()
}
  • sys_write我们将传入的位于应用程序内的缓冲区的开始地址和长度转化成一个字符串&str,然后使用批处理操作系统已经实现的print!宏打印出来。
  • sys_exit打印退出的应用程序的返回值并同样调用run_next_app切换到下一个应用程序。

执行应用程序

当批处理操作系统初始化完成,或者是某个应用运行结束或出错的时候,我们要调用run_next_app函数切换到下一个应用程序。此时CPU运行在S特权级,而它希望能切换到U特权级。在RISC-V架构中,唯一一种使得CPU特权级下降的方法就是执行Trap返回到特权指令,如sretmret等。事实上,在从操作系统内核返回到运行应用程序之前,要完成如下这些工作:

  • 构造应用程序开始执行所需要的Trap上下文
  • 通过__restore函数,从刚构造的Trap上下文中,恢复应用程序执行的部分寄存器
  • 设置sepcCSR的内容为应用程序入口点0x80400000
  • 切换scratchsp寄存器,设置sp指向应用程序用户栈
  • 执行sret从S特权级切换到U特权级

它们可以通过复用__restore的代码来更容易的实现上述工作。我们只需要在内核栈上压入一个为启动应用程序而特殊构造的Trap上下文,在通过__restore函数,就能让这些寄存器到达启动应用程序所需要的上下文状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// os/src/trap/context.rs

impl TrapContext {
    pub fn set_sp(&mut self, sp: usize) {
        self.x[2] = sp;
    }

    pub fn app_init_context(entry: usize, sp: usize) -> Self {
        let mut sstatus = sstatus::read();
        sstatus.set_spp(SPP::User);
        let mut cx = Self {
            x: [0; 32],
            sstatus,
            sepc: entry,
        };
        cx.set_sp(sp);
        cx
    }
}

TrapContext实现app_init_context方法,修改其中的sepc寄存器为应用程序入口点entrysp寄存器为我们设定的一个栈指针,并将sstatus寄存器的SPP字段设置为User。

run_next_app函数中我们能够看到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// os/src/batch.rs

pub fn run_next_app() -> ! {
    let mut app_manager = APP_MANAGER.exclusive_access();
    let current_app = app_manager.get_current_app();
    unsafe {
        app_manager.load_app(current_app);
    }

    app_manager.move_to_next_app();
    drop(app_manager);

    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!");
}

在17~22行所做的事情就是在内核栈上压入一个Trap上下文,其sepc是应用程序入口0x80400000 ,其sp寄存器指向用户栈,其sstatusSPP字段被设置为User。push_context的返回值是内核栈压入Trap上下文之后的栈顶,它会被作为__restore的参数,这使得在__restore函数中sp仍然可以指向内核栈道栈顶。这之后,就和执行一次普通的__restore函数调用一样了。