信号的捕捉、处理
“合适的时候”处理信号,指的是什么时候?
(根据流程pending->block->handler分析)
什么时候处理信号,一定是在内核态中处理信号,“合适的时候”就是指从内核态返回用户态的时候(即执行完系统调用接口的代码或者内核处理完异常,准备返回用户态时),这个时候进行信号检测(检查pending表)和处理。
为什么会进入内核态,程序执行自己编写的代码的时候是用户态,执行到系统调用接口或者遇到缺陷陷阱异常时,就由用户态切换到内核态。
如何进入内核态或者用户态,在汇编语言上有一个中断编号叫 80,汇编指令为int 80(这里的int不是整型的意思,是中断单词的缩写),用这个中断可以陷入内核态
ecs寄存器
CPU里寄存器有两套,一套可见,一套不可见,供CPU自用。
其中的ECS寄存器是不可被外部可见的,它的最低2位比特位表示当前CPU的执行权限,如果该寄存器最低2位里面是00,表示此时处于内核态;是11(即十进制整数3),表示此时是用户态。
当int 80(陷入内核)时,会把ecs寄存器最低2位比特位由3变为0。
检查最低2位,是否是0,就能给与能访问内核代码的权限
一.进程地址空间中的内核地址空间
有几个进程,就有几个用户级页表,因为进程具有独立性,但是内核级页表只有1份,所有的进程看到的内核地址空间都是相同的。
在进程视角:调用系统中的方法,比如说调用open函数,就是在我自己的进程地址空间中向高地址偏移,到内核地址空间找open函数的具体代码,执行完后再回到用户地址空间的正文代码区,这样在进程看来,我执行系统调用都是在我自己的进程地址空间执行的。
在操作系统视角:任何时刻都有进程执行,任何时刻进程需要执行操作系统的代码时,进程只需来到内核地址空间中找系统调用的虚拟地址,经过内核级页表映射,找到代码的物理内存空间即可,这样进程想执行系统调用,就能随时执行。
二.用户态和内核态
用户态是一个受管控的状态,比如说用户态受访问权限的约束、受到资源限制(有些资源你不能用)
内核态是os执行自己代码的一个状态,不受资源限制或者权限约束,具备非常高的优先级。
每个进程有自己的进程地址空间(不仅有自己的用户地址空间,也有自己的内核地址空间),每个进程的地址空间映射到物理内存的用户级页表不同。
内核地址空间整个操作系统只有一份,还有一个内核级页表,这两个能被所有进程看到。
当进程的时间片到了,底层硬件向os发送时钟中断,os在cpu内找当前正在运行的进程,通过它的进程地址空间找到进程切换的函数,然后在这个进程的地址空间的上下文里进行切换。因为是在这个进程地址空间的上下文进行切换,所以os能直接访问该进程在cpu里的上下文数据,然后把所有数据压入到进程pcb中,进而把进程放下去,换另外一个进程上来。
三.信号的捕捉和处理原理(4次内核态和用户态的切换)
一次信号捕捉并处理的过程
上图可以简化为这样:
可以看出,从信号的捕捉到信号处理完,总共4次内核态和用户态的切换
1.有人可能有疑问,在第3步的时候,做信号处理函数一定会返回用户态吗?
答:不一定的,因为需要检测到当前进程的pending表有信号没处理,且也没有被屏蔽,如果有就会根据handler表,看看是默认处理动作还是忽略,还是自定义动作。
如果是默认动作,在内核态执行信号的默认动作,该终止就终止,该暂停就暂停;
如果是忽略,则在内核态什么都不做,把pending表对应的比特位从1变为0,然后返回用户态,并从上一次中断的地方继续往下执行;
如果是自定义动作,返回用户态,执行信号的自定义处理函数。
2.处于内核态时,为什么不是os执行用户自定义的信号处理函数?
OS有权限执行user handler的方法,但是它不想去执行用户自己编写的任何代码,因为如果用户在自定义函数里写了一些非法操作,比如说盗取信息,以内核态,OS是一定会执行这些非法操作的,所以必须回到用户态来做。
执行自定义的信号处理函数,os会先切换到用户态,在用户态执行用户自定义的信号处理函数。
执行完自定义信号处理函数,os需要从用户态切换到内核态来知道上一次被中断的地方在哪(通过程序计数器知道地点),恢复函数栈帧结构,然后再从内核态切换为用户态,从上次被中断的地方继续向下执行,自此完成信号捕捉动作
信号捕捉除了siganl(int,void()(int))函数外,还有sigaction()
四.信号捕捉–sigaction()函数
注意sigaction名字,在Linux下即是一个结构体的名字,也是一个系统调用。
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。
第一个参数signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体。
第一个参数对应的信号编号,第二个是个结构体,是输入型参数,第三个参数是输出型参数,用来记录老的结构体数据。
sigaction结构体含有的成员变量
struct sigaction act, oact;//定义了两个sigaction结构体变量act和oact。sigaction结构体用于描述信号处理的方式。
act.sa_flags = 0;//将act的sa_flags字段设置为0,表示使用默认的信号处理方式。
sigemptyset(&act.sa_mask);//将act的sa_mask字段清空,表示不屏蔽任何信号
act.sa_handler = handler; //将自定义的信号处理函数handler赋值给act的sa_handler字段,表示当接收到相应的信号时,会调用该函数进行处理。
将sigacntion结构体里的sa_handler赋值为常数SIG_IGN(宏),表示忽略信号;
赋值为常数SIG_DFL表示执行系统默认动作;
赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数类型是返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号。显然,这是一个回调函数。
例子
act.sa_handler = handler
sigaction(2, &act, &oact);
sigaction(2, &act, &oact);
这行代码的作用是设置信号处理函数,在act结构体里由于设置了自定义的信号处理函数(act.sa_handler = handler),2号信号就可以通过sigaction函数和这个信号处理函数关联起来,同时将2号信号的默认信号处理方式(act.sa_handler变量没有被赋值前的处理方式)的结果返回给oact结构体
cout << "default action is:" << (int)(oact.sa_handler) << endl;
语句打印出原先的信号处理方式,以验证是否成功设置了新的信号处理函数。
五.先后接收到同类型信号的处理方法
将2号信号的处理方式注册为自定义处理方式,当一个进程第一次收到2号信号,进程会执行对应的信号处理方式,正在执行的同时,将2号信号加入屏蔽信号集(block表),这时候如果进程再次接收到第二次2号信号,那么这次的2号信号(新发生的相同信号),先把它记录在pending表中,然后阻塞这次的2号信号,直到信号处理函数返回且从信号屏蔽字中移除该信号后(代表上一次2号信号处理完),才会再次触发处理2号信号的流程。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字
可重入函数和不可重入函数
因为进程调度(也叫做因为时许问题)导致的数据丢失、内存泄漏的问题
上面的代码没有问题,插入结点的代码也没有问题,现在我想把node1头插进链表。
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库的很多实现都以不可重入的方式使用全局数据结构。
volatile关键字
编译器如果不执行优化动作,代码中的某个变量需要修改,cpu会访问内存,从内存中读取该变量到寄存器中,然后修改。
当编译器对代码进行了一定程度的优化之后,如果在main函数里发现没有要修改全局变量flag的代码,编译器会自作聪明的把flag第一次的值0放到寄存器里,从此往后cpu就只在cpu里自己检测flag,不访问内存。这时候来了2号信号,信号处理函数把flag值修改为1,但是这修改的是内存里的flag值,cpu里的flag值依然是0。
因为编译器优化的存在,让cpu无法看到内存,引起内存被遮盖的情况。优化是在编译阶段就优化好的,不是在加载到内存的时候才优化的
为了避免这种情况,程序员需要显式的告诉编译器不要优化我的某个子段,cpu必须访问内存,保持内存的可见性,这就是关键字volatile的作用
volatile int flag = 0;
void changeFlag(int signum)
{
cout << "更改flag:" << flag;
flag = 1;
cout << "->" << flag << endl;
}
int main()
{
signal(2, changeFlag);
int time = 10;
while (time--)
sleep(1);
cout << "进程正常退出后,flag为:" << flag << endl;
}
volatile
的作用包括:
- 保证变量的可见性:在多线程或中断服务例程等情况下,若一个变量由不同线程或程序共享,并且可能被异步修改,将其声明为
volatile
可以确保每次访问该变量时都从内存中读取最新值,防止使用缓存中的过期值。 - 防止编译器优化:通常编译器会进行各种优化以提高代码的执行效率,例如将频繁使用的变量缓存在寄存器中。然而,对于被
volatile
修饰的变量,编译器会禁止这种优化,确保每次读写都直接与内存交互,从而在正确的时间点获取正确的值。 - 维持访问顺序:即使在代码优化过程中,编译器对代码进行了重排,
volatile
也能保证对该变量的访问顺序得以保持。
SIGCHLD信号
用于子进程退出,给父进程发送的信号(只有linux这样干,其他环境不确定),不过默认处理动作是忽略。
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。
采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父
进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。请编写程序验证这样做不会产生僵尸进程。
测试代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
pid_t id;
while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
printf("wait child success: %d\n", id);
}
printf("child is quit! %d\n", getpid());
}
int main()
{
signal(SIGCHLD, handler);
pid_t cid;
if((cid = fork()) == 0){//child
printf("child : %d\n", getpid());
sleep(3);
exit(1);
}
while(1){
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}