Lab3 - 进程与异常

Lab3 中主要涉及到以下内容:

  • 进程的创建
  • 时钟中断与内核态
  • 进程调度与进程切换
  • 数据进程控制块 Env

进程控制块与初始化

  • 由于没有在 MOS 操作系统中实现线程,所以进程既是基本的分配单元,也是基本的执行单元。
  • 进程是一个活动中的实体,拥有自己的虚拟地址空间。
  • 程序是非活动的实体,执行中的程序就是进程

进程控制块 - PCB

struct Env {
struct Trapframe env_tf;// 保存上下文环境,定义于 trap.h 中
LIST_ENTRY(Env) env_link;// 构建空闲进程链表 env_free_link
u_int env_id;// 进程标识符
u_int env_parent_id;// 父进程的进程 id
u_int env_status;// 进程块状态位
Pde *env_pgdir;// 进程页目录的虚拟地址
TAILQ_ENTRY(Env) env_sched_link;// 构造调度队列 env_sched_list
u_int env_pri;// 进程优先级,与后续时间片调度进程相关
};
  • env_status
    • ENV_FREE:进程控制块处于空闲链表中,值为0
    • ENV_NOT_RUNNABLE阻塞态,可转变为就绪状态,值为1
    • ENV_RUNNABLE:就绪、执行状态(等待调度/运行中),值为2
  • 在 MOS 中,进程控制块的物理地址已经被分配好了(envs数组)

这里的初始化使用了__attribute__函数,做完再看 其中的结构体 TrapFrame 在 Lab4 中作用比较大,但在 Lab3 中没有必要过于关注,结构就不再过多介绍了 Env 块中存在两个链表(env_free_listenv_sched_list),TAILQ 结构在 Lab2 的 Probe 中已经提过了,该结构为双向的有尾列表,支持在头尾进行元素增删操作。

跨页地址映射 - map_segment - Exercise 3.2

  • 函数作用如下:物理地址映射到指定进程的虚拟地址中(更大的 page_insert
  • 将物理地址 pa 按页映射到指定进程页表中(va)
    • 映射大小 size 必须是页面大小的整数倍
  • 设置用到的页表项权限位为 perm

这个函数在上面的 env_init()中使用过,其将内核中 pages 与 envs 所在的物理地址映射到内核页表中,我们可以根据下面这个实例补充代码

map_segment(base_pgdir, 0, PADDR(pages), UPAGES, ROUND(npage * sizeof(struct Page), 
BY2PG), PTE_G);

代码中注释已经给好方向,使用page_insert(),将 [va, va+size) 所涉及到的每个页面都映射到 pa 开始的页面中

/* Overview:
* 将 pa 地址内容映射到指定页目录中的虚拟地址 va
*/
static void map_segment(Pde *pgdir, u_int asid, u_long pa, u_long va,
u_int size, u_int perm) {
/* 预先确保地址对齐 */
assert(pa % BY2PG == 0);
assert(va % BY2PG == 0);
assert(size % BY2PG == 0);

/* Step 1: 循环对应的每一页形成映射 */
for (int i = 0; i < size; i += BY2PG) {
/* 使用 pa2page 获取物理地址 pa 对应的页控制块
* va + i 表示每个虚拟页的基地址,pa + i 表示页框基地址
* 使用 page_insert 形成新映射,**权限**设置为 perm
* Exercise 3.2: Your code here. */
page_insert(pgdir, asid, pa2page(pa + i), va + i, perm);
}
}

进程块队列初始化 - env_init - Exercise 3.1

  • 函数功能:初始化 envs 链表
  • 完成 Env 控制块的空闲队列、调度队列的初始化功能
  • 空闲队列需要倒序插入,用来优先分配小序号的进程控制块 Env
  • 临时存放 内核结构 ‘pages’ 与 ‘envs’ ,为后续映射做准备(详见 Exercise 3.3)
void env_init(void) {
int i;
/* Step 1: 初始化 `env_free_list` 与 `env_sched_list` 两个调度队列 */
/* Exercise 3.1: Your code here. (1/2) */
LIST_INIT(&env_free_list);
TAILQ_INIT(&env_sched_list);

/* Step 2: 将所有进程控制块插入空闲队列中,注意需要 **倒序** 插入 */
/* Exercise 3.1: Your code here. (2/2) */
for (i = NENV - 1; i >= 0; i--) {
LIST_INSERT_HEAD(&env_free_list, &envs[i], env_link);
}

/* Step 3: 将内核结构 'pages' 与 'envs' 映射到每个用户空间的虚拟地址(UPAGES
* 和 UENVS)中,并要求只读,这里先暂时把两个内容存在一个临时页目录 'base_pgdir'
* 中,并建立映射,直至 `env_setup_vm' 中再将其拷贝进用户页目录 'env_pgdir'
*/
struct Page *p;
panic_on(page_alloc(&p));
p->pp_ref++;

base_pgdir = (Pde *)page2kva(p);
map_segment(base_pgdir, 0, PADDR(pages), UPAGES,
ROUND(npage * sizeof(struct Page), BY2PG), PTE_G);
map_segment(base_pgdir, 0, PADDR(envs), UENVS,
ROUND(NENV * sizeof(struct Env), BY2PG), PTE_G);
}

代码实现较简单,注意要区分 Env 结构体中存在的链表,env_free_list 链表需要使用 env_link 连接,使用LIST类宏操作;而 env_sched_list 需要使用 env_sched_link 连接,使用 TAILQ 类宏操作

env_id 与进程表示

在进程管理块 Env 中,有三个与 id 相关的字段,它们从不同的方式代表进程

  • env_id :(进程标识符,unique environment identifier)MOS 操作系统中使用 env_id 唯一地表示不同进程,在创建进程时被 mkenvid 函数赋值
  • env_asid :(地址空间标识符,Address-Space IDentifier)ASID也可以唯一地标记进程,同时为进程提供相应的地址保护
    • 在 MOS 实验系统中使用 <VirtualPageNumber, ASID> 作为索引在 TLB 中查询映射
    • 在 MOS 中,使用了位图法管理了64个 ASID ,具体位于 asid_alloc 函数中
  • env_parentid :(env_id of this env’s parent)顾名思义是创建本进程的父进程的 env_id

对于idasidid指的是控制块和线程自己的属性;asid则在 TLB 、地址管理上用的比较多 env_id 的值从同文件中的 mkenvid() 函数中得来,asid由同文件中的 asid_alloc() 得到

Linux 中的 ASID 分代机制 (有空再补)

   

加载二进制镜像

程序想要成为进程,必须要把对应的的 ELF 文件(此处为可执行文件)中所有需要加载的 程序段(Segment) 分配进虚拟内存空间中。但在 lab3 中我们还不能直接操作磁盘中的文件,所以 ELF 文件被转化为C数组的形式,再通过编译到内核完成加载 这里可以使用部分函数操作 ELF 文件,加载整个文件进入内存、获取其文件头、加载 segment 至内存

ELF 文件函数

  • load_icode():加载可执行文件至指定进程内存(调用的最外层函数)
  • elf_from():解析 ELF 文件头,获取段位置
  • elf_load_seg():加载 ELF 程序段
  • load_icode_mapper():分配一页物理页,在 env 块对应的页表中建立映射
    • 可能需要复制 src 处的数据到该物理页面中

加载 ELF 文件 - load_icode - Exercise 3.6

  • 函数作用:调用相关函数,将 ELF 文件加载进指定进程中
  • 解析 ELF 头获取段信息
  • 使用 elf_load_seg 加载每个程序段
  • 初始化 EPC 指向程序入口点地址
static void load_icode(struct Env *e, const void *binary, size_t size) {
/* Step 1: 解析 ELF 头 */
const Elf32_Ehdr *ehdr = elf_from(binary, size);
if (!ehdr) {
panic("bad elf at %x", binary);
}

/* Step 2: 循环加载每个程序段
* 循环:使用 ELF_FOREACH_PHDR_OFF
* 加载:使用 elf_load_seg
*/
size_t ph_off;
ELF_FOREACH_PHDR_OFF(ph_off, ehdr) {
Elf32_Phdr *ph = (Elf32_Phdr *)(binary + ph_off);
if (ph->p_type == PT_LOAD) {
// 'load_icode_mapper' 指定了用户的加载方式
panic_on(elf_load_seg(ph, binary + ph->p_offset, load_icode_mapper, e));
}
}

/* Step 3: 将进程起始地址 EPC 指向 ELF 入口 e_entry ,执行程序入口点指令 */
/* Exercise 3.6: Your code here. */
e->env_tf.cp0_epc = ehdr->e_entry;
/* env_tf.cp0_epc 字段指示了进程运行时PC 应指向的位置,说明其为连续的虚拟地址中
的某一个值 */
}
  • ELF_FOREACH_PHDR_OFF:在上述的顶层函数中,调用了一个宏,其展开后对 ELF 程序段进行循环:
#define ELF_FOREACH_PHDR_OFF(ph_off, ehdr)        \
(ph_off) = (ehdr)->e_phoff; \
for (int _ph_idx = 0; _ph_idx < (ehdr)->e_phnum; ++_ph_idx, (ph_off) += (ehdr)->e_phentsize)

elf_load_seg

  • 函数作用:把 ELF 程序段加载到 data 处; MOS 中在 load_icode 内配合 load_icode_mapper 调用,也就是把程序段加载到进程内存里
  • 按页划分,分别映射每一页至 data 中
  • 回调函数 map_page 决定映射的方式
/* Overview:
* 加载 ELF 文件的段至 data
*
* Pre-Condition:
* bin != NULL
*
* Post-Condition:
* Return 0 if success. Otherwise return < 0.
*/
int elf_load_seg(Elf32_Phdr *ph, const void *bin, elf_mapper_t map_page, void *data) {
u_long va = ph->p_vaddr;
size_t bin_size = ph->p_filesz;// 文件大小
size_t sgsize = ph->p_memsz;// 内存大小,需要补齐这一段差值
u_int perm = PTE_V;
if (ph->p_flags & PF_W) {
perm = PTE_D;
}

int r;
size_t i;
u_long offset = va - ROUNDDOWN(va, BY2PG);
if (offset != 0) { // 起始地址未页对齐,映射第一页
if ((r = map_page(data, va, offset, perm, bin, MIN(bin_size, BY2PG - offset))) !=
0) {
return r;
}
}

/* Step 1: 映射 p_filesz 至指定区中 */
for (i = offset ? MIN(bin_size, BY2PG - offset) : 0; i < bin_size; i += BY2PG) {
if ((r = map_page(data, va + i, 0, perm, bin + i, MIN(bin_size - i, BY2PG))) != 0) {
return r;
}
}

/* Step 2: 补全空白页至 p_memsz */
while (i < sgsize) {
if ((r = map_page(data, va + i, 0, perm, NULL, MIN(bin_size - i, BY2PG))) != 0) {
return r;
}
i += BY2PG;
}
return 0;
}

页映射回调函数 - load_icode_mapper - Exercise 3.5

  • 函数作用:作为 load_icode 中使用的回调函数,它决定了页映射的方式
  • 申请一个物理页 page_alloc
  • 复制一下src内容(注意不要复制多/少了) memcpy
  • 把这个复制好的物理页映射到目标 env 里 page_insert
/* Overview:
* 把 src 处数据映射到进程 data 的va 处,更新 perm
*
* Pre-Condition:
* 'offset + len' <= 'BY2PG'.
* 换句话说就是复制过去后也只在**同一个**虚拟页里面,不会跨页
*
*/
static int load_icode_mapper(void *data, u_long va, size_t offset, u_int perm,
const void *src, size_t len) {
struct Env *env = (struct Env *)data;
struct Page *p;
int r;

/* Step 1: 申请一个装内容的物理页 */
/* Exercise 3.5: Your code here. (1/2) */
if ((r = page_alloc(&p)) != 0) {
return r;
}
/* Step 2: 复制 src 内容 */
// Hint: You may want to use 'memcpy'.
if (src != NULL) {
/* Exercise 3.5: Your code here. (2/2) */
memcpy((void *) (page2kva(p) + offset), src, len);
}

/* Step 3: 把物理页插进 env 进程页表,创建映射,完成复制 */
return page_insert(env->env_pgdir, env->env_asid, p, va, perm);
}

 

进程创建

创建进程的过程主要由 env_alloc 函数实现,其步骤大致如下:

  • 申请一个空闲的进程控制块
  • 初始化这个空白的控制块
  • 初始化进程页目录
  • 从 env_free_list 中取出该控制块

用户栈是在使用过程中动态分配的

进程页目录初始化 - env_setup_vm - Exercise 3.3

  • 函数作用:初始化进程页目录(共享只读段映射和自映射)
  • 形成进程页表,初始化新进程的虚拟地址空间
  • 把 UTOP 至 UVPT 两段地址间的内核页表 base_pgdir 拷贝到进程页表中,借此暴露这段由所有进程共享的只读空间( Exercise 3.1完成了 base_pgdir 的建立与映射)

关于共享只读的空间,指导书这样解释:

在MOS 操作系统特意将一些内核的数据暴露到用户空间,使得进程不需要切换到内核态就能访问,这是MOS 特有的设计。在Lab4 和Lab6 中将用到此机制。而这里我们要暴露是UTOP 往上到UVPT 之间所有进程共享的只读空间,也就是把这部分内存对应的内核页表base_pgdir 拷贝到进程页表中。从UVPT 往上到ULIM 之间则是进程自己的页表。

拷贝的这一段空间,具体来说存放的是 envs 和 pages 两个经常使用的结构体,使其共享只读确实能够减少大量对内核空间的访问;不然用户进程申请个物理页都要进内核看 pages 了

/* Overview:
* 初始化进程的**用户内存(虚拟地址)空间**
*/
static int env_setup_vm(struct Env *e) {

/* Step 1: 使用 page_alloc 申请一页物理页框存放进程页目录 */

struct Page *p;
try(page_alloc(&p));
/* Exercise 3.3: Your code here. */
p->pp_ref++;
e->env_pgdir = (Pde *) page2kva(p);
/* Step 2: 将 'base_pgdir' 页目录内容拷贝至 'e->env_pgdir' 中 */
memcpy(e->env_pgdir + PDX(UTOP), base_pgdir + PDX(UTOP),
sizeof(Pde) * (PDX(UVPT) - PDX(UTOP)));

/* Step 3: 设置页表自映射:令对应的页目录项指向页目录物理基地址 */
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) PTE_V;
return 0;
}
  • 使用 memcpy 时虽然只进行了页目录的拷贝,但两个页目录此时指向了相同的二级页表(物理页),之后再寻址就一样了
  • 页表自映射细节在 Thinking 3.1 中,可以回 Report 看一眼

申请并初始化进程块 - env_alloc - Exercise 3.4

  • 函数作用如下:
  • 从空闲控制块链表中申请一个进程控制块(类似于 page_alloc 申请页)
  • 使用 env_setup_vm 函数和赋值语句对控制块进行初始化
  • 把申请好的控制块从链表中摘除并返回
/* Overview:
* 申请并初始化进程块,存放在 '*new' 中
*
* Pre-Condition:
* 无父进程时 parent_id = 0
* 需要初始化 envs(使用 env_init 函数)
*
* Post-Condition:
* return 0 on success
* return < 0 on error:无空闲进程、ASID 或 'env_setup_vm' 失败
*
* Hints:
* 可能需要初始化下列字段:
* 'env_id', 'env_asid', 'env_parent_id', 'env_tf.regs[29]',
* 'env_tf.cp0_status', 'env_user_tlb_mod_entry', 'env_runs'
*/
int env_alloc(struct Env **new, u_int parent_id) {
int r;
struct Env *e;

/* Step 1: 申请空闲块,存放在 e 里 */
/* Exercise 3.4: Your code here. (1/4) */
if (LIST_EMPTY(&env_free_list)) {
return -E_NO_FREE_ENV;
}
e = LIST_FIRST(&env_free_list);
/* Step 2: 使用 'env_setup_vm' 初始化用户空间 */
/* Exercise 3.4: Your code here. (2/4) */
if (env_setup_vm(e)) {
return -E_BAD_ENV;
}
/* Step 3: 初始化字段:
* 'env_user_tlb_mod_entry' (lab4), 'env_runs' (lab6), 'env_id' (lab3),
* 'env_asid' (lab3), 'env_parent_id' (lab3)
*
* Hint:
* asid: asid_alloc
* envid: mkenvid
*/
e->env_user_tlb_mod_entry = 0; // for lab4
e->env_runs = 0; // for lab6
/* Exercise 3.4: Your code here. (3/4) */
e->env_id = mkenvid(e);
if (asid_alloc(&(e->env_asid)) == -E_NO_FREE_ENV) {
return -E_NO_FREE_ENV;
}
e->env_parent_id = parent_id;
/* Step 4: 初始化 CP0 寄存器与栈顶寄存器 sp */
// Timer interrupt (STATUS_IM4) will be enabled.
e->env_tf.cp0_status = STATUS_IM4 STATUS_KUp STATUS_IEp;
// Keep space for 'argc' and 'argv'.
e->env_tf.regs[29] = USTACKTOP - sizeof(int) - sizeof(char **);

/* Step 5: 移出控制块并赋值 */
/* Exercise 3.4: Your code here. (4/4) */
LIST_REMOVE(e, env_link);
*new = e;
return 0;
}

初始化两个 ID 时,使用了两个不需要填空的函数,体现了操作系统生成不重复的 ID 的方式和位图法保存 ASID 的应用,这边也来看一下吧:

// 计算一个 ASID 并存入参数指针内
static int asid_alloc(u_int *asid) {
for (u_int i = 0; i < NASID; ++i) {
int index = i >> 5;
int inner = i & 31;
if ((asid_bitmap[index] & (1 << inner)) == 0) {
asid_bitmap[index] = 1 << inner;
*asid = i;
return 0;
}
}
return -E_NO_FREE_ENV;
}
// 根据当前 env ,计算其 env_id
u_int mkenvid(struct Env *e) {
static u_int i = 0;
return ((++i) << (1 + LOG2NENV)) (e - envs);
}

  在设置寄存器初始化时,我们使用了两个赋值语句,这两句关键语句需要解释一下:

  • e->env_tf.cp0_status = STATUS_IM4 STATUS_KUp STATUS_IEp
    • 初始化 CP0 的 SR 寄存器, IM4 代表允许响应4号中断、 KUp 代表处于用户态、 IEp 代表允许中断,这里实际上是初始化了中断响应机制所需的寄存器条件
  • e->env_tf.regs[29] = USTACKTOP - sizeof(int) - sizeof(char **)
    • 在 USTACKTOP 下存放的实际上就是用户栈,还记得倒置的 mips 栈吗,这里就是栈顶

对于 SR 寄存器,更具体的解释如下: 指导书P66  

创建内核进程 - env_create -Exercise 3.7

  • 函数作用:创建一个内核进程,并加载 ELF 文件
  • 申请没有父进程的进程控制块
  • 初始化 priority 和 ENV_RUNNABLE
  • 加载 ELF 文件并插入调度队列
/* Overview:
* 使用 'binary' 与 'priority' 字段创建一个进程
* 在进程调度开始之前创建**内核**进程
*
*/
struct Env *env_create(const void *binary, size_t size, int priority) {
struct Env *e;
/* Step 1: 申请一个进程控制块,因为没有父进程所以 parent_id = 0, 'env_alloc' */
/* Exercise 3.7: Your code here. (1/3) */
env_alloc(&e, 0);
/* Step 2: 标记 'priority' 并设置为 ENV_RUNNABLE,表示可以运行
*/
/* Exercise 3.7: Your code here. (2/3) */
e->env_pri = (u_int) priority;
e->env_status = ENV_RUNNABLE;
/* Step 3: 加载 ELF 文件,并插入 'env_sched_list' 头,表示允许调度 */
/* Exercise 3.7: Your code here. (3/3) */
load_icode(e, binary, size);
TAILQ_INSERT_HEAD(&env_sched_list, e, env_sched_link);
return e;
}
  • ENV_CREATE_PRIORITY:MOS 创建内核示例进程时使用的宏,定义在 include/env.h

这里用到的 ##x## 可以理解成变量替换后的字符串拼接

// priority = y
#define ENV_CREATE_PRIORITY(x, y) \
({ \
extern u_char binary_##x##_start[]; \
extern u_int binary_##x##_size; \
env_create(binary_##x##_start, (u_int)binary_##x##_size, y); \
})
// priority = 1
#define ENV_CREATE(x) \
({ \
extern u_char binary_##x##_start[]; \
extern u_int binary_##x##_size; \
env_create(binary_##x##_start, (u_int)binary_##x##_size, 1); \
})
// 创建进程,binary字段来自名为 binary_user_bare_loop_start 的外部数组,定义在 user/bare/loop.b.c 中
ENV_CREATE_PRIORITY(user_bare_loop, 2);

 

再探 mmu.h

至此,一个新的内核进程创建过程就结束了,在 Lab4 中还会用部分函数创建用户进程。回顾一下。在申请进程之前,我们先初始化了进程控制块链表(env_init),在申请过程中初始化了它的内存空间、页表(env_setup_vm)与进程控制块字段,最后返回(env_alloc)。 借助 Lab2 已有的布局和进程建立的过程,我们可以大致构建起一个不断完善的内存体系,这时候 include/mmu.h 内的内存布局图就可以再拿出来用了。不过实际上,仍有一些字段我们没有使用,这些字段在 Lab4 中会再加以利用。

/*
* Part 2. Our conventions.
*/

/*
o 4G -----------> +----------------------------+------------0x100000000
o ... kseg2
o KSEG2 -----> +----------------------------+------------0xc000 0000
o Devices kseg1
o KSEG1 -----> +----------------------------+------------0xa000 0000
o Invalid Memory /\
o +----------------------------+-----------Physical Memory Max
o ... kseg0
o KSTACKTOP-----> +----------------------------+-----------0x8040 0000---end
o Kernel Stack KSTKSIZE /\
o +----------------------------+----------
o Kernel Text PDMAP
o KERNBASE -----> +----------------------------+-----------0x8001 0000
o Exception Entry \/ \/
o ULIM -----> +----------------------------+------------0x8000 0000-------
o User VPT PDMAP /\
o UVPT -----> +----------------------------+------------0x7fc0 0000
o pages PDMAP
o UPAGES -----> +----------------------------+------------0x7f80 0000
o envs PDMAP
o UTOP,UENVS ---> +----------------------------+------------0x7f40 0000
o UXSTACKTOP -/ user exception stack BY2PG
o +----------------------------+------------0x7f3f f000
o BY2PG
o USTACKTOP ----> +----------------------------+------------0x7f3f e000
o normal user stack BY2PG
o +----------------------------+------------0x7f3f d000
a
a ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
a . .
a . . kuseg
a . .
a ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
a
o UTEXT -----> +----------------------------+------------0x0040 0000
o reserved for COW BY2PG
o UCOW -----> +----------------------------+------------0x003f f000
o reversed for temporary BY2PG
o UTEMP -----> +----------------------------+------------0x003f e000
o invalid memory \/
a 0 ------------> +----------------------------+ ----------------------------
o
*/

 

  • UPAGES 和 UENVS 处于用户可见区内,包含内核的页面控制块和进程控制块,每个进程拷贝成了相同的内容

执行进程 - env_run - Exercise 3.8

  • 函数作用:切换并运行指定进程
/* Overview:
* 切换当前运行进程
*
* Hints:
* 使用 'env_pop_tf'.
*/
void env_run(struct Env *e) {
assert(e->env_status == ENV_RUNNABLE);
pre_env_run(e); // WARNING: DO NOT MODIFY THIS LINE!

/* Step 1: 保存当前运行栈 */
if (curenv) {
curenv->env_tf = *((struct Trapframe *)KSTACKTOP - 1);
}

/* Step 2: 变更全局变量 curenv */
curenv = e;
curenv->env_runs++; // lab6

/* Step 3: 更改当前运行进程的页目录 */
/* Exercise 3.8: Your code here. (1/2) */
cur_pgdir = curenv->env_pgdir;

/* Step 4: 使用 'env_pop_tf' 更新寄存器,并返回用户态 */
/* Exercise 3.8: Your code here. (2/2) */
env_pop_tf(&curenv->env_tf, curenv->env_asid);
}
  • 返回用户态,实际上是个精妙的过程,只不过我们的填空不需要完成这部分,但实在是值得研究研究

env_pop_tf

  • 修改 CP0 寄存器和 sp 寄存器,为跳回用户态提供数据
LEAF(env_pop_tf)
.set reorder
.set at
sll a1, a1, 6
mtc0 a1, CP0_ENTRYHI # 把 ENTRYHI 中保存 ASID 的部分改为 curenv->env_asid
move sp, a0 # 把栈指针变更为 &curenv->env_tf
j ret_from_exception # 离开异常处理程序,返回用户态
END(env_pop_tf)

ret_from_exception

  • 刚才在 env_pop_tf 的最后一句中,我们跳转到了这个汇编函数,它的作用是离开异常处理程序,回到用户态,虽然当前调用不算是异常处理,但是想要完成的作用都一样:恢复用户态现场,返回用户态执行
FEXPORT(ret_from_exception)
RESTORE_SOME # 从 sp(视作 tf)中恢复除了 sp 以外的大部分寄存器
lw k0, TF_EPC(sp) # 从 tf 中取出 EPC
lw sp, TF_REG29(sp) # 从 env_tf 中取得用户栈指针,赋值给 sp ,即切换到用户栈
.set noreorder
jr k0 # 跳转至用户态 EPC ,离开异常处理程序
rfe # 调用 rfe,使 SR 寄存器中的二重栈出栈一层
.set reorder
  • 这里的 EPC 在初始化进程块时就被赋值成了 ELF 程序入口点,也就是说会直接 jr 到加载的 ELF 程序开始
  • 其中最关键的是 lw sp这一句,把用户栈指针从 tf 中取出,恢复了用户栈
  • 最后使用的 rfe 指令也刚好把 env_alloc 时初始化的二重栈用上了。每个进程都需要由此启动,所以确实都需要执行一次 rfe 指令
  • md,太妙了

 

中断和异常

P7 的痛苦回忆又回来了.jpg

我们实验里认为中断是异常的一种,并且是仅有的一种异步异常

异常处理逻辑

  • 设置 EPC 指向返回地址
  • 设置 SR 寄存器,强制 CPU 进入内核态
  • 设置 Cause 寄存器记录异常原因
  • 跳转到异常处理程序入口,执行处理

 

异常分发程序 - exc_gen_entry - Exercise 3.9

  • 异常分发程序:根据发生的异常跳转到异常处理程序
.section .text.exc_gen_entry
exc_gen_entry:
SAVE_ALL # 将当前寄存器形成 TrapFrame 保存在内核栈 KSTACKTOP 内
/* Exercise 3.9: Your code here. */
mfc0 t0, CP0_CAUSE
andi t0, 0x7c # 获取 Cause 寄存器中的 ExcCode
lw t0, exception_handlers(t0) # 通过 handler 获取分发的处理函数入口
jr t0 # 跳转到对应的异常处理入口,响应异常
  • lw t0, exception_handlers(t0) 指令明明是个访存指令,如何做到获取处理函数地址的呢?

实际上这是由于 exception_handlers 这个异常向量组以数组的形式保存在内存中。只需要将 t0 寄存器作为下标,我们就能直接访问了这个数组的对应内容(这个内容存放的就是处理函数的地址) 为了程序能找到这个分发程序和处理 TLB Miss 的程序,我们在 kernal.lds 中放置了它们所在的字段:

. = 0x80000000;
.tlb_miss_entry : {
*(.text.tlb_miss_entry)
}

= 0x80000080;
.exc_gen_entry : {
*(.text.exc_gen_entry)
}

(虽然处理 TLB 的函数会直接跳到主异常分发程序,没啥用(

异常向量组 - exception_handlers

  • 异常分发程序通过 exception_handlers 数组定位中断处理程序,而定义在 kern/traps.c 中的 exception_handlers 就称作异常向量组

这部分指导书的逻辑很明确,就直接借用了:

extern void handle_int(void);
extern void handle_tlb(void);
extern void handle_sys(void);
extern void handle_mod(void);
extern void handle_reserved(void);

// 函数定义在哪个文件可以参考 Report 内容,在思考题中出现过

void (*exception_handlers[32])(void) = {
[0 ... 31] = handle_reserved,
[0] = handle_int,
[2 ... 3] = handle_tlb,
#if !defined(LAB) LAB >= 4
[1] = handle_mod,
[8] = handle_sys,
#endif
};

通过把相应处理函数的地址填到对应数组项中,我们初始化了如下异常: 0 号异常的处理函数为handle_int,表示中断,由时钟中断、控制台中断等中断造成 1 号异常的处理函数为handle_mod,表示存储异常,进行存储操作时该页被标记为只读 2 号异常的处理函数为handle_tlb,表示TLB load 异常 3 号异常的处理函数为handle_tlb,表示TLB store 异常 8 号异常的处理函数为handle_sys,表示系统调用,用户进程通过执行syscall 指令陷 入内核

通过访问对应的异常下标,就能在异常分发程序中进入对应的处理函数(目前中断还不行),最后调用 ret_from_exception 返回用户态

中断处理

  • 中断处理的流程
  • 进入异常分发程序,判断为中断
  • 进入中断处理程序 handle_int,判断中断种类,再进行分发
  • 处理中断结束后,进入 ret_from_exception 返回用户态

目前我们的 MOS 只能处理一种时钟中断,所以中断处理就只有一种选择()

NESTED(handle_int, TF_SIZE, zero)
mfc0 t0, CP0_CAUSE
mfc0 t2, CP0_STATUS
and t0, t2 # 获取可以处理的时钟中断
andi t1, t0, STATUS_IM4 # 中断号 4 号:时钟中断
bnez t1, timer_irq
// TODO: handle other irqs
timer_irq:
sw zero, (KSEG1 DEV_RTC_ADDRESS DEV_RTC_INTERRUPT_ACK)
li a0, 0 # 将时钟响应位置0
j schedule # 执行 schedule(0)
END(handle_int)

时钟中断 - kclock_init - Exercise 3.11

MOS 系统产生定时的时钟中断,并根据这些中断分配每个进程运行的时间片(限制进程一次性运行的时间长度)

  • 初始化并启用时钟中断

kern/kclock.S 中的 kclock_init 函数完成了时钟中断的初始化,该函数向 KSEG1 DEV_RTC_ADDRESS DEV_RTC_HZ 位置写入200,其中 KSEG1 DEV_RTC_ADDRESS 是模拟器(GXemul)映射实时钟的位置。偏移量为 DEV_RTC_HZ 表示设置实时钟中断的频率,200 表示1 秒钟中断200 次。 随后再调用 kern/env_asm.S 中的 enable_irq 函数开启中断。

LEAF(kclock_init)
li t0, 200 // the timer interrupt frequency in Hz

/* Write 't0' into the timer (RTC) frequency register.
*
* Hint:
* To access device through mmio, a physical address must be converted to a
* kseg1 address.
* #define DEV_RTC_HZ 0x0100
* #define DEV_RTC_ADDRESS 0x15000000
* #define KSEG1 0xA0000000U
*/
/* Exercise 3.11: Your code here. */
sw t0 , (KSEG1 DEV_RTC_ADDRESS DEV_RTC_HZ)
jr ra
END(kclock_init)

enable_irq 函数在 Report 里也有,文章太长就不写力

  • 时钟中断的处理
    1. 中断产生,进入异常分发程序
    2. 判断为中断,进入中断处理程序
    3. 判断为时钟中断,执行处理函数
    4. 执行 schedule(0),进行进程调度

进程调度 - schedule - Exercise 3.12

  • 函数功能:根据参数 yield 和当前进程状态进行进程调度
/* Hints:
* 1. 使用 static 变量 count 记录剩余时间片
* 2. 不需要在 'noreturn' 函数中使用 return
*/
void schedule(int yield) {
static int count = 0; // remaining time slices of current env
struct Env *e = curenv;

/* We always decrease the 'count' by 1.
*
* If 'yield' is set, or 'count' has been decreased to 0, or 'e' (previous 'curenv') is
* 'NULL', or 'e' is not runnable, then we pick up a new env from 'env_sched_list' (list of
* all runnable envs), set 'count' to its priority, and schedule it with 'env_run'. **Panic
* if that list is empty**.
*/
/* Exercise 3.12: Your code here. */
if (yield != 0 count == 0 e == NULL e->env_status != ENV_RUNNABLE) {
if (e != NULL) {
TAILQ_REMOVE(&env_sched_list, e, env_sched_link);
}
if (e != NULL && e->env_status == ENV_RUNNABLE) {
TAILQ_INSERT_TAIL(&env_sched_list, e, env_sched_link);
}
if (TAILQ_EMPTY(&env_sched_list)) {
panic("schedule: no runnable envs");
}
e = TAILQ_FIRST(&env_sched_list);
count = e->env_pri;
}
count--;
env_run(e);
}
  • schedule 函数中,我们没有使用 return,而直接进入了新一轮的 env_run,保存当前已运行的部分内容,开始新的进程。
  • 这也说明了为什么只有 timer_irq 没有调用 ret_from_exceprion 函数,因为不用回到原本执行进程的用户态了

  Lab3 大体到这里就结束了,要写的要看的也太多了,有点逆天。