
目录
一、信号的概念
操作系统中进程的信号其实和我们现实生活中的信号规则非常相似,我们可以类比一下。信号是用来传输信息的一种标志,当我们接收到信号时,也就意味着我们接收到了信号带来的信息。在日常生活中,我们会接触到很多种信号,比如红绿灯、上课铃声、闹钟等等,我们看到红灯就知道这是让我们停止前进的信息,听到上课铃声就知道这是上课的信息,这就是日常生活中信号向我们传递的信息。并且我们接收到信号传递的信息以后能够做出相应的动作,比如红灯停绿灯行,上课、起床等一系列的动作,因此我们不仅有接收信号的能力,还有处理信号的能力。
同样的操作系统也是。
在操作系统中同样有着一套信号系统,操作系统的信号针对的对象是进程,进程在运行的时候除了执行自己的代码,还得要有能够识别信号和处理信号的能力。这些能力其实本质上是设计操作系统的程序员规定的,是一套约定好的信号标准,不同的信号代表不同的含义,针对不同的信号进程也有不同的处理方式。比如我们在Linux操作系统下输入指令kill -l就可以查看操作系统中的信号。

每个信号都有⼀个编号和⼀个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义
上面的这些信号中我们可以分为两类,1到31号属于分时信号,34到64号属于实时信号。这里分时、实时的概念和分时系统、实时系统的概念是相同的,我们现在电脑、手机用的基本上都是分时系统,是基于优先级的抢占式内核,而且是具有时间片的,进程只有在属于自己的时间片内运行,时间到了会自动从CPU剥离,等待下一次调度。而实时系统则是当有一个特殊进程来了的时候,即使CPU正在处理其它进程,也要停止下来先执行这个特殊的进程,它对于进程的相应速度是非常快的,比如说现在的智驾的L3紧急制动,哪怕车主正在踩油门,但是一旦车辆检测到后方有障碍物,就会紧急制动,把车主的行动先不执行。信号也是类似这个概念,分时信号可以不用立马去处理它,但是实时信号是必须立马处理的。
signal函数
signal函数是用来实现指定信号的自定义处理方式。它有两个参数,int signum 参数是信号编号,指定哪一个具体的信号;sighandler_t handler 是一个函数指针,我们可以利用该函数来自定义指定信号的处理方式。
#include <signal.h> // 信号处理函数类型定义(参数为信号编号) typedef void (*sighandler_t)(int); // 注册信号处理函数 sighandler_t signal(int signum, sighandler_t handler);
下面我们用一个代码例子来演示一下signal函数的使用,我们写一个自定义方法来处理2号信号,当我们在命令行按下ctrl + c的时候本质就是给前台进程发送2号信号给目标进程,进程对2号信号的默认处理方式是终止自己,今天我们不想用这个默认处理方式,我们自定义一个处理方式。
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "我是一个进程,我刚刚获取了一个信号:" << signo << endl;
}
int main()
{
signal(SIGINT, handler);
sleep(3);
cout << "自定义处理方式已经设置好了" << endl;
sleep(3);
while(true)
{
cout << "我是一个正在运行的进程,pid:" << getpid() << endl;
sleep(1);
}
return 0;
}

程序运行起来以后查看效果,当程序一直在运行的时候,我们按下ctrl + c它就能执行我们设置好的自定义处理方式,不再使用原来默认的处理方式。
注意:我们会发现,进程在这里是不会退出的?会一直进行,这是为什么?
实际上我们自定义了处理方式就是告诉操作系统 ——当这个进程收到 SIGINT 信号时,不要执行默认的终止操作,而是执行我自定义的 handler 函数。所以,除非我们在handler 里主动写 exit(0),否则进程不会退出!
那么这个例子可以告诉我们什么? 有四点
- 信号处理的「自定义性」:可以覆盖默认行为
- 信号处理的「异步性」:打断正常执行流程
- 信号处理函数的「简洁性」:尽量只做简单操作(信号处理函数中应避免复杂操作导致引发竞争和死锁)
- 进程的「持续性」:无主动退出逻辑则一直运行,进程的退出条件只有三种:① 执行到 main() 的 return;② 主动调用 exit()/_exit();③ 收到无法自定义的终止信号(如 SIGKILL);
二、信号的产生

接下来我们就来看看信号是如何在用户层产生的
1.键盘输入指令产生信号
上面我们介绍的ctrl + c这种是第一种产生信号的方式——通过键盘输入指令的方式(注意这里是键盘输入指令是产生信号并不是发送信号,给进程发送信号的是操作系统)。这种方式只需要我们在命令行输入相应的指令,就可以对指定的进程产生指定的信号。
除了这种方式,我们还可以编写代码通过系统调用接口来产生信号。
2.利用系统调用产生信号
kill函数
kill函数可以产生指定的信号并且发送该信号给指定的进程,这个函数的使用非常简单,它只有两个参数,pid_t pid 传入的是进程的pid,指定需要给哪个进程发送信号;int sig 传入的是信号编号,指定要产生哪一个信号。如果产生并发送信号成功的话则返回0,否则返回-1。

我们来看一段代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#include <cstring>
using namespace std;
// 子进程的信号处理函数
void child_handler(int signo) {
cout << "[子进程] 收到信号:" << signo << " (SIGINT),但不退出!" << endl;
}
int main() {
// 1. 创建子进程
pid_t child_pid = fork();
if (child_pid == -1) {
// fork 失败
cerr << "创建子进程失败:" << strerror(errno) << endl;
return 1;
}
// 子进程逻辑
if (child_pid == 0) {
// 注册 SIGINT 信号的自定义处理函数
signal(SIGINT, child_handler);
cout << "[子进程] 我启动了,PID:" << getpid() << endl;
// 子进程进入死循环,等待信号
while (true) {
sleep(1);
cout << "[子进程] 正在运行..." << endl;
}
}
// 父进程逻辑
else {
cout << "[父进程] 子进程创建成功,PID:" << child_pid << endl;
cout << "[父进程] 等待3秒后,向子进程发送 SIGINT 信号..." << endl;
// 2. 父进程休眠3秒,准备发送信号
sleep(3);
// 3. 调用 kill() 向子进程发送 SIGINT 信号
int ret = kill(child_pid, SIGINT);
if (ret == -1) {
cerr << "[父进程] 发送信号失败:" << strerror(errno) << endl;
// 杀死子进程后退出
kill(child_pid, SIGKILL);
wait(NULL);
return 1;
}
cout << "[父进程] 已成功向子进程发送 SIGINT 信号" << endl;
// 4. 等待5秒,观察子进程行为
sleep(5);
// 5. 发送 SIGKILL 信号强制终止子进程(SIGKILL 无法自定义处理)
cout << "[父进程] 发送 SIGKILL 信号终止子进程..." << endl;
kill(child_pid, SIGKILL);
// 等待子进程退出,回收资源
wait(NULL);
cout << "[父进程] 子进程已终止,程序结束" << endl;
}
return 0;
}

raise函数
raise函数的功能和kill函数的功能几乎一样,区别在于kill函数是给任意的指定进程发送指定信号,而raise是给自己发送指定信号。参数只需要传入信号编号指定要发送哪个信号就可以了。如果成功则返回0,否则返回非零。

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
// 整个代码就只有这⼀处打印
std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
signal(2, handler); // 先对2号信号进⾏捕捉
// 每隔1S,⾃⼰给⾃⼰发送2号信号
while(true)
{
sleep(1);
raise(2);
}
}
abort函数

abort函数是一个终止进程的函数,它会对调用的进程产生并发送 SIGABRT 信号,也就是6号信号,这个信号和9号信号都是终止进程的信号,但区别在于,9号信号不能被设置成自定义处理方式,即使被设置成了自定义处理方式,操作系统仍然会按默认方式处理;而6号进程可以被设置成自定义处理方式,它也会执行自定义处理方式来处理信号,但执行完自定义方式以后还会再执行默认处理方式。
3.由软件条件产生信号
我们可以通过一些系统调用接口来设置一些条件,当条件满足的时候就会自动产生并发送信号。
类似于回调函数。
alarm函数
alarm函数可以设定一个闹钟,参数 unsigned int seconds 传入的是一个未来的时间,单位是秒,比如我们传入1秒,那么在1秒之后操作系统就会给当前进程发送 SIGALRM 信号,该信号默认处理方式是终止当前进程。
4.硬件异常产生信号
我们在写代码的时候难免会写一些错误出来,比如说除零错误,数组越界,野指针等问题,这些问题一旦出现,我们的程序都没办法正常运行,会出现异常程序直接崩溃。
出现异常的本质是当前进程收到了操作系统发来的异常信号。以除零操作为例子,当我们在进行除法运算的时候,是CPU在帮我们运算,CPU中除了有存储运算数和结果的寄存器,还有一个状态寄存器,这个寄存器会记录当前运算的状态。当我们出现除零错误的时候,CPU内的状态寄存器会被设置为出错,此时就出现了硬件异常,操作系统会识别到CPU内有报错,此时它会去确认是哪一个进程出现了错误,出现了什么错误。当操作系统确定好了以后,就会产生异常信号,并且将该信号发送给目标进程。目标进程接收到信号以后在合适的时机会处理信号,一般异常信号的默认处理方式就是终止该进程。
SIGINT的默认处理动作是终⽌进程,SIGQUIT的默认处理动作是终⽌进程并且CoreDump
首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。
core dump机制是核心转储机制,这种机制发生在程序发生内部错误的时候。一般我们的程序终止是外部控制的,或者是代码调用系统接口控制的。还有些信号不仅会中断程序,还会发生core dump,这些信号中断程序是因为程序内部代码出现了错误。核心转储机制会把进程运行中对应的异常上下文数据核心转储到磁盘上,方便我们后续的调试。 如果发生了core dump,那么status参数里的core dump标志就会由0置为1。
在waitpid函数接口中有一个参数叫status,它是一个位图结构,如下图所示
三、阻塞信号
信号的其它相关概念
- 当我们实际执行信号的处理动作时成为信号递达(Delivery),信号处理的动作一般有忽略信号、执行默认动作、自定义执行动作
- 一个信号产生之后,操作系统不一定立马会执行信号的处理动作,这个信号可能会被暂时保存起来,信号从产生到递达之间的状态称为信号未决(Pending)
- 进程可以选择阻塞某个信号,如果一个信号被阻塞了,那么该信号将永远处于未决状态,直到进程解除对其的阻塞,才能执行递达动作
信号在内核中的表示图
图中我们可以看到:在进程PCB中维护了三张表,分别是 block表 、pending表 和 handler表。
- pending表是一个位图结构,它每一个比特位代表一个信号,比特位的值为1代表该信号产生了,比特位的值为0代表该信号还没有产生。
- handler表是一个指针数组,里面的每一个元素对应着每一种信号的处理方式,可以是忽略处理方式,可以是默认处理方式,也可以是自定义处理方式。
- block表也是一个位图结构,它的每一个比特位也代表着一个信号,比特位的值为1代表该信号被阻塞了,比特位的值为0代表该信号没有被阻塞。
所以进程接收信号并处理信号的流程应该是:首先看一下pending表中比特位的值为1的信号,代表着操作系统产生并向进程发送了这些信号,然后再看这些信号对应的block表中是否是阻塞状态,如果该信号被阻塞则不执行handler表中的处理动作,否则执行handler表中的处理动作。
信号集操作函数
上面介绍的block表和pending表是进程PCB中维护的一张用来记录每个信号是否阻塞以及是否产生的表,它们用相同的数据类型 sigset_t 来表示,sigset_t 称为信号集。
这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。
1.信号集设置函数
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
sigemptyset:信号集变量初始化函数,传入一个自定义的信号集变量set,将set中的所有比特位设置成0
sigfillset:信号集变量初始化函数,传入一个自定义的信号集变量set,将set中的所有比特位设置成1
sigaddset:传入信号集变量set以及信号编号signo,将信号集set中signo对应的比特位的值设置成1
sigdelset:传入信号集变量set以及信号编号signo,将信号集set中signo对应的比特位的值设置成0
sigismember:传入信号集变量set以及信号编号signo,查看信号集set中signo对应的比特位的值是否为1
上面这四个函数都是成功返回0,出错返回-1。而sigismember是一个布尔函数,用于判断一个信号集的有效号中是否包含 某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
2.sigprocmask
sigprocmask函数可以获取操作系统中block表的内容,也可以自定义更改操作系统中block表的内容。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
形参:
(1)const sigset_t *set:如果该参数传入进来的是非空指针,则根据该参数来更改进程的block表
(2)sigset_t *oset:如果该参数传入进来的是非空指针,则读取进程的当前block表通过该参数传出
(3)int how:这个参数传入进来的是选项,表示是如何更改进程block表,一共有三个选项:(假设set为新传入的block表,mask为原来的block表)
- SIG_BLOCK:set包含了我们希望添加到当前信号屏蔽字(block表)的信号,相当于mask=mask|set
- SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字(block表)中解除阻塞的信号,相当于mask=mask&~set
- SIG_SETMASK:设置当前信号屏蔽字(block表)为set所指向的值,相当于mask=set
3.sigpending函数
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1
四、信号捕捉
1.信号捕捉过程
进程会维护一个虚拟地址空间,其中虚拟地址空间被分成用户空间和内核空间。对应的页表也有两份,一份是内核级页表,一份是用户及页表。内核级页表是所有进程共享的页表,只有一份,进程访问该页表需要有权限;用户级页表是每一个进程都有一份,而且大家的用户级页表都不一样。内核级代码是将内核级别的代码和数据映射到物理内存上,正是因为这个机制,无论什么进程都可以找到内核的代码和数据,但前提是该进程要有权利访问内核级页表。
处于用户态的进程是没有权利访问内核级页表的,只能访问用户级页表;只有处于内核态的进程才有权利访问内核级页表和用户级页表。所以,进程想要具备访问内核级页表的权利,需要进行身份切换。将进程状态由用户态切换成内核态。
因此CPU内部有对应的状态寄存器(CR3寄存器),它里面会有比特位来标识当前进程的状态。

当进程由内核态切换回用户态的时候,进程会开始检测信号并处理信号。信号捕捉的过程如下图所示:
可以看到,总共有四次内核态与用户态的交互。

所以我们会总成四步就是:
- 程序正常在 “用户态” 跑,突然因为中断 / 异常跳进 “内核态” 处理问题;
- 内核处理完要回用户态前,先检查有没有要处理的信号;
- 要是信号需要自定义处理,就先回到用户态执行对应的信号处理函数;
- 信号函数执行完,会通过特殊操作再跳回内核态 “收尾”,最后回到用户态,接着之前被打断的地方继续跑。
简单说就是:程序被打断→内核处理→先处理信号→执行信号函数→再回到原来的流程。
2.sigaction函数
sigaction函数是信号捕捉函数,我们可以指定信号的捕捉动作是什么,可以指定是默认动作、忽略动作以及自定义动作。参数signum是指定信号的编号;参数act指针如果是非空指针,则根据act修改该信号的处理动作;参数oact指针如果是非空指针,则通过oact传出该信号原来的处理动作。函数调用成功则返回0,否则返回-1。
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction
*oact);
sigaction函数中的参数act指针和oact指针是指向结构体 struct sigaction{} 的,该结构体可以由我们用户来设置,由于我们不需要处理实时信号,所以有些参数我们是可以忽略不管的。我们只需要管理 sa_handler (函数指针,指向信号的处理函数)、sa_flags (默认设置为0即可)这两个参数。
当某个信号的处理函数被调用时,内核会自动将block表中该信号的比特位设置成1,即设置该信号为阻塞信号,直到处理函数执行完毕以后才恢复block表中原来的设置。这样就可以防止当前信号的处理函数还没有执行完又产生了该信号,此时由于该信号被设置为阻塞信号就暂时不会再去调用处理函数,直到原来的处理函数处理完毕。
五、可重入函数

我们来看着图讲解一下:
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上面的这个例子中,insert函数被不同的控制流调用,有可能会出现在第一次调用还没返回的时候就再次进入该函数插入另一个结点,这种现象称为重入。
insert函数访问一个全局链表,有可能因为重入而造成混乱,类似这样的函数称为不可重入函数。
反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。
六、volatile关键字
我们通过下面的代码,通过信号来理解volatile的关键字:我们定义一个全局变量flags并将其初始化设置为0,再设置2号信号的处理方式为自定义处理方式,其中自定义处理方式会将全局变量flags由0改变成1。当主函数正常运行时会在while循环内卡住,只有我们发送2号信号给进程,进程更改了flags的值while循环才会终止,最后正常退出。
#include <stdio.h>
#include <signal.h>
int flags = 0;
void handler(int signo)
{
flags = 1;
printf("更改flags:0 -> 1\n");
}
int main()
{
signal(2, handler);
while(!flags);
printf("进程是正常退出的\n");
return 0;
}
^Cchage flag 0 to 1
^Cchage flag 0 to 1
^Cchage flag 0 to 1
优化情况下,键入 CTRL-C,2号信号被捕捉,执行自定义动作,修改 flag=1,但是while 条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显,while 循环检查的 flag,并不是内存中最新的 fag,这就存在了数据二异性的问题。 while 检测的 flag 其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?
很明显需要 volatile。
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
八、SIGCHLD信号
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。(类似回调)
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程。也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。
我们来验证一下
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
void handler(int signo)
{
cout << "收到信号:" << signo << " pid:" << getpid() << endl;
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
// 10s之后子进程自动退出
int cnt = 10;
while(cnt--)
{
cout << "我是子进程,pid:" << getpid() << endl;
sleep(1);
}
exit(1);
}
waitpid(id, nullptr, 0);
return 0;
}

可以看到,子进程结束时发送了17号信号,所以SIGCHLD信号的使用可以让父进程在等待子进程的时候多了一种选择方式,以前如果子进程退出父进程需要回收子进程,但是父进程正在执行自己的任务不能被子进程的退出所打扰,那我们就只能把waitpid函数设置成WNOHANG非阻塞式等待,无论子进程退出还是没有退出,父进程都不会受到打扰。


图中我们可以看到:在进程PCB中维护了三张表,分别是 block表 、pending表 和 handler表。

被折叠的 条评论
为什么被折叠?



