# 实验目的

  1. 掌握系统调用的概念及流程
  2. 实现进程间通信机制
  3. 实现 fork 函数
  4. 掌握页写入异常的处理流程

在用户态下,用户进程不能访问系统的内核空间,也就是说它一般不能存取内核使用的内存数据,也不能调用内核函数,这一点是由体系结构保证的。然而,用户进程在特定的场景下往往需要执行一些只能由内核完成的操作,如操作硬件、动态分配内存,以及与其他进程进行通信等。允许在内核态执行用户程序提供的代码显然是不安全的,因此操作系统设计了一系列内核空间中的函数,当用户进程需要进行这些操作时,会引发特定的异常以陷入内核态,由内核调用对应的函数,从而安全地为用户进程提供受限的系统级操作,我们把这种机制称为 系统调用

在 Lab4 中,我们需要实现上述的系统调用机制,并在此基础上实现进程间通信(IPC)机制和一个重要的进程创建机制 fork。在 fork 部分的实验中,我们会介绍一种被称为写时复制(COW)的特性,以及与其相关的页写入异常处理。


# 系统调用

# 系统调用的本质

MIPS 中, syscall 是一条用于执行系统调用的 自陷指令,它使得进程陷入到内核的异常处理程序中,由内核根据系统调用时的上下文执行相应的内核函数,完成相应的功能,并最终返回到 syscall后一条指令

  • 存在一些只能由内核来完成的操作(如读写设备、创建进程、 IO 等)。
  • C 标准库中一些函数的实现须依赖于操作系统(如我们所探究的 puts 函数)。
  • 通过执行 syscall 指令,用户进程可以陷入到内核态,请求内核提供的服务。
  • 通过系统调用陷入到内核态时,需要在用户态与内核态之间进行数据传递与保护。

# 系统调用机制的实现

系统调用的流程如下:

  • 程序调用用户空间的 syscall_* 函数(这里 * 是通配符,指各种系统调用号)
  • syscall_* 函数调用 msyscall 函数,系统由此陷入内核态
  • 内核态中将异常分发到 handle_sys 函数,将系统调用所需要的信息传递入内核
  • 内核取得信息,执行对应的内核空间的系统调用函数( sys_*
  • 系统调用完成,并返回用户态,同时将返回值 “传递” 回用户态
  • 从系统调用函数返回,回到用户程序调用处

其中:

  • syscall_* 的函数是我们在用户空间中最接近的内核的函数在用户态调用
  • sys_* 的函数是内核中系统调用的具体实现部分,在内核态执行

系统调用号

类似于不同异常类型对应不同异常号,系统调用号是内核区分不同系统调用的唯一依据,是一个宏,例如: SYS_print_cons

# 栈帧

MIPS 的调用规范中,进入函数体时会通过对栈指针做减法(压栈)的方式为该函数自身的局部变量返回地址、** 调用函数的参数 ** 分配存储空间。

  • 调用方:在自身栈帧的底部预留被调用函数的参数存储空间。
  • 被调用方:从调用方的栈帧中读取参数

传参实现:

  • 寄存器 a0-a3 用于存放函数调用的前四个参数,但是仍要预留空间

    a0a3 的实参空间分配是有必要的,如果一个 callee 要使用 a0a3 这些参数寄存器,又不想破坏实参的值,就可以把他们临时 “写回” 到 caller 栈上相应的空间里,需要恢复的时候再恢复回来。

  • 其余的参数存放在栈中。

下图是调用有 6 个参数的 msyscall 函数的传参示意图

image-20250426153416849

# msyscall 函数

此函数没有局部变量,且是叶函数(不再调用其他函数),故也不需要分配栈帧。只需执行自陷指令 syscall 来陷入内核态并在处理结束后正常返回即可

请注意, syscall 指令是不允许在延迟槽中使用的

msyscall 一共有 6 个参数,第一个参数就是系统调用号,储存在 $a0 寄存器中。

# Exercise 4.1

LEAF(msyscall)
	move v0, a0
	syscall
	jr ra
	nop
END(msyscall)

# do_syscall 函数

在通过 syscall 指令陷入内核态后,处理器将 PC 寄存器指向一个内核中固定的异常处理入口。在异常向量表中,系统调用这一异常类型的处理入口为 handle_sys 函数,它是在 kern/genex.S 中定义的对 do_syscall 函数的包装

陷入内核

  • 系统从用户态切换到内核态后,内核首先需要将原用户进程的运行现场保存到内核空间(在 kern/entry.S 中通过 SAVE_ALL 宏完成)。随后的栈指针 $sp 则指向保存的 Trapframe
  • 当内核在 handle_ 开头的包装函数中调用实际进行异常处理的 C 函数时,这个栈指针 $spstruct Trapframe * 将作为参数传递给这个 C 函数。因此我们可以在 C 语言中通过它来获取用户态现场中的参数。
void do_syscall(struct Trapframe *tf);

流程如下:

  • 系统调用号检查

    • tf->regs[4] (对应 $a0 寄存器)获取系统调用号

    • 检查系统调用号是否有效,无效则设置错误码并返回

  • Step1: 更新 EPC

    • cp0_epc 存储着引发异常的指令地址。加 4 使其指向下一条指令,这样从异常 返回后会执行下一条指令
  • Step2: 获取处理函数

    • 使用系统调用号作为索引从 syscall_table 中获取对应的处理函数
  • Step3: 获取前 3 个参数

    • tf->regs[5-7] 对应 a1- a3 寄存器
  • Step4: 获取后 2 个参数

    • $sp + 16$sp + 20 位置读取这两个参数

    • tf->regs[29] 对应 $sp 寄存器

  • Step5: 调用处理函数

    • 使用提取的 5 个参数调用系统调用处理函数

    • 将返回值存入 tf->regs[2] (对应 $v0 寄存器)

# Exercise 4.2

void do_syscall(struct Trapframe *tf) {
	int (*func)(u_int, u_int, u_int, u_int, u_int);
	int sysno = tf->regs[4];
	if (sysno < 0 || sysno >= MAX_SYSNO) {
		tf->regs[2] = -E_NO_SYS;
		return;
	}
	/* Step 1: Add the EPC in 'tf' by a word (size of an instruction). */
	/* Exercise 4.2: Your code here. (1/4) */
	tf->cp0_epc += 4;
	/* Step 2: Use 'sysno' to get 'func' from 'syscall_table'. */
	/* Exercise 4.2: Your code here. (2/4) */
	func = syscall_table[sysno];
	/* Step 3: First 3 args are stored in $a1, $a2, $a3. */
	u_int arg1 = tf->regs[5];
	u_int arg2 = tf->regs[6];
	u_int arg3 = tf->regs[7];
	/* Step 4: Last 2 args are stored in stack at [$sp + 16 bytes], [$sp + 20 bytes]. */
	u_int arg4, arg5;
	/* Exercise 4.2: Your code here. (3/4) */
	arg4 = *(u_int*)(tf->regs[29] + 16);
	arg5 = *(u_int*)(tf->regs[29] + 20);
	/* Step 5: Invoke 'func' with retrieved arguments and store its return value to $v0 in 'tf'.
	 */
	/* Exercise 4.2: Your code here. (4/4) */
	tf->regs[2] = func(arg1, arg2, arg3, arg4, arg5);
}

# 基础系统调用函数

# envid2env 函数

int envid2env(u_int envid, struct Env **penv, int checkperm);

该函数用于将 envid 转换为对应的 Env 结构体指针的核心函数,同时还会进行权限检查。流程如下:

  • envid 获取 Env 结构体
    • envid == 0 是特殊约定,表示当前运行的进程( curenv
    • ENVX(envid) 是一个宏,从 envid 中提取进程控制块在 envs 数组中的索引
  • 检查进程控制块是否被占用、 env_id 是否匹配
  • 检查权限 checkperm :任何进程只能操作自己或者自己的子进程
  • 将得到的进程控制块赋给 *penv

# Exercise 4.3

int envid2env(u_int envid, struct Env **penv, int checkperm)
{
	struct Env *e;
	/* Step 1: Assign value to 'e' using 'envid'. */
	/* Hint:
	 *   If envid is zero, set 'penv' to 'curenv' and return 0.
	 *   You may want to use 'ENVX'.
	 */
	/* Exercise 4.3: Your code here. (1/2) */
	if (envid == 0)
	{
		*penv = curenv;
		return 0;
	}
	e = &envs[ENVX(envid)];
	if (e->env_status == ENV_FREE || e->env_id != envid)
	{
		return -E_BAD_ENV;
	}
	/* Step 2: Check when 'checkperm' is non-zero. */
	/* Hints:
	 *   Check whether the calling env has sufficient permissions to manipulate the
	 *   specified env, i.e. 'e' is either 'curenv' or its immediate child.
	 *   If violated, return '-E_BAD_ENV'.
	 */
	/* Exercise 4.3: Your code here. (2/2) */
	if (checkperm && e != curenv && e->env_parent_id != curenv->env_id)
	{
		return -E_BAD_ENV;
	}
	/* Step 3: Assign 'e' to '*penv'. */
	*penv = e;
	return 0;
}

# sys_mem_alloc 函数

int sys_mem_alloc(u_int envid, u_int va, u_int perm);

通过这个系统调用,用户程序可以给该程序所允许的虚拟内存空间 显式地 分配实际的物理内存。流程如下:

  • 检查虚拟地址的合法性
  • 通过上面的 envid2env 函数得到对应的 env
  • 申请空闲物理页
  • 建立物理页到虚拟地址的映射,存入 env 的页目录中

# Exercise 4.4

int sys_mem_alloc(u_int envid, u_int va, u_int perm) {
	struct Env *env;
	struct Page *pp;
	/* Step 1: Check if 'va' is a legal user virtual address using 'is_illegal_va'. */
	/* Exercise 4.4: Your code here. (1/3) */
	if (is_illegal_va(va))
	{
		return -E_INVAL; // 非法虚拟地址
	}
	/* Step 2: Convert the envid to its corresponding 'struct Env *' using 'envid2env'.*/
	/* Hint: **Always** validate the permission in syscalls! */
	/* Exercise 4.4: Your code here. (2/3) */
	if (envid2env(envid, &env, 1) != 0)
	{	
		return -E_BAD_ENV; // 返回错误码
	}
	/* Step 3: Allocate a physical page using 'page_alloc'. */
	/* Exercise 4.4: Your code here. (3/3) */
	if (page_alloc(&pp)!=0)
	{
		return -E_NO_MEM;
	}
	/* Step 4: Map the allocated page at 'va' with permission 'perm' using 'page_insert'. */
	return page_insert(env->env_pgdir, env->env_asid, pp, va, perm);
}

# sys_mem_map 函数

int sys_mem_map(u_int srcid, u_int srcva, u_int dstid, u_int dstva, u_int perm);

此函数的作用是将 源进程 地址空间中的相应内存映射到 目标进程 的相应地址空间的相应虚拟内存中去。此时两者共享一页物理内存

流程如下:

  • 虚拟地址合法性检查
  • 得到源进程控制块(查找时不检查权限)
  • 得到目标进程控制块(查找时不检查权限)
  • 使用 page_lookup() 函数在源进程页表中查找 srcva 对应的物理页 pp
    • 如果未映射 (pp == NULL) ,返回 -E_INVAL
  • 在目标进程创建新映射
    • 使用 page_insert() 函数将找到的物理页映射到目标进程的 dstva ,同时设置指定的权限位 perm

# Exercise 4.5

int sys_mem_map(u_int srcid, u_int srcva, u_int dstid, u_int dstva, u_int perm)
{
	struct Env *srcenv;
	struct Env *dstenv;
	struct Page *pp;
	/* Step 1: Check if 'srcva' and 'dstva' are legal user virtual addresses using
	 * 'is_illegal_va'. */
	/* Exercise 4.5: Your code here. (1/4) */
	if (is_illegal_va(srcva) || is_illegal_va(dstva))
	{
		return -E_INVAL;
	}
	/* Step 2: Convert the 'srcid' to its corresponding 'struct Env *' using 'envid2env'. */
	/* Exercise 4.5: Your code here. (2/4) */
	try(envid2env(srcid, &srcenv, 1));
	/* Step 3: Convert the 'dstid' to its corresponding 'struct Env *' using 'envid2env'. */
	/* Exercise 4.5: Your code here. (3/4) */
	try(envid2env(dstid, &dstenv, 1));
	/* Step 4: Find the physical page mapped at 'srcva' in the address space of 'srcid'. */
	/* Return -E_INVAL if 'srcva' is not mapped. */
	/* Exercise 4.5: Your code here. (4/4) */
	pp = page_lookup(srcenv->env_pgdir, srcva, NULL);
	if (pp == NULL)
	{
		return -E_INVAL;
	}
	/* Step 5: Map the physical page at 'dstva' in the address space of 'dstid'. */
	return page_insert(dstenv->env_pgdir, dstenv->env_asid, pp, dstva, perm);
}

# sys_mem_unmap 函数

int sys_mem_unmap(u_int envid, u_int va);

此函数功能解除某个进程地址空间虚拟内存和物理内存之间的映射关系。流程如下:

  • 虚拟地址合法性检查
  • 得到进程控制块 env (需要检查权限)
  • 使用 page_move() 函数解除映射

# Exercise 4.6

int sys_mem_unmap(u_int envid, u_int va) {
	struct Env *e;
	/* Step 1: Check if 'va' is a legal user virtual address using 'is_illegal_va'. */
	/* Exercise 4.6: Your code here. (1/2) */
	if (is_illegal_va(va))
	{
		return -E_INVAL;
	}
	/* Step 2: Convert the envid to its corresponding 'struct Env *' using 'envid2env'. */
	/* Exercise 4.6: Your code here. (2/2) */
	if (envid2env(envid, &e, 1) < 0)
	{
		return -E_BAD_ENV;
	}
	/* Step 3: Unmap the physical page at 'va' in the address space of 'envid'. */
	page_remove(e->env_pgdir, e->env_asid, va);
	return 0;
}

# sys_yield 函数

void __attribute__((noreturn)) sys_yield(void);

此函数的功能是实现用户进程对 CPU 的放弃,从而调度其他的进程

使用 __attribute__((noreturn)) 标记,表示这个函数不会返回到调用者

直接调用 schedule() 函数即可

# Exercise 4.7

void __attribute__((noreturn)) sys_yield(void) {
    // 调用调度器,主动让出 CPU
    schedule(1);  // 参数 1 表示主动让出 (yield)
    
    // 这个函数不会返回,因为调度器会选择另一个进程运行
    //__attribute__((noreturn)) 告诉编译器这个函数不会返回
    while (1) {}  // 理论上不会执行到这里
}

# 进程间通信机制 (IPC)

IPC 的目的是使两个进程之间可以通信,需要通过系统调用来实现

所有的进程都共享同一个内核空间(主要为 kseg0 )。因此,想要在不同空间之间交换数据,我们就可以借助于内核空间来实现。发送方进程可以将数据以系统调用的形式存放在进程控制块中,接收方进程同样以系统调用的方式在进程控制块中找到对应的数据,读取并返回

struct Env { 
	u_int env_ipc_value; 		// 进程传递的具体数值
	u_int env_ipc_from; 		// 发送方的进程 ID
	u_int env_ipc_recving; 		//1:等待接受数据中;0:不可接受数据
	u_int env_ipc_dstva; 		// 接收到的页面需要与自身的哪个虚拟页面完成映射
	u_int env_ipc_perm; 		// 传递的页面的权限位设置
};

# sys_ipc_recv 函数

int sys_ipc_recv(u_int dstva);

用于接受消息

  • 首先要将自身的 env_ipc_recving 设置为 1,表明该进程准备接受发送方的消息
  • 之后给 env_ipc_dstva 赋值,表明自己要将接受到的页面与 dstva 完成映射
  • 阻塞当前进程,即把当前进程的状态置为不可运行( ENV_NOT_RUNNABLE
  • 最后放弃 CPU (调用相关函数重新进行调度),等待发送方将数据发送过来

# sys_ipc_try_send 函数

int sys_ipc_try_send(u_int envid, u_int value, u_int srcva, u_int perm);

用于发送消息

  • 根据 envid 找到相应进程,如果指定进程为可接收状态 (考虑 env_ipc_recving ),则发送成功
  • 否则,函数返回 -E_IPC_NOT_RECV ,表示目标进程未处于接受状态
  • 清除接收进程的接收状态,将相应数据填入进程控制块,传递物理页面的映射关系
  • 修改进程控制块中的进程状态,使接受数据的进程可继续运行 ( ENV_RUNNABLE )

# Exercise 4.8

int sys_ipc_recv(u_int dstva)
{
	/* Step 1: Check if 'dstva' is either zero or a legal address. */
	if (dstva != 0 && is_illegal_va(dstva))
	{
		return -E_INVAL;
	}
	/* Step 2: Set 'curenv->env_ipc_recving' to 1. */
	/* Exercise 4.8: Your code here. (1/8) */
	curenv->env_ipc_recving = 1;
	/* Step 3: Set the value of 'curenv->env_ipc_dstva'. */
	/* Exercise 4.8: Your code here. (2/8) */
	curenv->env_ipc_dstva = dstva;
	/* Step 4: Set the status of 'curenv' to 'ENV_NOT_RUNNABLE' and remove it from
	 * 'env_sched_list'. */
	/* Exercise 4.8: Your code here. (3/8) */
	curenv->env_status = ENV_NOT_RUNNABLE;
	TAILQ_REMOVE(&env_sched_list, curenv, env_sched_link);
	/* Step 5: Give up the CPU and block until a message is received. */
	((struct Trapframe *)KSTACKTOP - 1)->regs[2] = 0;
	schedule(1);
}
int sys_ipc_try_send(u_int envid, u_int value, u_int srcva, u_int perm)
{
	struct Env *e;
	struct Page *p;
	/* Step 1: Check if 'srcva' is either zero or a legal address. */
	/* Exercise 4.8: Your code here. (4/8) */
	if (srcva != 0 && is_illegal_va(srcva))
	{
		return -E_INVAL;
	}
	/* Step 2: Convert 'envid' to 'struct Env *e'. */
	/* This is the only syscall where the 'envid2env' should be used with 'checkperm' UNSET,
	 * because the target env is not restricted to 'curenv''s children. */
	/* Exercise 4.8: Your code here. (5/8) */
	try(envid2env(envid, &e, 0));
	/* Step 3: Check if the target is waiting for a message. */
	/* Exercise 4.8: Your code here. (6/8) */
	if (e->env_ipc_recving == 0)
	{
		return -E_IPC_NOT_RECV;
	}
	/* Step 4: Set the target's ipc fields. */
	e->env_ipc_value = value;
	e->env_ipc_from = curenv->env_id;
	e->env_ipc_perm = PTE_V | perm;
	e->env_ipc_recving = 0;
	/* Step 5: Set the target's status to 'ENV_RUNNABLE' again and insert it to the tail of
	 * 'env_sched_list'. */
	/* Exercise 4.8: Your code here. (7/8) */
	e->env_status = ENV_RUNNABLE;
	TAILQ_INSERT_TAIL(&env_sched_list, e, env_sched_link);
	/* Step 6: If 'srcva' is not zero, map the page at 'srcva' in 'curenv' to 'e->env_ipc_dstva'
	 * in 'e'. */
	/* Return -E_INVAL if 'srcva' is not zero and not mapped in 'curenv'. */
	if (srcva != 0)
	{
		/* Exercise 4.8: Your code here. (8/8) */
		p = page_lookup(curenv->env_pgdir, srcva, NULL);
		if (p == NULL)
		{
			return -E_INVAL;
		}
		try(page_insert(e->env_pgdir, e->env_asid, p, e->env_ipc_dstva, perm));
	}
	return 0;
}

# Fork

# 概念

一个进程在调用 fork() 函数后,将从此分叉成为两个进程运行。

  • 其中新产生的进程称为原进程的子进程。

    • 子进程开始运行时的大部分上下文状态与原进程相同

      包括程序和 fork 运行时的现场(包括通用寄存器和程序计数器 PC 等)。

    • 在子进程中, fork() 调用的返回值为 0 。

  • 其中旧的进程称为子进程的父进程。

    • 在父进程中, fork() 调用的返回值为子进程的 env_id

      env_id 一定大于 0 。

      fork 失败的情况下,子进程不会被创建,且父进程将得到小于 0 的返回值

# 写时复制机制

在调用 fork 后,子进程会继承父进程地址空间中的代码段和数据段等内容。由于在 fork 后,父子进程将成为相互独立的两个进程,因此两个进程对于其内存的修改应该是互不影响的。

为了父子进程能够共用尽可能多的物理内存,我们希望引入一种写时复制( Copy-on-write,COW )机制:

  • fork 时,只需将地址空间中的所有可写页标记为写时复制页面。
  • 根据标记,在父进程或子进程对写时复制页面进行写入时,能够产生一种异常。
  • 操作系统处理异常如下。
    • 为当前进程试图写入的虚拟地址分配新的物理页面。
    • 新的页面复制原页面的内容。
    • 返回用户程序。
  • 处理完成后即可对新分配的物理页面进行写入。

进程调用 fork 时,需要对其所有的可写入的内存页面,设置页表项标志位 PTE_COW 并取消可写位 PTE_D (所有页均不可写,后续写入时触发异常),以实现写时复制保护

# fork 函数

这里我们将 Exercise 4.15 提前,先了解 fork 的执行流程

  • 为写时复制做准备:

    调用 syscall_set_tlb_mod_entry 设置父进程的 TLB Mod 异常处理函数,即 cow_entry

  • 调用 syscall_exofork 创建子进程

  • 调用 duppage 对所有共享页面做标记:

    • 将可写页面标记 COW
    • 取消所有页面的 PTE_D ,即标记不可写
  • 调用 syscall_set_tlb_mod_entry 设置子进程的 TLB Mod 异常处理函数,即 cow_entry

  • 调用 syscall_set_env_status 将子进程状态设为 ENV_RUNNABLE ,加入调度队列,等待执行。

下图直观地展示了上述步骤。

我们接下来要实现的就是以上步骤所调用的各个函数

image-20250507143741659

# Exercise 4.15

int fork(void)
{
	u_int child;
	u_int i;
	/* Step 1: Set our TLB Mod user exception entry to 'cow_entry' if not done yet. */
	if (env->env_user_tlb_mod_entry != (u_int)cow_entry)
	{
		try(syscall_set_tlb_mod_entry(0, cow_entry));
	}
	/* Step 2: Create a child env that's not ready to be scheduled. */
	// Hint: 'env' should always point to the current env itself, so we should fix it to the
	// correct value.
	child = syscall_exofork();
	if (child == 0)
	{
		env = envs + ENVX(syscall_getenvid());
		return 0;
	}
	/* Step 3: Map all mapped pages below 'USTACKTOP' into the child's address space. */
	// Hint: You should use 'duppage'.
	/* Exercise 4.15: Your code here. (1/2) */
	for (i = 0; i < PDX(UXSTACKTOP); i++)
	{
		if (vpd[i] & PTE_V)
		{
			for (u_int j = 0; j < PAGE_SIZE / sizeof(Pte); j++)
			{
				u_long va = (i * (PAGE_SIZE / sizeof(Pte)) + j) << PGSHIFT;
				if (va >= USTACKTOP)
				{
					break;
				}
				if (vpt[VPN(va)] & PTE_V)
				{
					duppage(child, VPN(va));
				}
			}
		}
	}
	/* Step 4: Set up the child's tlb mod handler and set child's 'env_status' to
	 * 'ENV_RUNNABLE'. */
	/* Hint:
	 *   You may use 'syscall_set_tlb_mod_entry' and 'syscall_set_env_status'
	 *   Child's TLB Mod user exception entry should handle COW, so set it to 'cow_entry'
	 */
	/* Exercise 4.15: Your code here. (2/2) */
	syscall_set_tlb_mod_entry(child, cow_entry);
	syscall_set_env_status(child, ENV_RUNNABLE);
	return child;
}

# sys_exofork 函数

int sys_exofork(void);

此函数的功能是创建子进程。在 fork 过程中,通过判断 syscall_exofork 的返回值来决定 fork 的返回值以及后续动作

  • 创建进程结构体

  • 保存现场

  • 子进程状态设置:

    状态设为 ENV_NOT_RUNNABLE (不可运行),继承父进程的优先级( env_pri

  • 返回值:
    父进程返回子进程的 env_id
    子进程(当它运行时)会因为寄存器状态中 regs[2]=0 而看到返回值为 0

# Exercise 4.9

int sys_exofork(void)
{
	struct Env *e;
	/* Step 1: Allocate a new env using 'env_alloc'. */
	/* Exercise 4.9: Your code here. (1/4) */
	try(env_alloc(&e, curenv->env_id));
	/* Step 2: Copy the current Trapframe below 'KSTACKTOP' to the new env's 'env_tf'. */
	/* Exercise 4.9: Your code here. (2/4) */
	e->env_tf = *((struct Trapframe *)KSTACKTOP - 1);
	/* Step 3: Set the new env's 'env_tf.regs[2]' to 0 to indicate the return value in child. */
	/* Exercise 4.9: Your code here. (3/4) */
	e->env_tf.regs[2] = 0;
	/* Step 4: Set up the new env's 'env_status' and 'env_pri'.  */
	/* Exercise 4.9: Your code here. (4/4) */
	e->env_status = ENV_NOT_RUNNABLE;
	e->env_pri = curenv->env_pri;
	return e->env_id;
}

# duppage 函数

此函数用于标记共享页

static void duppage(u_int envid, u_int vpn);
  • 获取页面权限
    • addr : 将虚拟页号转换为虚拟地址(左移 PGSHIFT 位,通常是 12 位,因为页大小是 4KB)
    • perm : 从页表项(通过 vpt 数组访问)中提取权限位(低 12 位)
  • 处理页面映射
    • 对于只读或共享页面,直接共享。
    • 对于可写页面,将其标记为 COW ,并在父子进程中取消写权限。
    • 当任一进程尝试写入 COW 页面时,会触发页错误,此时再真正复制页面。

# Exercise 4.10

static void duppage(u_int envid, u_int vpn)
{
	int r;
	u_int addr;
	u_int perm;
	/* Step 1: Get the permission of the page. */
	/* Hint: Use 'vpt' to find the page table entry. */
	/* Exercise 4.10: Your code here. (1/2) */
	addr = vpn << PGSHIFT;
	perm = vpt[vpn] & ((1 << PGSHIFT) - 1);
	/* Step 2: If the page is writable, and not shared with children, and not marked as COW yet,
	 * then map it as copy-on-write, both in the parent (0) and the child (envid). */
	/* Hint: The page should be first mapped to the child before remapped in the parent. (Why?)
	 */
	/* Exercise 4.10: Your code here. (2/2) */
	if ((perm & PTE_D) == 0 || (perm & PTE_LIBRARY) || (perm & PTE_COW))
	{
		if ((r = syscall_mem_map(0, (void *)addr, envid, (void *)addr, perm)) < 0)
		{
			user_panic("user panic mem map error: %d", r);
		}
	}
	else
	{
		if ((r = syscall_mem_map(0, (void *)addr, envid, (void *)addr,
								 (perm & ~PTE_D) | PTE_COW)) < 0)
		{
			user_panic("user panic mem map error: %d", r);
		}
		if ((r = syscall_mem_map(0, (void *)addr, 0, (void *)addr,
								 (perm & ~PTE_D) | PTE_COW)) < 0)
		{
			user_panic("user panic mem map error: %d", r);
		}
	}
}

# do_tlb_mod 函数

它负责调用用户态定义的 TLB 修改异常处理函数 env_user_tlb_mod_entry

  1. 保存当前 CPU 状态( Trapframe )到用户栈。
  2. 检查是否有用户态 TLB 修改处理函数( env_user_tlb_mod_entry )。
  3. 如果有,则设置 cp0_epc 跳转到该函数(通常用于处理 COW 或页面权限问题)。
  4. 如果没有,则 panic (因为无法处理异常)。

# Exercise 4.11

void do_tlb_mod(struct Trapframe *tf)
{
	struct Trapframe tmp_tf = *tf;
	if (tf->regs[29] < USTACKTOP || tf->regs[29] >= UXSTACKTOP)
	{
		tf->regs[29] = UXSTACKTOP;
	}
	tf->regs[29] -= sizeof(struct Trapframe);
	*(struct Trapframe *)tf->regs[29] = tmp_tf;
	Pte *pte;
	page_lookup(cur_pgdir, tf->cp0_badvaddr, &pte);
	if (curenv->env_user_tlb_mod_entry)
	{
		tf->regs[4] = tf->regs[29];
		tf->regs[29] -= sizeof(tf->regs[4]);
		// Hint: Set 'cp0_epc' in the context 'tf' to 'curenv->env_user_tlb_mod_entry'.
		/* Exercise 4.11: Your code here. */
		tf->cp0_epc = curenv->env_user_tlb_mod_entry;
	}
	else
	{
		panic("TLB Mod but no user handler registered");
	}
}

# sys_set_tlb_mod_entry 函数

# Exercise 4.12

int sys_set_tlb_mod_entry(u_int envid, u_int func)
{
	struct Env *env;
	/* Step 1: Convert the envid to its corresponding 'struct Env *' using 'envid2env'. */
	/* Exercise 4.12: Your code here. (1/2) */
	try(envid2env(envid, &env, 1));
	/* Step 2: Set its 'env_user_tlb_mod_entry' to 'func'. */
	/* Exercise 4.12: Your code here. (2/2) */
	env->env_user_tlb_mod_entry = func;
	return 0;
}

# cow_entry 函数

static void __attribute__((noreturn)) cow_entry(struct Trapframe *tf)

用于实现写时复制,不返回

  • 获取触发异常的地址和权限
  • 更新权限:移除 PTE_COW ,添加 PTE_D (可写)
  • 分配一个新页面( UCOW 作为临时映射地址)
  • 复制原页面内容到新页面
  • 将新页面映射到原地址 va
  • 解除 UCOW 的临时映射
  • 恢复现场,返回用户态继续执行

# Exercise 4.13

static void __attribute__((noreturn)) cow_entry(struct Trapframe *tf)
{
	u_int va = tf->cp0_badvaddr;
	u_int perm;
	/* Step 1: Find the 'perm' in which the faulting address 'va' is mapped. */
	/* Hint: Use 'vpt' and 'VPN' to find the page table entry. If the 'perm' doesn't have
	 * 'PTE_COW', launch a 'user_panic'. */
	/* Exercise 4.13: Your code here. (1/6) */
	perm = PTE_FLAGS(vpt[VPN(va)]);
	if ((perm & PTE_COW) == 0)
	{
		user_panic("PTE_COW not found, va=%08x, perm=%08x", va, perm);
	}
	/* Step 2: Remove 'PTE_COW' from the 'perm', and add 'PTE_D' to it. */
	/* Exercise 4.13: Your code here. (2/6) */
	perm = (perm & ~PTE_COW) | PTE_D;
	/* Step 3: Allocate a new page at 'UCOW'. */
	/* Exercise 4.13: Your code here. (3/6) */
	syscall_mem_alloc(0, (void *)UCOW, perm);
	/* Step 4: Copy the content of the faulting page at 'va' to 'UCOW'. */
	/* Hint: 'va' may not be aligned to a page! */
	/* Exercise 4.13: Your code here. (4/6) */
	memcpy((void *)UCOW, (void *)ROUNDDOWN(va, PAGE_SIZE), PAGE_SIZE);
	// Step 5: Map the page at 'UCOW' to 'va' with the new 'perm'.
	/* Exercise 4.13: Your code here. (5/6) */
	syscall_mem_map(0, (void *)UCOW, 0, (void *)va, perm);
	// Step 6: Unmap the page at 'UCOW'.
	/* Exercise 4.13: Your code here. (6/6) */
	syscall_mem_unmap(0, (void *)UCOW);
	// Step 7: Return to the faulting routine.
	int r = syscall_set_trapframe(0, tf);
	user_panic("syscall_set_trapframe returned %d", r);
}

# sys_set_env_status 函数

int sys_set_env_status(u_int envid, u_int status)

设置进程( Env )的运行状态,并管理调度队列

# Exercise 4.14

int sys_set_env_status(u_int envid, u_int status)
{
	struct Env *env;
	/* Step 1: Check if 'status' is valid. */
	/* Exercise 4.14: Your code here. (1/3) */
	if (status != ENV_RUNNABLE && status != ENV_NOT_RUNNABLE)
	{
		return -E_INVAL;
	}
	/* Step 2: Convert the envid to its corresponding 'struct Env *' using 'envid2env'. */
	/* Exercise 4.14: Your code here. (2/3) */
	try(envid2env(envid, &env, 1));
	/* Step 3: Update 'env_sched_list' if the 'env_status' of 'env' is being changed. */
	/* Exercise 4.14: Your code here. (3/3) */
	if (status == ENV_RUNNABLE && env->env_status != ENV_RUNNABLE)
	{
		TAILQ_INSERT_TAIL(&env_sched_list, env, env_sched_link);
	}
	else if (status == ENV_NOT_RUNNABLE && env->env_status != ENV_NOT_RUNNABLE)
	{
		TAILQ_REMOVE(&env_sched_list, env, env_sched_link);
	}
	/* Step 4: Set the 'env_status' of 'env'. */
	env->env_status = status;
	/* Step 5: Use 'schedule' with 'yield' set if ths 'env' is 'curenv'. */
	if (env == curenv)
	{
		schedule(1);
	}
	return 0;
}

# 思考题

# Thinking 4.1

  • 内核在保存现场的时候是如何避免破坏通用寄存器的?
  • 系统陷入内核调用后可以直接从当时的 a0-a3 参数寄存器中得到用户调用 msyscall 留下的信息吗?
  • 我们是怎么做到让 sys 开头的函数 “认为” 我们提供了和用户调用 msyscall 时同样的参数的?
  • 内核处理系统调用的过程对 Trapframe 做了哪些更改?这种修改对应的用户态的变化是什么?
  • 内核在保护现场时,将自身的局部变量、返回地址、调用函数的参数等压入栈中,保存此时通用寄存器中的值;返回时,再将这些值弹出栈并赋给相应的寄存器。
  • 不可以。这四个寄存器在系统陷入内核后,可能会进行其他的操作而导致里面的值被覆盖,直接访问是不安全的,应该从栈帧中来读取相关参数。
  • 在用户态调用 msyscall 时,前四个参数会被存入 a0-a3 寄存器,同时在栈帧底部保留 16 字节的空间,后两个参数被存入在预留空间之上的 8 字节空间内,内核态中显式地从保存的用户上下文中获取这些参数值,这样就可以让 sys 开头的函数认为我们提供了和用户调用 msyscall 时同样的参数。
  • EPC 值加 4 以保证系统调用完成后返回到正确地址,同时将返回值存入 2 号寄存器。用户态会正确执行下一条指令。

# Thinking 4.2

思考 envid2env 函数:为什么 envid2env 中需要判断 e->env_id != envid 的情况?如果没有这步判断会发生什么情况?

在通过索引取 envs 数组中的第 id 个进程块时只取了 envid 的后 10 位,但是 envid 的后 10 位在生成的时候只与进程页的物理位置有关。要保证一个进程的 id 号完全对应,仅仅看后十位是不够的,也要确保前 22 位是一样的,因此 e->env_id != envid 这一步确定进程的 id 确实是传入的 envid

# Thinking 4.3

请回顾 kern/env.c 文件中 mkenvid() 函数的实现,该函数不会返回 0,请结合系统调用和 IPC 部分的实现与 envid2env() 函数的行为进行解释。

在 mips 中认为 envid=0 表示的是当前进程。因此在创建一个新的进程是如果 envid=0 ,那么就无法区分新进程与当前进程。

# Thinking 4.4

关于 fork 函数的两个返回值,下面说法正确的是:

A、 fork 在父进程中被调用两次,产生两个返回值

B、 fork 在两个进程中分别被调用一次,产生两个不同的返回值

C、 fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值

D、 fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值

C

# Thinking 4.5

我们并不应该对所有的用户空间页都使用 duppage 进行映射。那么究竟哪些用户空间页应该映射,哪些不应该呢?请结合 kern/env.cenv_init 函数进行的页面映射、 include/mmu.h 里的内存布局图以及本章的后续描述进行思考。

  • 从 0 到 USTACKTOP 的用户空间页应该映射;
  • USTACKTOPUTOP 之间的内容是拿来处理页写入异常的,因此不需要与子进程共享;
  • UTOP 之上的内容是所有进程共享的,也不需要做父子进程之间单独的共享。

# Thinking 4.6

在遍历地址空间存取页表项时你需要使用到 vpdvpt 这两个指针,请参考 user/include/lib.h 中的相关定义,思考并回答这几个问题:

  • vptvpd 的作用是什么?怎样使用它们?
  • 从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?
  • 它们是如何体现自映射设计的?
  • 进程能够通过这种方式来修改自己的页表项吗?
  • vptvpd 分别是页表和页目录的首地址的指针。直接通过指针的方式使用,通过添加相应的值达到指定的页表项或页目录项。

  • 通过页表自映射机制

  • #define vpt ((const volatile Pte *)UVPT)
    #define vpd ((const volatile Pde *)(UVPT + (PDX(UVPT) << PGSHIFT)))
  • 陷入内核态后才可修改

# Thinking 4.7

do_tlb_mod 函数中,你可能注意到了一个向异常处理栈复制 Trapframe 运行现场的过程,请思考并回答这几个问题:

  • 这里实现了一个支持类似于 “异常重入” 的机制,而在什么时候会出现这种 “异常重入”?
  • 内核为什么需要将异常的现场 Trapframe 复制到用户空间?

异常重入机制的主要作用是允许操作系统在处理一个异常的同时,能够响应并处理其他异常。这种机制可以避免在处理一个异常时,系统被另一个异常中断,导致系统崩溃或数据丢失。

  • do_tlb_mod 中还需要写入页面,如果写入页面时出现异常,也就是页面不可写或者为写时复制页面的情况下会再次触发异常。

  • 内核将异常现场保存到用户空间是为了用户处理异常时抛出另外一个异常,此时另外一个异常处理完成后可以通过自己用户空间的异常处理栈来恢复现场。

# Thinking 4.8

在用户态处理页写入异常,相比于在内核态处理有什么优势?

  • 符合微内核设计,使得内核的设计更加小巧。
  • 用户与用户间相互独立,不互相影响,如果某个进程对于 tlb 的处理出现问题不会影响其他用户
  • 提高性能,减少额外的上下文切换

# Thinking 4.9

  • 为什么需要将 syscall_set_tlb_mod_entry 的调用放置在 syscall_exofork 之前?
  • 如果放置在写时复制保护机制完成之后会有怎样的效果?

需要在子进程创建前创立好子进程需要的环境,比如这里是为子进程准备好遇到 tlb_mod 异常的入口。

写时复制保护机制需要需要调用 syscall_mem_map ,如果此时出现缺页异常不能够及时响应。

# 参考资料

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

CircleCoder 微信支付

微信支付

CircleCoder 支付宝

支付宝

CircleCoder 贝宝

贝宝