上篇文章讲解了 COW Fork 页面错误的实现细节,接下来讲解后续的实现细节。

创建新的进程

这两段代码是在实现用户级别的 fork 操作,即创建一个新的进程。这个新的进程是当前进程的一个副本,包括代码、数据和堆栈等。

envid_t
fork(void)
{
    // ...
    envid_t envid = sys_exofork();
    if (envid < 0) {
        panic("sys_exofork: %e", envid);
    }

    if (envid == 0) {
        // Child process
        thisenv = &envs[ENVX(sys_getenvid())];
        return 0;
    }
    // ...
}

在 COW (Copy-On-Write) fork 中,sys_exofork() 函数被用来创建一个新的进程。这个新进程是当前进程的一个副本,但是它有自己的进程 ID,可以独立于其他进程运行。

sys_exofork() 函数创建的新进程与当前进程共享所有的内存页,但是这些内存页都被标记为只读。当新进程或者当前进程试图写入这些共享的内存页时,操作系统会捕获到这个写操作,然后为写操作的进程创建这个内存页的一个私有副本,这就是所谓的 "写时复制"(Copy-On-Write)。

这样做的好处是,如果新进程并没有修改任何内存页,那么就没有必要为新进程分配新的内存页,这样可以节省大量的内存。只有当新进程真正需要修改内存页时,才会为新进程分配新的内存页。

所以,在 COW fork 中,sys_exofork() 函数的作用是创建一个新的进程,并设置好所有的内存页为只读,以便实现写时复制(Copy-On-Write)。

sys_exofork 是如何创建新进程的?

接下来讲解sys_exofork是如何创建新进程的。

static envid_t
sys_exofork(void)
{
	struct Env *e;  // 用于指向新进程的指针
	// 调用 env_alloc 函数创建新进程,父进程 ID 为当前进程的 ID
	int ret = env_alloc(&e, curenv->env_id);
	// 如果创建新进程失败(返回值小于0),则返回错误码
	if (ret < 0) {
		return ret;
	}
	// 将当前进程的寄存器状态复制到新进程
	e->env_tf = curenv->env_tf;
	// 设置新进程的状态为不可运行
	e->env_status = ENV_NOT_RUNNABLE;
	// 设置新进程的 eax 寄存器的值为 0,这样在新进程中 sys_exofork 的返回值就为 0
	e->env_tf.tf_regs.reg_eax = 0;
	// 返回新进程的 ID
	return e->env_id;
}

首先,它调用env_alloc()函数创建一个新的进程。env_alloc()函数会返回一个进程结构体的指针,如果返回值小于 0,表示创建进程失败,此时会直接返回错误码。

然后,它将新进程的寄存器状态设置为当前进程的寄存器状态,这样新进程就拥有了和当前进程一样的寄存器状态。

接着,它将新进程的状态设置为ENV_NOT_RUNNABLE,表示新进程暂时不能运行。

最后,它将新进程的eax寄存器的值设置为 0,这样当新进程开始运行时,sys_exofork()函数会返回 0,表示新进程创建成功。

最终,这个函数返回新进程的进程 ID。

env_alloc 实现细节

env_alloc 是在操作系统中创建一个新的进程。当创建一个新的进程时需要一个新的、独立的执行上下文,包括代码、数据和堆栈等。这个新的进程是当前进程的一个副本,但是它有自己的进程 ID,可以独立于其他进程运行。

int
env_alloc(struct Env **newenv_store, envid_t parent_id)
{
	int32_t generation;  // 用于生成进程ID
	int r;  // 用于接收函数返回值
	struct Env *e;  // 用于指向新进程

	// 从空闲进程列表中获取一个进程
	if (!(e = env_free_list))
		return -E_NO_FREE_ENV;  // 如果没有空闲进程,返回错误

	// 为这个进程分配并设置页目录
	if ((r = env_setup_vm(e)) < 0)
		return r;  // 如果设置失败,返回错误

	// 为这个进程生成一个进程ID
	generation = (e->env_id + (1 << ENVGENSHIFT)) & ~(NENV - 1);
	if (generation <= 0)	// 确保不会生成负的进程ID
		generation = 1 << ENVGENSHIFT;
	e->env_id = generation | (e - envs);

	// 设置进程的基本状态变量
	e->env_parent_id = parent_id;  // 设置父进程ID
	e->env_type = ENV_TYPE_USER;  // 设置进程类型为用户进程
	e->env_status = ENV_RUNNABLE;  // 设置进程状态为可运行
	e->env_runs = 0;  // 初始化运行次数为0

	// 清除所有保存的寄存器状态,防止之前进程的寄存器值泄露到新进程
	memset(&e->env_tf, 0, sizeof(e->env_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_esp = USTACKTOP;  // 设置堆栈指针
	e->env_tf.tf_cs = GD_UT | 3;  // 设置代码段选择子
	// 你将在后面设置 e->env_tf.tf_eip

	// 在用户模式下启用中断
	e->env_tf.tf_eflags |= FL_IF;
	// 清除页错误处理程序,直到用户安装一个
	e->env_pgfault_upcall = 0;

	// 清除IPC接收标志
	e->env_ipc_recving = 0;

	// 提交分配
	env_free_list = e->env_link;  // 将空闲进程列表指向下一个空闲进程
	*newenv_store = e;  // 将新进程的地址存储到newenv_store

	cprintf("[%08x] new env %08x\n", curenv ? curenv->env_id : 0, e->env_id);  // 打印新进程的信息
	return 0;  // 返回成功
}

env_alloc函数的目的是在操作系统中创建一个新的进程。这个函数的主要工作流程如下:

  1. 从空闲进程列表中获取一个进程。如果没有空闲进程,函数返回错误代码。

  2. 为新进程分配并设置页目录。如果设置失败,函数返回错误代码。

  3. 为新进程生成一个进程 ID。进程 ID 是新进程的唯一标识,可以用来在后续的操作中引用新进程。

  4. 设置新进程的基本状态变量。这些状态变量包括父进程 ID、进程类型、进程状态和运行次数等。

  5. 清除所有保存的寄存器状态,防止之前进程的寄存器值泄露到新进程。

  6. 设置适当的初始值给段寄存器。这些初始值包括数据段选择子、附加段选择子、堆栈段选择子、堆栈指针和代码段选择子等。

  7. 在用户模式下启用中断。这样,新进程在运行时可以响应中断。

  8. 清除页错误处理程序,直到用户安装一个。这样,新进程在运行时如果发生页错误,操作系统就会调用用户安装的页错误处理程序。

  9. 清除 IPC 接收标志。这样,新进程在运行时可以接收 IPC 消息。

  10. 提交分配。将新进程的地址存储到 newenv_store,并将空闲进程列表指向下一个空闲进程。

  11. 打印新进程的信息。这样,用户可以知道新进程的进程 ID 和其他信息。

  12. 返回成功。这表示新进程已经成功创建。

如何为新进程分配并设置页目录?

env_setup_vm 是在操作系统中为新的进程设置虚拟内存布局的函数实现。下面是对代码中每个步骤的详细解释:

static int
env_setup_vm(struct Env *e)
{
	int i;
	struct PageInfo *p = NULL;

	// 为页目录分配一个页面
	if (!(p = page_alloc(ALLOC_ZERO)))
		return -E_NO_MEM;  // 如果分配失败,返回内存不足的错误

	// 增加页面的引用计数
	p->pp_ref++;
	// 将物理地址转换为内核虚拟地址,并设置为进程的页目录
	e->env_pgdir = (pde_t *) page2kva(p);
	// 将内核的页目录复制到新进程的页目录中
	memcpy(e->env_pgdir, kern_pgdir, PGSIZE);

	// 设置UVPT映射进程自己的页表为只读
	// 权限:内核读,用户读
	e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;

	return 0;  // 返回成功
}

这段代码的主要目的是为新的进程设置虚拟内存。首先,它为页目录分配一个页面。如果分配失败,函数返回内存不足的错误。然后,它增加页面的引用计数,并将物理地址转换为内核虚拟地址,设置为进程的页目录。

接着,它将内核的页目录复制到新进程的页目录中。在操作系统中,虚拟地址空间通常被分为用户空间和内核空间两部分。在 x86 架构中,UTOP(User TOP)是用户空间和内核空间的分界线。用户空间的地址范围从 0 到 UTOP,内核空间的地址范围从 UTOP 到顶部。

所有进程的虚拟地址空间在 UTOP 以上的部分都是相同的,这是因为这部分地址空间被用于内核代码、内核数据和内核堆栈等内核资源的映射。由于内核是所有进程共享的,因此所有进程都需要能够访问这些内核资源,所以所有进程的虚拟地址空间在 UTOP 以上的部分都被映射到相同的物理内存地址。

当我们创建一个新的进程时,我们需要为它创建一个新的虚拟地址空间。这就需要创建一个新的页目录和相应的页表。但是,操作系统的内核空间是被所有进程共享的,也就是说,所有进程的虚拟地址空间在 UTOP 以上的部分都是相同的。因此,我们可以直接复制内核的页目录到新进程的页目录,这样就可以快速地为新进程创建和内核相同的虚拟地址空间。

这种设计使得每个进程都可以在用户模式下运行自己的代码,同时在内核模式下访问共享的内核资源。当进程需要进行系统调用,如读写文件、创建新进程等操作时,它会切换到内核模式,此时可以访问 UTOP 以上的内核空间。系统调用完成后,再切换回用户模式,继续执行用户空间的代码。

总的来说,复制内核的页目录到新环境的页目录中,是为了保持虚拟地址空间的上半部分的一致性,以及为新环境提供正确的 UVPT 映射。

最后,它设置 UVPT 映射进程自己的页表为只读。这是因为进程不应该能够修改自己的页表,否则可能会破坏内存管理系统。这样,新的进程就有了自己的虚拟内存,可以加载和运行代码了。

复制用户栈

接下来需要将父进程的用户栈复制到子进程中,是的父子进程的用户栈是一致的。这样做可以确保正确的执行状态继承和维护进程的独立性。

envid_t
fork(void)
{
    // ...
	for (uint32_t addr = 0; addr < USTACKTOP; addr += PGSIZE) {
		// Check for present, user-mapped pages
		if ((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & (PTE_P | PTE_U))) {
			duppage(envid, PGNUM(addr));
		}
	}
    // ...
}

为什么 Fork 要保证父子进程具有相同的用户栈? 主要有以下几个原因:

  1. 维持代码执行上下文一致性 在 Fork 之后, 子进程需要从父进程的当前执行点继续执行。用户栈上存储了函数调用的上下文信息, 如局部变量、返回地址等。如果子进程和父进程的用户栈不同, 那么子进程将无法正确恢复执行上下文, 导致程序执行出错。

  2. 共享内存优化 在 COW Fork 中, 父子进程的内存页面被标记为只读, 并且共享相同的物理内存页。这种优化减少了内存占用, 提高了 Fork 效率。如果不复制用户栈, 父子进程可以共享用户栈页面, 从而节省物理内存。

  3. 维持进程地址空间一致性 用户栈是进程地址空间的一部分。如果父子进程的用户栈不同, 就意味着它们的地址空间布局不同。这将破坏地址空间一致性, 可能导致其他相关功能(如内存映射等)出现问题。

  4. 简化实现复杂度 如果不复制用户栈, 实现 COW Fork 将会更加复杂。需要特别处理用户栈区域, 而且需要考虑诸如栈增长方向、栈检测等问题。复制用户栈可以简化实现。

那段代码的作用就是遍历父进程的用户虚拟内存页面, 对于那些已经映射并且属于用户空间的页面, 调用 duppage 函数将它们复制到子进程的地址空间中。这样就确保了父子进程共享相同的用户内存映射, 包括用户栈区域。

综上所述, 在 COW Fork 中复制用户栈虽然会增加一些开销, 但可以简化实现、保证执行一致性, 并且利用了内存共享的优势, 因此是一个非常合理的设计和实现选择。

总结

总结一下,上篇文章解决了 COW Fork 中页面错误的问题。这篇文章讲解了后续如何创建一个子进程并且确保父子进程的堆栈信息一致。