简介
在本实验中,你将要实现一个基本的内核功能,要求它能够保护运行的用户模式环境(即:进程)。你将去增强这个 JOS 内核,去配置数据结构以便于保持对用户环境的跟踪、创建一个单一用户环境、将程序镜像加载到用户环境中、并将它启动运行。你也要写出一些 JOS 内核的函数,用来处理任何用户环境生成的系统调用,以及处理由用户环境引进的各种异常。
注意: 在本实验中,术语**“环境”** 和**“进程”** 是可互换的 —— 它们都表示同一个抽象概念,那就是允许你去运行的程序。我在介绍中使用术语**“环境”而不是使用传统术语“进程”**的目的是为了强调一点,那就是 JOS 的环境和 UNIX 的进程提供了不同的接口,并且它们的语义也不相同。
预备知识
使用 Git 去提交你自实验 2 以后的更改(如果有的话),获取课程仓库的最新版本,以及创建一个命名为 lab3
的本地分支,指向到我们的 lab3 分支上 origin/lab3
:
athena% cd ~/6.828/lab
athena% add git
athena% git commit -am 'changes to lab2 after handin'
Created commit 734fab7: changes to lab2 after handin
4 files changed, 42 insertions(+), 9 deletions(-)
athena% git pull
Already up-to-date.
athena% git checkout -b lab3 origin/lab3
Branch lab3 set up to track remote branch refs/remotes/origin/lab3.
Switched to a new branch "lab3"
athena% git merge lab2
Merge made by recursive.
kern/pmap.c | 42 +++++++++++++++++++
1 files changed, 42 insertions(+), 0 deletions(-)
athena%
实验 3 包含一些你将探索的新源文件:
inc/ env.h Public definitions for user-mode environments
trap.h Public definitions for trap handling
syscall.h Public definitions for system calls from user environments to the kernel
lib.h Public definitions for the user-mode support library
kern/ env.h Kernel-private definitions for user-mode environments
env.c Kernel code implementing user-mode environments
trap.h Kernel-private trap handling definitions
trap.c Trap handling code
trapentry.S Assembly-language trap handler entry-points
syscall.h Kernel-private definitions for system call handling
syscall.c System call implementation code
lib/ Makefrag Makefile fragment to build user-mode library, obj/lib/libjos.a
entry.S Assembly-language entry-point for user environments
libmain.c User-mode library setup code called from entry.S
syscall.c User-mode system call stub functions
console.c User-mode implementations of putchar and getchar, providing console I/O
exit.c User-mode implementation of exit
panic.c User-mode implementation of panic
user/ * Various test programs to check kernel lab 3 code
另外,一些在实验 2 中的源文件在实验 3 中将被修改。如果想去查看有什么更改,可以运行:
$ git diff lab2
你也可以另外去看一下 实验工具指南,它包含了与本实验有关的调试用户代码方面的信息。
实验要求
本实验分为两部分:Part A 和 Part B。Part A 在本实验完成后一周内提交;你将要提交你的更改和完成的动手实验,在提交之前要确保你的代码通过了 Part A 的所有检查(如果你的代码未通过 Part B 的检查也可以提交)。只需要在第二周提交 Part B 的期限之前代码检查通过即可。
由于在实验 2 中,你需要做实验中描述的所有正则表达式练习,并且至少通过一个挑战(是指整个实验,不是每个部分)。写出详细的问题答案并张贴在实验中,以及一到两个段落的关于你如何解决你选择的挑战问题的详细描述,并将它放在一个名为 answers-lab3.txt
的文件中,并将这个文件放在你的 lab
目标的根目录下。(如果你做了多个问题挑战,你仅需要提交其中一个即可)不要忘记使用 git add answers-lab3.txt
提交这个文件。
行内汇编语言
在本实验中你可能发现使用了 GCC 的行内汇编语言特性,虽然不使用它也可以完成实验。但至少你需要去理解这些行内汇编语言片段,这些汇编语言(asm
语句)片段已经存在于提供给你的源代码中。你可以在课程 参考资料 的页面上找到 GCC 行内汇编语言有关的信息。
Part A:用户环境和异常处理
新文件 inc/env.h
中包含了在 JOS 中关于用户环境的基本定义。现在就去阅读它。内核使用数据结构 Env
去保持对每个用户环境的跟踪。在本实验的开始,你将只创建一个环境,但你需要去设计 JOS 内核支持多环境;实验 4 将带来这个高级特性,允许用户环境去 fork
其它环境。
正如你在 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
中用 #define
定义的一个常量)一旦它被分配,对于每个 NENV
可能的环境,envs
数组将包含一个数据结构 Env
的单个实例。
JOS 内核在 env_free_list
上用数据结构 Env
保存了所有不活动的环境。这样的设计使得环境的分配和回收很容易,因为这只不过是添加或删除空闲列表的问题而已。
内核使用符号 curenv
来保持对任意给定时刻的 当前正在运行的环境 进行跟踪。在系统引导期间,在第一个环境运行之前,curenv
被初始化为 NULL
。
环境状态
数据结构 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; // 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
中的字段简介:
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
: 表示那个Env
结构所表示的是一个僵尸环境。一个僵尸环境将在下一次被内核捕获后被释放。我们在实验 4 之前不会去使用这个标志。
env_pgdir
: 这个变量持有这个环境的内核虚拟地址的页目录。
就像一个 Unix 进程一样,一个 JOS 环境耦合了“线程”和“地址空间”的概念。线程主要由保存的寄存器来定义(env_tf
字段),而地址空间由页目录和 env_pgdir
所指向的页表所定义。为运行一个环境,内核必须使用保存的寄存器值和相关的地址空间去设置 CPU。
我们的 struct Env
与 xv6 中的 struct proc
类似。它们都在一个 Trapframe
结构中持有环境(即进程)的用户模式寄存器状态。在 JOS 中,单个的环境并不能像 xv6 中的进程那样拥有它们自己的内核栈。在这里,内核中任意时间只能有一个 JOS 环境处于活动中,因此,JOS 仅需要一个单个的内核栈。
为环境分配数组
在实验 2 的 mem_init()
中,你为数组 pages[]
分配了内存,它是内核用于对页面分配与否的状态进行跟踪的一个表。你现在将需要去修改 mem_init()
,以便于后面使用它分配一个与结构 Env
类似的数组,这个数组被称为 envs
。
练习 1、修改在
kern/pmap.c
中的mem_init()
,以用于去分配和映射envs
数组。这个数组完全由Env
结构分配的实例NENV
组成,就像你分配的pages
数组一样。与pages
数组一样,由内存支持的数组envs
也将在UENVS
(它的定义在inc/memlayout.h
文件中)中映射用户只读的内存,以便于用户进程能够从这个数组中读取。
你应该去运行你的代码,并确保 check_kern_pgdir()
是没有问题的。
创建和运行环境
现在,你将在 kern/env.c
中写一些必需的代码去运行一个用户环境。因为我们并没有做一个文件系统,因此,我们将设置内核去加载一个嵌入到内核中的静态的二进制镜像。JOS 内核以一个 ELF 可运行镜像的方式将这个二进制镜像嵌入到内核中。
在实验 3 中,GNUmakefile
将在 obj/user/
目录中生成一些二进制镜像。如果你看到 kern/Makefrag
,你将注意到一些奇怪的的东西,它们“链接”这些二进制直接进入到内核中运行,就像 .o
文件一样。在链接器命令行上的 -b binary
选项,将因此把它们链接为“原生的”不解析的二进制文件,而不是由编译器产生的普通的 .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
,它通过配置硬件,在硬件上为 level 0(内核)权限和 level 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。
下面是用户代码相关的调用图。确保你理解了每一步的用途。
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 发现没有系统调用中断的服务程序时,它将生成一个一般保护异常,找到那个异常并去处理它,还将生成一个双重故障异常,同样也找到它并处理它,并且最后会出现所谓的“三重故障异常”。通常情况下,你将随后看到 CPU 复位以及系统重引导。虽然对于传统的应用程序(在 这篇博客文章 中解释了原因)这是重大的问题,但是对于内核开发来说,这是一个痛苦的过程,因此,在打了 6.828 补丁的 QEMU 上,你将可以看到转储的寄存器内容和一个“三重故障”的信息。
我们马上就会去处理这些问题,但是现在,我们可以使用调试器去检查我们是否进入了用户模式。使用 make qemu-gdb
并在 env_pop_tf
处设置一个 GDB 断点,它是你进入用户模式之前到达的最后一个函数。使用 si
单步进入这个函数;处理器将在 iret
指令之后进入用户模式。然后你将会看到在用户环境运行的第一个指令,它将是在 lib/entry.S
中的标签 start
的第一个指令 cmpl
。现在,在 hello
中的 sys_cputs()
的 int $0x30
处使用 b *0x...
(关于用户空间的地址,请查看 obj/user/hello.asm
)设置断点。这个指令 int
是系统调用去显示一个字符到控制台。如果到 int
还没有运行,那么可能在你的地址空间设置或程序加载代码时发生了错误;返回去找到问题并解决后重新运行。
处理中断和异常
到目前为止,在用户空间中的第一个系统调用指令 int $0x30
已正式寿终正寝了:一旦处理器进入用户模式,将无法返回。因此,现在,你需要去实现基本的异常和系统调用服务程序,因为那样才有可能让内核从用户模式代码中恢复对处理器的控制。你所做的第一件事情就是彻底地掌握 x86 的中断和异常机制的使用。
练习 3、如果你对中断和异常机制不熟悉的话,阅读 80386 程序员手册的第 9 章(或 IA-32 开发者手册的第 5 章)。
在这个实验中,对于中断、异常、以其它类似的东西,我们将遵循 Intel 的术语习惯。由于如 异常 exception 、 陷阱 trap 、 中断 interrupt 、 故障 fault 和 中止 abort 这些术语在不同的架构和操作系统上并没有一个统一的标准,我们经常在特定的架构下(如 x86)并不去考虑它们之间的细微差别。当你在本实验以外的地方看到这些术语时,它们的含义可能有细微的差别。
受保护的控制转移基础
异常和中断都是“受保护的控制转移”,它将导致处理器从用户模式切换到内核模式(CPL=0
)而不会让用户模式的代码干扰到内核的其它函数或其它的环境。在 Intel 的术语中,一个中断就是一个“受保护的控制转移”,它是由于处理器以外的外部异步事件所引发的,比如外部设备 I/O 活动通知。而异常正好与之相反,它是由当前正在运行的代码所引发的同步的、受保护的控制转移,比如由于发生了一个除零错误或对无效内存的访问。
为了确保这些受保护的控制转移是真正地受到保护,处理器的中断/异常机制设计是:当中断/异常发生时,当前运行的代码不能随意选择进入内核的位置和方式。而是,处理器在确保内核能够严格控制的条件下才能进入内核。在 x86 上,有两种机制协同来提供这种保护:
x86 允许最多有 256 个不同的中断或异常入口点去进入内核,每个入口点都使用一个不同的中断向量。一个向量是一个介于 0 和 255 之间的数字。一个中断向量是由中断源确定的:不同的设备、错误条件、以及应用程序去请求内核使用不同的向量生成中断。CPU 使用向量作为进入处理器的中断描述符表(IDT)的索引,它是内核设置的内核私有内存,GDT 也是。从这个表中的适当的条目中,处理器将加载:
* 将值加载到指令指针寄存器(EIP),指向内核代码设计好的,用于处理这种异常的服务程序。
* 将值加载到代码段寄存器(CS),它包含运行权限为 0—1 级别的、要运行的异常服务程序。(在 JOS 中,所有的异常处理程序都运行在内核模式中,运行级别为 0。)
EIP
和 CS
的原始值,这样那个异常服务程序就能够稍后通过还原旧的状态来回到中断发生时的代码位置。但是对于已保存的处理器的旧状态必须被保护起来,不能被无权限的用户模式代码访问;否则代码中的 bug 或恶意用户代码将危及内核。基于这个原因,当一个 x86 处理器产生一个中断或陷阱时,将导致权限级别的变更,从用户模式转换到内核模式,它也将导致在内核的内存中发生栈切换。有一个被称为 TSS 的任务状态描述符表规定段描述符和这个栈所处的地址。处理器在这个新栈上推送 SS
、ESP
、EFLAGS
、CS
、EIP
、以及一个可选的错误代码。然后它从中断描述符上加载 CS
和 EIP
的值,然后设置 ESP
和 SS
去指向新的栈。
虽然 TSS 很大并且默默地为各种用途服务,但是 JOS 仅用它去定义当从用户模式到内核模式的转移发生时,处理器即将切换过去的内核栈。因为在 JOS 中的“内核模式”仅运行在 x86 的运行级别 0 权限上,当进入内核模式时,处理器使用 TSS 上的 ESP0
和 SS0
字段去定义内核栈。JOS 并不去使用 TSS 的任何其它字段。
异常和中断的类型
所有的 x86 处理器上的同步异常都能够产生一个内部使用的、介于 0 到 31 之间的中断向量,因此它映射到 IDT 就是条目 0-31。例如,一个页故障总是通过向量 14 引发一个异常。大于 31 的中断向量仅用于软件中断,它由 int
指令生成,或异步硬件中断,当需要时,它们由外部设备产生。
在这一节中,我们将扩展 JOS 去处理向量为 0-31 之间的、内部产生的 x86 异常。在下一节中,我们将完成 JOS 的 48(0x30)号软件中断向量,JOS 将(随意选择的)使用它作为系统调用中断向量。在实验 4 中,我们将扩展 JOS 去处理外部生成的硬件中断,比如时钟中断。
一个示例
我们把这些片断综合到一起,通过一个示例来巩固一下。我们假设处理器在用户环境下运行代码,遇到一个除零问题。
SS0
和 ESP0
定义的栈,在 JOS 中,它们各自保存着值 GD_KD
和 KSTACKTOP
。KSTACKTOP
:+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20