一、什么是信号
首先,要把信号和信号量分别开,信号是用来通知进程发生了一些事件,而信号量是用来解决同步与互斥问题的。可以通过kill -l查询信号和对应的编号:

比较常见的是前31个,其中,2号SIGINT就是Ctrl+C发出的信号,9号SIGKILL用来强制杀死进程(不可被屏蔽),15号SIGTERM是kill的默认信号,17号SIGCHILD是子进程退出以后向父进程发出的信号。这四个相对更常见,而9号和19号SIGSTOP都是不可被屏蔽的信号,什么是信号屏蔽后面再解释。
通过kill -编号 <pid> 就可以在命令行向指定的进程发送对应编号的信号,而进程在接收到信号后,如果处于接收信号的时机且信号没有被屏蔽,就会去找对应信号的处理办法,从而处理对应的事件。
二、信号的产生和处理
2.1 信号的产生方式
- 通过命令行kill的方式产生信号,就是命令产生
- 通过组合键例如Ctrl+C的方式产生,就是终端产生
- 通过程序内部调用函数产生信号,就是程序内产生
- 当程序内发生除0或野指针等问题时产生的信号,就是硬件产生信号
首先是第一种方式,使用命令行,是相对比较直观的方式,但是需要获取进程的PID,可以直接让进程打印自己的PID或者ps然后grep找对对应的进程然后获得进程的PID。
然后是组合键,最常用的就是Ctrl+C,可以产生2号信号退出当前前台正在运行的进程。为什么可以通过组合键向前台进程发送信号呢?这是由于,键盘输入会向CPU申请硬件中断,CPU会将键盘输入读入内核,然后识别出这是组合键,根据组合键向前台进程发送信号。此外,除了Ctrl+C还有Ctrl+\(SIGQUIT)、Ctrl+Z(SIGTSTP)。
再然后就是调用函数产生信号:
cpp
#include <signal.h>
#include <stdlib.h>
// 常见信号产生函数
kill(pid, sig); // 向指定进程发送信号
raise(sig); // 向自身发送信号
abort(); // 发送 SIGABRT
alarm(seconds); // 设置定时器,超时发送SIGALRM
其中,kill函数可以用来模拟命令行的kill指令,也就是通过pid和sig指定要发送的信号和要发送的进程。raise则是向自己发送信号,类似于kill(getpid(),sig),而abort就是向自己发送SIGABRT信号,alarm则是设置一个倒计时,时间一到就给自己发送SIGALRM信号。
最后是除0或野指针错误等,在内核中会记录进程当前的状态,例如,如果发生了除0错误,就会在状态寄存器上记录当前的错误,CPU发现出错后,就会向进程发送信号,如果我们不做处理并且不退出进程,CPU就会一直发出异常信号。
2.2.1 信号的保存
进程并不是有信号就会马上处理的,例如在CPU保存上下文的时候,产生信号并不会让CPU马上处理这个信号。因此,信号需要保存下来,也就是未决信号集。未决信号集通常用一个bit表示一个信号,有点类似于位图,当接收到信号后,未决信号集就会将对应信号的比特位改为1,表示收到了该信号。如果,信号没有被屏蔽,等CPU可以处理进程信号的时候,就会找到对应的处理办法。
2.2.2 信号的阻塞
已经知道,收到的信号是通过位图的形式保存,那么很容易就能想到,信号的阻塞也可以使用同样的办法实现,只需要在要阻塞的信号的比特位改为1,就可以知道这里的信号会被阻塞。
信号为什么要阻塞呢?因为,在某些情况,例如进程进入临界区,这个时候就希望一些信号不要打断进程的运行,那么将这个信号阻塞,直到进程退出临界区再取消阻塞信号,然后再处理信号。
cpp
#include <signal.h>
int sigemptyset(sigset_t *set); // 清空信号集
int sigfillset(sigset_t *set); // 将所有信号加入信号集
int sigaddset(sigset_t *set, int signum); // 添加指定信号
int sigdelset(sigset_t *set, int signum); // 删除指定信号
int sigismember(const sigset_t *set, int signum); // 测试信号是否在信号集中
sigset_t这个类型的数据,就是上面提到的类似于位图的数据结构,通过上面这些函数,就可以修改sigset_t特定位置的值,从而方便后面调用函数阻塞特定的信号。
cpp
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
在我们完成对set的修改后,就可以调用sigprocmask函数,实现对阻塞信号集的修改。其中,how有三个参数:
cpp
SIG_BLOCK // 添加阻塞:将set中的信号添加到当前阻塞集合
SIG_UNBLOCK // 移除阻塞:从当前阻塞集合中移除set中的信号
SIG_SETMASK // 完全替换:用set替换当前阻塞集合
然后第一个set,就是我们修改好的set,而第二个oldset用来保存原来的阻塞信号集。
另外,我们还可以调用sigpending函数获取当前未决信号集,结合sigismember我们就可以在屏幕上直观的打出当前未决信号集,从而测试阻塞信号集的作用:
cpp
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
int main(void){
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTSTP);
sigset_t oldset;
sigprocmask(SIG_BLOCK, &set, &oldset);
while(true){
sigset_t pending;
sigpending(&pending);
for(int i = 31; i > 0; i--){
if(sigismember(&pending, i)){
printf("1");
}
else {
printf("0");
}
}
printf("\n");
sleep(1);
}
return 0;
}

我们可以看到,当使用^C以后,进程并没有终止,pending中的第二位变成了1,再输入^Z以后,进程也没有停止,第20位变为了1,最后使用^\才使进程退出。这就是因为,我们在前面把SIGINT和SIGTSTP加入了阻塞集。所以9号和19号信号不能被阻塞的意思就是,即使我们将这两个信号加入阻塞集,进程依然会接收到这两个信号。
为什么需要这两个信号?试想,如果一个进程将所有信号全部阻塞,那么如果这个进程是一个死循环,是否还有停下来的办法?如果没有这两个后门,就没有停止这个进程的方法了。
2.3 信号的处理
当进程接收到信号以后,通过有三种处理方式:SIG_DLF、SIG_IGN、用户自定义,也就是默认,忽略以及自定义。
我们可以借助signal函数来实现对信号处理方法的修改,例如:
cpp
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
void handler(int signo){
printf("接收到一个信号:%d\n", signo);
}
int main(){
signal(SIGINT, handler);
signal(SIGTSTP, SIG_IGN);
signal(SIGQUIT, SIG_DFL);
while(true){
pause();
}
return 0;
}

我们可以看到使用^C会打印信息,而^Z则没有反应,使用^\就退出了程序。此外还可以使用sigaction函数达成同样的效果。对于9号和19号信号出于同样的原因,同样不能修改这两个信号的处理方式。
三、处理信号的时机
我们已经知道,进程并不是随时都会处理信号,那么到底什么时候会处理信号呢?
首先,我们需要知道什么是内核态和用户态。由于一些操作可能会伤害到操作系统,因此,这些操作的权限不能直接给到用户,因此就有了内核态和用户态的区分,但是又为了让用户可以安全使用这部分操作,就有了系统提供的接口,也就是系统调用。因此,当用户使用系统调用时,CPU就会从用户态进入到内核态,实现只有在内核态才能完成的操作。
信号的处理时机,就是在CPU从内核态转到用户态的时候,这个时候CPU会检查是否有信号需要处理。而什么时候是CPU从内核态转到用户态呢?
- 系统调用返回时,必然是从内核态回到用户态
- 中断返回时,产生中断必然会进入内核态处理,返回时就是内核态到用户态
- 进程被重新调度时,调度进程一定是在内核态,命令转回到进程后需要回到用户态
总结起来,就是内核态到用户态时会检查是否有信号需要处理。
信号的使用非常灵活,相比于signal函数,还是更推荐使用sigaction函数修改信号的处理方式,而sigaction的使用这里不做更多介绍,可以在有需要的时候再了解。