【Linux杂货铺】进程信号

秋刀鱼的滋味@ 2024-06-12 10:07:20 阅读 87


目录

🌈前言🌈

📁 概念

📁 总体认识

📁 信号产生

 📂 kill命令 

 📂 通过终端按键

 📂 系统调用函数

 📂 软件条件

 📂 硬件异常

📁 core 和 term

📁 信号保存

📂 信号的相关概念

📂 内核表示

📂 sigset_t 

📂 信号集操作函数

 📂 sigprocmask

 📂 sigpending

📁 捕捉信号​编辑

📂 signal

📂 sigaction

📂 用户态和内核态

📁 可重入函数

📁 volatile关键字

📁 SIGCHLD

📁 总结


🌈前言🌈

        欢迎收看本期【Linux杂货铺】,本期内容将讲解信号的概念,本文旨在通过粗俗易懂的语言,并配上清晰的图画,让大家更好的理解信号的概念。在学习本章内容前,需要你对进程的概念及其操作熟悉。

【Linux杂货铺】进程的基本概念-CSDN博客

【Linux杂货铺】进程控制-CSDN博客

📁 概念

        信号是进程间事件异步通知的一种方式,属于软终端。信号就是一条消息,它通知进程发生了一件事。

        在Linux系统上支持30中不同类型的信号,每种信号都对应于某种系统事件。底层的硬件异常是由内核异常处理程序处理的,对于用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。

                                                                                                ——《深入理解计算机系统》

        我们先来使用一下信号:1. 用户输入命令,在shell下启动一个前台进程;2. 用户按Ctrl + c ,这个键盘输入产生了一个硬件中断,被OS获取,解释为信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。

注意:

1. Ctrl + C 产生的信号(SIGINT)只能发送给前台进程,一个命令后面加上&可以放到后台运行,这样shell不必等待进程结束就可以接受新的命令,启动新的进程。

2. shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接收到Ctrl + C这种控制键产生的信号

3. 前台进程在运行过程中用户随时可能按下Ctrl + C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步的,

📁 总体认识

        我们通过下图,来认识信号的处理过程。下图也是接下来讲解信号的主线。

        1. 因为某种事件发生,内核向进程发送了信号,该进程收到该信号;

        2. 如果正在处理某种事件,会在合适的时间去处理,在这个期间你收到信号但没有处理,叫做保存;

        3. 当时间合适,就处理该信号,有三种动作:a. 执行默认动作;b. 执行自定义动作;c. 忽略该动作。

        因为进程并不知道信号要发送,所以还在处理自己的事情,但满足某种条件,内核向进程发送信号,进程并不知情,这个过程就是异步的,进程并不知道什么时候产生信号。

        信号处理时,如果执行自定义动作,就要提供一个信号处理函数,要求内核切换到用户态来处理这个函数,这种方式叫做 捕捉(catch)一个信号。

        我们可以通过 kill -l 命令查看系统定义的信号列表

        a. 每个信号都有一个编号和一个宏定义,这些宏定义在signal.h中可以找到,例如有定义#define SIGINT 2

        b. 编号34以上的信号是实时信号,本章只讨论34以下的信号,不讨论实时信号。这些信号在各自什么条件下产生,默认处理动作是什么,在signal 7 中都有详细说明

        【 man 7 signal 】命令查看

📁 信号产生

 📂 kill命令 

        我们可以再shell下,输入kill -信号  进程pid  的方式向指定进程发送信号

 📂 通过终端按键

        Ctrl + C : 就是向前台进程发送2号信号。

        Ctrl + \ :就是向前台进程发送3号信号。

 📂 系统调用函数

        #include <signal.h>

        int kill(pid_t pid , int signo) : 向任意进程发送任意信号,kill命令就是调用kill函数。

        int raise(int signo) : 向当前进程发送信号。

        这两个函数都是成功返回0,失败返回-1。

        #include <stdlib.h>

        void abort(void):使当前进程接收到信号(6  SIGABRT)而终止异常。

        这个函数总是会成功,所以没有返回值。

 📂 软件条件

        1. SIGPIPE就是一种由软件条件而产生的信号,在管道章节中进行讲解,即读端关闭,写端不会一只写,而是会收到信号SIGPIPE而终止。

        2. alarm函数和SIGALRM信号

#include <unistd.h>unsigned int alarm(unsigned int seconds);/* 调用alarm函数可以设定一个闹钟, 也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。*/

这个函数返回值是0或者以前设定的闹钟的剩余秒数。

打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响, “以前设定的闹钟时间还余下的时间” 就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

#include <iostream>#include <unistd.h>int main(){ int count = 1; alarm(1); while(count++) { std::cout << "count = " << count << std::endl; } return 0;}

在一秒内计数,一秒后被SIGALRM终止。

 📂 硬件异常

        1. 除0错误

        CPU中有一个状态检测寄存器eflags,包含了多个状态标记位,其中有一个溢出标记位OF,可以帮助程序员检测可能得数值错误,如果溢出,OF标志位会被置为1,反之会被置为0。如果为1,内核会向进程发送 8号信号 SIGFPE。 

        2. 野指针        

        CPU中有一个CR2寄存器,保存着最后一次出现页故障的32为线性地址。即CPU尝试访问一个不存在的页面时,会发生叶股长,此时CPU会将引起页故障的线性地址保存在CR2寄存器中。

📁 core 和 term

        我们以部分信号为例,第三列表示该信号的默认动作,Core和Term都是中断进程,Ign是忽略动作。

        core和term的相同点都是中断进程,但是core会产生一份core文件,dump到硬盘中,协助我们进行debug文件,即事后调试。

        core文件相当于进程退出时的镜像文件(核心转储),将进程退出的内存数据dump到当前目录。云服务器的核心转储功能默认是关闭。

        当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core。

        进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这就是事后调试。

        一个进程允许产生多大的core文件取决于进程的Resource Limit,默认是不允许产生core文件,因为core文件可能包含用户密码等敏感信息不安全。

        在开发调试阶段,可以用ulimit命令更改这个限制,允许产生core文件。下图演示如何生成core文件,以及调试core文件。

📁 信号保存

        信号的产生和发送都是OS来操作的,因为OS是进程的管理者,且信号的并不是立即处理,而是在合适的时候。信号如果不是立即处理,那么信号就需要暂时被进程记录下来,记录在哪里?进程在还没有收到信号时,是否知道自己应该对哪些信号进行处理?

📂 信号的相关概念

        1. 实际执行信号的处理动作叫做 信号递达(Delivery)

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

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

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

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

📂 内核表示

        上图是信号在内核中的表示示意图。

        block 和 pending是两个位图,可以理解为32为比特位的位图,第几个比特位表示第几个信号,该比特位是否为1,表示该信号是否有效。在pending位图中,是否有效表示是否收到该信号,block位图中,是否有效表示是否阻塞该信号。

        handler是一个函数指针数组,又32个函数指针元素,通过下标来访问对应的方法,如果用户自定义了信号捕捉,该函数指针就指向该函数。

        因此,进程通过两个位图和一个有32个元素的函数指针数组来表示信号是否收到,是否递达信号,以及收到信号后,执行信号的动作。

        n号信号再被递达前,清除pending位图中对应的比特位,且n号信号在被递达时,会屏蔽你、

号信号,直到n号信号递达后,再处理n号信号。

📂 sigset_t 

        sigset_t 是 Linux提供的一种类型,叫做信号集,里面封装了位图,用户可以通过sigset_t 来操作进程对信号的屏蔽,获取进程的pending位图,block位图,添加屏蔽信号,删除屏蔽信号等。

        阻塞信号集也叫做当前继承的信号屏蔽字,这里的屏蔽应该理解为阻塞而不是忽略。

📂 信号集操作函数

        sigset_t 类型对于每种信号用一个bit为表示有效或者无效,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者角度不必关心,只需要调度以下函数来操作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 函数用来初始化指向的信号集,使其中所有信号对应的bit清零,表示该信号集不包含任何有效信号。

        sigfillset 函数用来初始化,使其中所有信号对应的bit置为1,表示该信号集不包含任何有效信号。

        在使用sigset_t 类型变量之前,一定要调用sigemptyset 或者 sigfillset函数做初始化,是信号集处于确定状态。

        用sigaddset 和 sigdelset在该信号集中添加或删除某种有效信号。

        这四个函数都是成功返回0,失败返回-1.

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

 📂 sigprocmask

        调用该函数,可以读取或者更改进程的信号屏蔽子(阻塞信号集)

#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset);成功返回0,失败返回-1

        如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出,如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。

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

 📂 sigpending

#include <signal.h>sigpending读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

        程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会 使SIGINT信号处于未决 状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞。

📁 捕捉信号

        上图是我们捕捉信号的流程,当进程在因为某种事件进入内核态,处理完这个事件就处理当前进程可以递达的信号。捕捉信号就是执行自定义信号处理函数,这个函数是在用户态,为了安全等问题,需要返回用户态去执行这个函数,再返回内核态,由内核态返回用户态。

        用户态和内核态会在下文中进行讲解,再次先做了解。

📂 signal

        signal函数就是用来捕捉特定信号的,提供自定义函数,来执行自定义动作。这里就和之前信号保存的内容连接在一起,将进程中函数指针表[signum]的函数指针 指向handler函数,当有该信号被递达时,就执行该函数。

        下图是就是捕捉2号信号,捕捉后打印内容:

#include <stdio.h>#include <signal.h>void handler(int signo){ std::cout << "get a signal : " << signo << std::endl;}int main(){ //对2号信号进行捕捉 signal(2,handler); while(true) { std::cout << "Running , pid : " << getpid() << std::endl; } return 0;}

📂 sigaction

        和signal函数一样,都是用来捕捉信号的,sigaction函数可以读取和修改指定信号相关联的处理动作。调用成功则返回0,出错返回-1。

        signum是指定信号的编号。若act不为空,则根据act修改信号的处理动作。若oact不为空,则通过oact传出该信号原来的处理动作,act和oact都是指向sigaction结构体。

        sigaction结构中,只需要处理3个参数即可,给出自定义的 sa_handler 函数, 初始化sa_mask,将sa_flags 初始化为0。

#include <iostream>#include <unistd.h>#include <signal.h>void handler(int signo){ std::cout << " get a signal" << signo << std::endl; exit(1);}int main(){ struct sigaction act,oact; act.sa_handler = handler; sigemptyset(&act.sa_mask); act.sa_flags = 0; sigaction(2,&act,&oact); while(true) { std::cout << "I am pid: " << getpid() << std::endl; sleep(1); } return 0;}

        将sa_handler赋值为SIG_IGN传给sigaction表示忽略信号,赋值为SIG_DFL表示执行系统的默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者想内核注册一个信号处理函数,该函数返回值void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用一个函数处理多种信号,显然这也是一个回调函数,不是被main函数调用,而是被OS调用。

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

📂 用户态和内核态

         在32位机器下,一个进程拥有4GB的虚拟地址空间,其中[0,3] 表示用户空间,[3,4] 表示内核空间,即OS本身就在我们进程的地址空间里,由内核级页表映射到物理内存且内核级页表只需要维护一份即可。访问OS,和访问库函数没有区别,但是OS不相信任何人,用户不能直接访问[3,4]地址空间,要受到一定约束,即只能通过系统调用。

        系统调用是OS提供的接口,存放在OS内,即进程虚拟地址空间中的[3,4]空间中。

         虚拟地址空间分为用户空间和内核空间,用户不能随便访问内核空间。例如,我们平时调用函数,直接在用户跳转到函数所在的起始地址即可,但是系统调用不同,它是存放在OS空间中。

        这是怎么做到用户不能随便访问内核空间的?

        CPU中有一个寄存器CS寄存器,这个寄存器主要用于存储当前正在执行指令的代码段的基地址。

        用户态切换到内核态,就是将CS寄存器中比特位将0改为1。用户想要跳转OS内核态,通过cs检测是否是0,如果是3,不允许访问。因此想要调用系统调用,就先要由用户态切换到内核态。

        因此,如果用户想要调用系统调用,先要检查CS寄存器比特位是否为0,如果不是改为0,访问内核空间。

        如果用户不使用系统调用,就不能访问到内核空间,因为寄存器的比特位为3,就不允许访问。

        用户态与内核态的切换是随时可能进行的。例如CPU要在一定的时间内,通知OS检查时间片,如果时间片到了就切换进程,此时就需要用户态与内核态的切换。

📁 可重入函数

        进程在执行函数时,可能会用户态与内核态进行切换,切换时进行信号检测,捕捉信号,在捕捉信号中再次调用该函数,引发了数据二义性问题,此时这个函数就是不可重入函数。

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

1. 调用了malloc或free,因为malloc也是用全局链表来管理堆的。

2. 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

📁 volatile关键字

        现代编译器会对程序进行优化,例如我们有一个全局函数,某一行调用后,此后不会再使用,影响后面的结果,此时编译器为了速度就不会一次一次内存中读取,而是将数据放在CPU寄存器中,即寄存器隐藏了内存中实际的值。

        此时,如果信号捕捉时修改了该变量,但是主函数访问该变量还是从CPU中读取,没有看到内存中修改后的数据,就引发了程序问题。

        volatile关键字的作用就是在变量前 + volatile ,要求编译器保持内存的可见性。

#include <signal.h>#include <iostream>#include <unistd.h>int g = 0;void handler(int signo){ std::cout << "get signal" << signo << std::endl; g = 1;}int main(){ signal(2,handler); while(!g) { ; } std::cout << "close" << std::endl; return 0;}

📁 SIGCHLD

        进程讲过用wait和waitpid函数等待子进程结束来清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞查询子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞就不能处理自己的工作;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下。

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

        此外,如果想不产生僵尸进程,还有一种方法:父进程调用sigaction将SIGCHLD的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生子进程,也不会通知父进程。系统的忽略动作和用户设置的SIG_IGN是不一样。

📁 总结

        以上就是进程信号的所有内容了,围绕信号的产生的发送,信号的保存,以及信号的处理讲解,每个阶段都有细分的小点,掌握这些,可以说对信号有了完全清晰的理解。

        最后,如果感觉本期内容对你有帮助,欢迎点赞,收藏关注Thanks♪(・ω・)ノ



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。