疑难点

1. 用户程序从磁盘文件到开始执行的整个过程

测试指令:(请先在examples目录下编译提供的用户样例代码)

pintos --gdb --filesys-size=2 -p ../../examples/echo -a echo -- -f -q run 'echo iloveos'

这条命令会将编译后的 echo 可执行文件(ELF)拷贝进磁盘,并在Pintos中执行。(参数的详细解释参见lab2文档的说明)

在lab0中,你实现了一个简易的kernel monitor,它会在你执行Pintos但不指定用户程序(即 Pintos指令中-- 后面的内容为空)时被执行,而在提供用户程序时(如上例中的 run 'echo iloveos' ),Pintos会执行 run_actions(argv) 。(见threads/init.c : pintos_init)

int pintos_init(void) {
	... ...
	... ...

	printf ("Boot complete.\\n");
  
  if (*argv != NULL) {
    /* Run actions specified on kernel command line. */
    run_actions (argv);
  } else {
    /* Run you kernel monitor in lab0 */
		toy_monitor(); 
  }

	... ...
	... ...
}

请你试着用gdb单步调试进入 run_actions,理解用户程序是如何被创建为进程并执行的。

大致流程概述:

内核主线程main:run_actionsrun_taskprocess_executethread_create

process_execute 调用 thread_create 时,会将 start_process 作为执行函数, 用户可执行文件的 filename 作为它的参数,由此创建出一个内核线程A供scheduler调度(注意,在Pintos中,一个用户进程仅对应一个内核线程,不同于Unix的pthread可以在同一个用户进程中创建多个内核线程)。

注意:此后 process_executestart_process 的执行是异步的,因为 start_process 只有在线程A被调度时才会被执行,而 process_execute 会继续在线程main中执行。因此下面的分析分别考虑线程main和线程A。

线程A:→ start_process (函数注释描述得相当详细,请仔细阅读)

初始化intr_frame并通过 load 载入ELF文件。

load 中,kernel会为A创建一个新的页表,并在其中添加用户态虚拟内存空间的映射,读入ELF文件,初始化用户栈以及确定用户程序的入口地址。

注意1(这部分很重要):每个内核线程的页表是什么,用户进程对应的内核线程的页表有何不同,这是大家需要必须搞清楚的一个问题。

对于第一个问题,简单来说,在Pintos的实现里,所有非用户进程的内核线程(比如main线程,idle线程)的thread结构体中的pagedir都为NULL。那大家可能要疑惑了,那这些线程怎么做地址翻译呢?请自己从 src/userprog/pagedir.c 中寻找答案。

对于第二个问题, load 函数中会为用户进程创建一个页表并初始化pagedir(之所以可以这么做是因为所有的用户进程都得从文件中load进来,因此load是所有用户进程启动前的必经之路)。这个页表是如何创建的?它里面包含了哪些虚拟地址空间的映射?请自己从 src/userprog/pagedir.c 中寻找答案。

注意2(请先阅读网站lab2文档底部的Program Startup Details):Pintos的C库将 _start() 函数(lib/user/entry.c)作为所有用户程序的入口地址,这个函数在用户定义的 main() 函数外包裹了一个 exit() 系统调用从而结束用户进程。

void
_start (int argc, char *argv[])
{
  exit (main (argc, argv));
}

因此,当start_process最终跳转到用户程序入口地址处(也即 _start )开始执行前,需要根据80x86的Calling Convention设置好栈中的参数内容(argc和argv),这也是你在Argument Passing部分需要实现的。这也意味着在实现Argument Passing之前,你几乎无法pass任何测试(因为所有的test case都有main函数)。

这里还有一个细节,就是start_process的最后跳转到用户程序的实现方式。要知道此前所有的操作都是在内核态,因此我们为了回到用户态,将跳转到用户程序的过程模拟成一次中断的返回。我们将用户程序的入口地址以及新分配的用户栈分别赋给intr_frame的eip和esp,这样就能利用中断恢复的机制跳转到用户程序。(关于中断的具体细节,参见section 3的内容)。

void* start_process (...)
{
.................
.................
.................
................. 
/* Start the user process by simulating a return from an
     interrupt, implemented by intr_exit (in
     threads/intr-stubs.S).  Because intr_exit takes all of its
     arguments on the stack in the form of a `struct intr_frame',
     we just point the stack pointer (%esp) to our stack frame
     and jump to it. */
  asm volatile ("movl %0, %%esp; jmp intr_exit" : : "g" (&if_) : "memory");
  NOT_REACHED ();
}

主线程main:→ process_wait

main线程在执行了 process_execute 启动了用户进程之后,会等待它执行结束。然后shutdown整个Pintos。

注意:目前 process_wait 的实现会直接返回(你需要在lab中完善它的实现),因此有可能在main线程执行 process_execute 之后,新创建的用户进程对应的内核线程A还没有机会被调度(也即其 start_process 还未被执行),Pintos就已经shutdown了。在完整实现这个函数之前,你可以先用一个while loop来work around,确保线程A会被调度,从而用户程序可以被执行。

2. 用户程序调用System Call

<aside> 💡 请先阅读Lab2文档Background部分的80x86 Calling Convention,以及System Call Details。

</aside>

在上一部分我们已经初步了解了用户程序是如何从磁盘上没有生命的二进制文件,被Pintos一步步load进内存并开始执行的。但用户程序是个很难伺候的主子,有各种各样的需求。比如想再exec一个新的程序,比如想wait一个程序结束,比如想要自己exit等等。而这些服务都需要操作系统来提供。

头文件 lib/syscall-nr.h 中定义了所有的系统调用号。

头文件 lib/user/syscall.h 中声明了所有的用户态系统调用的接口,用户程序就是通过调用这些接口来执行系统调用的。

源文件 lib/user/syscall.c 中有用户态系统调用接口的具体实现。它们本质上都是按照80x86的Calling Convention设置好参数,然后执行 int 0x30 ,即以中断号 0x30 执行中断。

3. Pintos的中断机制

<aside> 💡 请先阅读Lab2文档Background部分的80x86 Calling Convention,以及Code Guide第四部分Interrupt Handling的内容。

</aside>