「MIT 6.828」MIT 6.828 Fall 2018 lab5 (2)

Lab5之spawn, shared library, keyboard and shell

Posted by 许大仙 on March 6, 2021

Lab 5 (2)

接下来完成lab5后半部分。

Spawning Processes

我们已经给出了 spawn 的代码(参见lib/spawn.c)):

  • 它首先创建一个新环境
  • 然后从文件系统中加载一个程序映像到这个environment中
  • 接着启动运行这个程序的子环境。

之后父进程继续独立于子进程运行。

The spawn function effectively acts like a fork in UNIX followed by an immediate exec in the child process. \(spawn() = fork() + execve()\) spawn的代码解析:

// Spawn a child process from a program image loaded from the file system.
// 执行一个从文件系统中读取的程序。
// prog: the pathname of the program to run.
// argv: pointer to null-terminated array of pointers to strings,
// 	 which will be passed to the child as its command-line arguments.
// Returns child envid on success, < 0 on failure.

// 参数说明:prog为待执行程序的路径名,argv为待执行程序所需要的参数
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;

	// This code follows this procedure:
	//
	//   - Open the program file.
	//
	//   - Read the ELF header, as you have before, and sanity check its
	//     magic number.  (Check out your load_icode!)
	//
	//   - Use sys_exofork() to create a new environment.【新的Env结构】
	//
	//   - Set child_tf to an initial struct Trapframe for the child.
	//		调用系统调用sys_env_set_trapframe()
    // 		设置新的Env结构的Trapframe字段(该字段包含寄存器信息)
	//   - Call the init_stack() function above to set up
	//     the initial stack page for the child environment.
	//
	//   - Map all of the program's segments that are of p_type
	//     ELF_PROG_LOAD into the new environment's address space.
	//     Use the p_flags field in the Proghdr for each segment
	//     to determine how to map the segment:
	//		根据ELF文件中program header,将用户程序以Segment读入内存,并映射到指定的线性地址处
	//	* If the ELF flags do not include ELF_PROG_FLAG_WRITE,
	//	  then the segment contains text and read-only data.
	//	  Use read_map() to read the contents of this segment,
	//	  and map the pages it returns directly into the child
	//        so that multiple instances of the same program
	//	  will share the same copy of the program text.
	//        Be sure to map the program text read-only in the child.
	//        Read_map is like read but returns a pointer to the data in
	//        *blk rather than copying the data into another buffer.
	//
	//	* If the ELF segment flags DO include ELF_PROG_FLAG_WRITE,
	//	  then the segment contains read/write data and bss.
	//	  As with load_icode() in Lab 3, such an ELF segment
	//	  occupies p_memsz bytes in memory, but only the FIRST
	//	  p_filesz bytes of the segment are actually loaded
	//	  from the executable file - you must clear the rest to zero.
	//        For each page to be mapped for a read/write segment,
	//        allocate a page in the parent temporarily at UTEMP,
	//        read() the appropriate portion of the file into that page
	//	  and/or use memset() to zero non-loaded portions.
	//	  (You can avoid calling memset(), if you like, if
	//	  page_alloc() returns zeroed pages already.)
	//        Then insert the page mapping into the child.
	//        Look at init_stack() for inspiration.
	//        Be sure you understand why you can't use read_map() here.
	//
	//     Note: None of the segment addresses or lengths above
	//     are guaranteed to be page-aligned, so you must deal with
	//     these non-page-aligned values appropriately.
	//     The ELF linker does, however, guarantee that no two segments
	//     will overlap on the same page; and it guarantees that
	//     PGOFF(ph->p_offset) == PGOFF(ph->p_va).
	//
	//   - Call sys_env_set_trapframe(child, &child_tf) to set up the
	//     correct initial eip and esp values in the child.
	//		
	//   - Start the child process running with sys_env_set_status().
    //	调用系统调用sys_env_set_status()设置新的Env结构状态为ENV_RUNABLE。

	if ((r = open(prog, O_RDONLY)) < 0)
		return r;
	fd = r;	

	// Read elf header
    // 1.基于之前实现file system和IPC通信,读取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;
	}

	// Create new child environment
    // 2.调用fork,创建子进程
	if ((r = sys_exofork()) < 0)
		return r;
	child = r;

	// Set up trap frame, including initial stack.
	child_tf = envs[ENVX(child)].env_tf;
	child_tf.tf_eip = elf->e_entry;
	
    //这个函数计算了参数的字符串长度,以及指向这些字符串所需要的空间,还有argc和null-terminate的空间
    //存放在UTEMP+PGSIZE-string_len-argc*pointer_size-2的位置【这将会最终被复制到进程空间的栈帧的底部】
    //在UTEMP附近位置构建用户栈底的样式【argc、argv指针以及agrv指向的字符串】
	//之后给传入的第三个参数&child_tf.tf_esp进行赋值,设置栈顶esp为USTACK附近留足以上空间的地方。
    //最后就是把当前进程的UTEMP页映射到child进程的USTACKTOP - PGSIZE位置,从而构成child进程的栈帧
    //最最后,解除当前进程的UTEMP页映射【其实这个页就是一个中转的作用】
	if ((r = init_stack(child, argv, &child_tf.tf_esp)) < 0)
		return r;

	// Set up program segments as defined in ELF header.
    // 3.接下来就是解析ELF文件,加载type为LOAD的段
	ph = (struct Proghdr*) (elf_buf + elf->e_phoff);
	for (i = 0; i < elf->e_phnum; i++, ph++) {
		if (ph->p_type != ELF_PROG_LOAD)
			continue;
		perm = PTE_P | PTE_U;
		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);	//关闭文件描述符【会把文件系统进程内存中的dirty块flush到磁盘里】
	fd = -1;	//并且设置fd = -1【避免Use after free类似的问题】

	// Copy shared library state.
    // 3.exercise 8需要实现
	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)	//exercise 7需要实现
		panic("sys_env_set_trapframe: %e", r);

	if ((r = sys_env_set_status(child, ENV_RUNNABLE)) < 0)	//设置程序状态为可执行
		panic("sys_env_set_status: %e", r);
	
    //返回spawn生成的子进程id,之后这个子进程会被调度的【因为状态是ENV_RUNNABLE
	return child;	

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

We implemented spawn rather than a UNIX-style exec because spawn is easier to implement from user space in "exokernel fashion", without special help from the kernel.

现在考虑一下在用户空间中实现exec需要做什么?并且明确you understand why it is harder?

Exercise 7

spawn relies on the new syscall sys_env_set_trapframe to initialize the state of the newly created environment.

Implement sys_env_set_trapframe in kern/syscall.c (don’t forget to dispatch the new system call in syscall()).

// Set envid's trap frame to 'tf'.
// tf is modified to make sure that user environments always run at code
// protection level 3 (CPL 3), interrupts enabled, and IOPL of 0.
//
// Returns 0 on success, < 0 on error.  Errors are:
//	-E_BAD_ENV if environment envid doesn't currently exist,
//		or the caller doesn't have permission to change 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!
	int r;
	struct Env *e;
	if((r = envid2env(envid,&e,1)) < 0) return r;
    // The trap frame should be modified to run at Ring 3 (the lowest 2 bits of CS and SS register should set to 3)
    tf->tf_cs |= 3;
    tf->tf_ss |= 3;
    //interrupts enabled (set the IF bit in EFLAGS), and IOPL of 0 (clear the 2-bit IOPL field in EFLAGS).
	tf->tf_eflags |= FL_IF;
	tf->tf_eflags &= ~FL_IOPL_MASK;	//no need permission for IO
	e->env_tf = *tf;
	return 0;
	// panic("sys_env_set_trapframe not implemented");
}

然后记得在syscall()中增加对其的dispatcher。

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

Use make grade to test your code.

image-20220511220703708

Challenge!

Implement Unix-style exec.

参考:Challenge - Implement Unix-style exec by Github:YanTang Qin

Sharing library state across fork and spawn

在Unix中,文件描述符包含了很多内容如pipes,console I/O等。在我们的JOS里面,这些设备类型通过struct Dev来描述,其中的一些处理读写等功能的函数指针。

lib/fd.c实现了顶层的通用UNIX-like 文件描述符接口。每个都 struct Fd指示了其设备类型,并且lib/fd.c中的大多数函数只是将操作分派给适当类型的struct Dev中的函数。

lib/fd.c在每个应用的进程空间中【从FDTABLE的地址起】维护了文件描述符表(file description table),在这个内存区域为应用的每个文件描述符(目前,最多同时开启32个文件描述符)保留了4KB大小的one page空间。在任何时间,特定的文件描述符页仅在被使用的时候会被映射。每个文件描述符在从FILEDATA开始的区域中也有一个可选的“数据页” 可以被设备使用。

我们想在 `fork`和`spawn`中共享文件描述符状态,但文件描述符状态是保留在用户空间内存中。现在,对于fork而言,内存将被标记为写时复制,因此状态将被复制而不是共享。这意味着环境将无法在它们自己未打开的文件中进行查找,并且pipe将无法跨fork工作。对于spawn而言,内存will be left behind,根本不会被复制。实际上,spawn产生的环境中没有打开的文件描述符,为空。

我们将修改fork以阐明某些内存区域由“library operating system”使用并且应该始终共享。我们应该在页表条目中设置一个其他未使用的位(otherwise-unused bit),而不是一个硬编码地址范围((just like we did with the PTE_COW bit in fork,注释:PTE_COW marks copy-on-write page table entries.)。

我们在inc/lib.h中定义了一个新bit:PTE_SHARE。该位是 Intel 和 AMD 手册中标记为“可供软件使用”的三个 PTE 位之一。我们将建立一个convention(规定),如果页表条目(PTE)设置了该位,则页表条目应直接从父进程复制到子进程(不论是fork还是spawn)。请注意,这与将其标记为写时复制不同,我们希望share updates to the page.

简单来说,通过将JOS中定义PTE新的标志位PTE_SHARE,设置为1,这个PTE在fork()和spawn()时将直接拷贝到子进程页表,从而让父进程和子进程共享相同的页映射关系,从而达到父子进程共享文件描述符的目的

Exercise 8

Change duppage in lib/fork.c to follow the new convention.

If the page table entry has the PTE_SHARE bit set, just copy the mapping directly. (You should use PTE_SYSCALL, not 0xfff, to mask out the relevant bits from the page table entry. 0xfff picks up the accessed and dirty bits as well.)

Likewise, implement copy_shared_pages in lib/spawn.c.

It should loop through all page table entries in the current process (just like fork did), copying any page mappings that have the PTE_SHARE bit set into the child process.

具体的修改代码如下:

addr = (void*)((uint32_t)pn*PGSIZE);
pte = uvpt[pn];
+ if(pte & PTE_SHARE){
+    r = sys_page_map(0, addr, envid, addr, pte & PTE_SYSCALL);
+    if(r != 0){
+        panic("duppage error for child! va: %08x, error:%08x\n", addr, r);
+    }
+ }
+ else if((pte & PTE_COW) || (pte & PTE_W)){

// Copy the mappings for shared pages into the child address space.
static int
copy_shared_pages(envid_t child)
{
	// LAB 5: Your code here.
	uintptr_t addr;
	for(addr = 0; addr < UXSTACKTOP; addr += PGSIZE){
		if((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_P) && 
		(uvpt[PGNUM(addr)] & PTE_U) && (uvpt[PGNUM(addr)] & PTE_SHARE)) {
			int r =sys_page_map(0, (void *)addr, child, (void *)addr, (uvpt[PGNUM(addr)] & PTE_SYSCALL));
			if(r != 0){
				panic("copy_shared_pages for child!va:%08x,error:%08x\n",addr,r);
				return r;
			}
		}
	}
	return 0;
}

Now it will pass the PTE_SHARE [testpteshare] and PTE_SHARE [testfdsharing] tests in make grade.

The keyboard interface

为了让 shell 工作,我们需要一种输入方式。QEMU 一直在显示我们写入 CGA 显示器和串行端口的输出,但到目前为止,我们只在内核监视器( kernel monitor)中获取输入。在 QEMU 中,在图形窗口中输入的输入显示为从键盘到 JOS 的输入,而输入到控制台的输入显示为串行端口上的字符。 kern/console.c已经包含了键盘和串口的驱动程序【这些驱动从lab1的时候就被kernel monitor使用了】,但现在您需要将它们附加到JOS的剩余部分。

Exercise 9

In your kern/trap.c, call kbd_intr to handle trap IRQ_OFFSET+IRQ_KBD and serial_intr to handle trap IRQ_OFFSET+IRQ_SERIAL.

新增对键盘中断的处理,这部分的处理逻辑是将来自键盘与串口的输入存储至环形 buffer 中,通过读写指针来管理。

The console file type is used for stdin/stdout by default unless the user redirects them.

我们在lib/console.c 中为您实现了console input/output file type【控制台文件用于标准输入输出】。

kbd_intrserial_intr 使用最近读取的输入来填充缓冲区,在console file type耗尽缓冲区时。

在trap.c的trap_dispatch中加入下面的case代码:

static void
trap_dispatch(struct Trapframe *tf)
{
	// Handle processor exceptions.
	// LAB 3: Your code here.
	struct PushRegs *regs;
	switch (tf->tf_trapno)
	{
	/* …… */
	case IRQ_OFFSET + IRQ_KBD:
		kbd_intr();
		return;
	case IRQ_OFFSET + IRQ_SERIAL:
		serial_intr();
		return;
	default:
		break;
	}
    /* …… */   
}
//kbd_intr和serial_intr最终都调用了cons_intr来将字符输入到circular console input buffer.
// called by device interrupt routines to feed input characters
// into the circular console input buffer.
static void
cons_intr(int (*proc)(void))
{
	int c;	//对于proc
	//调用函数指针proc获取当前从设备输入的字符
    //kbd_intr和serial_intr的proc函数指针中都使用了inb指令从特定于设备的端口读取字符并返回。
	while ((c = (*proc)()) != -1) {
		if (c == 0)
			continue;
		cons.buf[cons.wpos++] = c;
		if (cons.wpos == CONSBUFSIZE)
			cons.wpos = 0;
	}
}

Test your code by running make run-testkbd and type a few lines. The system should echo your lines back to you as you finish them. Try typing in both the console and the graphical window, if you have both available.

The Shell

运行make run-icode or make run-icode-nox. 这个操作会启动JOS kernel,让启动user/icode程序。icode 程序执行了 init,其能启动console并设置文件描述符0和1作为标准输入和标准输出,接着,spawn sh, the shell,具体而言:

  • icode,调用 init 派生子进程 init【即加载init.c】,icode.c 传参如下:
r = spawnl("/init", "init", "initarg1", "initarg2", (char*)0)
  • init,spawnl+wait 循环派生子进程sh【即加载sh.c】,fork的sh子进程runcmd,而父进程wait,等待子进程结束
//↓ init.c
// 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(r);
}
//↓ sh.c
while (1) {
    char *buf;

    buf = readline(interactive ? "$ " : NULL);
    if (buf == NULL) {
        if (debug)
            cprintf("EXITING\n");
        exit();	// end of file
    }
    if (debug)
        cprintf("LINE: %s\n", buf);
    if (buf[0] == '#')
        continue;
    if (echocmds)
        printf("# %s\n", buf);
    if (debug)
        cprintf("BEFORE FORK\n");
    if ((r = fork()) < 0)
        panic("fork: %e", r);
    if (debug)
        cprintf("FORK: %d\n", r);
    if (r == 0) {
        runcmd(buf);	//调用runcmd,解析console的参数,并执行命令
        exit();
    } else
        wait(r);
}

之后你应该可以运行一下命令:

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

请注意,user library routine cprintf 直接打印到控制台,而不使用文件描述符代码。This is great for debugging but not great for piping into other programs.

要实现重定向,即要将输出print到特定文件描述符(例如,1, standard output),请使用fprintf(1, "...", ...)printf("...", ...) is a short-cut for printing to FD 1.】。有关示例,请参见user/lsfd.c

Exercise 10

The shell doesn’t support I/O redirection.

It would be nice to run sh <script instead of having to type in all the commands in the script by hand, as you did above.

Add I/O redirection for < to user/sh.c.

Test your implementation by typing sh <script into your shell

为了处理输入重定向,我们需要打开t为文件描述符0以读取。由于t用于输入的,因此应使用O_RDONLY标志打开。如果存在文件描述符0,则应将其替换为t,并且应关闭t的原始文件描述符。

//user/sh.c的在runcmd函数中添加如下片段:
case '<':	// Input redirection
    // Grab the filename from the argument list
	// gettoken 负责解析命令行参数, 所以对于 command < filename,t的值会变成filename
    if (gettoken(0, &t) != 'w') {
        cprintf("syntax error: < not followed by word\n");
        exit();
    }
    // Open 't' for reading as file descriptor 0
    // (which environments use as standard input).
    // We can't open a file onto a particular descriptor,
    // so open the file as 'fd',
    // then check whether 'fd' is 0.
    // If not, dup 'fd' onto file descriptor 0,
    // then close the original 'fd'.

    // LAB 5: Your code here.
    if((fd = open(t, O_RDONLY)) < 0){
        cprintf("open %s for read: %e", t, fd);
        exit();
    }
    if(fd != 0){
        if((r = dup(fd, 0)<0)){
            panic("duplicate error!");
        }
        close(fd);
    }
    break;

Run make run-testshell to test your shell. testshell simply feeds the above commands (also found in fs/testshell.sh) into the shell and then checks that the output matches fs/testshell.key.

这里我们可以注意一下shell的参数解析过程,类似于lexical scanning。

arg.c 将输入的可变参数数目 argc 与 字符串指针数组 argv 封装为 Argstate,这部分的内容可以参考 args.h 。

struct Argstate {
    int *argc;
    const char **argv;
    const char *curarg;
    const char *argvalue;
};

解析流程的过程类似于队列,每次都处理队头的字符串,处理完就将其出队,图示如下:

preview

如下这部分代码,args->argv[1] 拿到的是第一个参数字符串,然后+1,代表取第二个字符串参数。

// Shift arguments down one
args->curarg = args->argv[1] + 1;
memmove(args->argv + 1, args->argv + 2, sizeof(const char *) * (*args->argc - 1));
(*args->argc)--;

Challenge!

Add more features to the shell. Possibilities include (a few require changes to the file system too):

  • backgrounding commands (ls &)
  • multiple commands per line (ls; echo hi)
  • command grouping ((ls; echo hi) | cat > out)
  • environment variable expansion (echo $hello)
  • quoting (echo "a | b")
  • command-line history and/or editing
  • tab completion
  • directories, cd, and a PATH for command-lookup.
  • file creation
  • ctl-c to kill the running environment

but feel free to do something not on this list.

至此为止,我们已经完成了lab5实验,可以使用make grade对提交的内容进行评分,使用 make handin提交solution。

image-20220513205041470