【Create my OS】5 内核线程

December 07, 2024

Github代码仓库链接

终于,我们要开始研究进程和线程了,这会使我们的操作系统看起来更像一个操作系统。但是目前我们先不研究进程,而是先实现调度的基本单位——线程,而且是内核态的线程。

5.1 线程切换

1、进程和线程

  • 磁盘中存储的静态代码,经过编译、链接后形成的可执行文件,称为程序。进程就是被操作系统分配了资源并正在运行中的程序。进程这个词强调动态性,占用资源正在运行。操作系统将静态的程序加载到内存中,程序定义的各个段都被正确地映射出来,并且还占用一定的资源,如页表和文件描述符,同时,这个进程在适当的时候还可以占有 CPU,被 CPU 取指执行。
  • 线程是进程中正在运行的程序流,更关注其“正在运行”的特性。如果剥离线程,进程只需要负责维护操作系统所分配的资源。一个进程可以有多个线程,也可以只有一个线程。单个进程内有多个线程的情况下,线程会共享进程被分配到的资源。
  • 这样剥离开来,进程就成了操作系统分配资源的最小单位,管理页表、文件等资源。而线程则更专注于执行,是 CPU 调度的最小单位

2、线程的状态表示

  • CPU 中各个寄存器的值:关心程序运行过程中产生的中间结果
  • 线程栈:线程在进行函数调用的时候,需要将各种信息压栈,并借助寄存器和栈进行参数和返回值的传递。同时,函数在运行时的各种局部变量都是在栈上分配的。
  • 其他线程不会修改当前线程的栈(但是有这个能力),我们没有必要保存栈上内容。这样的话,我们只需要保存 CPU 中寄存器的值就好了。这就有点像中断发生时保存上下文的场景了,不过,由于采用函数调用的方式来切换线程,我们不用保存所有的寄存器。事实上,我们只需要保存以下内容:
    • ra 寄存器,用于保存返回地址
    • satp 寄存器,保存页表信息,本章的线程都可以说是同属于内核进程的,共用一个页表
    • s0 ~ s11,函数调用中被调用者保存的寄存器

3、线程的实现

  • 定义线程上下文
// kernel/context.h

// 线程运行上下文
typedef struct {
    usize ra;
    usize satp;
    usize s[12];
    InterruptContext ic;
} ThreadContext;

除去我们说的三部分内容以外,还包含了一个中断上下文,严格来说这不属于线程运行上下文的一部分,我们只是会借助中断返回机制来初始化线程,这部分仅会在线程初始化时被保存在栈上,后续并不会被保存。

  • 定义线程:
// kernel/thread.h

// 线程结构定义
typedef struct {
    // 线程上下文存储的地址
    usize contextAddr;
    // 线程栈底地址
    usize kstack;
} Thread;
  • 线程上下文只需要保存其地址,只有在切换线程时才需要关心线程上下文保存的位置
  • 保存栈底地址主要是为了在线程结束后能够回收这片空间,栈空间通过动态内存分配,后面会提到

4、线程切换

  • 通过 switchThread() 这个函数来完成线程的切换,切换线程主要就是切换上下文,并且跳转到目标线程上次结束的位置。
// kernel/thread.c

/*
 * 该函数用于切换上下文,保存当前函数的上下文,并恢复目标函数的上下文
 * 输入:线程上下文存储的地址(由于目标线程切换前最后是保存在栈上的,所以该地址即栈顶地址)
 * 输出:函数返回时即返回到了新线程的运行位置
 * naked 防止 gcc 在函数执行前后自动插入开场白和结束语,函数调用保存寄存器和栈指针这部分我们自行设计
 * noinline 防止 gcc 将函数内联,有些编译器为了避免跳转、返回,会将函数优化为内联,上下文切换借助了函数调用返回,不应内联
 */
__attribute__((naked, noinline)) void
switchContext(usize *self, usize *target)
{
    asm volatile(".include \"kernel/switch.asm\"");     // 调用汇编指令切换线程
}

// 线程切换
void
switchThread(Thread *self, Thread *target)
{
    switchContext(&self->contextAddr, &target->contextAddr);
}
  • 这里使用了两个属性,naked 表示不要在这个函数执行前后加入任何的开场白(prologue)和结语(epilogue),通常的编译器会根据函数调用约定,在函数开头自动加入保存寄存器、设置栈寄存器等内容,这部分我们自行来设置。
  • noinline 指示编译器不要将该函数内联,有些编译器会将函数优化为内联的,从而避免了跳转和返回。但是我们切换线程需要借助函数的调用-返回机制,因此需要声明此属性。
  • 这个函数传入了两个参数,分别是指向当前线程上下文保存地址的指针,和目标线程上下文地址的指针,由于目标线程被切换前是最后被保存在栈上的,所以该地址同时还是栈顶地址。
  • 根据函数调用约定,传入的两个参数分别被保存在 a0 和 a1 寄存器,即它们分别保存了“当前线程栈顶地址”所在的地址,以及“目标线程栈顶地址”所在的地址。

RISC-V 函数调用约定中的通用寄存器职能

寄存器 ABI 名字 描述 Saver
x0 zero 硬件连线0 -
x1 ra 返回地址 Caller
x2 sp 栈指针 Callee
x3 gp 全局指针 -
x4 tp 线程指针 -
x5-x7 t0-t2 临时寄存器 Caller
x8 s0/fp 保存的寄存器/帧指针 Callee
x9 s1 保存寄存器
保存原进程中的关键数据,
避免在函数调用过程中被破坏
Callee
x10-x11 a0-a1 函数参数/返回值 Caller
x12-x17 a2-a7 函数参数 Caller
x18-x27 s2-s11 保存寄存器 Callee
x28-x31 t3-t6 临时寄存器 Caller
这里向大家解释一下 CallerCallee 寄存器的区别:
  • Caller Saved寄存器在函数调用的时候不会保存。这里的意思是,一个Caller Saved寄存器可能被其他函数重写。假设我们在函数a中调用函数b,任何被函数a使用的并且是Caller Saved寄存器,调用函数b可能重写这些寄存器。我认为一个比较好的例子就是Return address寄存器(注,保存的是函数返回的地址),你可以看到ra寄存器是Caller Saved,这一点很重要,它导致了当函数a调用函数b的时侯,b会重写Return address。所以基本上来说,任何一个Caller Saved寄存器,作为调用方的函数要小心可能的数据可能的变化。因为Caller已经被保存到栈上了,所以调用时候数据不会保存,是可变的。
  • Callee Saved寄存器在函数调用的时候会保存,意思是在一些特定的场景下,你会想要确保一些数据在函数调用之后仍然能够保存,这个时候编译器可以选择使用 Callee 寄存器,所以我们在切换到其他线程的时候同样要保存Callee这部分寄存器。

5、线程切换 switch.S 的实现:

.equ XLENB, 8   # 寄存器字节数为8

    addi  sp, sp, (-XLENB*14)   # 分配栈空间
    sd sp, 0(a0)                # a0为传入的“当前线程上下文存储地址“,栈指针移动后更新上下文地址
    sd ra, 0*XLENB(sp)          # 将寄存器 ra 保存到栈上
    sd s0, 2*XLENB(sp)          # s0-s11
    sd s1, 3*XLENB(sp)
    sd s2, 4*XLENB(sp)
    sd s3, 5*XLENB(sp)
    sd s4, 6*XLENB(sp)
    sd s5, 7*XLENB(sp)
    sd s6, 8*XLENB(sp)
    sd s7, 9*XLENB(sp)
    sd s8, 10*XLENB(sp)
    sd s9, 11*XLENB(sp)
    sd s10, 12*XLENB(sp)
    sd s11, 13*XLENB(sp)
    csrr  s11, satp
    sd s11, 1*XLENB(sp)         # satp

    ld sp, 0(a1)                # 将“目标线程上下文存储地址”传入sp
    ld s11, 1*XLENB(sp)         
    csrw satp, s11              # 恢复 satp
    sfence.vma                  # 刷新TLB,使新配置页表生效
    ld ra, 0*XLENB(sp)          # 恢复 ra
    ld s0, 2*XLENB(sp)          # 恢复s0-s11
    ld s1, 3*XLENB(sp)
    ld s2, 4*XLENB(sp)
    ld s3, 5*XLENB(sp)
    ld s4, 6*XLENB(sp)
    ld s5, 7*XLENB(sp)
    ld s6, 8*XLENB(sp)
    ld s7, 9*XLENB(sp)
    ld s8, 10*XLENB(sp)
    ld s9, 11*XLENB(sp)
    ld s10, 12*XLENB(sp)
    ld s11, 13*XLENB(sp)
    addi sp, sp, (XLENB*14)     # 释放目标线程存储寄存器的栈空间

    sd zero, 0(a1)              # 清除a1寄存器
    ret

这部分代码和中断上下文保存与恢复很像,主要需要做的就两件事:

  1. 将 Callee-saved 寄存器保存在当前栈上,并更新传入的“当前栈顶地址”(更新栈顶)
  2. 从要恢复的线程栈中恢复目标线程的 Callee-saved 寄存器

5.2 构造线程结构

1、我们要构造一个静止的线程,使得当其他正在运行的线程切换到它时,就可以将寄存器和栈变成我们想要的状态,并且跳转到我们希望的地方开始运行。主要有这三步:设置栈顶地址传入可能的参数跳转到线程入口

  1. 首先我们需要分配一个空间作为内核栈。
// kernel/consts.h

#define KERNEL_STACK_SIZE   0x80000             /* 内核栈大小 128M */

// kernel/thread.c
/* 
 * 构建内核线程的内核栈
 * 输出栈空间的起始地址
*/
usize
newKernelStack()
{
    /* 将内核线程的线程栈分配在内核堆中 */
    usize bottom = (usize)kalloc(KERNEL_STACK_SIZE);    // 在内核堆上分配内存
    return bottom;
}
  1. 构造新的内核线程上下文ThreadContext,并把他压到栈上。
// kernel/thread.c

/*
 * 将线程上下文依次压入栈顶
 * 并返回新的栈顶地址,即线程上下文地址
*/
usize
pushContextToStack(ThreadContext self, usize stackTop)
{
    // 分配栈空间 -> 转换指针类型
    ThreadContext *ptr = (ThreadContext *)(stackTop - sizeof(ThreadContext));
    *ptr = self;
    return (usize)ptr;
}

/*
 * 创建新的内核线程上下文,并将线程上下文入栈
 * 借助中断恢复机制进行线程的初始化工作,即从中断恢复结束时即跳转到sepc,就是线程的入口点
 * 输入:线程入口点;内核线程线程栈顶;内核线程页表
 * 输出:线程上下文地址
*/
usize
newKernelThreadContext(usize entry, usize kernelStackTop, usize satp)
{
    InterruptContext ic;
    ic.x[2] = kernelStackTop;       // 设置sp寄存器为内核栈顶
    ic.sepc = entry;                // 中断返回地址为线程入口点
    ic.sstatus = r_sstatus();
    /* 内核线程,返回后特权级为 S-Mode */
    ic.sstatus |= SSTATUS_SPP;
    /* 开启新线程异步中断使能 */
    ic.sstatus |= SSTATUS_SPIE;     // 中断处理发生前的SIE值
    ic.sstatus &= ~SSTATUS_SIE;     // 禁用SIE,不想立即开启中断
    // 创建新线程上下文
    ThreadContext tc;
    // 借助中断的恢复机制,来初始化新线程的每个寄存器,从 Context 中恢复所有寄存器
    extern void __restore(); tc.ra = (usize)__restore;
    tc.satp = satp;                 // 设置页表
    tc.ic = ic;
    return pushContextToStack(tc, kernelStackTop);
}
  • 由于我们已经将完整的 ThreadContext 压栈,待 switchContext() 执行完成返回时,会自动回收 ra、satp 和 s0 ~ s11,栈顶只剩下一个 InterruptContext,这种情况恰好和从中断处理函数返回时是类似的情况!ra 的值被设置为 __restore(),就正是为了借用中断返回机制来初始化线程的一些寄存器,如传参等。从 __restore() 返回就会跳转到 InterruptContext 的 sepc 位置,这正是线程的入口点,同时栈顶指针 sp 也被正确地设置为 kernelStackTop

更具体地:当切换到这个新建的内核线程时,由于ra指向__restore所以回到此处继续运行(借助中断恢复机制),运行完后会跳转到sepc指定位置,此时我们指定为真正的新线程入口点entry。同时,中断恢复机制从栈中弹出保存的数据,此时sp所指位置正好是不包含中断上下文ic的线程上下文tc,即kernelStackTop

  • 同时我们还设置了 SSTATUS 寄存器的 SPP 位,这样在使用 sret 指令返回后的特权级保持为 S-Mode。同时,设置 SPIE 位和置空 SIE 位则是为了使得 S-Mode 线程能够被异步中断打断,为了下一节调度做准备。

3、创建线程

  • 我们还可以为线程传入初始化参数,根据调用约定,函数参数保存在 x10 ~ x17 中,我们利用中断恢复过程来填充这些寄存器,于是将参数保存在 InterruptContext 即可
// kernel/thread.c

// 为线程传入初始化参数
// 我们利用中断恢复过程来填充寄存器,所以将参数保存到ic
void
appendArguments(Thread *thread, usize args[8])
{
    ThreadContext *ptr = (ThreadContext *)thread->contextAddr;
    ptr->ic.x[10] = args[0];
    ptr->ic.x[11] = args[1];
    ptr->ic.x[12] = args[2];
    ptr->ic.x[13] = args[3];
    ptr->ic.x[14] = args[4];
    ptr->ic.x[15] = args[5];
    ptr->ic.x[16] = args[6];
    ptr->ic.x[17] = args[7];
}
  • 最后,可以创建线程了:
// kernel/thread.c

/*
 * 创建新的内核线程
 * 创建内核栈,创建上下文
*/
Thread
newKernelThread(usize entry)
{
    // 构建内核线程的内核栈
    usize stackBottom = newKernelStack();
    // 创建新的内核线程上下文
    usize contextAddr = newKernelThreadContext(
        entry,                              // 线程入口点
        stackBottom + KERNEL_STACK_SIZE,    // 内核栈顶
        r_satp()                            // 创建的内核线程与启动线程同属于一个进程,所以直接获取satp赋值
    );
    Thread t = {    // 线程上下文地址, 线程栈底地址
        contextAddr, stackBottom
    };
    return t;
}
  • 我们创建的内核线程与启动线程同属于一个“内核进程”,共用一个页表,所以可以直接通过 r_satp() 来设置新线程的 satp。
// kernle/riscv.h

static inline uint64
r_satp()
{
    uint64 x;
    asm volatile("csrr %0, satp" : "=r" (x) );
    return x;
}

5.3 从启动线程到新线程切换

1、上一节我们完成了所有线程相关结构的构建,这一节我们来尝试切换到一个新的线程,再切换回来。

  • 我们首先定义一个函数,当作新线程的入口点。新线程需要传入三个参数,分别是 from、current 和 c,c 是传入的一个字符参数,current 代表了这个新线程,from 为切换之前的线程,我们在这个函数最后手动切换回去。
  • 现在这个 from 显然就是指启动线程,但是我们身处启动线程内,如何构造一个 Thread 代表自身呢?答案是,构造一个空结构即可。
// kernel/thread.c

// 测试函数,作为新线程入口点
void
tempThreadFunc(Thread *from, Thread *current, usize c)
{
    printf("The char passed by is ");
    consolePutchar(c);              // 向终端输出字符
    consolePutchar('\n');
    printf("Hello world from tempThread!\n");
    switchThread(current, from);    // 手动线程切换回去
}

// 构造空结构Thread表示当前启动线程
// 在调用 switchThread() 函数时,会将当前线程的上下文信息保存到这个线程结构中
Thread
newBootThread()
{
    Thread t = {
        0L, 0L
    };
    return t;
}
  • 在调用 switchThread() 函数时,传入一个空的 Thread 代表当前线程,switchContext() 会将当前线程的上下文等信息自动填入空结构中,就完成了构建,并且还自动存储在了栈上。
  • 我们来测试一下线程切换:构建一个新线程,切换到新线程,再切换回启动线程。
// kernel/thread.c

// 测试线程切换
void
initThread()
{
    // 构建新的启动线程
    Thread bootThread = newBootThread();    
    // 构建新内核线程,入口点为测试函数
    Thread tempThread = newKernelThread((usize)tempThreadFunc); 
    usize args[8];
    args[0] = (usize)&bootThread;
    args[1] = (usize)&tempThread;
    args[2] = (long)'M';
    // 新内核线程参数初始化,将参数传入到tc.ic,switchContext恢复完后会借助中断初始化恢复寄存器,实现传参
    appendArguments(&tempThread, args);     
    switchThread(&bootThread, &tempThread); // 线程切换,即线程栈和上下文切换
    printf("I'm back from tempThread!\n");
}
  • 让我们来理一下initThread() 函数全过程(也就是第五章的全过程):
  1. 构建空结构表示当前启动线程
  2. 构建新的内核线程
    1. 创建内核线程的内核栈
    2. 创建内核线程的上下文(借助中断恢复机制来初始化新线程的每个寄存器)
  3. 由于借助ThreadContext.InterruptContext的恢复机制,故可以借助其传递参数,进行参数赋值
  4. 切换到新线程,新线程的返回地址为__restore,则恢复InterruptContext上下文,实现传参
  5. 在新线程入口函数结尾主动切换回启动线程

通过 switchThread() 进入新线程后,再切换回启动线程,就会继续执行下一行的 printf() 函数。在 main() 的最后调用这个函数并运行。

==== Init Interrupt ====
***** Init Memory *****
***** Remap Kernel *****
Safe and sound!
The char passed by is M
Hello world from tempThread!
I'm back from tempThread!
** TICKS = 100 **
** TICKS = 200 **
....

我们成功地进入了新线程,传入了参数,还成功地切换回来了!


Profile picture

Written by JokerDebug who works at Southeast University, Nanjing, China You can follow me on Github