深入解析Linux信号处理机制
目录
一:信号保存
1.1信号其他相关常见概念
1.2在内核中的表示
1.3 sigset_t
1.4信号集操作函数
1.4.1 sigprocmask
1.4.2 sigpending
二:信号捕捉
2.1信号捕捉的流程
2.2sigaction
2.3操作系统是怎么运行的
2.3.1硬件中断
2.3.2时钟中断
2.3.3死循环
2.3.4软中断
2.4 用户态和内核态
三:可重入函数
四:volatile
五:SIGCHLD信号
一:信号保存
上一篇文章信号的产生,有五种方式:键盘,指令,系统调用,软件条件,异常
信号的捕捉有三种方式:忽略,默认,自定义
下一个阶段:
1.1信号其他相关常见概念
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。(阻塞特定的信号之后,信号产生了,把信号进行pending保存,永远不传递,除非我们解除阻塞)
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
1.2在内核中的表示
信号在内核中的表示意图
你怎么能识别信号呢?识别信号是内置的,进程识别信号,是内核程序员写的内置特性。
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有⼀个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻 塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,⼀旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
POSIX.1允许系统递送该信一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在队列。
1.3 sigset_t
从上图来看,每个信号只有⼀个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
1.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清零,表示该信号集不包含任何有效信号。
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
注意:在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断⼀个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
1.4.1 sigprocmask
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改 进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号屏蔽字 备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了 how参数的可选值。(把老的拷贝到上层方便后续回复信号)
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
1.4.2 sigpending
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1
获得对应的pending集,信号发生的五种方式都在改变pending表
pending表中的1是在信号开始处理之前都置为0,然后处理完信号之后,再将对应信号的0置为1,如果是处理完之后再处理pending表,无法确定这个1是信号处理之前就有的,还是这次信号的
使用上述函数,进行小实验:
#include <iostream>
#include <vector>
#include <functional>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>void PrintPending(sigset_t& pending)
{std::cout <<"curr process [ "<<getpid()<<" ] pending:"<<std::endl;for(int signo = 31;signo >=1;signo--){if(sigismember(&pending,signo)){std::cout << "1";}else{std::cout << "0";}}std::cout << std::endl;
}void Handler(int signo)
{std::cout <<signo<<"号信号被递达"<<std::endl;std::cout <<"#########################"<<std::endl;sigset_t pending;sigpending(&pending);PrintPending(pending);std::cout <<"#########################"<<std::endl;}int main()
{//1.捕捉2号信号signal(2,Handler);//自定义捕捉//2.屏蔽2号信号sigset_t block_set,old_set;sigemptyset(&block_set);//初始化sigemptyset(&old_set);sigaddset(&block_set,SIGINT);//添加2号信号,有没有完成内核中block表的修改???//3.向阻塞表中添加2号信号sigprocmask(SIG_BLOCK,&block_set,&old_set);//此处真正的完成对内核block表的修改,屏蔽2号信号//4.获取当前进程的pending表int cnt = 15;while(true){//获取当前pending表sigset_t pending;sigpending(&pending);//打印pending表PrintPending(pending);cnt--;if(cnt == 0){std::cout << "解除对2号信号的屏蔽!!!" <<std::endl;sigprocmask(SIG_SETMASK,&old_set,&block_set);}sleep(1);}return 0;
}
二:信号捕捉
2.1信号捕捉的流程
处理信号,立即处理吗?我可能正在做优先级更高的事情,不会立即处理?什么时候?合适的时候。没有处理--->信号到来--->进程记录下来的对应的信号--->处理
进程在从内核态切换到用户态的时候,检测当前的pending和block表,决定是否处理,handler表处理信号方法。
1.信号捕捉方法执行的时候,为什么还要做权限的切换,直接内核执行完就完了吗?
如果在内核执行完,会有安全风险,因为handler是用户自己定的函数,如果让内核执行这个代码,如果里面有非法操作(删root用户,修改自己权限……),此时会有安全风险
2.为什么信号处理完,只能从内核返回
main 函数和我们写到handler函数没有调用关系,内核调用完handler函数弹栈之后,没办法返回直接返回main函数,而main函数与内核有调用关系,通过调用完内核可以弹栈返回main函数。所以内核调用handler,调用完了,返回到内核,main调用内核,内核调用完之后,弹栈返回main
3.OS怎么知道返回main函数之后,继续执行下一条代码?pc指针
PC指针保存当前正在执行的下一条指令的地址,处理异常时,OS保存PC指针,处理完后回复 PC指针
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
用户程序注册了 SIGQUIT 信号的处理函数 sighandler 。
当前正在执行 main 函数,这时发生中断或异常切换到内核态。
在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。
内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数, sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
sighandler 函数返回后自动执行特殊的系统调用sigreturn 再次进入内核态。
如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。
2.2sigaction
#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结构体
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统 默认动作,赋值为一个函数指针表示自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带⼀个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags字段包含⼀些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。
处理2号信号的时候,屏蔽3-7号信号:
信号处理支持串行处理,处理完2号信号,再处理其他信号
但是OS不允许信号进行嵌套处理,某一个信号正在处理,OS会自动把block位置为1,信号处理完之后再将信号置为1
2.3操作系统是怎么运行的
2.3.1硬件中断
中断向量表就是操作系统的⼀部分,启动就加载到内存中了
通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
由外部设备触发的,中断系统运行流程,叫做硬件中断
2.3.2时钟中断
进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢? 外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的 设备?
进程调度 --->时钟中断,IO--->外设中断信号
这样,操作系统不就在硬件的推动下,自动调度了么!!!
2.3.3死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可.操作系统的本质:就是⼀个死循环!操作系统就是基于中断向量表进行工作的。OS就是在硬件的调度下,自动调度的
2.3.4软中断
上述外部硬件中断,需要硬件设备触发。
有没有可能,因为软件原因,也触发上面的逻辑?有!
为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(可以写在我们的代码里)例如:int (老版使用)或 syscall(旧版本使用),可以让CPU内部触发中断逻辑。系统调用也是通过中断完成的!!!
所以:
用户层怎么把系统调用号给操作系统?寄存器(比如EAX)
操作系统怎么把返回值给用户?寄存器或者用户传入的缓冲区地址
系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执 行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法。
可是为什么我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall 呢?都是直接调用上层的函数的啊?
那是因为Linux的gnuC标准库,给我们把几乎所有的系统调用全部封装了。
#define SYS_ify(syscall_name) __NR_##syscall_name :是⼀个宏定义,用于将系统调用的名称转换为对应的系统调用号。
比如: SYS_ify(open) 会被展开为__NR_open
而系统调用号,不是 glibc 提供的,是内核提供的,内核提供系统调用入口函数 man 2 syscall ,或者直接提供汇编级别软中断命令 int or syscall ,并提供对应的头文件或者开发入口,让上层语言的设计者使用系统调用号,完成系统调用过程
缺页中断(虚拟内存有对应的地址,但是物理内存没有对应地址),内存碎片处理,除零,野指针都会被转化为CPU内部的软中断。CPU内部软中断分为两类:一类是陷阱:int 0x80 / syscall
一类是异常:除零 野指针……OS就是躺在中断处理历程上的代码块。
2.4 用户态和内核态
1.内核页表只有一份,用户页表不同的进程各自持有一份
2.操作系统无论怎么切换进程,都能找到同一个操作系统!操作系统系统调用方法的执行是在进的地址空间执行的,而用户调用系统调用(函数,库)也是在自己的地址空间上调用的。即对于任何进程,无论如何调度,任何进程最终都会找到同一个OS
3.不管是通过哪一个进程的地址空间进入内核,都是通过软中断进入OS的。
在硬件方面:CPU知道自己处于用户态还是内核态,CS段寄存器中的CPL,0表示内核,3表示用户,CPU自己会决定要不要调用int 0x80
软件层面:系统调用:int 0x80 / syscall
4.用户是如何进入内核?1.时钟中断 2.异常 3.陷阱(int 0x80/ syscall)
用户态和内核态是一直来回切换的
5.OS是怎么运行的?
OS内核固定历程,OS会自己fork一堆进程,然后和普通进程一样,调度这些进程
三:可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第⼀步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同⼀个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第⼀步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入一个节点,而最后只有⼀个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。
如果一个函数符合以下条件之一则是不可重入的:
1.调用了malloc或free,因为malloc也是用全局链表来管理堆的。
2.调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。(之前匿名管道里面父子进程不能同时向执行流写入,否则打印乱序出错进程间通信:管道与共享内存-CSDN博客)
我们C++中学的STL中的接口基本都是不可重入函数,里面会使用全局资源扩容
一般在命名上面,函数名带_r的是可重入函数
四:volatile
标准情况下,CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,while条件不满足,退出循环,进程退出
优化情况下,键入CTRL-C ,2号信号被捕捉,执行自定义动作,修改flag=1 ,但是while条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显, while 循环检查的flag,并不是内存中最新的flag,这就存在了数据⼆异性的问题。while检测的flag其实已经因为优化(寄存器+优化 = 屏蔽内存的可见性),被放在了CPU寄存器当中, !flag 也是逻辑运算,也是CPU做。如何解决呢?很明显需要 volatile
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
register:建议把内容优化到寄存器,只检测寄存器不再看内存
五:SIGCHLD信号
进程中讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查 询是否有子进程结束等待清理(也就是轮询的方式)。
采用第⼀种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
void Handler(int signo)
{printf("I get a signal,signalnum : %d\n", signo);pid_t rid;while ((rid = waitpid(rid, nullptr, WNOHANG)) > 0){printf("Wait child sucess,id %d\n", rid);}printf("child is quit,id : %d", getpid());
}int main()
{signal(SIGCHLD, Handler);pid_t id = fork();if (id < 0){perror("fork");exit(2);}else if (id == 0){printf("I am child process ,pid : %d\n", getpid());sleep(3);exit(1);}else{while (1){printf("I am father process, I am doing something\n");sleep(1);}}
}
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外⼀种办法:父进程调用sigaction将 SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程, 也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
子进程退出时,主动向父进程发出SIGCHLD信号,实现父子异步,发出信号之后回收,不需要父进程再阻塞回收。但是上面父进程使用信号回收子进程的代码还存在其他问题:
1.如果有n个信号,n个进程同时退出,向父进程发出信号,位图只会记录一次,即使不同时到达,最多也只能记录两个,一个正在处理,一个到达,之后的信号就会被屏蔽,所以存在风险
解决办法:循环回收,回收完n个进程之后,第n+1次时不确定进程个数,依然回收,此时判断
2.如果我们使用阻塞式回收呢?假设有10个进程,前6个退出,在进程走第7个进程时,就会阻塞等待第7个进程退出,如果第7个进程不退出,就会陷入死循环,第7个后面的进程退出,也回收不到
void Handler(int signo)
{printf("I get a signal,signalnum : %d\n", signo);while(true){pid_t rid = waitpid(-1,nullptr,WNOHANG);if(rid > 0){std::cout << "回收子进程成功,child id is :"<<rid<<std::endl;}else if(rid == 0){std::cout << "子进程已经全部回收完成"<<std::endl;break;}else{std::cout << "wait error"<<std::endl;break;}}}int main()
{::signal(SIGCHLD, Handler);for(int i = 1; i <= 10;i++){if(fork() == 0){sleep(5);printf("子进程退出,pid: %d\n",getpid());exit(0);}}while(true){sleep(1);}
}