———————————————————————————————————————————
信号入门
在了解信号之前有许多要理解的相关概念
我们可以先通过一个生活例子来初步认识一下信号
1.生活角度的信号
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”。
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取"。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取"。
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)。
- 快递到来的整个过程,你不能准确断定快递员什么时候给你打电话,所以该过程对你来讲是异步的
2.同步与异步,阻塞与非阻塞
同步就是发出一个调用后,当这个调用没有得到结果的时候,该调用就一直不返回
而异步则相反,当发出一个调用后,不管这个调用有没有取得结果,直接就返回了,后面通过状态,和通知来告诉调用者,或者通过回调函数来调用这个调用
阻塞和非阻塞关注的是程序在等待调用结构时的状态
阻塞调用指的是当获得调用结果之前,当前进程会被挂起
非阻塞调用指的是在没有获得调用结果之前,当前进程不会被挂起,会继续执行
举个通俗的例子:
你打电话问书店老板有没有 《分布式系统》这本书,如果是同步通信机制,书店老板会说,你稍等,〞我查一下",然后开始查啊查,等查好了(可能是5秒,也可能是一天)告诉你结果(返回结果)
而异步通信机制,书店老板直接告诉你我查一下啊,查好了打电话给你,然后直接挂电话了 (不返回结果)。然后查好了,他会主动打电话给你。在这里老板通过“回电”这种方式来回调如果是阻塞调用,那么当你问完书店老板以后,你会在电话前一直等,直到书店老板给你回电;而如果是非阻塞调用,那么当你问完以后就会去干其他事情了,例如刷剧打游戏等等
3.进程的注意事项
一个bash只能有一个前台进程,可以有多个后台进程
Ctrl-C 产生的信号只能发给前台进程。只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号
前台进程不能被暂停,一旦被暂停就被自动放到后台进程中去
一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程
前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步的
4.信号的概念
什么叫做信号?信号其实是向目标进程发送通知信息的一种机制
信号的本质:其实就是用软件来模拟中断的过程——软中断
中断是什么呢?本质是电信号。这里涉及部分硬件原理,大概理解一下即可
当你在键盘敲ctrl+c的时候,键盘这个外设就产生了电信号,通过总线发送给中断控制器,再通过8259将电信号转换为中断号(0~n),被OS捕捉
这里外设产生电信号被转换为中断号的过程一般叫硬中断
5.信号介绍
信号分为普通信号和实时信号,本篇主要讨论普通信号
可以通过kill -l命令查看系统定义的命令,每个信号的具体使用可以使用man -7 signal命令
总共有62个信号,1-31为普通信号(大部分为终止进程),其余为实时信号
可以看到每个信号由一个序号+一个名字组成,通过以前所学这里的名字我们可以大概推测出来是宏,类似于#define SIGHUP1
这里的序号就是中断号,而在进程中会存在一张函数指针数组(中断向量表),进程通过序号(数组下标)可以调用不同的函数
这里大概了解一下即可,下文会详解
6.如何全面理解信号
下文将从信号的产生,保存和捕捉处理三个大部分来详解一下
其中保存和捕捉处理十分重要
信号的产生
1.通过终端硬件产生
其实就是上文所讲的通过键盘发送信号,常见的有ctrl+c,向前台进程发送2号新号,ctrl+z(默认暂停进程),ctrl+·默认终止进程
敲下键盘-》外设产生电信号->转变为中断号-》被os拿到发送给进程-》每个进程都有自己的一个中断向量表,中断号与数组下标强相关,通过中断号调用对应的函数
还是下面这张图
注意:这里被os写入进程十分重要,因为os是软硬件的管理者,无论信号的产生有多少种方式,最后只能被os拿到,然后发送给进程
2.通过系统调用产生
kill命令是通过kill函数完成的,kill函数可以给一个指定的进程发送指定的信号
可以通过kill函数来实现自己的,这里需要用到之前学的命令行参数
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<signal.h>void Usage(char*s)
{printf("Usage:%s pid signo\n",s);
}
//kill -9
int main(int argc,char*argv[])
{if(argc!=3){Usage(argv[0]);return 1;}pid_t pid=atoi(argv[1]);int signo=atoi(argv[2]);kill(pid,signo);return 0;
}
除了kill函数,还有raise和abort
int raise(int sig)
raise函数用于给当前进程发送sig信号,成功返回1,不成功返回0
void handler(int signo)
{printf("get a signal:%d\n",signo);
}
int main()
{signal(2,handler);while(1){sleep(1);raise(2);}return 0;
}
void abort(void)
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<signal.h>
#include<unistd.h>void handler(int signo)
{printf("get a signal:%d\n",signo);
}int main()
{signal(6,handler);//对信号自定义捕捉while(1){sleep(1);abort();}return 0;
}
abort函数是一个无参无返回值的函数,就是向进程自己发送6号信号
即使6号信号被自定义捕捉后不退出进程,使用abort函数后总是会退出进程
总结:exit是终止正常结束的进程,abor是终止异常结束的进程,终止方法为向进程发送6号信号,即使6号信号被自定义捕捉后没有执行退出逻辑操作,调用abor函数后总是能退出
3.通过软件条件产生
SIGPIPE信号和闹钟SIGALRM
SIGPIPE信号(13号信号)实际上是一种由软件条件产生的信号,我们都知道管道遵从一定的规则
假如管道的读端关闭,写端还在写数据的时候,此时管道已经没有存在的必要了,写端就会收到SIGPIPE信号从而被终止
unsigned int alarm(unsigned int seconds);
调用alarm函数可以让os在seconds秒之后给当前进程发送SIGALARM信号,SIGALARM信号的默认动作是终止进程
4.通过硬件异常
当进程中出现除零错误或者野指针和越界访问问题,为什么程序会崩溃呢?因为os识别到相关错误向进程发送对应信号使其终止
那么是如何识别除零错误或者野指针和越界访问问题的呢?
先说除零错误。我们知道cpu中有一堆的寄存器,当寄存器进行算术的时候,有些状态寄存器的值也要改变。在这些状态寄存器中肯定有某个寄存器的某个比特位表示除数是否为0,一旦检测出来除数为0,那么对应的硬件信息就会被os所识别到,然后包装成软件信息发送信号给当前进程
野指针和越界访问问题
我们都知道当虚拟地址向物理地址转换的时候,是通过页表转换的,页表属于一种软件映射关系
而实际上从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责cpu内存访问请求的计算机硬件
当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。
既然MMU是硬件,所以就有对应的状态信息。当我们要访问不属于自己的虚拟地址空间的时候,MMU在转换的时候就会出现错误,从而被os识别,发送信号给进程,让进程终止
总结:程序之所以会崩溃,就是进行错误操作的时候一些硬件信息被os捕捉到,然后包装成软件信息向进程发送信号,从而终止进程
信号的保存
首先要理解一下几个概念
实际执行信号的处理动作,称为信号递达
信号从产生到递达之间的状态,称为信号未决
进程可以选择阻塞某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
需要注意的是,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。
三张表
信号被发送给一个进程之后,进程可能不是立即执行的,那么进程就要保存这个信号,怎么保存呢?通过位图保存
在一个进程中都会存在三张表,block位图(信号屏蔽字,阻塞信号集)表示对应信号是否被阻塞,pending位图表示该信号是否未决,还有一个handler表——函数指针数组,保存默认的处理方法
所以之前说的os发送信号给进程,其实就是向进程对应的位图写入数据
假如我向一个进程发送2号信号,该进程的pending表的二号位置就会变为1,此时2号信号信号未决,直到信号被处理之前,该位置一直为1;如果2号信号被写入pending表但是对应的block也被写入,就是信号被阻塞,此时不执行对应的默认处理方法,直到阻塞被解除
如果是忽略,那么就是对应的pending被写为1,block写为0。先将pending写为0,执行空方法,也就什么都不做
假设在进程在执行其他的信号方法的期间发送多个2号信号,pending的2号位置仍为1,当之前的方法处理完之后,2号的对应方法只被执行一次(其他系统可能不一样)
总结一下:
在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
block、pending和handler这三张表的每一个位置是一一对应的。
sigset_t及信号集操作函数
sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。
其实就是在系统中被定义的位图,我们直接把其当做c语言中的变量使用即可
修改位图就要修改其中的比特位,必然涉及大量的位操作,对于使用者的体验肯定是不好的,所以就有了信号集操作函数
#include <signal.h>int sigemptyset(sigset_t *set);int sigfillset(sigset_t *set);int sigaddset(sigset_t *set, int signum);int sigdelset(sigset_t *set, int signum);int sigismember(const sigset_t *set, int signum);
sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
sigaddset函数:在set所指向的信号集中添加某种有效信号。
sigdelset函数:在set所指向的信号集中删除某种有效信号。
sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。
sigprocmask
sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
如果oset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出。
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
sigpending
sigpending函数可以用于读取进程的未决信号集,
int sigpending(sigset_t *set);
关于以上接口大家可以自己去用用,这里贴个小实验给大家了解一下大概的用法
先用上面的函数将2号信号进行阻塞,使用kill命令或组合按键向进程发送2号信号,此时2号信号会一直被阻塞,并一直处于未决状态,使用sigpending函数获取当前进程的pending信号集进行验证。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>void printPending(sigset_t *pending)
{int i = 1;for (i = 1; i <= 31; i++){if (sigismember(pending, i)){printf("1 ");}else{printf("0 ");}}printf("\n");
}
void handler(int signo)
{printf("handler signo:%d\n", signo);
}
int main()
{signal(2, handler);sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);sigaddset(&set, 2); //SIGINTsigprocmask(SIG_SETMASK, &set, &oset); //阻塞2号信号sigset_t pending;sigemptyset(&pending);int count = 0;while (1){sigpending(&pending); //获取pendingprintPending(&pending); //打印pending位图(1表示未决)sleep(1);count++;if (count == 20){sigprocmask(SIG_SETMASK, &oset, NULL); //恢复曾经的信号屏蔽字printf("恢复信号屏蔽字\n");}}return 0;
}
信号的捕捉
拿完快递后我们会在合适的时候打开,同理进程也会在合适的时候处理信号,是在什么时候呢?
从内核态返回到用户态的时候,进行信号的检测和处理
在了解什么是内核态和用户态前,我们要先理解一下什么是内核空间和用户空间
用户空间和内核空间
每一个进程都有自己的进程地址空间,该进程地址空间由内核空间(3~4GB)和用户空间(1~3GB)组成
内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容
用户空间存放当前进程的代码和数据,每个进程看到的内容是不一样的(父子进程除外)
用户空间通过用户级页表与物理内存之间建立映射关系
内核空间通过内核级页表与物理内存之间建立映射关系
内核级页表是全局的,每个进程都用该页表去物理内存找os的代码和数据
而用户级页表是每个进程一份的,每个进程都用该页表去物理内存找该进程的代码和数据
用户态和内核态
在之前学习权限的时候我们就知道代码的执行是有权限的,假如不给权限你就无法完成一件事情
内核态通常用来执行操作系统的代码,是一种权限非常高的状态。
用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态
系统调用背后,就包含了进程身份的转变
进程收到信号之后,并不是立即处理信号,而是在合适的时候,这里所说的合适的时候实际上就是指,从内核态切换回用户态的时候
从用户态切换为内核态通常有如下几种情况:
- 需要进行系统调用时。
- 当前进程的时间片到了,导致进程切换。
- 产生异常、中断、陷阱等。
与之相对应,从内核态切换为用户态有如下几种情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等处理完毕。
进程默认是在用户态的,而在调用系统调用的时候,就会从用户态切换成内核态,然后通过在内核空间里的虚拟地址,通过内核级页表和MMU去物理内存中找到相应的代码和数据并执行
当进程收到的信号是默认信号的时候,如果是默认动作,那么把相应的pending表的对应位置置为1 之后,就会去找在内核空间的handler表并执行对应的代码
画圈的地方就是状态切换的地方
而如果信号被自定义捕捉的话,就要从内核态切换为用户态,去执行自定义的放法,执行完通过系统调用sigreturn返回到内核态
巧记
整体过程就是一个无穷符号!
如果有多个信号要处理,在处理完前面信号返回到内核态的时候,继续进行信号的检测,执行对应的方法,如此循环直到pending表为空,再返回到用户态,继续往下执行代码
为什么不能把自定义捕捉的函数放在内核空间中呢?
因为内核态处于很高的一种状态,有些用户态执行不了的方法它也能执行,为了防止该自定义函数用较高权限乱操作,例如删除数据库等操作,因为内核态的权限足够高可以支持它完成这项操作,所以要将自定义函数放在用户空间中,这样就能防止上面情况发生
os怎么知道该进程当前是处于用户态还是内核态的呢?
cpu中有相应的状态寄存器的某个位置可以标记,该位置可以被os识别并转换信息,例如0为用户态1为内核态,那么根据该位置的值就知道该进程是处于什么状态了
那么问题就来了,如果整个代码没有调用系统调用接口,该进程就不会切换成内核态,就不会进行信号的检测和处理了吗?
当然不是的!进程都是有相应的时间片的,一个进程的时间片到了cpu就要去调度下一个进程了,当前进程的时间片到了,导致进程切换也是要进入内核态的
总结
至此信号的讲解就结束了,本文从三个方面——信号的产生,保存和处理来进行分析,希望大家能对信号有个全面而又清晰的认识
本文仍有许多不足之处,欢迎各位小伙伴们随时私信交流!希望大家多多点赞转发支持一下