基于mykernel完成时间片轮询多道进程的简单内核

原创作品转载请注明出处+中科大孟宁老师的linux操作系统分析:https://github.com/mengning/linuxkernel/

操作系统概述

操作系统中,OS的任务有很多,比如内存管理、文件管理、进程管理等等,而这其中,进程的管理是至关重要的,因为只有实现了进程的管理,才能使得一个操作系统可以处理多种任务,通过进程的切换,可以从逻辑上实现任务的多个进程任务并行操作,使得操作系统可以更高效的服务于应用服务。本实验着重基于linuxkernel实现一个时间片轮转多道程序内核代码

实验环境

Ubuntu 18.04、vim、gcc、vscode、QEMU

实验步骤

首先安装QEMU工具,后面需要它显示内核执行的过程,可以UI展示

1
2
sudo apt-get install qemu # install QEMU
sudo ln -s /usr/bin/qemu-system-i386 /usr/bin/qemu

然后下载基于3.9.4版本的linux内核和孟老师基于内核裁剪出来的补丁(给孟宁老师实力点赞),然后进行解压和打补丁

1
2
3
4
5
6
wget https://www.kernel.org/pub/linux/kernel/v3.x/linux-3.9.4.tar.xz # download Linux Kernel 3.9.4 source code
wget https://raw.github.com/mengning/mykernel/master/mykernel_for_linux3.9.4sc.patch # download mykernel_for_linux3.9.4sc.patch
xz -d linux-3.9.4.tar.xz
tar -xvf linux-3.9.4.tar
cd linux-3.9.4
patch -p1 < ../mykernel_for_linux3.9.4sc.patch

然后就是编译源码,c语言里面可以通过强大的make实现编译,先预处理编译文件,在make编译

1
2
make allnoconfig
make

在这一步的时候,会报错,见下图,由于环境没有gcc7,通过网络,定位可以通过将linux-3.9.4/include/linux/下面的

1
2
cd /include/linux/
cp compiler-gcc4.h compiler-gcc5.h

然后在编译成功,如下图

我们再使用qemu工具来显示代码执行的UI过程

1
qemu -kernel arch/x86/boot/bzImage

可见我们的内核已经启动成功了,接下来我们就基于孟宁老师的项目代码去实现一个时间轮转多道程序。
我们先git clone git@github.com:mengning/mykernel.git代码到本地,然后将其中的myinterrupt.c,mymain.c,mypcb.h三个代码替换上面mykernel文件夹下对于的三个,替换前最好先像cp mymain.c mymain-bak.c这样备份一下,其中要把mypcb.h中的一行代码修改一下:

1
#define KERNEL_STACK_SIZE (unsigned long)1024*8

替换完之后,在make重新编译一下,这一次编译是让替换之后的代码重新编译,编译速度也会比上次快很多,因为是增量编译。

编译完之后,重新执行一下,输入qemu -kernel arch/x86/boot/bzImage
结果见下图

上图可以看出,时间片轮询多道程序已经实现了,总共有4个进程,pid分别为0,1,2,3;
下一节我们来分析代码,分析一下孟宁老师给出的简洁高效的实现时间片轮询多道进程的代码,基本深入浅出的阐明了基本工作原理。

源码分析

mypcb.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define MAX_TASK_NUM 10 // max num of task in system
#define KERNEL_STACK_SIZE (unsigned) long1024*8
#define PRIORITY_MAX 30 //priority range from 0 to 30
struct Thread {
unsigned long ip;//point to cpu run address
unsigned long sp;//point to the thread stack's top address
//todo add other attrubte of system thread
};
//PCB Struct
typedef struct PCB{
int pid; // pcb id
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
char stack[KERNEL_STACK_SIZE];// each pcb stack size is 1024*8
/* CPU-specific state of this task */
struct Thread thread;
unsigned long task_entry;//the task execute entry memory address
struct PCB *next;//pcb is a circular linked list
unsigned long priority;// task priority ////////
//todo add other attrubte of process control block
}tPCB;
//void my_schedule(int pid);
void my_schedule(void);

首先是mypcb.h,其中定义了两个结构和一个函数。第一个是结构Thread,里面有两个变量,ip和sp用于保存现场。
第二个是结构PCB,PCB结构定义了进程管理块,包括6各变量:
(1)pid进程标识符;
(2)state状态,-1表示不可运行,0表示可运行,>0表示停止;
(3)定义了一个栈空间;
(4)一个Thread变量;
(5)任务入口点;
(6)下一个PCB的指针;
还定义了一个my_schedule函数,以及两个宏定义。

mymain.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
tPCB task[MAX_TASK_NUM];
tPCB * my_current_task = NULL;
volatile int my_need_sched = 0;
void my_process(void);
unsigned long get_rand(int );
void sand_priority(void)
{
int i;
for(i=0;i<MAX_TASK_NUM;i++)
task[i].priority=get_rand(PRIORITY_MAX);
}
void __init my_start_kernel(void)
{
int pid = 0;
/* Initialize process 0*/
task[pid].pid = pid;
task[pid].state = 0;/* -1 unrunnable, 0 runnable, >0 stopped */
// set task 0 execute entry address to my_process
task[pid].task_entry = task[pid].thread.ip = (unsigned long)my_process;
task[pid].thread.sp = (unsigned long)&task[pid].stack[KERNEL_STACK_SIZE-1];
task[pid].next = &task[pid];
/*fork more process */
for(pid=1;pid<MAX_TASK_NUM;pid++)
{
memcpy(&task[pid],&task[0],sizeof(tPCB));
task[pid].pid = pid;
task[pid].state = -1;
task[pid].thread.sp = (unsigned long)&task[pid].stack[KERNEL_STACK_SIZE-1];
task[pid].priority=get_rand(PRIORITY_MAX);//each time all tasks get a random priority
}
task[MAX_TASK_NUM-1].next=&task[0];
printk(KERN_NOTICE "\n\n\n\n\n\n system begin :>>>process 0 running!!!<<<\n\n");
/* start process 0 by task[0] */
pid = 0;
my_current_task = &task[pid];
asm volatile(
"movl %1,%%esp\n\t" /* set task[pid].thread.sp to esp */
"pushl %1\n\t" /* push ebp */
"pushl %0\n\t" /* push task[pid].thread.ip */
"ret\n\t" /* pop task[pid].thread.ip to eip */
"popl %%ebp\n\t"
:
: "c" (task[pid].thread.ip),"d" (task[pid].thread.sp) /* input c or d mean %ecx/%edx*/
);
}
void my_process(void)
{
int i = 0;
while(1)
{
i++;
if(i%10000000 == 0)
{
if(my_need_sched == 1)
{
my_need_sched = 0;
sand_priority();
my_schedule();
}
}
}
}//end of my_process
//produce a random priority to a task
unsigned long get_rand(max)
{
unsigned long a;
unsigned long umax;
umax=(unsigned long)max;
get_random_bytes(&a, sizeof(unsigned long ));
a=(a+umax)%umax;
return a;
}

首先定义了3个全局变量,两个PCB结构,一个是所有的进程集合,一个是当前的进程。然后是两个函数,my_process和my_start_kernel。
我们重点来看一下my_start_kernel函数,函数分为三部分:
第一部分,是初始化进程0。pid代表了进程号,0是第一个。state代表运行状态,初始化为可运行。Thread的ip就是进程入口点,其实就是进程运行的起点。sp实际上是定义了一段进程的栈空间。最后定义了下一个PCB的链接先指向自己。
第二部分,是根据第一个进程0初始化余下的进程。因为我们设置最大进程数为4,所以这里实际上是设置了进程1-3的数据结构的值。
最后一个部分,是从进程0号开始运行。这里使用了内联汇编编程,实际上就是将进程0的thread.sp的值赋给esp,将当前运行的地址保存到栈中,这样如果切换的话就可以保证下一个进程结束时回到原来的位置执行。总而言之,my_start_kernel函数实现了定义进程数组,并运行第一个进程。
对于my_process函数来说:
就是建立一个循环不断运行进程,并输出表明进程正在运行的语句。这里注意有一个my_schedule()函数,实际上这个函数是在myinterrupt.c中实现的,主要作用是切换进程。

myinterrupt.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
void my_schedule(void)
{
tPCB * next;
tPCB * prev;
// if there no task running or only a task ,it shouldn't need schedule
if(my_current_task == NULL
|| my_current_task->next == NULL)
{
printk(KERN_NOTICE " time out!!!,but no more than 2 task,need not schedule\n");
return;
}
/* schedule */
next = get_next();
prev = my_current_task;
printk(KERN_NOTICE " the next task is %d priority is %u\n",next->pid,next->priority);
if(next->state == 0)/* -1 unrunnable, 0 runnable, >0 stopped */
{//save current scene
/* switch to next process */
asm volatile(
"pushl %%ebp\n\t" /* save ebp */
"movl %%esp,%0\n\t" /* save esp */
"movl %2,%%esp\n\t" /* restore esp */
"movl $1f,%1\n\t" /* save eip */
"pushl %3\n\t"
"ret\n\t" /* restore eip */
"1:\t" /* next process start here */
"popl %%ebp\n\t"
: "=m" (prev->thread.sp),"=m" (prev->thread.ip)
: "m" (next->thread.sp),"m" (next->thread.ip)
);
my_current_task = next;//switch to the next task
printk(KERN_NOTICE " switch from %d process to %d process\n >>>process %d running!!!<<<\n\n",prev->pid,next->pid,next->pid);
}
else
{
next->state = 0;
my_current_task = next;
printk(KERN_NOTICE " switch from %d process to %d process\n >>>process %d running!!!<<<\n\n\n",prev->pid,next->pid,next->pid);
/* switch to new process */
asm volatile(
"pushl %%ebp\n\t" /* save ebp */
"movl %%esp,%0\n\t" /* save esp */
"movl %2,%%esp\n\t" /* restore esp */
"movl %2,%%ebp\n\t" /* restore ebp */
"movl $1f,%1\n\t" /* save eip */
"pushl %3\n\t"
"ret\n\t" /* restore eip */
: "=m" (prev->thread.sp),"=m" (prev->thread.ip)
: "m" (next->thread.sp),"m" (next->thread.ip)
);
}
return;
}//end of my_schedule

首先定义了一些全局变量。然后主要实现了两个函数:my_time_handler和my_schedule,其中my_time_handler实现了中断,而my_schedule实现了中断之后进程的切换。
my_time_hander()这个函数也很简单,就是每1000毫秒的时候产生一个中断,产生中断之后把my_need_sched设置为1,这样mymain.c中的my_process函数就会调用my_schedule函数来进行进程切换。
my_schedule()实现了时间片轮转的中断处理过程。首先是初始化next和prev两个PCB结构。然后是循环运行代码,就是当下一个进程的state状态是可运行时,说明这个进程之前已经在运行了,此时可以继续执行,就切换到下一个进程,中间有一段内联汇编,实现了保存栈地址和栈指针,这样进程切换回来的时候就可以正常运行。然后根据之前保存的栈地址恢复执行。
当下一个进程的state不为0时,那么也就是说下一个进程还从来都没有执行过,所以这一段内联汇编的作用是开始执行一个新进程。总而言之,在上述函数的作用下,成功地实现了时间片轮转的中断处理内核的功能。通过分析源码,我们了解到时间片轮转算法的具体方法,即每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。调度程序所要做的就是维护一张就绪进程列表,当进程用完它的时间片后,它被移到队列的末尾。这是一种最古老,最简单,最公平且使用最广的算法。

实验分析

当mykernel自制操作系统启动后,即qemu命令执行后,系统会首先调用mymain产生一个进程,该进程内while循环条件恒为1所以永远执行输出“my_start_kernel here”+累计循环次数,没有结束的时候。此时由系统时钟中断触发调用myinterrupt产生一个新进程,并输出“my_timer_handler here”,结束后返回mymain产生的进程继续执行,等待下一次系统时钟中断触发调用myinterrupt产生一个新进程,myinterrupt产生的新进程和上一次的属于不同的进程,而mymain由于循环没有终止的时候,所以永远是原来的那个进程。通过观察可以发现,当执行myinterrupt后返回mymain时,终端输出的累计循环次数是连续的,并没有中断或重置,说明CPU和内核代码共同实现了保存现场和恢复现场的功能,会将一些重要的寄存器,比如eip、ebp、esp等保存下来,等待切换回来的时候继续执行。

实验总结

通过该实验操作,成功实现了一个简单的时间片轮转多道程序,通过一个精简的操作系统内核,完成了一个简单的操作系统功能。
计算机有三个法宝:存储程序计算机、函数调用堆栈、中断操作系统
有两把宝剑:中断上下文、进程上下文切换由于CPU只有一套寄存器,同一时间只能处理一个进程(暂且只考虑单核CPU),所以理论上只能单任务顺序执行
但基于三个法宝和两把宝剑,操作系统得以实现多任务操作。