一、实现多线程调度
在上一部分我们介绍了如何创建线程,以及内核中所用到的数据结构双向链表,这部分我们使用双向链表结构来实现多线程切换。
在thread.h文件中添加代码
cpp
struct task_struct {
uint32_t* self_kstack; // 用于存储线程的栈顶位置,栈顶放着线程要用到的运行信息
enum task_status status;
uint8_t priority; // 线程优先级
char name[16]; //用于存储自己的线程的名字
uint8_t ticks; //线程允许上处理器运行还剩下的滴答值,因为priority不能改变,所以要在其之外另行定义一个值来倒计时
uint32_t elapsed_ticks; //此任务自上cpu运行后至今占用了多少cpu嘀嗒数, 也就是此任务执行了多久*/
struct list_elem general_tag; //general_tag的作用是用于线程在一般的队列(如就绪队列或者等待队列)中的结点
struct list_elem all_list_tag; //all_list_tag的作用是用于线程队列thread_all_list(这个队列用于管理所有线程)中的结点
uint32_t* pgdir; // 进程自己页表的虚拟地址
uint32_t stack_magic; //如果线程的栈无限生长,总会覆盖地pcb的信息,那么需要定义个边界数来检测是否栈已经到了PCB的边界
};
在该文件中主要就是task_thread结构体中添加一些属性,ticks表示该线程执行的时间,elapsed_ticks表示该线程在处理器总执行时间,general_tag是在就绪队列中所存放的结点,all_list_tag是在全部线程队列中所存放的结点。
在thread.c文件中添加代码
cpp
#include "thread.h"
#include "stdint.h"
#include "string.h"
#include "global.h"
#include "memory.h"
#include "debug.h"
#include "interrupt.h"
#include "print.h"
#define PG_SIZE 4096
struct task_struct* main_thread; // 主线程PCB
struct list thread_ready_list; // 就绪队列
struct list thread_all_list; // 所有任务队列
static struct list_elem* thread_tag;// 用于保存队列中的线程结点
extern void switch_to(struct task_struct* cur, struct task_struct* next);
/* 获取当前线程pcb指针 */
struct task_struct* running_thread() {
uint32_t esp;
asm ("mov %%esp, %0" : "=g" (esp));
/* 取esp整数部分即pcb起始地址 */
return (struct task_struct*)(esp & 0xfffff000);
}
/* 由kernel_thread去执行function(func_arg) , 这个函数就是线程中去开启我们要运行的函数*/
static void kernel_thread(thread_func* function, void* func_arg) {
/* 执行function前要开中断,避免后面的时钟中断被屏蔽,而无法调度其它线程 */
intr_enable();
function(func_arg);
}
/*用于根据传入的线程的pcb地址、要运行的函数地址、函数的参数地址来初始化线程栈中的运行信息,核心就是填入要运行的函数地址与参数 */
void thread_create(struct task_struct* pthread, thread_func function, void* func_arg) {
/* 先预留中断使用栈的空间,可见thread.h中定义的结构 */
//pthread->self_kstack -= sizeof(struct intr_stack); //-=结果是sizeof(struct intr_stack)的4倍
//self_kstack类型为uint32_t*,也就是一个明确指向uint32_t类型值的地址,那么加减操作,都是会是sizeof(uint32_t) = 4 的倍数
pthread->self_kstack = (uint32_t*)((int)(pthread->self_kstack) - sizeof(struct intr_stack));
/* 再留出线程栈空间,可见thread.h中定义 */
//pthread->self_kstack -= sizeof(struct thread_stack);
pthread->self_kstack = (uint32_t*)((int)(pthread->self_kstack) - sizeof(struct thread_stack));
struct thread_stack* kthread_stack = (struct thread_stack*)pthread->self_kstack; //我们已经留出了线程栈的空间,现在将栈顶变成一个线程栈结构体
//指针,方便我们提前布置数据达到我们想要的目的
kthread_stack->eip = kernel_thread; //我们将线程的栈顶指向这里,并ret,就能直接跳入线程启动器开始执行。
//为什么这里我不能直接填传入进来的func,这也是函数地址啊,为什么还非要经过一个启动器呢?其实是可以不经过线程启动器的
//因为用不着,所以不用初始化这个返回地址kthread_stack->unused_retaddr
kthread_stack->function = function; //将线程启动器(thread_start)需要运行的函数地址放入线程栈中
kthread_stack->func_arg = func_arg; //将线程启动器(thread_start)需要运行的函数所需要的参数地址放入线程栈中
kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;
}
/* 初始化线程基本信息 , pcb中存储的是线程的管理信息,此函数用于根据传入的pcb的地址,线程的名字等来初始化线程的管理信息*/
void init_thread(struct task_struct* pthread, char* name, int prio) {
memset(pthread, 0, sizeof(*pthread)); //把pcb初始化为0
strcpy(pthread->name, name); //将传入的线程的名字填入线程的pcb中
if(pthread == main_thread){
pthread->status = TASK_RUNNING; //由于把main函数也封装成一个线程,并且它一直是运行的,故将其直接设为TASK_RUNNING */
}
else{
pthread->status = TASK_READY;
}
pthread->priority = prio;
/* self_kstack是线程自己在内核态下使用的栈顶地址 */
pthread->ticks = prio;
pthread->elapsed_ticks = 0;
pthread->pgdir = NULL; //线程没有自己的地址空间,进程的pcb这一项才有用,指向自己的页表虚拟地址
pthread->self_kstack = (uint32_t*)((uint32_t)pthread + PG_SIZE); //本操作系统比较简单,线程不会太大,就将线程栈顶定义为pcb地址
//+4096的地方,这样就留了一页给线程的信息(包含管理信息与运行信息)空间
pthread->stack_magic = 0x19870916; // /定义的边界数字,随便选的数字来判断线程的栈是否已经生长到覆盖pcb信息了
}
/* 创建一优先级为prio的线程,线程名为name,线程所执行的函数是function(func_arg) */
struct task_struct* thread_start(char* name, int prio, thread_func function, void* func_arg) {
/* pcb都位于内核空间,包括用户进程的pcb也是在内核空间 */
struct task_struct* thread = get_kernel_pages(1); //为线程的pcb申请4K空间的起始地址
init_thread(thread, name, prio); //初始化线程的pcb
thread_create(thread, function, func_arg); //初始化线程的线程栈
/* 确保之前不在队列中 */
ASSERT(!elem_find(&thread_ready_list, &thread->general_tag));
/* 加入就绪线程队列 */
list_append(&thread_ready_list, &thread->general_tag);
/* 确保之前不在队列中 */
ASSERT(!elem_find(&thread_all_list, &thread->all_list_tag));
/* 加入全部线程队列 */
list_append(&thread_all_list, &thread->all_list_tag);
return thread;
}
/* 将kernel中的main函数完善为主线程 */
static void make_main_thread(void) {
/* 因为main线程早已运行,咱们在loader.S中进入内核时的mov esp,0xc009f000,
就是为其预留了tcb,地址为0xc009e000,因此不需要通过get_kernel_page另分配一页*/
main_thread = running_thread();
init_thread(main_thread, "main", 31);
/* main函数是当前线程,当前线程不在thread_ready_list中,
* 所以只将其加在thread_all_list中. */
ASSERT(!elem_find(&thread_all_list, &main_thread->all_list_tag));
list_append(&thread_all_list, &main_thread->all_list_tag);
}
/* 实现任务调度 */
void schedule() {
ASSERT(intr_get_status() == INTR_OFF);
struct task_struct* cur = running_thread();
if (cur->status == TASK_RUNNING) { // 若此线程只是cpu时间片到了,将其加入到就绪队列尾
ASSERT(!elem_find(&thread_ready_list, &cur->general_tag));
list_append(&thread_ready_list, &cur->general_tag);
cur->ticks = cur->priority; // 重新将当前线程的ticks再重置为其priority;
cur->status = TASK_READY;
}
else {
/* 若此线程需要某事件发生后才能继续上cpu运行,
不需要将其加入队列,因为当前线程不在就绪队列中。*/
}
ASSERT(!list_empty(&thread_ready_list));
thread_tag = NULL; // thread_tag清空
/* 将thread_ready_list队列中的第一个就绪线程弹出,准备将其调度上cpu. */
thread_tag = list_pop(&thread_ready_list);
struct task_struct* next = elem2entry(struct task_struct, general_tag, thread_tag);
next->status = TASK_RUNNING;
switch_to(cur, next);
}
/* 初始化线程环境 */
void thread_init(void) {
put_str("thread_init start\n");
list_init(&thread_ready_list);
list_init(&thread_all_list);
/* 将当前main函数创建为线程 */
make_main_thread();
put_str("thread_init done\n");
}
开头分别定义了main_thread主线程的PCB,以及定义了两个队列,一个为保存已经就绪的队列,另一个用于保存全部线程的队列,switch_to为switch.s文件中使用汇编语言所定义的函数用于切换线程,running_thread()可返回当前正在运行线程的栈顶指针,init_thread()初始化线程中添加了判断当前线程是否是主线程,如果是主线程,修改主线程的状态,thread_start()函数中创建完线程添加了将线程加入到就绪队列和全部线程队列中,make_main_thread()主要是为main线程的PCB初始化信息,schedule()函数主要用于判断当前线程的时间片是否到时,如果到时,给当前线程状态修改为就绪状态,并且将该线程添加到就绪队列最后。
在thread文件夹中创建switch.s文件
bash
[bits 32]
section .text
global switch_to
switch_to:
;栈中此处是返回地址
push esi ;这4条就是对应压入线程栈中预留的ABI标准要求保存的,esp会保存在其他地方
push edi
push ebx
push ebp
mov eax, [esp + 20] ; 得到栈中的参数cur, cur = [esp+20]
mov [eax], esp ; 保存栈顶指针esp. task_struct的self_kstack字段,
; self_kstack在task_struct中的偏移为0,
; 所以直接往thread开头处存4字节便可。
;------------------ 以上是备份当前线程的环境,下面是恢复下一个线程的环境 ----------------
mov eax, [esp + 24] ; 得到栈中的参数next, next = [esp+24]
mov esp, [eax] ; pcb的第一个成员是self_kstack成员,用来记录0级栈顶指针,
; 用来上cpu时恢复0级栈,0级栈中保存了进程或线程所有信息,包括3级栈指针
pop ebp
pop ebx
pop edi
pop esi
ret ; 返回到上面switch_to下面的那句注释的返回地址,
; 未由中断进入,第一次执行时会返回到kernel_thread
该文件主要用于定义switch_to函数实现真正的切换进程。由schedule()函数调用。
在interrupt.c文件中添加代码
cpp
/* 通用的中断处理函数,用于初始化,一般用在异常出现时的处理 */
static void general_intr_handler(uint8_t vec_nr) {
if (vec_nr == 0x27 || vec_nr == 0x2f) { //伪中断向量,无需处理。详见书p337
return;
}
/* 将光标置为0,从屏幕左上角清出一片打印异常信息的区域,方便阅读 */
set_cursor(0);
int cursor_pos = 0;
while(cursor_pos < 320){
put_char(' ');
cursor_pos++;
}
set_cursor(0); // 重置光标为屏幕左上角
put_str("!!!!!!! excetion message begin !!!!!!!!\n");
set_cursor(88); // 从第2行第8个字符开始打印
put_str(intr_name[vec_nr]);
if (vec_nr == 14) { // 若为Pagefault,将缺失的地址打印出来并悬停
int page_fault_vaddr = 0;
asm ("movl %%cr2, %0" : "=r" (page_fault_vaddr)); // cr2是存放造成page_fault的地址
put_str("\npage fault addr is ");put_int(page_fault_vaddr);
}
put_str("\n!!!!!!! excetion message end !!!!!!!!\n");
// 能进入中断处理程序就表示已经处在关中断情况下,
// 不会出现调度进程的情况。故下面的死循环不会再被中断。
while(1);
}
/* 在中断处理程序数组第vector_no个元素中注册安装中断处理程序function */
void register_handler(uint8_t vector_no, intr_handler function) {
/* idt_table数组中的函数是在进入中断后根据中断向量号调用的,
* 见kernel/kernel.S的call [idt_table + %1*4] */
idt_table[vector_no] = function;
}
添加了general_intr_handler()中断通用处理函数主要用于打印异常信息,register_handler()函数就是根据中断向量号注册相应的中断处理函数。
在interrupt.h中添加函数声明
cpp
void register_handler(uint8_t vector_no, intr_handler function);
新建device文件夹。创建timer.c文件
cpp
#include "interrupt.h"
#include "thread.h"
#include "debug.h"
uint32_t ticks; // ticks是内核自中断开启以来总共的嘀嗒数
/* 时钟的中断处理函数 */
static void intr_timer_handler(void) {
struct task_struct* cur_thread = running_thread();
ASSERT(cur_thread->stack_magic == 0x19870916); // 检查栈是否溢出
cur_thread->elapsed_ticks++; // 记录此线程占用的cpu时间嘀
ticks++; //从内核第一次处理时间中断后开始至今的滴哒数,内核态和用户态总共的嘀哒数
if (cur_thread->ticks == 0) { // 若进程时间片用完就开始调度新的进程上cpu
schedule();
}
else { // 将当前进程的时间片-1
cur_thread->ticks--;
}
}
/* 初始化PIT8253 */
void timer_init() {
put_str("timer_init start\n");
/* 设置8253的定时周期,也就是发中断的周期 */
frequency_set(CONTRER0_PORT, COUNTER0_NO, READ_WRITE_LATCH, COUNTER_MODE, COUNTER0_VALUE);
register_handler(0x20, intr_timer_handler);
put_str("timer_init done\n");
}
该文件中定义了时钟中断处理函数,这也是线程切换的起点,发生时钟异常时就会调用该函数,先判断当前线程时间片是否到期,如果到期了就会调用schedule()函数完成线程调度。
修改init.c文件
cpp
#include "init.h"
#include "print.h"
#include "interrupt.h"
#include "timer.h"
#include "memory.h"
#include "thread.h"
/*负责初始化所有模块 */
void init_all() {
put_str("init_all\n");
idt_init(); //初始化中断
mem_init(); // 初始化内存管理系统
thread_init(); // 初始化线程相关结构
timer_init();
}
编写main函数进行测试
cpp
#include "print.h"
#include "init.h"
#include "thread.h"
#include "interrupt.h"
void k_thread_a(void*);
void k_thread_b(void*);
int main(void) {
put_str("I am kernel\n");
init_all();
int i = 999999;
thread_start("k_thread_a", 31, k_thread_a, "argA ");
thread_start("k_thread_b", 31, k_thread_b, "argB ");
intr_enable(); // 打开中断,使时钟中断起作用
while(1)
{
while(i--);
i=999999;
intr_disable();
put_str("main ");
intr_enable();
}
return 0;
}
/* 在线程中运行的函数 */
void k_thread_a(void* arg) {
/* 用void*来通用表示参数,被调用的函数知道自己需要什么类型的参数,自己转换再用 */
int i=9999999;
char* tmp = arg;
while(1)
{
while(i--);
i=999999;
intr_disable();
put_str(tmp);
intr_enable();
}
}
/* 在线程中运行的函数 */
void k_thread_b(void* arg) {
/* 用void*来通用表示参数,被调用的函数知道自己需要什么类型的参数,自己转换再用 */
int i=9999999;
char* tmp = arg;
while(1)
{
while(i--);
i=999999;
intr_disable();
put_str(tmp);
intr_enable();
}
}
测试结果就会看到main线程和其它两个线程交换打印字符

二、主线程切换到线程A的过程
当时钟中断发生时,会调用时钟中断处理函数,若当前时间片还没到,则继续执行,否则调用thread.c文件中的schedule()任务调度函数,给当前线程重新赋予时间滴答数并添加到就绪队列的末尾。调用switch.s中定义的switch_to()函数完成线程栈的切换。
每次主线程调用函数都会给自己的栈中压入返回地址,当调用switch_to函数时,主线程的栈如下图所示:

一次从未执行过的线程栈为:

当执行完switch_to函数时,就会把esp指针从主线程的self_kstack指针跳到线程A的self_kstack指针,然后连续出四次栈,就会执行eip,执行ret指令,就会执行线程A所指向的函数,当线程A的时间片执行结束后,线程A的栈就会像主线程的栈一样压入一系列调用函数的返回地址,当esp指针重新指向主线程的self_kstack时,依旧是连续出四次栈,从switch_to函数中一层一层返回到之前执行到的位置继续执行。上图只是为了更好地理解线程切换,并非实际栈中内容。