- 按照实验指导书配置环境
- 阅读课本第 0 章
- 异常控制(Concurrency)
- 中断:外设引起的外部事件中断,是异步的,与处理器无关
- 异常:处理器执行指令时检测到的非法事件,处理器主动异常处理或直接终止
- 陷入:程序通过系统调用请求操作系统而有意引发的事件
- 进程 Process(Concurrency, Virtualization)
- 进程是应用程序的一次执行过程,在执行过程中,由“操作系统”执行环境来管理程序执行过程中的进程上下文。
- 一个进程是一个具有一定独立功能的程序在一个数据集合上的一次动态执行过程
- 地址空间(Viratualization)
- 文件(Persistency)
- 文件是持久存储的抽象,并进一步扩展为外设的抽象(一切皆文件)
- 为什么外设抽象为文件,而不是内存
- 虚拟性
- 内存虚拟化,是一种空间虚拟化,可以分为内存地址虚拟化和内存大小虚拟化
- 内存地址虚拟化:编译器设定固定地址,通过操作系统放到闲置物理内存中,实现内存地址虚拟化
- 内存大小虚拟化:操作系统将物理内存中没有使用的空间换出,写到硬盘当中,实现内存大小虚拟化(swap)
- CPU 虚拟化
- 内存虚拟化,是一种空间虚拟化,可以分为内存地址虚拟化和内存大小虚拟化
- 并发性:改善系统资源利用率,但是带来对共享资源的争夺问题
- 并行:两个或多个事件在同一时刻发生
- 并发:两个或多个事件在同一时间间隔内发生
- 异步性:由于操作系统的调度和中断,会不时地暂停或打断当前正在运行的程序
- 共享性:并发运行是,对资源的共享访问。需要在操作系统乃至于硬件层次上的一致性
- 持久性:文件系统将存储介质中的数据读取到内存中,并可以把内存中的数据写回到存储介质中
- 操作系统是向下管理硬件资源,向上提供应用的软件。目的是对硬件资源作合理抽象,提供给用户
- 服务器操作系统注重并发性,手机操作系统注重持久性
- 面向用户的操作系统必须包括网络浏览器,因为信息交互是用户使用操作系统的首要原因
- 虚拟化:内存,CPU;并发性:CPU;异步性:CPU;共享性:存储介质,内存;持久性:存储介质
- 内存和文件系统
- 对资源(空间:内存,硬盘,时间:CPU,并发)的抽象
- C 可以直接操作内存,Java 需要基于虚拟机
- 通过操作系统同一抽象调用硬件。 程序执行:thread 内存分配:malloc 文件读写:io
- 单个OS:;批处理OS;多道程序OS;分时共享OS;
- 计算机组成原理
- 计算机主要由 CPU、物理内存、I/O 外设组成
- CPU 从物理内存中读取指令、译码并执行,可能会与物理内存和 I/O 打交道
- 物理内存 是计算机重要组成,CPU 唯一能够直接访 问的只有物理内存中的数据,CPU 将物理内存视为大字节数组,CPU 可以通过物理内存寻址,并逐字节地访问物理内存中保存的数据
- Qemu
- 指令
qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
- 指令介绍
-machine
计算机名-nographic
无图形界面-bios
初始化引导-device
loader
将 Qemu 模拟器开机之前将一个宿主文件载入到 Qemu 物理内存的指定位置当中
- Qemu 启动流程
- 第零阶段:物理内存起始地址:
0x80000000
,课程使用范围为[0x80000000,0x80800000]
,使用之前指令启动时,首先会将 bootloader 的rustsbi-qemu.bin
加载到0x80000000
上,接着将内核镜像加载到os.bin
加载到0x80200000
上 - 第一阶段:将必要的文件载入到 Qemu 物理内存之后,Qemu CPU 的程序计数器(PC, Program Counter)会被初始化为
0x1000
,因此 Qemu 实际执行的第一条指令位于物理地址0x1000
,接下来它将执行寥寥数条指令并跳转到物理地址0x80000000
对应的指令处并进入第二阶段。从后面的调试过程可以看出,该地址0x80000000
被固化在 Qemu 中,作为 Qemu 的使用者,我们在不触及 Qemu 源代码的情况下无法进行更改。 - 第二阶段:由于 Qemu 的第一阶段固定跳转到
0x80000000
,我们需要将负责第二阶段的 bootloader rustsbi-qemu.bin 放在以物理地址0x80000000
开头的物理内存中,这样就能保证0x80000000
处正好保存 bootloader 的第一条指令。在这一阶段,bootloader 负责对计算机进行一些初始化工作,并跳转到下一阶段软件的入口,在 Qemu 上即可实现将计算机控制权移交给我们的内核镜像 os.bin 。这里需要注意的是,对于不同的 bootloader 而言,下一阶段软件的入口不一定相同,而且获取这一信息的方式和时间点也不同:入口地址可能是一个预先约定好的固定的值,也有可能是在 bootloader 运行期间才动态获取到的值。我们选用的 RustSBI 则是将下一阶段的入口地址预先约定为固定的0x80200000
,在 RustSBI 的初始化工作完成之后,它会跳转到该地址并将计算机控制权移交给下一阶段的软件——也即我们的内核镜像。 - 第三阶段:为了正确地和上一阶段的 RustSBI 对接,我们需要保证内核的第一条指令位于物理地址
0x80200000
处。为此,我们需要将内核镜像预先加载到 Qemu 物理内存以地址0x80200000
开头的区域上。一旦 CPU 开始执行内核的第一条指令,证明计算机的控制权已经被移交给我们的内核,也就达到了本节的目标。
- 第零阶段:物理内存起始地址:
- 真实计算机加电启动流程
- 第一阶段:加电后 CPU 的 PC 寄存器被设置为计算机内部只读存储器(ROM,Read-only Memory)的物理地址,随后 CPU 开始运行 ROM 内的软件。我们一般将该软件称为固件(Firmware),它的功能是对 CPU 进行一些初始化操作,将后续阶段的 bootloader 的代码、数据从硬盘载入到物理内存,最后跳转到适当的地址将计算机控制权转移给 bootloader 。它大致对应于 Qemu 启动的第一阶段,即在物理地址 0x1000 处放置的若干条指令。可以看到 Qemu 上的固件非常简单,因为它并不需要负责将 bootloader 从硬盘加载到物理内存中,这个任务此前已经由 Qemu 自身完成了。
- 第二阶段:bootloader 同样完成一些 CPU 的初始化工作,将操作系统镜像从硬盘加载到物理内存中,最后跳转到适当地址将控制权转移给操作系统。可以看到一般情况下 bootloader 需要完成一些数据加载工作,这也就是它名字中 loader 的来源。它对应于 Qemu 启动的第二阶段。在 Qemu 中,我们使用的 RustSBI 功能较弱,它并没有能力完成加载的工作,内核镜像实际上是和 bootloader 一起在 Qemu 启动之前加载到物理内存中的。
- 第三阶段:控制权被转移给操作系统。由于篇幅所限后面我们就不再赘述了。
- 值得一提的是,为了让计算机的启动更加灵活,bootloader 目前可能非常复杂:它可能也分为多个阶段,并且能管理一些硬件资源,从复杂性上它已接近一个传统意义上的操作系统。
- 程序内存布局
- 可执行文件可以分为代码和数据两部分,代码由可被 CPU 解码并执行的指令组成,数据是被 CPU 视作可读写的内存空间
- 程序的内存布局由不同功能的段组成
- 代码:
.text
一个段 - 数据
- 全局数据段:只读
.rodata
读写.data
- 未初始化数据段:
.bss
未初始化全局数据,程序加载者由零初始化,最终该区域逐字节清零 - 堆(heap)用于放置程序运行时分配的数据,向高地址增长
- 栈(stack)用作函数调用、上下文保存恢复、函数局部变量,向低地址增长
- 全局数据段:只读
- 函数视角(局部变量与全局变量)
- 函数的输入参数与局部变量:保存在寄存器或栈帧当中,栈帧会通过栈指针加上偏移量访问
- 全局变量:保存在 .data .bss 中,有时通过 gp 加上偏移量访问
- 堆上的动态变量:本体保存在堆上,大小在运行时确定。但我们只能直接访问栈上或全局数据段上的编译器确定大小的变量,因此通过一个在编译时确定大小的指针(指针位宽编译器可知),该指针指向堆上的数据以访问。该指针可以作为局部变量保存在栈帧,也可以作为全局变量放在全局数据当中。
- 编译流程
- 编译器(compiler)将源文件转化为汇编语言,源文件仍是文本文件
- 汇编器(assembler)将每个源文件转化为机器码,得到一个二进制目标文件
- 链接器(linker)将上一步得到的目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件
- 汇编器输出的每一个文件都有独立的程序内存布局,链接器则将所有输入目标文件整合成一个整体的内存布局,主要有两项工作
- 第一件事情是将来自不同目标文件的段在目标内存布局中重新排布。
- 第二件事情是将符号替换为具体地址。
- 编写第一条内核指令
- 使用 ld 调整内存布局
- 手动加载内核可执行文件
rust-objcopy
丢弃元数据- stat 比较信息
- gdb 验证启动流程,可在目录使用
make debug
-
函数调用与栈
- 控制流(Control Flow):将 pc 寄存器设置到另一个地址
- 函数调用(Function Call):函数调用的返回跳转到一个运行时确定的地址
- rs(Source Register)源寄存器 imm(immediate)立即数 rd(Destination Register)目标寄存器
- 函数调用时,通过
jalr
指令保存返回地址并跳转;函数返回时,通过ret
伪指令,本质上是jalr x0, 0(x1)
回到跳转之前的下一条指令继续执行 - 函数上下文(Function Call Context):由于函数调用,在控制流转移前后需要保持不变的寄存器集合
- 被调用者保存(Callee-Saved)寄存器
- 被调用的函数可能会覆盖这些寄存器,需要被调用的函数保存的寄存器,即由被调用的函数保证在调用前后,这些寄存器保持不变
- 在被调用函数的起始,先保存函数执行过程中被用到的被调用者保存寄存器,然后执行函数,退出之前恢复寄存器
- 调用者保存(Caller-Saved)寄存器
- 被调用的函数可能会覆盖这些寄存器,需要发起调用的函数保存的寄存器,即由发起调用的函数来保证调用前后,这些寄存器保持不变
- 首先保存不希望在函数调用中发生变化的调用者保存寄存器,然后通过
jal/jalr
指令调用子函数,返回之前恢复寄存器
- 被调用者保存(Callee-Saved)寄存器
- 无论是调用函数还是被调用函数,都会因为调用行为而需要两段匹配的保存和恢复寄存器的汇编代码,可以称为开场(Prologue)和结尾(Epilogue)
-
调用规范ode
- 栈(Stack):保存函数调用的物理内存区域
- 栈指针(Stack Pointer):指向内存中栈顶地址(栈最低的地方)
- 栈帧(Stack Frame):用于被函数进行函数调用的一块物理内存
-
RustSBI
-
页表,虚拟内存
- 汇编
- 伪指令
- .section
- la 地址加载 (Load Address). 伪指令(Pseudoinstruction), RV32I and RV64I.
- 非伪指令
- 伪指令
- ch3 导读
- 通过提前加载应用程序到内存,减少应用程序切换开销
- 通过协作机制支持程序主动放弃处理器,提高系统执行效率
- 通过抢占机制支持程序被动放弃处理器,保证不同程序对处理器资源使用的公平性,也进一步提高了应用对 I/O 事件的响应效率
- ch1
- ABI/API
- ABI 定义了二进制机器代码级别的规则,主要包括基本数据类型、通用寄存器的使用、参数的传递规则、以及堆栈的使用等等。ABI是用来约束链接器 (Linker) 和汇编器 (Assembler) 的。在同一处理器下,基于不同高级语言编写的应用程序、库和操作系统,如果遵循同样的 ABI 定义,那么它们就能正确链接和执行。
- API 定义了一个源码级(如 C 语言)函数的参数,参数的类型,函数的返回值等。因此 API 是用来约束编译器 (Compiler) 的:一个 API 是给编译器的一些指令,它规定了源代码可以做以及不可以做哪些事。
- 操作系统主要通过基于 ABI 的系统调用接口来给应用程序提供上述服务,以支持应用程序的各种需求。
- 进程(即程序运行过程)管理:复制创建进程 fork 、退出进程 exit 、执行进程 exec 等。
- 线程管理:线程(即程序的一个执行流)的创建、执行、调度切换等。
- 线程同步互斥的并发控制:互斥锁 mutex 、信号量 semaphore 、管程 monitor 、条件变量 condition variable 等。
- 进程间通信:管道 pipe 、信号 signal 、事件 event 等。
- 虚存管理:内存空间映射 mmap 、改变数据段地址空间大小 sbrk 、共享内存 shm 等。
- 文件 I/O 操作:对存储设备中的文件进行读 read 、写 write 、打开 open 、关闭 close 等操作。
- 外设 I/O 操作:外设包括键盘、显示器、串口、磁盘、时钟 … ,主要采用文件 I/O 操作接口。
- 应用程序员只需访问统一的抽象概念(如文件、进程等),就可以使用各种复杂的计算机物理资源(处理器、内存、外设等)
- ABI/API
- 执行环境是应用程序正确运行所需的服务与管理环境,用来完成应用程序在运行时的数据与资源管理、应用程序的生存期等方面的处理,它定义了应用程序有权访问的其他数据或资源,并决定了应用程序的行为限制范围
- CCF,Common Control Flow
- ECF, Exceptional Control Flow
- 上下文是指仅会影响控制流正确执行的有限的物理/虚拟资源内容
- 生成程序二进制代码依赖编译器为主的开发环境,运行程序执行码依赖的是以操作系统为主的执行环境
- 由于希望使用 Rust 实现内核大多数功能,而函数调用是 Rust 必须存在的基本控制流,因此在跳转到 Rust 入口函数前需要进行栈的初始化工作
- LibOS 调用 RustSBI 实现基本功能,应用程序可以直接调用 LibOS 提供的字符串输出函数或关机函数达到应用与硬件隔离的操作系统目标
- 应用程序执行环境
- 应用通过调用编程语言提供的标准库或其他第三方库对外提供的函数接口,使得仅需少量源代码就能实现复杂功能。这些库可以认为是应用程序执行环境的一部分
- 应用程序总要直接或者间接的通过操作系统内核提供的系统调用实现功能。因此操作系统充当用户和内核之间的边界
- All problems in computer science can be solved by another level of indirection
- 理解应用的需求也很重要。一个能合理满足应用需求的操作系统设计是操作系统设计者需要深入考虑的问题。这也是一种权衡,过多的服务功能和过少的服务功能自然都是不合适的。
- 目标平台于目标三元组
- 现代编译器工具集工作流程
- 源代码 source code -> 预处理器 preprocessor -> 宏展开源代码
- 宏展开源代码 -> 编译器 compiler -> 汇编程序
- 汇编程序 -> 汇编器 assembler -> 目标代码 object code
- 目标代码 -> 链接器 linker -> 可执行文件 executables
- Rust 编译器通过目标三元组 Target Triplet 描述软件运行的目标平台,包括 CPU、操作系统和运行时库
- riscv64gc-unknow-none-elf
- CPU 架构 riscv64gc,G 指实现了 RV64I 加上标准指令集 MAFD 扩展,C 指提供压缩指令拓展
- CPU 厂商 unknown
- 操作系统 none
- elf executable and linking format 表示没有标准运行时库
- Rust 标准库与核心库
- std & core
- std 类似于 LibC,提供 Vec 和 Option 等,但需要操作系统支持
- core 不需要操作系统支持,包括了 Rust 语言相当一部分的核心机制
- std & core
- 移除标准库依赖
- 默认使用 riscv64gc作为目标平台,而不是默认的 x86_64-unknown-linux-gun,开发平台于可执行文件运行的目标平台不一致,称为交叉编译 cross compile
- 现代编译器工具集工作流程
- 内核第一条指令(基础篇)
- 计算机组成基础
- 计算机主要由处理器、物理内存和 I/O 外设三部分组成
- CPU 的主要功能是从物理内存中读取指令、译码并执行
- CPU 访问内存是通过数据总线(决定了每次读取的数据位数)和地址总线(决定了寻址范围)
- 基本类型数据对齐是指数据在内存中的偏移地址必须为一个字的整数倍,可以提升系统在读取数据时的性能
- 结构体数据对齐是指在结构体的上一个数据域结束和下一个数据域开始的地方填充一些无用的字节,以保证每个数据域都能对齐(即按照基础类型数据对齐)
- Qemu
- 使用 Qemu 模拟器上运行以检查其正确性
- Qemu 启动流程
- virt 硬件平台上,起始地址为 0x80000000,物理内存默认为 128MiB,本书使用最低 8MiB 内存,物理地址为 [0x80000000,x80800000]
- Qemu 开始执行指令之前,将 rustsbi-qemu.bin 加载到 0x80000000 开头的地址上,内核镜像夹 os.bin 加载到 0x80200000 开头的地址上
- Qemu 模拟器的启动分为三个阶段
- 将必要的文件加载到 Qemu 物理内存之后,Qemu CPU 的 PC Program Counter 会被初始化为 0x1000,因此第一条指令位于 0x1000,接下来它将执行几条指令并跳转到物理地址 0x80000000 对应的指令处,进入第二阶段。这个部分只能通过修改 Qemu 源码进行更改
- 将负责第二阶段的 bootloader rustsbi-qemu.bin 放在 0x80000000 开头的物理内存中,保证 0x80000000 处正好保存 bootloader 的第一条指令。这一阶段,bootloader 负责对计算机进行一些初始化工作,并跳转到下一阶段软件入口。即移交至内核镜像 os.bin。此外,对于不同的 bootloader,下一阶段的软件入口不一定相同:有可能是预先约定号的固定的值,也可能是 bootloader 运行期间动态获取的值。RustSBI 则是将下一阶段的入口地址约定为固定的 0x80200000,在 RustSBI 初始化之后,会跳转到该地址,并将计算机控制权移交给下一阶段软件,即内核jingxiang
- 保证内核的第一条指令位于物理地址 0x80200000 处。一旦 CPU 开始执行内核的第一条指令,则意味着计算机的控制权已经被移交给内核
- 真实计算机的加电启动流程
- 加电后 CPU 的 PC 寄存器被设置为计算机内部只读存储器 ROM,Read-only memory 的物理地址,随后 CPU 开始运行 ROM 内的软件,这些软件称为固件 Firmware,它的功能是初始化 CPU,将后续的 bootloader 代码、数据从硬盘加载到物理内存,最后跳转到适当的地址将计算机控制权转移给 bootloader,对应 Qemu 的第一阶段
- bootloader 同样完成 CPU 初始化工作,将操作系统镜像从硬盘加载到物理内存中,最后跳转到适当地址将控制权转移给操作系统。bootloader 也要完成一些数据加载工作,但是 RustSBI 功能较弱,需要通过 Qemu 启动之前加载,对应于 Qemu 的第二阶段
- 控制权移交给操作系统
- 程序内存布局与编译流程
- 程序内存布局
- 可执行文件中的字节可以分为代码和数据两个部分,代码由一条条可以被 CPU 解码并执行的指令组成,数据只是被 CPU 视作可读写的内存空间
- 字节还可以根据其功能划分为更小的单元:段。不同的段被编译器放置在内存不同的位置上,构成了程序的内存布局
- 代码部分:.text
- 数据部分:
- 已初始化数据段保存程序中那些已初始化的全局数据 .rodata 只读全局数据 .data 可修改全局数据
- 未初始化数据段 .bss 保存程序中未初始化的全局数据,通常由程序加载者代为进行零初始化
- heap 堆,存放程序运行时动态分配的数据,向高地址增长
- stack 栈,用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被放在栈帧内,向低地址增长
- 函数视角可以访问的变量
- 函数的入参和局部变量:保存在一些寄存器或函数的栈帧内,如果是在栈帧内是基于当前栈指针偏移来访问
- 全局变量:保存在 .data 和 .bss 中,通过 gp(x3) 寄存器保存两个数据段中间的一个位置,是基于 gp 加上一个偏移量来访问
- 堆上的动态变量:本体被保存在堆上,大小在运行时才能确定。只能直接访问栈上或全局数据段中编译器确定大小的变量。需要通过一个运行时分配内存的指针来访问(指向堆数据),因为指针的位宽在编译器就能够确定。该指针可以作为局部变量放在栈里,也可以作为全局变量放在全局数据段中。
- 编译流程
- 编译器 compiler:高级语言转化为汇编语言,生成的文件为 ASCII 或其他编码的文本文件
- 汇编器 Assembler:将文本格式的指令转化为机器码,得到一个二进制目标文件
- 链接器 Linker:将上一步得到的目标文件以及一些可能的外部文件链接在一起,形成一个完整的可执行文件
- 汇编器输出的每个目标文件都有一个独立的程序内存布局,而编译器需要将所有输入的目标文件整合成一个整体的内存布局
- 第一件事是将不同目标文件的段在目标内存布局中重新排布,相同功能的段被排在一起放进拼装后的目标文件当中,合并冲突
- 第二件事是将符号替换为具体地址,符号则为函数、变量的名字。在机器码级别,是直接通过变量或者函数的地址索引。当一个模块被转化为目标文件之后,它的内部符号就在目标文件中转化为具体地址,外部地址则会保存在一个符号表 symbol table 的区域内
- 程序内存布局
- 计算机组成基础
- 内核第一条指令(实践篇)
- 为内核支持函数调用
- 最简单的执行指令方式为顺序执行
- 分支、循环只需要实现跳转功能,即将 pc 寄存器设置到一个指定地址即可
- 函数调用(function call)则更为复杂
- 调用时,使用跳转指令跳转到被调用函数的位置;但是在被调用函数返回时,我们需要返回那条跳转过来的指令的下一条继续执行
- 核心区别在于:其他控制流只需要跳转到一个编译期固定下来的地址,而函数调用的返回跳转到一个运行时确定(准确来说是函数调用发生时)的地址
- 指令集必须给用于函数调用的跳转指令一些额外能力
- jar jalr
- rd destination register, riscv 中,通常使用 ra(x1)寄存器作为 rd,返回时只需要跳回 ra 所保存的地址即可;rs source register
- ret 为 jalr x0, 0(x1) 首先 x0 恒为 0 ,不需要写入,pc 指针跳转到 x1(ra)偏移 0 的位置,就是 ra 所保存的地址
- 这两条指令在设置 pc 寄存器完成跳转功能之前,还将当前跳转指令的下一条指令保存在 rd 寄存器中
- 函数调用时,使用 jalr 指令保存函数调用之后下一条指令的地址,在函数即将返回时,通过 ret 指令跳转回下一条地址
- 需要保证在函数执行的全程中, ra 不发生变化,但由于函数多层嵌套调用非常常见,因此需要通过某种手段保证这一店
- 将由于函数调用,在控制流转移前后需要保持不变的寄存器称之为函数调用上下文
- 由于每个 CPU 只有一套寄存器,因此想在子函数调用前后
- 在调用子函数之前,需要在物理内存中的一个区域保存函数调用上下文中的寄存器;而在函数执行完毕之后,需要从内存相同的区域读取并恢复函数调用上下文。需要子函数调用者和被调用则(子函数本身)合作完成。函数调用上下文中的寄存器被分为:
- 被调用者 Callee-Saved 寄存器:被调用的函数可能会覆盖这些寄存器,需要被调用的函数来保存的寄存器,即由被调用的函数来保证在调用前后,寄存器保持不变
- 调用者 Caller-Saved 寄存器:被调用的函数可能会覆盖这些寄存器,需要发起调用的函数来保存的寄存器,即由发起调用的函数在调用前后,寄存器保持不变
- 函数调用上下文的具体过程为:
- 调用函数:首先保存不希望在函数调用中发生变化的调用者保存寄存器,通过 jar/jalr 调用子函数,返回后恢复寄存器
- 被调用函数:在调用函数的起始,先保存函数执行过程中被用到的被调用者保存寄存器,然后执行函数,最后在函数推出之前恢复这些寄存器
- 调用函数和被调用函数都会因调用行为而需要两端匹配的保存和恢复寄存器的汇编代码,称为开场 Prologue 和结尾 Epilogue
- 调用规范 - 调用规范约定在指令集架构上,包括: 1. 函数输入参数和返回值如何传递 2. 函数调用上下问中调用者/被调用者保存寄存器的划分 3. 其他的在函数调用流程中对寄存器的使用方法 - 调用规范 - 函数中,开场代码负责分配新的栈空间,即 [新sp, 旧sp) 对应的内存可以用来函数调用上下文的保存与恢复,这块物理内存被称为栈帧 stack frame,结束时负责将开场代码分配的栈帧回收,
- RustSBI 提供的服务
- 除去初始化计算机启动,还在内核运行时响应内核的请求,为内核提供服务
- 在原有硬件和程序员工资的情况下,计算机的使用效率提高了 5 倍以上,程序员没有那么多空闲的时间用来聊天了
- 保护计算机系统不受有意或无意出错的程序破坏机制称为特权级
- 特权级的软硬件协同设计
- 让相对安全的操作系统运行在一个硬件保护的安全执行环境中,不受到应用程序的破坏;而让应用程序运行在另一个无法破坏硬件系统的受限执行环境中
- 应用程序不能任意访问地址空间
- 应用程序不能执行某些可能破坏计算机的指令
- 高特权级软件(操作系统)就称为低特权级软件(一般应用)的软件执行环境的重要组成
- 硬件上,通过为处理器设置两个不同安全等级的执行环境:用户态特权级、内核态特权级,且指出可能会破坏计算机系统的内核态特权指令子集,处理器在执行指令前会进行特权级安全检查,如果在错误的执行环境执行,会产生异常
- 软件上,通过传统的函数调用方式(即 call 和 ret)会直接绕过硬件特权级保护检查。所以可以设计新的机器指令:执行环境调用 Execution Environment Call, ecall 和执行环境返回 Execution Environment Return,
- ecall: 具有用户态到内核态的执行环境切换能力的函数调用指令
- eret: 具有内核态到用户态的执行环境切换能力的函数返回指令
- 让相对安全的操作系统运行在一个硬件保护的安全执行环境中,不受到应用程序的破坏;而让应用程序运行在另一个无法破坏硬件系统的受限执行环境中
- RISC-V 特权级架构
- 用户/应用模式 U, User/Application;监督模式 S, Supervisor;虚拟监督模式 H, Hypervisor;机器模式 M, Machine
- 应用程序运行在 U 上;操作系统运行在 S 上;Bootloader 等如 RustSBI 运行在 M 上,被称为监督模式执行环境 Supervisor Execution Environment, SEE
- 异常往往(不一定)伴随特权级切换。在 RISC-V 架构中,与常规控制流不同的异常控制流 Execption Control Flow, ECF 被称为异常 Execption,是 Trap 的种类之一
- ecall 是一种特殊陷入类指令。M 模式软件 SEE 和 S 模式的内核之间的接口称为监督模式二进制接口 Supervisor Binary Interface, SBI;内核和 U 模式的应用程序之间的接口被称为应用程序二进制接口 application Binary Interface, ABI 或系统调用 system call syscall
- 只有将接口下降到机器/汇编指令级才能够满足其跨高级语言的通用型和灵活性
- RISCV 特权级指令
- 与特权级无关的指令和通用寄存器在任何特权级都可以执行,而特权级对应的特殊指令和控制状态寄存器 Control and Status Register, CSR 来控制该特权级的某些行为并描述其状态
- RISC-V 有两类高特权级 S 模式的特权指令
- 指令本身为高特权级指令,sret(S 模式返回 U 模式)
- 指令访问了 S 特权级下才能访问的寄存器 sstatus
- ch2-cmt
- os/batch/batch.rs
- RISC-V 在 U/S 特权级下,BatchOS 如何和应用程序相互配合,完成特权级切换
- RISC-V 特权级切换
- 起因
- 应用程序在 BatchOS 提供的 Application Execution Environment, AEE 执行
- 当启动应用程序时,需要初始化用户态上下文,并切换到用户态执行应用程序
- 当应用程序发起系统调用 Trap 之后,在批处理系统中进行处理
- 当应用程序执行出错时,需要到 BatchOS 中杀死应用,并加载下一个应用
- 当应用程序执行结束时,需要 BatchOS 加载运行下一个应用
- 涉及到特权级切换,因此需要应用程序、操作系统和硬件一起协同,完成特权级切换
- 应用程序在 BatchOS 提供的 Application Execution Environment, AEE 执行
- 相关的控制状态寄存器 CSR
- 本章仅考虑如下流程:CPU 在 U 特权级运行应用程序,执行到 Trap,切换到 S 特权级,BatchOS 响应 Trap,执行系统调用服务,处理完毕后,从内核态返回到用户态应用程序继续执行后续指令
- sstatus SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息
- sepc 当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址
- scause 描述 Trap 的原因
- stval 给出 Trap 附加信息
- stvec 控制 Trap 处理代码的入口地址
- 特权级切换
- 类似于函数调用,系统 Trap 处理时,也需要对通用寄存器进行保存
- 除去通用寄存器以外,还有一些 CSR 会被修改,因此要保证变化在预期之内
- 起因
- 特权级切换的硬件控制机制
- 当 CPU 完成一条指令,如 ecall,并准备从 U 特权级 Trap 到 S 特权级时,硬件会完成如下事情:
- sstatus 的 SPP 字段会被修改为当前 CPU 的特权级 U/S
- sepc 会被修改为 Trap 处理完后默认执行的下一条指令地址
- scause/stval 会被修改成这次 Trap 的原因以及相关的附加信息
- CPU 会跳转到 stvec 所设置的 Trap 处理入口地址,并将特权级设置为 S,然后从 Trap 处理入口地址开始执行
- 当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级指令 sret 完成
- CPU 会按照当前的特权级按照 sstatus 的 SPP 字段设置为 U 或 S
- CPU 会跳转到 sepc 寄存器指向的那条指令,然后继续执行
- 当 CPU 完成一条指令,如 ecall,并准备从 U 特权级 Trap 到 S 特权级时,硬件会完成如下事情:
- 用户栈与内核栈
- Trap 出发的一瞬间,CPU 就会切换到 S 特权级,并跳转到 stvec 所指示的位置,但正式进入 S 特权级的 Trap 处理之前,需要保存原控制流的寄存器状态,通过内核栈保存。
- 使用两个不同栈主要是为了安全性:如果两个控制流使用同一个栈,可能会在返回之后,应用程序读到 Trap 控制流的历史信息,如一些内核函数地址
- TrapContext
- 对于通用寄存器,应用程序控制流和内核控制流运行在不同特权级上,所属软件可能由不同编程语言编写,这里保存全部寄存器是为了后续实现的方便
- 对于 CSR,Trap 后,硬件会覆盖 C S R 中的全部或是一部分。scause/stval 的情况是:它们总在 Trap 处理的第一时间就被使用,或者是在其他地方保存,因此被修改无关紧要。而对于 sstatus/sepc 而言,它们在 Trap 处理的全程有意义(如 sret 用到 sepc 中保存的地址),并且在 Trap 嵌套的情况下使得它们的值被覆盖,因此也需要一并保存,在 sret 之前恢复
- Trap 管理
- Trap 上下文的保存与恢复
- Trap 整体流程
0. 修改 stvec 寄存器指向正确的 Trap 处理入口
- 通过 __alltraps 将 Trap 上下文保存在内核栈上,跳转到 trap_handler 函数完成 Trap 分发处理。当 trap_handler 返回之后,使用 __restore 将内核栈上的 Trap 上下文恢复寄存器。使用 sret 返回应用程序执行
- __alltraps
- 使用 csrrw 交换 sscratch 和 sp,将 sp 指向内核栈,sscratch 指向用户栈
- 在内核栈上预分配 34 * 8 字节栈帧,使用 sp 偏移访问
- 保存 Trap 上下文的通用寄存器 x0~x31,跳过 x0、tp(x4)、sp(x2)
- 将 CSR 中的 sstatus 和 sepc 的值读到寄存器 t0 和 t1 保存到内核对应位置上,sscratch 读到 t2 上,保存到内核栈上,这个地址指向用户栈
- 将 sp(指向内核栈,即刚刚保存的上下文地址)传给 a0,再交由 trap_handler 处理上下文
- 这些读写 CSR 的指令通常是一类不能被打团完成多个读写操作的指令,即原子指令 Atomic Instruction
- __restore
- mv sp, a0 暂时认为 sp 仍然指向内核栈栈顶
- 先恢复 CSR 再恢复通用寄存器
- 回收栈帧
- 使用 csrrw 交换 sp 和 sscratch,sp 重新指回用户栈栈顶,sscratch 恢复进入 Trap 之前的状态,并指向内核栈栈顶
- sret 返回 U 特权级
- sscratch 是中继寄存器
- trap_handler
- trap_handler 返回结果和入参的 cx 不发生改变,因此 __restore 的时候, a0 在调用 trap_handler 前后没有发生变化,仍然指向分配 Trap 上下文之后的内核栈栈顶(因为是 sp 的地址,__alltraps 之后,sp 指向内核栈栈顶),因此 mv sp, a0 合理
- 分发处理 Trap,使用 Rust 的 riscv 库实现
- 第一种情况为 UserEnvCall,即系统调用。首先修改 Trap 上下文中的 sepc,让其增加 4。这是因为这是一个 ecall 指令触发的系统调用,硬件会将 sepc 设置为这条 ecall 指令所在的地址(因为是 Trap 之前最后一条执行指令)。而在 Trap 之后,希望从这条指令下一条指令开始执行。因此修改 spec,让他指向 ecall 之后长度为 ecall 指令的码长,即 4 字节,这样在 __restore 最后的 sret 就会跳转到 ecall 的下一条指令。此外取出 syscal ID 以及 a0~a2,传递给 syscall 并获取返回值。
- 分别处理访存错误和非法指令情况,此时打印错误并调用 run_next_app 切换运行下一个应用程序
- 还不支持的 Trap 类型,BatchOS 报 panic 错
- Trap 整体流程
0. 修改 stvec 寄存器指向正确的 Trap 处理入口
- 执行应用程序
- 复用 __restore 函数,在初始化 Trap 时,压入一个为启用应用程序而特殊构造的 Trap 上下文,通过 __restore 达到启动应用程序所需要的上下文状态
- Trap 上下文的保存与恢复