【MIT 6.828】5. 用户环境
本节详细学习 6.828 lab 3,操作系统的用户环境
0x00 课程介绍
在本实验中将会实现,运行一个受保护的用户模式(user-mode)环境(例如:“进程”等)所需的基本内核功能。我们需要增强 JOS 内核,设计一个数据结构来跟踪用户环境(user environment),创建单用户环境(single user environment),加载一个程序镜像(program image)并运行它。另外 JOS 内核还需要处理用户环境(user environment)发出的任何系统调用(system calls)和造成的任何其他异常(exceptions)。
注意:本实验中,术语“环境(environment)”和“进程(process)”的可以互换的,都是一个抽象概念:指允许你运行一个程序。
引入术语“环境(environment)”而不使用传统术语“进程(process)”,是为了强调 JOS 环境和 UNIX 进程提供不同的接口,并且不提供相同的语义。
使用命令 git checkout -b lab3 origin/lab3
切换到 lab3 的最新代码分支,lab3 增加了很多新的源文件,如下所示:
inc/ env.h 用户模式环境的公共定义
trap.h trap 处理的公共定义
syscall.h 从用户环境到内核的系统调用的公共定义
lib.h 用户模式支持库的公共定义
kern/ env.h 用户模式环境的内核私有定义
env.c 实现用户模式环境的内核代码
trap.h 内核私有 trap 处理定义
trap.c trap 处理代码
trapentry.S 汇编语言 trap 处理程序入口点
syscall.h 系统调用处理的内核私有定义
syscall.c 系统调用实现代码
lib/ Makefrag Makefile 片段来构建用户模式库,obj/lib/libjos.a
entry.S 用户环境的汇编语言入口点
libmain.c 从 entry.S 调用的用户模式库设置代码
syscall.c 用户模式 系统调用的存根(stub)函数
console.c 用户模式 putchar 和 getchar 的实现,提供控制台 I/O
exit.c 用户模式 退出的实现
panic.c 用户模式 panic 的实现
user/ * 用于检查内核 lab3 代码的各种测试程序
0x01 Part A:用户环境和异常处理
lab3 中新增的文件 inc/env.h
包含了 JOS 中用户环境(user environment)需要使用到的基础定义(definition)。内核使用 Env
数据结构跟踪每一个用户环境(user environment)。本实验中,你只需要创建一个环境,但是需要设计 JOS 内核支持多环境(multiple environments)。lab4 会利用这个特性,来学习多环境交互。
环境池 ID envid_t
由下图三部分组成,环境索引 ENVX(eid)
等于 envs[]
数组中的环境索引。 Uniqueifier 区分在不同时间创建但共享相同环境索引的环境。所有真实环境都大于 0(因此符号位为零)。envid_ts
小于 0 表示错误。 envid_t == 0
是特殊的,代表当前环境。
+1+---------------21-----------------+--------10--------+
|0| Uniqueifier | Environment |
| | | Index |
+------------------------------------+------------------+
\--- ENVX(eid) --/
内核维护了三个关于用户环境(user environment)的全局变量:
struct Env *envs = NULL; // 维护所有的环境
struct Env *curenv = NULL; // 当前环境
static struct Env *env_free_list; // 空闲环境列表
一旦 JOS 启动并运行,envs
指针将指向一个 Env 结构体的数组(该数组维护系统中所有的环境)。在我们的设计中,JOS 内核最多支持 NENV(#define NENV 1024
)个同时活动的环境(active environments),通常情况下,运行环境(running environments)会少的多。envs
数组将包含 Env 结构体的实例,该实例是 NENV(#define NENV 1024
)中的某一个可能的环境。
JOS 内核在变量 env_free_list
中保存所有不活跃(inactive)的 Env 结构体。这种设计使分配(allocation)和取消分配(deallocation)环境变的更简单,因为只需要从变量 env_free_list
中插入或者删除即可。
JOS 内核使用 curenv
变量来跟踪当前执行的环境。在系统启动期间,在运行第一个环境之前,curenv
被设置为 NULL。
环境状态
inc/env.h
中定义了 Env 结构体,如下所示(后续 lab 中会增加更多的字段):
struct Env {
struct Trapframe env_tf // 保存的寄存器
struct Env *env_link; // 下一个空闲的 Env
envid_t env_id; // 唯一的环境标识符
envid_t env_parent_id; // 该 env 的父级 env 的 env_id
enum EnvType env_type; // 特殊的系统环境
unsigned env_status; // 环境的状态
uint32_t env_runs; // 环境运行的次数
// Address space
pde_t *env_pgdir; // 页目录(page dir)的内核虚拟地址
};
env_tf
- Trapframe 结构体定义在
inc/trap.h
中,在当前环境未运行时(例如:当内核或其他环境运行时),维护着当前环境已保存的寄存器的值。内核在从用户模式切换到内核模式时会保存这些信息,以便以后可以在停止的位置恢复环境。
env_link
- 指向
env_free_list
中的下一个空闲 Env。env_free_list
指向列表中的第一个可用空闲环境(free environment)。
env_id
- 该值唯一标识一个环境(envs 数组中使用该值作为唯一标识)。当一个环境终止后,内核将重新分配一个 Env 结构体指向不同的环境,新环境会生成一个新的 env_id,与旧环境的 env_id 保证不相同,即使新环境复用了 envs 数组中的某一项元素(数组中每个元素都是一个插槽(slot))。
env_parent_id
- 内核在此保存了创建当前环境的环境的 env_id。通过这种方式,环境可以形成一个 “family tree”,这有助于做一些安全决策,比如:限制哪些环境可以对谁做什么操作等。
env_type
- 这用于区分特殊环境。对于大多数环境,它将是 ENV_TYPE_USER(ENV_TYPE_USER = 0)。我们将在以后的实验室中为特殊的系统服务环境介绍更多类型。
env_status
- env_status 变量,包含以下值:
- ENV_FREE:表示当前环境为非活跃(inactive)状态,因此该环境会在 env_free_list 中。
- ENV_RUNNABLE:表示该环境正在等待处理器运行。
- ENV_RUNNING:表示当前环境正在运行。
- ENV_NOT_RUNNABLE:表示该环境处于活跃状态(active),但它当前尚未准备好运行:例如,因为它正在等待来自另一个环境的进程间通信(IPC)。
- ENV_DYING:表示该环境是一个僵尸(zombie)环境。僵尸(zombie)环境将在下次捕获到内核时被释放。
env_pgdir
- 此变量保存此环境的页面目录(page directory)的内核虚拟地址。
与 Unix 进程一样,JOS 环境将“线程”(thread)和“地址空间”(address space)的概念结合了起来。线程(thread)主要由保存的寄存器(env_tf 字段)定义,地址空间(address space)由 env_pgdir 指向的页面目录(page directory)和页表(page table)定义。要运行环境,内核必须使用保存的寄存器和适当的地址空间去设置(set up)CPU。
JOS 内核的 Env 结构体类似于 xv6 中的 proc 结构体。这两种结构体都将环境(或叫做 xv6 中的进程)的用户模式(user-mode)寄存器状态保存在 Trapframe 结构体中。在 JOS 中,各个环境不像 xv6 中的进程那样有自己的内核堆栈。JOS 内核中一次只能有一个活动的 JOS 环境,因此 JOS 只需要一个内核堆栈。
分配环境数组(envs)
在 lab2 中,使用 mem_init()
为 pages[]
数组分配了内存,内核使用该表跟踪哪些页面是空闲的,哪些页面不是空闲的。现在需要进一步修改 mem_init()
,以分配一个 Env 结构体数组,称为 envs。
创建和运行环境
现在,需要在 kern/env.c
中编写运行用户环境所需的代码。因为我们还没有文件系统,所以我们会设置内核加载一个静态二进制镜像(static binary image,该二进制镜像会嵌入到内核本身)。JOS 将该二进制文件作为 ELF 可执行镜像嵌入内核中。
lab3 的 GNUmakefile 会在 obj/user/
目录下生成一些二进制镜像。如果看过 kern/Makefrag
文件,会注意到一些神奇的东西,它们将这些二进制文件直接“链接”(link)到内核可执行文件中,就像它们是 .o
文件一样。链接器命令行上的 -b binary
选项将使这些文件作为“原始”(raw)未解释的二进制文件(uninterpreted binary files)链接,而不是作为编译器生成的常规 .o
文件链接(其实,对链接器而言,这些文件根本不必是 ELF images,它们可以是任何东西,例如文本文件或图片)。如果在构建内核后查看 obj/kern/kernel.sym
文件,可以看到链接器“神奇地”生成了许多具有模糊名称的有趣变量,例如:_binary_obj_user_hello_start
、_binary_obj_user_hello_end
、_binary_obj_user_hello_size
。链接器通过修改二进制文件的文件名来生成这些变量名,这些变量为常规内核代码提供了一种引用嵌入式二进制文件的方法。
在 kern/init.c
的 i386_init()
函数中,将看到在环境中运行这些二进制镜像的代码。但是,设置用户环境的主要功能还没有完成,需要我们去补充。
// 在 env.c 文件中,完成以下函数功能:
env_init() // 初始化 envs 数组中的所有 Env 结构体,并将它们添加到 env_free_list 中。调用 env_init_percpu 函数,该函数使用 privilege level 0(内核)和 privilege level 3(用户)的单独段(separate segments)来配置分段硬件(segmentation hardware)。
env_setup_vm() // 为新环境分配页目录(page directory),并初始化新环境地址空间的内核部分。
region_alloc() // 为环境分配和映射物理内存。
load_icode() // 解析一个 ELF 二进制镜像,就像引导加载程序( boot loader)已经做的那样,并将其内容加载到新环境的用户地址空间中。
env_create() // 使用 env_alloc 分配环境,并调用 load_icode 将 ELF 二进制文件加载到环境中。
env_run() // 在用户模式下,启动一个给定的环境。
下面是调用用户代码之前的代码调用顺序图,学习下每一步完成了什么动作:
- start (kern/entry.S)
- i386_init (kern/init.c)
- cons_init
- mem_init
- env_init
- trap_init (still incomplete at this point)
- env_create
- env_run
- env_pop_tf
处理中断和异常
系统调用 int $0x30
指令是一条死胡同,一旦处理器进入用户模式,就无法返回。我们现在需要实现基本的异常(basic exception)和系统调用处理(system call handling),这样内核就有可能从用户模式代码(user-mode code)中恢复对处理器的控制。我们需要彻底熟悉 x86 中断和异常机制。
在本实验室中,我们通常遵循英特尔的中断、异常等术语。然而,诸如异常、陷阱、中断、故障和中止等术语在整个体系结构或操作系统中没有标准意义,并且在使用时通常不考虑它们在特定体系结构(如 x86)上的细微差别。当您在本实验室之外看到这些术语时,其含义可能略有不同。
学习 Chapter 9, Exceptions and Interrupts 部分:
- 中断(interrupts)和异常(exceptions)是一种特殊的控制转移(control transfer),它们的工作方式有点像未编程的调用(unprogrammed CALLs)
- 它们改变正常的程序流以处理外部事件、或报告错误、或异常情况
- 中断(interrupts)和异常(exceptions)之间的区别在于,中断用于处理
处理器
外部的异步事件,而异常用于处理处理器
在执行指令过程中检测到的条件(conditions) - 有两个外部中断源和两个异常源:
- 中断(这里的硬件中断是通过 CPU 物理层连接两个引脚实现的)
- 可屏蔽中断(Maskable interrupts),通过 INTR 引脚(pin)发出信号;
- 不可屏蔽中断(Nonmaskable interrupts),通过 NMI(Non-Maskable Interrupt,不可屏蔽中断)引脚发出信号;
- 异常
- 处理器检测(Processor detected),这些进一步分类为断层 faults、traps 和 aborts;
- 软件触发(Programmed),INTO、INT 3、INT n 和 BOUND 中的指令可以触发异常。这些指令通常被称为“软件中断”(software interrupts),但处理器将其作为异常处理。
- 中断(这里的硬件中断是通过 CPU 物理层连接两个引脚实现的)
个人理解
中断:
中断(interrupts)是 CPU 和操作系统的最基本特性之一,是支撑多用户、多进程的基础。如果没有中断(interrupts),CPU 会一直工作直到完成你交给它的工作,那么我们就无法同时做多项任务。但是有了中断(interrupts),操作系统就会触发中断(interrupts)来打断 CPU,让 CPU 听下手头的这个事情,去干其他事情,它的切换速度很快,在我们看来就是多项工作在并发来进行。
异常:
异常(exceptions)和中断(interrupts)其实是一个道理,都是用来打破 CPU 正常的工作,异常(exceptions)是 CPU 内部触发的,比如说除数为 0 时,CPU 会引发异常(exceptions),然后会转而执行处理异常(exceptions)的指令中去。另外软件也能通过指令来触发“软件中断”(software interrupts),在 CPU 看来就是程序发生了异常(exceptions)。总之:异常(exceptions)和中断(interrupts)的目的都是为了让 CPU 停下来去干其他事情
信号:
CPU 可以中断(interrupts),那么进程可以中断(interrupts)吗?比如说进程是个死循环,我想停止进程;或者说进程要接收异步 IO 的消息怎么办?进程也是可以被中断(interrupts),这里使用的是信号,进程会接收操作系统发送出来的信号,通过信号来执行特定的操作。比如说我想停止某个进程,如果是前台进程,我们通过执行 ctrl-c
来终止进程(ctrl-c
其实是一个硬件中断,操作系统收到以后会给进程发送 SIGINT 信号,SIGINT 是让进程终止的信号);如果是后台进程,一般通过 kill -9 pid
来杀死进程(kill -9 pid
原理也是操作系统会发送一个 SIGKILL 信号给进程)
# 操作系统定义的所有信号
[root@centos ~]# kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
进程为什么会处理信号,简单理解是因为进程的结构体中有一个 sigbitmap 字段用来保存信号,会处理发出来的信号。可以参考:Linux-进程信号
受保护的控制传输
// TODO: 待补充
异常和中断的类型
// TODO: 待补充
例子说明
// TODO: 待补充
嵌套异常和中断
// TODO: 待补充
设置 IDT(interrupt descriptor table)
// TODO: 待补充
0x02 Part B:页错误、断点异常、系统调用
处理页错误
// TODO: 待补充
断点异常
// TODO: 待补充 // TODO: 待补充
系统调用
用户进程通过调用“系统调用”(system calls)请求内核为它们做一些事情。当用户进程调用“系统调用”(system calls)时,处理器进入内核模式(kernel mode),处理器和内核协作保存用户进程的状态,内核执行适当的代码以执行系统调用,然后恢复用户进程。用户进程如何引起内核注意以及它如何指定要执行的调用的确切细节因系统而异。
在 JOS 内核中,我们将使用 int
指令,这会导致处理器中断(processor interrupt)。特别地是,我们将使用 int $0x30
作为系统调用中断(system call interrupt),可以使用定义好的常量 T_SYSCALL
(48=0x30)。所以必须设置中断描述符(interrupt descriptor),以允许用户进程触发该中断。请注意,中断 0x30
不能由硬件生成,因此,允许用户代码生成它不会引起歧义。
应用程序通过寄存器来传递系统调用编号(system call number)和系统调用参数(system call arguments)。这样,内核就不需要在用户环境的堆栈或指令流中四处搜索。系统调用编号(system call number)会在 %eax
寄存器中,参数(最多五个)将分别位于 %edx
、%ecx
、%ebx
、%edi
和 %esi
寄存器中。内核将返回值传回 %eax
寄存器中。调用“系统调用”(system calls)的汇编代码已经在 lib/syscall.c
中的 syscall()
中编写完成。有兴趣可以看看。
启动用户模式
// TODO: 待补充
页面错误和内存保护
// TODO: 待补充