实验手册
介绍
这个实验将实现需要获取受保护的用户模式环境运行的基本内核设施。为了跟踪用户环境、创建用户环境、加载程序并运行,你将增强JOS内核的数据结构。你同样也需要让JOS内核拥有处理用户环境使用的系统调用和错误的能力
注意:这个实验中,环境
和进程
是交叉使用的-都是指允许你运行程序的抽象。我们介绍"环境"属于而不是传统的"进程"术语,是为了强调JOS环境和UNIX进程提供不同的接口,没有提供相同的语义
开始
使用在lab2中修改的提交,拉取最新课程代码,切换到lab3的分支,并合并lab2
cd xv6-lab
git pull
git checkout lab3
git merge lab2
lab3包含一些你需要浏览的源码文件:
inc/env.h
用户模式环境的公有定义inc/trap.h
陷入处理的公有定义inc/syscall.h
从用户环境到内核的系统调用公有定义inc/lib.h
用户模式支持库的公有定义kern/env.h
给用户模式环境使用的内核私有定义kern/env.c
实现用户模式环境的内核代码kern/trap.h
陷入处理的内核私有定义kern/trap.c
陷入处理代码kern/trapentry.S
陷入处理入口的汇编语言kern/syscall.h
系统调用处理的内核私有定义kern/syscall.c
系统调用实现代码lib/Makefrag
构建用户模式库的Makefile分段,obj/lib/libjos.a
lib/entry.S
用户环境入口的汇编语言lib/libmain.c
从entry.S
调用用户模式库的代码lib/syscall.c
用户模式系统调用函数lib/console.c
用户模式关于putchar
和getchar
的实现,提供console的I/Olib/exit.c
用户模式exit
的实现lib/panic.c
用户模式panic
的实现user/*
检测lab3代码的测试程序
此外,在lab2中处理过的源码文件,在lab3中也被修改了。查看两个差异git diff lab2
你可能需要看下lab实验工具,因为它包含了调试用户代码和与本实验相关的一些信息
实验要求
就是实验过程的一些要求,关于提交和分数的,就不管了。
内联汇编
这个实验,尽管不使用内联汇编也能完成这个实验,但是你可能会发现GCC内联了汇编语言是非常有用的。至少,你需要能够理解源码中已经存在的内联汇编语言片段(asm
表达)。你能在课程reference materials
Part A: 用户环境和异常处理
inc/env.h
包含了JOS中用户环境的基本定义。现在阅读它,内核使用Env
数据结构来跟踪每一个用户环境。这个实验,你初始化创建一个环境,但是你需要设计JOS内核来支持多个环境。lab4将利用这个特性,允许用户环境fork
其他环境
正如你在kern/env.c
中看到的一样,内核维护了三个主要全局变量:
struct Env *envs = NULL; // 所有的环境
struct Env *curenv = NULL; // 当前环境
static struct Env *env_free_list; // 空闲环境列表
一旦JOS开始运行,envs
指针指向一个代表所有系统环境的Env
结构体数组。尽管在一个给定的时间内只能有很少的运行环境, 但是在我们的设计中, JOS内核支持最大NENV
同时活跃的环境。(NENV
是一个定义在inc/env.h
的常量)。一旦被分配了,envs
数组将包含一个单一的Env
数据结构
JOS内核用env_free_list
保存了所有不活跃的Env
结构体。这个设计让分配和释放环境变得简单,因为它仅只能添加或删除
内核使用curenv
变量来跟踪给定的时间,当前执行的环境。在启动的时候,第一个环境运行之前,curenv
初始化为NULL
环境状态
Env
结构体定义在inc/env.h
,尽管在后面的实验中,会添加更多的字段。具体如下:
struct env {
struct Tapframe env_tf; // 保存的寄存器
struct Env *env_link; // 下一个空闲Env
envid_t env_id; // 唯一的env_id
envid_t env_parent_id; // env_id的父id
enum EnvType env_type; // 表明特殊系统环境
unsigned env_status; // 环境状态
uint32_t env_runs; // 环境已经运行的次数
pde_t *env_pgdir; // 内核page dir的虚拟地址
};
具体的字段含义:
env_tf
: 这个结构定义在inc/trap.h
里面,当环境没有在运行的时候,为环境保留的寄存器。当从用户切换到内核模式时,内核会保存它,以便于后面切换回来的时候恢复env_link
: 这是env_free_list
的下一个Env
链表。env_free_list
指向列表中第一个空闲的环境env_id
: 当前使用的环境唯一id。在一个用户环境终止后,内核可能重新分配相同的Env
结构体给不同的环境-但是新的环境将使用不同的env_id
,尽管新的环境在envs
数组里面使用相同的槽env_parent_id
: 创建这个环境的环境env_id
。说白了就是父env_id
env_type
: 用来区别特殊的环境。对于大多数环境,都是使用ENV_TYPE_USER
,在后面的实验中,会介绍一些其他的系统服务环境env_status
: 保留下面值中的一个:ENV_FREE
: 表明Env
结构是非活跃的,因此在env_free_list
中ENV_RUNNABLE
: 可以运行的,就是在等待处理器运行ENV_RUNNING
: 正在运行ENV_NOT_RUNNABLE
:Env
结构体当前是活跃的环境,但是不准备运行:例如,等待一个其他环境的交互(IPC)ENV_DYING
: 表明是一个僵尸环境。僵尸环境可能在下一次陷入内核的时候会被释放。将在lab4中处理这个
env_pgdir
: 这个变量保存了这个环境页目录的内核虚拟地址
像一个Unix进程一样,一个JOS环境杂糅了线程和地址空间的概念。线程主要通过保存的寄存器定义(env_tf
字段),地址空间通过指向页表和页目录指针定义(env_pgdir
字段)。为了运行一个环境,内核必须设置CPU保存的寄存器和合适的地址空间
struct Env
是和xv6的struct proc
类似的。都用Trapframe
结构体保存了环境的用户模式寄存器状态。在JOS中,单独的环境没有自己栈,这点和xv6一样。JOS在内核中只能有一个环境活动,所以JOS是一个单内核栈
分配环境数组
在lab2中,在mem_init
中为pages[]
数组分配了内存, 内核用来跟踪页面空闲与否的表。现在需要修改mem_init
给Env
结构分配一个类似的数组,称为envs
进行练习1
创建和运行环境
为了运行一个用户环境,你需要在kern/env.c
中写一些代码。因为我们没有文件系统,所以我们还是让内核加载一个静态二进制镜像,镜像文件已经嵌入在内核内部。JOS把二进制嵌入到内核中作为一个ELF可执行镜像。
lab3的GNUMakefile
文件在obj/user/
目录生成了一些二进制镜像。如果你看过kern/Makefrag
,你会注意到一些链接这些二进制到内核可执行的魔法。链接器的-b binary
选择项能够把这些文件以原生的方式链接进去,而不是作为由编译器生成的常规的.o
文件。(由于链接器的关系,这些文件不必生成ELF镜像-他们能够是任何形式,比如文本文件或图片)在构建之后,如果你看了obj/kern/kernel.asm
文件,你会注意到链接器已经魔法产生了一些有趣的符号,比如_binary_obj_user_hello_start
, _binary_obj_user_hello_end
和_binary_obj_user_hello_size
。链接器通过二进制文件名称生成这些符号;这些符号用一个引用嵌入的二进制文件的方式提供了常规的内核代码
在kern/init.c
的i386_init()
方法中,你可以看到在一个环境里运行这些二进制镜像的代码。然而,设置用户环境的关键函数并不完整,你需要填充它们
进行练习2
练习2中,会出现Tripple fault
的错误,这是因为JOS还没有设置硬件从用户空间进入到内核空间。可以通过make qemu-nox-gdb
和make gdb
来调试运行,查看整个过程
处理中断和异常
如果做了上面的调试运行,可以看到在int $0x30
的系统调用就结束了:一旦进程进入用户模式,就没有办法回到内核模式了。你需要实现基本的异常和系统调用,以便于内核有可能从用户模式恢复控制权。你需要做的第一件事就是熟悉x86的中断和异常机制
进行练习3
这个实验,遵循Intel中断和异常的术语。然而,异常(exception)、陷入(trap)、中断(interrupt)、故障(fault)和中止(abort)在架构或操作系统之间没有标准的定义,并且经常也不怎么考虑其中的细微差别。如果你在课程之外看见了,也应该知道他们并没有什么不同
受保护控制转移的基础
异常和中断都是受保护控制转移,异常和中断都会导致处理器从用户切换到内核(CPL=0),不会给用户模式代码任何机会干扰内核或其他环境的函数。在Intel术语中,一个中断是由一个异步事件造成的受保护转移,比如I/O设备通知。异常是由同步事件造成的受保护转移,例如除0或者无效的内存
为了确保这些受保护的转移是受保护的,处理器设计了中断/异常机制,以便于当前代码在中断或异常发生的时候,不能随意进入内核。相反,处理器保证了只有在可控的条件下才能进入。在x86平台上,两个机制一起工作来提供保护:
中断描述符表。处理器保证中断和异常只能导致内核在特定的约定的入口进入,而不是在发生中断或异常的代码运行时。
x86允许有256个不同的中断或异常进入点进入内核,每一个都是不同的中断向量(interrupt vector)。一个向量从0到255。一个中断的向量是由中断源决定的:不同的设备,错误条件和程序对内核的请求都会生成不同向量的中断。CPU把向量作为处理器的中断描述符表(IDT)的索引,中断描述符表是在内核私有内存中的,跟GDT类似。处理器从这个表中合适的入口加载:
- 加载到指令指针(EIP)寄存器的值,指向处理异常类型的内核代码
- 加载到代码段(CS)寄存器的值,这个值在0-1位包含了处理异常运行的特权等级。(在JOS中,所有的异常都是在内核中处理的,都是特权等级0)
任务状态段。在中断和异常发生之前,处理器需要一个地方保存旧处理器状态,比如
EIP
和CS
的原始值,以便于异常处理后能够恢复之前的状态并从中断的地方继续执行。但是这个保存旧处理器状态的区域必须不受非特权用户模式代码的保护;否则有问题的用户代码会影响内核由于这个原因,当x86处理器陷入中断,会导致特权等级从用户到内核模式的改变,同样也会切换到内核内存栈。
TSS
(task state segment)结构体指定了段选择描述符和栈地址。处理器(在新的栈)压入SS
ESP
EFLAGS
CS
EIP
和可选择的错误代码。然后从中断描述符加载EIP
和CS
,并设置ESP
和SS
指向的栈尽管
TSS
是很大的,也能潜在为多种目的服务,但是JOS只用它来定义内核栈,当从用户切换到内核模式时,处理器应该切换到内核栈。因此,在JOS中,内核模式是x86架构的特权等级0,进入到内核模式是,处理器可以使用TSS
的ESP0
和SS0
字段来定义内核栈。 JOS不使用TSS
其他字段
异常和中断类型
x86处理器能在内部生成所有的同步异常使用0到31的中断向量,因此映射到IDT的0-31人口。例如,一个页错误总是通过向量14造成一个异常。中断向量大于31的部分是用作软件中断(software interrupts),可以用int
指令来生成,或者外部设备造成的异步硬件中断。
这一节,将扩展JOS处理x86内部生成的0-31异常向量表。在下一个部分,将让JOS处理软件中断向量48(0x30),JOS用它来作为系统调用中断向量。在lab4中,将扩展JOS处理生成的硬件中断,比如时钟中断
例子
假设处理器正在用户环境执行代码,然后遇到了除0的指令
- 处理器切换到由
TSS
的SS0
和ESP0
字段定义的栈,在JOS中,SS0
和ESP0
保存的是GD_KD
和KSTACKTOP
- 处理器把异常参数压入内核栈,起始地址为
KSTACKTOP
+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20 <---- ESP
+--------------------+
- 除以0的中断,在x86上是中断向量0,处理器读取IDT的入口0,然后设置
CS:EIP
指向处理函数 - 处理函数获得控制权然后处理异常,例如终止用户环境
对于一些特定的x86异常类型,除了上面5个标准字段外,处理器还会压入一个错误码(error code)。页错误异常(14向量)是非常重要的。查看80386手册,确定哪些异常需要压入错误码,错误码的意思又是什么。当处理器压入一个错误码,然后从用户模式进入时,栈的开始程序如下:
+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20
| error code | " - 24 <---- ESP
+--------------------+
嵌套的异常和中断
处理器可以从用户和内核模式接受异常和中断。只有当从用户模式进入到内核模式时,处理器才会在压入旧寄存器和通过IDT调用合适的异常处理之前自动切换栈。如果当中断或异常发生的时候,处理器已经是内核模式(CS
寄存器的低2位已经是0),CPU仅仅在相同的内核栈压人更多的数值。这样内核可以优雅处理由内核造成的嵌套异常。在实现保护的过程中,这个能力是非常重要的,在系统调用部分将会看到
如果处理器已经是内核模式,接受了一个嵌套的异常,因为不用切换栈,所以也不用保存旧的SS
和ESP
。对于没有错误码的异常而言,异常处理的入口如下:
+--------------------+ <---- old ESP
| old EFLAGS | " - 4
| 0x00000 | old CS | " - 8
| old EIP | " - 12
+--------------------+
对于有错误码的异常而言,跟之前一样,在旧的EIP
之后压入错误码
处理器的嵌套异常的能力有一个非常重要的警告。在内核模式下,如果处理器接受了一个异常,由于某些原因没有在内核栈上压入旧的状态(比如没有栈内存空间了),处理器不能自动恢复,只能重启。不用说的是,内核应该设计成不会导致那种情况的发生
设置IDT
现在有了在JOS设置IDT和处理异常的基本信息。现在,会设置IDT来处理0-31的中断向量。后面会处理32-47的(设备IRQs)的中断
头文件inc/trap.h
和kern/trap.h
包含了中断和异常相关的重要定义。kern/trap.h
文件是内核私有的,然而inc/trap.h
的定义是用户等级程序和库共有的
注意:一些0-31的异常被x86保留使用。因为永远也不能被处理器生成,你如何处理它们,真的不重要
总体控制流程如下:
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。每个处理函数应该在栈上构建一个struct Trapframe
(参考inc/trap.h
)并用指向Trapframe
的指针调用trap()
(在trap.c
中),然后处理异常/终端,或者分发给特定的处理函数
进行练习4
Part B: 页错误,断点异常,系统调用
处理页错误
页错误,中断向量14(T_PGFLT
)是非常重要的。当处理器发生了页错误,它会把造成错误的线性地址保存在一个特殊的处理器控制寄存器(CR2
)。在trap.c
中,需要提供一个特别的函数,page_fault_handler
来处理页错误异常
进行练习5
在实现系统调用的时候,会重新定义页错误处理
断点异常
断点异常,中断向量3(T_BRKPT
)通常用来允许调试者通过临时插入相关程序指令(int3
)在代码中插入断点。在JOS中,用户进程也可以使用JOS内核监视器来伪装成系统调用陷入内核。如果将内核监视器作为原始调试器,这种做法是合适的。lib/panic.c
中的panic
用户模式实现在展示崩溃信息后调用一个int3
进行练习6
系统调用
用户进程通过系统调用向向内核发请求。当用户进程调用一个系统调用时,处理器进入内核模式,处理器和内核合作保存用户进程状态,内核执行合适的代码来处理系统调用,然后恢复用户进程。至于如何进入内核,并且内核如何调用,每个系统都不相同
在JOS内核,使用int
指令,这个指令会造成处理器中断。特别是,使用int $0x30
作为系统调用中断。已经定义了一个常量T_SYSCALL
(48也就是0x30)。必须设置中断描述符,来让用户进程造成那个中断。注意中断0x30
不能由硬件生成,所以不会因为允许用户代码生成而造成歧义
程序在寄存器中会传递系统调用参数。这样,内核不用从用户环境的栈拿。系统调用序号保存在%eax
中,参数保存在%edx
,%ecx
,%ebx
,%edi
。内核会把返回数据保存在%eax
中。系统调用的汇编代码已经写好了,在lib/syscall.c
的syscall()
函数中。
进行练习7
用户模式启动
用户程序在lib/entry.S
的顶部运行。在一些设置之后,代码调用lib/libmain.c
的libmain
。可以修改libmain
来初始化全局指针thisenv
指向环境的struct Env
的envs
数组。(注意在Part A部分lib/entry.S
已经定义了envs
指向UENVS
)。提示:查阅inc/env.h
使用sys_getenvid
在hello程序的案例中,libmain
随后调用user/hello.c
的umain
。注意在打印了"hello, world"之后,尝试获取thisenv->env_id
。这也是为什么很容易出错。现在已经初始化了thisenv
,应该不会出错了。如果仍然出错,可能需要把UENVS
映射成用户可读(回到Part A的pmap.c
;这是第一次使用UENVS
)
进行练习8
页错误和内存保护
内存保护是一个操作系统关键的特征,确保一个程序的bug不会影响其他程序或操作系统本身
操作系统通常以硬件支持来实现内存保护。操作系统会通知硬件虚拟地址有效与否。当程序尝试获取一个无效或没有权限的地址时,处理器在造成错误的指令地方停止程序,然后陷入到内核,并尝试操作。如果错误修复了,内核可以修复然后让程序继续运行,如果不能修复,程序也不能运行,因为不能从这个错误跳过去
一个可修复的错误例子:自动扩展的栈。在很多系统中,内核初始化分配一个栈页面,然后如果程序错误在栈下面获取页面,内核将自动分配这些页面,让程序继续运行。内核只需要分配一个足够程序需要的栈内存就可以了,但是这样就有一个程序能在任意堆栈上运行的错觉
系统调用在内存保护中有一个有趣的问题。大多数系统允许用户将指针传递给内核。这些指针指向用户能读写的缓存。内核在执行系统调用的时候,会解引用。这就会带来两个问题:
- 内核的页错误可能比用户程序的页错误会更严重。如果在操作内核数据结构的时候发生了页错误,这是内核的bug,错误处理可能让内核崩溃(甚至是整个系统)。但是当内核解引用用户程序的指针的时候,需要有一种方法来记住造成页错误的用户程序的指针解引用
- 内核通常比用户有更多的内存权限。用户程序可能传递一个指针给系统调用,系统调用指向了内核可以读写但是用户不能读写的内存。内核必须小心不要被这些解引用指针所欺骗,因为这可能造成内存泄漏或破坏内核的完整性
由于这两个原因,内核在处理用户程序的指针的时候必须非常小心
用审查所有从用户空间传递到内核的指针的机制来解决这个问题。当程序传递指针给内核的时候,内核会检查地址在地址空间的用户部分,页表允许内存操作
因此内核永远不会因为解引用用户指针而造成页错误。如果内核有页错误,应该崩溃或者终止
进行练习9
进行练习10