【Linux】信号

祝大家新年快乐啦!!!新的一年,第一篇文章我们来谈谈Linux中的信号

目录

一、引入

二、系统内置的信号

三、前台进程和后台进程

四、signal函数

五、信号的产生

5.1 通过终端按键产生信号

5.2 调用系统函数向进程发信号

5.2.1 kill

5.2.2 raise

5.2.3 abort

5.3 由软件条件产生的信号

5.3.1 alarm

5.4 硬件异常产生的信号

六、核心转储

6.1 是什么核心转储

6.2 核心转储文件的产生

6.3 核心转储文件的使用举例

6.4 waitpid中status参数的core dump标志位

七、信号的保存

7.1 和信号相关的一些概念

7.2 信号在内核中的存储

7.3 sigset_t(信号集)

7.4 信号集操作函数

7.4.1 sigprocmask

7.4.2 sigpending

八、信号的处理

8.1 信号处理的原理

8.1.1 用户态和内核态

8.1.2 信号的捕捉

8.1.3 pending位图置0的时机

8.2 sigaction

九、SIGCHLD信号


一、引入

在生活中我们处处都可以遇到信号,比如红绿灯、闹钟、鸡叫或者是女朋友/男朋友的脸色

这些事务都有一个共性,当我们遇到它们时都会做出相应的行动

那我们为什么会做出对应的动作呢?这是因为曾经有人或事“培养”过自己。即便我们现在没有遇到这些信号,我们也知道该怎么处理它

所以我们可以认识并处理一个信号,进程也不例外,进程在没有收到信号的时候,其实它早就已经能够知道一个信号该怎么被处理了,比如kill -9

这就说明程序员当初在设计进程时,就内置了对信号的处理,当进程每收到一个信号时都会执行早已内置的代码了

但是信号的产生时间是不确定的,比如我们在处理一件重要的事情时突然响起了外卖小哥打来的电话,对于这种情况我们会先将手上的事情处理完再去取外卖

进程也不例外,其运行与收到信号具有异步性,所以在收到信号时,并不一定会直接去处理,我们将从进程收到信号一直到处理信号的这个时间段称为时间窗口,但是过了这段时间窗口进程需要对信号进行处理,这就意味着进程必须具有保存信号的能力

对于进程对信号做怎样的处理,主要有三种情况:默认动作、忽略信号、用户自定义动作

综上所述,我们对信号的讲解主要有三个部分:信号产生、信号保存、信号处理

二、系统内置的信号

我们在Linux中可以使用kill -l指令来查看系统中提供的所有信号:

我们可以看到系统中有1-31/34-64一共62种信号,该批信号分为两种:1-31编号的信号是普通信号,会进行详细的讲解;34-64编号的信号是实时信号,这部分信号我们不做重点介绍

这些普通信号在C语言中实际上是被定义的宏:

/* Signals.  */
#define	SIGHUP		1	/* Hangup (POSIX).  */
#define	SIGINT		2	/* Interrupt (ANSI).  */
#define	SIGQUIT		3	/* Quit (POSIX).  */
#define	SIGILL		4	/* Illegal instruction (ANSI).  */
#define	SIGTRAP		5	/* Trace trap (POSIX).  */
#define	SIGABRT		6	/* Abort (ANSI).  */
#define	SIGIOT		6	/* IOT trap (4.2 BSD).  */
#define	SIGBUS		7	/* BUS error (4.2 BSD).  */
#define	SIGFPE		8	/* Floating-point exception (ANSI).  */
#define	SIGKILL		9	/* Kill, unblockable (POSIX).  */
#define	SIGUSR1		10	/* User-defined signal 1 (POSIX).  */
#define	SIGSEGV		11	/* Segmentation violation (ANSI).  */
#define	SIGUSR2		12	/* User-defined signal 2 (POSIX).  */
#define	SIGPIPE		13	/* Broken pipe (POSIX).  */
#define	SIGALRM		14	/* Alarm clock (POSIX).  */
#define	SIGTERM		15	/* Termination (ANSI).  */
#define	SIGSTKFLT	16	/* Stack fault.  */
#define	SIGCLD		SIGCHLD	/* Same as SIGCHLD (System V).  */
#define	SIGCHLD		17	/* Child status has changed (POSIX).  */
#define	SIGCONT		18	/* Continue (POSIX).  */
#define	SIGSTOP		19	/* Stop, unblockable (POSIX).  */
#define	SIGTSTP		20	/* Keyboard stop (POSIX).  */
#define	SIGTTIN		21	/* Background read from tty (POSIX).  */
#define	SIGTTOU		22	/* Background write to tty (POSIX).  */
#define	SIGURG		23	/* Urgent condition on socket (4.2 BSD).  */
#define	SIGXCPU		24	/* CPU limit exceeded (4.2 BSD).  */
#define	SIGXFSZ		25	/* File size limit exceeded (4.2 BSD).  */
#define	SIGVTALRM	26	/* Virtual alarm clock (4.2 BSD).  */
#define	SIGPROF		27	/* Profiling alarm clock (4.2 BSD).  */
#define	SIGWINCH	28	/* Window size change (4.3 BSD, Sun).  */
#define	SIGPOLL		SIGIO	/* Pollable event occurred (System V).  */
#define	SIGIO		29	/* I/O now possible (4.2 BSD).  */
#define	SIGPWR		30	/* Power failure restart (System V).  */
#define SIGSYS		31	/* Bad system call.  */
#define SIGUNUSED	31

那进程要具有保存信号的能力,那该怎么保存呢?首先我们要知道一个进程是否有收到信号,以及收到了什么样的信号

对于判断一个进程是否收到了信号,这个很简单我们用一个数字表示即可:0(表示没收到)和1(表示收到了)

对于一个进程收到了什么样的信号,我们仔细观察一下普通信号的个数,一共有31个,那我们用位图表示即可,一个整型一共有32bit位,我们将每个位都用0和1表示是否收到了对应的信号即可

所以在进程的PCB中一定存在一个对应的位图结构来存储收到的信号

三、前台进程和后台进程

进程的种类有很多种,下面我们主要来说说前台和后台进程

在Linux中,我们可以使用./来将一个进程跑起来,在这个进程运行时,我们对终端输入任何指令都是没有效果的,但我们如果按下ctrl+c,会直接中断这个进程,例如:

#include<iostream>
#include<unistd.h>

int main()
{
    while(1)
    {
        std::cout << "我是一个进程,pid:" << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

这样的进程被称为前台进程,一般情况下前台进程只有一个,所以当我们运行起一个前台进程后,此时bash进程会退居到后台,这时我们输入的指令只能传给当前的前台进程,导致了没有任何效果,但是当我们输入ctrl+c后,会终止所有的前台进程

当然我们可以在运行进程的指令后加一个&

这样子,我们调起的进程会在后台运行:

此时我们输入指令是有效的,但是按下ctrl+c后该进程并不会终止

如果我们想终止一个在后台运行的进程就需要用到kill指令了:

四、signal函数

该函数应当在信号处理中进行讲解,但是为了方便我们实践的验证操作,在这里先进行介绍:

如果我们想让某个进程收到某个信号时不执行系统的默认动作,而是执行自定义动作,这时我们可以用到signal函数:

可以看到该函数有两个参数:sidnum和handler

● sidnum:传入信号编号,当进程再一次收到该信号时,会对应执行handler方法

● handler:传入void(*)(int)类型的函数地址,当收到sidnum信号时执行该方法

signal函数的三种常用用法如下:

  1. signal(signum, SIG_IGN):将信号signum的处理方式设置为忽略,即当接收到该信号时,程序不会做任何响应。
  2. signal(signum, SIG_DFL):将信号signum的处理方式恢复为默认,即当接收到该信号时,操作系统会按照默认操作处理该信号。
  3. signal(signum, handler):将信号signum的处理方式设置为由handler函数处理,即当接收到该信号时,会调用handler函数进行相应的处理。

下面是实例演示:

#include<iostream>
#include<unistd.h>
#include<signal.h>

void handler(int signum)//handler函数被signal调用时,会传入收到的信号编号
{
    std::cout<<"received signal:"<<signum<<std::endl;
}

int main()
{
    signal(2,handler);//将2号信号对应方法该为handler方法
    while(1)
    {
        std::cout << "我是一个进程,pid:" << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

ctrl+c本质上就是对前台进程发送2号信号,所以在使用ctrl+c时,进程并没有终止,而是执行了handler函数中对应的方法

那这样子的话,我们将所有的普通信号对应方法都用signal函数改为handler方法,那这个进程会不会变成一个无法杀死的进程?

我们来试试看:

#include<iostream>
#include<unistd.h>
#include<signal.h>

void handler(int signum)//handler函数被signal调用时,会传入收到的信号编号
{
    std::cout<<"received signal:"<<signum<<std::endl;
}

int main()
{
    for(int i=1;i<=31;++i)//将所有的普通信号对应方法改为handler方法
    {
        signal(i,handler);
    }
    while(1)
    {
        std::cout << "我是一个进程,pid:" << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

但事实并非我们所料,当我们向进程传入9号信号时,这个进程还是被终止了

原因是,操作系统的设计者早就考虑到该情况了,规定9号信号对应的方法不能被修改

五、信号的产生

5.1 通过终端按键产生信号

用户输入命令,在Shell下启动一个前台进程

用户按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出

这个信号产生的过程是有键盘的输入产生的,作为信号的来源之一

5.2 调用系统函数向进程发信号

5.2.1 kill

我们可以使用kill函数来向对应的进程传递信号:

该函数有两个参数:

pid:传入要发送信号的进程的PID

sig:传入要发送的信号编号

 kill函数的返回值为0表示成功,-1表示失败,并设置errno来指示错误的原因

下面我们来实操一下,利用main函数的两个形参来获取pid和sig(不熟悉的同学可以看到这里:【Linux】环境变量_linux 环境变量-CSDN博客):

#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<string>
#include<cstring>
#include<cerrno>

void User(std::string proc)
{
    std::cout<<"\t Useage:\n\t";
    std::cout<<proc<<" 信号编号 目标进程PID"<<std::endl;
}

int main(int argc, char* argv[])
{
    if(argc!=3)//输入格式有误
    {
        User(argv[0]);//打印用户手册
        exit(1);
    }
    int sig=atoi(argv[1]);
    int target_id=atoi(argv[2]);
    int n=kill(target_id,sig);//调用kill来对目标进程发送信号
    if(n!=0)//失败打印错误码并退出
    {
        std::cout<<errno<<":"<<strerror(errno)<<std::endl;
        exit(2);
    }
    return 0;
}

运行效果: 

5.2.2 raise

调用该函数可以向调用它的进程发一个信号:

其中,sig参数表示要发送的信号编号

raise函数会向当前进程发送指定的信号,并返回一个非零值表示成功,返回0表示失败

下面是使用举例:

#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<string>
#include<cstring>
#include<cerrno>

void myhandler(int signo)
{
    std::cout<<"get a signal:"<<signo<<std::endl;
}

int main()
{
    signal(2,myhandler);//修改2号信号对应方法
    while(1)
    {
        raise(2);//自己向自己发送2号信号
        sleep(1);
    }
    return 0;
}

5.2.3 abort

该函数可以向调用它的进程发送6号信号,最后终止进程

下面是实例演示:

#include<iostream>
#include<unistd.h>
#include<signal.h>

void myhandler(int signo)
{
    std::cout<<"get a signal:"<<signo<<std::endl;
}

int main()
{
    signal(6,myhandler);//修改6号信号对应方法
    while(1)
    {
        std::cout<<"begin"<<std::endl;
        abort();//自己向自己发送6号信号
        std::cout<<"end"<<std::endl;
    }
    return 0;
}

 

5.3 由软件条件产生的信号

我们在进程间通信的管道一期中说到过:当管道的读端被关闭时,进程再向管道写入数据就变成了一件无意义的事情,所以直接会向子进程传递13号信号(SIGPIPE)将写入进程杀掉(原文地址:【Linux】进程间通信——管道_linux任务间通信-CSDN博客

SIGPIPE就是一种由软件条件产生的信号

下面再来介绍:alarm函数以及SIGALRM信号

5.3.1 alarm

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号,该信号的默认处理动作是终止当前进程

这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数

下面我们来进行实例演示:

#include<iostream>
#include<unistd.h>
#include<signal.h>
void myhandler(int signo)
{
    std::cout<<"get a signal:"<<signo<<std::endl;
    exit(14);
}

int main()
{
    signal(SIGALRM,myhandler);//修改SIGALRM信号对应方法
    alarm(5);//5秒后向进程发送SIGALRM信号
    int count=0;
    while(1)
    {
        std::cout<<count++<<std::endl;
        sleep(1);
    }
    return 0;
}

运行效果: 

 

下面我们来看一下,函数的返回值仍然是不是以前设定的闹钟时间还余下的秒数:

#include<iostream>
#include<unistd.h>
#include<signal.h>

void myhandler(int signo)
{
    std::cout<<"get a signal:"<<signo<<std::endl;
    int n=alarm(5);//收到信号后,重置alarm函数
    std::cout<<"n:"<< n<<std::endl;
}

int main()
{
    signal(SIGALRM,myhandler);//修改SIGALRM信号对应方法
    alarm(5);//5秒后向进程发送SIGALRM信号
    while(1)
    {
        sleep(1);
    }
    return 0;
}

在该进程运行时,我们向该进程发送14号信号: 

可以看到,每次发送信号过后,alarm返回的值都是以前设定的闹钟时间还余下的秒数

另外将alarm的传入参数seconds的值置为0,表示取消以前设定的闹钟

下面来试试看:

#include<iostream>
#include<unistd.h>
#include<signal.h>

void myhandler(int signo)
{
    std::cout<<"get a signal:"<<signo<<std::endl;
    int n=alarm(0);//取消以前设定的闹钟
    std::cout<<"n:"<< n<<std::endl;
}

int main()
{
    std::cout<<"pid:"<<getpid()<<std::endl;
    signal(SIGALRM,myhandler);//修改SIGALRM信号对应方法
    alarm(10);//10秒后向进程发送SIGALRM信号
    int count=0;
    while(1)
    {
        std::cout<<count++<<std::endl;
        sleep(1);
    }
    return 0;
}

我们在该进程运行的第6秒后向该进程发送了一个14号信号,收到该信号后自定义的方法会重置取消之前的alarm,最终导致之前设定的alarm过了10秒后也不会发送信号:

5.4 硬件异常产生的信号

我们来看到下面的代码:

#include<iostream>

int main()
{
    int a=9;
    a/=0;
    std::cout<<"divide end..."<<std::endl;
    return 0;
}

运行结果:

我们可以看到在a除以0时,该进程收到信号终止了

这是由于a/0的结果会在cpu中计算出来并存储在寄存器当中,但在cpu的内部有一个状态寄存器,该寄存器会存储cpu近期计算结果是否出错溢出,当OS检查到状态寄存器有错误时,会立即向该进程发送8号信号,从而进程终止了

下面我们修改一下一下8号信号对应的默认动作来验证一下:

#include<iostream>
#include<signal.h>

void myhandler(int signo)
{
    std::cout<<"进程确实是收到了:"<<signo<<"号信号"<<std::endl;
}

int main()
{
    signal(SIGFPE,myhandler);
    int a=9;
    a/=0;
    std::cout<<"divide end..."<<std::endl;
    return 0;
}

 运行结果:

可是奇怪的是,为什么该进程会一直输出myhandler中对应的语句?

原因是每当该进程被OS调度时,OS都会检查到对应的状态寄存器出了问题,这时会再向该进程发送8号信号,导致了会一直执行myhandler方法

在C语言中还有一种常见的硬件异常产生的信号:野指针问题

#include<iostream>

int main()
{
    int* p=nullptr;
    *p=100;
    std::cout<<"野指针问题 ..."<<std::endl;
    return 0;
}

上面野指针的崩溃原因在于:在cpu按照指针的所指向的地址寻址时,有个硬件叫做MMU,该硬件会将虚拟地址对应页表转换为物理地址,在转化的过程中如果虚拟地址在页表没有对应的物理地址将会直接MMU硬件报错,如果找到了对应的物理地址,但是没有所需要的操作权限MMU也会直接报错,OS检查到后会向该进程发送11号(SIGSEGV)信号

所以这是野指针造成进程崩溃的根本原因

六、核心转储

6.1 是什么核心转储

核心转储(Core Dump)是指在程序发生崩溃或异常终止时,操作系统将程序的内存状态和各种调试信息保存到一个二进制文件中的过程。这个文件被称为核心转储文件或核心文件(一般文件名为:core.pid)。

核心转储文件记录了程序在崩溃时的内存映像,包括程序的当前状态、堆栈跟踪、寄存器状态等。它可以提供有关程序崩溃原因和状态的详细信息,对于调试和分析程序错误非常有用。

通过核心转储文件,我们可以使用调试器(例如gdb)来还原崩溃发生时的环境,并查看程序在崩溃前的状态。调试器可以使用核心转储文件来定位错误发生的位置,分析调用栈,查找内存溢出、访问越界和其他错误的原因。这对于调试复杂的程序和确定造成崩溃的原因非常有帮助。

需要注意的是,核心转储文件可能会包含敏感信息,因此在共享或发布时需要格外小心。同时,要使用核心转储文件进行调试,通常需要使用适当的调试工具和符号文件,以还原程序的调试符号信息和源代码行号等。

6.2 核心转储文件的产生

虚拟机下一般是可以看到核心转储文件的,但是如果是在云服务器下该功能一般是被关闭的,我们可以使用ulimit -a指令来查看Linux下各种类型文件大小的配置:

其中第一个就是核心转储文件的大小配置信息:为0字节

下面我们用ulimit -c指令将其设为需要的大小:

下面我们模拟进程异常退出的场景,看看会不会产生核心转储文件:

#include<iostream>
#include<unistd.h>
#include<signal.h>

int main()
{
    while(1)
    {
        std::cout<<"模拟异常退出中,pid:"<<getpid()<<std::endl;
        sleep(1);
    }
    return 0;
}

下面当进程再在运行时,我们对应信号表中的信号向其发送不一样的信号,看看会发生什么: 

先向其发送1号信号,但是并没有产生核心存储文件:

先向其发送2号信号,但是也没有产生核心存储文件:

再来试试三号信号,这时产生了核心转储文件:

总结一下上面的规律,我们可以发现:只有Action为Core的信号终止的进程才能产生核心转储文件

6.3 核心转储文件的使用举例

我们打开一个核心转储文件来看看:

发现其存储的全都是二进制乱码,所以该文件绝对不会是这样让我们看的

我们可以使用gdb调试工具来查看核心转储文件,但是前提是该文件的形成进程是在debug环境下运行的,否则我们无法获取到调试信息

下面我们来举例使用:

#include<iostream>

int main()
{
    int* p=nullptr;
    *p=100;//野指针
    std::cout<<"野指针问题 ..."<<std::endl;
    return 0;
}

我们现在运行上面代码形成的进程(在debug环境下进行):

可以看到该进程终止了并形成了一个核心转储文件 

现在我们使用gdb来调试试试看(对于gdb使用不熟悉的同学可以看到这里:【Linux】工具(5)——gdb_linux make debug-CSDN博客):

在进入gdb调试后,我们输入core-file指令,后面接上我们要查看的核心转储文件名

这样子就可以快速定位到程序出错的地方了,该调试方法被称为:事后调试

那为什么核心转储这么方便,在云服务器中默认是被关闭的呢?

这是因为云服务器属于生产环境,在生产环境中一般是运行着为用户提供服务的程序的,但不排除进程有挂掉的风险,一旦挂掉就必须尽快重启维持服务,这样在不断挂掉和重启的情况下,会产生大量的核心转储文件,会浪费大量的空间,所以导致了在云服务器中该功能是默认被关闭的

6.4 waitpid中status参数的core dump标志位

我们现在回到之前进程控制一期博客(【Linux】进程控制-CSDN博客)中遗留下的问题:

waitpid函数中输出型参数status的core dump标志位表示的就是,该进程异常终止后有没有形成核心转储文件(1为形成了,0表示没形成)

来段代码验证一下:

#include<iostream>
#include<unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    int id=fork();
    if(id==0)
    {
        std::cout<<"野指针问题 ..."<<std::endl;
        std::cout<<"野指针问题 ..."<<std::endl;
        int* p=nullptr;
        *p=100;//野指针
        std::cout<<"野指针问题 ..."<<std::endl;
        std::cout<<"野指针问题 ..."<<std::endl;
        exit(0);
    }
    int status=0;
    waitpid(id,&status,0);
    std::cout<<"exit code:"<<((status>>8)&0xFF)<<std::endl;
    std::cout<<"exit signal:"<<(status&0x7F)<<std::endl;
    std::cout<<"exit code:"<<((status>>7)&0x1)<<std::endl;
    return 0;
}

运行效果:

七、信号的保存

7.1 和信号相关的一些概念

我们先来介绍一些概念:

● 实际执行信号的处理动作称为信号递达(Delivery)

● 信号从产生到递达之间的状态,称为信号未决(Pending)

● 进程可以选择阻塞 (Block )某个信号

● 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作

● 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作

7.2 信号在内核中的存储

在Linux中信号在PCB中存储结构有三种:

pending表:位图结构(uint32_t)。每个比特位的位置,表示哪一个信号,每个比特位的内容,代表是否收到该信号

block表:位图结构(uint32_t)。每个比特位的位置,表示哪一个信号,每个比特位的内容,代表是否对应的信号该被阻塞
handler表:函数指针数组(void (*sighandler_t) (int) )。该数组的下标,表示信号编号,数组的特定下标的内容,表示该信号的递达动作

这三种结构决定了信号产生后是怎么被进程保存,并决定每种信号是否会被阻塞,以及其递达动作

如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?

在Linux中是这样处理的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本期不讨论实时信号。

7.3 sigset_t(信号集)

从上面的存储方式来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。

sigset_t的定义如下:

# define _SIGSET_NWORDS	(1024 / (8 * sizeof (unsigned long int)))
typedef struct
  {
    unsigned long int __val[_SIGSET_NWORDS];
  } __sigset_t;

typedef __sigset_t sigset_t;

我们可以看到该结构就是一个数组构成的位图

7.4 信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储,从使用者的角度是不必过于关心的,我们只能调用以下函数来操作sigset_ t变量:

#include <signal.h>

int sigemptyset (sigset_t *set);
//作用:清空信号集。参数:指向要操作的信号集的指针。返回:如果成功清空信号集,返回0;失败返回-1。

int sigfillset (sigset_t *set);
//作用:将所有信号添加到信号集。参数:指向要操作的信号集的指针。返回:如果成功将所有信号添加到信号集,返回0;失败返回-1。

int sigaddset (sigset_t *set, int signo);
//作用:向信号集中添加指定的信号。参数:指向要操作的信号集的指针,以及要添加的信号编号。返回:如果成功将信号添加到信号集,返回0;失败返回-1。

int sigdelset (sigset_t *set, int signo);
//作用:从信号集中删除指定的信号。参数:指向要操作的信号集的指针,以及要删除的信号编号。返回:如果成功从信号集中删除信号,返回0;失败返回-1。

int sigismember (const sigset_t *set, int signo);
//作用:检测指定的信号是否在信号集中。参数:指向要操作的信号集的指针,以及要检测的信号编号。返回:如果指定的信号在信号集中,返回1;如果不在,返回0;如果出错,返回-1。

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

函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。

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

7.4.1 sigprocmask

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集):

  • 参数how指定了信号屏蔽字的操作方式,可以是以下三个值之一:

    • SIG_BLOCK:将set中的信号添加到进程的当前信号屏蔽字中。
    • SIG_UNBLOCK:将set中的信号从进程的当前信号屏蔽字中移除。
    • SIG_SETMASK:将进程的当前信号屏蔽字设置为set中的值。
  • 参数set指向要设置的新信号屏蔽字的信号集。

  • 参数oset是一个可选参数,指向一个用于存储原始信号屏蔽字的信号集。如果不为NULL,则将进程的当前信号屏蔽字存储到oset中。

返回值:如果成功,返回0;如果出错,返回-1。

我们举例使用一下:

#include<iostream>
#include<unistd.h>
#include<signal.h>

void showBlock(sigset_t *set)
{
    int signo=1;
    for(;signo<=31;++signo)
    {
        if(sigismember(set,signo))//通过sigismember函数查找signo信号是否在set信号集中
            std::cout<<"1";
        else
            std::cout<<"0";
    }
    std::cout<<std::endl;
}

int main()
{
    sigset_t set,oset;//定义信号集
    sigemptyset(&set);//初始化设置信号集
    sigemptyset(&oset);//初始化旧的信号集,用来接收sigprocmask函数返回的老信号集
    sigaddset(&set,2);//向新信号集中增加2号信号
    sigprocmask(SIG_SETMASK,&set,&oset);//将阻塞信号集全部设置为set信号集
    while(1)
    {
        showBlock(&oset);//打印旧的信号集
        sleep(1);
    }
    return 0;
}

运行效果: 

我们可以看到在按下ctrl+c后该进程阻塞了该信号,并没有递达

7.4.2 sigpending

该函数可以查看进程当前的pending表

该函数接收一个指向sigset_t类型的输出参数型set,并将当前进程接收到的信号集存储在该参数中

函数的返回值为0表示成功,-1表示失败,并设置errno以指示错误的原因

下面来演示一下使用:

#include<iostream>
#include<unistd.h>
#include<signal.h>

void showBlock(sigset_t *set)
{
    int signo=1;
    std::cout<<"现在pending表中存储的信号集为:";
    for(;signo<=31;++signo)
    {
        if(sigismember(set,signo))//通过sigismember函数查找signo信号是否在set信号集中
            std::cout<<"1";
        else
            std::cout<<"0";
    }
    std::cout<<std::endl;
}

void handler(int signo)
{
    std::cout<<"收到了2号信号"<<std::endl;
}

int main()
{
    sigset_t set,oset,pending;//定义信号集
    sigemptyset(&set);//初始化设置信号集
    sigemptyset(&oset);//初始化旧的信号集,用来接收sigprocmask函数返回的老信号集
    sigemptyset(&pending);//初始化pending信号集,用来接收sigpending函数返回的信号集
    sigaddset(&set,2);//向新信号集中增加2号信号
    sigprocmask(SIG_SETMASK,&set,&oset);//将阻塞信号集全部设置为set信号集
    signal(2,handler);//自定义2号信号方法
    int count=0;
    while(1)
    {
        sigpending(&pending);//接受pending信号集
        showBlock(&pending);//打印pending信号集
        sleep(1);
        if(count++==5)
        {
            std::cout<<"2号信号阻塞解除"<<std::endl;
            sigprocmask(SIG_SETMASK,&oset,&set);//将阻塞信号集全部恢复为oset信号集
        }
    }
    return 0;
}

八、信号的处理

我们在引入的时候说过:信号的产生是异步的,当前进程可能正在做更重要的事情,所以在收到信号时,并不一定会直接去处理,需要等到合适的时候再处理

💡那什么时候是合适的时候呢?

当进程从内核态切换回用户态的时候,进程会在OS的指导下,进行信号的检测与处理

8.1 信号处理的原理

8.1.1 用户态和内核态

我们来看到32位下的进程地址空间:

可以看到在4GB内存的情况下,0-3GB是属于用户空间的,3-4GB是属于内核空间的

我们在之前博客中讲解的都是用户空间下的进程地址空间(不熟悉的同学可以看到这里:【Linux】进程地址空间-CSDN博客),下面我们要仔细说说内核空间的进程地址空间

在所有的进程运行起来都有其自己的内核空间和用户空间,其分别对应着两种不一样的页表,指向不一样的物理空间:

因为进程所执行的功能不一样,所以每个进程的用户级别空间是独一无二的;但是在同一个OS之下,所有进程3-4GB的内核空间都是一样的,也都可以看到同一张内核级页表,这就意味着所以的进程可以通过统一的窗口看到同一个OS!

那这样看来,每个进程度可以看到OS的进程空间,那OS运行的本质都是在各各不同进程之间的相同的内核进程地址空间中运行的!

我们每一次在自己的代码中使用系统调用函数,该进程都会到内核空间的地址当中进行函数跳转

综上所述,简而言之:当进程在跑用户进程空间中的代码时,该进程就处于用户态;当进程在跑内核进程空间中的代码时,该进程就处于内核态

💡那OS是如何区分进程在跑哪部分的代码的呢?

这是在CPU中有个名为CR3的寄存器,该寄存器有多个存储状态:3表示正在运行的进程执行的级别是用户态,0表示正在运行的进程执行的级别是内核态

💡那每个进程都有内核空间的话,岂不是每个进程都可以对OS进行访问了吗?

并不是的,只有当进程处于内核态时才能对内核空间进行访问

💡那谁来更改进程所处的状态级别呢?

我们如果想要直接访问OS的内核空间是无法做到的,但是OS提供的所有的系统调用,内部在正式执行调用逻辑的时候,会去修改执行级别!

8.1.2 信号的捕捉

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号

下图就是OS信号捕捉的全过程:

💡为什么要返回用户态再执行信号的自定义方法呢?难道是内核态的进程无法访问用户进程地址空间吗?

并不是这样的,内核态的进程权限是比用户态高的,这是为了防止用户在自定义函数中进行一些非法操作

 💡OS在检测信号时,如果有多个信号需要处理,此时OS会全部处理完吗?

并不会,OS在检测信号时,有多个信号需要处理,每次只处理一个,剩下没有处理完的信号会轮到下一次

8.1.3 pending位图置0的时机

我们在sigpending函数的实际举例中可以发现,当OS处理完一个信号后会将其pending结构中对应的位图置0,那到底是在OS调用自定义函数之前就置0,还是在调用完自定义函数之后置0呢?

我们来段代码验证一下:

#include<iostream>
#include<unistd.h>
#include<signal.h>

void showBlock(sigset_t *set)
{
    int signo=1;
    for(;signo<=31;++signo)
    {
        if(sigismember(set,signo))//通过sigismember函数查找signo信号是否在set信号集中
            std::cout<<"1";
        else
            std::cout<<"0";
    }
    std::cout<<std::endl;
}

void handler(int signo)
{
    sigset_t pending;
    sigemptyset(&pending);//初始化pending信号集,用来接收sigpending函数返回的信号集
    sigpending(&pending);//接受pending信号集
    std::cout<<"收到了2号信号,在handler函数内,pending表中存储的信号集为:";
    showBlock(&pending);//打印pending信号集
}

int main()
{
    sigset_t pending;
    sigemptyset(&pending);//初始化pending信号集,用来接收sigpending函数返回的信号集
    signal(2,handler);//自定义2号信号方法
    while(1)
    {
        std::cout<<"在handler函数外,pending表中存储的信号集为:";
        sigpending(&pending);//接受pending信号集
        showBlock(&pending);//打印pending信号集
        sleep(1);
    }
    return 0;
}

我们可以看到, OS调用自定义函数之前就将pending对应的位图置0了

8.2 sigaction

sigaction函数的功能与signal函数类似,但是多了一些更强大的细节:

我们可以看到该函数有三个参数:

● signum:传入要设置信号的编号。

● act:一个指向struct sigaction结构的指针,用于指定对传入信号处理的方式。struct sigaction结构定义如下:

struct sigaction
{
    void (*sa_handler)(int);
    void (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
};

该结构体中我们重点要注意的是sa_handler和sa_mask参数,其他参数与实时信号有关我们可以默认置0。sa_handler接收要传入void(*)(int)类型的函数地址,当收到sidnum信号时执行该方法;sa_mask接收要在执行sa_handler方法时屏蔽的信号集(在未执行完sa_handler方法时,即使sa_mask信号集中的信号时会阻塞不进行处理)

● oldact(输出型参数):一个指向struct sigaction结构的指针,用于保存之前的信号处理方式的信息。

下面是使用演示:

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstring>

void showBlock(sigset_t *set)
{
    int signo = 1;
    for (; signo <= 31; ++signo)
    {
        if (sigismember(set, signo)) // 通过sigismember函数查找signo信号是否在set信号集中
            std::cout << "1";
        else
            std::cout << "0";
    }
    std::cout << std::endl;
}

void handler(int signo)
{
    sigset_t pending;
    int count = 20;
    while (count--)
    {
        sigemptyset(&pending); // 初始化pending信号集,用来接收sigpending函数返回的信号集
        sigpending(&pending);  // 接受pending信号集
        std::cout << "执行2号信号对应方法,pending表中存储的信号集:";
        showBlock(&pending); // 打印pending信号集
        sleep(1);
    }
}

int main()
{
    struct sigaction set, oset;
    memset(&set, 0, sizeof(struct sigaction));
    memset(&oset, 0, sizeof(struct sigaction));
    set.sa_handler = handler;
    sigemptyset(&set.sa_mask);
    sigaddset(&set.sa_mask, 2); // 在handler方法执行时屏蔽2号信号
    sigaddset(&set.sa_mask, 3); // 在handler方法执行时屏蔽3号信号
    sigaddset(&set.sa_mask, 4); // 在handler方法执行时屏蔽4号信号
    sigaction(1, &set, &oset);  // 自定义1号信号方法
    while (1)
    {
        std::cout << "pid:" << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

我们可以看到在1号信号执行对应方法时,我们向该进程发送2/3/4在set.sa_mask信号集中被屏蔽的信号时,是无法做出反应的

九、SIGCHLD信号

我们在之前的博客中(【Linux】进程控制)讲过用wait和waitpid函数清理僵尸子进程,其父进程等待子进程有两种状态:阻塞和轮询

采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

那能否让子进程退出后告知父进程,父进程再去回收子进程呢?

当然可以,其实子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

我们来举例使用一下:

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

void handler(int signo)
{
    printf("收到了信号:%d,pid:%d,2秒后开始回收子进程\n", signo, getpid());
    sleep(2);
    while (1) // 不断循环使用waitpid函数回收子进程
    {
        pid_t ret = waitpid(-1, NULL, WNOHANG); // 任意回收一个子进程,子进程还没退出,父进程不进入阻塞
        if (ret > 0)                            // 回收成功
        {
            printf("成功回收到一个子进程,其pid:%d\n", ret);
        }
        else
            break; // 没有进程回收退出循环
    }
}

int main()
{
    signal(SIGCHLD, handler);
    int i = 0;
    for (; i < 5; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            sleep(i + 1);
            exit(1);
        }
    }
    while (1)
    {
        printf("我是父进程,pid:%d,在做其他事情,还没有收到信号\n", getpid());
        sleep(1);
    }
    return 0;
}

运行效果:

 

我们可以看到创建的五个子进程都被一一回收了

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用信号捕捉函数将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。

下面我们来段代码验证一下:

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

int main()
{
    signal(SIGCHLD, SIG_IGN);
    int i = 0;
    for (; i < 5; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            sleep(2);
            exit(1);
        }
    }
    while (1)
    {
        sleep(1);
    }
    return 0;
}

运行效果: 

我们可以看到该进程创建的子进程在2秒过后全部一下子被回收了


本期博客到这里就结束了哦,内容较多,如有纰漏还请各位指出呀

最后祝大家新年快乐!万事胜意~

  • 25
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

1e-12

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值