什么是信号

在我们日常生活中也有很多信号,比如日常生活中的红绿灯,还有快递到驿站了,也是一个信号,这个信号是给人发送的,在linux系统中,信号是给对应的进程发送的

linux当中的信号

在我们日常编写代码中,我们经常会用到ctrl+c来结束程序的运行,像下面这样

这本质就是在给进程发送信号,键盘按下对应的组合键时,操作系统会首先知道键盘按下的组合键,然后会给对应的前台进程发送对应的组合键的信号前台进程因为收到信号,进⽽引起进程退出

对应人来说收到信号可以马上处理也可以先记录下来等下处理,对于进程来说也是这样的,进程也可以马上处理和等一下处理,信号的产生相对于进程的运行是异步的

这里先给出几个基本结论

1.在信号产生之前,早就知道遇到对应的信号该如何处理了

2.信号能处理,但不是立即处理,而是可以等一下再处理,合适的时候再进行处理

3.人能识别信号,是接收过教育的,而进程也是如此,程序员设计的进程早就内置了遇到对应的信号该如何处理

4.信号源非常多(这里下面会详细说明)

信号的产生

我们可以先看看信号有哪些

下面是信号对应的宏定义

这里有很多种信号,分实时信号,和普通信号,这里主要讲解普通信号

比如说按下ctrl+c就是给进程发送信号,让他自己终止,相当一部分的信号都是终止信号

当进程收到信号的时候有多种处理方式,一种是收到信号做默认处理动作,二是自定义处理动作,三是忽略处理。

我们可以来尝试看看信号的产生,这里我们可以试一下更改信号的默认动作

这里要介绍一个新函数signal

作用:注册信号处理函数,用来指定收到某个信号后程序做什么,信号不再执行默认的方法,反而执行自定义的函数中指针指向的方法。

下面来看看代码

# include<iostream>
# include<signal.h>
void hander(int sg)
{
    std::cout<<"我是2号信号,我被替换了"<<std::endl;
}

int main()
{
    signal(SIGINT,hander);
    while(1)
    {
        ;
    }
    return 0;
}

运行结果

我们发现对应的进程在使用ctrl+c时无法退出,而直接被替换成了我们的自定义hander函数的处理动作

这样我们就看到了信号产生的过程,我们要知道信号是发送给目标进程的,那么目标进程又是谁呢,下面来说一下前后台进程

 前台进程和后台进程

我们要知道的一点就是操作系统给进程发送信号,是发送给对应的前台进程的,后台进程是收不到的,继续复用刚才的代码,我们在后面加一个 &这样进程就是后台进程了

我们发现我们无论怎么按ctrl+c都无法打印刚才的那一句话

总结:

1.后台进程无法从标准输入中获取内容!

2.前台进程能从键盘获取标准输入

为什么?

因为键盘只有一个输入的数据一定是给指定进程的,前台进程必须只有一个

当我们启动linux时候,linux系统会创建一个bash的进程这个bash就是一个前台进程等待用户的输入,前台进程不能没有,不然键盘就没法用了!

我们以前用父进程创建子进程时,父进程没有等待回收子进程,自己先退出了,这时候子进程就变成了孤儿进程,我们用ctrl+c也就终止不了了,这时候子进程就变成了后台进程,等待操作系统回收或者用kill杀掉

说完了我们再来谈谈下面的问题

什么叫做给进程发信号

系统调用

信号产生之后并不是立即处理的,所以进程要把接收到的信号记录下来。记录的目的是为了在合适的时候处理。

那么记录在哪里?在task_struct sigs里面。

struct task_struct
{
    unsigned int sigs;
}

为什么用一个无符号整型就可以了,答案是这个是一个位图,用32个比特位来表示,比特位来表示编号,编号是0就没有接收到这个信号,编号是1就代表接收到了,位图存放在task_struct里面而task_struct又属于内核,修改位图的本质就是在修改内核数据结构

发送信号本质就必须是操作系统来发送,而kill函数又是用c语言写的,本质就是系统调用

下面来介绍系统调用kill

第一个参数是进程的id号,第二个是信号

我们写一个代码看看

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

int main()
{
    pid_t pid = getpid();
    std::cout<<"pid = :"<<pid<<std::endl;
    int count = 5;
    while(true)
    {
        if(count == 0)
        {
            std::cout<<"杀掉自己"<<std::endl;
           kill(pid,9);
        }
        std::cout<<count<<std::endl;
        count--;
    }
    return 0;
}

这个代码就是来让进程自己杀死自己的,调用系统调用kill来完成。当然也可以是其他的pid号

我们来看看运行结果

当然自己给自己发送信号也可以使用raise函数

raise函数介绍,只需需要一个参数就是要发送哪一个信号。

这里我们再试一试发送9号信号

我们要知道的重要一点就是9号信号无法被自定义捕捉!

我们可以来测试一下调用signal函数

看看下面的代码

void sig_hander(int sig)
{
    std::cout<<"我是信号"<<sig<<"我被替换了"<<std::endl;
}

void test_sig()
{
    signal(SIGKILL,sig_hander);
    while(true)
    {
        std::cout<<"我是进程  "<<getpid()<<std::endl;
        sleep(1);
    }
}

int main()
{
    test_sig();
    return 0;
}

我们发现9号信号没有被捕捉调用kill仍然能被杀死

上面我们说了系统调用是信号产生的一种方式,下面我们来说一下另一种方式

硬件(异常)

我们先来模拟一下野指针

int main()
{
    int* ptr = nullptr;
    *ptr = 1;
    return 0;
}

运行会报错

操作系统是怎么知道他是空指针访问报错的,我们看看下面的图片

总结一下:

  • 进程内栈上有指针变量,赋值为 NULL(虚拟地址 0);执行*ptr解引用,访问虚拟地址 0。
  • MMU 拿虚拟地址查该进程的页表,虚拟地址 0 没有建立页表映射,没有对应物理内存
  • MMU 触发缺页硬件异常。
  • 操作系统知道是用户程序非法访问无效虚拟地址。
  • 操作系统内核向该进程发送 SIGSEGV,11 号段错误信号
  • 进程处理信号,SIGSEGV 默认动作:终止进程,我们看到程序崩溃。

还有就是/0操作同样也是硬件报错

CPU 的除法电路只能计算合法范围的整数除法,硬件没有定义除数为 0 的行为。

  1. CPU 停止正常执行,立刻抛出除法错误硬件异常
  2. 交给操作系统处理
  3. Linux 内核识别这是除零异常,向进程投递 SIGFPE(8)
  4. 进程收到 8 号信号,默认终止程序

软件产生的条件信号

同样软件也会产生信号,存在一个系统调用alarm闹钟

当闹钟时间到了的时候,它就会为当前进程推送一个信号

假设alarm(6)定一个6秒钟的闹钟,当时间到了之后会为当前进程发送一个信号

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

我们来看看

int main()
{
    alarm(5);
   while(1)
   {
    std::cout<<"hello,world"<<std::endl;
    sleep(1);
   }
    return 0;
}

这个函数也是有返回值的

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

我们可以由此来设计一个功能,让进程每一秒都执行一件事情

void hander(int sig)
{
    std::cout<<"我是一个任务"<<std::endl;
    alarm(1);
}

int main()
{
    int pid = getpid();
    alarm(1);
    signal(SIGALRM,hander);
    while(true)
    {
        std::cout<<"我是一个进程我的pid= :"<<pid<<std::endl;
        sleep(1);
    }
    return 0;
}

pause接口

阻塞、挂起当前进程,进入可中断睡眠,一直等待信号送达
 

也就是说这个程序会暂停直到信号被捕捉

有了对于上面两个接口的理解,我们可以推断出一个结论:让我们的进程一直处于暂停状态,受外部信号的驱动每隔一秒发送一个信号,信号到来时它会执行对应的任务,这不就是操作系统实现的功能吗?操作系统受到外部刺激作为驱动,让操作系统完成某些事情,下面我们模拟实现一个简单的样例

void send()
{
    std::cout<<"我是一个发送的任务"<<std::endl;
}

void get()
{
    std::cout<<"我是一个接收的任务"<<std::endl;
}

void task()
{
    std::cout<<"我是任务管理的任务"<<std::endl;
}

std::vector<std::function<void()>> f;

void func()
{
    f.push_back(send);
    f.push_back(get);
    f.push_back(task);
}

void hander(int sig)
{
    std::cout<<"##################################"<<std::endl;
    for(auto &a : f)
    {
        a();
        sleep(1);
    }
    std::cout<<"##################################"<<std::endl;
    alarm(1);
}

int main()
{
    func();
     alarm(1);
    signal(SIGALRM,hander);
    while(1)
    {
        pause();
    }
    return 0;
}

快速理解闹钟:

我们知道一个进程可以设置闹钟,操作系统中会有多个进程同时运行,那么操作系统也会把每一个闹钟先描述再组织起来,下面就是闹钟的结构

struct timer_list
{
    struct list_head entry;
    unsigned long expires;    
    void (*function)(unsigned long);
    unsigned long data;
    struct tvec_t_base_s *base;
};

闹钟的原理:

闹钟是用一个叫做最小堆组织起来的,我们知道在计算机里面有一个东西叫做时间戳,当当前的时间戳大于当前堆顶闹钟设置的值时,则堆顶的闹钟出堆,执行SIGARM,所以闹钟超时之后发送的信号叫做软件产生的条件信号。

总结

信号的产生大体可以概括成以下内容

1.键盘产生信号

2.系统调用产生信号

3.系统命令产生信号

4.硬件异常产生信号

5.软件条件产生信号

他们的本质就是修改位图比特位

信号的保存

说保存之前先来说一说下面的概念

实际执⾏信号的处理动作称为信号递达(Delivery)
信号从产⽣到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动 作。

信号抵达就是进程收到信号,并执行成功了,执行方式有三种:自定义,默认,忽略(忽略也是一种执行方式)

信号未决就是:信号在位图中还没来得及处理

上面说到的概念都能在内核中体现

我们来看看下面的三张表

前面的两个表其实就是两个位图,后面是一个函数指针数组

bolck表叫做阻塞表,他是一个位图

他代表要阻塞哪些信号1代表阻塞0代表不阻塞

pending表也叫信号未决表,他是一个位图

1代表收到了信号,但没有递达,直到递达了才会解除 0代表没有收到信号。

pending表按位与上block表就代表哪些可以信号可以直接递达

我们调用signal函数更改信号的默认处理动作,本质就是在修改后面的函数指针数组,把对应信号默认的函数指针修改成我们更改的函数。

所以当前有没有信号,有没有被阻塞,怎么处理我们就知道了。这三张表合起来,共同承担了进程怎么识别信号。

这个表应该横着看从上到下一供有31个描述信号的关系

sigset_t

从上图来看,每个信号只有⼀个bit的未决标志, ⾮0即1, 不记录该信号产⽣了多少次,阻塞标志也是这样 表⽰的。因此, 未决和阻塞标志可以⽤相同的数据类型sigset_t来存储, , 这个类型
可以表⽰每个信号的“有效”或“⽆效”状态, 在阻塞信号集中“有效”和“⽆效”的含义是该信号
是否被阻塞, ⽽在未决信号集中“有 效”和“⽆效”的含义是该信号是否处于未决状态。下面详细介绍信号集的各种操作。阻塞信号集也叫做当前进程的 这⾥的“屏蔽”应该理解为阻塞⽽不是忽略。

信号操作集函数

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置位,表⽰ 该信号集的有效信号 包括系 统⽀持的所有信号。
注意,在使⽤sigset_ t类型的变量之前,⼀定要调 ⽤sigemptyset或sigfillset做初始化,使信号集处于 确定的 状态。初始化sigset_t变量之后就可以在调⽤sigaddset和sigdelset在该信号集中添加或删 除某种有效信号
当我们调用完上面的函数想对当前进程设置对应信号的接收规则之后,我们真的就完成了对当前进程关于信号的修改了吗?答案是不是的,我们的函数是创建在栈上的,而上面的函数本质上是在对sigset_t类型进行修改,还没有传递到对应的内核中去,内核更新进程 PCB 里的blocked阻塞信号集需要调用sigprocmask才行

sigprocmask

调⽤函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是⾮空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是⾮空指针,则 更改
进程的信 号屏蔽字,参数how指⽰如何更改。如果oset和set都是⾮空指针,则先将原来的信号 屏蔽字 备份到oset⾥,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了 how参数的可选值。

sigpending

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

为什么sigpending只能读取不能修改呢?因为我们修改pending位图,本质是由键盘,系统调用,系统命令,硬件异常,软件条件来修改的,pending(未决信号位图)写入权在内核,用户进程无权直接修改,而上面说的那些事件本质是由内核去触发修改!

我们下面来看一个小实验,来看看pending位图的修改的过程

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

void hander(int sig)
{
    std::cout<<"抵达"<<sig<<"信号"<<std::endl;
    std::cout<<"调用二号信号"<<std::endl;
}

void printpedding(sigset_t &pending)
{
    pid_t id = getpid();
    printf("pid= %d:\n",id);
    for(int i = 31;i>=1;i--)
    {
        if(sigismember(&pending,i))
        {
            std::cout<<"1";
        }
        else
        {
            std::cout<<"0";
        }
    }
    std::cout<<std::endl;
}

int main()
{
    signal(SIGINT,hander);

    sigset_t block;
    sigset_t oblock;
    sigemptyset(&block);
    sigemptyset(&oblock);

    sigaddset(&block,SIGINT);
    
    sigprocmask(SIG_BLOCK,&block,&oblock);

   int count = 5;
    while(true)
    {
      sigset_t pending;
      int m = sigpending(&pending);
      printpedding(pending);

        if(count == 0)
        {
            std::cout<<"解除2号信号的屏蔽"<<std::endl;
            sigprocmask(SIG_SETMASK,&oblock,&block);
        }
        count--;
        sleep(1);
    }
    return 0;
}

上面的代码我们首先会更改2号信号的行为,然后初始化sigset_t biock oblock 然后往block信号集里面阻塞对应的2号信号调用sigprocmask修改内核,然后一直打印pending表,五秒钟之后解除对二号信号的屏蔽,来看看现象

当我们第一次按ctrl+c去触发2号信号时,我们看到pending未决信号表由0变1,当解除对2号信号的屏蔽之后,信号马上抵达,调用我们自己的hander函数,这样我们才能看到对应的信号由0变1的过程

进程终止的方式

core vs trem

term 和 Core 是 Linux 信号的两种默认终止动作,二者都会杀死进程,唯一差别:Core 在杀死进程前生成 core 核心转储文件,Term 直接结束,不生成转储文件

这个为什么要生成核心转储文件,这个是方便debug的,当我们用对应的调试器打开对应的代码调试时,由core核心转储文件能够让我们快速定位到出错的地方,这个又叫做事后调试。

我们看看之前的进程等待的这张图片,次底八位代表进程退出码,而第七位就有是否设置核心转储

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐