uCore-Tutorial-Guide-2023S/source/chapter3/2task-switching.rst

188 lines
9.2 KiB
ReStructuredText
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

进程和进程切换
================================
本节导读
--------------------------
本节会介绍进程的调度方式。这是本章的重点之一。
进程的概念
---------------------------------
导语中提到了,进程就是运行的程序。既然是程序,那么它就需要程序执行的一切资源,包括栈、寄存器等等。不同于用户线程,用户进程有着自己独立的用户栈和内核栈。但是无论如何寄存器是只有一套的,因此进程切换时对于寄存器的保存以及恢复是我们需要关心的问题。
本章中新增的proc.h定义了我们OS的进程的PCB进程管理块和进程一一对应结构体
.. code-block:: C
:linenos:
// kernel/trap.h
struct proc {
enum procstate state; // 进程状态
int pid; // 进程ID
uint64 ustack;
uint64 kstack;
struct trapframe *trapframe;
struct context context; // 用于保存进程内核态的寄存器信息,进程切换时使用
};
可以看到每一个进程的PCB都保存了它当前的状态以及它的PID每个进程的PID不同。同时记录了其用户栈和内核栈的起始地址。trapframe和context在异常中断的切换以及进程之间的切换起到了保存的重要作用。
进程的状态是大家比较熟悉的问题了。OS课程上将进程的状态分为创建、就绪、执行、等待以及结束5大阶段未来还会有挂起。在我们的OS之中对状态的分类略有不同。我们一般用RUNNABLE代表就绪的进程RUNNING代表正在执行的进程UNUSED代表池中未分配或已经结束的进程USED代表已经分配好但是还未加载完毕可以就绪执行的进程。
OS的进程结构
---------------------------------
在我们的OS之中我们采用了非常朴素的进程池方式来存放进程
.. code-block:: C
:linenos:
// kernel/trap.c
struct proc pool[NPROC];
struct proc idle; // boot proc始终存在
struct proc* current_proc; // 指示当前进程
char kstack[NPROC][PAGE_SIZE];
char ustack[NPROC][PAGE_SIZE];
char trapframe[NPROC][PAGE_SIZE];
extern char boot_stack_top[]; // bootstack用作 idle proc kernel stack
可以看到我们最多同时有NPROC个进程并且进程池的下标对应着进程的PID。这样使得我们操作进程十分方便。每一个进程的用户栈、内核栈以及trapframe所需的空间已经预先分配好了。当然缺点是进程池空间有限不过直到lab8之前大家都无需担心这个问题。
比较重要的是current_proc它代表着当前正在执行的进程。因此这个变量在进程切换时也需要维护来保证其正确性。活用此变量能大大方便我们的编程。
进程的分配
---------------------------------
进程的分配实际上本质就是从进程池中挑选一个还未使用状态为UNUSED的位置分配给进程。具体代码如下:
.. code-block:: C
:linenos:
// kernel/proc.c
struct proc* allocproc(void)
{
struct proc *p;
for(p = pool; p < &pool[NPROC]; p++) {
if(p->state == UNUSED) {
goto found;
}
}
return 0;
found:
p->pid = allocpid(); // 分配一个没有被使用过的 id
p->state = USED; // 标记该控制块被使用
memset(&p->context, 0, sizeof(p->context));
memset(p->trapframe, 0, PAGE_SIZE);
memset((void*)p->kstack, 0, PAGE_SIZE);
// 初始化第一次运行的上下文信息
p->context.ra = (uint64)usertrapret;
p->context.sp = p->kstack + PAGE_SIZE;
return p;
}
分配进程需要初始化其PID以及清空其栈空间并设置第一次运行的上下文信息为usertrapret使得进程能够从内核的S态返回U态并执行自己的代码。
接下来我们同样从栈上内容的角度来看 ``__switch`` 的整体流程:
.. image:: switch-1.png
.. image:: switch-2.png
Trap 执行流在调用 ``__switch`` 之前就需要明确知道即将切换到哪一条目前正处于暂停状态的 Trap 执行流,因此 ``__switch`` 有两个参数,第一个参数代表它自己,第二个参数则代表即将切换到的那条 Trap 执行流。这里我们用上面提到过的 ``task_cx_ptr2`` 作为代表。在上图中我们假设某次 ``__switch`` 调用要从 Trap 执行流 A 切换到 B一共可以分为五个阶段在每个阶段中我们都给出了 A 和 B 内核栈上的内容。
- 阶段 [1]:在 Trap 执行流 A 调用 ``__switch`` 之前A 的内核栈上只有 Trap 上下文和 Trap 处理的调用栈信息,而 B 是之前被切换出去的,它的栈顶还有额外的一个任务上下文;
- 阶段 [2]A 在自身的内核栈上分配一块任务上下文的空间在里面保存 CPU 当前的寄存器快照。随后,我们更新 A 的 ``task_cx_ptr``,只需写入指向它的指针 ``task_cx_ptr2`` 指向的内存即可;
- 阶段 [3]:这一步极为关键。这里读取 B 的 ``task_cx_ptr`` 或者说 ``task_cx_ptr2`` 指向的那块内存获取到 B 的内核栈栈顶位置,并复制给 ``sp`` 寄存器来换到 B 的内核栈。由于内核栈保存着它迄今为止的执行历史记录,可以说 **换栈也就实现了执行流的切换** 。正是因为这一步, ``__switch`` 才能做到一个函数跨两条执行流执行。
- 阶段 [4]CPU 从 B 的内核栈栈顶取出任务上下文并恢复寄存器状态,在这之后还要进行退栈操作。
- 阶段 [5]:对于 B 而言, ``__switch`` 函数返回,可以从调用 ``__switch`` 的位置继续向下执行。
从结果来看,我们看到 A 执行流 和 B 执行流的状态发生了互换, A 在保存任务上下文之后进入暂停状态,而 B 则恢复了上下文并在 CPU 上执行。
下面我们给出 ``__switch`` 的实现:
.. code-block:: riscv
:linenos:
# os/src/task/switch.S
.altmacro
.macro SAVE_SN n
sd s\n, (\n+1)*8(sp)
.endm
.macro LOAD_SN n
ld s\n, (\n+1)*8(sp)
.endm
.section .text
.globl __switch
__switch:
# __switch(
# current_task_cx_ptr2: &*const TaskContext,
# next_task_cx_ptr2: &*const TaskContext
# )
# push TaskContext to current sp and save its address to where a0 points to
addi sp, sp, -13*8
sd sp, 0(a0)
# fill TaskContext with ra & s0-s11
sd ra, 0(sp)
.set n, 0
.rept 12
SAVE_SN %n
.set n, n + 1
.endr
# ready for loading TaskContext a1 points to
ld sp, 0(a1)
# load registers in the TaskContext
ld ra, 0(sp)
.set n, 0
.rept 12
LOAD_SN %n
.set n, n + 1
.endr
# pop TaskContext
addi sp, sp, 13*8
ret
我们手写汇编代码来实现 ``__switch`` 。可以看到它的函数原型中的两个参数分别是当前 Trap 执行流和即将被切换到的 Trap 执行流的 ``task_cx_ptr2`` ,从 :ref:`RISC-V 调用规范 <term-calling-convention>` 可以知道它们分别通过寄存器 ``a0/a1`` 传入。
阶段 [2] 体现在第 18~26 行。第 18 行在 A 的内核栈上预留任务上下文的空间,然后将当前的栈顶位置保存下来。接下来就是逐个对寄存器进行保存,从中我们也能够看出 ``TaskContext`` 里面究竟包含哪些寄存器:
.. code-block:: rust
:linenos:
// os/src/task/context.rs
#[repr(C)]
pub struct TaskContext {
ra: usize,
s: [usize; 12],
}
这里面只保存了 ``ra`` 和被调用者保存的 ``s0~s11````ra`` 的保存很重要,它记录了 ``__switch`` 返回之后应该到哪里继续执行,从而在切换回来并 ``ret`` 之后能到正确的位置。而保存调用者保存的寄存器是因为,调用者保存的寄存器可以由编译器帮我们自动保存。我们会将这段汇编代码中的全局符号 ``__switch`` 解释为一个 Rust 函数:
.. code-block:: rust
:linenos:
// os/src/task/switch.rs
global_asm!(include_str!("switch.S"));
extern "C" {
pub fn __switch(
current_task_cx_ptr2: *const usize,
next_task_cx_ptr2: *const usize
);
}
我们会调用该函数来完成切换功能而不是直接跳转到符号 ``__switch`` 的地址。因此在调用前后 Rust 编译器会自动帮助我们插入保存/恢复调用者保存寄存器的汇编代码。
仔细观察的话可以发现 ``TaskContext`` 很像一个普通函数栈帧中的内容。正如之前所说, ``__switch`` 的实现除了换栈之外几乎就是一个普通函数,也能在这里得到体现。尽管如此,二者的内涵却有着很大的不同。
剩下的汇编代码就比较简单了。读者可以自行对照注释看看图示中的后面几个阶段各是如何实现的。另外,后面会出现传给 ``__switch`` 的两个参数相同,也就是某个 Trap 执行流自己切换到自己的情形,请读者对照图示思考目前的实现能否对它进行正确处理。
..
chyyuu有一个内核态切换的例子。