【Linux进阶之路】信号

发布时间:2023年12月17日

一 、初始信号

1.概念

信号我们可以大体上从角度来看:

  1. 认识与理解信号。
  2. 当信号来临时识别信号
  3. 不立即处理保存信号时保存信号
  4. 处理信号

我们下面举个生活中的例子:

  1. 外卖行业兴起,此时大家坐家里就可吃上美味可口的饭。
  2. 此时你了解到外卖,即让人骑着电车把饭给你送到家门口,然后有人会给你打电话让你取你买到的饭。(认识并理解外卖——理论上
  3. 此时你打开APP,尝试下了一单,然后打一把金铲铲,边打边等外卖到来。
  4. 半个小时之后有人给你打电话,说外卖到了。(外卖——实际上
  5. 但你正准备梭哈找三星五费,根本没空理外卖小哥,就让小哥放在外卖柜里面了。(保存外卖
  6. 当你找到三星五费爽局吃鸡之后,才意识到自己外卖还没领,于是下楼领了外卖。(处理外卖

当理解了这个例子之后我们再回归到进程:

  1. 进程有认识并处理信号的方法。(理论的信号)
  2. 进程收到了信号。(实际的信号)
  3. 可能不会立即处理信号。(保存信号)
  4. 最后对保存的信号进行处理。(处理信号)
  • 强调:即使没有实际的信号,进程也知道有哪些信号和对应的信号的处理方法,即理论和实际

那此处我们应该可以用自己的语言来给信号一个概念:

  • 信号是在进程已经认识的前提下,让正在运行的进程最终对其作出相应反应的一组概念/编号。也就是说信号就是一组概念,更进一步就是编号。而真正有用的是信号对应的方法,也就是进程作出的反应。
  • 举个例子更容易明白,了解与认识外卖并不重要,而是对应的用途(让你在家就能吃上现成的饭)更重要。
  • 信号的概念与编号 在这里插入图片描述
    说明:总共有62种信号,其中32 33信号没有。
  1. 1 - 31号信号为普通信号,是我们需要重点理解的。
  2. 34 - 64号信号为实时信号,作为拓展了解即可。

2. 简单认识

  • 下面我们通过这一个例子,来了解实际的信号。
#include<iostream>
#include<unistd.h>
using namespace std;
int main()
{
    int num = 1;
    while(true)
    {
        cout << "num is: " << num << endl;
        sleep(1);
    }
    return 0;
}
  • 图解:
    在这里插入图片描述

下面我们通过这个例子展开讨论。

问题1: bash命令行消失与出现这个动作是什么意思?

  • 解释:
  1. bash消失也就意味着可执行程序变为前台进程,bash变为后台进程。
  2. 前台进程是运行在前台的进程,因为要接受键盘信息,只能有一个。
  3. 后台进程是运行在后台的进程,可以向显示器打印信息,能有多个。
  • 简单使用:
    在这里插入图片描述
    此处涉及两点:
  1. ./可执行程序 +& 意为运行到后台进程。
  2. pidof 【进程名】 | args 【信号】,意为给一批相同的进程名发送指定信号。
  • 拓展:
  1. 此处因为转化为后台进程,因此无法给其发ctrl + c(给前台进程发送)。
  2. ctrl + c,无法终止掉bash进程(内部做了特殊处理)。

问题2:这里的ctrl + c 是什么意思?

  • 解释:
  1. 这里ctrl + c是给正在运行的前台进程发送的信号。
  2. 用户按下 Ctrl-C 用于中断进程。
  3. 对应的信号编号为SIGINT(2)——中断信号。
  • 补充:kill -l 用于查看信号编号,在进程控制 (详见目录二. 2.1)。
  • 如何验证这里的信号为中断信号?做实验便可知晓。
  1. 认识接口
//头文件:
 #include <signal.h>
//函数声明:
sighandler_t signal(int signum, sighandler_t handler);
/* 
用于对信号编号对应方法的自定义,若不设置用系统内置(默认)的处理方法。
2. signum:信号所对应的编号。
3. handler:信号所对应的处理方法。
*/
typedef void (*sighandler_t)(int); 
/* 这是一个函数指针类型的重定义,其参数为int,返回值类型为void */

/* 
返回值:
1 .设置成功,返回对应的信号的以前的处理方法。
2 .设置失败,返回SIG_ERR用于表示错误,并且错误码会指明错误信息。
*/
  1. 使用接口
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int signal)
{
    cout << endl;
    cout <<  "Catch " << signal << " signal." << endl;
}
int main()
{
    //对信号进行自定义:
    signal(SIGINT,handler);
    int num = 1;
    while(true)
    {
        cout << "num is: " << num++ << endl;
        sleep(1);
    }
    return 0;
}
  • 图解:
    在这里插入图片描述
  • 很显然2号信号处理时按照我们所定义的方法被捕获了。
  • 除此之外:
  1. ctrl + \也是信号,即这里的 \Quit。
  2. SIGQUIT(3): 退出信号,通常由用户按下 Ctrl-\ 产生,用于退出进程并生成核心转储文件。
  3. 证明方法同上。

3. 硬件信号

?为了展开讨论,我们以如下的例子进行分析:

在这里插入图片描述

  • 现象:以后台的方式运行进程向显示器里面打印信息,此时我们bash命令行为前台进程,此时我们再向前台的命令行输入指令,发现即使输出的信息在我们看来是错乱的,只要输入没问题,指令还是能正常运行的,这背后有什么原因呢?
  • 解释:
  1. 首先我们先来明确后台进程与前台进程的区别在于是否接收键盘信息
  2. 其次我们要明确显示器与键盘的区别,两者是独立的存在,但相辅相成
  3. 一般来看我们需要将输入的内容回显到显示器上,以便于进行校对。
  4. 但是有些场景输入的内容是不会进行回显的,比如密码登录,拿QQ来讲,虽然会有回显,但输入的密码超过一定位数之后就不在进行显示了,但这不代表你没有往里面输入数据
  5. 因此计算机的输入输出是给人看的,但也可以做到不给人看的输入输出。
  6. 因此键盘输入的数据是由你按下键的先后顺序决定的,跟显示器如何打印没有半毛钱关系,更代表了键盘与显示器独立。
  7. 理解独立与相辅相成的关系之后,我们再来谈如何实现回显的,其实很简单,就是将键盘里面的内容给显示器打印一份,至于显示器什么时候有空,那是显示器的事,当显示器有空时会将内容回显到屏幕上的。
  8. 因此这里的数据错乱是由显示器被多个进程打印信息所影响的,因为bash命令行读取的是键盘里面的内容,跟显示器如何打印无关,因此这里的ls也会正常进行运行。
  • 理解了键盘与显示器独立与相辅相成的关系之后,我们更进一步分析,既然OS是软硬件资源的管理者,那OS是如何快速获取到数据呢?
  1. 首先需要明确一点,在Linux下一切皆文件,也就是说键盘在操作系统看来也是文件,我们根据之前所学的可以画一个大概的图解:
    在这里插入图片描述
  2. 既然这样操作系统想要知道键盘中有数据,岂不是得遍历这个双向链表了,效率必然会降低。因此我们可以推断操作系统肯定不会这样做。

  • 既然这样我们直接给出实际的获取硬件的大概图解:
    在这里插入图片描述
    再具体分析一波:
  1. 首先键盘读取数据时,相应连接的中断单元检测到键盘中有数据。
  2. 于是硬件单元向与其连接的CPU发送中断号。
  3. CPU内相应的寄存器处理之后,给操作系统发送信号,告诉它是键盘有数据了。
  4. 操作系统去对应的中断向量表中找对应的操作硬件的方法。
  5. 然后执行对应的方法,将数据刷新拷贝到内核缓存区中。
  6. struct file检测到具体的方法,把数据拷贝到内核缓冲区。
  7. 上层获取数据,可能是显示器也可能是用户级缓存区。如果是显示器就是对显示器数据进行拷贝回显,如果是用户则要对数据执行对应的处理逻辑。
  • 拓展:
  1. 信号的产生与代码运行异步(软中断)。简单理解就是一个进程不会傻傻的等着信号来临,还会干自己的事情。
  2. 信号的来源为操作系统,因为信号的对象是进程,而且操作系统是进程的管理者,因此要让操作系统给进程发送信号。
  • 认识与理解信号。

二 、异常与信号

信号与异常有一定的关系,下面我们简单谈谈:

  1. 信号是通知进程处理事件的编号,不一定会导致进程退出。
  2. 异常是代码无法正常运行的执行流中断的情况,必须让进程进行退出,可以让程序员对异常捕获报错退出,也可以让系统自动退出。
  3. 信号是处理异常的一种底层实现方式。

1.信号处理异常

  1. 除0异常。
#include<iostream>
using namespace std;
int main()
{
    int a = 1;
    int b = 0;
    a / b; 
    //这一句代码会被编译器直接优化(汇编),因为中间的/并没有副作用,且没有对
    //表达式结果进行存储。因此计算无意义。因此对a/b不做相关的计算,即优化。
    cout << "div zero before." << endl;
    int c = a / b;
    //除 0,此处会出异常。这里因为要对 a / b 进行存储。
    //因此要对a / b进行相关的计算。
    cout << "div zero after."  << endl;
    return 0;
}
  • 运行
    在这里插入图片描述
    这里我们可以证明:
  1. 表达式a / b; 中间的 / 运算是没有被运行的。
  2. int c = a / b; 对 a / b 进行了运算,也发生了除0异常,其次之后的代码没有继续运行。
  3. 其次除0异常是由发信号的方式进行处理的,具体的编号为SIGFPE(8),并且可以大概看到这里处理的方式为将信号异常打印并退出进程

如果我们作死对除0信号的处理方法进行自定义,即改成不让进程退出会发生什么呢?

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void handler(int sig)
{
    cout << "catch sig: " << sig << " my pid is " 
    << getpid() << endl; 
    
    sleep(1);
    //exit(-1);
}
int main()
{
    signal(SIGFPE,handler);
    int a = 1;
    int b = 0;
    cout << "div zero before." << endl;
    int c = a / b;
    //除 0,此处会出异常。这里因为要对 a / b 进行存储,因此要对a / b
    //进行相关的计算。
    cout << "div zero after."  << endl;
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  1. 很显然,因为执行流出错,所以这里的无法返回原先的执行流往下执行,因此这里在处理完后,因为异常无法进行修正,所以再返回还会一直发送信号。
  2. 解决方法也很简单,只需要将上面handler的处理方法的注释代码放开即可。

下面我们再来谈谈除0错误的原理:
在这里插入图片描述

  • 拓展:Linux内核的进程上下文,保存在struct audit_context *audit_context 指向的内容里面;
  1. 野指针问题
#include<iostream>
using namespace std;
int main()
{
    int *p = nullptr;
    *p;//这里的*p并没有取值动作,因此编译器直接优化了,可以认为没有这一句代码。
    cout << "dereference before" << endl;
    *p = 0;//对其解引用并赋值;
    cout << "dereference after" << endl;
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  1. *p; 并不需要获取值,因此编译器认为是无意义的动作,直接优化了。。
  2. *p = 0,对0处的地址进行赋值,因此直接报错。
  3. 此处异常也是用信号的方式进行处理的,信号编号为SIGSEGV(11)。

我们再作死一次,对此信号进行捕获不做处理看看会发生什么?

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

using namespace std;

void handler(int sig)
{
    cout << "catch sigal is " << sig << endl;
    sleep(1);
    //exit(-1);
}
int main()
{
    signal(SIGSEGV,handler);
    int *p = nullptr;
    *p;//这里的*p并没有取值动作,因此编译器直接优化了,可以认为没有这一句代码。
    cout << "dereference before" << endl;
    *p = 0;//对其解引用并赋值;
    cout << "dereference after" << endl;
    return 0;
}

在这里插入图片描述

  1. 可见我们自定义处理方法之后,由于没退出,所以操作系统会一直发11号信号。
  2. 解决方法,将handler方法的注释代码放开,让其退出即可。

下面一张图解释野指针问题的原理:

在这里插入图片描述

  • 解释
  1. mm_struct里面会存放页表(一级页表项)。
  2. 页表的MMU会将虚拟地址到物理的转换。
  3. 转换成功放在cr3寄存器中进行寻址。
  4. 转换失败会放在cr2寄存器中,通过CPU给操作系统发送信息,再由操作系统给进程发送指定信号。

  • 总结:
  1. 异常是无法进行解决的,最好让进程交代后事,死而无憾。要不然就会化作Bug,让你做几天噩梦。
  2. 上述两种错误是硬件方面的错误,较为底层因此用信号进行处理。
  3. 异常的处理方式取决于运行环境与程序员的设计,比如管道读端关闭,写端写,Centos7下就会给进程发送SIGPIPE管道破裂信号,13号信号。因为认为此操作无意义。就直接让进程退出了。
  4. 从中我们看出软甲层面的管道,硬件的异常,操作系统可以通过信号进行管理。同时也验证了OS是进程的管理者。

2.特殊事件

?比如定一个闹钟:假如你现在该睡觉了,但是你7个小时之后要醒,此时你大概率会定一个闹钟,假如定了一个7个小时的闹钟。如果你把闹钟关了,就要一觉睡到天亮,即10个小时才醒。

此时我们简单的实现一下:

  1. 认识接口
/* 
头文件 
*/
#include<unistd.h>
/* 
函数声明 
*/
unsigned int alarm(unsigned int seconds);
/* 
函数参数:要设置闹钟的秒数。
返回值:上一次闹钟的剩余秒数。
*/

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

void handler(int sig)
{
    cout << "The alarm is go off." << endl;
    cout << "Please choice wake or sleep: ";
    string str;
    cin >> str;
    if(str == "wake") exit(0);//假如你选择醒着。
    return;//把闹钟关了继续睡。
}

int main()
{
    signal(SIGALRM,handler);
    alarm(7);//假设7代表的是你定了7个小时的闹钟。
    int seconds = 10;//这里的10代表着,你要睡10个小时才会自然醒。
    while(seconds)
    {
        cout << "I am sleeping……" << endl;
        sleep(1);
        seconds--;
    }
    cout << "You wake up." << endl;
    return 0;
}
  • 运行现象:
    在这里插入图片描述
  1. 睡眠七个"小时 "之后,把你叫醒了, 然后你选择了把闹钟关了继续睡,直到睡到自然醒。
  2. 闹钟响了一回,即向进程发送信号,处理完返回,继续执行后续代码。

  1. 原理
  1. alarm对应的时间信息,pid等信息,必然会被保存,很明显通过先描述成结构体存放相关信息。
  2. 等到alarm的时间到之后,再由操作系统发送信号。那alarm如何了解到进程的时间是否超时了呢?
  3. 其实很简单所有进程的alarm对象由优先级队列进行维护,即再组织,当堆顶的元素为最小的时间,当其大于当前时间时,出队列,然后由操作系统向对应的进程发送信号。

3.终端信号与内核信号

  1. 终端信号:是计算机与用户之间进行交互的信号,具体包含1,2,9,13,14,15,30, 10, 16,31, 12, 17号信号。
  2. 内核信号:是计算机内核,即进程内部出现异常之后,由操作系统发送的信号,具体包含3,4,6, 8,11号信号。像我们之前提及的除0错误,以及野指针问题,都是内核信号。
  • 除此之外:内核信号还会生成一个coredump文件,里面包含了一些错误信息。

我们使用父子进程的来简单的获取一个异常子进程的退出信息:

int main()
{
    pid_t rid = fork();
    if(rid == 0)
    {
        //子进程
        int a = 1,b = 0;
        int c = a / b;//除0异常直接退出进程。
    }
    int statue;
    int ret = waitpid(rid,&statue,0);//阻塞等待.
    if(ret != -1)
    {
        int exit_inf = statue >> 8;
        int sig_inf = statue & 0x7F;
        int core_dump = (statue >> 7) & 1; //将1左边的位数清0.
        
        cout <<"exit_code: " << exit_inf   << " " 
        	 << "signal: "	 << sig_inf    << " " 
        	 << "core_dump: " << core_dump  << endl;
    }
    return 0;
}
  • 获取原理
    在这里插入图片描述
  • 运行结果:

在这里插入图片描述

  1. 确实是8号信号没错,但core dump位不应该是1吗?
  • 解释:云服务的这个选项是默认关闭的,我们需要打开这个选项。
  • 指令:
ulimit -a # 查看内核文件的大小
ulimit -c 【字节数】# 要设置内核文件的大小


在这里插入图片描述

  1. 此处我们使用 -a 查看内核文件 -c设置内核文件大小为10240大小。
  2. 我们再次运行,core dump位标记为1,内核文件生成,符合预期。
  • 接下来我们就该看如何使用内核文件了。
    在这里插入图片描述
  • 说明:实在找不到异常出在哪,可用core文件进行定位。这里我的gdb可能是有点问题的,没显示出行号。
  • 最后再来谈谈为什么会服务器端会将core文件的选项关闭。
  1. 因为服务器是24小时不间断运行的,一旦出异常不可能把服务器关闭,检查好了再打开。
  2. 而是出异常接着重新启动运行。
  3. 如果重启就出异常,那么就会陷入一直重启一直出异常的循环当中。
  4. 那么如果每次异常都生成core文件,那么磁盘会被撑爆的。

三、深入信号

1.信号的发送

问题:操作系统如何向进程发送信号?

  • 明确一点,OS是进程的管理者,当然要由操作系统来发送信号。

解释:

  1. 外卖到了,你怎么知道有外卖呢?是外卖小哥给你打电话说有了,然后你记住你有外卖到了。
  2. 转换到进程,你怎么知道是有信号了呢?是OS给你的进程里面写入了对应的信号,然后进程就知道有信号到了。
  3. 那进程里面是如何记录的呢?其实很简单,判断在不在。其实用位图即可。
    在这里插入图片描述
    0表示信号不在,1表示信号在,这里的是unsigned long 类型的变量,用于存储32位普通信号
  4. 因此,OS向进程发送信号,本质上就是在对应的位图上,进行标记在不在的过程。
  • 补充:在OS给进程发送大量的相同普通信号时,进程只会处理一次信号,因为OS要做到让每一个信号等可能性的执行处理方法,即均衡。而实时信号为了做到准确快速高效,则可能会出现一个信号短时间内执行多次的情况。
  • 拓展: 所有的进行都要保存信号,那么保存信号的这张位图,如何管理呢?

  • 解释 使用双向链表的形式进行统一的管理。下图是进程内部的保存信号的位图结构。即保存在进程的pending表里面。
    在这里插入图片描述

2.信号的保存

说明:在计算机中,信号的保存一般被叫做阻塞。也叫做信号的未决。

回顾:

  1. 在外卖到时,回想起最开始的例子,我们是正在打金铲铲,并没有空去外卖,因此放在了外卖柜里面。
  2. 当我们打完金铲铲后,才意识到还有外卖没取,就去外卖柜里面取快递了。

联系进程:

  1. 当让快递小哥把外卖放在了外卖柜。是对信号的真阻塞(外卖是真实的)。
  2. 当你去取走外卖处理的时候。是信号的解除阻塞。
  3. 当你转心处理外卖时,为了不让别的事情无法打扰你。你把手机设成了勿扰模式。是对信号进行假阻塞(可能实际的信号进行屏蔽)。
  4. 当你处理完外卖时,恢复正常通知。这是对所有信号的假信号的解除阻塞。
  5. 当你查看信息时,是否有没有接收的重要信息。是再次检查实际的信号是否真阻塞了。

  • 重点:
  1. 阻塞是分成两类的,即真阻塞和假阻塞。
  2. 真阻塞是你正在处理别的信号,此时有收到了信号(实际上),对其进行阻塞。
  3. 假阻塞是在正在处理信号时,对信号进行假阻塞(理论上)之后再来不会进行处理。
  4. 解除假阻塞是已经处理完信号,再信号进行解除阻塞(理论上)。
  5. 再次查看是否还有信号(实际上),有就再对信号进行处理。
  • 总结:
  1. 因此我们可以在内核中查看两个block变量。
    在这里插入图片描述
  2. blocked表也是以位图的方式进行呈现的,与pending表的实现方式相同。
    在这里插入图片描述

如何进行简单的操作呢?

2.1.sigset_t

概念:

  1. 由操作系统封装的一个结构体变量,里面存放是位图结构。
  2. 想要对其进行操作,必然绕不开操作系统提供的系统调用接口。

系统接口:

#include <signal.h>
int sigemptyset(sigset_t *set);
/*记忆:empty,即对传入的set指针所指向的变量进行清空 */
int sigfillset(sigset_t *set);
/*记忆:fill,对指向的变量添上所有支持的信号位*/
int sigaddset (sigset_t *set, int signo);
/*记忆:add,对set指向的变量中,添入signo变量的编号*/
int sigdelset(sigset_t *set, int signo);
/*记忆: dele(te),对set指向的变量中,删除signo对应的编号*/ 
int sigismember(const sigset_t *set, int signo);
/*记忆: 检测signo信号是否在set指向的变量中,在返回1,不在返回0*/

/*返回值:以上函数执行成功返回1,失败返回-1,并会设置合理的错误码指向错误*/

2.2.sigprocmask

/*头文件*/
#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
/*返回值:若成功则为0,若出错则为-1,并会设置合适的错误码指明错误*/
/*
参数1:  1.SIG_BLOCK,将set里面的编号添加到block表中。
		2.SIG_UNBLOCK,将set里面的编号从block表中解除。
		3.SIG_SETMASK,将block表设置为set。
		
参数2:输入型参数,即想要让blocked表进行的修改。
参数3:输出型参数,即保存在blocked被修改前的所有信息,便于以后进行恢复。
*/
/*
补充系统调用接口:
头文件:
*/
#include <sys/types.h>
#include <signal.h>
/*
函数声明:
*/
int kill(pid_t pid, int sig);
/*
参数1:要发送进程的pid,
参数2:要发送的信号。
返回值:成功返回0,失败返回-1,并设置合适的错误码。
*/

/* 
头文件:
*/
#include<signal.h>
/*
函数声明:
*/
int sigpending(sigset_t *set);
/*
参数:输出型参数,传进一个sigset_t变量的地址,将pending表里面的字符串输出。
返回值:成功返回0,失败返回-1,并设置合适的错误码。
*/
  • 实验代码:
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
using namespace std;
void Print()
{
    sigset_t pending ;
    int sret = sigpending(&pending);
    if(sret == -1) return;
    for(int i = 31; i >= 1; i--)
    {
        if(sigismember(&pending,i))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}
void handler(int sig)
{
    cout << "this is " << sig << " sig." << endl;
    //执行时打印appending表
    Print();
}
int main()
{
    signal(SIGINT,handler);

    sigset_t set;//信号集。
    sigemptyset(&set);//对set清空
    sigaddset(&set,2);//添加信号,即SIC_INT,终端信号,ctrl + C

    //注意:此时我们并没有添加到进程中,只是在对栈区变量set完成了相关的赋值操作//将信号集 中的信号添加到blocked表中
    sigset_t oldset;
    sigprocmask(SIG_BLOCK,&set,&oldset);

    //此时我们给当前进程发送2号信号。
    kill(getpid(),2);
    //此时打印出pending表
    Print();
    //对2号信号进行解除阻塞,之后指向对应的方法
    sigprocmask(SIG_UNBLOCK,&set,&oldset);
    //执行后打印出ppending表.
    Print();
    return 0;
}
  • 运行结果:
    在这里插入图片描述

我们作死试一下看能不能将所有的信号进行阻塞:

#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
void Print(sigset_t who)
{
    for(int i = 31; i >= 1; i--)
    {
        if(sigismember(&who,i))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}
int main()
{
    sigset_t set;//信号集。
    sigemptyset(&set);//对set清空
    sigfillset(&set);//将set所有的有效位都填上。
    //注意:此时我们并没有添加到进程中,只是在对栈区变量set完成了相关的赋值操作//将信号集 中的信号添加到blocked表中
    Print(set);
    sigset_t oldset;
    sigprocmask(SIG_BLOCK,&set,&oldset);
    sigprocmask(SIG_BLOCK,&set,&oldset);
    //第二次重复设置是为了将blocked表拿出来。
    //此时打印出blocked表
    Print(oldset);
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 显然9号信号与19号新号是不能被阻塞的。
  • 解释:9号信号是终止进程的,19号信号是强行停止进程。都是为了方式恶意程序干扰操作系统的运行。

最后我们看一下,信号执行期间的blocked表。

#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
sigset_t set;//信号集。
sigset_t oldset;
void Print(sigset_t who)
{
    for(int i = 31; i >= 1; i--)
    {
        if(sigismember(&who,i))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}
void handler(int sig)
{
    cout << "this is " << sig << " sig." << endl;
    sigprocmask(SIG_BLOCK,nullptr,&oldset);
    //此处为了将blocked表,即oldset拿出来。
    //打印blocked表。
    Print(oldset);
}

int main()
{
    signal(SIGINT,handler);
    sigemptyset(&set);//对set清空
    sigaddset(&set,2);//添加信号,即SIC_INT,终端信号,ctrl + C
    //注意:此时我们并没有添加到进程中,只是在对栈区变量set完成了相关的赋值操作。
    //将信号集 中的信号添加到blocked表中
    sigprocmask(SIG_BLOCK,&set,&oldset);
    //给当前进程发送2号
    kill(getpid(),2);
    //对2号信号进行解除阻塞,之后指向对应的方法
    sigprocmask(SIG_UNBLOCK,&set,&oldset);
    //获取blocked表
    sigprocmask(SIG_BLOCK,nullptr,&oldset);
    Print(oldset);
    return 0;
}
  • 运行结果:
    在这里插入图片描述

3.信号的处理

  • 信号的处理也叫做信号的递达

在上面的讨论中我们已经简单的了解了信号处理的函数:

sighandler_t signal(int signum, sighandler_t handler);
typedef void (*sighandler_t)(int); 

  • 此处强调一点,信号的处理方式:
  1. 默认,即使用系统内置的handler处理方法。
  2. 自定义,即自己写一个指定类型的函数,使用signal函数将指针传进去。
  3. 忽略,即设置signal时,传进去一个SIG_ING,底层为对1进行强制类型转换为sighandler_t类型的。

我们此处验证一下,看是否所有的信号都能被自定义。

#include<iostream>
#include<signal.h>
using namespace std;
void handler(int sig)
{
    cout << "this is " << sig << "signal" << endl;
}
int main()
{
    for(int i = 1; i <= 31; i++)
    {
        sighandler_t sret =  signal(i,handler);
        if(sret == SIG_ERR)//设置失败。
            cout << i << " ";
    }
    cout << endl;
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 显然可见,9号与19号信号不能被自定义。
  • 接口函数与信号
/*
头文件: 
*/
 #include <stdlib.h>

/*
函数声明:
*/
void abort(void);
//此函数用于执行异常中断,即向进程发送6号信号。
#include<iostream>
#include<signal.h>
using namespace std;
void handler(int sig)
{
    cout << "this is " << sig << "signal" << endl;
}
int main()
{
    sighandler_t sret =  signal(6,handler);//6号信号,SIGABRT

    //调用abort函数,验证是否会产生信号并出现死循环的情况。
    while(true)
    {
        abort();
    }
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 显而易见,接口里面不止光发送了信号,还进行了其它的处理。
  • 除此之外,我们也来谈一谈处理的原理。
  1. 因为处理的方式可以自定义,进程内部也是维护了自己的handler表的。
  2. 因此当进行自定义时,我们就可以通过修改handler表的函数指针,进而实现自定义的功能。
  3. 具体的结构可看这张图解:
    在这里插入图片描述

一张图大概总结一下:

在这里插入图片描述

四、内核

1.原理

  • 信号是操作系统发送,进程自然也要调用对应提供的系统调用函数,而信号并不一定会使进程退出,且信号是异步的,因此会在操作系统与用户之间来回切换,这就涉及到了进程地址空间。

先给出图解:
在这里插入图片描述

  • 我们再对图解进行文字补充:
  1. 首先进程地址空间分为内核空间与用户空间,其中内核空间占1G,而用户空间占3G,当然这只是一个范围,具体一个进程并没有那么多。
  2. 一个进程有一个用户级页表,而内核级页表整个系统只能有一个,所有的进程都是浅拷贝链接到内核级页表的。
  3. 内核空间中有着操作系统的代码,通过转换到内核空间,再通过内核级页表可以访问到具体的系统调用接口的代码和数据。
  4. 用户要想执行操作系统的代码,必须转换为内核态,即访问内核空间的代码,而身份的转换需要寄存器,即CS寄存器,如果从用户态转换为内核态,则11变00,进而通过内核级页表,与MMU的cr3寄存器完成虚拟到物理的转化,因为操作系统也是进程里面的地址也是虚拟地址。

内核态与用户态具体是如何进行转换的呢?

  • 图解:

在这里插入图片描述

  • 抽象记忆:
    在这里插入图片描述
  • 拓展:

在返回用户态执行自定义的操作方法时,会将sys_sigreturn的栈帧压入栈顶便于执行完处理方法后,进行执行。

2.函数

此处我们主要讲解与内核结构相关的自定义处理方法的系统接口:

/*
头文件 
*/
 #include <signal.h>
/*
函数声明:
*/
 int sigaction(int signum, const struct sigaction *act,
               struct sigaction *oldact);
 struct sigaction 
 {
     void     (*sa_handler)(int);
     void     (*sa_sigaction)(int, siginfo_t *, void *);
     sigset_t   sa_mask;//
     int        sa_flags;
     void     (*sa_restorer)(void);
 };
 /*
参数2,3的类型。
1. 我们只需要重点了解两个即可。
2. 第一个为 sa_handler,即信号编号的处理方法.
3. 第二个为 sa_mask,即信号执行期间,不能被打扰的信号的编号数。
4. 拓展了解,sa_sigaction为实时信号。

函数参数:
1、信号的编号。
2、要写入的信号结构体。
3、以前的信号结构体。

返回值:
1.成功返回0.
2.失败返回-1,并设置合适的错误码。
*/

对比signal:

  1. 相较于signal,函数的参数的功能基本相同。
  2. 有所区别的是,此接口的实现将内核的信号的处理的结构暴露了出来,从而更加贴近底层。
  3. 因为是结构体,传参的设置更加灵活,且可以获取到更多的内核信息。
  • 补充:在这里我们只针对sa_mask这一信息进行讨论,其它目前阶段不做了解。

下面我们用这个接口进行实验:

  1. 简单使用sa_mask,验证是否能阻塞信号。
  2. 在处理时,再发生相同的信号,看会发生什么。
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<cstring>
using namespace std;
sigset_t oldset;
void Print(sigset_t who)
{
    for(int i = 31; i >= 1; i--)
    {
        if(sigismember(&who,i))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}
sigset_t getpending()
{
    sigset_t pending;
    sigpending(&pending);
    return pending;
}
void handler(int sig)
{
    cout << "this is " << sig << " sig." << endl;
    //打印pending表
    cout << "pending:";
    Print(getpending());
    sigprocmask(SIG_BLOCK,nullptr,&oldset);//将阻塞信号集拿出来
    //打印出blocked表
    cout << "blocked:";
    Print(oldset);
    //给当前进程发送1,2,3号信号
    kill(getpid(),1);
    kill(getpid(),2);
    kill(getpid(),3);
    //若执行到这里,表明1,2,3信号被阻塞.
    //再此查看pending表
    cout << "pending:";
    Print(getpending());
    //直接退出进程
    exit(0);
}
int main()
{
    struct sigaction act,oldact;
    //对结构体进行初始化,即内存置0
    memset(&act,0,sizeof(act));
    memset(&oldact,0,sizeof(oldact));
    //设置信号对应的方法
    act.sa_handler = handler;
    //添加阻塞信号集
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask,1);
    sigaddset(&act.sa_mask,3);
    //将结构体信息写入2号信号
    sigaction(2,&act,&oldact);
    //给当前进程发送2号新号
    kill(getpid(),2);
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 解释:
  1. 我们在设置时,把2号信号的方法进行自定义,并将1,3号信号进行阻塞。
  2. 在对进程发送2号信号之后,由于未进行阻塞,此时2号的pending表中的位置从1到0,且此时没有信号的发送发送,因此pending表全为0。
  3. 因为在2号信号执行期间为了避免相同信号的执行多次的情况,因此将2号位进行阻塞,其余1,3是在设置自定义方法时就进行阻塞的。
  4. 再发送1,2,3号信号由于阻塞不会再进行执行,当2号信号执行完毕后,会重新检查pending表里面信号进行执行。
  • 拓展:
  1. 可重入函数
    在这里插入图片描述

先简单介绍一下流程:

  1. 在执行结点插入时,即执行到p->next = head;后进程收到信号,暂停当前进程的执行流。
  2. 调用对应信号的处理方法,把node2插入到链表中,再返回。
  3. 执行head = p;此时head结点最终就呈现了图解的4现象。
  • 这种现象说轻一点就是结点丢失,严重一点会可能会导致内存的泄漏。
  • 因此在执行流多的情况下,可能会导致一定的错误出现,需要注意。
  1. volatile

说明:

  1. 表明变量可能被修改
  2. 告诉编译器不要直接进行优化,也就是不要把变量直接加载到寄存器中,就直接在CPU进行判断,而不考虑变量在内存中是否被改变,就好像是背水一战的感觉。

代码:

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

int a = 1;
void hanlder(int sig)
{
    cout << "this is " << sig << "sig" << endl;
    sig = 1;
}
int main()
{
    signal(2,hanlder);  
    while(a);
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 在gcc/g++编译器默认不进行优化,需要添加对应的选项。
  1. O1:这个标志告诉编译器进行基本的优化,包括减少代码大小和执行时间的优化。它会进行一些简单的优化,但是不会花费太多时间来进行深度优化。
  1. O2:这个标志启用了更多的优化,包括内联函数、循环展开和更多的指令调度。这将使编译时间略微增加,但生成的代码将更快。
  1. O3,这是最高级别的优化,它会尝试进行更激进的优化,包括更大范围的指令调度和内存访问优化。这可能会导致编译时间显著增加,而且并不是所有的代码都能从这些优化中受益。

这里我们使用O1进行优化,即可。

	g++ -o proc proc.cpp -O1 -std=c++11 
  • 运行:
    在这里插入图片描述
  • 可见加了优化之后,我们的进程输入两次ctrl + c,都没有终止进程。直到我们输入ctrl + | 才终止进程。

为了防止进程进行优化,我们可以加volatile关键字进行修饰。

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

volatile int a = 1;
void hanlder(int sig)
{
    cout << "this is " << sig << "sig" << endl;
    sig = 1;
}
int main()
{
    signal(2,hanlder);  
    while(a);
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  1. waitpid的信号使用方法
  • 当子进程终止时,会向子进程发送进程终止信号:
    SIGCHLD(17):子进程状态改变信号,当子进程停止或终止时产生。

验证:

void handler(int sig)
{
    cout << "process: "<< getpid() << " get sig " << sig << endl;
}
int main()
{
    signal(17,handler);
    cout << "I am father, my pid is " << getpid() << endl;
    pid_t rid = fork();
    if(rid == 0)
    {
        //子进程
        cout << "I am child, my pid is " << getpid() << endl;
        //子进程退出,向父进程发送17号信号
        exit(1);
    }
    //防止父进程先于子进程退出。
    sleep(1);
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 可见是父进程收到了子进程的信号。

以信号为基础我们就可以,通过信号来回收子进程,因为信号与进程运行时异步的,所以就可以把回收子进程的动作与父进程异步,使父子进程更加独立。

  • 举例:
void handler(int sig)
{
    cout << "process: "<< getpid() << " get sig " << sig << endl;
    pid_t rid =  waitpid(-1, nullptr, 0);
    if(rid != -1) cout << "handle success" << endl;
}
int main()
{
    signal(17,handler);
    cout << "I am father, my pid is " << getpid() << endl;
    pid_t rid = fork();
    if(rid == 0)
    {
        //子进程
        cout << "I am child, my pid is " << getpid() << endl;
        //子进程退出,向父进程发送17号信号
        exit(0);
    }
    //防止父进程过早退出。
    sleep(3);
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  1. 可见这里在信号发送后,父进程收到就立即去处理了。
  2. 但是这里有一个问题,就是当父进程一次收到大量相同信号时,只会处理一次。那就有问题了。
  • 如何解决呢?
void handler(int sig)
{
    cout << "process: "<< getpid() << " get sig " << sig << endl;
    while(waitpid(-1, nullptr, 0) != -1)
    {
        cout << "handle success" << endl;
    }
}
int main()
{
    signal(17,handler);
    cout << "I am father, my pid is " << getpid() << endl;
    for(int i = 0; i < 2; i++)
    {
        pid_t rid = fork();
        if(rid == 0)
        {
            //子进程
            cout << "I am child, my pid is " << getpid() << endl;
            //子进程退出,向父进程发送17号信号
            exit(0);
        }
        //sleep(1);
    }
    //防止父进程过早退出。
    sleep(10);
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 这是一次处理一批的。我们将sleep(1)注释解除再次运行。
  • 运行结果:
    在这里插入图片描述
  • 这是一次处理一次的。还有两种情况叠加进行处理的,因为在信号处理完之后还要进行检查pending表,再次进行处理。

除此之外,我们还可以让父进程直接进行忽略子进程的信号。

#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
int main()
{
    signal(17,SIG_IGN);
    cout << "I am father, my pid is " << getpid() << endl;
    pid_t rid = fork();
    if(rid == 0)
    {
        //子进程
        cout << "I am child, my pid is " << getpid() << endl;
        //子进程退出,向父进程发送17号信号
        sleep(1);
        exit(0);
    }
    //防止父进程过早退出。
    sleep(10);
    return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 可以明显看到,子进程没有变僵尸就退出了。

我们再使用默认的信号处理方式,实验一下:

  • 运行结果:
    在这里插入图片描述
  • 可见子进程是陷入僵尸状态的。

因此,我们对比一下SIGDEL和SIGIGN。
在这里插入图片描述

  • 这里的17号信号的默认动作就是ign, 如何理解?
  1. 我们要区分 SIGDEL信号对应的动作为ign 和 SIGIGN。
  2. 也就是对信号的动作忽略和对信号忽略是两个概念。

  • 总结
  1. 我们从概念的方面简单的认识了信号,并从生活和硬件方面对信号进行了深入的理解。
  2. 信号是异常的处理方法,但异常的处理方法也不一定只有信号,同理信号也不一定只能处理异常,也可以处理一些(如闹钟)特殊的事件。
  3. 深入了解了信号的三个阶段,信号的保存,发送,处理。
  4. 从内核级别理解信号处理过程,并且深入内核,也对进程地址空间进行了一定的探究。

尾序

? 今天的内容就分享到这里了,我是舜华,期待与你的下一次相遇!

文章来源:https://blog.csdn.net/Shun_Hua/article/details/134901570
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。