【Linux】进程信号

news/2025/1/11 20:38:39/

前言:

        大家好呀,欢迎大家点进这篇Linux学习笔记。本篇将会着重介绍Linux中信号的相关操作,更加深刻的去理解进程和操作系统之间的关系。

我的上一篇Linux博客:【Linux】进程间通信-共享内存_柒海啦的博客-CSDN博客

 

         让我们直接开始吧~

目录

一、信号引入

1.生活中的信号

2.Linux信号

思路:

​编辑

Linux中常见的信号:

3.核心转储

 4.自定义捕捉

-signal-

二、信号的产生

1.键盘产生

2.系统接口发送信号

-kill-

3.软件条件产生信号

-alarm-

4.硬件异常产生信号

三、信号的保存

1.信号的相关概念

2.内核中的三张信号表

3.操作信号表的接口

sigset_t

-本身操作位图接口-

-sigpending-

-sigprocmask-

4.直接操作信号表:

四、信号的处理

1.用户态和内核态

2.信号处理的整个流程

3.信号的操作

-sigaction-

五、补充知识

1.可重入函数

2.volatile关键字

3.SIGCHLD信号


一、信号引入

1.生活中的信号

        信号,想必并不是一个陌生的词汇。在生活中诸如红绿灯,闹钟等等都是信号。

        那么我们是如何认识这些信号的呢?因为我们的大脑能够去识别它们。那么什么叫做能识别?

        因为我们记住了对应场景下的信号,并且对于信号会产生后续的动作是要执行的。这样便就可以称作识别

        比如,我们知道交通上存在红绿灯,红灯要求我们执行的动作是停下,绿灯要求我们的动作是走。

        实际上,在解释识别的时候已经引入了本篇要提到的重点步骤了。那么我们开始具体引入Linux下的信号。

2.Linux信号

        那么,什么是Linux信号呢?

        本质是一种通知机制。用户或者操作系统通过发送一定的信号,通知进程某些事件已经发送,可以在后续进行处理

信号(LINUX信号机制)_百度百科 (baidu.com)

        我们可以具体来对信号进行讨论一下:进程需要接收信号,那么就必须要对信号有“识别”功能;信号具备“识别功能”也是由程序员所编写的;信号的产生具有随机性,并且进程可以正在处理其他事情,所以对于信号,可能不是进行立即进行处理的。

        针对上面的讨论,我们可以总结出如下要点:

1.信号是要产生的,并且具有随机性。

2.进程需要对信号进行存储,并且能够识别。

3.进程在合适的时候处理信号。

4.信号的产生相对于进程而言是异步(不等它,也就是自己做自己的,互相不干扰)的。

思路:

        所以,大致我们可以将Linux信号总结为如下的思路:

         首先我们需要明确的一点是,是对进程进行操作的。那么进程就需要具备保存信号的相关数据结构。这个结构就是位图

        位图保存在进程的PCB内核结构中。那么要对其进程发信号,那么就要对内核结构内的位图进行修改。既然涉及到了内核结构,那么拥有这个权限的就只能是操作系统了,所以上图中的操作系统和进程需要进行理解。

        说了这么多,那么在Linux中信号有哪些呢?

Linux中常见的信号:

        kill -l 命令可以查看当前系统中的所有信号(大概有62个信号)

        62个信号的原因是31到34有32 33不存在。

        另外需要说明的是1~31信号是普通信号,也是用的最多的信号,除此之外34~62信号是带了RT的信号,这些信号被称为实时信号。(实时信号应用于实时操作系统(严格的时序-特殊的行行业,遇到信号必须立即响应,比如一些车载系统))

        我们可以通过 man 7 signal 命令可以查看到特定信号的详细描述:

         signal就是对应的信号的宏,value就是所产生的对应下标,action就是信号所对应的默认动作。其中动作的解释如下:

       The entries in the "Action" column of the tables below specify the default disposition for each signal, as follows:

       Term   Default action is to terminate the process.

       Ign    Default action is to ignore the signal.

       Core   Default action is to terminate the process and dump core (see core(5)).

       Stop   Default action is to stop the process.

       Cont   Default action is to continue the process if it is currently stopped.

        下表的“动作”列指定了每个信号的默认配置,如下所示:

        Term 默认行为是终止该进程。

        Ign 默认动作是忽略信号。

        Core 默认操作是终止进程并转储核心(见core(5))。

        Stop 默认操作是停止进程。

        Cont 如果进程当前已停止,Cont的默认操作是继续该进程。

        可以发现,大部分信号的默认动作是终止。

        那么实际上此时我们可以理解一下信号发送的本质了:

        信号位图是在task_struct 维护起来的->内核数据结构->操作系统有权修改

        信号发送的本质是操作系统向目标进程写信号。OS直接修改对应PCB中的指定的位图结构,完成发送信号的过程。

        了解了总体的发送过程后,我们注意到上面默认动作的core-核心转储。这也和我们之前所学的一个知识相关连。核心转储是什么呢?

3.核心转储

        早在进程控制那里,我们已经接触了这个标志了。即wait&&waitpid的输出型参数从右往左数的第八位,就是core dump标志,即表示等待的子进程退出是否核心转储。

进程控制博客链接~ 【Linux】进程控制_柒海啦的博客-CSDN博客

         核心转储的概念:

        当进程出现某种异常的时候,是否由OS将当前进程在内存中的相关核心数据,转存到磁盘中去。主要是为了调试。

核心转储_百度百科 (baidu.com)

        为什么是为了调试呢?我们可以按照如下方式进行验证。

        需要注意的一点是在云服务器的环境下核心转储的功能是默认关闭的。(生产环境的原因,会造成频繁生成core文件,磁盘塞满,影响性能)我们可以通过ulimit指令进行相关配置。

         ulimit -a 可以查看相关的一些配置:

        可以看到corefile是默认关闭的,默认关闭的话信号处理类型是core也不能进行核心转储。

        ulimit -c 文件大小(kb) 可以打开core flie 操作,但是只是暂时性的,关闭重启会恢复默认(云服务器)。

        可以看到对应的core file 选项就可以生成了。在虚拟机中是默认打开的。所以此时我们就可以对wait进程等待接口的输出型参数的core dump位进行验证:

        那么如何验证呢?我们需要子进程触发错误让操作系统识别发送具有core的信号终止进程,父进程用来提取core dump和信号即可。而这个子进程触发错误我们可以使用除0错误-8号信号:

 (Floating point exception 浮点异常 - 默认处理方式是Core-终止并且进行核心转储)

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{// 验证wait类函数输出参数中的core dump标记if (fork() == 0){// 子进程int a = 100;a = a / 0;exit(1);}// 父进程int status;wait(&status);// waitpid(-1, &status, 0);  // 阻塞等待任意进程cout << "core dump: " << ((status >> 7) & 0x1) << " 信号: " << (status & 0x7f) << endl;  // core dump在从右往左数第八位 信号是最后七位return 0;
}

        编译运行结果:(注意编译使用-g开发者模式哦)

 

        可以看到,结果确实和我们说的那样。但是生成的这个core文件有什么用呢?因为是核心转储,所以文件里面保存的全是二进制,人去阅读的话根本看不懂,那么为何起到了调试作用呢?

        别急,-g是debug模式,不就是为了调试做准备的嘛,使用gdb工具调试signal可执行文件:

        可以看到,在gdb模式下是要core-file 选项打开core文件就可以立马定位到错误地方,这就便起到了调试的作用。

        那么如果我们在配置上将core file文件选项关闭的话,会不会生成core文件和打出对应的core dump呢?

 

         将原来文件删掉,再次运行:

        可以看到,虽然还是发送的8号信号,但是由于配置将core文件生成大小关闭,所以core dump识别关闭,也不会生成core文件了。

        所以进程等待中的core dump标记位:是否发生核心转储,是否真的dump到磁盘中去了。

 4.自定义捕捉

        前面我们了解了几种信号默认处理的方式,比如终止,core,忽略等。那么有没有可能我们自己在进程里面捕捉信号进行自定义处理呢?可以。操作系统给了我们这个权利-即提供了系统调用的接口。

-signal-

        这里会引入一种新的系统调用接口-signal函数:

man 2 signal

头文件

        #include <signal.h>

函数原型

        typedef void (*sighandler_t)(int);

        sighandler_t signal(int signum, sighandler_t handler);

函数解释

        sighandler_t:函数指针,指向返回值为void,参数为int的函数。

        signum:目标信号value(可传宏或者数字,推荐写宏)

        handler:需要自定义处理的函数指针(注意void (int) 类型的)

        返回值:函数指针返回的是该信号的默认方法。如果出错会设置错误码并且返回SIG_ERR。

注意

        注意signal函数仅仅是修改特定信号的后续处理动作,不是直接调用对应的处理动作。如果后续没有任何sigint信号产生,回调函数里设置对应的函数就不会被调用。

        我们可以利用 kill -信号 进程pid 对对应的进程发送信号进行验证。现在我们捕捉2号信号,写出如下的demo代码进行测试:

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int sig)  // 自定义处理函数
{// 实际上参数int接收的就是信号valuecout << getpid() << "进程接收到了" << sig << "信号" << endl;
}
int main()
{// 捕捉信号2测试,利用系统调用signalsignal(2, handler);while (1) sleep(1);  // 死循环不结束进程,让进程能反复的接收信号return 0;
}

        现在进行测试:

         可以看到,我们确实能够对2号信号进行捕捉,使其执行我们的自定义函数,不执行默认的终止程序。但是因为3号信号并没有捕捉,所以发送三号信号就结束了进程。(2号信号Interrupt from keyboard 键盘中断-快捷键ctrl+c 3号信号Quit from keyboard 退出键盘+core-快捷键ctrl \ )

        那么我们现在这里提出一个问题,如果将全部信号进行捕捉,那么这个进程岂不是就杀不死永存了呢?

二、信号的产生

        通过上面的信号是什么,有哪些,核心转储,自定义捕捉后我们对Linux进程接收信号处理信号有了一个大致的思路。现在我们来具体的看一下在Linux下信号是怎么产生的呢?

1.键盘产生

        实际上,我们平时终止前台进程的ctrl c快捷键就是向该进程发送2号信号进行终止的,另外ctrl \能发送三号信号终止,此终止还带上core的哦。另外...... 我们可以通过捕捉2和3号信号进行验证:

        通过键盘组合键对前端进程发送信号进行验证:

        其中killed是我们向该进程发送9号信号进行杀掉的。

2.系统接口发送信号

         除了键盘发送给前端进程信号外,进程还可以通过系统调用给指定进程发送信号。

        下面我们学习几个调用接口,均可以通过代码的方式对进程发送信号。

-kill-

man 2 kill

头文件

       #include <sys/types.h>
       #include <signal.h>

函数原型

        int kill(pid_t pid, int sig);

函数解释

        pid:给指定的进程的pid。

        sig:发送的信号。

        返回值:-1失败设置错误码,0发送成功。

man 3 raise

头文件

        #include <signal.h>

函数原型

        int raise(int sig);

函数解释

        sig:给本身(即自己进程)发送的信号value

        返回值:0表示成功,非0表示失败。

man 3 abort

头文件

        #include <stdlib.h>

函数原型

        void abort(void);

函数解释

        自己终止自己。向自己发送6号信号(Abort signal from 终止信号,带core dump位)

        raise和abort实际上是语言自己封装的系统调用。这里代码只演示第一种kill系统调用,向子进程发送终止信号父进程接收并且输出信号编号和core dump信息。

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
int main()
{// 父进程向子进程通过kill系统调用发送6号信号结束进程 输出子进程的信号和core dump码pid_t id = fork();if (id == 0){// 子进程while(1) sleep(1);exit(1);}// 父进程kill(id, 6);  // 子进程 6号信号int status;wait(&status);cout << "子进程的信号和core dump:" << (status&0x7f) << " " << ((status>>7)&1) << endl;return 0;
}

        我们如何理解系统接口发送信号呢?

        可以这么理解:用户调用系统接口->执行OS对应的系统调用代码->OS提取参数,或者设置特定的数值->OS向目标进程写信号->修改后进程后续处理信号->执行对应处理动作。 

3.软件条件产生信号

        什么是软件条件呢?就是对应进程出现异常或者特定的条件所触发的信号。我们可以通过管道这个例子进行引入:

        我们可以通过父进程创建匿名管道,父进程读出,子进程写入。父进程在读出五次后关闭读端,此时对于匿名管道来说,读端关闭,只有写端,此时管道无效,写端无意义,操作系统识别到发送终止信号-13号信号。

        我们可以通过如下代码进行验证:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <assert.h>
#include <string>
#include <signal.h>
using namespace std;
int main()
{// 验证匿名管道 读端关闭写端继续,操作系统发送信号 -软件条件产生信号int pipe_file[2];int n = pipe(pipe_file);assert(n != -1);(void)n;if (fork() == 0){// 子进程close(pipe_file[0]);  // 子进程关闭读端string arr;while (true){getline(cin, arr);cout << "-客户端写入-\n";write(pipe_file[1], arr.c_str(), arr.size());  // 不断的进行写入}exit(1);}// 父进程close(pipe_file[1]);  // 父进程关闭写端char buffer[1024] = {0};for (int i = 0; i < 5; ++i){read(pipe_file[0], buffer, 1024);  // 读取五次cout << "服务器端读入(还剩" << 5 - i << "次)# ";printf("%s\n", buffer);}cout << "-服务器端关闭读端-" << endl;close(pipe_file[0]);int status;pid_t num = waitpid(-1, &status, 0);  // 阻塞等待任意子进程assert(num != -1);(void)num;cout << "子进程退出信号: " << (status & 0x7f) << endl;  // 13return 0;
}

        验证如下:

         

        可以发现当服务器端关闭读端(即父进程)后,此时匿名管道存在不合理,如果此时客户端(子进程)在此进行写入就会被操作系统检测到,发送13号信号(pipe: write to pipe with no 无法对破坏的管道写入)结束客户端进程。

        上面就是软件条件产生信号的一个例子。 如果软件的某个条件不满足,操作系统检测到直接发送信号进行处理。

        当然的,操作系统也为我们提供了一个软件条件触发信号发送的接口:alarm。它会根据设置的时间,时间一到发送14号信号。(Timer signal from alarm(2) 闹钟定时信号 默认终止)

-alarm-

man 2 alarm

头文件

        #include <unistd.h>

函数原型

        unsigned int alarm(unsigned int seconds);

函数解释

        seconds:设定多少秒后发送信号。

        返回值:返回剩余的秒数,直到任意先前预定的警报到期交付,或为零,如果没有预先安排的警报。

        因为是条件触发,所以我们可以利用其设定的时间,在一定时间内计算(累加),算我们计算机的算力:(注意需要减少io操作)

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
static int num = 0;
void handler(int sig)  // 自定义处理函数
{// 实际上参数int接收的就是信号valuecout << getpid() << "进程接收到了" << sig << "信号" << endl;cout << num << endl;  // 只进行一次的io操作
}
int main()
{// 利用系统调用alarm计算1s内计算机累加到多少alarm(1);  // 设定一秒发送14号信号signal(SIGALRM, handler);  // 14信号的宏 - 先设置自定义处理宏while(true) ++num;return 0;
}

        可以看到有5亿多次。因为我所使用的是云服务器的原因(cout + 网络发送),所以效率会有所下降,可以利用本地的机子进行测试哦~ 

        现在我们就可以理解一下软件条件给进程发送信号:OS先识别某种软件条件触发或者不满足,OS构建信号,发送给指定的进程。

4.硬件异常产生信号

        实际上,/0错误就是硬件异常所导致的。我们先从一个现象入手进行讨论:

void handler(int sig)  // 自定义处理函数
{// 实际上参数int接收的就是信号valuecout << getpid() << "进程接收到了" << sig << "信号" << endl;sleep(1);
}int main()
{// 除零错误 - 硬件异常所导致的signal(SIGFPE, handler);  // 自定义8号信号 -浮点数异常错误int a = 100;a /= 0;while (true) sleep(1);return 0;
}

        编译运行观察现象:

         可以发现,除零错误确实触发了8号信号。但是同时也会发现另外的一个现象:/0只是执行了一次,但是信号却在不断的循环接收,这是什么情况呢?-这就要从硬件异常的本质来说了。

        我们知道,cpu处理器是由运算器和控制器组成。在cpu中有很多个寄存器,其中就有一个叫做状态寄存器,存在对应的状态标记位,OS会自动的进行计算完毕之后的检测。如果溢出标记位为1,那么操作系统就会立马识别到有溢出问题。立即找到是谁在运行的pid,OS完成对其信号的发送,进程在合适的时候进行处理。

        发生了硬件异常问题,我们一般是打印出错误信息然后退出。也可以不用退出(像上面的捕捉一样),但是不退出没有任何意义,寄存器中的异常一直没有解决,那么对应的检测也就会发出信号。

        除了除零错误是硬件异常外,实际在我们编程的过程中所犯的最多的错误段错误也是属于硬件异常。11号信号(SIGSEGV Invalid memory reference 无效的内存引用)。

 

        可以看到,上面只要发生了硬件异常都会产出同样的问题,循环报错。因为进程没有终止,那么对应硬件的异常就没有消失,就会不断的发出信号。

        我们可以结合内核来理解一下野指针、空指针引用或者越界问题:

1.首先,它们都是通过地址找到目标位置的。

2.注意语言上的地址都是虚拟地址。(可以看这篇博客了解哦~【Linux】进程的地址空间_柒海啦的博客-CSDN博客_linux 进程地址空间) 

3.将虚拟地址转化为物理地址。

4.页表+MMU(Memory Manager Unit,内存管理单元-硬件)的形式进行转换检查。

5.野指针,越界等凡是非法访问没有申请到的空间或者没有的空间,MMU在进行转化的时候就一定会进行报错。(注意,不要只以为CPU内存在寄存器,几乎所有的外设和常见的硬件都是存在寄存器的)

        结合上面信号的产生,我们其实可以总结出一条规律:无论是哪里产生的信号,最终都是被OS所识别,解释发送的。

三、信号的保存

        因为所有的信号都是OS解释进行发送的,那么OS究竟是怎么样发送给对应的进程的呢?进程内是否有着对应的数据结构进行修改和转化为处理动作呢?我们来细细的深究Linux下的信号的保存。

1.信号的相关概念

        在了解实际的保存结构前,我们先声明几条概念,在之后的表达中好更加的具体和严谨化:

1.我们实际处理信号的处理动作称作信号递达Delivery

2.信号从产生到递达之间的状态叫做信号未决Pending,会在未来合适的时候进行递达。

3.进程是可以阻塞Block)某些信号的。

4.注意阻塞和忽略是不同的。信号一旦被阻塞了,那么就不会被递达。而忽略是信号被递达之后可选的一种处理动作。

5.如果信号的阻塞状态被解除了,那么就可以被递达。

        了解了信号的相关概念后,我们来谈谈进制中具体的对信号是如何处理的。

2.内核中的三张信号表

        在进程的PCB中存在三张信号表,如下图所示:

         其中Block表,Pending表两张表都是位图结构(即用每一个比特位来存储对应编号信号的状态),Block表中0表示此信号为没有被阻塞,1表示被阻塞了;Pengding表中0表示此信号没有被发送,1表示此信号被发送了,待处理中。

        handler表示一个函数指针数组。其中SIG_DFL就是表示其默认的终止处理方法,并且强转成了0,SIG_IGN则是表示忽略的处理方法,强转成了1。所以实际上自定义捕捉就是修改此数组里面的对应方法。

        现在,我们可以大致理解一下信号的保存处理过程:进行递达的时候,操作系统首先识别信号编号,然后通过handler表找到对应的函数指针 ,然后先进行强转看是否等于0,等于0就执行默认动作,如果不是在判断是否等于1,就完成忽略。如果都不满足,那么就是调用对应的自定义方法。

        所以一个信号被处理,OS是如何进行处理的呢?

1.发送信号(OS修改pending位图)

2.处理信号(遍历pengding位图,如果信号存在查看其block,为1进行处理,为0就进行递达处理)

3.        pengding -> block -> handler

                        干涉信号所产生的动作

        正所谓结构决定算法。我们有了这个结构之后,就自然存在针对此结构进行修改的方法,操作系统提供了如下的系统调用接口:

3.操作信号表的接口

sigset_t

        首先,我们需要了解操作系统为我们提供了一个类型:sigset_t

        它是操作系统提供的一种位图类型,即信号集,可以表示三个信号表中的位图表(Block表和Pending表),但是注意此信号集可能随着系统的不同会有所变化。

        对于此信号集,我们需要注意的是:

1.不允许用户自己进行位操作。OS给我们提供了对应操作位图的方法。

2.用户是可以直接使用该类型的和内置类型或者自定义类型完全一致。

3.一定需要对应的系统接口来完成对应的功能,其中系统接口需要的参数就包含了sigset_t 定义的变量和对象。

        针对操作位图和对应系统接口完成对应的功能,我们下面分类进行详细介绍:

-本身操作位图接口-

头文件

        #include <signal.h>

函数原型和解释

        int sigemptyset(sigset_t *set); 
        初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。

        int sigfillset(sigset_t *set);
        初始化set所指向的信号集,使其中所有信号的对应bit置为1,表示该信号集的有效信号包括系统支持的所有信号。

        int sigaddset (sigset_t *set, int signo);//添加信号位

        int sigdelset(sigset_t *set, int signo);// 删除信号位

        int sigismember(const sigset_t *set, int signo);  //检测目标信号是否存在

        这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。

注意

        在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

        另外的,根据信号集我们就可以通过如下的系统调用修改内核中三张信号表中的两个信号集:

-sigpending-

头文件

        #include <signal.h>

函数原型

        int sigpending (sigset_t *set)

函数解释

        通过该函数获取被调用进程的pengding信号集。即set为输出型参数。

        成功返回0,否则返回-1。

-sigprocmask-

头文件

        #include <signal.h>

函数原型

        int sigprocmask(int how, const sigset_t *set, sigset_t *oldset)

函数解释

        how执行功能操作:

                SIG_BLOCK set包含了当前我们希望添加当前的信号屏蔽字(block表-set相当于新的block信号集)的信号。mask = mask | set

                SIG_UNBLOCK  set包含了当前我们希望解除的信号屏蔽字。mask = mask & ~set

                SIG_SETMASK 设置当前信号屏蔽字(block表)为set指向的值。mask = set

        set:当前想要执行功能的新的block信号集。

        oldset:输出型参数,返回修改前的block信号集。

        成功返回0,否则返回-1。

4.直接操作信号表:

         学习了上面的几个接口后,我们要能够从以下三个问题利用所学知识进行验证:

        1.如果对所有的信号都进行了自定义捕捉,那么是不是就存在一个不会被异常或者用户杀掉的进程吗?

        我们可以利用之前所学的自定义捕捉将1~31号信号全部捕捉,然后开启另一个窗口,获取对应进程pid进行发送信号验证即可:

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int sig)
{cout << "捕捉了当前" << sig << "信号" << endl;
}
int main()
{// 1 自定义捕捉1 ~ 31 是不是此时此进程就无敌了呢?for (int i = 0; i <= 31; ++i){signal(i, handler);}while (true) sleep(1);  // 循环验证return 0;
}

        我们可以写一个循环shell脚本进行验证 -bash

#!/bin/bash
# 发送信息脚本
i=1
id=$(pidof signal)
while [ $i -le 31 ]
do echo "kill -$i $id"kill -$i $idlet i++sleep 1
done

         验证结果如下:

         可以发现,当我们发送9号信号的时候,进程的自定义捕捉似乎没用,还是执行的默认终止方法。那我们可以设置一下脚本,跳过9号继续查看后序的信号:

         查看结果:

        可以看到19号信号也是无法被捕捉的,我们重复上面的操作跳过继续查看:

         可以看到,除了9号信号和19号信号无法捕捉外,其余信号均可以被自定义捕捉。我们学习了信号表,知道了储存结构就知道了所谓的信号捕捉实际就是在handler表内填充对应的自定义函数(void(int))即可。但是-9 绝对的杀死进程和-19暂停进程均不可被捕捉哦。

        2.如果将2号信号block,并且不断的获取当前进程的pending信号集,我们突然发送一个2号信号,我们就可以观察到对应的信号从0变为1

        因为要block,我们就要利用到sigset_t信号集类型,将对应的信号设置为存在,并且通过接口sigprocmask设置屏蔽字就可以将其对应信号进行屏蔽。

        其次我们在获取当前进程的pending信号集通过接口sigpending,不断去打印每个信号即可。

        利用如上思路我们就可以设计代码进行验证。为了方便验证,我们可以设计三个函数,一个函数用来设置对应信号的屏蔽字,一个函数用来解除对应信号的屏蔽字,还有个函数不断获取当前进程pengding信号集进行循环对31个信号进行打印即可。另外,对2信号进行捕捉,我们还可以看到由1变为0的过程。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <assert.h>
using namespace std;
void handler(int sig)
{cout << "捕捉了当前" << sig << "信号" << endl;
}
// 给对应信号添加信号屏蔽字
void AddBlock(int sig)
{sigset_t bset;// 信号集先初始化为0int n = sigemptyset(&bset);assert(n == 0);(void)n;sigaddset(&bset, sig);  // 对应信号进行添加进行添加n = sigprocmask(SIG_BLOCK, &bset, nullptr);  // 第三个参数是原来的信号集,这里是封装的函数,可以不需要哦assert(n == 0);
}
// 给对应信号去除信号屏蔽字
void EraseBlock(int sig)
{sigset_t bset;int n = sigemptyset(&bset);assert(n == 0);(void)n;sigaddset(&bset, sig);  // 对应信号进行添加进行添加n = sigprocmask(SIG_UNBLOCK, &bset, nullptr);  //只是操作方法变了 SIG_UNBLOCK -> &(~)assert(n == 0);
}// 打印当前进程的pengding信号集
void PrintPengding()
{sigset_t pset;  // 使用接口的是赋值,所以不需要进行初始化int n = sigpending(&pset);assert(n == 0);(void)n;for (int i = 1; i <= 31; ++i){if (sigismember(&pset, i)) cout << "1";else cout << "0";}cout << endl;
}int main()
{// 2 设置对应信号的屏蔽字 解除 打印当前进pengding信号集// 我们可以先对2号信号进行一个捕捉signal(2, handler);// 对2号信号设置屏蔽字AddBlock(2);cout << "2号信号已经屏蔽" << endl;int count = 0;while (true){count++;if (count == 20){// 当加到20的时候,我们将2号信号的信号屏蔽字进行解除,观察pengding表的变化cout << "2号信号屏蔽解除" << endl;EraseBlock(2);}PrintPengding();  // 不断的打印sleep(1);}return 0;
}

        验证如下:

        可以发现,我们四秒后发送了2号信号,由于2号信号已经被屏蔽了,所以2号信号无法被递达(即无法被执行),所以pending表就可以被我们肉眼的观察到由0变为了1。当我们的计数器count到20的时候,2号信号屏蔽解除。一旦解除,进程在适当的机会便就可以对该信号进行递达。由于此信号被我们所捕捉,所以就会执行我们的自定义函数,打出当前2号信号,并且pengding表我们也可以见到由1变为0的过程。

        3.如果我们对所有的信号进行阻塞,是不是也是无敌了呢

        可以按照第一个问题的思路进行设置,在全部1~31号信号全部设置屏蔽后利用我们的shell脚本挨个检测即可(也可以打印pending表进行直观的观察)。

int main()
{// 3 将1 ~31信号全部屏蔽,打印pengding表观察现象for (int i = 1; i <= 31; ++i) AddBlock(i);while (true){PrintPengding();sleep(1);}return 0;
}

        写了上面的接口后代码非常简单,我们主要来实践进行操作:

         可以看到,任然是9号信号出现了问题,后序测试中19号信号也是如此,那么我们还是将9 19进行跳过测试完,查看pending表的打印结果:

         可以看到如上的效果。9号信号和19号信号也是无法被屏蔽的。这样的话就不可能存在哪个进程是恶意程序,除非将操作系统破坏掉了。

        通过上面的系统接口,我们知道一般语言会提供.h .hpp(c/c++)内的自定义类型,OS也会给我们提供.h 和 OS自定义类型。语言调用外设,一定会调用系统调用。在语言层的头文件内一定也包含OS相关的头文件。

四、信号的处理

        在上面的信号的介绍和学习中,我们基本知道了信号的大致处理过程和内部的实现细节。但是其实一直存在一个疑惑,那就是进程合适的时候进行处理。这个合适的时候究竟是什么时候呢?

1.用户态和内核态

        那么我们首先需要了解进程运行时的用户态和内核态。

user 用户态

        是一个受到管控的状态(访问权限的约束,资源限制)

kernel 内核态

        是一个操作系统执行自己代码的一个状态。具备非常高的优先级(不受任何权限约束,资源随便使用)

        我们知道,进程在接收信号到执行信号(准确的说是由未决->递达的过程中),中间存在阻塞状态:pending->block->handler

        而信号相关的数据字段都是在进程内部的。而进程内部即PCB是属于内核范畴,那么此时进程的操作就必须由用户态转化为内核态进行操作。

        在内核态中,从内核态返回用户态的时候进行信号检测处理。那么为什么会进入内核态呢?(汇编存在一个指令 int 80 可以将当前进程由用户态变为内核态,系统调用一开始就会包含)

        我们为了更加深入理解这个过程,我们可以将以前的进程地址空间的相似的图拿来进行分析:

        每一个进程都有3~4G的地址空间,是给内核使用的,内核如何进行使用?内核也是在所有进程的地址空间上下文中跑的。可以执行进程切换的代码吗?当然可以。为什么能够执行OS的代码呢?凭借的是我们是处于内核态还是用户态。CPU内的寄存器实际上存在两套,一套是可见的,另一套是自用的(硬件上就可以专门区分执行用户态还是内核态)。CR3寄存器表示当前CPU的执行权限。int 80 修改。这样的话每个进程都可以随时切换到内核态操作同样一份的OS系统资源了。

        所有的软硬件资源都可以随时访问操作系统资源。->和访问库函数类似的,只不过有相关权限和身份的验证。执行系统调用的时候,身份就会转换。

        了解了这些后,我们就可以具体的回答一些问题:

        为什么要从用户态到内核态呢?因为有些功能,用户态无法执行。操作系统是软硬件资源的管理者,调用系统接口,或者时钟中断去调用内核代码。

        而从内核态在到用户态的时候:要么就是用户的代码没有执行完或者用户若干的进程并没有被调度。本质上操作系统就是为用户服务的,所以大量的时间肯定是为了执行用户进程。

        那么什么时候处理呢?在内核到用户态的时候进行信号检测和处理,返回的时候内核的功能已经做完了,此时就是合适的机会。

2.信号处理的整个流程

        根据如上,我们将信号处理的流程整理为如下的图进行表示:

        注意,在3到4这一段中,当前系统是属于kernel状态,当前状态是能够执行信号处理函数的,但是需要注意:因为如果此自定义函数中包含非法操作-rm scp... 此时是内核态那么就会被别人盗取。操作系统不相信任何人--提供更好的服务。所以就不能用内核态执行用户的代码。所以就会切换为用户态去执行,才能保证安全。

        上述流程看着长和复杂,其实我们可以通过如下的记忆方法进行记忆:

 

         记忆方法:无穷大 (主要是自定义信号捕捉函数)
        中间的焦点:kernel信号检测。从中偏上拉一根横线,向上是user 向下是kernel 横线有四个焦点 分别就是切换状态。

3.信号的操作

        signal实际上是基本的信号捕捉方法,还存在一种信号捕捉方法也是系统调用,操作会复杂一点,并且也会引入新的类型,但是功能比较丰富一点。

-sigaction-

man 2 sigaction

头文件:

        #include <signal.h>

函数原型:

        int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

函数解释:

        signum:对应信号编号

        struct sigaction:结构体类型,我们需要了解下面两个成员即可(其余暂时不了解)

                void     (*sa_handler)(int);  信号捕捉回调函数

                void     (*sa_sigaction)(int, siginfo_t *, void *);
                sigset_t   sa_mask;  屏蔽额外的信号设置信号集
                int        sa_flags;
                void     (*sa_restorer)(void);

        act:输入型参数

        oldact:输出型参数,输出修改前的sigaction结构

        返回值:0成功,-1失败

        我们利用上面这个接口编写代码,来深刻的理解一下为什么要有block。

        首先利用如上接口,捕捉2号信号,将原本的方法提取出来强转一下查看默认方法是0(终止)还是1(忽略),于此同时,我们在处理函数中休息10s,在休息途中如果来了同样的信号,操作系统会如何进行处理呢?(注意强转的时候会报错精度丢失,加上-fpermissive编译选项即可)

#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;void handler(int sig)
{cout << "捕捉了当前信号: " << sig << endl;sleep(10);
}int main()
{// 利用另一个信号捕捉接口去注册2号信号的自定义处理函数struct sigaction sa, oldsa;//先将内部的信号集清空一下sigemptyset(&sa.sa_mask);sa.sa_flags = 0;// 先默认置为0sa.sa_handler = handler;  // 注册自定义函数// 加载sigaction(2, &sa, &oldsa);cout << "2号信号默认的处理函数->" << (int)(oldsa.sa_handler) << endl;while (true) sleep(1);  // 主程序保持不退出,持续接收信号return 0;
}

        运行结果:

         可以发现,在自定义捕捉了2后,在正在处理2信号的处理函数的时候如果在来了一个2信号,是默认不会进行处理的。这是为什么呢?学到这里我们就可以很好的解释了,此时应该是操作系统默认在2号信号处理的时候给2号信号加上了信号屏蔽字。

        即当某个信号处理函数被调用时,内核会自动将当前信号加入进程的信号屏蔽字。直到处理结束为止

        当然,除了当前信号外的话,还想要屏蔽其他的信号的话,我们就可以设置sa_mask,这样在该信号被处理的时候就会屏蔽其他的信号,一旦处理完毕,就会合适的时候继续递达存在的信号。

        下面的代码演示中我们在处理函数处理途中打印当前进程的pending表,我们利用sa_mask对34567信号进行屏蔽,动态观察由0变成1的过程:

void PrintPending()
{sigset_t set;sigpending(&set);for (int i = 1; i <= 31; ++i){if (sigismember(&set, i)) cout << "1";else cout << "0";}cout << endl;
}void handler(int sig)
{cout << "捕捉了当前信号: " << sig << endl;int count = 10;while (true){PrintPending();count--;if (!count) break;sleep(1);}
}int main()
{//......for (int i = 3; i < 9; ++i) sigaddset(&sa.sa_mask, i); // 将345678加入屏蔽信号集//......return 0;
}
#!/bin/bash
i=2
id=$(pidof signal)
kill -2 $id
echo "kill -$i $id"
while [ $i -le 8 ]
doecho "kill -$i $id"kill -$i $idlet i++sleep 1
done

        运行结果:

        可以发现sa_mask的功能就是在信号被处理期间屏蔽其他信号间处理。 

五、补充知识

        一到四已经将信号整个流程学习完毕,现在的补充知识是在当前的信号场景可以简单拿出来进行说明的知识,在加上对一些的补充。

1.可重入函数

        我们以如下的例子进行引入:

        假设在链表进行头插的时候,我们发生了如下的事情:

        当主程序刚刚将node2->next = head 执行的时候如果不小心进入内核态,再由内核态转为用户态的时候处理信号,而信号里面对node3进行了一个头插,等跳回主程序的时候又将head进行修改。此时就及有可能导致node3空间浪费->内存泄漏。

        当然,上述存在耦合,但是这种情况就是因为时序(进程调度时序的变化)导致内存泄漏的问题 -- 非常不好被排查。问题就在于:不同的执行流执行同一种方法(main执行流和信号执行流)->函数被重入了。函数在特定时间段内,被多种执行流重入。如果被重入后函数没有问题,那么就是可重入函数,存在问题了,就是不可重入函数

        可重入函数和不可重入函数是函数的一种特征。目前我们用的大多数的函数,都是不可重入的。他们的显著特征:函数用了全局数据,传入一个数据... new malloc STL容器 不可被重入。

        可重入函数虽然安全,但是书写成本很高,不可重入函数虽然不安全,但是逻辑简单便于书写。

2.volatile关键字

        大家可能对此关键字并不熟悉,我们利用下面的一个场景引入对其关键字的认识和学习:

// 引入 volatile关键字
int flag = 0;void handler(int sig)
{(void)sig;cout << "flag由" << flag << "变为1" << endl;flag = 1;
}
int main()
{signal(2, handler);  // 利用2号信号捕捉函数对flag参数进行修改while (true){if (flag) break;}cout << "程序正常退出,flag为" << flag << endl;return 0;
}

        运行结果:

 

        乍眼一看,以为没有什么,这是正常的一个信号捕捉并且修改全局变量的过程,本身就会终止循环结束程序的,这跟上面的关键字有什么关系呢?

        那么我们如果在编译选项后面加上-O3 在此运行呢?-还是相同的代码:

 

        可以看到,明明flag已经由0变为1了,但是程序就是不会结束,我们发送信号3强行终止才会终止。

        实际上,-O3是G++或者GCC的编译优化选项。在编译阶段,编译器检查到主程序中并没有使用变量flag(即进行修改),所以就认为其不会被修改,则将其值保存在cpu的寄存器中,以此来减少与内存的交互来增加效率。

        可实际上,在当前的环境下,我们是不能对此变量进行优化的。如果不想对其优化,我们在flag前面加上关键字 volatile,就可以告诉编译系统不可对其变量进行优化,这样的话编译后进程就会乖乖的去内存提取信息。

 

        -> 让cpu无法看到内存 - 内存被遮盖了 - 
        -> 有一些有可能被优化的子段,需要程序员提醒,编译的代码必须保存内存的可见性。-volatile就可以起到作用了。    

3.SIGCHLD信号

        早在进程控制那里,我们就知道关于父子进程,子进程退出,如果父进程不等待回收子进程的话,子进程就会变成僵尸进程,造成内存泄漏的问题。

        实际上,子进程退出默认并不是什么都不做,而是向父进程发送信号SIGCHLD-17号信号,告诉父进程自己结束了。

        17号信号的默认动作是忽略。注意,这里的忽略是内核级别的忽略-在应用层是什么都不做(后面详细说明)。

        现在我们先来验证子进程是否会向父进程发送17号信号。

void handler(int sig)
{cout << "捕捉了当前信号: " << sig << endl;
}// 验证17号信号
int main()
{signal(SIGCHLD, handler);if (fork() == 0){// 子进程sleep(5);cout << "子进程退出" << endl;exit(0);}while (true);  // 父进程处理自己的事情return 0;
}

        运行结果:

 

         可以看到,第二张图就是我们以前验证过的。只不过我们验证了确实子进程退出会发送17号信号。

        那么在原来父进程需要写wait的情况下比较麻烦,那么我们现在是不是可以借助信号,当某一个子进程发送信号退出的时候,在自定义处理函数内进行回收呢?这样的话不就可以减轻父进程的压力嘛。

        但是这里需要注意的是,因为信号处理函数只是接收到了信号,并不知道对应进程的pid。所以为了解决这种问题,我们可以在一开始创建进程的时候利用数组进行保存,如果不保存也可以,利用waitpid-1的这个参数等待任意进程。并且,因为主程序还在继续执行它的任务,所以不可阻塞等待。

        另外,在严格意义上的相同时间如果出现多个子进程结束发送信号-可能会存在丢失的情况,因为panding表内只能存储一个,并且一个信号处理过程中该信号会被阻塞,所以使用while进行循环等待即可。

         运行结果:

 

         可以看到子进程回收成功,证明此方法时有效的。

        但是,如果我们父进程并不想管子进程是否退出并且其退出码什么的,可以让其退出直接就进行释放不用这么麻烦的信号处理吗?答案是自然可以。如下:

int main()
{signal(SIGCHLD, SIG_IGN);  // 17号信号置为忽略 - 应用层的忽略if (fork() == 0){// 子进程sleep(5);cout << "子进程退出" << endl;exit(0);}while (true);  // 父进程处理自己的事情return 0;
}

         可以发现,同样的被处理了,就不会存在僵尸进程造成内存泄漏问题,代码还简洁。

        因为此时传入的是应用层的忽略,可以这么理解:内核默认的忽略和应用层传入的忽略是存在区别的,系统更加偏向于不知道该如何回收,而应用层则是真忽略,并且还要回收。
 


http://www.ppmy.cn/news/10462.html

相关文章

Qu artu s II 9.0 软件的使用

1&#xff0e;实验目的和要求本实验为验证性实验&#xff0c;其目的是熟悉Qu artu s II 9.0 软件的使用&#xff0c;学会利用Qu artu s II 9.0 软件来完成整个EDA 开发的流程。2&#xff0e;实验原理利用VHDL 完成电路设计后&#xff0c;必须借助EDA 工具中的综合器、适配器、时…

云服务器定时执行python脚本

文章目录前言crontab简介基本语法定时任务具体内容python 脚本定时任务前言 在服务器上定时执行任务有两种方式&#xff0c;一种是at定时任务&#xff0c;一种是crond任务调度&#xff0c;at命令是一次性定时计划任务&#xff0c;at的守护进程 atd会以后台模式运行&#xff0c…

学校里很少提及但很实用的C语言开发基础知识

目录0. 前言1. 开发环境1.1 IDE1.2 代码文本编辑器1.3 编译器1.3.1 GCC1.4 调试器2. C语言2.1 位域2.2 指示器2.2.1 数组指示器2.2.2 结构体指示器2.2.3 结构体 数组2.3 变长数组2.4 预处理指令2.4.1 #运算符2.4.2 ##运算符2.4.3 可变参数宏2.5 泛型选择2.6 内建函数2.7 其他特…

我只是把握好了这3点,1个月后成功拿下大厂offer!

目录 一、写在前面二、技术广度的快速准备三、技术深度的快速准备四、基础功底的快速准备五、下篇预告 一、写在前面 春节过后&#xff0c;即将迎来的是一年一度的金三银四跳槽季。 假如你准备在金三银四跳槽的话&#xff0c;那么作为一个Java工程师&#xff0c;应该如何利…

遥感图像处理:最小噪声分离变换(Minimum Noise Fraction Rotation,MNF Rotation)

遥感图像处理&#xff1a;最小噪声分离变换&#xff08;Minimum Noise Fraction Rotation&#xff0c;MNF Rotation1.PCA变换2.MNF3.PCA和MNF1.PCA变换 在统计学中&#xff0c;主成分分析PCA是一种简化数据集的技术。它是一个线性变换。这个变换把数据变换到一个新的坐标系统中…

【自用】高频电子线路复习(更新中)

疫情原因 没有考试就放假回家了 返校后将先进行死亡考试周 七天考完九门 回校再进行极限复习只能说可以通过 而不利于绩点的提升 所以要从现在开始抽取一些时间进行学习 第七章 频率变换方法与电路分析 7.1 非线性电路包括 发送端的高频振荡器、倍频器、谐振功率放大器和调…

用一串Python代码爬取网站数据

如觉得博主文章写的不错或对你有所帮助的话&#xff0c;还望大家多多支持呀&#xff01;关注、点赞、收藏、评论。 目录一.编码问题二、文件编码三、基本方法四、登录五、断线重连六、正则匹配Excel操作转换网页特殊字符一.编码问题 因为涉及到中文&#xff0c;所以必然地涉及…

蓝桥杯寒假集训第九天(回文日期)

没有白走的路&#xff0c;每一步都算数&#x1f388;&#x1f388;&#x1f388; 题目描述&#xff1a; 输入一行数据&#xff0c;这个数据是从10000101到89991231&#xff0c;输出这个数据后面的第一个回文数&#xff0c;以及输出第一个ABABBABA型的回文数。 注意&#xff1…