内核进程管理子系统
4.1 概述
WOS 是支持多进程的操作系统内核。这就意味着,各个用户进程在运行过程中,彼此不能相互干扰,这样才能保证进程在主机中正常地运行。在某个 CPU 上,某一时刻,处 理器只会执行一个任务。程序是静态的、存储在文件系统上、尚未运行的指令代码;进程则是指正在运行的程序,即进行中的程序。一个进程的指令地址所组成的执行轨迹称为控制执行流。进程的执行流是独立的,互不干扰的。为了保证每个进程执行流的独立性,每个进程的运行必须获得运行所需要的各类资源,这些资源包括进程所使用的栈、一套自己的寄存器映像和内存资源等。操作系统为每个进程提供一个 PCB,即进程控制块,记录、描述和管理程序执行的动态变化过程,它就是进程的身份证,用它来记录与进程相关的信息,比如进程状态、PID 等。另外,在中断到来或发生进程切换时,必须(也只需要)把进程的寄存器组信息完整地保存下来。在 Linux 内核中,通常由 task_struct 结构描述。每个进程都有自己的 PCB,所有 PCB 放到一张表中维护,这就是进程表,PCB 就成为进程表中的“项”,因此 PCB 又可称为进程表项。另外进程 PCB 没有具体格式, 其实际格式取决于操作系统的功能复杂度。
进程从执行到结束的整个过程中,并不是所有阶段都一直开足马力在处理器上运行,有 时也会由于某些原因不得不停下来。为此,通常把进程“执行过程”中所经历的阶段按状态进行分类。进程有哪些状态,取决于操作系统对进程的管理办法,当一个进程在执行时,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换(switch)至另一个进程时,它就需要保存当前进程的所有状态,即保存当前线程的上下文,以便在再次执行该进程时,能够恢复到切换的状态执行下去。
4.2 实验项目
4.2.1 进程创建
1.实验目的
(1)理解进程的本质;
(2)理解进程控制块及其管理方法;
(3)掌握模拟器的基本使用方法和调试方法。
2. 实验准备
操作系统为每个进程管理一个 PCB。在 Linux 内核中,通常定义task_struct 结构描述。其实,进程 PCB 没有具体格式,其实际格式取决于操作系统的功能复杂度。
所谓创建进程,其实是初始化进程控制块各项。
一个进程开始之前,只要指定各段寄存器、eip、esp 和 eflags,就可以正常运行了,至于其他寄存器是用不到的,所以,在创建进程体时,必须初始化的寄存器列表为:cs、ds、es、fs、esp、eip 和 eflags。其中,cs、ds 等段寄存器对应的是 GDT 中的描述符,每个进程都有自己的 PCB,系统通过进程表来管理所有进程。进程表的组织形式比较灵活。其中在 Linux 系统中,进程表定义成进程队列数组的形式,例如 struct task_struct *task[NR_TASKS]; 规定系统可同时运行的最大进程数(见 kernel/sched.c);每个进程占一个数组元素。可以通过 task[]数组遍历所有进程的 PCB。但 Linux 也提供一个宏定义for_each_task()(见 include/linux/sched.h),它通过 next_task 遍历所有进程的 PCB。在 WOS 源代码的 include/adt/list.h 文件中,采用了一种(数据结构)类型无关的双循环链表实现方式。其思想是将指针 prev 和 next 从具体的数据结构中提取处理构成一种通用的 “双链表”数据结构 list_head,而 list_head 被作为一个成员嵌入到要拉链的数据结构(被称为宿主数据结构)中。这样,只需要一套通用的链表操作函数就可以将 list_head 成员作为“连接件”,把宿主数据结构链接起来。
3. 实验方案
在本实验中,WOS 进程控制块 PCB 主要包括以下几个部分:
①进程标识符,主要 pid;
②处理机状态信息:通用寄存器、指令计数器、程序状态字、用户栈指针;
③进程调度信息:进程状态、优先级等;
④进程控制信息:程序和数据的地址、同步和通信机制、资源清单、链接指针等;
由于,本节实验是在内核空间,创建内核进程,实现基本的进程管理及切换,因此 PCB不包含用户进程需要的资源记录,同时关于调度、通信、同步等信息会在后面实验中陆续添加。
enum proc_state{UNUSED=0,RUNNABLE,RUNNING,BLOCKED,STOPED};
struct task_struct
{
TrapFrame *tf;
char kstack[KSTACKSIZE];
pid_t pid;
char pname[20];
enum proc_state state;
int delay;
int number;
int nice;
ListHead linklist;
}task_struct ;
//其中,TrapFrame 结构用于定义系统寄存器信息,定义在 include/x86.h 中。
struct TrapFrame {
uint32_t edi, esi, ebp, esp_;
uint32_t ebx, edx, ecx, eax; // Register saved by pushal
uint32_t gs, fs, es, ds; // Segment register
int irq; // # of irq
uint32_t err, eip, cs, eflags; // Execution state before trap
uint32_t esp, ss; // Used only when returning to DPL=3
};
typedef struct TrapFrame TrapFrame;
为了管理系统中的所有进程控制块,系统还要维护如下全局变量与进程 PCB 相关的几个数据结构
#define NR_PROC 64
struct task_struct task[NR_PROC];
extern struct task_struct * current; /指向当前工作进程
ListHead *RunableList; //指向就绪队列的指针,便于使用list.h中的内联函数
说明 task_list是进程管理队列,可以根据系统需求创建多种队列,例如就绪队列、阻塞队列等等;在这里,task[]是进程表,NR_TASKS 定义了最大允许进程;current 表示当前占用 CPU且处于“运行”状态进程控制块指针。通常这个变量是只读的,只有在进程切换的时候才进行修改,并且整个切换和修改过程需要保证原子操作;RunableList 是用于管理就绪进程的双向循环列表结构,task_struct 中的成员变量 linklist 将链接入这个链表中(链表结构LishHead的定义在 include/adt/list.h 中)。
操作系统通过进程表来管理所有进程 PCB,task[]数组大小固定,因此系统中的进程是受限资源,即系统的最大进程数量是有限的。进程创建的本质就是为进程分配 PCB,并设置为就绪状态。在 WOS 操作系统中,为了实现进程管理需要进行如下操作:
A. task_init:task 进程表初始化
B. idle_init: 创建 0 号进程
C. kthread_create:进程创建
4-1 进程表初始化
struct task_struct* init_pcb1(struct task_struct * p,void (* proc)(void),const char *name );
extern int nextpid;
extern int ticks;
void task_init()
{
int i=0;
for(;i<NR_PROC;i++)
{
task[i].state=UNUSED;
}
}
4-2 创建 0 号进程
void idle_init()
{
struct task_struct * p;
task[0].state=RUNNING;
p=&task[0];
p=init_pcb1(p,idle_init,"idle");
current=p;
list_init(&RUNABLELIST);
RunableList=&RUNABLELIST;
printk("0 init ok\n");
}
4-3 进程创建
void creat_kthread(void (*proc)(void),const char *name )
{
struct task_struct * pp=NULL;
pp=&task[nextpid];
int i=0;
for(;i<NR_PROC;i++)
{
if(task[i].state==UNUSED)
{ pp=&task[i];
break;
}
}
pp=init_pcb1(pp,*proc,name);
printk("%d",pp);
printk("%s",pp->pname ,"is inited\n");
printk("%s","pid = ");
printk("%d\n",pp->pid);
}
4-4 初始化 PCB
struct task_struct* init_pcb1(struct task_struct * p,void (* proc)(void),const char *name )
{
TrapFrame *tf;
p->pid=nextpid++;
p->state=RUNNABLE;
tf=(TrapFrame*)(memset(p->kstack,0,KSTACKSIZE)+KSTACKSIZE)-1; //初始寄存器组
p->tf=tf;
tf->eflags=(uint32_t)(0x01<<9);
tf->cs=(uint32_t)KSEL(SEG_KCODE);
tf->ds=(uint32_t)KSEL(SEG_KDATA);
tf->gs=(uint32_t)KSEL(SEG_KDATA);
tf->eip=(uint32_t)*proc;
tf->esp=(uint32_t)tf;
tf->irq=0x03e8;
memcpy(p->pname,name,7); //设置进程名字
p->nice=15;
p->number=15;
p->delay=0;
list_init(&p->linklist);
if(nextpid>1)
list_add_after(RunableList,&p->linklist);
return p;
}
4.实验验证及分析
测试程序如下
#include "sched.h"
#include "debug.h"
void a(){
while(1){
printk("A");
wait_intr();
} }
void b(){
while(1){
printk("B");
wait_intr();
} }
void c(){
for(i=1; i<=10; i++){
printk("C");
wait_intr();
} }
void test_proc(){
create_kthread(a, "A_proc");
create_kthread(c, "B_proc");
create_kthread(b, "C_proc");
}
由实验结果看出单操作系统经过引导初始化后,创建的第一个程序为“0”号进程,接着创建了三个进程,分别打印出了PCB地址,进程名,进程的pid。
4.2.2 进程切换
- 实验目的
(1)了解系统中断机制;
(2)理解进程切换本质;
(3)掌握模拟器的基本使用方法和调试方法。 - 实验准备
操作系统是由“中断驱动”的。中断是现代操作系统实现并行性的基础之一。引入中断机制,操作系统在让应用程序放弃控制权或从应用程序获得控制权将具有更大的灵活性。因此,操作系统中进程的切换也是以中断为基础的。当中断发生时,当前进程放弃处理器,新进程获得处理器开始执行。可以这样讲,进程上下文的切换都是由中断事件引起的。
为了完成本小节关于进程切换的操作,首先了解当前系统中的中断机制实现情况。本节系统中涉及到的中断为“硬中断”,包括中断(又称外中断或异常中断)和异常(又称内中断或同步中断)。中断机制设置“中断描述符表”,以向量号为索引查找中断向量,然后转入中断处理程序或异常处理程序。在保护模式下,系统采用中断描述符表(Interrupt Descriptor Table,IDT),此表可以包含 256 个中断描述符,与中断或异常一一对应。描述符的作用是把程序控制权转给中断异常服务程序,每个描述符结构如图 所示,均占 8 个字节,通过它就能找到服务程序的起始地址、属性以及程序特权级别等。IDT 的位置由硬件中断描述符寄存器 IDTR 指定,它是一个 48 位的寄存器,高 32 位的 IDT 基址,低 16 位限定 IDT的长度。
由此可见,中断和异常是激活操作系统的方法,它暂停当前运行进程的执行,把处理器切换至核心态,内核获得处理器的控制权之后,如果需要就可以实现进程切换。内核在处理中断事件或系统调用或者在处理时钟中断事件期间发现运行进程的时间片耗尽等,都可能引发内核实施进程上下文切换。
假设,当中断发生时,进程让出处理器时,寄存器上下文将被保存到系统级上下文的相应的现场信息位置,这是内核就把这些信息压入核心栈;当内核处理完中断返回时,内核进行上下文切换,并从核心栈中弹出上下文。 - 实验方案
(1) 中断机制的实现
在系统初始化阶段,创建 IDT。每个中断/异常均有其相应的处理程序,在使用中断之 前,必须在 IDT 中注册以保证发生中断时能找到相应的中断处理程序。以 32 号时钟中断为例。 首先,在 IDT 中定义时钟中断描述符,代码如下所示,
idt[32] = GATE(STS_IG32, KSEL(SEG_KCODE), irq0, DPL_KERN);
定义 irq0 即中断处理程序,
.globl irq0;
irq0:
pushl $0; //错误码
pushl $1000; //时钟中断号=1000
jmp trap //
.extern irq_handle
trap:
cli
pushl %ds //入栈,段寄存器 ds、es、fs、gs
pushl %es
pushl %fs
pushl %gs
pushal //入栈,4 个通用寄存器(eax、ebx、ecx、edx)
//2 个指针寄存器(esp、ebp)
//2 个变址寄存器(esi、edi)
movw $KSEL(SEG_KDATA), %ax //设置 ds、es
movw %ax, %ds
movw %ax, %es
pushl %esp //栈顶指针寄存器入栈
call irq_handle //中断处理程序
movl (current),%esi
movl (%esi),%esppopal
popl %gs
popl %fs
popl %es
popl %ds
addl $8, %esp
iret //恢复 cs、eip、eflags 在这里插入代码片
使得当中断发生时,可以保护现场并且调用irq_handle()函数,这个中断处理函数会继续调用schedule()函数选择新的进程,接着将选择的新进程的工作栈弹出。
(2) 进程切换
在实现进程切换之前,我们不妨来模拟一下进程切换的行为。当前进程正在运行时,堆栈上
的内容是与进程相关的,而 current 指针指向了当前运行进程的 PCB。这个时候,中断发生了。中断处理程序会在当前的堆栈上保存 CS, EIP 和 EFLAGS 三个寄存器,并跳转到汇编代码执行。汇编代码如同预期的,把寄存器现场(TrapFrame)保存到堆栈上,并执行 pushl %esp将栈顶指针保存下来,这下,我们进程的状态就和之前图中的状态一样了。
C 语言代码继续在堆栈上运行,可能会在堆栈中插入新的内容,但当前进程 PCB 的结构却不会发生任何变化。C 语言代码可能会执行一些如中断处理的工作,然后判断是否需要执行进程切换。如果需要执行进程切换,C 语言代码只是把 current 指针切换: current = next_process(); 然后就从 C 语言代码返回了。返回后,汇编语言执行堆栈切换,然后照例恢复寄存器现场:
popal; popl %gs; popl %fs; popl %es; popl %ds
addl $8, %esp
iret
注意到此时的堆栈已经是新进程的堆栈了,在寄存器现场恢复完毕后,另一个线程就恢复执行了!
4-5 进程切换(Switching of Process)
void schedule()
{
struct task_struct * old=NULL;
struct task_struct * new=NULL;
struct task_struct * temp=NULL;
new=list_entry(RunableList->next,struct task_struct,linklist);
List_del(&new->linklist);
RunableList=RunableList->next;
new->state=RUNNING;
list_add_before(RunableList,&old->linklist);
old = current;
old->state=RUNNABLE;
current=new;
}
本实验进程切换,由中断触发,执行函数调用关系:trapirq_handle()schedule()
说明 1:idle 进程(即 0 号进程)是内核进程,只有在系统空闲时运行。进程切换时总是从 Runable_list 中选择一个就绪进程作为 current 进程调度执行,当 Runable_list 为空时才会再次调度 idle 进程。
5.实验验证及分析
测试程序如上一部分测试程序,将三个进程开始切换运行起来。
4.2.3 调度机制
- 实验目的
(1) 理解操作系统的调度管理机制。
(2) 熟悉mcore的系统调度框架,并实现基于优先级的调度算法。 - 实验准备
进程调度是操作系统最为核心到部分,执行十分频繁,其调度策略的优略降直接影响整个系统的性能,因而,这部分代码要求精心设计,并常驻内存。
内核中的调度程序用于选择系统中下一个要运行的进程。这种选择机制是多任务操作系统的基础。可以将调度程序看做在所有处于运行状态的进程之间分配 CPU 运行时间的管理代码。
例如在 Linux 系统中通过 schedule()函数来完成调度操作,为了能让进程有效地使用系统资源,又能使进程有较快的响应时间,就需要对进程的切换调度采用一定的调度策略。在Linux0.12 中采用了基于优先级排队的调度策略。schedule()函数首先扫描 task[]任务数组。通过比较每个就绪态任务的运行时间递减滴答计数 counter 的值来确定当前哪个进程运行的时间最少。哪一个的值大,就表示运行时间还不长,于是就选中该进程,并使用任务切换宏函数切换到该进程运行。如果此时所有处于就绪态进程的时间片都已经用完,系统就会根据每个进程的优先权值 priority,对系统中所有进程(包括正在睡眠的进程)重新计算每个任务需要运行的时间片值 couner。计算的公式是: counter=counter/2+priority;这样,正在睡眠的进程被唤醒时就具有较高的时间片 counter 值。然后 schedule()函数重新扫描任务数组中所有处于就绪态的进程,并重复上述过程,直到选择出一个进程为止。 - 实验方案
本实验模拟系统时钟,每次时钟到来时,使得 ticks 加 1,来统计系统时钟触发次数, 借此,为处理器调度提供时间上的辅助。 因此,对于计时器的基本操作包括:(1)初始化系统计时器;(2)定时器累加;(3)获得系统时钟值;
4-7 初始化系统计时器
void init_ticks()
{
ticks=0;
}
4-8 定时器累加 `
void acc_timer()
{
ticks++;
}
4-9 获得定时器值
int get_timer()
{
return ticks;
}
本实验中对操作系统时钟的实现非常简单,但是已经模拟了操作系统内核对于时钟操作 的本质。接下来需要对系统计时器进行测试,可以模拟实现 sleep()的功能,在这里称为 w_delay()来模拟实现让进程延迟睡眠功能。因为进程的PCB中记录delay数据的成员,这时当进程调用w_delay()函数时,将延时的值赋给当前进程的delay变量,接着陷入死循环采用忙等的方法直到delay延时的时间结束,跳出循环,结束延时。
4-10 延迟函数
void w_delay(int n)
{
current->delay=n;
while(1)
{
wait_intr();
if(current->delay==0)
{
break;
}
}
4—11中断处理函数部分代码
关于中断计时和延时及时的函数,当时间片结束后调用shedule()函数切换进程。
if(irq==1000)
{
/* interrupt */
acc_timer();
if(current->delay)
{
current->delay= current->delay-1;
}
if((current->number==0) || (current->number==get_timer()))
{
current->number=0;
schedule();
}
}
进程调度是操作系统的核心之一。进程调度的时机多种多样,就目前我们实现的系统功能,进程调度一般发生在进程被动放弃 CPU,当前进程的时间片用完,或一个进程被唤醒且其优先级高于当前进程的优先级等情况。
①处理当前进程:根据调度切换原因,将当前进程加入到就绪或阻塞队列;
②选择进程运行:扫描就绪队列中的所有就绪进程,从中选择合适的进程运行,将其设为 current,并从就绪队列中移除该进程。
③进程切换:进程上下文切换。
参考 Linux0.12 设计动态优先级调度算法:优先级高的就绪进程先运行,优先级低的就绪进程后运行,优先级相同的进程按轮转方式运行。
下面的结构用来描述基于优先级的进程轮转调度算法在 mcore 中的实现方案。
在 task_struct 结构中,存放于进程调度相关的成员供调度模块使用。
int nice; //进程可控优先因子
int number; //进程目前时间片配额,也称进程动态优先级
4-12 调度算法
void schedule()
{
struct task_struct * old=NULL;
struct task_struct * new=NULL;
struct task_struct * temp=NULL;
ListHead *ptr=NULL;
int flag=1;
/* for(;i<=3;i++) */
list_foreach(ptr,RunableList)
{
temp=list_entry(ptr,struct task_struct,linklist);
if((temp->state!=0)&&temp->number ==0)
continue;
else
{
flag=0;
break;
}
}
ptr=NULL;
if(flag)
{
/* for(;i<=3;i++) */
list_foreach(ptr,RunableList)
{
temp=list_entry(ptr,struct task_struct,linklist);
temp->number=temp->nice;
}
}
if(current->state)
{
old = current;
old->state=RUNNABLE;
}
/* new=&task[1]; */
new=list_entry(RunableList,struct task_struct,linklist);
if(new->state==0)
{
new=&task[0];
new->number=0;
}
ptr=NULL;
list_foreach(ptr,RunableList)
{
temp=list_entry(ptr,struct task_struct,linklist);
if((temp->state!=0)&&(new->number<=temp->number))
new=temp;
}
init_ticks();
current=new;
}
加入退出函数,使得进程可以执行完退出,更符合现实情况。当进程运行结束,需要释放PCB,回收进程资源,将其从就绪队列中删除,调用w_quit()完成。
4—13w_quit()函数
void w_quit()
{
rrent->state=UNUSED;
nextpid=nextpid-1;
list_del(¤t->linklist);
current->number=0;
wait_intr();
}
- 实验验证及分析
测试程序做了一些修改,来测试程序运行,a程序先延迟五个时钟中断,打印20次退出,b程序打印20次退出,c程序打印16次退出,一个时间片15个时钟中断,当全部退出时系统的当前进程为0号进程,且永不结束,一直等待新的进程创建。
void a(){
w_delay(5);
int i=20;
while(i--){
printk("A");
/* printk("%d",current->delay); */
wait_intr();
}w_quit();
}
void b(){
int i=20;
while(i--){
printk("B");
/* printk("%d",current->delay); */
wait_intr();
}w_quit();
}
void c(){
int i=1;
for(i=1; i<=16;i++ ){
printk("C");
/* printk("%d",current->delay); */
wait_intr();
}
w_quit();
}
void test_proc(){
creat_kthread(a,"A_proc");
creat_kthread(b,"B_proc");
creat_kthread(c,"C_proc");
}
《操作系统原理实验指导》 马立肖