文章目录
- 一 、初始信号
- 1.概念
- 2. 简单认识
- 3. 硬件信号
- 二 、异常与信号
- 1.信号处理异常
- 2.特殊事件
- 3.终端信号与内核信号
- 三、深入信号
- 1.信号的发送
- 2.信号的保存
- 2.1.sigset_t
- 2.2.sigprocmask
- 3.信号的处理
- 四、内核
- 1.原理
- 2.函数
- 尾序
一 、初始信号
1.概念
信号我们可以大体上从角度来看:
认识与理解
信号。- 当信号来临时
识别信号
。不立即处理
保存信号时保存信号
。处理信号
。
我们下面举个生活中的例子:
- 外卖行业兴起,此时大家坐家里就可吃上美味可口的饭。
- 此时你了解到外卖,即让人骑着电车把饭给你送到家门口,然后有人会给你打电话让你取你买到的饭。(
认识并理解外卖——理论上
)- 此时你打开APP,尝试下了一单,然后打一把金铲铲,边打边等外卖到来。
- 半个小时之后有人给你打电话,说外卖到了。(
外卖——实际上
)- 但你正准备梭哈找三星五费,根本没空理外卖小哥,就让小哥放在外卖柜里面了。(
保存外卖
)- 当你找到三星五费爽局吃鸡之后,才意识到自己外卖还没领,于是下楼领了外卖。(
处理外卖
)
当理解了这个例子之后我们再回归到进程:
- 进程有认识并处理信号的方法。(理论的信号)
- 进程收到了信号。(实际的信号)
- 可能不会立即处理信号。(保存信号)
- 最后对保存的信号进行处理。(处理信号)
- 强调:即使没有实际的信号,进程也知道有哪些信号和对应的信号的处理方法,即
理论和实际
。
那此处我们应该可以用自己的语言来给信号一个概念:
- 信号是在进程已经认识的前提下,让正在运行的进程最终对其作出相应反应的一组概念/编号。也就是说信号就是一组概念,更进一步就是编号。而真正有用的是信号对应的方法,也就是进程作出的反应。
- 举个例子更容易明白,了解与认识外卖并不重要,而是对应的用途(让你在家就能吃上现成的饭)更重要。
- 信号的概念与编号
说明:总共有62种信号,其中32 33信号没有。
- 1 - 31号信号为普通信号,是我们需要重点理解的。
- 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命令行消失与出现这个动作是什么意思?
- 解释:
- bash消失也就意味着可执行程序变为前台进程,bash变为后台进程。
- 前台进程是运行在前台的进程,因为要接受键盘信息,只能有一个。
- 后台进程是运行在后台的进程,可以向显示器打印信息,能有多个。
- 简单使用:
此处涉及两点:
- ./可执行程序 +
&
意为运行到后台进程。- pidof 【进程名】 | args 【信号】,意为给一批相同的进程名发送指定信号。
- 拓展:
- 此处因为转化为后台进程,因此无法给其发ctrl + c(给前台进程发送)。
- ctrl + c,无法终止掉bash进程(内部做了特殊处理)。
问题2:这里的ctrl + c 是什么意思?
- 解释:
- 这里ctrl + c是给正在运行的前台进程发送的信号。
- 用户按下 Ctrl-C 用于中断进程。
- 对应的信号编号为SIGINT(2)——中断信号。
- 补充:kill -l 用于查看信号编号,在进程控制 (详见目录二. 2.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用于表示错误,并且错误码会指明错误信息。
*/
- 使用接口
#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号信号处理时按照我们所定义的方法被捕获了。
- 除此之外:
- ctrl + \也是信号,即这里的 \Quit。
- SIGQUIT(3): 退出信号,通常由用户按下 Ctrl-\ 产生,用于退出进程并生成核心转储文件。
- 证明方法同上。
3. 硬件信号
为了展开讨论,我们以如下的例子进行分析:
- 现象:以后台的方式运行进程向显示器里面打印信息,此时我们bash命令行为前台进程,此时我们再向前台的命令行输入指令,发现即使输出的信息在我们看来是错乱的,只要输入没问题,指令还是能正常运行的,这背后有什么原因呢?
- 解释:
- 首先我们先来明确后台进程与前台进程的区别在于
是否接收键盘信息
。- 其次我们要明确显示器与键盘的区别,两者是
独立
的存在,但相辅相成
。- 一般来看我们需要将输入的内容
回显
到显示器上,以便于进行校对。- 但是有些场景输入的内容是不会进行回显的,比如密码登录,拿QQ来讲,虽然会有回显,但输入的密码超过一定位数之后就不在进行显示了,但这
不代表你没有往里面输入数据
。- 因此计算机的输入输出是给人看的,但也可以做到不给人看的输入输出。
- 因此键盘输入的数据是由你按下键的先后顺序决定的,跟显示器如何打印没有半毛钱关系,更代表了键盘与显示器独立。
- 理解独立与相辅相成的关系之后,我们再来谈如何实现回显的,其实很简单,就是将键盘里面的内容给显示器打印一份,至于显示器什么时候有空,那是显示器的事,当显示器有空时会将内容回显到屏幕上的。
- 因此这里的数据错乱是由显示器被多个进程打印信息所影响的,因为bash命令行读取的是键盘里面的内容,跟显示器如何打印无关,因此这里的ls也会正常进行运行。
- 理解了键盘与显示器独立与相辅相成的关系之后,我们更进一步分析,既然OS是软硬件资源的管理者,那OS是如何快速获取到数据呢?
- 首先需要明确一点,在Linux下一切皆文件,也就是说键盘在操作系统看来也是文件,我们根据之前所学的可以画一个大概的图解:
- 既然这样操作系统想要知道键盘中有数据,岂不是得遍历这个双向链表了,效率必然会降低。因此我们可以推断操作系统肯定不会这样做。
- 既然这样我们直接给出实际的获取硬件的大概图解:
再具体分析一波:
- 首先键盘读取数据时,相应连接的中断单元检测到键盘中有数据。
- 于是硬件单元向与其连接的CPU发送中断号。
- CPU内相应的寄存器处理之后,给操作系统发送信号,告诉它是键盘有数据了。
- 操作系统去对应的中断向量表中找对应的操作硬件的方法。
- 然后执行对应的方法,将数据刷新拷贝到内核缓存区中。
- struct file检测到具体的方法,把数据拷贝到内核缓冲区。
- 上层获取数据,可能是显示器也可能是用户级缓存区。如果是显示器就是对显示器数据进行拷贝回显,如果是用户则要对数据执行对应的处理逻辑。
- 拓展:
- 信号的产生与代码运行异步(软中断)。简单理解就是一个进程不会傻傻的等着信号来临,还会干自己的事情。
- 信号的来源为操作系统,因为信号的对象是进程,而且操作系统是进程的管理者,因此要让操作系统给进程发送信号。
- 认识与理解信号。
二 、异常与信号
信号与异常有一定的关系,下面我们简单谈谈:
- 信号是通知进程处理事件的编号,不一定会导致进程退出。
- 异常是代码无法正常运行的执行流中断的情况,必须让进程进行退出,可以让程序员对异常捕获报错退出,也可以让系统自动退出。
- 信号是处理异常的一种底层实现方式。
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;
}
- 运行
这里我们可以证明:
- 表达式a / b; 中间的 / 运算是没有被运行的。
- int c = a / b; 对 a / b 进行了运算,也发生了除0异常,其次之后的代码没有继续运行。
- 其次除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;
}
- 运行结果:
- 很显然,因为执行流出错,所以这里的无法返回原先的执行流往下执行,因此这里在处理完后,因为异常无法进行修正,所以再返回还会一直发送信号。
- 解决方法也很简单,只需要将上面handler的处理方法的注释代码放开即可。
下面我们再来谈谈除0错误的原理:
- 拓展:Linux内核的进程上下文,保存在
struct audit_context *audit_context
指向的内容里面;
- 野指针问题
#include<iostream>
using namespace std;
int main()
{
int *p = nullptr;
*p;//这里的*p并没有取值动作,因此编译器直接优化了,可以认为没有这一句代码。
cout << "dereference before" << endl;
*p = 0;//对其解引用并赋值;
cout << "dereference after" << endl;
return 0;
}
- 运行结果:
- *p; 并不需要获取值,因此编译器认为是无意义的动作,直接优化了。。
- *p = 0,对0处的地址进行赋值,因此直接报错。
- 此处异常也是用信号的方式进行处理的,信号编号为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;
}
- 可见我们自定义处理方法之后,由于没退出,所以操作系统会一直发11号信号。
- 解决方法,将handler方法的注释代码放开,让其退出即可。
下面一张图解释野指针问题的原理:
- 解释
- mm_struct里面会存放页表(一级页表项)。
- 页表的MMU会将虚拟地址到物理的转换。
- 转换成功放在cr3寄存器中进行寻址。
- 转换失败会放在cr2寄存器中,通过CPU给操作系统发送信息,再由操作系统给进程发送指定信号。
- 总结:
- 异常是无法进行解决的,最好让进程交代后事,死而无憾。要不然就会化作Bug,让你做几天噩梦。
- 上述两种错误是硬件方面的错误,较为底层因此用信号进行处理。
- 异常的处理方式取决于运行环境与程序员的设计,比如管道读端关闭,写端写,Centos7下就会给进程发送SIGPIPE管道破裂信号,13号信号。因为认为此操作无意义。就直接让进程退出了。
- 从中我们看出软甲层面的管道,硬件的异常,操作系统可以通过信号进行管理。同时也验证了OS是进程的管理者。
2.特殊事件
比如定一个闹钟:假如你现在该睡觉了,但是你7个小时之后要醒,此时你大概率会定一个闹钟,假如定了一个7个小时的闹钟。如果你把闹钟关了,就要一觉睡到天亮,即10个小时才醒。
此时我们简单的实现一下:
- 认识接口
/*
头文件
*/
#include<unistd.h>
/*
函数声明
*/
unsigned int alarm(unsigned int seconds);
/*
函数参数:要设置闹钟的秒数。
返回值:上一次闹钟的剩余秒数。
*/
- 代码
#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;
}
- 运行现象:
- 睡眠七个"小时 "之后,把你叫醒了, 然后你选择了把闹钟关了继续睡,直到睡到自然醒。
- 闹钟响了一回,即向进程发送信号,处理完返回,继续执行后续代码。
- 原理
- alarm对应的时间信息,pid等信息,必然会被保存,很明显通过
先描述
成结构体存放相关信息。- 等到alarm的时间到之后,再由操作系统发送信号。那alarm如何了解到进程的时间是否超时了呢?
- 其实很简单所有进程的alarm对象由
优先级队列
进行维护,即再组织
,当堆顶的元素为最小的时间,当其大于当前时间时,出队列,然后由操作系统向对应的进程发送信号。
3.终端信号与内核信号
- 终端信号:是计算机与用户之间进行交互的信号,具体包含1,2,9,13,14,15,30, 10, 16,31, 12, 17号信号。
- 内核信号:是计算机内核,即进程内部出现异常之后,由操作系统发送的信号,具体包含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;
}
- 获取原理
- 运行结果:
- 确实是8号信号没错,但core dump位不应该是1吗?
- 解释:云服务的这个选项是默认关闭的,我们需要打开这个选项。
- 指令:
ulimit -a # 查看内核文件的大小
ulimit -c 【字节数】# 要设置内核文件的大小
- 此处我们使用 -a 查看内核文件 -c设置内核文件大小为10240大小。
- 我们再次运行,core dump位标记为1,内核文件生成,符合预期。
- 接下来我们就该看如何使用内核文件了。
- 说明:实在找不到异常出在哪,可用core文件进行定位。这里我的gdb可能是有点问题的,没显示出行号。
- 最后再来谈谈为什么会服务器端会将core文件的选项关闭。
- 因为服务器是24小时不间断运行的,一旦出异常不可能把服务器关闭,检查好了再打开。
- 而是出异常接着重新启动运行。
- 如果重启就出异常,那么就会陷入一直重启一直出异常的循环当中。
- 那么如果每次异常都生成core文件,那么磁盘会被撑爆的。
三、深入信号
1.信号的发送
问题:操作系统如何向进程发送信号?
- 明确一点,OS是进程的管理者,当然要由操作系统来发送信号。
解释:
- 外卖到了,你怎么知道有外卖呢?是外卖小哥给你打电话说有了,然后你记住你有外卖到了。
- 转换到进程,你怎么知道是有信号了呢?是OS给你的进程里面写入了对应的信号,然后进程就知道有信号到了。
- 那进程里面是如何记录的呢?其实很简单,判断在不在。其实用位图即可。
0表示信号不在,1表示信号在,这里的是unsigned long 类型的变量,用于存储32位普通信号
。- 因此,OS向进程发送信号,本质上就是在对应的位图上,进行标记在不在的过程。
- 补充:在OS给进程发送大量的相同普通信号时,进程只会处理一次信号,因为OS要做到让每一个信号等可能性的执行处理方法,即均衡。而实时信号为了做到准确快速高效,则可能会出现一个信号短时间内执行多次的情况。
- 拓展: 所有的进行都要保存信号,那么保存信号的这张位图,如何管理呢?
- 解释 使用双向链表的形式进行统一的管理。下图是进程内部的保存信号的位图结构。即保存在进程的pending表里面。
2.信号的保存
说明:在计算机中,信号的保存一般被叫做阻塞。也叫做信号的未决。
回顾:
- 在外卖到时,回想起最开始的例子,我们是正在打金铲铲,并没有空去外卖,因此放在了外卖柜里面。
- 当我们打完金铲铲后,才意识到还有外卖没取,就去外卖柜里面取快递了。
联系进程:
- 当让快递小哥把外卖放在了外卖柜。是对信号的真阻塞(外卖是真实的)。
- 当你去取走外卖处理的时候。是信号的解除阻塞。
- 当你转心处理外卖时,为了不让别的事情无法打扰你。你把手机设成了勿扰模式。是对信号进行假阻塞(可能实际的信号进行屏蔽)。
- 当你处理完外卖时,恢复正常通知。这是对所有信号的假信号的解除阻塞。
- 当你查看信息时,是否有没有接收的重要信息。是再次检查实际的信号是否真阻塞了。
- 重点:
- 阻塞是分成两类的,即真阻塞和假阻塞。
- 真阻塞是你正在处理别的信号,此时有收到了信号(实际上),对其进行阻塞。
- 假阻塞是在正在处理信号时,对信号进行假阻塞(理论上)之后再来不会进行处理。
- 解除假阻塞是已经处理完信号,再信号进行解除阻塞(理论上)。
- 再次查看是否还有信号(实际上),有就再对信号进行处理。
- 总结:
- 因此我们可以在内核中查看两个block变量。
- blocked表也是以位图的方式进行呈现的,与pending表的实现方式相同。
如何进行简单的操作呢?
2.1.sigset_t
概念:
- 由操作系统封装的一个结构体变量,里面存放是位图结构。
- 想要对其进行操作,必然绕不开操作系统提供的系统调用接口。
系统接口:
#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);
- 此处强调一点,信号的处理方式:
- 默认,即使用系统内置的handler处理方法。
- 自定义,即自己写一个指定类型的函数,使用signal函数将指针传进去。
- 忽略,即设置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;
}
- 运行结果:
- 显而易见,接口里面不止光发送了信号,还进行了其它的处理。
- 除此之外,我们也来谈一谈处理的原理。
- 因为处理的方式可以自定义,进程内部也是维护了自己的handler表的。
- 因此当进行自定义时,我们就可以通过修改handler表的函数指针,进而实现自定义的功能。
- 具体的结构可看这张图解:
一张图大概总结一下:
四、内核
1.原理
- 信号是操作系统发送,进程自然也要调用对应提供的系统调用函数,而信号并不一定会使进程退出,且信号是异步的,因此会在操作系统与用户之间来回切换,这就涉及到了进程地址空间。
先给出图解:
- 我们再对图解进行文字补充:
- 首先进程地址空间分为内核空间与用户空间,其中内核空间占1G,而用户空间占3G,当然这只是一个范围,具体一个进程并没有那么多。
- 一个进程有一个用户级页表,而内核级页表整个系统只能有一个,所有的进程都是浅拷贝链接到内核级页表的。
- 内核空间中有着操作系统的代码,通过转换到内核空间,再通过内核级页表可以访问到具体的系统调用接口的代码和数据。
- 用户要想执行操作系统的代码,必须转换为内核态,即访问内核空间的代码,而身份的转换需要寄存器,即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:
- 相较于signal,函数的参数的功能基本相同。
- 有所区别的是,此接口的实现将内核的信号的处理的结构暴露了出来,从而更加贴近底层。
- 因为是结构体,传参的设置更加灵活,且可以获取到更多的内核信息。
- 补充:在这里我们只针对sa_mask这一信息进行讨论,其它目前阶段不做了解。
下面我们用这个接口进行实验:
- 简单使用sa_mask,验证是否能阻塞信号。
- 在处理时,再发生相同的信号,看会发生什么。
#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;
}
- 运行结果:
- 解释:
- 我们在设置时,把2号信号的方法进行自定义,并将1,3号信号进行阻塞。
- 在对进程发送2号信号之后,由于未进行阻塞,此时2号的pending表中的位置从1到0,且此时没有信号的发送发送,因此pending表全为0。
- 因为在2号信号执行期间为了避免相同信号的执行多次的情况,因此将2号位进行阻塞,其余1,3是在设置自定义方法时就进行阻塞的。
- 再发送1,2,3号信号由于阻塞不会再进行执行,当2号信号执行完毕后,会重新检查pending表里面信号进行执行。
- 拓展:
- 可重入函数
先简单介绍一下流程:
- 在执行结点插入时,即执行到p->next = head;后进程收到信号,暂停当前进程的执行流。
- 调用对应信号的处理方法,把node2插入到链表中,再返回。
- 执行head = p;此时head结点最终就呈现了图解的4现象。
- 这种现象说轻一点就是结点丢失,严重一点会可能会导致内存的泄漏。
- 因此在执行流多的情况下,可能会导致一定的错误出现,需要注意。
- volatile
说明:
- 表明变量可能被修改
- 告诉编译器
不要直接进行优化
,也就是不要把变量直接加载到寄存器中,就直接在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++编译器默认不进行优化,需要添加对应的选项。
- O1:这个标志告诉编译器进行基本的优化,包括减少代码大小和执行时间的优化。它会进行一些简单的优化,但是不会花费太多时间来进行深度优化。
- O2:这个标志启用了更多的优化,包括内联函数、循环展开和更多的指令调度。这将使编译时间略微增加,但生成的代码将更快。
- 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;
}
- 运行结果:
- 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;
}
- 运行结果:
- 可见这里在信号发送后,父进程收到就立即去处理了。
- 但是这里有一个问题,就是当父进程一次收到大量相同信号时,只会处理一次。那就有问题了。
- 如何解决呢?
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, 如何理解?
- 我们要区分 SIGDEL信号对应的动作为ign 和 SIGIGN。
- 也就是对信号的动作忽略和对信号忽略是两个概念。
- 总结
- 我们从概念的方面简单的认识了信号,并从生活和硬件方面对信号进行了深入的理解。
- 信号是异常的处理方法,但异常的处理方法也不一定只有信号,同理信号也不一定只能处理异常,也可以处理一些(如闹钟)特殊的事件。
- 深入了解了信号的三个阶段,信号的保存,发送,处理。
- 从内核级别理解信号处理过程,并且深入内核,也对进程地址空间进行了一定的探究。
尾序
今天的内容就分享到这里了,我是舜华
,期待与你的下一次相遇!