8. 内核线程

本章代码对应 commit :03f96ee09cac045f3ae9c84265052f3c819c4053

概要

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

  1. 实现内核线程相关结构体。

  2. 为新线程构造这个结构体。

  3. 实现线程切换。

线程相关结构体

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

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

// in process/structs.rs

use crate::context::Context;

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

在切换线程时,我们会将上下文直接保存在栈上,因此 Context 只需要保存最终的栈指针 sp,也就是上下文的起始地址 content_addr 即可,详细的上下文内容使用新的结构体 ContextContent 描述。还记得 Trap 中我们创建的栈帧结构体吗?与当时的处理方式类似,我们需要一个规定好格式的结构体来保存寄存器的内容:

// in context.rs

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

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

由于接下来需要在汇编中读写这个结构体的内容,因此我们需要在它的 内存布局 上和 rust 编译器达成一致。这里我们使用 #[repr(C)] 标记,表示让编译器对结构体使用 C 语言标准 的内存布局,否则 rust 默认的内存布局是未定义的,可能在变量顺序上有所调整。

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

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

// in struct.rs

pub struct KernelStack(usize);
const STACK_SIZE: usize = 0x8000;

其实内核栈就是一片固定大小的内存空间,因此我们只需要在 KernelStack 中记录栈的起始地址。

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

// in lib.rs
#![feature(alloc)]

// in 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 中,所以无需手动引入他。

为新线程构造结构体

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

// in process/struct.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 构造函数

// in context.rs
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)
    }
}

第 0 个内核线程的 content_addr 赋值为 0 的原因稍后说明

ContextContent 构造函数

// in context.rs
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, s1 暂时没用,所以用他来暂存参数 arg ,稍后通过汇编代码将 a0 赋值为 s0

线程切换

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

  1. 保存当前寄存器状态。

  2. 加载另一线程的寄存器状态。

// in process/structs.rs

impl Thread {
   pub fn switch_to(&mut self, target: &mut Thread) {
       unsafe {
           self.context.switch(&mut target.context);
       }
   }
}

// in lib.rs
#![feature(naked_functions)]

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

至此,我们只剩下最后一个任务:编写 process/switch.asm

//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
    csrw sstatus, s1
    addi sp, sp, (XLENB*14)

    Store zero, 0(a1)
    ret

Store sp, 0(a0)content_addr 赋值给 sp ,然后保存 callee saved 寄存器。保存完毕后通过 Load sp, 0(a1) 将目标线程的 content_addr 赋值给 sp ,然后恢复目标进程的寄存器。 mv a0, s0 用于传入新线程的参数, csrw sstatus, s1 设置新进程的 sstatus 寄存器。

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

最后在 process/mod.rs 中创建第 0 个和第 1 个内核线程,然后进行切换:

// in 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{
    }
}

// in init.rs

use crate::process::init as process_init;
#[no_mangle]
pub extern "C" fn rust_main(hartid: usize, dtb: usize) -> ! {
    ...
    process_init();
    loop {}
}

执行 make run ,屏幕打印出:

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

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

Last updated