【Linux】信号处理机制

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_41035588/article/details/83787122

一、信号引入

信号在最早的Unix系统中即被引入,用于用户态进程间通信,内核也可用信号通知进程系统所发生的时间。在现实生活中,我们每天都在接触信号,下课铃声、红绿灯、闹钟等都是信号。这些信号都有一个共同的特性,那就是简单、不能携带大量信息,满足一点条件时才产生

信号是软件中断,很多重要的程序都需要处理信号,信号提供了一种异步事件的方法,所以是系统中必不可少的东西!

Unix早期版本就提供了信号机制,但不可靠,信号可能丢失。Berkeley 和 AT&T都对信号模型做了更改,增加了可靠信号机制。但彼此不兼容。POSIX.1对可靠信号例程进行了标准化。

信号的机制A给B发送信号,B收到信号之前执行自己的代码,收到信号后,不管执行到程序的什么位置,都要暂停运行,去处理信号,处理完毕再继续执行。与硬件中断【硬件中断是一种在轮询循环,等待外部事件方面避免浪费处理器的宝贵时间的方式】类似——异步模式。但信号是软件层面上实现的中断,早期常被称为“软中断”。

信号的特质:由于信号是通过软件方法实现,其实现手段导致信号有很强的延时性。但对于用户来说,这个延迟时间非常短,不易察觉。

1. 信号的本质

操作系统给进程发送信号,本质上是给进程的PCB中写入数据,修改相应的PCB字段,进程在合适的时间去处理所接受的信号。我们模拟一下这样的场景:

1)用户输入一个命令,在shell下启动一个前台进程。
2)用户按下Ctrl-C,通过键盘输入产生了一个硬件中断。
3)如果CPU当前正在运行此进程的代码,则该进程的用户空间代码暂停执行,CPU从用户态切换到内核态处理中断。
4)终端驱动程序将Ctrl-C解释为一个SIGINT信号,记在该进程的PCB中。
5)当某个时刻从内核返回该进程的用户空间代码继续执行之前,首先处理PCB中记录的信号。SIGINT信号默认处理动作为终止信号,所以直接终止进程而不再返回到它的用户空间代码。

注意
ctrl+c产生的信号只能发给前台进程,运行时在后边加上&表示放到后台运行
shell可以同时运行一个前台进程任意多个后台进程
前台进程在任意时刻都可以接受到ctrl+c产生的信号,也就是说进程的用户代码执行到任何地方都可能收到SIGINT信号,所以信号对于进程的控制流程是异步的
1.处理信号的前提是要认识信号
2.信号一旦产生,可能不会立即处理它,等待合适的时间才去处理
3.信号事件的产生对进程而言是异步的
4.信号如果无法对它进行处理,就先将它记录下来
5.处理信号的三种方式执行确认/默认动作、忽略信号、自定义的动作(捕捉)


Ctrl-C所产生的信号只能发送给前台进程,如果想让其在后台运行,需要在命令后面加上&。这样shell不必等待进程结束就可以接受新的命令,启动新的进程。
在这里插入图片描述
上图中,R为后台进程,S+/R+为前台进程。Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接受到诸如Ctrl-C这样的信号,前台进程在运行过程中用户随时按下Ctrl-C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都可能受到SIGINT信号而被终止,因此信号相对于进程的控制流来说是异步的。。

2. 普通信号与实时信号

我们使用kill -l命令可以查看系统定义的信号列表,每个编号都有一个宏与之对应,可以在/usr/include/asm/signal.h中查看,下图中1-31为普通信号【不可靠信号,不支持排队,容易丢失】,34-64为实时信号。【可靠信号,支持排队,不会丢失】
在这里插入图片描述
那么使用上述信号的目的是什么呢?大致可总结两点。

1)让进程知道已经发生了一个特定的事件。
2)强迫进程执行它自己代码中的信号处理程序。

上述两个目的不是互斥的,因为进程经常通过执行一个特定的例程来对某一个事件作出反应

实时信号(real-time signal): 编号为34-64,它们与常规信号有很大的不同,因为它们必须排序以便发送多个信号能被接收到。但是同种信号的常规信号并不排序,尽管Linux内核并使用实时信号,它还是通过几个特定的系统调用完全实现了POSIX标准。

3. 信号的存储

内核给一个进程发送软中断信号的方法,是在进程所在的进程表项的信号域设置对应于该信号的位,而存储这32位信号的空间恰好需要4个字节,因此采用位图存储是最好不过的。bit位的位置表示对于信号的编号,用0来表示未接受到信号,1表示接受到信号

4. 产生信号的主要条件

1)用户在终端按下某些键时,终端驱动会发送信号给前台进程,例如Ctrl-C产生的SIGINT信号、Ctrl-\产生的SIGQUIT信号、Ctrl-Z产生的SIGTSTP信号

当我们在shell下启动一个前台进程,这个时候当用户在键盘上按下ctrl+c,这个时候键盘输入产生一个硬件中断,如果当前cpu正在执行这个进程的代码,则该进程的用户空间代码暂停执行,cpu从用户态切入内核态处理硬件中断,终端驱动程序将ctrl+c解释成一个SIGINT信号,将这个信号记录在这个前台进程的pcb中,当某个时候准备从内核返回到该用户空间代码的时候,首先检查pcb中的记录,发现有信号SIGIN未处理,而且发现这个信号的处理动作是默认动作,因为其默认动作是终止进程,那么将直接终止进程而不再返回它的用户空间去执行刚才未执行完的代码。
注意:按键产生方式产生的信号只能发给前台进程。因为前台程序在执行的过程中用户随时可以按下组合件所以这体现了信号具有异步性。


还要说明一个问题那就是Core Dump有的信号的默认处理动作是结束进程,但是有的信号在结束进程的同时并且Core Dump比如SIGOUIT,下面解释一下Core Dump:当一个进程要异常终止的时候,可以选择把进程的用户空间内的数据全部保存在磁盘上,这个保存数据的文件名是core,这个动作就是Core Dump。进程异常终止的原因一般来说是有BUG,所以我们可以通过这个文件来找出我们的错误出在了哪里。。。但是系统默认是不支持core文件产生的,因为其中会保存关系用户密码等敏感信息,不安全,或者程序经常挂掉回导致磁盘被写满,从而造成系统崩溃,如果我们想使用的话需要用ulimit来进行设置。

$ ulimit -a //查看用户资源限制
$ ulimit -c 1024 //修改core文件允许产生最大1024k`

需要找bug的话,使用gdb core-file +core文件,程序会自动跳到出错的地方

注意:线上服务器不能允许产生core文件


2)硬件异常产生的信号,这些条件由硬件检测并通知内核,然后内核向当前进程发送适当的信号。比如当前进程出现了野指针问题并访问了非法内存地址,MMU(内存管理单元)会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。 除零操作,CPU运算单元产生异常发送SIGFPE(8号)信号给当前进程

3)一个进程调用kill(2)函数可以发送信号给另一个进程,可以调用kill(1)命令发送信号给某个进程,kill(1)命令也是调用kill(2)函数实现的。如果不明确指定信号,则发送SIGTERM信号,该信号的默认处理动作是终止进程,当内核检测到软件条件发生时可以通过信号通知进程。

5. 如何处理信号

进程以三种方式对一个信号作出应答:

显示地忽略信号
执行与信号相关的缺省操作。由内核预定义的缺省操作取决于信号的类型,可以是以下类型之一:

Treminate:进程被终止(杀死)。
Dump:进程被终止(杀死),如果可能,创建包含进程执行上下文的核心转储文件。
Ignore:信号被忽略。
Stop:进程被停止,即把进程置为TASK_STOPPED状态。
Continue:如果进程被停止,就把它置为TASK_RUNNING状态。

③ 通过调用相应的信号处理函数捕捉信号自定义类型)。

信号捕捉函数:可以修改信号的默认动作,但某些信号是不能够被捕捉的,比如9号信号,它存在的目的是防止恶意进程入侵而无法被终止,在一定程度上保护了操作系统。

#include <signal.h>
ypedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

参数signum:信号的编号。
参数handler:是一个函数指针,表示接受此信号要执行的函数的地址。
返回值:若成功则为指向前次处理程序的指针,若出错则为SIG_ERR

我们做一个测试,我们对2号信号进行捕捉

#include <stdio.h>
#include <signal.h>
void myhandle()
{
	printf("myhandle\n");
}
int main()
{
	signal(2,myhandle);
	while(1);
	return 0;
}

在这里插入图片描述

此时Ctrl-C是不能终止程序的,无奈的我们只能用9号信号来杀死该进程。

下面我们修改一下程序:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
typedef void(*sighandler_t)(int);//函数指针
sighandler_t _handler = NULL;
void myhandler()
{
	printf("myhandler\n");
	signal(2,_handler);//恢复默认处理
}
int main()
{
	_handler = signal(2,myhandler);//捕捉2号信号
	while(1);
	return 0;
}

对上述程序的解释是:首先我们用Ctrl-C捕捉2号信号,并用_handler函数指针对象接受,在myhandler函数内,再次用Ctrl-C捕捉2号信号,并指向_handler捕捉成功,返回之前的信号处理函数,即恢复了默认处理,程序得以终止。

6. 产生信号的方法

1)通过终端按键产生信号
(Core dump)。

2)调用系统函数向进程发送信号。

首先在后台执行死循环程序,然后用kill命令给它发一个SIGSEGV信号。
在这里插入图片描述
我们将a.out程序在后台运行,之所以要按一次回车才显示段错误的原因在于,该进程终止之前已经回到了shell提示符等待用户输入下一条命令,shell不希望段错误的信息和用户新输入的交错在一起,所以等用户输入命令之后才会显示

kill命令是调用kill函数实现的,kill函数可以给一个特定的进程发送指定的信号。raise函数可以给当前进程发送指定的信号(自己也可以给自己发送信号)。原型如下:

#include <signal.h> 
int kill(pid_t pid, int signum); //给任意进程发送任意信号
int raise(int signo); //给自己发送任意信号

参数pid:进程号。
参数signum:信号的编号。
返回值:两者都是成功返回0,失败返回-1。

下面我们模拟一下kill命令,当然在执行此程序之前,我们可以额外编写一个简单的死循环程序。

#include <stdio.h>
#include <signal.h>
static void usage(const char *proc)
{
	printf("Usage:%s sig pid\n",proc);//帮助手册
}
int main(int argc,char *argv[])
{
	if(argc != 3)
	{
		usage(argv[0]);
		return 1;
	}
	int pid = atoi(argv[2]);//第三个命令行参数:进程的pid
	int sig =atoi(argv[1]);//第二个命令行参数:信号ID
	kill(pid,sig);
	return 0;
}

从下图可以看出,我们让死循环的程序在后台运行,其状态为R,当我们对其发送19号信号(暂停信号)时,由R状态变成了T状态,则证明模拟成功。
在这里插入图片描述
我们也同样模拟一下raise函数,给自己发送2号信号:

#include <stdio.h>
#include <signal.h>
int count = 0;
void myhandler(int sig)
{
	printf("count:%d , sig:%d\n",count++,sig);
}
int main(int argc,char *argv[])
{
	signal(2,myhandler);
	while(1)
	{
		raise(2);
		sleep(1);
	}
	return 0;
}

在这里插入图片描述
abort可以使当前进程接收到信号而异常终止,但是abort会认为进程不安全。

#include <stdlib.h> 
void abort(void);

类似于exit函数一样,abort函数总是成功的,因此没有返回值。

3)由软件条件产生信号

SIGTERE默认处理动作为终止进程
SIGALRM闹钟超时产生信号
SIGPIPE向读端已经关闭的管道写数据时产生

例如:进程可以通过调用alam向它自己发送SIGALRM信号,其函数原型如下:

#include <unistd.h>
unsigned int alam(unsigned int secs);

参数secs:alarm函数安排内核在secs秒内发送一个SIGALRM信号给调用进程。如果secs等于0,那么不会调度新的闹钟(alarm)。

返回值:前一次闹钟剩余的秒数,若以前没有设定闹钟,则为0。

下面这个程序,我们让SIGALRM信号在5秒内每次终端一次,当传送第6个SUGALRM信号时会终止。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{
	static int count = 0;
	printf("count:%d\n",count);
	if(++count < 5)
	{
		alarm(1);
	}
	else
	{
		printf("Complete...\n");
		exit(0);
	}
}
int main()
{
	signal(SIGALRM,handler);
	alarm(1);
	while(1){
		;
	}
	return 0;
}

在这里插入图片描述

我们使用signal函数设置了一个信号处理函数,只有进程收到一个SIGALRM信号,就异步调用该函数,中断main的while循环,当handler返回时,控制传递回main函数,它就从当初被信号到达时中断了的地方继续执行。

二、阻塞信号

1. 信号在内核中的表示

信号在内核中一般有三种状态:

1)信号递达(Delivery):实际执行信号的处理动作称为信号递达。
2)信号未决(Pending):信号从产生到递达之间的状态。
3)信号阻塞(Block):被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

注意阻塞与忽略是不同的,只有信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。信号在内核中的表示可以模拟成下图:
在这里插入图片描述

block:是用来设置信号屏蔽的,如果里面对应的是1则表示该信号产生了,并且被屏蔽不能抵达,即如果block里面是1则,pending里面一定是1
pending:表示产生了一个信号。
默认执行动作:表示这个信号一旦抵达根据默认执行动作的指示执行相关动作,如果用户自己设置了捕捉函数的话,则将执行用户指定的函数。

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针用来表示处理动作。信号产生时,内核在PCB中设置该信号的未决标志,直到信号递达才清除该标志。

我们对上述图的各种状态作以分析:

1)SIGHUP信号未阻塞也未产生过(未决),当它递达时执行默认处理动作。
2)SIGINT信号产生了,但是正在被阻塞(block),所以暂时不能递达。虽然他的处理动作是忽略,但在没有接触阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再接触阻塞。
3)SIGQUIT信号未产生过,而且一旦产生就会阻塞,它的处理动作是用户自定的函数sighandler。

常规信号在递达之前产生多次只记一次,而实时信号在递达之前产生多次可以依次放在一个队列里面。上图中,每个信号只有一个比特位的未决标志,0和1两种,不需要记录该信号出现了多少次,阻塞标志与其类似。

因此未决和阻塞标志可以使用相同的数据类型sigset_t信号集来存储,这个类型可以表示每个信号的有效与无效状态。在阻塞信号集中:代表是否被阻塞;在未决信号集中:代表信号是否处于未决状态

2. 信号集操作函数

sigset_t信号集

  • 每个信号只有一个比特的未决信号,阻塞也是一样。所以,它们两个可以用同样的数据结构sigset_t来存储,这个类型可以表示每个信号的有效和无效
  • 阻塞信号集中的有效无效代表该信号是否被阻塞,而未决信号集中的有效和无效代表信号是否处于未决状态。
  • 阻塞信号集也就当前进程的信号屏蔽字。

信号集操作函数

 #include <signal.h>
 int sigemptyset(sigset_t *set); 把信号集清空
 int sigfillset(sigset_t *set); 把信号集全部置成1
 int sigaddset(sigset_t *set, int signum); 根据signum,把信号集中的对应为置成1
 int sigdelset(sigset_t *set, int signum); 根据signum,把信号集中的对应为置成0
 int sigismember(const sigset_t *set, int signum);//判断signum是否在信号集中

返回值:前4个函数成功返回0,失败返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含返回1,不包含返回0,出错返回-1.

3. 信号屏蔽字

sigprocmask函数可以读取或更改进程的信号屏蔽字(阻塞信号集)。

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); 

返回值:成功返回0,失败返回-1。

参数how:指示如何更改信号屏蔽字,有以下三个参数可供选择:
在这里插入图片描述
参数set:表示你要指定的信号集合。

参数oldset:返回旧的阻塞信号集。

如果oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。
如果set是非空指针,则更改进程的信号屏蔽字。
如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后在根据set和how参数更改信号屏蔽字。
如果调用sigprocmask解除了对当前若干未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

4. 未决信号集

sigpending函数用来读取当前进程的信号未决集,通过set参数传出,即进程已经接受到信号,但是未递达的那些信号的集合。

int sigpending(sigset_t *set);//读取当前进程的信号未决集

返回值:成功返回0,出错返回-1。

sigpending读取当前进程的未决信号集,通过set参数传出,调用成功返回0,失败返回-1,我们通过例子来验证一下进程阻塞:

#include<stdio.h>
#include <signal.h>
void printf_signal(sigset_t *set)
{
	int i = 1;
	for(;i < 32;++i)
	{
		if(sigismember(set,i))//判断指定信号是否在目标集合中
			printf("1 ");
		else
			printf("0 ");
	}
	printf("\n");
}
int main()
{
	sigset_t s,p;
	sigemptyset(&s);//清空信号集
	sigaddset(&s,SIGINT);//给信号集s中添加SIGINT2号信号
	sigprocmask(SIG_BLOCK,&s,NULL);//设置阻塞信号集,阻塞SIGINT
	while(1)
	{
		sigpending(&p);//获取未决信号集
		printf_signal(&p);
		sleep(1);
	}
	return 0;
}

从上图结果可以看出,我们每隔一秒打印一遍32位信号的未决状态,由于我们阻塞了SIGINT信号,按Ctrl-C会使SIGINT信号处于未决状态。因为我们没有阻塞SIGQUIT信号,因此使用Ctrl-\也可以终止程序。
在这里插入图片描述
下面我们在写一个程序,当我们按下Ctrl-C的时候对2号信号置1进入未决状态,5秒之后阻塞信号集,看2号信号是否从未决状态到达递达状态。

#include <stdio.h>
#include <signal.h>
//打印pending表
void printpending(sigset_t *pending)
{
	int i = 1;
	for(;i < 32;i++)
	{
               //pending信号集中的有效信号如果包含i,返回1,不包含返回0
		if(sigismember(pending,i))
			printf("1 ");
		else
			printf("0 ");
	}
	printf("\n");
}
void handler(int sig)
{
	printf("sig: %d\n",sig);
	return ;
}
int main()
{
	sigset_t sigset,osigset;
	sigemptyset(&sigset);//初始化信号集sigset
	sigemptyset(&osigset);//初始化信号集osiget
	sigaddset(&sigset,2);//将信号集的2号位置1
	sigprocmask(SIG_SETMASK,&sigset,&osigset);//先将sigset的信号屏蔽字备份到osigset中
        //再将sigset设定为当前信号屏蔽字       
	signal(2,handler);//捕捉2号信号
	int count = 0;
	sigset_t pending;
	while(1)
	{
		sigpending(&pending);//读取未决信号集
		printpending(&pending);//打印未决表
		sleep(1);
		if(count++ > 5)
		{
                 //现将osigset的信号屏蔽字备份到sigset中,再将osigset设定为当前信号屏蔽字
			sigprocmask(SIG_SETMASK,&osigset,&sigset);
			count = 0;//循环起来
		}
	}
}

在这里插入图片描述

从结果可以看出,pending表的2号信号从1变成了0,即说明2号信号已经递达。

当然,如果我想在上一个程序的基础上,只要我按下Ctrl-C,它从未决变成递达,即接触了屏蔽,再次按下Ctrl-C,在从递达变成未决,循环往复。

#include <stdio.h>
#include <signal.h>
//打印pending表
void printpending(sigset_t *pending)
{
   int i = 1;
   for(;i < 32;i++)
   {
         //pending信号集中的有效信号如果包含i,返回1,不包含返回0
   	if(sigismember(pending,i))
   		printf("1 ");
   	else
   		printf("0 ");
   }
   printf("\n");
       //重新捕捉,从递达到未决
   sigset_t sigset,osigset;
   sigemptyset(&sigset);
   sigemptyset(&osigset);
   sigaddset(&sigset,2);
   sigprocmask(SIG_SETMASK,&sigset,&osigset);
}
void handler(int sig)
{
   printf("sig: %d\n",sig);
   return ;
}
int main()
{
   sigset_t sigset,osigset;//定义两个信号集
   sigemptyset(&sigset);//初始化sigset
   sigemptyset(&osigset);//初始化osigset
   sigaddset(&sigset,2);//将信号集的2号位置1
   sigprocmask(SIG_SETMASK,&sigset,&osigset);//先将sigset的信号屏蔽字备份到osigset中
       //再将sigset设定为当前信号屏蔽字
   signal(2,handler);
   int count = 0;
   sigset_t pending;
   while(1)
   {
   	sigpending(&pending);//读取未决信号集
   	printpending(&pending);//打印未决表
   	sleep(1);
   	if(count++ > 5)
   	{
             //先将sigset的信号屏蔽字备份到sigset中,再将osigset设定为当前信号屏蔽字
   		sigprocmask(SIG_SETMASK,&osigset,&sigset);
   		count = 0;//循环起来
   	}
   }
}

在这里插入图片描述
从实验现象可以看出,程序刚跑起来未决表全为0,当我们Ctrl-C时,未决表的2号信号为置为1,表示屏蔽了2号信号,5秒之后又从未决变成递达状态,当我们再次按下Ctrl-C时,解除了2号信号的屏蔽,又从递达变成未决。

三、 信号捕捉

1. 内核是如何捕捉信号的?

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为信号捕捉。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,画此图说明:在这里插入图片描述上图很好的说明了信号捕捉时用户与内核态的切换(用户处理信号最好的时机是程序从内核态切换到用户态时),下面就上图的操作作以解释说明:


1)用户程序注册了SIGQUIT信号的处理函数sighandler(自定义信号处理函数)。
2)当前正在执行main函数,这是发生中断、异常或系统调用切换的内核态。
3)在中断处理完毕后要返回用户态的main函数之前,检查到有信号SIGQUIT递达。
4)内核决定返回用户态后不是恢复main函数的上下文继续向下执行,而是执行sighandle函数,sighandler和main函数使用不同的堆栈空间,两者之间不存在调用和被调用的关系,属于两个独立的控制流程
5)sighandler函数返回后自动执行特殊的系统调用,调用sigreturn再次进入内核态。
6)如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续向下执行。


当然除过以上的解释之外,你是否注意了那几个特殊的ABCD坐标呢?没错,信号捕捉过程共发生了4次内核与用户态的切换,其中3与4是自定义捕捉函数引起的,可有可无。

除此之外,用户处理信号的时机上图红色箭头所示,即内核态切换到用户态之时,为什么要选此时原因在于:

信号不一定会被立即处理,操作系统不会为了处理一个信号而挂起当前正在运行的进程,这样产生的消耗太大紧急信号【实时信号】可能会被立即处理)。操作系统选择在内核态切换到用户态的时候去处理信号,不要单独进行进程切换而浪费时间。但是有时候一个正在睡眠的进程突然收到信号,操作系统肯定不愿意切换当前正在运行的进程,预示就将该信号存在此进程的PCB的信号字段中,在合适的时候处理信号。

2. 信号处理程序捕捉信号的基本思想

捕捉思想是对上述内核捕捉的一点补充,如果没有理解上图,请看下图信号处理程序捕获信号的基本过程:在这里插入图片描述一个进程可以有选择性地阻塞接受某种信号,当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接受,直到进程取消对这种信号的屏蔽

一个待处理信号最多只能被接收一次。因此,内核为每个进程在pending表中维护着待处理信号的集合,而在block表中维护着被阻塞的信号集合。只要传送一个类型为n的信号,内核就会设置pending表中的第n位。只要接收了类型为n的信号,内核就会清除pending表的第n位。

3. 可移植的信号处理

为了处理信号处理语义的差异,Posix标准定义了sigaction函数,它允许像Linux和Solaris这样与Posix兼容的系统上的用户,明确地指定它们想要的信号处理语义。

sigaction函数可以读取或指定信号相关联的处理动作调用成功返回0,失败返回-1,signal与其功能类似,但signal()是标准C的信号接口。函数原型如下:

#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);

参数signum:指定信号的编号(利用kill -l可以查看)。
参数*act:若act指针非空,则根据act修改该信号的处理动作。
参数*oldact:若oldact非空,则通过oldact传出该信号原来的处理动作。
返回值:成功返回0,出错返回-1。

上述参数中,act与oldact都指向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_handler:将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行默认动作,赋值为函数指针表示用自定义函数捕捉信号。该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这是一个回调函数,不是main函数调用,而是被系统所调用

sa_mask:是一个信号集,可以将信号加进进程的信号屏蔽字中。如果在调用信号处理函数时,除当前信号外,还希望屏蔽一些其他的信号,就可以使用sa_mask来屏蔽,仅当从信号捕捉函数返回时再将进程的信号屏蔽字复位为原先值。

sa_flags:包含多个选项,一般设置为0即可。

//sa_falgs
SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL
SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用
SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了 SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号

sa_restorer:实时处理函数(感兴趣可以自行了解一下)。

4. pause与alarm函数
#include <unistd.h> 
int pause(void);

函数说明:pause() 库函数使调用进程(或线程)睡眠状态,直到接收到信号,要么终止,要么导致它调用一个信号捕获函数

返回值:只返回-1。

错误代码:EINTR 有信号到达中断了此函数。

pause函数使调用进程挂起直到有信号递达可能出现的三种状态如下:

① 如果信号的处理动作是终止进程,则进程终止,pause函数没有机会返回。
② 如果信号的处理动作是忽略,则进程继续处于挂起状态,pause 不返回。
③ 如果信号的处理动作是捕捉,则调⽤用了信号处理函数之后pause返回-1,errno设置为 EINTR,所以pause只有出错的返回值

注意:alarm闹钟函数

5. 普通版本的mysleep
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
   //句柄函数
}
int mysleep(int seconds)
{
	struct sigaction act,oldact;
	act.sa_handler = handler;//设置自定义捕捉函数
	sigemptyset(&act.sa_mask);//初始化信号集
	act.sa_flags = 0;//默认一般为0
	sigaction(SIGALRM,&act,&oldact);//注册信号处理函数
	alarm(seconds);//设置闹钟
	pause();//挂起等待,直到有信号递达
	int time = alarm(0);//取消闹铃
	sigaction(SIGALRM,&oldact,NULL);//恢复默认信号处理动作
	return time;
}
int main()
{
	while(1)
	{
		mysleep(2);
		printf("sleeping...\n");
	}
	return 0;
}

现象是每2秒打印一次,模拟了sleep函数的概念。

对于上述代码的理解主要有以下几点:
主函数调用mysleep函数,mysleep函数调用sigaction注册了SIGALRM信号的处理函数。
调用alarm(seconds)设置闹钟。
调用pause函数挂起等待,内核切换到别的进程执行。
seconds秒之后,闹钟超时,内核发SIGALRM给这个进程。
从内核态返回这个进程的用户态之前处理未决信号,发现有SIGALRM信号,处理函数为handler。
切换到用户态执行handler函数,发现SIGALRM信号被自动屏蔽,从handler函数返回时SIGALRM信号自动解除屏蔽,然后自动执行系统调用sigreturn再次进入内核,再返回用户态继续执行进程的主控制流程。
pause函数返回-1,然后调用alarm(0)取消闹钟,调用sigaction恢复SIGALRM信号以前的处理动作。

关于上述问题的几点思考

问题1信号处理函数handler什么都没做,为什么还有注册它为SIGALRM的处理函数?不注册可以吗?

:必须注册。因为pause函数使调用进程挂起直到有信号递达,如果未注册,当有信号SIGALRM产生时会执行默认动作,即终止进程。

问题2为什么mysleep函数返回时要恢复SIGALRM信号原先的sigaction?

:mysleep函数在mysleep(time)之后不会对SIGALRM信号进行修改,将SIGALRM不恢复会使alarm()失效

问题3mysleep函数的返回值代表什么含义?什么情况下返回非0值?

:表示信号传来时闹钟还剩余的秒数。当闹钟结束前有其他信号发送给该进程,并对该进程进行了相关处理时,alarm(0)表示取消闹钟,且返回值为非0。

遗留的问题

上述mysleep程序虽然跑完了,但是并没有立即结束。出现这个问题的根本原因是系统运行的时序并不像我们写程序时所设想的那样,虽然alarm(seconds)紧接着下一步就是pause(),但是无法保证pause()一定会在调用alarm(seconds)之后的seconds秒之内被调用

由于异步事件在任何时候都可能会发送(异步事件在这里指出现更高优先级的进程),如果我们写程序时考虑不周密,就可能由于时序问题而导致错误,这就是竞态条件(Race Condition)。

6.避免竞态条件

设想如何将解除信号屏蔽与挂起等待信号合并成一个原子操作就可以避免时序问题,因此引入了sigsuspend函数。它不仅用于pause函数的挂起等待功能,而且也解决了竞态条件产生的时序问题

sigsupend()函数的原型如下:

#include <signal.h>
int sigsuspend(const sigset_t *mask);

参数make:指定进程的信号屏蔽字,可以临时解除对某一信号的屏蔽,然后挂起等待。当sigsuspend返回时,进程的信号屏蔽字恢复原先的值,如果原先对该信号是屏蔽的,返回后仍然屏蔽。

返回值:返回值与pause一致。永远返回-1,errno设置为EINTR

7. 规避竞态条件的mysleep
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void myhandler(int sig)
{
}
int mysleep(int seconds)
{
	struct sigaction act,oldact;
	sigset_t newmask,oldmask;//设置信号集
	act.sa_handler = myhandler;
	sigemptyset(&act.sa_mask);//初始化信号集
	act.sa_flags = 0;//一般设置为0
	sigaction(SIGALRM,&act,&oldact);//注册信号处理函数
	sigemptyset(&newmask);//初始化信号集
	sigaddset(&newmask,SIGALRM);//为信号添加SIGALRM信号
	alarm(seconds);//设置闹钟
	sigdelset(&oldmask,SIGALRM);//从信号集oldmask中删除SIGALRM信号
	sigsuspend(&oldmask);//替换pause
	int ret = alarm(0);//取消闹钟
	sigaction(SIGALRM,&oldact,NULL);//恢复默认信号处理动作
	return ret;
}
int main()
{
	while(1)
	{
	        mysleep(3);
		printf("sleeping...\n");
	}
	return 0;
}

网上对这里说的千篇一律,实在不该恭维。如果在调用mysleep函数时SIGALRM信号没有屏蔽,则有:

1)调用sigprocmask(SIG_BLOCK,&newmask, &oldmask)时,屏蔽SIGALRM。
2)调用sigsuspend(&suspmask)时,解除对SIGALRM的屏蔽,然后挂起等待。
3)SIGALRM递达后suspend返回,自动恢复原来的屏蔽字,也就是再次屏蔽SIGALRM。
4)调用sigprocmask(SIG_SETMASK, &oldmask, NULL)时,再次解除对SIGALRM的屏蔽。

8. pause与sigsupend的区别

sigsupend是凌驾于pasue基础之上的函数,不仅实现了pause函数特有的挂起等待功能,也解决了pasue的不足带来的时序问题。
当sigsupend函数的参数信号集为空信号时,sigsupend函数与pause函数功能一致,可以接受任何信号的中断。
sigsupend函数可以屏蔽信号,接受指定的信号中断,而pause不可以。
pause函数通过指定屏蔽信号可以达到sigsupend函数的功能。

9. 可重入函数

可重入函数指的是一个函数可以同时被多个执行流执行,但是不会引起任何问题。可重入函数只能访问自己的局部变量或参数。而一个函数同时被多个执行流执行,如果会引起问题,则叫不可重入函数,不可重入函数是因为访问一些全局的东西从而导致错乱的现象。

一个链表的例子说明可重入函数:

假设现在存在一条链表,要想这条链表头插一个结点。首先,mian函数调用insert函数向链表中插入结点node1,插入操作分为两步p->next=head,head->p,当函数执行完第一步p->next=head的时候,因为硬件中断使得进程切换到内核,回到用户态之前发现有信号待处理,于是切换到自定义函数handler去处理函数,但是在handler函数也调用了insert函数向同一个全局链表中插入结点node2,插入操作两步都进行完之后从handler函数返回内核态,再次回到用户态就直接从main函数中断的地方继续执行,现在执行插入函数的第二步,最后的结果是本来要向链表中插入两个结点,现在只剩一个结点了,因为head->node1了。

为什么两个不同的控制流调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

因为每个函数都有自己的函数栈帧,局部变量存在于栈帧之中,出了函数局部变量就已经销毁。

符合下边两个条件的函数一定是不可重入函数:

调用了malloc或free函数,因为malloc也是用全局链表来管理堆的。
调用了标准I/O函数。标准I/O库的很多实现都是以不可重入的方式使用全局数据结构。

附加

详解volatile

猜你喜欢

转载自blog.csdn.net/qq_41035588/article/details/83787122