MIT-JOS系列5:用户环境(一)
Part A:用户环境和异常处理
注:根据MIT-JOS的lab指导手册,以下不明确区分“环境”和“进程”
用户环境创建
本节中我们将实现一些内核的基本工具来支持受保护的用户进程的运行。我们将增加JOS内核的功能,为它增加一些数据结构来追踪用户进程的一些信息;创建一个单一用户的环境,并在其中加载运行一个程序。我们也会使JOS内核处理用户进程做出的任何系统调用和它导致的任何异常
内核利用ENV
数据结构来记录每一个环境的信息。目前我们只创建单一的用户环境,以后再在此基础上设计多用户环境
在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
个)申请一个Env
数据结构,存放在envs
数组中 - JOS内核将所有未使用的
Env
结构体放在env_free_list
链表中,以便用户环境的分配和回收 curenv
指针指向JOS内核中任意时刻正在执行的环境。当内核启动、还没有任何用户环境运行时,它被初始化为NULL
Env
数据结构设计如下:
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
:当内核或其他环境执行时,保存当前未执行的环境的寄存器变量。例如当内核从用户态切换到内核态运行时,用户态的重要寄存器将被保存,以便在回到用户态运行时恢复它们
env_link
:指向env_free_list
中该环境的下一个未使用的环境。只有在这个结构体也未使用时(也在env_free_list
中时)这个域才有用
env_id
:标识当前使用此Env
结构体的环境的唯一值。当一个环境终止、内核将这个环境的Env
重新分配给其他环境后,它们的env_id
也不相同
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
结构体对应的环境处于活跃状态,但此时无法运行:例如他正在等待来自另一个环境的消息ENV_DYING
:该Env
结构体对应的环境是一个僵尸环境,它将在系统下一次进入内核态时被回收
eng_pgdir
:保存该Env
结构体对应的环境的页表目录的虚拟地址
与Unix进程相类似,一个JOS环境结合了线程(thread)和地址空间(address space)的概念:线程主要通过保存寄存器的值(env_tf
)来定义,地址空间主要通过保存页表目录和页表(eng_pgdir
)来定义。要运行一个环境,内核必须为它设置合适的寄存器的值和合适的地址空间
在JOS系统中,环境并没有在内核中拥有各自独立的栈。。因为任意时刻只能有一个环境处于活跃状态,因此JOS内核只需要一个内核栈。
分配环境数组
在mem_init()
补充对envs
数组的内存分配。这个过程与分配页面数组pages
是一样的
envs = (struct Env *) boot_alloc(NENV*sizeof(struct Env));
memset(envs, 0, NENV*sizeof(struct Env));
然后需要建立envs
的映射关系:虚拟地址为UENVS
,权限为用户可读
boot_map_region(kern_pgdir, UENVS, ROUNDUP(envs_size, PGSIZE), PADDR(envs), PTE_U);
创建和运行环境
接下来要编写运行一个用户环境的必要代码。由于现在还没有文件系统,因此JOS将一些用户程序的静态二进制文件作为ELF文件嵌入在内核中,以便被载入和执行
Lab3的GNUmakefile
在obj/user/
目录下生成一系列二进制文件,它们通过-b binary
命令作为原始二进制文件被链接到内核中
在读取和运行这些二进制文件前,我们首先完成用户环境的初始化
env_init()
函数功能:初始化所有envs
数组中的Env
结构体并把它们加入到env_free_list
链表中
注意事项:env_free_list
中结构体的顺序应与envs
数组相同,即第一次调用env_alloc()
应该返回envs[0]
代码实现如下:
void
env_init(void)
{
// Set up envs array
// LAB 3: Your code here.
int i;
for (i=NENV-1; i>=0; i--) {
envs[i].env_status = ENV_FREE;
envs[i].env_link = env_free_list;
env_free_list = &envs[i];
}
// Per-CPU part of the initialization
env_init_percpu();
}
函数最后调用env_init_percpu()
配置段式内存管理系统,它所做的事包括
- 重新载入GDT表
- 初始化数据段寄存器GS、FS(留给用户数据段使用)、ES、DS、SS(在用户态和内核态切换使用)
- 初始化内核的代码段寄存器CS
- 初始化LDT表为0
env_setup_vm()
函数功能:为新的环境分配页表目录,并在新环境的地址空间中初始化与内核相关的部分
相关部分是指:用户和内核一样理论上能访问完整的4G虚拟内存(这里假设能访问到4G)。内核已建立UTOP以上的虚拟地址到物理地址的映射,因此内核拷贝一份这个映射到自己的页表目录中。对应的物理地址用户究竟能不能访问靠页表项中通过权限位控制
代码实现如下:
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.
// LAB 3: Your code here.
e->env_pgdir = (uintptr_t *)page2kva(p);
p->pp_ref++;
// 用户和内核一样能访问到4G的虚拟地址空间,其中
// UTOP以下的地址空间是用户自己的
// UTOP以上的地址空间是内核的
// 赋值一份UTOP以上的内核页表目录给用户目录页表,UTOP以下用户自由发挥
for (i=PDX(UTOP); i<NPDENTRIES; i++)
e->env_pgdir[i] = kern_pgdir[i];
// 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;
}
和内核页表目录一样这里填写了UVPT
的目录项,以便用户进程在用户空间访问到各页面的页表项,查询到页面的权限。具体在lab4中讲到,原理也可以直接参考MIT-JOS系列:用户态访问页表项详解
region_alloc()
函数功能:为环境分配len
字节的物理内存并映射到用户的虚拟地址空间
- 不需要对分配的空间初始化
- 分配的页用户和内核具有写权限
- 需要对起始地址
va
和长度len
进行4K页面对齐
代码实现如下:
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!)
uintptr_t start = ROUNDDOWN(va, PGSIZE);
uintptr_t end = start + ROUNDUP(len, PGSIZE);
struct PageInfo *p = NULL;
int i;
for (i=start; i<end; i+=PGSIZE) {
p = page_alloc(0);
if (!p)
panic("Out of memory!\n");
if (page_insert(e->env_pgdir, p, i, PTE_U | PTE_W) == -E_NO_MEM)
panic("Out of memory!\n");
}
}
load_icode()
函数功能:解析ELF二进制文件,加载其内容到新环境的用户地址空间中
- 每个用户进程都是一个ELF文件。像boot loader所做的那样,从ELF文件加载用户进程的初始代码区、堆栈和处理器标识位
- 这个函数仅在内核初始化期间、第一个用户态环境运行前被调用
- 函数将ELF文件中所有可加载的段载入到用户地址空间中,设置
e->env_tf.tf_eip
为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 section 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.)
// LAB 3: Your code here.
struct Elf *elf = (struct Elf*)binary;
if (elf->e_magic != ELF_MAGIC)
panic("load_icode() error: Not the ELF file.\n");
// 设置程序的入口
e->env_tf.tf_eip = elf->e_entry;
// 接下来要将kernel的数据复制到用户空间的虚拟地址,因此暂用一下用户页表目录以找到正确的虚拟地址
lcr3(PADDR(e->env_pgdir));
struct Proghdr *ph, *eph;
ph = (struct Proghdr*)(binary + elf->e_phoff);
eph = ph + elf->e_phnum;
for (; ph<eph; ph++) {
if (ph->p_type == ELF_PROG_LOAD) {
if (ph->p_filesz > ph->p_memsz)
panic("load_icode() error: file length > memory length.\n");
region_alloc(e, ph->p_va, ph->p_memsz);
// 将elf文件的段数据直接move到相应的虚拟地址空间
memmove(ph->p_va, binary+ph->p_offset, ph->p_filesz);
// 由于filesz<=memsz, 将多出来的memz置0
memset(ph->p_va+ph->p_filesz, 0, ph->p_memsz-ph->p_filesz);
}
}
// Now map one page for the program's initial stack
// at virtual address USTACKTOP - PGSIZE.
region_alloc(e, USTACKTOP-PGSIZE, PGSIZE);
// LAB 3: Your code here.
// 恢复内核的页表目录
lcr3(PADDR(kern_pgdir));
}
env_create()
函数功能:调用env_alloc()
创建一个新的环境,调用load_icode()
向环境中载入ELF文件
代码实现如下:
void
env_create(uint8_t *binary, enum EnvType type)
{
// LAB 3: Your code here.
struct Env *env = NULL;
int err = env_alloc(&env, 0);
if (err == -E_NO_FREE_ENV)
panic("env_create() error: no free environment.\n");
if (err == -E_NO_MEM)
panic("env_create() error: out of memory.\n");
env->env_type = type;
load_icode(env, binary);
}
env_run()
函数功能:运行一个给定的环境
- 如果是环境切换(即有环境正在运行):
- 如果当前环境
curenv
的env_status
为ENV_RUNNING
,设置它为ENV_RUNNABLE
- 设置
curenv为
新的环境 - 设置新的环境的
env_status
为ENV_RUNNING
- 更新
env_runs
计数 - 利用
lcr3()
切换地址空间
- 如果当前环境
- 利用
env_pop_tf()
恢复环境的重要寄存器并进入用户模式
代码实现如下:
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.
// 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.
// LAB 3: Your code here.
if (curenv != NULL && curenv->env_status == ENV_RUNNING) {
curenv->env_status = ENV_RUNNABLE;
}
curenv = e;
curenv->env_status = ENV_RUNNING;
curenv->env_runs++;
// 设置cr3中的页表目录地址:地址为物理地址
lcr3(PADDR(curenv->env_pgdir));
env_pop_tf(&curenv->env_tf);
// panic("env_run not yet implemented");
}
env_run()
这个函数是永不返回的(不是指没有返回值,而是指它跳转去执行别的代码了,而且永远不会回到这里了),它利用env_pop_tf()
函数设置新环境的重要寄存器,然后进入用户环境执行代码,因此env_pop_tf()
必须放在env_run()
的最后一行执行。env_pop_tf()
的代码如下:
// 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"
"\tpopal\n"
"\tpopl %%es\n"
"\tpopl %%ds\n"
"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
"\tiret\n"
: : "g" (tf) : "memory");
panic("iret failed"); /* mostly to placate the compiler */
}
env_pop_tf()
将tf
的内容视为栈,当前栈顶设置为tf
的第一个字段,pop过程能顺序访问tf
中的每个数据,从逐个出栈tf
里面的数据到相应的寄存器
-
movl %0,%%esp
将当前栈指针指向输入变量tf
-
popal
恢复所有r32
寄存器,即tf.PushRegs
里的东西 -
pop出
tf_es
赋值到es
-
pop出
tf_ds
赋值到ds
-
跳过
tf_trapno
和tf_err
,使esp
指向tf_eip
-
调用
iret
,跳转到用户进程执行代码调用
iret
时:从esp
指向的栈中顺序出栈eip, cs, eflags(标志寄存器), esp, ss
赋值到相应寄存器,然后程序跳转到cs:eip
处继续执行,因此这个函数正常状态下不会返回
用户环境小结
至此为止,进入运行一个新环境的函数调用如下:
start
(kern/entry.S
)i386_init
(kern/init.c
)cons_init
mem_init
env_init
trap_init
(还未实现)env_create
env_run
env_pop_tf
启动内核,创建并进入一个环境的过程大约如下:
- 启动内核,开启分页,设置栈区(
kern/entry.S
,之前lab1做的) - 初始化.bss段,初始化一系列硬件(
cons_init
,之前lab1做的) - 虚拟内存初始化(
mem_init
,之前lab2做的,lab3刚刚对其进行补充:增加初始化envs数组)- 用CMOS检测可用的物理内存
- 为kernel的页表目录分配内存,将页表目录
kern_pgdir
作为页表插入到页表项UVPT
处(以便内核以外的环境在UVPT
处能够查找到自己的页表目录,在lab4中会详细讲到) - 初始化
pages
数组,将物理内存以页为单位记录到pages
数组中并利用page_free_list
管理空闲页面,编写页表分配、释放、映射的相关函数 - 完成物理内存前256M的映射,与此同时填写了页表目录和二级页表,并赋予页面相应权限
- 设置
cr3
为kern_pgdir
的物理地址,并设置cr0
的标志
- 为所有可能的环境进行初始化(
env_init
)- 初始化
envs
数组中的每一项状态为ENV_FREE
,并放入未使用的环境env_free_list
链表中 - 加载GDT表和初始化段描述子
- 初始化
- 中断设置和异常处理(
trap_init
,还没做) - 创建一个新环境(
env_create
)- 调用
env_alloc()
初始化一个环境:这个函数不是由我们动手编写的,但调用了我们编写的一些函数- 从
env_free_list
拿出一个未被使用的Env
结构 - 利用
env_setup_vm()
初始化新环境的虚拟地址空间:- 为新环境分配一页内存作为页表目录
- 将其地址填写到新环境的
env_pgdir
域中 - 拷贝内核页表目录中
UTOP
以上部分(内核地址空间映射情况)到新环境的页表目录 - 将新的页表目录作为页表插入到新环境目录页表项的
UVPT
处
- 为新环境生成一个唯一标识
env_id
- 初始化新环境的其他域:
env_parent_id
,env_type
,env_status
,env_runs
- 初始化新环境的段寄存器
env_tf.tf_ds/es/ss/cs
关联当前GDT表,初始化栈指针env_tf.tf_esp
指向USTACKTOP
- 修改
env_free_list
指向下一个未被使用的Env
结构
- 从
- 调用
load_icode()
为新环境加载可执行二进制文件,这个二进制是进入新环境后执行的程序:- 以elf格式读取二进制的程序执行入口
e_entry
,并用这个入口设置新环境的env_tf.tf_eip
,让新环境能够从二进制的入口开始执行程序 - 利用
region_alloc()
为类型为ELF_PROG_LOAD
的节分配内存并映射到p_va
指示的位置 - 利用
memmove()
将这些节长度为p_filesz
的文件内容移动到p_va
指示的位置 - 由于
p_filesz<=p_memsz
,利用memset()
填充两者之间的空缺 - 注意:此处操作的
p_va
是新环境的地址空间,有别于kernel的地址空间,因此需要在操作之前先临时把cr3
设置为新环境的页表目录地址e->env_pgdir
,操作结束后再恢复到内核的页表目录地址
- 以elf格式读取二进制的程序执行入口
- 为新环境分配一页作为其栈区,映射到虚拟地址
USTACKTOP-PGSIZE
处- 这里我寻思着
USTACKTOP-PGSIZE
是在UTOP
底下,也属于用户内存区,也得在用户地址空间下执行?
- 这里我寻思着
- 调用
- 进入一个新环境,执行其程序(
env_run
)- 若当前有环境在运行,设置该环境的
env_status
为ENV_RUNNABLE
- 设置当前环境指针
curenv
指向新环境 - 修改新环境的运行状态为
ENV_RUNNING
,运行次数env_runs++
- 修改
cr3
为新环境的页表目录地址 - 调用
env_pop_tf()
进入新环境
- 若当前有环境在运行,设置该环境的
代码完成到这里,编译并启动qemu运行后,系统就应该能够顺利进入用户环境并执行hello
这个二进制文件。由于目前还没实现对中断和异常的处理,因此它将在hello
进行系统调用int $0x30
时出错(这行能在hello.asm
中找到)。JOS此时尚未设置硬件允许从用户空间转换到内核态,当CPU发现这个它没办法处理这个中断时,它引发一个异常;当它又发现它没办法处理这个异常时,它又引发了一个异常;但它发现它还是没办法处理这个异常,只好放弃,因此最终引发了“三重异常”,在qemu中得到Triple fault
的输出。
查看hello.asm
,我们可以在中断处设置断点b *0x800b44
,如果程序能够执行到这个位置没有异常,就说明之前编写的代码是正确的:
gdb:
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb) b *0x800b44
Breakpoint 1 at 0x800b44
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x800b44: int $0x30
Breakpoint 1, 0x00800b44 in ?? ()
(gdb)
qemu:
qxy@qxy-XPS-13-9360:~/1work/MIT-JOS/lab$ make qemu-nox-gdb
***
*** Now run 'make gdb'.
***
qemu-system-i386 -nographic -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::26000 -D qemu.log -S
6828 decimal is 15254 octal!
Physical memory: 131072K available, base = 640K, extended = 130432K
check_page_alloc() succeeded!
check_page() succeeded!
check_kern_pgdir() succeeded!
check_page_installed_pgdir() succeeded!
[00000000] new env 00001000
接下来,在gdb中输入si
再往下执行一步,将能在qemu中收到Triple fault
:
gdb:
Breakpoint 1, 0x00800b44 in ?? ()
(gdb) si
=> 0x800b44: int $0x30
Breakpoint 1, 0x00800b44 in ?? ()
(gdb)
qemu:
[00000000] new env 00001000
EAX=00000000 EBX=00000000 ECX=0000000d EDX=eebfde88
ESI=00000000 EDI=00000000 EBP=eebfde60 ESP=eebfde54
EIP=00800b44 EFL=00000092 [--S-A--] CPL=3 II=0 A20=1 SMM=0 HLT=0
ES =0023 00000000 ffffffff 00cff300 DPL=3 DS [-WA]
CS =001b 00000000 ffffffff 00cffa00 DPL=3 CS32 [-R-]
SS =0023 00000000 ffffffff 00cff300 DPL=3 DS [-WA]
DS =0023 00000000 ffffffff 00cff300 DPL=3 DS [-WA]
FS =0023 00000000 ffffffff 00cff300 DPL=3 DS [-WA]
GS =0023 00000000 ffffffff 00cff300 DPL=3 DS [-WA]
LDT=0000 00000000 00000000 00008200 DPL=0 LDT
TR =0028 f018eb60 00000067 00408900 DPL=0 TSS32-avl
GDT= f011b300 0000002f
IDT= f018e340 000007ff
CR0=80050033 CR2=00000000 CR3=003bc000 CR4=00000000
DR0=00000000 DR1=00000000 DR2=00000000 DR3=00000000
DR6=ffff0ff0 DR7=00000400
EFER=0000000000000000
Triple fault. Halting for inspection via QEMU monitor.
处理中断和异常
见下一篇博客MIT-JOS系列6:用户环境(二)