进程信号
- 信号入门
- 身边的信号
- 进程信号
- 产生信号
- 终端按键产生信号
- 调用系统函数向目标进程发信号
- kill
- raise
- abort
- 硬件异常产生信号
- 由软件条件产生信号
- 阻塞信号
- 信号其他相关常见概念
- 在内核中的表示
- sigset_t
- 信号集操作函数
- sigprocmask
- sigpending
- 捕捉信号
- 内核如何实现信号的捕捉
- sigaction
信号入门
信号分为四个阶段:
- 预备
- 产生
- 保存
- 处理
身边的信号
用一个简单的栗子来解释信号的四个阶段:
当我们过马路遇到红绿灯时,首先我们是能够识别红绿灯的(色盲除外),识别包含两个重要的因素:认识,并且能够产生对应的行为;可是我们为什么红绿灯呢?一定是有人所教育的,可能是在学校里面所学习的,亦或被家人所教育的,这其实就是信号的预备阶段;变绿灯时,我们不一定要立刻就过马路,如果此时我们有更重要的事情要处理,我们就会选择等待下一次,变灯其实就是产生信号,选择等待下一次就是信号的处理,在信号产生和处理之间还存在着信号的保存,也就是信号需要被记住;信号的处理方式也可以分为三种:默认动作,就是红灯停绿灯行,自定义动作,例如变绿灯之后等上几秒钟再过马路,忽略,不过马路
进程信号
将上面的概念,迁移至进程中
首先,我们有个共识:信号是由操作系统发给进程的;进程认识信号,并且还会做出对应的动作;并不是信号一产生,进程会立刻处理信号,所以进程本身必须有保存信号的能力;进程处理信号的方式也是三种:默认,自定义,忽略
,处理信号也称信号被捕捉
信号集,只学习[1,31]
普通信号
这里再理解一个概念:既然进程能够保存信号,那么应该保存在哪里呢???
其实不难想象,信号是保存在PCB
中的,结构体中存在一个属性是用来保存信号的
比特位的位置,代表信号编号;比特位的内容,代表是否收到信号,0表示没有,1表示有;所以发送信号的本质就是修改 PCB
信号位图,由于 PCB
是内核维护的数据结构对象,操作系统又是进程的管理者,所以无论发生什么信号,本质都是操作系统向进程发送的信号,操作系统必须提供发送信号和处理信号相关的系统调用
产生信号
终端按键产生信号
举个栗子,观察进程中的信号
int main()
{
while(true)
{
cout<<"我是一个进程"<<endl;
sleep(2);
}
}
热键ctrl+c
能够将进程终止,本质是操作系统将其解释为2号信号SIGINT
介绍一个函数
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signum
:信号编号handler
:函数指针,将信号自定义处理
修改上面的栗子
void handler(int signo)
{
cout<<"进程捕捉到一个信号,信号编号:"<<signo<<endl;
}
int main()
{
signal(2,handler);
while(true)
{
cout<<"我是一个进程"<<getpid()<<endl;
sleep(2);
}
}
虽然设置了 signal
函数,但是进程还是正常打印了四次,因为没有捕捉到信号, handker
函数并没有被调用;当输入热键时,信号被捕捉,由于处理方式是自定义的,所以进程没有直接退出
调用系统函数向目标进程发信号
kill
int kill(pid_t pid, int sig);
pid
:正在运行的进程的pid
sig
:将信号sig
发送给目标进程;可以是任意信号
实例如下:
mysignal.cpp
void Usage(const string&proc)
{
cout<<"\nUsage "<<proc<<" pid signo\n"<<endl;
}
int main(int argc,char*argv[])
{
if(argc!=3)
{
Usage(argv[0]);
exit(1);
}
pid_t pid=atoi(argv[1]);
int signo=atoi(argv[2]);
int n=kill(pid,signo);
if(n!=0)
{
perror("kill");
}
return 0;
}
mytest.cpp
int main()
{
while(true)
{
cout<<"我是一个正在运行的进程,pid: "<<getpid()<<endl;
sleep(1);
}
}
19号信号可以将进程停下来
raise
int raise(int sig);
将信号sig
直接发送给进程(自己)
int main()
{
int cnt=0;
while(cnt<=10)
{
printf("cnt:%d,pid:%d\n",cnt++,getpid());
sleep(1);
if(cnt>=5)
{
raise(9);
}
}
return 0;
}
9号信号直接将进程杀死
abort
void abort(void);
给进程(自己)发送指定信号,终止进程
int main()
{
int cnt=0;
while(cnt<=10)
{
printf("cnt:%d,pid:%d\n",cnt++,getpid());
sleep(1);
if(cnt>=5)
{
abort();
}
}
return 0;
}
发送的指定信号其实是6号信号
硬件异常产生信号
信号的产生不一定是用户显示地发送
观察下列代码:
int main()
{
while(true)
{
cout<<"我正在运行中..."<<endl;
sleep(1);
int a=10;
a/=0;
}
}
程序运行的结果是溢出,为什么进行a/=0
会终止进程,进程终止是收到操作系统发送的信号,具体是怎么做的呢?
在CPU中进行计算,将计算结果存放到寄存器中,由于a/=0
计算的结果溢出,此时状态寄存器将溢出标记位记为1,由于操作系统是管理软硬件资源的,CPU运算异常会被其得知,然后将8号信号发送给进程,让进程终止
验证如下:
void catchSIG(int signo)
{
cout<<"获取一个信号,信号编号是: "<<signo<<endl;
}
int main()
{
signal(SIGFPE,catchSIG);
while(true)
{
cout<<"我正在运行中..."<<endl;
sleep(1);
int a=10;
a/=0;
}
}
信号被捕获了,可是进程没有直接退出;收到信号,不一定会让进程退出,既然进程没有退出,就还有可能再次被获取
CPU内部地寄存器只有一份,寄存器中的内存保存地是进程的上下文;当进程被切换时,状态寄存器就会被保存和恢复,每一次的恢复,操作系统都会识别到CPU内部的状态寄存器的溢出标志位是1,便会向进程发送8号信号
由软件条件产生信号
在上一章的管道中,如果关闭管道一端读端,进程会收到13信号,结束进程,这个信号是在软件条件下产生的;这里介绍另一个在软件条件下产生的信号
unsigned int alarm(unsigned int seconds);
调用alarm
函数可以设定一个闹钟,也就是告知内核在seconds
秒之后给当前进程发送SIGALRM
信号,默认处理是终止当前进程
代码实现:
int main()
{
int cnt=0;
alarm(1);
while(true)
{
cout<<"cnt: "<<cnt++<<endl;
}
}
为什么说alarm
函数就是软件条件下的产生的信息呢???
闹钟其实就是由软件实现的,任何一个进程,都可以通过alarm
系统调用在内核中设置闹钟,操作系统内可能会存在着很多的闹钟,操作系统为了管理这些闹钟,需要先描述再组织
先描述
再描述
操作系统会周期性地检查这些闹钟;超时时,操作系统会发送信号SIGALARM
给进程,也就是调用alarm.p
,进而终止进程
最后还有一点,进程退出时的核心转储
观察代码
int main()
{
int a[10];
a[1000]=10;
return 0;
}
程序发生了段错误,并且进程是 core
退出,在上面还有信号是 term
退出,这种退出称为正常退出; core
退出时,程序还会做一些其他操作
云服务器上 core
退出时,看不到明显现象,默认关闭了 core file
自己可以打开云服务器中的 core file
再次运行程序
段错误后面出现了一个新的内容core dumped
也就是核心存储:当进程出现异常时,操作系统会将进程在对应时刻有效的数据转储到磁盘中,运行之后还多出来一个文件core.2545
,后面的编号就是引起问题进程的pid
核心转储存在的意义是为了调试,更方便用户检查进程崩溃的原因
实操一下:
总结
- 以上所有的信号产生都是由操作系统来执行,因为操作系统是管理软硬件资源的
- 信号的处理并不是立刻,而是在适合的时候
- 信号没有被立刻处理,信号会被暂时存放在PCB中
- 进程在收到信号之前,就已经知道该如何处理
- 操作系统向进程发送信号,其实就是修改PCB中的位图
阻塞信号
信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达
- 信号从产生到递达之间的状态称为信号未决
- 进程可以选择阻塞某个信号
- 被阻塞的信号产生时将保存在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
- 阻塞和忽略是不同的,信号只要被阻塞就不会递达,忽略本质上就是递达之后选择的一种处理动作
在内核中的表示
每个进程都有两个标志位分别表示阻塞和未决,还有一个函数指针表示处理动作;信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志;如果一个信号没有被产生,并不妨碍它可以先被阻塞
sigset_t
每个信号只有一个比特位,非0即1,不记录该信号产生了多少次,阻塞标志亦是于此;未决和阻塞都可以用相同的数据类型sigset_t
来存储,sigset_t
称为信号集,此类型可以表示每个信号的有效或无效状态;阻塞信号也称当前进程的信号屏蔽字,屏蔽是阻塞而不是忽略
信号集操作函数
sigset_t
类型对于每种信号用一个比特位表示有效或无效,至于类型内部如何存储这些比特位则依赖于系统实现
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);
- 函数
sigempty
初始化set
所指向的信号集,使其中所有信号对应的比特位清零,表示该信号集不包括任何有效信号 - 函数
sigfillset
初始化set
所指向的信号集,使其中所有信号对应的比特位置为1,表示该信号集的有效信号包括系统所支持的所有信号 - 函数
sigaddset
将信号添加到信号集中;函数sigdelset
将信号从信号集中删除;函数sigismember
判断某种信号是否在信号集中
sigprocmask
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
如果oldset
是非空指针,则读取进程当前的信号屏蔽字通过oldset
参传出,如果set
是非空指针,则更改进程当前的信号屏蔽字,参数how
指示如何更改;如果oldset
和set
都是非空指针,则将原来的信号屏蔽字备份到oldset
中,然后根据set
和how
参数更改当前的信号屏蔽字
SIG_BLOCK | set包含了所有待添加到信号集中的信号 |
---|---|
SIG_UNBLOCK | set包含了所有待从信号集中删除的信号 |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值 |
sigpending
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set
参数传出
默认情况下所有信号都不是阻塞的,如果信号被屏蔽,则该信号不会被递达
代码实现:屏蔽信号2
#include<iostream>
#include<signal.h>
#include<unistd.h>
#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31
using namespace std;
void show_pending(const sigset_t& pending)
{
for(int signo=MAX_SIGNUM;signo>=1;signo--)
{
if(sigismember(&pending,signo))
{
cout<<"1";
}
cout<<"0";
}
cout<<"\n";
}
int main()
{
//1.屏蔽指定信号
sigset_t block,oldblock,pending;
//1.1初始化
sigemptyset(&block);
sigemptyset(&oldblock);
sigemptyset(&pending);
//1.2添加要屏蔽的信息
sigaddset(&block,BLOCK_SIGNAL);
//1.3开始屏蔽
sigprocmask(SIG_SETMASK,&block,&oldblock);
//2.遍历打印pending信号集
while(true)
{
//2.1初始化
sigemptyset(&pending);
//2.2获取
sigpending(&pending);
//2.3打印
show_pending(pending);
//间隔一秒
sleep(1);
}
return 0;
}
捕捉信号
内核如何实现信号的捕捉
操作系统中进程存在两种状态:用户态,内核态;用户态一般会访问操作系统的资源和硬件资源,为达这一目的,必须通过系统提供的系统调用接口,而且执行系统调用的身份必须是内核,为什么用户可以访问系统调用接口呢???
在CPU中存在着许多的寄存器分为:可见寄存器,不可见寄存器,只要是和进程强相关的都是保存着进程的上下文的数据;名为CR3的寄存器保存着当前进程的运行级别:0表示内核态,3表示用户态,在系统调用接口的起始位置,存在着int 80汇编,会将用户态修改为内核态,从而可以以内核态的身份访问系统调用接口
进程以内核身份访问系统调用接口的具体过程又是怎么样的呢???
在之前进程空间中学习过,进程空间包括用户空间和内核空间,系统调用接口就与这内核空间有关:每个进程都有自己的进程空间,用户空间独享,内核空间只有一份,也就是说所有进程共享同一份内核空间;当进程访问接口时,只需要在进程空间上进行跳转即可,就类似与动态库加载到进程空间
图解:
当开机时,操作系统会从磁盘加载到内存中的内核区中,当进程以内核态身份访问系统调用时,会在进程空间中跳转到内核空间通过内核级页表映射到内存中操作系统完成相应的调用,完毕之后再跳转回用户空间
信号在产生时,并不会被立刻处理,而是在合适的时间被操作系统处理,这个合适的时间就是从内核态返回用户态时;所以,进程在进程切换或者系统调用时先进入内核态,在内核态中进行信号检测,也就是进程中的两个标志位(pending/block
)和函数指针(handler*
):如果信号未决且未被阻塞,查找函数指针是否有对应的自定义处理方法,若有,将进程内核态身份修改为用户身份完成对应的处理方法,再还原为内核身份,完成剩余的系统调用,待系统调用结束后,最后将身份修改为用户态继续执行后续的代码
图解:
需要注意的是:不能以内核态的身份执行用户态的代码,因为操作系统不相信任何人,以免有人进行恶意破坏
sigaction
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
signum
:待捕获的信号编号act
:结构体指针
其中包含,处理方法sa_handler
和信号集sa_mask
举个栗子,通过此函数捕获2号信号,捕获信号后休息20秒,多次向进程发送2号信号,观察进程运行结果
void Count(int cnt)
{
while(cnt)
{
printf("cnt:%2d\r",cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
void handler(int signo)
{
cout<<"get a signo"<<signo<<"正在处理..."<<endl;
Count(20);
}
int main()
{
struct sigaction act,oldact;
act.sa_handler=handler;
act.sa_flags=0;
sigemptyset(&act.sa_mask);
sigaction(SIGINT,&act,&oldact);
while(true)
sleep(1);
return 0;
}
虽然向进程发送多次同一种信号,但是进程只捕获了两次,因为当进程正在递达某一个信号时,将同种类型的信号是无法被抵达的,系统会自动将当前信号添加到屏蔽字中,将pending
位图中该信号所在位置修改为0,再次发送同一信号,会将pending
位图中该信号所在位置修改为1;当进程完成信号的递达时,系统会自动解除对该信号的屏蔽,所以系统会立即递达pending
位图中的信号,也就是捕获第二次信号
当我们正在处理2号信号时,还想屏蔽3号信号,此时只需要将3号信号加入sa_mask
信号集即可
void Count(int cnt)
{
while(cnt)
{
printf("cnt:%2d\r",cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
void handler(int signo)
{
cout<<"get a signo"<<signo<<"正在处理..."<<endl;
Count(20);
}
int main()
{
struct sigaction act,oldact;
act.sa_handler=handler;
act.sa_flags=0;
sigemptyset(&act.sa_mask);
//添加3号信号
sigaddset(&act.sa_mask,3);
sigaction(SIGINT,&act,&oldact);
while(true)
sleep(1);
return 0;
}
和上面有所不同的是,这里的进程在最后直接退出了,其实是因为,在2号信号被接触屏蔽后,再次执行2号信号,最后执行3号信号结束进程
进程处理信号的原则是串行处理同类型的信号,不允许递归