Linux:进程信号
生活角度的信号
以下课铃为例:人是能够识别下课铃的(认识 -> 产生行为(下课))
1.你为什么能识别下课铃呢?有人教育过你(手段) -- 让你在大脑中记住了对应的下课铃属性或者行为
2.当信号到来的时候,我们不一定立马处理这个信号,引号可以随时产生(异步),你可能做着更重要的事情(拖堂)
3.信号到来---时间窗口(必须的记住这个信号)---->信号被处理
4.产生的动作:默认动作
自定义动作
忽略动作
计算机中的信号
[1,32]普通信号
[34,64]实时信号
1.信号是操作系统给进程发的
2.进程本身是被程序员编写的属性和逻辑的集合——程序员编码完成的
3.当进程收到信号的时候,进程可能正在执行更重要的代码,所以信号不一定会被立即处理
4.进程本身必须要有对于信号的保存能力
5.进程在处理信号的时候,有三种动作(默认、自定义、忽略)(信号捕捉)
信号从产生到被处理的过程(信号的生命周期)
信号的整个生命周期被分为四个阶段:预备、信号产生、信号保存和信号处理。
在讲信号之前,我们已经对现实中的信号有了一定的了解,我们再来了解一下操作系统中的信号,由于信号发送到处理有一个时间窗口,那么必定需要保存信号。由于信号是由操作系统发送的,理所当然也由操作系统管理,所以喜好被保存在task_struct,信号以位图结构保存(具体情况在信号保存部分展开)。
下面我们再来了解一些关于信号的名词:
1 实际执行信号的处理动作称为信号递达(Delivery)
2 信号从产生到递达之间的状态,称为信号未决(Pending)。
3 进程可以选择阻塞 (Block )某个信号。
4 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
5 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
预备
预备主要完成两个工作:
1.信号与动作函数之间的匹配
OS发送信号之后需要由相应的动作与之匹配,就是处理信号时想要让进程执行的操作(一般默认操作是退出)。但是不能修改九号信号的默认处理函数,因为九号信号是管理员信号。
通过
man 7 signal
可以查看信号的默认处理函数:
2.信号的阻塞
我们可以主动阻塞一部分信号,这样进程在接收到信号之后就不会进行任何操作,只要不解除阻塞就永远处于信号未决。
通过ctrl + c我们可以向前端进程发送2号信号。
调用signal接口可以修改信号的处理函数。
#include<signal.h>
#include<iostream>
#include<unistd.h>
using namespace std;
//修改后的处理动作
void getSignal(int sig)
{
cout << "get signal : " << sig << endl;
}
int main()
{
// 修改处理函数
signal(SIGINT, getSignal);
while(true)
{
sleep(1);
cout << "this is a running process ........" << endl;
}
return 0;
}
信号的产生
信号的产生主要有四种方式,但本质都是操作系统修改PCB中的信号位图。
1.按键产生
ctrl + c : 热键(2号信号)
ctrl + \ : 热键(3号信号)
要注意,按键产生的信号发给的是前台程序。
#include<signal.h>
#include<iostream>
#include<unistd.h>
using namespace std;
//修改后的处理动作
void getSignal(int sig)
{
cout << "get signal : " << sig << endl;
}
int main()
{
sigset_t* old;
signal(SIGINT, getSignal);
signal(SIGQUIT, getSignal);
while(true)
{
sleep(1);
cout << "this is a running process : " << getpid() << "........" << endl;
}
return 0;
}
2.系统调用
我们使用命令行向前台进程发送信号使用的是:
kill -信号编号 进程ID
但本质上是bash创建子进程调用kill系统调用接口实现的。
调用成功返回0,调用失败返回-1.
abort() = raise(SIGABRT) = kill(getpid(), SIGABRT)
#include<signal.h>
#include<iostream>
#include<unistd.h>
#include<stdlib.h>
#include<cassert>
using namespace std;
//修改后的处理动作
void getSignal(int sig)
{
cout << "get signal : " << sig << endl;
}
int main()
{
//修改默认处理函数
signal(SIGABRT, getSignal);
while(true)
{
sleep(1);
cout << "this is a running process : " << getpid() << "........" << endl;
//发送信号
int sigcreate = kill(getpid(), SIGABRT);
assert(0 == sigcreate);
sigcreate = raise(SIGABRT);
assert(0 == sigcreate);
abort();//调用abort函数即使默认处理函数被替换或者忽略,也会退出当前进程
cout << "continue" << endl;
}
return 0;
}
3.硬件异常
除零错误
运算结果分为正、负和溢出三种,当除零时,结果溢出。在CPU中有状态寄存器,状态寄存器中有溢出标志位,当除零时溢出标志位置1,当操作系统发现标志位为一时作出处理,向进程发送8号信号。
int main()
{
//修改默认处理函数
signal(SIGFPE, getSignal);
while(true)
{
sleep(1);
cout << "this is a running process : " << getpid() << "........" << endl;
int a = 100;
a /= 0;
cout << "continue" << endl;
}
return 0;
}
通过上图我们可以发现,进程完全被阻塞在除零处,并不向下运行,这是为什么呢?
由于自定义处理函数调用完成之后并没有终止进程,即使完成调用这个进程上下文中的溢出标志位仍然是1,OS仍然会不断发送信号SIGFPE。
在时间片耗尽,当进程切换时,状态寄存器中的内容属于进程上下文,每一次上下文恢复,这时OS还是会检查进程的状态寄存器并且发送相应的信号。
野指针错误
由于虚拟地址空间与物理内存之间的映射是由页表+MMU共同管理的,当要发生越界访问时,MMU发生异常被OS发现,并且向相应的进程发送SIGSEGV信号(11号)。
int main()
{
// 修改默认处理函数
signal(SIGSEGV, getSignal);
sleep(1);
cout << "this is a running process : " << getpid() << "........" << endl;
int *p = nullptr;
*p = 4;
cout << "continue" << endl;
return 0;
}
4.软件条件产生
匿名管道读端关闭,OS向写进程发送13号信号(SIGPIPE)。
定时器:alarm 定时发送14号信号(SIGALRM)。
int main()
{
// 修改默认处理函数
signal(SIGALRM, getSignal);
while (true)
{
alarm(1);
sleep(1);
cout << "this is a running process : " << getpid() << "........" << endl;
cout << "continue" << endl;
}
return 0;
}
任意一个进程都可以通过alarm系统调用接口设置闹钟,OS要管理闹钟就要——先描述再组织。
并且OS使用优先级队列的方式组织闹钟,alarm(0)取消设置的闹钟。
信号保存
pending表 信号是否处于未决状态(是否收到对应信号)
block 是否阻塞了对应信号
handler 函数指针数组
如果一个信号没产生,不妨碍它被阻塞。普通信号只会统计一次,会丢失,实时信号使用队列管理,不会丢失。
信号保存的实质就是OS将pending表中信号对应的位置置1.
信号处理
进程退出时的核心转储问题(core dumped)
我们观察信号对应的默认处理函数发现分为两种:
Term 正常结束
Core 结束的同时将内存中的有效数据转储到磁盘中
通过ulimit -a(系统设置的资源上限)查看core file size
云服务器默认关闭了核心转储。
可以通过ulimit -c 1024设置打开core file 选项。
编译时带-g,gdb打开后输入core-file + 文件名产生异常分析。
信号捕捉
信号产生时不会被立刻处理,而是在合适的时候(从内核态返回用户态时)处理。
在虚拟地址空间中,我们一直所说的栈堆、常量区等等都是在用户空间中的。在虚拟地址空间中,还存在着内核空间。
每一个进程PCB可以通过页表,让虚拟地址空间跟物理空间建立映射关系,其中,用户空间使用的页表称为用户级页表。同样的,内核空间使用的页表称为内核级页表。
对于用户级页表来说,每一个进程都有自己独立的用户级页表,这样就能让每一个进程都能够通过自己的页表访问内存空间。但是内核级页表是让虚拟地址空间与物理地址空间中存放操作系统数据和代码的建立映射关系的,在计算机启动的时候,操作系统作被加载到了内存中,只有一份,是独一无二的。因此,内核级页表只有一份,每一个进程共享这一份。
因此,当进程要访问OS的接口的时候,只需要在自己的进程空间的用户空间上跳转到内核空间,然后通过内核页表映射到内存中即可,让执行操作之后,返回到原本的空间即可,此时需要把CR3中对应的数字由0改为3。
那么用户能够去访问内核的接口或数据,是因为CPU中的CR3中对应的数字是0.而由用户态转成内核态,从3到0的操作,在调用系统调用的时候自动完成。
捕捉信号
当进程从用户态转到内核态后,并且执行完系统调用,此时并没有马上返回,本着来了都来了,不能就这么回去,于是会去检查block、pending和handler表。
首先检查block位图,从起始位置开始,如果是1,那么往下找,找到为0的时候,就转到pending位图去找,如果是0,那么直接返回block位图中继续找,如果是1,那么就转到handler表中找相应的信号的处理方法。
处理方法有三种:默认动作、忽略动作和自定义动作。
默认动作就是直接终止进程,忽略动作就什么也不干,就返回回去。若是自定义动作,则会转到这个方法中执行代码。
sigset_t(信号集)
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统 实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做 任何解释,比如用printf直接打印sigset_t变量是没有意义的
#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(阻塞信号)
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信 号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
#include<cassert>
using namespace std;
static void show_pending(const sigset_t &pending)
{
for(int signo = 31; signo >= 1; signo--)
{
if(sigismember(&pending, signo))
{
cout << "1";
}
else cout << "0";
}
cout << "\n";
}
void myhandler(int signo)
{
sigset_t blocks, pendings, oblocks;
cout << "已递达信号" << endl;
sigemptyset(&blocks);
sigemptyset(&oblocks);
sleep(1);
sigpending(&pendings);
cout << "pending :";
show_pending(pendings);//打印pending表
sigprocmask(SIG_SETMASK, &blocks, &oblocks);
cout << "block :";
show_pending(oblocks);//打印block表
sleep(5);
}
int main()
{
signal(SIGINT, myhandler);
sigset_t blocks, pendings, oblocks;
// 1.1 初始化
sigemptyset(&blocks);
sigemptyset(&oblocks);
sigemptyset(&pendings);
// 1.2 添加要屏蔽的信号
sigaddset(&blocks, SIGINT);
//sigprocmask(SIG_SETMASK, &blocks, &oblocks);
while(true)
{
cout << "已屏蔽SIGINT。。。" << endl;
sigprocmask(SIG_SETMASK, &blocks, &oblocks);
sleep(1);
cout << "已发送SIGINT信号" <<endl;
kill(getpid(), SIGINT);
cout << "pending :";
sigpending(&pendings);
show_pending(pendings);//打印pending表
sleep(2);
cout << "已解除屏蔽" << endl;
sigprocmask(SIG_SETMASK, &oblocks, &blocks);
sleep(1);
}
return 0;
}
从结果来看,信号被递达时阻塞相应的信号,pending表相应位置置0,block表相应位置置1。当信号完成捕捉时,又会自动解除信号屏蔽。
void myhandler(int signo)
{
sigset_t blocks, pendings, oblocks;
cout << "已递达信号" << endl;
int cnt = 10;
while(cnt--)
{
cout << "pending :";
sigpending(&pendings);
show_pending(pendings);//打印pending表
sigemptyset(&blocks);
sigprocmask(SIG_SETMASK, &blocks, &oblocks);
sleep(1);
}
}
int main()
{
signal(SIGINT, myhandler);
while(true)
{
cout << "已发送SIGINT信号" <<endl;
kill(getpid(), SIGINT);
sleep(2);
}
return 0;
}
在信号处理函数内部也可以强行解除屏蔽(只是实验,不要这样用),在捕捉到信号之后会原地调用信号处理函数,等处理完之后才会回到当前状态继续处理,相当于一个信号处理函数被调用了多次(类似于递归)。
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo 是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传 出该信号原来的处理动作。
如果 在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需 要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。