信号时一种软件终端,提供了一种处理异步事件的方法,也是进程间通信的唯一一个异步的通信方式。Unix中定义了很多信号,有很多条件可以产生信号,对于这些信号有不同的处理方式。
在Linux系统下可以通过kill -l 查看所有信号的定义。
目前Linux中定义了62种信号,1-31号信号是普通信号,34—64号的信号,是实时信号。
本文主要讲解普通信号,简单介绍实时信号。
man 7 signal 可以查看信号在什么条件下产生默认处理动作是什么
linux中的信号本质是一个宏,可以在/usr/include/bits/signum.h系统目录下查看对应信号的相关信息。
信号收到时,不一定要立即处理,比如操作系统现在在干更重要的事,就可以暂时搁置信号,注意并不是一直不处理,而是要在合适的时候处理这个信号,所以信号一定要保存。
信号记录在进程的task_struct(PCB)结构体中,对于普通信号,本质是记录多个信号是否产生。由于只有是或者否,且普通信号编号是1-31,显然这里要使用整形位图来保存(比特位的位置代表是否收到信号,比特位为0代表没有收到,为1,表示收到了信号)。
进程收到信号,本质是位图被修改,只能通过操作系统修改进程内的信号位图(虽然可以通过命令行或者代码向某个进程发送信号,但本质还是是通过操作系统修改的)。
例如不小心产生了一个死循环代码并运行起来,那么我们要终止这个程序通常我们是键入ctrl+c来终止,而ctrl+c本质也是一种信号。
注意:shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接收到由键盘键入的信号。
在运行进程的命令后加上&即可将进程放在后台运行,这是shell不必等待进程就可以接受新的命令,启动新的进程,但是后台进程无法使用ctrl+C结束,可以通过kill命令来结束进程。
kill格式:kill -[要发送几号信号]【要终止进程的PID]。
为什么通过Ctrl+C可以终止一个进程呢?本质还是向进程发送2号信号,2号信号原本是结束进程的,如果我们不想让它终止进程,我们就要使用signal函数了
signal函数原型:
sighandler_t signal(int signum , sighandler_t handler);
参数解释:
signum:要自定义信号的编号,比如SIGINT是2号信号。
handler:是一个函数,在这个函数体中是自定义后signum号信号要执行的动作,也可以不叫handler但是前后一定要一致。
看一下linux man手册中的signal函数
废话不说,直接上实例:
#incldue
#include
#include
void sigcb(int signum)
{
printf("重定义成功!signum:%d\n",signum);
}
int main()
{
signal(2,sigcb);
while(1)
{
printf("a\n");
sleep(1);
}
return 0;
}
运行结果:
注意:不是所有信号都可以被自定义的·(捕捉),比如9号信号SIGKILL。
忽略此信号。
执行该信号的默认动作(系统中定义好的)。
提供一个信号处理函数(向上面那样自定义的函数),要求内核在处理信号时切换到用户态执行这个函数,这种方法称为捕捉一个信号。
介绍两个常用的键盘输入产生的信号:ctrl+c发送2号信号、ctrl+\发送3号信号等等
2号信号的默认处理动作时终止进程,3号信号默认动作是终止进程并且Core Dump。
当一个进程异常终止时,可以把进程的核心数据全部转储到磁盘上,文件名通常是core.PID,这一现象叫做Core Dump。
一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中) 。默认是不允许产生core文件的,因为core文件可能包含用户密码等敏感信息,泄漏会导致不安全。
如下图中云服务器环境中一般不允许产生core文件(core file size的值为0)。
使用ulimit -c修改core file size的值为10240
使用3号信号终止进程可以看到系统产生core文件。
程序异常时操作系统会向程序来终止进程,这一结果可以通过core文件来看到,下面通过用core文件进行事后调试来讲解上述过程。
事后调试
进程异常终止通常是因为有Bug(比如非法访问内存导致段错误),事后可以通过调试器检查core文件以检查错误原因,这叫做事后调试。
通过下面代码来大致演示core文件调试
#include
#include
int main()
{
int a=1/0;
return 0;
}
结果:
可以看到core文件中包含的调试信息非常详细、精准、如果代码量极大,那么core文件可以更快调试。
上面的图片显示程序由于接收到8号信号终止,正好匹配错误。
kill函数
函数原型:int kill(pid_t pid,int sig);
头文件:#include
#include 参数解释:pid:要给哪个进程发,pid为进程的唯一标识。
sig:是要给上述进程发送几号信号。
接下来实现一个mykill
#include
#include
#include
#include
int main(int argc,char* argv[])
{
if(argc!=3)
{
printf("input error!\n");
exit(1);
}
else{
pid_t pid=atoi(argv[1]);
int sig=atoi(argv[2]);
kill(pid,sig);
}
return 0;
}
这个程序可以实现与kill命令一样的功能,只不过运行时要加上./kill pid sig
raise函数
raise函数是自己给自己发送信号,与kill不同。
函数原型:int raise(int sig);
头文件:#include
参数解释:sig :要发送的信号编号。
实例:
#include
#include
#include
#include
void sigcb(int signum)
{
printf("get signum:%d\n",signum);
}
int main()
{
//处理一下,使程序受到信号不会终止。
signal(2,sigcb);
while(1)
{
printf("a\n");
sleep(1);
//使进程自己给自己发送2号信号
raise(2);
}
return 0;
}
运行结果:
abort函数
函数功能:使当前进程接收到信号而异常终止。
#include
#include
#include
#include
void sigcb(int signum)
{
printf("get signum:%d\n",signum);
}
int main()
{
//处理一下,使程序受到信号不会终止。
signal(2,sigcb);
while(1)
{
printf("a\n");
sleep(1);
//使进程自己给自己发送2号信号
abort();
}
return 0;
}
运行结果:
SIGPIPE
在管道有详细解释。
SIGALRM
触发SIGALRM信号需要alarm函数
alarm函数是设置一个计时器, 在计时器超时的时候, 产生SIGALRM信号. alarm也称为闹钟函数,一个进程只能有一个闹钟时间。如果不忽略或捕捉此信号, 它的默认操作是终止调用该alarm函数的进程。
函数原型:unsigned int alarm(unsigned int seconds);
参数解释:seconds:倒计时秒数
函数返回值:返回值为0,或者之前设置的闹钟还余下的秒数。
#include
#include
#include
#include
#include
int main()
{
int count=0;
alarm(1);
while(1)
{
printf("count:%d\n",count++);
}
return 0;
}
运行结果:
发生硬件异常时,他被它被硬件以某种方式检测到并通知内核,然后内核向当前进程发送适当的信号。
例如当前进程执行了除零的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号,并将该信号发送给进程。 再如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号,并将该信号发送给进程。
实际执行信号的处理动作称为信号抵达。
信号从产生到递达之间的状态,称之为信号未决。
进程可以选择阻塞某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意:阻塞和忽略是不同的,阻塞的本质是不让信号递达(直到解除阻塞),而忽略本质是递达后处理动作的一种。
如上:每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针(handler)表述处理动作。
信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志,之后如果被阻塞,则直到阻塞借书才处理。否则直接调用handler内的方法处理该函数。
在上图的例子中:
SIGHUP信号未阻塞也未产生过,如果他递达则执行默认处理动作。
SIGINT信号产生过,但是正在被阻塞,暂时不能被递达,虽然它的处理动作是忽略,但是在解除阻塞之前不能忽略这个信号。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,他的处理动作是用户自定义函数signalhandler。
注意:在linux下如果常规信号在递达之前产生多次则只记最后一次,而实时信号在递达之前产生多次,会依次放在一个队列中。
从上图来看,每个信号只有一个比特位的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志同理。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些比特位则是与操作系统有关的(所以不能冒然用按位与、按位或来拿到信号,因为操作系统底层不一定是这么实现的)。
从使用者的角度是不必关心的,使用者只能调用下面介绍的函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t,这是毫无意义的。
对信号集的操作
常用的信号集操作函数有以下五个
#include
//初始化set所指向的信号集,使其中所有信号的比特位清零,表示该信号集不包含任何有效信号
int sigemptyset(sigset_t *set);
//初始化set所指向的信号集,使其中所有信号的对应比特位置为1,表示该信号集的有效信号包括系统支持的所有信号
int sigfillset(sigset_t *set);
//向信号集set中添加编号为signo的信号
int sigaddset (sigset_t *set, int signo);
//从信号集set中删除编号为signo的信号
int sigdelset(sigset_t *set, int signo);
//判断编号为signo的信号是否在信号集set中
int sigismember(const sigset_t *set, int signo);
这四个函数都是成功返回0、出错返回-1。sigismember用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
注意,上面五个函数都是系统级别的函数,它们在使用时并不会影响进程的任何信息(不会修改进程的PCB)。
sigprocmask
调用函数sigprocmask可以读取或者更改进程的信号屏蔽字(block表)。
函数原型:int sigproc mask(int how,const sigset_t *set,sigset_t * oldset);
how有三个选项(下面的式子中假设mask是当前的信号屏蔽字,且这些式子只是帮助理解,不能实际写在代码中)
SIG_BLCOK:这时传入的set包含了希望添加到当前信号屏蔽字(block表)中的信号,可以理解为mask=mask|set;
SIG_UNBLOCK:这时传入的set包含了希望从当前信号屏蔽字(block表)中取消阻塞的信号,可以理解为mask=mask & ~set。
SIG_SETMASK:设置当前的信号屏蔽字为传入的set,相当于mask=set。
set就是传入一个信号集,然后根据how的选项来进行操作。
oldset是输出型参数,传入NULL时不做处理,如果传入非空则将原来的信号屏蔽字返回至oset,也就是拿到了修改之前的信号屏蔽字。
三个参数的功能整理如下:
如果oset是非空指针,则当前进程的信号屏蔽字通过oset参数传出。
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字
sigpending
获取当前进程的pending位图
实例
下面的代码的大致功能是首先阻塞2号信号SIGINT,然后不断打印pending表(这时应该是全0),当向该进程发送2号信号后会观察到pending表第二位变为1(被阻塞而无法递达)。
#include
#include
#include
void printfPending(sigset_t *pending)
{
int i=1;
for(;i<32;i++)
{
if(sigismember(pending,i))
{
printf("1");
}
else
printf("0");
}
printf("\n");
}
int main()
{
//定义两个信号集
sigset_t set,oset;
//初始化信号集
sigemptyset(&set);
sigemptyset(&oset);
//将要操作的信号编号加入set信号集
sigaddset(&set,2);
//将当前的set设置为信号屏蔽字,并将之前的信号屏蔽字保存到oset
sigprocmask(SIG_SETMASK,&set.&oset);
sigset_t pending;
while(1)
{
sigemptyset(&pending);
//拿到系统中的pending表保存到pending中
sigpending(&pending);
printfPending(&pending);
sleep(1);
}
return 0;
}
运行结果:
最后通过Ctrl+\发送信号终止进程成功了,因为这个信号没有被阻塞;但由于Ctrl+C发送的信号被阻塞,所以无法终止进程。
改进代码
#include
#include
#include
void printfPending(sigset_t *pending)
{
int i=1;
for(;i<32;i++)
{
if(sigismember(pending,i))
{
printf("1");
}
else
printf("0");
}
printf("\n");
}
int main()
{
//定义两个信号集
sigset_t set,oset;
//初始化信号集
sigemptyset(&set);
sigemptyset(&oset);
//将要操作的信号编号加入set信号集
sigaddset(&set,2);
//将当前的set设置为信号屏蔽字,并将之前的信号屏蔽字保存到oset
sigprocmask(SIG_SETMASK,&set.&oset);
sigset_t pending;
int count=0;
while(1)
{
sigemptyset(&pending);
//拿到系统中的pending表保存到pending中
sigpending(&pending);
printfPending(&pending);
sleep(1);
count++;
if(count>=5)
{
//将之前的信号屏蔽字重新设为block表,2号信号不会被阻塞,递达给系统,进程被终止。
sigprocmask(SIG_SETMASK,&oset,NULL);
}
}
return 0;
}
运行结果:
进程收到信号后不是立即对其进行处理的,而是在“合适”的时候,这个“合适”的时候指的是从内核态切换回用户态时。那么什么是内核态和用户态,下面进行介绍。
内核态通常执行OS的代码,是一种权限非常高的状态。用户态通常执行普通用户的代码,是一种受监管的普通状态
了解上面相关知识后,就不难理解下面信号处理的完整过程了,具体见下图。
如果忽略收到的信号或是按照默认处理动作处理时,整个过程比较简单(因为没有设计到多次设计状态的切换),而处理自定义的动作叫做信号捕捉,下面讲到的就是信号捕捉
sigaction函数可以读取和修改于指定信号相关联的处理动作。调用成功则返回0;出错则返回-1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体。
将sa_handler赋值为常数SIG_IGN表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,后面的代码都把sa_flags设为0;sa_sigaction是实时信号的处理函数,本篇暂不详细解释这两个字段
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时如果这种信号再次产生,那么他会被阻塞到当前处理动作结束为止。
实例:
#include
#incldue
#include
#include
//定义两个sigaction结构体
struct sigaction act, oact;
void handler(int sidno)
{
printf("signo:%d\n",signo);
sigaction(SIGINT,&oact,NULL);//将SIGINT的处理动作改为最开始(系统默认的)
}
int main()
{
memset(&act,0,sizeof(act));
memset(&oact,0,sizeof(oact));
act.sa_handler=handler;
act,sa_flags=0;
//初始化
sigemptyset(&act.sa_mask);
//传入act修改SIGINT的处理动作
sigaction(SIGINT,&act,&oact);
while(1)
{
printf("a\n");
sleep(1);
}
return 0;
}
运行结果:
这是一个C语言的关键字,但是仅从语言的层面较难理解,所以这里介绍操作系统的相关概念时来介绍这个关键字
下面这段代码预计会在收不到信号时一直死循环,如果收到2号信号则终止进程并打印内容
#include
#include
#include
#include
int flag=0;
void handler(int signo)
{
printf("signo:%d\n",signo);
flag=1;
}
int main()
{
signal(2,handler);
while(!flag);
printf("quit\n");
return 0;
}
正常运行:
加编译器优化运行
gcc -O3 test.c -o test
可以看到,多次向进程发送2号信号结果是打印了内容,但进程并没有正常退出。
这是预料之外的结果,原因是编译时优化程度太高,为flag在寄存器中开辟了空间,main函数读取时优先从寄存器读取,而handler函数中对flag的修改是在内存层面的,但main函数中读取一直从寄存器中读取,所以虽然内存中的flag已经设为1,但main函数从寄存器中读取到的flag一直是0,导致在while判断时一直为真、死循环
在定义flag时将其定义为volatile变量就可以避免上述情况。
#include
#include
#include
#include
volatile int flag=0;
void hander(int signo)
{
printf("signo:%d\n",signo);
flag=1;
}
int main()
{
signal(2,handler);
while(!flag);
printf("quit!\n");
return 0;
}
可以看到,即使是编译时优化,但结果仍和优化之前一样。这也是volatile的作用:保持内存的可见性,告知编译器,被该关键字修饰的变量不允许被优化,对该变量的任何操作都必须在真实的内存中进行操作