笔记011 - Lab3: User Environments

The UVPT

x86将虚拟地址转换为物理地址:CR3指向页目录,PDX是页目录索引,PTX是二级页表索引。对于处理器而言,并没有页目录、页表、页的概念,相反它只负责计算:pd = lcr3(); pt = (pd+4PDX); page = (pt+4PTX);
页目录是“特殊”的页表,假设我们在页目录中某一索引项V存储页目录本身的地址:

当设计PDX=PTX=V时,则可以通过pd = lcr3(); pt = (pd+4PDX); page = (pt+4PTX);后可以获得页目录的某一项。在jos中,V=0x3BD,所以(0x3BD<<22)|(0x3BD<<12) = 0xef400000 = UVPD。当PDX=V而PTX!=V时,通过pd = lcr3(); pt = (pd+4PDX); page = (pt+4PTX);后将指向到某一个页表的某一项,即:所有PDX=V的虚拟页刚好是所有页表本身,在jos中,V=0x3BD,所以UVPT = (0x3BD<<22) = 0xef400000,从该地址起的4M虚拟地址空间映射了jos的所有页表。设计UVPT的意义即在于通过虚拟地址可以访问到页表,但其前提在于在页目录中索引项V存储页目录本身的地址

Part A: User Environments and Exception Handling

在本实验中,术语environmentprocess是可以互换的,两者都是允许运行程序的一个抽象。jos的environment和unix的process提供不同的接口,因此提供了不同的语义,jos更加强调environment而不是process
关于environment,jos提供了三个全局变量:

1
2
3
4
//kern/env.c
struct Env *envs = NULL; // All environments
struct Env *curenv = NULL; // The current env
static struct Env *env_free_list; // Free environment list

jos内核最多支持“同时”执行NENV(inc/env.h)个活跃环境,所有不活跃的Env数据结构保存在env_free_list中。

1
2
3
4
5
6
7
8
9
10
11
12
13
//inc/env.h
struct Env {
struct Trapframe env_tf; // Saved registers
struct Env *env_link; // Next free Env
envid_t env_id; // Unique environment identifier
envid_t env_parent_id; // env_id of this env's parent
enum EnvType env_type; // Indicates special system environments
unsigned env_status; // Status of the environment
uint32_t env_runs; // Number of times environment has run

// Address space
pde_t *env_pgdir; // Kernel virtual address of page dir
};

env_tf:见inc/trap.h,当环境没被执行时(即当前执行内核代码或其他环境的代码),保存了环境的被保存的寄存器值。内核在从用户态切换到内核态的时候保存这些寄存器,确保环境可以被恢复执行。
env_link:连接到下一个Env,该Env位于env_free_list上,env_free_list上指向第一个空闲的环境。
env_id:当前使用Env数据结构的环境的唯一标识。当一个用户环境终止时,内核可以重新分配相同的Env数据结构给不同的环境,但是env_id不会相同。
env_parent_id:创建本环境的父环境id,可用于构建“family tree”,进而安全控制哪个环境可以对其他哪些环境做什么任务。
env_type:用于区分特殊的环境,大部分时候是ENV_TYPE_USER
env_status:以下几种值之一:

1
2
3
4
5
ENV_FREE:表明Env数据结构不活跃,位于env_free_list中。
ENV_RUNNABLE:表明Env数据结构所代表的环境正等待处理器执行。
ENV_RUNNING:表明Env数据结构所代表的环境正被执行。
ENV_NOT_RUNNABLE:表明Env数据结构所代表的环境是活跃的,但还不能执行,比如它正在等待其他环境的interprocess communication (IPC)。
ENV_DYING:表明Env数据结构所代表的环境是一个zombie。下一次trap到内核的时候,zombie环境将被释放。

env_pgdir:保存了环境的页目录的内核虚拟地址。

与unix对比:jos的environment也包含了threadaddress space两个概念,thread的定义主要是被保存的寄存器(env_tf),address space的定义主要是页目录和页表的指向(env_pgdir)。
与xv6对比:jos的struct Env类似于xv6的proc,两者都保存了用户模式寄存器状态,但是jos的环境并没有自己的内核栈,因为jos一次只有一个环境是活跃的,所以只需要一个内核栈。

Allocating the Environments Array

i386_init()函数之后,并没有进入循环,而是相应的对进程结构初始化和中断初始化,i386_init()函数最后会调用env_run(&envs[0]);运行一个进程。一个进程的执行不能对内核(kernel)和其他进程产生干扰,当进程执行特权指令时,需要处理器产生中断,从用户态切换到内核态,完成任务后中断返回到用户态。

1
Exercise 1.修改kern/pmap.c中mem_init()函数,分配并映射envs数组(NENV instances),参考pages数组的分配和映射。pages数组基址映射到UPAGES虚拟地址,envs数组基址映射到UENVS虚拟地址,两者的二级页表项权限都是用户只读,用户环境不能修改数组本身。顺利完成的话将通过check_kern_pgdir()检查。

解决方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//pmap.c
...
//////////////////////////////////////////////////////////////////////
// Make 'envs' point to an array of size 'NENV' of 'struct Env'.
// LAB 3: Your code here.
envs = (struct Env*)boot_alloc(NENV*sizeof(struct Env));
memset(envs,0,NENV*sizeof(struct Env));
...
//////////////////////////////////////////////////////////////////////
// Map the 'envs' array read-only by the user at linear address UENVS
// (ie. perm = PTE_U | PTE_P).
// Permissions:
// - the new image at UENVS -- kernel R, user R
// - envs itself -- kernel RW, user NONE
// LAB 3: Your code here.
boot_map_region(kern_pgdir,UENVS,PTSIZE,PADDR(envs),PTE_U | PTE_P);
...

这里注意到一个问题:pages和envs本身作为内核代码的数组,拥有自己的虚拟地址,且内核可对其进行读写。boot_map_region函数将两个数组分别映射到了UPAGES和UENVS起4M空间的虚拟地址,这相当于另外的映射镜像,其二级页表项权限被设为用户/内核可读,因此通过UPAGES和UENVS的虚拟地址去访问pages和envs的话,只能读不能写。
页面管理空间的布局情况如下:

Creating and Running Environments

由于还没实现文件系统,因此没办法从inode里读取程序内容,而是设置成由内核加载内嵌到内核的静态二进制镜像,且为ELF可执行格式,将嵌入到内核中的用户程序取出释放到相应链接器指定好的用户虚拟空间里。如果想了解二进制文件怎么连接进内核里,可以在build 内核之后查看kern/Makefrag、obj/kern/kernel.sym等文件。在JOS系统里面,采用和管理页相同的方式来管理进程,即用一个进程表来管理所有的进程,空闲的进程通过env_link相连接,用env_free_list指向空闲进程表的头节点。

1
2
3
4
5
6
7
Exercise 2.在kern/env.c中完成以下函数:
env_init():初始化进程数据结构数组,将所有数据结构加到env_free_list上。调用env_init_percpu函数为特权级别0(内核)和特权级别3(用户)将段式硬件配以单独的段。
env_setup_vm():分配页目录,初始化进程的内核部分地址空间。
region_alloc():分配和映射物理空间。
load_icode():解析ELF二进制镜像,加载进新进程的地址空间。
env_create():通过env_alloc分配一个新进程,调用load_icode加载ELF二进制镜像。
env_run():在用户模式下执行新进程。

进程的管理方法和页面的管理方法是相同的,都是用一组结构体的数组来管理,在这种管理的方式下,对于空闲进程的申请和添加,只需要用env_free_list这个参数就可以了。进程表的初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//env.c
void
env_init(void)
{
// Set up envs array
// LAB 3: Your code here.
env_free_list = NULL;
int i;
for( i = NENV -1; i>=0; i--){
envs[i].env_status = ENV_FREE;
envs[i].env_id = 0;
envs[i].env_link = env_free_list;
env_free_list = &envs[i];
}
// Per-CPU part of the initialization
env_init_percpu();
}

第一个用户进程的建立:

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
//init.c
ENV_CREATE(user_hello, ENV_TYPE_USER);

//env.h
#define ENV_PASTE3(x, y, z) x ## y ## z

#define ENV_CREATE(x, type) \
do { \
extern uint8_t ENV_PASTE3(_binary_obj_, x, _start)[]; \
env_create(ENV_PASTE3(_binary_obj_, x, _start), \
type); \
} while (0)


//env.c
void
env_create(uint8_t *binary, enum EnvType type)
{
// LAB 3: Your code here.
struct Env *newEnv;
//Allocates a new env
int i = env_alloc(&newEnv,0);
if(i<0) panic("env_create");
//loads the named elf binary into
load_icode(newEnv,binary);
//set env_type
newEnv->env_type = type;
}

env_alloc向进程表申请空闲的进程,在env_alloc的函数中,向进程表申请空闲的进程,对新申请的进程的struct Env(进程描述符)进行初始化,主要是对段寄存器的初始化。内核要开始的第一个用户进程不是通过中断等方法来进入到内核的,而是由内核直接载入的。对进程的进程描述符进行初始化,是为了模仿int x指令的作用,模拟第一个进程是通过中断进入了内核,在内核处理完了相应的操作之后,才返回用户态的。xv6的做法是也是类似载入程序内容后对进程的进程描述符进行初始化,不过它之后是通过int指令来中断,执行exec。

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
//env.c
int
env_alloc(struct Env **newenv_store, envid_t parent_id)
{
int32_t generation;
int r;
struct Env *e;

if (!(e = env_free_list))
return -E_NO_FREE_ENV;

// Allocate and set up the page directory for this environment.
if ((r = env_setup_vm(e)) < 0)
return r;

//todo...?????????????????????
// Generate an env_id for this environment.
generation = (e->env_id + (1 << ENVGENSHIFT)) & ~(NENV - 1);
if (generation <= 0) // Don't create a negative env_id.
generation = 1 << ENVGENSHIFT;
e->env_id = generation | (e - envs);

// Set the basic status variables.
e->env_parent_id = parent_id;
e->env_type = ENV_TYPE_USER;
e->env_status = ENV_RUNNABLE;
e->env_runs = 0;

// Clear out all the saved register state,
// to prevent the register values
// of a prior environment inhabiting this Env structure
// from "leaking" into our new environment.
memset(&e->env_tf, 0, sizeof(e->env_tf)); //env_tf是Trapframe结构体,不是Trapframe结构体指针!

// Set up appropriate initial values for the segment registers.
// GD_UD is the user data segment selector in the GDT, and
// GD_UT is the user text segment selector (see inc/memlayout.h).
// The low 2 bits of each segment register contains the
// Requestor Privilege Level (RPL); 3 means user mode. When
// we switch privilege levels, the hardware does various
// checks involving the RPL and the Descriptor Privilege Level
// (DPL) stored in the descriptors themselves.
e->env_tf.tf_ds = GD_UD | 3;
e->env_tf.tf_es = GD_UD | 3;
e->env_tf.tf_ss = GD_UD | 3;
e->env_tf.tf_esp = USTACKTOP;
e->env_tf.tf_cs = GD_UT | 3;
// You will set e->env_tf.tf_eip later.

// commit the allocation
env_free_list = e->env_link;
*newenv_store = e;

cprintf("env_id, %x\n", e->env_id);
cprintf("[%08x] new env %08x\n", curenv ? curenv->env_id : 0, e->env_id);
return 0;
}

env_setup_vm创建进程页目录。在xv6中,每个进程都有自己的内核栈,进程的trapframe也是存在内核栈中。但是jos中用户进程是使用同一个内核栈虚拟地址的。

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
//env.c
static int
env_setup_vm(struct Env *e)
{
int i;
struct PageInfo *p = NULL;

// Allocate a page for the page directory
if (!(p = page_alloc(ALLOC_ZERO)))
return -E_NO_MEM;

// LAB 3: Your code here.
//在没有具体的映射之前,物理地址都是直接加上KERNBASE作为虚拟地址的!!!
//内核空间的变量的虚拟地址也是如此
e->env_pgdir = (pde_t *) page2kva(p); //pa + KERNBASE !!!!!!
p->pp_ref = 1;
//use kern_pgdir as a template
/*
由于每个用户进程都需要共享内核空间,所以对于用户进程而言,在UTOP以上的部分,和系统内核的空间是完全一样的。
因此在pgdir开始设置的时候,只需要在一级页表目录上,把共享部分的一级页表目录部分复制进用户进程的地址空间就可以了,
这样,就实现了页面的共享。因为一级页目录里面存储的是二级页表目录的物理地址,其直接映射到物理内存部分,
而共享的内核部分的二级页目录在前期的内核操作中,已经完成了映射,所以二级页目录是不需要初始化的。
简单来说,不需要映射二级页表的原因是,用户进程可以和内核共用这些二级页表。

UTOP以下部分清空: 注意4G虚拟地址空间是由低到高每4M按顺序映射到页目录的一项的,因此需要取出UTOP的PDX索引部分,将前PDX项清空。
*/
memcpy(e->env_pgdir,kern_pgdir,PGSIZE);
memset(e->env_pgdir,0,UTOP >> PTSHIFT);

// UVPT maps the env's own page table read-only.
// Permissions: kernel R, user R
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;

return 0;
}

load_icode将用户进程的文件载入内存。由于还没有实现文件系统,所以用户进程实际的存放的位置实际上是在内存中的,文件载入内存,实际上是内存之间的数据的复制而已。程序载入内存的时候,需要把pgdir设置为用户进程的页目录,这样,这些程序才会载入用户进程所属的地址空间,而且在载入的过程中,根据elf文件中程序头表中载入内存的va和memsz,还需要为用户空间申请新的地址映射,在这个过程中,会建立新的页表。加载完ELF文件之后,为进程再申请一页物理页作为用户栈,并映射到USTACKTOP - PGSIZE,之前tampframe的esp也指向了USTACKTOP。

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
//env.c
static void
load_icode(struct Env *e, uint8_t *binary)
{
// LAB 3: Your code here.
struct Elf *elf = (struct Elf *)binary;
if (elf->e_magic != ELF_MAGIC)
panic("load_icode");
struct Proghdr *ph = (struct Proghdr *)(binary+elf->e_phoff);

lcr3(PADDR(e->env_pgdir));

int i;
for (i = 0; i < elf->e_phnum; ++i)
{
if(ph->p_type != ELF_PROG_LOAD) {//不可载入段
ph++;
continue;
}
//xv6分配用户空间是连续的, 给出起始地址va和结束地址,然后ROUNDUP(va),根据结束地址分配了足够的页空间,
//va的值是结束地址,而不是当前空间顶端。读取下一段的时候,新的开始地址是上次结束地址, 新的结束地址是ph.vaddr + ph.memsz。
//jos分配用户空间不是连续的,而是根据ph->p_va作为每次的开始地址,以p_memsz为长度进行页分配。
region_alloc(e,(void*)ph->p_va,ph->p_memsz);
//read into env's memory , need env's pgdir
memcpy((char*)ph->p_va,(char*)(binary + ph->p_offset),ph->p_filesz);
ph++;
}
// Now map one page for the program's initial stack
// at virtual address USTACKTOP - PGSIZE.

// LAB 3: Your code here.
region_alloc(e,(void*)USTACKTOP - PGSIZE,PGSIZE);

//todo...binary的数据位于内核空间的哪个节?
e->env_tf.tf_eip = elf->e_entry; // main

lcr3(PADDR(kern_pgdir));
}

注意到一个现象,加载程序分配用户空间时,在xv6中,分配的地址空间是连续的,进程的用户地址空间也是通过加载过程中分配的地址空间范围来决定。而在jos中,用户空间是不一定连续的,每次在加载段的时候,都是从新的段虚拟地址开始映射,不关心之前段的结束地址。jos中也没有设置进程的地址空间大小。因此,jos和xv6为进程分配物理空间的函数有些不同,xv6会记录上次分配的结束地址,而jos不会。region_alloc为va申请物理空间,并且完成映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//env.c
static void
region_alloc(struct Env *e, void *va, size_t len)
{
// LAB 3: Your code here.
if(ROUNDUP((pte_t)va + len, PGSIZE) >= KERNBASE){
panic("region_alloc panic, out of memory1");
}
int npages = (ROUNDUP((pte_t)va + len, PGSIZE) - ROUNDDOWN((pte_t)va, PGSIZE)) / PGSIZE;
struct PageInfo *p = NULL;
int i=0;
for(; i<npages; i++){
//加上ALLOC_ZERO标志则物理页内容初始化为'\0',如果这里没有初始化,则最好在分配物理页并且将内容拷贝进来后,将剩余的空间置0
if (!(p = page_alloc(ALLOC_ZERO))){
panic("region_alloc panic, out of memory2");
}
//map, use page_insert
if(page_insert(e->env_pgdir,p,(void*)((pte_t)va+i*PGSIZE),PTE_U|PTE_W)!=0){
panic("region_alloc panic, out of memory3");
}
}
}

之后env_run开始准备运行进程,先切换到用户进程的地址空间,然后调用env_pop_tf载入寄存器的值。

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
//env.c
void
env_run(struct Env *e)
{
// Step 1: If this is a context switch (a new environment is running):
// 1. Set the current environment (if any) back to
// ENV_RUNNABLE if it is ENV_RUNNING (think about
// what other states it can be in),
// 2. Set 'curenv' to the new environment,
// 3. Set its status to ENV_RUNNING,
// 4. Update its 'env_runs' counter,
// 5. Use lcr3() to switch to its address space.
// Step 2: Use env_pop_tf() to restore the environment's
// registers and drop into user mode in the
// environment.
// LAB 3: Your code here.
if(!e) panic("env_run panic");
if(curenv && curenv->env_status == ENV_RUNNING){
curenv->env_status = ENV_RUNNABLE;
}
curenv = e;
e->env_status = ENV_RUNNING;
e->env_runs += 1;
lcr3(PADDR(e->env_pgdir));

env_pop_tf(&(e->env_tf));//never return
//panic("env_run not yet implemented");
}

void
env_pop_tf(struct Trapframe *tf)
{
__asm __volatile("movl %0,%%esp\n"
"\tpopal\n"
"\tpopl %%es\n"
"\tpopl %%ds\n"
"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
"\tiret"
: : "g" (tf) : "memory");
panic("iret failed"); /* mostly to placate the compiler */
}

movl %0,%%esp,这里出现了占位符%0,通过后面的参数可以看到这里的占位符代表的意思是memory中的变量tf,即Trapframe的指针地址。iret之后发生特权级的改变(即由内核态转到了用户态),所以iret总共会压出5个寄存器,依次是eip、cs、eflags、esp、ss,即iret会从栈弹出代码段选择子及指令指针分别到CS与IP寄存器,并弹出标志寄存器内容到EFLAGS寄存器,然后弹出esp和ss寄存器的值,这些函数在env_alloc()以及load_icode()中都设置好了,其中EIP为用户程序入口地址,CS为用户程序代码段基地址。完成iret之后,eip就指向了程序的入口地址,cs也由内核态转向了用户态, esp也由内核栈转到了用户栈。

1
2
调试技巧:
调用env_pop_tf后,将开始执行用户代码,并且不会返回。使用b *0x...在env_pop_tf处设置断点,然后单步调试直至执行用户代码。参考obj/user/hello.asm,在sys_cputs()中的int $0x30处设置断点,如果以上函数补充正确的话,运行至此的指令均不会出错。下面将对中断进行处理。

Handling Interrupts and Exceptions

在没有实现系统调用之前,处理器一旦进入用户模式将没有办法返回。只有实现基本的异常和系统调用处理,才能使内核从用户模式代码中恢复内核对处理器的控制。另外,诸如异常、陷阱、中断、错误、中止(exception, trap, interrupt, fault, abort)等概念,在架构或操作系统中并没有标准意义,因此在一个特定的体系结构中如xv6,经常不考虑它们之间的细微差别。

Basics of Protected Control Transfer

exceptions和interrupts都是“protected control transfers”(受保护控制转移)的,它们将导致处理器从用户态切换为内核态,但用户代码不会影响其他进程以及内核。在Intel的术语中,interrupt通常指由外部异步事件引起的受保护控制转移(外部是相对于处理器而言),如外部设备I/O活动的通知,exception通常指由当前执行代码同步引起的受保护控制转移,如除0或非法内存访问。
为了保护控制转移,处理器仅在一定的控制条件下允许进入内核。x86使用两种保护机制:1、中断描述符表IDT,2、任务状态段TSS。
中断描述符表interrupt descriptor table:x86允许多达256个不同的中断或异常的内核入口点,每一个都有不同的中断向量。一个向量是一个0到255之间的数字。IDT表设置在内核的私有内存中,CPU使用向量在IDT表中进行索引,找到入口点后,处理器将会加载对应的值到eip(instruction pointer register)中,该值指向处理异常的内核代码,同时加载对应的值到cs(code segment register)中,该值的0-1位表明了执行异常处理代码的特权级别。在jos中,所有异常都在内核模式下处理,特权级别为0。
任务状态段TSS:处理器需要在调用执行异常处理代码之前保存好旧寄存器的状态,保证异常处理完毕后能恢复用户代码的执行。当interrupt或trap导致从用户态到内核态的特权级别改变时,x86处理器还将切换到内核内存的栈。task state segment即TSS指定了段选择子(ss)和栈地址(esp)。处理器将在新的栈上push SS、ESP、EFLAGS、CS、EIP,和一个可选的error code,然后从中断描述符加载cs和ip,并设置esp和ss引用新的栈。TSS可以用于各种各样的目的,但jos只使用它来定义处理器从用户模式切换到内核模式后的内核栈。因为jos的内核模式在特权级别0上,所以处理器使用ESP0和SS0来定义进入内核模式的内核栈。不同于xv6的设计,jos只用一个内核栈地址,其栈顶被映射到KSTACKTOP上,而xv6的每一个进程都有对应的内核栈。
TSS描述的是一个task在执行中的状态信息,重在描述代码切换之间权限的转换,用于保护机制。Env对应的是一个用户进程的状态,主要用于保持用户进程的独立,这里的进程是一个抽象程度较高的概念,包括PCB(jos中 evns 数组就等价于 PCB 表)、地址空间等,不仅仅是一段用户程序代码。
TSS结构如下所示:

Exceptions and Interrupts Example

假设处理器正在执行用户进程代码,并遇到了一条尝试除以0的divide指令:
1、处理器切换到TSS指定的SS0和ESP0指向的栈,jos中分别是GD_KD和KSTACKTOP。
2、处理器将相关寄存器保存到内核栈,从KSTACKTOP开始。

1
2
3
4
5
6
7
+--------------------+ KSTACKTOP             
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20 <---- ESP
+--------------------+

3、除0异常在x86上是中断向量0,处理器读取IDT表的第0项,根据该项描述的信息设置CS:EIP,指向处理函数。
4、处理函数获得控制权,开始处理异常,比如终止用户进程。

对于某些x86异常,处理器可能会将错误码进栈,比如页错误。布局如下:

1
2
3
4
5
6
7
8
+--------------------+ KSTACKTOP             
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20
| error code | " - 24 <---- ESP
+--------------------+

注意,将相关寄存器(和某些异常的错误码)保存到内核栈后,处理器才读取IDT表,寻找处理函数。

Nested Exceptions and Interrupts

处理器可以处理来自内核或用户模式的中断和异常。只有由用户模式切换到内核模式的时候,处理器才会在保存旧寄存器之前自动切换栈,并根据IDT调用合适的处理函数。如果中断或异常发生的时候,处理器已经处于内核模式(CS的低2位为0),将只在原来的栈上保存数据。这种情况下处理器可以优雅地处理由内核代码本身引起的内嵌异常。这种能力也是实施保护的工具,详见后面系统调用。
如果处理器已经在内核模式中且需要处理一个内嵌的异常,因为不需要切换栈,处理器不保存旧SS或ESP寄存器。对于不会导致错误码进栈的异常,进入异常处理程序的时候内核栈的样子如下:

1
2
3
4
5
+--------------------+ <---- old ESP
| old EFLAGS | " - 4
| 0x00000 | old CS | " - 8
| old EIP | " - 12
+--------------------+

对于需要将错误码进栈的异常,进入异常处理程序的时候内核栈的样子如下:

1
2
3
4
5
6
+--------------------+ <---- old ESP
| old EFLAGS | " - 4
| 0x00000 | old CS | " - 8
| old EIP | " - 12
| error code | " - 16
+--------------------+

假如处理器处于内核模式,且由于某些原因(如缺乏栈空间)导致无法保存处理器的旧状态,那么处理器将没有办法恢复原先状态。内核应该设计为不会发生这种情况。

JOS 系统中断过程的控制流如下所示:

Setting Up the IDT

kern/trap.h包含了内核专属的定义,inc/trap.h包含了用户级别的程序和库可用的定义。

1
2
Exercise 4: 在kern/trapentry.S中定义每个中断对应的中断处理程序,在kern/trap.c中根据定义好的中断处理程序初始化IDT。
每个中断对应的中断处理程序实际上是在内核栈中设置好Trapframe的布局结构,然后将这个结构传递给trap()函数进行处理,最后在trap_dispatch()中进行具体中断处理程序的分发。

关于中断处理程序的定义:

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
//see in kern/trapentry.S
/* TRAPHANDLER defines a globally-visible function for handling a trap.
* It pushes a trap number onto the stack, then jumps to _alltraps.
* Use TRAPHANDLER for traps where the CPU automatically pushes an error code.
*
* You shouldn't call a TRAPHANDLER function from C, but you may
* need to _declare_ one in C (for instance, to get a function pointer
* during IDT setup). You can declare the function with
* void NAME();
* where NAME is the argument passed to TRAPHANDLER.
*/
#define TRAPHANDLER(name, num) \
.globl name; /* define global symbol for 'name' */ \
.type name, @function; /* symbol type is function */ \
.align 2; /* align function definition */ \
name: /* function starts here */ \
pushl $(num); \
jmp _alltraps

/* Use TRAPHANDLER_NOEC for traps where the CPU doesn't push an error code.
* It pushes a 0 in place of the error code, so the trap frame has the same
* format in either case.
*/
#define TRAPHANDLER_NOEC(name, num) \
.globl name; \
.type name, @function; \
.align 2; \
name: \
pushl $0; \
pushl $(num); \
jmp _alltraps

这两个宏的功能是接受一个函数名和中断向量编号,定义出相应的以该函数名命名的中断处理程序,中断处理程序的执行流程是向栈里压入相关错误码和中断号,然后跳转到_alltraps把Trapframe剩下的部分在栈中设置好。对于某些中断,处理器将向栈中放入对应的中断错误码。当系统没有放入错误码的时候,中断处理函数则使用TRAPHANDLER_NOEC手动补齐。当用户使用int指令手动调用中断时,处理器不会放入错误码。具体的中断处理程序定义如下:

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
66
67
//see in kern/trapentry.S
/*
* Lab 3: Your code here for generating entry points for the different traps.
*/
TRAPHANDLER_NOEC(entry_point0,T_DIVIDE)
TRAPHANDLER_NOEC(entry_point1,T_DEBUG)
TRAPHANDLER_NOEC(entry_point2,T_NMI)
TRAPHANDLER_NOEC(entry_point3,T_BRKPT)
TRAPHANDLER_NOEC(entry_point4,T_OFLOW)
TRAPHANDLER_NOEC(entry_point5,T_BOUND)
TRAPHANDLER_NOEC(entry_point6,T_ILLOP)
TRAPHANDLER_NOEC(entry_point7,T_DEVICE)
TRAPHANDLER(entry_point8,T_DBLFLT)
# TRAPHANDLER(entry_point9,T_COPROC)
TRAPHANDLER(entry_point10,T_TSS)
TRAPHANDLER(entry_point11,T_SEGNP)
TRAPHANDLER(entry_point12,T_STACK)
TRAPHANDLER(entry_point13,T_GPFLT)
TRAPHANDLER(entry_point14,T_PGFLT)
# TRAPHANDLER(entry_point15,T_RES)
TRAPHANDLER_NOEC(entry_point16,T_FPERR)

TRAPHANDLER_NOEC(entry_point48,T_SYSCALL)

/*
* Lab 3: Your code here for _alltraps
*/
.globl _alltraps
_alltraps:
# Build trap frame. //将用户进程的寄存器保存
pushl %ds
pushl %es
pushal
//load GD_KD into %ds and %es
movw $(GD_KD), %ax
movw %ax, %ds
movw %ax, %es
pushl %esp
call trap

# entry point table
.data
.globl entry_points
entry_points:
.long entry_point0
.long entry_point1
.long entry_point2
.long entry_point3
.long entry_point4
.long entry_point5
.long entry_point6
.long entry_point7
.long entry_point8
# .long entry_point9
.long 0 #attendion: instead we should fill the hole with 0, or it will cause entry_points array's wrong index
.long entry_point10
.long entry_point11
.long entry_point12
.long entry_point13
.long entry_point14
# .long entry_point15
.long 0 #attendion: instead we should fill the hole with 0, or it will cause entry_points array's wrong index
.long entry_point16
.long 0
...
.long entry_point48
...

为了方便,将中断处理程序函数名定义到函数名数组entry_points中。之后初始化IDT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//see in kern/trap.c
void
trap_init(void)
{
extern struct Segdesc gdt[];

// LAB 3: Your code here.
/*
* add by jianzzz
*/
//entry_points store handler-function's address
extern uint32_t entry_points[]; //see in trapentry.S
int i;
for (i = 0; i < 16; ++i)
{
if (i==T_BRKPT)
SETGATE(idt[i], 0, GD_KT, entry_points[i], 3)
else if(i!=9 && i!=15)
SETGATE(idt[i],0,GD_KT,entry_points[i],0);//todo...why GD_KT???
}
SETGATE(idt[T_SYSCALL],0,GD_KT,entry_points[T_SYSCALL],3);
// Per-CPU setup
trap_init_percpu();
}

SETGATE第三个参数cs设为内核的代码段GD_KT。最后一个参数是用户特权级。到此为止,就完成了中断响应机制的建立。
中断处理过程图示如下:

中断门格式如下:

其结构 struct Gatedesc 是在 inc/mmu.h 中定义的。由其结构可知,中断或异常也有特权级别,由中断门描述符中的 DPL 约束,门描述符中的段选择子中的 CPL 说明中断处理程序运行的特权级别。

1
2
3
4
5
Questions:
1、为什么需要设计为每一个中断/异常都有单独的中断处理程序?如果所有的中断/异常都传递到相同的处理程序,会丢失什么特性?(这里指的中断处理程序是指跳转到_alltraps之前的处理过程)
中断处理程序在真正的处理之前会将中断号放入内核栈以组织成Trapframe的结构,如果所有的中断/异常都跳到同一个处理程序,那么无法正确设置中断/异常的中断号等。
2、user/softint程序的预想结果是抛出general protection fault (trap 13),但程序代码是执行int $14,如果内核实际允许该程序执行int $14并调用页错误处理程序,会发生什么情况?
中断向量14 Page fault的调用权限为0,只能由内核抛出,直接在softint中用int指令调用将会产生一般保护错误。

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

Handling Page Faults

当处理器遇到页错误时,会将导致页错误的线性地址保存到处理器控制寄存器CR2中。

1
Exercise 5.修改trap_dispatch(),将页错误分发给page_fault_handler()处理。

1
2
3
4
5
//see in kern/trap.c, trap_dispatch()
switch(tf->tf_trapno) {
case (T_PGFLT):
page_fault_handler(tf);
break;

The Breakpoint Exception

中断向量3(T_BRKPT)断点异常通常是用于允许调试器通过用单字节的int3软件中断指令临时替换相关程序指令的方式在程序代码中插入断点。jos中我们将这个异常变成一个粗糙的系统调用,任何用户程序都可以调用它从而嵌入到jos内核监控,在这个角度上jos相当于一个粗糙的调试器。

1
Exercise 6.修改trap_dispatch(),使得断点异常嵌入到内核监控程序。

1
2
3
4
5
6
7
8
9
10
//see in kern/trap.c, trap_dispatch()
switch(tf->tf_trapno) {
case (T_BRKPT):
//print_trapframe(tf);
monitor(tf);
break;

//see in kern/trap.c, trap_init()
if (i==T_BRKPT)
SETGATE(idt[i], 0, GD_KT, entry_points[i], 3)
1
Challenge! 熟悉EFLAGS,修改jos内核监控程序,当因为int3发生断点中断嵌入内核监控的时候,能够在当前位置执行“continue”操作和一次单步执行一条指令。

参考https://www.zhihu.com/question/40555332/answer/87130016?from=profile\_answer\_card ,一般情况下,在指令被执行前,断点指令会产生调试异常,如果异常处理程序在返回前没有移除断点的话,处理器在重启指令之前会再次发现断点,进而生成另一个调试异常。为了防止重复进入调试中断,Intel 64和IA-32架构使用RF标志控制处理器对指令断点的响应。RF置1则禁用断点指令产生调试异常,但是其它情况仍可以产生调试异常。RF置0则断点指令会产生调试异常。调试软件必须在用IRETD指令返回到被中断程序之前,将栈中的EFLAGES映象中的该位置为1,以阻止断点指令产生另外的调试异常。在返回并成功执行断点指令之后,处理器会自动清零该位,从而允许继续通过断点指令产生调试异常。
在jos中,情况有所不同。jos遇到断点指令后直接陷入了监控程序,而不会产生调试异常,因此跟RF没有什么关系。为了能够单步调试,需要EFLAGS的TF(Trap Flag)跟踪标志,置1则开启单步执行调试模式,置0则关闭。在单步执行模式下,处理器在每条指令后产生一个调试异常,这样在每条指令执行后都可以查看执行程序的状态。当然,为了达到观察每次调试结果,我们也同样需要将调试异常嵌入内核监控。

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
//see in kern/trap.c, trap_dispatch()
switch(tf->tf_trapno) {
case (T_DEBUG):
//print_trapframe(tf);
monitor(tf);
break;

//see in kern/monitor.c
int mon_continue(int argc,char **argv,struct Trapframe *tf){
uint32_t eflags;
if(tf==NULL){
cprintf("No trapped environment\n");
return 0;
}
eflags=tf->tf_eflags;
eflags &= ~FL_TF;
tf->tf_eflags=eflags;
return -1;
}

int mon_step(int argc,char **argv,struct Trapframe *tf){
uint32_t eflags;
if(tf==NULL){
cprintf("No trapped environment\n");
return 0;
}
eflags=tf->tf_eflags;
eflags |= FL_TF;
tf->tf_eflags=eflags;
return -1;
}

假设mon_continue对应的指令的continue,mon_step对应的指令是step。那么每次执行step的话,就相当于执行一次单步调试,这是因为:当我们执行int3时,eip记录了int3指令的下一条用户指令的位置,然后断点异常嵌入内核监控,此时如果输入step,内核会执行对应的mon_step函数,设置TF标志,然后返回-1;由于返回-1,内核会跳出监控程序返回用户程序,执行eip所指向的用户指令,记录下一条指令位置,然后发生调试异常嵌入内核监控,重复上述步骤即相当于每次执行一次单步调试。我们可以借助eip值和用户程序的asm文件查看每次执行的用户指令。如果输入continue会发生什么事呢:由于用户程序由lib/entry.S开始执行,然后调用lib/libmain.c中的libmain函数,可以看到最后libmain函数会调用exit,因此如果输入continue将会返回用户程序,最终触发一次系统调用并结束用户程序。

System calls

jos的系统调用是通过int 0x30实现的。程序将会使用寄存器传递系统调用号和系统调用参数,这样就不需要在用户环境的栈或指令流中进行查找。系统调用号存在%eax中,系统调用参数存在%edx, %ecx, %ebx, %edi, %esi中,最多传递5个参数。xv6使用宏定义将用户调用转换为系统调用,并使用用户栈传递参数;不同于xv6,用户程序调用系统调用时,jos通过汇编指令同时完成参数传递和调用转换,见lib/syscall.c

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
//see in lib/syscall.c
//用户代码通过该接口进行系统调用
static inline int32_t
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
int32_t ret;

// Generic system call: pass system call number in AX,
// up to five parameters in DX, CX, BX, DI, SI.
// Interrupt kernel with T_SYSCALL.
//
// The "volatile" tells the assembler not to optimize
// this instruction away just because we don't use the
// return value.
//
// The last clause tells the assembler that this can
// potentially change the condition codes and arbitrary
// memory locations.

asm volatile("int %1\n"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a1),
"c" (a2),
"b" (a3),
"D" (a4),
"S" (a5)
: "cc", "memory");

if(check && ret > 0)
panic("syscall %d returned %d (> 0)", num, ret);

return ret;
}

1
2
3
4
5
Exercise 7.为系统调用添加处理程序,包括:
1、kern/trapentry.S定义“预先”处理程序。
2、kern/trap.c的trap_init()函数初始化idt表。
3、trap_dispatch()调用syscall(),执行系统调用。
4、实现kern/syscall.c的syscall()。
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
//see in kern/trapentry.S
#define TRAPHANDLER_NOEC(name, num) \
.globl name; \
.type name, @function; \
.align 2; \
name: \
pushl $0; \
pushl $(num); \
jmp _alltraps

.text
TRAPHANDLER_NOEC(entry_point48,T_SYSCALL)

//see in kern/trap.c, trap_init()
SETGATE(idt[T_SYSCALL],0,GD_KT,entry_points[T_SYSCALL],3);

//see in kern/trap.c,trap_dispatch()
switch(tf->tf_trapno) {
case (T_SYSCALL):
ret_code = 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);
tf->tf_regs.reg_eax = ret_code;//attention
break;

/see in kern/syscall.c, syscall()
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
// Call the function corresponding to the 'syscallno' parameter.
// Return any appropriate return value.
// LAB 3: Your code here.
//panic("syscall not implemented");

switch (syscallno) {
case SYS_cputs:{
sys_cputs((char *)a1,a2); //refer to lib/syscall.c
return 0;
}
case SYS_cgetc:{
return sys_cgetc();
}
case SYS_getenvid:{
return sys_getenvid();
}
case SYS_env_destroy:{
return sys_env_destroy(a1);
}
case NSYSCALLS:
default:
return -E_NO_SYS;
}
}
1
2
3
4
5
6
7
8
9
Challenge! 使用sysenter和sysexit指令代替int 0x30和iret指令来实现系统调用。sysenter/sysexit的速度快于int/iret,因为使用了寄存器而不是栈。jos中可以这样实现:在kern/trapentry.S中添加sysenter_handler,用以保存返回用户程序的信息、设置内核环境、将syscall()参数进栈,然后直接调用syscall()。syscall()返回时,设置好返回信息后执行sysexit指令。同时,在kern/init.c设置model specific registers (MSRs),可参考Section 6.1.2 in Volume 2 of the AMD Architecture Programmer's Manual和the reference on SYSENTER in Volume 2B of the Intel reference manuals。最后,lib/syscall.c必须支持sysenter指令。
使用sysenter时,寄存器的布局可能是:
eax - syscall number
edx, ecx, ebx, edi - arg1, arg2, arg3, arg4
esi - return pc
ebp - return esp
esp - trashed by sysenter
GCC的内联汇编程序将在被直接告知加载值的时候自动保存寄存器。内联汇编程序不支持保存%ebp,需要添加代码来保存和恢复。返回地址可以通过使用类似于leal after_sysenter_label, %%esi的指令存在%esi中。
注意到这个方法最多支持4个参数,不同于原来可支持5个参数的方法。另外这个方法不更新当前进程的trapframe,不适合后续lab添加的一些system call。
1
???

User-mode startup

用户程序由lib/entry.S开始执行,经过一些设置之后调用lib/libmain.c中的libmain函数。libmain函数会调用umain,umain是用户程序的主函数入口。

1
Exercise 8. lib/entry.S已经定义envs指向UENVS,需要在libmain函数中初始化全局变量thisenv指向当前进程的struct Env结构,用户程序通过thisenv获取进程的相关信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//see in lib/libmain.c
void
libmain(int argc, char **argv)
{
// set thisenv to point at our Env structure in envs[].
// LAB 3: Your code here.
thisenv = 0;
//envs see in inc/lib.h
thisenv = &envs[ENVX(sys_getenvid())];

// save the name of the program so that panic() can use it
if (argc > 0)
binaryname = argv[0];

// call user main routine
umain(argc, argv); //see in user/*.c

// exit gracefully
exit();
}

Page faults and memory protection

操作系统通常依赖于硬件支持来实现内存保护。当程序试图访问一个无效的或者没有权限的地址,处理器停止导致故障的指令然后陷入到内核。如果故障是可以解决的,内核可以修复它并让程序继续运行。如果故障是不可以解决的,那么程序将不能继续执行,因为引起故障的指令永远不会被越过。
故障可以被解决的一个例子是:自动扩展栈。在许多系统中最初只分配了一页的栈空间。当程序错误访问到栈地址时(further down the stack),内核自动分配这些页并且恢复程序执行。基于此,内核按需分配栈空间,但是程序在拥有任意大栈的“错觉”下正常工作。
系统调用给内核留下了一个内存保护的问题:许多系统调用接口让用户程序传递指针到内核中,这些指针指向了可读写的用户缓冲区,内核在执行系统调用时会间接引用这些指针。这会引发两个问题:
1、在内核中发生页错误比在用户程序中发生页错误更加严重。如果内核在操作自己的数据结构时发生了页错误,页错误处理程序应该panic内核,因为这是内核的bug。但如果是间接引用用户程序传递的指针引起的页错误,需要一种方式记住这些间接引用导致的页错误都是代表用户程序的。
2、内核通常比用户程序拥有更多的内存权限。用户程序传递给内核的指针指向的内存对于内核来说可能是可读写的,但对于用户程序来说是不能读写的。内核应当注意不要暴露一些私有信息或破坏内核完整性。

1
Exercise 9. 解决上述两个问题。

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
问题1的解决:在kern/trap.c的page_fault_handler函数中识别页错误是发生在内核模式还是用户模式中(判断tf_cs的低2位),如果是内核模式,直接panic;如果是用户模式,销毁进程。
//see in kern/trap.c
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;

// Read processor's CR2 register to find the faulting address
fault_va = rcr2();

// Handle kernel-mode page faults.

// LAB 3: Your code here.
if ((tf->tf_cs&3) == 0)
panic("Kernel page fault!");
// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.

// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
}

问题2的解决:查看kern/pmap.c的user_mem_assert函数,并实现user_mem_check。然后在kern/syscall.c中检查系统调用的参数。user_mem_check函数主要是根据给定的权限查看给定的[va, va+len)是否有对应的访问权限。
//see in kern/pmap.c
//
// Check that an environment is allowed to access the range of memory
// [va, va+len) with permissions 'perm | PTE_P'.
// Normally 'perm' will contain PTE_U at least, but this is not required.
// 'va' and 'len' need not be page-aligned; you must test every page that
// contains any of that range. You will test either 'len/PGSIZE',
// 'len/PGSIZE + 1', or 'len/PGSIZE + 2' pages.
//
// A user program can access a virtual address if (1) the address is below
// ULIM, and (2) the page table gives it permission. These are exactly
// the tests you should implement here.
//
// If there is an error, set the 'user_mem_check_addr' variable to the first
// erroneous virtual address.
//
// Returns 0 if the user program can access this range of addresses,
// and -E_FAULT otherwise.
//
int
user_mem_check(struct Env *env, const void *va, size_t len, int perm)
{
// LAB 3: Your code here.
cprintf("user_mem_check va: %x, len: %x\n", va, len);
uint32_t begin = (uint32_t)ROUNDDOWN((char*)va,PGSIZE);
uint32_t end = (uint32_t)ROUNDUP((char*)(va+len),PGSIZE);
uint32_t i=0;
for(i=begin;i<end;i+=PGSIZE){
pte_t *pte = pgdir_walk(env->env_pgdir,(void *)i,false);
if(i>=ULIM || !pte || !(*pte & PTE_P) || (*pte & perm) != perm){
user_mem_check_addr = (i<(uint32_t)va?(uint32_t)va:i);
return -E_FAULT;
}
}
cprintf("user_mem_check success va: %x, len: %x\n", va, len);
return 0;
}

//
// Checks that environment 'env' is allowed to access the range
// of memory [va, va+len) with permissions 'perm | PTE_U | PTE_P'.
// If it can, then the function simply returns.
// If it cannot, 'env' is destroyed and, if env is the current
// environment, this function will not return.
//
void
user_mem_assert(struct Env *env, const void *va, size_t len, int perm)
{
if (user_mem_check(env, va, len, perm | PTE_U) < 0) {
cprintf("[%08x] user_mem_check assertion failure for "
"va %08x\n", env->env_id, user_mem_check_addr);
env_destroy(env); // may not return
}
}

//see in kern/syscall.c
// Print a string to the system console.
// The string is exactly 'len' characters long.
// Destroys the environment on memory errors.
static void
sys_cputs(const char *s, size_t len)
{
// Check that the user has permission to read memory [s, s+len).
// Destroy the environment if not.

// LAB 3: Your code here.
//see in kern/env.c : int envid2env(envid_t envid, struct Env **env_store, bool checkperm);
//struct Env* e ;
//envid2env(sys_getenvid(),&e,1);
user_mem_assert(curenv, s, len, PTE_U);//see in kern/pmap.c
// Print the string supplied by the user.
cprintf("%.*s", len, s);
}

遗留的问题

1、二进制文件怎么连接进内核里?
2、如何生成唯一pid?以及pid与ENVX的关系
3、binary的数据位于内核空间的哪个节?
4、为什么所有中断都设置为中断门?
5、Exercise 7的Challenge实现
6、实际用户程序编译之后的地址与所谓的段选择子:偏移的对应关系?
7、各种门的使用情况

显示 Gitment 评论