Linux进程信号的处理

目录

一、信号的引入

二、信号的产生

1.通过键盘产生

(1)发送2号信号

(2)只能向前端进程传递信号

2.程序异常收到信号

(1)程序异常发送信号的现象

(2)程序异常发送信号的原因

(3)如何定位程序异常

3.系统调用产生的信号

4.软件条件产生信号

5.如何理解OS向进程发送信号

三、信号产生中

1.基本概念

2.三张表

3.sigset_t

(1)系统调用类型

(2)信号集处理函数

4.sigprocmask与sigpending

(1)sigprocmask

(2)sigpending

5.举例

(1)屏蔽2号信号

(2)打印信号位图

(3)将2号信号递达

四、信号的处理

1.信号的处理方式

2.信号检测和处理

(1)用户态和内核态

(2)信号检测与处理流程

3.修改信号执行的两个函数

五、补充概念

1.可重入函数

2.volatile关键字

3.SIGCHLD信号

六、总结


信号的发送与进程间通信是不同的,信号只能够由操作系统来进行发送,而进程的作用是请求操作系统来发送信号。我们使用control+C可以终止一个进程的本质上其实就是向该进程发送一个2号信号。

一、信号的引入

1.在生活中有很多信号的场景的存在,比如红绿灯,闹钟,老师的脸色等,当我们获得了这些信号之后,我们立刻就知道下一步要去做什么了。注意,信号与进程之间通信的信号量之间是没有任何关系的。

2.同时,只有当绿灯量起来的时候我们才知道应该在绿灯的时候行走吗?显然不是的,进程也是这样,不管是否接收到了信号,进程都知道如果收到这些信号应该做什么。进程收到信号之后应该做什么是由操作系统工程师已经处理好的。

3.当我们收到某一种信号时,不一定立刻去处理该信号,因为可能有重要的事情需要去做。此时就需要将信号存储。信号存储的位置就是进程的PCB,信号的本身也是数据,因此在向进程中传递信号的本质就是向PCB中写入数据。

4.信号的发出者只有操作系统,无论我们如何发送信号都是请求操作系统来进行发送的。

通过以上分析,我们可以将信号的发送分为三大部分:分别是信号产生,信号保存和信号处理。在Linux系统中,我们可以使用

kill -l

来查看所有信号:

注意观察,是没有32和33号信号的,我们只研究前31个信号。

二、信号的产生

1.通过键盘产生

当一个进程是一个死循环的进程时,我们可以使用键盘进程control+C来终止掉进程。control+C的本质就是向该进程传递2号信号从而使该进程终止注意,键盘只能向前端进程传递信号。

下面来验证一下以上内容:

(1)发送2号信号

要验证这一问题,我们需要认识一个函数:

在signal这一函数中,它的第一个参数代表的是信号编号。它的第二个参数是一个函数指针,它指向的是一个返回值为void,参数为int的函数,该函数的参数就是signum,该参数会被signal函数自动传入到该函数中。

注意该函数执行的前提是收到了signum号信号,否则不会执行该函数。

当向该进程发送signum信号,则执行handler所指向的函数。我们可以根据该函数这一功能来间接判断发送的是哪一个信号。

#include<stdio.h>    
#include<signal.h>    
#include<unistd.h>    
void handler(int signo)    
{    
  printf("get a signo:%d\n",signo);    
}    
int main()    
{    
  signal(2,handler); //当收到2号信号时执行handler函数   
  while(1)    
  {    
    sleep(1);                                                                                                                                          
    printf("assistant is stupid!\n");    
  }    
}    

此时在进程运行的过程中,我们使用control+C就不会使进程退出了,而是直接执行handler函数:

我们可以使用control+\,即三号信号来终止进程。

(2)只能向前端进程传递信号

如果我们让进程在后台中运行,那么键盘将无法向进程中输入信号:

./test &

此时我们发现,当在键盘输入control+C的时候,并没有向进程传递信号,此时进程只能通过系统向进程传递9号信号来关闭。

2.程序异常收到信号

(1)程序异常发送信号的现象

当我们对一个空指针进行解引用的时候,程序会发生崩溃推出的,而程序退出的本质是收到了某种信号,导致了程序的退出,我们可以通过一下代码来找到令空指针解引用程序退出的信号。

#include<stdio.h>    
#include<signal.h>    
#include<unistd.h>    
#include<stdlib.h>    
void handler(int signo)    
{    
  printf("get the signal:%d\n",signo);    
  exit(1);    
}    
int main()    
{    
  int sig=1;    
  for(;sig<=31;sig++)    
  {    
     signal(sig,handler);//接收所有信号    
  }    
  int* p=NULL;    
  *p=100;//Segmentation fault段错误
}            

此时我们发现,传入的是第11号信号。

我们还可以测试一下,如果进行除0操作而造成崩溃使程序退出的信号:

我们发现传入的是8号信号。

因此我们可以得出结论:在win或者Linux系统下,进程崩溃的本质是进程收到了与崩溃对应的信号,然后进程执行信号的默认动作,即杀死进程。

(2)程序异常发送信号的原因

那么为什么会受到信号呢?

首先我们需要明确,计算机的一切计算操作都是在CPU中完成的,当CPU进程除0操作的时候会出现异常,而操作系统是硬件的管理者,当操作系统得知CPU运算出现异常的时候就会向产生异常进程发出信号,使其终止。

因此我们可以得到这样一个结论,程序的异常最终其实都会体现在其他的软件或者硬件上。

(3)如何定位程序异常

当程序异常崩溃时,我们最想知道的就是程序崩溃的原因,在哪里崩溃的。

在Linux系统中,当一个进程退出时,它的退出码和退出信号都会被设置(正常情况),当一个进程异常的时候,进程的终止信号会被设置,表明进程退出的原因。如果必要,OS还会设置退出信息中的标志位core dump(它在status的第8位),并将进程在内存中数据转移到磁盘中,方便后期调试。

在默认情况下,这种基于core dump的调试方式是被关掉的。当需要进行coredump调试,coredump位被设为1

我们可以通过ulimit指令来进行查看:

ulimit -a //查看系统资源
ullimit -c 10240 //允许进行coredump,设置大小为10240

此时当我们再运行问题代码时,会带一个core dump的提示,并且在当前目录下,还会找到生成的一个文件,这个就是我们的调试文件:

此时我们使用gdb来进行调试(注意如果要使用gdb的话需要在生成可执行文件的时候使用-g选项):
此时当我们使用r选项令代码运行起来时,gdb就会自动查看core.2206这个文件,从而给出问题出现的位置:

这里表示异常发生在代码的28行。

注意,并不是所有的异常(信号)可以进行上述coredump调试。

3.系统调用产生的信号

说人话就是用代码输入信号。

我们不仅可以在终端使用kill -x输入信号,系统也提供了一个名为kill的函数可以用于输入信号:

它的第一个参数为进程的pid,它的第二个参数为信号序号。通过这个函数我们可以向pid这个进程发送sig号信号。

我们可以使用它以及命令行传参,来模拟实现kill进程:

我们令argv第二个参数为信号,第三个参数为进程的pid,与kill进行对应。

static void Usage(const char* proc)//说明函数    
{    
  printf("Usage:\n\t %s signo who\n",proc);    
}    
int main(int argc,char* argv[])    
{    
//./mytest signo who
if(argc!=3)
{
  Usage(argv[0]);
  return 1;
}
int signo=atoi(argv[1]);
int who=atoi(argv[2]);
printf("signo:%d,who:%d\n",signo,who);
kill(who,signo);
}

此时就成功地模拟实现了kill进程。
kill函数可以给任何pid的进程输入信号,同时还有一个专门给自己输入信号的函数:raise。

raise(8)代表给自己输入8号信号

还可以使用abort()来给自己的进程输入abort信号(6号信号)。

4.软件条件产生信号

通过某种软件出发信号的发送,系统层面设置定时器,或者某种操作而导致条件不就绪的场景下,出发的信号发送。

比如在进程通信的管道通信中,当读端关闭,写端仍然在继续写,操作系统就会给写端发送sigpipe信号。就是一种典型的软件条件触发的信号发送。

我们再来介绍一个软件条件产生的信号,信号发出的软件OS

它表示的是一个闹钟,意思是seconds秒之后发送14号信号。它的返回值是上一个闹钟剩余的时间。当之前已经设定闹钟且该闹钟没有结束时,再次调用alarm只会取消闹钟,而不是设定新的闹钟。

int ret=alarm(30);
printf("assistant is stupid!\n");
while(1)
{
    sleep(1);
    int res=alarm(0);
    printf("ret:%d,res:%d\n",ret,res);
}

此时我们就可以捕捉到alarm的返回值,即上一个alarm剩余的时钟时间。

我们还可以利用这个alarm函数来记录一下5s内程序运行的速度:

int count=0;
alarm(5);
printf("hello world!\n");
while(1)
{
    count++;
    printf("%d\n",count);
}

可以看到5s内count被加到了446706。

5.如何理解OS向进程发送信号

OS发送的信号时直接发送给进程的PCB的。在进程的PCB中存在一个32位整数,我们将该整数看成一个位图的结构,它的每一位都代表一个信号,当值为1时,表示收到了该信号,当值为0时,表示没有收到该信号。

信号的传递本质是信号的写入,即OS向进程的PCB中的位图中写入比特位,就完成了信号的发送。

三、信号产生中

1.基本概念

1.信号递达:实际执行的信号处理动作叫做信号递达。递达其实就是信号的执行过程。

2.信号未决:信号从产生到递达之间的状态,称为信号未决。

3.信号阻塞:进程可以选择阻塞某个信号。

当进程阻塞某个信号时,该信号产生时将处于未决状态,直到进程取消阻塞,才执行递达的动作。只要被阻塞就不会被递达。这其实就是信号保存的过程。

阻塞与忽略的却别在于,忽略已经开始进行处理信号了,只不过处理信号的方式是不处理。

2.三张表

操作系统根据三张表来确定是否处理信号,以及如何处理信号:

这三张表分别是:block,pending,handler

其中block也是一个32位整数,我们也将其看成一个位图,比特位的位置代表信号的编号,比特位的内容代表是否被阻塞。block也称位屏蔽字。

pending表示的是是否收到该信号。即32位位图。

handler的内容是函数指针,它是一个函数指针数组,如果信号成功递达,则执行该函数所指向的函数,则该函数的地址会被传递给对应信号的handler中。

第一行表示的是,没有收到信号1,信号1没有被阻塞,如果信号1被递达执行默认操作。

第二行表示的是,收到了信号2,信号2被阻塞了,如果信号2递达执行忽略策略。

第三行表示的是,没有收到信号3,信号3被阻塞了,如果收到信号3,执行默认的操作。

等等。。

注意,如果某个信号被屏蔽了,操作系统就不会关心该信号是否被接收。

3.sigset_t

(1)系统调用类型

系统调用除了可以体现在函数上之外,还体现在操作系统提供的数据类型上。这些数据类型的最终目的也是配合系统调用函数来使用的。

比如在以上的三张表中,block表和pending表的本质其实就是一个32位整数,这个32位整数的类型就是sigset_t,由于它们并不是在用户层出现而是在希层出现的。因此需要操作系统的接口来实现对表的更改。

因此要修改sigset_t类型的数据,不能让用户直接进行修改,而是需要使用系统调用接口来进行修改。

sigset_t s;//定义一个sigset_t的变量

s=10;//错误,用户不能直接修改该变量的值

(2)信号集处理函数

修改sigset_t类型的变量,操作系统提供了如下函数:

int sigemptyset(sigset_t set);//初始化set所指向的信号集,将其中所有信号对应的bit清零。

int sigfillset(sigset_t set);//初始化set所指向的信号集,将所有信号对应的bit置为1。

int sigaddset(sigset_t set,int signo);//在信号集中添加signo号信号。

int sigdelset(sigset_t set,int signo);//在信号集中删除signo号信号。

4.sigprocmask与sigpending

(1)sigprocmask

使用man手册查询结果如下:

该函数是与屏蔽字有关的函数(block):

第二个参数表示传入一个新的sigset_t 类型的变量set,是一个输入性参数。

第三个参数表示返回之前的屏蔽字,是一个输出型参数。

第一个参数表示的是对屏蔽字进行的操作,可以传入如下几个变量:

SIG_BLOCK:包含了我们希望添加到屏蔽字的信号,相当于mask=mask|set。

SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set。

SIG_SETMASK:设置当前信号屏蔽字为set所指向的值,相当于mask=set。

(2)sigpending

它的用法很简单:

读取当前进程的pending表,通过set传出。

5.举例

下面用一个例子来具体地使用这些函数:

(1)屏蔽2号信号

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    sigset_t iset,oset;
    sigemptyset(&iset);
    sigemptyset(&oset);
    sigaddset(&iset,2);
    sigprocmask(SIG_SETMASK,&iset,&oset);
    while(1)
    {
        sleep(1);
        printf("hello world!\n");
    }
}

(2)打印信号位图

如果进程首先屏蔽掉2号信号,此时传入2号信号,并不断获取pending位图,并打印显示。由于2号信号不会递达,因此它一直在pending位图中,我们就可以进行观察了。

void showpending(sigset_t* set)
{
    int i=1;
    for(i=1;i<=31;i++)
    {
        if(sigismember(set,i))
        {
            printf("1");
        }
        else
        {
            printf("0");
        }
    }
    printf("\n");
}

int main()
{
    sigset_t iset,oset;
    sigemptyset(&iset);
    sigemptyset(&oset);
    sigprocmask(SIG_SETMASK,&iset,&oset);
    sigset_t pending;
    while(1)
    {
        sigemptyset(&pending);
        sigpending(&pending);
        showpending(&pending);
        sleep(1);
    }
}

此时当我们发送2号信号时,就可以看到pending表的变化:

(3)将2号信号递达

当将信号屏蔽之后,再解除屏蔽我们就可以看到pending表中2号信号由1变成0的过程。

但是由于2号信号递达之后进程会立刻退出,因此我们需要修改2号信号的执行方式来方便进行观察。

我们令20s之后2号信号递达:

  #include<stdio.h>    
  #include<signal.h>    
  #include<stdlib.h>    
  #include<unistd.h>    
  void showpending(sigset_t* set)    
  {    
    int i=1;                                                                                                                                           
    for(i=1;i<=31;i++)    
    {    
      if(sigismember(set,i))    
      {    
        printf("1");    
      }    
      else{    
        printf("0");    
      }    
    }    
    printf("\n");    
  }    
void handler(int signo)    
  {    
    printf("2号信号被递达了!\n");    
  }    
  int main()    
  {    
    signal(2,handler);    
    sigset_t iset,oset;    
    sigemptyset(&iset);    
    sigemptyset(&oset);    
    sigaddset(&iset,2);    
    sigprocmask(SIG_SETMASK,&iset,&oset);    
    sigset_t pending;    
    int count=0;
        while(1)
    {
      count++;
      sleep(1);
      if(count==20)
      {
        sigprocmask(SIG_SETMASK,&oset,NULL);
        printf("恢复2号信号\n");
      }
      sigemptyset(&pending);
      sigpending(&pending);
      showpending(&pending);
    }
  }                                                

四、信号的处理

1.信号的处理方式

一般而言,信号的处理由三种情况:

1.默认动作:即系统默认处理信号的方式,通常是暂停或者终止。

2。忽略动作:是一种信号处理方式,只不过处理方式是什么也不干。

3.自定义动作:使用signal函数就是在修改信号的处理方式,也称为信号的捕捉。注意,9号信号不能被自定义处理。

2.信号检测和处理

在信号产生中的模块中,我们了解到OS会对进程中信号的三张表进行检测,从而判断是否要执行某个信号。这里提到了两个关键词,分别是检测和执行,那么这两个动作发生在哪里呢?

要了解这一部分,我们需要了解进程处理过程中的两个状态:用户态和内核态

(1)用户态和内核态

用户态:用户代码和数据被访问或者执行到我所处的状态,我们自己 的代码都是在用户被执行的。

内核态:执行OS代码和数据的时候,计算机所处的状态,叫做内核态。OS代码全部在内核态执行。

两者的区别在于权限,它们之间的切换表现为系统调用。

当用户调用系统接口时,处理进入函数,身份也会发生变化,会由用户的身份变成内核的身份。我们知道当执行某个进程的时候,用户的代码和数据会被加载到内存中,同理内核的代码和数据也一定要加载到内存中。当我们开机的时候就是将内存和数据加载到内存中的过程。

除了用户区的页表之外,还有内核区的页表,对于任意一个进程来说,它的内核区的内容都是一样的。因此内核区的页表只需要存在一份即可,被多个进程所使用。由于内核页表的存在,我们能够保证所有的进程都能找到同一个操作系统。所谓系统调用,其实就是进程的身份转换为内核,然后根据内核页表找到系统函数并执行。

同时在CPU内部,有一个CR3的寄存器,它是用来判断当前进程执行的是用户态还是内核态,如果是内核态,它的值就会被赋值为0,如果是用户态它的值就会被赋值为3。

(2)信号检测与处理流程

当我们执行用户代码的时候,执行到一个系统调用,此时进入内核态,执行完系统调用的函数的代码后需要返回用户态。继续执行用户的下一条代码。在返回之前需要进行信号的检测与处理操作。当没有信号,或者信号被阻塞,或者信号的处理方式不是自定义的,此时直接进入用户态执行用户的下一条代码。

但如果信号被递达了,且是自定义处理的。此时就需要进入用户态执行该自定义函数,然后再返回内核态的sigret,然后再返回用户态执行下一条代码:

整个处理流程类似数学中的无穷大:

在处理信号的过程中:

1.当handler为默认状态的时候,直接释放资源,进程结束。(因为在内核态)

2.当handler为忽略状态的时候,直接将pending的1置为0。

3.当handler为自定义的时候,进程由用户态->内核态->用户态->内核态->用户态,一共经历了四次转变。

但是为什么要切换到用户态去执行handler的代码呢?OS显然也有权限去执行,但是它不相信任何人,用户只能使用OS的接口去让OS执行一些列的操作。

结论:当内核态即将切换为用户态的时候,进行信号的检测和处理。

3.修改信号执行的两个函数

其中一个函数就是signal函数,这个在前面已经介绍了,这里不多赘述。

另一个函数是sigaction。我们可以通过man手册进行查询:

它的第二个参数是一个输入型参数,表示的是一个结构体(这个结构体和函数是同名的),这个结构体中的第一个参数是一个函数指针,指向自定义的信号处理方法:

它的第三个参数是一个和第二个参数相同类型的结构体,它的第一个元素指向未修改之前的信号处理方法。

下面使用这个函数来自定义一个信号处理的方法:

#include<stdio.h>    
#include<signal.h>    
#include<stdlib.h>    
#include<unistd.h>    
#include<string.h>    
void handler(int signo)    
{    
  printf("get a signo:%d\n",signo);                                                                                                                    
}    
int main()    
{    
  struct sigaction act;    
  memset(&act,0,sizeof(act));    
  act.sa_handler=handler;    
  sigaction(2,&act,NULL);    
  while(1)    
  {    
    printf("hello world!\n");    
    sleep(1);    
  }    
}    

此时就对2号信号完成了修改:

下面来介绍一个该结构体中的另一个sa_mask:

当某个信号的处理函数在被调用的时候,内核自动将当前信号加入进程的屏蔽字。当函数结束后,屏蔽字恢复。这样保证了当处理一个信号时,如果该信号再次出现,则不会被处理。直到前一个信号已经被处理结束。

当处理一个信号时,除了该信号,我们还想屏蔽另一些信号,此时就需要sg_mask发挥作用了。

比如进程正在处理2号信号,此时在传入2号信号,信号不会被处理。我们希望不仅仅传入2号信号不会被处理,传入3号信号也不会被处理,就可以将sg_mask的3号信号置为1(sg_mask也是sigset_t类型)。

  sigemptyset(&act.sa_mask);    
  sigaddset(&act.sa_mask,3);          

在代码中加入这两段代码,即将sa_mask的信号3添加。此时当处理信号2的时候,信号也就被阻塞了。

五、补充概念

1.可重入函数

有这样一种场景,当我们在进行链表的插入时,本质上分为两步:

p->next=head;

head=p;

我们假设这段代码在一个名为insert的函数中执行。

如果执行完第一步之后,接收到了信号,信号的处理方式还是这个insert函数,又向其中插入一个新的节点,这种情况我们称之为函数的重入,他可能带来不好的后果:

此时就会造成无法找到node2这个节点的后果。

注意,并不是只有遇到系统调用接口的时候进程才会进入到内核态,当CPU调度不同的进程,将其从运行队列前端移动到后端的时候,是操作系统进行操作的,因此我们成insert为不可重入函数。与之对应的还有可重入函数。

大部分函数都是不可重入的。

2.volatile关键字

该关键字是C语言的比较冷门的关键字,我们站在信号的角度来认识一下它:

  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号信号的时候,flag由0置为1,结束死循环,程序退出。执行结果是这样的:

如果我们编译过程中采用优化编译的方式:

gcc test.c -o mytest -O3

此时运行的结果,我们发现死循环不会被终止。

下面来分析一下原因:

在main执行流的过程中,gcc发现main函数中没有人对flag进行修改,就会进行优化:即将flag永久地存放在寄存器中,下一次CPU调度该进程的时候就不需要进行寻址访问了,而是直接使用寄存器中的值。

当CPU读取完flag之后,就不会再对其进行读取了。而信号到来,内存中的flag发生了变化再CPU中的flag是不知道的。而判断操作都是再CPU中进行的,因此会一直死循环。

为了避免这一情况,只需要将flag定义为:

volatile int flag=0

volatile的作用就是告诉编译器不要对这个变量做任何优化,要读取必须贯穿式地读取内存,不要读取缓冲区中的数据,保存内存的可见性。

3.SIGCHLD信号

该进程是在子进程退出后对父进程发出的信号。该信号的默认处理动作是忽略。

void Getchild(int signo)    
{    
  printf("get a signo:%d\n",signo);                                                                                                                    
}    
int main()    
{    
  signal(SIGCHLD,Getchild);    
  pid_t id=fork();    
  if(id==0)    
  {    
    int count=5;    
    while(count)    
    {    
      printf("I am a child:%d\n",getpid());    
      sleep(1);    
      count--;    
    }    
    exit(0);    
  }    
  while(1);    
  return 0;    
}  

当子进程退出时,就会捕捉到17号信号:

而SIGCHLD更重要的作用在于,当如果我们显示设置忽略17号信号的话,子进程退出后会直接被父进程回收,而不用等待父进程结束而变成僵尸进程:

signal(SIGCHLD,SIG_IGN)

这种做法目前只在Linux系统下有效。

六、总结

信号的内容有很多,总结起来可以分为三个大方向:
信号产生前,信号产生中,信号产生后。
信号的产生共有四种方式,分别是由键盘产生,进程崩溃产生,系统调用产生,和软件条件产生。
信号产生中要记住三张表以及它们代表的含义。以及处理三张表的各种接口。
信号产生后需要了解用户态和内核态,以及进程对信号的检测和处理的"合适时间"所指。以及两个可以修改信号的函数。
最后补充了三个概念,分别是可重入函数,volatile关键字以及SIGCHLD信号。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值