从内核到用户态:Linux信号内核结构、保存与处理全链路剖析
Linux系列
文章目录
- Linux系列
- 前言
- 一、信号的保存
- 1.1 信号保存概念引入
- 1.2 信号的阻塞与保存
- 1.2.1 信号其他相关常见概念
- 1.2.2 信号在内核中的表示
- 二、信号相关接口
- 2.1 signal_t 结构体类型
- 2.2 信号集操作函数
- 三、信号的处理
- 3.1 进程地址空间
- 信号的检测与处理
- 总结
前言
Linux系统中,信号的保存涉及内核为每个进程所维护的task_struct
结构体对象,确保信号在产后、到进程处理前被正确的记录和管理,本篇文章我们将深入探索进程对信号的保存与处理。
一、信号的保存
1.1 信号保存概念引入
本篇我们主要介绍普通信号的保存与处理,实时信号后面文章会介绍
为什么要进行信号的保存?
进程在接收到信号时,可能不会立即处理,会在合适的位置进行处理,这就要求进程在接收到信号后,到处理信号前,要将收的信号保存下来。
进程以什么形式保存信号?
在进程的task_struct
结构体中,存在一个专门的位图结构(这个我们后面详细介绍),这里我们先以整形来代替:
当进程收到一个信号时,就会将task_struct
中的signal
对应的比特位从0
变为1
,这里的0、1
就表示信号的有、无,比特位的位置(第几个),表示信号的编号,而操作系统向进程发送信号本质就是,修改task_struct
结构体对象中位图对应的比特位,而OS
是进程的管理者,只有它才有资格修改task_struct
的内容属性,这也是为什么在前几篇文章中,只能由操作系统发送信号的原因之一。
1.2 信号的阻塞与保存
1.2.1 信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态(此时信号仍会被保存),直到进程解除对此信号的阻塞,才执行递达的动作,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
1.2.2 信号在内核中的表示
信号在内核中存在两个位图和一个方法(函数指针)向量表,每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP
信号未阻塞也未产生过,当它递达时执行默认处理动作,(SIG_DFL
表示该信号执行默认处理动作)。
SIGINT
信号产生过,但正在被阻塞,所以暂时不能递达。方法向量表中:SIG_IGN
表示该信号忽略处理,但是在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作(将处理动作设为默认、自定义)之后再解除阻塞。
SIGQUIT
信号未产生过,一旦产生SIGQUIT
信号将被阻塞,它的处理动作是用户自定义函数sighandler
。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?Linux是这样实现的:普通信号在递达之前产生多次只计一次(多余信号丢失),而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
对于这种内核级的结构,OS
不允许用户直接对他们进行访问,所以系统提供了较多的接口,以便用户对信号的属性信息进行修改和获取(如:阻塞信号、修改执行方法)。
二、信号相关接口
系统给我们提供了一个signal_t类型的结构体,帮助用户获取信号属性信息,这种由系统提供的结构体类型,在管道部分也介绍过
2.1 signal_t 结构体类型
从之前的介绍中,我们可以知道每个信号只有一个bit
位的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t
来存储,sigset_t
称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略(简单来说就是位图结构中0
1
的表示)。
2.2 信号集操作函数
sigset_t
类型对于每种信号用一个bit
表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit
则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t
对象:
#include <signal.h>
int sigemptyset(sigset_t *set);//将位图全部置0
int sigfillset(sigset_t *set);//将位图全部置1
int sigaddset (sigset_t *set, int signo);//向信号集中添加signo信号
int sigdelset(sigset_t *set, int signo);//将signo信号从信号集中删除
int sigismember(const sigset_t *set, int signo);//检查signo信号是否在信号集中
前四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask()
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
参数
如果oset
是非空指针,则读取进程的当前信号屏蔽字通过oset
参数传出(输出型参数)。如果set
是非空指针,则更改进程的信号屏蔽字,how
表示如何更改。如果ose
t和set
都是非空指针,则先将原来的信号屏蔽字备份到oset
里,然后根据set
和how
参数更改信号屏蔽字。假设当前的信号屏蔽字为mask(代表整个位图),下表说明了how参数的可选值:
SIG_BLOCK
:将set
中的信号全部添加到,当前进程的block
中。
SIG_UNBLOCK
:将set
中存在的信号,从当前进程的block
中移除(解除阻塞)。
SIG_SETMASK
:将当前进程block
的信号,全部替换为set
的信号。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达
sigpending()
#include <signal.h>
int sigpending(sigset_t *set);
功能:读取当前进程的未决信号集,通过set参数传出。
调用成功则返回0,出错则返回-1。
示例演示:
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;void func(sigset_t&rset)
{for(int i=1;i<=31;i++){if(sigismember(&rset,i))cout<<1;else cout<<0;}cout<<endl;return;
}
int main()
{cout<<getpid()<<endl;sigset_t set,oset,rset;sigemptyset(&set);//将信号集初始化sigemptyset(&oset);sigemptyset(&rset);sigaddset(&set,2);//向信号集中添加2号和8号信号sigaddset(&set,8);int n=sigismember(&set,2);if(n==1)cout<<"Signal No. 2 was added successfully"<<endl;sigprocmask(SIG_BLOCK,&set,&oset);//阻塞set中存在的信号,并将当前进程就的block保存在oset中while(1){sigpending(&rset);//将当前进程的未决信号读取到rset信号集中sleep(2);func(rset);//将获取的pending内容打印出来}return 0;
}
可以看到将2
号和8
号阻塞后,即使进程收到2
、8
信号,依然不会处理,处于未决状态,值得一提的是9
和19
号信号是无法被阻塞的,和他们无法被捕捉一样特殊,剩下的接口大家自己尝试吧,这里就不演示了。
三、信号的处理
我们一直在说,进程收到信号后可能不会立即处理,要在合适的时候处理,那么什么时候合适呢?要回答这个问题,我们就要重新谈一谈进程地址空间了。
3.1 进程地址空间
我们在介绍进程地址并没有对内核空间和用户空间进行区分介绍,我们说过,操作系统会给每一个进程,创建一个自己的进程地址空间,CPU
执行计算时,根据虚拟地址通过页表映射到物理内存,完成代码的执行、数据的访问,但是进程被创建出来时,操作系统的代码和数据,同样被映射到地址空间中,不过在进程地址空间中有单独的内存空间,留给我们的系统资源映射,由于操作系统的代码和数据比较稳定,所以在映射时,会生成一份特殊的内核级页表,所有的进程都共用这一份页表,而用户空间则会另外生成页表,有几个进程就会存在几个用户级页表—进程独立性,当我们执行系统调用,访问操作系统的资源,操作系统并不相信用户,所有在CPU
中存在一个ecs寄存器
,该寄存器有两个标志位0
1
组合可以表示四个状态,当CPU
执行用户代码时,该寄存器的标志位位1 1
,当要进行系统调用或访问系统资源时,就会执行int 80;
陷入内核,标志位变为0 0
。从进程视角看,当我们调用系统代码时,就时去内核空间执行普通语句了(就在我自己的地址空间中执行),这就有点像对共享内存的访问。
看不懂也没关系,只需要知道,执行代码时会进行身份切换即可
信号的检测与处理
当我们的进程从内核态返回用户态时,就会进行信号的检测,如果存在未决信号,并且该信号没有被忽略,操作系统就会调用该信号的处理方法进行处理,并将未决状态修改,而要访问这些内核级的代码和数据,只能以内核态的身份来访问。
总结
Linux通过pending
集合和blocked
掩码管理信号保存,标准信号与实时信号分别采用位图和队列实现不同语义。理解这些机制有助于编写健壮的信号处理代码,避免竞态条件和信号丢失。
Linux处理信号实在身份切换时,对信号进行检测和处理的。
Linux执行普通代码和系统代码时,会进行身份的切换,这很好的保证了,系统资源的安全性。