文章目录
前言: 生活中有各种信号,需要我们去识别并做出相应的反应。进程也是如此,操作系统给进程发信号,进程会根据信号来做出相应的动作。信号是有多个的,比如老板现在打电话过来,让提交一下PPT,同时外卖小哥也打来电话,说下楼取一下外卖;这就需要人去衡量一下轻重缓急了,当然是吃饭重要,所以忽略老板的信号,直接去执行外卖小哥给的信号。本章呢,会很细节的带大家了解信号,控制信号,解密信号的处理原理。灰常银杏。
1. 信号的感性理解
- 信号的产生,是操作系统发给进程的。
- 进程在没收到信号前,是否可以做到识别以及处理将要来到的信号?当然可以,就好比人,看到红灯知道要停车,这是人通过学习而掌握的常识,进程也一样,对于如何处理信号,是它们的常识,本质上大佬在写进程源代码时,就设置好了对信号的识别。
- 信号不会被立即处理,而是在合适的时候,去处理信号。就好比:上午母亲说记得收衣服,我收到这个信号了,我不会立即去收衣服,而是等衣服晾干了(合适的时机),才去收的衣服。当然这是感性的理解,后面会将到什么时候对于处理信号才是合适的时机。
- 进程收到信号后,会保存起来,以备在合适的时机,去处理。
- 保存在哪呢?进程控制块 struct task_struct 之中,所以信号本质也是一个数据。
- 信号发送的本质:向进程控制块中,写入信号数据。
- 信号发送的方式是多样的,但是本质都是操作系统向进程发送的。
我们可以先使用kill -l
,来查看信号列表:
本章只研究 1~31信号,这是写时信号, 43 ~ 64不是写时信号,先不考虑。其实如果想要了解所有这些信号的细节可以使用 man 7 signal
,来查看一下:
2. 发送信号的方式
- 通过键盘发送信号,只针对前台进程
- 进程异常,产生信号
- 通过调用系统调用接口函数,向进程发送信号
- 满足某些软件条件,也可以产生信号
2.1 键盘发送信号
常见的有 ctrl + c ,ctrl + z ,ctrl + ;都是用来终止进程的。
比如: 我写一个 死循环打印”hollow ly“
#include<stdio.h>
#include<unistd.h>
int main()
{
while(1)
{
printf("hollow ly\n");
sleep(1);
}
return 0;
}
接下来,运行我使用键盘,终止进程:
2.2 进程异常产生信号
当进程出现异常时,会产生信号,一般这种情况我们叫做程序崩溃,其实说白了,程序崩溃的本质就是操作系统,通过发送信号,来干掉这个程序。具体崩溃的原因,也可以通过获取信号的方式,来知晓;大家在学习进程等待中,不知道,是否还记得Core Dump 标志位 ?
就这个,code_dump标志位,这次来好好讲解一下它:
程序崩溃,一般情况下,我们不只想要知道它崩溃的原因,还想要知道它崩溃在哪行代码上,这就需要设置
core dump ,如果进程出现异常,它会接收到信号,从而设置 进程退出 收到的信号信息 在上图status 中的 0~7位,存的就是接收的信号信息;如果,想要知道程序,崩溃在哪行代码就需要设置 core dump。code_dump标志位表示的就是: 有core dump 信息为 1,没有core dump 信息为 0。
我来举个例子: 3 / 0 ,用 0作除数,肯定会导致进程异常退出。
#include<stdio.h>
int main()
{
int a =3;
a /=0;
return 0;
}
编译时会报错,不过没关系,编译好了运行时,异常退出,我们看一下运行结果:
好了,明显是程序崩溃了,我先要知道崩溃在何处?利用 core dump ,默认情况下,core dump 是 0,没有被设置,可以用 ulimit -a
查看:
所以先得设置 core dump,使用 ulimit -c 1024
,再次查看一下:
我们再次运行程序,是这样的结果:
表明,已经有了core dump信息,而且当前目录下,还会有一个core. 文件:
可以查看一下,这个core. 文件,里面全是二进制,其实是调试信息:
使用gdb来调试可执行程序,然后 输入 core -file core文件名
,得到程序崩溃在哪处以及崩溃原因:
所以综上: 进程异常退出的本质是发送信号给进程,如果想要知道程序崩溃在何处?方便事后调试,我们可以设置core dump,然后再次运行程序,就会有一个core file,里面有调试信息,最后使用gdb进行调试程序,输入
core -file core文件名
,就可以看到详细的进程崩溃原因。
2.3 调用系统函数发送信号
kill 命令就是用的kill函数实现的,比如要杀死某个进程 kill -9 PID
:
比如:我运行起来test,用 kill -9
杀死这个进程
介绍发送信号的三个接口函数:
- int kill(pid_t pid, int signo);
- int raise(int signo);
- void abort(void);
kill()可以向任意的进程发送信号:
- kill 的参数 :第一个参数 pid -> 向某个进程发送信号的PID,第二个参数 sig -> 发送的信号
- kill 的返回值:返回 0表示成功,返回 -1 表示失败
raise()只能向自己发送信号
- raise 的参数 : 发送的信号
- raise 的返回值 :返回 0表示成功,返回 -1 表示失败
abort()函数,很简单粗暴,就是让当前的进程,异常退出。
我们来验证一下:对于raise()和abort(),我姑且不验证了,太简单了。
我们来模拟实现一下 kill 指令:
#include<sys/types.h>
#include<signal.h>
#include<stdio.h>
#include<stdlib.h>
int main(int argc, char *argv[])
{
if(argc != 3)
{
printf("输入格式有误: 请输入 kill PID signal\n");
return 0;
}
int PID = atoi(argv[1]);
int sn = atoi(argv[2]);
int ret = kill(PID,sn);
if(ret == 0)
{
printf("指令发送成功\n");
}
else
{
printf("指令发送失败\n");
}
return 0;
}
比如:我现在形成的可执行文件 是 kill 。那么我在命令行运行 ./kill PID signal
就能够发送信号了。
2.4 触发软件条件,发送信号
比如之前学到命名管道,如果读端已经关闭,写端还在写入,那么系统会发送信号 13 来终止进程。
现在主要讲:alarm函数 和SIGALRM信号。
alarm()函数的作用是,设置一个时间,时间一到就会发送信号SIGALRM,默认行为是终止当前进程。
- alarm()的参数:seconds,设置一个秒数,相当于计时;如果seconds设置为0,那么取消之前设置的alarm。
- alarm()的返回值:返回值是0或者是以前设定的闹钟时间还余下的秒数。
举个例子:我们看看 1s 可以打印多少个数字
#include<unistd.h>
#include<stdio.h>
int main()
{
int n = 1;
alarm(1);
while(1)
{
printf("%d\n",n);
n++;
}
return 0;
}
3. 信号的控制
3.1 先来学习几个概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号的流程:
操作系统发来信号,进程保存信号,看这个信号是否被阻塞?
阻塞那么就先保存着,不阻塞那么就在合适的时机递达信号。
递达后有三种情况:执行信号的默认行为,忽略信号,执行自定义的信号行为。
注意
: 阻塞 vs 忽略
阻塞根本就没有递达,一直保存着。
忽略是已经递达,只不过进程不理会它,进程不保存此信号。
3.2 信号发送的本质
信号发送的方式多样,但是本质是操作系统向进程控制块中的信号位图
,进行写入。感觉还是抽象,我们来看看这些信号:
为啥是 1 ~ 31号为写时信号,信号[1,31]这其实是有规律的,能想像成位图嘛?当然可以!!!
假设 有一个无符号整型 int ,默认为 0,保存在进程控制块中,那么就是:
操作系统,发来信号:1。
那么这个int 的第一位 变成 1。
操作系统,再发过来信号2,就将第二个比特位设置为 1。
对,进程就是这样来保存信号的。
3.3 信号的阻塞
信号的阻塞,就会导致信号,不会被递达,也就是不会被进程执行,一直被存储在进程控制块中,存储就是对应的位图设置为 1 。 昂,信号阻塞是这样,但是进程是如何判定这个信号要被阻塞呢?答案是也是靠位图,那么必定也有一个位图用来判断信号是否被阻塞,设置为 0 表示为不阻塞,设置为 1 表示阻塞。
3.4 信号的捕捉初识
信号没有被阻塞,递达到进程后,进程有三种处理:
- 以默认信号方式处理
- 忽略此信号
- 自定义信号来处理
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。也就是第三种情况,学习一个函数 signal()
,它是就是用来捕捉信号,并且还可以自定义信号的行为:
- signal()的参数:第一个参数 signum 是要捕捉的信号,可以是宏,或者是信号值;第二个参数是一个函数指针,也就是我们要进行自定义信号的行为,看一看上面,这个参数一个 void (*)(int)型函数指针。
- signal()的返回值:返回的是我们自定义的函数指针,如果出错就返回 -1 。
例子:我们来捕捉一下 2号信号,也就是 ctrl + c 。
#include<stdio.h>
#include<signal.h>
void header(int sig)
{
printf("get a sig :%d\n",sig);
}
int main()
{
signal(2,header);
while(1);
return 0;
}
header()就是我们自定义的信号行为,那么现在程序运行起来,我们按 ctrl + c,发送 2号信号,就会自定义2号信号的行为。
运行一下:
很明显,我按下 ctrl +c,信号2 已经被自定义了。
还有一个函数. sigaction()
,可以用于捕捉信号,不过需要我们定义一个结构体:
结构体:
- sigaction()的参数:第一个参数 signum是我们要捕捉的信号;第二个参数 act是输入型参数,在这个结构体中,我们关注第一个结构体成员和第三个结构体成员,很明显sa_handler是一个函数指针,sa_mask表示要额外屏蔽的信号,函数调用结束会恢复被额外屏蔽的信号;第三个参数 oldact 是输出型参数,会保存信号之前的处理动作,不关心的话,可以设置为空。
- sigaction()的返回值:成功返回0,出错返回 -1。
例子:捕捉 2号 信号,并额外屏蔽 3号信号
#include<stdio.h>
#include<signal.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
void header(int sig)
{
printf("get a sig:%d \n",sig);
}
int main()
{
// signal(2,header);
//
struct sigaction at;
memset(&at, 0, sizeof(at));
at.sa_handler = header;
sigemptyset(&at.sa_mask);
sigaddset(&at.sa_mask, 3);
sigaction(2,&at,NULL);
while(1);
return 0;
}
3.5 信号捕捉的本质
信号的处理可以按照默认情况,也可以忽略,也可以捕捉自定义行为。这是怎么办到的?
说明原因:信号是否阻塞,信号的保存,信号的行为其实用的是三张表
这幅图,非常重要,大家好好理解。
其实创建进程时,handler块 就已经保存,所有的信号的默认处理方法,存的都是函数指针 ;这幅图应该横着看,假如传来 1号 信号,先看看block位图的第一个比特位是否为 1 ,为 1就是阻塞,为 0就没有阻塞;pending位图第一个比特位设置为 1,如果为阻塞态那么 此 比特位一直是 1,表示一直保存,直到解除阻塞并递达后才置为 0 ;如果不为阻塞态,那么就在递达后从 1 置0;handler可以看作一个函数指针数组,数组的下标就是对应的信号,那么信号捕捉的本质是什么?那就是 对handler指针数组中的内容进行重写,改变了这个handler数组中信号对应的函数指针,就是所谓的信号捕捉。
3.6 信号集操作函数
其实就是对位图的操作,我们如果能够操作 block位图,就控制好了对信号是否阻塞?我们如果可以操作 pending位图,就可以查看到当前未决的信号;如果可以修改handler表中的函数指针,就完成信号捕捉!
我们一个一个的学:
#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);
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
int sigpending(sigset_t *set);
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
首先,先看参数中有 sigset_t 类型,这个类型,我们是不能够 自己来进行操作的,比如 赋值,算术运算等都不可以,它只能通过函数接口,来进行初始化,或者是赋值等,这个类型,可以看作是 位图。
(1) sigemptyset(sigset_t *set)
是 用于初始化位图的
初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
(2) sigfillset(sigset_t *set)
也用于初始化位图
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置为 1 ,表示 该信号集的有效信号包括系统支持的所有信号。
(3) sigaddset (sigset_t *set, int signo)
和int sigdelset(sigset_t *set, int signo)
是用于添加信号,删除信号,使用前需要对此信号集初始化
sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
(4) sigismember(const sigset_t *set, int signo)
是用于判断的
sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
以上的操作,相当于操作 位图 结构。
(5) int sigprocmask(int how, const sigset_t *set, sigset_t *oset)
用于查看 block表或者修改 block表
先看参数:
- 第一个参数 how :表示要如何对当前进程的block操作,有三个宏定义,可供传参
SIG_BLOCK : 包含了要添加当前信号屏蔽字的信号,相当于 mask |= set 。
SIG_UNBLOCK :包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask = mask& ~set。
SIG_SERMASK : 设置当前信号屏蔽字为set所指向的值,相当于 mask = set。
- 第二个参数 set : 是我们传进来的位图结构,根据how的指示来进行 对 block的操作。
- 第三个参数 oset :如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出,是对以前屏蔽字的记录,屏蔽字就可以理解成记录阻塞的位图结构。
其返回值: 成功返回 0,失败返回 -1。
(6) int sigpending(sigset_t *set)
参数 set 是一个输出型参数:
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
(7)int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact)
这个函数上文有讲解
需要对以上强调一点 :并不是所有的信号都是可以被屏蔽和捕捉的,这是好理解的,如果所有的信号都能被屏蔽,被捕捉,那么就能够搞出一个金刚不坏的进程,这是操作系统不想看到的,所以 像 9号信号,它是无法被屏蔽,捕捉的。
例子:现在我要屏蔽一下 2号信号:
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
int main()
{
sigset_t set;
//初始化 set
sigemptyset(&set);
//添加2号信号,被block阻塞
sigaddset(&set,2);
sigprocmask(SIG_SETMASK,&set,NULL);
int count =10;
while(count)
{
printf("count is: %d\n",count);
sleep(1);
count--;
}
// 解除阻塞
sigprocmask(SIG_UNBLOCK,&set,NULL);
while(1);
return 0;
}
运行时,我们可以看到,2信号确实被屏蔽了,但是10s后,我们之前发送的 2 信号,不被屏蔽了,就会被抵达,并执行2信号的默认行为:
现在要求,不是说过,信号被阻塞,无法递达,但是会在pending位图中保存吗?我想要看一看,到底有没有被保存,所以我们需要利用 函数接口 sigpending(),以及sigismember() 。
我们实现一下,还是刚才的例子,只不过要查看一些 pending位图:
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
void show_pending(sigset_t *set)
{
printf("curr process pending: ");
int i=0;
while(++i)
{
if(sigismember(set, i))
{
printf("1");
}
else
{
printf("0");
}
if(i == 31)
{
break;
}
}
printf("\n");
}
int main()
{
sigset_t set;
//初始化 set
sigemptyset(&set);
//添加2号信号,被block阻塞
sigaddset(&set,2);
sigprocmask(SIG_SETMASK,&set,NULL);
int count = 0;
sigset_t pending;
while(1){
sigemptyset(&pending);
sigpending(&pending);
show_pending(&pending);
sleep(1);
count++;
if(count == 10){
sigprocmask(SIG_SETMASK, &set, NULL);
//2号信号的默认动作是终止进程,所以看不到现象
printf("恢复2号信号,可以被递达了\n");
break;
}
}
return 0;
}
4. 信号的递达
上文讲过,信号是怎么传送的?本质是操作系统想进程发送信号。信号是如何控制的?有三张表,分别控制 阻塞,保存,信号行为。信号是怎么递达的?上面说过,在合适的时候,信号会递达(进程执行信号),什么是合适的时候?递达后,可以处理默认信号行为,捕捉后的行为,或者忽略,这个通过学习信号捕捉,大家都懂了,就是通过替换 handler表中的函数指针,现在的问题就是 : 进程如何判断 现在是处理信号的合适时机!!!
先给出结论: 信号递达的合适时机 -> 从内核态 切换回 用户态 就会进行信号检测和信号处理,也就是 检查那三个表
。
4.1 用户态和内核态理解
可以复习一下,我们知道进程都是有虚拟地址空间的,在高低出有内核空间,所以内核的数据代码在内核空间。毕竟是虚拟的,每个进程都有独立的的页表,但是我想说的是,所有的进程都是共享的同一份内核页表,为什么?因为无论进程如何切换,内核就这有一个。为什么要搞一个内核空间呢?因为要区分内核和用户,操作系统是不信任用户的,所以你只有是内核态,才能访问内核的代码和数据。
所以:
- 内核态:使用内核级页表,只能访问内核的数据和代码
- 用户态:使用用户级页表,只能访问用户的数据和代码
我可以简易的画图,帮助大家理解:
那么何时,会由用户态切到内核态呢?比如 调用系统函数
4.2 操作系统,发出信号 到 进程执行信号 全过程
进程在运行,操作系统发来信号,进程就会中断或者异常,从用户态切到内核态,去查看是什么信号?操作系统,你要让我做什么?有点类似,你在吃饭,公司给你打了个紧急电话,你火速从一个干饭人员,变成一个员工,跑到公司,看看发生什么事了?
现在开始查这三张表:
自定义的话,需要返回到用户态去执行自定义的函数,然后返回到内核态,从内核态再返回。这有点有始有终的感觉,你开始是从内核进来的,那么也从内核态出去。
画图就是:
那么我有个疑问:这个过程有多少次用户和内核的切换?
来个妙招:
5. 信号的总结
以上就是操作系统中,信号的讲解。
我们从信号的发送,信号发送中,信号发送后,种种细节都说了说。
就是这样的逻辑,走下来的。当然,其中也穿插了不少补充知识。
6.补充知识 关键字 volatile
volatile 在C语言中有所学习,到那时可能有点小懵,在这里学习,就感觉很轻松了。
我先编写一个简单的程序:
#include<stdio.h>
#include<signal.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
int flag =0;
void header(int sig)
{
printf("get a sig:%d \n",sig);
flag =1;
printf("flag 0 -> 1\n");
}
int main()
{
signal(2,header);
while(!flag);
printf("进程退出\n");
return 0;
}
这个程序,捕捉2号信号后,将flag 由0 置为 1,从而退出循环,进程退出。
看看运行结果:
没什么问题,但是如果编译的时候,优化了呢?有影响吗?我们来看看,编译的时候 加上选项 -3
就是编译优化。
运行结果是:无法进行程序退出了,说明flag还是 0,那么它的值没有被修改吗?答案是修改了,只不过因为优化编译,它在cpu中的寄存器中的值是1,而内存中的值还是 0。 所以就有了 volatile。flag用它修饰后,表示不要对此变量作任何优化,而是保持它在内存中的一贯性。
结尾语: 以上就是本篇内容,觉得有帮助的老铁可以点一个小赞!!!