Linux进程信号
信号入门
1、生活角度的信号
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:
1. 执行默认动作(幸福的打开快递,使用商品)
2. 执行自定义动作(快递是零食,你要送给你你的女朋友)
3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏) - 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
2、技术应用角度的信号
先看下面的代码,一眼就看出来是死循环。
1 #include <stdio.h>
2 int main()
3 {
4 while(1){
5 printf("I am a process, I am waiting signal!\n");
6 sleep(1);
7 }
8 }
我们直接Ctrl+C终止这个进程
我们知道可以终止这个进程,但是为什么能终止?
我们输入Ctrl+C,在Shell下启动一个前台进程 ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号(Ctrl+C是2号信号),2号信号发送给目标前台进程。前台进程因为收到2号信号,进而引起进程退出。
上面是系统默认的处理方式,输入Ctrl+C就会终止掉进程,就跟你默默的打开快递一样,没有做任何思考(进程就是你,操作系统就是快递员,信号就是快递)。
那么我们是否可以自定义进行处理?当然是可以的,这里先介绍一个函数:signal函数
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal()将信号符号设置为处理程序,处理程序可以是SIGIGN、SIG-DFL,也可以是程序员定义的函数(“信号处理程序”)的地址。
signum:信号编号
如果配置设置为SIG_IGN,则忽略该信号。
如果配置设置为SIG_DFL,则发生与信号关联的默认动作。
如果配置设置为一个功能,然后首先要么配置重置到SIG_DFL,或sianal被阻塞,然后处理程序是用signum参数调用。如果对处理程序的调用导致信号被阻塞,那么信号在从处理程序返回时被解除阻塞。
handler:对应自处理方法
我们写来用signal函数自定义捕抓这个信号,signal函数对2号信号进行捕捉,如果我们捕抓到了2号信号,证明Ctrl+C确实是收到了2号信号
1 #include <stdio.h>
2 #include <signal.h>
3 void handler(int signo)
4 {
5 printf("this signo is %d\n",signo);
6 }
7 int main()
8 {
9 signal(2, handler);
10 while(1){
11 printf("I am a process, I am waiting signal!\n");
12 sleep(1);
13 }
14 }
当该进程收到2号信号时,我们在输入Ctrl+C就不会终止进程,而是执行我们对应的自定义方法,捕抓到了该信号,并打印出来。由此证明,Ctrl+C确实是收到了2号信号。
这里要退出这个进程可以使用Ctrl+\(Ctrl+\对应3号信号SIGQUIT,代表退出信号) 。
3、注意
- Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
4、信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
5、用kill -l命令可以察看系统定义的信号列表
下面是每个信号编号对应的信号。
其中1-31号信号是普通信号,34-64号是实时信号。每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义#define SIGINT 2(Ctrl+C对应的信号)
我们可以通过命令,找出对应信号编号宏定义的路径,在查看其宏定义。
[dy@VM-12-10-centos rumen]$ sudo find / | grep 'signum.h'
/usr/include/bits/signum.h
下面是信号对应的宏定义
6、信号是如何发送的以及如何记录?
- 信号如何发送:进程收到信号,本质是进程内对应结构体里面的信号位图被修改了,只有OS有这个资格去修改,所以是OS直接去修改了目标进程task_struct中的信号位图。例外信号发送的方式有多种,但是只有OS有资格发送(掌管生杀大权)。
- 信号如何记录:当一个进程接受到信号后,该信号记录在进程对应的task_struct(PCB)中,PCB本质就是一个结构体变量,我们可以用32位的位图来记录一个信号是否产生。
其中比特位的位置代表信号编号,比特位的内容就代表是否收到信号,比如第6个比特位是1就表明收到了6号信号。
7、信号处理常见方式概览
可选的处理动作有以下三种:
- 忽略此信号。(忽略并不是不处理,比如你拿了快递丢到一边,继续玩手机)
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
可以通过命令man 7 signal查看各个信号默认的处理动作
注意:SIGKILL和SIGSTOP命令是不能捕捉的,捕捉了,系统就不能杀掉一个进程和停止一个进程了。
产生信号
1、通过终端按键产生信号
刚开始,我们就已经展示了Ctrl+C能终止一个进程。其实Ctrl+\也可以终止掉一个进程。我们继续拿第一次的代码来展示。
1 #include <stdio.h>
2 int main()
3 {
4 while(1){
5 printf("I am a process, I am waiting signal!\n");
6 sleep(1);
7 }
8 }
终止掉了该进程。
那么Ctrl+C和Ctrl+\ 有什么区别?
1、Ctrl+C对应2号信号(SIGINT),SIGINT的默认处理动作是终止进程。
2、Ctrl+\ 对应3号信号(SIGQUIT),SIGQUIT的默认处理动作是终止进程并且Core Dump。
这个两个命令都是终止进程,但是SIGQUIT多出了一个操作为Core Dump(核心转储)。
Core Dump
那么什么是核心转储(Core Dump)?我们先来看一下这个两个命令对应的信号的默认处理方式。
从上面这张图,我们看到两个都是终止进程,但是对应的行为Action是不一样的,一个对应Term,一个对应Core,我们在来看看这两个的区别。
看完这两个的区别之后,我们在来看核心转储的概念。
- 首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做CoreDump。
- 进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortemDebug(事后调试)。
- 一个进程允许产生多大的core文件取决于进程的Resource Limit(资源限制)(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K:
$ ulimit -c 1024
了解概念之后我们继续往下走,刚刚我们知道一个进程默认是不允许产生core文件的,即核心转储被关闭了,我们可以通过使用ulimit -a
命令查看当前资源限制的设定。
可以看到,core file size的大小为0,因为核心转储被关闭了。我们可以通过命令ulimit -c size
来修改其大小,这就代表着核心转储被打开了。
注意:ulimit命令改变了Shell进程的Resource Limit,test进程的PCB由Shell进程复制而来,所以也具有和Shell进程相同的Resource Limit值,这样就可以产生Core Dump了
我们在运行刚开始的文件,输入Ctrl+\ 就会显示Core Dump,并且会产生一个Core文件(保存进程的用户空间内存数据),core文件后面还带有一个进程编号pid
我们在对应的程序里面获取对应的pid,来验证一下是否是对应的进程编号。
1 #include <stdio.h>
2 #include <unistd.h>
3 int main()
4 {
5 while(1){
6 printf("pid:%d\n",getpid());
7 printf("I am a process, I am waiting signal!\n");
8 sleep(1);
9 }
10 }
我们这里可以看到是一样的,说明就是对应的进程编号
上面我们的程序异常终止了,我们肯定会去想为什么终止,这时候就需要去调试代码。
核心转储作用:核心转储的目的就是为了在调试时,方便问题的定位。
我们写一段代码来展示:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello world\n");
sleep(3);
int a = 1/0;
return 0;
}
除0错误,生成了一个core文件。
我们通过命令gdb
对当前的可执行程序进行调试,然后直接使用core-file core
文件命令加载core文件,即可判断出该程序在终止时收到了8号信号,并且定位到了错误的代码处,显示行数。
这样的操作就叫事后调试,以后我们的程序崩溃了,就可以打开核心转储,gdb
调试即可找到对应报错的行数。
我们之前学进程等待的时候,其中的waitpid
函数和wait
函数:
pid_t waitpid(pid_t pid, int *status, int options);
pid_t wait(int*status);
status是一个输出型参数,用于获取子进程的退出状态。status的不同比特位所代表的信息不同(可以当位图理解,0表示没有,1表示有),具体如下图。
如果进程是正常退出,高八位表示其退出状态,如果进程是异常终止,则低七位表示该进程收到对应的终止信号,第八位是core dump标志,表示该进程是否进行了核心转储。
我们编写一个进程等待的程序,在子进程中进行一个非法访问,比如野指针异常、除0、异常终止等。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
pid_t pid = fork();
if(pid < 0)
{
perror("fork fail");
return 1;
}
else if(pid == 0)
{
printf("i am child\n");
int a = 1/0;
}
else
{
int status = 0;
int ret = wait(&status);
printf("exitCode:%d, core dump:%d, exitSignal:%d\n",(status>>8)&0xFF,(status>>7)&1, status & 0x7F);
}
return 0;
}
通过下面的运行结果,我们可以看到core dump
标志位为1,即第八位为1,说明对应子进程发生了核心转储(为0则表示没有发生核心转储)。
子进程没有发生核心转储,core dump
标志位为0。
所以我们现在知道了core dump
标志位的作用:表示一个进程崩溃的时候,是否进行了core dump。
组合键
通过终端按键产生信号,并不只有Ctrl+C、Ctrl+\,还有其他的组合键。我们这里写一个程序,将1-31号信号进行捕获,并将收到信号后的默认处理动作改为我们自定义的动作。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signal)
{
printf("catch signal:%d\n", signal);
}
int main()
{
int signo;
for (signo = 1; signo <= 31; signo++){
signal(signo, handler);
}
while (1){
sleep(1);
}
return 0;
}
通过运行结果,我们可以看到Ctrl+C、Ctrl+\、Ctrl+Z都被捕获了,然后执行我们的自定义动作,需要注意的是,我们去杀死这个程序时,执行kill pid
,我们可以捕抓到对应的信号,但是kill -9 pid
我们是捕获不到的,刚开始就说过,如果9号信号捕获了,那么一个进程就永远都杀不死了,即便是操作系统本身。
2、 调用系统函数向进程发信号
我们先在后台执行一个死循环程序,然后执行kill -SIGSEGV pid
命令发送SIGSEGV信号给该进程。
指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成 kill -SIGSEGV 19707
或 kill -11 19707,11是信号SIGSEGV的编号。以往遇 到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。
kill命令:调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。
#include <signal.h>
int kill(pid_t pid, int signo);
成功返回0,错误返回-1
模拟kill命令
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
void Usage(char* proc)
{
printf("Usage: %s pid signo\n", proc);
}
int main(int argc, char* argv[])
{
if(argc != 3){
Usage(argv[0]);
return 1;
}
argv是命令行参数指针数组
pid_t pid = atoi(argv[1]);
int signo = atoi(argv[2]);
kill(pid, signo);
return 0;
}
我们开起一个后台进程
[dy@VM-12-10-centos SignalGeneration]$ sleep 1000 &
[1] 30379
然后我们通过自己编写的代码,通过程序名 进程pid 信号编号
杀死该后台进程。
raise函数:raise函数可以给当前进程发送指定的信号(自己给自己发信号)
int raise(int signo);
成功返回0,错误返回-1
通过程序,我们来看一下raise的使用:
1 #include <stdio.h>
2 #include <signal.h>
3 #include <unistd.h>
4 void handler(int signo)
5 {
6 printf("this signo is %d\n",signo);
7 }
8 int main()
9 {
10 signal(2, handler);
11 while(1)
12 {
13 sleep(1);
14 raise(2);
15 }
16 }
每隔一秒给自己发送一个2号信号
当然,9号信号不能自己给自己发,会直接终止掉进程的,至于原因,这里就不在冗余。
abort函数:使当前进程接收到信号而异常终止。
#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值
通过程序,我们来看一下abort的使用:
1 #include <stdio.h>
2 #include <signal.h>
3 #include <stdlib.h>
4 #include <unistd.h>
5 void handler(int signo)
6 {
7 printf("this signo is %d\n",signo);
8 }
9 int main()
10 {
11 signal(6, handler);
12 while(1)
13 {
14 sleep(1);
15 abort();
16 }
17 }
发送一个指定信号(SIGABRT:6)就异常终止。
abort函数的作用是异常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,但使用abort函数终止进程总是成功的。
exit函数的作用是正常终止进程,使用exit函数终止进程可能会失败,
3、由软件条件产生信号
进行通信中的SIGPIPE信号:
- SIGPIPE信号是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
我们来看例外的一种软件条件产生的信号,SIGALRM信号:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm 函数可以设定一个闹钟
也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号
该信号的默认处理动作是终止当前进程
alarm函数的返回值:
若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
如果调用alarm函数前,进程没有设置闹钟,则返回值为0。
比如,我们可以看看在自己的终端上面,一秒钟可以将一个变量累加到多少。
1 #include <stdio.h>
2 #include <unistd.h>
3 int main()
4 {
5 int count = 0;
6 alarm(1);
7 while(1)
8 {
9 count++;
10 printf("count = %d\n",count);
11 }
12 }
在我自己的服务器上,可以加到24000左右。
其实,count累加的值远远大于上面的运行结果。由于我们每进行一次累加就进行了一次打印操作,而与外设之间的IO操作所需的时间要比累加操作的时间更长。再加上自己网络传输等耗时,所以累加的比较少。
我们可以让count一直累加,然后捕获SIGALRM信号,最后在输出count看看是多少。
1 #include <stdio.h>
2 #include <unistd.h>
3 #include <signal.h>
4 #include <stdlib.h>
5 int count = 0;
6 void handler(int sig)
7 {
8 printf("catch a signal:%d\n", sig);
9 printf("count = %d\n",count);
10 exit(-1);
11 }
12 int main()
13 {
14 signal(SIGALRM, handler);
15 alarm(1);
16 while(1)
17 {
18 count++;
19 }
20 return 0;
21 }
由此证明:与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的。
4、硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
模拟一下野指针异常
1 #include <stdio.h>
2 #include <unistd.h>
3 #include <signal.h>
4 #include <stdlib.h>
5 int main()
6 {
7 int *p;
8 *p = 100;
9 return 0;
10 }
通过运行结果我们可以得知,程序异常了,操作系统是如何识别到的?
首先,学进程的时候我们就知道,访问一个变量,需要经过页表的映射,虚拟地址转换成物理地址,最终找到对应的数据位置。
总结:
1、其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,但现在MMU已经集成到CPU当中了。
2、当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。
3、而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送SIGSEGV信号。
所以C/C++程序崩溃的原因是收到了除0、野指针、越界等信号,其错误最终一定会在硬件层面上有所表现,进而被OS识别到。
阻塞信号
1、信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
2、在内核中的表示
信号在内核中的表示示意图
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理? POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。我们讨论的是普通信号。
这三张表的解释:
- 在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
- 在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
- handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
- block、pending和handler这三张表的每一个位置是一一对应的。
- 00表示未发送,11表示发送了被阻塞,10表示一发送就被阻塞。
3、sigset_t
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。sigset_t类型如下:
typedef struct {
unsigned long sig[_NSIG_WORDS];
} sigset_t
这个类型可以表示每个信号的“有效”或“无效”状态
- 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞
- 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
特别说明:阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
4、信号集操作函数
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清零,表示该信号集不包含 任何有效信号。成功返回0,失败返回1。
- 函数sigfillset初始化set:所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。成功返回0,失败返回1。
- 函数sigaddset:在set所指向的信号集中添加某种有效信号。成功返回0,失败返回1。
- 函数sigdelset:在set所指向的信号集中删除某种有效信号。成功返回0,失败返回1。
- sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1
注意:在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
sigprocmask
用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
1、如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
2、如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
3、如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:
4、如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达
sigpending
#include <signal.h>
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
下面用刚学的几个函数做个实验。步骤如下:
1.先把2号信号屏蔽
2. kill
||键盘发送2号信号,可以预见,2号信号不会被递达
3. 2号将会一直被阻塞,一定一直在pending中
4. 使用sigpending获取当前进程的pending 信号集
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t *pending)
{
int i = 1;
for(; i <= 31; i++){
if(sigismember(pending, i)){
printf("1 ");
}
else{
printf("0 ");
}
}
printf("\n");
}
int main()
{
sigset_t set, oset; //创建信号集 相当于int a
sigemptyset(&set);//初始化 相当于a = 0
sigemptyset(&oset);
sigaddset(&set, 2); //SIGINT,添加2号信号到set中,对应Ctrl+C
sigprocmask(SIG_SETMASK, &set, &oset);//2号信号被阻塞
sigset_t pending;
while(1){
sigemptyset(&pending);
sigpending(&pending);//读取当前进程的未决信号集,通过set参数传出
printPending(&pending);//判断一个信号集中是否含有该信号
sleep(1);
}
return 0;
}
在程序刚刚运行时,因为没有收到任何信号,所以信号集中全部为0,当我们用kill
命令向该进程发送2号信号时,此时2号信号被阻塞,一直处于未决状态,所以kill
后一直显示为1。
为了方便观察,一个信号被阻塞后又恢复,我们这里可以设置一个自定义处理方法,对2号信号进行捕获,这样我们就能观察到变化。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t *pending)
{
int i = 1;
for(; i <= 31; i++){
if(sigismember(pending, i)){
printf("1 ");
}
else{
printf("0 ");
}
}
printf("\n");
}
void handler(int signo)
{
printf("hanndler signo: %d\n", signo);
}
int main()
{
signal(2, handler);//捕抓,自定义处理
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2); //SIGINT
sigprocmask(SIG_SETMASK, &set, &oset);//2号信号被阻塞
sigset_t pending;
int count = 0;
while(1){
sigemptyset(&pending);
sigpending(&pending);
printPending(&pending);
sleep(1);
count++;
if(count == 10){
sigprocmask(SIG_SETMASK, &oset, NULL); //恢复2号信号
printf("恢复信号屏蔽字\n");
}
}
return 0;
}
程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会 使SIGINT信号处于未决状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞。
捕捉信号
1、内核空间与用户空间
每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:
- 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
- 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。
内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。
进程切换:
- 在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
- 执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。
注意:所有进程无论如何切换,都能看到操作系统,但是不一定都能访问。
2、内核态与用户态
内核态:通常用来执行OS代码,是一种权限非常高的状态
用户态:是一种用来执行普通用户代码的状态,是一种受监管的普通状态
所以进程收到信号之后,不是立即处理信号的,而是在合适的时候,这个时候就是从内核态切换回用户态的时候,进行信号处理。
用户态切换到内核态:
- 系统调用
- 时间片到了导致进程切换
- 异常、中断、陷阱
内核态切回用户态:
- 系统调用执行完毕
- 进程切换完毕
- 异常终端陷阱处理完毕
3、内核如何实现信号的捕捉
默认处理或者忽略
- 我们在执行程序的时候,有可能会进行系统调用而陷入内核,内核处理完毕后就要返回用户,此时需要检查pending的状态(即未决信号集)。如果此时发现pengding中有未决信号,并且没有阻塞,则需要去处理该信号,如果该信号是默认或者忽略,就执行默认的处理动作,执行完毕后,如果没有其他信号递达,则返回用户态。
自定义处理
- 但是如果我们需要对该信号进行捕抓,就要执行我们对应的自处理动作。即用户自定义函数,在信号递达时就调用这个函数,就称为捕捉信号。比如此时用户程序定义了自处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。内核决定返回用户态后,不是恢复main函数的上下文执行,而是执行sighandler函数。sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态,内核再次检查是否有新的信号递达,如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行。
上面的图可以当初∞来记。
存在的问题:
1、识别到信号可以被捕捉(自定义),当前的身份是内核态,可以直接访问用户态的代码吗? ?
理论上是可以的,但是绝对不能这样设计!
原因:用户执行的动作有可能是非法操作,内核去处理这个信号的话,出现问题的话OS就管不了,比如清空数据库,随便更改其代码和数据。
也就是说不能让操作系统直接去执行用户的代码,因为操作系统无法保证用户的代码是合法代码,即操作系统不信任任何用户。
4、sigaction
sigaction也可以捕捉信号。
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。
- signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。
- 若oact指针非空,则通过oact传出该信号原来的处理动作。
- act和oact指向sigaction结构体:
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_handlers
- 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号
- 赋值为常数SIG_DFL表示执行系统默认动作
- 赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数
该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags字段包含一些选项,本章的代码都把sa_flags设为0。
接下来,我们使用sigaction函数对2号信号进行捕捉,我们这里捕捉一次,然后修改参数为NULL,就执行其默认的处理动作。
1 #include <stdio.h>
2 #include <string.h>
3 #include <unistd.h>
4 #include <signal.h>
5 struct sigaction act, oact;
6
7 void handler(int signo)
8 {
9 printf("get a signal: %d\n", signo);
10 sigaction(SIGINT, &oact, NULL);//执行默认处理动作
11 }
12
13 int main()
14 {
15 memset(&act, 0, sizeof(act));
16 memset(&oact, 0, sizeof(oact));
17
18 act.sa_handler = handler;自处理函数
19 act.sa_flags = 0;没有其他选项
20 sigemptyset(&act.sa_mask);
21
22 sigaction(SIGINT, &act, &oact);
23 while(1){
24 printf("I am a process!\n");
25 sleep(1);
26 }
27 return 0;
28 }
可重入函数
1、main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数
2、sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步
3、结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了(node2这个结点就再也找不到了,造成内存泄漏)
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构
volatile
我们先来看一段代码:
1 #include <stdio.h>
2 #include <signal.h>
3 int flag = 0;
W> 4 void handler(int sig)
5 {
6 printf("chage flag 0 to 1\n");
7 flag = 1;
8 }
9 int main()
10 {
11 signal(2, handler);
12 while(!flag);
13 printf("process quit normal\n");
14 return 0;
15 }
上面的运行结果在我们的预料之类,按住Ctrl+C发送2号信号,然后执行自定义处理动作,flag置1,结束进程。
通过man gcc命令,可查询到对应的优化参数
我们编译程序的时候,进行优化:
执行结果
[dy@VM-12-10-centos rumen]$
^Cchage flag 0 to 1
^Cchage flag 0 to 1
^Cchage flag 0 to 1
优化情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?
很明显, while 循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。 while检测的flag其实已经因为优化,被放在了CPU寄存器当中。这时就需要用
volatile
解决。
1 #include <stdio.h>
2 #include <signal.h>
3 volatile int flag = 0;
W> 4 void handler(int sig)
5 {
6 printf("chage flag 0 to 1\n");
7 flag = 1;
8 }
9 int main()
10 {
11 signal(2, handler);
12 while(!flag);
13 printf("process quit normal\n");
14 return 0;
15 }
此时,就不会存在二异性的问题。
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
SIGCHLD信号 - 选学了解
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。
父进程自定义SIGCHLD信号的处理函数
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <signal.h>
W> 4 void handler(int sig)
5 {
6 pid_t id;
E> 7 while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
E> 8 printf("father's pid: %d ,wait child success: %d\n", getppid(), id);
9 }
E> 10 printf("child is quit! %d\n", getpid());
11 exit(1);
12 }
13 int main()
14 {
15 signal(SIGCHLD, handler);
16 pid_t cid;
E> 17 if((cid = fork()) == 0){//child
E> 18 printf("child : %d\n", getpid());
E> 19 sleep(3);
20 exit(1);
21 }
22 while(1){
23 printf("father proc is doing some thing!\n");
E> 24 sleep(1);
25 }
26 return 0;
27 }
拿到对应的17号(SIGCHLD)
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。请编写程序验证这样做不会产生僵尸进程。
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <signal.h>
4 void handler(int sig)
5 {
6 pid_t id;
E> 7 while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
E> 8 printf("father's pid: %d ,wait child success: %d\n", getppid(), id);
9 }
E> 10 printf("child is quit! %d,sig is %d\n", getpid(), sig);
11 exit(1);
12 }
13 int main()
14 {
15 //signal(SIGCHLD, handler);
16 signal(SIGCHLD, SIG_IGN);//改为SIG_IGNji
17 pid_t cid;
E> 18 if((cid = fork()) == 0){//child
E> 19 printf("child : %d\n", getpid());
20 exit(1);
21 }
22 while(1){
23 printf("father proc is doing some thing!\n");
E> 24 sleep(1);
25 }
26 return 0;
27 }