Lv的杂货铺

我们都是阴沟里的虫子,但总还是得有人仰望星空

0%

Lab 3: User-Level Environments

Part A: User Environments and Exception Handling

在这个lab中,“环境”事实上等同于“进程”的概念,但这里为了强调在JOS中的环境和UNIX中的进程的实现以及接口还是有些区别的,所以统一使用“环境”一词。

Exercise 1

在exercise 1中,要求分配一个envs数组,用于保存进程的上下文信息,并将其映射到虚拟地址空间中的UENVS处。这个工作非常简单,对着上一个实验中的照猫画虎就行了,代码如下

1
2
envs = (struct Env*)boot_alloc(NENV * sizeof(struct Env));
memset(envs, 0, NENV * sizeof(struct Env));

在分配了空间之后,在pmap.c中建立虚拟内存之后的部分加入将其映射到UENVS处的代码

1
boot_map_region(kern_pgdir, UENVS, NENV * sizeof(struct Env), PADDR(envs), PTE_U | PTE_P);

接下来,开始着手创建用户运行环境。由于到现在为止还没有一个文件系统,因此这里先是使用了一个替代方案,在编译的时候,用户程序的ELF镜像便已经被链接到了内核中,相当于,系统运行时用户程序的ELF镜像就已经在内存中了。如果要加载一个用户程序,只需要根据ELF文件把程序的内容加载到相应的用户地址空间,而不必考虑怎么把ELF文件从磁盘加载到内存。

Exercise 2

在exercise 2中,需要完成几个函数。

env_init()初始化上面分配的envs数组以及env_free_list指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
	// LAB 3: Your code here.

int i = 0;
for(; i < NENV - 1; i++)
{
envs[i].env_status = ENV_FREE;
envs[i].env_id = 0;
envs[i].env_link = &envs[i+1];
}

envs[i].env_status = ENV_FREE;
envs[i].env_link = NULL;
env_free_list = envs;

之后,env_init()调用env_init_percpu()来配置段的特权级。事实上,段机制在这里的主要用途就是标示特权级,所以可以看到,GDT表的段的base都是0,limit都是最大值。

然后是env_setup_vm(),用于初始化一个环境的地址空间,这需要分配一个物理页来作为这个环境的页目录表并初始化。所有环境在UTOP以上的地址空间的内容都是相同的,所以直接照搬内核的相应条目就行来,而UTOP以下对应的条目应当都为空;此外,因为当环境被销毁之后,相应的页也应当被释放,而环境的页目录表的对应物理页的pp_ref应当加1来保证以后能够成功地释放掉。整个代码为:

1
2
3
4
5
6
7
8
9
	// LAB 3: Your code here.

pde_t* env_pgdir = page2kva(p);
e->env_pgdir = env_pgdir;memset(env_pgdir, 0, PGSIZE);

for(int i=PDX(UTOP);i<NPDENTRIES;i++)
e->env_pgdir[i]=kern_pgdir[i];

p->pp_ref++;

下一个是region_alloc(),给出一个虚拟地址va及长度len,给这一段虚拟地址分配足够的物理页并建立物理页和虚拟页的映射。需要注意的是,函数的参数,虚拟地址,可能不是页对齐的,也就是说,[va, va+len),可能开始于一个页的后半部分,而结束于最后一个页的前半部分,那么,考虑到这种情况,需要使用ROUNDDOWNROUNDUP宏来辅助了。除此之外,代码还是比较简单的。

1
2
3
4
5
6
7
8
9
10
11
12
void* start = (void *)ROUNDDOWN((uint32_t)va, PGSIZE);
void* end = (void *)ROUNDUP((uint32_t)va + len, PGSIZE);
for(void* i = start; i < end; i += PGSIZE)
{
struct PageInfo *p = page_alloc(0);
if(!p)
panic("region_alloc: page_alloc error!\n");

int r = page_insert(e->env_pgdir, p, i, PTE_W | PTE_U);
if(r)
panic("region_alloc: page_insert error %e\n", r);
}

下一个load_icode()用于把用户程序的ELF镜像加载到相应的地址。这部分跟之前lab的bootloader加载内核到正确地址非常像,唯一需要注意的就是,把用户程序加载到相应的地址,当然是让用户进程使用的,那么整个过程的使用的当然是用户环境的地址空间,而现在运行在内核态,CR3寄存器中放的是kern_pgdir,所以就有必要将其换成用户环境的env_pgdir,然后之后再换回来就行了。而之前也提到了,在UTOP以上所有环境的地址映射都是一样的,都照搬的kern_pgdir,这也就保证了把CR3寄存器的内容换成env_pgdir后,程序也能够正确运行。代码如下:

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
	// LAB 3: Your code here.

struct Elf* ELFHDR = (struct Elf*)binary;
struct Proghdr *ph, *eph;
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;

lcr3(PADDR(e->env_pgdir));
for (; ph < eph; ph++)
{
if(ph->p_type == ELF_PROG_LOAD)
{
region_alloc(e, (void*)ph->p_va, ph->p_memsz);
memcpy((void*)ph->p_va, (void*)((uint32_t)binary + ph->p_offset), ph->p_filesz);
memset((void*)(ph->p_va + ph->p_filesz), 0, ph->p_memsz - ph->p_filesz);
}
}
lcr3(PADDR(kern_pgdir));

e->env_tf.tf_eip = ELFHDR->e_entry;


// Now map one page for the program's initial stack
// at virtual address USTACKTOP - PGSIZE.

// LAB 3: Your code here.

struct PageInfo* p = page_alloc(0);
if(!p)
panic("load_icode: page_alloc error!\n");

int r = page_insert(e->env_pgdir, p, (void*)(USTACKTOP - PGSIZE), PTE_U | PTE_W);
if(r)
panic("load_icode: page_insert error %e\n", r);

env_create()非常简单,直接上代码:

1
2
3
4
5
	// LAB 3: Your code here.
struct Env* env;
env_alloc(&env, 0);
load_icode(env, binary);
env->env_type = type;

同样,env_run()也非常简单,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    // LAB 3: Your code here.

if(e->env_status == ENV_RUNNABLE)
{
if(curenv && curenv->env_status == ENV_RUNNING)
curenv->env_status = ENV_RUNNABLE;

curenv = e;
curenv->env_status = ENV_RUNNING;
curenv->env_runs++;
lcr3(PADDR(e->env_pgdir));
}

env_pop_tf(&e->env_tf);

panic("env_run not yet implemented");

然后,开始着手进行中断和异常处理。当一个中断或异常发生的时候,CPU会把当前的一些上下文信息,如一些寄存器到值保存到一个栈中,然后跳转到相应到中断处理程序进行处理,处理完后然后再把这些恢复,而这个栈显然不是一个随便的地方。TSS指定了当发生中断或异常的时候要转到的栈,CPU首先把esp寄存器到值切换到TSS指定的,然后才能进行中断现场保护。TSS的初始化lab事先已经做好了,事实上就是地址空间的内核栈。而“跳转到相应到中断处理程序进行处理”,是根据中断号,作为索引查IDT表,得到相应中断处理程序的CS:IP,跳转到相应地址。

Exercise 3

exercise 3要求实现类似上述的机制。在中断或异常发生的时候,CPU会自己将esp转到TSS指定的栈,压入一些寄存器的值,根据中断号的不同,有些压入一个error code,而有些则不压入error code。题目中给出了一些提示了,每个IDT表项对应的中断处理程序,应当根据不同的情况压入一些额外的内容,来保证最后栈中的内容刚好构成了一个struct Trapframe结构体,当作trap()的参数,最后call trap(trap.c中定义)。

有些中断号CPU没有压入error code,而有些压入了,而struct Trapframe中是有error code的字段的,所以我们要把这些没压入error code的情况,补一个随便的值占位,然后再参考struct Trapframe的定义来压入些寄存器值,然后,栈中的内容从低地址向高地址看就是一个完整的struct Trapframe了。而trap()的参数是struct Trapframe*,是一个指针,那么,只需要在最后,把esp压栈,call trap,就能够正确地调用trap()了!

总的来说,中断处理程序的工作是在栈中构造出一个struct Trapframe,然后call trap。而具体的中断处理的工作,就交给trap()完成了。在trap()的最后,中断/异常处理完毕,它将会使用env_run(curenv)来跳转到用户进程运行。

在trapentry.S中,已经给出来了两个辅助的宏来减少一些任务量。我们需要在C文件中声明一系列中断处理函数(便于在trap_init()中引用),然后在trapentry.S中使用这些宏来实际“实现”这些函数————以汇编的方式,然后用另外的宏在trap_init()中来初始化IDT的相应内容。

trapentry.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
/*
* Lab 3: Your code here for generating entry points for the different traps.
*/

TRAPHANDLER_NOEC(handler0, T_DIVIDE)
TRAPHANDLER_NOEC(handler1, T_DEBUG)
TRAPHANDLER_NOEC(handler2, T_NMI)
TRAPHANDLER_NOEC(handler3, T_BRKPT)
TRAPHANDLER_NOEC(handler4, T_OFLOW)
TRAPHANDLER_NOEC(handler5, T_BOUND)
TRAPHANDLER_NOEC(handler6, T_ILLOP)
TRAPHANDLER_NOEC(handler7, T_DEVICE)

TRAPHANDLER(handler8, T_DBLFLT)

TRAPHANDLER(handler10, T_TSS)
TRAPHANDLER(handler11, T_SEGNP)
TRAPHANDLER(handler12, T_STACK)
TRAPHANDLER(handler13, T_GPFLT)
TRAPHANDLER(handler14, T_PGFLT)

TRAPHANDLER_NOEC(handler16, T_FPERR)

TRAPHANDLER(handler17, T_ALIGN)

TRAPHANDLER_NOEC(handler18, T_MCHK)
TRAPHANDLER_NOEC(handler19, T_SIMDERR)

TRAPHANDLER_NOEC(handler48, T_SYSCALL)

/*
* Lab 3: Your code here for _alltraps
*/
_alltraps:
pushl %ds
pushl %es
pushal
mov $GD_KD, %ax
mov %ax, %ds
mov %ax, %es
pushl %esp
call trap

根据前面给出的资料,除了中断号为2的,以及系统调用以外,其余的都是属于“exception”(fault、trap、abort都属于exception)

img

而根据mmu.h中关于SETGATE宏的注释:

  • istrap: 1 for a trap (= exception) gate, 0 for an interrupt gate.

事实上,经过测试,istrap字段无论设置为0还是为1并不会影响评分脚本的结果。

从常理来看,虽然不影响评分,但显然并不合适。如果设为0的话,在进入相应的中断处理程序中的时候,会屏蔽中断直到处理完毕,对于一些exception的处理这明显不合适。StackOverflow上这个问题的第二个回答我觉得讲的不错,可以参考一下。

Similar way, gate of any type in IDT can be called in software. The reason for the using trap gates for system call and exceptions is simple. No any reasons to disable interrupts. Interrupt disabling is a bad thing, because it increases interrupt handling latencies and increase probability of interrupt lost. Due to this no one won’t disable them without any serious reason on the hands.

但是,虽然在这里并不重要,但在后面的lab4中,提到了:

In JOS, we make a key simplification compared to xv6 Unix. External device interrupts are always disabled when in the kernel (and, like xv6, enabled when in user space). External interrupts are controlled by the FL_IF flag bit of the %eflags register (see inc/mmu.h). When this bit is set, external interrupts are enabled. While the bit can be modified in several ways, because of our simplification, we will handle it solely through the process of saving and restoring %eflags register as we enter and leave user mode.

也就是说,通常不应该设为0的,但是在JOS中,处于内核态时屏蔽所有外部中断,也就是IF flag为0,那么,istrap就需要设置为0了。

那么,接下来在trap_init()中要添加的内容就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    // LAB 3: Your code here.

SETGATE(idt[0], 0, GD_KT, &handler0, 0)
SETGATE(idt[1], 0, GD_KT, &handler1, 0)
SETGATE(idt[2], 0, GD_KT, &handler2, 0)
SETGATE(idt[3], 0, GD_KT, &handler3, 3)
SETGATE(idt[4], 0, GD_KT, &handler4, 0)
SETGATE(idt[5], 0, GD_KT, &handler5, 0)
SETGATE(idt[6], 0, GD_KT, &handler6, 0)
SETGATE(idt[7], 0, GD_KT, &handler7, 0)
SETGATE(idt[8], 0, GD_KT, &handler8, 0)

SETGATE(idt[10], 0, GD_KT, &handler10, 0)
SETGATE(idt[11], 0, GD_KT, &handler11, 0)
SETGATE(idt[12], 0, GD_KT, &handler12, 0)
SETGATE(idt[13], 0, GD_KT, &handler13, 0)
SETGATE(idt[14], 0, GD_KT, &handler14, 0)

SETGATE(idt[16], 0, GD_KT, &handler16, 0)
SETGATE(idt[17], 0, GD_KT, &handler17, 0)
SETGATE(idt[18], 0, GD_KT, &handler18, 0)
SETGATE(idt[19], 0, GD_KT, &handler19, 0)

SETGATE(idt[48], 0, GD_KT, &handler48, 3)

此外,声明这些中断处理函数。理论上在任意合理的地方都行,这里选择在inc/trap.h中声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void handler0();
void handler1();
void handler2();
void handler3();
void handler4();
void handler5();
void handler6();
void handler7();
void handler8();
void handler9();
void handler10();
void handler11();
void handler12();
void handler13();
void handler14();

void handler16();
void handler17();
void handler18();
void handler19();

void handler48();

需要注意的是,14号中断(page fault)的设置SETGATE(idt[14], 0, GD_KT, &handler14, 0),dpl字段设置是0,意义是如果想要通过软中断触发,需要处于特权级0(内核态)。事实上,page fault无论如何也不应该允许用户进程使用int指令触发。

Part B: Page Faults, Breakpoints Exceptions, and System Calls

trap()的分析

在进入函数之后,首先,使用内联汇编重置DF,因为之前的用户环境可能设置了DF,然后,确保中断被屏蔽,接着,根据tf->tf_cs字段判断是由用户态进入到内核态的还是之前就处在内核态:有时,在进行中断处理时会遇到异常,这就造成了嵌套的异常。程序只需要在由用户态进入内核态的这种情况中才需要把当前环境的struct Trapframe*保存到环境的env_tf字段,这样,当所有的中断/异常都被处理完毕后,就能够正确地退回原来的用户进程了。然后,调用trap_dispatch()分派中断/异常处理,最后,通过env_run(curenv)退回之前的用户进程。

Exercise 5、6、7

这三个exercise都非常简单,于是放在一起说了。要做的就是根据传入的struct Trapframe*判断中断/异常类型,然后调用相应的处理函数进行处理。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    // LAB 3: Your code here.
switch (tf->tf_trapno)
{
case T_PGFLT:
page_fault_handler(tf);
return;
case T_BRKPT:
monitor(tf);
return;
case T_SYSCALL:
tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax, tf->tf_regs.reg_edx,
tf->tf_regs.reg_ecx, tf->tf_regs.reg_ebx, tf->tf_regs.reg_edi, tf->tf_regs.reg_esi);
return;
default:
break;
}

Exercise 8

exercise 8要求初始化thisenv,指向当前的环境,代码只有一行:

1
2
3
	// LAB 3: Your code here.

thisenv = &envs[ENVX(sys_getenvid())];

Exercise 9

exercise 9要求完成一部分内存保护机制。首先是,如果在用户态发生了page fault,应当panic。在page fault发生时候处于什么特权级可以通过查看cs的低两位来完成,在page_fault_handler()增加一行:

1
2
3
	// LAB 3: Your code here.
if(!(tf->tf_cs & 3))
panic("page fault occurs in kernel mode!\n");

然后是pmap.c的user_mem_check()user_mem_check()user_mem_assert()中被使用到。给定一个环境env,虚拟地址va以及长度len,函数确定[va, va+len)范围内存地址是否可以被用户进程合法访问,如果合法,返回0,否则返回一个负值。

由于内存管理是以页为粒度的,所以不需要一个字节一个字节的比较,按页为单位判断即可,把首个不合法的内存地址存到给定的静态全局变量中。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// LAB 3: Your code here.
if((uint32_t)va + len > ULIM)
{
user_mem_check_addr = (uint32_t)va;
return -E_FAULT;
}

uint32_t lo = ROUNDDOWN((uint32_t)va, PGSIZE), hi = ROUNDDOWN((uint32_t)va + len - 1, PGSIZE);
for(uint32_t i = lo; i <= hi; i += PGSIZE)
{
if((env->env_pgdir[PDX(i)] & PTE_U) &&
((((pte_t*)UVPT)[i/PGSIZE]) & PTE_U))
continue;
if(i == lo)
user_mem_check_addr = (uint32_t)va;
else
user_mem_check_addr = i;
return -E_FAULT;
}

return 0;

然后,在涉及到内存保护的系统调用中使用user_mem_assert()添加合法性判断。在kern/syscall.c的sys_cputs()添加:

1
2
	// LAB 3: Your code here.
user_mem_assert(curenv, s, len, PTE_U);

至此,整个lab就算完成了。使用make grade进行评分

img

lab的完整代码在我的GitHub中可以找到

(完)