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