提要
上一篇文章中讲述了线程机制原理,这篇则是根据线程机制的原理简单实现线程,并在初始化线程后实现简单的线程调度。
非常简单的构建线程,PCB的结构很简单,线程栈也很小。线程调度的实现相对于大型操作系统来说也很简单,是在现有的条件下实现的简单线程调度。这里没有提到进程结构体,但用到了task_struct的命名方式,是因为后续实现用户进程也是通过线程实现的,到时候只是在现有的结构上增加结构体成员变量,进程和线程的区别只是有无资源。做这些的目的是为了深入理解线程和线程调度的原理。
线程初始化
task_struct结构体中的成员:
该结构体中声明了一些PCB的一些信息,用来记录线程的信息。
初始化task_struct结构体中的成员:
此时PCB的情况如下图所示:
初始化线程栈
线程栈结构体:
线程栈是构建线程非常重要的结构体。线程第一次运行时eip指向kernel_thread。在第一次之后,该eip记录发生任务切换时指令地址,保证任务再次运行的时候能够继续运行。ebp,ebx,edi,esi是ABI要求的需要保存的寄存器(ABI介绍可查阅LINUX系统编程)。
仅第一次上CPU使用的内容是线程运行函数kernel_thead的参数,function是要封装函数的地址,func_arg是function函数运行时的参数地址。组合起来就是funciton(func_arg),该函数就是我们真正想要运行的函数。
汇编中调用另一个程序段,通过call指令,通过ret指令返回。因为使用C语言实现的内核,函数的参数由主调函数确定。这里要说一下call指令必须要通过ret指令返回,但是ret指令并不需要绑定call使用。ret指令的作用就是读取栈中的返回地址加载到eip,这样程序计数器的方向就发生了改变。在后面kernel_thread(function, func_arg)中kernel_thread是直接通过switch_to函数返回的(直接执行ret指令,不是通过call指令调用),kernel_thread函数主要是要执行funciton(func_arg)函数,结构体中eip这个函数指针让switch_to函数知道要去执行kernel_thread函数,该函数里要去调用另外一个函数function,结构体中为kernel_therad的栈空间构造好它需要的参数。因为要符合C语言函数调用的标准这里需要一个unused_ret作占位用。可以理解为在平时是程序员为函数确定函数调用的参数,现在需要内核为函数确定函数调用的参数。unused_ret是为了符合C语言函数调用的规则,function和func_arg是函数调用的参数。
初始化线程栈:
函数说明:
初始化线程栈。任务调度是由中断驱动的,现在PCB的顶端一定是中断处理程序要保存的上下文,所以先给中断栈留出位置。然后留出线程栈的空间,因为栈内存中是高地址到低地址扩展的,结构体定义是从低地址到高地址定义。然后进行初始化,让self_kstack指向现在的栈顶。eip指向要kernel_thread,这是线程要封装的函数的入口。function和func_arg分别指向kernel_thread要调用的函数和函数的参数。然后初始化结构体中定义的寄存器的值,线程第一次运行时这些寄存器的值应该为0。此时的PCB如下图所示。intr_stack是中断栈,这里就不展开了,里面保存了所有通用寄存器。图中的顶端是为线程PCB申请的一页内存的顶端。
创建线程
函数说明:
PCB需要在内存中存储,这样才能根据PCB进行任务调度,所以先申请一页的内存用于保存线程的PCB,这里的PCB比较小申请一页就行了。然后调用上面介绍的函数,进行初始化,经过初始化后,线程已经构建完成了。现在将PCB保存在就绪队列中和全部任务队列中,根据任务队列找到要调度的任务。就绪队列保存可以上CPU运行的线程,全部任务队列保存内核中所有的线程。只有就绪队列中的线程才可以直接上CPU运行。
时钟中断处理程序
函数说明:
这里任务调度是通过时钟中断实现的,所以要注册相应的时钟中断处理程序。时钟中断处理程序先获取当前正在运行的现场,确定线程的PCB是完整的没有被破坏。每次时钟中断是一个滴答,当前线程的elapsed_ticks用来记录总共占用CPU的时间。线程ticks记录一次上CPU运行的时间,这个时间不为0说明当前线程还可以继续使用CPU,只需要-1即可,当这个时间为0时,需要被换下处理器了,此时就调用schedule()函数。
调度函数schedule
函数说明:
该调度函数主要是将当前正在运行线程的状态变为就绪态,然后加入到就绪队列中,然后从就绪队列中出队一个线程,进行线程调度。当前线程为cur,马上要上CPU运行的线程状态变为运行态,记录在next中,然后调用switch_to(cur, next)完成调度。如果当前线程因为某些原因不是运行态(比如线程阻塞),则不加入就绪队列参与调度。
线程替换switch_to
switch_to是一个汇编程序,使用汇编是因为只是为了实现转换没有其他复杂的内容,就不使用内嵌汇编了。switch_to是通过schedule()调用的,参数为cur,next。cur为当前要被换下CPU的线程,next为要换上CPU运行的线程。此时栈中的情况如下图。只关心switch_to有关的内容,然后给出switch_to函数。
函数说明:
esp+20的位置存储的是线程cur,esp+24的位置存储的是线程next。现在将cur取出存入eax临时保存,eax现在的值为当前线程PCB的起始地址,PCB的起始地址为self_stack,将当前esp的值存入当前线程的self_stack保存起来。
然后从栈esp+24中取出线程next存入eax,现在eax的值为线程PCB的起始地址,PCB的起始地址保存着线程next的栈顶self_stack,将该栈顶存入esp,此时线程栈交换完成。
然后进行返回,如果线程正在运行的线程是第一次运行,将上面初始化的线程栈再次放到这以方便看。
这是交换栈后新线程(next)的栈,经过pop ebp,ebx,edi,esi后,esp指向了eip,然后switch_to执行最后一条指令ret,读取eip的值,此时eip的值为函数kernel_thread的地址,之前说过ret指令并不需要绑定call指令使用,ret指令将esp指向的内存保存进eip使程序计数器cs:eip的方向发生改变。现在eip的值为kernel_thread的地址,就是说现在要去执行kernel_thread。将上面的kernel_thread角度的栈放到下面方便看:
开始执行kernel_thread函数。这样next线程中我们要执行的function(func_arg)就得以执行了,实现多个线程运行。
kernel_thread函数:
如果当前线程不是第一次执行,那么上面ret之前栈中的eip就是调用switch_to的返回地址,这里是schedule()调用的switch_to,所以eip指向的返回地址在schedule中。
每当发生时钟中断时,就会进行上述的情况,往复进行实现了多线程调度。
参考书籍:
《操作系统真相还原》-- 郑钢
《LINUX系统编程》-- ROBERT LOVE
文章转自:小组18级成员--胡庆伟
原文地址:https://blog.csdn.net/qq_43769572/article/details/105576485