基于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”。

基于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,并在就绪进程链表中取出下一个被调度进程,完成进程切换。

进程切换的代码是内嵌的汇编代码,涉及到寄存器的存储和内核栈的操作,上面已经详细分析过这部分代码,在此就不多赘述了。

通过本次实验,让我真正了解了一个操作系统内核是如何完成进程调度的,虽然只是很简单的模拟实现,但无疑加深了我对理论知识的理解,而不再只是将知识停留在书本上。

posted @ 2020-05-11 18:11  ustcZG  阅读(182)  评论(0)    收藏  举报