操作系统lab3实验报告


实验文档-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_elfload_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中,需要复制的页面特征可以由以下的一张图表示

image

因此我们可以分析出复制的情况有这么几种:

.text & .data

  • 第一段,需要切除前半部分的offset的一段。
  • 中间的普通段。
  • 最后一段,即前半部分属于.test & .data,后半部分属于.bss
  • 需要考虑的特殊情况有:
    • 第一段的前半段已经装载过内容,因此不能在这一段进行allocinsert操作,从而保留前半段内容。
    • 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_SPCP0_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中,其中基地址0xb5000000gxemul用于映射实时钟的地址,偏移量0x100代表时钟的频率。将栈指针设置为KERNEL_SP中能够正确产生时钟中断,二、再调用宏函数setup_c0_status 来设置CP0_STATUS的值,最后通过jr ra来返回。


思考10:

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

答:在我们的操作系统中,设置了一个进程就绪队列,并且给每一个进程添加了一个时间片,这个时间片起到计时的作用,一旦时间片的时间走完,则代表该进程需要执行时钟中断操作,则再将这个进程移动到就绪队列的尾端,并复原其时间片,再让就绪队列最首端的进程执行相应的时间片段,按照这种规律实现循环往复,从而做到根据时钟周期切换进程。


二、实验难点图示

难点1:初始化新进程地址空间,即env_setup_vm函数的填写

在这个函数中,内存空间被分成了如下的两个部分,即UTOP以上和UTOP以下,在UTOP以下的部分,我们需要将页目录的这一块区域清零,而在UTOP以上的部分,用户不能操作,属于内核态,因此我们可以将boot_pgdir的内容直接复制到进程的页目录中。

UTOP之上有一块被称为UVPT的地址,这一块区域作为用户进程页目录,需要用自映射机制进行单独处理。

地址空间的结构图如下:

image


难点2:加载二进制镜像

这一部分的内容较多且难度较大,由三个函数共同完成,即:

  • env.c中的load_icode
  • kernal_elfloader.c中的load_elf
  • env.c中的load_icode_mapper

其中load_icode为实现这个功能的代码,它的功能在于:

  1. 分配内存
  2. 将二进制代码装入分配好的内存中

其中,第二步,即装入内存的操作交给了函数load_elf来完成,而load_elf的工作又被分为:

  1. 解析ELF结构
  2. ELF的内容复制到内存中

其中,第二步,即将内容复制到内存中的操作又交给了load_icode_mapper函数去进行,所以三段代码的协作方式如下图:

image

其中,函数load_icode_mapper函数的难点及图示已在思考5中给出

在函数load_elf中,我们不难发现,我们在load_icode_mapper中用到的许多参量在这里都有了很明确的实例对应,具体映射如下:

image

因此我们只要将给定的ELF文件进行正确解析,就能利用load_icode_mapper对其进行内容复制

函数load_icode重点在于设置PC值,即从load_elf中返回的entry_point


难点3:中断与进程调度

中断机制的设置主要包括三步,都较为容易:

  1. start.S中补充异常分法代码
  2. 修改链接脚本,使得异常中断发生时能够正确的跳转到异常处理代码段
  3. 调用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_Auser_Bcode_A.ccode_B.c中被定义,且两个文件中都只有一个unsigned char型数组,两个数组中,只有第六行的第三个值不同,user_A0x1user_B0x2,于是猜测这个值决定了这两个进程的输出,经过修改这个值并重新运行,此猜测得到验证,与最后输出中呈现的1为2的两倍恰好对应。

进程的调度也是基于这个时间片来进行,主要的步骤为如下几步:

  1. 设置两个队列,其中一个为目前的进程调度队列q0,另一个为一个空队列q1
  2. 首先判断当前队列指针指向的队首进程的env_status
    • 如果为ENV_FREE,则要将该进程从队列中移除
    • 如果为ENV_NOT_RUNNABLE,则直接将其插入另一个队列的尾部
    • 如果为ENV_RUNNABLE,则判断这个进程的时间片是否用完,若用完则复原其时间片并将其插入到另一个队列尾部
    • 当一个队列为空时,将指针转移到另一个队列队首

image


三、体会与感想

本次实验中首次让我们脱离“程序”的概念,而是用“进程”来思考问题。

就代码填写难度来说,个人认为这一lab的难度是要低于lab2的,大多数的部分只需要调用相关函数即可完成,但本次实验仍然会在许多的地方运用到lab2的内容,因此可以看出关于内存管理的内容贯穿着整个操作系统课程,一定要掌握充分。

本单元仍然需要多阅读代码,且此次需要阅读的代码量更大,分布更广,我认为对代码的理解应该为:

  • 需要填写的代码:一定要理解每一行的意思,清楚这一个函数的功能以及其中每一个语句的含义
  • 在填写中反复调用的函数和宏函数:同样需要理解代码的含义与功能,以便在实验中能够灵活运用
  • 功能极其特化,只在一个或有限几个地方会用到,且十分难以理解的C代码或汇编代码:最起码要理解这个函数的功能是什么,在精力允许情况下可以尝试去掌握其中的细节

还有一个十分重要的内容便是程序Bug的修复,操作系统的Bug有时候会十分隐蔽,可能在很多请况下都发现不了,并且能通过课程组提供的自测和公测点,但是一旦在后期突然出现就会十分致命,并且很难找到(比如我在lab2时曾因为某个寻址语句没有进行强制类型转化导致lab2-2的课上测试一直0分且完全没有意识到是课下的内容出了问题)。同样的,在这次实验中许多身边的同学都在最后一个进程调度中无法得到正常的结果,最终发现是lab2-1的宏函数填写出了问题。因此我们在之后填写代码中一定要十分细心,多去关注可能出bug的语句,必要时与同学和老师,助教交流。

在Debug过程中,由于在虚拟式上没有ide的调试工具,可以采用在可疑点的上下文增加一些有标记性意义的输出:

  • 判断是否在这一步被卡住:输出chk1chk2等具有索引的寻踪字段
  • 判断这一步是否发生内容错误:输出env_id等与程序执行中的重要变量有关的信息(在lab2-1-exam的part2给了我们一种全方位的判断信息是否正确的思路,可以在之后的实验中采用)

同时,在填写一些复杂度很高的函数,如load_icode_mapper时,不能拿到代码,看到hint就往上一股脑的填,而应该去尝试复盘整个过程,并考虑到各种情况,无重复、无遗漏地对每个情况进行处理


四、指导书反馈

个人认为在load_icode_mapper中的函数内容可以略加改善

在其中,部分已提供的代码是一个全局循环,其中iBY2PG为一个单位递增

/*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中能够解析进程的行为。


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM