Linux进程信号


一、认识信号

在生活中有很多熟悉的信号,比如红绿灯、下课铃声等。当听到或者想到这些信号的时候,我们就会作出或者知道我们要做什么事了。

Linux中的信号也是这样的作用。

使用命令kill -l可以查看系统定义的信号列表。其中前31个为普通信号,后31个为实时信号。普通信号和实时的区别在于前者不支持排队,可能会造成信号丢失,而后者不会。本文章只介绍普通信号。
在这里插入图片描述

man 7 signal可以查看信号的详细信息。
在这里插入图片描述

1.1 使用ctr c向前台进程发送信号

常用的使用键盘 ctrl + c 向进程发送终止信号
在这里插入图片描述

在这里插入图片描述
注意:

  1. Ctrl+C 产生的信号只能发给前台进程。一个命令后面加个&(./signal &)可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

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

在这里插入图片描述

这就是因为自己写的进程被放在后台,此时bash就是前台进程,能够运行指定,同时向屏幕打印。此时输入fg 进程路径就可以让进程从后台回到前台。

在这里插入图片描述

1.2 验证ctrl c是几号信号

首先需要了解一个系统调用

  • 作用 :这个接口能够捕捉并重定向一个信号的默认处理动作,使信号不执行原来的动作,而是执行我们自定义的动作
  • 参数:
    signum:对应信号的编号(普通信号编号1-31,实时信号编号34-64)
    handler:回调函数(函数指针),传一个函数的地址。这个函数就是我们自定义的处理动作。
    在这里插入图片描述

在这里插入图片描述

可以看到结果是2号信号。
在这里插入图片描述
也可以使用Ctrl \来退出,这个指令是发送的是3号信号SIGQUIT

1.3 常用信号

信号编号信号名信号含义
1SIGHUP当用户退出终端时,由该终端开启的所有进程都退接收到这个信号,默认动作为终止进程。
2SIGINT程序终止(interrupt)信号, 在用户键入INTR字符(通常是Ctrl+C)时发出,用于通知前台进程组终止进程。
3SIGQUIT和SIGINT类似, 但由QUIT字符(通常是Ctrl+\)来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号。
9SIGKILL用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。
15SIGTERM程序结束(terminate)信号, 与SIGKILL不同的是该信号可以被阻塞和处理。通常用来要求程序自己正常退出。
20SIGSTOP停止(stopped)进程的执行. 注意它和terminate以及interrupt的区别:该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略.

二、信号的产生

信号常见的产生方式有,键盘输入、程序出现异常问题、系统调用产生、软件产生。
信号产生的方式种类虽然非常多,但是无论产生信号的方式千差万别,但是最终,一定都是通过OS向目标进程发送的信号!

2.1 通过键盘

前面已经了解过了:

  • ctrl+c 2号信号 SIGINT
  • ctrl+z 20号信号 SIGTSTP
  • ctrl+\ 3号信号 SIGQUIT

对应的SIGINT的默认处理动作是终止进程,SIGTSTP停止终端交互进程的运行,SIGQUIT的默认处理动作是终止进程并且Core Dump(下面会讲)。

2.2 硬件异常产生信号

进程产生异常,本质是因为硬件运算发生错误。下面列举两种程序常见的异常。

2.2.1 指针越界访问

这个问题对使用C/C++的人来说非常常见,也是做OJ是的段错误。
在这里插入图片描述

在这里插入图片描述
如果不捕捉就是报段错误。
在这里插入图片描述
显然虚拟地址访问数据,虚拟地址需要先转换到物理地址,如果是野指针,那么在页表之个找不到对应的映射关系,这个地址转化就会发生错误。硬件运行发生错误被OS检测到传给OS,OS再发送相关信号给进程。

2.2.2 浮点异常

常见的算数异常比如除0。 报错浮点异常。
在这里插入图片描述
在这里插入图片描述
对应为8号信号。

2.2.3 core文件

进程的崩溃的本质,是进程收到对应信号,然后执行进程收到该信号的默认动作(杀死进程)。

为什么会收到信号呢?
软件上面的错误,通常会体现在硬件或其他软件上,而OS是硬件的管理者,收到错误信息后,就会给产生错误的进程发送信号。

当进程崩溃的时候,最想知道的当然是崩溃的原因 ,可通过进程退出信号拿到。崩溃的原因即崩溃时,收到的是哪一个信号。
更想知道在哪一行崩溃了。这是就需要OS设置退出信号中的 core dump 标志位,将内存中进程的退出信息保存到磁盘方便后期调试,形成core文件。这样调试的时候就可以直接定位到错误的行。

回顾一下进程的退出:

在Linux中,当一个进程退出的时候,它的退出码和退出信号都会被设置(正常情况)
当一个进程异常的时候,进程的退出信号会被设置,表明当前进程退出的原因
在这里插入图片描述

父进程等待子进程可以拿到退出信息, 其中有一个输出型参数status,低8位中的低7位表示退出信号,第8位表示Core Dump,如果为0则表示不需要生成core文件,为1表示需要。

使用命令umilit -a查看生成core文件是否打开。为 0 表示关闭。
在这里插入图片描述
这个功能默认是关闭的

  1. 一旦进程发生错误,就会产生core文件,甚至系统启动的时候也会发生错误,如果不加以控制,生成的临时文件可能会将磁盘堆满。
  2. 并不是所有的信号都需要Core Dump, 产生core文件。比如我们的kill -9号信号,系统直接终止进程,是不需要调试的。
  3. core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K

若关闭,使用ulimit -c size设置大小为size字节。表示打开。一次登录,只能修改一次,若要再修改,需重新登录。
在这里插入图片描述
这下运行程序,发生错误后,打开调试器进行调试输入命令core-file core.xxx命令即可得到错误信息。(编译程序时需加上 -g 选项,core.xxx是生成的core文件)

将信号处理方式改为默认,即不再调用自定义的处理函数。编译运行程序,可以看到有core dumped标志, 并生成一个core文件。
在这里插入图片描述
打开gdb调试,输入命令core-file core.3743即可看到调试信息。
在这里插入图片描述
验证一下,core dump标志位是否被设置。
在这里插入图片描述

core dump 标志位被设置为1,会产生core文件。
在这里插入图片描述

2.3 系统调用

2.3.1 kill

常用命令kill 向指定进程发生信号,比如kill -9 xxx向pid为xxx的进程发送指定9号信号。

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

参数:
pid:代表目标进程的pid
sig:代表要发送几号信号
返回值:
调用成功返回0,失败返回-1
在这里插入图片描述
收到9号信号退出的,没有设置core dump标志位。
在这里插入图片描述

2.3.2 raise

这个函数与kill不同的是,raise是自己给自己发生信号。

#include <signal.h>
int raise(int sig);

sig:给自己发送信号的名称
返回值:
调用成功返回0,失败-1

2.3.3 abort

abort使当前进程给自己发生6号信号SIGABRT,使当前进程接收到信号而异常终止。

#include <stdlib.h>
void abort(void);

就像exit函数一样,abort函数总是会成功的,所以没有返回值。
在这里插入图片描述
使用函数捕捉信号,可以看到发送的是6号信号。
在这里插入图片描述

2.4 软件产生

软件层面通常是OS来触发信号的发送,系统层面设置定时器,或者某种操作而导致条件不就绪等场景下,就会发送信号。
例如:
进程间通信的匿名管道通信中:当读端不光不读,而且还关闭了读fd(文件描述符),写端一直在写,最终写进程会受到sigpipe (13),就是一种典型的软件条件触发的信号发送。

2.4.1 alarm

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

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

在这里插入图片描述
在这里插入图片描述
再来看一下发送的几号信号。
在这里插入图片描述

这里也可以用来展现CPU运行和IO的速度差别,取消输出语句,在自定义的重载函数里面输出循环次数cnt 的值。
在这里插入图片描述
在这里插入图片描述
同样的时间,差距如此巨大。可见IO是非常慢的。

三、信号的保存

信号是被立即处理的吗?不是的
也需信号送达时你在做着更重要的事。等处理完事再处理信号。

3.1 信号传递相关概念

先来认识三个概念:

  • 实际执行信号的处理动作称为信号递达(Delivery) — (处理方式有三种:默认(SIG_DFL),忽略(SIG_IGN), 自定义捕捉)

  • 信号从产生到递达之间的状态,称为信号未决(Pending)。信号被暂存在pending位图中

  • 进程可以选择阻塞 (Block )某个信号。
    本质是OS,允许进程暂时屏蔽指定的信号
    1.该信号依旧是未决的
    2.该信号不会被递达,直到解除阻塞!方可递达

  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

3.2 信号在内核中的表示

内核针对上面三种状态,采用三个数组来表示。

在这里插入图片描述

是否收到进程的表示 — (未决)pending 位图
进程中,采用位图来标识该进程是否收到信号!
pending位图 :保存已经收到但未被抵达的信号。
所谓的比特位的位置(第几个),代表的就是哪一个信号比特位的内容(0/1),代表的就是是否收到了信号。

所以,本质上OS发送信号的本质:修改目标进程的pending位图。

进程是否被阻塞 — block 位图

block表:本质上,也是位图结构

uint32_t block ;

比特位的位置:代表信号的编号
比特位的内容:代表信号是否(1/0)被阻塞 – 阻塞位图,也叫作信号屏蔽字。

如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理? 允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。

信号的递达方式 — handler 函数指针数组

每个信号的编号就是该数组的下标,里面存放函数地址,表示对该信号的处理方法。

3.3 处理信号保存的系统调用

3.3.1 sigset_t

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

sigset_t,是一个位图结构,不同的OS的实现是不同的。
为什么要有一个特定的数据类型呢? 因为OS不相信任何人,不能让用户能直接修改变量,需要使用特定的函数来修改,但其存储方式和普通变量相同。

3.3.2 信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释。不可使用位运算操作符对sigset_t操作。
在这里插入图片描述
sigemptyset(sigset_t *set) : 初始化set所指向的信号集,使其中所有信号的对应bit清零(0),表示该信号集不包含 任何有效信号。
sigfillset(sigset_t *set): 初始化set所指向的信号集,使其中所有信号的对应bit置位(1),表示该信号集的有效信号包括系统支持的所有信号。
sigaddset (sigset_t *set, int signo)和sigdelset(sigset_t *set, int signo) : 添加和删除指定信号。
sigismember(const sigset_t *set, int signo): 判断signo信号是否在set表示的信号集合里面。

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

在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信。

3.3.3 sigpending

在这里插入图片描述
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

参数是输出型参数,不修改pending 位图,只是单纯的获取pending 位图。

3.3.4 sigprocmask

在这里插入图片描述
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。set是输入型参数, oldset是输出型参数。

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

假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

how值作用
SIG_BLOCKset 包含了我们希望添加到信号屏蔽字的信号,相当于 mask |= set
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask = mask&~set
SIG_SETMASK设置当前信号屏蔽字为set指向的值吗, 相当于 mask = set

返回值:若成功则为0,若出错则为-1

3.3.5 代码演示

#include <stdio.h>                                                                                                                    
#include <signal.h>          
#include <unistd.h>          
#include <stdlib.h>      
#include <sys/wait.h>    
#include <sys/types.h>    
                         
void show_pending(sigset_t *set)    
{                        
  printf("current process pending: ");    
  int i = 1;    
  // 遍历每个信号如果在pending位图里面打印1,否则打印0    
  for(; i < 32; i ++ ){    
    if(sigismember(set, i)){    
      printf("1");    
    } else {    
      printf("0");    
    }    
  }    
  printf("\n");    
}    
void handler(int signo)    
{    
  printf("我是%d号信号, 已正常递达\n", signo);    
}    
int main()    
{ 
sigset_t iset, oset; // 和普通变量一样,存在栈上
  // iset |= 1; 这样做是非法的,不能直接修改
  
  sigemptyset(&iset);
  sigemptyset(&oset);

  signal(2, handler); // 自定义处理
  sigaddset(&iset, 2); // 添加一个2号信号 ctrl + c

  // 1. 设置当前的屏蔽字为iset, 将2号信号屏弊,无法被递达
  // 2. 获取老的屏蔽字到oset中
  sigprocmask(SIG_SETMASK, &iset, &oset);

  int cnt = 0;
  sigset_t pending;
  while(1){
    sigemptyset(&pending);
    // 获取未决 pending 位图
    sigpending(&pending);

    // 打印pending位图
	show_pending(&pending);
  
    sleep(1);
    cnt ++ ;
    // 5秒恢复正常
    if(cnt == 5){
      sigprocmask(SIG_SETMASK, &oset, NULL); //设置为老的屏蔽字, 恢复默认设置
      printf("2号信号恢复正常\n");
    }
  }
  return 0;
} 

在这里插入图片描述

四、信号的处理

之前我们说过,进程收到信号并不是立即处理而是在合适的时候。那什么时候才合适呢?

当进程从内核态非返回到用户态的时候,进行检测并处理。检测pending位图,处理的三种方式(默认、忽略、自定义)。

4.1 内核态和用户态

内核态:执行OS的代码和数据时,计算机所处的状态就叫做内核态。0S的代码的执行全部都是在内核态。
用户态:就是用户代码和数据被访问或者执行的时候,所处的状态。我们自己写的代码全部都是在用户态执行的!
主要区别:在于权限不同。

用户调用系统函数的时候后,除了进入函数,身份也会发生变化,用户身份变成内核身份

先来看一张图
在这里插入图片描述
用户的数据和代码一定要被加载到内存
OS的数据和代码只有一份,也是一定要被加载内存中的! 因为每个进程都需要系统代码,所以内核页表被所有进程共享! 只有一份。

进程之间无论如何切换,我们能够保证我们一定能够找到同一个OS,因为我们每个进程都有3~4G的地址空间,使用同一张内核页表所谓的系统调用;就是进程的身份转化成为内核,然后根据内核页表找到系统函数,执行就行了在大部分情况下,实际上我们OS都是可以在进程的上下文中直接运行的

那执行中怎么区分是系统代码还是用户代码,CPU内有寄存器(CP3)保存了当前进程的状态,可以区分当前用户状态。
用户态使用的是,用户级页表,只能访问用户数据和代码。
内核态使用的是,内核级页表,只能访问内核级的数据和代码。

4.2 信号处理过程

在这里插入图片描述
在这里插入图片描述

4.3 相关系统调用

在最开始的时候其实已经介绍过一个signal函数,可以将指定信号替换成自定义信号。

现在介绍一个能修改更多信息的函数。

4.3.1 sigaction

在这里插入图片描述
在这里插入图片描述
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。
signum是指定信号的编号。act是传入参数,oldact是传出参数。
act指针非空,则根据act修改该信号的处理动作。
oldact指针非空,则通过oact传出该信号原来的处理动作。

结构体变量 void (*sa_handler)(int);
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,但不是被main函数调用,而是被系统所调用。 这个参数与signal的参数类似。

sigset_t sa_mask;
这是信号集类型的变量。作用是什么呢?
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

#include <stdio.h>    
#include <signal.h>    
#include <unistd.h>    
#include <stdlib.h>    
#include <sys/wait.h>    
#include <sys/types.h>    
#include <string.h>    
    
void handler(int signo)    
{    
  int cnt = 5;                                                                                                                        
  while(cnt -- ){    
    printf("%d信号被执行\n",signo);    
    sleep(1);    
  }    
}    
    
int main()    
{    
    
  struct sigaction act;    
  memset(&act, 0, sizeof(act));    
  act.sa_handler = handler;    
  sigemptyset(&act.sa_mask);    
    
  sigaddset(&act.sa_mask, 3); // 执行其他信号时屏蔽3号信号 
  sigaction(2, &act, NULL); // 修改2号信号的部分信息

  while(1){
    printf("Hello \n");
    sleep(1);
  }

  return 0;
}

在这里插入图片描述

五、信号相关知识了解

5.1 可重入函数

在这里插入图片描述
在自己的函数体重遇到信号,在处理信号的函数内又执行一次函数,这就是重入。

一个函数一旦重入,有可能出现问题—该函数不可被重入,称为不可被重入函数,例如上面的insert
一个函数一旦重入,不会出现问题—该函数可重入,称为可被重入函数。
我们所学到的大部分函数,STL,boost库中的函数,大部分都是不可重入的!

5.2 volatile

先来看下面这部分代码。

#include <stdio.h>    
#include <signal.h>    
    
int flag = 0;    
    
void handler(int signo)    
{    
  flag = 1;    
  printf("%d号信号送达\n", signo);                                                                                                    
}    
    
int main()    
{    
  signal(2, handler);    
    
  while(!flag); // 条件成立死循环    
    
  printf("进程正常退出\n"); // 按理如果发生2号信号,循环终止,程序正常结束    
  return 0;    
} 

在这里插入图片描述
这说明编译器进行了优化,将主函数中的 flag 的值固定了。

原本全局变量flag=0,放在内存中,执行循环判断是CPU去内存取该变量的值来判断,但是编译器优化后,使得CPU将该变量的值存放在一份寄存器中,这样就不用频繁访问主存,直接获取寄存器的值,当收到信号将flag的值改变后,寄存器的值没有变化,改变的是内存上的值。所以导致无法终止循环。这是就要用到volatile关键字了

volatile int flag = 0;

在这里插入图片描述

volatile 作用 :保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量
的任何操作,都必须在真实的内存中进行操作。

5.3 SIGCHLD

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

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

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

void GetChild(int signo)
{
    waitpid(-1, NULL, 0); // 回收子进程
    printf("get a signal : %d, pid: %d\n", signo, getpid());
}

int main()
{
    signal(SIGCHLD, GetChild);
    pid_t id = fork();
    if(id == 0){
        //child
        int cnt = 5;
        while(cnt){
            printf("我是子进程: %d\n", getpid());
            sleep(1);
            cnt--;
        }
        exit(0);
    }

    while(1);
    return 0;
}

在这里插入图片描述

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

	//显示设置忽略17号信号,当进程退出后,自动释放僵尸进程
    //只在Linux下有效
    signal(SIGCHLD, SIG_IGN);

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

s_persist

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值