●个人主页:你帅你先说.
●欢迎点赞关注收藏
●既选择了远方,便只顾风雨兼程。
●欢迎大家有问题随时私信我!
●版权:本文由[你帅你先说.]原创,CSDN首发,侵权必究。
信号是进程之间事件异步通知的一种方式,属于软中断。
我们首先要明确的是信号和信号量是完全不同的两个概念,他们没有关系,只是名字很像而已。
信号我们很好理解,就是字面意思。
比如你正在上某节课时,突然一声"TiMi~~",这个时候你就明白有人上号了,这里的"TiMi"就是一种信号。
我们来看看Linux的常见信号。
我们发现,虽然标有64,但不是连续的,31以后直接跳到了34,实际上有62个信号,前31个信号我们叫做普通信号
,后31个我们叫做实时信号
。
Linux系统提供了一个可以修改进程对信号的默认处理动作。
我们用代码来使用一下这个函数
刚开始我们没有发送信号时,函数没有被调用,但我们在键盘上敲出Ctrl C
,系统就接收到了信号。
信号的产生方式其中一种就是通过键盘产生的信号,只能用来终止前台进程。
总结:
进程收到信号的处理方案有三种情况。
1.默认动作—一般是终止自己、暂停等。
2.忽略动作—是一种信号处理的方式,只不过动作就是什么也不干。
3.自定义动作(信号的捕捉)—我们刚刚用signal方法,就是在修改信号的处理动作,由默认动作变成自定义动作。(注意:9号信号不可以被捕捉,即不能被自定义)
在Windows 或 Linux下,进程奔溃的本质是进程收到了对应的信号,然后进程执行信号的默认处理动作(杀掉进程)。
为什么会发送信号?
在操作系统中,软件上面的错误,通常会体现在硬件或其他软件上。当CPU发现硬件被破坏了就会发送信号来终止进程。
当你程序崩溃时,你肯定会收到崩溃的原因,这个崩溃的原因我们之前讲过,是在waitpid里status的低七位存储的。
总而言之,在Linux中,当一个进程正常退出的时候,它的退出码和退出信号都会被设置。当一个进程异常退出时,进程的退出信号会被设置,表明当前进程退出的原因。有的时候,OS会设置退出信息中core dump标志位,并将进程在内存中的数据转储到磁盘中,方便我们后期调试。
我们来看看怎么用status来查看退出信息。
我们知道0是不能做除数的,所以对应8号信号的浮点数错误。
除了我们在命令行上敲
kill 信号 pid
来控制信号外,我们还可以通过系统调用接口kill()
来操控信号。
我们发现这个函数使用起来非常容易,只需要pid和信号就可以执行。下面来看看kill的使用方法。
命令行中我们输入 ./xxxx 信号 pid,所以argc是3。
通过某种软件来触发信号的发送。
例如在系统层面设置定时器,或者某种操作而导致条件不就绪等这样的场景下,触发的信号发送。
我们之前将进程间通信时,当读端不读,还关闭了fd,写端一直在写,最终写进程会收到sigpipe(13),这就是一中典型的软件条件触发的信号发送。
我们再来认识个接口
设置在seconds秒后发送一个SIGALRM信号。
**这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。**当seconds等于0时表示取消闹钟。
前面说了那么多,总结一下就是,产生信号有三种方式:
1.键盘产生信号。
2.进程异常产生信号。
3.通过系统调用产生信号。
4.软件条件产生信号。
学到这里,我们可能还是有困惑,OS系统是如何给进程发送信号的?
实际上是task_struct里定义了用于保存记录是否收到了对应信号的变量,在这里用到了我们之前在文件系统里所讲的位图结构,但接收到信号就把该信号置为1,没收到则为0。所以OS给进程发送信号的本质是向指定进程的task_struct中的信号位图写入bit位。与其说是发送信号,不如说是写入信号。
- 实际执行信号的处理动作称为信号递达(自定义捕捉、默认、忽略)
- 信号从产生到递达之间的状态,称为信号未决(本质是这个信号被暂存在task_struct信号位图中,未决)。
- 进程可以选择阻塞 (Block )某个信号(本质是OS系统允许进程暂时屏蔽指定的信号,该信号依旧是未决的,该信号不会被递达,直到解除阻塞才能递达)。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
pending位图实际上就是我们刚刚讲的用来标识是否接收到了信号。(已经收到但是还没有被递达的信号)
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,
sigset_t称为信号集
,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞
,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态
。阻塞信号集也叫做当前进程的信号屏蔽字
(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
信号集操作函数
#include
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置位,表示 该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
//函数的本质就是修改block位图
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
#include
#include
#include
#include
void handler(int signo)
{
while(1)
{
printf("get a signo: %d\n", signo);
sleep(1);
}
}
int main()
{
sigset_t iset,oset;
sigemptyset(&iset);
sigemptyset(&oset);
sigaddset(iset, 2);
//sigaddset(iset, 9);9号信号不可被屏蔽!
//设置屏蔽字
sigprocmask(SIG_SETMASK,&iset,&oset);
while(1)
{
printf("hello world!\n");
sleep(1);
}
return 0;
}
此时2号信号对应的Ctrl C键就失效了。
sigpending
#include
sigpending(sigset_t * set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
//不对pending位图修改,只获取进程的pending位图
接下来我们来实现一个功能。
首先屏蔽掉2号信号,不断的获取当前进程的pending位图,并打印显示,手动发送2号信号(信号不会被递达),然后再不断的获取当前进程的pending位图,并打印。
#include
#include
#include
#include
void show_pending(sigset_t *set)
{
printf("curr process pending: ");
for(int i = 1; i <= 31; i++)
{
if(sigismember(set, i))
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
}
void handler(int signo)
{
printf("%d 号信号被递达了,已经处理完成!\n", signo);
}
int main()
{
signal(2, handler);
sigset_t iset, oset;
sigemptyset(&iset);
sigemptyset(&oset);
sigaddset(&iset, 2);
sigprocmask(SIG_SETMASK, &iset, &oset);
int count = 0;
sigset_t pending;
while(1)
{
sigemptyset(&pending);
sigpending(&pending);
show_pending(&pending);
sleep(1);
count++;
if(count == 10)
{
//解除对2号信号的屏蔽
sigprocmask(SIG_SETMASK, &oset, NULL);
//2号信号的默认动作是终止进程,所以看不到现象
printf("恢复2号信号,可以被递达了\n");
}
}
return 0;
}
现象我就不演示了,在这里要注意一点,当信号被递达,在pending位图中就会由0置1。
实际上,接收到的信号不一定是马上处理的,有的时候进程正在处理更重要的事,这时信号就会被延时处理。
那信号什么时候被处理(检测,递达(默认、忽略、自定义)?
信号是被保存在进程的PCB中,即pending位图里面。当进程从内核态
返回到用户态
的时候,进行检测和处理工作。
内核态:执行OS的代码和数据时,计算机所处的状态叫做内核态,OS的代码的执行全部都是在内核态。
用户态:用户代码和数据被访问或者执行的时候所处的状态。我们自己写的代码全部都是在用户态执行的!
用户调用系统函数后,除了进入函数,身份也会发生变化,用户身份变成内核身份。
到这可能大家还是很懵,这个用户态和内核态究竟是什么?
前面我们说过每个进程都有它的虚拟地址空间,地址空间有页表可以映射到物理内存上,我们之前所说的页表准确来说是用户级页表,每个进程都有属于自己的用户级页表,而在OS系统中,除了用户级页表还有系统级页表,系统级页表在OS系统中只有一份(即被所有进程共享)。在地址空间的分布中,有1个G的内核空间,这个空间就是通过内核页表映射到物理内存上找到OS的代码和数据。这样设计就能保证既能看到自己写的代码,也能看到OS的代码。那在OS系统中是怎么区分状态的?实际上在OS系统中有一个寄存器CR3
保存着状态,进程具有地址空间是能看到用户和内核的所有内容的,但不一定能访问,能不能访问取决于现在CR3保存的是什么状态。
总结一下就是两点:
1.用户态使用的是用户级页表,只能访问用户数据和代码。
2.内核态使用的是内核级页表,只能访问内核级的数据和代码。
所以现在你就能明白了,所谓的系统调用就是进程的身份转化为内核,然后根据内核页表找到系统函数执行调用。
理解了这些接下来我要放的图你就能更好的理解了。
这就是整个信号处理的过程。
不知道有没有会有疑惑,为什么在进行信号捕捉的时候一定要切回用户态,按理说OS系统拥有的权限更高,完全可以执行用户的代码。其实这是为了安全起见,因为OS系统的身份特殊,一般不会直接去执行用户的代码。
我们来看一个场景
#include
#include
int flag = 0;
void handler(int signo)
{
flag = 1;
printf("change flag 0 to 1\n");
}
int main()
{
signal(2, handler);
while(!flag);
printf("这个进程是正常退出的!\n");
return 0;
}
在编译器有优化的情况下,这段程序运行起来之后,无论你发送几次2号信号,都无法退出,这里是因为做了优化。
我们知道计算是在CPU上进行的,所以flag的值会给CPU去运算,但这段代码编译器检测到flag的值(在主函数里)只是用来检测,并没有修改,干脆省点事,之后直接去访问CPU上存的数据,这就会导致flag的值永远是0,虽然在handler函数里修改了,但只是修改了内存上的flag值。编译器的这种优化显然不是我们想要的,这时就有了volatile
的关键字。
#include
#include
//此时编译器不会对这个变量做任何优化
volatile int flag = 0;
void handler(int signo)
{
flag = 1;
printf("change flag 0 to 1\n");
}
int main()
{
signal(2, handler);
while(!flag);
printf("这个进程是正常退出的!\n");
return 0;
}
总结一下,volatile的作用是:
让编译器不对此变量做任何优化,读取必须贯穿式的读取内存,不要读取中间缓冲区寄存区中的数据。
我们在学进程时讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD
信号,该信号的默认处理动作是忽略
,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:
父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN
,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
喜欢这篇文章的可以给个一键三连
点赞关注收藏