MIT6.828_JOS进程创建

MIT6.828_JOS进程创建

有关进程的内容有很多,本文先理清JOS进程相关的数据结构,JOS怎样创建进程,JOS的第一个进程做了写什么。

进程相关数据结构

与xv6系统struct proc结构类似,JOS系统有一个struct Env作为进程控制块(PCB)。

顺带一提,linux使用struct task_struct表示每一个可调度实体,只要有task_struct就能够被OS调度执行,不区分进程还是线程。(JOS和xv6没有涉及到内核线程,课程的作业中对用户级线程做了补充)

JOSstruct Env如下所示:

struct Env {
	struct Trapframe env_tf;	// Saved registers, 进程从用户转到内核态时的上下文记录
	struct Env *env_link;		// Next free Env , 用来链入 env_free_list
	envid_t env_id;			// Unique environment identifier, 相当于 process id
	envid_t env_parent_id;		// env_id of this env's parent, 相当于parent process 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, 相当于 ticks,对之后的调度有用
	// Address space
	pde_t *env_pgdir;		// 本线程的页目录表,表示了这一个进程所拥有的address space
};

与xv6相同,JOS为了运行一个用户进程,内核必须设置好Env中的env_tf 和 env_pgdir,前者代表了进程的执行流程而后者代表了进程的address space。与xv6不同的是,JOS只需要一个内核栈!

JOS用一个数组记录所有的Env,一共有1024个Env,在一开始时它们的状态都是free,且都被链入env_free_list中。

JOS还用一个curenv变量记录当前正在运行的进程,在许多场合中非常有用,与linux的current有类似的思想,但是current是一个宏,它通过esp的计算得到task_struct 结构。

pmap.c文件下的mem_init()分配Env数组的物理内存,然后将其映射到虚拟地址UENVS上。

envs = (struct Env*)boot_alloc(sizeof(struct Env)*NENV);
memset(envs, 0, sizeof(struct Env)*NENV);
boot_map_region(kern_pgdir, UENVS, PTSIZE,PADDR(envs),PTE_U);

image-20221026223411016

创建并运行初始进程

注意,本节的创建流程只针对操作系统初始化后,一开始运行的两个进程,一是第一个用户进程,二是文件服务进程,我将在后头的JOS文件系统中整理,本文的重点则是第一个用户进程,看看它做了什么。

流程图如下所示:

image-20230216213252300

创建流程中,有几点要注意

  1. 上面也说了,此时文件系统还没有初始化,因此ELF文件实际上是直接链接在内核中的,可以使用链接器产生的符号直接读取

  2. enc_alloc 除了要把进程状态初始化:

    // Set the basic status variables.
    e->env_parent_id = parent_id;
    e->env_type = ENV_TYPE_USER;
    e->env_status = ENV_RUNNABLE;
    e->env_runs = 0;
    

    还要把env.tf,即进程的trapframe结构初始化

    memset(&e->env_tf, 0, sizeof(e->env_tf));
    // 将 tf 中的段寄存器初始化为用户段选择子
    e->env_tf.tf_ds = GD_UD | 3;
    e->env_tf.tf_es = GD_UD | 3;
    e->env_tf.tf_ss = GD_UD | 3;
    e->env_tf.tf_cs = GD_UT | 3;
    // 设置从中断返回的用户栈地址
    e->env_tf.tf_esp = USTACKTOP;
    

    其中tf_esp 设置为用户栈的顶部,如下图所示:

    image-20230216212738908

  3. env_setup_vm(),分配一个物理页作为用户环境的pgdir,复制kern_pgdir的内核映射部分,然后将pgdir映射到UVPT处

    for (int i = PDX(UTOP); i < NPDENTRIES; i++) { // 复制内核部分
        e->env_pgdir[i] = kern_pgdir[i];
    }
    e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U; // 映射页目录页
    
  4. load_icode在将elf文件加载值内存的同时,还要手动设置env.tf结构中eip的值,这样最后中断返回时才能返回值用户程序

    e->env_tf.tf_eip = elf->e_entry; // 设置新环境的初始运行地址
    
  5. load_icode函数中要将页表切换至用户环境,这样方便了 memmove的操作,记得在完成操作后还要切换回内核环境

    lcr3(PADDR(e->env_pgdir)); // 切换到用户程序的页表
    // 
    lcr3(PADDR(kern_pgdir)); // 切换回kernel的pgdir
    

    注意最后为进程分配了用户栈,否则当用户进程执行中断返回时,由于esp寄存器中的地址无效将发生pagefault错误。

  6. 使用memmove将文件内容写入对应的内存,与bootloader加载内核不同,这里把程序加载到虚拟地址而不是物理地址处

    memmove((void*)ph->p_va, (void*)(binary + ph->p_offset), ph->p_filesz);// ph->p_va,不是ph->p_pa
    
  7. 考虑未初始化的变量,一个段在内存中的大小总是大于等于它在文件中的大小,因此要把大于的那部分初始化为0,这个部分就是.bss段。这也对应了C语言“未初始化变量默认为0”的概念,这其实不是编译器的功劳,而是操作系统。

    memset((void *)(ph->p_va + ph->p_filesz), 0, ph->p_memsz - ph->p_filesz);
    

env_create()后的物理内存布局以及虚拟地址的映射?注意这里已经有了两个虚拟地址空间,一个是内核的(这样说似乎并不准确,因为一般意义上内核是没有地址空间的,但是JOS中确实有一个kerpgdir代表了内核的地址空间),一个是我们刚创建的用户环境,而且内核那块的虚拟地址空间是相同的

下图是我们刚创建的用户环境的地址空间,内核映射没有画出来,和之前的内核映射是一样的。即虚拟地址UTOP之上的映射没有变,除了uvpt处的页目录表映射变成了用户环境的页目录表:

image-20221027104259301

第一个用户程序至此已经创建完成,那么该怎么使其执行呢?

在这之前的所有代码执行,都是在内核态完成的,要想运行用户态的代码只有一种办法,那就是中断返回。但是之前用户程序还从来没有通过中断进入内核,谈何中断返回?还是看看实际代码怎么做的吧,虽然JOS是个教学系统,但大致思想是相通的。

首先调用sched_yeild由调度程序选取一个可运行的进程,也就是说选取一个env.state == RUNNABLE的env结构,至于怎么选取这就涉及到CPU调度功能的实现,我将在之后的博客中详细说明。

选取一个env结构后,调用env_run(env)

// kern\sched.c
void
sched_yeild(void)
{
 	// ...   
    env_run(&envs[j])
    // ...
}
// kern\env.c
void
env_run(struct Env *e)
{

    //1. 如果当前CPU已经在运行进程了,先将当前CPU运行的进程状态从RUNNING设置为RUNNABLE
	if (curenv && curenv->env_status == ENV_RUNNING) {
		curenv->env_status = ENV_RUNNABLE;
	}
    //2. 记录本CPU即将运行的进程, 并修改状态
	curenv = e; 
	curenv->env_status = ENV_RUNNING;
	curenv->env_runs++; // 记录运行实践+1
    //3. 切换页目录
	lcr3(PADDR(curenv->env_pgdir)); 
	//4 从中断返回至用户态
	env_pop_tf(&(curenv->env_tf)); // 中断返回至用户态
}

最后执行env_pop_tf,即从中断返回:

void
env_pop_tf(struct Trapframe *tf)
{
	curenv->env_cpunum = cpunum();
	unlock_kernel(); // 由于是多核系统,因此涉及到内核锁的释放和获取,将在锁的相关博文中详细阐述
	asm volatile(
		"\tmovl %0,%%esp\n" // esp 指向trapframe 末尾
		"\tpopal\n"
		"\tpopl %%es\n"
		"\tpopl %%ds\n"
		"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
		"\tiret\n"// 从中断返回, eip 在load_icode()设置 或者 在trapentry.S中保存
		: : "g" (tf) : "memory"); 
	panic("iret failed");  /* mostly to placate the compiler */
}

由于第一个进程从未中断进入过内核,因此trapframe中保存的值是我们在创建进程过程时,手动设置的,见上面创建流程的注意点2和4。返回成功后,用户程序的第一条指令位于虚拟地址的0x800020(即eip = 0x800020), 初始esp即为USTACKTOP。

初始进程做了写什么

从i386_init()函数创建了第一个进程,可以看出,第一个用户进程是user/icode.c文件的代码

// user\icode.c
void
umain(int argc, char **argv)
{
    //... 省略打印操作系统信息的大妈

    cprintf("icode: spawn /init\n");
	if ((r = spawnl("/init", "init", "initarg1", "initarg2", (char*)0)) < 0)
		panic("icode: spawn /init: %e", r);
	cprintf("icode: exiting\n");
}

JOS中spawnl的作用与linux的fork + exec功能类似(spawnl将在之后的文章中分析),那么icode的主要逻辑就是加载init用户进程,去掉一些不太重要以及测试代码后init.c的代码如下所示:

// user\init.c
void
umain(int argc, char **argv)
{
	int i, r, x, want;
	cprintf("init: running\n");
	// being run directly from kernel, so no file descriptors open yet
	close(0);
	if ((r = opencons()) < 0)
		panic("opencons: %e", r);
	if (r != 0)
		panic("first opencons used fd %d", r);
	if ((r = dup(0, 1)) < 0)
		panic("dup: %e", r);
	while (1) {
		cprintf("init: starting sh\n");
		r = spawnl("/sh", "sh", (char*)0);
		if (r < 0) {
			cprintf("init: spawn sh: %e\n", r);
			continue;
		}
		// wait shell进程结束
		wait(r);
	}
}

其中close,opencons,dup都是和文件系统相关的函数,我将在文件系统相关博文中分析,这里做一下粗浅介绍。

我想你应该知道linux的每个用户程序都有初始的3个文件描述符,0 = 标准输入, 1 = 标准输出, 2 = 标准错误。

那么类似的JOS这里的opencons()则是将控制台抽象为一个描述符,这个描述符“指向”控制台,而这之前的close(0)则保证这个描述符为0;

dup(int oldfd, int new fd)的作用则与linux中dup2相似,使得newfd的指向与oldfd相同,也就是说执行完 dup(0,1)语句后,描述符1也指向控制台。

最后init进程再次spawnl一个进程,它是操作系统与用户直接的外壳,即shell程序。

而init进程则调用wait系统调用等待shell进程退出。

第一个用户进程是内核调用env_create直接创建的,那么JOS是否有其他方式创建用户进程呢?

先类比一下linux系统,用户创建进程的方式不外乎就是fork系统调用

  1. 如果想要创建一个一模一样的用户进程则直接调用fork
  2. 如果想创建运行其他程序的进程则先调用fork,然后在子进程中调用exec运行其他程序

JOS也提供了这两种创建的方式,但是JOS并不是宏内核,使用 fork + exec的方式去运行其他进程会比较困难,所以JOS使用spawn系统调用实现这件事,具体分析见下文。

CopyOnWrite Fork

Linux的Fork系统调用就实现了CopyOnWrite机制,所谓写时复制就是等到父子进程的某个进程正真开始写的时候再正真分配物理页面,否则父子两个进程的虚拟地址空间映射到同一片物理页。而且,就算是发生了写操作,采用渐进方式逐步分配物理页,不会一下子复制整个进程空间涉及到的页面,在极端情况下:比如进程对它拥有的空间都进行了写操作,这时父子进程才会拥有完整的、独立的两片物理空间。

JOS的写时复制机制也是上面的那个意思,但是它的某些理念与linux不太相同。比如:JOS把缺页处理的具体方式交给用户程序执行,而且处理方式可以由用户自定义。

此外,JOS给用户的系统调用接口很细,能让用户自己决定新进程的内容,比如能够部分复制父进程的pgdir到子进程,能够设置进程的状态,能够分配物理页并指定映射到哪个虚拟地址。总体而言,JOS更加信任用户。

与linux的另一个区别是,linux的fork是系统调用,JOS的fork是库函数,它使用5个系统调用组合而成

在完成写时复制机制之前,我们首先需要完成两个前置功能:

  • 类unix的fork系统调用
  • 缺页处理机制,使JOS能够在用户态处理缺页异常

DumbFork

先来实现一个比较“笨重”的fork,即它会立刻将父进程的所有内容拷贝到子进程,不会管只读数据还是可读写数据

JOS使用5个系统调用来完成用户进程的复制,

envid_t sys_exofork(void);
int sys_page_alloc(envid_t envid, void *va, int perm);
int sys_page_map(envid_t srcenvid, void *srcva, envid_t dstenvid, void *dstva, int perm);
int sys_page_unmap(envid_t envid, void *va);
int sys_env_set_status(envid_t envid, int status);

sys_exofork会创建一个子进程,分别在父子进程中返回两次,但是sys_exofork只会把父进程的内核映射复制给子进程,其他映射要不要复制,用户再使用sys_page_map等调用自行决定。

sys_env_set_status由父进程调用,用于父进程激活子进程。由于子进程在被sys_exofork后的状态是 非runnable的,因此需要父进程设置子进程状态位runnable才能被cpu调度。

其中sys_page_map函数,可以将两个不同进程的不同虚拟地址映射到相同的物理地址,是进程间通信的核心

具体看一看sys_exofork函数是如何做到返回两次,但返回值不同的,关键有2点

  • 子进程的trapframe和父进程一致,这样当子进程中断返回时,也是返回相同的用户代码处执行
  • 但是子进程trapframe的eax寄存器值赋值为0,这样子进程的中断返回值是0。 而父进程的返回值就是子进程的env_id,它在sys_exoforkf返回后在strap_dispatch()中设置

系统调用sys_exofork代码如下:

static envid_t
sys_exofork(void)
{
	struct Env* new_env;
	int env_alloc_ret = env_alloc(&new_env, curenv->env_id); // 找一个空env标识子进程,并且env_alloc这个函数已经完成了内核映射的复制
	if (env_alloc_ret < 0) {
		return env_alloc_ret;
	}
	new_env->env_status = ENV_NOT_RUNNABLE; // 新进程的运行状态为ENV_NOT_RUNNABLE,不能够立即运行
	memmove(&new_env->env_tf,&curenv->env_tf,sizeof(new_env->env_tf)); // 复制父进程的trapframe到子进程
	new_env->env_tf.tf_regs.reg_eax = 0; // 但是子进程中断返回值是0
	return new_env->env_id; // 父进程(env)的返回值是子进程的env_id
}
////////父进程的返回值在这里设置
static void
trap_dispatch(struct Trapframe *tf)
{
	int ret ;
	switch(tf->tf_trapno){
	........
		case T_SYSCALL:		
			ret = syscall(tf->tf_regs.reg_eax,
					tf->tf_regs.reg_edx,
					tf->tf_regs.reg_ecx,
					tf->tf_regs.reg_ebx,
					tf->tf_regs.reg_edi,
					tf->tf_regs.reg_esi);
			tf->tf_regs.reg_eax = ret;// 设置中断返回值,sys_exofork的情况下返回的是子进程的id
			break;

有了这5个系统调用,我们就可以使用它们提供一个类fork功能的函数,唯一缺少的就是没有应用写时拷贝技术,它会把父进程的所有映射复制给子进程,并且实际地分配了物理页面。lab的测试代码称之为dumbfork,,其中的子函数duppage很有意思,仅使用虚拟地址在两个不同进程中复制物理页面,有点像进程间通信的机制

envid_t
dumbfork(void)
{
	envid_t envid;
	uint8_t *addr;
	int r;
	extern unsigned char end[]; // 内核代码段最后的地址
	envid = sys_exofork();
	if (envid < 0)
		panic("sys_exofork: %e", envid);
	if (envid == 0) {
		// 子进程的thisenv的指针也是从父进程中拷贝过来的,修改它让它指向本进程env
		thisenv = &envs[ENVX(sys_getenvid())];
		return 0;
	}

	// We're the parent.
	// *Eagerly* copy our entire address space into the child.
	for (addr = (uint8_t*) UTEXT; addr < end; addr += PGSIZE)
		duppage(envid, addr);

	// 把父进程当前地栈也复制给子进程一份
	// addr这个变量在本进程的栈的底端!
	duppage(envid, ROUNDDOWN(&addr, PGSIZE));

	// 让子进程开始运行, 在父进程执行完这行代码之后,子进程才能被调度!
    // 因此上面的子进程的if分支在时间上晚于这行代码的执行
	if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0)
		panic("sys_env_set_status: %e", r);

	return envid;
}

// 整个过程有点像进程间通信!?
void
duppage(envid_t dstenv, void *addr)
{
	int r;
    // 子进程在相同的虚拟地址addr处创建并映射物理页面,不需要知道具体的物理地址就能完成下面的复制操作
	if ((r = sys_page_alloc(dstenv, addr, PTE_P|PTE_U|PTE_W)) < 0)
		panic("sys_page_alloc: %e", r);
	// 子进程的addr和父进程的UTEMP虚拟地址映射到了相同的物理地址上,即子进程刚刚创建的物理页面
	if ((r = sys_page_map(dstenv, addr, 0, UTEMP, PTE_P|PTE_U|PTE_W)) < 0)
		panic("sys_page_map: %e", r);
	// 此时就可以用虚拟地址执行memmove函数了,这样就把父进程addr对应的物理页面内容,复制到了子进程addr对应的物理页面
	memmove(UTEMP, addr, PGSIZE); // woc! 进程间通信?
	// 拷贝完后,解除父进程的临时映射
	if ((r = sys_page_unmap(0, UTEMP)) < 0)
		panic("sys_page_unmap: %e", r);
}

image-20221104115402306

所以说其中的sys_page_map是关键函数,它是如何将两个不同进程的不同虚拟地址映射到同一个物理地址上的?

  1. 找到源进程的虚拟地址所映射的物理页面
  2. 将这个页面插入到目标进程的页目录中

本质上,这两个都是对硬件MMU机制的模拟,如果你熟悉课程lab2中的代码,那么理解这个函数应该不是很困难。

// 将dstenvid代表的进程的dstva这个虚拟地址映射到
// srcenvid代表的进程的srcva这个虚拟地址所映射的物理页面
static int
sys_page_map(envid_t srcenvid, void *srcva,
	     envid_t dstenvid, void *dstva, int perm)
{
    // ... 略过一些检查
    // 1. 找到相应的物理页面
    struct PageInfo *srcpp;
	pte_t *srcpte;
	srcpp = page_lookup(srce->env_pgdir, srcva, &srcpte);

	// 2. 将物理页面插入到另一个进程的pgdir中
	int r = page_insert(dste->env_pgdir, srcpp, dstva, perm);
	if(r != 0)
		return r;
	return 0;
    
}

用户态的缺页异常处理

为了完成写时拷贝机制,我们还需要缺页异常处理。JOS的缺页异常处理很有意思:它的主要工作是放在用户态进行异常处理的,内核仅仅参与了部分的参数设置工作。这暗示了JOS不是宏内核。

为了达成这一目标,编写系统调用sys_env_set_pgfault_upcall对指定一个的Env 结构新增的env_pgfault_upcall成员进行赋值。这个成员是一个函数指针,指向了当该进程发生page fault时,应该从中断返回至哪个代码块开始执行。

另外,当一个用户程序在执行缺页处理函数时,它应该使用一个特殊的栈,称为用户异常栈(user exception stack)任何想要进行缺页中断处理的用户程序都必须事先在虚拟地址的0xeec00000处映射一块栈空间

异常处理过程中,用户态的栈切换也是必不可少的,这也是让我感到有难度的地方,因为涉及到汇编语言的编写、C语言与汇编的调用和C的函数调用规约。

代码实现

首先修改在kern/trap.c中的缺页中断处理函数page_fault_handler,使得缺页异常中断返回至用户处理函数,并且在用户异常栈上设置一个UTrapframe,UTrapframe类似于trapframe,但是UTrapframe保存了从用户态程序返回至用户态程序的信息,其中的值都和trapframe相同,utf_fault_va记录引起缺页中断的地址,它起初记录在cr2寄存器中,内核将cr2的值复制到UtrapFrame中。UTrapframe结构如下:

struct UTrapframe {
	/* information about the fault */
	uint32_t utf_fault_va;
	uint32_t utf_err;
	/* trap-time return state */
	struct PushRegs utf_regs;
	uintptr_t utf_eip;
	uint32_t utf_eflags;
	/* the trap-time stack to return to */
	uintptr_t utf_esp;
} __attribute__((packed));

首先是内核中关于缺页处理的函数如下,主要工作就是设置UTrapframe。UTrapframe的物理地址空间应该在异常发生之前就已经设定好了。请看下文的set_pgfault_handler函数。

// kern\trap.c文件中
void
page_fault_handler(struct Trapframe *tf)
{
	uint32_t fault_va;
	fault_va = rcr2();  // 将发生异常的地址从cr2寄存器中取出
	// 通过cs的cpl判断是否处于内核
	if((tf->tf_cs & 3) == 0) {
		// 如果pagefault发生在内核中(cs的后两位是0),我们表示无能为力
		panic("page_fault in kernel, fault address %d\n", fault_va);
	}
	struct UTrapframe *utf;
    // 判断用户进程是否设置了用户级的缺页处理函数,如果不是直接销毁用户进程!
	if (curenv->env_pgfault_upcall) {
         // 处理递归发生异常的情况
		// 发生异常时,用户环境已经在用户异常堆栈上运行,应该在当前tf->tf_esp下启动新的堆栈帧
		if(tf->tf_esp<=UXSTACKTOP-1 && tf->tf_esp >=UXSTACKTOP-PGSIZE) 
            	// 递归发生异常,则在异常栈上空出4字节后在处理
			utf = (struct UTrapframe *)(tf->tf_esp - sizeof(struct UTrapframe) -4);
		else 
            	// 首次发生异常,应该在UXSTACKTOP启动新的堆栈帧
			utf = (struct UTrapframe *)(UXSTACKTOP - sizeof(struct UTrapframe));
		// 防止异常栈溢出
		user_mem_assert(curenv, (const void *)utf, sizeof(struct UTrapframe), PTE_W);
		// 设置异常栈
		utf->utf_fault_va = fault_va;
		utf->utf_err = tf->tf_err;  // 缺页中断的错误码,有硬件自动压入到trapframe上
		utf->utf_regs = tf->tf_regs;
		utf->utf_eflags = tf->tf_eflags;
		utf->utf_eip = tf->tf_eip;
		utf->utf_esp = tf->tf_esp; 	
		// 设置中断返回的两个重要寄存器, esp 和 eip
		// 中断返回时的用户程序的栈在UXSTACKTOP下,而EIP指向pfentry.S的__pgfault_upcall
		tf->tf_esp = (uintptr_t)utf;
		tf->tf_eip = (uintptr_t)curenv->env_pgfault_upcall;
		// 从中断返回
		env_run(curenv);
	} else {
		// Destroy the environment that caused the fault.
		cprintf("[%08x] user fault va %08x ip %08x\n",
			curenv->env_id, fault_va, tf->tf_eip);
		print_trapframe(tf);
		env_destroy(curenv);
	}
}

tf->tf_eip = (uintptr_t)curenv->env_pgfault_upcall,这行代码,规定了中断返回的地点。它由sys_env_set_pgfault_upcall设定,是一个由汇编语言编写的函数,是用户程序执行缺页处理的入口函数,它执行用户栈的切换,并调用_pgfault_handler,_pgfault_handler函数才是真正的缺页处理函数,而这个汇编文件指示一个路由。如下所示为pfentry.S:

.text
.globl _pgfault_upcall
_pgfault_upcall:
	pushl %esp			// function argument: pointer to UTF
	movl _pgfault_handler, %eax
    // Call the C page fault handler.
	call *%eax
	addl $4, %esp			// pop function argument
	// 跳过 fault_va 和 utf_err
	add $8, %esp
	// 保存eip
	movl 0x20(%esp), %eax // 0x20(%esp)==*(%esp+32)==trap-time eip;

	subl $4, 0x28(%esp) // 对traptime stack做压栈操作
	movl 0x28(%esp), %ebx // 然后将trap-time eip放在这栈底
	movl %eax, (%ebx)

	popal
	addl $4, %esp // skip  trap-time eip
	popfl //将标志寄存器的值出栈
	// Switch back to the adjusted trap-time stack.
	popl %esp // 恢复traptime stack, 此时esp指向traptime stack中的trap-time eip
	// Return to re-execute the instruction that faulted.
	ret 

其中call *%eax调用_pgfault_handler,pgfault_handler是个全局函数指针,它指向用户自定义的正真的缺页处理函数 。

怎么编写用户自己的缺页中断函数?当缺页异常发生后,经过内核一系列的处理操作,Utrapframe已经存储了发生缺页中断的地址,只要使用sys_page_alloc在那个发生地址的虚拟地址建立并映射物理页就可以了。下面是一个示例用户函数,用来处理缺页中断:

void
handler(struct UTrapframe *utf)
{	// 这是用户函数,按照用户需求编写
	int r;
	void *addr = (void*)utf->utf_fault_va;  // 发生缺页中断的地址
	if ((r = sys_page_alloc(0, ROUNDDOWN(addr, PGSIZE),  // 使用系统调用映射物理地址
				PTE_P|PTE_U|PTE_W)) < 0)
		panic("allocating at %x in page fault handler: %e", addr, r);
	snprintf((char*) addr, 100, "this string was faulted in at %x", addr); // 可能发生第二次pagefault
}

handler返回值pentyr.S中,将用户态使用的栈从exception stack切换到traptime stack,最后只能够返回,这样程序就回到了发生缺页异常时的程序中了。

最后看看_pgfault_handler这个函数指针是怎样被设置的。其实很简单,只需要调用set_pgfault_handler即可

void
set_pgfault_handler(void (*handler)(struct UTrapframe *utf))
{
	int r;

	if (_pgfault_handler == 0) {
		// 分配异常栈
		r=sys_page_alloc(thisenv->env_id, (void *)(UXSTACKTOP-PGSIZE), PTE_W|PTE_U);
		if(r!=0)
			panic("fail to alloc a page for UXSTACKTOP!\n");
        // 将汇编代码的_pgfault_upcall设置到env结构中
		r=sys_env_set_pgfault_upcall(thisenv->env_id, _pgfault_upcall);
		if(r!=0)
			panic("fail to set pgfault upcall!\n");
		// panic("set_pgfault_handler not implemented");
	}

	// !就在这里_pgfault_handler函数指针被指定为用户编写的处理函数!
	_pgfault_handler = handler;
}

例如,用户想要在缺页异常发生时,运行上面定义的handler函数,那么只需要调用:

set_pgfault_handler*(handler);

CopyOnWrite Fork实现

我们已经有了实现CopyOnWrite 的所有机制,是时候来包装一个使用CopyOnWrite 的fork库函数了。

CopyOnWrite Fork和之前dumbfork的大体流程类似,区别有二:

  • duppage时不执行正真的物理页复制操作,而是把父进程的内存映射拷贝到子进程中,这样父子进程的相同虚拟地址指向同一个物理地址
  • 要设置父子进程的缺页处理函数

为了支持CopyOnWrite Fork机制,我们还要启用PTE、PDE表项中的为用户态预留的标志位AVL,用第十一位表示是否是写时复制,标记位PTE_COW。CPU硬件在检查PTE表项时,不会检查这个位,而是留给软件做后续操作。

image-20221104164937704

下面是实现了写时复制机制的fork的具体代码如下,其中duppage函数不再真正地拷贝每一个物理页,而是将父进程地页目录内容拷贝到子进程地页目录中。

envid_t
fork(void)
{
	envid_t envid;
	int r;
	unsigned pn;
	set_pgfault_handler(pgfault);// 1. 设置缺页处理函数,这样之后父子进程的实际缺页处理函数就是pgfault了,但是只有在父进程的struct env中存有_pgfault_upcall这个中转站,因此下面还要进一步处理。 对应下面的2.
	envid = sys_exofork(); // 这里分配子进程的pgdir,并建立内核代码的映射
	if (envid == 0) {
		// 子进程
		// 修改thisenv,thisenv是用户态的数据结构,内核不会帮我们修改
		thisenv = &envs[ENVX(sys_getenvid())];
		return 0;
	}
	// 父进程
	// 遍历 utext和ustacktop之间的虚拟内存页,然后对存在的虚实页面映射进行拷贝
	for (pn=PGNUM(UTEXT); pn<PGNUM(USTACKTOP); pn++){ 
		if ((uvpd[pn >> 10] & PTE_P) && (uvpt[pn] & PTE_P))
			if ((r =  duppage(envid, pn)) < 0)
				return r;
	}
	// 给子进程分配异常栈,父进程的异常栈已经在上面调用set_pgfault_handler时设置好了。
	if ((r = sys_page_alloc(envid,(void *) (UXSTACKTOP - PGSIZE), (PTE_U | PTE_P | PTE_W))) < 0) {
		 panic("fork.c:fork() : sys_page_alloc failed");
	}
	extern void _pgfault_upcall(void); //缺页处理的汇编函数入口,它会调用pgfault
	// 2. 对应上面的1.
	if((r = sys_env_set_pgfault_upcall(envid, _pgfault_upcall)) < 0){
			panic("fork.c:fork() : sys_set_pgfault_upcall:%e", r);
	}
	if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0)//设置子进程可运行,在这一行之后,子进程才可能被调度执行!
            return r;

	// 父进程返回子进程envid
	return envid; 
}

duppage的具体代码如下:

static int
duppage(envid_t envid, unsigned pn)
{
	int r;
	void * va = (void *)(pn * PGSIZE);
	if ( (uvpt[pn] & PTE_W) || (uvpt[pn] & PTE_COW)) {
        // 有写权限的PTE表项将它映射为只读且COW
		// 子进程复制父进程的映射关系,并设置为只读、COW
		r = sys_page_map(0, va, envid, va, PTE_COW | PTE_P | PTE_U);  // 注意没有PTE_W
		if (r < 0)  {
			return r;
		}
		// 重新映射父进程的虚拟地址,并设置为只读、COW
		r = sys_page_map(0,va, 0,va, PTE_U | PTE_COW | PTE_P);// 注意没有PTE_W
		if (r < 0) {
			return r;
		}
		// 此时父子进程的va都映射到同一物理内存,且权限都是只读!无论父子进程的那个会往va里写,都会出发缺页中断
	} else  {
        // 只读权限的PTE表项将它映射为只读且不会触发写时复制
		r = sys_page_map(0,va,envid,va, PTE_P | PTE_U);
		if (r < 0) {
			return r;
		}
	}
	return 0;
}

改写后的duppage实际上不会物理页的内容,只是使用sys_page_map将子进程的虚拟地址的页目录和页表设置成父进程的数据,在此之上,会将父子进程的PTE都设置为只读、COW

那么最后就是fork的缺页中断的用户处理函数了,显然我们首先需要检查①缺页中断是否由写错误产生②发生缺页中断地址对应的PTE是否是被标记了COW,如果是的话就实际分配一个物理页面,并把原来的内容复制到这个新页面中,最后重新与新物理页面建立映射。

static void
pgfault(struct UTrapframe *utf)
{
	void *addr = (void *) utf->utf_fault_va;
	uint32_t err = utf->utf_err;
	int r;
	//  (uvpd[PDX(addr)] & PTE_P)是为了先判断对应的页表存在
	// 判断这个缺页中断是否由写错误产生, 并且产生中段的地址的权限是否为COW,若不是则panic
	if ( !((err & FEC_WR) && (uvpd[PDX(addr)] & PTE_P) &&  (uvpt[PGNUM(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_COW))) {
		panic("fork.c:pgfault() : pagefault conditon wrong");
	}
	// 分配一个物理页面,将它与PFTEMP建立映射
	if ((r = sys_page_alloc(0,PFTEMP,PTE_W | PTE_U | PTE_P) < 0)) {
		panic("fork.c:pgfault() : sys_page_alloc failed");
	}
	addr = ROUNDDOWN(addr, PGSIZE);
	// 将 发生页错误处的内容拷贝到PFTEMP处
	memmove((void *)PFTEMP,addr,PGSIZE);
	// 将页错误地址映射到物理地址上
	r = sys_page_map(0,(void *)PFTEMP,0,addr,PTE_W | PTE_U | PTE_P); // 注意两个envid都是0,源和目标都是本进程
	if (r < 0) {
		panic("fork.c:pgfault() :sysmap failed");
	}
	// 解除物理页与PFTEMP的映射
	r = sys_page_unmap(0,(void *) PFTEMP);
	if (r < 0) {
		panic("page_unmap error");
	}
}

此外,为了在用户态能够读取PTE表项,我们已经把页表项只读地映射到了虚拟地址UPAGES处,通过uvpt和uvpd这两个变量就能在用户态中读取对应地PTE,详见实验指导页面。

图示fork和Copy on write过程

  1. 调用fork,父子进程的映射完全相同,且所有地址都被标记为只读、COW

    image-20221104233137453

  2. 无论哪个进程,如果只是执行读操作的话,是没有任何问题的。

    image-20221104233357644

  3. 但一旦发生了写操作(这里假设子进程发生写操作),那么CPU硬件就会检测到这与PTE的只读标记不容,因此发起缺页异常,错误码为写错误,将发生缺页中断的地址放在cr2寄存器后,从用户态转移至内核态。切换内核栈,又从IDT中找到对应的描述符,将对应中断入口函数的地址装载进EIP寄存器,最后构建了trapframe

    image-20221104220613203

  4. trap()会dispatch这个中断,显然这个中断将在我们的page_fault_handler中处理,它会设置异常栈、修改trapframe,最重要的是将TrapFrame的eip修改为用户的缺页中断处理程序。

    image-20221104223659513

  5. 然后page_fault_handler执行中断返回,由于我们修改了trapframe的eip,因此我们返回至用户程序的pentry.S处,在这里我们用Utrapframe作为参数调用真正的缺页处理函数,在这个场景中这就是上一节的static void pgfault(struct UTrapframe *utf)函数

    image-20221104223842850

  6. static void pgfault(struct UTrapframe *utf)函数怎么处理缺页错误呢?具体的逻辑看上面的代码部分,它同dumbfork类似,都用到了临时虚拟地址。首先分配一个新的物理页面,将它映射至PFTEMP虚拟地址,此时的PTE标记为PTE_W标记为可写,这个虚拟地址和UTEMP一样都是专门空出来为这种情况服务的。记引起缺页异常的页面记作A页面,然后memmove将A页面的内容全部拷贝至新分配的物理地址中。

    image-20221104225400596

  7. 然后pgfault函数将A页面重新映射到新分配的物理页面上。这很容易做到,只要我们拷贝PFTEEMP对应的PTE到A页面对应的PTE中即可,同时这也断开了A页面与原来只读页面的映射。最后断开PFTEMP的映射,我们就完成了这一次的缺页异常处理。可以看到只有发生缺页中断的那个页重新分配的物理内存被子进程独享,其他内容依然和父进程共享。

    image-20221104230506886

  8. 最后我们返回原来的用户程序继续运行,返这些信息本来在中断发生时记录在Trapframe中,但是在第五步中将这些信息拷贝给了Utrapframe,因此用户异常栈中有足够的信息使程序流恢复到原样。提一嘴,与中断不同(由int指令或者外设引起),异常发生的eip保存值是当前执行的指令,而不是当前指令的下一条指令,这样当我们处理好异常后,再一次执行这个引起异常的指令。

    image-20221104232537143

通过学习写时拷贝机制,再一次体会到了内存分页机制的强大!

spawnl

初始进程在最后会调用spawnl启动一个shell子进程,然后wait等待shell进程退出。

r = spawnl("/sh", "sh", (char*)0);
...
wait(r);

与jos的fork相同,spawnl也是个库函数,同样使用多个系统的组合玩完成子程序的创建。

其调用树如下,所有工作都是父进程完成的。

  • spawnl
    • spawn
      • open 打开要运行的程序文件
      • sys_exofork 创建子进程
      • init_stack 初始化子进程的用户栈, 设置子进程trapframe中的esp指针
      • map_segment 将elf文件加载至子进程内存,设置子进程trapframe中的eip指针
      • sys_env_set_trapframe 设置子进程的中断栈
      • sys_env_set_status 设置进程的状态为可执行

下面从spawnl的代码看起:

// Spawn, taking command-line arguments array directly on the stack.
// prog指向程序名字符串,arg0指向命令行参数的字符串
// 以r = spawnl("/sh", "sh", (char*)0)为例, 那么这里prog参数就指向"prog"字符串,arg0指向以 “sh”和“char*0”组成的内存地址开头
int
spawnl(const char *prog, const char *arg0, ...)
{
    // 第一遍循环获取参数个数
	int argc=0;
	va_list vl;
	va_start(vl, arg0);
	while(va_arg(vl, void *) != NULL)
		argc++;
	va_end(vl);

	const char *argv[argc+2]; // 头尾各多出一个位值,尾部那个表示字符串结尾
	argv[0] = arg0;
	argv[argc+1] = NULL;
    
	// 第二遍循环,制作参数列表
	va_start(vl, arg0);
	unsigned i;
	for(i=0;i<argc;i++)
		argv[i+1] = va_arg(vl, const char *);
	va_end(vl);
	return spawn(prog, argv);
}

不想在va_xx几个宏上再做文章,有许多资料已经详尽清楚地解释了这3个宏

va_start(vl, arg0);
循环 {
    va_arg(vl, void *)
}
va_end(vl);

它们的作用就是将栈可变参数一个个分离出来,或是计数、或是制作指针数组,得到的两个值就是主函数main接受的两个参数。《操作系统真象还原》一书对这几个宏做了详细解释。

总之,spawnl就是生成了一个char*数组,存放参数个数,以及各个可变参数的地址,然后加上要加载的程序名调用spawn。spawn才是正真做事的那个。

spawn的代码如下:

int
spawn(const char *prog, const char **argv)
{
	unsigned char elf_buf[512];
	struct Trapframe child_tf;
	envid_t child;

	int fd, i, r;
	struct Elf *elf;
	struct Proghdr *ph;
	int perm;
	// 1. 打开要运行的程序所在文件
	if ((r = open(prog, O_RDONLY)) < 0)
		return r;
	fd = r;

	// Read elf header
	elf = (struct Elf*) elf_buf;
	// ...

	// 2. 创建子进程
	if ((r = sys_exofork()) < 0)
		return r;
	child = r;
	// 但是所有工作都由父进程完成
    
	// 设置子进程的trapframe
	child_tf = envs[ENVX(child)].env_tf;
	child_tf.tf_eip = elf->e_entry;
	// 3. 初始化子进程的用户栈,即子进程主函数的参数设置
	if ((r = init_stack(child, argv, &child_tf.tf_esp)) < 0)
		return r;

	// 4.将elf文件加载至子进程内存。类似的循环已经见过很多次
	ph = (struct Proghdr*) (elf_buf + elf->e_phoff);
	for (i = 0; i < elf->e_phnum; i++, ph++) {
		// 调用map_segment 将文件的各种段加载到子进程的内存中
	}
	close(fd); // 加载完毕,关闭文件
	fd = -1;

	// ...
	// 5. 设置子进程的中断栈
	if ((r = sys_env_set_trapframe(child, &child_tf)) < 0)
		panic("sys_env_set_trapframe: %e", r);
	// 6. 设置子进程的运行状态为可执行
	if ((r = sys_env_set_status(child, ENV_RUNNABLE)) < 0)
		panic("sys_env_set_status: %e", r);

	return child;
	// ...
}

其中比较陌生的步骤应该就是init_stack这个函数,它将初始化子进程的用户栈,即子进程入口函数的参数设置,大体有5个步骤:

  1. 计算参数个数
  2. 先在 父进程 的在UTMP之上做好地址分配,暂时在这个地址做数据处理
  3. 处理argv数组
  4. 更新子进程的trapframe中的esp指针
  5. 将父进程UTEMP页复制到子进程的用户栈
static int
init_stack(envid_t child, const char **argv, uintptr_t *init_esp)
{
	size_t string_size;
	int argc, i, r;
	char *string_store;
	uintptr_t *argv_store;

	// 1. 计算参数个数
	// 计算存放这些参数共需要多少空间
	string_size = 0;
	for (argc = 0; argv[argc] != 0; argc++)
		string_size += strlen(argv[argc]) + 1;

	// 2. 先在 父进程 的在UTMP之上做好地址分配
	// strings 指向参数字符串的存放地址
	string_store = (char*) UTEMP + PGSIZE - string_size;
	// argv在stirngs的下面,指向一个数组,这个数组的每个元素都指向一个strings中的一个参数,在下面的循环中处理
	argv_store = (uintptr_t*) (ROUNDDOWN(string_store, 4) - 4 * (argc + 1));
	// ...
	// 在虚拟地址UTMP处分配一个物理页.
	if ((r = sys_page_alloc(0, (void*) UTEMP, PTE_P|PTE_U|PTE_W)) < 0)
		return r;

	// 3. 处理argv数组, lab中的注释已经很详细了,就不做更改了
	//	* Initialize 'argv_store[i]' to point to argument string i,
	//	  for all 0 <= i < argc.
	//	  Also, copy the argument strings from 'argv' into the
	//	  newly-allocated stack page.
	//
	//	* Set 'argv_store[argc]' to 0 to null-terminate the args array.
	//
	//	* Push two more words onto the child's stack below 'args',
	//	  containing the argc and argv parameters to be passed
	//	  to the child's umain() function.
	//	  argv should be below argc on the stack.
	//	  (Again, argv should use an address valid in the child's
	//	  environment.)
	//
	//	* Set *init_esp to the initial stack pointer for the child,
	//	  (Again, use an address valid in the child's environment.)
	for (i = 0; i < argc; i++) {
        // 这个循环将argv数组元素的指向调整正确
		argv_store[i] = UTEMP2USTACK(string_store);
		strcpy(string_store, argv[i]);
		string_store += strlen(argv[i]) + 1;
	}
	argv_store[argc] = 0;
	assert(string_store == (char*)UTEMP + PGSIZE);
	argv_store[-1] = UTEMP2USTACK(argv_store);
	argv_store[-2] = argc; 
	// 4. 更新子进程的esp指针
	*init_esp = UTEMP2USTACK(&argv_store[-2]);

	// 5. 将父进程UTEMP页复制到子进程的用户栈
	if ((r = sys_page_map(0, UTEMP, child, (void*) (USTACKTOP - PGSIZE), PTE_P | PTE_U | PTE_W)) < 0)
		goto error;
	if ((r = sys_page_unmap(0, UTEMP)) < 0)
		goto error;

	return 0;

error:
	sys_page_unmap(0, UTEMP);
	return r;
}

整个spawnl的思路与fork非常相似,区别在于两点:

  • fork不需要额外进行elf文件处理,它已经在父进程的进程空间了,子进程一一拷贝即可。但spawn需要通过文件系统打开文件并处理它。
  • fork时子进程拷贝父进程地址空间的所有内容,包括用户栈,这样子进程返回地址与父进程相同。但是spawn的子进程需要丛头开始运行,根本不存在用户栈,因此需要为子进程手动分配一个用户栈。

spawn不仅需要手动分配用户栈,还需要将主函数参数进行处理,放置到栈顶,这是spawn一个的难点。

以调用r = spawnl("/sh", "sh", (char*)0);为例,我试着图解主函数的参数如何被建立起来。

  1. spawnl的形参位于栈上,字符串则存放于ELF文件的数据段上。spawnl会另外在栈上建立argv数组,还是指向elf文件的内容,并且在最后添加一个null指针。

    image-20230223154225399

  2. spawnl再以prog和agrv做参数调用spawn, 因为传递的是指针,所以栈的指向是相同的。

  3. 主要还是init_stack做了很多工作,首先会在UTEMP地址归规划参数存放的位置。包括string_store, 表示命令行字符串开始的位置,argv_store表示传给主函数argv的存储位置,然后将字符串从elf文件复制过来:

    image-20230223161324633

  4. 最后将这个UTEMP页复制到子进程的用户栈上,并设置trapframe.esp到最后一个参数上,而栈低的两个参数正好是主函数的两个参数,它们一个类型为int,一个类型为char**。JOS中,用户程序的主函数定义如下:

    void
    umain(int argc, char **argv)
    {
    	/// ...
    }
    

    符合调用规约!

    image-20230223161801616

用户进程的第一行代码

“趁热”说下这个话题,因为上个话题讲的是spawnl,它将启动一个子进程从头开始执行特定的C程序,但是用户进程的第一行代码是否就是我们平时些的mian函数呢?---不是,第一行代码是库函数的某个起始函数,然后再由库函数调用用户程序的main()函数。

在linux中main函数不是用户态执行的第一条指令,而是C运行库中的__start函数,___start为用户程序准备好参数后再调用main函数。

而JOS用户代码的主函数名是 "umain" 不是 "main"! 这也暗示了其实主函数函数叫什么都无所谓的,就看链接器怎么定义主函数的符号,如果链接器定义主函数的符号main,那么_start就调用main(),如果主函数被定义为not_main,那么_start就调用not_main()。(这里推荐阅读《程序员的自我修养》这本书。)

JOS中,lib/entry.S 和 lib/libmain.C 充当了C运行库的作用,它把用户函数的起始地址称作umain,那么用户在使用JOS编写程序时,他们必须把用户主函数的名称定义为 umain,否则会发生链接错误。

// lib\entry.S
.globl _start
_start: // 用户程序实际上执行的第一个函数是这个
	cmpl $USTACKTOP, %esp
	jne args_exist
	
	pushl $0  // 如果用户程序不需要参数,则手动push两个0。否则,在init_stack函数中就已经设置好两个参数了,见上一节。
	pushl $0  

args_exist: 
	call libmain
1:	jmp 1b
    
// lib\libmain.c
void
libmain(int argc, char **argv)
{
	// set thisenv to point at our Env structure in envs[].
	thisenv = &envs[ENVX(sys_getenvid())];
	// save the name of the program so that panic() can use it
	if (argc > 0)
		binaryname = argv[0];
	// 调用用户程序的main函数
	umain(argc, argv);
	exit();  // 用户程序执行完后,库函数还得做一些其他工作
}

而_start这个符号又被user.ld这个链接脚本指定为入口,并加载到虚拟地址0x800020处:

......略
ENTRY(_start)

SECTIONS
{
	/* Load programs at this address: "." means the current address */
	. = 0x800020;
	
	......略
}

不仅用户程序程序代码之前,用户代码的之后,都要有libmain的加工。在执行用户程序前libmain要为用户程序准备参数,然后执行hello.c的主程序,最后再回到libmain中,执行exit()销毁用户环境,详见上面的lib\entry.S代码。

image-20221028154016415

wait

JOS的wait实现较为简单,忙等待子进程结束,在这期间它会调用sys_yield主动放弃CPU运行。

// Waits until 'envid' exits.
void
wait(envid_t envid)
{
	const volatile struct Env *e;

	assert(envid != 0);
	e = &envs[ENVX(envid)];
	while (e->env_id == envid && e->env_status != ENV_FREE)
		sys_yield();
}

在一个while循环中等待某个进程运行结束(e->env_status == ENV_FREE),如果还没有结束则调用sys_yield。

sys_yield将会暂停执行本进程的执行上下文,挑选另一个进程来执行。本进程的state将由running变为runnable。

env_destroy

相当于xv6中的kill调用,能够销毁一个进程。JOS的实现也是差不多的,先改变进程状态为dying,然后free evn结构,最后调用sched_yield调度另一个线程。因为本线程的状态为dying,因此在后续的CPU调度中,再也不会选择本线程继续执行了。

void
env_destroy(struct Env *e)
{
	// If e is currently running on other CPUs, we change its state to
	// ENV_DYING. A zombie environment will be freed the next time
	// it traps to the kernel.
	if (e->env_status == ENV_RUNNING && curenv != e) {
		e->env_status = ENV_DYING;
		return;
	}

	env_free(e);

	if (curenv == e) {
		curenv = NULL;
		sched_yield();
	}
}

关于进程状态、进程调度的内容在下一篇文章中讲,感觉是个大工程。

posted @ 2023-02-28 15:34  别杀那头猪  阅读(125)  评论(0编辑  收藏  举报