实验报告
思考题
Thinking 3.1
实现自映射,也就是需要页目录作为二级页表时对应的区域是整个页表,页目录的每一页表项对应的是每一个二级页表。
也即在pgdir中PTX的值和PDX相等
的项对应的是一级页表首地址。
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V
要表达的就是这个意思,又加上了有效位。
Thinking 3.2
不可以没有data参数。
我们先找找这个函数的使用实例吧。
1
|
int elf_load_seg(Elf32_Phdr *ph, const void *bin, elf_mapper_t map_page, void *data)
|
首先在env_create中:
1
2
3
4
5
6
7
8
9
|
struct Env *env_create(const void *binary, size_t size, int priority) {
struct Env *e;
try(env_alloc(&e, 0));
e->env_pri = priority;
e->env_status = ENV_RUNNABLE;
load_icode(e, binary, size);
TAILQ_INSERT_HEAD(&env_sched_list, e, env_sched_link);
return e;
}
|
调用了函数load_icode()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
static void load_icode(struct Env *e, const void *binary, size_t size) {
const Elf32_Ehdr *ehdr = elf_from(binary, size);
if (!ehdr) {
panic("bad elf at %x", binary);
}
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) {
panic_on(elf_load_seg(ph, binary + ph->p_offset, load_icode_mapper, e));
}
}
e->env_tf.cp0_epc = ehdr->e_entry;
}
|
在load_icode函数中我们找到了elf_load_seg()的调用。在这次调用中,回调函数map_page函数即为load_icode_mapper(),而data参数是传入的进程指针e,在env_create中的体现则是新分配的进程e。
我们在load_icode_mapper()函数中
1
2
3
4
5
6
7
8
9
|
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;
try(page_alloc(&p));
if (src != NULL) {
memcpy((void *)page2kva(p) + offset, src, len);
}
return page_insert(env->env_pgdir, env->env_asid, p, va, perm);
}
|
可以看到data需要用于构造页表映射,为其提供pgdir与asid信息。
假如没有这个参数,page_insert函数就会缺少应有的参数。
Thinking 3.3
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
|
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, PAGE_SIZE);
if (offset != 0) { //第一个不完整的页的映射
if ((r = map_page(data, va, offset, perm, bin, MIN(bin_size, PAGE_SIZE - offset))) != 0) {
return r;
}
}
// 完整的页的映射
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;
}
}
while (i < sgsize) { // 文件大小<内存,将剩余的内存都写入NULL
if ((r = map_page(data, va + i, 0, perm, NULL, MIN(sgsize - i, PAGE_SIZE))) != 0) {
return r;
}
i += PAGE_SIZE;
}
return 0;
}
|
因此函数考虑了:
- 对于段头不页对齐的段,通过Offset将第一个不完整的页进行映射
- 对于文件内容未占满整页时,通过MIN来保证写入内容的长度
- 文件大小小于内存大小时,仍继续映射,用NULL写入剩余内存
- 在每一次映射都及时检查返回值并报出异常
Thinking 3.4
参见指导书:
这里的env_tf.cp0_epc字段指示了进程恢复运行时PC应恢复到的位置。我们要运行的
进程的代码段预先被载入到了内存中,且程序入口为e_entry,当我们运行进程时,CPU将自
动从PC所指的位置开始执行二进制码。
而CPU执行过程中用到的都是虚拟地址,因此这里的env_tf.cp0_epc也是虚拟地址。
此外,e_entry本身的属性也是虚拟地址。
1
2
3
4
5
6
7
|
typedef struct {
//unsigned char e_ident[EI_NIDENT];
// ...
Elf32_Addr e_entry; /* Entry point virtual address */
//Elf32_Off e_phoff;
// ...
} Elf32_Ehdr;
|
Thinking 3.5
在genex.S中:
0号异常处理handle_int:
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)
|
对于其他异常使用了宏定义同一处理方法。
1号异常 do_tlb_mod以及
2、3号异常 do_tlb_refill:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
.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
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
|
函数的具体实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
void do_tlb_mod(struct Trapframe *tf) {
struct Trapframe tmp_tf = *tf;
if (tf->regs[29] < USTACKTOP || tf->regs[29] >= UXSTACKTOP) {
tf->regs[29] = UXSTACKTOP;
}
tf->regs[29] -= sizeof(struct Trapframe);
*(struct Trapframe *)tf->regs[29] = tmp_tf;
Pte *pte;
page_lookup(cur_pgdir, tf->cp0_badvaddr, &pte);
if (curenv->env_user_tlb_mod_entry) {
tf->regs[4] = tf->regs[29];
tf->regs[29] -= sizeof(tf->regs[4]);
tf->cp0_epc = curenv->env_user_tlb_mod_entry;
} else {
panic("TLB Mod but no user handler registered");
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
NESTED(do_tlb_refill, 24, zero)
mfc0 a1, CP0_BADVADDR
mfc0 a2, CP0_ENTRYHI
andi a2, a2, 0xff
.globl do_tlb_refill_call;
do_tlb_refill_call:
addi sp, sp, -24
sw ra, 20(sp)
addi a0, sp, 12
jal _do_tlb_refill
lw a0, 12(sp)
lw a1, 16(sp)
lw ra, 20(sp)
addi sp, sp, 24
mtc0 a0, CP0_ENTRYLO0
mtc0 a1, CP0_ENTRYLO1
nop
tlbwr
jr ra
END(do_tlb_refill)
|
Thinking 3.6
在entry.S:
1
2
3
4
5
6
7
8
9
10
11
|
.section .text.exc_gen_entry
exc_gen_entry: # 进入异常
SAVE_ALL
mfc0 t0, CP0_STATUS
and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE)
# IE置0,禁止一切中断
mtc0 t0, CP0_STATUS
mfc0 t0, CP0_CAUSE
andi t0, 0x7c # 得到同步异常码
lw t0, exception_handlers(t0)
jr t0
|
在env_asm.S中的env_pop_tf
和genex.S中的handle_\exception
都调用了genex.S定义的ret_from_exception
:
1
2
3
|
FEXPORT(ret_from_exception)
RESTORE_ALL # 恢复寄存器的值,也即恢复时钟中断
eret # 恢复用户态
|
env_pop_tf
的调用可以参见env_run(),即启动一个线程时,应开启时钟中断。
因此总结来看:
- 在线程启动时,时钟中断开启
- 线程在用户态正常运行,时钟中断保持开启
- 线程陷入中断或异常后,取消时钟中断
- 线程异常处理结束,恢复时钟中断
Thinking 3.7
首先,每一次时钟中断都会进入exc_gen_entry。
我曾经对这个感到十分困惑,在文件夹里找了很久也没有找到,到底是谁调用了exc_gen_entry…
直到我扒到kernel.lds
。
我突然明白:这就是PC地址啊!
检测中断,跳转PC这些事,都是我们P7硬件所实现的啊!
到达exc_gen_entry不需要函数调用的入口!它就是CPU当前PC执行的指令!
接下来思路畅通无阻。
exc_gen_entry进入内核态,检测异常信息,并进行handle_int的跳转。
handle_int的最后一句便是j schedule
调用schedule函数。
schedule函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
void schedule(int yield) {
static int count = 0;
struct Env *e = curenv;
if (e == NULL || count == 0 || e->env_status != ENV_RUNNABLE || yield) {
if (e != NULL && e->env_status == ENV_RUNNABLE) {
TAILQ_REMOVE(&env_sched_list, e, env_sched_link);
TAILQ_INSERT_TAIL(&env_sched_list, e, env_sched_link);
}
e = TAILQ_FIRST(&env_sched_list);
if (e == NULL) {
panic("schedule: no runnable envs\n"); //Panic if the list is empty
}
count = e->env_pri;
}
count--;
env_run(e);
}
|
难点分析
本次lab3主要分为进程与异常两部分,但异常的实现也建立在进程上,因此核心处理的问题就是进程。
进程的建立
mkenvid: 创建一个独一无二的标识符
asid_alloc: 分配一个asid标识
map_segment: 创建页的映射
env_init: 初始化进程管理系统的数据结构
- 初始化链表:
env_free_list:使用 LIST_INIT 初始化空闲进程链表。
env_sched_list:使用 TAILQ_INIT 初始化调度进程队列。
- 初始化所有环境:
遍历 envs 数组,将每个进程的状态设置为 ENV_FREE。
将这些进程插入到 env_free_list 中,且顺序与 envs 数组中的顺序一致。
- 映射内核数据结构到用户空间:
分配一个页作为基础页目录 base_pgdir。
将内核的 pages 和 envs 数组映射到用户空间的 UPAGES 和 UENVS 区域,权限为只读(PTE_G)。
env_setup_vm: 为给定的进程 e 初始化用户地址空间。
- 分配页目录并复制模板:
分配一个物理页作为页目录,将页的内核虚拟地址赋给 e->env_pgdir。
从 base_pgdir 复制 [UTOP, UVPT) 部分的页目录项到 e->env_pgdir。这样所有进程的内核部分地址空间是一致的。
- 自映射页表:
将进程的页目录映射到 UVPT,使得用户进程可以通过 UVPT 读取自己的页表(只读权限)。
env_alloc: 分配并初始化一个新的进程
load_icode_mapper: 实现段的映射与复制
load_icode: 加载可执行文件,并初始化进程入口
env_create: alloc + 属性设置 + 文件加载 + env_sched_list 插入
进程的启动
env_pop_tf: 恢复用户态执行上下文
1
2
3
4
5
|
mtc0 a1, CP0_ENTRYHI
move sp, a0
RESET_KCLOCK
RESTORE_ALL
eret
|
env_run: 切换到目标用户进程并执行
如果当前有正在运行的进程(curenv != NULL),保存其上下文到 curenv->env_tf。
其中KSTACKTOP 是内核栈的顶部地址,则
(struct Trapframe *)KSTACKTOP - 1 指向内核栈中保存的 Trapframe(即当前进程的寄存器状态)
将内核栈中的 Trapframe 复制到 curenv->env_tf,以便后续恢复。
然后切换进程、切换页表、恢复当前进程对应的上下文
进程的结束
env_free: 释放一个线程。
具体实现中要清除的内容还是蛮复杂的:
遍历页表并释放物理页、释放页表和页目录、释放 ASID、刷新 TLB、放回空闲链表
env_destroy: 若当前进程就是要被destroy的,需要再进行schedule
进程的异常
异常的识别由硬件实现,硬件会直接把PC跳转到entry。
异常入口: 保存上下文,进入内核态,得到异常类型,跳转到异常处理函数
异常处理函数: 见Thinking 3.5
实验体会
本次实验相较于Lab2,用到的宏数量有所降低。此外,许多宏在Lab2就有所接触,因此上手起来比Lab2要更顺利一点。
由于Lab2中的page_alloc()是可以失败的,而lab3的进程又高度依赖页面,因此在Lab3中较多地使用了try()与panic()宏。
Lab3的异常处理和P7明明讲的是同一件事,却完全是两套内容。然而软硬两套内容的内部逻辑如此自洽,以至于我惊讶于当前OS的实现原来也离不开CO的异常判断;也从未想过学CO时所谓的异常处理函数并不是简简单单的六个字。
目前我对于load_icode的理解还不够深入,只是能接受这个函数。面对lab3的丰富函数,不理一下调用场景与逻辑,难免会感到迷茫。
望一切顺遂。