【Linux】进程信号

🌇个人主页平凡的小苏
📚学习格言:命运给你一个低的起点,是想看你精彩的翻盘,而不是让你自甘堕落,脚下的路虽然难走,但我还能走,比起向阳而生,我更想尝试逆风翻盘。
🛸C++专栏:Linux内功修炼
家人们更新不易,你们的👍点赞👍和⭐关注⭐真的对我真重要,各位路 过的友友麻烦多多点赞关注。欢迎你们的私信提问,感谢你们的转发! 关注我,关注我,关注我,你们将会看到更多的优质内容!!

在这里插入图片描述

一、信号入门

1、前台进程与后台进程

前台进程:

  • 是在终端中运行的命令,那么该终端就为进程的控制终端,一旦这个终端关闭,这个进程也随之消失。

我们编写以下程序并运行:

#include<iostream>
#include<cstdio>
#include<unistd.h>
using namespace std;
int main()
{
    while (1)
    {
        printf("hello world\n");
        sleep(1);
    }
    return 0;
}

此进程在当前状态下就是前台进程。我们知道该程序的运行结果就是死循环地进行打印,而对于死循环来说,最好的方式就是使用Ctrl+C(前台进程适用)对其进行终止。

后台进程:

  • 后台进程也叫守护进程(Daemon),是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。 不受终端控制,它不需要终端的交互;Linux的大多数服务器就是使用守护进程实现的。比如Web服务器的httpd等。

我们在./myproc命令后加上 & 符号,就可以把此命令放到后台执行,也就是切换至后台进程:

从图中可以看出在命令后面加了&后,会发现按ctrl+c无法终止。但是按命令是有效果的,因为这是后台进程,不会影响命令。如果想要查看该后台进程,可以按jobs,看到当前后台进程:

image-20240125220734611

如果想要将后台进程变为前台进程,可以按fg [jobs中对应的号],这里对应的是1,就是fg 1。这时,按ctrl+c就可以结束进程了。

image-20240125220745524

我们可以通过jobs命令查看所有创建的后台进程,这里把3号进程转换为前台进程(fg 3),并ctrl + c结束进程:

image-20240125220754122

如果现在把5号进程提到前台(fg 5),但是我如果后悔了,我们就可以先按ctrl + z键,此时会发现5号进程处于Stopped暂停状态,再输入bg 5命令,即可将5号进程running状态,并且是后台进程:

image-20240125220802894

现在来总结前台、后台进程的相关指令:

image-20240125220812513

注意

  • Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  • Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  • 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方,任何时刻,都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
    问:为什么使用Ctrl+C后,该进程就终止了?

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

2、信号概念

信号是进程之间事件异步通知的一种方式,属于软中断。

3、用kill -l命令察看系统定义的信号列表

我们可以使用kill -l命令查看Linux当中的信号列表

image-20240125220851995

实际上kill命令是通过调用kill函数实现的,kill函数可以给指定的进程发送指定的信号,kill函数的函数原型如下:

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

二、信号产生

1、用户层产生信号的方式

上文说到信号是会被进程记住的(有没有产生 + 什么信号产生)。实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块PCB中的。我们都知道进程控制块本质就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生。因此,我们可以用一个32位的位图来记录信号是否产生:

task_struct
{
    uint32_t sig; //位图
};

image-20240123151912521

  • 其中,比特位在位图中的位置代表了什么信号产生,比特位在位图中的内容代表是否收到对应的信号,如上第5个比特位是1就表明收到了5号信号。

task_struc是内核的数据结构,所以只有OS操作系统有直接修改这个task_struct的数据位图的权利。因为OS是进程的管理者,进程的所有的属性的获取和设置只能由OS来操作。所以无论信号怎么产生,最终一定只能是OS来进行信号的设置。

2、信号是如何产生的

  • 一个进程收到信号,本质就是该进程内的信号位图被修改了,也就是该进程的数据被修改了,而只有操作系统才有资格修改进程的数据,因为操作系统是进程的管理者。也就是说,信号的产生本质上就是操作系统直接去修改目标进程的task_struct中的信号位图。

注意: 信号只能由操作系统发送,但信号发送的方式有多种。

通过终端发送信号

当面对下面的死循环程序时,我们都知道可以按ctrl-c来终止该进程。

#include<iostream>
#include<unistd.h>
#include<cstdio>
using namespace std;
int main()
{
    while (1)
    {
        printf("hello world\n");
        sleep(1);
    }
    return 0;
}

但实际上除了按ctrl-c之外,按ctrl-\也可以终止该进程:

image-20240123152410318

按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。

自定义捕捉信号

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

using namespace std;

void myhandler(int signo)
{
    cout << "process get a signal: " << signo << endl;
}
int main()
{
    signal(SIGINT,myhandler);//收到这个信号才能调用这个函数
    //信号的产生和我们自己的代码的运行是异步的
    while(true)
    {
        cout << "I am a crazy process" << endl;
        sleep(1);
    }
    return 0;
}

image-20240123152605640

此时会发现无论怎么按ctrl-c都无法结束该进程,原因是都会去调用自定义处理动作。为了杀掉此进程,此时我们可以先使用如下的命令查看进程的pid信息,换一种杀掉进程的信号,因为2号信号已经被我们自定义捕捉了。

总结用户层产生信号的方式:键盘产生

  • 注意这个是键盘产生的信号,不是键盘发送的信号,是OS发送的信号。

问:OS是如何发送信号的?

  • OS能找到每个进程的take_struct,也能找到当前显示器上前台进程的take_struct,每一个进程的take_struct内部都有一个位图,OS在拿到了对应的信号后,将这个对应的位置由0设为1,OS就完成了信号的发送(OS发送信号,也可以说成是写入信号)

3、由软件条产生信号

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

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

alarm函数的返回值:

  • 若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置
  • 如果调用alarm函数前,进程没有设置闹钟,则返回值为0

4、由硬件异常产生信号

进程崩溃的本质就是进程在运行过程中收到了操作系统发来的信号而被终止。那么操作系统是如何识别到一个进程触发了某种问题呢?

除零

  • 我们都知道cpu内部有一堆寄存器,当我们在做类似除0这类算数运算的时候,我们是先将这两个操作数分别放到cpu的寄存器中,然后进行算术运算并把结果写回寄存器当中。此外,cpu当中还有一组寄存器叫做状态寄存器,它是用来表征本次计算是否出现问题,来标记当前指令执行结果的各种状态信息,如有无进位,有无溢出等待。而OS是软硬件资源的管理者。如果本次计算出现了问题,那么状态寄存器当中特定的标记位会被置位。也就是说,当我们除0的时候,cpu内部的状态寄存器会被设置成为:有报错,浮点数越界。此时OS就会马上识别到当前cpu内部有报错啦(谁干的 && 是什么报错)
  • 此时OS就将识别到的硬件错误包装成信号——》向目标进程发送信号——》目标进程在合适的时候处理信号——》默认终止进程。

越界 && 野指针

  • 根据我们先前的学习依然知道在语言层面使用的地址(指针),其实都是虚拟地址——》最后被转换成物理地址——》才能访问物理内存——》才能读取对应的数据和代码。如果虚拟地址有问题,地址转化的工作是由MMU内存管理单元(硬件)+ 页表(软件)做的。转化过程就会引起问题,表现在硬件MMU上,此时OS就发现硬件出现了问题(谁干的 && 是什么报错)
  • 此时OS就将识别到的硬件错误包装成信号——》向目标进程发送信号——》目标进程在合适的时候处理信号——》默认终止进程

5、核心转储cour dump

先前我们在学习进程等待的时候,说到过进程等待时,父进程在必要时需要获取子进程的退出状态,我们需要用到status参数,调用waitpid函数完成:

image-20240123220427433

其中次低8位代表的是进程退出时的退出状态(进程退出码),低7位代表的是进程是否收到信号(异常终止),其中有一个标记位(第8位)叫核心转储core dump。

问:什么是核心存储core dump?

  • 在云服务器中,核心转储core dump是默认被关掉的,我们可以使用 ulimit -a 命令查看当前资源限制的设定:

image-20240123220549635

其中,第一行显示的core文件大小为0,即表示核心转储是被关闭的。所以这也就是为什么我上面获得的core dump为0,因为我core文件默认是关闭的为0啊。但是我们可以通过ulimit -c size命令来设置core文件的大小

image-20240123220643650

core文件的大小设置完毕后,就相当于将核心转储的功能打开了。

总结

  • 当我们一个进程在异常退出时,如果收到了某些信号, 某些异常是系统的,为了便于调试,它会在你异常退出时,触发core dump核心转储机制,像一些内部的错误,进程在异常终止后,core dump标记位给你置1,并且会在当前路径下给你生成一个大文件,上面的core.22218文件就是的(后面的数字代表此进程pid),像一些外部的错误则跟我没关系了
  • core dump会把进程在运行中,对应的异常上下文数据,core dump核心转储到磁盘上,方便调试,并且会把当前退出的status的core dump标志位给置为1。

core dump有何用呢?

看如下的代码测试:

#include <iostream>
using namespace std;
int main()
{
    cout << "begin ..." << endl;
    int *p = nullptr;
    *p = 1000;
    cout << "end ..." << endl;
    return 0;
}

很明显,上述代码发生了野指针错误,测试如下:

image-20240123220843111

此时我们在当前目录下可以看到核心转储时生成的core文件:

image-20240123220912356

现在我们使用gdb来调试此可执行程序,然后直接使用 core-file core文件 命令加载core文件,即可判断该程序在第7行发生了段错误,并在进曾终止时收到了11号信号,且定位到了产生该错误的具体位置的代码:

image-20240123220928077

注意:事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试。如上就是core dump的好处(便于调试)

问:为什么core dump一般默认是关掉的?

  • 虽然core dump的好处很明显**(便于调试,直接定位错误)**,但是假象一下,如果有一天你的代码本身发生了错误,不是外部错误,万一有些解决策略就是把服务不断重启,那么就会出现一个问题,一运行就挂,每次重启就core dump一下,且赠送你一个几百kb左右大小的core文件,若重启了一晚上,那么你的磁盘全是core文件,磁盘上全是垃圾文件,那么OS就可能收到影响。若扩大到企业级那风险可就大了,即使你限制了core文件的大小,但这些垃圾文件总归是不好的。

三、信号捕捉

1、信号其它相关概念

  • 递达:实际执行信号的处理动作称为信号递达(Delivery),也叫信号处理(执行默认动作,忽略,自定义捕捉)
  • 未决:信号从产生到递达之间的状态,称为信号未决(Pending)
  • 阻塞:进程可以选择阻塞 (Block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作

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

2、信号在内核中的表示

信号在内核中的表示示意图如下:

image-20240124125558126

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针数组(handler)表示处理动作。

  • block:阻塞信号集,和pending都是位图,对应的比特位为1,就会拦截对应的信号去递达对应的方法,即使pending为1收到了信号也没用。

  • pending:用来识别信号中对应信号的位置,若为1,就说明收到信号,为0,说明没收到信号。

  • handler:用来处理信号,信号的编号就作为这个函数指针的数组下标,直接可以访问到对应的自定义的方法,或者系统默认的处理方法。

这三个应该先看pending位,如果pending为1,再去看block,如果block为0,再去看handler。

解释上图:

  • 信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。

  • SIGINT信号的pendging位为1,说明收到2号信号。但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再解除阻塞。

  • SIGQUIT信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。

3、sigset_t

根据信号在内核中的表示方法,每个信号只有一个bit的未决标志,非0即1,不会记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,在我当前的云服务器中,sigset_t类型的定义如下:(不同OS实现sigset_t的方案可能不同)

sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。

  • 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞

  • 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。

block阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

4、信号搜集函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”,至于这个类型内部如何存储这些bit则依赖于系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

#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所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。

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

  • sigaddset函数:在set所指向的信号集中添加某种有效信号。

  • sigdelset函数:在set所指向的信号集中删除某种有效信号。

  • sigemptyset、sigfillset、sigaddset、sigdelset函数都是成功返回0,出错返回-1。

  • sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。

注意: 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号处于确定的状态。

5、sigprocmask

sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数的函数原型如下:

	#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

参数说明:

  • 如果oset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出

  • 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改

  • 如果oset和set都是非空指针,则先将原来的信号屏蔽字被分到oset里,然后根据set和how参数更改信号屏蔽字

假设当前的信号屏蔽字为mask,下标说明了how参数的可选值及其含义:

image-20240124173753828

返回值说明:

  • sigprocmask函数调用成功返回0,出错返回-1。

注意: 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。

6、sigpending

sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:

#include <signal.h>
int sigpending(sigset_t *set);

sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1。

来看如下的实验:不断的获取当前进程的pending信号集,以2号信号为例,如果我们直接获取2号信号,会因为递达的太快,我们还没等看到打印出来,就已经结束了,所以这里我们利用信号屏蔽字,把2号信号屏蔽,具体操作如下:

  • 先用上述的函数将2号信号进行屏蔽(阻塞)

  • 使用kill命令或组合按键向进程发送2号信号

  • 此时2号信号会一直被阻塞,并一直处于pending(未决)状态

  • 使用sigpending函数获取当前进程的pending信号集进行验证

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

using namespace std;
void PrintPending(sigset_t& pending)
{
    for(int signo = 31; signo >= 1; signo--)
    {
        if(sigismember(&pending,signo))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << endl << endl;
}
int main()
{
    //对2号信号忽略
    signal(2,SIG_IGN);
    //1.先对2号信号进行屏蔽
    sigset_t bset,oset;
    sigemptyset(&bset);
    sigemptyset(&oset);
    sigaddset(&bset,2);

    //调用系统调用,将数据设置进内核
    sigprocmask(SIG_SETMASK,&bset,&oset);//这里才是把2号信号进行屏蔽了
    //重复打印当前进程的pending
    sigset_t pending;
    int cnt = 0;
    while(true)
    {
        //获取
        int n = sigpending(&pending);
        //打印
        PrintPending(pending);
        sleep(1);
        cnt++;
        //解除阻塞,pending会由1变成0
        if(cnt == 10)
        {
            cout << "2号进行解除屏蔽" << endl;
            sigprocmask(SIG_SETMASK,&oset,nullptr);
        }
    }
    //发送2号信号
    return 0;
}

image-20240124213901161

根据测试结果得知:程序刚刚运行时,因为没有收到任何信号,所以此时该进程的pending表一直是0,当我们使用kill命令向该进程发送2号信号后,由于2号信号是阻塞的,因此2号信号一直处于未决状态,所以我们看到pending表中的第二个数字一直是1。

问:如果我们把信号全部屏蔽了会怎么样呢?

  • 从1开始屏蔽,一直到8都可以成功,但是到9号信号的时候,就会直接把进程killed,而不会被屏蔽掉。而且19号信号也不会被屏蔽

7、捕捉信号

内核态 && 用户态

进程处理信号,不是立即处理的时候,是在合适的时候处理的。那么这个合适的时候是什么时候呢?

  • 当当前进程从内核态,切换到用户态的时候,进行信号的检测与处理!

问1:何为内核态?何为用户态?

首先,每一个进程都有自己的进程地址空间,该进程地址空间由内核空间(3G4G)和用户空间(03G)组成:

  • 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。

  • 内核空间存储的实际上是OS代码和数据,通过内核级页表与物理内存之间建立映射关系。

内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是OS的代码和数据,所有进程看到的都是一样的内容。用户级页表(03G)每一个进程都有一份,而且大家的用户级页表都是不一样的。内核级页表(3G4G)所有进程共享,只有一份,前提是你有权利访问。一个进程是如此,多个进程亦然如此:

image-20240124231204589

问2:如何理解进程切换?

  • 在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。

  • 执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。

回到一开始的问题:何为内核态与用户态?

  • 内核态通常用来执行操作系统的代码,是一种权限非常高的状态。
  • 用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。

进程收到信号之后,并不是立即处理信号,而是在合适的时候,这里所说的合适的时候实际上就是指,从内核态切换回用户态的时候。

8、内核如何实现信号的捕捉

  • 进程的信号在被合适的时候进行处理 —— 从内核态切换到用户态的时候 —— 先检测信号,如果有必要再处理

看如下的图:

image-20240124232538618

  • 当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。

  • 但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。

    image-20240124232711039

上述信号捕捉的过程较为复杂,我们可以借助下图来帮助我们记忆:(∞)

image-20240124232803458

其中,该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,图形中间的圆点就代表着进行信号检测。

9、sigaction

捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉(设置对特定信号的特定处理的动作),sigaction函数的函数原型如下:

#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

参数说明:

  • signum:对哪个信号实施信号自定义捕捉
  • act:若act指针非空,则根据act修改该信号的处理动作
  • oldact:若oldact指针非空,则通过oldact传出该信号原来的处理动作

其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:

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_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
  • 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
  • 将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
  • 注意:所注册的信号处理函数的返回值为void,参数为int,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然这是一个回调函数,不是被main函数调用,而是被系统所调用。

结构体的第二个成员sa_sigaction:

  • sa_sigation是实时信号的处理函数。

结构体的第三个成员sa_mask

  • 当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
  • 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。

结构体的第四个成员sa_flags

  • sa_flags字段包含一些选项,这里直接将sa_flags设置为0即可。

结构体的第五个成员sa_restorer

  • 该参数没有使用

示例:(下面我们用sigaction函数对2号信号进行了捕捉,对2号信号的处理动作依次进行测试)

  • 给sa_handler赋值为函数指针,表示自定义捕捉信号:
#include <iostream>
#include <cstring>
#include <signal.h>
#include <unistd.h>

using namespace std;
void handler(int signo)
{
    cout << "我的自定义捕捉信号: " <<  signo << endl;
}
int main()
{
    struct sigaction act,oact;
    memset(&act, 0, sizeof act);
    memset(&oact, 0, sizeof oact);

    act.sa_handler = handler;
    sigaction(2,&act,&oact);
    while(1)
    {
        sleep(1);
    }
    return 0;
}

image-20240125143750308

四、可重入函数

先前我们学习链表的时候,都清楚链表头插的过程:(如下带哨兵位头节点的单链表)

image-20240125151130207

下面主函数中调用insert函数向链表中插入节点node1,此时某信号处理函数也调用了insert函数向链表中插入节点node2,乍一看好像没什么问题:

image-20240125151207285

下面我们来分析一下,对于下面这个链表:

image-20240125151231018

1、首先,main函数中调用了insert函数,想将结点node1插入链表,但插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数:

image-20240125151249392

2、而sighandler函数中也调用了insert函数,将结点node2插入到了链表中,插入操作完成第一步后的情况如下:

image-20240125151306168

3、当结点node2插入的两步操作都做完之后从sighandler返回内核态,此时链表的布局如下:

image-20240125151321304

4、再次回到用户态就从main函数调用的insert函数中继续往下执行,即继续进行结点node1的插入操作:

image-20240125151334106

最终结果是,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点就再也找不到了,造成了内存泄漏。

  • 总览过程如下:

image-20240125151417883

  • 像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。
  • 而insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数。

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

五、volatile

volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。

  • 在下面的代码中,我们对2号信号进行了捕捉,当该进程收到2号信号时会将全局变量flags由0置1。也就是说,在进程收到2号信号之前,该进程会一直处于死循环状态,直到收到2号信号时将flags置1才能够正常退出。
#include<stdio.h>
#include<signal.h>
int flags = 0;
void handler(int signo)
{
    printf("更改flags:0->1\n");
    flags = 1;
}
int main()
{
    signal(2, handler);
    while (!flags);
    printf("进程正常退出的!\n");
    return 0;
}

image-20240125151756473

这里的结果和我i们的预期是相同的,因为发送了2号信号,就将flags由0置1了。但是这时在gcc编译器中,如果在别的编译器(高优化级别的)中就不一定是这种效果了。

  • 这里我们更改一下编译器的编译层度(让编译器优化级别增高):

image-20240125151810959

此时我们增加了编译器的优化级别,flags就很可能会被设置进寄存器里头,从此while循环检测的时候,其只从寄存器中读取数据,但是我们后续修改的flags是内存中的,不是寄存器中的,所以寄存器中的flags恒为0,此进程就会一直陷入死循环。

image-20240125151826264

结果如下:

image-20240125151842078

面对这种情况(编译器把flags优化到寄存器),我们就可以使用volatile关键字对flags变量进行修饰,告诉编译器,对flags变量的任何操作都必须真实的在内存中进行(不准对flags做任何优化),即保持了内存的可见性:

#include<stdio.h>
#include<signal.h>
volatile int flags = 0;
void handler(int signo)
{
    printf("更改flags:0->1\n");
    flags = 1;
}
int main()
{
    signal(2, handler);
    while (!flags);
    printf("进程正常退出的!\n");
    return 0;
}

此时就算我们编译代码时携带-O2选项,当进程收到2号信号将内存中的flags变量置1时,main函数执行流也能够检测到内存中flags变量的变化,进而跳出死循环正常退出。

image-20240125151911431

评论 31
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

平凡的小苏

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

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

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

打赏作者

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

抵扣说明:

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

余额充值