signal
- 信号引入
- 什么是信号?
- 如何产生信号?
- 通过按键产生信号
- 调用系统函数向进程发信号
- 系统调用函数发送信号的流程:
- 由软件条件产生信号
- 软件发送信号的流程:
- 硬件异常产生信号
- 硬件异常的流程:
- Deliver、Pending、Block概念
- 信号在内核表示示意图
- sigset_t
- 信号集操作函数
- 注意
- 信号捕捉
- 捕捉信号的时机:
- 可重入函数
- volatile
信号引入
我们在linux编写代码时,如果想提前结束一个进程,通常我们会按ctrl+c组合键:
其实这就是想OS传递了一个中断进程的信号,我们平常就有意无意的在使用它!
- 如何理解组合键变成信号呢?‘
OS解释组合键->查找进程列表->前台运行的进程->OS写入对应的信号到进程内部的位图结构中(OS直接修改进程PCB的位图结构)。
什么是信号?
-
信号是进程之间事件异步通知的一种方式,属于软中断。(进程无论怎么运行,我们都能使用信号来通知他们执行动作)。
-
使用kill -l 命令,可查看linux下的信号。(1~31是常用的信号,34~64是实时信号)
-
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,如下图:
-
对于信号,一般有三种处理函数:忽略此信号、执行该信号的默认处理动作、修改信号的默认处理动作(本来在OS内核执行的默认动作,被切换到用户态执行用户自己的函数,这种方式被称为Catch一个信号)。
如何产生信号?
我们先认识一个,可以修改信号处理动作的函数:signal()
第一个参数是信号,可填宏定义可填数字。
第二个参数是回调函数,用于定义用户想要执行的动作。
#include<iostream>
#include <unistd.h>
#include<signal.h>
using namespace std;void catchSignal(int signum)
{cout<<"我收到了一个信号,正在处理:"<<signal<<" Pid:"<<getpid()<<endl;
}int main()
{int i = 0;signal(SIGINT, catchSignal);while(1){sleep(1);cout<<"这是一个死循环"<<++i<<endl;}return 0;
}
该例子我们修改了SIGINT的默认处理动作,所以当我们按Crtl+C的时候,进程并没有中止,而是执行了自己定义的函数。
通过按键产生信号
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump。
- 什么是Core Dump?
当一个进程要异常终止时,可以选择把进程的用户控件内存数据全部保存到磁盘上,文件名通常是core,这就叫Core Dump。然后事后可以检查core文件,查看错误原因,这叫Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认不允许产生core文件,因为core可能包含密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制。
ulimit -c 1024
修改shell进程的Resource Limit,允许core文件最大为1024K
ulimit -a
查看相关信息
只要动作是action的都会产生core文件:
下面演示如何产生core 文件:设置了一段除0的代码
int main()
{int i = 10;while(1){sleep(1);i/=0;}return 0;
}
如何使用core文件呢?使用gdb调试命令:
这样就可以直接根据core文件,定位出错误的地方了。
调用系统函数向进程发信号
我们平常使用的kill命令,其实就是调用的系统kill函数:
这个函数的功能就是给指定的进程发送信号:
raise函数:
自己给自己发信号,成功返回0,错误返回-1;
int main()
{int i = 10;while(1){sleep(1);raise(SIGSEGV); //段错误信号}return 0;
}
abort函数:
void abort(void)
使当前进程接收到信号而终止;
int main()
{int i = 10;while(1){sleep(1);abort(); //什么也不填,相当与exit}return 0;
}
系统调用函数发送信号的流程:
用户调用系统调用接口->执行OS对用的系统调用代码->OS提取参数,或者设置特定数值->OS向目标进程写信号->修改对应进程的信号标记位(PCB里)->进程后续执行对应的处理动作
由软件条件产生信号
该函数可以理解为设置一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号,该信号的默认处理动作是终止当前进程(也可以通过修改默认处理动作,去完成用户的需求)
软件发送信号的流程:
OS先识别到某种软件条件触发或者不满足->OS构建信号,发送给指定的进程。
硬件异常产生信号
CPU除0错误,访问非法内存地址,都是硬件异常。
硬件异常的流程:
除0错误:
1.CPU在计算时,发现状态寄存器的溢出标记位是1->OS系统识别出有溢出问题,立即找到谁在运行这个程序->OS给这个进程发送信号,进程会在合适的时候,进行处理。
2. 出现硬件异常,进程一定会退出吗?不一定!默认是退出,但是我们即使不退出,也做不了什么。
3. 为什么会死循环?如果你把除0的默认动作改了之后,溢出标志位就一直是1(没有人改它),所以会一直执行你改正的动作。
指针越界问题:
4. 指针必须通过地址找到目标位置
5. 而语言层面的地址,是虚拟地址
6. 将虚拟地址转化成物理地址需要(页表+MMU内存管理单元)
7. 如果是野指针,越界->非法地址->MMU转化的时候,OS一定会报错!
Deliver、Pending、Block概念
- 信号递达(Deliver):执行信号的处理动作
- 信号未决(Pending):还没有响应的信号
- 阻塞(Block):阻塞某个信号
- 阻塞和忽略是不同的,阻塞是未处理,忽略是处理动作是忽略。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
信号在内核表示示意图
信号产生会修改PCB(pcb有指向信号数据结构的指针),Block、pending都是位图结构,handler是信号相应的动作
- block表示的位图为是否阻塞该信号、pending表示接收到该信号。
- 信号处理流程:OS->pending->block(如果被阻塞了就不处理呢)否则就进入handler处理。
sigset_t
该类型是系统的位图变量(用来描述上面的位图结构),并且OS提供了对它的操作函数。
信号集操作函数
#include<signal.h>
//set是信号集[]
int sigemptyset(sigset_t *set); //把set都置为0
int sigfillset(sigset_t *set); //把set都置为1
int sigaddset(sigset_t *set, int signo); //把signo 数字的信号 置为1
int sigdelset(sigset_t * set, int signo); //删除signo ,位图置为0
int sigismember(sigset_t *set, int signo); //该信号集有效信号是否有signo,有就返回1,没有返回0,出错返回-1;
前四个函数成功返回0,出错返回-1;
功能:读取或更改进程的信号屏蔽字(阻塞信号集)
int sigprocmask (int how, const sigset_t *set, sigset_t *oset);
返回值:成功返回0,出错返回-1.
如果oset是非空,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都非空,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
- SIG_BLOCK:set包含了希望添加到当前信号屏蔽字的信号,相当于mask = mask|set
- SIG_UNBLOCK:set包含希望解除阻塞的信号,相当于mask = mask&~set
- SIG_SETMASK:设置当前信号屏蔽字为set指向的值, 相当于mask = set
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达
读取当前进程pending位图信息,通过set传出
int sigpending(sigset_t *set);
调用成功返回0,调用失败返回-1
注意
如果我们对所有信号都进行block,是不是就可以写出一个无法杀死的进程了?
不对,比如说9号信号是无法被屏蔽的!
信号捕捉
捕捉信号的时机:
解释:因为信号相关字段在PCB中,所以信号的检测是一定会在内核态进行。主程序遇到异常后,OS要转到内核态处理异常,当处理完准备返回用户态的时候,此时,进行信号的处理,检测信号是否被屏蔽,未屏蔽再中断,回到用户态,执行信号处理函数,然后再返回内核态,接着被中断的位置继续返回用户态,执行函数。
signal.h
功能:读取和修改与指定信号相关联的处理动作(更高级的signal)
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
- signo:指定的信号编号
- 若act非空,则根据act修改该信号的处理动作
- 若oact非空,则将原来的信号处理动作,保留在此。
- 成功返回0,出错返回-1
其中该结构体我们只关心画圈圈的两个参数,其他不用管。
下面的例子屏蔽了2号信号。并获得了2号信号的默认动作。
//makefile
mytext:mytext.ccg++ -o $@ $^ -std=c++11 -fpermissive
.PHONY:clean
clean:rm -f mytext//ytext.cc
#include<iostream>
#include<signal.h>
#include <unistd.h>
using namespace std;void handler(int signum)
{cout<<"处理信号:"<<signum<<endl;
}int main()
{// cout<<"hello world"<<endl;//内核从数据类型,用户栈定义struct sigaction act, oact;act.sa_flags = 0;sigemptyset(&act.sa_mask);act.sa_handler = handler;//设置到当前进程的PCB中sigaction(2, &act,&oact);cout<<"默认处理动作oact:"<<(int)(oact.sa_handler)<<endl;while(1) sleep(1);return 0;}
可重入函数
简单理解说,就是多个进程可同时进入的函数,并且多次运行的结果唯一,此函数就是可重入函数。反之如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表管理堆的。
- 调用了标准IO库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
我们有如下代码逻辑,全局flag,当收到信号时,全局flag变成1,进程结束。
int flag = 0;
void handler(int sig)
{printf("change flag 0 to 1\n");flag = 1;
}int main()
{signal(2, handler);while(!flag);printf("process quiit normal\n");return 0;
}//makefile
mytext:mytext.ccg++ -o $@ $^ -std=c++11
.PHONY:clean
clean:rm -f mytext
但是当我们加上O2优化的时候,此时进程就不退出了!
这是为什么呢?(因为编译器把代码优化了)
优化情况下,键入 CTRL-C,2号信号被捕捉,执行自定义动作,修改 flag=1,但是 while 条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显,while循环检查的flag并不是内存中最新的flag,这就存在了数据二异性的问题。while 检测的flag其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?需要 volatile。
volatile int flag = 0; //volatile 防止编译器优化!
void handler(int sig)
{printf("change flag 0 to 1\n");flag = 1;
}int main()
{signal(2, handler);while(!flag);printf("process quiit normal\n");return 0;
}
加了volatile后,问题就被解决了。