Spawning Process

有了文件系统了,我们终于可以方便地读取磁盘中的文件了。到目前为止,我们创建进程的方法一直都是在编译内核的时候将程序链接到数据段,在 i386_init 通过 ENV_CREATE 宏创建。
现在我们应该考虑通过文件系统直接将用户程序从硬盘中读取出来,spawn 就是这样的东西。
spawn和unix中的exec不同,spawn 在用户空间实现,不需要内核的特殊帮助,读取文件、创建进程完全通过 syscall。
spawn 已经实现好了,位于 lib/spawn.c 中。很有必要学习一下其中的代码。

spawn.c

spawn 很像 icode_load,但是他需要通过文件的方式读取数据。
而且栈的创建、子进程状态的设置,内存映射都需要以syscall的方式实现。

// 从文件系统加载的程序映像中生成一个子进程。
// prog:要运行的程序的路径名。
// argv: 字符串指针数组的空端指针,这些字符串将作为命令行参数传递给子进程。
// 成功时返回子程序 envid,失败时返回 <0。
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;

	// 打开 elf 文件
	if ((r = open(prog, O_RDONLY)) < 0)
		return r;
	fd = r;

	// 读取 elf文件头
	elf = (struct Elf*) elf_buf;
	if (readn(fd, elf_buf, sizeof(elf_buf)) != sizeof(elf_buf)
	    || elf->e_magic != ELF_MAGIC) {
		close(fd);
		cprintf("elf magic %08x want %08x\n", elf->e_magic, ELF_MAGIC);
		return -E_NOT_EXEC;
	}

	// 创建子进程
	if ((r = sys_exofork()) < 0)
		return r;
	child = r;

	// Set up trap frame, including initial stack.
	// 将子进程的 eip 设置为 elf 的入口点
	child_tf = envs[ENVX(child)].env_tf;
	child_tf.tf_eip = elf->e_entry;

	// 为子进程设置栈
	if ((r = init_stack(child, argv, &child_tf.tf_esp)) < 0)
		return r;

	// Set up program segments as defined in ELF header.
	// 将 elf 的程序段载入内存
	ph = (struct Proghdr*) (elf_buf + elf->e_phoff);
	for (i = 0; i < elf->e_phnum; i++, ph++) {
		// 使用 Proghdr 中每个程序段的 p_flags 字段来确定如何映射程序段: 
		if (ph->p_type != ELF_PROG_LOAD)
			continue;
		perm = PTE_P | PTE_U;
		// 如果 ELF 标志不包括 ELF_PROG_FLAG_WRITE,则段包含文本和只读数据。
		// 如果 ELF 段标志包含 ELF_PROG_FLAG_WRITE,则该段包含读/写数据和 bss。
		if (ph->p_flags & ELF_PROG_FLAG_WRITE)
			perm |= PTE_W;
		if ((r = map_segment(child, ph->p_va, ph->p_memsz,
				     fd, ph->p_filesz, ph->p_offset, perm)) < 0)
			goto error;
	}
	close(fd);
	fd = -1;

	// Copy shared library state.
	if ((r = copy_shared_pages(child)) < 0)
		panic("copy_shared_pages: %e", r);

	child_tf.tf_eflags |= FL_IOPL_3;   // devious: see user/faultio.c
	if ((r = sys_env_set_trapframe(child, &child_tf)) < 0)
		panic("sys_env_set_trapframe: %e", r);

	if ((r = sys_env_set_status(child, ENV_RUNNABLE)) < 0)
		panic("sys_env_set_status: %e", r);

	return child;

error:
	sys_env_destroy(child);
	close(fd);
	return r;
}

spawn的步骤:

  • 打开程序文件。
  • 像以前一样读取 ELF 头文件,并检查其神奇数字是否正确。 (检查你的 load_icode!)。
  • 使用 sys_exofork() 创建一个新环境。
  • 将 child_tf 设置为子程序的初始 struct Trapframe。
  • 调用上面的 init_stack() 函数,为子环境设置初始堆栈页面。
  • 将所有 p_type ELF_PROG_LOAD 类型的程序段映射到新环境的地址空间。

init_stack 则是先在父进程的 UTMP 上将子进程的用户栈布局好,然后通过 sys_page_map 将物理页映射到子进程中。布局情况如下:

//下面的argv[n]指的是字符串首地址,也是这个栈中对应条目的虚拟地址
		argv_2 -->			|		"initarg2"		| 	<--  USTACKTOP 
		argv_1 -->			|		"initarg1"		|
		argv_0 -->			|		"init"			|
							|		 0(NULL)		|
							|		 &argv_2		|
							|		 &argv_1		|
		&argv  -->			|		 &argv_0		|
							|	  	 &argv		    |
 child->esp(往上是出栈) -->  |		   3		  	|
————————————————

练习7

练习 7. `spawn` 依靠新的系统调用 `sys_env_set_trapframe` 来初始化新创建环境的状态。在 `kern/syscall.c` 中实现 `sys_env_set_trapframe`(别忘了在 `syscall()` 中调度新的系统调用)。

运行 `kern/init.c` 中的 `user/spawnhello` 程序来测试代码,该程序将尝试从文件系统中生成 `/hello`。

使用 `make grade` 测试代码。
// 将 envid 的陷阱框架设置为 “tf”。
// 修改 tf 是为了确保用户环境始终运行在代码
// 保护级别 3(CPL 3),启用中断,IOPL 为 0。
//
// 成功时返回 0,错误时返回 <0。 错误是
// -E_BAD_ENV 如果环境 envid 当前不存在、
// 或调用者没有权限更改 envid。
static int
sys_env_set_trapframe(envid_t envid, struct Trapframe *tf)
{
	// LAB 5: Your code here.
	// Remember to check whether the user has supplied us with a good
	// address!
	// panic("sys_env_set_trapframe not implemented");
	struct Env * e;
	if(envid2env(envid, &e, true) < 0)
		return -E_BAD_ENV;
	tf->tf_eflags = FL_IF;				//允许中断
	tf->tf_eflags &= ~FL_IOPL_MASK;		//IOPL为0
	tf->tf_cs = GD_UT | 3;				//保护级别 3
	e->env_tf = *tf;
	return 0;
}

然后在 kern/syscall.c : syscall 中补充:

case SYS_env_set_trapframe:
			ret = sys_env_set_trapframe((envid_t) a1, (struct Trapframe *)a2);
			return ret;

为了测试效果,在 kern/init.c : i386_init 中补充:

#if defined(TEST)
	// Don't touch -- used by grading script!
	ENV_CREATE(TEST, ENV_TYPE_USER);
#else
	// Touch all you want.
	// ENV_CREATE(user_icode, ENV_TYPE_USER);
	ENV_CREATE(user_spawnhello, ENV_TYPE_USER);
#endif // TEST*

测试效果 make qemu:

image.png

跨 fork 和 spawn 共享库状态

我们希望在 forkspawn 之间共享文件描述符状态,但文件描述符状态保存在用户空间内存中。
现在,fork 时,内存将被标记为写入时复制,因此状态将被复制而非共享(这意味着进程无法在自己未打开的文件中寻址,管道也无法在 fork 时工作)。
spawn时,内存将被留下,根本不会被复制。(实际上,生成(spawned)的进程一开始并没有打开文件描述符)。

我们将修改 fork,使其知道某些内存区域被 "库操作系统 "使用,并应始终共享。
我们将在页表项中设置一个未使用的位,而不是在某个地方硬编码一个区域列表(就像我们在 fork 中设置 PTE_COW 位一样)。

我们在 inc/lib.h 中定义了一个新的 PTE_SHARE 位。
该位是 Intel 和 AMD 手册中标明 "可用于软件 "的三个 PTE 位之一。
我们将建立一个惯例,即如果页表项设置了该位,则 PTE 应在 forkspawn 中直接从父节点复制到子节点。
请注意,这与 "写入时复制 "不同:如第一段所述,我们要确保共享页面更新。

练习 8. 修改 `lib/fork.c` 中的 `duppage`,以遵循新的约定。如果页表项设置了 `PTE_SHARE` 位,则直接复制映射即可。(您应该使用 `PTE_SYSCALL`,而不是 0xfff 来屏蔽掉页表项中的相关位。0xfff 还会拾取访问位和脏位)。

同样,在 `lib/spawn.c` 中实现 `copy_shared_pages`。它应该循环查看当前进程中的所有页表项(就像 fork 所做的),将任何设置了 `PTE_SHARE` 位的页面映射复制到子进程中。

lib/fork.c : duppage

// 将当前进程(父进程)的内存映射(页表)复制给子进程,同时标记COW
static int
duppage(envid_t envid, unsigned pn)
{
	int r;

	// LAB 4: Your code here.
	// panic("duppage not implemented");
	void *addr = (void *)(pn * PGSIZE);
	//如果页表项设置了 `PTE_SHARE` 位,则直接复制映射即可。
	if(uvpt[pn] & PTE_SHARE){
		sys_page_map(0, addr, envid, addr, PTE_SYSCALL);	
	}
	//对父进程所有可写页或COW页,标记COW
	else if ((uvpt[pn]&PTE_W)|| (uvpt[pn] & PTE_COW)){
		if ((r = sys_page_map(0, addr, envid, addr, PTE_COW|PTE_U|PTE_P)) < 0)
			panic("duppage:sys_page_map:%e", r);
		if ((r = sys_page_map(0, addr, 0, addr, PTE_COW|PTE_U|PTE_P)) < 0)
			panic("duppage:sys_page_map:%e", r);
	}
	//对于父进程的只读页不标记COW
	else{
		sys_page_map(0, addr, envid, addr, PTE_U|PTE_P);	
	}
	return 0;
}

lib/spawn.c : copy_shared_pages

// 将共享页面的映射复制到子地址空间。
static int
copy_shared_pages(envid_t child)
{
	// LAB 5: Your code here.
	uintptr_t addr;
	for (addr = 0; addr < UTOP; addr += PGSIZE) {
		if ((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_P) &&
				(uvpt[PGNUM(addr)] & PTE_U) && (uvpt[PGNUM(addr)] & PTE_SHARE)) {
            sys_page_map(0, (void*)addr, child, (void*)addr, (uvpt[PGNUM(addr)] & PTE_SYSCALL));
        }
	}
	return 0;
}

话说,我们是在什么时候将文件描述符标记为 PTE_SHARE 的呢?vscode搜索一下:
答案是在 serve_open 的末尾,文件的主循环在处理open请求时,调用serve_open,然后serve_open 申请一个新的openfile,代表打开的文件。然后将该openfile关联的 FD 所在物理页,以及该物理页权限返回给serve,如下图

image.png

紧接着 serve 调用 ipc_send 将 FD 物理页发送给客户端,并以带有 PTE_SHARE 的权限,将FD安装在客户端调用 ipc_recv 时指定的地址。

因此,所有通过open打开的文件描述符,都是 PTE_SHARE 的。经过 fork 或 spawn 后,父子进程共享。

键盘接口

为了让 shell 正常工作,我们需要一种输入方式。QEMU 一直在显示我们写入 CGA 显示屏和串行端口的输出,但到目前为止,我们只在内核监视器中输入了内容。在 QEMU 中,在图形窗口中输入的内容会以键盘输入的形式显示在 JOS 上,而输入到控制台的内容则会以串行端口上的字符形式显示。kern/console.c 已经包含了内核监视器从实验一开始就使用的键盘和串行驱动程序,但现在你需要将它们连接到系统的其他部分。

练习 9. 在 kern/trap.c 中,调用 kbd_intr 处理陷阱 IRQ_OFFSET+IRQ_KBD,调用 serial_intr 处理陷阱 IRQ_OFFSET+IRQ_SERIAL。

我们在 lib/console.c 中为您实现了控制台输入/输出文件类型。kbd_intr 和 serial_intr 会将最近读取的输入内容填入缓冲区,而控制台文件类型则会耗尽缓冲区(除非用户重定向,否则默认情况下控制台文件类型用于 stdin/stdout)。

运行 make run-testkbd 并键入几行代码,测试你的代码。当你输入完毕时,系统会回声提示。如果控制台和图形窗口都可用,请尝试同时在控制台和图形窗口中键入。

kern/trap.c : trap_dispatch 中添加:

// Handle keyboard and serial interrupts.
	// LAB 5: Your code here.
	if (tf->tf_trapno == IRQ_OFFSET + IRQ_KBD){
		kbd_intr();
  		return;
	}
	if (tf->tf_trapno == IRQ_OFFSET + IRQ_SERIAL){
		serial_intr();
  		return;
	}

然后 make run-testkbd

image.png

看起来只是简单的回显了输入,来看看代码
user/testkbd

#include <inc/lib.h>

void
umain(int argc, char **argv)
{
	int i, r;

	// Spin for a bit to let the console quiet
	for (i = 0; i < 10; ++i)
		sys_yield();

	close(0);
	// 打开一个文件,这个文件的设备类型是终端
	if ((r = opencons()) < 0)
		panic("opencons: %e", r);
	// 由于是第一个打开的,fd应该是0
	if (r != 0)
		panic("first opencons used fd %d", r);
	// 复制一个文件描述符
	if ((r = dup(0, 1)) < 0)
		panic("dup: %e", r);

	for(;;){
		char *buf;

		buf = readline("Type a line: ");
		if (buf != NULL)
			// fprintf 最终会调用write向fd1写入数据
			// 此时会将内容显示在终端上
			fprintf(1, "%s\n", buf);
		else
			fprintf(1, "(end of file received)\n");
	}
}

首先打开了文件描述符0,其设备类型为终端devcons(可以通过 opencons 看到)。
然后复制了文件描述符0得到文件描述符1,并向文件描述符1写入我们输入的字符串。
从而使得终端显示了我们的输入。
image.png

The Shell

运行 make run-icode 或 make run-icode-nox。这将运行内核并启动用户/icode。icode 会执行 init,将控制台设置为文件描述符 0 和 1(标准输入和标准输出)。然后会生成 shell sh。你应该可以运行以下命令:

	echo hello world | cat
	cat lorem |cat
	cat lorem |num
	cat lorem |num |num |num |num |num
	lsfd

请注意,用户库例程 cprintf 直接打印到控制台,而不使用文件描述符代码。这非常适合调试,但不适合在其他程序中使用。printf("...", ...) 是打印到 FD 1 的快捷方式。有关示例,请参见 user/lsfd.c。

练习 10.

shell 不支持 I/O 重定向。如果能运行 sh <script 就好了,而不必像上面那样手写输入脚本中的所有命令。将 < 的 I/O 重定向添加到 user/sh.c。

在 shell 中键入 sh <script 测试你的实现

运行 make run-testshell 测试你的 shell。testshell 只需将上述命令(也可在 fs/testshell.sh 中找到)输入 shell,然后检查输出是否与 fs/testshell.key 一致。

熟悉 linux 的 bash 的话,应该知道IO重定向的概念。 < 用于重定向标准输入。比如说 [命令a] < [文件b] 的含义就是,将命令a的标准输入改为文件b。
标准输入的文件描述符编号是0,所以我们要做的就是将,打开 文件b,然后将 文件描述符0 改为文件b:


// LAB 5: Your code here.
// panic("< redirection not implemented");
// t是gettoken得到的当前短语,即文件b的文件名,打开文件b
if ((fd = open(t, O_RDONLY)) < 0) {
	cprintf("open %s for read: %e", t, fd);
	exit();
}
if (fd != 0) {
	// 将文件描述符0 变为文件b的副本
	dup(fd, 0);
	// 关闭文件b
	close(fd);
}
break;

image.png

关于 /lib/sh.c
sh.c 实现了一个 shell,其核心函数式 runcmd。逻辑其实也很简单,通过循环调用gettoken 解析命令。然后根据重定向的需求修改输入输出,最后通过 spawn 运行相关程序。

image.png

管道部分比较有意思:
image.png

管道左侧直接跳转到 runit 运行命令,右侧则重新解析命令。

管道右侧需要等待管道左侧运行完毕后,再运行:

image.png