lab3实验报告

思考题

Thinking 3.1

题面

1
请结合 MOS 中的页目录自映射应用解释代码中 e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V 的含义。

回答

在MOS中,根据自映射机制,将页表和页目录映射到了用户空间中的0x7fc00000-0x80000000(UVPT-ULIM)区域,而由于自映射,所以物理内存上页目录单独占一页,其余二级页表单独占一页,但是在虚空间上某一个二级页表对应的虚拟页和页目录对应的虚拟页实际上重叠的,也就是某个二级页表虚地址同页目录虚地址重合,那么这两者对应的物理页框也应该重合,而页目录的每一个页表项存储的是相应二级页表所在页的基地址,因此在页目录中应该有一个页表项存储的是到页目录这一页本身的物理页框号,也即”自映射页表”。
因此,由于按需分配元组,在初始化对应页目录时需要设置映射到页目录自身的页表项,而其在页目录中的索引应该与虚空间上页目录按4KB大小在所有二级页表中的索引以及第一个二级页表按4MB大小在虚空间上的索引,也就是PDX(UVPT)PDX()的作用是取一个32位二进制数的高10位,也就是按4MB大小在虚空间上的索引,而等式右边的PADDR(e->env_pgdir) | PTE_V中的PADDR(e->env_pgdir)代表页目录的物理基地址,而该物理地址由于是通过分配好的页控制块转换而来,因此一定4KB对齐的,也就是低12位都是0,|``PTE_V也就是为其置有效位,实现了该页表项高20位是自映射到当前所在页的物理页号,而低12位则是相应的权限位,实现了自映射页目录项的设置。

Thinking 3.2

题面

1
elf_load_seg 以函数指针的形式,接受外部自定义的回调函数 map_page。请你找到与之相关的 data 这一参数在此处的来源,并思考它的作用。没有这个参数可不可以?为什么?

回答

相关代码中出现的位置如下:

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
//env.c/load_icode_mapper()
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;
...
return page_insert(env->env_pgdir, env->env_asid, p, va, perm);
}

//lib.c/elf_load_seg()
int elf_load_seg(Elf32_Phdr *ph, const void *bin, elf_mapper_t map_page, void *data) {
...
if ((r = map_page(data, va, offset, perm, bin,
MIN(bin_size, PAGE_SIZE - offset))) != 0) {
return r;
}
...
}

//env.c/load_icode()
static void load_icode(struct Env *e, const void *binary, size_t size) {
/* Step 1: Use 'elf_from' to parse an ELF header from 'binary'. */
const Elf32_Ehdr *ehdr = elf_from(binary, size);
...
panic_on(elf_load_seg(ph, binary + ph->p_offset, load_icode_mapper, e));
...
}

由上述代码可见,在load_icode()函数中调用了elf_load_seg()函数,给其传入的data参数是进程控制块指针,而elf_load_seg()函数通过回调函数map_page(),也就是load_icode_mapper()函数,其中data参数传递给了load_icode_mapper()函数,在该函数中调用了page_insert()来建立对应进程空间中虚地址与分配的物理页面的映射关系。
因此,不可以没有该参数,若没有该参数将无法提供当前进程的页目录地址以及ASID,无法完成page_insert()函数的功能

Thinking 3.3

题面

1
结合 elf_load_seg 的参数和实现,考虑该函数需要处理哪些页面加载的情况。

回答

相关加载内容如下:

相关代码如下:

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
37
38
39
40
41
42
43
44
45
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; //可写的话将页表项的可写位置1
}

int r;
size_t i;
u_long offset = va - ROUNDDOWN(va, PAGE_SIZE);
//假若地址未对齐,取地址所在页超出页头的偏移为offset
if (offset != 0) {
//在偏移不为0的情况下,将相应内容,写入以该页偏移offset为始的位置,写入(bin_size, PAGE_SIZE - offset)间的最小值个字节
//因为假若后续长度不及PAGE_SIZE - offset,强行写入会发生越界读取的情况
if ((r = map_page(data, va, offset, perm, bin,
MIN(bin_size, PAGE_SIZE - offset))) != 0) {
return r;
}
}

/* Step 1: load all content of bin into memory. */
//写入data段和text段的所有内容,此处在末尾未对齐前按页大小存入,
//未对齐处按多出部分写入,具体实现是写入的总量是MIN(bin_size - i,PAGE_SIZE)
for (i = offset ? MIN(bin_size, PAGE_SIZE - offset) : 0; i < bin_size; i += PAGE_SIZE) {
if ((r = map_page(data, va + i, 0, perm, bin + i, MIN(bin_size - i, PAGE_SIZE))) !=
0) {
//
return r;
}
}

/* Step 2: alloc pages to reach `sgsize` when `bin_size` < `sgsize`. */
//如果bin_size小于sgsize,将后续内容全部填零,也就是将.bss段全部清零处理,同前未对齐部分也采用MIN(sgsize - i, PAGE_SIZE)处理
while (i < sgsize) {
if ((r = map_page(data, va + i, 0, perm, NULL, MIN(sgsize - i, PAGE_SIZE))) != 0) {
return r;
}
i += PAGE_SIZE;
}
return 0;
}

因此该函数需要考虑segment.text段和.data段组成的内容的起始地址所在页未对齐的页面,后续对齐的页面以及末地址未对齐的页面,还有.bss段所占页面的加载情况。

Thinking 3.4

题面

1
2
思考上面这一段话,并根据自己在 Lab2 中的理解,回答:
• 你认为这里的 env_tf.cp0_epc 存储的是物理地址还是虚拟地址?

回答

我认为这里应该是虚拟地址,因为在MOS中访问的地址永远是虚拟地址

Thinking 3.5

题面

1
试找出 0123 号异常处理函数的具体实现位置。8 号异常(系统调用)涉及的 do_syscall() 函数将在 Lab4 中实现。

回答

异常向量组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);

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、1、2、3号异常处理函数为handle_inthandle_mod
handle_tlb(2、3相同),根据grep指令寻找到具体实现在kern/genex.S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
NESTED(handle_int, TF_SIZE, zero)
mfc0 t0, CP0_CAUSE
mfc0 t2, CP0_STATUS
and t0, t2
andi t1, t0, STATUS_IM7
bnez t1, timer_irq
timer_irq:
li a0, 0
j schedule
END(handle_int)

BUILD_HANDLER tlb do_tlb_refill

#if !defined(LAB) || LAB >= 4
BUILD_HANDLER mod do_tlb_mod
BUILD_HANDLER sys do_syscall
#endif

BUILD_HANDLER reserved do_reserved

Thinking 3.6

题面

1
阅读 entry.S、genex.S 和 env_asm.S 这几个文件,并尝试说出时钟中断在哪些时候开启,在哪些时候关闭。

回答

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#entry.S
#include <asm/asm.h>
#include <stackframe.h>
.section .text.tlb_miss_entry
tlb_miss_entry:
j exc_gen_entry
.section .text.exc_gen_entry
exc_gen_entry:
SAVE_ALL
mfc0 t0, CP0_STATUS
and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE)
mtc0 t0, CP0_STATUS #关闭中断,同时关闭时钟中断
/* Exercise 3.9: Your code here. */
mfc0 t0, CP0_CAUSE
andi t0, 0x7c
lw t0, exception_handlers(t0)
jr t0 #跳转到相应的异常处理程序

#genex.S
#include <asm/asm.h>
#include <stackframe.h>
.macro BUILD_HANDLER exception handler
NESTED(handle_\exception, TF_SIZE + 8, zero)
move a0, sp
addiu sp, sp, -8
jal \handler
addiu sp, sp, 8
j ret_from_exception #返回异常,开中断
END(handle_\exception)
.endm
.text
FEXPORT(ret_from_exception)
RESTORE_ALL
eret
NESTED(handle_int, TF_SIZE, zero)
mfc0 t0, CP0_CAUSE
mfc0 t2, CP0_STATUS
and t0, t2
andi t1, t0, STATUS_IM7
bnez t1, timer_irq #判断是否是7号中断位引发的时钟中断,是的话跳转到相应的处理代码处
timer_irq:
li a0, 0
j schedule #跳转到调度函数schedule
END(handle_int)
BUILD_HANDLER tlb do_tlb_refill
#if !defined(LAB) || LAB >= 4
BUILD_HANDLER mod do_tlb_mod
BUILD_HANDLER sys do_syscall
#endif
BUILD_HANDLER reserved do_reserved

#env_asm.S
#include <asm/asm.h>
#include <mmu.h>
#include <trap.h>
#include <kclock.h>
.text
LEAF(env_pop_tf)
.set reorder
.set at
mtc0 a1, CP0_ENTRYHI
move sp, a0
RESET_KCLOCK
j ret_from_exception #返回异常,开中断
END(env_pop_tf)

Thinking 3.7

题面

1
阅读相关代码,思考操作系统是怎么根据时钟中断切换进程的。

回答

genex.S中有如下代码:

1
2
3
4
5
6
7
8
9
10
NESTED(handle_int, TF_SIZE, zero)
mfc0 t0, CP0_CAUSE
mfc0 t2, CP0_STATUS
and t0, t2
andi t1, t0, STATUS_IM7
bnez t1, timer_irq
timer_irq:
li a0, 0
j schedule
END(handle_int)

OS通过相应异常向量组,调用handle_int函数来处理中断,根据**CP0_CAUSECP0_STATUS的值来判断当前是否是7号的时钟中断,如果两者都是就跳转到相应的处理程序timer_irq,其通过schedule函数进行进程调度**。

lab3课下

Exercise 3.1

题面

1
2
完成 env_init 函数。
实现 Env 控制块的空闲队列和调度队列的初始化功能。请注意,你需要按倒序将所有控制块插入到空闲链表的头部,使得编号更小的进程控制块被优先分配。

回答

由于env_free_list采用的是LIST结构,而env_sched_list采用的是TAILQ结构,因此这两者分别采用LIST_INIT()TAILQ_INIT()两个宏来完成初始化,需要注意的是,这里传入的参数都是指针形式。由于要求将空闲PCB倒序插入空闲链表中,因此循环变量应从空闲PCB总数减1开始,直到0,使用LIST_INSERT_HEAD()宏插入到空闲链表中,注意宏的相应参数要求

Exercise 3.2

题面

1
请你结合 env_init 中的使用方式,完成 map_segment 函数。

回答

看到虚地址与物理地址建立映射,立马想到lab2中使用过的建立虚地址与物理页框之间的映射关系的函数page_insert,由于映射长度相同,即偏移量相同,因此va + ipa + i就可以分别代表映射关系的双方,为了获取相应的物理页,可以使用pa2page()宏将pa + i转换为对应的物理页,再通过page_insert函数建立映射关系即可。

Exercise 3.3

题面

1
2
完成 env_setup_vm 函数。
仔细阅读前文的提示理解一个进程虚拟地址空间的分布,根据注释完成函数,实现初始化一个新进程地址空间的功能。

回答

根据提示即可,将相应页面的引用次数pp_ref加一,并给相应的新建的PCB的页目录首地址修改为分配的物理地址对应的虚地址,借助**page2kva()宏进行虚实地址转换**,注意,env_pgdirPde *类型,需要强制类型转换。

Exercise 3.4

题面

1
2
3
4
5
完成 env_alloc 函数。
env_alloc 函数实现了申请并初始化一个进程控制块的功能。这里给出如下提示:
1. 回忆 Lab2 中的链表宏 LIST_FIRST、LIST_REMOVE,实现在 env_free_list 中申请空闲进程控制块。
2. 用 env_setup_vm 初始化新进程的地址空间。
3. 仔细阅读前文中对与 Lab3 相关的域的介绍,思考相关域的恰当赋值。

回答

根据提示作答即可,将题目需要初始化的结构体成员进行相应的赋值,一定要注意异常情况需要返回负值,例如:一开始应当使用LIST_EMPTY()宏来检查env_free_list是否为空,而**env_setup_vm()函数也有可能因为空闲页控制块为空而抛出异常asid_alloc()函数由于无空闲asid而返回负值等情况,需要提前返回相应异常值**。

Exercise 3.5

题面

1
2
 完成 kern/env.c 中的 load_icode_mapper 函数。
提示:可能使用到的函数有 page_alloc,page_insert,memcpy。

回答

根据提示一步步填空即可,不过需要注意的是page_alloc()函数是有可能返回空,也就是返回一个负数的,因此需要考虑page_free_list为空的特殊情况。此外对于memcpy的使用可以参照string.c中的用法,注意写入的是带偏移量offset的位置.

1
2
3
void *memcpy(void *dst, const void *src, size_t n) {
...
}

Exercise 3.6

题面

1
根据注释的提示,完成 kern/env.c 中的 load_icode 函数。

回答

根据提示完成相应填空即可。该函数的作用就是将二进制文件中的所有段加载到内存上,通过ELF_FOREACH_PHDR_OFF遍历所有段的偏移,根据判断其是否可加载的状态(ph->p_type == PT_LOAD),若可加载就通过调用eld_load_seg()函数利用load_icode_mapper回调函数加载相应的段到内存上;否则不进行加载。

Exercise 3.7

题面

1
2
完成 env_create 函数。
根据提示,理解并恰当使用前面实现的函数,完成 kern/env.c 中 env_create 函数的填写,实现创建一个新进程的功能。

回答

根据提示一步一步完成,首先利用env_alloc()函数分配一个新的PCB,注意可能会分配失败,即返回了负值,需要判断一下。接着将传入的priority参数赋给新分配的eenv_pri变量成员,设置其状态env_statusENV_RUNNABLE,紧接着将传入的binarysize作为参数调用load_icode()函数加载相应段内容到内存上,并将分配好的PCB插入env_sched_list中,注意采用的宏不同于之前的,是TAILQ_INSERT_HEAD。**注意此处的field域不再是env_link而是env_sched_link**。

Exercise 3.8

题面

1
2
完成 env_run。
仔细阅读前文讲解,并根据注释填写 kern/env.c 中的 env_run 函数。

回答

根据提示,env_run()函数的主要作用是保存当前进程的上下文切换当前进程为e修改全局变量cur_pgdirenv_pop_tf()函数将切换后的进程的上下文恢复到相应位置异常返回。其中尤其注意env_pop_tf()的用法:extern void env_pop_tf(struct Trapframe *tf, u_int asid) __attribute__((noreturn));,其中第一个参数传入的是指针形式,而第二个参数传入的是ASID,由于ASID只有8位,因此需要对进程控制块提供的env_asid进行高24位置零处理,做法就是将其&一个NASID - 1NASID宏的值是256。==尤其注意,在这里env_pop_tf()函数是noreturn,不返回的,在这里的不返回不是指不返回返回值,而是该函数的结束将结束调用其的函数,也就是此处env_pop_tf()函数内结束后,env_run()函数就结束了,那么env_run()函数也是noreturn的,因此调用env_run()函数会在该函数中直接结束,并不会去运行逻辑上在env_run()函数之后的语句。==

Exercise 3.9

题面

1
2
补充 kern/entry.S。
理解异常分发代码,并将异常分发代码填至 kern/entry.S 恰当的部分。

回答

在理解的前提下根据提示,完成填空即可。该汇编代码就是根据相应的异常,跳转到相应的异常处理程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.section .text.tlb_miss_entry
tlb_miss_entry: #用户态地址的TLB Miss发生时
j exc_gen_entry

.section .text.exc_gen_entry
exc_gen_entry: #其余的异常发生的处理程序,其实前者发生时也会跳转到该程序进行处理
SAVE_ALL #保留当前内容,为异常处理后重新执行指令做准备
/*
* Note: When EXL is set or UM is unset, the processor is in kernel mode.
* When EXL is set, the value of EPC is not updated when a new exception occurs.
* To keep the processor in kernel mode and enable exception reentrancy,
* we unset UM and EXL, and unset IE to globally disable interrupts.
*/
mfc0 t0, CP0_STATUS
and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE)
mtc0 t0, CP0_STATUS #关中断,使CPU处于内核态,允许异常重入
/* Exercise 3.9: Your code here. */
mfc0 t0, CP0_CAUSE
andi t0, 0x7c
lw t0, exception_handlers(t0)
jr t0 #跳转到相应的异常处理程序

Exercise 3.10

题面

1
2
补全 kernel.lds
根据前文讲解将 kernel.lds 代码补全使得异常发生后可以跳到异常分发代码。

回答

根据提示完成填空即可,注意Linker Script的语法要求。

Exercise 3.11

题面

1
2
补充 RESET_KCLOCK 宏。
通过上面的描述,补充 include/kclock.h 中的 RESET_KCLOCK 宏。

回答

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define TIMER_INTERVAL (500000) // WARNING: DO NOT MODIFY THIS LINE!                                                                                                                                                                      
// clang-format off
.macro RESET_KCLOCK
li t0, TIMER_INTERVAL
/*
* Hint:
* Use 'mtc0' to write an appropriate value into the CP0_COUNT and CP0_COMPARE registers.
* Writing to the CP0_COMPARE register will clear the timer interrupt.
* The CP0_COUNT register increments at a fixed frequency. When the values of CP0_COUNT and
* CP0_COMPARE registers are equal, the timer interrupt will be triggered.
*
*/
/* Exercise 3.11: Your code here. */
mtc0 zero, CP0_COUNT
mtc0 t0, CP0_COMPARE
.endm

根据要求将CP0_COUNT清零,注意mtc0只能将寄存器的值赋给相应的cp0寄存器,并将给定的计数器周期数t0赋给CP0_COMPARE

Exercise 3.12

题面

1
2
完成 schedule 函数。
根据注释,填写 kern/sched.c 中的 schedule 函数实现切换进程的功能,使得进程能够被正确调度。

回答

代码和解析如下:

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
void schedule(int yield) {
static int count = 0; // remaining time slices of current env
struct Env *e = curenv;
//先判断当前的进程是否需要切换,也就是以下四种情况是否满足:
// 1.当前进程为NULL
// 2.传入参数yield不为0
// 3.当前进程的时间片结束,即count为0
// 4.当前进程进入阻塞状态(ENV_NOT_RUNNABLE)
if (e == NULL) {
panic_on(TAILQ_EMPTY(&env_sched_list)); //如果调度队列为空,panic
e = TAILQ_FIRST(&env_sched_list); //否则去出调度队列头
count = e->env_pri; //切换时间片为该进程的优先级
}
else {
if (yield != 0 || count == 0 || e->env_status == ENV_NOT_RUNNABLE) {
if (e->env_status == ENV_RUNNABLE) {
//在进程切换的前提下,如果当前进程仍在就绪状态,应将其移到调度队列末尾
//注意是移动,因此需要先把该进程控制块从调度队列中删除,再插入到队列末尾
//因为插入宏并没有去检查该进程控制块是否在队列中,不删除就插入,会形成环状
//也就是如果当前进程就在队列首的话,不删除而直接将其插入到末尾,其还在队列首,之后切换也还是这个进程
//导致了某一进程连续占用不释放,失去了进程切换的意义。
TAILQ_REMOVE(&env_sched_list, e, env_sched_link);
TAILQ_INSERT_TAIL(&env_sched_list, e, env_sched_link);
}
//此外由于进程控制块的在就绪队列的充要条件是PCB处于就绪或运行状态,也就是ENV_RUNNABLE,因此如果此处是由于当前进程的状态为阻塞态而进入该切换逻辑的话,其已经在切换之前就被REMOVE出去了,也就是此时不需要再删除队列首的PCB,当前队列首的PCB应该是接下来要运行的。
panic_on(TAILQ_EMPTY(&env_sched_list));
e = TAILQ_FIRST(&env_sched_list);
count = e->env_pri;
}
}
//每次env_run前都需要将count减一,说明用了一个时间间隔
//这个count一定得是当前进程的剩余时间片,切换后的或者没切换的
count--;
env_run(e);
//注意在这个函数后的语句将不会被执行
}

lab3课下难点

本次lab主要涉及两个方面——进程和异常,前者主要主要考察到的是创建、加载和运行切换,而后者则主要涉及到分发与处理。

进程

进程创建

由于进程控制块(PCB)是CPU感知进程的唯一标识,因此创建进程实际上就是创建PCB,根据PCBenv_status不同,也就是进程状态的不同,分别采用调度队列env_sched_list和空闲队列env_free_list来管理PCB,前者便于管理已分配PCB和对进程调度,采用的是类似的TAILQ结构,这是因为在进程进行切换的时候会涉及到将某个进程插入队列尾的情况,而LIST结构并未实现相应宏方法。后者用来存储空闲的PCB,采用的是lab2的LIST结构。

在PCB中有两个用来标识该进程的代号,一个是env_id,一个是env_asid,简单的来说,后者是为了解决原来切换页表时全部无效化TLB页表项带来的频繁TLB Miss问题,便于TLB标识虚拟地址空间。而前者是进程的总标识,只在进程完全结束后才被释放供其他进程使用,而后者是需要等到进程被销毁或者其对应TLB被清空时,其ASID就可被分配给其他进程,两者的释放时机略有不同,可以理解为ASID就是为了实现切换页表TLB不全清空而生

此后通过env_alloc()函数创建通过下列步骤相应的PCB:

  1. env_free_list中申请一个空闲PCB。

  2. 手动初始化PCB。

  3. 为新进程初始化页目录。

  4. 将该PCB从空闲链表中去除,用于使用。

加载

这部分指导书的讲解并不多,主要是要去理解下列函数的用途:

1
2
3
4
5
6
7
8
9
10
11
12
13
// lib/elfloader.c
const Elf32_Ehdr *elf_from(const void *binary, size_t size);
int elf_load_seg(Elf32_Phdr *ph, const void *bin,elf_mapper_t map_page, void *data);
//map_page和data用于接受一个自定义回调函数(前者)和需传递给回调函数的额外参数(后者),当该函数解析到一个需加载到内存中的页面时,会将有关信息作为参数传给回调函数,由其完成加载
//从ph中获取va(当前段被加载到的虚地址)、sgsize(段在内存的大小)、bin_size(段在文件中大小)和perm,完成:
// 1.加载段所有数据(bin)中的所有内容至内存(va);2.若段在文件中大小小于新分配页面大小,余下部分由0填充

// kern/env.c
static void load_icode(struct Env *e, const void *binary, size_t size);
//负责加载可执行文件binary到进程e内存中,其调用的elf_from函数完成对ELF文件头的解析,elf_load_seg负责将ELF文件的一个segment加载到内存。
//从ELF文件中解析出每个segment段头ph,及其数据在内存中的起始位置bin,再由elf_load_seg函数将参数指定的程序段加载到地址空间
static int load_icode_mapper(void *data, u_long va, size_t offset, u_int perm, const void *src, size_t len);
//回调函数map_page的具体实现

此后再通过env_create()函数在创建完一个新的PCB的基础上去创建进程。

进程运行与切换

也就是env_run()函数的实现,大致功能如下:

  1. 保存当前进程上下文信息,一般保存在env_tf成员变量中,实验中存放寄存器状态的地方是KSTACKTOP以下的一个sizeof(TrapFrame)大小的区域中,将后者拷贝到前者,就可达到保存进程上下文的效果。

  2. 切换curenv为即将运行进程e

  3. 设置全局变量cur_pgdir为当前进程页目录地址。

  4. 调用env_pop_tf函数,恢复现场,异常返回。

==尤其注意,在这里env_pop_tf()函数是noreturn,不返回的,在这里的不返回不是指不返回返回值,而是该函数的结束将结束调用其的函数,也就是此处env_pop_tf()函数内结束后,env_run()函数就结束了,那么env_run()函数也是noreturn的,因此调用env_run()函数会在该函数中直接结束,并不会去运行逻辑上在env_run()函数之后的语句==。

中断异常

部分机制在计组中有所涉略,此处就指明新的出现的过程。

处理流程

处理异常大致流程:

  1. 设置EPC指向异常返回的地址。
  2. EXL位,强制CPU进入内核态(进入特权模式)并禁止中断,可不能中断套娃
  3. 设置Cause寄存器,记录异常发生原因。
  4. CPU开始从异常入口处取指,此后交由异常处理程序处理。

异常分发

异常分发程序将检测发生了哪种异常,并调用相应的异常处理程序,一般被要求固定在某个物理地址上,以确保正确的跳转
实验的异常分发程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.section .text.exc_gen_entry
exc_gen_entry:
SAVE_ALL #将当前上下文保存到内核异常栈中
/*
* Note: When EXL is set or UM is unset, the processor is in kernel mode.
* When EXL is set, the value of EPC is not updated when a new exception occurs.
* To keep the processor in kernel mode and enable exception reentrancy,
* we unset UM and EXL, and unset IE to globally disable interrupts.
*/
mfc0 t0, CP0_STATUS
and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE) #清除Status寄存器值中的UM、EXL、IE位,使CPU保持内核态、关中断并允许嵌套异常
mtc0 t0, CP0_STATUS #写入Status寄存器中
/* Exercise 3.9: Your code here. */
mfc0 t0, CP0_CAUSE
andi t0, 0x7c #取Cause寄存器中的2-6位,即异常的类型码
lw t0, exception_handlers(t0) #以异常码为索引在exception_handlers数组里找对应中断处理函数
jr t0 #跳转到相应的中断处理程序处

此外,.text.exc_gen_entry段和.text.tlb_miss_entry段需被linker Script当置到特定位置,分别放置在0x800001800x80000000处,都是异常处理程序入口。实验系统中CPU发生异常(除用户态地址的TLB Miss异常)会自动跳转到前者,否则跳转到后者。

异常向量组

exception_handlers称为异常向量组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);

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
};

GNU C的扩展语法:[first ... last] = value,用于对数组某个区间上的元素赋同值,重叠的允许覆盖:
例如:int arr[5] = {[0 ... 3] = 1, [2 ... 4] = 2}; <=> int arr[5] = {1, 1, 2, 2, 2}
一些异常如下:

  • 0号异常 处理函数为handle_int,中断,由时钟中断、控制台中断等造成。
  • 1号异常 处理函数handle_mod,存储异常,存储操作时该页被标记为只读。
  • 2号异常 handle_tlbTLB load异常
  • 3号异常 handle_tlbTLB store异常
  • 8号异常 handle_sys,系统调用,用户进程执行syscall指令陷入内核。

时钟中断

中断处理流程:

  1. 异常分发,判断出当前异常为中断异常,随后进入相应的中断处理程序。
  2. 中断处理程序进一步判断Cause寄存器中由几号中断位引发的中断,然后进入不同的中断服务函数。
  3. 中断处理完成,通过ret_from_exception函数恢复现场,继续执行。
    时钟中断出现原因:与时间片轮转调度算法密切相关。MOS通过硬件定时器产生的时钟中断来判断一个进程的时间片结束。当时间片结束时当前进程挂起,MOS从调度队列中选取合适的进程运行。MOS利用CP0中内置的Timer产生时钟中断
    具体有两种控制Timer的寄存器,CountCompare寄存器,前者按某种仅与CPU流水线频率相关频率自增,后者保持不变。当两者值相等且不为0时,触发时钟中断
    RESET_KCLOCKCount清零,将Compare置为期望的计时器周期数。MOS中,时钟中断初始化发生在调度执行每一个进程前,也就是env_pop_tf调用RESET_KCLOCK,又在RESTORE_aLL中恢复Status寄存器,开启中断。
    时钟中断一产生会触发4KC硬件的异常中断处理流程,地址指向0x80000180,跳转到相应代码段执行,对时钟引起的中断,通过.text_exc_gen_entry代码段分发,调用handle_int函数处理,其根据Cause值判断是否为7号中断位引发的时钟中断,是则执行timer_irq,跳转到schedule执行。

进程调度

算法:用N*TIMER_INTERVAL来量化时间片长度,此处的N是进程的优先级,遍历调度链表找出当前的最优被调度进程。
schedule函数被调用时,(该函数也是本次实验的难点,一定要注意认真理解清楚了再下手操作,==尤其注意运行进程时都要将count–、因阻塞而切换不需要再删除队首、env_run()函数的noreturn属性等==)当前正运行进程在curenv中,其剩余时间片长度存储在count中,当下列情况发生时,进行进程切换

  • 尚未调度过任何进程,即curenvNULL.
  • 当前进程已用完时间片,count == 0.
  • 当前进程不再就绪,被阻塞或退出.
  • yield参数指定须发生切换.
    无需切换时,只需让count自减一,调用env_run函数,继续执行当前进程,发生切换时,还需判断当前进程是否仍就绪,若是将其移至调度链表末尾,之后选中调度链表首部进程来调度运行,count设置为其优先级。

TLB Miss异常的产生与处理

产生

4Kc的MMU对kuseg段的地址映射,仅由TLB完成。正常情况下,TLB命中虚地址的访存过程全由硬件完成,OS不插手,但是当TLB在转换时发现TLB中还没对应的映射项目,就会产生一个TLB Miss异常。硬件会打断无法继续进行的访存操作,陷入内核并跳转执行OS的对应异常处理程序(tlb_miss_entry),由OS查找页表,填上缺失的TLB项,再从异常返回,重执行打断的访存
相应的异常处理程序为handle_tlb,其通过宏BUILD_HANDLER包装了do_tlb_refill函数完成TLB重填:
硬件:发生TLB Miss时,CPU将引发地址装入BadVAddr中、虚页号填入EntryHi中的VPN域,Cause中的ExcCode域写为TLBL(读请求)或TLBS(写请求)。
软件:从BadVAddr中取出引发地址,在cur_pgdir中查找该地址对应的物理地址与权限位,然后将相应物理页号和权限位填入EntryLoPFN域和权限位,用tlbwrEntryHiEntryHi中相应内容随机写入TLB中的一项中,最后调用ret_from_exception从异常返回

心得体会

此次作业的代码量不算大,但是要真正理解进程创建、加载到切换运行而阅读的代码量是巨大的,大量的函数需要去厘清相应实现和关联调用,着实是个巨大的挑战,为此也花了近10个小时来完成课下。

尽管如此,还是在课上折戟,本质还是并没有真正理解一些函数的性质,像env_run()函数的noreturn属性等,这些本来应该在课下解决的却堆积到了课上,导致了课上的败北,值得敲响警钟。

总而言之,此次lab需要真正理解每一行代码、每一个函数的作用,只有这样才能对完成题目得心应手,之后的lab需要去贯彻落实这一点!