「BUAA OS Lab3」难点梳理与理解总结
进程创建
进程控制块(Process Control Block)
PCB是用来进程管理的数据结构,在我们的MOS中就是Env
结构体,具体内容可以参考源码或Lab 3 名词解释。在后文中,我们可能混用PCB和Env
,其实两者基本是一个东西。
对于PCB,我们仿照Lab 2中的思路,为envs
开辟空间,通过boot_map_segment
将其map
到UENVS空间。
之后,我们类比page_init
,将空闲进程块连缀成env_free_list
,值得注意的是这里guidebook强调了顺序问题,需要稍加注意。
进程创建流程
- 从
env_free_list
获取一个空的PCB - 对PCB进行初始化
- 为进程分配资源
- 从空闲链表中移出并开始执行
获取空PCB并初始化
第1和第2步是在env_alloc
中进行的,其代码如下:
1 | int |
env_setup_vm
1 | static int |
在我们的MOS中支持多进程,多进程彼此互不干扰,因此每个进程有自己的页表。
在env_setup_vm
中,我们首先调用page_alloc
申请一个页目录页,通过page2kva
获取该页的虚拟地址并转型为Pde *
并赋给pgdir
以便于访问。在这里我们再啰嗦一下,代码中要访问的地址都是虚拟地址。
由于每个进程有自己单独的页表,这个页表会映射完整的4G空间,其中,由于用户态2G加上内核态2G,因此,内核态2G是公用的,因此我们拷贝内核页表。
然后,我们进行一些PCB内容的初始化,具体为设置pgdir
,设置env_cr3
。
这里,我们再啰嗦一下,每个进程都有自己的视图下的4G空间,不同进程都在同一个虚拟地址有数据是完全可能的。值得注意的是,获取pgdir的具体流程为:申请可用物理页一个,将物理页在kseg0映射的虚拟地址赋给pgdir
。由kseg0和物理内存一一映射关系我们可以知道,我们是真正改变了内核虚拟空间的这部分内容,由于对于所有进程内核虚拟空间都是共享的,因此对于其他进程也可以看到。
为进程分配资源并将PCB从空闲链表中移出
首先明确目的:我们需要加载二进制镜像
为了便于理解和对函数的补全,我们需要先从更高的抽象来知道各个函数如何协作完成了这一过程:
loac_icode
函数首先申请一个空闲页,并将该进程pgdir
对应的两届页表结构中的USTOCK-BY2PG
映射刚才申请的空闲页中,并设置好权限。这里的权限为PTE_R
,即,writable
。然后,调用了load_elf
函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34static void
load_icode(struct Env *e, u_char *binary, u_int size)
{
/* Hint:
* You must figure out which permissions you'll need
* for the different mappings you create.
* Remember that the binary image is an a.out format image,
* which contains both text and data.
*/
struct Page *p = NULL;
u_long entry_point;
u_long r;
u_long perm;
/* Step 1: alloc a page. */
perm = PTE_R;
if ((r = page_alloc(&p)) != 0) {
return;
}
/* Step 2: Use appropriate perm to set initial stack for new Env. */
/* Hint: Should the user-stack be writable? */
if ((r = page_insert(e->env_pgdir, p, USTACKTOP - BY2PG, perm)) != 0) {
return;
}
if ((r = load_elf(binary, size, &entry_point, e, load_icode_mapper)) != 0) {
return;
}
/* Step 3: load the binary using elf loader. */
/* Step 4: Set CPU's PC register as appropriate value. */
e->env_tf.pc = entry_point;
}load_elf
函数解析ELF,并利用函数指针int(*map)
调用load_icode_mapper()
函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42int load_elf(u_char *binary, int size, u_long *entry_point, void *user_data,
int (*map)(u_long va, u_int32_t sgsize,
u_char *bin, u_int32_t bin_size, void *user_data))
{
Elf32_Ehdr *ehdr = (Elf32_Ehdr *)binary;
Elf32_Phdr *phdr = NULL;
/* As a loader, we just care about segment,
* so we just parse program headers.
*/
u_char *ptr_ph_table = NULL;
Elf32_Half ph_entry_count;
Elf32_Half ph_entry_size;
int r;
// check whether `binary` is a ELF file.
if (size < 4 || !is_elf_format(binary)) {
return -1;
}
ptr_ph_table = binary + ehdr->e_phoff; // 程序头表所在处与此文件头的偏移
ph_entry_count = ehdr->e_phnum; // 程序头表入口数
ph_entry_size = ehdr->e_phentsize; // 程序头表入口大小
while (ph_entry_count--) {
phdr = (Elf32_Phdr *)ptr_ph_table;
if (phdr->p_type == PT_LOAD) {
/* Your task here! */
/* Real map all section at correct virtual address.Return < 0 if error. */
/* Hint: Call the callback function you have achieved before. */
r = map(phdr->p_vaddr, phdr->p_memsz, binary + phdr->p_offset, phdr->p_filesz, user_data);
if (r != 0) {
return r;
}
}
ptr_ph_table += ph_entry_size;
}
*entry_point = ehdr->e_entry;
return 0;
}load_icode_mapper
函数根据传入的参数将ELF文件加载进内存。
这个函数非常重要,而且指导书中基本是只抛给了我们一张图,因此我将在下面代码的注释中结合图示仔细讲解:
1 | static int load_icode_mapper(u_long va, u_int32_t sgsize, |
- 返回
load_icode
函数,设置PC
寄存器,使得可以正常进入执行。
上面实现了进程创建,在这之后,我们通过env_create
调用env_create_priority
进而创建进程。
值得注意的是,我们在真正创建进程的时候,通过了ENV_CREATE
这一封装宏。
进程运行和切换
这一部分涉及的函数主要是env_run
。这里需要注意,我们说运行一个新进程,但也往往意味着进程切换而非单纯的进程运行,因此我们可以合并来考虑两者,类似共同的循环节,因此我们可以统一考虑进程切换。
进程切换时,需要保存的内容有:
- 进程本身的信息
- 进程周围环境的信息
整体流程如下:
- 保存进程上下文信息,设置当前进程上下文中的
pc
为epc
- 切换
curenv
使其指向新进程 - 调用
lcontext
函数,设置全局变量mCONTEXT
为当前进程页目录地址,会在TLB重填时用到 - 调用
env_pop_tf
保存现场
以下我们以注释的形式解释env_run
:
1 | void |
另外有两点强调一下:
- 在发生时钟中断的时候,操作系统将进程状态的栈顶地址保存进时钟栈
TIMESTACK
,进入时钟中断后,把TIMESTACK的值赋给寄存器们,再执行中断处理。 TIMESTACK
是发生时钟中断后进程状态的存储区,而KERNEL_SP
是发生系统调用后的进程状态的存储区。env_destroy
中,当curenv == e
的时候,会将KERNEL_SP
的tf
拷贝到TIMESTACK
中,据学姐说法是调用sched_yield
获取下一个要执行的进程之前,要将环境恢复到调用当前进程之前的环境,或者也可能和kill到最后一个进程的时候要恢复到最初状态有关。这部分内容有待将来学习过程中补充或勘误。- 我们看到,
TIMESTACK
所在的页面不应当存在于可被申请的page_free_list
中,因此需要在page_init
中将其摘出
中断异常
首先明确中断和异常的关系:引起控制流突变的就叫做异常,因此中断是异常的一种,且是仅有的一种异步异常。
相关寄存器
中断和异常用到了CPU中的协处理器(CP0)中的12号(SR),13号(Cause)和14号(EPC)寄存器,介绍如下:
寄存器助记符 | CP0寄存器编号 | 描述 |
---|---|---|
SR | 12 | Status Reg,状态寄存器,包括中断引脚是能,其他CPU模式等位域 |
Cause | 13 | 记录导致异常的原因 |
EPC | 14 | 异常结束后程序恢复执行的位置 |
处理异常流程
- 设置EPC指向异常结束时重新返回的地址
- 设置SR位,强制CPU进入内核态(有更高级的特权)并禁止中断
- 设置Cause寄存器,记录异常发生的原因
- CPU开始从异常入口位置取值,此后交给软件处理(这就是我们要做的工作)
时钟中断
实验中常用的是0号异常,即中断异常的处理函数,对我们来说,会有如下流程:
- 跳转到异常分发代码
.text.exc_vec3
- 跳到对应的异常处理函数(0号异常)处
handle_int
中通过指令andi t1, t0, STATUSF_IP4
来判断是否是4号中断,如果是的话跳转到timer_irq
再跳转到sched_yield
,选择下一个进程来执行
1 | NESTED(except_vec3, 0, sp) |
时钟初始化
值得注意的是,上面的流程概览的前提是完成了时钟的初始化。以下是时钟初始化流程:
init/init.c
中调用了kclock_init
函数进行初始化kclock_init
直接调用set_timer
设置时钟kclock_asm.S
实现了set_timer
,具体为在0xb5,000,100位置写入0xc8,其中0xb5,000,000是模拟器gxemul映射实时钟的位置,偏移量0x100表示设置实时钟中断的频率,0xc8表示1秒钟中断200次。也就是说,如果在这里写入0,代表一秒钟中断0次,即关闭实时钟。R3000中实时钟绑定在了4号终端上,也就是说这里是在设置对4号中断的触发,注意是4号中断而非4号异常。
进程调度
进程调度主要在sched_yield
中实现,以下将结合代码具体讲解:
1 | void sched_yield(void) |
This is copyright.