目录
三、保存信号
当前阶段:

✍️信号相关的概念补充
- 实际执行信号的处理动作,我们称之为信号递达(Delivery)。
- 信号从产生到递达之间的状态,我们称之为信号未决(Pending)。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 需要注意的是,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。
✍️在内核中的表示
示意图如下:

- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再接触阻塞。
- SIGQUIT信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。
敲黑板:
1、在block位图和pending位图中,比特位的位置代表某一个信号,比特位的内容分别是代表该信号是否被阻塞(block),代表是否收到该信号(pending)。
2、handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
3、block、pending和handler这三张表的每一个位置是一一对应的。
✍️sigset_t
我们在实际存储的时候,未决和阻塞标志可以使用同一个数据类型来存储,那就是sigset_t,我们打开我们的云服务器中的/usr/include/x86_64-linux-gnu/bits/types/sigset_t.h文件就可以看到:
cpp
#ifndef __sigset_t_defined
#define __sigset_t_defined 1
#include <bits/types/__sigset_t.h>
/* A set of signals to be blocked, unblocked, or waited for. */
typedef __sigset_t sigset_t;
#endif
这里我们发现这个类型被typedef成了__sigset_t,这个时候我们打开/usr/include/x86_64-linux-gnu/bits/types/__sigset_t.h文件,就可以看到:
cpp
#ifndef ____sigset_t_defined
#define ____sigset_t_defined
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
#endif
我们称这个sigset_t为信号集,这个类型可以表示每一个信号的有效和无效的状态。
阻塞信号集也叫当前进程的信号屏蔽字(Signal Mask)。
✍️信号集相关操作函数
我们的sigset_t类型表示的是每个信号不同标志的有效和无效,我们这里并不需要关心这些bit是什么存储的,这个依赖于具体的操作系统,我们更加关心的是使用信号集,下面是几个常用的操作函数:
cpp
/* Clear all signals from SET. */
int sigemptyset (sigset_t *__set);
/* Set all signals in SET. */
int sigfillset (sigset_t *__set);
/* Add SIGNO to SET. */
int sigaddset (sigset_t *__set, int __signo);
/* Remove SIGNO from SET. */
int sigdelset (sigset_t *__set, int __signo);
/* Return 1 if SIGNO is in SET, 0 if not. */
int sigismember (const sigset_t *__set, int __signo);
说明一下:
- sigemptyset函数:如其名,用来初始化所指向的信号集,使得其中的所有信号的对应bit清零,表示该信号集不包含任何有效的信号。
- sigfillset函数:初始化set所指向的信号集,使得其中的所有信号的bit都置位。
- sigaddset函数:在set所指向的信号集中添加某个信号。
- sigdelset函数:在set所指向的信号集中删除某个信号。
- 这上面几个函数都是成功返回0,出错返回-1。
- sigismember函数:判断在set所指向的信号集中是否包含了某个信号,若包含就返回1,不包含就返回0,调用失败就返回-1。
敲黑板:
这里要注意一点,就是在使用sigset_t类型之前一定要调用sigemptyset或是sigfillset来进行初始化,使得我们的信号集处于一种确定的状态。
我们可以可以写个代码见一见:
cpp
#include <stdio.h>
#include <signal.h>
#include <asm-generic/signal.h>
int main() {
sigset_t s;
sigemptyset(&s);
// sigfillset(&s);
sigaddset(&s, SIGINT);
sigdelset(&s, SIGINT);
sigismember(&s, SIGINT);
return 0;
}
敲黑板:
这个代码里面的s和我们平时定义的变量是一样的,都是在用户空间中定义的变量,我们后面的一系列的操作实际上都是在对用户空间的变量进行修改,不会影响到进程的任何行为,所以我们还需要通过系统调用,将变量s写透进操作系统中。
✍️sigprocmask
这个函数就是用来读取或是更改进程的信号屏蔽字的,函数原型如下:
cpp
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
set,如果是非空的,则更改进程的信号屏蔽字,参数how用来指示如何更改。
oldset,如果是非空的,则读取进程当前的信号屏蔽字通过oldset参数传出。
如果这两个参数都是非空指针,那么就将原来的信号屏蔽字备份到oldset中,再来通过how的选项来更改信号屏蔽字。
选项 | 含义 | 说明 |
---|---|---|
SIG_BLOCK |
将 set 中的信号 加入 屏蔽集 |
就是"阻塞这些信号" |
SIG_UNBLOCK |
将 set 中的信号 从屏蔽集移除 |
就是"解除阻塞这些信号" |
SIG_SETMASK |
用 set 替换整个屏蔽集 |
旧的屏蔽设置会被完全替换掉 |
返回值说明:
sigpromask函数调用成功返回0,出错返回-1。
✍️sigpending
sigpenging函数可以用来读取进程的未决信号集,函数的原型如下:
cpp
int sigpending(sigset_t *set);
参数说明:
这个函数就是读取当前进程的未决信号集,并通过set参数传出,调用成功返回0,出错返回-1。
下面我们来写个代码来见一见:
我们的思路是:先用上述的函数将2号信号进行阻塞,再使用kill命令或是组合按键(ctrl + c)发送2号信号,这会导致2号信号一直处在被阻塞的状态,也就是处在了pending状态,使用sigpending函数获取当前进程的pending信号集进行验证。
代码如下:
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdbool.h>
void printPending(sigset_t *pending) {
for(int i = 1; i <= 31; i++) {
if(sigismember(pending, i)) {
printf("1 ");
} else {
printf("0 ");
}
}
printf("\n");
}
int main() {
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oldset);
sigset_t pending;
sigemptyset(&pending);
while(true) {
sigpending(&pending);
printPending(&pending);
sleep(1);
}
return 0;
}
我们可以看到,程序在收到了kill命令向进程发送的2号信号的时候,由于2号信号是阻塞的,所以2号信号一直处在未决的状态,我们可以看到pending标的第二个数字一直是1。

我们这里改进了一下,为了看到我们的2号信号递达之后的pending表的变化,我们设置了一个解除2号信号阻塞状态的动作,解除之后我们的2号信号就会立即被递达。因为2号信号的默认动作是终止进程,所以我们收到2号信号之后要对其进行捕捉,让它执行我们自定义的动作。
代码如下:
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdbool.h>
void printPending(sigset_t *pending) {
for(int i = 1; i <= 31; i++) {
if(sigismember(pending, i)) {
printf("1 ");
} else {
printf("0 ");
}
}
printf("\n");
}
void handler(int signal) {
printf("我是%d, 我获得了一个信号%d\n", getpid(), signal);
}
int main() {
signal(2, handler);
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oldset);
sigset_t pending;
sigemptyset(&pending);
int count = 0;
while(true) {
sigpending(&pending);
printPending(&pending);
sleep(1);
count++;
if(count == 20) {
sigprocmask(SIG_SETMASK, &oldset, NULL);
printf("这里开始恢复\n");
}
}
return 0;
}
这里我们可以看到,进程在收到2号信号之后,该信号在一段时间内处于未决状态,当解除了2号信号就会立即递达,执行我们给出的自定义动作,而此时的pending表也会变回全是0的状态。

敲黑板:
我们这里发现在我们执行sigprocmask函数之后,我们是先执行自定义动作再是打印"这里开始恢复"的,这是因为我们的信号处理函数会打断当前正在执行的函数,而去处理信号的动作,再返回来执行后续操作。
四、捕捉信号
当前阶段:
✍️内核空间和用户空间
我们知道每一个进程都是有自己的进程地址空间的,进程等待地址空间是由内核空间和用户空间组成的:
用户写的代码和数据是位于我们的用户空间的,通过用户级页表和物理内存之间建立起映射关系。
内核空间就是存储的实际操作系统的代码和数据,通过内核级页表和物理内存之间建立映射关系。
内核页表是一个全局的页表,它是用来维护操作系统的代码和进程之间关系的,所以在每个进程的地址空间中,用户空间是属于当前的进程的,每一个进程的代码和数据都是不同的,但是内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是同样的内容。

分析完之后我们应该怎么理解进程切换呢?
1、我们先要保证操作系统的代码的执行,于是我们先要在当前进程的进程地址空间中的内核空间中找到操作系统的代码和数据。
2、我们要先执行操作系统的代码和数据,将当前进程的代码和数据剥离下来,然后换上我们要切换的进程的代码和数据。
敲黑板:
我们访问用户空间必须是出于用户态的,我们访问内核空间的时候必须是要处于内核态的。
✍️用户态和内核态
其实理解用户态和内核态主要是要理解他们之间是怎么进行切换的。
我们先来分开谈一谈概念:
内核态:通常用来执行操作系统的代码,是一种高权限的状态。
用户态:是一种用来执行普通用户的代码的状态,是一种受到监管的较低权限状态。
就比如我们在处理信号的时候,我们往往是说在合适的时候处理,这个合适的时候就是从高权限的内核态切换回低权限的用户态的时候。
那么用户态和内核态之间是怎么进行交换的呢?
其实我们需要知道有哪些情况是从用户态切换到内核态,哪些是内核态切换回用户态的:
前者:
需要使用系统调用的时候。
当前进程的时间片到了的时候导致进程切换。
产生了异常、终端和陷阱等。
后者与前者一一对应:
系统调用返回的时候。
进程切换完毕。
异常、中断和陷阱等处理完。
说明一下:
这里的用户态切换到内核态我们也可以说是陷入内核,其实我们的每一次陷入内核,本质上就是因为我们要执行操作系统的代码,比如系统调用的时候,我们就要进行陷入内核的操作了。
✍️内核对信号的捕捉
我们在执行一些流程的时候,很可能会因为一些情况陷入了内核,当我们的内核处理完毕之后准备返回用户态时,就需要进行信号的未决(pending)检查。
在检查pending位图的时候,如果发现了有未决的信号,而且这个信号没有被阻塞,就对这个信号进行处理。如果这个信号的处理动作是默认或是忽略,则执行完该信号之后就要清除其对应的pending标志位,如果已经没有了新的信号要进行递达,就直接返回回用户态即可,在主流程中上一次终端的地方(也就是陷入内核的地方)继续向后执行即可。流程简图如下:

但是呢如果这里的信号是自定义捕捉了的,也就是说这个动作是用户提供的,那么我们在处理这个信号的时候就要先返回回用户态执行对应的自定义处理动作了,执行完了之后再通过某个特殊的系统调用(sigreturn)再次陷入内核中清除对应的、pending标志位,如果是已经没有了新的信号要递达了,那么就直接返回用户态,继续执行在上一次中断的地方之后的代码了。流程简图如下:
敲黑板:
这里需要注意的是,我们的sighandler和main函数是使用的不同的堆栈空间的,它们之间不存在什么调用和被调用的关系的,是相对独立的进程。
怎么记忆
其实这个过程可以类比到我们数学中的无穷符号------"♾️",也就是下面这个图了:

✍️sigaction函数
我们的捕捉动作出了我们上面用的,signal函数之外,我们还可以使用sigaction函数对信号进行捕捉,函数的原型如下:
cpp
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数说明:
第一个参数signum代表的是指定的信号的编号。
第二个参数act指针非空时,根据act修改信号的处理动作。
第三个参数oldact非空时,则通过oldact传入该信号的原来的动作。
这里面的后两个参数的类型都是segaction,该结构体的定义如下:
cpp
struct sigaction {
void (*sa_handler)(int); // 信号处理函数指针(简单处理)
sigset_t sa_mask; // 信号掩码,指定在处理该信号时要阻塞的信号
int sa_flags; // 控制信号处理行为的标志
void (*sa_restorer)(void); // 过时字段,现代系统通常不使用
};
第一个成员:
如果是赋值为常数SIG_IGN传入到了sigaction函数中,表示忽略信号。
如果是赋值为常数SIG_DFL传入到了sigaction函数中,表示执行系统的默认动作。
如果是赋值为一个函数指针,表示的是自定函数捕捉信号,或是说向内核注册了一个信号处理函数。
第二个成员:
这里我们首先需要明确的是,当我们某一个信号的处理函数被调用的时候,内核会自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回的时候就又会恢复到原来的样子,这是为了保障我们在处理一个信号的时候,如果这个信号再次产生了,我们就会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,我们还想着屏蔽一些其他的信号,则用sa_mask字段说明这些需要额外屏蔽的信号,同样的当我们的信号处理函数返回的时候,自动恢复我们之前的信号屏蔽字。
第三个成员:
我们这里不考虑那么多了,直接设置为0就行了。
第四个成员:
不使用。
五、可重入函数
我们先来导入一个之前我们学习过的链表的例子,我们在主函数中调用insert函数来向链表中插入我们的节点node1,某一个信号处理函数也调用了insset函数插入节点node2,表面上看是没什么问题。我们可以来分析一下:
下面我们来分析一下这个过程:
1、首先,我们的main函数中调用了insert函数把node1插入到链表中,但是我们的插入操作是分为两步的,刚做完了第一步的时候,我们假设这个时候我们因为硬件中断使得进程切换到了内核中,再次回到用户态之前要检查有无信号要处理,于是我们就要切换到sighandler函数,示意图:

2、我们的sighandler函数也是调用的insert函数,将节点node2插入到链表里面,我们完成第一步的示意图:

3、当节点node2插入操作做完了之后我们从sighandler返回内核态,示意图:
4、我们回到用户态的时候要从main函数调用的insert函数中中断的位置起开始继续执行,也就是完成我们插入节点的第二步,示意图如下:
最终我们发现,main函数和sighandler函数先后向链表中插入了两个节点,但是只有一个节点是有效的,我们的node2节点就失效了,这会造成内存泄露的问题。
我们把像insert函数一样被不同的控制流调用,造成在第一次调用的时候还没有返回就再一次地进入了该函数的现象,我们称之为是重入。
我们把这种可能因为重入而造成错乱的函数称之为不可重入函数,反之我们一个函数只是访问自己的局部变量或是参数的函数我们称之为可重入函数。
敲黑板:
符合下面条件之一的函数就是不可重入函数:
调用了malloc和free,因为malloc也是使用全局链表来管理堆。
调用了标准IO库的函数,因为这个库中的很多实现都是以不可重入的形式来使用全局的数据结构的。
六、volatile
这是一个C语言的关键字,是用来保存内存的可见性的。
我们可以写个代码来来见一见现象:
代码逻辑:我们对2号信号进行捕捉,当我们该进程收到了2号信号的时候会把全局变量flag从0变成1,也就是说在进程收到了2号信号之前,这个进程会一直处在死循环的这么一个状态,直到flag变化了才退出。
代码如下:
cpp
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int flag = 0;
void handler(int signal)
{
printf("我是%d, 我获得了一个信号%d\n", getpid(), signal);
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("Quit!\n");
return 0;
}
执行结果如图:

这个代码的结果好像都是在我们的预料之中,但是实际上却不是这样的,我们代码中有两个执行流,一个是main函数,一个是handler函数,在while循环里面是在main函数中的,在编译器编译的时候我们只能检测到main函数中的flag变量。这个时候编译器检测到main函数中没有对flag变量进行修改操作,在编译器优化到了级别较高的时候,我们就有可能设置进寄存器里面了,这个时候的main函数在检测flag的时候只检测寄存器里面的值,而handler只是将内存中的flag修改了,这个时候就算是进程收到了2号信号也是不会打断while循环的。
示意图:

我们在编译代码的时候可以带上-O3(大写的字母O,我们在写算法题使用C++,也会有O2优化,也叫氧气优化,这里的O3也叫臭氧优化,比较好记忆哈哈)选项来使得编译器处在级别最高的时候,这个时候就算是进程发送2号信号,进程也是不会终止的:

面对这个情况,我们就可以使用我们提到的volatile关键字来对flag变量来进行修饰了,告诉编译器,对flag变量的任何操作都是必须在内存中进行。
代码如下:
cpp
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
volatile int flag = 0;
void handler(int signal)
{
printf("我是%d, 我获得了一个信号%d\n", getpid(), signal);
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("Quit!\n");
return 0;
}
这个时候我们可以观察到我们的进程可以正常退出了:

七、SIGCHILD信号
我们在之前的了解中知道,为了避免出现僵尸进程,我们的父进程需要使用wait或是waitpid函数等待子进程结束,父进程可以阻塞式的等待子进程结束,也可以非阻塞地查询是否有子进程结束。我们采用这两种方式都会使得程序实现复杂。
实际上,子进程终止的时候会给父进程放一个SIGCHLD的信号,这个信号的默认处理动作就是忽略,父进程可以自定义式的改这个信号的处理动作,这样父进程就只需要关心自己的动作即可,不用关心子进程了,子进程终止会通知父进程,然后父进程再处理。
我们可以写个代码来见一见:
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
void handler(int signal) {
printf("我是%d, 我获得了一个信号%d\n", getpid(), signal);
int ret = 0;
// waitpid(WNOHANG) 非阻塞,返回值为子进程的PID或者0,表示没有子进程退出
while ((ret = waitpid(-1, NULL, WNOHANG)) > 0) {
printf("等待子进程: %d成功\n", ret);
}
}
int main() {
struct sigaction sa;
sa.sa_handler = handler;
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // 添加处理标志
sigemptyset(&sa.sa_mask); // 清空信号屏蔽字
// 注册信号处理程序
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction failed");
exit(1);
}
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("child is running, begin dead: %d\n", getpid());
sleep(3);
exit(1);
} else if (pid > 0) {
// 父进程A
while (1); // 父进程一直运行
} else {
perror("fork failed");
exit(1);
}
return 0;
}
此时父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时父进程收到SIGCHLD信号,会自动进行该信号的自定义处理动作,进而对子进程进行清理。

其实除了上面的这个方法之外,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_GN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。
例如,下面代码中调用signal函数将SIGCHLD信号的处理动作自定义为忽略。
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main() {
signal(SIGCHLD, SIG_IGN);
if(fork() == 0) {
// 子进程
printf("子进程正在运行, 子进程pid: %d", getpid());
sleep(3);
exit(1);
}
// 父进程
while(1);
return 0;
}
这个时候子进程会自动清理,不会产生僵尸进程,就也不会通知父进程了。
