Linux—进程信号

进程信号

位图 (3)

感性理解信号

  • 当在网上买了快递,而手机提示今天快递会到,在快递要来临之前,你知道应该怎么处理快递,即你能事先识别“快递”。

  • 此时你在打游戏,当快递到楼下了,你收到了消息说快递到了,即你接收到了信号。

  • 此时你会有三种反应,一是马上起身去拿快递并打开使用,这叫默认动作;二是拿完快递回来给老妈(因为是给老妈买的),这叫自定义动作;三是忽略快递,继续打游戏。这三种都是信号被捕捉的表现。

  • 从收到通知到你拿到快递这段时间,叫做时间窗口。即从接收到信号到对信号做出反应这段时间叫做时间窗口。

  • 当消息通知你快递到了,你立刻去拿快递,按照默认动作去做反应,这叫同步;当收到消息时,游戏正打的火热,并不是立刻去拿快递而是过了一段时间再去拿,这叫异步。

技术应用角度上理解信号

  • 在时间窗口里进程不能把信号忘掉,进程必然要具有保存信号的能力。实际上进程把信号保存在PCB里。PCB里有个unsigned int结构,该结构可以看作信号位图,该位图有32位。

  • 信号表里一共有62种信号。从1号到64号,其中没有32号和33号。现在我们只了解1号到31号信号,[1,31]号信号叫做普通信号,[34,64]号信号叫实时信号。

kill -l查看信号名称和编号

image-20230602213542449

  • 实际上操作系统给进程发信号,操作系统会进到进程的pcb中(也就操作系统有权限)修改该信号位图上的比特位,全0则无信号,有1则有信号,1的位置代表几号信号。那么OS必须提供给用户发送信号处理信号相关系统调用函数。

image-20230604094125586

  • 实际上有很多种给进程发送信号的方式,但本质上都是OS向目标进程发送信号。

man 7 signal查看信号信息

image-20230603160954412

信号的产生

处理信号的方式有三种:

  • 执行该信号的默认处理动作
  • 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉一个信号,又或者说是自定义动作
  • 忽略此信号

我们可以通过signal函数自定义相应信号的执行动作

函数原型:

       #include <signal.h>

       typedef void (*sighandler_t)(int);

       sighandler_t signal(int signum, sighandler_t handler);
  • signum为要处理的信号号码
  • handler为处理该信号所要执行的处理函数,参数类型是函数指针
  • 调用成功返回先前信号处理函数的指针,失败返回SIG_ERR(-1)

实际上,当进程收到signum时,会通过sighandler_t函数指针回调handler函数

按键产生信号

我们知道Ctrl-C可以杀死前台进程

image-20230604102845251

  • 实际上Ctrl-C产生2号信号

mytest.cc

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;

void handler(int signo)
{
    
    
cout<<"进程捕捉到了一个信号,信号编号是: "<<signo<<endl;

}
int main()
{
    
    
    signal(2,handler);
    while(1)
    {
    
    
        cout<<"我是一个进程,mypid: "<<getpid()<<endl;
        sleep(1);
    }
    
    return 0;
}

image-20230604095913501

  • 运行后,当我在前台按 Ctrl-C时,可以看到 Ctrl-C被OS翻译成2号信号并发给该进程,让进程回调2号信号对应的处理函数

通过man 7 signal查询2号信号

image-20230604101959261

  • 可以看到从键盘输入的中断可以被翻译程2号信号-SIGINT

注意:

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

除了Ctrl-C可以杀死前台进程外,Ctrl-\也可以杀死前台进程

image-20230604103049232

  • 实际上Ctrl-\是OS给进程发送3号信号

image-20230604103213419

  • 通过man 7 signal查询到从键盘输入的退出可以被翻译程3号信号-SIGQUIT

image-20230604172705525

系统调用产生信号

函数原型:

       #include <sys/types.h>
       #include <signal.h>

       int kill(pid_t pid, int sig);
  • pid为需要向对于pid的进程发送信号
  • sig为想要发送几号信号
  • 调用成功返回0,失败返回-1

向指定pid的进程发送sig信号

mysign.cc

#include<iostream>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<string.h>

using namespace std;

void Usage(const string& s)
{
    
    
  std::cout << "\nUsage: " << s << " pid signo\n" << std::endl;
}


int main(int argc,char* argv[])
{
    
    
 if(argc!=3)
 {
    
    
    Usage(argv[0]);
    exit(-1);
 }

pid_t pid=atoi(argv[1]);
int signo=atoi(argv[2]);
int n=kill(pid,signo);
if(n!=0)
{
    
    
    perror("kill error");
    exit(1);
}

    return 0;
}
  • 通过命令行参数获取进程pid和信号signo,实际上shell在解析命令行时,将输入的内容解析成长字符串,字符串与字符串之间由空格间开,字符串的数量传给argc,argv是一个指针数组,指针指向解析后的字符串。由于kill系统调用只需要两个参数进程pid和signo,这里设置需要向命令行输入3个参数,输入模板:【程序 进程pid 信号】如:./mysign 23554 9—向pid为23554的./mysign进程发送9号信号
  • atoi函数可以将字符串转化成整形数,如:在这里可以将字符串"23554"转化成整形数23554

mytest.cc

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;


int main()
{
    
    
    //signal(2,handler);
    while(1)
    {
    
    
        cout<<"我是一个进程,mypid: "<<getpid()<<endl;
        sleep(1);
    }
    return 0;
}

image-20230604105643106

  • 在这里我向pid为22191的进程./mysign发送9号信号终止其运行

给自己发送信号

函数原型:

       #include <signal.h>

       int raise(int sig);
  • sig为发送的信号

  • 调用成功返回0

mysign.cc

#include<iostream>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
using namespace std;


int main()

{
    
    
    int cnt=0;
    while(cnt<10)
    {
    
    
        cout<<"cnt: "<<cnt<<endl;
        cnt++;
        sleep(1);
 if(cnt==5)
 {
    
    
    int tmp=raise(3);
    assert(tmp==0);
 }

    }
    return 0;
}

image-20230604111821048

  • 当参数cnt自增到5时自己给自己发送3号信号以至于进程退出

给进程发送指定信号

函数原型:

       #include <stdlib.h>

       void abort(void);
#include<iostream>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<stdlib.h>
using namespace std;


int main()

{
    
    
    int cnt=0;
    while(cnt<10)
    {
    
    
        cout<<"cnt: "<<cnt<<endl;
        cnt++;
        sleep(1);
 if(cnt==5)
 {
    
    
    abort();
 }

    }
    return 0;
}

image-20230604112422864

  • 通过查表可以得知,实际上abort函数是给进程发送6号信号

image-20230604112529354

image-20230604112733019

信号的意义

  • 实际上信号表里大部分的信号都是终止进程,虽然这些信号的处理动作一样,但信号的不同,意味着对不同的事件进行处理,即在不同的场景下通过信号处理进程。

硬件产生信号

除0操作

#include<iostream>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<stdlib.h>
using namespace std;
int main()

{
    
    
    int cnt=0;
    while(cnt<10)
    {
    
    
        cout<<"cnt: "<<cnt<<endl;
        cnt++;
        sleep(1);
 if(cnt==5)
 {
    
    
int a=10;
a/=0;
 }

    }
    return 0;
}

image-20230604120333413

  • 可以看到当进程内除0操作时会造成进程直接退出

  • 通过查表可以得知实际上进程有除0操作时,OS会向进程发送8号信号SIGFPE

image-20230604120558890

mysign.cc

#include<iostream>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<stdlib.h>
using namespace std;
void catchsig(int signo)
{
    
    
    cout<<"我是一个进程,此时我接收到了信号: "<<signo<<endl;
    sleep(1);
}
int main(int argc,char *argv[])
{
    
    
 signal(SIGFPE,catchsig);
int a=10;
a/=0;
while(1)
{
    
    
    cout<<"我是一个进程,正在运行中....."<<endl;
    sleep(1);
}
    return 0;
}

image-20230604151302282

当程序运行到a/=0时,会导致OS给该进程发送8号信号,其原因如下:

image-20230604152412013

  • 实际上CUP在做运算时,会把参数分别放到寄存器中,然后把计算出来的结果也放到寄存器中,当除0时,CPU得出的结果会非常大,以至于寄存器存不下,那么CPU会舍去结果不放到寄存器中,而去状态寄存器中将溢出标记位由0置1,OS知道CPU异常后,就会把该CPU内的状态转变成信号发送给该除0操作的进程以至于终止该进程。

至于程序运行到a/=0,之后的代码都不运行其原因如下:

image-20230604152609473

  • 实际上当进程收到信号时,不一定引起进程退出,那么该进程还会被再次调度。
  • CPU内部的寄存器只有一份,然而寄存器的内容是属于被调度进程的。当切换到该进程时,寄存器需要恢复进程的上下文,然后再次运算,运算到除0操作,CPU就会将状态寄存器的溢出标志位由0置1,然后OS就会向该进程发送8号信号。即当进程被切换时,就会由无数次状态寄存器被保存和恢复的过程,所以OS会向该进程发送无数次8号信号。

野指针访问

image-20230604154710516

  • 程序中有野指针越界访问时,会报错段错误(Segmentation fault)

mysign.cc

#include<iostream>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<stdlib.h>
using namespace std;

void catchsig(int signo)
{
    
    
    cout<<"我是一个进程,此时我接收到了信号: "<<signo<<endl;
    sleep(1);
}
int main(int argc,char *argv[])
{
    
    
 signal(11,catchsig);
while(1)
{
    
    
    cout<<"我是一个进程,正在运行中....."<<endl;
int *ptr;
ptr=nullptr;
*ptr=10;
    sleep(1);
}
    return 0;
}

  • 通过查表可以得知程序中有野指针越界访问时,OS会给进程发送11号信号SIGSEGV

image-20230604155221266

其发送11号信号原因如下:

image-20230604160216422

  • 进程的PCB能找到虚拟地址,然后再通过页表与物理地址进行映射,进而进程能找到物理地址上的数据。
  • 当有进程想要通过虚拟地址想要越界访问到物理地址时,页表会截断该访问。而页表上的MMU(内存管理单元)会报错,实际上MMU存在于CPU中。当OS知道MMU报错,就会发送11号信号给该进程令其终止。

信号的意义其二

  • 虽然大部分信号的动作都是终止进程,但是信号能够反馈一定信息给程序员,让程序员知道哪里出现了错误,让其去修正。

软件产生信号

SIGPIPE是一种由软件条件产生的信号,在“管道”中已经介绍过了。本节主要介绍alarm函数和SIGALRM信号。

alarm函数

函数原型:

       #include <unistd.h>

       unsigned int alarm(unsigned int seconds);
  • seconds是无符号整数,在linux下即是传的秒数。如果seconds值为0,表示取消以前设定的闹钟
  • 调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动 作是终止当前进程
  • 返回值是0或者是以前设定的闹钟时间还余下的秒数

通过查表得知,与alarm相对应的信号是14号SIGALRM

image-20230604163003733

mysign.cc

#include<iostream>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<stdlib.h>
using namespace std;

void catchsig(int signo)
{
    
    
    cout<<"我是一个进程,此时我接收到了信号: "<<signo<<endl;
    exit(0);
   
}

int main(int argc,char *argv[])
{
    
    
    int cnt=0;
signal(SIGALRM,catchsig);
alarm(1);
while(1)
{
    
    
    cnt++;
    cout<<"cnt: "<<cnt<<endl;
}
return 0;
}

image-20230604163659441

  • 可以看到cnt自增并且打印了7w多次,期间一秒后闹钟响了,进程终止。

  • 因为IO很慢,大大拖慢了CPU的处理速度,若是让cnt自增,最后闹钟响后再打印cnt,就能得知该云服务器CPU处理数据的速度了。

#include<iostream>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
using namespace std;
    int cnt=0;
void catchsig(int signo)
{
    
    
  cout<<"the alarm is ringing now,cnt: "<<cnt<<endl;
  exit(0);
}
int main(int argc,char *argv[])
{
    
    
signal(SIGALRM,catchsig);
alarm(1);
while(1)
{
    
    
    cnt++;
}
return 0;
}

image-20230604164344502

  • 可以看到该云服务器CPU处理数据的速度是5亿多

若在处理SIGALRM信号的自定义函数体中,我不让该函数退出

image-20230604164643200

  • 可以看到,信息只打印一次,说明catchsig函数只调用了一次,进而说明OS只给进程发送了一次SIGALRM信号。
  • 实际上alarm函数是一次性的,使用过一次就没有了,相当于一次性闹钟

但我们仍可以在自定义函数体内定义一次alarm函数,当闹钟响后,新的闹钟又被设置了。呈现出来的效果是cnt在1秒内不断自增,到期1秒打印一次,然后再不断自增,再打印,其效果与sleep函数相似

image-20230604165205906

设置闹钟的软件条件

  • 实际上任意一个进程都能通过alarm系统调用在内核中设置闹钟,那么在OS中会存在很多闹钟,OS就需要管理这些闹钟的数据结构

alarm 内核数据结构伪代码

struct alarm 
{
    
    
uint64_t when;//未来的超时时间
int type;//闹钟类型,是一次性的还是周期性的
task_struct *p;//指向设置该闹钟的进程pcb
struct alarm* next;//指向下一个闹钟
}

image-20230604171008221

  • 实际上在OS中,会有一个头指针指向一个最先响的闹钟,然后紧接着是第二先响的闹钟,以此类推
  • OS会周期性的检查这些闹钟,当第一个闹钟响后,就去除第一个闹钟,然后检查下一个
  • 这样管理闹钟就转变为以链表的方式管理闹钟的内核数据结构

核心转储

  • 当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。

在之前通过man 7 signal 中可以看到,各个信号的Action有几种,如:Term,Core,Ign,Cont,Stop;在其中,Action为Core的信号支持核心转储

在云服务器中,核心转储是默认被关掉的,我们可以通过使用ulimit -a命令查看当前资源限制的设定。

image-20230604193105985

  • 可以看到第一行显示core文件大小默认为0,表示默认下核心转储是关闭的。因为core文件中可能包含用户密码等敏感信息,不安全。
  • ulimit命令改变的是Shell进程的Resource Limit,但mysign进程的PCB是由Shell进程复制而来的,所以也具有和Shell进程相同的Resource Limit值。

我们可以通过ulimit -c size命令设置core文件大小

image-20230604193440146

  • core文件大小设置好后,表示核心转储功能打开。

image-20230604194912069

  • 现在以除0操作为例,当进程因为有除0操作而被OS发送11号信号以至于终止时,会发现报错后面加多了一句(core dumped),并且此时目录底下多了core文件,后面的数字是该进程的pid

由于要通过core对该进程进行调试,所以在makefile文件中标识mysign文件是可调试的(带-g)

image-20230604195350472

  • 现在进入gdb模式进行调试,输入core-flie core文件名

输入后就能看到该进程是接收到了8号信号即浮点溢出的错误,并且代码位于第61行

  • 而相对于core,term功能是直接杀死进程

关于能否捕获全部信号

我们可以通过signal函数捕捉相应信号,让其回调我们自定义的函数,那能否捕捉到所有的信号呢?

mysign.cc

#include<iostream>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<stdlib.h>
using namespace std;

void catchsig(int signo)
{
    
    
   cout<<"我是一个进程,此时我接收到了信号: "<<signo<<endl;
   alarm(1);
}

int main(int argc,char* argv[])
{
    
    
 for(int signo=1;signo<=31;signo++)
 {
    
    
    signal(signo,catchsig);
 }
while(1)
{
    
    
    cout<<"我在运行着:"<<getpid()<<endl;
 sleep(1);
}
  • 在这里我相应捕捉了1~31号信号

image-20230604201732848

  • 通过实验,证明9号信号SIGKILL无法被捕获,实际上19号信号SIGSTOP也无法被捕获

信号相关概念

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

信号在内核中的表示

image-20230610094312808

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

注意一下:

  • 在block位图和pending位图中,比特位的位置都代表着某一个信号。其中,block位图上0代表未阻塞该信号,1代表阻塞该信号;pending位图上0代表未接收到该信号,1代表接收到该信号
  • handler本质上是一个指针数组,数组内存放处理对应信号的方法(函数)的地址。其中方法包括三种:默认,自定义,忽略。
  • block,pending和handler这三个数据结构上的位置是一一对应的

认识sigset_t

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

捕捉信号

信号的捕捉

前提须知:

  1. 当用户需要访问资源时,需要通过系统调用去访问。该资源可以是硬件资源(键盘,鼠标,显示器,硬盘)也可以是OS自身的资源(getpid获取进程pid,vector扩容申请内存)
  2. 实际上,我们正常情况下是用户态,用户态是没有权限去访问资源的。所以需要从用户态转变为内核态,在内核态下去访问对应资源。而从用户态到内核态的途径就是系统调用,可以把系统调用理解为一个门口,在门口外是用户态,在门口内是内核态。那么进行用户态到内核态的切换必然需要消耗,而为了减少消耗应该尽量减少状态切换。(如vector提前扩容)

image-20230610103712677

  1. cpu中有很多寄存器,其中有可见寄存器和不可见寄存器(状态寄存器)
  2. 可见寄存器中有可以恢复当前进程的上下文,还有保存着当前进程的pcb的起始地址,这样能找到当前进程的内核数据结构;还有保存用户态的页表起始地址等等;不可见寄存器中,CR3寄存器保存当前进程的运行级别,0代表内核态,3代表用户态,进程切换状态要通过该CR3寄存器。

image-20230610110656827

  1. 之前提到,在32位系统下的内存中,0-3G是用户空间,3-4G是内核空间。相应的,内存要通过页表与磁盘上的数据进行映射。由于进程的task_struct是通过mm_struct找到用户级页表,再与磁盘进行映射,进程在OS中有很多份,每一个进程都要与用户级页表一一对应,所以用户级页表在OS中有很多份;相同的,3-4G(内核空间)也需要通过内核级页表与磁盘上的数据进行映射。由于磁盘上内核数据只有一份,所以只需要将一份内核数据加载到内存中,因此只需要一份内核级页表。

  2. 由于每一个进程都有自己的地址空间(mm_struct),用户空间是每个进程独占的,而内核空间是每个进程都能访问到的,或者说是内核空间是每个进程共有的。所以进程访问内核空间只需要在进程地址空间上跳转到内核空间即可。这意味着,当进程切换时,3-4G的内核空间不会被更改。

  3. 实际上,进程切换时,OS会将进程的上下文加载到CPU中,然后执行对应用户空间上的代码。当需要访问资源时调用系统调用,OS会将去到进程对应的CR3寄存器,将用户态改为内核态,然后从用户空间跳转到内核空间进行资源访问,访问完后再将内核态改为用户态,回到用户空间继续执行相应的上下文。

内核如何实现信号的捕捉

  • 实际上,当进程在用户态执行上下文时因为中断、异常原因陷入内核,在内核中处理异常完毕后返回用户态之前去检查相应进程的block位图,pending位图是否有信号递达。

当有相应信号递达,且信号对应的处理方法是默认动作或者忽略时,执行完后就直接返回用户态继续从上次中断的地方执行上下文。

image-20230610121846123

当有相应信号递达,且信号对应的处理方法是自定义时,会通过系统调用从内核态切换到用户态,然后去用户内存执行相应的处理函数,执行完后通过特定的系统调用sigreturn返回内核态。在清除对应的 pending标志位后,如果没有新的信号递达,最后返回用户态继续从上次中断的地方执行上下文。

image-20230610160003401

  • 注意sighandlermain函数使用不同的堆栈空间,它们之间没有相互调用的关系,是两个独立的控制流程!

  • 控制流程大致可以抽象成以下:

image-20230610161735720

  • 实际上内核态比用户态要高一层权限,内核态可以进入用户态进行操作(向下兼容),但并不能这么设计!其一OS不相信任何用户,有可能用户利用OS去对用户空间上的资源越界访问造成安全性问题;其二在用户空间通过用户态的形式操作,也是让用户对自己负责。

信号集操作函数

sigpromask

调用函数sigpromask可以读取或者更改进程的信号屏蔽字(阻塞信号集)

函数原型:

    #include <signal.h>
    int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • 参数how的可选值在下表给出
SIG_BLOCK set包含了我们希望添加到当前进程的信号屏蔽字的信号,相当于mask=mask|set
SIG_UNBLOCK set包含了我们希望从当前进程的信号屏蔽字中去掉的信号,相当于mask=mask&~set
SIG_SETMASK 设置当前进程的信号屏蔽字为set指向的值,相当于mask=set
  • 如果参数set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。

  • 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。即保存先前的信号屏蔽字

  • 综上,如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后 根据set和how参数更改信号屏蔽字。

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

sigpending

获取当前进程的未决信号集

函数原型:

      #include <signal.h>
      int sigpending(sigset_t *set);
  • set是输出型参数,将进程的pending位图通过set传出

  • 返回值:调用成功返回0,失败返回-1

sigemptyset

函数原型:

#include <signal.h>
int sigemptyset(sigset_t *set);
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
  • 返回值:调用成功返回0,失败返回-1

sigaddset

函数原型:

#include <signal.h>
int sigaddset (sigset_t *set, int signo);
  • sigaddset函数在set信号集中添加某种有效信号signo

  • 返回值:调用成功返回0,失败返回-1

sigdelset

#include <signal.h>
int sigdelset(sigset_t *set, int signo);
  • sigaddset函数在set信号集中删除某种有效信号signo
  • 返回值:调用成功返回0,失败返回-1

sigismember

函数原型:

#include <signal.h>
int sigismember(const sigset_t *set, int signo); 
  • sigismember函数判断set信号集中是否存在指定信号signo
  • 返回值:若存在返回1,不存在返回-1

sigfillset

函数原型:

#include <signal.h>
int sigfillset(sigset_t *set);
  • sigfillset函数初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
using namespace std;
#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31

static void show_pending(sigset_t &pending)
{
    
    
    for(int signo=MAX_SIGNUM;signo>=1;signo--)
    {
    
    
        if(sigismember(&pending,signo))
        {
    
    
            cout<<"1";
        }else
        cout<<"0";
    }
    cout<<"\n";
}
int main()
{
    
    
    //先初始化
    sigset_t block,oblock,pending;
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);
    sigaddset(&block,BLOCK_SIGNAL);//把二号信号添加到block位图中
    //将定义的位图设置进进程内核中
    sigprocmask(SIG_SETMASK,&block,&oblock);
    //打印pending位图
  while(true)
  {
    
    
    sigemptyset(&pending);
    sigpending(&pending);//获取进程的pending位图并置进pending位图中
    show_pending(pending);//打印pending位图
    sleep(1);//间隔打印
  }
    return 0;
}
  • 通过sigaddset和sigprocmask函数将2号信号添加到进程的屏蔽字中
  • 当进程收到二号信号时,会去进程的pending位图将对应位置由0置1,但是由于被阻塞了,该信号无法递达,所以打印pending位图可以看到2号信号对应处为1

image-20230610174549629

image-20230610175119294

#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>

using namespace std;

#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31

static vector<int> sigarr{
    
    2,3};//将2,3号信号添加到进程的信号屏蔽字中

static void show_pending(sigset_t &pending)
{
    
    
    for(int signo=MAX_SIGNUM;signo>=1;signo--)
    {
    
    
        if(sigismember(&pending,signo))
        {
    
    
            cout<<"1";
        }else
        cout<<"0";
    }
    cout<<"\n";
}
void myhandler(int signo)
{
    
    
  cout<<signo<<"信号已经被抵达\n"<<endl;
}
int main()
{
    
    
  signal(2,myhandler);
    //先初始化
    sigset_t block,oblock,pending;
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);
    sigaddset(&block,BLOCK_SIGNAL);//把二号信号添加到block位图中
  //for(const auto&sig:sigarr) sigaddset(&block,sig);
  //把vector内指定的信号添加进block位图中
  
    //将定义的位图设置进进程内核中
    sigprocmask(SIG_SETMASK,&block,&oblock);
    
    //打印pending位图
    int cnt=10;
  while(true)
  {
    
    
    sigemptyset(&pending);
    sigpending(&pending);//获取进程的pending位图并置进pending位图中
    show_pending(pending);//打印pending位图
    sleep(1);//间隔打印
    if(cnt--==0)
    {
    
    
      cout<<"恢复对信号的屏蔽,此时不屏蔽任何信号\n"<<endl;
      sigprocmask(SIG_SETMASK,&oblock,&block);
      
    }
  }

    return 0;
}

image-20230610182843498

  • 先是对二号信号进行屏蔽,在进程的阻塞信号集中将2号信号对应的位图由0置1,然后接收到二号信号,但由于被阻塞所以处于未决状态,未决(pending)位图对应的二号信号位置由0置1
  • 10秒后将进程的阻塞信号集对应二号信号的位图由1置0,即取消对二号信号的屏蔽,此时二号信号立刻被递达,执行对应的处理函数。
  • 而后信号屏蔽字全为0不再屏蔽信号,所以接收到二号信号也不再阻塞,即信号不再处于未决状态,直接执行对应的处理函数,所以pending位图一直为全0。

sigaction

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

函数原型:

 #include <signal.h>
 int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • signum是指定信号的编号
  • 若act指针非空,则根据act修改该信号的处理动作。
  • 若oact指针非空,则通过oact传出该信号原来的处理动作。
  • 返回值:调用成功则返回0,失败则返回-1

image-20230611110730571

act和oact指向sigaction结构体

  • 将sa_handler赋值为SIG_IGN表示忽略信号;赋值为SIG_DEL表示执行系统默认动作,也可以赋值为一个函数指针,该指针指向自定义函数,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。注意,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
  • sa_flags和sa_restorer在这里不展开介绍,都置为NULL
  • sa_mask是信号屏蔽字即阻塞位图,在这里可以将多个信号加入屏蔽字中
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
using namespace std;
void Count(int cnt)
{
    
    
  while(cnt)
  {
    
    
    printf("cnt: %2d\r",cnt);
    fflush(stdout);
    cnt--;
    sleep(1);
  }
  cout<<"\n";
}
void handler(int signo)
{
    
    
  cout<<"get a signo: "<<signo<<endl;
  Count(10);//在handler方法里待十秒
}
int main()
{
    
    
 struct sigaction act,oact;
 act.sa_handler=handler;
 act.sa_flags=0;
sigaction(SIGINT,&act,&oact);
while(true) sleep(1);
  return 0;
}
  • 将自定义方法handler的指针置进act结构体里,后续进程接收到二号信号时,就去执行指定的handler方法
  • 在handler方法里,先是打印了传进来的信号编号,然后进行了10秒的倒计时即在handler方法里待了十秒

image-20230611112650765

可以看到,对该进程发送了多次二号信号,实际上只递达了两次二号信号

  • 当进程接收到2号信号时,此时进程不屏蔽任何信号,直接去执行2号信号对应的处理方法(handler函数),此时进程会将2号信号对应的信号屏蔽字由0置1。即在处理2号信号时,屏蔽后来的相同信号
  • 在执行2号信号的处理函数期间(10秒期间),由于进程对应2号信号的pending位图为0,当进程再次接收到2号信号时,OS会去pending位图对应位置由0置1,即后来的2号信号处于未决状态。此后再接收2号信号,由于其对应的pending位图只有一个bit位,所以pending位图最多能使一个2号信号未决,后来的都无意义(可以0->1,但不能由1->2,且1->1无意义)
  • 等当前二号信号的处理函数执行完后,对应2号信号的信号屏蔽字自动由1置0即解除对2号信号的屏蔽,此时对应2号信号的pending位图为1(依然有2号信号处于未决状态的话),立即将pending位图由1置0,然后去执行对应的处理函数,信号屏蔽字再次自动由0置1

其发送了多次2号信号只递达了两次二号信号根本原因在于,pending位图最多可以容纳一次未决信号,后来的再无意义

image-20230611115145348

  • 在这里我将3号信号加入进程的信号屏蔽字中

image-20230611115306821

  • 可以看到,发生了多次2号信号,递达了两次,然后发送多次三号信号,实际上3号信号接收到执行默认动作进程退出
  • 将3号信号加入结构体act的sa_mask位图的意义在于,将3号信号加入进程的信号屏蔽字中。当执行2号信号的处理方法时,进程会自然的屏蔽2号信号,此时会同时屏蔽3号信号即3号信号对应的信号屏蔽字由0置1。现接收到了3号信号,其对应的pending位图由0置1。直到2号信号全部递达后进程不再屏蔽2号信号时,进程才解除对3号信号的屏蔽,其对应的pending位图由1置0,然后才会递达3号信号。

可重入函数

现在有一个场景,这里有三个结点,head指向的结点,node1和node2。在main函数中,先让node1头插到链表里,然后异常陷入内核,调用处理函数时将node2结点头插到链表里。在理论层面上看是没有问题的,但实际上:

image-20230611161354514

image-20230611160954143

  • 先是node1结点头插到head指向的结点前
  • 然后因为中断或异常等原因陷入内核,处理完异常后检查到有信号需要处理,于是切换到sighandler函数执行

image-20230611161552333

  • node2结点头插到head指向的结点前,然后head指针指向node2,此时执行完handler函数,通过特殊的系统调用回到内核态,然后回到main主执行流继续执行上下文

image-20230611161809374

  • 最后head指向node1。实际上就node1结点有效的头插到了链表里。
  • main函数执行流和handler函数执行流是两个执行流。像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。
  • insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。相应的若一个函数只访问自己的局部变量或参数,则称为可重入函数。

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

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

关键字volatile

  • volatile修饰的变量保持内存的可见性
#include<stdio.h>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
int quit=0;

void handler(int signo)
{
    
    
    printf("pid: %d, %d 号信号正在被捕获!\n",getpid(),signo);
    printf("quit: %d",quit);
    quit=1;
    printf("-> %d\n",quit);
}

int main()
{
    
    

signal(2,handler);
while(!quit);
 printf("注意,我是正常退出的!\n");
    return 0;
}
  • 全局变量quit为0,然后在main函数中一直调用while循环,直到接收到了2号信号,在处理函数handler内quit由0变1,然后回到主执行流main函数,while判断为假不再执行while循环

image-20230611173847945

image-20230611174907007

  • 实际上,quit变量被加载到内存中,然后while循环OS会将变量quit load到CPU中进行运算,第一次quit是0,所以while判断为真,继续进行while循环;接收2号信号后,处理函数handler将quit置为1,因此while循环为假,执行下文

在gcc编译中,可以调整优化级别,这里我调整为O3级别

image-20230611174344418

image-20230611174321709

  • 可以看到调整优化级别后,进程没有正常退出

image-20230611183841049

  • OS把进程加载到内存中时,会将变量quit直接load到CPU中,之后的while循环判断都依靠CPU的变量quit,不在依赖内存中的quit变量。所以当进程接收到2号信号时执行handler处理函数将全局变量quit由0置1。但内存中的变量quit不会更新到CPU中,所以while循环一直为真,一直执行死循环,导致进程不能正常退出。

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

image-20230611184948068

SIGCHLD信号

当子进程图退出或者被中止时子进程会发送17号信号SIGCHILD给父进程

#include<stdio.h>
#include<signal.h>
#include<stdlib.h>
#include<sys/types.h>
#include<unistd.h>
void Count(int cnt)
{
    
    
    while(cnt)
    {
    
    
        //cout<<"cnt:"<<cnt;
        printf("cnt: %d\r",cnt);
        fflush(stdout);
        cnt--;
        sleep(1);
    }
    printf("\n");
}
void handler(int signo)
{
    
    
 printf("我是父进程,pid:%d ,我接收到了子进程的%d 号信号\n",getpid(),signo);
}
int main()
{
    
    
    signal(SIGCHLD,handler);
    printf("我是父进程id: %d, ppid: %d\n",getpid(),getppid());
  pid_t id=fork();
  if(id==0)
  {
    
    
      printf("我是子进程id: %d, ppid: %d\n",getpid(),getppid());
      Count(5);
      exit(1);
  }
  while(1) sleep(1);
    return 0;
}

image-20230611193524717

  • 实际上在代码里,父进程是阻塞等待子进程发送信号,然后去回收子进程的,阻塞等待期间父进程不能做其他事情

让父进程不阻塞等待回收子进程的方法

  1. 在处理17号信号函数handler中通过waitpid回收子进程,但是函数的第三个参数option传参HNOHANG,表示不阻塞等待回收子进程

waitpid函数原型

       #include <sys/types.h>
       #include <sys/wait.h>
       pid_t waitpid(pid_t pid, int *status, int options);
  • pid=-1时等待任何子进程,此时的waitpid()函数就退化成了普通的wait()函数
  • status保存子进程的状态信息,有了这个信息父进程就可以了解子进程为什么会退出,是正常退出还是出了什么错误,这里设置成NULL即可
  • option设置为WHOHANG表示不阻塞等待回收子进程
  • 返回值:如果成功,则为子进程的PID,如果options为WNOHANG,则返回0,如果发生其他错误,则返回-1

处理函数handler

void handler(int signo)
{
    
    
 pid_t id=0;
 while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
    
    
 printf("wait child success: %d\n", id);
 }
 printf("child is quit! %d\n", getpid());
}
  1. 在signal函数中第二个参数传参SIG_IGN(这种方法对于linux环境可用,其余不保证)
    signal(SIGCHLD,SIG_IGN);

猜你喜欢

转载自blog.csdn.net/m0_71841506/article/details/131172295