这次的实验是线程管理,因为是运行在内核中,虽然使用进程的方式创建于使用,但其实创建的是线程,不过原理和进程是一样的。
链接为:点击打开链接
实验目标:
实现线程的创建
线程之间的调度
线程的创建:
进程便是正在运行的程序,用户程序通过进程的方式运行在操作系统中,所以说操作系统天生便是为进程服务的。进程不是程序写好之后就有的,它是由操作系统创建的。
一个进程都有些什么内容呢?
代码区:进程是程序,所以肯定有代码,代码区是必须的。
数据区:全局变量,静态变量,常量都存储在这里。大部分程序都会使用到数据区。
(插一小段:为什么代码段和数据段要分开来呢?
首先,冯诺依曼体系结构中指令也是以数字的形式来表示的,与数据是没有明显区分,所以为了区分指令与数据,将它们分段表示是很有必要的。
另外,代码段是可以共享的,数据段则不可以,对于同一进程的多个线程,如此分段,代码段便只需要一个,不用为每个线程都拷贝一份啦。
在编译期间,这样分段可以提高编译器效率,比如说链接时查找符号会更加方便。
在运行期间,访问控制也很方便,代码段就是只可读的,常量也是只可读,变量则是可读可写的。
)
栈区(stack):局部变量和临时变量的存储地方。函数调用,中断处理时的参数传递也需要用到栈,多个程序直接来回切换所必需的东西呀!
堆区(heap):分配程序员自己动态申请的变量,比如C中的malloc和C++中的new操作。
这上面的四种内容合称为进程的地址空间(address space),地址空间便是进程可以合法使用的地址的集合。
除了地址空间,进程还有以下内容:
进程号pid_t pid
运行状态:可调度,阻塞态,等待I/O操作等等
上下文信息,即陷阱帧
各种资源,比如锁,文件,信号量,消息等。
操作系统使用进程控制块PCB来对进程进行管理。
简单的进程控制块:
为了实现一个简单的进程调度,首先创建一个简单的进程控制块,它只包含堆栈以及上下文信息。tf指针指向上下文信息,即陷阱帧的位置,所以在进行上下文切换的时候,只需要把PCB中tf指针的值赋给ESP寄存器就可以了。
#define KSTACK_SIZE 4096
struct PCB{
void *tf;
uint8_t kstack[KSTACK_SIZE];
}
上下文的切换
在一个支持多任务的操作系统上,我们可以同时上qq,看网页,看视频,多个任务同时进行着。
这里的同时指的是程序的并发执行,多个程序之间或者通过轮询,或抢占方式得到CPU资源,使得CPU在这些程序直接来回切换,并且切换之间的时间间隔很短,短到用户感觉不到有任何停顿,这些程序就像是在同时进行着一般。
并行与并发概念不同,并行是指真正的同时运行,所以需要多核的支持,GPU的cuda加速也属于并行。
那么CPU在程序之间的切换,究竟是如何实现的呢?
在上一次的lab0中,游戏是通过中断来驱动的,每个时钟中断来临,会去调用新字符生成以及字符位置更新的处理程序,键盘响应中断来临,则会调用键盘处理程序。
每次中断来临,CPU需要
- 把此时的现场信息保存到堆栈上,
- 之后跳到中断处理程序,
- 接着根据之前保存的信息恢复现场,
- 最后退出中断。
假设我们有A和B两个程序,当A正在运行的时候,突然中断来了,此时CPU就会跳到中断处理,相关汇编代码如下:
irq0:pushl $1000;jmp asm_do_irq
asm_do_irq:
pushal
pushl %esp
call irq_handle
#switch %esp to other program
popal
addl $4,%esp
iret
上面代码首先将中断号#irq=1000入栈,之后pushal将所有通用寄存器入栈,接着将此时栈指针%esp值入栈,加上硬件保存的EFLAGS,CS,EIP,便形成了一个陷阱帧(TrapFrame),它将A的现场信息保存好了。这个陷阱帧结构就保存在A进程自己的堆栈上面。
此时asm_do_irq将A的栈顶指针作为参数传给C函数irq_handle中去,如果想要跳到程序B执行,则在irq_handle中我们要将栈指针esp指向进程B保存现场信息的栈指针位置,即将寄存器ESP切换到B的堆栈上。
之后popal,将B的通用寄存器们恢复,addl $4,%esp这个是个出栈处理,因为栈中加入数据栈指针是要从高地址向低地址前进,这一句应该是与pushl $1000对应,将中断号出栈吧。之后恢复B的EIP,CS,EFLAGS(这个是硬件做的吧,所以代码中没有显示),从中断返回之后,我们就在运行B啦。
进程A则被暂时挂起,将来A再次得到CPU的话,ESP寄存器便会切换到A的堆栈上,A的现场会恢复,A便又可以执行了。
图片出自jyy大神,挺好的说明了上下文切换的变换。
pcb是每个进程都有的进程控制块,每个进程控制块里边有进程自己的堆栈,在堆栈里边保存了一个TrapFrame,用tf指针指向TrapFrame。
上下文切换有两种,分别是硬件上下文切换以及软件上下文切换,但是大多数系统比如Nanos,windows,linux都是使用软件实现上下文切换,why?
首先,硬件实现上下文切换,就代表这个操作依赖于相关硬件,因此便不能运行在所有的CPU上面。
其次,软件实现效率会更高,硬件实现会保存所有的CPU状态,但是并不是所有的CPU状态都需要使用,所以选择软件实现便可以有选择地保存与加载,从而提高了性能。
进程的创建:
我们创建了一个进程之后,如何让它运行呢?在中断到来之后,中断处理函数中会调用schedule调度函数,它修改宏current,指向想要运行的程序的pcb,
PCB中的tf指针会指向进程的陷阱帧,那么在irq_handle结束之后,回到asm_do_irq中时ESP就已经指向了这个进程的堆栈,之后出栈恢复现场。
下面引用
linux内核设计与实现一书中关于进程的一些说明:
内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中,链表中的每一项都是类型为task_struct,称为进程描述符(process descriptor)的结构,该结构定义在<linux/sched.h>文件中。进程描述符包含一个具体进程的所有信息。
在2.6以前的内核中,各个进程的task_struct存放在它们内核栈的尾端,这样做事为了让那些像x86那样寄存器较少的硬件体系结构只要通过栈指针就可以计算出它的位置,而避免使用额外的寄存器专门记录。由于现在用slab分配器动态生成task_struct,所以只需要在栈底(对于向下增长的
栈)或栈顶(对于向上增长的
栈)创建一个新的结构struct_thread_info。每个人物的thread_info结构在它的内核栈的尾端分配,它的结构中task域存放指向实际task_struct的指针。
在内核中,访问任务通常需要获得指向其task_struct的指针,实际上,内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current宏查找到当前正在运行进程的进程描述符的速度就尤为重要。
陷阱帧的结构如下:
- 8个32位的通用寄存器
- 4个32位的段寄存器
- 1个32位的中断号
- 1个32位的错误码
- 由硬件保存的3个32位执行状态用的寄存器eip,cs,eflags
- 最后还有个esp和ss,用户态可访问
因为用不了malloc和new操作,而create_kthread函数要求返回一个PCB *,该怎么办呢?
在函数里边动态申请时不可行的,直接创建一个局部PCB也不行,所以要预先申请一个pcbpool用于保存PCB,同时也需要设定最大线程数。
所以在process.h里边,
- 申请一个pcbpool,
- 因为现在是简单实现,所以不采用链表方式,而是用length记录目前已有的进程总数,curIndex记录当前运行进程的pcbpool下标,
- 实现一个申请pcb的操作,返回指针
util.c的进程创建的代码如下:
- 首先分配一个新的PCB给新进程,
- 之后在这个PCB的栈顶分配一个陷阱帧TrapFrame(这里应该是向上增长的栈),并将指向陷阱帧的指针值赋给tf。
- 最后初始化陷阱帧
- 通用寄存器的初始化无所谓,可以为0,也可以不写
- 机器状态字eip是线程启动后第一条指令的地址,也就是函数的起始地址fun,fun是指针,所以要转化成32位uint数据
- cs指向内核的代码段
- ds,es,fs,gs是个数据段寄存器指向内核中的数据段
- eflags标志寄存器,下图所示,第9位IF位要设置成1,第1位不使用,固定为1,其它为则都设置为0,所以最后为0x00000202
- xxx其实无所谓的,可以不做处理,反正也用不到
- 其它的用不到,随便赋值或者不管它们
这里注意不要用下面这种形式,因为pcb->kstack是uint8_t类型,所以就用个uint8_t指针指向pcb->kstack,之后再一个字节一个字节的赋值,虽然是可以的,但是在这里是不支持指针转化,会把这个warning当做error来处理,所以不可行。
指针转化
uint8_t *stack_ptr;
(uint32_t *)stack_ptr这样做个转化都break strict alising rule。坑爹哦!
中断处理部分:
如图中代码所示,在asm_do_irq中,首先保存现场信息,之后调用irq_handle函数,
在irq_handle中,会调用schedule调度函数,将current指向我们生成的线程的PCB。
所以我们要做的任务就是:
从irq_handle中返回之后,将current指向的PCB里边记录的陷阱帧地址即current->tf赋给esp寄存器。
按照PCB的定义,tf指针位于PCB最开始处,所以current指向的也可以看做是tf指针记录的值,不需要加偏移量。
(current)也就是current地址指向的值,所以将(current)先赋给任意一个没什么用的寄存器,这里选择%eax,之后再讲%eax赋给%esp,这样esp寄存器就指向了新线程的堆栈段了,之后就是出栈恢复现场啦。
schedule调度
简单地实现下轮询
线程切换测试
我们创建三个线程A,B,C,让它们不断循环输出字符,按照预期,应该会是a,b,c字符交替出现
结果如下:
每次时钟中断来临,就会切换一下线程,然后输出的字符也就相应改变,哈哈哈哈,线程切换ok啦!