文章目录
- 【 0. 引言 】
- 0.1 上章回顾
- 0.2 背景
- 0.3 协作式操作系统
- 0.4 抢占式操作系统
- 0.3 进程小述
- 0.3 本章任务
- 【 1. 多道程序放置与加载 】
- 1.1 多道程序的放置
- 1.2 多道程序的加载
- 【 2. 进程基础结构 】
- 2.1 进程的概念
- 2.2 进程的基本管理
- 2.3 进程的分配
- 【 3. 多道程序与协作式调度 】
- 3.1 多道程序背景
- 3.2 yield 系统调用
- 3.3 进程的切换
- 3.3.1 进程的切换?
- 3.3.2 idle进程与scheduler
- 3.4 yield函数的实现
- 【 4. 分时多任务系统与抢占式调度 】
- 4.1 分时多任务系统的背景
- 4.2 RISC-V 架构中的中断
- 4.2.1 中断与陷入
- 4.2.2 RISC-V 中断的分类
- 4.2.3 中断屏蔽
- 4.2.4 中断 的 Trap处理
- 4.2.5 嵌套中断 与 嵌套Trap
- 4.3 时钟中断与计时器
- 4.4 syscall 实现
- 4.5 抢占式调度
- 参考
【 0. 引言 】
0.1 上章回顾
- 上一章,我们实现了一个简单的批处理系统。首先,它能够自动按照顺序加载并运行序列中的每一个应用,当一个应用运行结束之后无需操作员的手动替换;另一方面,在硬件提供的特权级机制的帮助下,运行在更高特权级的它不会受到有意或者无意出错的应用的影响,可以全方位监控运行在用户态特权级的应用的执行,一旦应用越过了硬件所设置特权级界限或主动申请获得操作系统的服务,就会触发 Trap 并进入到批处理系统中进行处理。无论原因是应用出错或是应用声明自己执行完毕,批处理系统都只需要加载序列中的下一个应用并进入执行。可以看到批处理系统的特性是:在内存中同一时间最多只需驻留一个应用,这是因为只有当一个应用出错或退出之后,批处理系统才会去将另一个应用加载到相同的一块内存区域。
0.2 背景
- 计算机硬件在快速发展,内存容量在逐渐增大,处理器的速度也在增加,外设IO性能方面的进展不大。这就使得以往内存只能放下一个程序的情况得到很大改善,但 处理器的空闲程度加大了。于是科学家就开始考虑 在内存中尽量同时驻留多个应用,这样处理器的利用率就会提高,但 只有一个程序执行完毕后或主动放弃执行,处理器才能执行另外一个程序,这种运行方式称为 多道程序 。
0.3 协作式操作系统
- 早期的计算机系统大部分是单处理器计算机系统。当处理器进一步发展后,它与IO的速度差距也进一步拉大。这时计算机科学家发现,在 多道程序 运行方式下,一个程序如果不让出处理器,其他程序是无法执行的。如果一个应用由于IO操作让处理器空闲下来或让处理器忙等,那其他需要处理器资源进行计算的应用还是没法使用空闲的处理器资源。于是就想到,让应用在执行IO操作时,可以 主动释放 处理器,让其他应用继续执行。当然执行 放弃处理器 的操作算是一种对处理器资源的直接管理,所以应用程序可以发出这样的系统调用,让操作系统来具体完成。这样的操作系统就是支持 多道程序 协作式操作系统。
0.4 抢占式操作系统
-
背景
计算机科学家很快发现,编写应用程序的科学家(简称应用程序员)来自不同的领域,他们不一定有友好互助的意识,也不了解其他程序的执行情况,很难(也没必要)有提高整个系统利用率上的大局观。在他们的脑海里,整个计算机就应该是为他们自己的应用准备的,不用考虑其他程序的运行。这导致 应用程序员在编写程序时,无法做到在程序的合适位置放弃处理器的系统调用请求 ,这样系统的整体利用率还是无法提高。 -
方法
基于以上,站在系统的层面,还是需要有一种办法能 强制打断应用程序的执行,来提高整个系统的效率,让在整个系统中执行的多个程序之间占用计算机资源的情况相对公平一些。根据计算机系统的硬件设计,为提高I/O效率,外设可以通过硬件中断机制来与处理机进行I/O交互操作,这种硬件中断机制·可随时打断应用程序的执行,并让操作系统来完成对外设的I/O响应。
而 操作系统可进一步利用某种以固定时长为时间间隔的外设中断(比如时钟中断)来强制打断一个程序的执行,这样一个程序只能运行 一段时间(可以简称为一个 Time Slice 时间片)就一定会让出处理器,且操作系统可以在处理外设的I/O响应后,让不同应用程序分时占用处理器执行,并可通过程序占用处理器的总执行时间来评估运行的程序对处理器资源的消耗。 -
具体细节
我们可以把 一个程序在一个时间片上占用处理器执行的过程 称为一个 任务 (Task),让操作系统对不同程序的 任务 进行管理。通过平衡各个程序在整个时间段上的任务数,就达到一定程度的系统公平和高效的系统效率。在一个包含多个时间片的时间段上,会有属于不同程序的多个任务在轮流占用处理器执行,这样的操作系统就是支持 分时多任务 的 抢占式操作系统 。 -
本章所介绍的多道程序和分时多任务系统都有一些共同的特点:
- 在内存中同一时间可以驻留多个应用。
- 所有的应用都是在系统启动的时候分别加载到内存的不同区域中。
- 由于目前计算机系统中只有一个处理器,则同一时间最多只有一个应用在执行,剩下的应用则处于就绪状态,需要内核将处理器分配给它们才能开始执行,一旦应用开始执行,它就处于运行状态了。
0.3 进程小述
- 随着章节的推进,我们的OS从测例嵌入main.c到批处理的OS系统,再到本章的多进程OS,可以说我们的OS已经初具雏形了。在进入本章的内容之前,大家需要对进程有一个清晰的认识: 进程就是 执行(或叫进行)中的程序,因此:
- 每一个进程需要有程序运行所需要的资源。
- 每一个进程又有着自己的优先级,使得进程在调度的时候存在某种机制使得高优先级的进程能够优先执行,而低优先级的进程又不能永远不执行。
- 同时,进程的引入也意味着同时存在多个app在运行,这意味着我们 需要一次将多个测例的bin文件移动到指定的内存位置之中,并且为每一个bin都做好配套的初始化。
0.3 本章任务
我们本章就要设计一个支持优先级进程调度的OS。本章展现了操作系统一系列功能:
- 通过 提前加载应用程序到内存,减少应用程序切换开销。
- 通过 协作机制支持程序主动放弃处理器,提高系统执行效率 。
- 通过 抢占机制支持程序被动放弃处理器,提高不同程序对处理器资源使用的公平性,也进一步提高了应用对I/O事件的响应效率。
【 1. 多道程序放置与加载 】
- 每个应用都需要按照它的编号被分别放置并加载到内存中不同的位置。本节我们就来介绍它是如何实现的。通过具体实现,可以看到多个应用程序被一次性地加载到内存中,这样在切换到另外一个应用程序执行会很快,不像前一章介绍的操作系统,还要有清空前一个应用,然后加载当前应用的过程与开销。
- 但我们也会了解到,每个应用程序需要知道自己运行时在内存中的不同位置,这对应用程序的编写带来了一定的麻烦。而且操作系统也要知道每个应用程序运行时的位置,不能任意移动应用程序所在的内存空间,即不能在运行时根据内存空间的动态空闲情况,把应用程序调整到合适的空闲空间中。
1.1 多道程序的放置
- 我们仍然 使用pack.py以及kernel.py来生成链接测例bin文件的link_app.S以及kernel_app.ld两个文件。这些内容相较第二章并没有任何改变,主要改变的是对多道程序的加载上,要求同时加载多个程序了。
1.2 多道程序的加载
- 从本章开始不再使用上一章批处理操作系统的run_next_app函数,让我们看看loader.c文件之中修改了什么:
可以看到,进程 load 的逻辑其实没有变化。但是我们在 run_all_app函数 之中 一次性加载了所有的测例程序。具体的方式是 遍历每一个app获取其放置的位置,并根据其序号i设置相对于BASE_ADDRESS的偏移量作为程序的起始位置。我们认为设定每个进程所使用的空间是 [0x80400000 + i*0x20000, 0x80400000 + (i+1)*0x20000),每个进程的最大 size 为 0x20000,i 即为进程编号。需要注意此时同时完成了每一个程序对应的进程的初始化以及状态的设置(对进程还不熟悉的同学可以阅读下一节)。
// os/loader.c
int load_app(int n, uint64* info) {
uint64 start = info[n], end = info[n+1], length = end - start;
memset((void*)BASE_ADDRESS + n * MAX_APP_SIZE, 0, MAX_APP_SIZE);
memmove((void*)BASE_ADDRESS + n * MAX_APP_SIZE, (void*)start, length);
return length;
}
// load all apps and init the corresponding `proc` structure.
// 这个函数的更过细节在之后讲解
int run_all_app()
{
for (int i = 0; i < app_num; ++i) {
struct proc *p = allocproc();
struct trapframe *trapframe = p->trapframe;
load_app(i, app_info_ptr);
uint64 entry = BASE_ADDRESS + i * MAX_APP_SIZE;
trapframe->epc = entry;
trapframe->sp = (uint64)p->ustack + USER_STACK_SIZE;
p->state = RUNNABLE;
}
return 0;
}
- 现在应用程序加载完毕了。不同于批处理操作系统,我们该如何执行它们呢?进入下一节。
【 2. 进程基础结构 】
- 本节会介绍进程的调度方式。这是本章的重点之一。
2.1 进程的概念
- 进程就是运行的程序,既然是程序,那么它就需要程序执行的一切资源,包括栈、寄存器等等。 不同于用户线程,用户进程有着自己独立的用户栈和内核栈。但是无论如何寄存器是只有一套的,因此进程切换时对于寄存器的保存以及恢复是我们需要关心的问题。
- 为了研究进程的切换,我们先来搞懂用户进程长啥样,是如何运行的,不妨从上一节的 run_all_app 函数开始研究:
int run_all_app()
{
for (int i = 0; i < app_num; ++i) {
struct proc *p = allocproc();
struct trapframe *trapframe = p->trapframe;
load_app(i, app_info_ptr);
uint64 entry = BASE_ADDRESS + i * MAX_APP_SIZE;
trapframe->epc = entry;
trapframe->sp = (uint64)p->ustack + USER_STACK_SIZE;
p->state = RUNNABLE;
}
return 0;
}
- 首先介绍 struct proc 的定义。本章中新增的proc.h定义了我们OS的进程的 PCB 结构体(进程管理块,和进程一一对应,它包含了进程几乎所有的信息):
可以看到 每一个进程的PCB都保存了它当前的状态以及它的PID(每个进程的PID不同),同时 记录了其用户栈和内核栈的起始地址。trapframe和context在异常中断的切换以及进程之间的切换起到了保存的重要作用。
// os/proc.h
struct proc {
enum procstate state; // 进程状态
int pid; // 进程ID
uint64 ustack; // 进程用户栈虚拟地址(用户页表)
uint64 kstack; // 进程内核栈虚拟地址(内核页表)
struct trapframe *trapframe; // 进程中断帧
struct context context; // 用于保存进程内核态的寄存器信息,进程切换时使用
};
enum procstate {
UNUSED, // 未初始化
USED, // 基本初始化,未加载用户程序
SLEEPING, // 休眠状态(未使用,留待后续拓展)
RUNNABLE, // 可运行
RUNNING, // 当前正在运行
ZOMBIE, // 已经 exit
};
- 进程的状态
进程的状态分为 创建、就绪、执行、等待 以及 结束 5大阶段(未来还会有 挂起)。在我们的OS之中对状态的分类略有不同。
进程的状态 | 英文 |
---|---|
就绪的进程 | RUNNABLE |
正在执行的进程 | RUNNING |
池中未分配或已经结束的进程 | UNUSED |
已经分配好但是还未加载完毕的进程 | USED |
2.2 进程的基本管理
- 在我们的OS之中,我们采用了非常朴素的进程池方式来存放进程:
- 可以看到我们最多同时有 NPROC 个进程,每一个进程的用户栈、内核栈以及trapframe所需的空间已经预先分配好了。当然缺点是进程池空间有限,不过直到 lab8 之前大家都无需担心这个问题。
- 这里的 idle 进程 是我们的 boot 进程,是我们执行初始化的进程,事实上,在引入用户进程前,idle 是唯一的一个进程。
- 比较重要的是 current_proc,它代表着当前正在执行的进程,因此这个变量在进程切换时也需要维护来保证其正确性,活用此变量能大大方便我们的编程。
// os/trap.c
struct proc pool[NPROC]; // 全局进程池
struct proc idle; // boot 进程
struct proc* current_proc; // 指示当前进程
// 由于还有没内存管理机制,静态分配一些进程资源
char kstack[NPROC][PAGE_SIZE];
__attribute__((aligned(4096))) char ustack[NPROC][PAGE_SIZE];
__attribute__((aligned(4096))) char trapframe[NPROC][PAGE_SIZE];
- 进程模块初始化函数如下:
// kernel/trap.c
void procinit()
{
struct proc *p;
for(p = pool; p < &pool[NPROC]; p++) {
p->state = UNUSED;
p->kstack = (uint64)kstack[p - pool];
p->ustack = (uint64)ustack[p - pool];
p->trapframe = (struct trapframe*)trapframe[p - pool];
}
idle.kstack = (uint64)boot_stack_top;
idle.pid = 0;
}
2.3 进程的分配
- 回到 run_all_app 函数,可以注意到首每个用户进程都被分配了一个 proc 结构,通过 alloc_proc 函数。 进程的分配实际上本质就是从进程池中挑选一个还未使用(状态为UNUSED)的位置分配给进程。具体代码如下:
分配进程需要初始化其PID以及清空其栈空间,并设置 context 第一次运行的入口地址 usertrapret,使得进程能够从内核的S态返回U态并执行自己的代码。
1// os/proc.c
2
3// Look in the process table for an UNUSED proc.
4// If found, initialize state required to run in the kernel.
5// If there are no free procs, or a memory allocation fails, return 0.
6struct proc *allocproc()
7{
8 struct proc *p;
9 for (p = pool; p < &pool[NPROC]; p++) {
10 if (p->state == UNUSED) {
11 goto found;
12 }
13 }
14 return 0;
15
16found:
17 p->pid = allocpid();
18 p->state = USED;
19 memset(&p->context, 0, sizeof(p->context));
20 memset(p->trapframe, 0, PAGE_SIZE);
21 memset((void *)p->kstack, 0, PAGE_SIZE);
22 p->context.ra = (uint64)usertrapret;
23 p->context.sp = p->kstack + PAGE_SIZE;
24 return p;
25}
【 3. 多道程序与协作式调度 】
- 这一节我们主要介绍进程切换相关的具体实现。
3.1 多道程序背景
- 还记得第二章中介绍的批处理系统的设计初衷吗?它是注意到 CPU 并没有一直在执行应用程序,在一个应用程序运行结束直到下一个应用程序开始运行的这段时间,可能 需要操作员取出上一个程序的执行结果并手动进行程序卡片的替换,这段空档期对于宝贵的 CPU 计算资源 是一种巨大的浪费。于是批处理系统横空出世,它可以自动连续完成应用的加载和运行,并将一些本不需要 CPU 完成的简单任务交给廉价的外围设备,从而让 CPU 能够更加专注于计算任务本身,大大提高了 CPU 的利用率。
- 尽管 CPU 一直在跑应用了,但是其利用率仍有上升的空间。随着应用需求的不断复杂,有的时候会在内核的监督下访问一些外设,它们也是计算机系统的另一个非常重要的组成部分,即 输入/输出 (I/O, Input/Output) 。CPU 会将请求和一些附加的参数写入外设, 待外设处理完毕 之后, CPU 便可以从外设读到请求的处理结果。
比如:在从作为外部存储的磁盘上读取数据的时候,CPU 将要读取的扇区的编号以及读到的数据放到的物理地址传给磁盘,在磁盘对请求进行调度并完成数据拷贝之后,就能在物理内存中看到要读取的数据。 - 在一个应用对外设发出了请求之后,它不能立即向下执行,而是要等待外设将请求处理完毕并拿到完整的处理结果之后才能继续。那么如何知道外设是否已经完成了请求呢?
通常 外设会提供一个可读的寄存器记录它目前的工作状态,于是 CPU 需要不断原地循环读取它直到它的结果显示设备已经将请求处理完毕了,才能向下执行。然而,外设的计算速度和 CPU 相比可能慢了几个数量级,这就导致 CPU 有大量时间浪费在等待外设这件事情上,这段时间它几乎没有做任何事情,也在一定程度上造成了 CPU 的利用率不够理想。 - 我们暂时考虑 CPU 只能 单方面 通过读取外设提供的寄存器来获取外设请求处理的状态。 多道程序的思想在于:内核同时管理多个应用。如果外设处理的时间足够长,那我们可以 先进行任务切换去执行其他应用,在某次切换回来之后,应用再次读取设备寄存器,发现请求已经处理完毕了,那么就可以用拿到的完整的数据继续向下执行了。这样的话,只要同时存在的应用足够多,就能保证 CPU 不必浪费时间在等待外设上,而是几乎一直在进行计算。这种任务切换,是通过应用进行一个名为 sys_yield 的系统调用来实现的,这意味着它主动交出 CPU 的使用权给其他应用, 这正是“协作式”的含义。
3.2 yield 系统调用
-
一个应用会持续运行下去,直到它主动调用 sys_yield 来交出 CPU 使用权。内核将很大的权力下放到应用,让所有的应用互相协作来最终达成最大化 CPU 利用率,充分利用计算资源这一终极目标。在计算机发展的早期,由于应用基本上都是一些简单的计算任务,且程序员都比较遵守规则,因此内核可以信赖应用,这样协作式的制度是没有问题的。
-
下图描述了一种多道程序执行的典型情况。其中横轴为时间线,纵轴为正在执行的实体。
- 开始时,某个应用(蓝色)向外设提交了一个请求,随即可以看到对应的外设(紫色)开始工作。但是外设(紫色)要工作相当长的一段时间,因此应用(蓝色)不会去等待它结束而是会调用 sys_yield 主动交出 CPU 使用权来切换到另一个应用(绿色)。
- 应用(绿色)在执行了一段时间之后调用了 sys_yield ,此时内核决定让应用(蓝色)继续执行。
- 应用(蓝色)检查了一下外设的工作状态,发现请求尚未处理完,于是再次调用 sys_yield 切换到应用(绿色)。
- 然后应用(绿色)执行了一段时间之后 sys_yield 再次切换回这个应用(蓝色),这次的不同是它发现外设已经处理完请求了,于是它终于可以向下执行了。
-
上面我们是通过“避免无谓的外设等待来提高 CPU 利用率”这一切入点来引入 sys_yield 。但其实调用 sys_yield 不一定与外设有关。随着内核功能的逐渐复杂,我们 还会遇到很多其他类型的需要等待其完成才能继续向下执行的事件,我们都可以 立即调用 sys_yield 来避免等待过程造成的浪费。
-
sys_yield 的缺点
当应用调用 sys_yield ,然后主动交出 CPU 使用权之后,它 下一次再被允许使用 CPU 的时间点与内核的调度策略与 当前的总体应用执行情况 有关,很有可能远远迟于该应用等待的事件(如外设处理完请求)达成的时间点。这就会造成该应用的响应延迟不稳定,有可能极高。
比如,设想一下,敲击键盘之后隔了数分钟之后才能在屏幕上看到字符,这已经超出了人类所能忍受的范畴。但也请不要担心,我们后面会有更加优雅的解决方案。 -
我们给出 sys_yield 的标准接口:
/// 功能:应用主动交出 CPU 所有权并切换到其他应用。
/// 返回值:总是返回 0。
/// syscall ID:124
int sys_yield();
- 用户库对应的实现和封装:
// user/lib/syscall.c
int sched_yield()
{
return syscall(SYS_sched_yield);
}
- 接下来我们介绍内核应如何实现该系统调用。介绍本章的最最最重要的进程切换(调度)问题。
3.3 进程的切换
3.3.1 进程的切换?
- 说到切换,大家肯定对第二章中异常产生后,从U态切换到S态的流程历历在目。实际上,进程的切换和它十分类似。大家可以类比一下,第二章我们是从进程1—>内核态处理异常—>进程1。那我们完全可以把整个流程转换为进程1–>内核切换–>进程2–>切换–>进程1来实现执行流的切换,并且保证中途的保存和恢复不出错呀!当然这么做会比较复杂,我们的处理方式更加复古一点,但是思路基本是一样的。回顾一下进程的PCB结构体中两个用于切换的结构体成员:
struct trapframe *trapframe;
struct context context;
- trapframe大家在第二章已经和它打过交到了。那么context这个结构体又记录了什么呢?
它相比 trapframe, context 结构体 只记录了被调用者保存的寄存器。
1// os/trap.h
2
3// Saved registers for kernel context switches.
4struct context {
5 uint64 ra;
6 uint64 sp;
7 // callee-saved
8 uint64 s0;
9 uint64 s1;
10 uint64 s2;
11 uint64 s3;
12 uint64 s4;
13 uint64 s5;
14 uint64 s6;
15 uint64 s7;
16 uint64 s8;
17 uint64 s9;
18 uint64 s10;
19 uint64 s11;
20};
- 在切换的核心函数 swtch 函数(注意拼写)之中,就是对 上面这个 context 结构体进行了操作:
- 为什么只切换这些寄存器就能实现一个切换的效果呢?这是因为 执行了swtch切换状态之后,切换的目标进程恢复了保存在context之中的寄存器,并且sp寄存器也指向了它自己栈的位置,ra指向自己测例代码的位置而不是之前函数的位置,这已经足够其从切换出去的位置继续执行了(切换的过程可以视为一次函数调用)。因为真正切换swtch都在内核态发生,也无需记录更多的数据。
1# Context switch
2#
3# void swtch(struct context *old, struct context *new);
4#
5# Save current registers in old. Load from new.
6
7.globl swtch
8
9# a0 = &old_context, a1 = &new_context
10
11swtch:
12 sd ra, 0(a0) # save `ra`
13 sd sp, 8(a0) # save `sp`
14 sd s0, 16(a0)
15 sd s1, 24(a0)
16 sd s2, 32(a0)
17 sd s3, 40(a0)
18 sd s4, 48(a0)
19 sd s5, 56(a0)
20 sd s6, 64(a0)
21 sd s7, 72(a0)
22 sd s8, 80(a0)
23 sd s9, 88(a0)
24 sd s10, 96(a0)
25 sd s11, 104(a0)
26
27 ld ra, 0(a1) # restore `ra`
28 ld sp, 8(a1) # restore `sp`
29 ld s0, 16(a1)
30 ld s1, 24(a1)
31 ld s2, 32(a1)
32 ld s3, 40(a1)
33 ld s4, 48(a1)
34 ld s5, 56(a1)
35 ld s6, 64(a1)
36 ld s7, 72(a1)
37 ld s8, 80(a1)
38 ld s9, 88(a1)
39 ld s10, 96(a1)
40 ld s11, 104(a1)
41
42 ret # return to new `ra`
- 总结一下,swtch函数干了这些事情:
- 执行流:通过切换 ra
- 堆栈:通过切换 sp
- 寄存器: 通过保存和恢复被调用者保存寄存器。调用者保存寄存器由编译器生成的代码负责保存和恢复。
3.3.2 idle进程与scheduler
- proc.c文件中除了current_proc 还记录了一个idle_proc。这个进程是干什么的呢?
实际上,idle 进程 是第一个进程 (boot进程),也是 唯一的一个永远会存在的进程,它还有一个大家更熟悉的面孔,它就是 os 的 main 函数。
是时候从头开始梳理从机器 boot 到多个用户进程相互切换到底发生了什么了。 - 可以看到,在main函数完成了一系列的初始化,并且执行了run_all_app加载完了所有测例之后。它就进入了 scheduler调度函数。
void main() {
clean_bss(); // 清空 bss 段
trapinit(); // 开启中断
batchinit(); // 初始化 app_info_ptr 指针
procinit(); // 初始化线程池
// timerinit(); // 开启时钟中断,现在还没有
run_all_app(); // 加载所有用户程序
scheduler(); // 开始调度
}
- scheduler调度函数。这个函数就完成了一系列的调度工作:
- 可以看到一旦main进入调度状态就进入一个死循环再也回不去了…但它也没必要回去,它现在活着的意义就是为了进行进程的调度。 在循环中每一次idle进程都会遍历整个进程池来寻找RUNNABLE(就绪)状态的进程并执行swtch函数切换到它。我们这里的scheduler函数就是最普通的调度函数,完全没有考虑优先度以及复杂度。
1void
2scheduler(void)
3{
4 struct proc *p;
5
6 for(;;){
7 for(p = pool; p < &pool[NPROC]; p++) {
8 if(p->state == RUNNABLE) {
9 p->state = RUNNING;
10 current_proc = p;
11 swtch(&idle.context, &p->context);
12 }
13 }
14 }
15}
3.4 yield函数的实现
- yield 函数具体实现如下:
// os/proc.c
// Give up the CPU for one scheduling round.
void yield(void)
{
current_proc->state = RUNNABLE;
sched();
}
void sched(void)
{
struct proc *p = curr_proc();
swtch(&p->context, &idle.context);
}
- yield函数的本质就是主动放弃执行,并把context移交给负责scheduler进程的idle进程。那这个时候 idle 进程在干什么?idle 线程死循环在了一件事情上:寻找一个 RUNNABLE 的进程,然后切换到它开始执行。当这个进程调用 sched 后,执行流会回到 idle 线程,然后继续开始寻找,如此往复。直到所有进程执行完毕,在 sys_exit 系统调用中有统计计数,一旦 exit 的进程达到用户程序数量就关机。
- 也就是说,所有进程间切换都需要通过 idle 中转一下。那么可不可以一步到位呢?答案是肯定的,其实rust版代码lab3) 就是采取这种实现:在一个进程退出时,直接寻找下一个就绪进程,然后直接切换过去,没有 idle 的中转。两种实现都是可行的。
- 在了解这些之后,我们就可以实现协作式调度了,主要是 sys_yeild 系统调用,其实现十分简单,自行查看 kernel/syscall.c文件。
【 4. 分时多任务系统与抢占式调度 】
- 本节的重点是操作系统对中断的处理和对应用程序的抢占。为此,对 任务 的概念进行进一步扩展和延伸:
- 分时多任务:操作系统管理每个应用程序,以时间片为单位来分时占用处理器 运行应用。
- 时间片轮转调度:操作系统 在一个程序用完其时间片后,就抢占当前程序并 调用下一个程序执行,周而复始,形成对应用程序在任务级别上的时间片轮转调度。
4.1 分时多任务系统的背景
- 上一节我们介绍了多道程序,它是一种允许应用在等待外设时主动切换到其他应用来达到总体 CPU 利用率最高的设计。那个时候,应用是不太注重自身的运行情况的,即使它 yield 交出 CPU 资源之后需要很久才能再拿到,使得它真正在 CPU 执行的相邻两时间段距离都很远,应用也是无所谓的。因为它们的目标是总体 CPU 利用率最高,可以换成一个等价的指标: 吞吐量 (Throughput) 。大概可以理解为在某个时间点将一组应用放进去,要求在一段固定的时间之内执行完毕的应用最多,或者是总进度百分比最大。因此,所有的应用和编写应用的程序员都有这样的共识:只要 CPU 一直在做实际的工作就好。
- 从现在的眼光来看,当时的应用更多是一种 后台应用 (Background Application) ,在将它加入执行队列之后我们只需定期确认它的运行状态。而随着技术的发展,涌现了越来越多的 交互式应用 (Interactive Application) ,它们要达成的一个重要目标就是提高用户操作的响应速度,这样才能优化应用的使用体验。对于这些应用而言,即使需要等待外设或某些事件,它们也不会倾向于主动 yield 交出 CPU 使用权,因为这样可能会带来无法接受的延迟。也就是说,应用之间相比合作更多的是互相竞争宝贵的硬件资源。
- 如果应用自己很少 yield ,内核就要开始收回之前下放的权力,由它自己对 CPU 资源进行集中管理并合理分配给各应用,这就是内核需要提供的任务调度能力。我们可以将多道程序的调度机制分类成 协作式调度 (Cooperative Scheduling) ,因为它的特征是: 只要一个应用不主动 yield 交出 CPU 使用权,它就会一直执行下去;与之相对,抢占式调度 (Preemptive Scheduling) 则是 应用 随时 都有被内核切换出去的可能。
- 现代的任务调度算法基本都是抢占式的,它要求每个应用只能连续执行一段时间,然后内核就会将它强制性切换出去。一般将 时间片 (Time Slice) 作为应用连续执行时长的度量单位,每个时间片可能在毫秒量级。调度算法需要考虑:每次在换出之前给一个应用多少时间片去执行,以及要换入哪个应用。可以从性能和 公平性 (Fairness) 两个维度来评价调度算法,后者要求多个应用分到的时间片占比不应差距过大。
- 简单起见,本书中我们使用 时间片轮转算法 (RR, Round-Robin) 来对应用进行调度,只要对它进行少许拓展就能完全满足我们续的需求。本章中我们仅需要最原始的 RR 算法,用文字描述的话就是: 维护一个任务队列,每次从队头取出一个应用执行一个时间片,然后把它丢到队尾,再继续从队头取出一个应用,以此类推直到所有的应用执行完毕。
4.2 RISC-V 架构中的中断
- RR时间片轮转调度算法 的核心机制就在于 计时,操作系统的计时功能是依靠硬件提供的 时钟中断 来实现的。
4.2.1 中断与陷入
- 中断 (Interrupt) 和我们第二章中介绍的 用于系统调用的 陷入 Trap 一样都是异常 ,但是它们被触发的原因确是不同的。对于某个处理器核而言,
- 陷入 与发起 陷入 的指令执行是 同步 (Synchronous) 的, 陷入 被触发的原因一定能够追溯到某条指令的执行;
- 而 中断则 异步 (Asynchronous) 于当前正在进行的指令,也就是说 中断来自于哪个外设以及中断如何触发完全与处理器正在执行的当前指令无关。
- 从底层硬件的角度可能更容易理解这里所提到的同步和异步。
以一个处理器传统的五级流水线设计而言,里面含有取指、译码、算术、 访存、寄存器等单元,都属于执行指令所需的硬件资源。那么假如某条指令的执行出现了问题,一定能被其中某个单元看到并反馈给流水线控制单元,从而它会在执行预定的下一条指令之前先进入异常处理流程。也就是说,异常在这些单元内部即可被发现并解决。
而对于中断,可以想象为想发起中断的是一套完全不同的电路(从时钟中断来看就是简单的计数和比较器),这套电路仅通过一根导线接入进来,当想要触发中断的时候则输入一个高电平或正边沿,处理器会在每执行完一条指令之后检查一下这根线,看情况决定是继续执行接下来的指令还是进入中断处理流程。也就是说,大多数情况下,指令执行的相关硬件单元和可能发起中断的电路是完全独立 并行 (Parallel) 运行的,它们中间只有一根导线相连,除此之外指令执行的那些单元就完全不知道对方处于什么状态了。
4.2.2 RISC-V 中断的分类
- 在不考虑指令集拓展的情况下,RISC-V 架构中定义了如下中断:
Interrupt | Exception Code | Description |
---|---|---|
1 | 1 | Supervisor software interrupt |
1 | 3 | Machine software interrupt |
1 | 5 | Supervisor timer interrupt |
1 | 7 | Machine timer interrupt |
1 | 9 | Supervisor external interrupt |
1 | 11 | Machine external interrupt |
- RISC-V 的中断可以分成三类:
软件中断 (Software Interrupt)
时钟中断 (Timer Interrupt)
外部中断 (External Interrupt)
4.2.3 中断屏蔽
- 另外,相比异常,中断和特权级之间的联系更为紧密,可以看到这三种中断每一个都有 M/S 特权级两个版本。
- 中断的特权级可以决定该中断是否会被屏蔽,以及需要 Trap 到 CPU 的哪个特权级进行处理。
- 在判断中断是否会被屏蔽的时候,有以下规则:
- 如果中断的特权级低于 CPU 当前的特权级,则该中断会 被屏蔽,不会被处理;
- 如果中断的特权级高于与 CPU 当前的特权级或相同,则需要 通过相应的 CSR寄存器 判断 该中断是否会被屏蔽。
- 以内核所在的 S 特权级为例,中断屏蔽相应的 CSR 有 sstatus 和 sie 。
- sstatus 的 sie 为 S 特权级的中断使能,能够同时控制三种中断,如果将其清零则会将它们全部屏蔽。
- sstatus.sie 置 1 ,还要看 sie 这个 CSR,它的三个字段 ssie/stie/seie 分别控制 S 特权级的软件中断、时钟中断和外部中断的中断使能。
比如对于 S 态时钟中断来说,如果 CPU 不高于 S 特权级,需要 sstatus.sie 和 sie.stie 均为 1 该中断才不会被屏蔽;如果 CPU 当前特权级高于 S 特权级,则该中断一定会被屏蔽。
4.2.4 中断 的 Trap处理
- 如果中断没有被屏蔽,那么接下来就需要 Trap 进行处理,而具体 Trap 到哪个特权级与一些中断代理 CSR 的设置有关。
- 默认情况下,所有的中断都需要 Trap 到 M 特权级处理。
- 而设置这些代理 CSR 之后,就可以 Trap 到低特权级处理,但是 Trap 到的特权级不能低于中断的特权级。
- 事实上 所有的异常默认也都是 Trap 到 M 特权级处理的,它们也有一套对应的异常代理 CSR ,设置之后也可以 Trap 到低优先级来处理异常。
- 我们会在 深入机器模式:RustSBI中再深入介绍中断/异常代理。在正文中我们只需要了解:
- 包括系统调用(即来自 U 特权级的环境调用)在内的所有异常都会 Trap 到 S 特权级处理;
- 只需考虑 S 特权级的时钟/软件/外部中断,且它们都会被 Trap 到 S 特权级处理。
4.2.5 嵌套中断 与 嵌套Trap
- 默认情况下,当 Trap 进入某个特权级之后,在 Trap 处理的过程中同特权级的中断都会被屏蔽。
这里我们还需要对第二章介绍的 Trap 发生时的硬件机制做一下补充,以 Trap 到 S 特权级为例:
- 当 Trap 发生时,sstatus.sie 会被保存在 sstatus.spie 字段中,同时 sstatus.sie 置零,这也就在 Trap 处理的过程中屏蔽了所有 S 特权级的中断;
- 当 Trap 处理完毕 sret 的时候, sstatus.sie 会恢复到 sstatus.spie 内的值。
- 也就是说,如果不去手动设置 sstatus CSR ,在只考虑 S 特权级中断的情况下,是不会出现 嵌套中断的。嵌套中断 (Nested Interrupt) 是指 在处理一个中断的过程中再一次触发了中断从而通过 Trap 来处理。由于默认情况下一旦进入 Trap 硬件就自动禁用所有同特权级中断,自然也就 不会再次触发中断导致嵌套中断 了。
- 嵌套中断可以分为两部分:在处理一个中断的过程中又被同特权级/高特权级中断所打断。默认情况下硬件会避免前一部分,也可以通过手动设置来允许前一部分的存在;而从上面介绍的规则可以知道,后一部分则是无论如何设置都不可避免的。
- 嵌套 Trap 则是指 处理一个 Trap 过程中又再次发生 Trap ,嵌套中断算是嵌套 Trap 的一部分。
- RISC-V 架构的 U 特权级中断
目前,RISC-V 用户态中断作为代号 N 的一个指令集拓展而存在。有兴趣的读者可以阅读最新版的 RISC-V 特权级架构规范一探究竟。
4.3 时钟中断与计时器
- 由于需要一种计时机制,RISC-V 架构要求处理器要有一个内置时钟,其频率一般低于 CPU 主频。此外,还有一个计数器统计处理器自上电以来经过了多少个内置时钟的时钟周期。在 RV64 架构上,该计数器保存在一个 64 位的 CSR mtime 中,我们无需担心它的溢出问题,在内核运行全程可以认为它是一直递增的。
- 另外一个 64 位的 CSR mtimecmp 的作用是:一旦计数器 mtime 的值超过了 mtimecmp,就会触发一次时钟中断。这使得我们可以方便的通过设置 mtimecmp 的值来决定下一次时钟中断何时触发。可惜的是,它们都是 M 特权级的 CSR ,而我们的内核处在 S 特权级,是不被硬件允许直接访问它们的。好在运行在 M 特权级的 SEE 已经预留了相应的接口,我们可以调用它们来间接实现计时器的控制。
- timer.c 子模块的 get_cycle 函数可以 获取当前 mtime 计数器的值。
// os/timer.c
/// read the `mtime` regiser
uint64 get_cycle()
{
return r_time();
}
- sbi.c 有一个 set_timer 调用,是一个由 SEE 提供的标准 SBI 接口函数,它可以用来 设置 mtimecmp 的值。
1// os/sbi.c
2
3void set_timer(uint64 stime)
4{
5 sbi_call(SBI_SET_TIMER, stime, 0, 0);
6}
7
8// os/timer.c
9
10#define TICKS_PER_SEC (100)
11#define CPU_FREQ (12500000)
12
13/// Set the next timer interrupt
14void set_next_timer()
15{
16 const uint64 timebase = CPU_FREQ / TICKS_PER_SEC;
17 set_timer(get_cycle() + timebase);
18}
- timer.c 的 set_next_trigger 函数对 set_timer 进行了封装,它首先读取当前 mtime 的值,然后计算出 10ms(也就是一个tick) 之内计数器的增量,再将 mtimecmp 设置为二者的和。这样,10ms 之后一个 S 特权级时钟中断就会被触发。至于增量的计算方式, CLOCK_FREQ 是一个预先获取到的各平台不同的时钟频率,单位为赫兹,也就是一秒钟之内计数器的增量, 10ms 的话只需除以常数 TICKS_PER_SEC 也就是 100 即可。
4.4 syscall 实现
- 既然引入了 time 模块,我们也顺势实现了 time 有关的一个系统调用 gettimeofday:
我们只需要把 get_cycle 得到的 cycle 数换算了秒和微妙,填入对应结构即可。
typedef struct {
uint64 sec; // 自 Unix 纪元起的秒数
uint64 usec; // 微秒数,也就是秒的小数点后的内容
} TimeVal;
/// tz 参数表示时区,这里我们忽略这个参数
uint64 sys_gettimeofday(TimeVal *val, int _tz)
{
uint64 cycle = get_cycle();
val->sec = cycle / CPU_FREQ;
val->usec = (cycle % CPU_FREQ) * 1000000 / CPU_FREQ;
return 0;
}
- syscall 标准定义如下:
/// 功能:获取当前的时间,填写 TimeVal 结构体
/// 返回值:始终为 0
/// syscall ID:169
int sys_gettimeofday(TimeVal *val, int tz)
4.5 抢占式调度
- 有了时钟中断和计时器,抢占式调度就很容易实现了:
我们只需在 usertrap 函数下新增一个分支,当发现触发了一个 S 特权级时钟中断 的时候,首先 重新设置一个 10ms 的计时器,然后调用上一小节提到的 yield 函数暂停当前应用并切换到下一个。
void usertrap() {
// ...
switch(cause) {
case SupervisorTimer:
set_next_timer();
yield();
break;
// ...
}
}
- 为了避免 S 特权级时钟中断被屏蔽,我们需要在执行第一个应用之前进行一些初始化设置:
这样,当一个应用运行了 10ms 之后,一个 S 特权级时钟中断就会被触发。由于应用运行在 U 特权级,且 sie 寄存器被正确设置,该中断不会被屏蔽,而是 Trap 到 S 特权级内的我们的 trap_handler 里面进行处理,并顺利切换到下一个应用。这便是我们所期望的抢占式调度机制。
1// os/main.c
2
3void main{
4 // ...
5 timer_init();
6 // ...
7}
8
9// os/trap.c
10
11/// Enable timer interrupt
12void timer_init()
13{
14 // 设置 ``sie.stie`` 使得 S 特权级时钟中断不会被屏蔽;
15 w_sie(r_sie() | SIE_STIE);
16 // 设置第一个 10ms 的计时器。
17 set_next_timer();
18}
- 目前在等待某些事件的时候仍然需要 yield ,其中一个原因是为了节约 CPU 计算资源,另一个原因是当事件依赖于其他的应用的时候,由于只有一个 CPU ,当前应用的等待可能永远不会结束。这种情况下需要先将它切换出去,使得其他的应用到达它所期待的状态并满足事件的生成条件,再切换回来。
- 这里我们先通过 yield 来优化 轮询 (Busy Loop) 过程带来的 CPU 资源浪费。在 ch3b_sleep.c 这个应用中:
它的功能是等待 3000ms 然后退出。可以看出,我们会 在循环里面 sched_yield() 来主动交出 CPU 而不是无意义的忙等。尽管我们不这样做,已有的抢占式调度还是会在它循环 10ms 之后切换到其他应用,但是这样能让内核给其他应用分配更多的 CPU 资源并让它们更早运行结束。
// user/src/ch3b_sleep.c
int main()
{
// get_mtime() 是 gettimeofday 的封装,得到毫秒数。
int64 current_time = get_mtime();
int64 wait_for = current_time + 3000;
while (get_mtime() < wait_for) {
sched_yield();
}
puts("Test sleep OK!");
return 0;
}
参考
- 多道程序放置与加载
- 进程基础结构
- 多道程序与协作式调度
- 分时多任务系统与抢占式调度