本实验的主要任务是实现一个保护模式下的用户模式环境(进程),首先,完成数据结构维护进程,创建一个单用户环境,加载一个程序,然后运行之。最后需要实现系统调用,同时也需要处理它产生的各种常见异常。这里会涉及到很多操作系统的基本概念的实现,所以需要我们去认真理解,另外因为其他操作系统也实现了类似的机制,但是本实验的实现只使用了最基本而重要的部分,保证能够创建进程并且运行,对我们起到了“骨架”的作用,为我们理解其他系统的实现或者丰富目前系统有很好的启示。
一)实验准备
1.源码:
更新最新软件:git pull
获取软件:git checkout -b lab3 origin/lab3
2.更新的文件介绍:
本实验的主要内容是围绕三个部分展开的第一个就是进程的定义与操作;第二个就是中断处理——系统调用,各种异常;第三个为用户进程的处理流程。对于前者我们将以面向对象的方式来描述之,对于第二个我们将结合实验3前篇的介绍来具体解剖一下实现细节。
二)用户进程
操作系统为了能够管理与控制用户进程,所以需要对用户进程进行抽象,我们将以用户进程的属性最小集(包含进程的最基本的属性,能够完全实现用户进程创建与管理的操作)来描述之。
属性(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——进程id,唯一识别进程的编号
envid_t env_parent_id; // env_id of this env's parent——父进程id,用于表示进程间的关系,方便对进程进行调试与信息传递
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——进程的页目录地址,用于指定进程的内存分配
};
进程的现场:用于保存进程当前运行的寄存器值,当进程进入内核运行之后保存寄存器状态,用于之后恢复进程使用。详细定义如下:
struct PushRegs {//主要保存所有寄存器值
/* registers as pushed by pusha 如下的寄存器的顺序与实现是pusha的顺序*/
uint32_t reg_edi;
uint32_t reg_esi;
uint32_t reg_ebp;
uint32_t reg_oesp; /* Useless */
uint32_t reg_ebx;
uint32_t reg_edx;
uint32_t reg_ecx;
uint32_t reg_eax;
} __attribute__((packed));
struct Trapframe {
struct PushRegs tf_regs;
uint16_t tf_es;//软件保存es与ds
uint16_t tf_padding1;
uint16_t tf_ds;
uint16_t tf_padding2;
uint32_t tf_trapno;//软件保存中断号用于软件根据它来处理分别处理
/* below here defined by x86 hardware ,当发生中断时,x86的硬件自动将如下的寄存器保存到堆栈中*/
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));
根据如上的结构体定义,我们可以发现寄存器的顺序是固定的,必需按照硬件与处理器的要求来确定——主要是中断执行流程与指令保存所有寄存器的指令(PUSHA).
进程的特定类型:指明进程的一种属性用于赋予进程特定的访问权限——在试验5中会用到,默认为ENV_TYPE_USER。
进程的状态:进程的切换其实是一个有限状态机模型,而目前我们的使用的是经典5种状态切换模型:
其他属性都是简单易懂的,见注释即可,不需要另外介绍。
方法(kern/env.h声明,kern/env.c实现):
voidenv_init(void);//初始化用户进程的基本数据结构。初始化所有Env结构,然后将它们放到env_free_list; 然后调用 env_init_percpu,配置段地址,区分内核态与用户态。
输入:e为分配的进程地址
parent_id为输入的父进程id。
输出:分配结果,成功或者失败
intenv_alloc(struct Env **e, envid_t parent_id);//根据父进程id给用户进程分配内存,进程处于ENV_FREE状态。
输入:e被释放的进程地址
voidenv_free(struct Env *e);//释放给的用户进程的内存
输入:binary传入的用户进程执行程序地址
size为传入的进程大小
type为进程的特定类型
voidenv_create(uint8_t *binary, size_t size, enum EnvType type);//根据给定的用户执行程序地址创建用户进程。
输入:e被销毁的用户进程地址
voidenv_destroy(struct Env *e);//销毁给定的用户进程
输入:envid为给定的用户进程id
env_store得到的用户进程
checkperm是否检查权限
输出:是否获得成功
intenvid2env(envid_t envid, struct Env **env_store, bool checkperm);//通过给定的进程id,得到对应的用户进程数据结构
输入:e为运行的进程地址,之后进程处于ENV_RUNNING状态
voidenv_run(struct Env *e) __attribute__((noreturn));//运行进程。
如上为public的接口,如下为private的接口:
env_init_percpu();//执行每个处理器的初始化。
输入:e为分配虚拟内存空间的进程
输出:是否分配成功
int env_setup_vm(struct Env *e);//分配1个页目录给新进程,创建进程的内核地址空间。
输入:e为用户进程
va为分配空间给定的开始虚拟地址
len为分配空间的长度
void region_alloc(struct Env *e, void *va, size_t len);//为进程e给定的地址区间[va,va+len)分配物理地址。
输入:e为用户进程
binary为给定的用户进程程序地址
size为给定的用户进程代码长度
void load_icode(struct Env *e, uint8_t *binary, size_t size);//根据给定的用户进程程序地址,通过解析elf bin文件(像开机loader一样),然后加载它到用户空间。
输入:tf为当前要进行用户进程的运行状态。
void env_pop_tf(struct Trapframe *tf);//根据给定的用户进程的运行状态,跳入该进程运行之。
初始化:
struct Env *envs = NULL;// All environments——指向所有进程数组的地址
当系统初始化时,会为进程管理分配一段空间(NENV个进程)作为系统支持的所有进程数组,由envs指向。在mem_init()中实现内存分配,并将其映射到虚拟地址UENVS。
struct Env *curenv = NULL;// The current env——指向当前运行的进程
static struct Env *env_free_list;// Free environment list——指向没有分配的进程。
实现了类似内存管理的方式,分配一个数组保存所有的进程信息,然后用进程数据结构中的空间指针链接到空间链表中,用于记录进程空间的使用情况。它的实现在env_init()中实现。
三)中断与异常处理
中断与异常处理是与处理器构架息息相关的,我们在上一篇博客已经详细介绍了。本节就是对于它的实现,首先先看一下我们实现的逻辑流程图:
其中包含了两个流程——初始化x86中断的流程与中断处理流程。如上图所示,中断初始化流程,在trap_init()中设置中断向量表(IDT),将每个中断指向内核执行的代码——如图所示,用宏(SETGATE)设置了中断向量0(除0中断),系统调用(T_SYSCALL--48)的中断;在设置中断向量的同时,需要初始化一个任务管理段(TSS)用于发生堆栈切换时,找到保存的堆栈,目前设置为内核堆栈,因为堆栈切换只会发生在从用户进程切换到内核执行。对于中断向量设定的执行函数的实现,也是用宏的方式(在trapentry.S中)来实现TRAPHANDLER/TRAPHANDLER_NOEC(没有ErrorCode),主要执行功能为将中断号压入堆栈(如果没有ErrorCode,需要压入0来填充),然后跳转到_alltraps执行。当中断发生时,硬件会做处理(可以参考如上博客,将堆栈与处理器状态压入堆栈),然后调用我们设定的中断处理函数(TXX),从而执行_alltraps,_alltraps主要为调用trap函数执行创造条件(将struct Trapframe的结构体压入堆栈),然后调用trap。最后trap再调用中断分配函数trap_dispatch,实现根据不同的中断号,去掉用对应的中断处理。
如上所述,硬件会根据不同的中断号去调用不同的中断处理,但是为什么我们实现的内核又先将所有的中断处理汇聚到trap函数之后,又重新通过trap_dispatch去分配呢?我个人认为原因有两个:其一为为中断提供统一的调用接口,其二为中断只能调用没有参数的函数,这对于C语言实现的函数来说,获取中断时的状态很是复杂,这样处理可以简化中断处理;但是这样明显损失了程序效率。
对于中断或者异常发生在用户进程,会发生堆栈切换(使用内核的堆栈),对于trap函数的参数就是全的struct Trapframe结构,而当发生在内核状态,不会发生堆栈切换,所以对于trap函数来说,只有struct Trapframe结构的部分(没有最低的SS/ESP),但是对于我们C语言访问struct Trapframe结构的数据来说,也是正确的。
如上图所示,我们主要实现了页错误,断点异常,系统调用的3个中断处理。
对于页错误异常(T_PGFLT-14)——我们考虑的是页缺失(访问了没有定义的内存地址),我们的处理是为之分配物理空间,然后跳转到中断之前的地方继续执行。根据x86开发手册,访问异常的地址在寄存器CR2中保存着。
断点异常(T_BRKPT--3)的处理是用来允许调试器插入断点到程序代码中,进行程序调试,我们只是简单的返回到程序中断处执行。
系统中断(T_SYSCALL--48)实现的功能为用户进程提供操作系统的服务接口,我们使用硬件中断不能使用的48号中断,将传递给操作系统的参数保存到寄存器中,具体对应关系(详细的见lib/syscall.c)如下,当执行完内核服务时,我们会返回到用户进程继续执行。
参数 寄存器
Num(系统调用号) EAX
A1(参数1) EDX
A2 ECX
A3 EBP
A4 EDI
A5 ESI
返回值以EAX返回。
详细代码如下:
static inline int32_t
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4
#ifndef FAST_SYSCALL
, uint32_t a5
#endif
)
{
int32_t ret;
// Generic system call: pass system call number in AX,
// up to five parameters in DX, CX, BX, DI, SI.
// Interrupt kernel with T_SYSCALL.
//
// The "volatile" tells the assembler not to optimize
// this instruction away just because we don't use the
// return value.
//
// The last clause tells the assembler that this can
// potentially change the condition codes and arbitrary
// memory locations.
#ifndef FAST_SYSCALL
asm volatile("int %1\n"
: "=a" (ret)
: "i" (T_SYSCALL),
#else
// set_msr4fast_syscall(IA32_SYSENTER_CS,0x0,GD_KD);
asm volatile(
"pushl %%ebp\n"
"pushl %%esi\n"
"movl %%esp,%%ebp\n"
"leal 1f,%%esi\n"
"sysenter\n"
"1:popl %%esi\n"
" popl %%ebp\n"
:"=a"(ret):
#endif
"a" (num),
"d" (a1),
"c" (a2),
"b" (a3),
"D" (a4)
#ifndef FAST_SYSCALL
,"S" (a5)
#endif
: "cc", "memory");
if(check && ret > 0)
panic("syscall %d returned %d (> 0)", num, ret);
return ret;
}
系统调用流程如下:
如上图所示,我们以向终端输出字符串为例——sys_cputs.从左到右为用户进程到内核的交互流程。从服务使用层来看,用户进程使用系统调用的直接结果为使用内核的服务;从接口对等层来看,用户进程与内核的接口都是一一对应的,即系统调用的接口实现在内核中有且仅有唯一一个与之对应;从接口传递层来看,也有一一对应的关系,而且是唯一的调用syscall,但是对于用户进程来说是产生中断(INT 0x30),而对于内核来说则是调用接口对等层,其中联系二者的纽带为系统的中断机制。从上到下来看,对于用户进程来说系统调用就是一系列的接口调用,最终通过中断机制间接调用了内核的服务函数。
对于如上流程,我们需要回想BIOS为软件提供接口的方式也是中断,但是它的功能是不同的中断号提供,而我们的系统调用却是使用相同的中断号。然而,这并不妨碍我们对其的统一理解。
对于如上流程,我们需要理解,用户进程与内核进程的功能分工而协作关系,内核控制了系统的所有资源(比如,终端,磁盘,内存等),对于用户进程来说,需要使用资源就需要对内核提出功能请求,而请求的方式就是系统调用。从用户进程的角度来说,它通过系统调用使用系统资源,就像是运行在操作系统为之创建的虚拟机上。对于内核来说,它只是一套准备好的接口,时刻等待着用户进程的请求。
对于如上流程,我们还需要反思,纯粹而简单的一次调用,却辗转经历了如此多的步骤,可以想象一次系统调用的代价是很大的,对于我们应用软件的编写与操作系统的实现来说都有指导意义,比如:编写应用软件,需要尽可能少的调用系统调用;操作系统的优化来说,尽可能的使系统调用的路径缩短,一种解决方案是使用快速调用指令(sysenter/sysexit)。
四)用户进程启动代码
当有了如上知识储备,就应该创建与运行我们用户进程了。对于目前的程序状态,我们没有支持对文件系统的支持,所以我们只能将我们的用户进程的代码放入到内核中,然后加载运行之。我们以运行hello为例,代码实现为user/hello.c。对于如上的镜像设置我们需要细致的分析一下user/hello.c的编译流程:
如上图所示,我们需要编译user/hello.c时,需要依赖于libjos.a,entry.o,然后以entry.o,hello.o,libjos.a在user.ld的脚本上链接成hello,最后在被链接器将编译出来的内核kernel,用 -b binary的参数链接成最终的kernel文件。如上过程需要注意两个方面,第一是entry.o必需在第一个被链接,第二个链接生成的kernel如何访问hello的程序——这个通过readelf -s kernel发现,在kernel中的地址区间[_binary_hello_start,_binary_hello_end]即为hello的运行程序。本来还应该分析hello镜像到内存映像的图,但是在之前的博客已经分析过了,所以这里只是给出指引。
然后我们需要了解加载hello的流程:
如上所示,为了创建用户进程,我们首先需要为所有进程分配内存空间(为所有的进程分配一个数组空间),然后使用用户进程的接口来初始化进程空间的管理,最后根据在内核保存的user_hello进程的elf数据创建用于进程,接着调用env_run来运行之。在调用env_create进程时,我们首先需要在进程的内存空间分配一个进程给当前进程(env_alloc),当分配之后再调用load_icode去解析hello的elf数据映射到用户进程的地址空间中。当创建好了用户进程好了之后,就需要运行之——通过修改它的运行状态,然后切换到用户进程的地址空间(加载页目录到CR3),最后再调用env_pop_tf通过中断返回指令iret跳转到用户进程执行之。
当从内核切换到用户进程之后,没有直接运行我们定义的umain函数(类似c语言的程序进入点),这是为什么呢?为了解决这个谜团,我们还是先看看用户进程运行的流程吧,当然也是本节的主题——用户进程启动代码:
如上图所示,在运行用户编辑的C代码之前的程序即为用户进程启动代码,根据user.ld的配置与执行流程,发现用户进程代码主要实现的功能为定义一些用户进程的配置信息(比如:指向当前进程的指针),同时也创建了类似命令行参数的基本结构传入给用户的main函数两个参数:命令行参数个数与命令行参数列表。这样做的好处,是为用户进程创建必要的运行环境,保证程序运行得更加方便。当然也是可以直接运行c代码的,但是有了一段汇编的桥接代码似乎成了操作系统的调用c函数进入点的习惯,而有了这样的过度,让c语言程序运行得更加有序。
一叶说:麻雀虽小五脏俱全,我们实现的这种精简结构的进程管理与运行简单的用户进程,已经将进程创建,运行,基本管理的结构演示的一览无余,而我们现代的操作系统大都有类似的结构,当我们理解了它的实现之后,以此作为“脊骨”去分析与理解现代操作系统,将会起到事半功倍的效果。当然,也可以以此为基础来进行扩展,实现更加丰富的进程管理功能。这些基本结构的演示也让我们知道了用户进程与操作系统交互的方法,为我们实现用户进程的代码也提供了指导意义,保证我们的程序能够更加有效而且准确,同时也为我们封装基本的函数库——比如:C语言基本库,系统调用接口等提供了基础,也加深了对用户进程的加载与运行的理解,在编译c语言程序与使用编译器时也提供了很好的参考。