信号处理的核心机制:从保存、处理到可重入性与volatile
一、信号保存
1. 信号其它常见概念
实际执行信号的处理动作称为信号递达(Delivery)。
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞(Block)某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
。
注:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
。
举个例子:今天上课老师布置了作业,你回到宿舍之后并没有立即写作业,而是开了两把游戏,这个阶段叫做信号未决,之后,你开始写作业,叫做信号递达,你不写作业,这叫做屏蔽(阻塞)信号。
2. 信号的保存
信号是保存在进程PCB里,使用位图的方式来保存信号
,比如说 int pending,4个字节,32个bit,而我们只研究前31个信号,这完全是够用的,pending bit的位置表示信号编号,内容表示是否收到,int block,bit 的位置表示信号编号,内容表示是否阻塞
。
将来这个信号是否递达,需要查看pending bit的内容以及block bit 的内容,如果pending和block的同一个 bit 的内容都是1,表示收到信号了,但是该信号被阻塞了,这个信号就无法递达。
3. 信号具体在内核中是如何保存的
内核中具体是如何实现的呢,来看下面的一张图。
handler是一个函数指针数组,存储的是处理方法的地址
,这张图和我们上述表述的原理是一样的。
结论:进程能识别信号,本质是三张表:pending表,block表,handler表,对信号的操作,本质都是对这三张表的操作
。
在用户层面,给我们提供了一些操作函数和 sigset_t
类型,当然了,这些并没有将数据设置到内核里。
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); //用来判断一个信号集的有效信号中是否包含某种信号
要修改内核里的数据,必须调用系统调用
。
//how修改block表
//set输入信号集,表示要修改的信号
//oldset保存旧的block表
int sigprocmask(int how, const sigset_t* set, sigset_t* oldset);//检查和修改进程的信号屏蔽字
how的三个选项:
SIG_BLOCK:将 set 中的信号加入当前屏蔽字(阻塞)
。
SIG_UNBLOCK:将 set 中的信号移除当前屏蔽字(解除阻塞)
。
SIG_SETMASK:直接将 set 设置为新的屏蔽字(覆盖当前值)
。
//用于检查进程有哪些信号处于等待中
int sigpending(sigset_t* set); //成功返回0,错误返回-1
解除信号屏蔽之后,为什么没有打印sigprocmask之后的打印语句呢?这是因为2号信号默认是终止进程的,所以解除信号屏蔽之后,进程直接终止
。
那么,我们能否直接屏蔽所有信号呢?
可以看到,大部分信号都是被屏蔽掉的,但是9号和19号信号不可被屏蔽,不可被捕捉,不可被忽略
。
那么,信号屏蔽解除之后,信号是在递达前由1变成0,还是递达之后。我们可以验证一下。
是在递达前变化的
。
二、信号处理
1. 信号具体是在什么时候处理的
我们说进程处理信号时,不一定会立即处理信号,所以进程需要对信号进行保存。进程会在合适的时候对信号进行处理,那么,什么时候处理呢?具体如何处理?
结论:进程从内核态切换回用户态的时候,OS会检查当前进程的三张表,决定要不要处理信号
。
当进程由于中断,异常或者系统调用时进入内核,以系统调用为例,系统调用结束之后就应该从内核态切换回用户态,但是在切换之前,OS会先处理进程中传递的信号,调用do_signal函数,检查这三张表
,如果处理行为是忽略信号或者默认处理方式,那么是很容易的事情,因为OS默认就有权限,但是如果是自定义捕捉,就比较麻烦,OS检测信号完毕之后就要调用自定义捕捉函数(用户态),信号处理函数返回时执行特殊的系统调用 sigreturn 再次进入内核,最后在返回用户态
。
2. 软硬件中断
硬件中断:
外设向中断控制器发送中断请求,形成中断号,中断控制器通过针脚向CPU发送请求,保存进程硬件上下文,得到中断号,EIP指针指向中断方法
。
前面我们说过,硬件中断是由硬件报错等方式向OS发送数据的方式,OS会内置一些处理方法,处理硬件中断的请求,这些方法构成一张中断向量表,所以中断向量表也是OS的一部分
。
结论1:中断向量表(IDT)是OS的核心组成部分,由内核初始化并维护,存储所有中断/异常的处理程序入口
。
结论2:CPU执行中断向量表中的方法,就是执行OS的代码
。
时钟中断:
OS也是一款软件,OS是由谁来执行的呢?
结论:操作系统的运行依赖于多种事件触发,包括时钟中断、系统调用、硬件中断和异常。其中,时钟中断以固定间隔强制CPU执行OS调度和计时任务,但OS的完整功能还需要通过系统调用(主动请求)和其他中断(如设备I/O)协同实现
。
所谓时钟源中断就是间隔一定时间向CPU发送中断请求,当CPU收到中断请求,会执行OS注册的中断处理程序,进而调用 do_timer 里有时间片,时间片到了就切换(schedule)进程,调用OS的调度器,检查是否需要切换进程(时间片耗尽,进程优先级等),每次时钟中断,OS会更新内核的时间计数器,维护系统运行时间,保证CPU公平调度进程。
软中断:
C语言被编译成汇编语言,在汇编语言中存在一种汇编指令 int(interrupt)或者 syscall(一般是0x80),触发CPU从用户态切换到内核态
,这样就不用像外设键盘等一样向CPU发送中断请求了,而是通过软件的方式触发的。
在CPU内部有着许多的指令集,就是为了做出相应的处理动作。这就好比一个刚出生不久的婴孩,你给他发出站起来的指令,他是无法执行的,所以,CPU内部有着许多的指令集,这样在执行进程时就能够认识这些二进制代表的是什么含义了,汇编语言也会经过汇编器翻译成二进制的。
在OS中有许多系统调用,这些系统调用全部被放在了一个函数指针数组里进行管理,把这个数组叫做全局的系统调用表。调用系统调用时,只需要通过数组下标,查找到要调用的系统调用就可以了
。但是我们怎么知道数组下标呢?OS会给每一个系统调用分配一个系统调用号,本质就是系统调用的数组下标
。
进程在被调度时,OS需要为进程分配PCB,虚拟地址空间等资源
,那么OS需不需要先加载到内存里呢?答案是要的。那么我们的进程是如何访问到OS的代码和数据的呢?通过虚拟地址空间。在OS中,除了要给我们自己的进程分配页表,还有一份内核级页表,映射OS的代码和数据,我们以前说的页表指的都是用户级页表
。
对于进程来讲,每一个进程都要有自己的用户级页表,但是对于每一个进程来讲,共用一个内核级页表
。
结论1:无论进程怎么切换,怎么调度,每一个进程都可以找到同一个内核,CPU也随时可以找到内核
。
结论2:用户访问OS,只能通过系统调用,我们的进程看到系统调用,是通过虚拟地址空间看到的
。
所有的函数调用未来都可以理解成为在我自己的虚拟地址空间中完成
。
我们用系统调用时,只知道系统调用名呀,并不知道系统调用具体在哪里?
前面我们说了系统调用是通过系统调用号找到的
,所以,是如何进行的呢?
第一步:系统调用名转化为系统调用号
。
第二步:主动触发一次系统调用号,然后让CPU进行中断处理,进行系统调用查表,最后执行
。
系统调用名转化为系统调用号,这个系统调用号会被存储在CPU里的exa寄存器中,通过汇编指令 int 0x80或者 syscall 触发软中断,然后让CPU执行内核的中断处理方法,进行查表,执行系统调用
。
可是我们只调用了 open这样的系统调用,这些工作我们可没做,是谁做的呢?
所以,OS提供的真正的系统调用,不是C风格的。而是通过系统调用号,约定好的寄存器,汇编指令等技术提供的系统调用
。
可是这不对啊,以前我们使用的系统调用都是C风格的啊,这是因为用C语言给我们把系统调用封装起来了
。
系统调用是基于软中断的
。
如果我们使用了野指针访问代码和数据呢?指到内核数据了怎么办?所以,有内核态和用户态
,这涉及到了权限管理的问题。
在CPU里有一个叫做cs(code segment)寄存器,指向代码区,其中低两位(bit)用来表示状态
,0表示内核态,3表示用户态。
在OS中,刚才我们说有用户级页表和内核级页表,其实是一个页表,只是在页表中有一个标志位用来表示状态,如果是内核态,这个标志位就是0,是用户态,这个标志位就是3。在访问内核区或者用户区时,就会进行权限,标志位的检查
。
如果要访问内核空间,就必须要更改CPL,那么如何更改呢?
通过 int 0x80或者 syscall更改CPL为内核态,将这种方法和软中断进行绑定,固定进行中断处理,然后根据系统调用号查表,执行系统调用
。
OS怎么知道硬件报错了?答案是硬件报错触发了CPU的中断,CPU执行中断处理方法,中断处理方法就是OS的一部分
。
如何理解写时拷贝?如何理解缺页中断?
例如 fork之后,父子进程共享代码和数据,同时数据段的权限从 rw 修改为 r ,如果一方需要修改数据,就会触发MMU报错,这个时候CPU就会执行中断处理方法,拷贝空间,修改页表权限等
。
缺页中断:new , malloc 申请空间时,不会立即向内存申请空间,这是一种惰性申请,它会从虚拟地址空间中申请一块空间,返回虚拟地址,就默认给你申请了,你拿着这个地址去访问CPU就会报错,所以,缺页中断是基于中断机制的
。
所以,什么是OS呢?OS就是一个死循环,是一个基于中断工作的软件集合
。
3. 信号处理
这个话题谈完了,现在回到信号处理上。
//signum信号编号
//act信号捕捉方法
//oldact信号旧的执行方法
//成功返回0,失败返回-1
int sigaction(int signum, const struct sigaction* act, struct sigaction* oldact); //信号捕捉
struct sigaction
{void (*sa_handler)(int);//信号执行方法void (*sa_sigaction)(int, siginfo_t *, void *);sigset_t sa_mask;//int sa_flags;//设置为0,默认阻塞同种信号void (*sa_restorer)(void);
};
接下来介绍 sa_mask,当某个信号处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,它会被阻塞到当前处理结束为止(防止信号被递归调用)
。
如果除了当前信号需要被屏蔽外,还需要屏蔽另外一些信号,则用 sa_mask字段说明这些需要额外屏蔽的信号。
三、可重入函数与不可重入函数
用一张图来说明。
像这样,main 函数中调用 insert 函数来链接 node1 节点,但是由于信号的到来(可以是硬件中断),转而去执行了 sighandler 函数,里面也调用了 insert 函数,链接 node2 节点,函数执行完依次出栈返回第一次调用 insert 函数的地方,接下来执行 head = p 操作,就会导致 node2 结点没有被链接到链表里,造成了内存泄漏。
执行 insert 函数中,由于信号的到来,转而执行 sighandler 方法,像这种我们就说 insert 函数被重入了,由于函数被重入而导致的数据错误或者逻辑混乱,该函数就叫做不可重入函数。反之,没有出现错误就叫做可重入函数。
符合下列条件之一,函数则是不可重入的:
.
调用 malloc 或 free,因为 malloc 底层也是用全局链表来管理堆的
。
.
调用了标准 I/O 库函数。标准 I/O库的很多实现都以不可重入的方式使用全局数据结构
。
.
具有全局变量,全局属性
。
四、volatile
写一段代码来说明:
while 循环在判断时,会不断的从内存中取数据
,进行判断。但编译器可能会对我们的代码进行优化,将 flag 变量的内容放到寄存器里,register关键字就是建议编译器将数据放到寄存器里的
。
gcc, g++编译器默认是没有进行优化的。
在没有优化的前提下,CPU每次读取 flag 数据,都会进行访存,从内存中读取数据
,所以,当我们发送了2号信号,flag 就会从0变成1,CPU再次访存时,拿到的数据就是1,然后再进行判断,就会为假。
而编译器一旦进行优化,就会把数据读到寄存器中,至此不会在从内存中读取数据,所以,当你发送2号信号之后,flag的数据从0变成了1,但是CPU访问的时候,不会在从内存中读取数据,而是读取寄存器中的数据,所以,判断结果依然为真。
总结:因为编译器的优化,屏蔽了内存数据
。
volatile:禁止编译器优化,保持内存可见性
。
五、了解SIGCHLD信号
子进程在终止时会给父进程发送SIGCHLD信号,该信号的默认处理动作是忽略。我们以前写的父进程等待子进程的代码并不好,因为父进程在等待子进程的时候,要么阻塞式等待,父进程不能处理自己的工作,要么轮询式等待,父进程需要时不时的询问子进程是否退出,今天,我们就可以让父进程自定义SIGCHLD信号处理函数,在自定义捕捉函数里等待子进程,子进程退出时通知父进程即可
。
解决僵尸进程的第二种办法:父进程调用 sigaction 函数将 SIGCHLD的处理动作置为 SIG_IGN,这样子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。(仅对Linux系统有效,其它系统不保证)
。
父进程对子进程的处理 SIGCHLD,signal 处理动作是 SIG_DFL(在内核中被设置成了忽略)
。
第二种做法也是忽略,但可以认为是做了特殊处理的。
今天的文章分享到此结束,给小编点个一键三连吧。