进程基础结构

本节导读

本节会介绍进程的调度方式。这是本章的重点之一。

进程的概念

导语中提到了,进程就是运行的程序。既然是程序,那么它就需要程序执行的一切资源,包括栈、寄存器等等。不同于用户线程,用户进程有着自己独立的用户栈和内核栈。但是无论如何寄存器是只有一套的,因此进程切换时对于寄存器的保存以及恢复是我们需要关心的问题。

为了研究进程的切换,我们先来搞懂用户进程长啥样,是如何运行的。不妨从上一节的 run_all_app 函数开始研究:

int run_all_app()
{
    for (int i = 0; i < app_num; ++i) {
        struct proc *p = allocproc();
        struct trapframe *trapframe = p->trapframe;
        load_app(i, app_info_ptr);
        uint64 entry = BASE_ADDRESS + i * MAX_APP_SIZE;
        trapframe->epc = entry;
        trapframe->sp = (uint64)p->ustack + USER_STACK_SIZE;
        p->state = RUNNABLE;
    }
    return 0;
}

首先介绍 struct proc 的定义。本章中新增的proc.h定义了我们OS的进程的PCB(进程管理块,和进程一一对应。它包含了进程几乎所有的信息)结构体;

 1// os/proc.h
 2
 3struct proc {
 4    enum procstate state;   // 进程状态
 5    int pid;                // 进程ID
 6    uint64 ustack;          // 进程用户栈虚拟地址(用户页表)
 7    uint64 kstack;          // 进程内核栈虚拟地址(内核页表)
 8    struct trapframe *trapframe;   // 进程中断帧
 9    struct context context; // 用于保存进程内核态的寄存器信息,进程切换时使用
10};
11
12enum procstate {
13    UNUSED,     // 未初始化
14    USED,       // 基本初始化,未加载用户程序
15    SLEEPING,   // 休眠状态(未使用,留待后续拓展)
16    RUNNABLE,   // 可运行
17    RUNNING,    // 当前正在运行
18    ZOMBIE,     // 已经 exit
19};

可以看到每一个进程的PCB都保存了它当前的状态以及它的PID(每个进程的PID不同)。同时记录了其用户栈和内核栈的起始地址。trapframe和context在异常中断的切换以及进程之间的切换起到了保存的重要作用。

进程的状态是大家比较熟悉的问题了。OS课程上将进程的状态分为创建、就绪、执行、等待以及结束5大阶段(未来还会有挂起)。在我们的OS之中对状态的分类略有不同。我们一般用RUNNABLE代表就绪的进程,RUNNING代表正在执行的进程,UNUSED代表池中未分配或已经结束的进程,USED代表已经分配好但是还未加载完毕的进程。

进程的基本管理

在我们的OS之中,我们采用了非常朴素的进程池方式来存放进程:

 1// os/trap.c
 2
 3struct proc pool[NPROC];    // 全局进程池
 4struct proc idle;           // boot 进程
 5struct proc* current_proc;  // 指示当前进程
 6
 7// 由于还有没内存管理机制,静态分配一些进程资源
 8char kstack[NPROC][PAGE_SIZE];
 9__attribute__((aligned(4096))) char ustack[NPROC][PAGE_SIZE];
10__attribute__((aligned(4096))) char trapframe[NPROC][PAGE_SIZE];

可以看到我们最多同时有 NPROC 个进程,每一个进程的用户栈、内核栈以及trapframe所需的空间已经预先分配好了。当然缺点是进程池空间有限,不过直到lab8 之前大家都无需担心这个问题。

这里的 idle 进程是我们的 boot 进程,是我们执行初始化的进程,事实上,在引入用户进程前,idle 是唯一一个进程。比较重要的是 current_proc,它代表着当前正在执行的进程。因此这个变量在进程切换时也需要维护来保证其正确性。活用此变量能大大方便我们的编程。

进程模块初始化函数如下:

// kernel/trap.c

void procinit()
{
    struct proc *p;
    for(p = pool; p < &pool[NPROC]; p++) {
        p->state = UNUSED;
        p->kstack = (uint64)kstack[p - pool];
        p->ustack = (uint64)ustack[p - pool];
        p->trapframe = (struct trapframe*)trapframe[p - pool];
    }
    idle.kstack = (uint64)boot_stack_top;
    idle.pid = 0;
}

进程的分配

回到 run_all_app 函数,可以注意到首每个用户进程都被分配了一个 proc 结构,通过 alloc_proc 函数。进程的分配实际上本质就是从进程池中挑选一个还未使用(状态为UNUSED)的位置分配给进程。具体代码如下:

 1// os/proc.c
 2
 3// Look in the process table for an UNUSED proc.
 4// If found, initialize state required to run in the kernel.
 5// If there are no free procs, or a memory allocation fails, return 0.
 6struct proc *allocproc()
 7{
 8    struct proc *p;
 9    for (p = pool; p < &pool[NPROC]; p++) {
10        if (p->state == UNUSED) {
11            goto found;
12        }
13    }
14    return 0;
15
16found:
17    p->pid = allocpid();
18    p->state = USED;
19    memset(&p->context, 0, sizeof(p->context));
20    memset(p->trapframe, 0, PAGE_SIZE);
21    memset((void *)p->kstack, 0, PAGE_SIZE);
22    p->context.ra = (uint64)usertrapret;
23    p->context.sp = p->kstack + PAGE_SIZE;
24    return p;
25}

分配进程需要初始化其PID以及清空其栈空间,并设置 context 第一次运行的入口地址 usertrapret,使得进程能够从内核的S态返回U态并执行自己的代码。我们需要看看进程切换相关的东西了。