MOS

『MOS』 Process

『MOS』 进程

Posted by Coekjan on August 15, 2021

进程控制块

进程是程序的一次运行, MOS中采用进程控制块来记载进程的信息:

1
2
3
4
5
6
7
8
9
10
11
12
struct Env {
    struct Trapframe    env_tf;
    Pde                *env_pgdir;
    u_int               env_id;
    u_int               env_parent_id;
    u_int               env_status;
    u_int               env_cr3;
    u_int               env_pri;
    LIST_ENTRY(Env)     env_link;
    LIST_ENTRY(Env)     env_sched_link;
    // ...
};

其中出现了一个结构类型 struct Trapframe , 当发生进程调度时, 需要将当前进程的现场保存到其进程控制块中的 env_tf. 该结构类型的定义为:

1
2
3
4
5
6
7
8
9
10
struct Trapframe {
    unsigned long regs[32];
    unsigned long cp0_status;
    unsigned long hi;
    unsigned long lo;
    unsigned long cp0_badvaddr;
    unsigned long cp0_cause;
    unsigned long cp0_epc;
    unsigned long pc;
};

很容易看出来, 这个结构体用于保存中断现场的寄存器值.

另外, struct Env 中的其他字段的意义如下:

  • env_pgdir: 进程的页目录基地址的虚拟地址. 进程切换时内核中的 mCONTEXT 将设置为目标进程的 env_pgdir.
  • env_id: 进程号. 需要保证当前存活的进程具有不同的进程号.
  • env_parent_id: 父进程号. 后续实现 fork 时需要设置该字段.
  • env_status: 进程控制块的状态. 只可能是 ENV_FREE , ENV_RUNNABLE , ENV_NOT_RUNNABLE 其中之一.
    • ENV_FREE: 意味着此进程控制块处于空闲链表中, 不表征任何存活的进程.
    • ENV_RUNNABLE: 意味着此进程控制块处于调度链表中, 且没有被阻塞, 表征了一个存活的可运行的进程.
    • ENV_NOT_RUNNABLE: 意味着此进程控制块处于调度链表中, 但处于阻塞状态, 表征了一个存活但被阻塞的进程.

      需要注意的是, 这里的三种状态与进程三状态是不同的.

  • env_cr3: 保存进程页目录基地址对应的物理地址.
  • env_pri: 进程的优先级.
  • env_link: 若进程控制块处于空闲链表中, 则此字段链接到下一个空闲进程控制块.
  • env_sched_link: 若进程控制块处于调度链表中, 则此字段链接到下一个可调度的进程控制块.

这里有两个链表链接字段(env_link , env_sched_link), 将在下文进一步解释.

进程的地址空间与初始化

MOS的进程在用户态下(SR寄存器中 KUc 位为 0)时无法访问 0x80000000 以上的地址空间, 只有当切换到内核态(SR寄存器中 KUc1)后才能访问这些高地址. 陷入内核的本质是切换到内核态.

MOS的设计为所有进程都具有4GB的虚拟地址空间. 下面是MOS进程的地址空间:

图中, 所有进程的蓝色区域都是一样的(共享内核空间). 另外, 紫色区域:

  • UVPT 用于映射进程页表, 每个进程的这部分都不一样.
  • UPAGES 用于映射内存管理中的 pages 数组, 每个进程的这部分都一样.
  • UENVS 用于映射进程管理中的 envs 数组, 每个进程的这部分都一样.

    envs 数组将在后文的进程控制块管理中详细介绍.

回忆一下启动的过程, 我们在 mips_vm_init 中申请了 pagesenvs 这两个数组的空间, 并将它们与虚地址 UPAGESUENVS 建立了映射.

1
2
3
4
5
6
7
8
pages = (struct Page *)alloc(npage * sizeof(struct Page), BY2PG, 1);
printf("to memory %x for struct Pages.\n", freemem);
n = ROUND(npage * sizeof(struct Page), BY2PG);
boot_map_segment(pgdir, UPAGES, n, PADDR(pages), PTE_R);

envs = (struct Env *)alloc(NENV * sizeof(struct Env), BY2PG, 1);
n = ROUND(NENV * sizeof(struct Env), BY2PG);
boot_map_segment(pgdir, UENVS, n, PADDR(envs), PTE_R);

而这里的 UPAGESUENVS 恰好就位于紫色区域内. 也就意味着我们可以将这部分映射关系直接复用. 这里就可以解释启动中 boot_map_segment(boot_pgdir, ...) 的重要作用, 即制作了一个页目录映射模板 boot_pgdir , 只要复制其中的 UENVSUPAGES 对应的表项就可以完成映射!

MOS中使用 env_setup_vm 为一个进程控制块表征的进程设置初始虚空间.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static int env_setup_vm(struct Env *e) {
    int i;
    struct Page *p = NULL;
    // Step 1. alloc a page for `e->env_pgdir`
    if (page_alloc(&p) != 0) {
        panic("env_setup_vm `page_alloc` error");
    }
    p->pp_ref++;
    e->env_pgdir = (Pde *) page2kva(p);
    for (i = 0; i < PDX(UTOP); ++i) {
        e->env_pgdir[i] = 0;
    }
    for (; i < PDX(UVPT); ++i) {
        e->env_pgdir[i] = /* Step 2. copy entry from template `boot_pgdir` */;
    }
    e->env_cr3 = PADDR(e->env_pgdir);
    e->env_pgdir[PDX(UVPT)] = e->env_cr3 | PTE_V; // ***SELF-MAPPING***
    return 0;
}

调用完 env_setup_vm 后, 就为进程设置好了初始虚空间, 但可运行的机器码等程序信息还未加入到内存中, 这需要后文中的 load_icode 进行设置.

进程控制块的管理

进程管理初始化

MOS中允许的最大进程数目是 1024 , 并在 mips_vm_init 时在内核空间中申请了一个 1024 长的进程控制块数组:

1
2
3
4
// Note: NENV == 1024
envs = (struct Env *)alloc(NENV * sizeof(struct Env), BY2PG, 1);
n = ROUND(NENV * sizeof(struct Env), BY2PG);
boot_map_segment(pgdir, UENVS, n, PADDR(envs), PTE_R);

随后进行 env_init , 在此函数中将 envs 数组中的进程控制块挂载到空闲链表中, 并初始化调度链表.

  • 空闲链表 env_free_list 中存储目前不表征存活进程的进程控制块.
  • 调度双链表 env_sched_list 用于进行进程调度.
1
2
3
4
5
6
7
8
9
10
void env_init() {
    int i;
    LIST_INIT(&env_free_list);
    LIST_INIT(&env_sched_list[0]);
    LIST_INIT(&env_sched_list[1]);
    for (i = NENV - 1; i >= 0; --i) {
        LIST_INSERT_HEAD(&env_free_list, &envs[i], env_link);
        envs[i].env_status = ENV_FREE;
    }
}

这里定义的 env_init 函数将在 mips_init 中调用:

1
2
3
4
5
6
7
8
9
void mips_init() {
    mips_detect_memory();
    mips_vm_init();
    page_init();
    env_init();             // <~~
    // ...
    trap_init();
    // ...
}

链表法管理空闲进程控制块

MOS中采用链表法管理空闲进程控制块. 也就是上文中反复提到的空闲链表 env_free_list . 事实上, 采用何种管理方法(链表法, 位图法)都是无关紧要的, 重点是无论采用什么管理方法, 都要提供一致的对外接口 env_alloc, env_free 来分配和释放资源. 这跟内存管理中的物理内存块管理是一样的.

env_alloc

env_alloc 的主要任务是申请一个进程控制块, 并给它的一些字段设置合理的初始值. 大致框架为:

这里设置的初始值将在进程开始运行时的 env_pop_tf 汇编函数中加载到 CPU 的状态:

  • 这里初始化的 CPU 状态有栈指针寄存器, CP0 中的 SR 寄存器.

还有一些初始值表示进程的一些属性, 如进程号, 进程状态, 父进程号.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int env_alloc(struct Env **new, u_int parent_id) {
    int r;
    struct Env *e;
    // Step 1. alloc a page for page directory
    if (/* `env_free_list` is empty */) {
        *new = 0;
        return -E_NO_FREE_ENV;
    }
    e = /* the head of `env_free_list` */;
    /* remove `e` from `env_free_list` */;
    
    // Step 2. use `env_setup_vm` to initial virtual memory space of `e`
    
    // Step 3. initial fields of `e`
    e->env_id = mkenvid(e);
    e->env_status = ENV_RUNNABLE;
    e->env_parent_id = parent_id;
    e->env_tf.cp0_status = 0x10001004;
    e->env_tf.regs[29] = USTACKTOP;
    *new = e;
    return 0;
}

这里给进程控制块的字段进行了初始化, 这里做一些说明:

  • env_id: 使用 mkenvid 创建一个进程号. 官方代码中的 mkenvid 实现有一些小问题, 我们将在后续的文章中修复它.
  • env_tf.cp0_status: 这一字段将在运行时被放入CP0的SR寄存器中, 对照SR寄存器的位含义, 可知道 0x10001004 的意思为将SR寄存器中的 CU0, IM[8 + 4], IEp 置高.
    • CU0 被置高, 意味着 CP0 被启用.
    • IM[8 + 4] 被置高, 意味着接受 4 号中断(MOS 中 4 号中断是时钟中断)
    • IEp 被置高, 返回用户态时, IEc 将获得 IEp 的值(二重栈), 这意味着返回用户态后启用全局中断使能.
  • env_tf.regs[29]: $29 寄存器为 sp 栈指针寄存器, 这里初始化 sp 为用户栈栈顶.

env_free

官方给出了 env_free 全部代码, 这里仅作概述:

  1. 将进程页目录中的所有页面移除(取消映射).
  2. 将页目录占用的物理页面回收.
  3. 将进程控制块放回到 env_free_list 中.

进程的生命周期

一个进程具有其完整的生命周期: 创建, 运行, 销毁.

创建 - 加载程序到内存

在介绍创建进程的方法之前, 需要关注一下何时会创建进程?

  1. 系统初始化时(mips_init)创建初始进程.
  2. 进程通过系统调用来产生子进程.

本文介绍的是系统初始化时创建进程的方法, 另一种方法将在后续的文章中介绍.

MOS初始化时, 可以使用 env_createenv_create_priority 来创建进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
void env_create_priority(u_char *binary, int size, int priority) {
    struct Env *e;
    if (env_alloc(&e, 0) != 0) {
        panic("env_create_priority `env_alloc` error");
    }
    e->env_pri = priority;
    load_icode(e, binary, size);
    LIST_INSERT_HEAD(&env_sched_list[0], e, env_sched_link);
}

void env_create(u_char *binary, int size) {
    env_create_priority(binary, size, 1);
}

这里涉及到两个关键参数 binarysize. 我们希望 MOS 初始化时能够创建若干个初始进程, 而进程是程序的一次执行, 因此需要在 MOS 初始化时取得可执行文件的二进制码(ELF 格式), 因此只需要在编译时, 将外部文件的二进制码与大小链接到 MOS 内核中即可.

事实上, MOS 内核编译得到的 vmlinux 文件就包含了 Makefile 指定的外部程序. 假设被链接的外部可执行程序名为 prog , 则 vmlinux 中就存在符号:

  • binary_prog_start: 存储程序二进制码的起始地址.
  • binary_prog_size: 存储程序二进制码的长度.

那么, 在 MOS 初始化时, 就可以通过下述代码来创建进程:

1
2
3
extern u_char binary_prog_start[];
extern u_int binary_prog_size;
env_create_priority(binary_prog_start, binary_prog_size, 1);

为简化这一创建进程的代码, 可以通过宏来简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define ENV_CREATE_PRIORITY(x, y)                             \
    {                                                         \
        extern u_char binary_ ## x ## _start[];               \
        extern u_int binary_ ## x ## _size;                   \
        env_create_priority(                                  \
            binary_ ## x ## _start,                           \
            binary_ ## x ## _size,                            \
            y                                                 \
        );                                                    \
    }

#define ENV_CREATE(x)                                         \
    {                                                         \
        extern u_char binary_ ## x ## _start[];               \
        extern u_int binary_ ## x ## _size;                   \
        env_create(                                           \
            binary_ ## x ## _start,                           \
            binary_ ## x ## _size                             \
        );                                                    \
    }

这里用到了 load_icode 将二进制码加载到内存中, 随后将进程控制块挂入调度双链表中. 下面重点讲解 load_icode 的原理与框架.

icode: image of code

首先, 要知道二进制码有什么特征. 由于链接到 MOS 内核文件的二进制码都由linux下的mips-4KC交叉编译器产生, 这意味着得到的程序二进制码具有ELF格式. 因此我们只需要直接将这段二进制码解释为ELF形式即可得到程序的各种信息.

C 语言中, 类型解释内存. 对于一个 ELF 二进制码, 只需要将其首地址指针转为 ELF 指针类型, 即可通过该指针来获取其中的信息.

而ELF格式中, 已经明确给出了各个段的内容与虚地址, 因此可以直接通过解析ELF来将整个程序加载到内存中. 在MOS中, load_icode 负责为进程初始化栈并调用 load_elf 加载程序, 而 load_elf 负责解析ELF文件并调用 load_icode_mapper, 最后 load_icode_mapper 则将取到的程序段复制到内存中(创建镜像), 并在进程的页目录中为其建立映射(使用 page_insert).

除此之外, load_elf 最终将 ELF 可执行文件的入口地址 entry_point 以参数形式外传.

\[\begin{CD} \texttt{load\_icode}\\ @VVV\\ \texttt{load\_elf}\\ @VVV\\ \texttt{load\_icode\_mapper} \end{CD}\]

根据上述解释, 不难得到 load_icodeload_icode_mapper 的大致框架:

load_elf (lib/kernel_elfloader.c)的官方代码已经很明确了, 这里不再做详细解释.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static void load_icode_mapper( // assert `sgsize` >= `bin_size`
    u_long va, u_int32_t sgsize,
    u_char *bin, u_int32_t bin_size, void *user_data
) {
    struct Env *env = (struct Env *)user_data;
    struct Page *p = NULL;
    u_long i;
    int r;
    u_long offset = va - ROUNDDOWN(va, BY2PG);
    // difficult & challenging task!
    // HINT: `page_alloc`, `page_insert`, `bcopy` help to finish the task

    // Step 1. if `offset` != 0, think where to map the unaligned code
    // Step 2. map `bin` with the size of `bin_size` to the memory
    // Step 3. if `bin_size` < `sgsize`, fill the rest of segment with `0`
}

static void load_icode(struct Env *e, u_char *binary, u_int size) {
    struct Page *p = NULL;
    u_long entry_point;
    // Step 1. initialize stack for the process
    if (page_alloc(&p) != 0) {
        panic("load_icode `page_alloc` error");
    }
    if (/* use `page_insert` to map the stack */ != 0) {
        panic("load_icode `page_insert` error");
    }

    // Step 2. load image of the code
    if (load_elf(binary, size, &entry_point, e, load_icode_mapper) != 0) {
        panic("load_icode `load_elf` error");
    }

    // Step 3. set initial pc
    e->env_tf.pc = entry_point;
}

load_icodeenv_create_priority 中最关键的一步, 完成这一步后, 进程控制块就被加入到调度双链表中, 参与到时间片调度的序列中. 当调度算法决定让该进程运行时, 就会调用 env_run 函数运行它, 这将在下一节中详细介绍.

运行 - 时间片调度与上下文切换

当定时器发出中断信号后, 系统陷入内核(进入内核态), 保存现场到 TIMESTACK 并将栈指针 sp 寄存器设置为 TIMESTACK. 随后立即调用 sched_yield 函数.

据宏定义, TIMESTACK 事实上是 0x82000000 , 但此物理页事实上并未由 page_alloc 获取, 因此这里存在问题(此物理页面有可能会被进程占用). 为修补此问题, 可对 page_init 进行一点修改: 即将 page 结构体挂入链表时, 排除 pa2page(PADDR(TIMESTACK - BY2PG)) 这一页面.

\[\begin{CD} \texttt{except\_vec3}\\ @VVV\\ \texttt{handle\_int}\\ @VVV\\ \texttt{timer\_irq}\\ @VVV\\ \texttt{sched\_yield}\\ @VVV\\ \texttt{env\_run} \end{CD}\]

sched_yield 的作用就是在调度双链表中决定下一个运行的进程, 并调用 env_run 运行它. 同样, 采用怎样的调度方案都是无关紧要的, 重要的是:

  1. 若调度链表为空, 应当陷入死循环(系统故障).
  2. 若调度链表非空, 则应当使得其中的进程都有机会得到运行机会, 并最终调用 env_run 运行进程.

MOS中采用的是简单的带优先级的时间片调度方案, sched_yield 的大致框架为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void sched_yield() {
    static int count = 0;
    static int point = 0;
    static struct Env *e = NULL;
    if (count == 0 || e == NULL || e->env_status != ENV_RUNNABLE) {
        if (e != NULL) {
            /* move `e` to the tail of `env_sched_list[1 - point]` */
        }
        while (1) {
            while (LIST_EMPTY(&env_sched_list[point])) point = 1 - point;
            e = LIST_FIRST(&env_sched_list[point]);
            if (e->env_status == ENV_RUNNABLE) {
                count = e->env_pri; // PRIORITY
                break;
            } else {
                /**
                 * if the status of `e` is `ENV_NOT_RUNNABLE`,
                 *     move it to the tail of `env_sched_list[1 - point]`
                 */
            }
        }
    }
    count--;
    env_run(e);
}

env_run 中, 大致具有如下步骤:

  1. 若当前有进程在运行, 则应当将该进程的现场保存到其进程控制块的 env_tf 中.
  2. mCONTEXT 设置为待运行进程的页目录基地址.

    切换页目录, 可以使得 TLB 缺失, 引发 do_refill 时填写正确的映射关系.

  3. 将待运行进程的现场恢复到CPU的状态中.

因此其大致框架为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void env_run(struct Env *e) {
    // Step 1. save registers if necessary
    if (curenv != NULL) {
        struct Trapframe *cur_tf =
            (struct Trapframe *) (TIMESTACK - sizeof(struct Trapframe));
        bcopy(cur_tf, &(curenv->env_tf), sizeof(struct Trapframe));
        curenv->env_tf.pc = curenv->env_tf.cp0_epc;
    }
    curenv = e;
    // Step 2. switch address space
    lcontext(curenv->env_pgdir);
    // Step 3. restore registers of `e` and return to user-mode
    env_pop_tf(&(curenv->env_tf), GET_ENV_ASID(curenv->env_id));
}

第一步中, 将当前进程的运行现场保存到其进程控制块的 env_tf 字段, 以便后续恢复到此进程时从恢复该现场.

同时, 将 env_tf.pc 设置为 env_tf.cp0_epc. 这是由于: 当时钟中断到来后, CP0 的 EPC 寄存器存储了当前进程的被中断指令的虚地址, env_pop_tf 的恢复现场过程中, 将会通过 jr 指令跳转到 env_tf 中的 pc 值, 从而回滚进程. 因此这里必须要将 pc 设置为被中断指令的虚地址, 即 cp0_epc.

随后使用了 lcontext 汇编函数将 curenv->env_pgdir 存储到 mCONTEXT 中, 用 env_pop_tf 汇编函数恢复待运行进程的现场.

lcontext 的定义很简单, 就是将传入的参数值存入 mCONTEXT:

1
2
3
4
5
6
LEAF(lcontext)
    .extern mCONTEXT
    sw      a0, mCONTEXT
    jr      ra
    nop
END(lcontext)

env_pop_tf 则相对复杂, 但核心任务是明确的: 将ASID填入EntryHi中, 以及将参数中的 env_tf 恢复到CPU状态中.

MOS 中切换地址空间, 不仅要让 mCONTEXT 改为进程的页目录基地址, 还要将进程的 ASID 存入 EntryHi.

对于 env_pop_tf , 官方已经给出了完整的实现, 读者可详细阅读理解官方代码, 此处不作详述.

销毁 - 释放进程控制块与占用的内存

前文通过 env_create 创建进程, 此处使用 env_destory 销毁进程.

销毁进程的大致步骤是:

  1. 调用 env_free 将该进程对应的进程控制块释放.
  2. 若被销毁的进程是当前进程, 则需要调用 sched_yield 让其他进程运行.
1
2
3
4
5
6
7
void env_destroy(struct Env *e) {
    env_free(e);
    if (curenv == e) {
        curenv = NULL;
        sched_yield();
    }
}

官方代码中, 这里还有一段用 bcopy 来复制现场的代码. 但按代码逻辑而言, 这里已经把 curenvNULL , 因此 env_run 中也不会用到这个现场信息, 故这段 bcopy 为冗余代码.