您的当前位置:首页正文

【Linux杂货铺】进程信号

2024-11-10 来源:个人技术集锦


?前言?

? 概念

? 总体认识

? 信号产生

 ? kill命令 

 ? 通过终端按键

 ? 系统调用函数

 ? 软件条件

 ? 硬件异常

? core 和 term

? 信号保存

? 信号的相关概念

? 内核表示

? sigset_t 

? 信号集操作函数

 ? sigprocmask

 ? sigpending

? 捕捉信号​编辑

? signal

? sigaction

? 用户态和内核态

? 可重入函数

? volatile关键字

? SIGCHLD

? 总结


? 概念

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

        在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. 野指针        

? core 和 term

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

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

        当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是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字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

? 用户态和内核态

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

        用户态切换到内核态,就是将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♪(・ω・)ノ

显示全文