Linux信号
信号的概念
软中断信号(signal,又简称为信号)用来通知进程发生了异步事件。在软件层次上是对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是进程间通信机制中唯一的异步通信机制,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。进程之间可以互相通过系统调用kill发送软中断信号。内核也可以因为内部事件而给进程发送信号,通知进程发生了某个事件。信号机制除了基本通知功能外,还可以传递附加信息。
信号的分类
使用命令kill -l可以查看所有的信号。
信号一共有62种,其中131号信号是普通信号**,**3464号信号是实时信号,普通信号和实时信号各自都有31个,每个信号都有一个编号和一个宏定义名称:
信号的产生
通过键盘按键产生
当我们遇到程序死循环,或者程序执行到一半不想执行的情况,通常会按ctrl +c 来终止这个进程,或者用ctrl+\ 也可以终止进程。
写一个死循环,运行程序
ctrl+c 的本质其实是向进程发送2号信号SIGINT,ctrl+\ 是向进程发送3号信号SIGQUIT。
查看手册,发现2号信号和3号信号的默认处理方式有些不一样
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump
Core Dump核心转储
首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: ulimit -c 1024
- 使用
ulimit -a 命令查看当前资源限制的设定
第一行显示core文件的大小为0,即表示核心转储是被关闭的。
- 使用
ulimit -c size 命令来设置core文件的大小
- 再次使用
ctrl+\ 终止这个进程,发现后面显示core dump
- 当前目录下会多出一个文件
core.pid ,.后面就是被终止进程的pid
写一个野指针访问
#include<stdio.h>
int main(){
int *p = NULL;
*p = 666;
return 0;
}
- 运行程序,就会出现段错误,程序崩溃,这时就会在目录下出现
core 文件
- 接下来使用gdb调试这个程序,然后使用
core-file core文件 命令加载core文件,即可判断出该程序在终止时收到了11号信号,并且定位到了产生该错误的具体代码。
通过系统函数向进程发信号
向指定进程发送指定信号,
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数:
- pid:要发送信号的进程pid
- sig:要发送的信号
返回值:
成功返回0
失败返回-1
使用命令行参数和kill接口实现一个进程,用来杀死指定进程。
#include<stdio.h>
#include<sys/types.h>
#include<signal.h>
#include<stdlib.h>
int main(int argc, char* argv[]) {
if (argc == 3) {
kill(atoi(argv[1]), atoi(argv[2]));
}
return 0;
}
向调用进程发送指定信号
#include <signal.h>
int raise(int sig);
参数:
sig:指定的信号
返回值:
成功返回0
失败返回-1
(库函数)给当前进程发送SIGABRT信号,使得当前进程异常终止
#include <stdlib.h>
void abort(void);
没有参数,没有返回值,直接调用即可
通过软件条件产生信号
在学习管道的时候,我们知道关闭所有读端的时候,再往管道内写入数据,进程就会收到SIGPIPE 信号被终止
调用alarm 接口可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
参数:
返回值:
- 若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
- 如果调用alarm函数前,进程没有设置闹钟,则返回值为0。
#include<unistd.h>
int main(){
alarm(5);
sleep(100);
return 0;
}
由硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,进程需要使用的地址都是通过页表映射到物理内存的,如果发生非法访问内存,有可能页表种就没有这个内存的映射信息,或者说没有对这块内存的访问权限,MMU会识别到页表访问异常,内核将这个异常解释为SIGSEGV信号发送给进程。
#include<stdio.h>
int main(){
int *p = NULL;
*p = 666;
return 0;
}
#include<stdio.h>
int main(){
int a = 1/0;
return 0;
}
由此可以确认,我们在C/C++当中除零,内存越界等异常,在系统层面上,是被当成信号处理的。
信号的保存
在了解信号的保存方式之前,我们先了解一下信号的专业词汇
- 实际执行信号的处理动作称为信号
递达 (Delivery) - 信号从产生到递达之间的状态,称为信号
未决 (Pending)。 - 进程可以选择
阻塞 (Block )某个信号。 - 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而
忽略 是在递达之后可选的一种处理动作。
信号保存原理
信号是通知进程发生了异步事件,进程收到信号后,并不是立即处理的,而是先保存起来(未决),并且判断这个信号是不是被阻塞(屏蔽),等到进程从内核态切换为用户态的时候,才会处理信号(递达)。
那么信号在被递达之前,是怎么被保存的呢?信号的屏蔽又是怎么实现的?
信号产生后,进程会收到信号,在进程PCB中,有三个表分别用来记录信号的相关信息,其中pending表和block表是用位图来实现的,位图的每一位都代表一个信号的种类,而每一位的值代表状态。
比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞
handler其实是一个函数指针数组,下表就是代表几号信号,数组的内容也就是函数指针,指向的是该信号递达时的处理动作,(默认/忽略/自定义)
- 上图的1号信号,pending位图数据为
0 ,就是当前进程没有收到1号信号,block为0 ,进程收到1号信号不会被阻塞(屏蔽),handler为SIG_DFL ,表示收到1号信号后的处理方式为默认信号处理程序。 - 上图的2号信号,pending位图数据为
1 ,表示当前进程收到2号信号,信号处于未决状态,等待递达,block为1 ,2号信号被屏蔽,在解除2号信号的屏蔽 之前,未决的2号信号不会被递达 ,handler为SIG_IGN ,收到2号信号的处理方式为忽略信号的处理程序 - 上图的3号信号,pending位图数据为
1 ,表示当前进程收到3号信号,信号处于未决状态,等待递达,block为0 ,3号信号不会被屏蔽,在进程内核态返回用户态时,3号信号会被递达,递达的方式是用户自定义处理
源码具体结构:
上图的pending表和block表,都用一个相同的数据类型sigset_t 来实现位图存储信息
sigset_t(信号集)
从上图来看,pending表和block表的位图结构,就是由这个sigset_t类型来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
信号集操作函数
由于每个平台的sigset_t信号集实现位图的方法不一定一样,所以我们不推荐对这个信号集直接进行修改,而是通过系统提供给我们的接口来修改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);
功能:
初始化set所指向的信号集,使其中所有信号的对应bit清零 ,表示该信号集不包含任何有效信号。
参数:
sigset_t类型的变量,传入要操作的信号集的指针
返回值:
成功返回0,出错返回-1。
功能:
初始化 set所指向的信号集,使其中所有信号 的对应bit位清零 。
参数:
sigset_t类型的变量,传入要操作的信号集的指针
返回值:
成功返回0,出错返回-1。
功能:
初始化 set所指向的信号集,使其中所有信号 的对应bit位全部设置为1
参数:
sigset_t类型的变量,传入要操作的信号集的指针
返回值:
成功返回0,出错返回-1。
功能:
设置 set所指向的信号集,使其中所指定的信号 的bit位设置为1
参数:
- 第一个参数:sigset_t类型的变量,传入要操作的信号集的指针
- 第二个参数:要设置的信号编号
返回值:
成功返回0,出错返回-1。
功能:
设置 set所指向的信号集,使其中所指定的信号 的bit位设置为0
参数:
- 第一个参数:sigset_t类型的变量,传入要操作的信号集的指针
- 第二个参数:要设置的信号编号
返回值:
成功返回0,出错返回-1。
功能:
判断 set所指向的信号集中是否包含指定的信号
参数:
- 第一个参数:sigset_t类型的变量,传入要操作的信号集的指针
- 第二个参数:要设置的信号编号
返回值:
若包含则返回1 ,不包含则返回0 ,调用失败返回-1 。
注意:
在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
修改/获取进程信号的信号屏蔽字(sigprocmask接口)
上面的原理中说的block表 ,也叫做信号屏蔽字 或者阻塞信号集
sigset_t以及操作函数都是对信号集这个变量进行操作,我们要真正修改或者获取进程信号阻塞的位图,就要调用sigprocmask接口。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t* oldset);
参数:
- 第一个参数how,表示要对信号屏蔽字进行的操作类型
- 第二个参数set,就是我们要设置的信号集的指针
- 第三个参数oldset,是一个输出型参数,可以获取到当前进程的信号屏蔽字,保存到oldset中,用作备份
参数说明:
- 如果
set是非空指针 ,则更改进程的信号屏蔽字,参数how指示如何更改。 - 如果
oldset是非空指针 ,则读取进程当前的信号屏蔽字通过oldset参数传出。 - 如果
oldset和set都是非空指针 ,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。
how参数 的可选值:
选项 | 功能 |
---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask | SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask | SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
返回值:
成功返回0,出错返回-1。
获取进程信号pending表(sigpending接口)
pending表中存放着当前进程未决信号集 ,只能获取数据,不能修改。
#include <signal.h>
int sigpending(sigset_t *set);
参数:
sigset_t类型的变量,传入要操作的信号集的指针
返回值:
成功返回0,出错返回-1。
实现一个小程序,打印进程收到信号的pending表位图信息
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void show_pending(sigset_t* pending) {
int sig = 1;
for (; sig <= 31; ++sig) {
if (sigismember(pending, sig)) {
printf("1");
}
else {
printf("0");
}
}
printf("\n");
}
void sighandler(int sig) {
printf("catch a sig:%d\n", sig);
}
int main() {
sigset_t pending;
sigset_t block, oldblock;
sigemptyset(&block);
sigemptyset(&oldblock);
sigaddset(&block, 2);
sigprocmask(SIG_SETMASK, &block, &oldblock);
signal(2, sighandler);
int count = 0;
while (1) {
sigemptyset(&pending);
sigpending(&pending);
show_pending(&pending);
sleep(1);
count++;
if (count == 10) {
sigprocmask(SIG_SETMASK, &oldblock, NULL);
printf("恢复原来阻塞信号集\n");
}
}
return 0;
}
信号的处理
信号的处理动作也叫信号的递达
信号的递达方式一般有三种,默认,忽略,捕捉
而信号的递达并不是收到信号就立即递达 ,而是先保存,等到进程由内核态转为用户态的时候,才递达信号。
默认递达
信号的默认递达 方式使用man 7 signal 可以查看手册
大多数信号的默认递达方式是结束进程
忽略递达
忽略递达 的意思是收到了该信号,对这个信号的处理方式为忽略 ,也就是不理这个信号,是信号递达的一种方式
而屏蔽信号 则是把这个信号的屏蔽字修改为1,信号处于未决状态 ,信号没有递达 。
自定义捕捉
信号的另一种递达方式 叫做捕捉 ,就是用户自己设置了收到信号后的处理方式 ,如果信号不被阻塞 的话,收到信号时,就会执行用户自定义的代码
signal接口
前面已经使用过signal接口,就是自定义信号的处理方式,也叫信号的捕捉。
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数:
- 第一个参数signum,表示需要自定义的信号编号
- 第二个参数handler,表示自己自定义函数的地址
返回值
该函数返回一个指向信号处理程序的指针。出错则返回SIG_ERR(-1)。
使用:
#include<signal.h>
#include<stdio.h>
#include<unistd.h>
void sighandler(int sig) {
printf("catch a signal: %d\n", sig);
}
int main() {
while (1) {
signal(2, sighandler);
}
return 0;
}
按下ctrl+c 向进程发送2号信号,信号被捕捉,输出如上图
sigaction接口
sigaction和signal类似,都是自定义捕捉指定信号。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
参数:
- 第一个参数signum,表示需要自定义的信号编号
- 第二个参数cat,若act指针非空,则根据act修改该信号的处理动作。
- 第三个参数oldact,若oldact指针非空,则通过oldact传出该信号原来的处理动作
第二个参数和第三个参数是一个结构体指针变量,这个结构体指针如下:
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t*, void*);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
要自定义信号处理,我们需要对第一个成员 sa_handler 进行设置
- 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
- 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
- 将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
将第三个成员sa_mask 信号集初始化为空
将第四个成员sa_flags 设置为0
返回值:
成功返回0,出错返回-1。
使用:
#include<signal.h>
#include<stdio.h>
#include<unistd.h>
void sighandler(int sig) {
printf("catch a signal: %d\n", sig);
}
int main() {
while (1) {
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = sighandler;
sigaction(2, &act, &oact);
}
return 0;
}
信号的捕捉流程(内核实现)
内核空间和用户空间
每个进程都有自己的地址空间,进程地址空间分为用户空间和内核空间
进程的代码和数据都存放在用户空间,通过用户级页表映射到物理内存中 操作系统的系统调用等的代码和数据存放在内核空间,通过内核级页表映射到物理内存中
用户空间是每个进程私有的,数据和代码都独有一份,内核空间的代码和数据,操作系统只有一份,所有进程都使用这一份。
内核态和用户态
- 当进程在执行操作系统的代码(系统调用等)时,进程处于内核态,处于内核态时,进程的权限大
- 当进程在执行自己的代码时,处于用户态,用户态的权限很小
在几种情况下,进程会从用户态切换到内核态 :
在几种情况下,进程会从内核态切换到用户态 :
从用户态切换到内核态叫做陷入内核 ,一般来说,内核态只会执行系统调用代码,而用户态只会执行用户代码,理论上来说内核态的权限足够大,可以执行用户态的代码,但是为了内核安全,不会进行这样的操作。
内核实现信号捕捉
进程收到操作系统发送的信号时,不会立即处理 ,而是等到由内核态切换为用户态 时才会处理。
在执行代码过程中,可能会遇到一些问题,异常或者系统调用 ,进程就会陷入内核 ,在处理完异常或者执行完系统调用时,会先检查PCB中的pending位图
如果发现有未决的信号,就会查看block位图,看这个信号是否被阻塞
如果发现未决信号,并且信号没有被阻塞,就会让信号递达
- 如果这时信号的
递达方式是默认 ,那么信号递达大概率会直接结束进程 - 如果这时信号的
递达方式是忽略 ,那么信号递达之后会清除pending位图的未决状态 ,然会返回用户态 ,从主控流程切换到内核态的地方继续执行
- 如果这时信号的
递达方式是自定义捕捉 ,那么信号在执行递达时会返回用户态执行处理函数 ,执行完通过特殊的系统调用sigreturn再次陷入内核 ,清除pending位图,完成信号递达 ,再次检查pending位图 ,没有新的信号要递达,直接返回用户态,继续执行主控流程代码 。
sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
用户态和内核态的几次切换过程,可以用一个简易图来记
与直线的一次相交就代表一次状态切换
可重入函数和不可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱 ,像这样的函数称为 不可重入函数 ,反之,如果一个函数只访问自己的局部变量或参数 ,则称为可重入(Reentrant) 函数 。
如果一个函数符合以下条件之一则是不可重入 的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile关键字
对于一个普通变量,为提高存取速率,编译器会先将变量的值存储在一个寄存器中,以后再取变量值时,就存寄存器中取出。
但是用voliate修饰的变量,就说明这个变量会发生意向不到的改变。也就是说,优化器每次在读取该值时,不会假设这个值了,每次都会小心的在读取这个变量的值,而不是在寄存器中取保留的备份。
举一个例子
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig){
printf("chage flag 0 to 1\n");
flag = 1;
}
int main(){
signal(2, handler);
while (!flag);
printf("process quit normal\n");
return 0;
}
进程运行时,一直死循环,当按下ctrl+c 向进程发送2号信号时,信号被捕捉,就会把flog设为1,死循环就会结束
如果我们把编译器的优化等级设置为O2 编译执行,再看看结果
按下ctrl+c 进程没有退出,说明flag被编译器优化了,存到了寄存器中,按下ctrl+c ,flag只会修改内存中flag的值,寄存器中的flag还是0,所以进程一直继续
给全局变量加上关键字volatile 再次编译执行
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
|