在本实验中,我们将实现运行受保护的用户模式环境(即“进程”)所需的基本内核设施。我们将增强JOS内核,设置数据结构来跟踪用户环境,创建单个用户环境,将程序映像加载到其中,并开始运行。我们还将使JOS内核能够处理用户环境发出的任何系统调用,以及处理它引起的任何其它异常。
注意:在这个实验室中,术语“环境”和“进程”是可互换的——两者都指运行程序的抽象。我们引入了术语“环境”而不是传统的术语“进程”,以强调JOS环境和UNIX进程提供不同的接口,并且不提供相同的语义。
1. Part A:用户环境和异常处理
在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启动并运行后,envs指针指向表示系统中所有环境的Env结构数组。在我们的设计中,JOS内核将支持最多NENV个同时活动的环境,尽管在任何给定时间运行的环境通常要少得多。(NENV在inc/env.h中定义的常量)分配后,env数组将包含每个NENV个可能环境的Env结构体的单个实例。
JOS内核在Env_free_list上保留所有不活动的Env结构。这种设计允许轻松的分配和取消分配环境,因为只需将它们添加到空闲列表或从空闲列表中删除即可。
内核使用curenv随时跟踪当前执行的环境。在启动期间,在运行第一个环境之前,curenv最初设置为NULL。
1.1 环境状态
环境结构体在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_free_list上的下一个Env的指针。env_free_list指向列表中的第一个空闲环境。
env_id:内核在这里存储一个值,该值唯一标识当前使用此Env结构体的环境(即,使用envs数组中的这个特定的槽)。在用户环境终止后,内核可以将相同的Env结构重新分配给不同的环境,但新环境将具有与旧环境不同的Env_id,即使新环境正在重新使用envs数组中的相同的槽。
env_parent_id:内核在此存储创建此环境的环境(父环境)的env_id。通过这种方式,环境可以形成一个“家谱”,这将有助于做出关于允许哪些环境对谁做什么的安全决策。
env_type:这用于区分特殊环境。对于大多数环境,它将是ENV_TYPE_USER。我们将在稍后的实验室中为特殊系统服务环境引入更多类型。
env_status:此变量包含以下值之一:
ENV_FREE
:表示Env结构处于非活动状态,因此位于Env_free_list上。ENV_RUNNABLE
:表示Env结构表示等待在处理器上运行的环境。ENV_RUNNING
:表示Env结构表示当前运行的环境。ENV_NOT_RUNNABLE
:指示Env结构表示当前活动的环境,但当前未准备好运行:例如,因为它正在等待来自另一个环境的进程间通信(IPC)。ENV_DYING
:指示环境结构表示僵尸环境。僵尸环境将在下次陷入到内核时释放。在实验4之前,我们不会使用此标志。
env_pgdir:此变量保存此环境的页目录的虚拟地址。
与Unix进程一样,JOS环境结合了“线程”和“地址空间”的概念。线程主要由保存的寄存器(env_tf字段)定义,地址空间由env_pgdir指向的页目录和页表定义。要运行环境,内核必须使用保存的寄存器和适当的地址空间来设置CPU。
我们的struct Env类似于xv6中的struct proc。这两种结构都将环境(即进程)的用户模式寄存器状态保存在Trapframe结构中。在JOS中,各个环境不像xv6中的进程那样具有自己的内核堆栈。内核中一次只能有一个 JOS 环境处于活动状态,因此 JOS 只需要一个内核堆栈。
1.2 分配环境数组
在实验2中,您在mem_init()中为pages[]数组分配了内存,这是内核用来跟踪哪些物理页是空闲的,哪些物理页不是空闲的表。现在需要进一步修改mem_init(),以分配一个类似的Env结构体数组,称为envs。
练习1:修改kern/pmap.c中的mem_init()以分配和映射envs数组。此数组由Env结构体的NENV个实例组成,其分配方式与物理页数组的分配方式非常相似。与物理页一样,支持env的内存也应该映射为用户只读的UENVS(在inc/memlayout.h中定义),以便用户进程可以从该数组中读取。
您应该运行代码并确保check_kern_pgdir()成功。
答:比较简单,直接在相应位置补充代码
//////////////////////////////////////////////////////////////////////
// Make 'envs' point to an array of size 'NENV' of 'struct Env'.
// LAB 3: Your code here.
envs = (struct Env*)boot_alloc(sizeof(struct Env) * NENV);
memset(envs, 0, sizeof(struct Env) * NENV);
//////////////////////////////////////////////////////////////////////
// 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);
重新测试实验2
现在的内存映射关系如下图所示
1.3 创建并运行环境
现在,您将在kern/env.c中编写运行用户环境所需的代码。因为我们还没有文件系统,所以我们将设置内核来加载硬编码到内核本身的静态二进制映像。JOS将此二进制文件作为ELF可执行映像嵌入内核。
本实验的GNUmake
文件在obj/user/
目录中生成大量二进制映像。如果您查看kern/Makefrag
,您会注意到一些神奇之处,它们将这些二进制文件直接“链接”到内核可执行文件中,就像它们是.o
文件一样。链接器命令行上的-b
二进制选项使得这些文件被链接为“原始”未解释的二进制文件,而不是编译器生成的常规.o
文件。(就链接器而言,这些文件根本不必是ELF映像——它们可以是任何东西,例如文本文件或图片!)如果在构建内核后查看obj/kern/kernel.sym,您会注意到链接器“神奇地”生成了许多名称晦涩的有趣符号,如_binary_obj_user_hello_start
、_binary_obj_user_hello_end
和_binary_obj_user_hello_size
。链接器通过修改二进制文件的文件名来生成这些符号名,这些符号为常规内核代码提供了一种引用嵌入式二进制文件的方法。
在kern/init.c
中的i386_init()
中,您将看到在环境中运行这些二进制映像之一的代码。然而,设置用户环境的关键函数并不完整;你需要填写。
练习2:在文件env.c
中,完成以下函数的编码:
env_init()
:初始化envs
数组中的所有Env
结构,并将它们添加到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()
:启动以用户模式运行的给定环境。
在编写这些函数时,您可能会发现新的cprintf
动词%e
很有用——它会打印与错误代码相对应的描述。例如:
r = -E_NO_MEM;
panic("env_alloc: %e", r);
将panic错误消息"env_alloc: out of memory"。
答:首先查看一下/kern/init.c
的i386_init
函数
void
i386_init(void)
{
extern char edata[], end[];
// Before doing anything else, complete the ELF loading process.
// Clear the uninitialized global data (BSS) section of our program.
// This ensures that all static/global variables start out zero.
memset(edata, 0, end - edata);
// Initialize the console.
// Can't call cprintf until after we do this!
cons_init();
cprintf("6828 decimal is %o octal!\n", 6828);
// Lab 2 memory management initialization functions
mem_init();
// Lab 3 user environment initialization functions
env_init();
trap_init();
#if defined(TEST)
// Don't touch -- used by grading script!
ENV_CREATE(TEST, ENV_TYPE_USER);
#else
// Touch all you want.
ENV_CREATE(user_hello, ENV_TYPE_USER);
#endif // TEST*
// We only have one user environment for now, so just run it.
env_run(&envs[0]);
}
由lab1我们知道,entry.S
调用i386_init
函数进行各项初始化设置,在lab3中,i386_init
进行环境初始化后,切换到第0个用户环境运行相应代码。我们现在根据题目来补充用户环境设置的各个基本函数。
首先是env_init()
函数,查看它的注释
// Mark all environments in 'envs' as free, set their env_ids to 0,
// and insert them into the env_free_list.
// Make sure the environments are in the free list in the same order
// they are in the envs array (i.e., so that the first call to
// env_alloc() returns envs[0]).
// 将“envs”中的所有环境标记为空闲,将其env_id设置为0,并将其插入env_free_list。
// 确保环境在空闲列表中的顺序与envs数组中的顺序相同(即,第一次调用env_alloc()返回envs[0])。
比较简单,只需注意链表的顺序与数组的顺序是相同的。
void
env_init(void)
{
// Set up envs array
// LAB 3: Your code here.
envs[0].env_status = ENV_FREE;
envs[0].env_id = 0;
env_free_list = &envs[0];
for (int i = 1; i < NENV; ++i) {
envs[i].env_status = ENV_FREE;
envs[i].env_id = 0;
envs[i - 1].env_link = &envs[i];
}
// Per-CPU part of the initialization 初始化分段寄存器,实质上是加载GDT(全局描述符表)
env_init_percpu();
}
接下来是env_setup_vm()
,查看注释
//
// Initialize the kernel virtual memory layout for environment e.
// Allocate a page directory, set e->env_pgdir accordingly,
// and initialize the kernel portion of the new environment's address space.
// Do NOT (yet) map anything into the user portion
// of the environment's virtual address space.
//
// Returns 0 on success, < 0 on error. Errors include:
// -E_NO_MEM if page directory or table could not be allocated.
// 初始化环境e的内核虚拟内存布局。
// 分配一个页目录,相应地设置e->env_pgdir,并初始化新环境地址空间的内核部分。
// 不要将任何内容映射到环境的虚拟地址空间的用户部分。
// 成功时返回0,错误时返回 < 0。错误包括: - E_NO_MEM,如果无法分配页目录或表。
env_setup_vm()
接受参数
struct Env *e
返回设置该环境的页目录和页表,按照提升写代码如下
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; // 分配物理页错误
// Now, set e->env_pgdir and initialize the page directory.
//
// Hint:
// - The VA space of all envs is identical above UTOP
// (except at UVPT, which we've set below).
// See inc/memlayout.h for permissions and layout.
// Can you use kern_pgdir as a template? Hint: Yes.
// (Make sure you got the permissions right in Lab 2.)
// - The initial VA below UTOP is empty.
// - You do not need to make any more calls to page_alloc.
// - Note: In general, pp_ref is not maintained for
// physical pages mapped only above UTOP, but env_pgdir
// is an exception -- you need to increment env_pgdir's
// pp_ref for env_free to work correctly.
// - The functions in kern/pmap.h are handy.
// 现在,设置e->env_pgdir并初始化页面目录。
// 提示:
// - 所有env的VA空间在UTOP之上是相同的(除了UVPT,我们在下面设置)。
// 有关权限和布局,请参阅inc / memlayout.h。
// 您可以使用kern_pgdir作为模板吗?提示:是的。
// (确保您在实验室2中获得了正确的权限。)
// - UTOP下面的初始VA为空。
// - 您不需要再调用page_alloc。
// - 注意:通常,pp_ref不会为了仅映射在UTOP之上的物理页维护,但env_pgdir是一个例外——您需要增加env_pgdir的pp_ref,env_free的环境才能正常工作。
// - kern / pmap.h中的函数很方便。
// LAB 3: Your code here.
p->pp_ref++;
e->env_pgdir = page2kva(p);
memcpy(e->env_pgdir, kern_pgdir, PGSIZE);// 内核与环境的虚拟地址空间在UTOP之上是相同的(除了UVPT),而此时内核页表只设置了UTOP之上的地址空间映射,因此直接将内核页表赋值给环境页表即可。
// 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;// 设置UVPT(将页表页目录的地址设置到该处)
return 0;
接下来是region_alloc()
,查看注释
//
// Allocate len bytes of physical memory for environment env,
// and map it at virtual address va in the environment's address space.
// Does not zero or otherwise initialize the mapped pages in any way.
// Pages should be writable by user and kernel.
// Panic if any allocation attempt fails.
// 为env环境分配len字节的物理内存,并将其映射到环境地址空间中的虚拟地址va。
// 不以任何方式对映射页进行清零或初始化。
// 页面应该可以由用户和内核写入。
// 如果任何分配尝试失败,将引发panic。
region_alloc()
接受参数
struct Env *e
void *va
size_t len
为环境e
分配len
字节的物理内存,并将其映射到虚拟地址va
,根据提升编写代码
static void
region_alloc(struct Env *e, void *va, size_t len)
{
// LAB 3: Your code here.
// (But only if you need it for load_icode.)
//
// Hint: It is easier to use region_alloc if the caller can pass
// 'va' and 'len' values that are not page-aligned.
// You should round va down, and round (va + len) up.
// (Watch out for corner-cases!)
// 实验室3:这里是你的代码。
//(但仅当您需要它来load_icode时。)提示:如果调用者可以传递不对齐页面的“va”和“len”值,则使用region_alloc更容易。
// 你应该向下舍入va,向上舍入(va + len)。
// (小心角落的箱子!)
void* begin = ROUNDDOWN(va, PGSIZE),* end = ROUNDUP(va + len, PGSIZE);// 舍入保证页对齐
while (begin < end) {
struct PageInfo* temp = page_alloc(0); // 分配物理页
if (temp == NULL) {
panic("region_alloc failed\n");
}
int flag = page_insert(e->env_pgdir, temp, begin, PTE_U | PTE_W);// 该物理页插入虚拟地址
if (flag) {
panic("out of free memory\n");
}
begin += PGSIZE;
}
}
接下来是load_icode()
,查看注释
//
// Set up the initial program binary, stack, and processor flags
// for a user process.
// This function is ONLY called during kernel initialization,
// before running the first user-mode environment.
//
// This function loads all loadable segments from the ELF binary image
// into the environment's user memory, starting at the appropriate
// virtual addresses indicated in the ELF program header.
// At the same time it clears to zero any portions of these segments
// that are marked in the program header as being mapped
// but not actually present in the ELF file - i.e., the program's bss section.
//
// All this is very similar to what our boot loader does, except the boot
// loader also needs to read the code from disk. Take a look at
// boot/main.c to get ideas.
//
// Finally, this function maps one page for the program's initial stack.
//
// load_icode panics if it encounters problems.
// - How might load_icode fail? What might be wrong with the given input?
// 为用户进程设置初始程序二进制、堆栈和处理器标志。
// 此函数仅在内核初始化期间调用,然后运行第一个用户模式环境。
// 此函数将ELF二进制映像中的所有可加载段加载到环境的用户内存中,从ELF程序头中指示的适当虚拟地址开始。
// 同时,它将这些段中在程序头中标记为已映射但实际不存在于ELF文件(即程序的bss部分)的任何部分清零。
// 所有这些都与我们的引导加载程序非常相似,只是引导加载程序还需要从磁盘读取代码。请查看boot / main.c以获得想法。
// 最后,此函数为程序的初始堆栈映射一个页。
// 如果load_code遇到问题,它会恐慌。
// - load_icode如何失败?给定的输入可能有什么问题?
load_icode()
接受参数
struct Env *e
uint8_t *binary
将内存地址binary
处的ELF
二进制映像的所有可加载段加载到环境的用户内存中,并将.bss段初始化为零,最后为程序的初始堆栈映射一个物理页。查看提升,编写代码
static void
load_icode(struct Env *e, uint8_t *binary)
{
// Hints:
// Load each program segment into virtual memory
// at the address specified in the ELF segment header.
// You should only load segments with ph->p_type == ELF_PROG_LOAD.
// Each segment's virtual address can be found in ph->p_va
// and its size in memory can be found in ph->p_memsz.
// The ph->p_filesz bytes from the ELF binary, starting at
// 'binary + ph->p_offset', should be copied to virtual address
// ph->p_va. Any remaining memory bytes should be cleared to zero.
// (The ELF header should have ph->p_filesz <= ph->p_memsz.)
// Use functions from the previous lab to allocate and map pages.
//
// All page protection bits should be user read/write for now.
// ELF segments are not necessarily page-aligned, but you can
// assume for this function that no two segments will touch
// the same virtual page.
//
// You may find a function like region_alloc useful.
//
// Loading the segments is much simpler if you can move data
// directly into the virtual addresses stored in the ELF binary.
// So which page directory should be in force during
// this function?
//
// You must also do something with the program's entry point,
// to make sure that the environment starts executing there.
// What? (See env_run() and env_pop_tf() below.)
//
// 提示:将每个程序段加载到ELF段标头中指定的地址的虚拟内存中。
// 只应加载ph->p_type == ELF_PROG_load的段。
// 每个段的虚拟地址可以在ph->p_va中找到,其在内存中的大小可以在ph->p_memsz中找到。
// ELF二进制文件的ph->p_filesz字节,从“binary + ph->p_offset”开始,应复制到虚拟地址ph->p_va。任何剩余的内存字节都应清除为零。
// (ELF标头应具有ph->p_filesz <= ph->p_memsz。)使用上一个实验的函数来分配和映射页面。
// 现在,所有页面保护位都应该是用户读 / 写的。
// ELF段不一定是页面对齐的,但您可以假设没有两个段会接触同一虚拟页面。
// 您可能会发现region_alloc这样的函数很有用。
// 如果可以将数据直接移动到存储在ELF二进制文件中的虚拟地址中,那么加载段就简单得多。
// 那么,在该功能期间,哪个页面目录应该有效?
// 您还必须对程序的入口点执行一些操作,以确保环境开始在那里执行什么(请参见下面的env_run()和env_pop_tf()。)
struct Proghdr* ph, * eph; // 参考boot/main.c,设置程序头指针
struct Elf* elf = (struct Elf*)binary;
if (elf->e_magic != ELF_MAGIC) {
panic("ELF is invalid\n"); // 魔数不等于ELF_MAGIC,该ELF映像无效
}
ph = (struct Proghdr*)(binary + elf->e_phoff);
eph = ph + elf->e_phnum; // 参考boot/main.c
lcr3(PADDR(e->env_pgdir)); // 切换为用户页目录(方便加载ELF映像)
for (; ph < eph; ph++) {
if (ph->p_type != ELF_PROG_LOAD) { // 判断该段是否需要加载
continue;
}
region_alloc(e, (void *)ph->p_va, ph->p_memsz); // 分配ph->p_memsz个字节的物理内存
memset((void*)ph->p_va, 0, ph->p_memsz); // 将该段值赋0
memcpy((void*)ph->p_va, (void*)(binary + ph->p_offset), ph->p_filesz); // 将该段所有字节加载到内存中,因为有.bss段的存在,所以有ph->p_filesz <= ph->p_memsz
}
lcr3(PADDR(kern_pgdir)); // 切换回系统页目录
e->env_status = ENV_RUNNABLE;
e->env_tf.tf_eip = elf->e_entry; // 将环境的eip寄存器值设置为该程序入口点
// LAB 3: Your code here.
// Now map one page for the program's initial stack
// at virtual address USTACKTOP - PGSIZE.
//现在,将程序的初始堆栈以一个页映射到虚拟地址USTACKTOP-PGSIZE。
// LAB 3: Your code here.
region_alloc(e, (void*)(USTACKTOP - PGSIZE), PGSIZE);
}
ELF文件以一个ELF文件头开始,通过elf->e_magic
字段判断该文件是否符合ELF格式,然后通过elf->e_phoff
获取程序头距离ELF文件的偏移,ph
指向的就是程序头的起始位置,相当于一个数组,程序头记录了有哪些段需要加载,加载到哪个虚拟地址。elf->e_phnum
保存了总共有多少个段。循环加载各个段,分配从虚拟地址p_va
开始的p_memsz
个字节的物理内存。并将ELF文件中binary + ph->p_offset
偏移处的段复制到线性地址p_va
处,并将.bss
段初始化。该函数实质上是一个ELF可执行文件加载器。
接下来是env_create()
,查看注释
//
// Allocates a new env with env_alloc, loads the named elf
// binary into it with load_icode, and sets its env_type.
// This function is ONLY called during kernel initialization,
// before running the first user-mode environment.
// The new env's parent ID is set to 0.
//
// 使用env_alloc分配一个新的env,使用load_icode将命名的elf二进制文件加载到其中,并设置其env_type。
// 此函数仅在内核初始化期间调用,然后运行第一个用户模式环境。
// 新env的父ID设置为0。
env_create()
接受参数
uint8_t *binary
enum EnvType type
使用env_alloc
分配一个新的env
,使用load_icode
将命名的elf二进制文件加载到其中,并设置其为env_type
。比较简单,代码如下
void
env_create(uint8_t *binary, enum EnvType type)
{
// LAB 3: Your code here.
struct Env* e;
int flag = env_alloc(&e, 0);
if (flag) {
panic("env_alloc: %e\n", flag);
}
load_icode(e, binary);
e->env_type = type;
}
最后是env_run()
函数,查看注释
//
// Context switch from curenv to env e.
// Note: if this is the first call to env_run, curenv is NULL.
//
// This function does not return.
// 上下文从curenv切换到env e。
// 注意:如果这是第一次调用env_run,则curenv为NULL。
// 此函数不返回。
根据提示编写代码,比较简单
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.
// 步骤1:如果这是一个上下文切换(一个新环境正在运行):
// 1.如果当前环境为ENV_RUNNING(请考虑它可能处于什么其他状态),则将当前环境(如果有)设置回ENV_RUNNABLE,
// 2.将“curenv”设置为新环境
// 3.将其状态设置为ENV_RUNNING
// 4.更新其“env_runs”计数器
// 5.使用lcr3()切换到其地址空间。
// 步骤2:使用env_pop_tf()恢复环境的寄存器,并在环境中进入用户模式。
//
// Hint: This function loads the new environment's state from
// e->env_tf. Go back through the code you wrote above
// and make sure you have set the relevant parts of
// e->env_tf to sensible values.
//提示:此函数从e->env_tf加载新环境的状态。回顾上面编写的代码,确保已将e->env_tf的相关部分设置为合理的值。
// LAB 3: Your code here.
if (curenv != NULL && curenv->env_status == ENV_RUNNING) {
curenv->env_status = ENV_RUNNABLE;
}
curenv = e;
e->env_status = ENV_RUNNING;
e->env_runs++;
lcr3(PADDR(e->env_pgdir));
env_pop_tf(&e->env_tf);
}
这里我们重点看一下env_pop_tf
函数
struct PushRegs {
/* registers as pushed by pusha */
uint32_t reg_edi;
uint32_t reg_esi;
uint32_t reg_ebp;
uint32_t reg_oesp; /* Useless */
uint32_t reg_ebx;
uint32_t reg_edx;
uint32_t reg_ecx;
uint32_t reg_eax;
} __attribute__((packed));
struct Trapframe {
struct PushRegs tf_regs;
uint16_t tf_es;
uint16_t tf_padding1;
uint16_t tf_ds;
uint16_t tf_padding2;
uint32_t tf_trapno;
/* below here defined by x86 hardware */
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding3;
uint32_t tf_eflags;
/* below here only when crossing rings, such as from user to kernel */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding4;
} __attribute__((packed));
//
// Restores the register values in the Trapframe with the 'iret' instruction.
// This exits the kernel and starts executing some environment's code.
//
// This function does not return.
//
void
env_pop_tf(struct Trapframe *tf)
{
asm volatile(
"\tmovl %0,%%esp\n" // 将tf赋值个esp寄存器
"\tpopal\n" // 弹出8个通用寄存器的值,即结构体中PushRegs寄存器的值
"\tpopl %%es\n" // 弹出寄存器es的值
"\tpopl %%ds\n" // 弹出寄存器ds的值
"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */ // 跳过tf_trapno与tf_err
"\tiret\n" // 该指令是中断返回指令,具体动作如下:从Trapframe结构中依次弹出tf_eip,tf_cs,tf_eflags,tf_esp,tf_ss到相应寄存器。
: : "g" (tf) : "memory");
// 此时可以看到,弹出的值与Trapframe结构体从上到下完全一致
panic("iret failed"); /* mostly to placate the compiler */
}
此时,以下各个函数的调用目的也就不言而喻了
`start` (`kern/entry.S`)
`i386_init` (`kern/init.c`) 初始化设置
`cons_init` 打印一些信息
`mem_init` 内存基本设置
`env_init` 环境基本设置
`trap_init` (still incomplete at this point) 中断设置(此时不完整)
`env_create` 将ELF可执行文件加载到用户内存
`env_run` 切换到用户环境
`env_pop_tf` 切换上下文,并运行用户代码
完成后,应该编译内核并在qemu下运行它。如果一切顺利,系统应该进入用户空间并执行hello二进制文件,直到它使用int指令进行系统调用。此时会出现错误,因为JOS没有设置硬件来允许从用户空间到内核的任何转换。当CPU发现它没有设置好处理这个系统调用中断时,它将生成一个通用保护异常,发现它无法处理,生成一个双重故障异常,发现自己也无法处理,最后放弃,这就是所谓的"三重故障"。通常,您会看到CPU重置和系统重新启动。虽然这对遗留应用程序很重要(博客),但这对内核开发来说是一个难题,因此使用6.828补丁的QEMU,您将看到一个寄存器转储和一条“三重错误”消息。如下图所示
我们将很快解决这个问题,但现在我们可以使用调试器检查是否进入用户模式。使用make qemu-gdb
并在env_pop_tf
处设置一个gdb断点,这应该是在实际进入用户模式之前最后一个命中的函数。使用si
单步执行此函数;处理器应在iret
指令之后进入用户模式。然后,您应该在用户环境的可执行文件中看到第一条指令,这是lib/entry.s
中标签开头的cmpl
指令。现在使用b *0x...
在hello中sys_cputs()
中的int$0x30
处设置断点(有关用户空间地址,请参见obj/user/hello.asm
)。此int
是向控制台显示字符的系统调用。如果不能执行到int,那么地址空间设置或程序加载代码有问题;返回并修复它,然后继续。
调试过程如下:
The target architecture is set to "i8086".
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb) b env_pop_tf // 在env_pop_tf处设置断点
Breakpoint 1 at 0xf0103840: file kern/env.c, line 522.
(gdb) c
Continuing.
The target architecture is set to "i386".
=> 0xf0103840 <env_pop_tf>: push %ebp
Breakpoint 1, env_pop_tf (tf=0xf01c3000) at kern/env.c:522
522 {
(gdb) si
=> 0xf0103841 <env_pop_tf+1>: mov %esp,%ebp
0xf0103841 522 {
(gdb) si
=> 0xf0103843 <env_pop_tf+3>: push %ebx
0xf0103843 522 {
(gdb) si
=> 0xf0103844 <env_pop_tf+4>: sub $0x8,%esp
0xf0103844 522 {
(gdb) si
=> 0xf0103847 <env_pop_tf+7>: call 0xf0100167 <__x86.get_pc_thunk.bx>
0xf0103847 522 {
(gdb) si
=> 0xf0100167 <__x86.get_pc_thunk.bx>: mov (%esp),%ebx
0xf0100167 in __x86.get_pc_thunk.bx ()
(gdb) si
=> 0xf010016a <__x86.get_pc_thunk.bx+3>: ret
0xf010016a in __x86.get_pc_thunk.bx ()
(gdb) si
=> 0xf010384c <env_pop_tf+12>: add $0x7b01c,%ebx
0xf010384c in env_pop_tf (tf=0xf01c3000) at kern/env.c:522
522 {
(gdb) si
=> 0xf0103852 <env_pop_tf+18>: mov 0x8(%ebp),%esp
523 asm volatile(
(gdb) si
=> 0xf0103855 <env_pop_tf+21>: popa
0xf0103855 523 asm volatile(
(gdb) si
=> 0xf0103856 <env_pop_tf+22>: pop %es
0xf0103856 in env_pop_tf (tf=<error reading variable: Unknown argument list address for `tf'.>) at kern/env.c:523
523 asm volatile(
(gdb) si
=> 0xf0103857 <env_pop_tf+23>: pop %ds
0xf0103857 523 asm volatile(
(gdb) si
=> 0xf0103858 <env_pop_tf+24>: add $0x8,%esp
0xf0103858 523 asm volatile(
(gdb) si
=> 0xf010385b <env_pop_tf+27>: iret
0xf010385b 523 asm volatile(
(gdb) info registers // 执行iret前,查看寄存器的值
eax 0x0 0
ecx 0x0 0
edx 0x0 0
ebx 0x0 0
esp 0xf01c3030 0xf01c3030
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0xf010385b 0xf010385b <env_pop_tf+27>
eflags 0x96 [ PF AF SF ]
cs 0x8 8 // 内核代码段的段选择子的值是0x8
ss 0x10 16
ds 0x23 35
es 0x23 35
fs 0x23 35
gs 0x23 35
(gdb) si
=> 0x800020: cmp $0xeebfe000,%esp
0x00800020 in ?? ()
(gdb) info registers // 执行iret后,查看寄存器的值
eax 0x0 0
ecx 0x0 0
edx 0x0 0
ebx 0x0 0
esp 0xeebfe000 0xeebfe000
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0x800020 0x800020
eflags 0x2 [ ]
cs 0x1b 27 // 用户代码段的段选择子是0x18(权限位为3)
ss 0x23 35
ds 0x23 35
es 0x23 35
fs 0x23 35
gs 0x23 35
(gdb) b *0x00800b56
Breakpoint 2 at 0x800b56
(gdb) c
Continuing.
=> 0x800b56: int $0x30 // 执行到系统调用(目前还不能用)
Breakpoint 2, 0x00800b56 in ?? ()
(gdb)
可以看到已经执行到了系统调用int$0x30
指令,说明我们上面写的代码是正确的。
值得一提的是,cs
寄存器中存放代码段选择子,ds,es,fs,ss,gs
寄存器存放数据段选择子。根据inc/memlayout.h
所定义的全局段描述符表的编号
// Global descriptor numbers
#define GD_KT 0x08 // kernel text
#define GD_KD 0x10 // kernel data
#define GD_UT 0x18 // user text
#define GD_UD 0x20 // user data
#define GD_TSS0 0x28 // Task segment selector for CPU 0
参考全局段描述符表段选择子的结构,可以轻松得出全局段描述符表的结构
编号 | 内容 |
---|---|
0 | 0(Intel文档要求,第一项必须为0) |
1 | 内核代码段 |
2 | 内核数据段 |
3 | 用户代码段 |
4 | 用户数据段 |
5 | CPU 的任务段选择子 |
因此在未执行iret
指令前,cs
寄存器还未更新,存储内核代码段的段选择子的值,因为内核权限是0,所以其值为0x08
。执行指令后,cs
寄存器的值更新为用户代码段的段选择子的值,因为用户权限是3,所以其值为0x1b
(0x18 | 3
)。用户环境的段选择子的值在我们上面编写的代码中并未涉及,其实是在env_alloc()
函数中设置好的
//
// Allocates and initializes a new environment.
// On success, the new environment is stored in *newenv_store.
//
// Returns 0 on success, < 0 on failure. Errors include:
// -E_NO_FREE_ENV if all NENV environments are allocated
// -E_NO_MEM on memory exhaustion
//
//分配并初始化新环境。
//成功后,新环境存储在* newenv_store中。
//成功时返回0,失败时返回 < 0。错误包括: - E_NO_FREE_ENV(如果分配了所有NENV环境) - E_NO_MEM(内存耗尽)
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;
// 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));
// 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; // 设置esp的值
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("[%08x] new env %08x\n", curenv ? curenv->env_id : 0, e->env_id);
return 0;
}
1.4 处理中断和异常
此时,用户空间中的第一个int $0x30
系统调用指令是死路一条:一旦处理器进入用户模式,就无法返回。现在需要实现基本的异常和系统调用处理,这样内核就可以从用户模式代码中恢复对处理器的控制。您应该做的第一件事是彻底熟悉x86中断和异常机制。
练习3:如果您还没有熟悉,阅读 80386 程序员手册(或 IA-32 开发人员手册的 第 5 章) 中的 第 9 章异常和中断。
答:可参考CPU中断及中断处理过程,陷入,中断和驱动程序,中断描述符表一起阅读
在本实验中,我们通常遵循 Intel 的中断、异常等术语。然而,异常、陷阱、中断、故障和中止等术语在不同架构或操作系统之间没有标准含义,并且在使用时经常不考虑它们在特定架构(如 x86)上的细微差别。当您在本实验室之外看到这些术语时,其含义可能会略有不同。
1.5 受保护控制转移的基础知识
异常和中断都是“受保护的控制转移”,这会导致处理器从用户模式切换到内核模式(CPL=0),而不会给用户模式代码任何干扰内核或其他环境功能的机会。在英特尔的术语中,中断是一种受保护的控制转移,通常由处理器外部的异步事件引起,例如外部设备I/O活动的通知。相反,异常是由当前运行的代码同步导致的受保护的控制转移,例如,由于除以0或者访问无效的内存。
为了确保这些受保护的控制传输实际上受到保护,处理器的中断/异常机制被设计为,当中断或异常发生时,当前运行的代码不能够任意选择内核进入的位置或方式。相反,处理器确保只有在精心控制的条件下才能进入内核。在x86上,两种机制协同工作以提供这种保护:
- 中断描述符表:处理器确保中断和异常只能导致内核在由内核自身确定的几个特定的、定义良好的入口点进入,而不是由执行中断或异常时运行的代码。
x86允许多达256个不同的中断或异常进入内核,每个都有不同的中断向量。中断向量是介于0和255之间的数字。中断向量由中断的来源决定:不同的设备、错误条件和对内核的应用程序请求生成具有不同向量的中断。CPU使用向量作为处理器中断描述符表(IDT)的索引,内核在内核专用内存中设置该表,与GDT非常相似。处理器从该表中的相应条目中:
- 加载到指令指针寄存器(EIP)中的值,指向指定用于处理该类型异常的内核代码。
- 加载到代码段选择子寄存器(CS)中的值,该值在位0-1中包括异常处理程序要运行的特权级别。(在JOS中,所有异常都以内核模式处理,特权级别为0。)
- 任务状态段:处理器需要一个地方来保存中断或异常发生之前的旧处理器状态,例如处理器调用异常处理程序之前EIP和CS的原始值,以便异常处理程序稍后可以恢复旧状态,并从中断代码停止的位置恢复中断代码。但旧处理器状态的保存区域必须反过来受到保护,不受非特权用户模式代码的影响;否则,错误或恶意的用户代码可能会危及内核。
因此,当x86处理器执行中断或陷阱,导致特权级别从用户模式更改为内核模式时,它也会切换到内核内存中的堆栈。名为任务状态段(TSS)的结构指定了段选择器和此堆栈所在的地址。处理器(在此新堆栈上)push SS、ESP、EFLAGS、CS、EIP和可选错误代码。然后,它从中断描述符加载CS和EIP,并设置ESP和SS以引用新堆栈。
虽然TSS很大,可能有多种用途,但JOS仅使用它来定义处理器在从用户模式转换到内核模式时应切换到的内核堆栈。由于JOS中的“内核模式”是x86上的特权级别0,因此处理器在进入内核模式时使用TSS的ESP0和SS0字段来定义内核堆栈。JOS不使用任何其他TSS字段。
1.6 异常和中断类型
x86处理器可以在内部生成的所有同步异常都使用0到31之间的中断向量,因此映射到IDT条目0-31。例如,页错误总是通过向量14导致异常。大于31的中断向量仅用于软件中断,该中断可以由int指令生成,也可以由外部设备在需要注意时引起的异步硬件中断生成。
在本节中,我们将扩展JOS以处理向量0-31中内部生成的x86异常。在下一节中,我们将使JOS处理软件中断向量48(0x30),JOS(相当任意地)将其用作其系统调用中断向量。在lab4中,我们将扩展JOS以处理外部生成的硬件中断,如时钟中断。
1.7 一个例子
让我们将这些片段放在一起,并通过一个示例进行跟踪。假设处理器在用户环境中执行代码,遇到一个试图除以零的除法指令。
- cpu切换到由TSS的SS0和ESP0字段定义的堆栈,在JOS中将分别保存为值GD_KD和KSTACKTOP。
- cpu在内核堆栈上push异常参数,从地址KSTACKTOP开始:
+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20 <---- ESP
+--------------------+
- 因为我们正在处理一个除法错误,即x86上的中断向量0,所以cpu读取IDT条目0,并将CS:EIP设置为指向条目描述的处理程序函数。
- 处理程序函数控制并处理异常,例如终止用户环境。
对于某些类型的x86异常,除了上面的“标准”五个字之外,cpu还会将另一个包含错误代码的字push到堆栈上。第14个页错误异常就是一个重要的例子。请参阅80386手册,以确定处理器推送错误代码的异常编号,以及在这种情况下错误代码的含义。当处理器push错误代码时,当从用户模式进入时,在异常处理程序的开头,堆栈将如下所示:
+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20
| error code | " - 24 <---- ESP
+--------------------+
1.8 嵌套异常和中断
cpu可以从内核模式和用户模式接受异常和中断。然而,只有当从用户模式进入内核时,x86 处理器才会自动切换堆栈,然后将其旧寄存器状态压入堆栈并通过 IDT 调用适当的异常处理程序。如果在中断或异常发生时cpu已经处于内核模式( CS寄存器的低 2 位已经为零),则 cpu只是将寄存器值压入同一个内核堆栈。这样,内核就可以优雅地处理由内核自身代码引起的嵌套异常。此功能是实现保护的重要工具,我们将在稍后的系统调用部分中看到。
如果cpu已经处于内核模式并发生嵌套异常,因为它不需要切换堆栈,所以它不会保存旧的SS或ESP寄存器。对于不push错误代码的异常类型,因此内核堆栈在进入异常处理程序时如下所示:
+--------------------+ <---- old ESP
| old EFLAGS | " - 4
| 0x00000 | old CS | " - 8
| old EIP | " - 12
+--------------------+
对于push错误代码的异常类型,处理器会像以前一样,在old EIP
之后立即推送错误码。
cpu的嵌套异常功能有一个重要的警告。如果cpu在已经处于内核模式时发生异常,并且由于任何原因(如缺少堆栈空间)无法将其旧状态push到内核堆栈,那么cpu无法进行任何恢复,因此它只需重置自身。无需多言,内核的设计应该确保不会发生这种情况。
1.9 设置IDT
现在,我们应该拥有设置IDT和处理JOS中的异常所需的基本信息。现在,我们将设置IDT来处理中断向量0-31(处理器异常)。我们将在本实验中稍后处理系统调用中断,并在稍后的实验室中添加中断32-47(设备IRQs)。
头文件inc/trap.h
和kern/trap.h
包含与中断和异常相关的重要定义,我们需要熟悉这些定义。文件kern/trap.h
包含对内核严格私有的定义,而inc/trap.h
包含对用户级程序和库也有用的定义。
注意:0-31范围内的某些异常由Intel定义为保留。由于它们永远不会由处理器生成,因此如何处理它们并不重要。做你认为最干净的事。
您应该实现的总体控制流程如下所示:
IDT trapentry.S trap.c
+----------------+
| &handler1 |---------> handler1: trap (struct Trapframe *tf)
| | // do stuff {
| | call trap // handle the exception/interrupt
| | // ... }
+----------------+
| &handler2 |--------> handler2:
| | // do stuff
| | call trap
| | // ...
+----------------+
.
.
.
+----------------+
| &handlerX |--------> handlerX:
| | // do stuff
| | call trap
| | // ...
+----------------+
每个异常或中断都应该在trapentry.S
中有自己的处理程序,trap_init()
应该使用这些处理程序的地址初始化IDT。每个处理程序都应该在堆栈上构建一个结构Trapframe
(请参阅inc/trap.h
),并使用指向Trapframe
的指针调用trap()
(在trap.c
中)。trap()
然后处理异常/中断或分派给特定的处理程序函数。
练习4:编辑trapentry.S
和trap.c
并实现上述功能。trapentry.S
中的宏TRAPHANDLER
和TRAPHANDER_NOEC
以及inc/trap.h
中的T_*
定义会对您有所帮助。您需要在trapentry.S
中为inc/trap.h
中定义的每个陷阱添加一个入口点(使用这些宏),并且必须提供TRAPHANDLER
宏引用的_alltraps
。您还需要修改trap_init()
来初始化idt
,以指向trapentry.S
;SETGATE
宏在这里很有用。
你的_alltraps
应该:
- push值以使堆栈看起来像结构体Trapframe
- 将GD_KD加载到%ds和%es中
- pushl %esp将指向Trapframe的指针作为参数传递给trap()
- call trap(trap 还会返回吗?)
考虑使用pushal指令;它非常适合Trapframe结构的布局。
在进行任何系统调用之前,使用用户目录中导致异常的一些测试程序(如user/divzero)测试陷阱处理代码。此时,您应该能够在divzero、softint和baddegment测试中取得成功。
答:首先我们查看trapentry.S
文件,该文件首先定义了两个宏
#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
#define TRAPHANDLER_NOEC(name, num) \
.globl name; \
.type name, @function; \
.align 2; \
name: \
pushl $0; \
pushl $(num); \
jmp _alltraps
这两个宏TRAPHANDLER
与TRAPHANDER_NOEC
分别用于处理有错误代码的中断与无错误代码的中断,区别在于是否push 0填充tf_err
字段,并将中断向量号填入tf_trapno
字段,然后跳转到_alltraps
函数。因此到目前为止,cpu已经将tf_ss,tf_err,tf_esp,tf_trapno,tf_eflags,tf_cs,tf_eip,tf_err
入栈,TRAPHANDLER_NOEC
及TRAPHANDLER
将tf_trapno
入栈,我们在_alltraps
函数中将tf_ds,tf_es,以及通用寄存器的值压入栈中,使其顺序满足Trapframe
结构体内的字段顺序
.text
/*
* Lab 3: Your code here for generating entry points for the different traps.
*/
TRAPHANDLER_NOEC(th0, T_DIVIDE) // 0 #DE 除 0 异常
TRAPHANDLER_NOEC(th1, T_DEBUG) // 1 #DB 调试异常
TRAPHANDLER_NOEC(th2, T_NMI) // 2 NMI
TRAPHANDLER_NOEC(th3, T_BRKPT) // 3 BP 断点异常
TRAPHANDLER_NOEC(th4, T_OFLOW) // 4 #OF 溢出
TRAPHANDLER_NOEC(th5, T_BOUND) // 5 #BR 对数组的引用超出边界
TRAPHANDLER_NOEC(th6, T_ILLOP) // 6 #UD 无效或未定义的操作码
TRAPHANDLER_NOEC(th7, T_DEVICE) // 7 #NM 设备不可用(无数学协处理器)
TRAPHANDLER(th8, T_DBLFLT) // 8 #DF 双重故障(有错误代码)
TRAPHANDLER(th10, T_TSS) // 10 #TS 无效TSS(有错误代码)
TRAPHANDLER(th11, T_SEGNP) // 11 #NP 段不存在(有错误代码)
TRAPHANDLER(th12, T_STACK) // 12 #SS 栈错误(有错误代码)
TRAPHANDLER(th13, T_GPFLT) // 13 #GP 常规保护(有错误代码)
TRAPHANDLER(th14, T_PGFLT) // 14 #PF 页故障(有错误代码)
TRAPHANDLER_NOEC(th16, T_FPERR) // 16 #MF 浮点处理单元错误
TRAPHANDLER_NOEC(th17, T_ALIGN) // 17 #AC 对齐检查
TRAPHANDLER_NOEC(th18, T_MCHK) // 18 #MC 机器检查
TRAPHANDLER_NOEC(th19, T_SIMDERR) // 19 #XM SIMD(单指令多数据)浮点异常
/*
* Lab 3: Your code here for _alltraps
*/
_alltraps: // cpu已经将tf_ss,tf_err,tf_esp,tf_trapno,tf_eflags,tf_cs,tf_eip,tf_err入栈,TRAPHANDLER_NOEC及TRAPHANDLER将tf_trapno入栈,因此压入tf_ds,tf_es,以及通用寄存器即可
pushl %ds // pushl 压入一个双字(4个字节)
pushl %es
pushal
pushl $GD_KD
popl %ds
pushl $GD_KD
popl %es // 这里不知道为什么不能直接使用movl?还没找到原因
pushl %esp // 压入trap()的参数tf,%esp指向Trapframe结构体
call trap
此时的栈结构
符合Trapframe
结构体的字段顺序。
接下来我们来初始化中断描述符表,即补充trap_init
函数。我们可以利用inc/mmu.h
中的SETGATE
宏来初始化中断描述符表
#define SETGATE(gate, istrap, sel, off, dpl) \
{ \
(gate).gd_off_15_0 = (uint32_t) (off) & 0xffff; \
(gate).gd_sel = (sel); \
(gate).gd_args = 0; \
(gate).gd_rsv1 = 0; \
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).gd_s = 0; \
(gate).gd_dpl = (dpl); \
(gate).gd_p = 1; \
(gate).gd_off_31_16 = (uint32_t) (off) >> 16; \
}
宏参数从左到右依次为中断描述符结构体(门结构体)gate
,是否为陷阱istrap
,所属段选择子sel
,中断处理程序在代码段中的偏移量off
,权限标志dpl
(要满足:old cs cpl <= 中断描述符dpl),因此编写代码如下所示
void
trap_init(void)
{
extern struct Segdesc gdt[];
// LAB 3: Your code here.
extern void th0();
extern void th1();
extern void th3();
extern void th4();
extern void th5();
extern void th6();
extern void th7();
extern void th8();
extern void th10();
extern void th11();
extern void th12();
extern void th13();
extern void th14();
extern void th16();
extern void th17();
extern void th18();
extern void th19();
SETGATE(idt[0], 0, GD_KT, th0, 0); //格式如下:SETGATE(gate, istrap, sel, off, dpl),定义在inc/mmu.h中
SETGATE(idt[1], 0, GD_KT, th1, 0); //设置idt[1],0-31被用来处理异常事件(非陷阱,即系统调用),段选择子为内核代码段,段内偏移为th1,dpl设置为0(防止运行在用户态的代码调用该中断处理程序)
SETGATE(idt[3], 0, GD_KT, th3, 3);
SETGATE(idt[4], 0, GD_KT, th4, 0);
SETGATE(idt[5], 0, GD_KT, th5, 0);
SETGATE(idt[6], 0, GD_KT, th6, 0);
SETGATE(idt[7], 0, GD_KT, th7, 0);
SETGATE(idt[8], 0, GD_KT, th8, 0);
SETGATE(idt[10], 0, GD_KT, th10, 0);
SETGATE(idt[11], 0, GD_KT, th11, 0);
SETGATE(idt[12], 0, GD_KT, th12, 0);
SETGATE(idt[13], 0, GD_KT, th13, 0);
SETGATE(idt[14], 0, GD_KT, th14, 0);
SETGATE(idt[16], 0, GD_KT, th16, 0);
SETGATE(idt[17], 0, GD_KT, th17, 0);
SETGATE(idt[18], 0, GD_KT, th18, 0);
SETGATE(idt[19], 0, GD_KT, th19, 0);
// Per-CPU setup
trap_init_percpu();
}
trap_init
会在内核初始化时由i386_init()
函数调用。trap_init_percpu()中的lidt(&idt_pd);
后加载该中断描述符表。
PART B:缺页错误、断点异常和系统调用
现在我们的内核有了最基本的中断处理能力,我们还会进一步的对它升级使得它能够提供依赖于异常处理的重要的操作系统原语。。
2.1 处理缺页错误
缺页错误,第14号中断(T_PGFLT
),是一个非常重要的中断贯穿在lab3以lab4。缺页中断的中断号是14,发生时引发缺页中断的线性地址将会被存储到CR2
寄存器中。
在kern/trap.c
中已经提供了一个基本的函数page_fault_handler
用于处理page fault
。
练习5:修改trap_dispatch()
将page fault
转发到page_fault_handler()
中断处理函数。运行make grade
,应该可以通过faultread
,faultreadkernel
,faultwrite
和faultwritekernel
。我们可以将JOS
加载到一个特定的程序用命令make run-x
,比如说make run-hello
来运行hello这个用户程序。
答:比较简单,不赘述了
if (tf->tf_trapno == T_PGFLT) {
page_fault_handler(tf);
return;
}
2.2 断点异常
breakpoint excepiton是三号中断(T_BRKPT
),debugger通常用它来暂时替代程序代码。在JOS中,我们使用JOS kernel monitor来暂时替代这个。
练习6:修改trap_dispatch()
的代码,让它能够通过调用monitor
来处理breakpoint exception
。如果运行make grade
,通过breakpoint
测试点
答:很简单,不赘述了
if (tf->tf_trapno == T_BRKPT) {
monitor(tf);
return;
}
2.3 系统调用
用户进程通过系统调用(system call
)来请求内核来做些事。当用户进程调用系统调用的时候,处理器进入了内核模式,内核和处理器共同来保存用户程序的状态(上下文),内核执行代码来完成系统调用然后返回用户程序。用户进程如何吸引内核的注意以及如何指定系统调用会不同。
在JOS中,我们将会使用int
指令,它会造成处理器中断。我们使用int 0x30
来调用系统调用。你需要在IDT中设置好来使得用户进程调用。
程序使用寄存器来传递它所需要的是系统调用号以及参数。这样一来,内核不需要访问用户栈。system call number
(系统调用号)放在eax寄存器当中,然后参数(最多只有五个)会放在edx,ecx,ebx,edi,esi
寄存器当中。内核将会把返回值放在eax寄存器当中。调用system call
的汇编代码在lib/syscall.c
的syscall()
当中。
练习7:为T_SYSCALL
增加处理函数。你需要修改kern/trapentry.S
和kern/trap.c
中的trap_init()
。同样的还需要修改下trap_dispatch()
让它能够通过调用kern/syscall.c
中的syscall()
来处理中断,同时还需要弄好返回值以及各个参数。最后你还需要实现kern/syscall.c
中的syscall()
函数。确保当system call number
无效的时候返回-E_INVAL
你应该阅读并且理解lib/syscall.c
中的代码,尤其是内联汇编代码。
并且还要处理列在inc/syscall.h
中的system call
。
运行hello
程序,此时应该可以打印出hello world
,并且运行make grade
,此时应该已经通过了testbss
测试点。
答:首先我们在kern/trapentry.S
与kern/trap.c
的trap_init()
中添加以下代码
TRAPHANDLER_NOEC(th_syscall,T_SYSCALL)
extern void th_syscall();
SETGATE(idt[T_SYSCALL], 0, GD_KT, th_syscall, 3); // DPL设置为3的原因是,中断过程的顺利完成需要将用户环境寄存器cs中的CPL(当前环境权限级别)<= 中断描述符的DPL
这样,就将系统调用的中断向量号0x30
(T_SYSCALL = 48
)加载到中断描述符表中。接下来修改kern/trap.c
的trap_dispatch()
函数,按照要求,用户程序使用寄存器来传递它所需要的是系统调用号以及参数,system call number
(系统调用号)放在eax寄存器当中,然后参数会放在edx,ecx,ebx,edi,esi
寄存器当中,而这些寄存器的值保存在tf
指向的Trapframe
结构体,在运行完成时,将运行结果存入eax
寄存器中。因此编写代码
if (tf->tf_trapno == T_SYSCALL) {
uint32_t result = 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 = result;
return;
}
接下来查看kern/syscall.c
的syscall()
函数,该函数的结构很简单,就是对于对应的调用号调用对应的函数去处理。这里初看会让人摸不着头脑,因为我们不知道哪个系统调用号对应哪个调用函数,也不知道参数该如何传递给对应函数。因此我们从用户程序入手,首先查看user/hello.c
// hello, world
#include <inc/lib.h>
void
umain(int argc, char **argv)
{
cprintf("hello, world\n");
cprintf("i am environment %08x\n", thisenv->env_id);
}
该函数很简单,就是调用cprintf
打印hello, world
与i am environment $(env_id)
字符串,注意user/hello.c
的头文件是<inc/lib.h>
,通过查看该头文件我们知道user/hello.c
调用的是lib/printf.c
(毕竟用户程序不能直接调用内核函数),因此我们查看lib/printf.c
Implementation of cprintf console output for user environments,
// based on printfmt() and the sys_cputs() system call.
//
// cprintf is a debugging statement, not a generic output statement.
// It is very important that it always go to the console, especially when
// debugging file descriptor code!
#include <inc/types.h>
#include <inc/stdio.h>
#include <inc/stdarg.h>
#include <inc/lib.h>
// Collect up to 256 characters into a buffer
// and perform ONE system call to print all of them,
// in order to make the lines output to the console atomic
// and prevent interrupts from causing context switches
// in the middle of a console output line and such.
struct printbuf {
int idx; // current buffer index
int cnt; // total bytes printed so far
char buf[256];
};
static void
putch(int ch, struct printbuf *b)
{
b->buf[b->idx++] = ch;
if (b->idx == 256-1) {
sys_cputs(b->buf, b->idx);
b->idx = 0;
}
b->cnt++;
}
int
vcprintf(const char *fmt, va_list ap)
{
struct printbuf b;
b.idx = 0;
b.cnt = 0;
vprintfmt((void*)putch, &b, fmt, ap);
sys_cputs(b.buf, b.idx);
return b.cnt;
}
int
cprintf(const char *fmt, ...)
{
va_list ap;
int cnt;
va_start(ap, fmt);
cnt = vcprintf(fmt, ap);
va_end(ap);
return cnt;
}
可以看到,函数调用链为cprintf->vcprintf->sys_cputs
,sys_cputs
函数在lib/syscall.c
中,查看
// System call stubs.
#include <inc/syscall.h>
#include <inc/lib.h>
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;
}
void
sys_cputs(const char *s, size_t len)
{
syscall(SYS_cputs, 0, (uint32_t)s, len, 0, 0, 0);
}
int
sys_cgetc(void)
{
return syscall(SYS_cgetc, 0, 0, 0, 0, 0, 0);
}
int
sys_env_destroy(envid_t envid)
{
return syscall(SYS_env_destroy, 1, envid, 0, 0, 0, 0);
}
envid_t
sys_getenvid(void)
{
return syscall(SYS_getenvid, 0, 0, 0, 0, 0, 0);
}
可以看到sys_cputs
函数调用syscall
函数,并将要打印的字符串的首地址与字符串大小作为a1
与a2
参数传递给它,然后使用一个内联汇编指令int
启动cpu中断过程。在这里,我们也找到了其他系统调用号以及它们对应的函数。因此修改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.
// 调用与“syscallno”参数对应的函数。返回任何适当的返回值。
// LAB 3: Your code here.
int32_t temp;
switch (syscallno) {
case SYS_cputs:
sys_cputs((char*)a1, (size_t)a2);
temp = 0;
break;
case SYS_cgetc:
temp = sys_cgetc();
break;
case SYS_getenvid:
temp = sys_getenvid();
break;
case SYS_env_destroy:
temp = sys_env_destroy((envid_t)a1);
break;
default:
return -E_INVAL;
}
return temp;
}
至此,我们以hello用户程序为例回顾一下调用过程,hello
调用lib/printf.c
中的cprintf
函数在终端输出字符串,cprintf
函数调用vcprintf
函数,vcprintf
函数以字符串首指针和字符串长度为参数调用lib/syscall.c
的sys_cputs
函数,该函数以这两个参数调用sys_cputs
函数。sys_cputs
调用syscall
(lib/syscall.c
中的)函数,该函数使用内联汇编指令开启中断过程,将system call number
放在eax寄存器当中,然后将参数按顺序放在edx,ecx,ebx,edi,esi
寄存器当中,然后按照上面所述的步骤将用户环境的上下文压入栈中。因此系统调用号SYS_cputs
保存在eax
寄存器当中,字符串的首地址与字符串长度分别保存在edx
寄存器与ecx
中。随后根据中断向量号在中断描述符表中找到中断处理程序的地址,运行中断处理程序。中断处理程序首先调用trap
,将刚刚保存在栈中的环境上下文复制到当前环境curenv
的env_tf
结构体中去,随后调用trap_dispatch
函数,该函数以不同的中断向量号调用不同的函数,对于系统调用(48),调用syscall
函数(kern/syscall.c
中的那个),syscall
根据系统调用号调用对应的函数进行处理并返回。因此,sys_cputs
的系统调用号对应调用sys_cputs
函数(kern/syscall.c
中的那个),该函数调用cprintf
(kern/printf.c
中的)向终端输出字符串。最后,trap
函数调用env_run(curenv)
继续运行当前环境运行。注意到,中断处理的整个过程中,我们并未切换页目录和页表(也就是说此时仍是中断处理前运行环境的虚拟地址空间),这是因为所有环境的页目录的内核部分的虚拟地址与物理地址的映射是相同的,因此我们使用用户环境的页目录和页表也能访问内核栈空间,并且运行在内核的中断处理程序也可以通过字符串地址直接访问字符串。
2.4 用户模式启动
一个用户程序开始在lib/entry.S
的顶部运行。经过一些设置后,这段代码调用lib/libmain.c
中的libmain()
。您应该修改libmain()
(请注意,lib/entry.S
已经定义了env
以指向您在partA中设置的UENVS
映射。)提示:在inc/env.h
中查找并且使用sys_getenvid
。
libmain()
然后调用umain
,在hello程序的情况下,umain
位于user/hello.c
中。请注意,在打印“hello,world”后,它会尝试访问thienv->env_id
。这就是它早些时候出现故障的原因。现在您已经正确初始化了thienv
,它应该不会出错。如果它仍然存在故障,您可能还没有映射用户可读的UENVS
区域(回到pmap.c中的A部分;这是我们第一次实际使用UENVS区域)。
练习8:将所需代码添加到用户库,然后启动内核。您应该看到user/hello
打印“hello,world”,然后打印“i am environment 00001000”。user/hello
然后尝试通过调用sys_env_destroy()
来“退出”(请参见lib/libmain.c
和lib/exit.c
)。由于内核当前只支持一个用户环境,因此它应该报告它已经破坏了唯一的环境,然后将其放入内核监视器。你应该能够取得好成绩,才能在hello考试中取得成功。
答:代码逻辑很简单,难点在于通过返回的当前环境env_id
找到当前环境的指针,好在lib/entry.S
中定义了一个envs
符号指向映射UENVS
也就是环境数组的物理地址的虚拟地址,并且查看inc/env.h
,里面定义了env_id
到环境数组索引的方法。
// An environment ID 'envid_t' has three parts:
//
// +1+---------------21-----------------+--------10--------+
// |0| Uniqueifier | Environment |
// | | | Index |
// +------------------------------------+------------------+
// \--- ENVX(eid) --/
//
// The environment index ENVX(eid) equals the environment's index in the
// 'envs[]' array. The uniqueifier distinguishes environments that were
// created at different times, but share the same environment index.
//
// All real environments are greater than 0 (so the sign bit is zero).
// envid_ts less than 0 signify errors. The envid_t == 0 is special, and
// stands for the current environment.
// 环境ID“envid_t”有三部分:
// +1+---------------21-----------------+--------10--------+
// |0| Uniqueifier | Environment |
// | | | Index |
// +------------------------------------+------------------+
// \--- ENVX(eid) --/
//
// 环境索引ENVX(eid)等于“envs[]”数组中的环境索引。唯一的区别是在不同时间创建的环境,但共享相同的环境索引。
// 所有真实环境都大于0(因此符号位为零)。envid_ts小于0表示错误。envid_t == 0是特殊的,代表当前环境。
#define LOG2NENV 10
#define NENV (1 << LOG2NENV)
#define ENVX(envid) ((envid) & (NENV - 1))
因此修改lib/libmain.c
中的libmain()
函数
void
libmain(int argc, char **argv)
{
// set thisenv to point at our Env structure in envs[].
// 将thsienv设置为指向envs[]中我们的Env结构
// LAB 3: Your code here.
envid_t thisenvid = 0;
thisenvid = sys_getenvid();
thisenv = &envs[ENVX(thisenvid)];
// 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);
// exit gracefully
exit();
}
2.5 页错误和内存保护
内存保护是操作系统的一项重要功能,可确保一个程序中的错误不会破坏其他程序或破坏操作系统本身。
操作系统通常依赖硬件支持来实现内存保护。操作系统会通知硬件哪些虚拟地址有效,哪些无效。当程序试图访问一个无效的地址或它没有权限访问的地址时,处理器会在导致错误的指令处停止程序,然后将有关尝试操作的信息捕获到内核中。如果故障可以修复,内核可以修复它并让程序继续运行。如果故障不可修复,则程序无法继续,因为它永远无法通过导致故障的指令。
作为可修复故障的示例,考虑自动扩展堆栈。在许多系统中,内核最初分配一个堆栈页,然后如果程序错误地访问堆栈下方的页,内核将自动分配这些页并让程序继续。通过这样做,内核只分配程序所需的堆栈内存,但程序可以在具有任意大堆栈的错觉下工作。
系统调用为内存保护带来了一个有趣的问题。大多数系统调用接口允许用户程序向内核传递指针。这些指针指向要读取或写入的用户缓冲区。然后内核在执行系统调用时解引用这些指针。这有两个问题:
- 内核中的页错误可能比用户程序中的页故障严重得多。如果内核页在操作自己的数据结构时出错,这是一个内核错误,错误处理程序应该使内核(以及整个系统)恐慌。但是当内核正在解引用用户程序给它的指针时,它需要一种方法来记住这些解引用导致的任何页错误实际上都是代表用户程序的。
- 内核通常具有比用户程序更多的内存权限。用户程序可能会传递一个指向系统调用的指针,该系统调用指向内核可以读取或写入但用户程序无法读取的内存。内核必须小心,不要被骗去解引用这样的指针,因为这可能会泄露私有信息或破坏内核的完整性。
由于这两个原因,内核在处理用户程序提供的指针时必须非常小心。
现在,您将使用一个机制来解决这两个问题,该机制将仔细检查从用户空间传递到内核的所有指针。当程序向内核传递指针时,内核将检查地址是否在地址空间的用户部分中,以及页表是否允许内存操作。因此,内核不会因为解引用用户提供的指针而出现页面错误。如果内核出现页错误,它应该死机并终止。
练习9:如果在内核模式下发生页错误,请将kern/trap.c
更改为panic
。
提示:要确定故障是在用户模式还是在内核模式下发生,请检查tf_cs
的低位。
读取kern/pmap.c
中的user_mem_assert
,并在同一文件中实现user_mem_check
。
将kern/syscall.c
更改为系统调用的健全性检查参数。
启动内核,运行user/bugyhello
。环境应该被破坏,内核不应该恐慌。您应该看到:
[00001000] user_mem_check assertion failure for va 00000001
[00001000] free env 00001000
Destroyed the only environment - nothing more to do!
最后,将kern/kdebug.c
中的debuginfo_eip
更改为调用user_mem_check
在usd
、stabs
和stabstr
上。如果现在运行user/breakpoint
,那么应该能够从内核监视器运行backtrace
,并在内核因页错误而死机之前看到backtrace
遍历到lib/libmain.c
中。此页错误的原因是什么?你不需要修复它,但你应该理解为什么会发生这种情况。
答:首先,我们修改kern/trap.c
上发生页错误错误时调用的中断处理代码,在page_fault_handler
中添加
if (!(curenv->env_tf.tf_cs & 0x3)) {
panic("Kernel fault");
}
实现kern/pmap.c
中的user_mem_check
函数,首先查看注释
//
// 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.
//
// 检查是否允许环境使用“perm|PTE_P”权限访问内存范围[va,va+len)。
// 通常,“perm”至少包含PTE_U,但这不是必需的。
// “va”和“len”不需要页对齐;您必须测试包含任何该范围的每一页。您将测试“len / PGSIZE”、“len / PGSIZE + 1”或“len / PGSIZE + 2”页。
// 如果(1)地址低于ULIM,并且(2)页面表允许访问虚拟地址,则用户程序可以访问该地址。这些正是您应该在这里实现的测试。
// 如果存在错误,请将“user_mem_check_addr”变量设置为第一个错误的虚拟地址。
// 如果用户程序可以访问此地址范围,则返回0,否则返回 -E_FAULT。
这个有点像我们在lab2中编写的内存映射函数,只不过需要注意的是,我们这里不能直接使用PADDR
和KADDR
这两个宏来转换地址,因为现在mmu使用的是用户环境的页表,内存的映射与内核的内存映射方式不同。我们这里使用pgdir_walk
函数来找到用户虚拟地址的物理地址映射。编写代码如下
int
user_mem_check(struct Env *env, const void *va, size_t len, int perm)
{
// LAB 3: Your code here.
uint32_t begin = (uint32_t)ROUNDDOWN(va, PGSIZE);
uint32_t end = (uint32_t)ROUNDUP(va + len, PGSIZE);
while (begin < end) {
pte_t* pte = pgdir_walk(env->env_pgdir, begin, 0);
if ((begin >= ULIM) || (pte == NULL) || (((uint32_t)*pte & perm) != perm)) {
if (begin < va) {
user_mem_check_addr = (uintptr_t)va;
return -E_FAULT;
}
user_mem_check_addr = (uintptr_t)begin;
return -E_FAULT;
}
begin += PGSIZE;
}
return 0;
}
然后,修改kern/syscall.c
,在sys_cputs
中添加
user_mem_assert(curenv, (void *)s, len, PTE_P | PTE_U);
最后,修改kern/kdebug.c
的debuginfo_eip
添加
// Make sure this memory is valid.
// Return -1 if it is not. Hint: Call user_mem_check.
// LAB 3: Your code here.
if (user_mem_check(curenv, (void*)usd, sizeof(usd), PTE_P) < 0)
{
return -1;
}
stabs = usd->stabs;
stab_end = usd->stab_end;
stabstr = usd->stabstr;
stabstr_end = usd->stabstr_end;
// Make sure the STABS and string table memory is valid.
// LAB 3: Your code here.
if ((user_mem_check(curenv, (void*)stabs, (uint32_t)(stab_end)-(uint32_t)(stabs), PTE_P) < 0)
|| (user_mem_check(curenv, (void*)stabstr, (uint32_t)(stabstr)-(uint32_t)(stabstr_end), PTE_P) < 0))
{
return -1;
}
ps:代码很简单,就是出现页错误的原因我还不清楚......
练习10:启动内核,运行user/elvhello。环境应该被破坏,内核不应该恐慌。您应该看到:
[00000000] new env 00001000
...
[00001000] user_mem_check assertion failure for va f010000c
[00001000] free env 00001000
答:代码同上
至此,所有练习都结束了,以下是测试结果
全部通过
总结
本实验实现了用户环境(进程)运行的基本内核设施,包括对环境进行管理、中断描述符表的构建以及中断处理程序。总结起来,大致完成了以下三件事:
- 对环境进行管理:
- 内核维护一个
Env
数组envs
,每个Env
结构体对应一个环境,Env结构体包括Trapframe env_tf
(中断发生时保寸环境上下文),pde_t *env_pgdir
(环境的页目录地址)。 - 定义了
env_init()
,env_setup_vm()
等函数,用于管理环境,建立环境,运行环境并销毁环境,同时可以加载用户ELF文件并运行。
- 创建中断处理函数,建立并加载中断描述符表,使内核可以进行中断处理。以下是详细过程:
- 利用内核的中断处理机制,实现系统调用。详细过程如下:
至此,实验完成