type
status
date
slug
summary
tags
category
icon
password
第一章:应用程序与基本执行环境
- APP调用OS service而不是直接与M-Mode的模块进行交互
- 整个程序的App部分和OS部分的镜像会被加载到内存中
应用程序执行环境与平台支持
环境栈:
- 环境并不是绝对的,上层程序的环境就是下层程序
- 黑色块表示相邻两层执行环境的接口
- 系统调用充当了用户和内核之间的边界
- 除了最上层的应用程序和最下层的硬件平台必须存在之外,作为中间层的函数库和操作系统内核并不是必须存在的
目标三元组:
- 处理器 (Processor,也称CPU),内存 (Memory) 还有 I/O 设备。其中处理器无疑是其中最复杂,同时也最关键的一个。它与软件约定一套 指令集体系结构 (ISA, Instruction Set Architecture),使得软件可以通过 ISA 中提供的机器指令来访问各种硬件资源
- 如果ISA不同,同一个语言生成的可执行文件可能不同——跨平台
- Rust编译器通过 目标三元组 (Target Triplet) 来描述一个软件运行的目标平台,它一般包括 CPU、操作系统和运行时库等信息
- CPU架构是x86_64
- CPU厂商unknown
- 操作系统是Linux
- 运行时库是GNU libc
host字段:
rust支持的基于risc-v的平台:
- rustc --print target-list| grep riscv
- riscv32gc-unknown-linux-gnu
- riscv32i-unknown-none-elf
- riscv32imac-unknown-none-elf
- riscv32imc-unknown-none-elf
- riscv64gc-unknown-linux-gnu
- riscv64gc-unknown-none-elf——本实验需要的平台,裸机运行,不需要标准运行时库
- riscv64imac-unknown-none-elf
- 交叉编译 (Cross Compile)
移除标准库依赖
#![no_std]
告诉 Rust 编译器不使用 Rust 标准库 std 转而使用核心库 core(core库不需要操作系统的支持)
#![no_main]
告诉编译器我们没有一般意义上的
main
函数,并将原来的 main
函数删除内核第一条指令
使用gdb-multiarch调试程序:
- 安装
- 运行
gdb运行结果:
- 进入第一阶段
- 查看程序计数器所指的指令和之后的10条指令
[GDB output] 0x0000000000001000 in ?? ()
将必要的文件载入到 Qemu 物理内存之后,Qemu CPU 的程序计数器(PC, Program Counter)会被初始化为
0x1000
,因此 Qemu 实际执行的第一条指令位于物理地址 0x1000
,接下来它将执行寥寥数条指令并跳转到物理地址 0x80000000
对应的指令处并进入第二阶段可以看出从1014开始,后面的指令都是unimp,说明只有前5条指令是Qemu固件所包含的指令。执行到1010时,t0里面恰好是0x80000000,这样就可以直接跳转到到该地址执行我们自己编写的指令
- 进入第二阶段
由于 Qemu 的第一阶段固定跳转到
0x80000000
,我们需要将负责第二阶段的 bootloader rustsbi-qemu.bin
放在以物理地址 0x80000000
开头的物理内存中,这样就能保证 0x80000000
处正好保存 bootloader 的第一条指令。在这一阶段,bootloader 负责对计算机进行一些初始化工作,并跳转到下一阶段软件的入口,在 Qemu 上即可实现将计算机控制权移交给我们的内核镜像 os.bin
。- 进入第三阶段
我们选用的 RustSBI 则是将下一阶段的入口地址预先约定为固定的
0x80200000
,在 RustSBI 的初始化工作完成之后,它会跳转到该地址并将计算机控制权移交给下一阶段的软件——也即我们的内核镜像。一旦 CPU 开始执行内核的第一条指令,证明计算机的控制权已经被移交给我们的内核,也就达到了本节的目标。可以看到程序成功停在了设定好的0x80200000处
为内核支持函数调用
- 第一条表示jump and link,将当前指令的下一条指令放入rd,跳转目标地址是相对于
pc
指向的地址的
- 第二条表示jump and link register,将当前指令的下一条指令放入rd,跳转目标地址是相对于寄存器
rs
指向的地址的
- rs 表示 源寄存器 (Source Register), imm 表示 立即数 (Immediate),是一个常数,二者构成了指令的输入部分;而 rd 表示 目标寄存器 (Destination Register),它是指令的输出部分。rs 和 rd 可以在 32 个通用寄存器 x0~x31 中选取。
通常使用
ra
寄存器(即 x1
寄存器)作为其中的 rd
对应的具体寄存器,因此在函数返回的时候,只需跳转回 ra
所保存的地址即可。- 在进行函数调用的时候,我们通过
jalr
指令保存返回地址并实现跳转;而在函数即将返回的时候,则通过ret
伪指令回到跳转之前的下一条指令继续执行
分配栈空间的代码:
- 分配了4096*16字节,也就是64KiB的空间用作接下来要运行的程序的栈空间
- 在 RISC-V 架构上,栈是从高地址向低地址增长
- 在进入主函数之前 分配好栈空间
- 对于范围中的每个地址
a
,使用for_each
方法执行一个闭包操作。这个闭包使用unsafe
代码,因为它直接操作了内存。
(a as *mut u8)
将地址a
转换为一个可变指针,因为按字节编码,需要逐字节对bss段清零
write_volatile(0)
将地址a
指向的内存空间写入值0
第二章:批处理系统
特权级机制
为了保护我们的批处理操作系统不受到出错应用程序的影响并全程稳定工作,单凭软件实现是很难做到的,而是需要 CPU 提供一种特权级隔离机制,使 CPU 在执行应用程序和操作系统内核的指令时处于不同的特权级
为确保操作系统的安全,对应用程序而言,需要限制的主要有两个方面:
- 应用程序不能访问任意的地址空间(这个在第四章会进一步讲解,本章不会涉及)
- 应用程序不能执行某些可能破坏计算机系统的指令(本章的重点)
函数:
ecall
具有用户态到内核态的执行环境切换能力的函数调用指令;
sret
:具有内核态到用户态的执行环境切换能力的函数返回指令。
RISC-V 架构中一共定义了 4 种特权级:
级别 | 编码 | 名称 |
0 | 00 | 用户/应用模式 (U, User/Application) |
1 | 01 | 监督模式 (S, Supervisor) |
2 | 10 | 虚拟监督模式 (H, Hypervisor) |
3 | 11 | 机器模式 (M, Machine) |
也就是说,之前ch1完成的最简单的内核并没有应用特权隔离机制,而在后续的学习中,我们会涉及到RISC-V的 M/S/U 三种特权级:
- 应用程序在U模式
- kernel在S模式
- 而第一章提到的预编译的 bootloader –
RustSBI
实际上是运行在更底层的 M 模式特权级下的软件,是操作系统内核的执行环境
操作系统二进制接口:“SBI”:
之前用过的bootloader,它为操作系统提供运行环境,因此它应该在操作系统之下——M模式中,为操作系统提供一系列二进制接口, RISC-V给出了此类环境和二进制接口的规范,称为“操作系统二进制接口”,即“SBI”,它将管理S、U等特权上的程序或通用的操作系统。
BIOS和SBI:
现行通用的多阶段引导模型为:
ROM -> LOADER -> RUNTIME -> BOOTLOADER -> OS
Loader 要干的事情,就是内存初始化,以及加载 Runtime 和 BootLoader 程序。而Loader自己也是一段程序,常见的Loader就包括 BIOS 和 UEFI,后者是前者的继任者。
如果把BIOS当做一个泛化的术语使用,而不是指某个具体实现的话,那么可以认为 SBI 是 BIOS 的组成部分之一。
Trap:
- 执行环境要对上层运行的程序进行监控和管理
- 上层程序出错后,需要运行执行环境中的代码对错误进行处理——CPU特权级切换
- 而执行环境中的代码运行完毕,就要回到上层被中断的地方继续执行,这种控制流就叫异常控制流ECF,是RISC-V语境下的 Trap 种类之一
- 其中 断点 (Breakpoint) 和 执行环境调用 (Environment call) 两种异常(为了与其他非有意为之的异常区分,会把这种有意为之的指令称为 陷入 或 trap 类指令,此处的陷入为操作系统中传统概念)是通过在上层软件中执行一条特定的指令触发的:执行
ebreak
这条指令之后就会触发断点陷入异常;而执行ecall
这条指令时候则会随着 CPU 当前所处特权级而触发不同的异常
ABI和SBI:
- M 模式软件 SEE 和 S 模式的内核之间的接口被称为 监督模式二进制接口 (Supervisor Binary Interface, SBI)
- 而内核和 U 模式的应用程序之间的接口被称为 应用程序二进制接口 (Application Binary Interface, ABI),当然它有一个更加通俗的名字—— 系统调用 (syscall, System Call)
- 是机器/汇编指令级的一种接口
执行环境调用导致的Trap:
实现应用程序
保证应用程序的代码在用户态能正常运行是将要实现的批处理系统的关键任务之一
即设计一个应用程序和基本的支持功能库,这样应用程序在用户态通过操作系统提供的服务完成自身的任务
第二章新增系统调用:
因为有从U模式到M模式的变化,所以应该使用ecall来完成对内核函数的调用
inlateout("x10") args[0] => ret
表示x10
寄存器在汇编代码开始执行前,其值被设置为args[0]
,并且在汇编代码执行完成后,x10
寄存器的值被存储到ret
变量中
in("x11") args[1],
和in("x12") args[2],
:这两行是传递系统调用的参数到相应的寄存器中
in("x17") id
:这行是将系统调用号传递给寄存器x17
,以便进行系统调用
- 在第一章中,我们曾经使用
global_asm!
宏来嵌入全局汇编代码,而这里的asm!
宏可以将汇编代码嵌入到局部的函数上下文中。相比global_asm!
,asm!
宏可以获取上下文中的变量信息并允许嵌入的汇编代码对这些变量进行操作。由于编译器的能力不足以判定插入汇编代码这个行为的安全性,所以我们需要将其包裹在 unsafe 块中自己来对它负责。 第一章是:
实现批处理操作系统
在本章中,我们要把应用程序的二进制镜像文件作为数据段链接到内核里, 内核需要知道应用程序的数量和它们的位置。
我们在
os
的 batch
子模块中实现一个应用管理器,它的主要功能是:- 保存应用数量和各自的位置信息,以及当前执行到第几个应用了。
- 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行。
应用管理器 AppManager
结构体定义如下:
num_app
:应用程序的数量
current_app
:当前执行到哪个应用程序了
app_start
:应用程序的开始地址,最后一个应用程序要标记结束地址
可变全局变量:
- 我们希望将
AppManager
实例化为一个全局变量,使得任何函数都可以直接访问,又希望可以修改它
- 使用RefCell
RefCell:
- 它提供了在不可变引用(
&T
)的基础上进行内部可变性的机制,允许在不违反 Rust 的借用规则的前提下,在运行时进行可变性检查
unsafe impl<T> Sync for UPSafeCell<T> {}
:- 实现UPSafeCell的线程安全特征并标记它为unsafe
- 因为目前我们的程序只在单核上运行,且RefCell的可变性检查保证只有一个可变借用存在
exclusive_access
:- 当我们要访问数据时(无论读还是写),需要首先调用
exclusive_access
获得数据的可变借用标记,通过它可以完成数据的读写,在操作完成之后我们需要销毁这个标记,此后才能开始对该数据的下一次访问
- 相比
RefCell
它不再允许多个读操作同时存在
- 依靠RefCell提供的可变性检查,当使用者违背了上述模式,比如访问之后忘记销毁就开启下一次访问时,程序会 panic 并退出
构建名为APP_MANAGER的全局结构体变量:
lazy_static!
宏提供了全局变量的运行时初始化功能。一般情况下,全局变量必须在编译期设置初始值, 但是有些全局变量的初始化依赖于运行期间才能得到的数据。 如这里我们借助lazy_static!
声明了一个AppManager
结构的名为APP_MANAGER
的全局实例, 只有在它第一次被使用到的时候才会进行实际的初始化工作
extern "C" { fn _num_app(); }
这段语句表示:声明一个外部函数
_num_app。
这个 extern 块告诉 Rust 编译器,在当前作用域中存在一个名为 _num_app
的外部函数,但是其具体的实现并不在 Rust 代码中,而是由其他地方提供,通常是由汇编语言或者其他语言实现link_app.S
是一个汇编语言文件,其中包含了 _num_app
这个符号的实现。通常情况下,汇编或者其他语言实现的函数会在链接过程中与 Rust 代码进行链接,从而形成最终的可执行文件或者库文件let num_app_ptr = _num_app as usize as *const usize;
- 该指针当前指向app的数量
- 后面是每个app的起始地址,最后一个app有结束地址,因此后面的代码:
构建一个指向该符号的指针
表示:创建一个名为
app_start_raw
的不可变引用,指向从 num_app_ptr.add(1)
开始,长度为 num_app + 1
的连续内存块。这个切片是从指针 num_app_ptr
的下一个元素开始,长度为 num_app + 1
。- 然后应该把它们装进数组
app_start
AppManager
的方法
pub fn print_app_info(&self)
打印出总共的app数量和每个app的起始地址和结束地址
unsafe fn load_app(&self, app_id: usize)
这个方法负责将参数
app_id
对应的应用程序的二进制镜像加载到物理内存以 0x80400000
起始的位置首先将一块内存清空,然后找到待加载应用二进制镜像的位置,并将它复制到正确的位置。它本质上是把数据从一块内存复制到另一块内存,从批处理操作系统的角度来看,是将操作系统数据段的一部分数据(实际上是应用程序)复制到了一个可以执行代码的内存区域
大致流程
build.py将应用程序编译,执行一个 Cargo 命令来编译 Rust 代码,并设置链接器的参数,以指定代码段的起始地址→链接器链接→build.rs生成汇编文件,将应用程序编译好的数据插入app_X_start和app_X_end这两个符号之间→batch.rs依次执行应用程序
实现特权级的切换
RISC-V特权级切换
批处理系统在执行应用程序之前进行一些初始化工作,并监控应用程序的执行:
- 当启动应用程序的时候,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序;
- 当应用程序发起系统调用(即发出 Trap)之后,需要到批处理操作系统中进行处理;
- 当应用程序执行出错的时候,需要到批处理操作系统中杀死该应用并加载运行下一个应用;
- 当应用程序执行结束的时候,需要到批处理操作系统中加载运行下一个应用(实际上也是通过系统调用
sys_exit
来实现的)。
批处理系统遇到Trap后的流程:
- 当 CPU 在用户态特权级( RISC-V 的 U 模式)运行应用程序,执行到 Trap,切换到内核态特权级( RISC-V的S 模式)
- 批处理操作系统的对应代码响应 Trap,并执行系统调用服务
- 处理完毕后,从内核态返回到用户态应用程序继续执行后续指
只要是 Trap 到 S 特权级,操作系统就会使用 S 特权级中与 Trap 相关的 控制状态寄存器 (CSR, Control and Status Register) 来辅助 Trap 处理
进入 S 特权级 Trap 的相关 CSR:
CSR 名 | 该 CSR 与 Trap 相关的功能 |
sstatus | SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息 |
sepc | 当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址 |
scause | 描述 Trap 的原因 |
stval | 给出 Trap 附加信息 |
stvec | 控制 Trap 处理代码的入口地址 |
注意
sstatus
是 S 特权级最重要的 CSR,可以从多个方面控制 S 特权级的 CPU 行为和执行状态。特权级切换的硬件控制机制
当 CPU 执行完一条指令(如
ecall
)并准备从用户特权级 陷入( Trap
)到 S 特权级的时候,硬件会自动完成如下这些事情:sstatus
的SPP
字段会被修改为 CPU 当前的特权级(U/S)。
sepc
会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。
scause/stval
分别会被修改成这次 Trap 的原因以及相关的附加信息。
- CPU 会跳转到
stvec
所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。
用户栈与内核栈
但是在正式进入 S 特权级的 Trap 处理之前,上面提到过我们必须保存原控制流的寄存器状态,这一般通过内核栈来保存
以用户态的栈为例:
栈是从高地址向低地址增长,因此数组的结尾才是栈顶的初始位置
保存寄存器(Trap上下文在进入Trap前后不能变):
- 由于通用寄存器在用户态和内核态都需要被使用,很难判断哪些寄存器变了哪些寄存器没变,因此干脆保存32个寄存器
- 对于 CSR 而言,我们知道进入 Trap 的时候,硬件会立即覆盖掉
scause/stval/sstatus/sepc
的全部或是其中一部分。scause/stval
的情况是:它总是在 Trap 处理的第一时间就被使用或者是在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。而对于sstatus/sepc
而言,它们会在 Trap 处理的全程有意义(在 Trap 控制流最后sret
的时候还用到了它们),而且确实会出现 Trap 嵌套的情况使得它们的值被覆盖掉。所以我们需要将它们也一起保存下来,并在sret
之前恢复原样
Trap 管理
特权级切换的核心是对Trap的管理。这主要涉及到如下一些内容:
- 应用程序通过
ecall
进入到内核状态时,操作系统保存被打断的应用程序的 Trap 上下文;
- 操作系统根据Trap相关的CSR寄存器内容,完成系统调用服务的分发与处理;
- 操作系统完成系统调用服务后,需要恢复被打断的应用程序的Trap 上下文,并通
sret
让应用程序继续执行。
stvec 相关细节
在 RV64 中,
stvec
是一个 64 位的 CSR,在中断使能的情况下,保存了中断处理的入口地址。它有两个字段:- MODE 位于 [1:0],长度为 2 bits;
- BASE 位于 [63:2],长度为 62 bits。
当 MODE 字段为 0 的时候,
stvec
被设置为 Direct 模式,此时进入 S 模式的 Trap 无论原因如何,处理 Trap 的入口地址都是 BASE<<2
, CPU 会跳转到这个地方进行异常处理。本书中我们只会将 stvec
设置为 Direct 模式。而 stvec
还可以被设置为 Vectored 模式,有兴趣的同学可以自行参考 RISC-V 指令集特权级规范。Trap 上下文的保存与恢复:
Trap 处理的总体流程如下:
- 首先通过
__alltraps
将 Trap 上下文保存在内核栈上
- 然后跳转到使用 Rust 编写的
trap_handler
函数完成 Trap 分发及处理
- 当
trap_handler
返回之后,使用__restore
从保存在内核栈上的 Trap 上下文恢复寄存器。最后通过一条sret
指令回到应用程序执行。
保存Trap上下文__alltraps:
首先修改栈指针 sp:
csrrw
是 CSR 读写指令的一种形式,它的作用是从指定的 CSR 中读取值,并将该值写入目标寄存器,然后将目标寄存器的旧值写入指定的 CSR 中因此,这条指令的意思是:将 sscratch 寄存器中的值写入栈指针寄存器(
sp
),然后将栈指针寄存器的旧值写入 sscratch 寄存器中。即当前的sp指向内核栈,将sp的旧值(指向用户栈)存进sscratch保存寄存器:
通用寄存器:
其中.set开始那一段,表示一个循环,调用宏
SAVE_GP
,用于保存指定编号的通用寄存器的值到当前栈帧中。在每次迭代中,%n
会被替换为当前的局部符号 n
的值,即当前寄存器的编号,而每保存一个就进行n++sstatus和sepc的保存:
用户栈指针的保存:
首先将 sscratch 的值读到寄存器 t2 并保存到内核栈上,注意: sscratch 的值是进入 Trap 之前的 sp 的值,指向用户栈。而现在的 sp 则指向内核栈
调用trap_handler:
将当前栈指针
sp
的值移动到参数寄存器 a0
中,即将栈指针地址作为参数传递给 trap_handler
函数,调用trap_handler现在的sp指向内核栈,现在的内核栈保存了Trap上下文,而 Trap 处理函数
trap_handler
需要 Trap 上下文的原因在于:它需要知道其中某些寄存器的值,比如在系统调用的时候应用程序传过来的 syscall ID 和对应参数。我们不能直接使用这些寄存器现在的值,因为它们可能已经被修改了,因此要去内核栈上找已经被保存下来的值寄存器:
- x0~x31
x0
到x31
是通用寄存器,用于存储数据或地址。其中,x0
通常被称为零寄存器(zero register),始终为零,不可被写入。
- t0~t2
t0
到 t2
也是通用寄存器,用于临时存储数据。它们在 RISC-V 规范中没有具体的用途,通常用于临时变量的存储或者用作数据传递的中间寄存器- a0~a7
a0
到 a7
是参数寄存器,用于函数参数的传递。在函数调用时,调用者将参数存储在这些寄存器中,然后被调用函数可以在其中读取参数恢复Trap上下文__restore:
恢复寄存器:
回收栈空间:
此时sp指向用户栈顶,sscratch里面是sp的旧值,即内核栈顶
Trap 分发与处理
- 触发 Trap 的原因是来自 U 特权级的 Environment Call,也就是系统调用
将sepc的值+4,因为ecall是进入Trap前执行的最后一条语句,此时sepc的值是这条语句的地址,而现在希望Trap的操作完成后返回用户态时,从ecall的下一条指令继续执行程序。因此我们只需修改 Trap 上下文里面的 sepc,让它增加
ecall
指令的码长,也即 4 字节这样在
__restore
的时候 sepc 在恢复之后就会指向 ecall
的下一条指令,并在 sret
之后从那里开始执- 后面几行分别处理应用程序出现访存错误和非法指令错误的情形。此时需要打印错误信息并调用
run_next_app
直接切换并运行下一个应用程序
- _的情形:当遇到目前还不支持的 Trap 类型的时候,“邓式鱼” 批处理操作系统整个 panic 报错退出
执行应用程序
当批处理操作系统初始化完成,或者是某个应用程序运行结束或出错的时候,我们要调用
run_next_app
函数切换到下一个应用程序。此时 CPU 运行在 S 特权级,而它希望能够切换到 U 特权级。在 RISC-V 架构中,唯一一种能够使得 CPU 特权级下降的方法就是执行 Trap 返回的特权指令,如 sret
、mret
等事实上,在从操作系统内核返回到运行应用程序之前,要完成如下这些工作:
- 构造应用程序开始执行所需的 Trap 上下文;
- 通过
__restore
函数,从刚构造的 Trap 上下文中,恢复应用程序执行的部分寄存器;
- 设置
sepc
CSR的内容为应用程序入口点0x80400000
;
- 切换
scratch
和sp
寄存器,设置sp
指向应用程序用户栈;
- 执行
sret
从 S 特权级切换到 U 特权级。
- 我们只需要在内核栈上压入一个为启动应用程序而特殊构造的 Trap 上下文,再通过
__restore
函数,就能让这些寄存器到达启动应用程序所需要的上下文状态
- sp←a0让sp指向新的栈顶,此时栈顶是这个特殊的Trap上下文
总的流程
build.py编译user里面的用户应用程序:
启动qemu和rustsbi,编译os
执行build.rs,将bin文件插入到link_app.S,生成link_app.S
链接
main.rs运行batch.rs,开始批处理
batch.rs首先初始化一个实例:APP_MANAGER: UPSafeCell<AppManager>
从link_app.S里面找到_num_app符号,找到每个app的地址存进app_start数组,然后将其打印出来
然后依次执行应用程序,其中应用程序出现错误或者开始下一个应用程序时,需要有用户态到内核态的切换。需要用到Trap.S
这个函数不会返回,会一直将加载应用程序并把当前的应用程序的上下文压入内核栈,sret后就会自动执行下一个应用程序,直到完成
- Author:orangec
- URL:orange’s blog | welcome to my blog (clovy.top)/5046ff4e56f14646a066789773d923fc
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts