linux进程信号


前言


一、信号入门

1、生活角度的信号

  • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”。
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
  • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”。
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)。
  • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。

2、技术应用角度的信号

我们在执行像如下的死循环打印的程序时,如果我们想要终止这个进程,我们可以在键盘中按Ctrl + C,然后就可以终止当前前台进程了,那么为什么可以通过这种方式来终止进程呢?
其实当我们在键盘中按Ctrl + C时,此时键盘输入会产生一个硬件中断,这个中断会被操作系统获取,并且解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。Ctrl + C表示的就是2信号。
在这里插入图片描述
在这里插入图片描述
下面我们来验证Ctrl + C就是给进程发送的2信号。我们看到当进程运行时,我们通过kill命令给该进程发送2信号,该进程也终止了。
在这里插入图片描述
注意:

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

Linux下的信号:
那么除了2信号,Linux中还有什么信号呢?我们可以使用kill -l命令来查看Linux中的信号。我们通常将[1,31]信号称为普通信号,而[34,64]信号称为实时信号。

//查看信号列表
kill -l

在这里插入图片描述
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2
信号名定义路径:/usr/include/bits/signum.h
在这里插入图片描述
下面是[1,31]信号表示的意思。

信号编号信号名信号含义
1SIGHUP如果终端接口检测到一个连接断开,则会将此信号发送给与该终端相关的控制进程,该信号的默认处理动作是终止进程。

3、产生信号

在学习产生信号之前,我们需要先了解当信号发送给进程后,进程是怎样保存信号信息的。实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。我们都知道进程控制块本质上就是一个结构体变量,在Linux中进程控制块是内核数据结构task_struct。而对于信号来说我们主要就是记录某种信号是否产生,因此,我们可以用一个32位的位图来记录信号是否产生。其中比特位的位置代表信号的编号,而比特位的内容就代表是否收到对应信号,比如第6个比特位是1就表明收到了6号信号。
当一个进程收到信号,本质就是该进程的内核数据结构task_struct中的信号位图被修改了,而内核数据结构只能通过操作系统来进行修改,因为操作系统是进程的管理者。所以信号的产生本质上就是操作系统直接去修改目标进程的task_struct中的信号位图。
在这里插入图片描述

信号处理常见方式:

  • 默认(进程自带的,程序员写好的逻辑),即执行该信号的默认处理动作。例如我们像进程发送2信号,进程就进行终止。
  • 忽略(也是信号处理的一种方式),即忽略此信号。
  • 自定义动作(捕捉信号),即提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。

在Linux中,我们可以通过man手册查看各个信号默认的处理动作。

//查看信号的信息
man 7 signal

在这里插入图片描述

3.1 通过终端按键产生信号

当我们执行死循环程序时,我们可以通过Ctrl + C来终止进程,其实我们还可以使用Ctrl + \来终止进程。按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。
在这里插入图片描述
下面我们来使用signal函数验证Ctrl + C向进程发送2信号,Ctrl + \向进程发送3信号。
我们先来学习signal系统调用的使用。我们上面讲了信号处理常见方式有默认、忽略、自定义,而signal函数就是我们自定义一个进程收到某个信号后的处理动作的,signal函数的第一个参数就是要自定义处理动作的信号,第二个函数就是设置对应信号的处理方法。可以看到sighandler_t类型是一个函数指针,这个指针指向一个参数为int没有返回值的函数。所以在signal函数中采用回调函数的方式来将某一个信号的处理方法进行重写。并且在signal函数中调用handler函数时会将signum作为handler的参数传进去。
在这里插入图片描述
下面我们自定义信号2的处理函数。下面的代码中我们使用signal函数自定义了2信号的处理动作为catchSig函数,此时当这个进程收到2信号时,就不会执行默认的处理动作了,而是执行我们修改后的处理动作,即调用catchSig函数。signal函数修改进程对特定信号的后序处理动作一般都写在程序的开头,表示事先声明下面的程序中当遇到某个信号时,不会再执行默认的处理动作,而是执行修改后的处理动作。
在这里插入图片描述
我们看到此时当按Ctrl + C时向进程发送2信号就不会终止进程了,因为我们修改了该进程对2信号的处理动作为调用catchSig函数。此时我们想要终止进程可以按Ctrl + \。
在这里插入图片描述
下面我们将进程收到3信号的处理动作也修改为调用catchSig函数。然后当进程运行后,我们按Ctrl + C时,可以看到进程收到了2信号,我们按Ctrl + \时可以看到进程收到了3信号。此时我们可以使用kill -9 pid命名来杀掉该进程。
在这里插入图片描述
在这里插入图片描述
那么2信号和3信号都是终止进程,它们之间有什么不同呢?
我们看到2信号SIGINT的默认处理动作为Term,3信号SIGQUIT的默认处理动作为Core。SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump在这里插入图片描述T即erm和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作,那就是核心转储(Core Dump)。
在这里插入图片描述
那么什么是核心转储?
核心转储(Core Dump)就是当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。
所以在云服务器上,一般核心转储的功能都是被默认关闭的,我们可以通过ulimit -a 命令来查看当前资源限制的设定。

ulimit -a

在这里插入图片描述
我们看到当前服务器中允许产生的core文件的大小为0,我们可以使用 ulimit -c 1024命令改变Shell进程的Resource Limit,允许core文件最大为1024K。这个命令设置了本次登录中允许core文件最大为1024K,当下一次登录时允许core文件的最大值还是为0。

ulimit -c 1024

在这里插入图片描述
当我们设置好了核心转储后,此时执行下面的死循环进程,然后我们向这个进程发送3信号终止该进程,可以看到生成了一个core文件。而当我们向这个进程发送2信号终止该进程时,没有生成core文件。
注意:
ulimit命令改变的是Shell进程的Resource Limit,但test02进程的PCB是从父进程Shell进程复制而来的,所以也具有和Shell进程相同的Resource Limit值。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
那么生成的core文件有什么用呢?
我们知道进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,而事后我们可以用调试器检查core文件以查清错误原因,这种调试方式叫做Post-mortem Debug(事后调试)。下面我们来演示怎么使用core文件来进行调试。
下面我们写一段代码,这段代码中有一个除0错误,而除0错误操作系统会向进程发送8信号,表示出现浮点错误,并且8信号的默认处理方式为Core类型,也会生成core文件。
在这里插入图片描述
在这里插入图片描述
我们编译时生成debug版本的程序,然后我们使用gdb进行调试,我们输入core-file core.26499命令,可以看到gdb中直接跳到了出现错误的地方。所以核心转储可以在进程出现某种异常的时候,将当前进程在内存中的相关核心数据转存到磁盘中,这样在调试时就可以使用生成的core文件来进行调试了。

core-file core.26499

在这里插入图片描述
在这里插入图片描述
我们在前面学习进程等待时,当时我们了解了wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。status按照比特位的方式,将32个比特位进行划分,用来保存不同的信息。我们当时学习的status的低16位中,知道了8-15位是保存子进程退出码的,而第7位core dump我们当时没有讲解,其实status的第7位就是记录这个等待的进程在退出时是否进行了核心转储。
下面我们验证进程等待中的status的core dump标记位。
在这里插入图片描述

我们看到当创建的子进程生成core文件后,父进程中接收到的status的core dump位变为1。
在这里插入图片描述
我们将子进程打印自己的pid,然后睡眠100秒,这期间我们使用kill 命令向子进程中发送2信号,因为2信号不会生成core文件,所以我们看到父进程中的status的core dump位为0。
在这里插入图片描述
在这里插入图片描述
下面我们将生成的core文件的大小改为0,即代表关闭了核心转储功能,此时当进程收到8信号时,就不会生成core文件了,并且父进程中的status的core dump位也为0。
在这里插入图片描述
在这里插入图片描述在这里插入图片描述
为什么服务器中默认关闭生成core文件的功能呢?
这是因为core文件中可能包含用户密码等敏感信息,不安全。并且当一个进程异常退出时就会生成core文件,而如果程序一直挂掉又启动的话,那么就会在磁盘中生成大量的core文件。

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

用户除了可以使用键盘上的Ctrl + C等按键向前台进程发送信号外,还可以在代码中调用系统调用函数来向进程发送信号。下面我们来看kill系统调用函数。
kill
kill系统调用函数的第一个参数为进程pid,即要向哪个进程发送信号,就填哪个进程的pid。
第二个参数为sig,即要发送的信号。
其实我们前面在命令行中输入kill -2 pid命令时,底层调用的就是kill系统调用。
在这里插入图片描述
下面我们来模拟实现一个kill程序。
在这里插入图片描述
我们看到使用test04程序成功向test01程序发送了3信号终止了test01程序。
在这里插入图片描述

raise

我们还可以在程序中调用raise函数来让操作系统给当前进程发送一个信号。
arise函数只有一个参数sig,这个参数就是该进程想让操作系统发送给自己的信号。如果信号发送成功,arise返回0,否则返回一个非零值。
在这里插入图片描述
下面我们让一个进程在睡眠2秒后调用raise函数让操作系统向自己发送一个3信号来终止自己。
在这里插入图片描述
在这里插入图片描述

abort
abort函数是一个无参数无返回值的函数。我们看到abort函数默认向当前进程发送一个SIGABRT信号,即6信号。
在这里插入图片描述
在这里插入图片描述
下面我们改变当前进程收到6信号后的默认处理方式,将当前进程收到6信号后调用handler函数。但是我们看到与之前不同的是,test06进程还是退出了。这是因为abort函数的作用是异常终止进程,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,因此使用exit函数终止进程可能会失败,但使用abort函数终止进程总是成功的,所以我们就是修改了SIGABRT信号的默认处理方式,该进程还是会终止。那么我们就知道了调用abort函数一定可以将进程终止。
在这里插入图片描述
在这里插入图片描述

3.3 由软件条件产生信号

SIGPIPE信号

SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
下面代码当中,创建匿名管道进行父子进程之间的通信,其中父进程是读端进程,子进程是写端进程,但是一开始通信父进程就将读端关闭了,那么此时子进程在向管道写入数据时就会收到SIGPIPE信号,进而被终止。
在这里插入图片描述
在这里插入图片描述

SIGALRM信号

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动
作是终止当前进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
在这里插入图片描述
下面我们使用alarm定时器来验证1秒内CPU会进行多少次count++。
我们先调用alarm设置一个1秒的闹钟,当1秒后alarm会向当前进程发送SIGALRM信号,而SIGALRM信号的默认处理方式就是终止进程。我们看到测试的结果为CPU每秒才进行了不到两万次++运算,这肯定是不准的,这是因为这个测试方法每一次++都进行了打印,而打印就需要IO操作,并且因为是云服务器,所以还会有网络的影响。
在这里插入图片描述
在这里插入图片描述
下面的代码中我们修改了SIGALRM信号默认的处理方式,当进程收到SIGALRM信号时会打印count的值,这样我们就准确的算出了1秒内CPU计算++的次数。并且每当我们向该进程发送一个14信号时,就会调用一次catchSig函数打印count的值。
在这里插入图片描述
在这里插入图片描述
当我们需要间隔一定的时间执行一件事时,我们就可以在闹钟触发后再定一个新的闹钟。下面我们写一个间隔一定时间就打印日志的一个程序来体会alarm函数的应用。

#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<functional>
#include<vector>

using namespace std;

typedef function<void ()> func;
vector<func> callbacks;
uint64_t count = 0;

void catchSig(int signum)
{
  for(auto &f : callbacks)
  {
    f();
  }
  alarm(2);
}
//打印日志函数
void showCount()
{
  cout<<"final count: "<<count<<endl;
}

void showLog()
{
  cout<<"这个是日志功能"<<endl;
}

void logUser()
{
  if(fork()==0)
  {
    execl("/usr/bin/who","who",nullptr);
    exit(1);
  }
  wait(nullptr);
}

int main()
{
  signal(SIGALRM,catchSig);
  alarm(2);
  callbacks.push_back(showCount);
  callbacks.push_back(showLog);
  callbacks.push_back(logUser);
  while(true)
  {
    count++;
    cout<<"进程还在运行"<<endl;
    sleep(1);
  }

  return 0;
}

我们看到每隔2秒程序就会打印以下的信息,并且不会影响程序的正常运行,程序还是正常执行。这就是一种定时任务。
在这里插入图片描述
当通过软件条件给进程发送信号时,操作系统都做了什么呢?
操作系统先识别到某种软件条件触发是否满足,如果满足操作系统就构建信号,然后将信号发送给指定的进程。

3.4 硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
下面我们写一段代码产生了除0的异常,我们发现程序会一直循环打印。但是上面我们使用abort函数时,虽然改变了6信号的默认处理方式,但是程序还是会因为异常而退出,而当除0出现异常时,为什么程序不会因为异常而退出呢?
我们知道,CPU当中有一堆的寄存器,当我们需要对两个数进行算术运算时,我们是先将这两个操作数分别放到两个寄存器当中,然后进行算术运算并把结果写回寄存器当中。此外,CPU当中还有一组寄存器叫做状态寄存器,它可以用来标记当前指令执行结果的各种状态信息,如有无进位、有无溢出等等。而操作系统是软硬件资源的管理者,在程序运行过程中,若操作系统发现CPU内的某个状态标志位被置位,而这次置位就是因为出现了某种除0错误而导致的,那么此时操作系统就会马上识别到当前是哪个进程导致的该错误,并将所识别到的硬件错误包装成信号发送给目标进程,本质就是操作系统去直接找到这个进程的task_struct,并向该进程的位图中写入8信号,写入8号信号后这个进程就会在合适的时候被终止。但是因为寄存器中的异常一直没有被解决,并且也没有办法解决。所以操作系统会一直检测到这个异常,然后给进程发8信号,这样进程就会一直收到8信号而一直执行handler函数。
在这里插入图片描述
在这里插入图片描述
那对于下面的野指针问题,或者越界访问的问题时,操作系统又是如何识别到的呢?
在这里插入图片描述
在这里插入图片描述
首先我们需要知道的是,当我们要访问一个变量时,一定要先经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。
其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,但现在MMU已经集成到CPU当中了。
当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。
而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送SIGSEGV信号。
在这里插入图片描述
下面我们来验证当出现野指针或越界访问时,操作系统发送的信号为SIGSEGV信号,即11信号。
在这里插入图片描述
在这里插入图片描述
**总结: **
通过上面的分析,我们可以总结出所有的信号都有它的来源,但是最终全部都会被操作系统给识别到,然后操作系统对这些信号进行解释,并发送给对应的进程,即修改对应进程的task_struct内核结构体中的数据。

二、阻塞信号

1、信号其他相关常见概念

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

2、在内核中的表示

信号在内核中的表示示意图如下所示。
在这里插入图片描述

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

在每个进程的task_struct中都记录了这样的两个位图结构和一个函数指针数组,pending位图结构里面的每一位就记录了对应信号是否为未决状态。block位图结构记录了对应信号是否屏蔽。handler为一个函数指针数组,里面记录了对应信号的处理动作。前面我们使用signal函数修改信号的默认处理动作的过程其实就是将自己写的sighandler函数的地址放入到handler函数指针数组中。
我们前面说了信号的处理有三种,分别为忽略、默认、自定义。操作系统向一个进程发送一个信号就是修改这个进程的pending位图里的内容,当进程处理信号时会先去pending位图中查找哪些信号需要被处理,当发现比特位为1的信号后,然后去block位图中查看当前信号是否被阻塞,block中为0表示没有被阻塞,此时才会来到handler函数指针数据中执行对应信号的处理动作。在得到信号编号signum后,并不会直接执行handler数组里面的对应函数,而是先将函数进行强转,如果结果为0,表示执行这个信号的默认动作;如果结果为1,表示忽略这个信号;只有结果不为0或1时,才会执行这个信号的自定义动作。
在这里插入图片描述

3、sigset_t

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

我们知道在每个语言中都会给我们提供.h、.hpp和语言自定义的类型,以便我们使用该语言进行一些操作。那么操作系统也给我们提供了一些.h头文件和操作系统自定义的类型,而sigset_t就是操作系统提供的一种自定义类型。

  • sigset_t 不允许用户自己进行位操作,但是操作系统给用户提供了对应的操作位图的方法。
  • sigset_t 是用户可以直接使用的类型,和使用语言的内置类型、自定义类型没有任何差别。
  • sigset_t 操作系统定义这个类型,一定是因为向用户提供了对应的系统调用接口完成对应的功能,而这个系统调用接口的参数可能就需要sigset_t 类型的变量或者对象。

4、 信号集操作函数

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位置1,表示该信号集的有效信号包括系统支持的所有信号。
  • 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
  • 这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

5、sigpending系统调用和sigprocmask系统调用

sigpending

我们可以使用sigpending系统调用来获得当前进程的pending信号集。sigpending系统调用读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
在这里插入图片描述

sigprocmask

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。返回值:若成功则为0,若出错则为-1.
如果set是空指针,oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则更改进程的信号屏蔽字为set,参数how指示如何更改。如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

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

在这里插入图片描述
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

下面我们来使用上面的函数验证一些问题,首先我们思考一下,如果我们对所有的信号都进行了自定义捕捉,那么我们是不是就写了一个不会被异常或者用户杀掉的进程?

我们写如下的程序,将1-31的普通信号都自定义捕捉。
在这里插入图片描述
我们向当前进程发送信号时,虽然我们对9号信号进行了自定义捕捉,但是发现发送9号信号时还是杀掉了当前进程。这是因为操作系统规定了SIGKILL信号不能被捕捉。
在这里插入图片描述

下面我们演示将2号信号block,并且不断的获取并打印当前进程的pending信号集,然后我们突然发送一个信号,我们就能看到pending信号集中,2号信号对应的比特位由0变为1。

#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<assert.h>
using namespace std;
void catchSig(int signum)
{
  std::cout<<"获得了一个信号:"<<signum<<endl;
}

static void showPending(sigset_t &pending)
{
  for(int sig = 1; sig<=31; sig++)
  {
    //调用sigismember函数检查pending信号集中是否有指定信号。
    if(sigismember(&pending,sig))
    {
      cout<<"1";
    }
    else
    {
      cout<<"0";
    }
  }
  cout<<endl;
}

int main()
{

  //1. 定义信号集对象
  sigset_t bset,obset;
  sigset_t pending;

  //2. 初始化信号集对象
  //将上面的信号集对象的比特位都置为0
  sigemptyset(&bset);
  sigemptyset(&obset);
  sigemptyset(&pending);

  //3. 添加要进行屏蔽的信号
  //此时bset信号集的2号信号的比特位为1
  sigaddset(&bset,2);  //也可以传入2信号的宏定义SIGINT

  //4. 设置set到内核中对应的进程内部(默认清空进程不会对任何信号进行block)
  //使用sigprocmask系统调用接口来设置当前进程的信号屏蔽字为bset。
  int n = sigprocmask(SIG_BLOCK, &bset, &obset); 
  assert(n == 0);
  (void)n;
  cout<<"block 2 信号成功。"<<endl;
  
  //5. 重复打印当前进程的pending信号集
  while(1)
  {
    // 5.1 获取当前进程的pending信号集
    sigpending(&pending);
    // 5.2 调用showPending函数显示当前进程的pending信号集中没有被递达的信号
    showPending(pending);
    sleep(1);
  }
  return 0;
}

当向当前进程发送一个2号信号时,我们看到了当前进程的pending信号集中2号信号对应的比特位变为了1,但是因为当前进程将2号信号屏蔽了,所以2号信号永远处于未决状态,而永远不会被递达。
在这里插入图片描述
下面的程序中,在20秒后就解除对2号信号的屏蔽。然后我们在这之前向当前进程发送一个2号信号,我们看到当前进程的pending信号集的2号信号的比特位变为1了,但是当解除了2号信号的屏蔽后,当前进程马上被终止了。这是因为默认情况下,恢复对于2号信号的block的时候,2号信号会进行递达,但是2号信号的默认处理动作是终止进程,所以当前进程才被终止了。
在这里插入图片描述
在这里插入图片描述
下面我们对2号信号进行自定义捕捉,然后当我们向当前进程发送2号信号时,我们看到当前进程的pending信号集中2号信号对应的比特位变为1,而当解除对2号信号的block后,2号信号马上被递达,执行了自定义处理动作,然后当前进程的pending信号集中的2号信号对应的比特位变为0。
在这里插入图片描述
在这里插入图片描述

我们通过上面的测试发现可以通过操作系统提供的sigpending接口来获取当前进程的pending信号集,但是操作系统好像没有提供一个接口来改变当前进程的pending信号集。这是因为前面我们学习的所有的信号发送方式都是修改当前进程pending信号集的方式。

下面我们封装了一个blockSig接口用来将传入的信号进行屏蔽。然后我们将1-31号信号都进行屏蔽。
在这里插入图片描述
然后我们写一个shell脚本,用来向当前进程发送信号。
在这里插入图片描述
我们看到当向进程发送9号信号时,当前进程还是被杀掉了,这说明9号信号不能被屏蔽。
在这里插入图片描述
然后我们将shell脚本中向进程发送信号时,跳过9号信号。
在这里插入图片描述
然后我们看到当shell脚本向进程发送19号信号时,当前进程被暂停了,这说明19号信号也无法被屏蔽。
在这里插入图片描述
然后我们将shell脚本中向进程发送信号时,跳过9号信号和19号信号。
在这里插入图片描述
我们看到这次进程没有被终止或暂停了,但是我们看到当前进程的pending信号集中18和19的位置都为0了,这是因为18信号和19信号类似。
在这里插入图片描述
在这里插入图片描述

通过上面的测试我们知道了:
有两种信号不能被屏蔽:SIGKILL(9)、SIGSTOP(19)。
有两种信号不能被捕捉:SIGKILL(9)、SIGSTOP(19)。

三、捕捉信号

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。

1、内核空间与用户空间

我们知道每一个进程都有自己的进程地址空间,这个进程的地址空间由内核空间和用户空间组成,进程的地址空间中的用户空间采用用户级页表映射到物理内存中,内核空间采用内核级页表映射到物理内存中。

  • 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
  • 内核空间存储的是操作系统内核程序,通过内核级页表与物理内存之间建立映射关系。

内核级页表是一个全局的页表,只有一份,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。所以在物理内存的不同区域有每个不同进程的代码和数据,但是只有一份操作系统的代码和数据,所有进程共享这一份操作系统的代码和数据。
在这里插入图片描述
那么为什么要这样设计呢?这是因为出于安全考虑。
在计算机系统中将指令分为特权指令和非特权指令。

  • 特权指令:是指不允许用户直接使用的指令,如I/O指令、置中断指令,存取用于内存保护的寄存器、送程序状态字到程序状态字寄存器等的指令。
  • 非特权指令:是指允许用户直接使用的指令,它不能直接访问系统中的软硬件资源,仅限于访问用户的地址空间,这也是为了防止用户程序对系统造成破坏。

2、用户态与内核态

在具体实现上,将CPU的运行模式分为用户态(目态)和核心态(管态、内核态)。可以理解为CPU内部有一个小开关,当小开关为0时,CPU处于内核态,此时CPU可以执行特权指令,切换到用户态的指令也是特权指令。当小开关为1时,CPU处于用户态,此时CPU只能执行非特权指令。而这个小开关就是CR3寄存器。应用程序运行在用户态,操作系统内核程序运行在内核态。应用程序向操作系统请求服务时通过使用访管指令,从而产生一个中断事件将操作系统转换为内核态。
所以当一个进程想要切换到另一个进程时,此时需要执行操作系统内核程序,那么当前进程就会通过产生一个中断来进入内核态执行操作系统内核程序。即在当前进程的进程地址空间中的内核空间中找到操作系统内核程序,然后执行转换进程的程序,将当前进程的代码和数据从CPU中撤下来并且将当前进程的寄存器数据都保存(进程上下文),然后换上另一个进程的代码和数据。

我们上面讲的进程收到信号后并不是立即处理信号,而是在合适的时候,其实就是在从内核态切换为用户态的时候。
从用户态切换为内核态通常发生在下面的几种情况中:
(1). 需要进行系统调用时。
(2). 当前进程的时间片到了,进行进程切换。
(3). 产生中断(外中断)和异常(内中断)时。

从内核态切换为用户态有如下的几种情况:
(1). 系统调用返回时。
(2). 进程切换完毕。
(3). 异常和中断处理完毕。

其中,CPU由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时候,本质上是因为我们需要执行操作系统的内核程序,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。

内核如何实现信号的捕捉
当我们在执行主控制流程的时候,可能因为某些情况而让CPU陷入内核,当CPU在内核态执行完内核程序后准备切换为用户态时,此时会检查当前进程的pending信号集,即会在这个时候进行当前进程的信号处理。因为此时CPU还没有退出内核态,所以有权利查看当前进程的pending信号集。
在查看当前进程的pending信号集时,当发现有处于未决状态的信号,并且这个信号没有被阻塞,那么此时就需要对该信号进行处理。
如果处于未决状态的信号的处理动作是默认或者忽略,则执行该信号的处理动作后将pending信号集中对应的标志位置为0。如果没有新的信号要递达后,那么CPU就会返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
在这里插入图片描述
如果处于未决状态的信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么执行该信号的处理动作时需要先切换为用户态执行用户提供的自定义处理动作,执行完后再通过特殊的系统调用sigreturn切换为内核态,然后修改当前进程的task_struct内核数据结构中的pending信号集,将处理完的信号的标志位置为0。如果没有其它信号要进行递达,那么就再次切换为用户态,并且返回到主控制流程中上次被中断的地方继续向下执行。
在这里插入图片描述
下面我们通过一个例子来体会这个过程。如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

我们可以使用下面的图片来总结当待处理信号是自定义捕捉时的情况。
这个图中的绿色点就表示在内核态时检查未决信号的过程。而4个蓝色的点就表示状态的切换,箭头方向就代表着此次状态切换的方向。
在这里插入图片描述
那么当识别到信号的处理动作是用户自定义的时,能不能直接在内核态执行用户自定义函数呢?
理论上是可以的,但是因为内核态中执行的都是内核程序,如果允许在内核态执行用户代码的话,那么可能用户代码会有一些非法操作破坏系统,所以出于安全考虑在内核态时不能执行用户代码。

3、sigaction

我们前面学习了调用signal系统调用接口来自定义捕捉信号,操作系统还提供了sigaction系统调用接口来进行自定义捕捉信号。sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oldact指针非空,则通过oldact传出该信号原来的处理动作。act和oldact指向sigaction结构体。在这里sigaction接口和sigaction结构体的名字相同。
在这里插入图片描述
在这里插入图片描述
下面我们使用sigaction接口来自定义捕捉2号信号。
我们在调用sigaction接口时一定需要创建一个sigaction结构体对象当作第二个参数,并且这个结构体对象需要进行一系列初始化,至于第三个参数我们如果不想要接收当前信号旧的处理动作,就可以设置为nullptr。
在这里插入图片描述

因为上面的代码中用到了强转,会出现精度问题,所以在编译时使用 -fpermissive 选项忽略精度问题。
在这里插入图片描述
我们看到使用sigaction接口成功自定义捕捉了2号信号,并且我们看到2号信号原来的处理动作是默认。
在这里插入图片描述
下面我们将2号信号的处理动作在代码开始就设置为忽略,然后我们看到sigaction接口返回的oact中2号信号的旧的处理动作就为忽略了。
在这里插入图片描述
在这里插入图片描述

我们知道自定义捕捉信号会执行用户自定义的处理动作,那么当在执行自定义动作的时候,此时又来了同样的信号,操作系统该如何处理呢?
答:当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前信号处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

下面我们来验证一下上面的结论。
当我们自定义捕捉到2号信号后,会执行handler函数,在handler函数中我们sleep(10),在这期间如果操作系统再次收到2号信号,那么此时2号信号是被屏蔽的,所以不会进行递达。只有当执行完handler函数后操作系统才会递达下一个信号。
在这里插入图片描述
我们看到当向进程发送2号信号后,再发送2号信号时,此时2号信号已经被屏蔽了。
在这里插入图片描述
下面我们来通过显示当前进程的pending信号集来看到2号信号被阻塞了。
在这里插入图片描述
我们看到当执行当前的2号信号时,此时如果再次发送2号信号给这个进程,这个进程的pending信号集是会记录当前信号的,但是并不会马上执行对应的处理动作,而是等执行完当前信号的处理动作后再执行新的信号的处理动作。
在这里插入图片描述

设置sa_mask,在处理2号信号的期间也屏蔽其它信号
在这里插入图片描述
我们看到在执行2号信号的处理动作期间,向进程发送3、4、5号信号,都被操作系统屏蔽了。但是当2号信号的处理动作执行完后,当前进程马上被终止了,这是因为3号信号的默认处理动作是终止进程,所以当执行完2号信号的处理动作后,执行3号信号的处理动作终止进程了。
在这里插入图片描述

4、可重入函数

前面我们学习数据结构时知道了单链表头插结点,分为两步操作,第一步将新结点的next指针赋值为head指针,第二步将head指针指向新的头结点。
下图的情况中main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
在这里插入图片描述
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。

想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
答:这是因为两次调用函数都建立了栈帧,表明上看是访问的同一个局部变量,实际是各个函数访问各自栈帧中的局部变量。

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

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

5、volatile关键字

下面我们使用一个例子来体会volatile关键字的用处。
我们写一个下面的程序,程序执行时flag为0,此时程序会一直死循环。然后我们向进程发送一个2号信号,进程执行2号信号的自定义处理函数changeFlag,在changeFlag函数内将flag改为1,那么main主控制流程中就不满足while循环条件了,然后会执行下面的语句进行打印。我们看到该程序的执行结果如下,和我们分析的一致。
在这里插入图片描述
在这里插入图片描述
然后我们在makefile文件中,使用g++编译器编译mysignal.cc文件时,我们使用g++编译器的优化选项,O1、O2、O3即为g++编译器的优化选项,O3的优化级别最高。
在这里插入图片描述
然后我们重新编译mysignal.cc文件,再次执行这个程序时,我们向进程发送2号信号,但是此时进程的结果却变了。这就是因为g++编译器做的优化出了问题,导致我们程序的结果和预期的不一致。
在这里插入图片描述
下面我们来分析出现这种现象的原因。
当我们不使用g++的优化选项来编译代码时,生成的可执行程序中当需要用到flag变量时会去内存中读取flag变量的值,所以当在changeFlag函数中改变了flag变量的值为1后,此时内存中flag的值就变为1了,然后程序执行到while循环中时用到了flag变量,所以会去内存中取flag的值,取回来为1。经过判断后发现不满足while循环条件,然后执行下一条语句。
在这里插入图片描述
当我们使用g++的优化选项来编译代码时,在编译阶段g++编译器看到程序下面的代码中都没有改变flag变量的值,所以就采用优化,将flag变量的值存到edx寄存器中,然后编译生成的可执行程序中想要使用flag变量时,就去edx寄存器中取值,这样访问flag变量的值就不需要访问内存了,就提高了程序的效率。但是当我们执行程序时,向进程发送一个2号信号,然后changeFlag函数中内存中的flag变量的值改为1,但是edx寄存器中的flag的值并没有改变,这就存在了数据二异性的问题。所以在while循环判断时用到flag变量的值时,不会去内存中取flag变量的值,而是去edx寄存器中取到flag变量的值为0,所以还满足while循环的条件,然后就会一直进行while循环。
在这里插入图片描述
所以当我们使用g++编译器的优化选项进行编译时,可能会出现使CPU无法看到内存的情况。如果我们不想让flag变量被编译器经过上面的优化,我们就可以使用volatile关键字修饰flag变量,这样就保持flag变量的内存可见性了。
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
在这里插入图片描述
在这里插入图片描述

5、SIGCHLD信号

前面学习进程时我们知道用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。其实子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,即向父进程发送SIGCHLD信号,当父进程收到SIGCHLD信号就知道子进程终止了,然后父进程在信号处理函数中调用wait清理子进程即可。
在这里插入图片描述

下面我们验证子进程退出时会向父进程发送SIGCHLD信号。

我们将父进程中自定义捕捉SIGCHLD信号,然后使用fork接口创建一个子进程,然后让子进程退出,这样父进程就会收到SIGCHLD信号而执行handler函数。我们看到程序运行的结果和我们预期的是一样的。但是我们也发现了因为子进程退出了,但是父进程并没有调用wait或waitpid系统调用接口来回收子进程的资源,所以当前子进程为僵尸进程。
在这里插入图片描述
在这里插入图片描述
我们在handler函数中使用wait系统调用来回收子进程资源,这样当父进程收到SIGCHLD信号而执行handler函数后就会调用wait系统调用来回收子进程资源,这样就不会产生僵尸进程了。
在这里插入图片描述
在这里插入图片描述
上面的代码中我们只创建了一个子进程,所以当这个子进程退出时向父进程发送SIGCHLD信号,然后父进程调用handler函数,handler函数里面调用wait系统调用将这个子进程的资源回收即可。但是当我们创建了很多进程时,此时我们需要在handler函数中使用while循环来等待每个程序退出。
例如如果有10个子进程,我们让10个子进程都sleep(10)秒后退出,那么我们可以直接在handler函数中循环使用wait系统调用接口即可。因为wait函数如果等待成功会返回子进程pid,然后会继续进入循环中调用wait函数等待下一个子进程,直到所有的子进程都等待成功,然后wait函数返回-1,然后跳出while循环。我们看到当子进程退出后父进程继续执行自己的代码。
在这里插入图片描述
在这里插入图片描述
但是当我们创建的10个子进程不是同时退出时,那么这个程序就会在handler函数中的wait函数中进行阻塞式等待。下面我们让5个子进程10秒后退出,让另外5个子进程100秒后退出。我们看到当前5个子进程sleep10秒后退出了,此时父进程收到SIGCHLD信号执行handler函数,在handler函数中循环调用wait函数,但是因为还有5个子进程没有退出,所以父进程会在handler函数中的wait函数中进行阻塞式等待。此时父进程就无法再执行自己的其它代码了,而需要阻塞等待子进程都退出后才能执行其它代码。
在这里插入图片描述
在这里插入图片描述
此时如果我们不想让父进程阻塞式的等待子进程,那么我们就需要在handler函数中使用waitpid函数来进行循环等待子进程了,并且将waitpid函数设置为非阻塞式等待。这样当父进程执行handler函数中的waitpid函数时,如果还有进程没有退出,那么因为是非阻塞式的等待,所以父进程还会执行下面的代码,并且因为waitpid函数当还有子进程没有退出时,waitpid函数会返回0,所以会退出循环。
在这里插入图片描述
在这里插入图片描述

并且如果父进程关心这些子进程的退出状态的话,那么就使用wait等待来得到子进程退出状态。如果父进程不关心子进程的退出状态的话,那么就可以在父进程中手动设置收到SIGCHLD信号后执行忽略处理。需要注意的是我们手动设置的信号忽略和操作系统级别的信号忽略是不一样的。操作系统级别的忽略就是什么都不做,所以也不会清理子进程的资源。而我们自己使用signal手动设置的用户级别忽略和操作系统层面的忽略不一样,会清理资源。
在这里插入图片描述
我们看到虽然父进程没有调用wait和waitpid函数来回收子进程的资源,但是也没有出现僵尸进程。
在这里插入图片描述

我们还需要知道并不是只有子进程退出时才向父进程发送SIGCHLD信号,当子进程暂停时也会向父进程发送SIGCHLD信号。
在这里插入图片描述
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值