一、思考题

Thinking 3.1

请结合 MOS 中的页目录自映射应用解释代码中e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V的含义。

答:

PDX(UVPT)是得出UVPT所处的页目录号,即UVPT处于第PDX(UVPT)个页目录项所映射的4MB空间,因此页目录也被第PDX(UVPT)映射,然后将该页目录号所对应的的页目录项映射为页目录的物理基地址,并且加上权限位。

页目录基地址:

UVPT+UVPT>>10

映射到页目录的页目录项的基地址:

UVPT+UVPT>>10+UVPT>>20

该页表项处于第几个页目录项:

(UVPT>>20) >> 2 = UVPT>>22 = PDX(UVPT)

Thinking 3.2

elf_load_seg以函数指针的形式,接受外部自定义的回调函数map_page。 请你找到与之相关的 data 这一参数在此处的来源,并思考它的作用。没有这个参数可不可以?为什么?

答:

data是传入的进程控制块指针,在load_icode_mapper和load_icode函数中被调用,在load_icode函数中data被赋予进程控制块的指针e

static int load_icode_mapper(void *data, u_long va, size_t offset, u_int perm,const void *src, size_t len){
……
}
static void load_icode(struct Env *e, const void *binary, size_t size) {
……
panic_on(elf_load_seg(ph, binary + ph->p_offset, load_icode_mapper, e));
……
}

不能没有这个参数,因为需要根据这个进程去实现它的内存管理,为这个进程分配相应的物理页。

Thinking 3.3

结合 elf_load_seg 的参数和实现,考虑该函数需要处理哪些页面加载的情况。

答:

  1. 首先,函数判断va是否页对齐,如果不对齐,需要将多余的地址记为offset,并且offset所在的剩下的BY2PG的binary数据写入对应页的对应地址;(第一个 map_page)

  2. 然后,依次将段内的页映射到物理空间;(第二个 map_page)

  3. 最后,若发现其在内存的大小大于在文件中的大小,则需要把多余的空间用0填充满。(第三个 map_page)

Thinking 3.4

思考上面这一段话,并根据自己在 Lab2 中的理解,回答:你认为这里的 env_tf.cp0_epc 存储的是物理地址还是虚拟地址?

我认为存储的虚拟地址,因为epc存储的是发生错误时CPU所处的指令地址,那么对于CPU来说,所见的都是虚拟地址,因此env_tf.cp0_epc存储的是虚拟地址。

Thinking 3.5

试找出 0、1、2、3 号异常处理函数的具体实现位置。8 号异常(系统调用) 涉及的 do_syscall() 函数将在 Lab4 中实现。

答:

handle_intgenex.S文件中

handle_modhandle_tlb,handle_sys都是通过genex.S文件中的宏函数BUILD_HANDLER实现的

Thinking 3.6

阅读 entry.S、genex.S 和 env_asm.S 这几个文件,并尝试说出时钟中断 在哪些时候开启,在哪些时候关闭。

enable_irq 
li t0, (STATUS_CU0 | STATUS_IM4 | STATUS_IEc) //将t0赋为打开时钟中断的值
mtc0 t0, CP0_STATUS //将t0的值赋给SR寄存器,使之可以相应时钟中断
jr ra //返回
timer_irq
sw zero, (KSEG1 | DEV_RTC_ADDRESS | DEV_RTC_INTERRUPT_ACK) //写 KSEG1 | DEV_RTC_ADDRESS | DEV_RTC_INTERRUPT_ACK 地址响应时钟中断 li a0, 0
j schedule //走到调度函数,进行进程调度

Thinking 3.7

阅读相关代码,思考操作系统是怎么根据时钟中断切换进程的。

当内核创建新进程时,将其插入调度链表的头部;在其不再就绪(被阻塞)或退出时,将其从调度链表中移除。当时间片使用完了,我们将当前的进程控制块插入到调度链表的尾部。接下来从调度链表的头部取出一个就绪的进程进行切换。

二、难点分析

接下来,我们还是从lab3的12个exercise入手,依次分析

Exercise 3.1 : env_init()

像之前一样,对于进程,我们也是从进程初始化开始。

说他是初始化函数,但其实他并不是对每个进程内部进行初始化,他的作用有两个:

  1. 初始化env_free_list和env_sched_list这两个链表
  2. 段地址映射

第一个作用很简单,就是将envs数组中的所有进程倒序存进env_free_list,和页表初始化几乎一致。

但是第二个作用刚开始看可能会比较令人费解,其实呢,我们可以理解为我们创建了一个模板页目录,将map_segment 函数在该页表中将内核数组 pages 和 envs 映射到了用户空间的 UPAGES 和 UENVS 处。这样的好处就是用户程序也能够通过 UPAGESUENVS 的用户地址空间获取 PageEnv 的信息,而不用去访问内核。

Exercise 3.2 : map_segment()

刚才我们说到env_init()中的最后调用了map_segment()来完成段地址映射,那我们来看看这个函数的作用:将在一级页表基地址 pgdir 对应的两级页表结构中做段地址映射,将虚拟地址段 [va,va+size) 映射到物理地址段 [pa,pa+size)

在lab2中,我们实现了page_insert(),它的作用就是把一个虚拟地址va映射到一个物理页面上,那现在我们需要将虚拟地址段 [va,va+size) 映射到物理地址段 [pa,pa+size),也就只需要一页一页的调用page_insert()来映射就好了。

Exercise 3.3 : env_setup_vm()

在完成最初的初始化后,根据lab2中的套路,接下来我们要开始env_alloc()了,那Exercise 3.3的env_setup_vm()正是在env_alloc()中调用的一个函数,他的作用是是初始化新进程的地址空间。因为每个进程都有独立的地址空间,所以要为新进程初始化页目录。

o     4G ----------->  +----------------------------+------------0x100000000
o | ... | kseg2
o KSEG2 -----> +----------------------------+------------0xc000 0000
o | Devices | kseg1
o KSEG1 -----> +----------------------------+------------0xa000 0000
o | Invalid Memory | /|\
o +----------------------------+----|-------Physical Memory Max
o | ... | kseg0
o KSTACKTOP-----> +----------------------------+----|-------0x8040 0000-------end
o | Kernel Stack | | KSTKSIZE /|\
o +----------------------------+----|------ |
o | Kernel Text | | PDMAP
o KERNBASE -----> +----------------------------+----|-------0x8001 0000 |
o | Exception Entry | \|/ \|/
o ULIM -----> +----------------------------+------------0x8000 0000-------
o | User VPT | PDMAP /|\
o UVPT -----> +----------------------------+------------0x7fc0 0000 |
o | pages | PDMAP |
o UPAGES -----> +----------------------------+------------0x7f80 0000 |
o | envs | PDMAP |
o UTOP,UENVS -----> +----------------------------+------------0x7f40 0000 |
o UXSTACKTOP -/ | user exception stack | BY2PG |
o +----------------------------+------------0x7f3f f000 |
o | | BY2PG |
o USTACKTOP ----> +----------------------------+------------0x7f3f e000 |
o | normal user stack | BY2PG |
o +----------------------------+------------0x7f3f d000 |
a | | |
a ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
a . . |
a . . kuseg
a . . |
a |~~~~~~~~~~~~~~~~~~~~~~~~~~~~| |
a | | |
o UTEXT -----> +----------------------------+------------0x0040 0000 |
o | reserved for COW | BY2PG |
o UCOW -----> +----------------------------+------------0x003f f000 |
o | reversed for temporary | BY2PG |
o UTEMP -----> +----------------------------+------------0x003f e000 |
o | invalid memory | \|/
a 0 ------------> +----------------------------+ ----------------------------
o

而这里我们要暴露是上图 UTOP 往上到 UVPT 之间所有进程共享的只读空间,因为我们刚才map_segment()已经完成base_pgdir到UTOP~UVPT的映射,所以我们只需要把这部分内存对应的内核页表 base_pgdir 拷贝到我们现在的进程页表中即可,如下:

memcpy(e->env_pgdir + PDX(UTOP), base_pgdir + PDX(UTOP), 
sizeof(Pde) * (PDX(UVPT) - PDX(UTOP)));

这样,我们每个用户程序就都可以不用切换到内核态就可以访问到envs和pages数组的内容了。

然后从 UVPT 往上到 ULIM 之间才是进程自己的页表。

在env_setup_vm()最后,我们完成了页表自映射,并设置权限只读,见Thinking 3.1

Exercise 3.4 : env_alloc()

好的,刚才说env_setup_vm()是env_alloc()的一小步,那我们现在来完成看看这个进程分配函数,他分四步完成:

  1. 从env_free_list取出了一个空闲进程控制块(PCB)

  2. 调用env_setup_vm()初始化进程控制块的用户地址空间。也就是为进程控制块创建对应的二级页表。

  3. 接下俩继续初始化PCB的一些其他信息,本lab要填写的是

    e->env_id = mkenvid(e);
    try(asid_alloc(&e->env_asid));
    e->env_parent_id = parent_id;

​ 除此之外,e->env_tf.cp0_status要进行对应的中断使能位赋值,e->env_tf.regs[29]设置栈指针。

  1. 然后我们已经完成了本函数对这个PCB的初始化操作,接下来就把他从env_free_list中移除即可

Exercise 3.5 : load_icode_mapper()

接下来,我们要做的事情是把将程序加载到新进程的地址空间中。

这里有很多层函数调用关系

首先我们进入load_icode()函数,这个函数遍历ELF文件每个段的段头表,并调用elf_load_seg()将每个段加载新进程的地址空间中,而我们这里的load_icode_mapper()就是elf_load_seg()的一个回调函数,由它完成段中单个页面的加载过程

所以,这里回调函数load_icode_mapper()的作用是分配所需的物理页面,并在页表中建立映射。若 src 非空,你还需要将该处的 ELF 数据拷贝到物理页面中。

实现过程也就是:

  1. 分配一个物理页面
  2. 若 src 非空,将该处的 ELF 数据拷贝到申请的物理页面中
  3. 并在该进程的页表中建立新申请的物理页面的映射

Exercise 3.6 : load_icode()

刚才说了,load_icode()是完成程序加载的主函数(姑且这么叫),它调用了不少函数完成了将整个程序加载到新进程的地址空间中,那么还差最后一步,就是:

e->env_tf.cp0_epc = ehdr->e_entry;

将进程恢复运行时的回到位置e->env_tf.cp0_epc设置为刚才加载完的程序的入口ehdr->e_entry

Exercise 3.7 : env_create()

前面准备了这么多,我们终于可以创建一个进程了,我们会发现这个函数干的事就是把之前说的那些函数合起来,分四步:

  1. 调用env_alloc()申请一个进程
  2. 设置申请到的进程的e->env_pri(其实就是时间片),并将e->env_status设置为ENV_RUNNABLE
  3. 调用load_icode加载程序到进程的地址空间
  4. 将申请到的进程加入到env_sched_list的头部

Exercise 3.8 : env_run()

上一步我们创建的线程是一个静态的线程,接下来我们就要让它跑起来

他也是分两步:

  1. 将正在执行的进程(如果有)的现场保存到对应的进程控制块中。
  2. 将curenv换成env_run()的参数e(也可能还是原来的进程),然后将当前的页表基地址换成现在进程的页表基地址
  3. 恢复现场,异常返回

这里的异常返回使用了:

env_pop_tf(&curenv->env_tf, curenv->env_asid);

其中是对中断返回的处理,那中断的内容我们在接下来说说

Exercise 3.9 : exc_gen_entry

在中断发生后,CPU会先处理中断并跳转到指定位置,然后交给操作系统处理。

在本实验中,CPU 发生异常(除了用户态地址的 TLB Miss 异常)后,就会自动跳转到地址 0x80000180 处;发生用户态地址的 TLB Miss 异常时,会自动跳转到地址 0x80000000 处。

这里我们先写一个位于地址 0x80000180 处的异常分发代码exc_gen_entry,他的作用是:

  1. 使用 SAVE_ALL 宏将保存上下文,关闭中断且允许嵌套异常。
  2. Cause 寄存器的内容拷贝到 t0 寄存器中并取得 Cause 寄存器中的 2~6 位,也就是对应的异常码
  3. 得到的异常码作为索引在 exception_handlers 数组中找到对应的中断处理函数
  4. 跳转到对应的中断处理函数中,响应异常

Exercise 3.10 : 异常处理程序地址

刚才说了,我们的异常处理程序位于0x80000000和0x80000180,于是我们需要把这两个异常处理程序放到这个位置即可

Exercise 3.11: RESET_KCLOCK

因为我们每次定时器的count和compare相等之后都会引发一次时钟中断,进行是否切换进程的判断,所以每次时钟中断后,我们需要将定时器复位,即初始化。

那我们这里初始化定时器用到的RESET_KCLOCK宏,就干了两件事:

  1. 将Count寄存器清零
  2. 将Compare寄存器设置为我们期望的时间间隔(为一个定值)

Exercise 3.12 : schedule()

好了,干了这么多,我们终于到了本实验的最后一步了:调度进程,让进程有序跑起来

那么我们的进程调度程序就是首先判断是否切换进程,之后再运行进程

那我们稍微详细说一下切换进程这里,首先切换进程有四个条件:

  1. 尚未调度过任何进程(curenv == NULL);
  2. 当前进程已经用完了时间片 ( count <= 0);
  3. 当前进程不再就绪(e->env_status != ENV_RUNNABLE);
  4. yield 参数指定必须发生切换 ( yield == 1)。

如果需要进程切换,首先将当前存在进程,就把他从env_sched_list中删去,但如果他仍然是就绪态,就把他再加回调度队列的末尾。

然后再从调度队列中取出头部的进程,从而完成进程切换。

三、实验体会

lab3主要是对进程的创建和运行,对中断的处理。

有了lab2的经验之后,理解lab3也相对没有那么困难。主要就是捋清楚各个函数的调用关系和各自的作用以及实现逻辑,就可以比较好的弄明白从零开始的进程调度运行。