学号131
原创作品,转载请注明出处。
本实验资源来源: https://github.com/mengning/linuxkernel/
实验环境
Ubuntu 18.04 虚拟机
VMware Workstation Pro 15.0.2 for Windows
实验目的
-
分析进程的启动和进程的切换机制
-
加深对操作系统工作原理的理解
-
动手编写简单的内核
实验步骤
安装qemu,利用命令sudo apt-get install qemu. Qemu是一套可以在Windows操作系统中仿真出另一套操作系统的仿真软件.
利用sudo ln -s /usr/bin/qemu-system-i386 /usr/bin/qemu代码实现qemu的软链接,类似于windows的快捷方式,此时我们可以在终端下输入qemu进行使用了。
下载并安装linux-3.9.4的内核与相应的补丁
-
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
需要学习的是:patch的生成以及如何打一个补丁。 可以参考以下链接:Linux打Patch的方法、补丁(patch)的制作与应用
进行编译
-
make allnoconfig
-
make
此处需要注意的是,由于使用的内核版本较老可能会出现缺少如下图所示的缺少相应的gcc文件的情况无法编译
查找对应目录的文件可以发现有以下几个文件
因此可以将compiler-gcc4.h的文件名重新命名为compiler-gcc7.h,为了以防万一将源文件先进行复制备份操作。命令如下:
Sudo cp compiler-gcc4.h compiler-gcc4.h.bak
Sudo mv compiler-gcc4.h compiler-gcc7.h
修改后,成功进行了编译
利用qemu启动内核
另外,若出现无法关闭qemu的情况,可用下面这条命令:
ps -A | grep qemu | awk '{print $1}' | xargs sudo kill -9
原生代码中mymain.c的核心部分如下:
void __init my_start_kernel(void)
{
int i = 0;
while(1)
{
i++;
if(i%100000 == 0)
printk(KERN_NOTICE "my_start_kernel here %d \n",i);
}
}
而myinterrupt.c的核心部分,作为时钟中断的中断处理函数
/*
* Called by timer interrupt.
*/
void my_timer_handler(void)
{
printk(KERN_NOTICE "\n>>>>>>>>>>>>>>>>>my_timer_handler here<<<<<<<<<<<<<<<<<<\n\n");
}
至于内核的具体运行过程,初始化硬件的操作如何,在my_start_kernel之前代码都已经实现了。接下的学习中,将具体介绍如何从启动到内核运行的过程。
实验的要求是让我们在两个文件的基础上实现一个简单的操作系统内核。
接下来将老师提供的实例代码拷贝到自己的机器中进行运行,重新进行make,对整个过程进行相应的深入理解。
具体操作为:
1、修改mymain.c、myinterrupt.c中的代码为老师所提供的代码
2、增加mypcb.h文件然后使用make allnoconfig和make命令进行运行
在第一次make时出现了如下错误
经分析是原代码中的
#unsigned long
多余了,删除后直接对程序进行
make
即可跑通程序
实验的相应截图如下:
此处由于原代码的循环输出过快,将原代码中的循环次数由
10000000
增加为了
10000000
次循环,以方便观察。
注意到这里的输出结果变化是有两种情况的。第一次运行该程序
时,进程
1
到
2
的切换中输出的是
2-
和
2+
。但是当程序循环运行了一次后,再次进行进程的切换时,从进程
3
到
0
是直接输出了
0+
的这和程序内部的汇编代码的执行有关系,后面会详细介绍。
代码分析
首先我们来看mypcb.h这个文件
/* CPU-specific state of this task */
struct Thread {
unsigned long ip;
unsigned long sp;
};
typedef struct PCB{
int pid;
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
char stack[KERNEL_STACK_SIZE];
/* CPU-specific state of this task */
struct Thread thread;
unsigned long task_entry;
struct PCB *next;
这段头文件定义了任务控制块应有的几个关键的变量,首先是线程(Thread)结构体中的sp和Ip两个变量,sp指向的是任务运行时的。其次在PCB中定义了任务的id号,以及任务的三种不同状态,任务的入口,数组栈,以及一个单向的任务链表。
当然,从实现的角度来看该程序可以采用双向链表的方式,引入prev指针来进行进一步的优化操作。
文件mymain.c的分析如下:
#include <linux/types.h>
#include <linux/string.h>
#include <linux/ctype.h>
#include <linux/tty.h>
#include <linux/vmalloc.h>
#include "mypcb.h"
tPCB task[MAX_TASK_NUM];
tPCB * my_current_task = NULL;
volatile int my_need_sched = 0;
void my_process(void);
void __init my_start_kernel(void)
{
int pid = 0;
int i;
/* Initialize process 0*/
task[pid].pid = pid;
task[pid].state = 0;/* -1 unrunnable, 0 runnable, >0 stopped */
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];
首先这一段代码初始化了pid=0;定义了0号进程,再说明了代码的运行状态为可执行。
task[pid].task_entry = task[pid].thread.ip = (unsigned long)my_process
说明了0号
进程
的入口为My_process
task[pid].thread.sp = (unsigned long)&task[pid].stack[KERNEL_STACK_SIZE-1]
由于栈是向下进行增长的因此为进程0的sp指向最大的地址。
/*fork more process */
for(i=1;i<MAX_TASK_NUM;i++)
{
memcpy(&task[i],&task[0],sizeof(tPCB));
task[i].pid = i;
//*(&task[i].stack[KERNEL_STACK_SIZE-1] - 1) = (unsigned long)&task[i].stack[KERNEL_STACK_SIZE-1];
task[i].thread.sp = (unsigned long)(&task[i].stack[KERNEL_STACK_SIZE-1]);
task[i].next = task[i-1].next;
task[i-1].next = &task[i];
}
此处的代码目的在于初始化一个大小为MAX_TASK_NUM的单向链表,利用的是前面进程0先进行相应的赋值然后再对每个任务进行各自的初始化操作,最后将整个任务链表单向链接起来。
/* 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 */
:
: "c" (task[pid].thread.ip),"d" (task[pid].thread.sp) /* input c or d mean %ecx/%edx*/
);
这一段代码完成了任务栈的全部的形成过程,其中%1代表的是
task[pid].thread.sp,代码中的第一行将当前要运行的相应的任务的sp指针给压入CPU的esp寄存器之中,然后再把sp给进行压栈的操作。这可以类比为常规的push %ebp; movl %esp, %ebp;的操作。
"pushl %0\n\t"
将eip压入了栈中,然后利用ret弹栈到eip(这样操作的原因在于x86中eip的指针是无法直接进行修改的因此只好采用这样的方法),通过以上的代码,pid=0这个线程就开始了运行。
此处没有对开始压入的ebp进行弹出操作时因为该任务时不允许退出的,因此不需要实现对ebp的pop操作。
void my_process(void)
{
int i = 0;
while(1)
{
i++;
//10000000次循环之后才检查是否有任务需要调度
if(i%10000000 == 0)
{
printk(KERN_NOTICE "this is process %d -\n",my_current_task->pid);
if(my_need_sched == 1)
{
my_need_sched = 0;
my_schedule();
}
printk(KERN_NOTICE "this is process %d +\n",my_current_task->pid);
}
}
这一段代码的含义较为简单,首先通过i%1000000==0这个判断的条件来定期的输出相应的内容
然后通过标志位my_need_sched来判断要不要进行进程的调度操作,my_need_sched是在文件myinterrupt.c中通过函数
my_timer_handler来实现的,linux内核在运行的过程中会周期性的调用my_timer_handler这个函数。
进程的调度核心操作在于my_schedule这个函数,调度的方法很多,这里是在有任务可以调度的情况下,对一个任务进行调度。
下面将对myinterrupt.c这个文件进行相应的分析处理。
我们分析其中的核心调度函数
void my_schedule(void)
if(next->state == 0)/* -1 unrunnable, 0 runnable, >0 stopped */
{
my_current_task = next;
printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid);
/* 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)
);
首先
判断一下下一个任务的状态是否为正常(此处由于初始化状态下均为0,所以这个判断在这边是不起作用的)。
其中核心为其汇编的代码:
"pushl %%ebp\n\t" /* save ebp */
这个代码用于保存当前进程的ebp,因为此时esp仍然在当前的进程A的堆栈中
"movl %%esp,%0\n\t" /* save esp */
将esp保存到A的thread.sp之中,这样做保证在调用my_shcedule的时候,prev是指向当前A进程的进程描述中的
"movl %2,%%esp\n\t" /* restore esp */
从下一个进程(进程B)中拿出之前保留在B中的esp,从这个时候开始CPU执行的内容已经是B进程了,因为esp指向了B的栈区,但是目前ebp仍然是指向A的栈区的。
"movl $1f,%1\n\t" /* save eip */
他表明当下一次A进程被my_shcedule再次调度回来的时候,会从下面的"1:\t" 开始进行相应的执行。
"pushl %3\n\t"
这里要分两种情况进行讨论,如果B进程在之前被调度过了,那么这里的next->thread.ip指向的就是1f的标号地址,如果进程B在之前没有被调度过的话,那么这里存的就是Myprocess这个函数地址。
"ret\n\t" /* restore eip */
根据上面讨论得到的结果,如果next->thread.ip指向的是1f的标号地址那么程序将继续向下运行,如果next->thread.ip指向的是myprocess这个函数的话,将直接跳转到myprocess函数中,下面的两行代码是不会运行的。
"1:\t" /* next process start here */
"popl %%ebp\n\t"
如果代码运行到了这两行,说明进程B之前被调用过了,B的ebp信息被备份在了B进程之中,这里执行相应的恢复操作。
总的来看这个代码的堆栈变化过程可以用以下图片表示:
通过上面的说明,解释了最开始实验运行中进程切换的两种不同状态出现的原因。
这段代码的关键在于这个过程中存在着两个堆栈,在代码的运行期间,有一个时期esp和ebp不在同一个堆栈上。这里只需要记住一个基本的原则,pop/push操作都是对esp所指向的堆栈而言的,这也会改变esp本身,除此之外的其他变量的引用都是对ebp所指向的堆栈进行的。
实验总结
通过以上的汇编代码可以将整个Linux系统最一般的运行过程概括为如下步骤:
1、进程1正在用户态进行运行
2、发生中断,系统开始保护现场(三大关键寄存器:程序的入口cs:eip、栈顶指针esp、标志位的指针eflags)
3、系统在中断的过程中调用了my_schedule函数,完成了进程切换
4、在进程切换的过程中运行到了标号1(即1f那一行的代码)就开始正式运行进程2了(假设进程2之前已经被调度过一次了)
5、完成对进程2的相关寄存器值的恢复操作
6、进行用户态进程2的运行
参考资料
《Linux内核设计与实现》第三版