基于mykernel 2.0编写一个操作系统内核
1 实验内容
(1)按照https://github.com/mengning/mykernel 的说明配置mykernel 2.0,熟悉Linux内核的编译;
(2)基于mykernel 2.0编写一个操作系统内核,参照https://github.com/mengning/mykernel 提供的范例代码
(3)简要分析操作系统内核核心功能及运行工作机制
2 实验环境准备工作
(1)本机环境
虚拟机:VMware® Workstation 12 Pro
Linux:ubuntu-18.04.4-desktop-amd64
3 配置并编译mykernel 2.0(参照https://github.com/mengning/mykernel说明配置mykernel 2.0)
(1)下载内核文件,补丁,编译并在QEMU中模拟启动运行内核
在终端中依次执行一下指令
wget https://raw.github.com/mengning/mykernel/master/mykernel-2.0_for_linux-5.4.34.patch
sudo apt install axel
axel -n 20 https://mirrors.edge.kernel.org/pub/linux/kernel/v5.x/linux-5.4.34.tar.xz
xz -d linux-5.4.34.tar.xz
tar -xvf linux-5.4.34.tar
cd linux-5.4.34
patch -p1 < ../mykernel-2.0_for_linux-5.4.34.patch
sudo apt install build-essential gcc-multilib libncurses-dev bison flex libssl-dev libelf-dev
sudo apt install qemu # install QEMU
make defconfig # Default configuration is based on 'x86_64_defconfig'
make -j$(nproc)
qemu-system-x86_64 -kernel arch/x86/boot/bzImage
上图所示打好补丁的内核使用默认配置,编译成功
内核在QEMU中成功启动,从QEMU窗口中可以看到my_start_kernel在执行,同时my_timer_handler时钟中断处理程序也在周期性执行。
(2)分析代码
/* mymain.c */
void __init my_start_kernel(void)
{
int i = 0;
while(1)
{
i++;
if(i%100000 == 0)
pr_notice("my_start_kernel here %d \n",i);
}
}
/*
* myinterrupt.c
* Called by timer interrupt.
*/
void my_timer_handler(void)
{
pr_notice("\n>>>>>>>>>>>>>>>>>my_timer_handler here<<<<<<<<<<<<<<<<<<\n\n");
}
在mymain.c中,my_start_kernel根据变量i的计数值,每循环累加到100000的整数倍就会打印输出“my_start_kernel here”;在myinterrupt.c中,my_timer_handler时钟中断处理程序也在周期性执行,打印输出“my_timer_handler here”。
4 基于mykernel 2.0编写一个操作系统内核(参照https://github.com/mengning/mykernel 提供的范例代码)
(1)首先进入mykernel目录,并在该目录下增加一个mypcb.h 头文件,用来定义进程控制块(Process Control Block),也就是进程结构体的定义,在Linux内核中是struct tast_struct结构体。
/* mypcb.h */
#define MAX_TASK_NUM 4
#define KERNEL_STACK_SIZE 1024*8
/* 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;
}tPCB;
void my_schedule(void);
头文件中声明了一个Thread结构体,用来保存当前进程(线程)上下文,包括CPU状态相关的RIP(指令指针)和RSP(堆栈栈顶)寄存器的值。
声明了进程控制块PCB结构体,其中包含有:
pid,进程号,用于唯一标识一个进程
state,用于标记当前进程状态
stack,进程内核栈,用来存储进程上下文信息
thread,保存当前进程(线程)寄存器RIP和RSP的值
task_entry,进程执行体的函数入口
next,用于指向下一个PCB结构体的指针
最后声明了调度函数。
(2)对mymain.c进行修改,这里是mykernel内核代码的入口,负责初始化内核的各个组成部分。在Linux内核源代码中,实际的内核入口是init/main.c中的start_kernel(void)函数。同时,在mymain.c中添加了my_process函数,用来作为进程的代码模拟一个个进程,只是我们这里采用的是进程运行完一个时间片后主动让出CPU的方式,没有采用中断的时机完成进程切换。
/* 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];
/*fork more process */
for(i=1;i<MAX_TASK_NUM;i++)
{
memcpy(&task[i],&task[0],sizeof(tPCB));
task[i].pid = i;
task[i].state = 0;
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];
}
/* start process 0 by task[0] */
pid = 0;
my_current_task = &task[pid];
asm volatile(
"movq %1,%%rsp\n\t" /* set task[pid].thread.sp to rsp */
"pushq %1\n\t" /* push rbp */
"pushq %0\n\t" /* push task[pid].thread.ip */
"ret\n\t" /* pop task[pid].thread.ip to rip */
:
: "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)
{
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);
}
}
}
可以看到,my_start_kernel(void)首先初始化了0号进程,作为所有进程的祖先进程,并依次复制出(fork)其他子进程,之后CPU控制权交给0号进程,其中内嵌的汇编代码就是启动0号进程。
asm volatile(
"movq %1,%%rsp\n\t" /* set task[pid].thread.sp to rsp */
"pushq %1\n\t" /* push rbp */
"pushq %0\n\t" /* push task[pid].thread.ip */
"ret\n\t" /* pop task[pid].thread.ip to rip */
:
: "c" (task[pid].thread.ip),"d" (task[pid].thread.sp) /* input c or d mean %ecx/%edx*/
);
movq %1,%%rsp 将RSP寄存器指向进程0的堆栈栈底,task[pid].thread.sp初始值即为进程0的堆栈栈底。
pushq %1 将当前RBP寄存器的值压栈,因为是空栈,所以RSP与RBP相同。这里简化起见,直接使用进程的堆栈栈顶的值task[pid].thread.sp,相应的RSP寄存器指向的位置也发生了变化,RSP = RSP - 8,RSP寄存器指向堆栈底部第一个64位的存储单元。
pushq %0 将当前进程的RIP(这里是初始化的值my_process(void)函数的位置)入栈,相应的RSP寄存器指向的位置也发生了变化,RSP = RSP - 8,RSP寄存器指向堆栈底部第二个64位的存储单元。
ret 将栈顶位置的task[0].thread.ip,也就是my_process(void)函数的地址放入RIP寄存器中,相应的RSP寄存器指向的位置也发生了变化,RSP = RSP + 8,RSP寄存器指向堆栈底部第一个64位的存储单元。
这样完成了进程0的启动,开始执行my_process(void)函数的代码。
(3)对myinterrupt.c进行修改,my_timer_handler(void)用来记录时间片,当前进程时间片用完后就会自动调用进程调度函数,执行进程调度。增加进程切换的代码my_schedule(void)函数,在Linux内核源代码中对应的是schedule(void)函数。
/* myinterrupt.c */
#include <linux/types.h>
#include <linux/string.h>
#include <linux/ctype.h>
#include <linux/tty.h>
#include <linux/vmalloc.h>
#include "mypcb.h"
extern tPCB task[MAX_TASK_NUM];
extern tPCB * my_current_task;
extern volatile int my_need_sched;
volatile int time_count = 0;
/*
* Called by timer interrupt.
*/
void my_timer_handler(void)
{
if(time_count%1000 == 0 && my_need_sched != 1)
{
printk(KERN_NOTICE ">>>my_timer_handler here<<<\n");
my_need_sched = 1;
}
time_count++;
return;
}
void my_schedule(void)
{
tPCB * next;
tPCB * prev;
if(my_current_task == NULL
|| my_current_task->next == NULL)
{
return;
}
printk(KERN_NOTICE ">>>my_schedule<<<\n");
/* schedule */
next = my_current_task->next;
prev = my_current_task;
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(
"pushq %%rbp\n\t" /* save rbp of prev */
"movq %%rsp,%0\n\t" /* save rsp of prev */
"movq %2,%%rsp\n\t" /* restore rsp of next */
"movq $1f,%1\n\t" /* save rip of prev */
"pushq %3\n\t"
"ret\n\t" /* restore rip of next */
"1:\t" /* next process start here */
"popq %%rbp\n\t"
: "=m" (prev->thread.sp),"=m" (prev->thread.ip)
: "m" (next->thread.sp),"m" (next->thread.ip)
);
}
return;
}
主要分析my_schedule(void)函数中内嵌汇编代码,这部分代码完成了保存当前进程上下文、恢复被调度进程的上下文,实现了真正意义上的进程切换。
pushq %%rbp 保存prev进程(即当前进程)当前RBP寄存器的值到prev进程的堆栈;
movq %%rsp,%0 保存prev进程当前RSP寄存器的值到prev->thread.sp,这时RSP寄存器指向进程的栈顶地址,实际上就是将prev进程的栈顶地址保存;
movq %2,%%rsp 将next进程(被调度进程)的栈顶地址next->thread.sp放入RSP寄存器,完成了当前进程和被调度进程的堆栈切换。
movq $1f,%1 保存prev进程当前RIP寄存器值到prev->thread.ip,这里$1f是指标号1。
pushq %3 把即将执行的next进程的指令地址next->thread.ip入栈,这时的next->thread.ip可能是被调度进程的起点my_process(void)函数,也可能是$1f(标号1)。第一次被执行从头开始为进程的起点my_process(void)函数,其余的情况均为$1f(标号1),因为next进程如果之前运行过那么它就一定曾经也作为prev进程被进程切换过。
ret 就是将压入栈中的next->thread.ip放入RIP寄存器,之所以不直接放入RIP寄存器中,时因为程序不能直接使用RIP寄存器,只能通过call、ret等指令间接改变RIP寄存器。
1: 标号1是一个特殊的地址位置,该位置的地址是$1f。
popq %%rbp 将next进程堆栈基地址从堆栈中恢复到RBP寄存器中。
到这里开始执行next进程了。
至此,一个简易的操作系统内核编写完成。
(4)重新编译并在QEMU上模拟运行
回到内核目录下,依次执行
make clean;
make -j4
重新编译内核成功
在QEMU上模拟启动运行内核
可以看到,编写的简易内核依据时间片轮转算法实现了进程调度。
5 简要分析操作系统内核核心功能及运行工作机制
完整的Linux内核是非常复杂的,当前Linux内核的主要功能包含几个方面:进程管理、内存管理、文件系统、设备控制和网络接口等。
本次实验编写了一个简易的内核用来模拟实现内核的进程调度功能。
首先,执行mymain.c中的内核入口函数my_start_kernel(void),该函数初始化了0号进程,作为所有进程的祖先进程,并依次复制出(fork)其他子进程。同时,在mymain.c中添加了my_process函数,用来作为进程的代码模拟一个个进程。
之后,my_timer_handler(void)用来记录时间片,根据变量time_count的计数值,每当time_count累加到1000的整数倍,表示当前进程时间片用完,会将my_need_sched状态置为1,以通知当前进程让出CPU,并在就绪进程链表中取出下一个被调度进程,完成进程切换。
进程切换的代码是内嵌的汇编代码,涉及到寄存器的存储和内核栈的操作,上面已经详细分析过这部分代码,在此就不多赘述了。
通过本次实验,让我真正了解了一个操作系统内核是如何完成进程调度的,虽然只是很简单的模拟实现,但无疑加深了我对理论知识的理解,而不再只是将知识停留在书本上。