第九章 线程(一) 在内核中实现线程
本文是对《操作系统真象还原》第九章(一)学习的笔记,欢迎大家一起交流。
我们在本节的任务:
- 创建并初始化PCB
- 模拟pthread_create函数创建线程并执行线程函数
首先我们要明确内核级线程的优势,内核级线程是cpu的一个调度单位,当一个进程中的线程越多,享受cpu服务的时间也就越多。所谓线程,其实也就是去执行一个函数,和在进程的没有本质区别,但是借助书中的一个例子,我们喜欢吃黄瓜,宫保鸡丁里面有黄瓜,但是我们也可以点一个拍黄瓜让厨师专门做黄瓜,线程所执行的函数也是这样的,执行整个进程时可以顺便执行这个函数,也可以新起一个线程专门执行这个函数。
准备的数据结构
进程/线程状态
/* 进程或线程的状态 */
enum task_status {TASK_RUNNING,TASK_READY,TASK_BLOCKED,TASK_WAITING,TASK_HANGING,TASK_DIED
};
线程栈
定义线程栈,存储线程执行时的运行信息
/*********** 线程栈thread_stack ************ 线程自己的栈,用于存储线程中待执行的函数* 此结构在线程自己的内核栈中位置不固定,* 用在switch_to时保存线程环境。* 实际位置取决于实际运行情况。******************************************/
struct thread_stack {uint32_t ebp;uint32_t ebx;uint32_t edi;uint32_t esi;//这个位置会放一个名叫eip,返回void的函数指针(*epi的*决定了这是个指针),//该函数传入的参数是一个thread_func类型的函数指针与函数的参数地址void (*eip) (thread_func* func, void* func_arg);//以下三条是模仿call进入thread_start执行的栈内布局构建的,call进入就会压入参数与返回地址,因为我们是ret进入kernel_thread执行的//要想让kernel_thread正常执行,就必须人为给它造返回地址,参数void (*unused_retaddr);thread_func* function; // Kernel_thread运行所需要的函数地址void* func_arg; // Kernel_thread运行所需要的参数地址
};
PCB
PCB以后还会进行补充,本节用到的东西如下:
/* 进程或线程的pcb,程序控制块, 此结构体用于存储线程的管理信息*/
struct task_struct {uint32_t* self_kstack; // 用于存储线程的栈顶位置,栈顶放着线程要用到的运行信息enum task_status status;uint8_t priority; // 线程优先级char name[16]; //用于存储自己的线程的名字uint32_t stack_magic; //如果线程的栈无限生长,总会覆盖地pcb的信息,那么需要定义个边界数来检测是否栈已经到了PCB的边界
};
第一个结构self_kstack
即指向thread_stack
一个pcb占一个自然页,即4kb,低地址开始是pcb相关信息,高地址是线程的栈,向低地址扩展,所以在最后一项定义一个魔数,每次对该数值进行校验即可判断有没有溢出。
中断栈
本节中不会用到,但是要为它预留空间,所以先定义
/*********** 中断栈intr_stack ************ 此结构用于中断发生时保护程序(线程或进程)的上下文环境:* 进程或线程被外部中断或软中断打断时,会按照此结构压入上下文* 寄存器, intr_exit中的出栈操作是此结构的逆操作* 此栈在线程自己的内核栈中位置固定,所在页的最顶端
********************************************/
struct intr_stack
{uint32_t vec_no; // kernel.S 宏VECTOR中push %1压入的中断号uint32_t edi;uint32_t esi;uint32_t ebp;uint32_t esp_dummy; // 虽然pushad把esp也压入,但esp是不断变化的,所以会被popad忽略uint32_t ebx;uint32_t edx;uint32_t ecx;uint32_t eax;uint32_t gs;uint32_t fs;uint32_t es;uint32_t ds;/* 以下由cpu从低特权级进入高特权级时压入 */uint32_t err_code; // err_code会被压入在eip之后void (*eip) (void);uint32_t cs;uint32_t eflags;void* esp;uint32_t ss;
};
代码部分
代码逻辑如下:
- 向内存申请一页空间,分配给要创建的线程
- 初始化该线程的PCB
- 通过PCB中的栈顶指针进一步初始化线程栈的运行信息
- 正式运行线程执行函数
thread_start
thread_start函数即对应了上面说的代码逻辑,对应第四步的汇编我们后面再说
/* 创建一优先级为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); // 初始化线程的pcbthread_create(thread, function, func_arg); // 初始化线程的线程栈// 我们task_struct->self_kstack指向thread_stack的起始位置,然后pop升栈,// 到了通过线程启动器来的地址,ret进入去运行真正的实际函数// 通过ret指令进入,原因:1、函数地址与参数可以放入栈中统一管理;2、ret指令可以直接从栈顶取地址跳入执行asm volatile("movl %0, %%esp; pop %%ebp; pop %%ebx; pop %%edi; pop %%esi; ret" : : "g"(thread->self_kstack) : "memory");return thread;
}
初始化pcb
即对task_struct结构体进行初始化
/* 初始化线程基本信息 , pcb中存储的是线程的管理信息,此函数用于根据传入的pcb的地址,线程的名字等来初始化线程的管理信息*/
void init_thread(struct task_struct *pthread, char *name, int prio)
{memset(pthread, 0, sizeof(*pthread)); // 把pcb初始化为0pthread->status = TASK_RUNNING; //这个函数是创建线程的一部分,自然线程的状态就是运行态strcpy(pthread->name, name);pthread->priority = prio;/* self_kstack是线程自己在内核态下使用的栈顶地址 */pthread->self_kstack = (uint32_t *)((uint32_t)pthread + PG_SIZE); // 本操作系统比较简单,线程不会太大,就将线程栈顶定义为pcb地址//+4096的地方,这样就留了一页给线程的信息(包含管理信息与运行信息)空间pthread->stack_magic = 0x19870916; // /定义的边界数字,随便选的数字来判断线程的栈是否已经生长到覆盖pcb信息了
}
我们前面说过了一个pcb占一个自然页,即4kb,低地址开始是pcb相关信息,高地址是线程的栈,向低地址扩展,故有
pthread->self_kstack = (uint32_t *)((uint32_t)pthread + PG_SIZE);
目前的pcb布局如下(中断栈和线程栈现在还没有,下一步就有了):
初始化线程栈
/*用于根据传入的线程的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_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->function = function;kthread_stack->func_arg = func_arg;//我们将线程的栈顶指向这里,并ret,就能直接跳入线程启动器开始执行。//为什么这里我不能直接填传入进来的func,这也是函数地址啊,为什么还非要经过一个启动器呢?其实是可以不经过线程启动器的kthread_stack->eip = kernel_thread; //下面的寄存器用不到, 先置为0kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;//因为用不着,所以不用初始化这个返回地址kthread_stack->unused_retaddr}
首先先预留中断时使用的栈,然后再预留线程栈的空间,预留完之后对线程栈进行初始化。
kernel_thread是通用的线程启动器,里面核心是执行function(func_arg)
,我们将eip初始化为该值,后面就可以直接去这个函数执行,后面再详说。
目前的pcb布局如下:
thread_start中的关键汇编
/*4.上述准备好线程运行时的栈信息后,即可运行执行函数了*/asm volatile("movl %0,%%esp; \pop %%ebp; \pop %%ebx; \pop %%edi; \pop %%esi; \ret":: "g"(thread->self_kstack): "memory");
当来到这里时,首先将esp赋为thread->self_kstack,也就是pcb线程栈的最下端,然后不断pop,pop完四个寄存器,然后ret,此时正好对应线程启动器的指针,如下图:
然后执行ret,eip就会来到线程启动器,其中esp自动+4,布局如下:
然后再往下一步很多人讲错了,我们去执行线程启动器,反汇编如下:
会再push ebp,此时内存中布局如下,正好符合取参规范
可以验证以下下面的数据和上面的数据。
于是接下来,根据c语言的函数调用约定,kernel_thread
会取出占位的返回地址上边的两个参数,也就是执行函数的地址与执行函数的参数,然后调用执行函数运行
kernel_thread
如下:
// 线程启动器
/* 由kernel_thread去执行function(func_arg) , 这个函数就是线程中去开启我们要运行的函数*/
static void kernel_thread(thread_func* function, void* func_arg) {function(func_arg);
}