北航操作系统课程lab3实验报告
实验思考题
Thinking 3.1
思考envid2env 函数:
为什么envid2env 中需要判断e->env_id != envid 的情况?如果没有这步判断会发生什么情况?
在判断前,实际上已经有了e = &envs[ENVX(envid)];
这表明e->env_id
一定与envid
在低10位上相同,因为e的产生就是通过低10位的序号来访问struct Env
结构体的。但在ASID上却并不一定相同。若没有这步判断,则会查询到一个不存在
Thinking 3.2
结合include/mmu.h 中的地址空间布局,思考env_setup_vm 函数:
• UTOP 和ULIM 的含义分别是什么,UTOP 和ULIM 之间的区域与UTOP以下的区域相比有什么区别?
• 请结合系统自映射机制解释代码中pgdir[PDX(UVPT)]=env_cr3的含义。
• 谈谈自己对进程中物理地址和虚拟地址的理解。
UTOP = 0x7f400000
,是为用户所能操纵的地址空间的最大值;ULIM = 0x80000000
,是操作系统分配给用户地址空间的最大值。两者之间的区域对用户进程而言是一个只读片段,保存着页表、struct Page
、struct Env
,显然这些都是用户程序不允许动的。UTOP以下的区域就可以被用户程序所读写。
由自映射机制可得,pgdir[PDX(UVPT)]
就代表页表起始地址所对应的页目录项。env_cr3
是该进程的页目录物理地址,这样即可通过页表项的虚拟地址准确找到物理地址。
可以理解为多个进程都可以使用UTOP以下的虚拟地址,但若进程间没有共享段的话,相同的虚拟地址对应于不同的物理地址。进程直接操作虚拟地址,操作系统则需要建立起不同进程的虚拟地址对物理地址的映射关系。
Thinking 3.3
找到 user_data 这一参数的来源,思考它的作用。没有这个参数可不可以?为什么?(可以尝试说明实际的应用场景,举一个实际的库中的例子)
//最先调用的是load_icode函数
load_elf(binary, size, &entry_point, (void*)e, load_icode_mapper);
//在调用的load_elf函数中,将(void *) e 转化为 (void *) user_data,这体现在函数的定义上
int load_elf(u_char *binary, int size, u_long *entry_point, void *user_data,
int (*map))
//也体现对load_icode_mapper函数的调用上
map(phdr->p_vaddr, phdr->p_memsz, binary + phdr->p_offset, phdr->p_filesz, user_data)
//可见最后一个参数赫然是原先的进程指针
//在函数load_icode_mapper中也证实了这一点
struct Env *env = (struct Env *)user_data;
如果没有这个进程指针,那么后续步骤将无法完成。感觉绕这么大一个弯子好像就是想要将这个进程指针一直传递下去。
Thinking 3.4
结合load_icode_mapper 的参数以及二进制镜像的大小,考虑该函数可能会面临哪几种复制的情况?你是否都考虑到了?
可以直接考虑最复杂的情况,即va
不页对齐,va+bin_size
也不页对齐,va+sg_size
也不页对齐。在加载.text && .data
段和.bss
段时先判断初始是否页对齐,然后再判断结尾是否页对齐。
Thinking 3.5
思考上面这一段话,并根据自己在lab2 中的理解,回答:
• 你认为这里的 env_tf.pc 存储的是物理地址还是虚拟地址?
• 你觉得entry_point其值对于每个进程是否一样?该如何理解这种统一或不同?
pc当然是存的虚拟地址呀,计组做过无数遍了(-!-)
对每个进程都一样。*entry_point = ehdr->e_entry;
尽管不同进程其实虚拟地址一样,但加载的二进制文件、页表肯定是不一样的。每个进程起始地址统一会降低操作系统的复杂度,但在部分相同虚拟地址中的内容不同也区分了不同的进程。
Thinking 3.6
请查阅相关资料解释,上面提到的epc是什么?为什么要将env_tf.pc设置为epc呢?
EPC寄存器是CP0寄存器组中的一个寄存器,用来存放异常中断发生时进程正在执行的指令地址(一般该地址对应的指令还未被执行)。切换进程时,相当于施加了一个异常,这时硬件会自动帮我们把当前pc保存在EPC寄存器中,所以下次再轮到这个进程执行时,直接从该pc对应地址开始执行,而不是从头执行。
Thinking 3.7
关于 TIMESTACK,请思考以下问题:
• 操作系统在何时将什么内容存到了 TIMESTACK 区域
• TIMESTACK 和 env_asm.S 中所定义的 KERNEL_SP 的含义有何不同
在env_destory
和env_run
函数中利用到了TIMESTACK区域,前者将自身进程栈中存放的内容赋值到该区域,后者将该区域的内容复制到当前进程的状态中,以便切换到下一进程。
//在stackframe.S中,异常处理会将栈指针置于TIMESTACK处
//这样就能在发生异常时将当前进程状态存入TIMESTACK
//env_destory销毁本身进程时也是如此
li sp, 0x82000000
lw sp, KERNEL_SP
Thinking 3.8
试找出上述 5 个异常处理函数的具体实现位置。
handle_int
函数在genex.S文件中,handle_sys
函数在syscall.S文件中。handle_reserved
、handle_tlb
、handle_mod
都在genex.S文件中,没有直接明确的函数名,是靠拼接而成,具体声明位于最后,但定义在最开始。
Thinking 3.9
阅读 kclock_asm.S 和 genex.S 两个文件,并尝试说出 set_timer 和timer_irq 函数中每行汇编代码的作用
LEAF(set_timer)
//对定时器的初始化
• li t0, 0xc8
• sb t0, 0xb5000100
//向0xb5000100地址写入0xc8
• sw sp, KERNEL_SP
//保存当前栈指针
setup_c0_status STATUS_CU0|0x1001 0
//把CP0_STATUS第12位和第0位置1,允许4号中断,并表示开启了中断,禁止再次响应中断
• jr ra
//函数返回
• nop
END(set_timer)
timer_irq:
sb zero, 0xb5000110
//向地址0xb5000110写入0,不理解为什么
1: j sched_yield
//展开对进程的调度
nop
/*li t1, 0xff
lw t0, delay
addu t0, 1
sw t0, delay
beq t0,t1,1f
nop*/
j ret_from_exception
//跳转到ret_from_exception函数,执行rfe指令
nop
Thinking 3.10
阅读相关代码,思考操作系统是怎么根据时钟周期切换进程的。
进程装在两个队列中,一次运行一个进程。定时器周期性产生中断,使得当前进程被迫停止,通过执行sched_yield
函数,来进行进程的调度,若该进程时间片还未用完,则可用时间片数量-1,否则会切换到下一个进程,保存上下文。并将原来的进程送到另一个队列的末尾,若进程不处于RUNNABLE状态,则会进行其他处理。
实验难点展示
env_setup_vm函数
想要填好这个函数,就必须得明白用户虚拟地址空间的一些宏定义的段所代表的含义。
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
UVPT相当于是新进程的页表了,在对页目录更新时,我们需要将这部分的物理地址给存到自映射下的页目录项里,方便我们对页表进行访问。而PAGES和ENVS分别对应着1024个页面和1024个进程,映射到固定的物理地址,在进程切换过程中是不能由进程本身去更改的,相对于各个进程各自的地址空间,这部分可以理解为全局变量,所以在对页目录的更新时,不能像对UTOP以下的地址空间那样直接赋值0,而是和boot_pgdir一样映射到固定的那部分物理地址。
这个函数初始化了进程的地址空间的内核部分,用户部分由于还没用,所以页目录项都为0。
load_icode_mapper函数
这个函数写的我很有成就感,因为可以很轻易的搞懂这部分想要干什么,其他函数或许得仰仗往届学长了。前一段是要用bcopy
,后一段是要用bzero
。函数的目的就是把&entry_point
地址的数据给加载到一个段的起始地址上。途中会有三处地址不对齐的地方,分别是起始地址、bin_size结束地址、sg_size结束地址。具体实现部分不再赘述,但有一点需要意识到,当初始地址不对齐时,需要检测这个初始地址对应页面是否已经被分配且使用了,如果是,则不需要额外分配,如不是,则需要重新分配。
env_run函数
注释让我参照env_destory函数,但还是有很大不同的。这里如果是用结构体进行赋值的话是没有问题的,而且不用bcopy使得函数看起来还很简洁,但当我使用bcopy的时候,却没有过得了测试点,原因我现在也没搞清楚,但在我看来两者应该是等价的才对。在这个函数中,需要设置要运行的这个进程状态为RUNNABLE,env_run这个参数表明的是该进程运行次数,e->env_run++
可以放在这个函数里,也可以放在sched_yield函数里,因为正常情况下后者都会调用一次前者,当然,最主要的是env_run这个参数目前其实也用不到,无所谓了。
sched_yield函数
这是中断异常模块唯一一个让我们写的函数。这个函数有很多种写法,最主要的是要考虑取出来的进程如果不是RUNNABLE状态时,应该怎么办,我的想法是如果函数时FREE状态,则直接将其从所在的列表删掉,如果是NOTRUNNABLE状态,则把它当成时间片用完的进程处理,将其放到另一个列表队尾,等待其获得所需资源改变状态。反正就是要找到一个能够运行的进程出来,至少应该有一个处于RUNNABLE状态的进程才能使得这个函数正常运行,此外,该函数的实现表明也有可能有如下情况出现:即当前进程的时间片用完后,该调度函数仍然选择当前进程作为下一个执行的进程。这也与JAVA里的yield()
函数有异曲同工之妙啊。
流程图
这是PPT里的一张函数调用关系图,感觉我如果要画可能应该也不会比这个详细了,主要是懒^_^
创建一个进程需要如下步骤:
1、从空闲PCB链表中申请一个PCB块(env_alloc)
2、初始化新进程的地址空间(env_setup_vm),这需要申请页面作为新进程的页目录(page_alloc)
3、加载二进制镜像并设置堆栈、pc(load_icode),这需要申请页面来储存二进制文件(page_alloc)
运行一个进程之前要保存原进程的上下文(将 TIMESTACK区域的值存入自己的PCB中),设置要运行进程的上下文。上下文包括了页目录位置、寄存器值、pc值。
中断异常的产生与处理:
1、kclock_init调用set_timer初始化时钟产生周期性中断
2、这个中断异常被except_vec3所检测到,CAUSE寄存器应该是硬件设置的,该函数检测异常类型,并跳到相应的异常处理地址。
3、handle_int用于处理中断异常,保存进程上下文(将寄存器的值存入TIMESTACK区域中)
4、若产生的是4号中断,则进入time_irq中,调用sched_yield,表明当前进程已经用完了一个时间片。
体会与感想
这部分的难点主要在调试,跳板机上多进程的调试让人很头大,也不知道什么时候会切换进程,所以debug是要虚空de了。
这部分的Exercise比lab 2简单,中断异常的地方甚至只需要复制代码就行。但在填写过程中还是遇到了很多问题,对PCB的初始化设置究竟需要考虑到哪些参数是没有明说的,以及进程调度函数中的代码有些是不是真的需要也不太知道,就是说删掉后也可以,可能没有相应的测试点,亦或许根本不重要。
这部分最难的是汇编函数,但没有让我们写,所以整体上还比较快乐的。其实我们没必要知道太多关于寄存器的细节,甚至在中断异常时,寄存器的各个位的意义,如何变化这对我而言都不太能理解,也不太想理解,而且感觉也不怎么用得到。