MIT 6.828 JOS学习笔记17. Lab 3.1 Part A User Environments
Introduction
在这个实验中,我们将实现操作系统的一些基本功能,来实现用户环境下的进程的正常运行。你将会加强JOS内核的功能,为它增添一些重要的数据结构,用来记录用户进程环境的一些信息;创建一个单一的用户环境,并且加载一个程序运行它。你也可以让JOS内核能够完成用户环境所作出的任何系统调用,以及处理用户环境产生的各种异常。
Part A: User Environments and Exception Handling
新包含的文件inc/env.h里面包含了JOS内核的有关用户环境(User Environment)的一些基本定义。用户环境指的就是一个应用程序运行在系统中所需要的一个上下文环境,操作系统内核使用数据结构 Env 来记录每一个用户环境的信息。在这个实验中,我们只会创建一个用户环境,但是之后我们会把它设计成能够支持多用户环境,即多个用户程序并发执行。
在 kern/env.c 文件中我们看到,操作系统一共维护了三个重要的和用户环境相关的全局变量:
struct Env *envs = NULL; //所有的 Env 结构体
struct Env *curenv = NULL; //目前正在运行的用户环境
static struct Env *env_free_list; //还没有被使用的 Env 结构体链表
一旦JOS启动,envs指针便指向了一个 Env 结构体链表,表示系统中所有的用户环境的env。在我们的设计中,JOS内核将支持同一时刻最多 NENV 个活跃的用户环境,尽管这个数字要比真实情况下任意给定时刻的活跃用户环境数要多很多。系统会为每一个活跃的用户环境在envs链表中维护一个 Env 结构体。
JOS内核也把所有不活跃的Env结构体,用env_free_list链接起来。这种设计方式非常方便进行用户环境env的分配和回收。
内核也会把 curenv 指针指向在任意时刻正在执行的用户环境的 Env 结构体。在内核启动时,并且还没有任何用户环境运行时,curenv的值为NULL。
Environment Status
我们要看一下,Env结构体每一个字段的具体含义是什么,Env结构体定义在 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; //envid of this env's parent
enum EnvType env_type; //Indicates special system environment
unsigned env_status; //Status of the environment
uint32_t env_runs; //Number of the times environment has run
pde_t *env_pgdir; //Kernel virtual address of page dir.
};
env_tf:
这个类型的结构体在inc/trap.h文件中被定义,里面存放着当用户环境暂停运行时,所有重要寄存器的值。内核也会在系统从用户态切换到内核态时保存这些值,这样的话用户环境可以在之后被恢复,继续执行。
env_link:
这个指针指向在env_free_list中,该结构体的后一个free的Env结构体。当然前提是这个结构体还没有被分配给任意一个用户环境时,该域才有用。
env_id:
这个值可以唯一的确定使用这个结构体的用户环境是什么。当这个用户环境终止,内核会把这个结构体分配给另外一个不同的环境,这个新的环境会有不同的env_id值。
env_parent_id:
创建这个用户环境的父用户环境的env_id
env_type:
用于区别出来某个特定的用户环境。对于大多数环境来说,它的值都是 ENV_TYPE_USER.
env_status:
这个变量存放以下可能的值
ENV_FREE: 代表这个结构体是不活跃的,应该在链表env_free_list中。
ENV_RUNNABLE: 代表这个结构体对应的用户环境已经就绪,等待被分配处理机。
ENV_RUNNING: 代表这个结构体对应的用户环境正在运行。
ENV_NOT_RUNNABLE: 代表这个结构体所代表的是一个活跃的用户环境,但是它不能被调度运行,因为它在等待其他环境传递给它的消息。
ENV_DYING: 代表这个结构体对应的是一个僵尸环境。一个僵尸环境在下一次陷入内核时会被释放回收。
env_pgdir:
这个变量存放着这个环境的页目录的虚拟地址
就像Unix中的进程一样,一个JOS环境中结合了“线程”和“地址空间”的概念。线程通常是由被保存的寄存器的值来定义的,而地址空间则是由env_pgdir所指向的页目录表还有页表来定义的。为了运行一个用户环境,内核必须设置合适的寄存器的值以及合适的地址空间。
Allocating the Environments Array
在lab 2,你在mem_init() 函数中分配了pages数组的地址空间,用于记录内核中所有的页的信息。现在你需要进一步去修改mem_init()函数,来分配一个Env结构体数组,叫做envs。
Exercise 1. 修改一下mem_init()的代码,让它能够分配envs数组。这个数组是由NENV个Env结构体组成的。envs数组所在的这部分内存空间也应该是用户模式只读的。被映射到虚拟地址UENVS处。
答:
就像题目中说的那样,我们只需要像在Lab2里面分配pages数组那样,分配一个Env数组给指针envs就可以了。
主要要在两个地方要添加代码,首先要在page_init()之前为envs分配内存空间。
envs = (struct Env*)boot_alloc(NENV*sizeof(struct Env)); memset(envs, 0, NENV * sizeof(struct Env));
然后要在页表中设置它的映射关系,位于check_page()函数之后
boot_map_region(kern_pgdir, UENVS, PTSIZE, PADDR(envs), PTE_U);
Creating and Running Environments
现在你需要去编写 kern/env.c 文件来运行一个用户环境了。由于你现在没有文件系统,所以必须把内核设置成能够加载内核中的静态二进制程序映像文件。
Lab3 里面的 GNUmakefile 文件在obj/user/目录下面生成了一系列的二进制映像文件。如果你看一下 kern/Makefrag 文件,你会发现一些奇妙的地方,这些地方把二进制文件直接链接到内核可执行文件中,只要这些文件是.o文件。其中在链接器命令行中的-b binary 选项会使这些文件被当做二进制执行文件链接到内核之后。
在 i386_init() 函数中,你会看到运行上述二进制文件的代码,但是我们需要完成能够设置这些代码的运行用户环境的功能。
Exercise 2. 在文件 env.c中,完成下列函数:
env_init(): 初始化所有的在envs数组中的 Env结构体,并把它们加入到 env_free_list中。 还要调用 env_init_percpu,这个函数要配置段式内存管理系统,让它所管理的段,可能具有两种访问优先级其中的一种,一个是内核运行时的0优先级,以及用户运行时的3优先级。
env_setup_vm(): 为一个新的用户环境分配一个页目录表,并且初始化这个用户环境的地址空间中的和内核相关的部分。
region_alloc(): 为用户环境分配物理地址空间
load_icode(): 分析一个ELF文件,类似于boot loader做的那样,我们可以把它的内容加载到用户环境下。
env_create(): 利用env_alloc函数和load_icode函数,加载一个ELF文件到用户环境中
env_run(): 在用户模式下,开始运行一个用户环境。
答:
env_init函数很简单,就是遍历 envs 数组中的所有 Env 结构体,把每一个结构体的 env_id 字段置0,因为要求所有的 Env 在 env_free_list 中的顺序,要和它在 envs 中的顺序一致,所以需要采用头插法。
代码:
1 void 2 env_init(void) 3 { 4 // Set up envs array 5 // LAB 3: Your code here. 6 int i; 7 env_free_list = NULL; 8 for(i=NENV-1; i>=0; i--){ 9 envs[i].env_id = 0; 10 envs[i].env_status = ENV_FREE; 11 envs[i].env_link = env_free_list; 12 env_free_list = &envs[i]; 13 } 14 // Per-CPU part of the initialization 15 env_init_percpu(); 16 }
env_setup_vm 函数主要是初始化新的用户环境的页目录表,不过只设置页目录表中和操作系统内核跟内核相关的页目录项,用户环境的页目录项不要设置,因为所有用户环境的页目录表中和操作系统相关的页目录项都是一样的(除了虚拟地址UVPT,这个也会单独进行设置),所以我们可以参照 kern_pgdir 中的内容来设置 env_pgdir 中的内容。
代码:
1 static int 2 env_setup_vm(struct Env *e) 3 { 4 int i; 5 struct PageInfo *p = NULL; 6 7 // Allocate a page for the page directory 8 if (!(p = page_alloc(ALLOC_ZERO))) 9 return -E_NO_MEM; 10 11 // LAB 3: Your code here. 12 e->env_pgdir = (pde_t *)page2kva(p); 13 p->pp_ref++; 14 15 //Map the directory below UTOP. 16 for(i = 0; i < PDX(UTOP); i++) { 17 e->env_pgdir[i] = 0; 18 } 19 20 //Map the directory above UTOP 21 for(i = PDX(UTOP); i < NPDENTRIES; i++) { 22 e->env_pgdir[i] = kern_pgdir[i]; 23 } 24 25 // UVPT maps the env's own page table read-only. 26 // Permissions: kernel R, user R 27 e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U; 28 29 return 0; 30 }
region_alloc 为用户环境分配物理空间,这里注意我们要先把起始地址和终止地址进行页对齐,对其之后我们就可以以页为单位,为其一个页一个页的分配内存,并且修改页目录表和页表。
代码:
1 static void 2 region_alloc(struct Env *e, void *va, size_t len) 3 { 4 // LAB 3: Your code here. 5 void* start = (void *)ROUNDDOWN((uint32_t)va, PGSIZE); 6 void* end = (void *)ROUNDUP((uint32_t)va+len, PGSIZE); 7 struct PageInfo *p = NULL; 8 void* i; 9 int r; 10 for(i=start; i<end; i+=PGSIZE){ 11 p = page_alloc(0); 12 if(p == NULL) 13 panic(" region alloc, allocation failed."); 14 15 r = page_insert(e->env_pgdir, p, i, PTE_W | PTE_U); 16 if(r != 0) { 17 panic("region alloc error"); 18 } 19 } 20 }
load_icode 功能是为每一个用户进程设置它的初始代码区,堆栈以及处理器标识位。每个用户程序都是ELF文件,所以我们要解析该ELF文件。
代码:
1 static void 2 load_icode(struct Env *e, uint8_t *binary) 3 { 4 5 // LAB 3: Your code here. 6 struct Elf* header = (struct Elf*)binary; 7 8 if(header->e_magic != ELF_MAGIC) { 9 panic("load_icode failed: The binary we load is not elf.\n"); 10 } 11 12 if(header->e_entry == 0){ 13 panic("load_icode failed: The elf file can't be excuterd.\n"); 14 } 15 16 e->env_tf.tf_eip = header->e_entry; 17 18 lcr3(PADDR(e->env_pgdir)); //????? 19 20 struct Proghdr *ph, *eph; 21 ph = (struct Proghdr* )((uint8_t *)header + header->e_phoff); 22 eph = ph + header->e_phnum; 23 for(; ph < eph; ph++) { 24 if(ph->p_type == ELF_PROG_LOAD) { 25 if(ph->p_memsz - ph->p_filesz < 0) { 26 panic("load icode failed : p_memsz < p_filesz.\n"); 27 } 28 29 region_alloc(e, (void *)ph->p_va, ph->p_memsz); 30 memmove((void *)ph->p_va, binary + ph->p_offset, ph->p_filesz); 31 memset((void *)(ph->p_va + ph->p_filesz), 0, ph->p_memsz - ph->p_filesz); 32 } 33 } 34 35 // Now map one page for the program's initial stack 36 // at virtual address USTACKTOP - PGSIZE. 37 region_alloc(e,(void *)(USTACKTOP-PGSIZE), PGSIZE); 38 // LAB 3: Your code here. 39 }
env_create 是利用env_alloc函数和load_icode函数,加载一个ELF文件到用户环境中
代码:
1 void 2 env_create(uint8_t *binary, enum EnvType type) 3 { 4 // LAB 3: Your code here. 5 struct Env *e; 6 int rc; 7 if((rc = env_alloc(&e, 0)) != 0) { 8 panic("env_create failed: env_alloc failed.\n"); 9 } 10 11 load_icode(e, binary); 12 e->env_type = type; 13 }
env_run 是真正开始运行一个用户环境
代码:
1 void 2 env_run(struct Env *e) 3 { 4 5 if(curenv != NULL && curenv->env_status == ENV_RUNNING) { 6 curenv->env_status = ENV_RUNNABLE; 7 } 8 9 curenv = e; 10 curenv->env_status = ENV_RUNNING; 11 curenv->env_runs++; 12 lcr3(PADDR(curenv->env_pgdir)); 13 14 env_pop_tf(&curenv->env_tf); 15 // LAB 3: Your code here. 16 17 panic("env_run not yet implemented"); 18 }
用户环境的代码被调用前,操作系统一共按顺序执行了以下几个函数:
* start (kern/entry.S)
* i386_init (kern/init.c)
cons_init
mem_init
env_init
trap_init (目前还未实现)
env_create
env_run
env_pop_tf
一旦你完成上述子函数的代码,并且在QEMU下编译运行,系统会进入用户空间,并且开始执行hello程序,直到它做出一个系统调用指令int。但是这个系统调用指令不能成功运行,因为到目前为止,JOS还没有设置相关硬件来实现从用户态向内核态的转换功能。当CPU发现,它没有被设置成能够处理这种系统调用中断时,它会触发一个保护异常,然后发现这个保护异常也无法处理,从而又产生一个错误异常,然后又发现仍旧无法解决问题,所以最后放弃,我们把这个叫做"triple fault"。通常来说,接下来CPU会复位,系统会重启。
所以我们马上要来解决这个问题,不过解决之前我们可以使用调试器来检查一下程序要进入用户模式时做了什么。使用make qemu-gdb 并且在 env_pop_tf 处设置断点,这条指令应该是即将进入用户模式之前的最后一条指令。然后进行单步调试,处理会在执行完 iret 指令后进入用户模式。然后依旧可以看到进入用户态后执行的第一条指令了,该指令是一个cmp指令,开始于文件 lib/entry.S 中。 现在使用 b *0x... 设置一个断点在hello文件(obj/user/hello.asm)中的sys_cputs函数中的 int $0x30 指令处。这个int指令是一个系统调用,用来展示一个字符到控制台。如果你的程序运行不到这个int指令,说明有错误。
Handling Interrupts and Exceptions
到目前为止,当程序运行到第一个系统调用 int $0x30 时,就会进入错误的状态,因为现在系统无法从用户态切换到内核态。所以你需要实现一个基本的异常/系统调用处理机制,使得内核可以从用户态转换为内核态。你应该先熟悉一下X86的异常中断机制。
Basics of Protected Control Transfer
异常(Exception)和中断(Interrupts)都是“受到保护的控制转移方法”,都会使处理器从用户态转移为内核态。在Intel的术语中,一个中断指的是由外部异步事件引起的处理器控制权转移,比如外部IO设备发送来的中断信号。一个异常则是由于当前正在运行的指令所带来的同步的处理器控制权的转移,比如除零溢出异常。
为了能够确保这些控制的转移能够真正被保护起来,处理器的中断/异常机制通常被设计为:用户态的代码无权选择内核中的代码从哪里开始执行。处理器可以确保只有在某些条件下,才能进入内核态。在X86上,有两种机制配合工作来提供这种保护:
1. 中断向量表:
处理器保证中断和异常只能够引起内核进入到一些特定的,被事先定义好的程序入口点,而不是由触发中断的程序来决定中断程序入口点。
X86允许多达256个不同的中断和异常,每一个都配备一个独一无二的中断向量。一个向量指的就是0到255中的一个数。一个中断向量的值是根据中断源来决定的:不同设备,错误条件,以及对内核的请求都会产生出不同的中断和中断向量的组合。CPU将使用这个向量作为这个中断在中断向量表中的索引,这个表是由内核设置的,放在内核空间中,和GDT很像。通过这个表中的任意一个表项,处理器可以知道:
*需要加载到EIP寄存器中的值,这个值指向了处理这个中断的中断处理程序的位置。
*需要加载到CS寄存器中的值,里面还包含了这个中断处理程序的运行特权级。(即这个程序是在用户态还是内核态下运行。)
2. 任务状态段
处理器还需要一个地方来存放,当异常/中断发生时,处理器的状态,比如EIP和CS寄存器的值。这样的话,中断处理程序一会可以重新返回到原来的程序中。这段内存自然也要保护起来,不能被用户态的程序所篡改。
正因为如此,当一个x86处理器要处理一个中断,异常并且使运行特权级从用户态转为内核态时,它也会把它的堆栈切换到内核空间中。一个叫做 “任务状态段(TSS)”的数据结构将会详细记录这个堆栈所在的段的段描述符和地址。处理器会把SS,ESP,EFLAGS,CS,EIP以及一个可选错误码等等这些值压入到这个堆栈上。然后加载中断处理程序的CS,EIP值,并且设置ESP,SS寄存器指向新的堆栈。
尽管TSS非常大,并且还有很多其他的功能,但是JOS仅仅使用它来定义处理器从用户态转向内核态所采用的内核堆栈,由于JOS中的内核态指的就是特权级0,所以处理器用TSS中的ESP0,SS0字段来指明这个内核堆栈的位置,大小。
Types of Exceptions and Interrupts
所有的由X86处理器内部产生的异常的向量值是0到31之间的整数。比如,页表错所对应的向量值是14.而大于31号的中断向量对应的是软件中断,由int指令生成;或者是外部中断,由外部设备生成。
在这一章,我们将扩展JOS的功能,使它能够处理0~31号内部异常。在下一章会让JOS能够处理48号软件中断,主要被用来做系统调用。在Lab4中会继续扩展JOS使它能够处理外部硬件中断,比如时钟中断。
An Example
让我们看一个实例,假设处理器正在用户状态下运行代码,但是遇到了一个除法指令,并且除数为0.
1. 处理器会首先切换自己的堆栈,切换到由TSS的SS0,ESP0字段所指定的内核堆栈区,这两个字段分别存放着GD_KD和KSTACKTOP的值。
2. 处理器把异常参数压入到内核堆栈中,起始于地址KSTACKTOP:
3. 因为我们要处理的是除零异常,它的中断向量是0,处理器会读取IDT表中的0号表项,并且把CS:EIP的值设置为0号中断处理函数的地址值。
4. 中断处理函数开始执行,并且处理中断。
对于某些特定的异常,除了上面图中要保存的五个值之外,还要再压入一个字,叫做错误码。比如页表错,就是其中一个实例。当压入错误码之后,内核堆栈的状态如下:
以上几步都是由硬件自动完成的。
Nested Exceptions and Interrupts
处理器在用户态下和内核态下都可以处理异常或中断。只有当处理器从用户态切换到内核态时,才会自动地切换堆栈,并且把一些寄存器中的原来的值压入到堆栈上,并且触发相应的中断处理函数。但如果处理器已经由于正在处理中断而处在内核态下时,此时CPU只会向内核堆栈压入更多的值。通过这种方式,内核就可处理嵌套中断。
如果处理器已经在内核态下并且遇到嵌套中断,因为它不需要切换堆栈,所以它不需要存储SS,ESP寄存器的值。此时内核堆栈的就像下面这个样子:
这里有一个重要的警告。如果处理器在内核态下接受一个异常,而且由于一些原因,比如堆栈空间不足,不能把当前的状态信息(寄存器的值)压入到内核堆栈中时,那么处理器是无法恢复到原来的状态了,它会自动重启。
Setting Up the IDT
你现在应该有了所有的基本信息去设置IDT表,并且在JOS处理异常。现在你只需要处理内部异常(中断向量号0~31)。
在头文件 inc/trap.h和kern/trap.h 中包含了和中断异常相关的非常重要的定义,你应该好好熟悉一下。kern/trap.h 文件中包含了仅内核可见的一些定义, inc/trap.h 中包含了用户态也可见的一些定义。
最后你要实现的代码的效果如下:
每一个中断或异常都有它自己的中断处理函数,分别定义在 trapentry.S中,trap_init()将初始化IDT表。每一个处理函数都应该构建一个结构体 Trapframe 在堆栈上,并且调用trap()函数指向这个结构体,trap()然后处理异常/中断,给他分配一个中断处理函数。
所以整个操作系统的中断控制流程为:
1. trap_init() 先将所有中断处理函数的起始地址放到中断向量表IDT中。
2. 当中断发生时,不管是外部中断还是内部中断,处理器捕捉到该中断,进入核心态,根据中断向量去查询中断向量表,找到对应的表项
3. 保存被中断的程序的上下文到内核堆栈中,调用这个表项中指明的中断处理函数。
4. 执行中断处理函数。
5. 执行完成后,恢复被中断的进程的上下文,返回用户态,继续运行这个进程。
Exercise 4.
编辑一下trapentry.S 和 trap.c 文件,并且实现上面所说的功能。宏定义 TRAPHANDLER 和 TRAPHANDLER_NOEC 会对你有帮助。你将会在 trapentry.S文件中为在inc/trap.h文件中的每一个trap加入一个入口指, 你也将会提供_alttraps的值。
你需要修改trap_init()函数来初始化idt表,使表中每一项指向定义在trapentry.S中的入口指针,SETGATE宏定义在这里用得上。
你所实现的 _alltraps 应该:
1. 把值压入堆栈使堆栈看起来像一个结构体 Trapframe
2. 加载 GD_KD 的值到 %ds, %es寄存器中
3. 把%esp的值压入,并且传递一个指向Trapframe的指针到trap()函数中。
4. 调用trap
考虑使用pushal指令,他会很好的和结构体 Trapframe 的布局配合好。
答:
首先看一下 trapentry.S 文件,里面定义了两个宏定义,TRAPHANDLER,TRAPHANDLER_NOEC。他们的功能从汇编代码中可以看出:声明了一个全局符号name,并且这个符号是函数类型的,代表它是一个中断处理函数名。其实这里就是两个宏定义的函数。这两个函数就是当系统检测到一个中断/异常时,需要首先完成的一部分操作,包括:中断异常码,中断错误码(error code)。正是因为有些中断有中断错误码,有些没有,所以我们采用利用两个宏定义函数。
然后就会调用 _alltraps,_alltraps函数其实就是为了能够让程序在之后调用trap.c中的trap函数时,能够正确的访问到输入的参数,即Trapframe指针类型的输入参数tf。
所以在trapentry.S中,我们要根据这个中断是否有中断错误码,来选择调用TRAPHANDLER,还是TRAPHANDLER_NOEC,然后再统一调用_alltraps,其实目的就是为了能够让系统在正式运行中断处理程序之前完成必要的准备工作,比如保存现场等等。
具体的代码可以去看一下我的github https://github.com/fatsheepzzq/6.828mit/
而在trap.c文件中,我们应该继续完善trap_init函数,这个函数中将会对系统的IDT表进行初始化设置。
同理,由于篇幅有限,可以去我的github上看一下相关的代码。
Question:
1. What is the purpose of having an individual handler function for each exception/interrupt? (i.e., if all exceptions/interrupts were delivered to the same handler, what feature that exists in the current implementation could not be provided?)
答:
不同的中断或者异常当然需要不同的中断处理函数,因为不同的异常/中断可能需要不同的处理方式,比如有些异常是代表指令有错误,则不会返回被中断的命令。而有些中断可能只是为了处理外部IO事件,此时执行完中断函数还要返回到被中断的程序中继续运行。
2. Did you have to do anything to make the user/softint program behave correctly? The grade script expects it to produce a general protection fault (trap 13), but softint's code says int $14. Why should this produce interrupt vector 13? What happens if the kernel actually allows softint's int $14 instruction to invoke the kernel's page fault handler (which is interrupt vector 14)?
答:
因为当前的系统正在运行在用户态下,特权级为3,而INT指令为系统指令,特权级为0。特权级为3的程序不能直接调用特权级为0的程序,会引发一个General Protection Exception,即trap 13。
以上就是Lab3 Part A~
欢迎大家的意见与问题
zzqwf12345@163.com