Linux进程信号
一、信号的概念
信号是进程之间通知的一种方式,属于软中断(进程收到信号并不是立即处理)。
例子:信号灯,红灯和绿灯,红灯禁止通行,绿灯可以通行,但是并不是所有人都会遵守信号灯。
二、信号的种类
使用kill -l 指令可以罗列信号的种类
总共62个信号:
1~31非实时信号,非可靠信号,信号可能会丢失
34~64实时信号,可靠信号,信号不会丢失
三、信号的产生
硬件产生
-
Crtl +C 2号信号 SIGINT即 interrupt中断信号
-
Crtl +Z 20号信号 SIGTSTP
-
Crtl +| 3号信号 SIGQUIT
软件产生
发送信号的函数
kill 函数
可以给指定进程发送信号
raise 函数
哪个进程调用发送给哪个进程,该函数的实现调用了kill函数
扩展:崩溃程序收到的信号
- 解引用空指针
int main()
{
int *lp=NULL;
*lp=10;
return 0;
}
release版本gdb:
debug版本gdb:
注:使用gdb调试时需要使用debug版本
- 内存访问越界
int arr[5]={0};
for(int i=0;i<10000;i++)
{
printf("%d ",arr[i]);
}
内存越界后,操作系统并不会立即报错,但是如果越界访问的内存已经分配给其它进程, 操作系统便会给该进程发送一个11号信号SIGSEGV(segmentation violation,段错误),令其终止
- 除0
int a=10;
int b=0;
int c=a/b;
除0操作后,操作系统发出了8号信号(SIGFPE,算术运算异常)
- double free
char*p =(char*)malloc(1024);
strcpy(p,"free test");
printf("%s\n",p);
free(p);
free(p);//重复释放
接收到6号信号(Abnormal termination,异常终止)
四、信号的处理方式
操作系统对信号的处理方式
-
默认处理方式,SIG_DFL,操作系统当中已经定义信号的处理方式了
eg:
2号信号 终止进程额
11号信号 终止进程,并且产生核心转储文件 -
忽略处理方式
进程收到忽略处理方式的信号后,不进行处理
eg:
僵尸进程产生:子进程先于父进程退出,子进程退出的时候会给父进程发送SIGCHLD信号,父进程接收到这个信号,忽略处理,导致父进程并没有回收子进程的状态信息,子进程变成了僵尸进程 -
自定义处理方式
程序员可以改变信号的处理方式,定义一个函数,当进程收到该信号的时候,调用程序员自己写的函数
五、信号的注册
一个进程收到一个信号,这个过程称之为注册,信号的注册和注销并不是一个过程,是两个独立的过程
注册:操作系统内核向进程注册(发送)信号
注销:进程处理信号
sig位图是什么
-
查看Linux内核源码中task_struct结构体的定义
路径:linux-3.10.0-957.el7/include/linux/sched.h
task_struct结构体中有一个sigpending结构体对象pending,与信号注册有关 -
查看sigpending的定义
路径:linux-3.10.0-957.el7/include/linux/signal.h
sigpending结构体中有两个成员变量,双向链表list,和sigset_t结构体对象 -
查看sigset结构体的定义
路径:linux-3.10.0-957.el7/include/uapi/asm-generic/signal.h
sig是一个数组,但是造作系统当中并没有将其当作数组使用,而是当作位图使用,即只用到了sig这个数组的第一个元素,将这个元素的64个比特位当作位图使用,注册信号时将该信号对应的比特位置为1,表示进程收到了该信号。注:Linux操作系统中long是8个字节,64个比特位,而在Windows中是4个字节 。
总结:进程的task_struct结构体中有一个struct sigpending类对象 pending,struct sigpending 结构体中有一个sigset_t类对象 signal,sigset_t结构体中有一个unsigned long类型的数组signal(使用数组是为了方便扩展,后续有更多的信号),这个数组的第一个元素的64个比特位作为位图,表示62种信号,当进程收到一个信号时,就将该信号对应的比特位置为1。
sigqueue队列
信号注册的时候除了修改信号对应的位图为1,还要添加sigqueue节点到sigqueue队列,sigqueue队列在操作系统内核中的本质是一个双向链表,满足先进先出的特性。
添加sigqueue节点时,实时信号和非实时信号有区别:
- 非实时信号
- 第一次注册,修改sig位图(0–>1),添加sigqueue节点
- 第二次注册相同值的信号,修改sig位图(1–>1),不添加sigqueue节点
- 实时信号
- 第一次注册,修改sig位图(0–>1),添加sigqueue节点
- 第二次注册相同值的信号,修改sig位图(1–>1),添加sigqueue节点
所以实时信号和非实时信号的区别就是第二次注册相同值的信号时,是否会添加sigqueue节点到sigqueue队列。
六、信号的注销
信号的注销与注册要做的事情一样,修改sig位图为0,将sigqueue节点进行出队操作,需要注意的就是,对于可靠信号,对sigqueue节点进行出队操作后,需要判断sigqueue队列中是否还有相同信号的sigqueue节点,如果没有再将sig位图的比特位修改为0,表示该信号已经全部注销。
七、信号的自定义处理方式
signal函数
概念
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signum:要进行自定义处理的信号值
handler:一个函数指针,即自定义的处理方式
这里的handler指向的是一个回调函数,当进程收到对应的信号时,操作系统会调用自定义的handler函数,即被回调的函数sigcallback的参数signum是操作系统传递给sigcallback函数的,操作系统向该进程注册一个信号时,会将该信号的信号值传递给自定义的程序。
测试代码
运行结果:
sigaction函数
概念
#include <signal.h>
struct sigaction {
void (*sa_handler)(int);//函数指针,保存信号的处理方式
void (*sa_sigaction)(int, siginfo_t *, void *);//也是保存信号处理方式的函数指针,与sa_flags配合使用
sigset_t sa_mask;//进程在调用函数指针指向的函数处理信号时,如果收到其它信号,先将收到的信号保存在sa_mask当中,之后再放到sig位图当中
int sa_flags;//当sa_flags值为SA_SIGINFC的时候,信号的处理方式为sa_sigaction
void (*sa_restorer)(void);//保留字段
};
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
signum:信号值
act:要更改为什么处理方式(入参)
oldact:旧的处理方式(出参)
测试代码
运行结果:
实现原理
从内核源码的角度分析
- task_struct结构体中包含一个struct sighand_struct结构体指针sighand,(task_struct结构体位于sched.h头文件中)
- 查看sighand_struct结构体的定义,sighand_struct结构体中包含一个k_sigaction结构体数组action[_NSIG],_NSIG在Linux中一般定义为64,action数组的每一个元素都保存着一个信号的处理方式
- 查看k_sigaction结构体的定义,该结构体中有一个sigaction结构体对象sa
- 查看sigaction结构体的定义
sigaction函数修改的就是sigaction结构体的内容,而signal函数只能修改sigaction结构体中的sa_handler函数指针
总结:进程的task_struct结构体中包含一个sighand_struct结构体指针sighand,这个指针指向的sighand_sturct结构体对象中包含一个k_sigaction结构体数组action[_NISG],而k_sigaction结构体中包含一个sigaction结构体对象sa,sigaction函数就是通过修改进程的sigaction结构体来实现信号的自定义处理的。
八、信号的捕捉流程
Linux中,捕捉信号和自定义信号处理函数是指同一件事情,捕捉信号意味着使用signal()或sigaction()等函数,将进程中的某个信号与一个用户定义的信号处理函数绑定。当进程接收到该信号时,内核将自动调用用户定义的信号处理函数来处理该信号
信号的处理时机
当进程从内核态切换回用户态的时候,会调用do_signal函数,检查当前进程是否有需要处理的信号
常见的进入到内核的方式:
- 调用系统函数
- 内存访问越界,访问空指针
- 调用库函数
不同处理信号方式的区别
对于默认处理方式和忽略处理方式,直接在内核中进行处理
对于自定义处理方式,则需要调用程序员自己定义的函数进行处理,具体步骤如下:
- 从内核态切换回用户态时,会调用do_signal函数,检查是否有要处理的信号
- 如果有自定义了信号的处理方式的信号需要处理,则切换到用户态,执行自定义函数(用户空间)
- 执行完毕后,调用sigreturn()函数回到操作系统内核(内核空间)
- 再次调用do_signal()函数,因为在执行自定义函数期间,进程可能会收到其它信号(内核空间)
- 调用sys_sigreturn函数回到用户空间,继续执行代码
九、信号的阻塞
基本概念
信号阻塞的含义是,当进程的某一个信号被阻塞时,如果进程收到该信号会暂不处理,等到信号不阻塞了之后再处理
信号的注册和阻塞, 信号的阻塞并不会影响信号的注册
函数接口
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数
how:想要sigpromask函数实现的功能
- SIG_BLOCK:设置某个信号为阻塞状态
- SIG_UNBLOCK:设置某个信号为非阻塞状态
- SIG_SETMASK:用第二个参数set替换旧的阻塞位图
set:新设置的阻塞位图
oldset:旧的阻塞位图
实现原理
阻塞位图中,某个位为1时,表示该信号被阻塞,为0表示非阻塞
- 第一个参数为SIG_BLOCK,设置阻塞时,用第二个参数set与oldset按位或,得到新的阻塞位图
- 第一个参数为SIG_UNBLOCK,设置非阻塞时,用第二个参数set取反和oldset按位与
- 第一个参数为SIG_SETMASK,用第二个参数set替代旧的阻塞位图
测试代码
程序
运行结果
这里阻塞了所有的信号,所以想要终止进程,需要向进程发送9号或者19号信号,这两个信号是不能被阻塞的。
阻塞实时与非实时信号
测试代码
向进程发送信号
测试结果
进程接收到的2号信号和40号进程都是5次,但是2号信号只处理了一次,而40号信号处理了五次,进一步验证了非可靠信号和可靠信号。这是因为可靠信号第二次注册同一信号时,会再次添加节点到sigqueue队列,而非可靠信号不会。
阻塞信号与解除阻塞的顺序关系
这里还有一个现象,我们发送信号的时候先发送的2号信号,但是解除阻塞以后先处理的时40号信号,这是因为在这个程序中,使用sigfillset函数将set位图只为了全1,内核会默认按照从小到大的顺序进行处理,即先将2号信号添加到信号掩码(signal mask)中,再添加40号信号,当进程再次调用sigprocmask()函数修改信号掩码时,内核会按照先进后出的顺序恢复信号掩码,这样就保证了解除阻塞的顺序与阻塞的顺序相反,所以看到的现象是先处理40号信号,再处理2号信号。
十一、扩展
自定义信号处理解决僵尸进程
回顾僵尸进程的概念:僵尸状态是一种特殊的状态,当子进程退出,而父进程还在运行,但父进程没有读取到子进程的返回信息,子进程就会进入z状态,成为僵尸进程。
之前解决僵尸进程的办法是,父进程调用wait或者waitpid函数进行进程等待,直到子进程退出,回收子进程的退出状态信息。但是这种解决方式也有缺点,父进程调用wait函数后会进行阻塞等待,waitpid函数虽然不会阻塞等待,但是一般要搭配循环使用,这两种方式都会导致父进程无法再做其他事。
这一问题可以通过自定义信号处理方式来解决,自定义17号信号(SIGCHLD),当子进程退出时,内核会向父进程发送SIGCHLD信号,将wait()函数或者waitpid()函数写在SIGCHLD信号的自定义处理方式中,父进程便会在子进程退出时进行进程等待,避免产生僵尸进程。
测试代码
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<signal.h>
#include<sys/wait.h>
void sigcallback(int signum)
{
//在SIGCHLD信号的自定义处理方式中调用wait
printf("recv signum is %d...\n",signum);
int status;
wait(&status);
}
int main()
{
pid_t pid=fork();
if(pid<0)
{
perror("fork");
return -1;
}
else if(pid==0)
{
//child
//子进程三秒后正常终止
sleep(3);
exit(0);
}
else
{
//father
//父进程中,自定义信号处理,在等待期间可以处理其他业务
signal(SIGCHLD,sigcallback);
while(1)
{
printf("I'm running...\n");
sleep(1);
}
}
return 0;
}
运行结果:
查看进程状态:
volatile关键字
作用:保证内存的可见性。每次CPU要获取数据都是从内从中获取,拒绝编译器的优化方案,即不从寄存器中获取数据。
示例
这个程序中,将2号信号自定义处理,并且在自定义处理函数中将全局变量g_val的值赋为0,所以只要在向该进程发送2号信号,即在键盘键入Ctrl C,循环条件不满足,进程便会终止
运行结果如图:
但是,如果编译的时候选择的优化等级较高,便会产生不同的结果,gcc/g++的编译选项“-O0 -O1 -O2 -O3”,一般优化级别越来越高,处理速度越快。默认
优化等级为O0,即不进行任何优化,下面将优化等级设置为O3,查看程序运行情况:
优化后运行结果:
发送2号信号没有终止进程,因为此时优化等级较高,CPU会直接中寄存器中获取数据,虽然内存中的g_val的值已经被修改为0,但是寄存器中的g_val的值仍然是1,所以循环还在继续。
volatile关键字的作用就是让CPU始终从内存获取数据,只要修改了内存中的g_val的值,就可以让进程结束,如图:
volatile关键字修饰后运行结果: