Github代码仓库链接
终于,我们要开始研究进程和线程了,这会使我们的操作系统看起来更像一个操作系统。但是目前我们先不研究进程,而是先实现调度的基本单位——线程,而且是内核态的线程。
1、进程和线程
// 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;
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
指示编译器不要将该函数内联,有些编译器会将函数优化为内联的,从而避免了跳转和返回。但是我们切换线程需要借助函数的调用-返回机制,因此需要声明此属性。寄存器 | 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 寄存器的区别: |
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、我们要构造一个静止的线程,使得当其他正在运行的线程切换到它时,就可以将寄存器和栈变成我们想要的状态,并且跳转到我们希望的地方开始运行。主要有这三步:设置栈顶地址,传入可能的参数,跳转到线程入口。
// 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);
}
switchContext()
执行完成返回时,会自动回收 ra、satp 和 s0 ~ s11,栈顶只剩下一个 InterruptContext,这种情况恰好和从中断处理函数返回时是类似的情况!ra 的值被设置为 __restore()
,就正是为了借用中断返回机制来初始化线程的一些寄存器,如传参等。从 __restore()
返回就会跳转到 InterruptContext 的 sepc
位置,这正是线程的入口点,同时栈顶指针 sp 也被正确地设置为 kernelStackTop
更具体地:当切换到这个新建的内核线程时,由于
ra
指向__restore
所以回到此处继续运行(借助中断恢复机制),运行完后会跳转到sepc
指定位置,此时我们指定为真正的新线程入口点entry
。同时,中断恢复机制从栈中弹出保存的数据,此时sp
所指位置正好是不包含中断上下文ic
的线程上下文tc
,即kernelStackTop
sret
指令返回后的特权级保持为 S-Mode。同时,设置 SPIE 位和置空 SIE 位则是为了使得 S-Mode 线程能够被异步中断打断,为了下一节调度做准备。// 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;
}
1、上一节我们完成了所有线程相关结构的构建,这一节我们来尝试切换到一个新的线程,再切换回来。
// 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 **
....
我们成功地进入了新线程,传入了参数,还成功地切换回来了!