终于,我们要开始研究进程和线程了,这会使我们的操作系统看起来更像一个操作系统。但是目前我们先不研究进程,而是先实现调度的基本单位——线程,而且是内核态的线程。
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 |
这里向大家解释一下 Caller 和 Callee 寄存器的区别: |
- 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
这部分代码和中断上下文保存与恢复很像,主要需要做的就两件事:
- 将 Callee-saved 寄存器保存在当前栈上,并更新传入的“当前栈顶地址”(更新栈顶)
- 从要恢复的线程栈中恢复目标线程的 Callee-saved 寄存器
5.2 构造线程结构
1、我们要构造一个静止的线程,使得当其他正在运行的线程切换到它时,就可以将寄存器和栈变成我们想要的状态,并且跳转到我们希望的地方开始运行。主要有这三步:设置栈顶地址,传入可能的参数,跳转到线程入口。
- 首先我们需要分配一个空间作为内核栈。
// kernel/consts.h
#define KERNEL_STACK_SIZE 0x80000 /* 内核栈大小 128M */
// kernel/thread.c
/*
* 构建内核线程的内核栈
* 输出栈空间的起始地址
*/
usize
newKernelStack()
{
/* 将内核线程的线程栈分配在内核堆中 */
usize bottom = (usize)kalloc(KERNEL_STACK_SIZE); // 在内核堆上分配内存
return bottom;
}
- 构造新的内核线程上下文
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()
函数全过程(也就是第五章的全过程):
- 构建空结构表示当前启动线程
- 构建新的内核线程
- 创建内核线程的内核栈
- 创建内核线程的上下文(借助中断恢复机制来初始化新线程的每个寄存器)
- 由于借助
ThreadContext.InterruptContext
的恢复机制,故可以借助其传递参数,进行参数赋值 - 切换到新线程,新线程的返回地址为
__restore
,则恢复InterruptContext
上下文,实现传参 - 在新线程入口函数结尾主动切换回启动线程
通过 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 **
....
我们成功地进入了新线程,传入了参数,还成功地切换回来了!