Shells
Signals
在 异常控制流(ECF)1 中,我们介绍了Interrupts,Traps,Faults和Aborts,这是硬件和软件合作以提供基本的底层异常机制。接下来我们要研究一种更高层的软件形式异常,称为 Linux信号,它允许进程和内核 中断其他进程。
一个信号(signal)就是一条小消息,它通知进程系统中发生了一个某种类型的事件。如下图
如果是低层的硬件异常,是由内核异常处理程序处理的,一般对用户不可见。但信号不同,信号提供了一种机制来通知用户进程发生了这 些异常。比如,一个进程试图除以0,那么内核就会发送给它一个SIGFPE信号(号码8);如果一个进程执行一条非法指令,那么内核就会发送给它一个SIGSEGV信号(号码11). Kernel是通过 kill这一个系统调用来发送指令的,发送的指令可以是 SIGKILL也可以是 其他指令
此外,一个进程可以通过向另一个进程发送一个 SIGKILL 信号(号码9)强制终止它。当一个子进程终止或者停止的时候,内核会发送一个SIGCHILD的信号来给父进程
信号术语
下面我们来简单看一下发送信号的过程:是由两个不同步骤组成的:
- 发送信号。内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号可以有两个原因 :
- 内核检测到一个系统时间,比如除零错误或者子进程终止
- 一个进程调用了kill函数,显示地要求内核发送一个信号给目的进程。一个进程也可以发送信号给它自己
一个发出的,而没有被接手的信号叫做待处理信号(pending signal),在任何时刻,一种类型最多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,只是被简单的丢弃。我们可以理解为是一个“状态”而非一个队列。
一个进程可以选择性的阻塞接收某种信号,当一种信号被阻塞的时候,它仍然可以被发送但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
- 接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应的时候,他就接受了信号,进程可以忽略这个信号,终止或者捕获这个信号。如下图所示
一个待处理的信号只能被接收一次。内核为每个进程在pending位向量中维护待处理信号的集合。而在blocked向量中维护被阻塞的信号集合。只要传送了一个类型为k的信号,内核就会设置pending中的第k位,而只要接收了一个类型为k的信号,内核就会清除pending中的第k位
发送信号
Unix系统提供了大量向进程发送信号的机制,所有这些机制都是基于进程组这个概念的
进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp函数返回当前进程的进程组ID
1 |
|
默认的,一个子进程和他的父进程同属于一个进程组。一个进程可以通过使用setpgid
函数来改变自己或者其他进程的进程组
1 |
|
setpgid
函数将进程pid
组改为pgid
。如果pid
是0,那么就使用当前进程的PID。如果pgid
为0,那么就用pid
指定的进程的PID作为进程组ID。例如,如果进程15213是调用进程,那么 setpgid(0,0);
会创建一个新的进程组,其进程组ID是15213,并且把进程15213加入到这个新的进程组中
用 /bin/kill程序发送信号
/bin/kill 程序可以向另外的进程发送任意的信号。
例如,命令 linux>/bin/kill -9 15213
发送信号9(SIGKILL)给进程15213.
一个为负的PID会导致i信号被发送到进程组PID中的每个进程
例如:linux>/bin/kill -9 -15213
会发送一个SIGKILL信号给进程组15213中的每个进程。
从键盘发送信号
下图展示了一个有一个前台作业和两个后台作业的shell,前台作业中的父进程PID为20,进程组也为20.父进程创建两个子进程,每个也都是进程组20的成员。
在键盘上输入 Ctrl+C
会导致内核发送一个 SIGINT 信号到前台进程组中的每一个进程,默认情况下,结果是终止前台作业。
输入Ctrl+Z
会发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止(挂起suspend)前台作业。
用kill 函数发送信号
进程可以通过调用kill 函数发送信号给其他进程(包括他们自己)
1 |
|
这里有三种不同的发送情况
- 如果 $pid>0$ ,那么
kill
函数就会发送信号号码sig
给进程pid
. - 如果 $pid=0$ ,那么
kill
发送信号sig
给调用进程所在进程组中的每一个进程,包括调用进程自己 - 如果 $pid<0$ ,那么
kill
发送信号sig
给进程组 $|pid|$pid
的绝对值中的每一个进程。
下面是一个例子
首先我们创建N个子进程,每一个子进程都是无限循环。然后在对每一个子进程发送SIGINT信号,终止进程运行。
接下来我们对每一个子进程进行回收,看到底是哪个进程退出。
1 | void fork12() |
用alarm函数发送信号
进程可以通过调用alarm函数向它自己发送SIGALRM信号。
1 |
|
alarm函数安排kernel在secs
秒之后发送一个 SIGALRM信号给调用进程。
接收信号
当内核把进程p从内核模式切换到用户模式的时候(例如上下文切换) 如下图:
它会检查进程p的未被阻塞的待处理信号的集合,这个集合就是 pending&~blocked
,因为pending的是合法的信号,blocked维护的是被阻塞的信号
如果集合为空,意味着要么信号没到,要么信号被屏蔽了 ,那么kernel将控制传递到p的逻辑控制流中的下一条指令($I_{next}$)
如果集合非空,那么内核选择集合中的某个信号k(通常是最小的k),然后强制p接收信号k,并做出相应的触发。接着会对pnb
中所有的信号做此操作。做完之后再把控制权返回给 ($I_{next}$)
Installing Signal Handlers
之前我们给出每个信号类型相关联的默认行为。例如,收到SIGKILL的默认行为是终止接收进程。但是其实我们可以通过使用signal
函数修改和信号相关联的默认行为。唯一的例外是 SIGSTOP
和 SIGKILL
,他们的默认行为是不能被修改的
1 |
|
通过这个函数我们可以用三种方法之一来改变与信号signum相关联的行为
- 如果 handler参数 是 SIG_IGN ,那么忽略类型为 signum 的信号
- 如果 handler参数 是SIG_DFL ,那么类型为 signum的信号行为恢复为默认行为
- 否则,handler就是用户自定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为 signum的信号,就会调用这个程序。
- 通过把处理程序的地址传递到 signal函数从而改变默认行为叫做 设置信号处理程序(installing the handler)。
- 调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号
下面是一个 Signal Handling Example
在main函数当中,我们首先 handler
,就是将 SIGINT这个信号和我自己写的 sigint_handler
函数关联,然后手动终止掉。这样当我们按下 Ctrl+C
之后,就不只是简单的退出而已,而是会说一段废话……
所以现在我们知道一些流氓软件的套路了,它就是不停地弹出弹框不让人退出程序。
1 | void sigint_handler(int sig) /* SIGINT handler */ |
信号处理程序可以被其他信号处理程序中断掉,这也被称为 Nested Signal Handlers 如下图所示。
在这个例子当中,主程序捕获到信号s,该信号会中断主程序,并将控制转移到处理程序S。S在运行的时候,程序捕获到信号t($t\neq s$),该信号会中断S,控制转移到处理程序T。 当T返回的时候,S从它被中断地地方继续执行,最后,S返回,控制传送回主程序,主程序从它被中断的地方继续执行
阻塞和解除阻塞信号
隐式阻塞机制
内核会默认阻塞任何当前处理程序正在处理信号类型的待处理信号。
比如说在这张图中,假设程序捕捉到了信号s,当前正在运行处理程序S。如果发送给该进程另外一个信号s,那么直到处理程序S返回,s会变成待处理而没有被接受。
显式阻塞机制
我们可以使用 sigprocmask
函数和它的辅助函数,明确的阻塞和解除阻塞选定的信号。
1 |
|
sigprocmask
函数改变的是 blocked 位向量。具体的行为依赖于how的值:how有三个值
- SIG_BLOCK: 函数把set中的信号添加到blocked当中($blocked = blocked~|set$)
- SIG_UNBLOCK: 函数从block中删除set中的信号 ($blocked=blocked$&~$set$)
- SIG_SETMASK: block=set
1 | sigset_t mask, prev_mask;//prev是保留原来的mask |