作者:于波
原创作品转载请注明出处
参考:《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000
这篇博客是Linux内核分析课程第二周课程的作业,要求是完成一个简单的时间片轮转多道程序系统内核,并分析理解操作系统是如何完成进程启动和进程切换的。
一、实验环境介绍
实验环境的搭建过程可以从mykernel的说明中找到,也可以使用课程实验楼提供的虚拟机来完成。链接中的说明文档已经介绍的很清楚,只要点击链接打开,然后按照上面的步骤一步步做就能很容易把环境建好了。
建好的环境中,有两个已经给我们准备好的文件 mymain.c和myinterrupt.c
他们的内容分别如下所示,这里省略了包含头文件的内容,而只关注函数体部分。
- mymain.c:
- myinterrupt.c:
mymain.c中,my_start_kernel()函数是内核的入口,它执行了一个无限的循环,每次循环中都在一个整数值i上自增1,每增加十万,就输出一行提示来,同时输出i的值;
myinterrupt.c中,my_timer_handler()是系统的计时器的回调函数,每次计时器引发了中断,这个函数就会被调用,现在这个中断处理指示输出了一行文字表明中断处理函数被执行了。
我们来运行一下这个内核来直观地感受下他的执行结果。编译这份内核代码,然后在模拟器下运行,运行界面将如下图所示:
这个简单的程序只有内核入口函数的主循环和中断处理程序在工作。从执行结果也可以看到我们的内核主循环和时钟中断处理程序交替产生的输出。而一个操作系统应该能够同时允许多个进程在其上执行,而我们今天的任务,就是实现一个简单的按照时间片轮转规则,来同时执行多个进程的系统内核。
二、进程描述数据结构
精简的进程描述数据结构定义在头文件mypcb.h中,内容如下所示:
/*
* File: mykernel/mypcb.h
*/
#define MAX_TASK_NUM 10
#define KERNEL_STACK_SIZE (16*1024)
// CPU Register data of a task
struct Thread {
unsigned long eip; // Point to CPU run address
unsigned long esp; // Point to the task stack's top address
// TODO: other attribute of system thread
};
// PCB struct
typedef struct PCB {
int pid; // Task ID
volatile long state; // -1:unrunnable, 0: runnable, >0 stoped
char stack[KERNEL_STACK_SIZE]; // Process stack
struct Thread thread;
unsigned long task_entry; // Task execute entry memory address
struct PCB * next; // Next task, all tasks linked in circle
// TODO: other attribute of process control block
}tPCB;
void my_schedule(void);
首先定义了一个宏MAX_TASK_NUM来控制我们的内核交替执行的进程数量,KERNEL_STACK_SIZE是每个进程在内核中分配的栈空间的大小,这里我们指定了16K的大小。
结构体Thread定义了一些变量来保存进程被挂起时的现场,我们的这一版的内核只保存程序计数器EIP和栈顶指针ESP。然后结构体tPCB定义了控制进程运行需要的一些属性。
最后是进程调度函数的函数声明。
因为这个头文件完全是C程序,注释也比较全面,很容易理解。所以就不多说了。
三、内核入口函数 (mymain.c)
mymain.c函数的内容比较长,为了能看得更清楚,我们一段一段来解释,黄底部分是我们的代码内容。
#include "mypcb.h"
tPCB task[MAX_TASK_NUM];
tPCB * g_current_task = NULL;
volatile int g_need_sched = 0;
这里定义了全局的结构保存系统中的所有进程控制信息,g_current_task是当前正在运行的进程控制块指针,另外定义了全局的变量g_need_sched来表示当前系统的调度状态,当该值为1是表示要求内核执行一次进程的切换。
void my_process(void); // 进程入口函数声明
void __init my_start_kernel(void)
{
int i, pid = 0;
task[pid].pid = pid;
task[pid].state = 0;
task[pid].task_entry = (unsigned long)my_process;
task[pid].thread.eip = (unsigned long)my_process;
task[pid].thread.esp = (unsigned long)&task[pid].stack[KERNEL_STACK_SIZE-1];
task[pid].next = &task[pid];
初始化进程控制块数组的第1块,进程ID设置为0,进程状态设置为0,因为这是我们马上要运行的第一个进程;进程入口task_entry设置为函数my_process,同时程序计数器EIP也设置为函数my_process的入口地址;进程栈顶指针设置为进程控制块数组中预留的栈空间的最高地址,因为栈的增长是向下的;最后将下一个内存控制块指针指向自己,构成一个只有一个进程控制块的环。
for(i = 1; i < MAX_TASK_NUM; i++)
{
memcpy(&task[i], &task[0], sizeof(tPCB));
task[i].pid = i;
task[i].state = -1;
task[i].thread.esp=(unsigned long)&task[i].stack[KERNEL_STACK_SIZE-1];
task[i-1].next = &task[i];
}
task[MAX_TASK_NUM-1].next = &task[0];
这一部分是初始化更多的内存控制块,将后续的其他内存控制块依次初始化,运行状态设置为-1(未准备运行),入口地址也是my_process函数地址,栈顶地址设置为各自的栈空间的最高地址;将上一块控制块的next指针指向当前块,最后在循环外,将最后一个控制块的next指针指向第一块内存控制块,从而使所有的内存控制块构成一个单向的循环列表。
pid = 0;
g_current_task = &task[pid];
当前运行的进程指向第一个进程。
asm volatile(
"movl %1,%%esp\n\t"
"pushl %1\n\t"
"pushl %0\n\t"
"ret\n\t"
"popl %%ebp\n\t"
:
: "c" (task[pid].thread.eip), "d" (task[pid].thread.esp)
);
}
这块汇编代码将使CPU转向第一个进程控制块指定的入口函数去执行,也就是完成了程序启动的功能。下面来分析一下它是如何做到的。分析之前,有必要先简单介绍一下嵌入式汇编的基础知识.
C嵌入汇编的基本格式是asm("汇编语句":输出参数列表:输入参数列表:影响的寄存器)。
上面的代码中,输出列表为空,输入参数有两个,分别是thread.eip和thread.esp。汇编语句中%0表示引用参数列表的第0个参数,即thread.eip,而%1就是引用第一个参数,即thread.esp。而参数列表中的"c"和"d"分别表示将引用变量的值加载进ECX和EDX寄存器。更详细的列表可以通过关键字”C嵌入式汇编“很容易在网络上获得。
有了这些知识就可以回头来看我们的汇编代码了。
movl %1,%%esp 将内存控制块中的ESP的值赋给ESP寄存器,结合之前的PCB初始化代码,这句实际是用进程的栈空间的最高地址初始化ESP寄存器;
pushl %1 再次访问我们的栈顶地址值,并将这个值保存到栈上;
后面的两句汇编应该合在一起看,
pushl %0
ret
首先把内存控制块中的EIP压入栈,然后用ret指令将该值从栈上弹出并赋值给EIP寄存器,由此间接实现了改变程序计数器EIP的功能,让我们的CPU跳转到指定的函数地址去执行。而这里我们修改的EIP的值,是在初始化阶段设置的函数my_process函数入口地址。因此我们的内核通过上面的四句代码就可以实现进程栈指针初始化,并跳转到该进程的入口函数执行的功能了。
void my_process(void)
{
printk(KERN_NOTICE ">>>> Within task: %d <<<<\n", g_current_task->pid);
int i = 0;
while(1)
{
i++;
if ( i % 10000 == 0 )
{
if(g_need_sched == 1)
{
g_need_sched = 0;
my_schedule();
}
}
}
}
这是我们进程的入口函数,在进程刚开始的时候会输出当前进程的进程ID,随后就进入一个无限循环,每循环1000次,检查一下全局的调度标志,如果发现系统指示要进行进程调度,则执行一次调度函数my_schedule(),同时清空系统的进程调度标志。
这个调度函数实现在myinterrupt.c中,后面会解释。
四、中断处理程序 (myinterrupt.c)
#include "mypcb.h"
extern tPCB task[MAX_TASK_NUM];
extern tPCB * g_current_task;
extern volatile int g_need_sched;
引用的外部进程控制块数组和当前运行的进程指针,以及全局的调度标志。
volatile int time_count = 0;
// Called by timer interrupt.
void my_timer_handler(void)
{
if(time_count % 1000 == 0 && g_need_sched != 1)
{
printk(KERN_NOTICE ">>>> Timer handler set schedule flag here <<<<\n");
g_need_sched = 1;
}
time_count++;
return;
}
这个中断处理函数会被系统时钟周期性的调用,每次被调用,就增加一个计数器的值,每增加1000,就检查一次全局调度标志,如果标志还没有被设置,就设置该标志位1,并输出一些信息来标识时钟终端处理函数设置了调度标志。 一旦这个全局调度标志被设置,在mymain.c的my_process函数中就会检测到,然后下面的my_schedule函数就将被调用。
void my_schedule(void)
{
tPCB * next;
tPCB * prev;
if( g_current_task == NULL || g_current_task->next == NULL)
{
// No Running task or only have one running task, don't need schedule
return;
}
printk(KERN_NOTICE ">>>> Schedule tasks here <<<<\n");
next = g_current_task->next;
prev = g_current_task;
if( next->state == 0 ) // Task state is Runnable
{
printk(KERN_NOTICE ">>>>>> Next task state is Runnable <<<<<<\n");
asm volatile (
"pushl %%ebp\n\t"
"movl %%esp,%0\n\t"
"movl %2,%%esp\n\t"
"movl $1f,%1\n\t"
"pushl %3\n\t"
"ret\n\t"
"1:\t"
"popl %%ebp\n\t"
: "=m" (prev->thread.esp), "=m" (prev->thread.eip)
: "m" (next->thread.esp), "m" (next->thread.eip)
);
g_current_task = next;
printk(KERN_NOTICE ">>>> Switch from task %d to %d <<<<\n",
prev->pid,next->pid);
}
else
{
next->state = 0;
g_current_task = next;
printk(KERN_NOTICE ">>>> Switch from task %d to new task %d <<<<\n",
prev->pid, next->pid);
asm volatile(
"pushl %%ebp\n\t"
"movl %%esp,%0\n\t"
"movl %2,%%esp\n\t"
"movl %2,%%ebp\n\t"
"movl $1f,%1\n\t"
"pushl %3\n\t"
"ret\n\t"
: "=m" (prev->thread.esp), "=m" (prev->thread.eip)
: "m" (next->thread.esp), "m" (next->thread.eip)
);
}
return;
}
这个函数将全局的当前运行进程指针指向链表中的下一个进程,然后查看新进程的运行状态,如果是可运行的(state==0),则执行下面一段汇编,并把进程的可运行状态设置为可运行状态(next->state = 0;):asm volatile (
"pushl %%ebp\n\t"
"movl %%esp,%0\n\t"
"movl %2,%%esp\n\t"
"movl $1f,%1\n\t"
"pushl %3\n\t"
"ret\n\t"
"1:\t"
"popl %%ebp\n\t"
: "=m" (prev->thread.esp), "=m" (prev->thread.eip)
: "m" (next->thread.esp), "m" (next->thread.eip)
);
而如果新进程的可运行状态不是可运行的,表示进程是第一次被启动,就执行下面一段汇编代码:
asm volatile(
"pushl %%ebp\n\t"
"movl %%esp,%0\n\t"
"movl %2,%%esp\n\t"
"movl %2,%%ebp\n\t"
"movl $1f,%1\n\t"
"pushl %3\n\t"
"ret\n\t"
: "=m" (prev->thread.esp), "=m" (prev->thread.eip)
: "m" (next->thread.esp), "m" (next->thread.eip)
);
这两段代码其实很相似,差别只在最后两行上。我们只从第一段分析来入手,第一段代码看懂之后,第二段也会很容易理解了。
代码的第一行将当前的EBP压栈;然后第二行将当前的ESP值存入被换出的进程的现场状态变量thread.esp中;第三行是将被换入进程的esp值加载到当前的ESP寄存器中,也就是切换到被换入的进程的栈空间上去;第四行比较特殊,这个常数1f比较特殊,它其实是一条汇编指令,表示跳转到标号为1的行去继续执行,也就是这段代码的最后两行处,而这条汇编是保存到了被换出进程的保存现场的内存空间thread.eip中,也就是说,这条指令就是下一次已经执行过的进程别换入是将跳转到的代码;第五行和第六行前面已经分析过,他们合起来实现了间接修改EIP寄存器的目的,而被修改的值,就是保存在进程的现场中的EIP值,由此实现了将程序跳转到被换入的程序去执行的功能。
对被换入的进程,当第一次被换入时,thread.eip的值等于函数my_process的入口地址;如果一个进程之前被调用过,那么thread.eip就已经被修改成了1f,所以下次再次被换入的时候就会执行这段代码的最后两行,也就是“popl %%ebp”,即将栈上的值出栈并赋值给寄存器EBP,而这正是第一行中保存在栈中的EBP值。
这些代码执行完之后去了哪里呢?别忘了,我们现在是在my_schedule中,而现在已经是这个函数的末尾了,所以下面我们退出my_shedule函数了,回到了my_process继续执行计数器加一,继续检测全局调度标志的循环中了。
五、中断处理程序的切换
上面我们的代码实现的只是在我们自定义的10个用户进程中切换的功能,而中断处理程序和内核入口函数所在的进程是如何切换的呢?其实原理是一样的,无非也是保存当前的现场,将CPU状态设置为被换入的进程的信息,其中最主要的信息是EIP和ESP,像上面我们的切换程序所示的;而更复杂的程序还需要保存其他可能会被修改的寄存器,而这些Linux的中断处理程序都已经给我们处理好了。
关于这个简单的时间片轮转切换进程的内核就解释这么多了,鄙人才疏学浅,有理解错误之处望多多指教。