Linux信号详解

目录

前言

一、信号概念

1、生活中的信号

2、计算机中的信号 

3、实现信号捕捉

二、产生信号的方式

1. 通过终端按键产生信号

格外拓展:核心转储(core dump)

status 

2、调用系统函数向进程发信号

3. 由软件条件产生信号

 4、硬件异常产生信号

三、阻塞信号

1、概念

 2、内核中的表示

3、信号集操作函数

3.1用户层函数

3.2 系统接口

四、信号捕获

1、用户态和内核态 

2、内核如何捕获进程信号

2.1捕获信号过程

2.2 sigaction

五、函数的重入

六、c语言关键字volatile

总结


前言

哈喽,小伙伴们大家好,本篇文章我将和大家一起学习进程信号。进程在某种意义上其实和我们人很像。在大多数情况下,我们的日常生活就是起床、上课、学习、休闲、休息,按部就班的度过每一天。但有时候也会遇到突发状况,比如生病啦,或者家里突然有事需要回家啦,这些突发状况是不可避免的,因为往往是事情推着人走的,我们要有处理突发状况的能力。进程也是如此,通常情况下按部就班的运行着,但保不齐什么时候就遇到突发状况,为了保证进程能在突发状况中做出相应的处理,信号由此产生。操作系统会根据突发状况给进程传递不同的信号,让进程做出相应的处理。看到这里,大家是不是感觉到了信号是一个非常有用的东西呢,那么事不宜迟,拿好小本本,和我一起开始信号的学习吧。


一、信号概念

1、生活中的信号

信号不仅仅存在计算机中,同样存在我们的日常生活中。在谈计算机中的信号前,我们先来聊聊生活中的信号。

在日常生活中,信号与我们的关系有如下特征:

  • 信号还没有产生的时候,我们要知道如何识别信号,并且知道接收信号后该如何反应。比如我们过马路时,我们首先需要知道红绿灯是一种信号,并且知道根据颜色的不同做出什么反应。这些东西往往是小时候爸爸妈妈教给我们的。所以我们得出一个结论:我们能够识别的信号特征,以及相应的处理过程是因为之前有人对我们进行过"教育"。
  • 当信号产生的时候,我们不一定要立刻去处理信号,因为我们可能有优先级更高的事情。比如我们正在打游戏的时候显示快递到了,我们不会立刻去取,而是等游戏打完后找一个合适的时间去取。
  • 信号已经产生,但我们暂时没有处理,那么一定要有某种方式记录这个信号已经产生。比如我们收到快递到了的信号后没有立刻去拿,但手机里的短信记录着快递已经到了。
  • 处理信号时往往有三种方式:默认行为,自定义行为,忽略行为。比如我们在过马路的时候遇到红灯,默认行为是站在原地等。但有一次我突然想上厕所,于是趁着红灯的时间去了一下路边的厕所,这就是自定义行为。而忽略行为就是不管红灯的提示,径直走过马路。

2、计算机中的信号 

在计算机中,进程就好比生活中的人,信号有以下特征:

  • 进程虽然现在没有受到信号,但是进程一定知道如何识别信号和做出相应反应。程序员在设计进程的时候已经内置了处理方案,信号是进程中特有的特征。
  • 进程在收到信号时可能不会立即处理,因为有优先级更高的事情。如果这样需要先把信号记录下来,等合适的时候再处理。
  • 进程处理信号有三种方式:(1)默认行为:终止进程,暂停,继续运行等。(2)自定义行为,由我们自己编写。这种方式也称为捕捉一个信号(3)忽略信号。

可以看到,计算机中的信号和生活中的信号其实是很相似的。下面我们思考两个问题:

信号是如何记录的?

我们先来谈谈信号是如何记录的,在学习进程时候,我们应该了解了每个进程都对应一个task_struct(PCB),在这个task_struct中,记录着进程的各种信息,各种信息中同样也包括信号的记录。信号在task_struct中是以位图的方式记录的,task_struct有变量signal,可以把它的类型理解成无符号整数。比特位的位置为信号编号,比特位的内容为是否收到信号,假如收到6号信号就会把第六个比特位置1。我们可以通过下面的命令查看信号编号。

命令:kill   -l         功能:查看系统定义的信号列表       

Linux信号详解_第1张图片

  •  每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #define   SIGINT   2
  • 31号之前为普通信号,34号之后为实时信号,本章我们主要讨论普通信号。

 信号是如何发送的?

进程收到信号,本质上是进程的信号位图被改了。那么谁有权限改进程内的东西呢?答案当然是OS,操作系统是进程的管理者,拥有绝对的权限。所以信号发送的本质就是操作系统修改了进程的信号位图,进程根据修改后位图的值做出相应处理。

3、实现信号捕捉

signal函数

原型:signal(int sig, void (*func)(int))     

功能:用来自定义信号处理方式

参数:

  • sig:信号编号
  • func为函数指针,传入的函数为信号的处理方式

代码如下:

Linux信号详解_第2张图片 代码运行结果如下,ctrl+c对应的是信号2,终止进程。我们可以发现信号2被我们自定义了。

注意:有些信号是不能被捕捉的,因为如果所有的信号都被捕捉,那么操作系统就再也没有办法杀死进程了。

二、产生信号的方式

1. 通过终端按键产生信号

通过终端按键也就是通过键盘产生信号,比如我们常用的ctrl+c。ctrl+/。

注意:

  • Ctrl-C 产生的信号只能发给前台进程。Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  • 一个命令后面加个&可以放到后台运行,bash默认是前台进程,这样bash(Shell)不必等待进程结束就可以接受新的命令,启动新的进程。
  • 前台进程在运行的任何时候都可能收到ctrl+c产生的信号发生终止,所以说信号对于进程的控制流程来说是异步。

格外拓展:核心转储(core dump)

man  7  signal     功能:查看信号详细信息

从上面我们可以看出:SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,接下来我们来验证一下。

 再验证之前我们先来学习一个概念:Core Dump

在程序正常结束后,我们可以通过错误码判断程序是否运行正确。但代码在运行过程中出错,我们也要有办法判断,其中一个方法就是调试,除此之外,linux为我们提供了核心转储功能。当一个进程要异常终止时,可以选择把进程的核心内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。

核心转储功能一般在云服务器,线上生产时是关闭的。

ulimit      -a     查看系统资源

可以发现core文件大小为0。想要使用此功能,我们首先要改一下core文件大小。

 修改后运行进程,然后发送信号让它异常终止:

Linux信号详解_第3张图片

可以发现在发送信号后进行了(core dumped),并且多出了一个core.6229文件。我们使用gdb调试myproc,再查看core文件,可以得到进程异常终止信息。这种方法也适用于代码中内存越界,除0等错误,可以快速定位到第几行。

status 

大家是否还记得在进程等待的时候有一个status,被信号所杀时候,其中第低八位就是core dump标志,如果发生了core dump,则该位为1,否则为0。

Linux信号详解_第4张图片

2、调用系统函数向进程发信号

kill函数

kill命令:kill   进程pid   信号编号              

kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。

int kill(pid_t pid, int signo);

signo为信号编号。成功返回0,失败返回-1。

我们也可以尝试自己来写一个kill命令。代码如下: 

#include     
#include     
#include     
void usage(char* proc)    
{    
  printf("usage:%s pid signo\n ",proc);    
}    
    
int main(int argc,char* argv[])    
{    
  if(argc!=3)    
  {    
    usage(argv[1]);    
    return 1;    
  }    
  pid_t pid=atoi(argv[1]);    
    int signo=atoi(argv[2]);    
  kill(pid,signo);    
  return 0;                                                                                                                                                             
}    

raise函数

raise函数可以给当前进程发送指定的信号(自己给自己发信号)。

int raise(int signo);

成功返回0,失败返回-1。

abort函数

void abort(void);

功能:向自己发送6号信号SIGABRT

注意:SIGABRT可以被捕捉,但是捕捉之后依然会让进程终止,这就是SIGABRT的特点
就像exit函数一样,abort函数总是会成功的,所以没有返回值。

3. 由软件条件产生信号

alarm函数

#include
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。

alarm的返回值是0或者是剩余秒数。如果闹钟被提前唤醒,返回值为剩余秒数,否则是0。

写一个计数程序,看看cou一秒钟可以计多少次数。

Linux信号详解_第5张图片

结果如下: 

 4、硬件异常产生信号

在代码中有野指针,除0等操作时程序会出现异常,这时候会产生信号,然后程序崩溃。程序崩溃的本质就是收到了信号。

下面对这个过程进行具体解释:以除0操作为例,我们知道计算都是在cpu中的,cpu中有一个状态寄存器,当进行除0操作后,状态寄存器会异常。os是软硬件的管理者,当检测到cpu状态异常后,会定位到相应的进程,更改进程的信号位图,进程通过位图识别到信号发生崩溃。

再比如当前进程访问了非法内存地址,负责虚拟地址与物理内存的一个硬件MMU会产生异常,之后和上述过程一样。

三、阻塞信号

1、概念

  • 进程处理信号的动作叫做信号递达到。
  • 信号从产生到递达的过程叫做信号未决。
  • 进程可以选择阻塞某个信号,被阻塞的信号一直处在未决状态,只有解除了阻塞后才能进入递达。
  • 阻塞和忽略是不一样的,只要有阻塞就不会递达,而忽略是递达后的一种处理动作。

 2、内核中的表示

Linux信号详解_第6张图片

  • 每个进程都有两个标志位,分别是阻塞(block)和未决(pending),还有一个函数指针指向信号处理的函数。
  • 当函数产生后,内核在进程控制块中把pending被设为1,只有等函数递达,pending才变回0。
  • SIGHUP信号未产生过。
  • SIGINT信号产生了,正处于未决状态。由于被阻塞了,暂时不能递达。
  • SIGQUIT信号还未产生,但是已经提前被阻塞,产生后会立刻进入阻塞状态。
  • 在对某个信号进行阻塞过程中,如果这个信号产生了多次,常规信号只计最后一次。实时信号本文不考虑。

sigset_t类型

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

3、信号集操作函数

3.1用户层函数

sigset_t每一位都代表一个信号的“有效”或“无效”状态,至于sigset_t内部是如何存储这些数据的我们不需要关心,是由操作系统去维护的。所以我们想要操作sigset_t变量要通过下面的这些函数,而不应该对内部数据有任何操作,因为这是没有意义的。

#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);

  • sigemptyset和sigfillset为初始化函数,初始化set所指向的信号集。sigempty将所有bit位清零,sigfillset将所有bit位置位。
  • 在使用sigset_t类型的变量前,必须要对它进行初始化,使信号集处在确定状态。
  • 在对sigset_t类型的变量初始化后,可以用sigaddset或sigdelste对信号集进行有效信号的增加和删除。
  • 这四个函数都是成功返回1,失败返回0。

int sigismember(const sigset_t *set, int signo);

sigismember是一个bool函数,用来判断某个信号集中是否包含某个有效信号,包含返回1,不包含返回0。

3.2 系统接口

我们上面定义的sigset_t类型变量是在栈中的,本质上是在用户层,没有进操作系统。我们对它的操作仅仅是改变这个变量的值,并不会影响进程的任何行为。我们想借助sigset_t类型变量影响系统和进程需要调用下面的接口。

sigprocmask函数

#include
原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

功能:来读取或更改进程的阻塞信号集。
返回值: 若成功则为0,若出错则为-1

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

Linux信号详解_第7张图片

sigpending函数

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

下面我们写一个进程来完成以下操作:

  • 将信号2阻塞
  • 发送信号2
  • 十秒后解除信号2阻塞
  • 每隔一秒打印一次未决信号集

代码如下:

void hander(int signo)    
{    
  printf("%d阻塞结束\n",signo);    
}    
int main()    
{    
  sigset_t set,oset;    
  sigemptyset(&set);    
  sigemptyset(&oset);    
  sigaddset(&set,2);    
  sigprocmask(SIG_SETMASK,&set,&oset);//将2阻塞    
    
  signal(2,hander);//捕获2信号    
    
  sigset_t pending;    
  int count=0;    
  while(1)    
  {    
    sigpending(&pending);//获取未决信号集    
    //每隔一秒打印未决信号集    
    signal_printf(&pending);    
    sleep(1);    
    count++;    
    if(count==10)    
    {    
      //十秒后解除阻塞
      sigprocmask(SIG_SETMASK,&oset,NULL); 
    }
  }
  return 0;                                                                                                                                                             
}

四、信号捕获

1、用户态和内核态 

用户态:执行用户自己的代码,系统所处的状态叫做用户态。用户态是一种受监管的普通状态。

内核态:有时候我们写的代码中,调用了系统接口,本质上就是调用了内核级的代码,这时候就需要内核态权限。内核态通常用来执行os代码,是一种权限非常高的状态。

用户态->内核态:系统调用,时间片到了导致进程切换,异常,中断,陷阱,这些情况会切换到内核态。

内核态->用户态:系统调用,进程切换,异常,中断,陷阱处理完毕后,会切换回用户态。

在用户态时,访问用户的代码和数据,切换到内核态后,比如调用系统接口后,往往会执行操作系统内核中的代码。那么进程是怎么找到操作系统的呢?首先要明确一个概念,操作系统也是一款软件,既然是软件运行的时候就会被加载到内存中。每个进程都有一张地址空间表,这张表下半部分为用户区,通过用户页表映射找到内存中用户的代码和数据。上方为内核空间,保存了内核的虚拟地址,可以通过内核页表映射找到内存中操作系统内核的代码和数据。

Linux信号详解_第8张图片

进程无论如何切换,都能看到操作系统,但不一定能访问,只有处在内核态时才能够访问。那么处于内核态还是用户态的标志是什么呢?我们知道代码是加载到cpu中进行运算的,在cpu中有一个cr3寄存器,里面记录了是否此时的状态是用户态还是内核态。同时cpu中还有寄存器记录着用户页表和内核页表的值,当在某种状态下访问越界,cpu可以检索到。

2、内核如何捕获进程信号

2.1捕获信号过程

信号递达时处理的动作为用户自己定义的函数,这称为捕捉信号。

通过前文的学习我们知道,进程在收到信号后不一定是立刻处理的,而是等到适合的时候。那么什么是适合的时候呢?答案就是系统从用户态切到内核态后。

假设用户程序注册了信号2的处理函数sighandler。下面我来阐述捕获进程信号的过程:

  • 当前正在运行main函数为用户态,此时发生了中断或程序异常,切换到内核态。
  • 当中断处理完毕后在返回用户态的main函数前要检查是否有信号未决,假设收到了信号2,则先进入信号2的处理函数sighandler,由于sighandler是用户程序定义的,所以要切换到用户态。
  • 等sighandler函数执行完毕后,自动执行特殊的系统调用sigreturn再次进入内核态。
  • 如果没有新的信号要递达,这次返回用户态进入main函数中继续执行上下文。

思考一个问题:在内核态是否能调用用户的代码和数据呢?

理论上以内核的权限是可以的,但实际上并不能这么做,因为操作系统不信任任何人,它担心用户会越权执行一些非法操作。

2.2 sigaction

#include
原型:int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

功能:sigaction函数可以读取和修改与指定信号相关联的处理动作。

返回值:调用成功则返回0,出错则返回- 1。

参数:signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。

act和oact指向sigaction结构体: 

Linux信号详解_第9张图片

sa_handler为我们自己定义的处理函数。当某个信号的处理函数被调用时,内核会自动将该信号加入当前进程的阻塞信号集,等调用结束后再恢复。这就保证了在处理某个信号时,如果这种信号再次产生,会被阻塞到当前信号处理完毕为止。如果在调用信号处理函数时,除了阻塞当前信号外,还想阻塞其它信号,则用sa_mask设置,调用完毕后自动恢复。sa_flags涉及一些选项,而siginfo是实时信号的处理函数,本章不做解释。

测试代码如下:

struct sigaction act, oact;
void handler(int signo)
{
    printf("get a signal: %d\n", signo);
    sigaction(SIGINT, &oact, NULL);
}
int main()
{
    memset(&act, 0, sizeof(act));
    memset(&oact, 0, sizeof(oact));

    act.sa_handler = handler;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);

    sigaction(SIGINT, &act, &oact);
    while(1){
        printf("I am a process!\n");
        sleep(1);
    }
    return 0;
}

五、函数的重入

我们来看下面的过程:

mian函数正在调用insert函数向链表中插入节点。insert函数分为两步,刚刚执行完第一步时此时硬件发生中断,使进程切换到内核。中断处理完毕切换到用户态之前发现有信号未决,于是进入了信号的处理函数,信号的处理函数中同样有insert操作,于是向链表中插入了一个新节点。进行完毕后返回到main函数,按照上下文接着执行main函数中insert函数的第二步,令头节点指向node1。从图中可以看出,这时候出现了一个问题,明明我们想要两个节点插入,可实际仅仅插入了一个,node2丢失了,也就是我们平常说的内存泄漏。

Linux信号详解_第10张图片

 像上例这样,insert函数被不同的控制流调用,在第一次调用还没有返回时就再次进入该函数,这称为重入。如果insert访问的是一个全局链表,就可能因为重入而产生错误,这称为不可重入函数。相反,如果一个函数只访问自己的参数和局部变量,则称为可重入函数。

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc和free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数,表述I/O库的很多实现也是通过不可重入的方式使用全局数据结构。

我们平常所调用的函数,自己写的函数,STL中的库函数基本都是不可重入的。

六、c语言关键字volatile

volatile是c语言中的一个关键字,在语言层面我们很难理解它,今天从信号角度我们来理解一下。

我们来看下面一段代码:

Linux信号详解_第11张图片

从代码表面看上去运行逻辑应该是,flag为0时候一直while循环,当有信号2产生进入到信号2的处理函数中,flag改成1,循环结束,进程退出。但是事实真的是如此吗?

我们用编译器O3的优化级别来编译(默认是O1或O2),然后运行,结果如下:

我们发现收到信号后进程并没有退出。原因是main函数和和信号处理函数是两个执行流,while循环在main函数中,编译器只会检查main函数,发现flag并没有main函数中改变。在优化级别较高的时候,flag会被直接写进cpu的寄存器中,cpu读取flag时直接从寄存器里读,信号处理函数改变flag改变的是flag在内存中的值,寄存器中flag的值始终保持0。

为了解决这个问题,我们可以在定义flag的时候前面加上volatile。

volatile int flag=0;

再次编译运行,发现可以退出:

volatile的作用:

避免变量被写到寄存器中,就算写到内存器中,读取时也要先读取内存中的值,然后刷新到寄存器里。 


总结

本文主要介绍了linux系统下信号的相关知识,希望能给大家带来帮助。如果觉得写的还不错的话可以点赞支持一下博主,我也将努力为大家带来更加优质的内容。感谢阅读,山高路远,来日方长,我们下次见。

你可能感兴趣的:(Linux系统与网络编程,linux,操作系统)