Featured image of post 北航OS Lab3 进程与异常

北航OS Lab3 进程与异常

实验报告

实验报告

思考题

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

因此函数考虑了:

  1. 对于段头不页对齐的段,通过Offset将第一个不完整的页进行映射
  2. 对于文件内容未占满整页时,通过MIN来保证写入内容的长度
  3. 文件大小小于内存大小时,仍继续映射,用NULL写入剩余内存
  4. 在每一次映射都及时检查返回值并报出异常

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(),即启动一个线程时,应开启时钟中断。

因此总结来看:

  1. 在线程启动时,时钟中断开启
  2. 线程在用户态正常运行,时钟中断保持开启
  3. 线程陷入中断或异常后,取消时钟中断
  4. 线程异常处理结束,恢复时钟中断

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函数:

  • 不切换进程时就进行count–,然后继续env_run

  • count减为零或其他三个条件之一满足时

    • 进行进程的切换:
      • 若当前进程未执行完毕,就移到队尾
      • 若执行完毕会触发env_destroy无需处理
    • 接着取出队首进程进行执行
      • 若已无执行进程则panic
    • 设置count的值,并进行count–与env_run
 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: 初始化进程管理系统的数据结构

  1. 初始化链表: env_free_list:使用 LIST_INIT 初始化空闲进程链表。 env_sched_list:使用 TAILQ_INIT 初始化调度进程队列。
  2. 初始化所有环境: 遍历 envs 数组,将每个进程的状态设置为 ENV_FREE。 将这些进程插入到 env_free_list 中,且顺序与 envs 数组中的顺序一致。
  3. 映射内核数据结构到用户空间: 分配一个页作为基础页目录 base_pgdir。 将内核的 pages 和 envs 数组映射到用户空间的 UPAGES 和 UENVS 区域,权限为只读(PTE_G)。

env_setup_vm: 为给定的进程 e 初始化用户地址空间。

  1. 分配页目录并复制模板: 分配一个物理页作为页目录,将页的内核虚拟地址赋给 e->env_pgdir。 从 base_pgdir 复制 [UTOP, UVPT) 部分的页目录项到 e->env_pgdir。这样所有进程的内核部分地址空间是一致的。
  2. 自映射页表: 将进程的页目录映射到 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的丰富函数,不理一下调用场景与逻辑,难免会感到迷茫。

望一切顺遂。

comments powered by Disqus
Easy Life and Easy Learning
使用 Hugo 构建
主题 StackJimmy 设计