信号是进程之间事件异步通知的一种方式,是一个软中断,当某进程收到信号时,会中止当前程序的执行而去处理信号,然后回到断点继续往下执行。
输入命令kill -l
可以查看所有信号,Linux系统中一共有62个信号,其中1 ~ 31号信号是普通信号;34 ~ 64号信号是实时信号,在实际应用的编程中,用到的主要还是普通信号:
每个信号都有一个对应的编号,在系统中信号的名称其实是每一个编号宏定义出来的常量,相关信息可以在/usr/include/bits/signum.h
这个文件中找到:
在刚开始学习Linux时,老师告诉过我们想要终止某个正在运行中的程序时按ctrl + c
或者ctrl + \
就可以终止这个正在运行中的程序,为什么会这样呢?
其实我们按了ctrl + c
或ctrl + \
后系统会向这个正在运行中的前台进程发送2号信号SIGINT或者3号信号SIGQUIT来终止它的运行。
比如我们执行下面这段死循环代码:
#include
#include
int main()
{
while(1)
{
printf("I am runing\n");
sleep(1);
}
return 0;
}
信号的处理有以下三种方式:
关于这三种处理方式最后部分会做详细介绍,我们这里先简单介绍一下signal函数,它是自定义处理信号的函数之一。
signal函数第一个参数传入我们要自定义处理的信号的编号(宏名称也行),第二个参数传入一个我们自由定的sighandler_t类型的函数。
头文件:#include <signal.h>
函数原型:sighandler_t signal(int signum, sighandler_t handler);
sighandler_t其实是一个函数指针类型,这个函数的返回值为void,参数只有一个就是我们要自定义处理的信号的编号,函数内容就是我们要自定义处理该信号的方法:
typedef void (*sighandler_t)(int);
signal函数使用举例:我们自定义捕捉2号(SIGINT)和3号(SIGQUIT)信号:
#include
#include
#include
void Handler(int signo)
{
printf(" receive signal:%d\n", signo);
}
int main()
{
signal(2, Handler);
signal(3, Handler);
while(1)
{
printf("I am runing\n");
sleep(1);
}
return 0;
}
编译运行后,我们通过键盘按键ctrl + c
和ctrl + \
发出的2号、3号信号都被我们以自定义的方式处理了:
操作系统通过信号告诉进程发生了某个事件,打断进程当前的操作,去处理这个事件。
下面我们根据信号的生命周期来了解信号相关的使用方法和它的特点。
通过键盘按键产生:用户在终端下按下某些键时,终端驱动程序会发送信号给前台进程。
我们可以通过ctrl + c
和ctrl + \
这两个按键分别向前台进程发送2号(SIGINT)和3号(SIGQUIT)信号来最终它的运行,这两个信号的共同的都是终止前台进程的运行,但是ctrl + \
除了终止进程外还会产生一个core文件。
当程序运行的过程中异常终止或崩溃,操作系统会将程序当时的内存状态记录下来,保存在一个文件中(core文件),这种行为就叫做Core Dump(中文有的翻译成“核心转储”)。我们可以认为 core dump 是“内存快照”,但实际上,除了内存信息之外,还有些关键的程序运行状态也会同时 dump 下来,例如寄存器信息(包括程序指针、栈指针等)、内存管理信息、其他处理器和操作系统状态和信息。core dump 对于编程人员诊断和调试程序是非常有帮助的,因为对于有些程序错误是很难重现的,例如指针异常,而 core dump 文件可以再现程序出错时的情景。
有的平台默认core文件大小为0,所以我们虽然按ctrl + c
异常终止进程也不会产生core文件,我们可以使用命令ulimit -a
查看到当前core文件的大小:
可以看到当前我的平台上core文件大小为0,使用命令ulimit -c size
可以修改当前平台core文件的大小,我们把它设置为1024:
设置完成后,我们运行下面这段死循环程序,通过键盘输入2号和3号信号观察结果:
结果发现ctrl + c
发送的2号信号仅仅终止了进程, 而ctrl + \
发送的3号信号最终不仅终止了进程的运行,而且还在当前目录下生成了一个core.pid
文件,该文件后缀是被终止的进程的pid。
这个生成的core文件可以帮助我们找到程序出错的位置,比如我们执行下面这段代码,这里故意写了一个错误的行为:解引用空指针。
编译时,编译器不会检查到这个错误,只是在运行这个程序的时候告诉你发生了段错误(实质上是操作系统发送了SIGSEGV(11)信号),即我们的程序出现了内存访问上的错误,但又没告诉你具体出错在哪一行:
想要解决这个错误,把代码从头开始检查是很麻烦的,这个时候我们可以利用生成的core文件,在gdb调试里输入core-file core文件名
即可定位到到底是在哪一行发生了段错误和接收到了什么信号:
在学习进程等待时有一个函数叫做waitpid,它的第二个参数status要我们自己作为输出型参数传入,作为一个整型数据,我们只关心它的后16位即第0-15个比特位,它们的值表示被等待进程的最终终止后的状态:
执行下面这段代码:子进程非法内存访问导致异常终止并接收到SIGSEGV(11)信号,父进程使用waitpid阻塞等待子进程退出并检测子进程异常终止时是否发生了核心转储和导致子进程终止的信号:
#include
#include
#include
#include
int main()
{
pid_t id = fork();
if(id == 0) //chiled
{
int* p = NULL;
*p = 100;
}
else if(id > 0) //father
{
int status = 0;
if(waitpid(-1, &status, 0) == -1)
{
perror("waitpid error\n");
}
else
{
printf("core_dump:%d,signal:%d\n", (status>>7) & 1, status & 0x7f);
}
}
else
{
perror("fork error\n");
}
return 0;
}
编译运行,一开始没设置核心转储,所以coredump为0;后面设置了核心转储coredump为1。
键盘按键产生信号总结
使用kill -信号 进程pid
向指定进程发送指定的信号。在一个终端执行死循环不断打印自己进程的pid,另一个终端使用kill命令发送SIGKILL(9)信号杀死这个死循环进程:
kill命令是调用kill函数实现的。kill函数是将信号发送给指定的pid进程。普通用户利用kill函数将信号发送给该用户下任意一个进程,而root用户可以将信号发送给系统中任意一个进程。
kill函数原型及其说明如下:
函数原型 | int kill(pid_t pid, int sig); |
---|---|
头文件 | #include #include |
函数说明 | kill可以用来传送参数sig指定的信号给参数pid指定的进程 |
函数传入值 | pid>0 将信号传给进程识别码为pid的进程 pid=0 将信号传给和当前进程相同进程组的所有进程 pid = -1 将信号广播传送给进程组识别码为pid绝对值的所有进程 sig 信号编号 |
函数返回值 | 成功:0 失败:-1 |
使用kill函数模拟实现kill命令
左边程序执行死循环打印自己进程的pid,右边程序模拟实现kill命令:
两个程序分别编译运行,用我们自己模拟的kill命令发送9号信号杀死左边进程:
raise函数原型及其说明如下:
函数原型 | int raise(int sig); |
---|---|
头文件 | #include |
函数说明 | raise函数用于进程向自身发送信号 等价于:kill(getpid(),signo); |
函数传入值 | 需要发送的信号编号 |
函数返回值 | 成功:0 失败:-1 |
raise函数使用举例
使用raise函数每隔一秒向自己发送一个SIGINT(2)信号,并自定义处理捕捉到该信号:
#include
#include
#include
void Handler(int signo)
{
printf("receive signo:%d\n", signo);
}
int main()
{
// 1、自定义处理收到了得2号信号
signal(2, Handler);
// 2、使用raise每秒给自己发送一个2号信号
while(1)
{
if(raise(2) == -1)
{
perror("raise error\n");
return 1;
}
sleep(1);
}
return 0;
}
abort函数原型及其说明如下:
函数原型 | void abort(void); |
---|---|
头文件 | #include |
函数说明 | 给调用进程自己发送SIGABRT(6)信号,该信号不能被忽略和阻塞 如果有对SIGABRT注册了捕获函数,那么会先执行捕获函数,捕获函数执行后如果依然进程没有退出,那么恢复捕获函数为默认(终止),然后再次发送SIGABRT给进程。 |
函数传入值 | 无 |
函数返回值 | 无 |
abort函数使用举例
仅仅使用这个函数,向自己发送SIGABRT(6)信号赖异常终止自己进程:
#include
#include
#include
int main()
{
abort();
return 0;
}
编译运行
如果有对SIGABRT注册了捕获函数,那么会先执行捕获函数,捕获函数执行后如果依然进程没有退出,那么恢复捕获函数为默认(终止),然后再次发送SIGABRT给进程。
#include
#include
#include
void Handler(int signo)
{
printf("recieve signo:%d\n", signo);
}
int main()
{
signal(6, Handler);
abort();
return 0;
}
编译运行
捕获函数执行后进程退出(不论异常或正常退出),abort函数就不会再次发送SIGABRT给进程:
#include
#include
#include
void Handler(int signo)
{
printf("recieve signo:%d\n", signo);
exit(1);// 终止进程
}
int main()
{
signal(6, Handler);
abort();
return 0;
}
当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
#include
#include
#include
#include
#include
int main()
{
// 1、创建一个匿名管道
int fd[2] = {0};
if(pipe(fd) == -1)
{
perror("pipe error\n");
return 1;
}
pid_t id = fork();
// 2、子进程向管道写入数据,父进程把读端关闭导致子进程收到SIGPIPE(13)信号
if(id == 0) //child
{
close(fd[0]);
const char* str = "hello Linux\n";
while(1)
{
write(fd[1], str, strlen(str));
sleep(1);
}
}
else if(id > 0) //father
{
close(fd[0]);
close(fd[1]);
int status = 0;
waitpid(-1, &status, 0);;
printf("child exit,recieve signal:%d\n", status & 0x7f);
}
else
{
perror("fork error\n");
return 2;
}
return 0;
}
alarm也称为闹钟函数,它可以在进程中设置一个定时器,当定时器指定的时间到时,它向进程发送SIGALRM(14)信号。可以设置忽略或者不捕获此信号,如果采用默认方式其动作是终止调用该alarm函数的进程。
alarm函数原型及其说明如下:
函数原型 | unsigned int alarm(unsigned int seconds); |
---|---|
头文件 | #include |
函数说明 | alarm()函数的主要功能是设置信号传送闹钟,即用来设置信号SIGALRM在经过参数seconds秒数后发送给目前的进程。如果未设置信号SIGALARM的处理函数,那么alarm()默认处理终止进程。 |
函数传入值 | 计时器时间 |
函数返回值 | 如果在seconds秒内再次调用了alarm函数设置了新的闹钟,则后面定时器的设置将覆盖前面的设置,即之前设置的秒数被新的闹钟时间取代,返回上次定时器剩余时间;当参数seconds为0时,之前设置的定时器闹钟将被取消,并将剩下的时间返回。 |
alarm函数使用举例
使用alarm函数实现一个计时器功能,5秒后终止当前进程运行:
#include
#include
int main()
{
alarm(5);
int count = 0;
while(1)
{
printf("count = %d\n", count++);
sleep(1);
}
return 0;
}
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
模拟一下野指针异常
#include
int main()
{
printf("***** begin *****\n");
int* p = NULL;
*p = 100;
printf("***** end *****\n");
return 0;;
}
编译运行
另外我们可以自定义捕捉野指针异常发送的SIGSEGV(11)信号:
#include
#include
#include
void Handler(int signo)
{
printf("reveive signal:%d\n", signo);
exit(1);
}
int main()
{
signal(11, Handler);
printf("***** begin *****\n");
int* p = NULL;
*p = 100;
printf("***** end *****\n");
return 0;;
}
编译运行
由此可以确认,C/C++当中除零,内存越界等异常,在系统层面上,是被当成信号处理的。
在Linux的进程控制块task_struct中有两个sigset_t类型的数据结构:
关于它们两个的类型sigset_t,可以把它理解为是一张位图。每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态:
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用相关函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#define _NSIG 64
#define _NSIG_BPW __BITS_PER_LONG// 64
#define _NSIG_WORDS (_NSIG / _NSIG_BPW)
typedef struct {
unsigned long sig[_NSIG_WORDS];
} sigset_t;
最后还有一个数据结构sighand,它相当于一个函数指针数组,其中数组下标对应信号编号,数组元素存储的是对该信号的处理动作包括:默认(IG_DEL)、忽略(SIG_IGN)、自定义处理。另外,信号在合适的时候才会执行处理动作,什么是"合适的时候"?这个在下面信号递达中会解释。
三个数据结构之间的相互作用
以下5个函数可以用来设置sigset_t类型的信号集bit位上的值,它们可以作用于未决(pending)信号集合阻塞(blocked)信号集:
每个函数的作用:
使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset函数对其做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
sigpending函数读取当前进程的未决信号集,并通过set参数传出。
sigpending函数使用举例
以位图的方式模拟打印进程的未决信号集:
#include
#include
#include
#include
void PrintfSet(const sigset_t set)
{
for(int i = 1; i <= 31; ++i)
{
int ret = sigismember(&set, i);
if(ret == 1)// 包含返回1
{
printf("1 ");
}
else if(ret == 0)// 不包含返回0
{
printf("0 ");
}
else// 调用出错返回-1
{
perror("sigismember error\n");
exit(1);
}
}
printf("\n");
}
int main()
{
// 1、建立一个set类型的变量
sigset_t pending_set;
sigemptyset(&pending_set);
// 2、读取当前进程的未决信号集
sigpending(&pending_set);
// 3、以位图的方式模拟打印未决信号集
PrintfSet(pending_set);
return 0;
}
编译运行,进程没有收到任何信号所以未决信号集中每一个bit位都是0
问题:每一个信号处理完毕后都会从pending集合中移除?
答:普通信号(非实时信号)是这样的,但实时信号不一定。对于实时信号而言如果只占用一个sigqueue结构(进程只收到该信号一次),则应该把信号在进程的未决信号集中删除(信号注销完毕)。否则,不应该在进程的未决信号集中删除该信号(信号注销完毕)。
分析如下:
信号生命周期为从信号发送到信号处理函数的执行完毕。
对于一个完整的信号生命周期(从信号发送到相应的处理函数执行完毕)来说,可以分为三个重要的阶段,这三个阶段由四个重要事件来刻画:信号诞生;信号在进程中注册完毕;信号在进程中的注销完毕;信号处理函数执行完毕。相邻两个事件的时间间隔构成信号生命周期的一个阶段。
下面阐述四个事件的实际意义:
1、信号"诞生"。信号的诞生指的是触发信号的事件发生(如检测到硬件异常、定时器超时以及调用信号发送函数kill()或sigqueue()等)。
2、信号在目标进程中"注册";进程的task_struct结构中有关于本进程中未决信号的数据成员,即struct sigpending pending
,它类型的结构定义如下:
struct sigpending pending:
struct sigpending{
structsigqueue *head, **tail;
sigset_t signal;
};
其中第三个成员是进程中所有未决信号集,第一、第二个成员分别指向一个sigqueue类型的结构链(称之为"未决信号信息链")的首尾,信息链中的每个sigqueue结构刻画一个特定信号所携带的信息,并指向下一个sigqueue结构:
struct sigqueue{
structsigqueue *next;
siginfo_tinfo;
}
信号在进程中注册指的就是信号值加入到进程的未决信号集中(sigpending结构的第二个成员sigset_t signal),并且信号所携带的信息被保留到未决信号信息链的某个sigqueue结构中。只要信号在进程的未决信号集中,表明进程已经知道这些信号的存在,但还没来得及处理,或者该信号被进程阻塞。
注意:
①:当一个实时信号发送给一个进程时,不管该信号是否已经在进程中注册,都会被再注册一次,因此,信号不会丢失,因此,实时信号又叫做"可靠信号"。这意味着同一个实时信号可以在同一个进程的未决信号信息链中占有多个sigqueue结构(进程每收到一个实时信号,都会为它分配一个结构来登记该信号信息,并把该结构添加在未决信号链尾,即所有诞生的实时信号都会在目标进程中注册)。
②:当一个非实时信号发送给一个进程时,如果该信号已经在进程中注册,则该信号将被丢弃,造成信号丢失。因此,非实时信号又叫做"不可靠信号"。这意味着同一个非实时信号在进程的未决信号信息链中,至多占有一个sigqueue结构(一个非实时信号诞生后,(1)、如果发现相同的信号已经在目标结构中注册,则不再注册,对于进程来说,相当于不知道本次信号发生,信号丢失;(2)、如果进程的未决信号中没有相同信号,则在进程中注册自己)。
3、信号在进程中的注销。在目标进程执行过程中,会检测是否有信号等待处理(每次从系统空间返回到用户空间时都做这样的检查)。如果存在未决信号等待处理且该信号没有被进程阻塞,则在运行相应的信号处理函数前,进程会把信号在未决信号链中占有的结构卸掉。是否将信号从进程未决信号集中删除对于实时与非实时信号是不同的。对于非实时信号来说,由于在未决信号信息链中最多只占用一个sigqueue结构,因此该结构被释放后,应该把信号在进程未决信号集中删除(信号注销完毕);而对于实时信号来说,可能在未决信号信息链中占用多个sigqueue结构,因此应该针对占用sigqueue结构的数目区别对待:如果只占用一个sigqueue结构(进程只收到该信号一次),则应该把信号在进程的未决信号集中删除(信号注销完毕)。否则,不应该在进程的未决信号集中删除该信号(信号注销完毕)。
4、信号生命终止。进程在执行信号相应处理函数之前,首先要把信号在进程中注销,进程注销信号后,立即执行相应的信号处理函数,执行完毕后,信号的本次发送对进程的影响彻底结束。
最后要说的是:在信号被注销到相应的信号处理函数执行完毕这段时间内,如果进程又收到同一信号多次,则对实时信号来说,每一次都会在进程中注册;而对于非实时信号来说,无论收到多少次信号,都会视为只收到一个信号,只在进程中注册一次。
我们在自己定义并操作的sigset_t类型的信号集变量是存储在用户空间的栈上的,也就是说我们对这个信号集变量的操作并没有同步到进程真正的阻塞信号集中。
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集):
参数解释
假设当前的阻塞信号集为mask,下表说明了how参数的可选值:
how选项 | 作用 |
---|---|
SIGBLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
sigprocmask函数使用举例
#include
#include
#include
#include
void PrintfSet(const sigset_t set)
{
for(int i = 1; i <= 31; ++i)
{
int ret = sigismember(&set, i);
if(ret == 1)// 包含返回1
{
printf("1 ");
}
else if(ret == 0)// 不包含返回0
{
printf("0 ");
}
else// 调用出错返回-1
{
perror("sigismember error\n");
exit(1);
}
}
printf("\n");
}
void Handler(int signo)
{
printf("handler signal:%d\n", signo);
}
int main()
{
// 自定义处理2号信号
signal(2, Handler);
// 1、把阻塞信号集中的2号信号置为"有效"
sigset_t blocked_set;
sigemptyset(&blocked_set);
sigaddset(&blocked_set, 2);
sigprocmask(SIG_BLOCK, &blocked_set, NULL);
printf("blocked set signal:2\n");
// 2、不断打印进程的未决信号集
int count = 0;
sigset_t pending_set;
while(1)
{
sigemptyset(&pending_set);
sigpending(&pending_set);
PrintfSet(pending_set);
++count;
sleep(1);
if(count == 2)// 向自己发送2号信号
{
raise(2);
printf("pending set signal:2\n");
}
else if(count == 4)// 把阻塞信号集中的2号信号置为"无效"
{
sigprocmask(SIG_UNBLOCK, &blocked_set, NULL);
printf("blocked no signal:2\n");
}
}
}
问题:若当前进程处于阻塞状态,则此时到来的信号能否被处理?
答:信号会打断进程当前的阻塞状态去处理信号,即此时到来的信号是会马上去处理的。比如当我们想用SIGKILL(9)信号杀死一个进程时,不会因为该进程是阻塞状态而稍后在杀死它。
信号具有以下三种操作方式:
SIG_IGN
常数信号函数的忽略。但SIGKILL和SIGSTOP信号不能忽略。SIG_DFL
常数表示信号函数的默认值。对大多数信号来说,系统的默认动作是终止该进程。SIGKILL(9)和SIGSTOP(19)的几点说明
SIGKILL提供给管理员杀死进程的权利, SIGSTOP提供给管理员暂停进程的权利,所以这两个信号不能被忽略、重定义和阻塞。这两个信号是管理员对进程握有的最后两张底牌,假设有一个恶意程序忽略或重定义了所有信号,这时我们可以使用这两个信号来控制或杀死恶意程序。
一个进程无法被kill杀死的可能有哪些?
关于信号的默认处理方式,下表列出了常见信号的默认处理方式说明:
信号 | 信号说明 |
---|---|
SIGINT(2) | 来自键盘的中断信号(ctrl + c) |
SIGQUIT(3) | 来自键盘的中断信号,并设置core dump。(ctrl + \) |
SIGABRT(8) | 异常终止进程 |
SIGFPE(8) | 浮点异常信号(例如浮点运算溢出) |
SIGKILL(9) | 该信号强制结束接收信号的进程 |
SIGSEGV(11) | 进程发生内存访问错误 |
SIGPIPE(13) | 管道读端关闭,系统会向写端进程发送该信号并终止写端进程 |
SIGALRM(14) | 进程的定时器到期时,发送该信号 |
SIGCHLD(17) | 标识子进程停止或结束的信号 |
SIGSTOP(19) | 来自键盘(ctrl + z)或调试程序的停止执行信号 |
更多的关于信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明入命令man 7 signal
可查看:
什么是用户态?什么是内核态?
内核态:当一个任务(进程)执行指令发生系统调用、中断、异常而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核区空间。
用户态:当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。
为什么要区分用户态和内核态?
出于安全的考虑:在操作系统中有一些较危险指令,应交由受信任的内核来完成。(比如涉及到对底层硬件的访问修改操作时),这样就可以确保用户程序不能随便操作系统的数据,这样防止用户程序误操作或者是恶意破坏系统。
用户态、内核态的切换时间?
用户态切换到内核态:
内核态切换回用户态:
在前边说过,当进程在收到一个信号后并不一定会立即处理,如果不是非常紧急的信号是不会立即进行处理的,而是等到一个合适的时机才会处理。而这个合适的时机又是什么时候呢?
当进程由于中断、异常或系统调用而进入到内核,处理完成准备再次切换到用户态去继续执行主控制流程时,操作系统会检测该进程的pending表和bolcked表,看是否有信号需要处理。
问题1:当信号处理方式为自定义时为什么要特意返回到用户态去处理,而不在内核态中处理?
答:内核态的权限是不受限制的,理论上可以在内核态中执行用户态的信号自定义处理函数,但是如果这个自定义处理函数中有一些危险或越权行为,内核态是不会检测直接执行的,所以再切回到用户态指向是为了保证安全性。
问题2:为什么进程不在收到信号的时候就立即执行对信号的处理呢?
答:如果要去处理信号,就得将当前进程挂起(进程切换),而进程切换是需要保存上下文信息等,还要为了处理信号而切换到内核态去,这样开销是太大了,进程执行的好好的本来就不愿意被信号所打扰,结果还要浪费很多时间和精力去处理它,进程肯定是不愿意的。所以,选择在进程从内核态正要切换回用户态的时候再处理,这是一种很节约处理成本的操作。
问题3:所有的信号处理方式都是在用户态完成信号捕捉的,对还是不对?
答:不对,只有自定义处理方式的信号会在用户态进行处理。忽略和默认都是在内核态执行完成并把pending表中对应信号位置置为“无效”。
在文章一开始有简单介绍了signal函数自定义处理信号,下面完整介绍siganl函数
头文件:#include <signal.h>
函数原型:sighandler_t signal(int signum, sighandler_t handler);
typedef void (*sighandler_t)(int);
函数说明:设置信号处理方式,signal()会依参数signum指定的信号集编号来设置信号的处理函数。当指定的信号到达时,就会跳转到参数handler指定的函数执行。
返回值:
参数:
附加说明:
在UNIX环境中,在信号发生跳转到自定义的handler函数执行后,系统会自动将此处理函数换回原来系统预设的默认处理方式,如果要改变此情形,则要用sigaction函数。造Linux环境中不存在此问题。
sigaction函数用来查询和设置信号处理方式,它是用来替换早期的signal函数。sigaction函数的头文件和函数原型如下:
函数说明:sigaction()会依参数signum指定的信号编号来设置该信号的处理函数。
函数返回值:
参数:
signum:可以指定SIGKILL和SIGSTOP以外的所有信号。
第二个参数act用来设置对特定信号的操作方式。
第三个参数oldact如果不是NULL指针,则原来的信号处理方式会由此参数返回。
第二、三个参数都是struct 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);
}
下面我们来逐一解释struct sigaction
结构体的成员:
sigaction函数使用举例
我们使用sigaction函数自定义捕捉2号信号,在自定义捕捉函数中又利用oact恢复之前的2号信号的处理方式。即我们想要达到的效果是第一次按ctrl + c执行信号自定义捕捉函数,第二次按ctrl + c后执行2号信号的默认处理方式,即直接终止进程。
#include
#include
#include
struct sigaction act, oact;
// 在自定义处理函数中恢复对2号信号原来的处理动作(即默认处理动作)
void Handler(int signo)
{
printf(" handler signal:%d\n", signo);
sigaction(2, &oact, NULL);
printf("recover signal:2\n");
}
int main()
{
// 1、对sigaction的第二个参数的成员进行初始化(主要设置了一个自定义捕捉函数)
act.sa_handler = Handler;
sigemptyset(&act.sa_mask);
act.sa_restorer = NULL;
act.sa_flags = 0;
// 2、调用sigaction函数来自定义处理2号信号
sigaction(2, &act, &oact);
// 3、死循环打印一句话
while(1)
{
printf("I am runing\n");
sleep(1);
}
return 0;
}
volatile中文含义是易失的。它用于修饰一个变量,保证该变量在保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
下面程序自定义捕捉了2号信号并修改了全局变量flag的值,当我们发送2号信号时会影响到主控制流的运行逻辑:
#include
#include
int flag = 0;
void Handler(int signo)
{
flag = 1;
printf(" flag already set to 1\n");
}
int main()
{
signal(2, Handler);
while(!flag)
{}
printf("process ending\n");
return 0;
}
编译运行,原本主控制流一直死循环,我们发送2号信号后执行它的自定义处理函数修改了全局变量flag的值,导致主控制流的死循环终止:
当然现在一切都很正常,对于该程序的flag全局变量,我们还可以对其深入分析:
综上,CPU考虑优化flag变量,可以把它的值放到CPU的寄存器中,这样每次CPU需要拿flag变量出来运算时直接从寄存器拿而不用到该变量的真实物理空间(内存)中去拿了,CPU这样的优化可以提高效率。
我们使用gcc命令时默认gcc的优化级别是O1,这个优化级别算比较低的,也就是说我们的flag并没有被放到寄存器中。前面我们使用的编译命令如下,源文件叫myproc.c,目标文件叫myproc:
gcc -std=c99 -std=gnu99 -g myproc.c -o myproc
通过man gcc
命令查看可以查看到gcc优化级别的选项:
接下来我们修改编译命令,加上-O2
选项来提高gcc编译的优化级别:
gcc -std=c99 -std=gnu99 -g -O2 myproc.c -o myproc
我们使用上面的命令编译前面的代码,可以先设想一下:由于优化级别提高了,先前flag的值为0被保存到CPU寄存器中,即使捕捉到了信号也确实修改了flag在内存中的值变为1,但在主控制流拿到flag变量的值是先前经过优化保存到CPU寄存器中的0,所以不论发送多少次2号信号,主控制流永远都在执行死循环:
这个时候volatile就派上用场了,它用于修饰一个变量,保证该变量在保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
我们给全局变量flag加上volatile关键字,让flag不要被优化:
再次编译运行,观察到发送2号信号,主控制流中拿到的flag的值是被Handler函数修改后的内存中的值,所以终止了主控制流中的死循环:
SIGCHLD(17)信号是跟子进程有关的,子进程在以下三种情形会发送SIGCHLD信号给父进程:
SIGCHLD的默认处理方式是什么都不干;另外我们在递达函数中可以设置SIG_IGN选项表示忽略该信号,这里的忽略含义是通知内核父进程对子进程的结束不关心,子进程结束后由内核负责回收,父进程不关心子进程的退出。
下面代码使用signal函数自定义捕捉SIGCHLD(17)信号:
#include
#include
#include
#include
#include
void Handler(int signo)
{
printf("No.%d receive siganl:%d\n", getpid(), signo);
}
int main()
{
signal(17, Handler);
pid_t id = fork();
if(id == 0)// child
{
printf("I am child,pid is:%d,ppid is:%d\n", getpid(), getppid());
return 1;
}
waitpid(-1, NULL, 0);
return 0;
}
编译运行,发现子进程退出后给它的父进程发送了SIGCHLD(17)信号:
子进程执行结束之后,父进程如果不对其进行回收,子进程就会变为僵尸进程。父进程可以通过调用wait()函数和waitpid()函数去回收子进程。
由于子进程结束时会发送SIGCHLD信号给父进程,不过此信号的默认动作为忽略,我们可以通过系统函数sigaction()设置信号捕捉,在信号捕捉函数中去回收子进程。下面是几点注意事项:
#include
#include
#include
#include
#include
void sys_err(char* str)
{
perror(str);
exit(1);
}
void Handler(int signo)
{
int status;
pid_t pid;
while ((pid = waitpid(0, &status, WNOHANG)) > 0)
{
if (WIFEXITED(status))
printf("---------------------------child %d exit %d\n", pid, WEXITSTATUS(status));
else if (WIFSIGNALED(status)) printf("child %d cancel signal %d\n", pid, WTERMSIG(status));
}
}
int main()
{
pid_t pid;
int i;
for (i = 0; i < 10; i++)
{
if ((pid = fork()) == 0)
break;
else if (pid < 0)
sys_err("fork");
}
if (pid == 0) //子进程
{
int n = 1;
while (n--)
{
printf("child ID %d\n", getpid());
sleep(1);
}
return i + 1;}
else if (pid > 0) //父进程
{
// 1、父进程主执行流一开始就先把SIGCHLD阻塞
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 17);
sigprocmask(SIG_BLOCK, &set, &oset);
// 2、自定义捕捉子进程终止时向父进程发送的17号信号
struct sigaction act;
act.sa_handler = Handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, NULL);
// 3、设置完SIGCHLD信号的信号捕捉函数之后再解除对该信号的阻塞
sigprocmask(SIG_SETMASK, &oset, NULL);
// 4、父进程等待接收SIGCHLD信号
while (1)
{
printf("parent ID %d\n", getpid());
sleep(1);
}
}
return 0;
}
不可重入函数:函数不可重入指的是函数中可以在不同的执行流中调用函数会出现数据二义问题。
可重入函数:函数可重入指的是函数中可以在不同的执行流中调用函数而不会出现数据二义问题。
总结:函数是否可重入的关键在于函数内部是否对全局数据进行了非原子操作。
不可重入函数被中断的话,可能会出现问题,看下面程序:
可以看出在进程主控程序的insert函数未执行完时(在执行完p->next=head时,收到一个信号,需要立即去处理信号),马上内核接着调用了insert函数来将node2节点插入链表。预期结果应该是node1与node2两个节点都插入链表中形成一个单链表,但由于insert函数中的变量head为全局变量,因此函数的两次调用都可以对同一个变量修改,其修改的顺序不同会造成结果不同,最终导致达不到预期的结果。因此,insert函数是不可重入函数,重入调用,会导致意外结果呈现。究其原因,是该函数内部实现使用了全局变量。