OS - Lab4实验报告

归档于2025年7月8日。

感觉开始上强度了。

我需要从头理一些东西。

杂记

syscall的处理

debugf()为例:

  • 处理参数,随后调用vdebugf()
  • vdebugf()中调用熟悉的vprintfmt,用debug_output作为后端
  • 随后还调用了debug_flush(),调用出syscall_print_cons
  • syscall_print_cons()调用msyscall(),传入信号参数SYS_print_cons,指示之

好,到了msyscall,发现其只做了这么简单的事情:

1
2
3
4
5
6
7
8
9
1 #include <asm/asm.h>
2
3 LEAF(msyscall)
4 // Just use 'syscall' instruction and return.
5
6 /* Exercise 4.1: Your code here. */
7 syscall
8 jr ra
9 END(msyscall)

只调了个syscall,然后返回,没了?我参数呢?在调用msyscall的时候,参数就已经存在了栈上了,不用担心。

之后就是进入syscall的异常处理程序;自然我们想到往genex.s看:

1
BUILD_HANDLER sys do_syscall

好的,异常是由do_syscall()做的,继续看;存在syscall_all.c里的:

众所周知,我们的异常处理会保存Trapframe;Trapframe的保存,是在进入了内核态之后,在entry.S中进行;此时Trapframe保存了我们msyscall()的参数,故我们的参数这里才会用到;

首先就是第一个参数;当然是从$a0($4)里取了;这样,我们就知道要执行哪一个系统调用,从syscall_table[]里取函数了。

出问题的时候,就返回一个异常值;函数返回值(第一个)就是$v0($2)了;存了之后return就是。

整个系统调用流程大概就是这样。

sys_*()

需要注意的是,我们在系统调用中进行的系统操作(增删映射,设置进程控制块状态等),一般都是由sys_*()完成的,而非直接使用page_remove()等函数。

最主要的原因是,我们需要由envid2env(),围绕envid/pid进行一些处理,比如:

  • envid == 0,则指向当前进程
  • 判断进程是否已被释放(status == ENV_FREE
  • 进程ID是否出错:我们由ENVX(envid)取出的进程块的envid,应该与我们现在这个envid一致
  • 权限检查:检查操作是否是进程自己对自己,或者是进程对自己的 “直系子进程”

我自己做Lab4的时候没怎么注意这个问题,然后就踩坑了。

进程间通信

我们这里是通过内存共享实现的。

为了实现IPC,我们需要在进程块中加这么几样东西:

  • 起点;来自哪个进程
  • 终点;被映射到了接收方的哪个虚拟地址
  • 传递的信息
  • 当前进程是否可接受信息
  • 当前进程是否在等待要接收的信息

进行进程通信这个操作,还是通过系统调用(syscall) 实现的。因此,对应逻辑在sys_*()中。

我们在这里的问题是,这里对应的共享内存空间到底是哪里?

阅读源码,我们发现其利用is_illegal_va()检查地址是否合法:

1
2
3
112 static inline int is_illegal_va(u_long va) {
113 return va < UTEMP || va >= UTOP;
114 }

然后,是时候好好看看内存布局了:
Pasted image 20250427102524.png

  • <UTEMP:无效内存对应空间
  • >= UTOP:超出kuseg的一定范围;换言之,超出了用户栈的范围

也就是说,我们共享的信息,是在用户空间内共享的。

1
我们知道,所有的进程都共享同一个内核空间(主要为 kseg0)。因此,想要在不同空间之 间交换数据,我们就可以借助于内核空间来实现。

指导书的这句话,意思是借助内核空间,进行两个进程对同一内存块的映射。

看向sys_ipc_recv()
Pasted image 20250427103520.png

我们为何要((Trapframe *)KSTACKTOP - 1 )->regs[2] = 0

此时,我们的当前进程的栈,自然在栈顶上;栈使用的空间正好就是Trapframe的大小,故我们这么做,就是在访问当前进程的上下文,将函数返回值设为0.

那么,为什么我们这里不直接return呢? 因为我们此时要做到一个阻塞进程的效果,若直接return 0,进程就会继续恢复进行,不符合我们的预期;将返回值暂存在上下文中,就可以做到阻塞解除后,进程被重新调度,恢复执行,得到返回值0的效果了

阻塞的解除,是在sys_ipc_try_send()里,由修改接收方状态为ENV_RUNNABLE,并将接收方重新放回调度队列中实现的

对了,还有个好玩的事情:
Pasted image 20250427150912.png

为何我们不需要checkperm?因为进程间通信不需要检查进程是否为父子关系;checkperm检查的是envid是否指向自己,或自己的直系子进程。

src/dstva == 0

我认为指导书这里说的不是很清楚?

不难注意到,我们在try_send()时,在srcva == 0时,不会将发送方的某个页面,映射到接收方的某个页面上。

但是,我们还是会对接收方进程控制块的ipc域进行修改(e.g. 待接收位拉低,value位设为要传递的value值),并将接收方放回调度队列。 这一点很关键。

当然,这其中一个作用就如指导书所言,仅由进程块的value域传递小量信息,快捷方便;但我认为这跟进程的同步也有关系

回顾前面的recv(),我们在待接收时阻塞,在发送时唤醒,这不就是多进程的同步吗?(笑

fork()

fork()怎么创建一个与父函数不同的新进程的?这个答案指导书已经告诉我们了,通过fork()-exec()的配合。

小实验

对了,在做实验的时候,我发现了一个有趣的现象。在使用管道重定向带fork()程序的输出的时候,输出有些奇怪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 1 #include <stdio.h>
2 #include <unistd.h>
3
4 int main() {
5 int var = 1;
6 long pid;
7 printf("Before: var = %d\n",var);
8 pid = fork();
9 if (pid == 0) {
10 var = 2;
11 sleep(3);
12 printf("Child: %d",var);
13 } else {
14 sleep(2);
15 printf("Parent: %d",var);
16 }
17 printf(", pid: %ld\n", (long) getpid());
18 return 0;
19 }
1
2
3
4
1 Before: var = 1
2 Parent: 1, pid: 113267
3 Before: var = 1
4 Child: 2, pid: 113268

Before句按理只会输出一次,为何这里输出了两次?由于我们的实验还没做到管道部分,这里我们暂时也不知道为什么…

自上而下分析

fork()干了这么几件事:

  • 设置父进程的TLB Mod Handler
  • exofork()创建子进程,获得创建好的进程控制块的PID
  • 自此开始分割父进程和子进程;父进程在此完成所有操作,返回0
  • 子进程:构建父进程所有页的映射
  • 设置子进程的TLB Mod Handler,并让子进程进入RUNNABLE

还有一点:

1
MOS 允许进程访问自身的进程控制块,而在 user/lib/libos.c 的实现中,用户程序在运 行时入口会将一个用户空间中的指针变量 struct Env *env 指向当前进程的控制块。对于 fork 后的子进程,它具有了一个与父亲不同的进程控制块,因此在子进程第一次被调度的时候(当然 这时还是在 fork 函数中)需要对 env 指针进行更新,使其仍指向当前进程的控制块。这一更新 过程与运行时入口对 env 指针的初始化过程相同。

这就是为什么:
Pasted image 20250427134303.png

继续。构建父进程映射部分的细节颇有意思
Pasted image 20250427132910.png

结合上面的内存空间图,我们得知:我们将kuseg段,所有在USTACKTOP下的页面全部映射到子进程中(当然要做COW的处理);这样一来,循环条件就很明显了:找所有USTACKTOP下的页。

随后就是里面的条件了:

  • 页号对应页目录项要有效
  • 页表项要有效

非有效即可认为映射不准确,故选择不映射;到时候会产生缺页异常重新取的。

思考题

4.1

在我们上面回顾了一下syscall的流程后,这个问题就很好解答了。

  • 将GPR的所有信息存入Trapframe,在处理完成后再从Trapframe恢复,这样GPR的信息在调用前后就不会发生变化。
  • 理论而言是可以的,因为此时的$a0-$a4按理没有发生改变,但这违反了我们保护现场的原则;这也是我们为何需要从Trapframe中读取的原因;此外,Trapframe自身能保证GPR就是调用前的状态,有更保险的方法为什么不用()
  • do_syscall()从Trapframe中读出msyscall()传入的参数,从而将这些参数分发给sys_*()
  • 这部分的改变主要在do_syscall()中发生;
    此时,cp0_epc加4;在用户态,表示异常处理完后直接执行下一条指令;
    tf->regs[2]被设为了调用的处理函数的返回值;如果没有对应系统调用的处理函数,会返回一个错误值;对于用户态,我们此处就可以根据返回值确定系统调用的状态,对应进行处理。

4.2

为了检测envid对应的进程是否真的存在。

有这么一种可能:

  • 传入envid2env()envid是一个当前不存在的进程
  • envid指向的envs[]存的是无效信息
  • 如果不判断,我们就会错误地认为这个进程存在…

4.3

首先,mkenvid()正如其名,创建一个新的进程ID,它的结果当然不应该为0。

envid2env()这一函数其实“名不副实”,因为除根据envid获取进程控制块外,他还整合了检查进程控制权限的功能。

何为“进程控制权限”?阅读源码,我们知道,“进程控制”只有在两种情况下合法:

  • 查询的envid,对应的正好就是调用envid2env()的进程自己;
  • 查询的envid,对应的是调用进程的 “直系子进程”

envid2env()能处理envid==0的情况,其实是为了给进程获取自身的进程控制块提供方便。毕竟,进程此时不获取自己的进程控制块,是不知道自己的PID的。

4.4

C。

父函数调用fork(),生成了子进程;随后,给父子两进程返回不同的返回值。

4.5

把内存空间图继续搬下来:
Pasted image 20250427102524.png

对于USTACKTOP以上的空间:

  • ULIM-UVPT:父进程自己的页表,我们的duppage会做重建页表的工作,故不用
  • pages/envs:被映射到系统的pages[]envs[]两个内核数组;每个进程在env_init时就做了映射,故不用
  • exception_stack:仅在用户态处理异常时要用到,这里用不上,故不管

剩下的只要满足条件,就要映射。

4.6

首先,我们知道,页表是被映射到了一个确定的虚拟地址上的,且我们的MOS采用了页表自映射。

  • vpdvpt分别获取页目录和页表的起始地址lib.h中将二者分别定义为了Pde *Pte *,故按照指针的用法使用之即可;比如说,可以vpd[PDE_ID],也可以vpd + PDE_ID
  • 页表被映射到了一个确定的虚拟地址上;一个进程拥有一套自己的页表;在切换进程时,这个页表也会被切换。在得知基地址和偏移量后,能访问页表就很自然了。
    1
    2
    #define vpt ((const volatile Pte *)UVPT)  
    #define vpd ((const volatile Pde *)(UVPT + (PDX(UVPT) << PGSHIFT)))
  • vpdUVPT + (PDX(UVPT) << PGSHIFT)已经很明显了,不再赘述
  • 不能,权限不够

4.7

  • 重入机制如下,注意tf->regs[29] >= UXSTACKTOP

    1
    2
    3
    4
    5
    6
    80 void do_tlb_mod(struct Trapframe *tf) {
    81 struct Trapframe tmp_tf = *tf;
    82
    83 if (tf->regs[29] < USTACKTOP || tf->regs[29] >= UXSTACKTOP) {
    84 tf->regs[29] = UXSTACKTOP;
    85 }

    考虑这么一种可能:do_tlb_mod的处理函数,再次访问了一个需要COW的页。

  • 此处异常处理的一部分在用户态处理,这是有可能破坏上下文的;为了维护进程进入异常处理前后的上下文一致,我们自然需要保存进入异常前的上下文。此外,异常现场是在进入异常时保存的,对用户态透明,故我们需要将其复制到用户空间。

    以防我忘了,多写一点:
    do_tlb_mod()中,进行了上下文的复制;对于现在放在Trapframe的那一份上下文,我们修改其cp0.epc到处理函数的位置;

    随后,开始执行处理函数(如cow_entry)。执行完成后,会调用syscall_set_trapframe,以复制的那一份上下文,恢复进程的上下文。

    恢复$sp和上下文,是在sys_set_trapframe处完成的。

4.8

  • 微内核的概念中,异常处理放在用户态处理,减少了因权限不足等问题引起系统崩溃的可能。
  • 在用户态处理页写入异常时,我们可以调度其他进程继续执行,避免主机为了处理这一异常,让其他进程等待太久。

4.9

  • 我认为二者相对位置没什么影响。因为第一个set_entry是对于父进程的,子进程直到父进程为其设置tlb_mod_entry(第二个set_entry)前都不会执行,故没有影响。放的位置可能就看个人喜好了。
  • 写时复制保护,保护的是USTACKTOP下的所有内容;这当然也包括了我们的函数参数;假设我们调用了一个函数,就可能触发COW;如果我们将set_entry放在COW机制后,就有可能导致调用函数时产生COW异常,而没有对应处理函数的问题。

难点分析

  • 各个过程耦合程度挺高的,需要真正重头过一遍才能看懂。具体见我上面的杂记吧。
  • 在前几个单元的思维定势下,我常常忘记一些地方需要用sys_*();比如duppage中页表的映射,我下意识就输入了page_insert()
  • 细节很多。比如说fork进行COW映射时,还需要关注页目录/页表项是否有效。没有注释的提醒,我一开始确实没有考虑这么多…

实验体会

我深刻感觉到本单元开始,任务的加重。

首先是注释细化程度进一步下降,这让我不得不更谨慎地考虑实现细节。最后确实发现不少细节漏掉了,比如上面提到的COW映射,与页目录/页表有效有关的问题。我自觉自己注意细节的能力在日趋下降,是时候想办法改变一下了。

说实话,本次Lab是我第一次大量参考往届学长/MOS开源仓库的代码。虽说能保证最后结果正确,但为了避免印象不深,而在第二天从头再看一遍的时间开销还是很大。怎么才能达到独立完成,和参考他人经验之间的平衡,是个值得探讨的问题。

原创声明

本人报告的绝大部分内容由我自己独立完成。但我在思考题4.5、4.7处遇到了一些困难,有参考两位学长(学姐)博客中的思路,谨此致谢:

作者

LajiPZ

发布于

2025-07-08

更新于

2025-07-09

许可协议

评论