第三讲 基于特权级的隔离与批处理
第三节 实践:批处理操作系统
一 实验目标/历史背景
批处理操作系统是一种能够在隔离环境下运行多个应用程序的系统,其目的是确保安全与资源的高效利用。
批处理OS目标
-
隔离与安全:
- 应用与操作系统之间的隔离确保了系统的完整性和安全性,防止恶意或有缺陷的应用破坏系统。
- 通过特权级别和虚存机制限制应用程序对硬件资源的直接访问。
-
批处理:
- 系统自动加载并逐个执行多个应用程序,每次内存中只存在一个应用程序。
- 在应用程序之间保持明确的状态切换,确保不会出现资源冲突和数据泄漏。
软件设计目标
-
软件层次结构:
- 将应用程序与操作系统分别编译并最终合成一个镜像,减少了耦合性,便于调试与更新。
- 操作系统负责应用程序的加载、管理和调度。
-
系统调用接口:
- 使用系统调用机制(如 ECall)替代函数调用,实现应用程序与操作系统的安全通信。
- 构建支持用户程序的库函数,简化应用程序的开发与调试。
-
操作系统管理与调度:
- 操作系统应具备管理应用程序和初始化的能力。
- 利用特权级别、虚拟内存和系统调用机制,实现批处理环境下应用程序之间的状态切换和资源分配。
状态切换
-
状态保存与恢复:
- 应用程序在系统调用或中断时,会触发状态保存操作,以保护当前执行的上下文信息。
- 在返回到应用程序或进入其他程序时,系统应确保上下文信息能够完整恢复,以保持程序的连续性。
-
切换机制:
- 使用特定的硬件寄存器(如 CSR)存储和恢复状态信息。
- 系统调用和中断处理程序需要结合硬件机制以确保切换过程的安全性和完整性。
实验要求
- 理解运行其他软件的软件
- 理解特权级和特权级切换
- 理解系统调用
- 会写批处理操作系统
历史背景
-
GM-NAA I/O System (1956):
- 1956年首次出现的一款早期操作系统,具有批处理特性。它的名字中,
GM
代表通用汽车公司 (General Motors)。 - 这种批处理系统从当时汽车流水线的管理模式中获得灵感,旨在最大限度地提高计算资源的利用率。
- 1956年首次出现的一款早期操作系统,具有批处理特性。它的名字中,
-
MULTICS OS:
- MULTICS(Multiplexed Information and Computing Service)在1960年代末作为一种先进的多用户操作系统出现,使用 GE 645 大型机的八级硬件保护机制,实现了安全的多用户批处理环境。
- 保护环 (Protection Ring) 机制在现代 X86 架构中以
Ring 0-3
的形式继续存在,但在 RISC-V 架构中,这种特权级别称为 模式 (Mode)。
二 实现步骤
- 构建镜像:
- 将操作系统和应用程序编译成独立的二进制文件,然后合并成一个完整的镜像文件,供批处理系统加载并执行。
- 通过批处理支持多个APP的自动加载和运行
- 跨特权级切换:
- 支持应用程序与操作系统间的特权级别切换。应用程序通过系统调用请求服务,操作系统根据请求提供适当的功能。
- 应用程序和操作系统之间的通信可以通过
ECall
指令来实现。 - 利用硬件特权级机制实现对操作系统自身的保护
- 支持跨特权级的系统调用
- 执行与处理:
- 系统按顺序加载并执行批处理镜像中的各个应用程序。
- 执行过程中,系统需要管理好每个应用的状态和资源,确保它们在各自的地址空间内独立运行。
- 实现特权级的切换
- 实验和调试:
- 建议下载代码,运行并修改,以便更好地理解批处理操作系统的内部结构和运行机制。
- 运行时可以观察到操作系统如何逐个加载并执行应用程序,执行完毕后进行适当的清理工作。
编译步骤
git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
cd rCore-Tutorial-v3
git checkout ch2
cd os
make run
参考运行结果
...
[kernel] num_app = 5
[kernel] app_0 [0x8020a038, 0x8020af90)
...
[kernel] Loading app_0
Hello, world!
[kernel] Application exited with code 0
[kernel] Loading app_1
...
[kernel] Panicked at src/batch.rs:58 All applications completed!
三 软件架构
批处理操作系统的架构主要由两个主要部分构成:
-
用户态应用层(User Mode Applications):
- 用户态程序可以是任何简单的程序,例如打印输出或求和计算程序。
- 通过用户态库的封装,用户程序可以直接调用库函数,如
u_print
,来发送系统调用指令 ECall。 - 用户库通过封装系统调用指令,为用户程序提供易用的接口,将复杂的系统操作隐藏在后台。
-
操作系统内核层(Kernel Layer):
-
应用管理器(App Manager):负责从镜像中检索并加载每个应用程序到内存执行。
-
中断处理与系统调用服务(Trap Handler & System Services):
- 处理与应用程序相关的中断和系统调用。
- 管理特权级别的状态保存与恢复,确保从用户态到内核态的切换顺利。
- 最底层的
trap.S
汇编程序负责保存与恢复特权状态。
-
硬件抽象与外设交互:
- 使用 RISC-V 的 SBI(Supervisor Binary Interface)调用,简化对实际外设的操作。
- 通过 Rust SBI 接口,OS 负责字符串显示,外设驱动设计被隐藏。
-
构建应用
- 使用
link_app.S
作为应用程序的链接脚本,将多个用户程序的二进制镜像与操作系统内核一起打包成单一的镜像。- 把多个应用合在一起与OS形成一个二进制镜像
├── os
│ ├── build.rs(新增:生成 link_app.S 将应用作为一个数据段链接到内核)
│ ├── Cargo.toml
│ ├── Makefile(修改:构建内核之前先构建应用)
│ └── src
│ ├── link_app.S(构建产物,由 os/build.rs 输出)
-
Python 或 Rust 脚本会自动生成此汇编脚本,确保最终生成镜像的一致性。
-
多应用程序支持:系统允许同时存在多个用户程序,通过批处理模式依次执行每个程序。
改进OS
- 特权级别切换:通过
trap.S
等程序实现用户态与内核态之间的切换,保障程序隔离与特权级别的正确性。- 加载和执行程序、特权级上下文切换
├── os
│ └── src
│ ├── batch.rs(新增:实现了一个简单的批处理系统)
│ ├── main.rs(修改:主函数中需要初始化 Trap 处理并加载和执行应用)
│ └── trap(新增:Trap 相关子模块 trap)
│ ├── context.rs(包含 Trap 上下文 TrapContext)
│ ├── mod.rs(包含 Trap 处理入口 trap_handler)
│ └── trap.S(包含 Trap 上下文保存与恢复的汇编代码)
- 中断与异常处理:确保所有应用程序运行时的异常被有效捕获与处理。
- 应用加载与管理:内核层负责检索和加载每个用户程序,确保其执行环境的完整性。
系统调用
系统调用机制是用户程序和内核交互的重要桥梁。其主要目标是确保用户程序可以借助内核的功能完成所需的任务,同时保持不同特权级别间的隔离。
├── os
│ └── src
│ ├── syscall(新增:系统调用子模块 syscall)
│ │ ├── fs.rs(包含文件 I/O 相关的 syscall)
│ │ ├── mod.rs(提供 syscall 方法根据 syscall ID 进行分发处理)
│ │ └── process.rs(包含任务处理相关的 syscall)
- 字符串显示:
- 用户程序需要显示字符串时,会逐字符地将内容通过系统调用交给内核。
- 内核利用 RISC-V 的 SBI(Supervisor Binary Interface)调用来最终处理显示任务。
RustSBI
负责与底层硬件交互,使得内核层无需直接处理硬件操作。
添加应用
系统中包含五个简单的应用程序,每个程序都旨在测试和展示不同的操作系统功能。批处理OS会按照文件名开头的数字顺序依次加载并运行它们
└── user(新增:应用测例保存在 user 目录下)
└── src
├── bin(基于用户库 user_lib 开发的应用,每个应用放在一个源文件中)
│ ├── 00hello_world.rs # 显示字符串的应用
│ ├── 01store_fault.rs # 非法写操作的应用
│ ├── 02power.rs # 计算与I/O频繁交替的应用
│ ├── 03priv_inst.rs # 执行特权指令的应用
│ └── 04priv_csr.rs # 执行CSR操作指令的应用
下面逐一进行描述:
-
00_hello_world.rs:
- 显示字符串 "Hello, World!"。
- 用于验证基本的系统调用输出功能。
-
01_store_fault.rs:
- 执行非法写操作,触发内存存取错误。
- 用于测试操作系统异常处理功能,确保在越权或非法操作时产生适当的错误反馈。
-
02_power.rs:
- 不断交替读取和写入 I/O 设备。
- 用于测试 I/O 频繁交替的能力以及资源调度的健壮性。
-
03_priv_inst.rs:
- 尝试执行特权指令。
- 用于验证系统对特权指令的正确隔离和处理。
-
04_priv_csr.rs:
- 尝试对 CSR(控制状态寄存器)进行读写操作。
- 用于测试系统对 CSR 操作的隔离和异常处理能力。
这些应用程序通过编号的方式方便查找和执行,同时它们的多样性确保了操作系统各项功能的正确性。
应用库和编译应用支持
-
用户态系统库:
- 为用户态程序提供简单的函数接口,如
u_print
,封装了系统调用逻辑。 - 用户库中包含了特权级别转换的汇编指令,使用户程序可以轻松调用内核功能。
- 为用户态程序提供简单的函数接口,如
-
汇编层的
ecall
封装:- 以汇编形式实现系统调用的具体细节。
└── user(新增:应用测例保存在 user 目录下)
└── src
├── console.rs # 支持println!的相关函数与宏
├── lang_items.rs # 实现panic_handler函数
├── lib.rs(用户库 user_lib) # 应用调用函数的底层支撑库
├── linker.ld # 应用的链接脚本
└── syscall.rs(包含 syscall 方法生成实际用于系统调用的汇编指令,
各个具体的 syscall 都是通过 syscall 来实现的)
四 相关硬件
相关硬件与异常处理
在设计批处理操作系统的过程中,硬件的支持和特定指令的使用至关重要,尤其是 RISC-V 架构的特权指令。以下是这些关键指令及其在异常处理中的角色:
关键指令与异常处理
-
ecall
指令:ecall
是特权指令,允许用户态程序通过系统调用进入内核态(或从更低特权级进入更高特权级)。- 在批处理操作系统中,
ecall
用于从用户态 (u-mode
) 切换至内核态 (s-mode
) 以执行系统调用,如显示字符串、读写 I/O 等操作。 - 在 RISC-V 架构中,
ecall
产生Environment Call from U-Mode
异常,进入内核并执行系统调用处理程序。
-
sret
指令:sret
是返回指令,用于从内核态返回用户态,恢复执行用户态程序。- 系统调用处理程序执行完毕后,通过
sret
恢复特权级的上下文信息,使得控制权交还给先前的用户程序。
-
e-break
指令:e-break
是调试相关的特权指令,触发断点异常,使程序进入调试状态。- 用于开发和调试时,捕获并显示程序中的断点,以便系统开发者分析和修改。
特权级转换流程
- 用户程序通过
ecall
进入内核态后:- 内核保存当前的程序计数器 (
sepc
) 和特权级别信息。 - 根据系统调用编号跳转至相应的系统调用处理程序。
- 处理程序执行完毕后,通过
sret
恢复用户态,并继续执行程序。
- 内核保存当前的程序计数器 (
外设支持
- Supervisor Binary Interface (SBI):
- RISC-V 的 SBI 提供对外设的统一接口。
- 内核通过 SBI 完成与硬件串口、计时器等设备的交互,使其可以专注于高层的系统管理。
硬件异常处理流程
硬件异常处理流程如下:
- 异常或中断发生,硬件保存程序计数器和特权级别信息。
- 将异常或中断原因(如
ecall from U-Mode
、e-break
等)存储在指定寄存器中。 - 跳转至异常处理程序,由操作系统处理和解决。
- 处理完毕后,通过特权返回指令恢复特权级别和继续执行。
这些机制确保在用户态和内核态之间切换时,有一个稳定、安全且高效的上下文保存和恢复流程。
RISC-V异常向量
Interrupt | Exception Code | Description | |
---|---|---|---|
0 | 0 | Instruction address misaligned | 指令地址未对齐 |
0 | 1 | Instruction access fault | 指令访问错误 |
0 | 2 | Illegal instruction | 非法指令 |
0 | 3 | Breakpoint | 断点 |
0 | 4 | Load address misaligned | 加载地址未对齐 |
0 | 5 | Load access fault | 加载访问错误 |
0 | 6 | Store/AMO address misaligned | 存储/AMO 地址未对齐 |
0 | 7 | Store/AMO access fault | 存储/AMO 访问错误 |
0 | 8 | Environment call from U-mode | U 模式环境调用 |
0 | 9 | Environment call from S-mode | S 模式环境调用 |
0 | 11 | Environment call from M-mode | M 模式环境调用 |
0 | 12 | Instruction page fault | 指令页错误 |
0 | 13 | Load page fault | 加载页错误 |
0 | 15 | Store/AMO page fault | 存储/AMO 页错误 |
- AMO: atomic memory operation 原子内存操作
五 应用程序设计
5.1 项目结构
在程序设计中,用户态程序与操作系统之间的交互需要依赖于底层硬件和库的支持。特权级别的管理、系统调用的封装以及程序初始化都对实现批处理操作系统至关重要。
用户态程序结构
-
应用程序与库的分离:
- 应用程序应专注于业务逻辑,使用库中的共性代码进行系统调用和底层支持。
- 库封装了应用程序常用的功能,如打印、I/O、错误处理等。
-
库的组织结构:
user/src/
目录包含应用程序和其所需的库文件。bin/
放置基于用户库 user_lib 开发的应用。
-
库的引用方式:
- C 语言通过
#include
引用库的头文件。 - Rust 语言通过
crate
或者extern crate
指令加载库。
- C 语言通过
-
应用与底层支撑库分离
└── user(应用程序和底层支撑库)
└── src
├── bin(该目录放置基于用户库 user_lib 开发的应用)
├── lib.rs(用户库 user_lib) # 库函数的底层支撑库
├── ...... # 支撑库相关文件
└── linker.ld # 应用的链接脚本
- 引入外部库
#![allow(unused)] fn main() { #[macro_use] extern crate user_lib; }
程序初始化流程/设计支撑库
-
启动逻辑 (
start
):- 在执行应用程序的
main
函数之前,启动逻辑会初始化程序的执行环境。 - 初始化包括清零未初始化的全局变量所在的
.bss
段,确保所有变量初始值正确。
- 在执行应用程序的
-
主逻辑 (
main
):- 应用程序的主要功能和逻辑在
main
函数中实现。 - 在初始化完成后,
start
函数将调用main
开始执行程序。
- 应用程序的主要功能和逻辑在
在 lib.rs 中我们定义了用户库的入口点 _start :
#![allow(unused)] fn main() { #[no_mangle] #[link_section = ".text.entry"] pub extern "C" fn _start() -> ! { clear_bss(); exit(main()); panic!("unreachable after sys_exit!"); } }
异常与退出处理
-
异常处理:
- 在程序执行过程中,如果发生异常(如未处理的系统调用、内存访问违规等),库会触发
panic
,以便操作系统进行相应处理。
- 在程序执行过程中,如果发生异常(如未处理的系统调用、内存访问违规等),库会触发
-
正常退出:
- 当程序正常结束时,将通过系统调用通知操作系统退出。
- 退出时,系统调用确保上下文和资源的清理,并返回操作系统供后续程序执行。
5.2 内存布局
编译器生成的程序结构
- 代码与数据段: 应用程序在内存中的布局分为代码段和数据段两大部分。每个部分进一步细分,包括已初始化数据、未初始化数据、只读数据等。
- 链接器脚本 (
.ld
文件): 链接器使用脚本确定最终执行程序的内存布局。脚本指定代码段、数据段的起始地址和各自的大小。我们可以修改.ld
文件调整内存布局,但需要确保操作系统也了解这些更改。
系统调用的参数传递
-
寄存器传递方式:
- 为提高系统调用的效率,使用寄存器传递参数与返回值。
- 特定的寄存器约定:A7 寄存器用于指定系统调用号,A0-A2 等用于传递参数或接收返回值。
-
硬件约定与软件协议:
- 这类参数传递约定是一种软件协议,由应用程序和操作系统共同遵循。
- 硬件本身不干预参数传递的约定,所以只要软件双方遵守这个约定,系统调用就能顺利进行。
设计支撑库
user/src/linker.ld
- 将程序的起始物理地址调整为 0x80400000 ,应用程序都会被加载到这个物理地址上运行;
- 将 _start 所在的 .text.entry 放在整个程序的开头,也就是说批处理系统只要在加载之后跳转到 0x80400000 就已经进入了 用户库的入口点,并会在初始化之后跳转到应用程序主逻辑;
- 提供了最终生成可执行文件的 .bss 段的起始和终止地址,方便 clear_bss 函数使用。
- 其余的部分与之前相同
5.3 系统调用
在批处理操作系统中,系统调用(syscall)是一种机制,让应用程序可以请求操作系统的服务或完成特定任务。下面是实现系统调用的关键点:
-
Sys Write 与 Sys Exit
- Sys Write: 负责将字符串写入输出设备,需要提供缓冲区地址和缓冲区大小等参数。
- 调用结束后,操作系统会返回实际写入的字节数。
- Sys Exit: 负责退出程序,并带有退出码参数,调用后应用程序将不再继续执行。
- 以感叹号
!
标识的系统调用是不会返回的。
- Sys Write: 负责将字符串写入输出设备,需要提供缓冲区地址和缓冲区大小等参数。
-
内联汇编实现
- 使用嵌入式的内联汇编来实现系统调用,可以确保性能和效率。
- 汇编代码包括:
- 设置系统调用 ID 和参数到指定寄存器(A0, A1, A2 等)。
- 通过
ecall
指令触发系统调用。 - 从寄存器中获取返回值并传递给程序。
- 示例内联汇编代码(Rust 风格):
- 系统调用 ID 被存储在 X17 寄存器(A7)。
- 参数 被存储在 X10, X11, 和 X12(A0, A1, A2)等寄存器。
- 返回值 被存储在 X10(A0)。
-
流程
- 汇编代码将传入的参数存储到特定寄存器。
- 发出
ecall
指令,触发 名为 Environment call from U-mode 的异常 - Trap 进入 S 模式执行批处理系统针对这个异常特别提供的服务代码
- 操作系统处理后,将结果保存在寄存器中并返回应用程序。
- a0~a6 保存系统调用的参数, a0 保存返回值, a7 用来传递 syscall ID
通过这种内联汇编机制,系统调用可在应用程序和操作系统之间保持良好连接,使用户程序能够充分利用操作系统提供的服务。
Register | ABI Name | Description | 描述 | Saver |
---|---|---|---|---|
x0 | zero | Hard-wired zero | 硬连线零值 | —— |
x1 | ra | Return address | 返回地址 | Caller |
x2 | sp | Stack pointer | 栈指针 | Callee |
x3 | gp | Global pointer | 全局指针 | —— |
x4 | tp | Thread pointer | 线程指针 | —— |
x5 | t0 | Temporary/alternate link register | 临时寄存器/备用链接寄存器 | Caller |
x6–7 | t1–2 | Temporaries | 临时寄存器 | Caller |
x8 | s0/fp | Saved register/frame pointer | 保存的寄存器/帧指针 | Callee |
x9 | s1 | Saved register | 保存的寄存器 | Callee |
x10–11 | a0–1 | Function arguments/return values | 函数参数/返回值 | Caller |
x12–17 | a2–7 | Function arguments | 函数参数 | Caller |
x18–27 | s2–11 | Saved registers | 保存的寄存器 | Callee |
x28–31 | t3–6 | Temporaries | 临时寄存器 | Caller |
f0–7 | ft0–7 | FP temporaries | 浮点临时寄存器 | Caller |
f8–9 | fs0–1 | FP saved registers | 浮点保存寄存器 | Callee |
f10–11 | fa0–1 | FP arguments/return values | 浮点参数/返回值 | Caller |
f12–17 | fa2–7 | FP arguments | 浮点参数 | Caller |
f18–27 | fs2–11 | FP saved registers | 浮点保存寄存器 | Callee |
f28–31 | ft8–11 | FP temporaries | 浮点临时寄存器 | Caller |
Caller
表示寄存器在调用者保存策略中由调用方负责保存。Callee
表示寄存器在调用者保存策略中由被调用方负责保存。
应用程序设计
-
系统调用的实现
- 状态保存与恢复:
- 应用程序无需手动管理状态保存与恢复,OS 会自动处理。
- 汇编代码的重要性:
- 系统调用需要特定的汇编代码(如
ecall
)来触发,而这在高级语言中无法直接实现。 - 高级语言(如 Rust 和 C)无法直接翻译成
ecall
指令。
- 系统调用需要特定的汇编代码(如
- 状态保存与恢复:
-
实现库支持
- Sys Write 和 Sys Exit:
- 这些函数封装了系统调用,实现应用程序与操作系统的接口。
- 使用系统调用函数
syscall
来触发具体的ecall
指令。
- 嵌入汇编的作用:
- 汇编代码提供了一种直接与操作系统交互的方法,将调用 ID 和参数加载到特定寄存器中。
ecall
发出后,操作系统执行对应的功能并返回结果。
- Sys Write 和 Sys Exit:
-
应用程序与库的关系
- 库设计的目标:
- 确保应用程序通过高级语言函数(如
println!
或printf
)调用底层的系统服务。 - 将复杂的系统调用细节隐藏在库中,以便应用程序更容易实现。
- 确保应用程序通过高级语言函数(如
- 库设计的目标:
-
应用程序开发的要点
- 管控地址空间:
- 应用程序不需过多关注底层地址空间的管理,由操作系统来提供保护。
- 拓展系统调用:
- 当需要新功能时,可以在库中添加新的系统调用,并扩展
syscall
实现。
- 当需要新功能时,可以在库中添加新的系统调用,并扩展
- 管控地址空间:
通过这种设计方式,应用程序可以更专注于业务逻辑,而操作系统与库的封装确保了应用程序的安全性和稳定性。
系统调用支撑库
#![allow(unused)] fn main() { /// 功能:将内存中缓冲区中的数据写入文件。 /// 参数:`fd` 表示待写入文件的文件描述符; /// `buf` 表示内存中缓冲区的起始地址; /// `len` 表示内存中缓冲区的长度。 /// 返回值:返回成功写入的长度。 /// syscall ID:64 fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize; /// 功能:退出应用程序并将返回值告知批处理系统。 /// 参数:`xstate` 表示应用程序的返回值。 /// 返回值:该系统调用不应该返回。 /// syscall ID:93 fn sys_exit(xstate: usize) -> !; }
系统调用参数传递
#![allow(unused)] fn main() { 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 //syscall编号 ); } ret //返回值 } }
参考文档:Rust by Example - Inline assembly
系统调用封装
#![allow(unused)] fn main() { 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(xstate: i32) -> isize { syscall(SYSCALL_EXIT, [xstate as usize, 0, 0]) } }
系统调用封装
#![allow(unused)] fn main() { pub fn write(fd: usize, buf: &[u8]) -> isize { sys_write(fd, buf) } const STDOUT: usize = 1; impl Write for Stdout { fn write_str(&mut self, s: &str) -> fmt::Result { write(STDOUT, s.as_bytes()); Ok(()) } } }
六 内核程序设计
6.1 应用管理和加载
-
形成单一镜像
- 构建脚本:
- 使用
builder.rs
或者 Python 脚本将应用程序和内核代码合并为一个完整的镜像。 - 使用汇编指令
incbin
将应用程序的二进制文件纳入最终镜像中。
- 使用
- 构建脚本:
-
将应用程序链接到内核
- 查找与加载:
- 自动生成的汇编程序包含应用程序的位置信息(如
app0_start
和app0_end
),这些变量用于标识应用程序的起始和结束位置。 app_manager
负责查找这些位置信息,并将应用程序二进制代码拷贝到预定的内存地址。
- 自动生成的汇编程序包含应用程序的位置信息(如
- 固定的执行地址:
- 应用程序应加载到预设的固定地址,以符合链接器脚本
link.ld
中的指定位置。
- 应用程序应加载到预设的固定地址,以符合链接器脚本
- 查找与加载:
-
处理特权级切换
- ecall 指令的处理:
- 应用程序通过
ecall
指令请求系统调用,此时特权级从用户态切换到内核态。 - 内核需要响应这些系统调用,确保在执行前对相关资源进行初始化并保持状态一致。
- 应用程序通过
- 状态管理与切换:
- 内核需负责保存当前用户态的状态,以便系统调用完成后能正确恢复并返回到用户态。
- ecall 指令的处理:
-
进一步的初始化与执行
- 初始化工作:
- 在执行应用程序前,需要完成必要的内存管理与系统调用机制初始化。
- 特权级的切换和状态的管理需要在内核设计中重点考虑。
- 初始化工作:
通过以上流程,内核可以有效链接和执行应用程序,管理系统调用和切换不同的特权级。
应用程序管理与加载
-
应用程序管理(
app_manager.rs
)- 初始化过程:
- 在初始化过程中,
app_manager
会识别应用程序的起始和结束地址,并将其保存在全局变量中,方便后续访问和管理。
- 在初始化过程中,
- 全局变量:
- 这些全局变量会存储当前加载的应用程序数量以及每个程序的起始和结束位置。
- 初始化过程:
-
加载应用程序
- 从预定地址加载:
- 应用程序会从指定的内存地址加载到另一个固定的内存区域,以确保它能在合适的地址范围内执行。
- 内嵌汇编指令
fence.i
:- 为确保指令缓存(I-Cache)内容的正确性,在应用程序加载完成后会执行
fence.i
指令。 - 这条指令并非特权指令,而是扩展指令,用于清空 I-Cache。
- 原因:不清空 I-Cache 可能导致指令缓存中仍保存上一个应用程序的指令。
fence.i
确保新程序的代码能够正确执行。 - CPU 对物理内存所做的缓存又分成d-cache和i-cache
- OS将修改会被 CPU 取指的内存区域,这会使得 i-cache 中含有与内存中不一致的内容
- OS在这里必须使用 fence.i 指令手动清空 i-cache ,让里面所有的内容全部失效,才能够保证CPU访问内存数据和代码的正确性。
- 为确保指令缓存(I-Cache)内容的正确性,在应用程序加载完成后会执行
- 从预定地址加载:
通过以上管理与加载机制,系统能够正确识别、加载和运行不同的应用程序,并确保各程序之间的缓存数据不会混淆。
将应用程序映像链接到内核
# os/src/link_app.S 由脚本 os/build.rs 生成
.section .data
.global _num_app
_num_app:
.quad 5
.quad app_0_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:
应用程序管理数据结构
#![allow(unused)] fn main() { // os/src/batch.rs struct AppManager { num_app: usize, current_app: usize, app_start: [usize; MAX_APP_NUM + 1], } }
找到应用程序二进制码
- 找到 link_app.S 中提供的符号 _num_app
#![allow(unused)] fn main() { 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; ... app_start[..=num_app].copy_from_slice(app_start_raw); AppManager { num_app, current_app: 0, app_start, } }
加载应用程序二进制码
#![allow(unused)] fn main() { unsafe fn load_app(&self, app_id: usize) { // clear icache asm!("fence.i"); // clear app area ... 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); } }
6.2 特权级切换
在特权级切换中,硬件需要处理以下四个关键步骤,以确保正确切换到内核模式并记录相关状态。
-
保存前一个特权级状态(sstatus 寄存器):
SPP
(Supervisor Previous Privilege)记录了执行ecall
指令时的特权级,以便恢复时知道应该返回哪个特权级。SPP
等字段给出 Trap 发生之前 CPU 的特权级(S/U)等
- 主要用于在返回用户态或更低的特权级时,确保返回正确的上下文。
-
保存异常指令地址(SEPC 寄存器):
SEPC
(Supervisor Exception Program Counter)保存了发生异常或ecall
指令的确切地址。- 记录 Trap 发生之前执行的最后一条指令的地址
- 这使得操作系统能够识别并确定应该恢复到哪个指令继续执行。
- 在异常处理完毕后,软件通常需要将该地址加4,以跳过已执行的
ecall
指令,继续执行后续的正常指令。
-
保存异常原因/附加信息(SCAUSE/stval 寄存器):
SCAUSE
(Supervisor Cause)记录了ecall
或其他异常的具体原因。- 描述 Trap 的原因
- 操作系统通过读取
SCAUSE
,可以识别异常的类型,以便进行适当的处理。 stval
给出 Trap 附加信息
-
切换到内核特权级并跳转到异常处理入口(STVEC):
- 硬件将特权级设为 S 模式,并将程序计数器跳转到内核设置的异常处理入口。
STVEC
寄存器保存了异常处理程序的入口地址,硬件会根据它跳转到相应的处理函数执行。- 控制 Trap 处理代码的入口地址
通过这四个关键步骤,硬件确保了从用户模式到内核模式的平稳切换,并使内核有足够信息来准确处理异常和 ecall
指令。
CSR 名 | 该 CSR 与 Trap 相关的功能 |
---|---|
sstatus | SPP 等字段给出 Trap 发生之前 CPU 的特权级(S/U)等 |
sepc | 记录 Trap 发生之前执行的最后一条指令的地址 |
scause | 描述 Trap 的原因 |
stval | 给出 Trap 附加信息 |
stvec | 控制 Trap 处理代码的入口地址 |
特权级切换与用户栈和内核栈
在操作系统中,用户栈和内核栈分别用于处理用户态和内核态的任务。它们的分离设计旨在提高系统的安全性和稳定性。以下是一些设计与实现的原因:
-
安全性:
- 用户程序的栈不受信任,可能包含恶意数据或受损。让内核直接使用用户栈可能导致安全漏洞,例如被用户代码利用进行攻击。
- 使用单独的内核栈确保内核在处理异常和系统调用时能够在受控且可信的内存区域操作,避免受到用户程序的影响。
-
隔离与稳定性:
- 用户程序的栈内存空间可能不稳定或不一致,在内核处理复杂任务或大量数据时,可能会出现栈溢出、非法访问等问题。
- 内核栈在内核管理的独立区域中,确保操作系统在处理系统调用、异常和中断时,有足够的栈空间和稳定的内存布局。
-
状态管理:
- 系统调用和中断处理可能需要保存和恢复大量的寄存器和状态信息。
- 内核栈能够专门用于保存内核态的状态信息,确保系统能够正确地从异常或中断中恢复。
-
性能优化:
- 内核栈可以通过特定的硬件机制或结构进行优化,使其在系统调用和异常处理中更加高效。
- 这有助于在性能敏感的系统调用和中断处理中保持高效响应。
综上所述,分离用户栈和内核栈是确保系统安全、稳定和高效运行的重要设计原则。它使得操作系统在面对不受信任的用户程序时,仍能保持其完整性和可靠性。
#![allow(unused)] fn main() { const USER_STACK_SIZE: usize = 4096 * 2; const KERNEL_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] }; }
特权级切换中的换栈
#![allow(unused)] fn main() { impl UserStack { fn get_sp(&self) -> usize { self.data.as_ptr() as usize + USER_STACK_SIZE } } RegSP = USER_STACK.get_sp(); RegSP = KERNEL_STACK.get_sp(); }
6.3 Trap上下文
用户和内核栈的初始化与异常上下文
用户栈和内核栈的初始化:
- 在系统初始化时,操作系统将为应用程序分配独立的用户栈,并为内核程序分配独立的内核栈。
- 在执行应用程序之前,需要将应用程序的执行环境设置好,包括用户栈。
- 在响应系统调用时,操作系统将准备内核栈,以处理接下来的任务。
异常上下文:
- 为了让内核在处理完系统调用后能够正确恢复应用程序状态,需要保存异常上下文(Trap Context)。
- 异常上下文保存的信息包括:
- 通用寄存器的值(可能包含32个寄存器的内容)。
- 特权级状态(
sstatus
)和异常返回地址(sepc
)。
状态保存的重要性:
-
通用寄存器:
- 通用寄存器包含应用程序当前的操作数据和状态。
- 如果不保存这些寄存器的数据,内核在执行过程中会覆盖它们,导致应用程序的状态丢失或异常。
-
sstatus
和sepc
:sstatus
:指示特权级状态,确保返回到用户态时,系统能恢复正确的特权级设置。sepc
:记录异常发生的指令地址,用于系统调用处理完毕后,准确返回用户代码。
额外保存的原因:
- 如果在处理系统调用或异常期间再次发生异常或中断,特权级状态和异常返回地址可能会被覆盖。
- 保存这些状态有助于操作系统在复杂的多重异常情况下,确保正确恢复到用户代码的执行状态。
通过在异常上下文中保存所有必要的寄存器和状态信息,操作系统能够确保在从内核返回用户态时恢复应用程序的执行环境。
Trap 上下文数据结构
#![allow(unused)] fn main() { #[repr(C)] pub struct TrapContext { pub x: [usize; 32], pub sstatus: Sstatus, pub sepc: usize, } }
- 对于通用寄存器而言,应用程序/内核控制流运行在不同的特权级
- 进入 Trap 的时候,硬件会立即覆盖掉 scause/stval/sstatus/sepc
sscratch CSR 重要的中转寄存器
- 暂时保存内核栈的地址
- 作为一个中转站让 sp (目前指向的用户栈的地址)的值可以暂时保存在 sscratch
- 仅需一条
csrrw sp, sscratch, sp // 交换对 sp 和 sscratch 两个寄存器内容
- 完成用户栈-->内核栈的切换
保存通用寄存器的宏
# os/src/trap/trap.S
.macro SAVE_GP n
sd x\n, \n*8(sp)
.endm
6.4 Trap处理流程
特权级切换与异常处理入口点初始化
- 设置特权级切换的入口点是重要的准备工作,确保当发生系统调用或异常时,操作系统能够正确处理。
- 通过设置
STVEC
寄存器,将异常处理程序的入口点指向trap.S
中的汇编函数__alltraps
,使得所有的异常处理都进入这个函数。
Trap 入口点
#![allow(unused)] fn main() { pub fn init() { extern "C" { fn __alltraps(); } unsafe { stvec::write(__alltraps as usize, TrapMode::Direct); } } }
系统调用过程中的Trap上下文处理
- 应用程序通过
ecall
进入到内核状态时,操作系统保存被打断的应用程序的 Trap 上下文; - 操作系统根据 Trap 相关的 CSR 寄存器内容,完成系统调用服务的分发与处理;
- 操作系统完成系统调用服务后,需要恢复被打断的应用程序的 Trap 上下文,并通
sret
指令让应用程序继续执行。
or 异常处理流程
- 识别异常:通过读取
scause
和mtval
等寄存器获取异常类型和相关地址信息。- 保存状态:保存寄存器和栈信息以便恢复。
- 采取措施:根据异常类型选择合适的响应,比如页面错误处理、访问权限检查、系统调用等。
- 恢复状态:在处理完成后,恢复状态并返回继续执行。
异常发生后的具体处理步骤
-
保存上下文:
- 在异常发生后,操作系统会进入异常处理程序,并首先将应用程序的上下文信息(寄存器、状态)保存到内存中的一个特定区域(通常是内核栈)。
- 上下文信息保存在一个
trap context
结构中,确保操作系统能够恢复被中断的应用程序状态。
-
特权级切换与异常信息:
- 确保
trap context
完整保存后,操作系统将开始处理异常或系统调用。 - 通过检查相关寄存器,确定是哪种异常或系统调用触发了特权级切换。例如,系统调用会通过特定寄存器(如
A7
)传递调用编号。 - 根据系统调用编号或异常类型,操作系统会调用相应的服务例程或异常处理程序。
- 确保
-
系统调用处理:
- 在这个操作系统实现中,主要处理两个基本系统调用:
sys_write
:将应用程序请求的字符串输出到终端。sys_exit
:退出应用程序并返回控制权给操作系统。
- 在处理系统调用时,操作系统会从寄存器中读取传递的参数,执行对应的功能。
- 在这个操作系统实现中,主要处理两个基本系统调用:
注意事项
- 完整的寄存器保存:为了在从内核返回到用户态时正确恢复状态,需要确保所有寄存器都被完整保存。
- 控制跳转:根据异常或系统调用的类型,操作系统必须确保跳转到适当的处理例程,才能提供正确的服务或处理异常。
Trap处理流程代码
- 首先通过 __alltraps 将 Trap 上下文保存在内核栈上;
- 然后跳转到 trap_handler 函数完成 Trap 分发及处理。
__alltraps:
csrrw sp, sscratch, sp
# now sp->kernel stack, sscratch->user stack
# allocate a TrapContext on kernel stack
addi sp, sp, -34*8
保存Trap上下文
保存通用寄存器
# 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
保存 sstatus 和 sepc
# 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)
保存 user SP
# read user stack from sscratch and save it on the kernel stack
csrr t2, sscratch
sd t2, 2*8(sp)
#![allow(unused)] fn main() { pub struct TrapContext { pub x: [usize; 32], pub sstatus: Sstatus, pub sepc: usize, } }
调用trap_handler
# set input argument of trap_handler(cx: &mut TrapContext)
mv a0, sp
call trap_handler
让寄存器 a0 指向内核栈的栈指针也就是我们刚刚保存的 Trap 上下文的地址,这是由于我们接下来要调用 trap_handler 进行 Trap 处理,它的第一个参数 cx 由调用规范要从 a0 中获取。
恢复Trap上下文
- 大部分是保存寄存器的反向操作;
- 最后一步是
sret
指令 //从内核态返回到用户态
注:后面讲解“执行程序”时会比较详细的讲解"恢复Trap上下文"
trap_handler处理syscall
#![allow(unused)] fn main() { #[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; } ... } cx } }
#![allow(unused)] fn main() { pub fn sys_exit(xstate: i32) -> ! { println!("[kernel] Application exited with code {}", xstate); run_next_app() } }
6.5 执行应用程序
应用程序的执行时机
- 当批处理操作系统初始化完成
- 某个应用程序运行结束或出错
从内核态切换到用户态
- 准备好应用的上下文
Trap上下文
- 恢复应用的相关寄存器
- 特别是应用用户栈指针和执行地址
- 返回用户态让应用执行
特权级切换与恢复
返回用户态:执行 sret
- 当操作系统完成了系统调用或异常处理后,需要通过
sret
指令从内核态返回用户态。 sret
指令确保操作系统能正确跳回到应用程序中断的位置,继续执行。sret
指令的硬件逻辑:- 恢复响应中断/异常
- CPU Mode从S-Mode 回到U-Mode
pc
<--spec
CSR- 继续运行
恢复上下文
-
在执行
sret
之前,需要从内核栈恢复寄存器的上下文信息,包括通用寄存器、状态寄存器等,确保返回时能够恢复用户程序的执行状态。 -
切换到下一个应用程序
调用 run_next_app 函数切换到下一个应用程序:
- 构造应用程序开始执行所需的 Trap 上下文;
- 通过
__restore
函数,从刚构造的 Trap 上下文中,恢复应用程序执行的部分寄存器; - 设置
sepc
CSR的内容为应用程序入口点0x80400000
; - 切换
scratch
和sp
寄存器,设置sp
指向应用程序用户栈; - 执行
sret
从 S 特权级切换到 U 特权级。
sscratch
寄存器与栈切换
- 为了在执行上下文保存与恢复时避免破坏寄存器,需要一个中转寄存器来存储关键数据。
sscratch
寄存器用于保存当前用户态栈顶地址,以便在切换到内核栈时可以恢复回来。- 通过指令
csrrw SP, sscratch, SP
,将当前栈顶地址与sscratch
中的数据进行交换:- 先将用户态栈顶地址保存到
sscratch
。 - 再将
sscratch
中保存的内核栈地址赋值给当前栈顶寄存器SP
。 - 这一步使得当前执行环境切换到内核栈,为接下来的异常处理提供独立的空间。
- 先将用户态栈顶地址保存到
相关代码:
构造Trap上下文
#![allow(unused)] fn main() { 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 }
运行下一程序
#![allow(unused)] fn main() { ub fn run_next_app() -> ! { ... unsafe { app_manager.load_app(current_app); } ... 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!"); } }
__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
创建第一个应用程序的 Trap Context
- Trap Context 初始化:尽管第一个应用程序还未开始执行,系统仍需为它构建一个完整的 Trap Context。
- 寄存器设置:
X0
到X31
除SP
之外的寄存器通常初始化为零。 - 栈指针 (SP):指向预分配的用户态栈的起始地址,确保程序执行时能够正确使用栈。
- SEPC:设置为该应用程序的入口地址,以便系统在 SRET 指令执行后跳转到正确的入口。
- 寄存器设置:
内核态返回用户态的机制
- SRET 指令:SRET(Supervisor Return)通过以下方式实现从内核态到用户态的切换:
- 前一个状态:根据
sstatus
中的前一个状态位,切换至u mode
或其他模式。 - 恢复 PC:从
sepc
恢复 PC 寄存器的值,确保从指定的应用程序入口地址开始执行。
- 前一个状态:根据
管理特权指令的安全性
- PC 寄存器的访问限制:由于
PC
寄存器管理当前执行的指令位置,它并不直接对用户应用程序开放,只能由 CPU 和内核进行读取和设置。这样设计的原因在于保护系统的安全,防止恶意程序干扰执行流或读取系统敏感信息。
小结
内核态上下文管理
保存 trap
上下文
trap
上下文的保存过程旨在确保内核可以在处理完系统调用或异常后,正确恢复到用户态继续执行。- 在保存过程中:
- 首先将所有通用寄存器的内容(包括
sstatus
和sepc
)保存到内核栈顶。 - 使用
sscratch
寄存器作为临时存储用户态栈顶的中间值。 - 在
trap
上下文保存的过程中,确保最终sp
寄存器内容正确地存储到trap
上下文中。
- 首先将所有通用寄存器的内容(包括
trap_handler
的处理
- 当进入
trap_handler
时,处理的主要任务是:- 确保内核栈在处理中保持正确的栈顶。
- 完成函数调用前所需的准备工作,使得接下来的处理过程可以正常进行。
- 使用编译器生成的高级代码执行剩下的
syscall
或异常处理逻辑。
利用内核栈与编译器生成代码
- 内核栈在系统调用和异常处理中是独立的栈空间,用于确保内核逻辑与用户程序逻辑分离。
- 编译器生成的代码可以高效管理函数调用过程中的栈数据,确保上下文完整保存与恢复。
trap
上下文保存:通过保存寄存器和相关状态信息,确保特权级之间能够正确切换与恢复。
trap_handler
:提供了函数调用的栈管理,并与编译器生成代码紧密协作,确保操作系统能够正确完成系统调用与异常处理。
内核与应用程序的交互
系统调用的处理与恢复
-
系统调用处理
- 当用户程序发出
ecall
指令时,硬件触发特权级切换并跳入操作系统。 - 通过读取
scause
和stval
寄存器,操作系统能够识别ecall
的原因。 - 如果
ecall
源自用户态系统调用,将根据相应的调用 ID 来调用操作系统的服务。 - 执行
sys_write
系统调用时,操作系统会调用 SBI (Supervisor Binary Interface) 服务来输出字符。 sys_exit
系统调用用于用户程序主动退出,此时操作系统会标记当前任务结束,准备加载并运行下一个程序。
- 当用户程序发出
-
系统调用的限制
- 当前批处理操作系统仅支持主动退出的程序和被异常终止的程序两种情况的切换。
- 如果用户程序进入无限循环而不发出
exit
,则操作系统无法中断并切换任务,因为缺乏时钟中断机制。
应用程序的执行与初始化
-
应用程序初始化
- 应用程序的执行需要预先设置好执行环境,尤其是用户栈的内存空间和相关寄存器。
- 操作系统需确保栈空间足够,并通过初始化来确保执行环境的完整性。
-
执行控制
- 应用程序执行完或主动退出时,操作系统才会加载并切换到下一个应用程序。
下一步工作
- 中断机制的引入
- 现有的批处理操作系统仅能处理主动退出和异常终止的程序。为了实现更复杂的任务管理,应引入时钟中断机制来主动打断执行中的程序,实现更全面的多任务管理。
初始化用户程序的执行环境
在批处理操作系统中,必须提前为第一个用户程序创建一个完整的执行环境,以便从内核态切换到用户态并开始运行该程序。
要点
在设计批处理操作系统与应用程序之间的交互时,需要关注以下核心概念和机制:
1. 应用程序的初始化与系统调用
- 库函数支持:应用程序通过库函数发起系统调用,如
sys_write
和sys_exit
。库函数封装ecall
指令,将请求传递到内核。 - 应用程序环境:应用程序的栈和寄存器初始化确保程序的正确运行。
2. 内核对系统调用的响应
- Trap Context 保存与恢复:在执行
ecall
时,系统必须保存当前状态,以便稍后恢复。Trap Context 包含所有重要的寄存器和状态信息。 - 系统调用处理:在内核中,根据系统调用的编号执行相应的逻辑,比如字符串输出或进程退出。
- 特权级切换:特权级切换的关键在于保存和恢复正确的状态,以确保应用程序的连续执行。
3. 特殊寄存器和特权指令的作用
- sscratch 寄存器:用于缓存用户态的栈顶指针,以便在特权级切换时安全地管理内核栈与用户栈。
- SRET 指令:根据
sstatus
和sepc
的值,将控制权从内核返回用户态。
总结
- OS 与应用程序分工明确:应用程序通过系统调用请求内核的服务,内核通过 Trap Handler 安全地响应请求。
- 特权级隔离:通过分开管理内核和用户栈,并使用特定的特权指令,确保特权级之间的安全切换。
- 硬件与软件协同:特定的硬件寄存器与软件逻辑紧密配合,共同完成状态保存与恢复。