异常控制流(ECF)2

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
2
#include <unistd.h>
pid_t getpgrp(void);

默认的,一个子进程和他的父进程同属于一个进程组。一个进程可以通过使用setpgid 函数来改变自己或者其他进程的进程组

1
2
#inlcude <unistd.h>
int setpgid(pid_t pid,pid_t pgid);//若成功则返回0,否则返回-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
2
3
4
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid,int sig)

这里有三种不同的发送情况

  • 如果 $pid>0$ ,那么kill函数就会发送信号号码 sig给进程 pid.
  • 如果 $pid=0$ ,那么kill 发送信号sig调用进程所在进程组中的每一个进程,包括调用进程自己
  • 如果 $pid<0$ ,那么kill 发送信号sig 给进程组 $|pid|$ pid的绝对值中的每一个进程。

下面是一个例子

首先我们创建N个子进程,每一个子进程都是无限循环。然后在对每一个子进程发送SIGINT信号,终止进程运行。

接下来我们对每一个子进程进行回收,看到底是哪个进程退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void fork12()
{
pid_t pid[N];
int i;
int child_status;
for (i = 0; i < N; i++)
if ((pid[i] = fork()) == 0) {
/* Child: Infinite Loop */
while(1)
;
}

for (i = 0; i < N; i++) {
printf("Killing process %d\n", pid[i]);
kill(pid[i], SIGINT);
}
for (i = 0; i < N; i++) {
pid_t wpid = wait(&child_status);
if (WIFEXITED(child_status))
printf("Child %d terminated with exit status %d\n",wpid, WEXITSTATUS(child_status));
else
printf("Child %d terminated abnormally\n", wpid);
}
}

用alarm函数发送信号

进程可以通过调用alarm函数向它自己发送SIGALRM信号。

1
2
3
#include <unistd.h>
unsigned int alarm(unsigned int secs);
//返回: 前一次alarm剩余的秒数,若以前没有设定alarm,则为0

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函数修改和信号相关联的默认行为。唯一的例外是 SIGSTOPSIGKILL,他们的默认行为是不能被修改的

1
2
3
4
5
#include <signal.h>
typedef void(*sighandler_t)(int);

sighandler_t signal(int signum,sighandler_t handler);
//返回:若成功则为指向前次处理程序的指针,若出错则为 SIG_ERR(不设置errno)

通过这个函数我们可以用三种方法之一来改变与信号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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void sigint_handler(int sig) /* SIGINT handler */
{
printf("So you think you can stop the bomb with ctrl-c, do you?\n");
sleep(2);
printf("Well...");
fflush(stdout);
sleep(1);
printf("OK. :-)\n");
exit(0);
}
int main(int argc, char** argv)
{
/* Install the SIGINT handler */
if (signal(SIGINT, sigint_handler) == SIG_ERR)
unix_error("signal error");
/* Wait for the receipt of a signal */
pause();

return 0;
}

信号处理程序可以被其他信号处理程序中断掉,这也被称为 Nested Signal Handlers 如下图所示。

在这个例子当中,主程序捕获到信号s,该信号会中断主程序,并将控制转移到处理程序S。S在运行的时候,程序捕获到信号t($t\neq s$),该信号会中断S,控制转移到处理程序T。 当T返回的时候,S从它被中断地地方继续执行,最后,S返回,控制传送回主程序,主程序从它被中断的地方继续执行

阻塞和解除阻塞信号

隐式阻塞机制

内核会默认阻塞任何当前处理程序正在处理信号类型的待处理信号。

比如说在这张图中,假设程序捕捉到了信号s,当前正在运行处理程序S。如果发送给该进程另外一个信号s,那么直到处理程序S返回,s会变成待处理而没有被接受。

显式阻塞机制

我们可以使用 sigprocmask函数和它的辅助函数,明确的阻塞和解除阻塞选定的信号。

1
2
3
4
5
6
7
8
9
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);//把某一个特定的为加上去
int sigdelset(sigset_t *set, int signum);//成功则返回0,否则返回-1

int sigismember(const sigset_t *set,int signum);
//若signum是set的成员则为1,如果不是则为0,若出错则为-1

sigprocmask 函数改变的是 blocked 位向量。具体的行为依赖于how的值:how有三个值

  • SIG_BLOCK: 函数把set中的信号添加到blocked当中($blocked = blocked~|set$)
  • SIG_UNBLOCK: 函数从block中删除set中的信号 ($blocked=blocked$&~$set$)
  • SIG_SETMASK: block=set
1
2
3
4
5
6
7
8
sigset_t mask, prev_mask;//prev是保留原来的mask
Sigemptyset(&mask);//创建一个空的mask
Sigaddset(femask, SIGINT);//将SIGINT位设置为1
/* Block SIGINT and and save previous blocked set */
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);// 注册这个mask,将pre_mask保存下来,把新的信号添加到blocked当中
// Code region that will not be interrupted by SIGINT
/* Restore previous blocked set, unblocking SIGINT */
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);//结束以后我们要回归原样
-------------本文结束,感谢您的阅读-------------