type
status
slug
date
summary
tags
category
password
icon

Lab2

应用程序总是难免会出现错误,如果一个程序的执行错误导致其它程序或者整个计算机系统都无法运行就太糟糕了。人们希望一个应用程序的错误不要影响到其它应用程序、操作系统和整个计算机系统。这就需要操作系统能够终止出错的应用程序,转而运行下一个应用程序。这种 保护 计算机系统不受有意或无意出错的程序破坏的机制被称为 特权级 (Privilege) 机制,它让应用程序运行在用户态,而操作系统运行在内核态,且实现用户态和内核态的隔离,这需要计算机软件和硬件的共同努力。 对于lab的user目录下的代码:
每个应用程序的实现都在对应的单个文件中。打开 hello_world.rs,能看到一个 main 函数,还有外部库引用:
#[macro_use]extern crate user_lib;
这个外部库其实就是 user 目录下的 lib.rs 以及它引用的若干子模块。 在 user/Cargo.toml 中我们对于库的名字进行了设置: name =  "user_lib" 。 它作为 bin 目录下的源程序所依赖的用户库,等价于其他编程语言提供的标准库。
 
我们在 lib.rs 中看到了另一个 main :
我们使用 Rust 宏将其标志为弱链接。这样在最后链接的时候, 虽然 lib.rs 和 bin 目录下的某个应用程序中都有 main 符号, 但由于 lib.rs 中的 main 符号是弱链接, 链接器会使用 bin 目录下的函数作为 main 。 如果在 bin 目录下找不到任何 main ,那么编译也能通过,但会在运行时报错。

特权级的软硬件协同设计

实现特权级机制的根本原因是应用程序运行的安全性不可充分信任。在上一章里,操作系统以库的形式和应用紧密连接在一起,构成一个整体来执行。随着应用需求的增加,操作系统的体积也越来越大;同时应用自身也会越来越复杂。由于操作系统会被频繁访问,来给多个应用提供服务,所以它可能的错误会比较快地被发现。但应用自身的错误可能就不会很快发现。由于二者通过编译器形成一个单一执行程序来执行,导致即使是应用程序本身的问题,也会让操作系统受到连累,从而可能导致整个计算机系统都不可用了。
所以,计算机科学家和工程师就想到一个方法,让相对安全可靠的操作系统运行在一个硬件保护的安全执行环境中,不受到应用程序的破坏;而让应用程序运行在另外一个无法破坏操作系统的受限执行环境中。
为确保操作系统的安全,对应用程序而言,需要限制的主要有两个方面:
  • 应用程序不能访问任意的地址空间(这个在第四章会进一步讲解,本章不会涉及)
  • 应用程序不能执行某些可能破坏计算机系统的指令(本章的重点)
假设有了这样的限制,我们还需要确保应用程序能够得到操作系统的服务,即应用程序和操作系统还需要有交互的手段。使得低特权级软件只能做高特权级软件允许它做的,且超出低特权级软件能力的功能必须寻求高特权级软件的帮助。这样,高特权级软件(操作系统)就成为低特权级软件(一般应用)的软件执行环境的重要组成部分。
一个比较简洁的方法就是,处理器设置两个不同安全等级的执行环境:用户态特权级的执行环境和内核态特权级的执行环境。且明确指出可能破坏计算机系统的内核态特权级指令子集,规定内核态特权级指令子集中的指令只能在内核态特权级的执行环境中执行。处理器在执行指令前会进行特权级安全检查,如果在用户态执行环境中执行这些内核态特权级指令,会产生异常。
采用传统的函数调用方式(即通常的 call 和 ret 指令或指令组合)将会直接绕过硬件的特权级保护检查。为了解决这个问题, RISC-V 提供了新的机器指令:执行环境调用指令(Execution Environment Call,简称 ecall )和一类执行环境返回(Execution Environment Return,简称 eret )指令。其中:
  • ecall 具有用户态到内核态的执行环境切换能力的函数调用指令;
  • sret :具有内核态到用户态的执行环境切换能力的函数返回指令。
sret 与 eret 的联系与区别
eret 代表一类执行环境返回指令,而 sret 特指从 Supervisor 模式的执行环境(即 OS 内核)返回的那条指令,也是本书中主要用到的指令。除了 sret 之外, mret 也属于执行环境返回指令,当从 Machine 模式的执行环境返回时使用, RustSBI 会用到这条指令。
sret 与 eret 的联系与区别 eret 代表一类执行环境返回指令,而 sret 特指从 Supervisor 模式的执行环境(即 OS 内核)返回的那条指令,也是本书中主要用到的指令。除了 sret 之外, mret 也属于执行环境返回指令,当从 Machine 模式的执行环境返回时使用, RustSBI 会用到这条指令。
硬件具有了这样的机制后,还需要操作系统的配合才能最终完成对操作系统自身的保护。首先,操作系统需要提供相应的功能代码,能在执行 sret 前准备和恢复用户态执行应用程序的上下文。其次,在应用程序调用 ecall 指令后,能够检查应用程序的系统调用参数,确保参数不会破坏操作系统。
一般来说, ecall 这条指令和 eret 这类指令分别可以用来让 CPU 从当前特权级切换到比当前高一级的特权级和切换到不高于当前的特权级,因此上面提到的两条指令的功能仅是其中一种用法。在本书中,大多数情况我们只需考虑这种用法即可。
读者可能会好奇一共有多少种不同的特权级,在不同的指令集体系结构中特权级的数量也是不同的。x86 和 RISC-V 设计了多达 4 种特权级,而对于一般的操作系统而言,其实只要两种特权级就够了。
一般来说, ecall 这条指令和 eret 这类指令分别可以用来让 CPU 从当前特权级切换到比当前高一级的特权级和切换到不高于当前的特权级,因此上面提到的两条指令的功能仅是其中一种用法。在本书中,大多数情况我们只需考虑这种用法即可。 读者可能会好奇一共有多少种不同的特权级,在不同的指令集体系结构中特权级的数量也是不同的。x86 和 RISC-V 设计了多达 4 种特权级,而对于一般的操作系统而言,其实只要两种特权级就够了。
SBI全称:Supervisor Binary Interface
notion image
notion image
运行在 M 模式上的软件被称为 监督模式执行环境 (SEE, Supervisor Execution Environment)
按需实现 RISC-V 特权级
按需实现 RISC-V 特权级
RISC-V 架构中,只有 M 模式是必须实现的,剩下的特权级则可以根据跑在 CPU 上应用的实际需求进行调整:
  • 简单的嵌入式应用只需要实现 M 模式;
  • 带有一定保护能力的嵌入式系统需要实现 M/U 模式;
  • 复杂的多任务系统则需要实现 M/S/U 模式。
  • 到目前为止,(Hypervisor, H)模式的特权规范还没完全制定好,所以本书不会涉及。
我们会涉及到RISC-V的 M/S/U 三种特权级:其中应用程序和用户态支持库运行在 U 模式的最低特权级;操作系统内核运行在 S 模式特权级(在本章表现为一个简单的批处理系统),形成支撑应用程序和用户态支持库的执行环境;而第一章提到的预编译的 bootloader – RustSBI 实际上是运行在更底层的 M 模式特权级下的软件,是操作系统内核的执行环境。整个软件系统就由这三层运行在不同特权级下的不同软件组成。
执行环境的另一种功能是对上层软件的执行进行监控管理。监控管理可以理解为,当上层软件执行的时候出现了一些异常或特殊情况,导致需要用到执行环境中提供的功能,因此需要暂停上层软件的执行,转而运行执行环境的代码。由于上层软件和执行环境被设计为运行在不同的特权级,这个过程也往往(而 不一定 )伴随着 CPU 的 特权级切换 。当执行环境的代码运行结束后,我们需要回到上层软件暂停的位置继续执行。在 RISC-V 架构中,这种与常规控制流(顺序、循环、分支、函数调用)不同的 异常控制流 (ECF, Exception Control Flow) 被称为 异常(Exception) ,是 RISC-V 语境下的 Trap 种类之一。 用户态应用直接触发从用户态到内核态的异常的原因总体上可以分为两种:其一是用户态软件为获得内核态操作系统的服务功能而执行特殊指令;其二是在执行某条指令期间产生了错误(如执行了用户态不允许执行的指令或者其他错误)并被 CPU 检测到。下表中我们给出了 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
0
7
Store/AMO access fault
0
8
Environment call from U-mode
0
9
Environment call from S-mode
0
11
Environment call from M-mode
0
12
Instruction page fault
0
13
Load page fault
0
15
Store/AMO page fault
其中 断点 (Breakpoint) 和 执行环境调用 (Environment call) 两种异常(为了与其他非有意为之的异常区分,会把这种有意为之的指令称为 陷入 或 trap 类指令,此处的陷入为操作系统中传统概念)是通过在上层软件中执行一条特定的指令触发的:执行 ebreak 这条指令之后就会触发断点陷入异常;而执行 ecall 这条指令时候则会随着 CPU 当前所处特权级而触发不同的异常。从表中可以看出,当 CPU 分别处于 M/S/U 三种特权级时执行 ecall 这条指令会触发三种异常(分别参考上表 Exception Code 为 11/9/8 对应的行)。
notion image
在这里我们需要说明一下执行环境调用 ecall ,这是一种很特殊的 陷入 类的指令,上图中相邻两特权级软件之间的接口正是基于这种陷入机制实现的。M 模式软件 SEE 和 S 模式的内核之间的接口被称为 监督模式二进制接口 (Supervisor Binary Interface, SBI),而内核和 U 模式的应用程序之间的接口被称为 应用程序二进制接口 (Application Binary Interface, ABI),当然它有一个更加通俗的名字—— 系统调用
而之所以叫做二进制接口,是因为它与高级编程语言的内部调用接口不同,是机器/汇编指令级的一种接口。事实上 M/S/U 三个特权级的软件可分别由不同的编程语言实现,即使是用同一种编程语言实现的,其调用也并不是普通的函数调用控制流,而是 陷入异常控制流 ,在该过程中会切换 CPU 特权级。因此只有将接口下降到机器/汇编指令级才能够满足其跨高级语言的通用性和灵活性。
可以看到,在这样的架构之下,每层特权级的软件都只能做高特权级软件允许它做的、且不会产生什么撼动高特权级软件的事情,一旦低特权级软件的要求超出了其能力范围,就必须寻求高特权级软件的帮助,否则就是一种异常行为了。因此,在软件(应用、操作系统等)执行过程中我们经常能够看到特权级切换。如下图所示:
notion image

RISC-V的特权指令

与特权级无关的一般的指令和通用寄存器 x0 ~ x31 在任何特权级都可以执行。而每个特权级都对应一些特殊指令和 控制状态寄存器 (CSR, Control and Status Register) ,来控制该特权级的某些行为并描述其状态。当然特权指令不仅具有读写 CSR 的指令,还有其他功能的特权指令。
指令
含义
sret
从 S 模式返回 U 模式:在 U 模式下执行会产生非法指令异常
wfi
处理器在空闲时进入低功耗状态等待中断:在 U 模式下执行会产生非法指令异常
sfence.vma
刷新 TLB 缓存:在 U 模式下执行会产生非法指令异常
访问 S 模式 CSR 的指令
通过访问 sepc/stvec/scause/sscartch/stval/sstatus/satp等CSR 来改变系统状态:在 U 模式下执行会产生非法指令异常

2.3实现应用程序

注解
注解
RISC-V 寄存器编号从 0~31 ,表示为 x0~x31 。 其中: - x10~x17 : 对应 a0~a7 - x1 :对应 ra
在 Rust 的内联汇编(inline assembly)中,=> 符号用于指定一个寄存器的输入和输出值。
在代码中:
inlateout("x10") args[0] => ret 表示 x10 寄存器在汇编代码开始执行前,其值被设置为 args[0],并且在汇编代码执行完成后,x10 寄存器的值被存储到 ret 变量中。
inlateout 是一个指示符,表示这个寄存器既用作输入,又用作输出。"x10" 是寄存器的名称,args[0] 是输入值,ret 是输出值。
所以,=> ret 的意思是,将寄存器 x10 的值在汇编代码执行完成后存储到 ret 变量中。
在第一章中,我们曾经使用 global_asm! 宏来嵌入全局汇编代码,而这里的 asm! 宏可以将汇编代码嵌入到局部的函数上下文中。相比 global_asm! , asm! 宏可以获取上下文中的变量信息并允许嵌入的汇编代码对这些变量进行操作。由于编译器的能力不足以判定插入汇编代码这个行为的安全性,所以我们需要将其包裹在 unsafe 块中自己来对它负责。 第一章是:
Qemu 的用户态模拟和系统级模拟
Qemu 的用户态模拟和系统级模拟
Qemu 有两种运行模式:用户态模拟(User mode)和系统级模拟(System mode)。在 RISC-V 架构中,用户态模拟可使用 qemu-riscv64 模拟器,它可以模拟一台预装了 Linux 操作系统的 RISC-V 计算机。但是一般情况下我们并不通过输入命令来与之交互(就像我们正常使用 Linux 操作系统一样),它仅支持载入并执行单个可执行文件。具体来说,它可以解析基于 RISC-V 的应用级 ELF 可执行文件,加载到内存并跳转到入口点开始执行。在翻译并执行指令时,如果碰到是系统调用相关的汇编指令,它会把不同处理器(如 RISC-V)的 Linux 系统调用转换为本机处理器(如 x86-64)上的 Linux 系统调用,这样就可以让本机 Linux 完成系统调用,并返回结果(再转换成 RISC-V 能识别的数据)给这些应用。相对的,我们使用 qemu-system-riscv64 模拟器来系统级模拟一台 RISC-V 64 裸机,它包含处理器、内存及其他外部设备,支持运行完整的操作系统。

2.4实现批处理操作系统

Rust 可以使用 & 或 &mut 后面加上值被绑定到的变量的名字来分别生成值的不可变引用和可变引用,我们称这些引用分别不可变/可变 借用 (Borrow) 它们引用的值。顾名思义,我们可以通过可变引用来修改它借用的值,但通过不可变引用则只能读取而不能修改。这些引用同样是需要被绑定到变量上的值,只是它们的类型是引用类型。在 Rust 中,引用类型的使用需要被编译器检查,但在数据表达上,和 C 的指针一样它只记录它借用的值所在的地址,因此在内存中它随平台不同仅会占据 4 字节或 8 字节空间。
make -C 是一个在 makefile 中常用的命令,它的作用是改变当前目录。-C 后面通常会跟一个目录路径,make 会先切换到这个目录,然后在这个目录下执行 make 命令。
例如,make -C /path/to/directory 会让 make 切换到 /path/to/directory 这个目录,然后在这个目录下执行 make 命令。
CSR 名
该 CSR 与 Trap 相关的功能
sstatus
SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息
sepc
当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址
scause
描述 Trap 的原因
stval
给出 Trap 附加信息
stvec
控制 Trap 处理代码的入口地址
stvec 相关细节
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 指令集特权级规范。
 
保存寄存器:
可以看到里面包含所有的通用寄存器 x0~x31 ,还有 sstatus 和 sepc 。那么为什么需要保存它们呢?
  • 对于通用寄存器而言,两条控制流(应用程序控制流和内核控制流)运行在不同的特权级,所属的软件也可能由不同的编程语言编写,虽然在 Trap 控制流中只是会执行 Trap 处理相关的代码,但依然可能直接或间接调用很多模块,因此很难甚至不可能找出哪些寄存器无需保存。既然如此我们就只能全部保存了。但这里也有一些例外,如 x0 被硬编码为 0 ,它自然不会有变化;还有 tp(x4) 寄存器,除非我们手动出于一些特殊用途使用它,否则一般也不会被用到。虽然它们无需保存,但我们仍然在 TrapContext 中为它们预留空间,主要是为了后续的实现方便。
  • 对于 CSR 而言,我们知道进入 Trap 的时候,硬件会立即覆盖掉 scause/stval/sstatus/sepc 的全部或是其中一部分。scause/stval 的情况是:它总是在 Trap 处理的第一时间就被使用或者是在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。而对于 sstatus/sepc 而言,它们会在 Trap 处理的全程有意义(在 Trap 控制流最后 sret 的时候还用到了它们),而且确实会出现 Trap 嵌套的情况使得它们的值被覆盖掉。所以我们需要将它们也一起保存下来,并在 sret 之前恢复原样。

Trap的分发处理

第 8~11 行,发现触发 Trap 的原因是来自 U 特权级的 Environment Call,也就是系统调用。这里我们首先修改保存在内核栈上的 Trap 上下文里面 sepc,让其增加 4。这是因为我们知道这是一个由 ecall 指令触发的系统调用,在进入 Trap 的时候,硬件会将 sepc 设置为这条 ecall 指令所在的地址(因为它是进入 Trap 之前最后一条执行的指令)。而在 Trap 返回之后,我们希望应用程序控制流从 ecall 的下一条指令开始执行。因此我们只需修改 Trap 上下文里面的 sepc,让它增加 ecall 指令的码长,也即 4 字节。这样在 __restore 的时候 sepc 在恢复之后就会指向 ecall 的下一条指令,并在 sret 之后从那里开始执行。
事实上,在从操作系统内核返回到运行应用程序之前,要完成如下这些工作:
  • 构造应用程序开始执行所需的 Trap 上下文;
  • 通过 __restore 函数,从刚构造的 Trap 上下文中,恢复应用程序执行的部分寄存器;
  • 设置 sepc CSR的内容为应用程序入口点 0x80400000
  • 切换 scratch 和 sp 寄存器,设置 sp 指向应用程序用户栈;
  • 执行 sret 从 S 特权级切换到 U 特权级。
 
rcoreLab-ch1rcoreLab-ch3
Loading...
liamY
liamY
Chasing Possible
最新发布
Enter AMX (Advanced Matrix Extensions)
2025-3-17
ktransformers相关内容学习
2025-2-16
sglang_benchmark
2025-2-7
SnapKV: LLM Knows What You are Looking for Before Generation
2024-12-12
数字电路复习
2024-12-11
CacheBlend: Fast Large Language Model Serving with Cached Knowledge Fusion论文学习
2024-11-23
公告
🎉Liam’s blog🎉
-- 全新上线 ---
👏欢迎comment👏
⚠️由于浏览器缓存的原因,有些内容是更新了的但是需要手动刷新3次左右,页面才会显示更新内容