实现内核线程

本章代码对应分支:thread

概要

内核可以看作一个服务线程,管理软硬件资源,响应用户线程的种种合理以及不合理的请求。为了防止可能的阻塞,支持多线程是必要的。内核线程就是内核的分身,一个分身可以处理一件特定事情。内核线程的调度由内核负责,一个内核线程处于阻塞状态时不影响其他的内核线程。本章我们的任务有:

  1. 实现内核线程相关结构体。
  2. 为新线程构造这个结构体。
  3. 实现线程切换。

线程相关结构体

创建 process/structs.rs ,并在 lib.rs 中加入 mod process

描述一个线程,需要知道其全部的寄存器信息以及栈的使用情况:

  • process/structs.rs
use crate::context::Context;

pub struct Thread {
    pub context: Context, // 线程相关的上下文
    pub kstack: KernelStack, // 线程对应的内核栈
}

在切换线程时,我们会将上下文直接保存在栈上,因此 Context 只需要保存最终的栈指针 sp,也就是上下文的起始地址 content_addr 即可,详细的上下文内容使用新的结构体 ContextContent 描述。与 Trap 章节创建的栈帧结构的方式类似,我们需要一个规定好格式的结构体来读取寄存器的内容(具体的保存过程仍然需要用过汇编代码保存):

  • context.rs
#[repr(C)]
pub struct Context {
    content_addr: usize // 上下文内容存储的位置
}

#[repr(C)]
struct ContextContent {
    ra: usize, // 返回地址
    satp: usize, // 二级页表所在位置
    s: [usize; 12], // 被调用者保存的寄存器
}

复读:其中 #[repr(C)] 表示对这个结构体按 C 语言标准 进行内存布局,也就是从起始地址开始,按字段的定义顺序依次排列。如果不写的话,Rust 对它的内存布局是不确定的,会导致我们无法使用汇编代码对它进行正确的读写。

在发生函数调用时, riscv32 约定了 调用者保存寄存器(caller saved)被调用者保存寄存器(callee saved) ,保存前者的代码由编译器悄悄的帮我们生成,保存后者的代码则需要我们自己编写,所以结构体中只包含部分寄存器。

接下来我们定义 内核栈(KernelStack) 的结构:

  • structs.rs
pub struct KernelStack(usize);
const STACK_SIZE: usize = 0x8000;

其实内核栈保存了该内核线程的各种数据以及上下文内容,本质上它就是一片固定大小的内存空间,因此我们只需要在 KernelStack 中记录栈的起始地址。

为了实现简单,栈空间就直接从内核堆中分配了。我们需要在它的构造函数(new)中分配内存,并在析构函数(Drop)中回收内存。具体而言,我们使用 rust 的 alloc API 实现内存分配和回收:

  • lib.rs
#![feature(alloc)]
  • process/struct.rs
extern crate alloc;
use alloc::alloc::{alloc, dealloc, Layout};
impl KernelStack {
    pub fn new() -> KernelStack {
        let bottom =
            unsafe {
                alloc(Layout::from_size_align(STACK_SIZE, STACK_SIZE).unwrap())
            } as usize;
        KernelStack(bottom)
    }

    pub fn top(&self) -> usize {
        self.0 + STACK_SIZE
    }
}

impl Drop for KernelStack {
    fn drop(&mut self) {
        unsafe {
            dealloc(
                self.0 as _,
                Layout::from_size_align(STACK_SIZE, STACK_SIZE).unwrap()
            );
        }
    }
}

Drop trait 包含在 prelode 中,所以无需手动引入他。

为新线程构造结构体

在操作系统中,有一个特殊的线程,其名字为为 idle 。其作用是初始化一些信息,并且在没有其他线程需要运行的时候运行他。为此我们需要对这个特殊的内核线程和内核其他的线程进行一些区分:

  • process/structs.rs
use riscv::register::satp;
impl Thread {
    pub fn new_idle() -> Thread {
        unsafe {
            Thread {
                context: Context::null(),
                kstack: KernelStack::new(),
            }
        }
    }

    pub fn new_kernel(entry: extern "C" fn(usize) -> !, arg: usize) -> Thread {
        unsafe {
            let kstack_ = KernelStack::new();
            Thread {
                context: Context::new_kernel_thread(entry, arg, kstack_.top(), satp::read().bits()),
                kstack: kstack_,
            }
        }
    }
}

内核线程的 kstack 除了存放线程运行需要使用的内容,还需要存放 ContextContent 。因此在创建 Thread 的时候,需要为其分配 kstack ,将 ContextContext 内容复制到 kstack 的 top 。而 Context 只保存 ContextContent 首地址 content_addr :

  • context.rs

Context 的构造函数:

impl Context {
    pub unsafe fn null() -> Context {
        Context { content_addr: 0 }
    }

    pub unsafe fn new_kernel_thread(
        entry: extern "C" fn(usize) -> !,
        arg: usize,
        kstack_top: usize,
        satp: usize ) -> Context {
        ContextContent::new_kernel_thread(entry, arg, kstack_top, satp).push_at(kstack_top)
    }
}

idle 线程的 content_addr 赋值为 0 的原因稍后说明

ContextContent 的构造函数:

use core::mem::zeroed;
use riscv::register::sstatus;
impl ContextContent {
    fn new_kernel_thread(entry: extern "C" fn(usize) -> !, arg: usize , kstack_top: usize, satp: usize) -> ContextContent {
        let mut content: ContextContent = unsafe { zeroed() };
        content.ra = entry as usize;
        content.satp = satp;
        content.s[0] = arg;
        let mut sstatus_ = sstatus::read();
        sstatus_.set_spp(sstatus::SPP::Supervisor); // 代表 sret 之后的特权级仍为 S
        content.s[1] = sstatus_.bits();
        content
    }

    unsafe fn push_at(self, stack_top: usize) -> Context {
        let ptr = (stack_top as *mut ContextContent).sub(1);
        *ptr = self; // 拷贝 ContextContent
        Context { content_addr: ptr as usize }
    }
}

新线程的 s0 暂时没用,所以用他来暂存参数 arg ,稍后通过汇编代码将 a0 赋值为 s0 。

sret 之后的权限模式和中断状态由 sstatus 控制,这一步 content.ra = entry as usize 指定跳转地址。

线程切换

创建好线程之后,则需要有办法能够在多个线程中相互切换。 线程切换 也叫 上下文切换 。切换的过程需要两步:

  1. 保存当前寄存器状态。
  2. 加载另一线程的寄存器状态。

  3. process/structs.rs

impl Thread {
   pub fn switch_to(&mut self, target: &mut Thread) {
       unsafe {
           self.context.switch(&mut target.context);
       }
   }
}
  • lib.rs
#![feature(naked_functions)]
  • context.rs
impl Context {
    #[naked]
    #[inline(never)]
    pub unsafe extern "C" fn switch(&mut self, target: &mut Context) {
        asm!(include_str!("process/switch.asm") :::: "volatile");
    }
}

由于我们要完全手写汇编实现 switch 函数,因此需要给编译器一些特殊标记: #[inline(never)] 表示禁止函数内联。这是由于我们需要 retra 寄存器控制切换线程。如果内联了就没有 ret ,也就无法实现线程切换了。

#[naked] 标签表示不希望编译器产生多余的汇编代码。这里最重要的是 extern "C" 修饰,这表示该函数使用 C 语言的 ABI ,所以规范中所有调用者保存的寄存器(caller-saved)都会保存在栈上。

至此,我们只剩下最后一个任务:编写 switch 的过程 :

  • process/switch.asm
.equ XLENB, 4
.macro Load reg, mem
    lw \reg, \mem
.endm
.macro Store reg, mem
    sw \reg, \mem
.endm

    addi  sp, sp, (-XLENB*14)
    Store sp, 0(a0)
    Store ra, 0*XLENB(sp)
    Store s0, 2*XLENB(sp)
    Store s1, 3*XLENB(sp)
    Store s2, 4*XLENB(sp)
    Store s3, 5*XLENB(sp)
    Store s4, 6*XLENB(sp)
    Store s5, 7*XLENB(sp)
    Store s6, 8*XLENB(sp)
    Store s7, 9*XLENB(sp)
    Store s8, 10*XLENB(sp)
    Store s9, 11*XLENB(sp)
    Store s10, 12*XLENB(sp)
    Store s11, 13*XLENB(sp)
    csrr  s11, satp
    Store s11, 1*XLENB(sp)

    Load sp, 0(a1)
    Load s11, 1*XLENB(sp)
    csrw satp, s11
    Load ra, 0*XLENB(sp)
    Load s0, 2*XLENB(sp)
    Load s1, 3*XLENB(sp)
    Load s2, 4*XLENB(sp)
    Load s3, 5*XLENB(sp)
    Load s4, 6*XLENB(sp)
    Load s5, 7*XLENB(sp)
    Load s6, 8*XLENB(sp)
    Load s7, 9*XLENB(sp)
    Load s8, 10*XLENB(sp)
    Load s9, 11*XLENB(sp)
    Load s10, 12*XLENB(sp)
    Load s11, 13*XLENB(sp)
    mv a0, s0
    addi sp, sp, (XLENB*14)

    Store zero, 0(a1)
    ret

Store sp, 0(a0) 将用于保存上下文地址的 content_addr 赋值给 sp ,然后在 sp 指向的位置保存 callee saved 寄存器。保存完毕后通过 Load sp, 0(a1) 将目标线程的 content_addr 赋值给 sp ,然后恢复目标线程的寄存器。 mv a0, s0 用于传入新线程的参数。

对于新创建的线程,因为没有 caller 为其保存 caller-saved ,所以 s0a0 赋的值可以被保留下来。而对于就旧线程, a0 将被恢复为 caller saved 中的值,不受 s0 的影响; sstatus 同理。

最后创建 idle 线程和 hello 线程,然后进行切换:

  • process/mod.rs
mod structs;
use structs::Thread;

pub fn init() {
    let mut loop_thread = Thread::new_idle();
    let mut hello_thread = Thread::new_kernel(hello_thread, 666);
    loop_thread.switch_to(&mut hello_thread);
}

#[no_mangle]
pub extern "C" fn hello_thread(arg: usize) -> ! {
    println!("hello thread");
    println!("arg is {}", arg);
    loop{
    }
}
  • init.rs
#[no_mangle]
pub extern "C" fn rust_main(hartid: usize, dtb: usize) -> ! {
    ...
    crate::process::init();
    loop {}
}

执行 make run ,屏幕打印出:

hello thread
arg is 666
100 ticks!
100 ticks!
...

表示我们已经成功创建并切换至 hello_thread

预告

现在我们已经可以正确的切换内核线程,但是线程切换过去之后就再也没有回到 idle 线程了。而这就是我们下一章的任务:线程调度。

results matching ""

    No results matching ""

    results matching ""

      No results matching ""