Lab4_Challenge实验报告
一、任务背景
信号(英语:Signal)是 Unix、类 Unix 以及其他 POSIX 兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统会打断进程正常的控制流程,此时,任何非原子操作都将被打断。如果进程注册了信号的处理函数,那么它将被执行,否则就执行默认的处理函数。信号的机制类似于硬件中断(异常),不同之处在于中断由处理器发出并由内核处理,而信号由内核发出并由用户程序处理。除了进程通过系统调用向另一进程(或它自身)发出的信号,内核还可以将发生的中断通过信号通知给引发中断的进程。如果说系统调用是一种用户程序通知内核的机制,那么信号就是内核通知用户程序的机制。任务:在我们的 MOS 操作系统中实现这样一套机制。
二、实现思路
1.保存的内容
考虑到信号是针对进程而言的,进程拥有各自的信号以及信号处理函数,因此将信号、信号处理函数(sigaction)以及进程掩码储存于进程控制块中,具体言之,应当有如下几部分内容:
- 一个sigaction的列表
- 进程的掩码
- 信号等待栈(由于后接收到的信号先处理,因此使用栈存储信号)
- 被掩盖信号等待栈
- 在用户态下定义的信号处理封装函数入口
代码如下:
1 | // Lab 4 challenge struct |
2.信号的注册
信号注册函数实际上是将一个用户态下定义的handler
的地址放进env的sigaction列表中。任何对于env的操作应当位于内核态中,因此需要实现系统调用函数。
在我的设计中,对于Lab4 challenge 中的所有用户态的函数都定义在user/lib/signal.c
中。
信号注册函数逻辑是如果oldact不为空,则将旧的信号处理函数存在此地址中。在此过程后,将传入的新act赋值给进程对应信号的sigaction。
代码如下:
1 | int sys_sigaction(int signum, const struct sigaction *act, struct sigaction *oldact) |
进程掩码修改逻辑类似,将旧的掩码放入oldset位置(如果oldset不为空)。之后根据how的值对进程掩码进行一些位运算。
进程掩码修改函数和信号集操作函数是对于信号集的一些位运算,比较简单,不在这里展示。
应当注意的是信号集操作是用户态函数,不需设置系统调用。
3.信号的发送
信号发送函数本质上就是向编号为envid的进程的信号等待栈中压一个信号。使用系统调用即可。
4.信号的处理
对于信号处理应当是Lab4 challenge 的核心部分。
由于信号存放在进程控制块中,所以如果想要处理信号,就应当在内核态下将其取出处理。而信号处理函数又是用户在用户态下定义的函数,因此想要处理一个信号,应当先进入内核态取得信号,之后返回用户态,调用其在用户态下的处理函数。这个逻辑类似于写时复制的原理,实现也与其比较类似。
应当注意的是,在信号开始处理时,要一次处理所有未被阻塞的信号,这就意味着在一个用户态的信号处理函数执行结束后,应当返回内核态,重新执行信号处理,取出等待栈中下一个信号继续处理,直到等待栈空。
对于用户态函数,我的实现是实现一个封装函数,进入用户态时执行这个函数,通过该函数调用用户定义的信号处理函数,最后通过系统调用返回内核态。
信号应当在每次陷入内核后就得到处理。如果只在进程调度时处理信号,那么一个进程给自己发送信号,当时间片执行完后没有调度,信号就会一直得不到处理。
综合以上思考,我的信号处理整体思路如下:
- 内核态在
kern/signal.c
中定义do_signal
函数,这个函数类似于do_syscal
、do_tlb_mod
函数,区别在于每次陷入内核都会执行这个函数。这个函数应当在陷入内核的handler调用的处理函数执行后再执行,防止影响本身陷入内核处理的事务。调用函数位置如下

- 在do_signal 函数中,首先遍历信号等待栈,如果栈为空则返回,不为空,判断信号是否被阻塞,若被阻塞,则将其压入阻塞信号栈中。若没有被阻塞,则将其拿出等待进一步处理。
- 设置Trapframe,将原先的Trapframe存入栈中,并修改为使用用户异常栈处理信号处理函数,这一操作与do_tlb_mod中的操作一致,目的在于回到用户态但不改变原先的用户上下文。
1 | struct Trapframe tmp_tf = *tf; |
- 判断该信号是否注册过用户态的处理函数,如果没有,则执行信号的默认处理动作。
- 设置寄存器,向其中传入用户态封装函数需要使用的参数,以便返回用户态时执行用户态封装函数。
- 将
cp0_epc
设置为定义的用户态封装函数入口,便于返回用户态时执行该函数。
1 | if (curenv->env_user_signal_entry) { |
- 设置一个用户态封装函数
sighandler
,该函数首先执行用户设定的处理函数,再进行系统调用syscall_set_trapframe
,将保存的原用户上下文传回,并且回到内核进行下一个信号的处理。值得注意的是这个封装函数应当是无返回的,类似于cow_entry
。
1 | void __attribute__((noreturn)) sighandler(struct Trapframe *tf, int signum, void (*sa_handler)(int)) |
5.一些细节
5.1 阻塞信号的处理
在信号处理部分,我将被阻塞的信号放置于信号阻塞栈中。那这些阻塞信号应当在什么时候被拿出处理呢?我的设计是将其放于sys_sigprocmask
中,也就是在进程掩码发生变化时,我将所有被阻塞信号放回等待栈中,交给do_signal
进一步判断,考虑到每次进程掩码改变可能只改变一位也就是一个信号可以变得不被阻塞,do_signal
将遍历到的被阻塞信号再放回阻塞信号队列,将没有被阻塞的信号取出。
1 | int sys_sigprocmask(int how, const struct sigset_t *set, struct sigset_t *oldset) |
5.2 初始化与fork
在进程被创建时,它的等待栈和阻塞栈指针应当被初始化为0(当然也可以是-1)。我将这个过程放置在env_create
中。
在fork一个子进程时,子进程继承父进程注册的函数也即信号处理函数,等待栈和阻塞信号栈全部清空。相关实现位于sys_exofork
中。
1 | int sys_exofork(void) { |
5.3 SIGKILL处理
9号信号即SIGKILL不可被阻塞,因此在do_signal的信号阻塞判断前首先判断信号是否为阻塞信号,如果是,则直接结束程序运行。
5.4 空指针处理
在程序中如果访问了一个空指针,在原实现中会跳到passive_alloc
panic,经过了解,访问空指针实际上应当触发第11号信号,因此在_do_tlb_refill
中添加判断,如果va
小于UTEMP
则向当前进程发送11号信号。
5.5 写时复制处理
需要处理可能出现写时复制的地址。由于很多数据的修改都位于内核态,而在这之前并未写时复制,则会出现异常。应当在使用可能出现这种异常的位置提前触发写时复制。只需使用memset
即可处理。在本次任务中,只需在写oldset
或oldact
前memset
该地址即可。
5.6 include.mk的修改
由于我在内核与用户态都添加了名为signal.c
的文件,因此需要更改include.mk文件使其被正确链接。
三、实验难点
我认为本实验的难点应当是理解信号处理的内核态->用户态->内核态->…这一机制。具体分析与实现思路在上文中有涉及,在此就不赘述了。
四、心得体会
Lab4 challenge是我认为比较考验对MOS理解的一个挑战性任务。在这次任务中,我加深了对用户态、内核态转变的理解,学会利用系统调用以及用户自定义函数等实现信号机制,也对操作系统的信号有更进一步了解。本次实验指导书内容较少,更培养我的独立思考能力,收获很大。