Linux信号的诞生与归宿:内核如何管理信号的生成、阻塞和递达?

发布于:2025-03-22 ⋅ 阅读:(13) ⋅ 点赞:(0)

个人主页:敲上瘾-CSDN博客

个人专栏:Linux学习游戏数据结构c语言基础c++学习算法

目录

一、认识信号

二、信号的产生

1.键盘输入

2.系统调用

3.系统指令

4.硬件异常

5.软件条件

三、信号的保存

1.block

2.pending

3.handler

四、信号的捕捉

五、核心转储

六、从不可重入函数

七、特殊信号

9号和19号信号

SIGCHIL信号


一、认识信号

        什么是信号?信号是一种异步事件通知机制,类似于生活中的红绿灯、闹钟、电话铃等,用于中断当前任务并提醒处理新事件。

        注:异步就是「发起一个任务后不用干等着,先做别的事,等结果好了再回来处理」

类比生活中的信号,我们来理解一下进程中信号相关的基本结论如下:

  • 进程在信号没有产生时就知道各个信号该如何处理了。
  • 信号产生后不必立即处理,可以稍等一会,合适的时候处理。
  • 进程内已经内置了对信号的识别和处理机制。
  • 信号种类很多,产生信号的方式也很多。

信号的处理有这三种方式:

  • 默认处理方法
  • 自定义处理方法
  • 忽略处理

二、信号的产生

在命令行中查找信号的相关信息,使用如下指令:

kill -l

我们可以得到这样一张表:

注意:这里的信号个数并不是64个,如上表中并没有32和33信号。

其中1~31为普通信号,34~64为实时信号,在这里我们只探讨普通信号。 

1.键盘输入

        在我们运行程序时通常会用Ctrl+c来使程序退出,这其实是向前台程序发送2号信号。除此之外还有Ctrl+\,表示发送3号信号,同样是让程序退出,2号信号与3号信号的区别将在下文核心转储部分详细讲解。

        Ctrl+z:发送20号信号,让程序暂停。

这些就是通过键盘发送信号的一种方式,如何验证呢?

我们可以使用以下函数:

signal函数用于改变信号的处理方法,即自定义信号处理方法。

signal声明:

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
  • 参数signum:传入一个信号编号或信号名称。
  • 参数handler:传入自定义的信号处理方法,即一个返回类型为void*,参数为int类型的函数。
  • 返回值: 返回 旧的信号处理函数(函数指针)

注:signal内部会将signum作为参数传入handler函数。

测试代码: 

void handler(int sig)
{
    cout<<"正在处理"<<sig<<"号信号"<<endl;
}
int main()
{   
    int cnt=0;
    signal(SIGINT,handler);
    while(true)
    {
        cout<<"Run: "<<cnt++<<endl;
        sleep(1);
    }
}

注意:由于2号信号处理方法已被改变,Ctrl+c无法杀死程序,可以使用Ctrl+\。 

        当有多个程序在运行时,会分为前台和后台程序,前台程序只有一个,后台程序可以有多个键盘输入的信息只能被前台程序读取。例如,上述代码生成的可执行程序test,当我们运行test时它默认是前台程序,ls,cd,mkdir等指令会失效。因为shell命令行程序已经切换到后台了。

  • ./test:放在前台运行。
  • ./test &:放在后台运行。

切换前后台程序的方法:

方法一:

  1. jobs:查看所有后台任务。
  2. fg 任务号:特定的进程提到前台。

方法二:

  1. Ctrl+z:暂停当前进程,然后自动把后台提到前台
  2. bg 任务号:把刚才暂停的任务恢复运行。

2.系统调用

除了键盘产生信号,我们还可以直接用kill、raise、abort这些接口向系统发信号。

使用方法如下(这里我们暂不对返回值进行讨论):

kill函数声明:

int kill(pid_t pid, int sig);
  • 参数pid:传入需要发信号的进程pid。
  • 参数sig:传入需要发送的信号编号。 
  • 功能:向任意进程发送信号

raise函数声明:

int raise(int sig);
  • 参数sig:传入需要发送的信号编号。
  • 功能:向自己发送信号

abort函数声明:

void abort(void);
  • 功能:向自己发送6号信号

我们同样可以使用改变信号处理的方法来验证。 这里就不展示。

3.系统指令

kill 信号编号 进程pid

使用kill指令向指定的进程发送指定的信号。 

4.硬件异常

        发送信号方式还有硬件异常,比如引用空指针,除0等等这些非法操作最终是反应到了硬件上,然后产生信号。比如我们可以这样做测试:

void sig_handle(int sig)
{
    cout<<"接收到信号:"<<sig<<endl;
    exit(1);
}
int main()
{
    for(int i=1;i<32;i++)
        signal(i,sig_handle);
    int a = 10;
    a /= 0;
    //int* p = nullptr;
    //*p = 10;
    return 0;
}

除0触发8号信号,引用空指针触发11号信号。

5.软件条件

        软件条件触发信号,比如alarm, alarm函数是一个用于设置定时器的系统调用,主要作用是让内核在指定的时间后向进程发送SIGALRM信号。它的核心功能是提供一种简单的超时机制或定时任务调度。

alarm声明:

unsigned int alarm(unsigned int seconds);
  • 参数seconds:是定时器倒计时时间(单位:秒)。若为 0,表示取消之前设置的定时器。
  • 返回值:之前未完成的定时器剩余时间(秒)。例如:如果之前设置了 5 秒的定时器,3 秒后再次调用 alarm(2),返回值为 2(剩余时间),新定时器将在 2 秒后触发。

三、信号的保存

        在开篇就提到信号并不一定是产生后就马上被处理的,所以需先将它保存下来。而信号又分为两种状态:信号未决,信号递达

  • 信号未决:信号被保存但没有被处理。
  • 信号递达:信号被处理。

进程可以阻塞信号,被阻塞的信号产生时会保持在未决状态,直到解除阻塞才能被递达。

注:阻塞和忽略是不同的,忽略是在递达后的一种处理方式。

在程序中信号的相关信息会被保存在block、pending、handler这三张表中。

  • block表:记录的是信号的阻塞状态。
  • pending表:记录的是未决情况。
  • handler表:储存的是信号的处理方法。

        从上图来看,每个信号只有⼀个bit的未决标志,⾮0即1,不记录该信号产⽣了多少次,阻塞标志也是这样表⽰的。因此,未决和阻塞标志可以⽤相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表⽰每个信号的“有效”或“⽆效”状态,在阻塞信号集中“有效”和“⽆效”的含义是该信号是否被阻塞,⽽在未决信号集中“有效”和“⽆效”的含义是该信号是否处于未决状态。

        阻塞信号集也叫作当前进程的“信号屏蔽字”。

1.block

关于信号集的处理函数有这些:

  • int sigemptyset(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清零,表示该信号集不包含任何无效信号
  • sigaddset:添加无效信号。
  • sigdelset:删除无效信号。
  • sigismember:查看一个信号是否有效,返回0表示有效,返回1表示无效。

block表储存的是信号的阻塞状态,用的是位图的原理,1表示阻塞,0表示未阻塞。

        以上这些函数只是用来设置信号集,接下来使用函数sigprocmask把信号集设置到程序中,使其信号屏蔽字改变。

sigprocmask函数声明如下:

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

参数how:需要我们传入一个可选参数,表示要做的操作,这个参数可以是:

mask表示的是程序当前的信号屏蔽字,这里我们最常用的是SIG_SETMASK选项

参数set:把设置好的信号屏蔽字(类型为signal_t*)传入。

参数oldset:这是一个输出型参数,获取到旧的信号屏蔽字。

测试代码:

void handler(int sig)
{
    cout<<"正在处理"<<sig<<"号信号"<<endl;
}
int main()
{ 
    signal(2,handler);
    sigset_t block,oblock;
    sigemptyset(&block);
    sigaddset(&block,SIGINT);//屏蔽2号信号
    sigprocmask(SIG_SETMASK,&block,&oblock);
    while(true)
    {
        cout<<"hello linux"<<endl;
        sleep(1);
    }
    return 0;
}

2.pending

        pending这张表用来标记信号是否处于未决状态。函数sigpending可以获取pending表

声明如下:

int sigpending(sigset_t *set);

参数set:这是一个输出型参数,用来获取到pending表的信息。

然后我们可以借助setismember来打印pending表的信息。 

测试代码:

int main()
{
    sigset_t sig;
    sigpending(&sig);
    for(int i=31;i>=1;i--)
    {
        //判断i号信号是否未决
        if(sigismember(&sig,i))
            cout<<1;
        else 
            cout<<0;
    }
    return 0;
}

注:一个信号在即将要被处理前会把pending表对应的bit位改为0,而不是在处理完后修改。

3.handler

        handler表是一个函数指针数组,储存了每一个信号的处理方式。SIG_DEL表示默认处理,SIG_IGN表示忽略处理,然后还可以使用函数signal设定自定义处理方法。

其中SIG_DEL,SIG_IGN可作为参数传入signal函数中。

除了使用signal函数设置自定义处理方法外,还可以使用sigaction。

sigaction声明如下:

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

其中sigact是一个结构体类型,声明如下:

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:在处理该信号的过程中需要阻塞的信号。

        其它成员变量用得很少这里就不再探讨。所以与signal接口相比,sigaction并表示简单的设置自定义处理方法,它还能做更复杂的处理。

关于sigaction的参数:

  • 参数signum:需要设置的信号编号
  • 参数act:传入一个自定义的struct sigaction类型的地址
  • 参数oldact:一个输出型参数,获取到旧的struct sigaction信息。

测试代码:

void handler(int sig)
{
    cout << "收到信号" << sig << endl;
    while (true)
    {
        sigset_t s;
        sigpending(&s);
        for (int i = 31; i >= 1; i--)
        {
            if (sigismember(&s, i)) cout << '1';
            else cout << '0';
        }
        cout << endl;
        sleep(1);
    }
}
int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, 2);
    sigaddset(&act.sa_mask, 3);
    sigaction(SIGINT, &act, &oact);
    while (true)
    {
        cout << "hello linux! my pid:" << getpid() << endl;
        sleep(1);
    }
    return 0;
}

测试结果:

四、信号的捕捉

        接下来我们来学习信号的处理,在此之前最好对CPU中断机制有所了解,可以通过下面这篇文章进行学习:操作系统的心脏节拍:CPU中断如何驱动内核运转?-CSDN博客

        如上图是系统处理自定义信号的流程图,而默认处理和忽略处理就比较简单,只到第3步。

        注:无论你写的程序是否有系统调用,是否触发异常等 都有机会进入内核状态,因为在CPU中还存在着时钟中断能多次使你的程序陷入内核。

我们可以把自定义信号处理中状态的转化抽象成这样一个图:

        其中用户态和内核态之间做了四次转化,如图红圈部分,而pending表的检查是在内核态内完成的。

五、核心转储

我们通过输入以下指令可以看到信号相关的信息:

man 7 signal

如下Action这一栏的表示默认行为

标识符 全称 含义
Term Terminate 终止进程。进程会立即终止。
Core Core Dump + Terminate 生成核心转储文件并终止进程。进程终止时生成 core 文件(用于调试)。
Ign Ignore 忽略信号。进程不会采取任何动作。
Cont Continue 恢复进程执行。如果进程被暂停(如 SIGSTOP),则恢复运行。
Stop Stop 暂停进程。进程会被挂起,直到收到 SIGCONT 信号。

        之前我们说过Ctrl+c和Ctrl+\都是使进程退出,但并没有讲它们的区别,其实就是Ctrl+\会比Ctrl+c多产生一个core文件,它是把内核中核心数据转储到磁盘上,这个文件可能储存到当前路径,也有可能储存到路径:/var/lib/systemd/coredump中。

        但在一般情况下是生成不了这个core文件的,因为云服务器出于 安全性、资源管理 和 合规性 的考虑,默认关闭了核心转储(Core Dump)。比如恶意用户可能故意触发程序崩溃,生成大量核心转储文件,耗尽磁盘空间,导致系统瘫痪。

通过以下指令可以看到关于core dump的信息:

ulimit -a

如下:

        我们看到code file size为0,表明核心转储已经被关闭了,可以通过ulimit -c指令临时打开,并设置大小。

比如:

ulimit -c 40960

debug:

core文件有什么作用呢?

        我们让程序生成core文件通常是用来查找bug的,使用gdb打开出bug的程序,然后输入指令 core-file core后程序能跳转到出问题的具体代码的位置。

core dump标志位:

在使用waitpid回收子进程时,其中有一个输出型参数,用来获取⼦进程退出状态。如下:

        这里第8个比特位记录的就是是否生成core文件,1表示生成core文件,0表示没有生成。

六、从不可重入函数

        在我们执行程序过程中,可能任务执行到一半就因接收到信号,而先去处理信号了。那么如果程序和信号处理的是同一个数据呢,会出现什么问题?

         像这样会被两个及以上的执行流同时调用而发生不可预料的结果的函数被称为不可重入函数,需要警惕这样的事情发生。而函数内部只有自己的临时变量,这样的函数是可重入的

七、特殊信号

9号和19号信号

  • SIGKILL(9) 的默认行为是 立即终止进程

  • SIGSTOP(19) 的默认行为是 强制暂停进程(进入停止状态,直到收到SIGCONT)。

        这两个信号的默认行为是操作系统强制执行的,进程无法干预,也就是无法对它们进行阻塞、忽略、自定义处理方法等。

        这是出于操作系统的 安全性和稳定性 考虑,试想一下如果所有信号都可以被阻塞、忽略或自定义处理方法。那么我们就可以做这么一个恶意程序,把所有信号都阻塞了,然后写一个死循环,那么程序不就无法退出了吗?还可以更狠一点,在循环内不断申请内存空间。

所以这样的设计可以防止恶意进程失控,为管理员提供终极控制权。

SIGCHIL信号

17号信号(SIGCHIL)是在子进程退出后向父进程发送的。

        当我们知道这一点我们就可以自定义17号信号的处理方法,让父进程对子进程的等待操作在信号处理里面完成,这样父进程就不用去关心子进程的回收问题,从而实现异步功能

代码示例:

void handler(int sig)
{
    while(true)
    {
        int n = waitpid(-1,nullptr,WNOHANG);
        if(n==0) break;
        else if(n<0)
        {
            perror("waitpid");
            exit(1);
        }
        else
            cout<<"wait success: "<<n<<endl;
    }
}
int main()
{
    signal(17,handler);
    for(int i=0;i<10;i++)
    {
        sleep(1);
        int id=fork();
        if(id==0)
        {
            cout<<"child process:"<<getpid()<<" exit "<<endl;
            sleep(1);
            exit(1);
        }
    }
    return 0;
}

        我们回想一下操作系统为什么要在子进程退出后设计一个僵尸进程让用户主动回收呢?子进程退出后操作系统直接把它回收不好吗?

        其实这样设计是很合理的,我们创建子进程不就是让子进程异步去帮我们完成任务嘛,那么它完成得怎么样我们总应该要知道,所以才有了僵尸进程来储存任务的完成情况。而当我们并不关心子进程的任务完成情况时,那么是不是就用不着僵尸进程这种机制啊?

        答案是:是的!所以操作系统也为我们设计了一种不生用成僵尸进程的方法。

        只需要把17号信号的处理方法设置为忽略处理,即SIG_IGN(上文handler部分已讲解),这样操作系统就不会给我们生成僵尸进程。

        细心的读者可能会发现,17号信号的默认行为就是Ign(忽略)吗?在信号信息表的Action这一栏可以找到。

        要注意用户不做任何自定义信号处理时,所有信号都是默认处理方式(即SIG_DFL),而17号的默认行为是Ign而已。和忽略处理(即SIG_IGN)是不同的,是否忽略必须让用户自己指明。

非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!74c0781738354c71be3d62e05688fecc.png