一. 什么是信号
1. 信号概念
信号是进程之间事件异步通知的一种方式,是一个软中断,当某进程收到信号时,会中止当前程序的执行而去处理信号,然后回到断点继续往下执行。
输入命令kill -l
可以查看所有信号,Linux系统中一共有62个信号,其中1 ~ 31号信号是普通信号;34 ~ 64号信号是实时信号,在实际应用的编程中,用到的主要还是普通信号:
每个信号都有一个对应的编号,在系统中信号的名称其实是每一个编号宏定义出来的常量,相关信息可以在/usr/include/bits/signum.h
这个文件中找到:
2. 我们使用过的信号
在刚开始学习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; }
运行一段时间后,按ctrl + c来最终这个进程:
3. signal函数自定义处理信号
信号的处理有以下三种方式:
- 默认
- 自定义
- 忽略
关于这三种处理方式最后部分会做详细介绍,我们这里先简单介绍一下signal函数,它是自定义处理信号的函数之一。
signal函数第一个参数传入我们要自定义处理的信号的编号(宏名称也行),第二个参数传入一个我们自由定的sighandler_t类型的函数。
头文件:#include 函数原型: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号信号都被我们以自定义的方式处理了:
二. 为什么要有信号
操作系统通过信号告诉进程发生了某个事件,打断进程当前的操作,去处理这个事件。
三. 信号的生命周期
下面我们根据信号的生命周期来了解信号相关的使用方法和它的特点。
1. 信号产生
1.1 键盘按键产生信号
通过键盘按键产生:用户在终端下按下某些键时,终端驱动程序会发送信号给前台进程。
我们可以通过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。
键盘按键产生信号总结
ctrl+c - SIGINT(2) - 终止进程。
ctrl+\ - SIGQUIT(3) - 终止进程并发生核心转储。
ctrl+z - SIGTSTP(19) - 使进程变为T状态
1.2 系统函数产生信号
1.2.1 kill命令和kill函数
使用kill -信号 进程pid向指定进程发送指定的信号。在一个终端执行死循环不断打印自己进程的pid,另一个终端使用kill命令发送SIGKILL(9)信号杀死这个死循环进程:
kill命令是调用kill函数实现的。kill函数是将信号发送给指定的pid进程。普通用户利用kill函数将信号发送给该用户下任意一个进程,而root用户可以将信号发送给系统中任意一个进程。
kill函数原型及其说明如下:
使用kill函数模拟实现kill命令
左边程序执行死循环打印自己进程的pid,右边程序模拟实现kill命令:
两个程序分别编译运行,用我们自己模拟的kill命令发送9号信号杀死左边进程:
1.2.2 raise函数给自己发送信号
raise函数原型及其说明如下:
raise函数使用举例
使用raise函数每隔一秒向自己发送一个SIGINT(2)信号,并自定义处理捕捉到该信号:
#include <stdio.h> #include <unistd.h> #include <signal.h> 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; }
编译运行:
1.2.3 abort函数使当前进程接收到信号而异常终止
abort函数原型及其说明如下:
abort函数使用举例
仅仅使用这个函数,向自己发送SIGABRT(6)信号赖异常终止自己进程:
#include <stdio.h> #include <stdlib.h> #include <signal.h> int main() { abort(); return 0; }
编译运行
如果有对SIGABRT注册了捕获函数,那么会先执行捕获函数,捕获函数执行后如果依然进程没有退出,那么恢复捕获函数为默认(终止),然后再次发送SIGABRT给进程。
#include <stdio.h> #include <stdlib.h> #include <signal.h> void Handler(int signo) { printf("recieve signo:%d\n", signo); } int main() { signal(6, Handler); abort(); return 0; }
编译运行
捕获函数执行后进程退出(不论异常或正常退出),abort函数就不会再次发送SIGABRT给进程:
#include <stdio.h> #include <stdlib.h> #include <signal.h> void Handler(int signo) { printf("recieve signo:%d\n", signo); exit(1);// 终止进程 } int main() { signal(6, Handler); abort(); return 0; }
编译运行:
1.3 软件条件产生信号
1.3.1 管道通信相关的SIGPIPE(13)信号
当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> 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; }
编译运行
1.3.2 alarm函数产生的SIGALRM(14)信号
alarm也称为闹钟函数,它可以在进程中设置一个定时器,当定时器指定的时间到时,它向进程发送SIGALRM(14)信号。可以设置忽略或者不捕获此信号,如果采用默认方式其动作是终止调用该alarm函数的进程。
alarm函数原型及其说明如下:
alarm函数使用举例
使用alarm函数实现一个计时器功能,5秒后终止当前进程运行:
#include #include int main() { alarm(5); int count = 0; while(1) { printf("count = %d\n", count++); sleep(1); } return 0; }
编译运行
1.4 硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
模拟一下野指针异
#include <stdio.h> int main() { printf("***** begin *****\n"); int* p = NULL; *p = 100; printf("***** end *****\n"); return 0;; }
编译运行
另外我们可以自定义捕捉野指针异常发送的SIGSEGV(11)信号:
#include <stdio.h> #include <stdlib.h> #include <signal.h> 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++当中除零,内存越界等异常,在系统层面上,是被当成信号处理的。
2. 信号阻塞
2.1 信号的生命周期概述
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)
进程可以选择阻塞 (Block )某个信号
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
2.2 信号相关的内核数据结构
在Linux的进程控制块task_struct中有两个sigset_t类型的数据结构:
pending(未决)信号集
blocked(阻塞)信号集
关于它们两个的类型sigset_t,可以把它理解为是一张位图。每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态:
- 在未决信号集中,bit位的位置表示是哪个信号,bit位的值1/0表示当前进程是否收到该信号。
- 在阻塞信号集中,bit位的位置表示是哪个信号,bit位的值1/0表示该信号是否被阻塞。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
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)、自定义处理。另外,信号在合适的时候才会执行处理动作,什么是"合适的时候"?这个在下面信号递达中会解释。
三个数据结构之间的相互作用
每个信号都有两个标志位分别表示阻塞(blocked)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。这里不讨论实时信号。
2.3 信号集操作函数
2.3.1 设置信号集的函数
以下5个函数可以用来设置sigset_t类型的信号集bit位上的值,它们可以作用于未决(pending)信号集合阻塞(blocked)信号集:
每个函数的作用:
- sigemptyset函数:常用来初始化set所指向的信号集,使其中所有信号的对应bit位清零,表示该信号集不包含任何有效信号。
- sigfillset函数:使set所指向的信号集中所有信号的对应bi位置1,表示该信号集的有效信号包括系统支持的所有信号。
- sigaddset函数:在set所指向的信号集中添加某种有效信号。
- sigdelset函数:在set所指向的信号集中删除某种有效信号。
- sigismember函数:判断在set所指向的信号集中是否包含某种信号。
使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset函数对其做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
2.3.2 查询未决信号集的函数 — sigpending
sigpending函数读取当前进程的未决信号集,并通过set参数传出。
sigpending函数使用举例
以位图的方式模拟打印进程的未决信号集:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <signal.h> 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、信号生命终止。进程在执行信号相应处理函数之前,首先要把信号在进程中注销,进程注销信号后,立即执行相应的信号处理函数,执行完毕后,信号的本次发送对进程的影响彻底结束。
最后要说的是:在信号被注销到相应的信号处理函数执行完毕这段时间内,如果进程又收到同一信号多次,则对实时信号来说,每一次都会在进程中注册;而对于非实时信号来说,无论收到多少次信号,都会视为只收到一个信号,只在进程中注册一次。
2.3.3 设置信号阻塞集的函数 — sigprocmask
我们在自己定义并操作的sigset_t类型的信号集变量是存储在用户空间的栈上的,也就是说我们对这个信号集变量的操作并没有同步到进程真正的阻塞信号集中。
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集):
参数解释
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的阻塞信号集为mask,下表说明了how参数的可选值:
sigprocmask函数使用举例
我们一开始先阻塞2号信号,之后一直循环打印进程的未决信号集。
两秒钟后调用raise函数给自己进程发送2号信号,可以观察到未决信号集的第二个bit位变为1了,其余都为0。
再经过两秒钟后解除对2号信号的阻塞,因为前面已经接收到了2号信号,阻塞解除后我们使用自定义方法处理2号信号。
处理完成后系统会自动把进程未决信号集中代表2号信号的位置置为“无效”。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <signal.h> 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)信号杀死一个进程时,不会因为该进程是阻塞状态而稍后在杀死它。
3. 信号递达
3.1 信号的三种处理方式
信号具有以下三种操作方式:
- 忽略此信号,
SIG_IGN
常数信号函数的忽略。但SIGKILL和SIGSTOP信号不能忽略。 - 捕捉信号。在某种信号发生时,调用一个用户自定义函数,在用户自定义函数中可执行用户希望对这个事件进行的处理。同样SIGKILL和SIGSTOP信号不能被捕捉。
- 执行系统默认的动作,
SIG_DFL
常数表示信号函数的默认值。对大多数信号来说,系统的默认动作是终止该进程。
SIGKILL(9)和SIGSTOP(19)的几点说明
SIGKILL提供给管理员杀死进程的权利, SIGSTOP提供给管理员暂停进程的权利,所以这两个信号不能被忽略、重定义和阻塞。这两个信号是管理员对进程握有的最后两张底牌,假设有一个恶意程序忽略或重定义了所有信号,这时我们可以使用这两个信号来控制或杀死恶意程序。
一个进程无法被kill杀死的可能有哪些?
- 该进程是僵尸进程。僵尸进程因为已经退出,因此不做任何信号相关的处理。
- 该进程当前状态是停止状态。进程停止运行,则将不再处理信号。
3.2 信号的默认处理方式
关于信号的默认处理方式,下表列出了常见信号的默认处理方式说明:
更多的关于信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明入命令man 7 signal
可查看:
3.3 信号的处理
3.3.1 用户态和内核态
什么是用户态?什么是内核态?
内核态:当一个任务(进程)执行指令发生系统调用、中断、异常而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核区空间。
用户态:当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。
为什么要区分用户态和内核态?
出于安全的考虑:在操作系统中有一些较危险指令,应交由受信任的内核来完成。(比如涉及到对底层硬件的访问修改操作时),这样就可以确保用户程序不能随便操作系统的数据,这样防止用户程序误操作或者是恶意破坏系统。
用户态、内核态的切换时间?
用户态切换到内核态:
- 执行系统调用时
- 处理中断、异常时
内核态切换回用户态:
- 系统调用结束
- 中断、异常处理完毕
3.3.2 信号处理过程
在前边说过,当进程在收到一个信号后并不一定会立即处理,如果不是非常紧急的信号是不会立即进行处理的,而是等到一个合适的时机才会处理。而这个合适的时机又是什么时候呢?
当进程由于中断、异常或系统调用而进入到内核,处理完成准备再次切换到用户态去继续执行主控制流程时,操作系统会检测该进程的pending表和bolcked表,看是否有信号需要处理。
没有信号需要处理:
有信号需要处理,处理方式为默认或忽略:
当信号需要处理,处理方式为自定义时:
问题1:当信号处理方式为自定义时为什么要特意返回到用户态去处理,而不在内核态中处理?
答:内核态的权限是不受限制的,理论上可以在内核态中执行用户态的信号自定义处理函数,但是如果这个自定义处理函数中有一些危险或越权行为,内核态是不会检测直接执行的,所以再切回到用户态指向是为了保证安全性。
问题2:为什么进程不在收到信号的时候就立即执行对信号的处理呢?
答:如果要去处理信号,就得将当前进程挂起(进程切换),而进程切换是需要保存上下文信息等,还要为了处理信号而切换到内核态去,这样开销是太大了,进程执行的好好的本来就不愿意被信号所打扰,结果还要浪费很多时间和精力去处理它,进程肯定是不愿意的。所以,选择在进程从内核态正要切换回用户态的时候再处理,这是一种很节约处理成本的操作。
问题3:所有的信号处理方式都是在用户态完成信号捕捉的,对还是不对?
答:不对,只有自定义处理方式的信号会在用户态进行处理。忽略和默认都是在内核态执行完成并把pending表中对应信号位置置为“无效”。
3.4 信号递达的相关函数
3.4.1 signal函数
在文章一开始有简单介绍了signal函数自定义处理信号,下面完整介绍siganl函数
头文件:#include 函数原型:sighandler_t signal(int signum, sighandler_t handler); typedef void (*sighandler_t)(int);
函数说明:设置信号处理方式,signal()会依参数signum指定的信号集编号来设置信号的处理函数。当指定的信号到达时,就会跳转到参数handler指定的函数执行。
返回值:
成功:返回先前的信号处理函数指针。
出错:SIG_ERR(-1)
参数:
- signum:指定信号编号。
- handler:
- SIG_ING:忽略参数signum指定的信号。
- SIG_DFL:使用参数signum默认的处理方式。
自定义信号函数处理指针。
附加说明:
在UNIX环境中,在信号发生跳转到自定义的handler函数执行后,系统会自动将此处理函数换回原来系统预设的默认处理方式,如果要改变此情形,则要用sigaction函数。造Linux环境中不存在此问题。
3.4.2 sigaction函数
sigaction函数用来查询和设置信号处理方式,它是用来替换早期的signal函数。sigaction函数的头文件和函数原型如下:
函数说明:sigaction()会依参数signum指定的信号编号来设置该信号的处理函数。
函数返回值:
成功:0
出错:-1
参数:
- 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
结构体的成员:
- sa_handler:此参数和signal()的参数handler相同,此参数主要用来对信号旧的安装函数signal()处理形式的支持。
- sa_sigaction:新的信号安装机制,处理函数被调用的时候,不但可以得到信号编号,而且可以获悉被调用的原因以及产生问题的上下文的相关信息。
- sa_mask:用来设置在处理该信号时暂时将sa_mask指定的信号搁置。
- sa_restorer: 此参数没有使用。
- sa_flags:用来设置信号处理的其他相关操作,下列的数值可用。可用或运算(|)组合:
- A_NOCLDSTOP:如果参数signum为SIGCHLD,则当子进程暂停时并不会通知父进程。
- SA_ONESHOT/SA_RESETHAND:当调用新的信号处理函数前,将此信号处理方式改为系统预设的方式。
- SA_RESTART:被信号中断的系统调用会自行重启。
- SA_NOMASK/SA_NODEFER:在处理此信号未结束前不理会此信号的再次到来。
- SA_SIGINFO:信号处理函数是带有三个参数的sa_sigaction
sigaction函数使用举例
我们使用sigaction函数自定义捕捉2号信号,在自定义捕捉函数中又利用oact恢复之前的2号信号的处理方式。即我们想要达到的效果是第一次按ctrl + c执行信号自定义捕捉函数,第二次按ctrl + c后执行2号信号的默认处理方式,即直接终止进程。
#include <stdio.h> #include <signal.h> #include <unistd.h> 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关键字
1. volatile介绍
volatile中文含义是易失的。它用于修饰一个变量,保证该变量在保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
2. 理解volatile的作用
下面程序自定义捕捉了2号信号并修改了全局变量flag的值,当我们发送2号信号时会影响到主控制流的运行逻辑:
#include <stdio.h> #include <signal.h> 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全局变量,我们还可以对其深入分析:
- Handler自定义处理信号函数和main主函数之间不存在被调用与调用的关系,它们是两个独立的控制流。
- 在main主控制流中,编译器看到的是flag变量不会被修改,并且该变量作为while的判断条件一直要被拿来做逻辑运算。
综上,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信号
1. SIGCHLD介绍
SIGCHLD(17)信号是跟子进程有关的,子进程在以下三种情形会发送SIGCHLD信号给父进程:
- 子进程终止
- 子进程接收到SIGSTOP信号停止时
- 子进程处于停止状态,接收到SIGCONT后唤醒
SIGCHLD的默认处理方式是什么都不干;另外我们在递达函数中可以设置SIG_IGN选项表示忽略该信号,这里的忽略含义是通知内核父进程对子进程的结束不关心,子进程结束后由内核负责回收,父进程不关心子进程的退出。
下面代码使用signal函数自定义捕捉SIGCHLD(17)信号:
#include <stdio.h> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <sys/wait.h> 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)信号:
2. 信号捕捉函数中回收子进程
子进程执行结束之后,父进程如果不对其进行回收,子进程就会变为僵尸进程。父进程可以通过调用wait()函数和waitpid()函数去回收子进程。
由于子进程结束时会发送SIGCHLD信号给父进程,不过此信号的默认动作为忽略,我们可以通过系统函数sigaction()设置信号捕捉,在信号捕捉函数中去回收子进程。下面是几点注意事项:
- 在设置SIGCHLD信号的信号捕捉函数之前为了程序的严谨性,要先使用系统函数sigprocmask()去阻塞SIGCHLD信号,在设置完SIGCHLD信号的信号捕捉函数之后再解除阻塞。这样做的原因是:如果我们的子进程先于父进程执行,假如在父进程设置完SIGCHLD的信号捕捉函数之前所有子进程都执行结束了,那么父进程就不会再收到子进程发送的SIGCHLD信号,信号捕捉函数就不会执行,进而回收子进程的系统函数waitpid()就不会被调用,那么就会造成所有的子进程变为僵尸进程。
- 想要回收所有子进程调用waitpid必须使用while循环结构,不能使用if结构。因为在执行SIGCHLD信号捕捉函数期间,如果两个或多个子进程同时结束,那么SIGCHLD信号只记录一次,此时如果使用if结构就会导致同时结束的子进程只回收一个。即SIGCHLD信号捕捉函数只调用执行一次,而里面的waitpid需要函数执行多次,才可以将之前死掉的所有子进程回收。
#include <sys/wait.h> #include <signal.h> #include <stdio.h> #include <unistd.h> #include <stdlib.h> 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; }
编译运行:
六. 可重入函数
1. 相关概念
不可重入函数:函数不可重入指的是函数中可以在不同的执行流中调用函数会出现数据二义问题。
可重入函数:函数可重入指的是函数中可以在不同的执行流中调用函数而不会出现数据二义问题。
总结:函数是否可重入的关键在于函数内部是否对全局数据进行了非原子操作。
不可重入函数被中断的话,可能会出现问题,看下面程序:
可以看出在进程主控程序的insert函数未执行完时(在执行完p->next=head时,收到一个信号,需要立即去处理信号),马上内核接着调用了insert函数来将node2节点插入链表。预期结果应该是node1与node2两个节点都插入链表中形成一个单链表,但由于insert函数中的变量head为全局变量,因此函数的两次调用都可以对同一个变量修改,其修改的顺序不同会造成结果不同,最终导致达不到预期的结果。因此,insert函数是不可重入函数,重入调用,会导致意外结果呈现。究其原因,是该函数内部实现使用了全局变量。
2. 不可重入函数的形成原因
使用了静态数据结构。
函数中使用了malloc()或者free()函数,因为它们是用全局链表来管理堆的。
函数内使用了标准的I/O函数。标准I/O函数中,很多使用了全局数据结构。
3. 预防设计不可重入函数的几点事项
对于可重入函数,函数内部不能含有全局变量和static变量,因为这种变量可以被函数的多次重入调用共同控制,其最终的结果依赖于它们的执行顺序。如果必须访问全局或静态变量,需要利用信号量的进行保护。
可重入函数的定义中也不能使用malloc和free函数。
信号捕捉函数应该设计成可重入函数