[Linux]信号

●个人主页:你帅你先说.
●欢迎点赞关注收藏
●既选择了远方,便只顾风雨兼程。
●欢迎大家有问题随时私信我!
●版权:本文由[你帅你先说.]原创,CSDN首发,侵权必究。

为您导航

  • 1.信号概念
  • 2.产生信号
    • 2.1键盘产生信号
    • 2.2进程异常产生信号
    • 2.3系统调用产生信号
    • 2.4软件条件产生信号
  • 3.阻塞信号
    • 3.1信号其他相关常见概念
    • 3.2信号在内核中的结构
    • 3.3 sigset_t
  • 4.volatile
  • 5.SIGCHLD信号

1.信号概念

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

我们首先要明确的是信号和信号量是完全不同的两个概念,他们没有关系,只是名字很像而已。
信号我们很好理解,就是字面意思。
比如你正在上某节课时,突然一声"TiMi~~",这个时候你就明白有人上号了,这里的"TiMi"就是一种信号。
我们来看看Linux的常见信号。
[Linux]信号_第1张图片
我们发现,虽然标有64,但不是连续的,31以后直接跳到了34,实际上有62个信号,前31个信号我们叫做普通信号,后31个我们叫做实时信号

2.产生信号

2.1键盘产生信号

[Linux]信号_第2张图片
Linux系统提供了一个可以修改进程对信号的默认处理动作。
我们用代码来使用一下这个函数
[Linux]信号_第3张图片
[Linux]信号_第4张图片
刚开始我们没有发送信号时,函数没有被调用,但我们在键盘上敲出Ctrl C,系统就接收到了信号。
信号的产生方式其中一种就是通过键盘产生的信号,只能用来终止前台进程。
总结:
进程收到信号的处理方案有三种情况。
1.默认动作—一般是终止自己、暂停等。
2.忽略动作—是一种信号处理的方式,只不过动作就是什么也不干。
3.自定义动作(信号的捕捉)—我们刚刚用signal方法,就是在修改信号的处理动作,由默认动作变成自定义动作。(注意:9号信号不可以被捕捉,即不能被自定义)
在Windows 或 Linux下,进程奔溃的本质是进程收到了对应的信号,然后进程执行信号的默认处理动作(杀掉进程)。
为什么会发送信号?
在操作系统中,软件上面的错误,通常会体现在硬件或其他软件上。当CPU发现硬件被破坏了就会发送信号来终止进程。
当你程序崩溃时,你肯定会收到崩溃的原因,这个崩溃的原因我们之前讲过,是在waitpid里status的低七位存储的。
总而言之,在Linux中,当一个进程正常退出的时候,它的退出码和退出信号都会被设置。当一个进程异常退出时,进程的退出信号会被设置,表明当前进程退出的原因。有的时候,OS会设置退出信息中core dump标志位,并将进程在内存中的数据转储到磁盘中,方便我们后期调试。

2.2进程异常产生信号

我们来看看怎么用status来查看退出信息。
[Linux]信号_第5张图片
[Linux]信号_第6张图片
我们知道0是不能做除数的,所以对应8号信号的浮点数错误。
[Linux]信号_第7张图片

2.3系统调用产生信号

除了我们在命令行上敲

kill 信号 pid 

来控制信号外,我们还可以通过系统调用接口kill()来操控信号。
[Linux]信号_第8张图片
我们发现这个函数使用起来非常容易,只需要pid和信号就可以执行。下面来看看kill的使用方法。
[Linux]信号_第9张图片
命令行中我们输入 ./xxxx 信号 pid,所以argc是3。
[Linux]信号_第10张图片
[Linux]信号_第11张图片

2.4软件条件产生信号

通过某种软件来触发信号的发送。
例如在系统层面设置定时器,或者某种操作而导致条件不就绪等这样的场景下,触发的信号发送。
我们之前将进程间通信时,当读端不读,还关闭了fd,写端一直在写,最终写进程会收到sigpipe(13),这就是一中典型的软件条件触发的信号发送。

我们再来认识个接口
[Linux]信号_第12张图片
设置在seconds秒后发送一个SIGALRM信号。
**这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。**当seconds等于0时表示取消闹钟。
前面说了那么多,总结一下就是,产生信号有三种方式:
1.键盘产生信号。
2.进程异常产生信号。
3.通过系统调用产生信号。
4.软件条件产生信号。
学到这里,我们可能还是有困惑,OS系统是如何给进程发送信号的?
实际上是task_struct里定义了用于保存记录是否收到了对应信号的变量,在这里用到了我们之前在文件系统里所讲的位图结构,但接收到信号就把该信号置为1,没收到则为0。所以OS给进程发送信号的本质是向指定进程的task_struct中的信号位图写入bit位。与其说是发送信号,不如说是写入信号。

3.阻塞信号

3.1信号其他相关常见概念

  • 实际执行信号的处理动作称为信号递达(自定义捕捉、默认、忽略)
  • 信号从产生到递达之间的状态,称为信号未决(本质是这个信号被暂存在task_struct信号位图中,未决)。
  • 进程可以选择阻塞 (Block )某个信号(本质是OS系统允许进程暂时屏蔽指定的信号,该信号依旧是未决的,该信号不会被递达,直到解除阻塞才能递达)。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

3.2信号在内核中的结构

[Linux]信号_第13张图片
pending位图实际上就是我们刚刚讲的用来标识是否接收到了信号。(已经收到但是还没有被递达的信号)

3.3 sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

信号集操作函数

#include 
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在该信号集中添加或删除某种有效信。

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

#include 
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1
//函数的本质就是修改block位图

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

[Linux]信号_第14张图片
来看段代码

#include 
#include 
#include 
#include 
void handler(int signo)
{
    while(1)
    {
        printf("get a signo: %d\n", signo);
        sleep(1);
    }
}
int main()
{
	sigset_t iset,oset;
	
    sigemptyset(&iset);
    sigemptyset(&oset);

    sigaddset(iset, 2);
    //sigaddset(iset, 9);9号信号不可被屏蔽!
	//设置屏蔽字
    sigprocmask(SIG_SETMASK,&iset,&oset);
    while(1)
    {
        printf("hello world!\n");
        sleep(1);
    }
    return 0;
}

[Linux]信号_第15张图片
此时2号信号对应的Ctrl C键就失效了。
sigpending

#include 
sigpending(sigset_t * set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1//不对pending位图修改,只获取进程的pending位图

接下来我们来实现一个功能。
首先屏蔽掉2号信号,不断的获取当前进程的pending位图,并打印显示,手动发送2号信号(信号不会被递达),然后再不断的获取当前进程的pending位图,并打印。

#include 
#include 
#include 
#include 
void show_pending(sigset_t *set)
{
   printf("curr process pending: ");
   for(int i = 1; i <= 31; i++)
   {
       if(sigismember(set, i))
       {
           printf("1");
       }
       else
       {
           printf("0");
       }
   }

   printf("\n");
}

void handler(int signo)
{
   printf("%d 号信号被递达了,已经处理完成!\n", signo);
}
int main()
{
 
   signal(2, handler);

   sigset_t iset, oset;

   sigemptyset(&iset);
   sigemptyset(&oset);

   sigaddset(&iset, 2);

  
   sigprocmask(SIG_SETMASK, &iset, &oset);

   int count = 0;
   sigset_t pending;
   while(1)
   {
       sigemptyset(&pending);

       sigpending(&pending);

       show_pending(&pending);

       sleep(1);

       count++;

       if(count == 10)
       {
           //解除对2号信号的屏蔽
           sigprocmask(SIG_SETMASK, &oset, NULL);
           //2号信号的默认动作是终止进程,所以看不到现象
           printf("恢复2号信号,可以被递达了\n");
       }
   }
   
   return 0;
}

现象我就不演示了,在这里要注意一点,当信号被递达,在pending位图中就会由0置1。

实际上,接收到的信号不一定是马上处理的,有的时候进程正在处理更重要的事,这时信号就会被延时处理。
那信号什么时候被处理(检测,递达(默认、忽略、自定义)?
信号是被保存在进程的PCB中,即pending位图里面。当进程从内核态返回到用户态的时候,进行检测和处理工作。

内核态:执行OS的代码和数据时,计算机所处的状态叫做内核态,OS的代码的执行全部都是在内核态。
用户态:用户代码和数据被访问或者执行的时候所处的状态。我们自己写的代码全部都是在用户态执行的!

用户调用系统函数后,除了进入函数,身份也会发生变化,用户身份变成内核身份。
到这可能大家还是很懵,这个用户态和内核态究竟是什么?
前面我们说过每个进程都有它的虚拟地址空间,地址空间有页表可以映射到物理内存上,我们之前所说的页表准确来说是用户级页表,每个进程都有属于自己的用户级页表,而在OS系统中,除了用户级页表还有系统级页表,系统级页表在OS系统中只有一份(即被所有进程共享)。在地址空间的分布中,有1个G的内核空间,这个空间就是通过内核页表映射到物理内存上找到OS的代码和数据。这样设计就能保证既能看到自己写的代码,也能看到OS的代码。那在OS系统中是怎么区分状态的?实际上在OS系统中有一个寄存器CR3保存着状态,进程具有地址空间是能看到用户和内核的所有内容的,但不一定能访问,能不能访问取决于现在CR3保存的是什么状态。
总结一下就是两点:
1.用户态使用的是用户级页表,只能访问用户数据和代码。
2.内核态使用的是内核级页表,只能访问内核级的数据和代码。

所以现在你就能明白了,所谓的系统调用就是进程的身份转化为内核,然后根据内核页表找到系统函数执行调用。
理解了这些接下来我要放的图你就能更好的理解了。
[Linux]信号_第16张图片
这就是整个信号处理的过程。
不知道有没有会有疑惑,为什么在进行信号捕捉的时候一定要切回用户态,按理说OS系统拥有的权限更高,完全可以执行用户的代码。其实这是为了安全起见,因为OS系统的身份特殊,一般不会直接去执行用户的代码。

4.volatile

我们来看一个场景

#include 
#include 

int flag = 0;

void handler(int signo)
{
    flag = 1;
    printf("change flag 0 to 1\n");
}

int main()
{
    signal(2, handler);

    while(!flag);

    printf("这个进程是正常退出的!\n");

    return 0;
}

在编译器有优化的情况下,这段程序运行起来之后,无论你发送几次2号信号,都无法退出,这里是因为做了优化。
[Linux]信号_第17张图片
我们知道计算是在CPU上进行的,所以flag的值会给CPU去运算,但这段代码编译器检测到flag的值(在主函数里)只是用来检测,并没有修改,干脆省点事,之后直接去访问CPU上存的数据,这就会导致flag的值永远是0,虽然在handler函数里修改了,但只是修改了内存上的flag值。编译器的这种优化显然不是我们想要的,这时就有了volatile的关键字。

#include 
#include 
//此时编译器不会对这个变量做任何优化
volatile int flag = 0;

void handler(int signo)
{
    flag = 1;
    printf("change flag 0 to 1\n");
}

int main()
{
    signal(2, handler);

    while(!flag);

    printf("这个进程是正常退出的!\n");

    return 0;
}

总结一下,volatile的作用是:
让编译器不对此变量做任何优化,读取必须贯穿式的读取内存,不要读取中间缓冲区寄存区中的数据。

5.SIGCHLD信号

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

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

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:

父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。

喜欢这篇文章的可以给个一键三连点赞关注收藏

你可能感兴趣的:(Linux,linux,运维,服务器)