课后练习
编程题
-
- 扩展内核,能够显示操作系统切换任务的过程。
-
** 扩展内核,能够统计每个应用执行后的完成时间:用户态完成时间和内核态完成时间。
-
** 编写浮点应用程序 A,并扩展内核,支持面向浮点应用的正常切换与抢占。
-
** 编写应用程序或扩展内核,能够统计任务切换的大致开销。
-
*** 扩展内核,支持在内核态响应中断。
-
*** 扩展内核,支持在内核运行的任务(简称内核任务),并支持内核任务的抢占式切换。
注:上述扩展内核的编程基于 rcore/ucore tutorial v3: Branch ch3
问答题
-
- 协作式调度与抢占式调度的区别是什么?
协作式调度中,进程主动放弃 (yield) 执行资源,暂停运行,将占用的资源让给其它进程;抢占式调度中,进程会被强制打断暂停,释放资源让给别的进程。
-
- 中断、异常和系统调用有何异同之处?
-
相同点
- 都会从通常的控制流中跳出,进入 trap handler 进行处理。
-
不同点
- 中断的来源是异步的外部事件,由外设、时钟、别的 hart 等外部来源,与 CPU 正在做什么没关系。
- 异常是 CPU 正在执行的指令遇到问题无法正常进行而产生的。
- 系统调用是程序有意想让操作系统帮忙执行一些操作,用专门的指令(如
ecall
)触发的。
-
- RISC-V 支持哪些中断 / 异常?
如何判断进入 OS 内核的起因是调用还是异常?
-
- 如何判断进入操作系统内核的起因是由于中断还是异常?
检查 mcause 寄存器的最高位,1 表示中断,0 表示异常。
当然在 Rust 中也可以直接利用 riscv
库提供的接口判断:
let scause = scause::read();
if scause.is_interrupt() {
do_something
}
if scause.is_exception() {
do_something
}
又或者,可以按照 trap/mod.rs:trap_handler()
中的写法,用 match scause.cause()
来判断。
中断机制中 PLIC 和 CLINT
- ** 在 RISC-V 中断机制中,PLIC 和 CLINT 各起到了什么作用?
- CLINT 处理时钟中断 (
MTI
) 和核间的软件中断 (MSI
); - PLIC 处理外部来源的中断 (
MEI
)。
PLIC 的规范文档: https://github.com/riscv/riscv-plic-spec
RISC-V 中断嵌套
- ** 基于 RISC-V 的操作系统支持中断嵌套?请给出进一步的解释说明。
RISC-V 原生不支持中断嵌套。
- (在 S 态的内核中)只有
sstatus
的SIE
位为 1 时,才会开启中断,再由sie
寄存器控制哪些中断可以触发。 - 触发中断时,
sstatus.SPIE
置为sstatus.SIE
,而sstatus.SIE
置为 0;当执行sret
时,sstatus.SIE
置为sstatus.SPIE
,而sstatus.SPIE
置为 1。 - 这意味着触发中断时,因为
sstatus.SIE
为0,所以无法再次触发中断。
- ** 本章提出的任务的概念与前面提到的进程的概念之间有何区别与联系?
-
联系:任务和进程都有自己独立的栈、上下文信息,任务是进程的“原始版本”,在第五章会将目前的用户程序从任务升级为进程。
-
区别:任务之间没有地址空间隔离,实际上是能相互访问到的;进程之间有地址空间隔离,一个进程无法访问到另一个进程的地址。
任务地址空间中的数据和代码段类型
-
- 简单描述一下任务的地址空间中有哪些类型的数据和代码。
可参照 user/src/linker.ld
:
-
.text
:任务的代码段,其中开头的.text.entry
段包含任务的入口地址 -
.rodata
:只读数据,包含字符串常量,如测例中的println!("Test power_3 OK!");
实际打印的字符串存在这里 -
.data
:需要初始化的全局变量 -
.bss
:未初始化或初始为0的全局变量。 -
在之后第四章的
user/src/bin/00power_3.rs
中,会把第三章中在用户栈上定义的数组移到全局变量中static mut S: [u64; LEN] = [0u64; LEN];
-
在第五章的
user/lib.rs
中,会在bss
段构造一个用户堆static mut HEAP_SPACE: [u8; USER_HEAP_SIZE] = [0; USER_HEAP_SIZE];
除此之外,在内核中为每个任务构造的用户栈 os/src/loader.rs:USER_STACK
也属于各自任务的地址。
-
- 任务控制块保存哪些内容?
在本章中,任务控制块即 os/src/task/task.rs:TaskControlBlock
保存任务目前的执行状态 task_status
和任务上下文 task_cx
。
-
- 任务上下文切换需要保存与恢复哪些内容?
需要保存通用寄存器的值,PC;恢复的时候除了保存的内容以外还要恢复特权级到用户态。
特权级上下文和任务上下文的异同
-
- 特权级上下文和任务上下文有何异同?
-
相同点:特权级上下文和任务上下文都保留了一组寄存器,都代表一个“执行流”
-
不同点:
- 特权级上下文切换可以发生在中断异常时,所以它不符合函数调用约定,需要保存所有通用寄存器。同时它又涉及特权级切换,所以还额外保留了一些 CSR,在切换时还会涉及更多的 CSR。
- 任务上下文由内核手动触发,它包装在
os/src/task/switch.rs:__switch()
里,所以==除了“返回函数与调用函数不同”之外,它符合函数调用约定,只需要保存通用寄存器中callee
类型的寄存器==。为了满足切换执行流时“返回函数与调用函数不同”的要求,它还额外保存ra
。
上下文切换需要使用汇编语言实现的原因
-
- 上下文切换为什么需要用汇编语言实现?
上下文切换过程中,需要我们直接控制所有的寄存器。C 和 Rust 编译器在编译代码的时候都会“自作主张”使用通用寄存器,以及我们不知道的情况下访问栈,这是我们需要避免的。
切换到内核的时候,保存好用户态状态之后,我们将栈指针指向内核栈,相当于构建好一个高级语言可以正常运行的环境,这时候就可以由高级语言接管了。
-
- 有哪些可能的时机导致任务切换?
系统调用(包括进程结束执行)、时钟中断。
- ** 在设计任务控制块时,为何采用分离的内核栈和用户栈,而不用一个栈?
用户程序可以任意修改栈指针,将其指向任意位置,而内核在运行的时候总希望在某一个合法的栈上,所以需要用分开的两个栈。
此外,利用后面的章节的知识可以保护内核和用户栈,让用户无法读写内核栈上的内容,保证安全。
Linux 与 RISC-V
- *** 我们已经在 rCore 里实现了不少操作系统的基本功能:特权级、上下文切换、系统调用…… 为了让大家对相关代码更熟悉,我们来以另一个操作系统为例,比较一下功能的实现。看看换一段代码,你还认不认识操作系统。
阅读 Linux 源代码,特别是 riscv
架构相关的代码,回答以下问题:
- Linux 正常运行的时候,
stvec
指向哪个函数?是哪段代码设置的stvec
的值?
arch/riscv/kernel/entry.S
里的handle_exception
;arch/riscv/kernel/head.S
里的setup_trap_vector
- Linux 里进行上下文切换的函数叫什么?(对应 rCore 的
__switch
)
arch/riscv/kernel/entry.S
里的 __switch_to
- Linux 里,和 rCore 中的
TrapContext
和TaskContext
这两个类型大致对应的结构体叫什么?
TrapContext
对应pt_regs
;TaskContext
对应task_struct
(在task_struct
中也包含一些其它的和调度相关的信息)
- Linux 在内核态运行的时候,
tp
寄存器的值有什么含义?sscratch
的值是什么?
tp
指向当前被打断的任务的task_struct
(参见arch/riscv/include/asm/current.h
里的宏current
);sscratch
是 0
- Linux 在用户态运行的时候,
sscratch
的值有什么含义?
sscratch
指向当前正在运行的任务的 task_struct
,这样设计可以用来区分异常来自用户态还是内核态。
- Linux 在切换到内核态的时候,保存了和用户态程序相关的什么状态?
所有通用寄存器, sstatus
, sepc
, scause
- Linux 在内核态的时候,被打断的用户态程序的寄存器值存在哪里?在 C 代码里如何访问?
- 内核栈底;
arch/riscv/include/asm/processor.h
里的task_pt_regs
宏
- Linux 是如何根据系统调用编号找到对应的函数的?(对应 rCore 的
syscall::syscall()
函数的功能)
arch/riscv/kernel/syscall_table.c
里的 sys_call_table
作为跳转表,根据系统调用编号调用。
- Linux 用户程序调用
ecall
的参数是怎么传给系统调用的实现的?系统调用的返回值是怎样返回给用户态的?
- 从保存的
pt_regs
中读保存的 a0 到 a7 到机器寄存器里,这样系统调用实现的 C 函数就会作为参数接收到这些值, - 返回值是将返回的 a0 写入保存的
pt_regs
,然后切换回用户态的代码负责将其“恢复”到 a0
阅读代码的时候,可以重点关注一下如下几个文件,尤其是第一个 entry.S
,当然也可能会需要读到其它代码:
-
arch/riscv/kernel/entry.S
(与 rCore 的switch.S
对比) -
arch/riscv/include/asm/current.h
-
arch/riscv/include/asm/processor.h
-
arch/riscv/include/asm/switch_to.h
-
arch/riscv/kernel/process.c
-
arch/riscv/kernel/syscall_table.c
-
arch/riscv/kernel/traps.c
-
include/linux/sched.h
此外,推荐使用 https://elixir.bootlin.com 阅读 Linux 源码,方便查找各个函数、类型、变量的定义及引用情况。
一些提示:
-
Linux 支持各种架构,查找架构相关的代码的时候,请认准文件名中的
arch/riscv
。 -
为了同时兼容 RV32 和 RV64,Linux 在汇编代码中用了几个宏定义。例如,
REG_L
在 RV32 上是lw
,而在 RV64 上是ld
。同理,REG_S
在 RV32 上是sw
,而在 RV64 上是sd
。 -
如果看到
#ifdef CONFIG_
相关的预处理指令,是 Linux 根据编译时的配置启用不同的代码。一般阅读代码时,要么比较容易判断出这些宏有没有被定义,要么其实无关紧要。比如,Linux 内核确实应该和 rCore 一样,是在 S-mode 运行的,所以CONFIG_RISCV_M_MODE
应该是没有启用的。 -
汇编代码中可能会看到有些
TASK_
和 PT_ 开头的常量,找不到定义。这些常量并没有直接写在源码里,而是自动生成的。在汇编语言中需要用到的很多
struct
里偏移量的常量定义可以在arch/riscv/kernel/asm-offsets.c
文件里找到。其中,OFFSET(NAME, struct_name, field)
指的是NAME
的值定义为field
这一项在struct_name
结构体里,距离结构体开头的偏移量。最终这些代码会生成asm/asm-offsets.h
供汇编代码使用。 -
#include <asm/unistd.h>
在arch/riscv/include/uapi/asm/unistd.h
,#include <asm-generic/unistd.h>
在include/uapi/asm-generic/unistd.h
。
实验练习
实验练习包括实践作业和问答作业两部分。
实践作业
获取任务信息
ch3 中,我们的系统已经能够支持多个任务分时轮流运行,我们希望引入一个新的系统调用 sys_task_info
以获取任务的信息,定义如下:
fn sys_task_info(id: usize, ts: *mut TaskInfo) -> isize
-
syscall ID: 410
-
根据任务 ID 查询任务信息,任务信息包括任务 ID、任务控制块相关信息(任务状态)、任务使用的系统调用及调用次数、任务总运行时长。
struct TaskInfo {
id: usize,
status: TaskStatus,
call: [SyscallInfo; MAX_SYSCALL_NUM],
time: usize
}
- 系统调用信息采用数组形式对每个系统调用的次数进行统计,相关结构定义如下:
struct SyscallInfo {
id: usize,
times: usize
}
-
参数:
- id: 待查询任务 id
- ts: 待查询任务信息
-
返回值:执行成功返回 0,错误返回 - 1
- 说明:
- 相关结构已在框架中给出,只需添加逻辑实现功能需求即可。
-
提示:
- 大胆修改已有框架!除了配置文件,你几乎可以随意修改已有框架的内容。
- 程序运行时间可以通过调用
get_time()
获取。 - 系统调用次数可以考虑在进入内核态系统调用异常处理函数之后,进入具体系统调用函数之前维护。
- 阅读 TaskManager 的实现,思考如何维护内核控制块信息(可以在控制块可变部分加入其他需要的信息)
打印调用堆栈(选做)
我们在调试程序时,除了正在执行的函数外,往往还需要知道当前的调用堆栈。这样的功能通常由调试器、运行环境、 IDE 或操作系统等提供,但现在我们只能靠自己了。最基本的实现只需打印出调用链上的函数地址,更丰富的功能包括打印出函数名、函数定义、传递的参数等等。
本实验我们不提供新的测例,仅提供参考实现,各位同学可以通过对照 GDB 、参考实现或自行构造调用链等方式检验自己的实现是否正确。
提示
可以参考《编译原理》课程中关于函数调用栈帧的内容。
实验要求
-
完成分支: ch3-lab
-
实验目录要求
├── os(内核实现)
│ ├── Cargo.toml(配置文件)
│ └── src(所有内核的源代码放在 os/src 目录下)
│ ├── main.rs(内核主函数)
│ └── ...
├── reports (不是 report)
│ ├── lab3.md/pdf
│ └── ...
├── ...
-
通过所有已有的测例:
CI 使用的测例与本地相同,测试中,user 文件夹及其它与构建相关的文件将被替换,请不要试图依靠硬编码通过测试。
注解
你的实现只需且必须通过测例,建议读者感到困惑时先检查测例。
问答作业
-
正确进入 U 态后,程序的特征还应有:使用 S 态特权指令,访问 S 态寄存器后会报错。 请同学们可以自行测试这些内容 (运行 Rust 两个 bad 测例 (ch2b_bad_*.rs) ) , 描述程序出错行为,同时注意注明你使用的 sbi 及其版本。
-
请通过 gdb 跟踪或阅读源代码了解机器从加电到跳转到 0x80200000 的过程,并描述重要的跳转。回答内核是如何进入 S 态的?
-
事实上进入 rustsbi (0x80000000) 之后就不需要使用 gdb 调试了。可以直接阅读 代码 。
-
可以使用 Makefile 中的
make debug
指令。 -
一些可能用到的 gdb 指令:
-
x/10i 0x80000000
: 显示 0x80000000 处的 10 条汇编指令。 -
x/10i $pc
: 显示即将执行的 10 条汇编指令。 -
x/10xw 0x80000000
: 显示 0x80000000 处的 10 条数据,格式为 16 进制 32bit。 -
info register
: 显示当前所有寄存器信息。 -
info r t0
: 显示 t0 寄存器的值。 -
break funcname
: 在目标函数第一条指令处设置断点。 -
break *0x80200000
: 在 0x80200000 出设置断点。 -
continue
: 执行直到碰到断点。 -
si
: 单步执行一条汇编指令。
-
-
实验练习的提交报告要求
-
简单总结与上次实验相比本次实验你增加的东西(控制在 5 行以内,不要贴代码)。
-
完成问答问题。
-
(optional) 你对本次实验设计及难度 / 工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。