通过前面的学习我们知道,在前两个实验中最主要的程序就是
kern/init.c
文件中i386_init
函数,但是我们看到最后却是一个while
循环结束。
while (1)
monitor(NULL);
这个monitor
就是简单读取用户输入然后通过字符串调用给定的几个函数。我们可以把这个函数看做i386_init
的子函数,也就是程序一直在i386_init
中运行,也就是一直以内核形式运行。实验三就开始完成一个正常的操作系统的用户模式的建立,以及两者直接转换。
引言 #
什么是用户呢,我们把内核当做一个大程序,用户就是小程序,两者基本上没有差别,但是为了让众多用户和内核平稳运行,我们必须要区别对待用户和内核
首先我们要相信内核,内核是我们精心设计的,相反用户我们不能相信它,任何人都可以犯错误,我们知道计算机程序其实非常精巧,当他们完整的时候,他们可以完成你无法想象的工作,但他们有时候少掉一行指令时,他们就会瞬间崩溃,所以我们必须要保证就算用户怎么折腾都不会把内核搞垮
所以我们来考虑一下怎么来限制用户不影响整个系统,第一用户不能动内核在内存上的程序,甚至连查看的资格都没有,这就是为什么前面我们已经绞尽脑汁把内核放到高地址上确保其“高高在上”;第二个用户不能在CPU一直跑,假如它有权限一直在CPU上跑,那么内核就废了,没有CPU可以用相当于“断了胳膊”;第三用户必须要有一些内核的权限,比如说申请一些内存啥的。
接下来我们就从这几个问题出发来探索一下xv6为什么要这样设计用户空间。
用户是个程序? #
在完成用户设计之前,首先我们先做个实验,来验证一下用户是个程序。首先我们在kern/init.c
文件中i386_init
的头部加上#include <inc/elf.h>
然后在#if defined(TEST)
代码前加入几行代码
extern uint8_t _binary_obj_user_hello_start[];
struct Elf* header = ((struct Elf *) _binary_obj_user_hello_start);
assert(header->e_magic == ELF_MAGIC);
((void (*)(void)) (header->e_entry))();
这段代码你如果仔细观察过前面操作系统载入的源码(boot/main.c
中)你会发现基本上差不多。首先我们声明了_binary_obj_user_hello_start
外部变量,这个变量是通过ld
编译器将内核中用户程序hello.c
的起始地址定义来的,由于我们现在没有文件系统,内核就把用户程序一股脑链接到自己身上,在以后有了文件系统就不需要了。但是它给了我们一个便利,我们现在可以直接在内存上运行它
当然虽然它的确是个程序(assert那不会出错),但是它还是跑不起来,因为它的虚拟地址不在内核内存上,必须要像前面映射内存物理空间才能运行,但是他的确是一个程序,从这里我们得到一个结论,用户其实也就是一段程序,接下来我们就看看如何用软件与硬件的结合的设计解决上面的问题。
权限 #
首先我们来看看权限问题,x86
系统硬件给我们提供了一个分段式权限功能,在开启内存分页后,CS
寄存器16位里面拿出两位来当做权限管理,分为四级(0b00、0b01、0b10、0b11
),最小的权限最大,当然xv6只用了两级,如下图所示
PS:当然CS
寄存器拿出来三位来当权限管理,但是xv6将第三位用户和内核都设置为1,所以我们也不介绍这一位
我们在CS
寄存器的这两位称为CPL,用来区分权限。在引导和操作系统的交互这篇博客我们知道,在x86
系统中,这个CS
寄存器是标志段选择子,在GDT
表中每个表项也有两位用来标志权限,我们称他们为DPL
,他们的意义也就是CS
寄存器选择这个段时他们最低拥有的权限。
当然在GDT
表中的段选择子也有一个表示权限的称作RPL
,这三者的关系在这里面介绍的很详细,RPL
是历史遗留问题但是操作系统基本上没有使用这个功能,所以这里我们也不解释,感兴趣可以看一下前面的介绍。
在kern/env.c
的结构体gdt
我们声明了一个GDT
表,并且声明了一个系统段和用户段。
这两个段就是我们权限的基础我们接下来通过实验来验证权限位对操作系统的保护
我们首先在user/badsegment.c
中加入一句汇编
asm volatile("ljmp %0,$1f\n 1:\n" :: "i" (0x08)); // 将CS设置为0x08
我们尝试将CS
设置为0x08
(内核段)结果直接引发一个General Protection
的异常,但是当我们尝试执行这句
asm volatile("ljmp %0,$1f\n 1:\n" :: "i" (0x18)); // 将CS设置为0x18
我们发现程序没有引发异常(这段的含义是跳转到用户段,由于已经在用户段,所以没有触发异常),这说明x86
通过CS
寄存器很好的实现了内核和用户的保护(用户不能直接跳到内核去执行代码)
在user/faultwritekernl.c
和user/faultreadkernl
中我们发现尝试写入或者读取内核也失败了,这是通过前面章节的内存分页权限实现的,这里就不介绍了。
现在我们知道,通过我们不懈努力内核和用户直接存在一个鸿沟,但是这时候摆在我们面前很大的问题,内核拥有一些非常重要的权限比如申请内存,如果用户没有这个权限那么功能非常受限,所以我们就要提到一个内核和用户交互的手段:Trap。
Trap #
其实这个Trap包括内部中断、软中断、异常。但是xv6统一将他们叫做陷阱(Trap),这个Trap主要完成从用户到内核的一种跳转,我们就不介绍内部中断和异常,因为这些都是系统完成,我们来介绍了一下软中断,这个我们人为可以操作,而且我们可以把其他中断、异常当做系统去帮我们调用这个指令
int $n
很简单x86共定义了256个中断向量,存在物理内存0x000
-0x3ff
之间,每个存贮一个cs
值一个eip
的值,这个n只要是在256直接就行,当调用这个的时候我们直接调到那里读取cs
和eip
的值。
在正式介绍Trap
之前我们要简单的介绍一下两个知识点
IRET #
这是iret
是一个机器码,我们前面介绍了无论是操作系统还是用户没有办法直接跳段(会引发General Protection fault
),而且我们知道前面只是测试了跳代码段,我们知道两个不同的程序,不仅仅是代码段不同,堆栈段也要不同,才能保证各个程序的独立。所以系统为了解决这个问题,直接提供一个iret
指令,这个全名应该叫中断返回
,它的功能其实很简单,就是实现上面的跳代码段和堆栈段还有恢复EFLAGS
寄存器值,这个主要应用在从系统跳转到用户上面。
感兴趣的可以看看这个详细资料,接下来我们看看在xv6
里面如何使用iret
来实现跳段
在kern/env.c
的env_pop_tf
函数实现了切换程序段的功能
asm volatile(
"\tmovl %0,%%esp\n"
"\tpopal\n"
"\tpopl %%es\n"
"\tpopl %%ds\n"
"\taddl $0x8,%%esp\n"
"\tiret"
: : "g" (tf) : "memory");
首先第一个就是把tf指针指向的值赋给esp
寄存器,要想知道这个操作的意思就必须知道,一个结构体其实在程序里面就是一块连续的内存值,所以这里就是把esp
的值指向tf
代表的Trapframe
结构体的开始位置,所以我们得看看这个Trapframe
组成是什么
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));
第一个是PushRegs
结构体,这个对应我们第一个操作popal
,将PushRegs
所有值全部从堆栈中取出,我们发现当到iret
操作时,正好对应取出代码段还有堆栈段和EFLAGS
寄存器,所以其实iret
的操作非常简单,只是将三个取出指令和成了一个。
TSS #
前面我们捋了捋从系统跳转到用户,因为我们给用户的都是刚新建的值,所以这个并没有什么问题,但是你要想一想,当用户想跳转到系统的时候,这个时候它也必须要切换堆栈,因为用户可以还没有准备好(有时候可以是因为堆栈内存不够,如果把寄存器值强行插值到用户堆栈会引发二次异常)。但是假如使用操作系统的堆栈,在我们切换之前系统必须要知道内核堆栈在哪
所以x86提供了一个tr
寄存器给我们使用,这个寄存器的位数为16位,专门存贮一个选择子(类似前面段选择子),这个选择子base
地址必须指向一块空余空间。前面知道我们要想从用户跳操作系统,必须知道操作系统的堆栈(中断向量表提供了代码段cs:ip),所以x86干脆规定了这个空间名字为TSS
,并声明了一大串寄存器的值备用,当然这些是硬件规定的,软件用不用无所谓,但是硬件会在跳转的时候,读取里面固定位置的值,也就是我们需要的堆栈段ss:esp
,在inc/mmu.h
的Taskstate
结构体中声明了这段内存规定的字段
所以我们在kern/trap.c
中声明了(ss:esp)
ts.ts_esp0 = KSTACKTOP;
ts.ts_ss0 = GD_KD;
这样我们在跳转的时候就能获取到内核的堆栈段,从而进入内核模式,当然硬件设定TSS
的作用一开始为了考虑任务切换,它也提供了相应的指令,但是需要200左右时钟周期来完成,所以我们xv6
嫌弃其速度太慢,只是使用了其保存内核堆栈地址的作用,所以在xv6
中TSS
只是一个“数据库”而已。
介绍完前面的基础知识之后,接下来的就是这个Trap
,前面我们知道在得到int
这个指令时候这个时候就开始一个Trap
开始。
这个时候硬件接管程序控制,你也可以认为跳转到硬件代码去了。在给的资料第三章的X86 Protection
里面介绍了这个过程,我们将前面几个步骤很我们前面给的资料联系起来
后面推寄存器值就不解释了,同
Trapframe
的反向结构相同,之所以会这样,是因为堆栈地址是向下生长,而程序地址是向上生长的
硬件这部分操作为
- 获取中断向量表第n个的地址(n为
int
后面整数)
然后开始权限检查,这个也是为什么程序不能随意调用系统资源的原因
- 检测DPL与CPL的大小关系
这个DPL
不是前面GDT
里面的,这个是存贮在中断向量表中CS
里面的值,这个设置可以让我们给每个中断向量设置一个权限,看它允许是用户调用还是系统调用,如果只允许系统调用,那么用户调用直接引发General Protection Fault
(13号中断),在我们的xv6中,我们只允许用户调用Debug
和System Call
中断,其他只能由系统调用
- 判断保留
esp
和ss
,检测目标的段与当前段
当我们在操作系统中出现中断的时候,如果硬件还傻乎乎的将操作系统的堆栈归位那么就进入死循环了,所以这个目的就是避免同属一个段的时候不会切换堆栈
- 当跳段的时候,从
TSS
中取到esp
和ss
切换堆栈段
这个就是前面弹到的TSS
的功能,接下来我们所以推出去的值全部存贮在要跳的段的堆栈上面了。
推完Trap
程序的寄存器值,保留好“犯罪现场”后,就可以进入内核进行处理这个异常了。
至此我们介绍完了严格有序的处理从用户到内核的跳转,当我们处理完后就可以通过iret
回到用户程序,这样就完成了用户拥有内核权限的设计。
用户时间控制 #
接下来的用户使用CPU时间设置也是通过这个这个Trap
的中断实现,你可以理解为硬件在每个CPU上在每隔个10ms
的时候就触发一个中断。这样到内核的时候,内核发现是时间中断,就保留用户的寄存器值到用户自己空间(Env
里面有一个env_tf
变量可以存贮这些信息),然后调用其他或者这个程序,这样的话,我们就实现了一个时间控制。
PS:这里我们提一下这个Env
结构体,内核初始化了1024个这个结构体,这说明我们可以同时拥有1024个用户程序运行,这个结构体就是用户环境,也就是我们在Unix
系统上输入ps
中的pid
(每个pid
代表一个程序)
总结 #
至今我们介绍了通过一个简简单单的Trap
操作就实现了复杂的权限管理和空间隔离,虽然操作系统看起来非常复杂,但其实都是由简单干练的概念搭建起来的,因为Unix
的哲学就是简单至上
。
引用 #
https://stackoverflow.com/questions/6892421/switching-to-user-mode-using-iret
https://stackoverflow.com/questions/36617718/difference-between-dpl-and-rpl-in-x86