线程的创建

优质
小牛编辑
134浏览
2023-12-01

线程的创建

接下来,我们的第一个目标就是创建一个线程并且让他运行起来。一个线程要开始运行,需要这些准备工作:

  • 建立页表映射,需要包括以下映射空间:
    • 线程所执行的一段指令
    • 线程执行栈
    • 操作系统的部分内存空间
  • 设置起始执行的地址
  • 初始化各种寄存器,比如 sp
  • 可选:设置一些执行参数(例如 argcargv等 )

思考:为什么线程即便与操作系统无关,也需要在内存中映射操作系统的内存空间呢?

Click to show

当发生中断时,需要跳转到 stvec 所指向的中断处理过程。如果操作系统的内存不在页表之中,将无法处理中断。

当然,也不是所有操作系统的代码都需要被映射,但是为了实现简便,我们会为每个进程的页表映射全部操作系统的内存。而由于这些页表都标记为内核权限(即 U 位为 0),也不必担心用户线程可以随意访问。

执行第一个线程

因为启动线程需要修改各种寄存器的值,所以我们又要使用汇编了。不过,这一次我们只需要对 interrupt.asm 稍作修改就可以了。

interrupt.asm 中的 __restore 标签现在就能派上用途了。原本这段汇编代码的作用是将之前所保存的 Context 恢复到寄存器中,而现在我们让它使用一个精心设计的 Context,就可以让程序在恢复后直接进入我们的新线程。

首先我们稍作修改,添加一行 mv sp, a0。原本这里是读取之前存好的 Context,现在我们让其从 a0 中读取我们设计好的 Context。这样,我们可以直接在 Rust 代码中调用 __restore(context)

os/src/interrupt/interrupt.asm

__restore:
    mv      sp, a0  # 加入这一行
    # ...

那么我们需要如何设计 Context 呢?

  • 通用寄存器
    • sp:应当指向该线程的栈顶
    • a0-a7:按照函数调用规则,用来传递参数
    • ra:线程执行完应该跳转到哪里呢?在后续系统调用章节我们会介绍正确的处理方式。现在,我们先将其设为一个不可执行的地址,这样线程一结束就会触发页面异常
  • sepc
    • 执行 sret 指令后会跳转到这里,所以 sepc 应当存储线程的入口地址(执行的函数地址)
  • sstatus
    • spp 位按照用户态或内核态有所不同
    • spie 位为 1

[info] sstatus 标志位的具体意义

  • spp:中断前系统处于内核态(1)还是用户态(0)
  • sie:内核态是否允许中断。对用户态而言,无论 sie 取何值都开启中断
  • spie:中断前是否开中断(用户态中断时可能 sie 为 0)

硬件处理流程

  • 在中断发生时,系统要切换到内核态。此时,切换前的状态会被保存在 spp 位中(1 表示切换前处于内核态)。同时,切换前是否开中断会被保存在 spie 位中,而 sie 位会被置 0,表示关闭中断。
  • 在中断结束,执行 sret 指令时,会根据 spp 位的值决定 sret 执行后是处于内核态还是用户态。与此同时,spie 位的值会被写入 sie 位,而 spie 位置 1。这样,特权状态和中断状态就全部恢复了。

为何如此繁琐?

  • 特权状态:
    中断处理流程必须切换到内核态,所以中断时需要用 spp 来保存之前的状态。
    回忆计算机组成原理的知识,sret 指令必须同时完成跳转并切换状态的工作。
  • 中断状态:
    中断刚发生时,必须关闭中断,以保证现场保存的过程不会被干扰。同理,现场恢复的过程也必须关中断。因此,需要有以上两个硬件自动执行的操作。
    由于中断可能嵌套,在保存现场后,根据中断的种类,可能会再开启部分中断的使能。

设计好 Context 之后,我们只需要将它应用到所有的寄存器上(即执行 __restore),就可以切换到第一个线程了。

os/src/main.rs: rust_main()

extern "C" {
    fn __restore(context: usize);
}
// 获取第一个线程的 Context,具体原理后面讲解
let context = PROCESSOR.lock().prepare_next_thread();
// 启动第一个线程
unsafe { __restore(context as usize) };
unreachable!()

为什么 unreachable

我们直接调用的 __restore 并没有 ret 指令,甚至 ra 都会被 Context 中的数值直接覆盖。这意味着,一旦我们执行了 __restore(context),程序就无法返回到调用它的位置了。注:直接 jump 是一个非常危险的操作

但是没有关系,我们也不需要这个函数返回。因为开始执行第一个线程,意味着操作系统的初始化已经完成,再回到 rust_main() 也没有意义了。甚至原本我们使用的栈 bootstack,也可以被回收(不过我们现在就丢掉不管吧)。

在启动时不打开中断

现在,我们会在线程开始运行时开启中断,而在操作系统初始化的过程中是不应该有中断的。所以,我们删去之前设置「开启中断」的代码。

os/interrupt/timer.rs

/// 初始化时钟中断
///
/// 开启时钟中断使能,并且预约第一次时钟中断
pub fn init() {
    unsafe {
        // 开启 STIE,允许时钟中断
        sie::set_stimer();
        // (删除)开启 SIE(不是 sie 寄存器),允许内核态被中断打断
        // sstatus::set_sie();
    }
    // 设置下一次时钟中断
    set_next_timeout();
}

小结

为了执行一个线程,我们需要初始化所有寄存器的值。为此,我们选择构建一个 Context 然后跳转至 interrupt.asm 中的 __restore 来执行,用这个 Context 来写入所有寄存器。

思考

__restore 现在会将 a0 寄存器视为一个 *mut Context 来读取,因此我们在执行第一个线程时只需调用 __restore(context)

那么,如果是程序发生了中断,执行到 __restore 的时候,a0 的值又是谁赋予的呢?