实验文档-lab3
一、思考题汇总
思考1:
为什么我们在构造空闲进程链表时必须使用特定的插入的顺序?(顺序或者逆序)
答:插入空闲进程链表时采用的是逆序插入。
由于我们的操作系统在插入空闲进程链表时采用的方式为LIST_INSERT_HEAD
,所以在插入时只有通过逆序插入,才能使我们在从env_free_list
中取出进程块时能够从前往后地进行取出。
思考2:
思考env.c/mkenvid
函数和envid2env
函数:
-
请你谈谈对
mkenvid
函数中生成id
的运算的理解,为什么这么做? -
为什么
envid2env
中需要判断e->env_id != envid
的情况?如果没有这步判断会发生什么情况?
答:(1)生成id时,先用一个静态变量表示第几次调用这个函数,再将其左移11位,再加上这个env块在env数组中的偏移,得到这个进程块所对应的env_id
。
(2)在该判断语句的之前有一句e = &envs[ENVX(envid)];
来通过envid来求出env。可以发现,在这个语句中判断对应关系只用了低位位数,也就是进程块的偏移。但实际上,envid还有高位部分,高位不同代表这个进程块被调用过不止一次,仍然不是一个进程。但由于一个进程块同时只能对标一个正在执行的进程,所以若高位不同,代表所查询的envid所对应的进程一定不存在,因此返回-E_BAD_ENV。若没有这步判断,则会在查询一个不存在的进程id时却能够得到对应的进程,导致程序错误。
思考3:
结合include/mmu.h 中的地址空间布局,思考env_setup_vm 函数:
-
我们在初始化新进程的地址空间时为什么不把整个地址空间的pgdir 都清零,而是复制内核的boot_pgdir作为一部分模板?(提示:mips 虚拟空间布局)
-
UTOP 和ULIM 的含义分别是什么,在UTOP 到ULIM 的区域与其他用户区相比有什么最大的区别?
-
在env_setup_vm 函数的最后,我们为什么要让pgdir[PDX(UVPT)]=env_cr3?(提示: 结合系统自映射机制)
-
谈谈自己对进程中物理地址和虚拟地址的理解
答:(1)MIPS操作系统的虚拟地址采用的是2G/2G的结构,其中高2G是内核区,低2G是用户区。在内核区中。每一个进程的这一段区域虚拟地址到物理地址的映射都是完全一样的,这就意味着所以的进程都可以直接通过在boot_pgdir
中复制来得到这一部分的内容。
(2)UTOP = 0x7f400000
,其含义为用户所能操纵的地址空间的最大值;ULIM = 0x80000000
,其含义为操作系统分配给用户地址空间的最大值。这一段空间被定义为一个只读片段,属于“内核态”,主要功能在于让用户进程去查看其他进程的信息,用户在此处进行读取不会陷入异常。
(3)UVPT
的含义为User Virtual Page Table,因此这一段需要映射到他的进程在pgdir
中的页目录地址。所以我们在将这一段空间的虚拟地址转化为物理地址时可以很快找到对应的页目录。
(4)进程只能操作虚拟地址,而实现虚拟地址和物理地址之间的映射由操作系统完成。
思考4:
思考user_data 这个参数的作用。没有这个参数可不可以?为什么?(如果你能说明哪些应用场景中可能会应用这种设计就更好了。可以举一个实际的库中的例子)
答:在函数load_icode_mapper
中,被传入的user_data
被用于这样一个语句中:
struct Env *env = (struct Env *)user_data;
因此这个所谓的user_data
实际上在函数中的真正含义就是这个被操作的进程指针。那么我们来一步步追溯这个变量最开始是以什么形式被传入的。
回到load_elf
函数中,我们可以看到user_data
从函数本身被传入到调用load_icode_mapper
中没有改变,那再回到调用load_elf
的load_icode
中,我们发现在调用load_elf
时的语句为:
r = load_elf(binary, size, &entry_point, e, load_icode_mapper);
而其中的e则为传入load_icode
中的struct Env *e
,因此我们的推测得到证实。
没有进程指针,我们的加载镜像的步骤显然不能正常完成。
思考5:
结合load_icode_mapper 的参数以及二进制镜像的大小,考虑该函数可能会面临哪几种复制的情况?你是否都考虑到了? (提示:1、页面大小是多少;2、回顾lab1中的ELF文件解析,什么时候需要自动填充.bss段)
答:在load_icode_mapper
中,需要复制的页面特征可以由以下的一张图表示
因此我们可以分析出复制的情况有这么几种:
.text & .data
:
- 第一段,需要切除前半部分的
offset
的一段。 - 中间的普通段。
- 最后一段,即前半部分属于
.test & .data
,后半部分属于.bss
。 - 需要考虑的特殊情况有:
- 第一段的前半段已经装载过内容,因此不能在这一段进行
alloc
与insert
操作,从而保留前半段内容。 offset
= 0,此时从最开始的所有端可以当做正常页处理。.test & .data
与.bss
被某一个页分割恰好切开,不存在共同占用一个page
的情况。.test & .data
这一段的长度极小,即第一个page
就为最后一个page
,因此需要同时对两侧的页面分割进行判定与相应操作。
- 第一段的前半段已经装载过内容,因此不能在这一段进行
.bss
:
- 第一段,需要同前半段的
.test & .data
段协同考虑。 - 中间的普通段。
- 最后一段,即前半部分属于
.bss
,后半段在需要复制的内容之外。 - 需要考虑的特殊情况有:
- 第一段的前半段已经在
.text & .data
段被装载过相关内容,为保证那一段内容不被破坏,在处理.bss
的这一段时,不能使用alloc
以及insert
来进行新的页面插入。注:在操作正确的情况下,只要两段不是恰好的页面分割,那么一定会出现这种情况!! .test & .data
与.bss
被某一个页分割恰好切开,不存在共同占用一个page
的情况。.bss
这一段的长度极小,即第一个page
就为最后一个page
。- 最后一页被恰好在
page
的交界分开。
- 第一段的前半段已经在
最终代码如下:
/*** exercise 3.6 ***/
static int load_icode_mapper(u_long va, u_int32_t sgsize,
u_char *bin, u_int32_t bin_size, void *user_data)
{
struct Env *env = (struct Env *)user_data;
struct Page *p = NULL;
u_long i;
int r;
u_long offset = va - ROUNDDOWN(va, BY2PG);
Pte *temp;
/*Step 1: load all content of bin into memory. */
i = 0;
if (offset != 0) {
p = page_lookup(env -> env_pgdir, va, &temp);
if (p == 0) {
xxxxx;
}
if (BY2PG - offset <= bin_size) {
xxxxx;
} else {
xxxxx;
}
}
for (; i < bin_size; i += BY2PG) {
/* Hint: You should alloc a new page. */
xxxxx;
if (BY2PG + i - offset <= bin_size) {
xxxxx;
} else {
xxxxx;
}
}
/*Step 2: alloc pages to reach `sgsize` when `bin_size` < `sgsize`.
* hint: variable `i` has the value of `bin_size` now! */
offset = i - ROUNDDOWN(i, BY2PG);
if (offset != 0) {
p = page_lookup(env -> env_pgdir, va + i, &temp);
if (offset + sgsize - i < BY2PG) {
xxxxx;
} else {
xxxxx;
}
}
while (i < sgsize) {
xxxxx;
if (sgsize - i < BY2PG) {
xxxxx;
} else {
xxxxx;
}
i += BY2PG;
}
return 0;
}
思考6:
思考上面这一段话,并根据自己在lab2 中的理解,回答:
-
我们这里出现的” 指令位置” 的概念,你认为该概念是针对虚拟空间,还是物理内存所定义的呢?
-
你觉得
entry_point
其值对于每个进程是否一样?该如何理解这种统一或不同
答:(1)此概念针对的是虚拟地址空间
(2)该值是从load_elf
中的*entry_point = ehdr->e_entry;
语句中进行赋值,所以该值对于每个进程是一样的。这种值来源于他们都是从ELF文件中的同一个部分进行取值的,elf文件结构的统一决定了该值的统一。
思考7:
思考一下,要保存的进程上下文中的env_tf.pc
的值应该设置为多少?为什么要这样设置?
答:应当设置为env_tf.cp0_epc
。因为在《计算机组成》中介绍过EPC寄存器就是用来存放异常中断发生时进程正在执行的指令的地址的。因此在进入异常处理时,进程上下文中的env_tf.pc
应该设置为epc
。
思考8:
思考TIMESTACK 的含义,并找出相关语句与证明来回答以下关于TIMESTACK 的问题:
-
请给出一个你认为合适的TIMESTACK 的定义
-
请为你的定义在实验中找出合适的代码段作为证据(请对代码段进行分析)
-
思考TIMESTACK 和第18 行的KERNEL_SP 的含义有何不同
答:(1)TIMESTACK
是内存中的一块栈空间,用于存储进程的状态。
(2)TIMESTACK
在env.c中再两处地方被调用:
env_destroy
:
bcopy((void *)KERNEL_SP - sizeof(struct Trapframe),
(void *)TIMESTACK - sizeof(struct Trapframe),
sizeof(struct Trapframe));
env_run
:
old = (struct Trapframe *)(TIMESTACK - sizeof(struct Trapframe));
bcopy((void *)old, (void *)&(curenv->env_tf), sizeof(struct Trapframe));
我们不难发现,在我们的操作系统代码中对这个变量的调用均在bcopy
中进行,并且利用的都是在TIMESTACK
以下的一段长度为struct Trapframe
的空间。在env_destroy
中,将存于KERNEL_SP
的进程状态复制到TIMESTACK
处,而在env_run
中,则是从这一段空间中取出进程状态并转移给当前进程。
在mmu.h中,我们得到TIMESTACK
的具体值为0x82000000
,对这段地址的调用进行查找,我们在汇编代码stackframe.S中又找到了一处调用该段地址的地方,即:
.macro get_sp
mfc0 k1, CP0_CAUSE
andi k1, 0x107C
xori k1, 0x1000
bnez k1, 1f
nop
li sp, 0x82000000
j 2f
nop
1:
bltz sp, 2f
nop
lw sp, KERNEL_SP
nop
2: nop
.endm
这段代码的功能为获取栈指针的值,若检测到是中断异常则将栈指针置于TIMESTACK
处,这样在发生中断时我们就能将当前进程的状态存入TIMESTACK
处,从而进行保存。
(3)从上面那段汇编代码中我们可以看出,将栈指针设在TIMESTACK
还是KERNEL_SP
与CP0_CAUSE
有关,经查阅,在发生中断时将进程的状态保存到TIMESTACK
中,在发生系统调用时,将进程的状态保存到KERNEL_SP
中。
思考9:
阅读 kclock_asm.S 文件并说出每行汇编代码的作用
答:kclock_asm.S内容如下:
.macro setup_c0_status set clr
.set push
mfc0 t0, CP0_STATUS
or t0, \set|\clr
xor t0, \clr
mtc0 t0, CP0_STATUS
.set pop
.endm
.text
LEAF(set_timer)
li t0, 0x01
sb t0, 0xb5000100
sw sp, KERNEL_SP
setup_c0_status STATUS_CU0|0x1001 0
jr ra
nop
END(set_timer)
从.text
段开始:
首先先将0x01
写入地址0xb5000100
中,其中基地址0xb5000000
为gxemul
用于映射实时钟的地址,偏移量0x100
代表时钟的频率。将栈指针设置为KERNEL_SP
中能够正确产生时钟中断,二、再调用宏函数setup_c0_status
来设置CP0_STATUS
的值,最后通过jr ra
来返回。
思考10:
阅读相关代码,思考操作系统是怎么根据时钟周期切换进程的。
答:在我们的操作系统中,设置了一个进程就绪队列,并且给每一个进程添加了一个时间片,这个时间片起到计时的作用,一旦时间片的时间走完,则代表该进程需要执行时钟中断操作,则再将这个进程移动到就绪队列的尾端,并复原其时间片,再让就绪队列最首端的进程执行相应的时间片段,按照这种规律实现循环往复,从而做到根据时钟周期切换进程。
二、实验难点图示
难点1:初始化新进程地址空间,即env_setup_vm
函数的填写
在这个函数中,内存空间被分成了如下的两个部分,即UTOP
以上和UTOP
以下,在UTOP
以下的部分,我们需要将页目录的这一块区域清零,而在UTOP
以上的部分,用户不能操作,属于内核态,因此我们可以将boot_pgdir
的内容直接复制到进程的页目录中。
在UTOP
之上有一块被称为UVPT
的地址,这一块区域作为用户进程页目录,需要用自映射机制进行单独处理。
地址空间的结构图如下:
难点2:加载二进制镜像
这一部分的内容较多且难度较大,由三个函数共同完成,即:
- env.c中的
load_icode
- kernal_elfloader.c中的
load_elf
- env.c中的
load_icode_mapper
其中load_icode
为实现这个功能的代码,它的功能在于:
- 分配内存
- 将二进制代码装入分配好的内存中
其中,第二步,即装入内存的操作交给了函数load_elf
来完成,而load_elf
的工作又被分为:
- 解析
ELF
结构 - 将
ELF
的内容复制到内存中
其中,第二步,即将内容复制到内存中的操作又交给了load_icode_mapper
函数去进行,所以三段代码的协作方式如下图:
其中,函数load_icode_mapper
函数的难点及图示已在思考5中给出
在函数load_elf
中,我们不难发现,我们在load_icode_mapper
中用到的许多参量在这里都有了很明确的实例对应,具体映射如下:
因此我们只要将给定的ELF文件进行正确解析,就能利用load_icode_mapper
对其进行内容复制
函数load_icode
重点在于设置PC
值,即从load_elf
中返回的entry_point
难点3:中断与进程调度
中断机制的设置主要包括三步,都较为容易:
- 在
start.S
中补充异常分法代码 - 修改链接脚本,使得异常中断发生时能够正确的跳转到异常处理代码段
- 调用
set_timer()
开启时钟中断
进程的调度主要在sched_yield
中进行。
在我们的env
结构体中,有这么一个变量:
u_int env_pri;
这个变量的字面含义为进程的“优先级”,但实际上在完成实验的过程中,我们可以发现这个所谓“优先级”与进程的优先执行并没有关系,它的真正含义是一个进程所能连续运行时间片的大小,即产生多少次时钟中断之后必须要切换到下一个进程。在创建一个进程时,这个量的赋值在函数env_create_priority
中进行:
void
env_create_priority(u_char *binary, int size, int priority)
{
struct Env *e;
/*Step 1: Use env_alloc to alloc a new env. */
if (env_alloc(&e, 0) != 0) {
return;
}
/*Step 2: assign priority to the new env. */
e->env_pri = priority;
/*Step 3: Use load_icode() to load the named elf binary,
and insert it into env_sched_list using LIST_INSERT_HEAD. */
load_icode(e, binary, size);
LIST_INSERT_HEAD(&env_sched_list[0], e, env_sched_link);
}
我们在init.c
中创建进程时,所用到的代码为:
ENV_CREATE_PRIORITY(user_A, 2);
ENV_CREATE_PRIORITY(user_B, 1);
我们可以看到这两个进程的“优先级”分别被设置为了2和1,这也就解释了为什么最后的输出结果中两个量的输出比例为2:1
经过查找,发现代码中的user_A
与user_B
在code_A.c
与code_B.c
中被定义,且两个文件中都只有一个unsigned char
型数组,两个数组中,只有第六行的第三个值不同,user_A
为0x1
,user_B
为0x2
,于是猜测这个值决定了这两个进程的输出,经过修改这个值并重新运行,此猜测得到验证,与最后输出中呈现的1为2的两倍恰好对应。
进程的调度也是基于这个时间片来进行,主要的步骤为如下几步:
- 设置两个队列,其中一个为目前的进程调度队列
q0
,另一个为一个空队列q1
。 - 首先判断当前队列指针指向的队首进程的
env_status
- 如果为
ENV_FREE
,则要将该进程从队列中移除 - 如果为
ENV_NOT_RUNNABLE
,则直接将其插入另一个队列的尾部 - 如果为
ENV_RUNNABLE
,则判断这个进程的时间片是否用完,若用完则复原其时间片并将其插入到另一个队列尾部 - 当一个队列为空时,将指针转移到另一个队列队首
- 如果为
三、体会与感想
本次实验中首次让我们脱离“程序”的概念,而是用“进程”来思考问题。
就代码填写难度来说,个人认为这一lab的难度是要低于lab2的,大多数的部分只需要调用相关函数即可完成,但本次实验仍然会在许多的地方运用到lab2的内容,因此可以看出关于内存管理的内容贯穿着整个操作系统课程,一定要掌握充分。
本单元仍然需要多阅读代码,且此次需要阅读的代码量更大,分布更广,我认为对代码的理解应该为:
- 需要填写的代码:一定要理解每一行的意思,清楚这一个函数的功能以及其中每一个语句的含义
- 在填写中反复调用的函数和宏函数:同样需要理解代码的含义与功能,以便在实验中能够灵活运用
- 功能极其特化,只在一个或有限几个地方会用到,且十分难以理解的C代码或汇编代码:最起码要理解这个函数的功能是什么,在精力允许情况下可以尝试去掌握其中的细节
还有一个十分重要的内容便是程序Bug
的修复,操作系统的Bug
有时候会十分隐蔽,可能在很多请况下都发现不了,并且能通过课程组提供的自测和公测点,但是一旦在后期突然出现就会十分致命,并且很难找到(比如我在lab2时曾因为某个寻址语句没有进行强制类型转化导致lab2-2的课上测试一直0分且完全没有意识到是课下的内容出了问题)。同样的,在这次实验中许多身边的同学都在最后一个进程调度中无法得到正常的结果,最终发现是lab2-1的宏函数填写出了问题。因此我们在之后填写代码中一定要十分细心,多去关注可能出bug的语句,必要时与同学和老师,助教交流。
在Debug过程中,由于在虚拟式上没有ide的调试工具,可以采用在可疑点的上下文增加一些有标记性意义的输出:
- 判断是否在这一步被卡住:输出
chk1
,chk2
等具有索引的寻踪字段 - 判断这一步是否发生内容错误:输出
env_id
等与程序执行中的重要变量有关的信息(在lab2-1-exam的part2给了我们一种全方位的判断信息是否正确的思路,可以在之后的实验中采用)
同时,在填写一些复杂度很高的函数,如load_icode_mapper
时,不能拿到代码,看到hint就往上一股脑的填,而应该去尝试复盘整个过程,并考虑到各种情况,无重复、无遗漏地对每个情况进行处理
四、指导书反馈
个人认为在load_icode_mapper
中的函数内容可以略加改善
在其中,部分已提供的代码是一个全局循环,其中i
以BY2PG
为一个单位递增
/*Step 1: load all content of bin into memory. */
for (i = 0; i < bin_size; i += BY2PG) {
/* Hint: You should alloc a new page. */
}
但实际上,经过分析我们得出i
要想变化到正确位置,每次递增的量并不一定是BY2PG
(详见思考5),且并不一定每一个段都需要alloc
操作,因此我认为这段代码具有一定的误导性,且如果要完全保留的话可能会在后面写出一些意义不明的代码(如强制调整i
的位置),可在之后的课程中改善。
五、残留难点
本次实验的主要残留难点在于进程的行为,即这个进程在执行时在做什么。
个人目前的猜测是进程的行为和之前发现的输出数据一样,存储在code_a.c
的大数组中,用机器码表示,但目前仍然没有具体找出能够代表行为的代码,可能在之后的lab中能够解析进程的行为。