Linux进程信号的产生、保存、处理
一、信号的概念
信号是一种向目标进程发送消息, 异步通知的一种机制,属于软中断。本质上是用软件来模拟中断行为!
信号在产生之前,操作系统就已经内置了所有信号的识别和处理方式。(通过宏定义出得数字编号识别信号种类;在内核中存在一张handler表,即信号处理函数表。该表的数组下标对应信号编号,数组中存放的内容指向对应信号的处理方式)
并且信号产生时,操作系统可能在做更重要的事情,也就意味着信号产生之后不会被立即处理,需要被保存。在Linux中,每一个进程PCB中存在一张位图用来保存信号。位图中比特位的位置表示信号种类,比特位内容表示信号的有无!
进程是无法得知信号何时来的,也就意味着信号的到来和进程执行是异步的。
同理,在操作系统中已经提前内置了信号信息。我们通过kill -l查看:

- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到。其中
1~31为普通信号,43~64为实时信号(不关心),没有32、33号信号!这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明:man 7 signal。
二、信号的产生
在Linux中,信号的产生方式分为四大类:键盘终端产生、系统调用产生、硬件异常产生、软件条件产生!
2.1 键盘终端产生
1)、终端信号产生过程
我们在终端输入诸如ctrl c、ctrl z、ctrl\等指令,我们发现可以将进程终止退出,或者暂停。当我们输入这些指令后,对应硬件会向CPU发送光电信号通知CPU。此时CPU会将这些光电信号转化为中断号,在中断向量表中找到对应硬件的读取方法,将硬件数据读取到内存中。
当操作系统识别到这些特殊数据后,不会将数据拷贝给目标进程。而是转化为向目标进程发送信号,从而将目标进程退出或暂停等行为!
2)、操作系统得知硬件数据就绪手段
硬件和进程之间通常会伴随着数据拷贝。比如我们在终端输入指令信息,最后是需要将数据拷贝到内存中,然后看情况是否需要拷贝给目标进程的。当我们输入完指令回车确认后,意味着键盘资源就绪,等待操作系统将数据读走。但操作系统如何的知硬件资源就绪??
操作系统监测硬件资源就绪,有两种方式:
- ①:操作系统定期轮询检测所有硬件,判断那些硬件数据就绪。但这非常不现实,如何评判设定轮询间隔时间?时间太长,系统整体效率降低;时间太短会导致CPU疯狂进程监测,减少进程调度次数,进而影响程序性能!
- ②:另一种方式就是硬件就绪后,让硬件主动通知操作系统!
首先CPU周围会存在很多针脚,这些针脚对应唯一的一个编号,每一个针脚对应一个唯一硬件。当硬件资源就绪之后,硬件会向CPU发送光电信号。此时CPU检测到光电信号后,会将收到的光电信号转化为对应针脚的编号,并将该数字保存到寄存器中,我们也将这些编号称为中断号。
当OS识别到该寄存器中存在数据时,操作系统就知道存在硬件已经硬件资源就绪了,OS会立马停下手中的所有工作,将对应硬件中的数据加载到CPU中,完成数据的加载。
为了进一步提高读取硬件数据的效率,操作系统中存在一张函数指针数组,该数组中下标位置对应中断号种类,数组中保存了对应硬件的读取方法!我们将该函数指针数组称为中断向量表,该表会在操作系统初始化时生成!此时操作系统通过中断号即可索引到对应硬件的读取方法,快速将硬件数据读取到内存!!
当然CPU的针脚是有限的,硬件也不是直接和CPU链接。在CPU和硬件之间通信需要经过主板,而主板存在一定的扩展能力。并且并不是所有的硬件都有发送中断能力!

3)、 实例:前台进程、后台进程
下面我们创建一个process.cc源文件,让其死循环输出信息。
#include <iostream>
#include <unistd.h>
int main()
{
int cnt = 0;
while(true)
{
std::cout << "running ..." << ++cnt << std::endl;
sleep(1);
}
return 0;
}
- 我们编译运行后,产生一个前台进程。我们可以在终端输入
ctrl c发送2号信号来终止前台进程!

我们在键盘上按下ctrl c后,会产生硬件中断。操作系统会识别到硬件数据就绪,此时操作系统读键盘上的数据,发送给目标进程。前台进程因为收到2号信号,进而引起信号退出!!
- 我们也可以通过
ctrl z发送20号信号暂停前台进程!但由于前台进程不能被暂停,否则键盘将失效。此时当前被暂停的前台进程后转化为后台进程。shell外壳进程快速从后台切换为前台进程。
下面我们将前台进程输入重定向到log.txt,死循环打印消息。然后ctrl z发送20号信号,此时前台进程会变为后台进程。具体效果如下:

我们发现ctrl z向目标进程发送20号信号后,前台进程变为后台进程,并且被暂停!
jobs指令可以查看当前系统中的后台进程。bg 指令+ 后台进程编号可以重新启动后台进程。fg 指令+ 后台进程编号可以将后台进程提到前台,变为前台进程!- 前台进程只能有一个(键盘只有一个),后台进程可以有多个。两者本质区别在于前台进程可以接收用户输入,后台不行。
shell进程比较特殊,不会被ctrl c杀掉。并且根据具体情况,Os会自动将shell提到前台或后台!!
4)、验证终端按键是否产生信号
上述我们通过终端按键让进程产生一系列行为。当ctrl c真的向目标进程发送了2号信号吗?ctrl z真的向目标进程发送了20号信号吗?我们需要进一步验证!
操作系统提供了一个signal系统调用即可,可以自定义捕捉信号。
#include <signal.h>
//函数原型如下,signal()第二个参数用于自定义捕捉信号
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
下面我们以自定义捕捉2号信号,分别通过终端ctrl c和用户主动发送2号信号,对比进程行为!!
【源代码如下】:自定义捕捉2号信号,让进程受到2号信号退出时,打印一段消息!!
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "自定义捕捉信号: " << signo << std::endl;
exit(0);
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
//自定义捕捉2号信号,signal()会将待捕捉信号种类数字传给handler()
signal(2, handler);
int cnt = 0;
while(true)
{
std::cout << "running ..." << ++cnt << std::endl;
sleep(1);
}
return 0;
}
【终端ctrl c效果】:

【发送2号信号效果】:

- 我们发现两者行为一直,系统都受到了2号信号。进一步验证终端输入可以发送信号!!
2.2 系统调用产生
1)、实例
操作系统提供了系统调用接口kill,用来向指定进程发送信号!
#include <signal.h>
int kill(pid_t pid, int sig); //发送成功,返回0;否则返回-1
【实例】:进程打印3次消息后,通过系统调用接口发送2号信号
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "自定义捕捉信号: " << signo << std::endl;
exit(0);
}
int main()
{
int count = 3, cnt = 0;
signal(2, handler);//自定义捕捉2号信号
while(true)
{
std::cout << "running ..." << ++cnt << std::endl;
if(--count == 0)
kill(getpid(), 2);
sleep(1);
}
return 0;
}
【运行结果】:

2)、产生信号的系统调用总结
- ①:kill函数可以给一个指定的进程发送指定的信号。(kill命令是调用kill函数实现的)
int kill(pid_t pid, int sig) - ②:raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
int raise(int signo); //成功返回0,错误返回-1。 - ③:abort函数异常终止当前进程,并且产生一个SIGABRT信号。这个函数通常用于程序遇到无法恢复的错误时,立即终止程序运行。
void abort(void); //就像exit函数一样,abort函数总是会成功的,所以没有返回值
2.3、硬件异常产生
下面以浮点数溢出和空指针解非法解引用错误为例
1)、浮点数溢出,CPU产生信号
我们知道除式中,除数为0是非法的。此时CUP硬件会发送8号信号,表示浮点数异常Floating point exception。我们先来看看相关现象,代码如下:(我们特意让进程一直被运行,并且8号信号自定义捕捉。进程收到8号信号时不退出)
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "自定义捕捉信号: " << signo << std::endl;
sleep(1);
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
signal(8, handler);
int x = 10;
x /= 0;
while(true)
{}
return 0;
}
【运行结果】:

我们观察到进程确实收到了8号信号。
2)、浮点数溢出,产生信号原理
在CPU中存在许多寄存器,其中存在一个名为status的状态寄存器,其中存在一个标记位用来保存最近一次运算结果是否发送溢出!!一旦发送浮点数溢出,该标志位会被置为1,此时结果无意义!
对于上述代码,假设我们CPU寄存器eax中保存10,ebx寄存器中保存0。10/0,本质上是除一个无限小的数,导致结果无限大,发生溢出。此时操作系统会将溢出标记位由0置1,此时操作系统会识别到该信息,会立即暂停进程。此时操作系统会将该异常解释为kill(targetprocess, signo)。然后将相关信号保存到进程PCB的信号位图中中!当异常处理完后,下次会被CPU再次调度运行。但在进程调度前,进程上下文需要先被恢复,此时状态寄存器中的溢出标记位以及为1,此时操作系统会再次发送相关信号,进入死循环。

3)、空指针解引用错误,MMU产生信号原理
MMU(内存管理单元),它是一种负责处理中央处理器(CPU)的内存访问请求的计算机硬件,现如今一般别集成到CPU上。 它的功能包括虚拟地址到物理地址的转换(即虚拟内存管理)、内存保护等!
对空指针进行非法解引用,即试图对0号地址进行写入。但此时页表中没有建立相关映射,此时MMU进程虚拟地址向物理地址转化时发送失败,MMU报错,相关标志位改变。该变化会被OS识别后向目标进程写信号!!
2.4 软件异常产生信号
对于管道,比如匿名管道等存在同步机制的管道。当读端关闭,此时管道写端也会关闭退出。这就是一种典型的软件异常。当管道写端关闭,写端进行写入时会触发 SIGPIPE14信号。进而关闭读端退出!
下面我i们以alarm函数为例,测试软件异常。
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);//返回值是0或者是以前设定的闹钟时间还余下的秒数
// alarm(0):表示取消原有闹钟,并返回上一个闹钟的剩余时间
下面我们设置一个3秒的闹钟,程序运行后向进程发送14号信号!
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
int cnt = 0;
void handler(int signo)
{
//下面注释代码:我们可以主动发送14号信号,历史闹钟剩余时间,并取消历史闹钟
//int n = alarm(0);//取消历史闹钟,如果存在返回剩余时间
// int n = alarm(3); //从设闹钟,返回上一个闹钟剩余时间
//std::cout << "result:" << n << std::endl;
std::cout << "自定义捕捉信号: " << signo << "alarm" << std::endl;
exit(0);
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
signal(14, handler);
alarm(30);
while (true)
{
sleep(1);
}
return 0;
}
【运行结果】:

三、Core Dump核心转储
Core Dump核心转储是指在进程异常终止时发送信号,如果信号的默认处理方法为core,此时操作系统会将该进程退出的核心上下文转储到磁盘上,形成core文件。该文件中包含调试信息,文件较大,并且可能包含用户密码等敏感信息,一般默认禁止生成。
在LInux中绝大部分信号的默认行为都是终止进程,并且分为Term、Core两大类。其中Term报错进程终止原因非常明确,比如管道破损读端关闭导致进程终止。而Core类报错更像一种真正的异常,虽然问题原因瞄准准确,但问题一般比较严重,通常是程序代码本身存在问题,并且需要用户对代码进一步排查。

所以在发送Core时,除了终止程序,还会生成一个Core文件。Core文件会将当前进程退出的核心上下文进行保存,转储到磁盘上。可以通过ulimit -a查看系统配置信息,其中就包含core文件的默认大小.(默认为0).但我们可以用ulimit -c命令改变Shell进程的Resource Limit,允许core文件最大为1024K: ulimit -c1024

四、信号保存和信号集
4.1 信号相关术语概念
- ①:执行信号的处理动作称为信号递达(Delivery)
- ②:信号从产生到递达之间的状态称为信号未决(Pending)。即信号产生后未被立即处理,保存在信号位图中。
- ③:进程可以阻塞信号。被阻塞的信号会始终保持未决状态,只有解除阻塞后才能被递达!
- ④:阻塞和忽略不同。忽略本身是处理信号的一种方式;而阻塞的信号没有没处理,处于未决状态。
4.2 信号内核保存方式
在进程PCB中,存在3张表:block表、pending表和handler表(1~31号信号保存方式)。具体如下:

- 每个信号都存在两个标志位(阻塞和pending),以及一个函数指针表示信号的处理动作。
- 在信号产生后,操作系统会将Pending表中特定比特位内容置1。如何需要将该信号设置为阻塞状态,此时仅需将block位图中的特定比特位下标由0置1即可。
- handler表中保存的是信号处理函数指针。信号处理方式由3种:默认、忽略和自定义。默认和忽略分别对应宏
SIG_DFL、SIG_IGN,这两个宏是由数字0和1强壮为的函数指针。
- 操作系统在处理信号时,首先会检查Pending是否存在信号,即位图中是否存在比特位为1。如果存在,此时OS会查看block表中查看信号是否设置位阻塞。如果信号没有阻塞,此时OS会根据信号编号索引到handler表中,执行对应的信号处理方法!
- 在Linux中,:规信号在递达之前产生多次只计一次。而实时信号在递达之前产生多次可以依次放在一个队列里。
- 上述
xxx位图只是站在内核的角度表述。在用户层,我们一般将block位图表称为阻塞信号集或信号屏蔽字;而将Pending位图称为未决信号集!
4.3 信号集操作函数
对于信号的处理方法,我们直接通过系统调用signal函数即可将自定义方法设进至内核。但信号屏蔽字和未决信号集是位图结构,如果也让用户自定义设置不仅繁琐,而且非常容易出差。
所以操作系统提高了一个新的数据结构sigset_t(称为信号集),这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
1)、信号集操作函数
系统中提高了5个信号集操作函数:
#include <signal.h>
int sigemptyset(sigset_t *set); // 将信号集清空,比特位全0
int sigfillset(sigset_t *set); // 将信号集比特位全部置1
int sigaddset (sigset_t *set, int signo); // 将指定信号添加到信号集中
int sigdelset(sigset_t *set, int signo); // 从信号集中删除指定信号
int sigismember(const sigset_t *set, int signo); // 判断信号集中是否存在指定信号
2)、sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
- ①:
sigprocmask函数可以将信号机通过参数set传入设置进内核,并将老的信号屏蔽字通过oset带出。 - ②:
how参数选项有如下3种:

3)、sigpending
#include <signal.h>
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
五、信号捕捉处理
进程从内核态返回用户态时发生信号捕捉!!
5.1 用户态、内核态
用户态是一种受控状态,能够访问的资源是有限的。而内核态是一种操作系统的工作状态,能够访问绝大部分系统资源!!(系统调用的背后就包含身份的变化)
5.2 如何标识用户态和内核态
进程是被加载到CPU上运行的,而CPU本身就存在工作级别。在PCU中存在一个名为CS的寄存器。该寄存器中的后两个比特位的内容表示CPU的工作级别。(1表示内核态,3表示用户态)
所以进程运行时,只需要检测CS寄存器的后两个比特位的内容即可判断进程当前所处工作状态!
除此之外,CPU中还存在两个寄存器CR3和CR1。R3寄存器中保存的是页目录表的物理地址,帮助快速找到页表地址!而CR1寄存器保存了曾经引发缺页中断的虚拟地址。
5.3 操作系统如何被运行?
首先进程间是相互独立的,因此每个进程都存在一张用户级页表。此处在外,还存在一张内核级页表。;所有进程共用同一张内核级页表,该页表指向的是操作系统的代码和数据!!除此在外,还存在一个名为CMOS的硬件单元,该硬件会周期性的,高频率的向CPU发生时钟中断。当CPU收到对于的中断信号时,CPU会通过当前进程的CPB找到内核级页表,进而找到操作系统的代码和数据进行执行!!
5.4 sigaction
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact)
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
- sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo
是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传
出该信号原来的处理动作。act和oact指向sigaction结构体。 - 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动
作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回
值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信
号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
5.5 信号的捕捉流程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。


- 在函数栈帧开辟时,就以及内置有关函数结束后返回值放回位置信息。此时OS只需在创建用户自定义方法时,串改该函数的返回值,改为sigreturn的地址即可!
- 信号捕捉处理是在所有信号都以及处理完成之后,才会返回用户态!
- 我们不一定通过系统调用陷入内核,执行完,返回用户态才进行信号检查。一个进程在生命周期中会涉及到进程切换。但进程重新被加载运行时,OS需要先进行内核态将当前进程的数据进行恢复。恢复完成之后,OS需要将进程的工作模式从内核态切换会用户态。在该过程中,OS会对进程信号进行自定义捕捉,对信号进程处理!
六、可重入函数
6.1 不可重入函数实例
先来解释下面这段流程图函数。首先mian函数正在执行链表List的头插。但不巧的时,新节点在插入一般时(即只执行性node1->next=head->next, 但还未执行head->next=node1),由于进程时间片已经到了,需要却换到下一个进程。
在上述过程中,未决信号集中已经保存了一个信号。该信号的处理当时为自定义捕捉insert。(该信号的捕捉方式是向链表List中头插节点)
当下一次该进程被重新调度时,OS在内核态工作模式将当前进程的数据和硬件上下文恢复之后,需要返回用户态执行剩下代码。在返回用户态之前,信号监测,执行insert函数完成头插node2节点。当返回用户态时,执行头插node1节点的下一步。
此时我们发现头节点head指向node1,而node2失效导致问题产生!
6.2 可重入函数、不可重入函数
可重入函数是指一个函数被多个执行流进入,但程序本身或程序逻辑不会出现任何异常时,我们称该函数为可重入函数!
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数!
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的.
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构.
- 使用全局或静态变量,或未加保护的共享资源!
七、volatile关键字
保持内存的可见性。告诉编译器,被volatile修饰的变量,不允许被优化。对该变量的任何处理操作,都必须在真实的内存中进行操作!
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
[hb@localhost code_test]$ cat Makefile
sig:sig.c
gcc -o sig sig.c -O2 // -O2表示编译器优化等级
.PHONY:clean
clean:
rm -f sig
[hb@localhost code_test]$ ./sig
^Cchage flag 0 to 1
^Cchage flag 0 to 1
^Cchage flag 0 to 1
八、SIGCHLD信号
为了防止子进程退出时进入僵尸状态,导致内存泄漏问题,父进程通常需要通过wait/waitpid来等待回收子进程。但子进程退出时并不是静悄悄的,而是会像父进程发生SIGCHLD信号!但SIGCHLD信号的默认处理动作是忽略!
所以这里还有一种新的回收子进程方式。父进程直接自定义捕捉SIGCHLD信号,在自定义函数中对子进程进行回收。(一旦调用了自定义函数,意味着父进程信号未决图中一定保存了SIGCHLD信号,一定有子进程退出了)
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用!
1803



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



