首先介绍几个新的概念:
注意: 阻塞和忽略是不同的,只要信号被阻塞就不会被递达,但是忽略是在递达之后进行的一种处理动作。
信号在内核中的表示示意图
当我们使用signal注册一个自定义处理方式时,操作系统会将我们定义的函数指针放在handler表中,在信号递达后调用。如果是默认处理方式,会调用handler默认的初始函数指针所对应的函数。
信号产生后,操作系统就会修改pending位图,使信号处于未决状态。
操作系统会按照一定的顺序来检查block表和pending表,然后去调用相应信号编号的处理方式来完成信号递达。大概逻辑(伪代码):
if(1<<(signo - 1) & pcb->block)
{
//signo信号被阻塞,不会被递达
}
else
{
if(1<<(signo - 1) & pcb->pending)
{
//信号递达,处理该信号
handler[signo - 1];
}
}
操作系统在对信号进行检测的时候,先检测的是信号的block位图,如果对应信号的比特位被置一,说明该信号被阻塞,就不再去检测pending位图。如果没有被阻塞,才会去检测pending位图,如果相应的位被置一,再去调用handler表中的处理函数。
结论: 如果一个信号没有产生,但是并不妨碍它被阻塞。
被阻塞的信号,在产生之后就会一直处于未决状态,不会被递达,只有当阻塞被解除后才会被递达。
默认情况下,所有信号都是不被阻塞的,所有信号都没有产生,也就是block位图和pending位图都是0。
pending图,block图以及handler表是存放在内核数据结构中的,所以只能由操作系统来修改,我们用户如果要修改也能通过操作系统来实现,所以操作系统同样给我们提供了系统调用。
对于block位图和pending位图的修改,操作系统提供了一族系统调用,称为信号集操作函数。
- sigset_t set:信号集变量。
- int signum:信号编号。
- 返回值:成功返回0,失败返回-1。
信号集:
用户在设置pending位图和block位图的时候,并不能直接让系统调用将内核中对于的比特位置一或清0,而是需要预先在一个变量中表达出我们的意愿,然后将这个变量通过系统调用给到操作系统,再由操作系统去修改内核数据结构。
系统提供的信号集操作函数操作的也是也是这个域先处理的变量,之所以也用系统调用来处理这个变量,是因为这个变量不单单是一个32位的整形变量,它的结构和内核是对应的,所以操作也要按照相应的规则。
- 从使用者的角度不必关心具体是如何操作的,只需要使用信号集操作函数来操作sigset_t变量即可。
- sigset_t变量用其他方式是无法操作的,比如用printf去打印,这是没有意义的。
代码演示:
int main()
{
sigset_t block,pending;
//清空位图
sigemptyset(&block);
sigemptyset(&pending);//---初始化位图
//所有位置,置一
sigfillset(&block);
sigfillset(&pending);//---设置所有信号
//指定位置,置一
sigaddset(&block,2);
sigaddset(&pending,2);//---设置指定信号
//指定位置清空
sigdelset(&block,2);
sigdelset(&pending,2);//---判断指定信号
//判断指定位置是否置一
bool ret1=sigismember(&block,2);
bool ret2=sigismember(&pending,2);
return 0;
}
在使用sigset_t类型的变量之前,一定要调用sigemptyset进行初始化,使信号集处于确定状态。
此时我们已经对sigset_t变量预处理好了,下一步就是把这个变量交给操作系统了,操作系统同样提供了对应的系统调用。
sigprocmask():
该系统调用是专门用来修改内核数据结构中的block位图的。
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
set:这是一个输出型参数,用来返回从内核中获取的pending位图情况。
返回值:若成功则为0,若出错则为-1
#include
#include
#include
#include
#include
using namespace std;
void handler(int signo)
{
cout<<"信号已送达,编号是:"<=1;--signo){
//该位信号有效打印1,否则打印0
if(sigismember(&pending,signo))//判断位图比特位是否为1
{
cout<<"1";
}
else{
cout<<"0";
}
}
cout< sigarr={2,3};//存放要被屏蔽的信号编号
int main()
{
sigset_t block,pending,oblock;
//清空位图
sigemptyset(&block);
sigemptyset(&pending);//---初始化位图
//添加想要屏蔽的信号
for(auto& signo:sigarr){
sigaddset(&block,signo);//添加屏蔽信号
//自定义处理指定信号
signal(signo,handler);
}
//开始屏蔽,设置进内核
sigprocmask(SIG_SETMASK,&block,&oblock);//使置进内核的block位图中
//打印pending位图
while(1){
//初始化
sigemptyset(&pending);
//获取内核中的pending位图
sigpending(&pending);
//打印
Show_pending(pending);
sleep(1);
}
return 0;
}
运行结果:
所以说,被阻塞的信号,即使产生也是处于未决状态,不会被递达。
int cnt=0;
while(1){
//初始化
sigemptyset(&pending);
//获取内核中的pending位图
sigpending(&pending);
//打印
Show_pending(pending);
sleep(1);
if(++cnt==10){
cout<<"解除屏蔽"<
运行结果:
现在我们知道,进程在接收到信号后并不是立刻处理的,而是在适当的时候,那这个适当的时候到底是什么时候呢?
- 从内核态返回用户态的时候信号递达。
信号只是处理的话非常简单,就是在执行默认的处理方式或者自定义方式,再或者是忽略,最重要的是信号处理的时机,也就是信号的捕获。
信号的捕捉:
- 用户态:正在执行用户层的代码,此时CPU的状态是用户态。
- 内核态:正在通过系统调用访问内核或者硬件资源时,此时CPU的状态是内核态。
虽然系统调用是在我们的代码中写的,也就是用户在使用,但是具体的执行者是内核,也就是操作系统。
现在是知道了什么是用户态,什么是内核态,但是操作系统是怎么知道当前进程的身份状态的呢?
CPU中的寄存器虽然只有一套,但是有很多,有可见寄存器,如eax,ebx等等,还有很多的不可见寄存器,凡是和当前进程强相关的,都属于当前进程的上下文数据。
如上图中:
- CR3寄存器:专门用来表征当前进程的运行级别的。
- 0:表示内核态,此时访问的是内核资源或者硬件。
- 3:表示用户态,此时执行的是用户层的代码。
操作系统是一个进行软硬件资源管理的软件,它很容易就可以获取到CPU中CR3寄存器中是0还是3,从而知道当前是用户态还是内核态。
执行系统调用时,执行者是操作系统,而不是用户。那么又存在一个问题,一个进程是怎么跑到操作系统中执行代码的呢?
对进程地址空间进行一个补充介绍:
我们之前一直所说的页表都是用户级页表,每个进程都有一个。
进程地址空间的大小一共有4GB,我们之前谈论的只有0~3GB,这3GB的空间属于用户空间,用来存放用户的代码,数据等。为了保证进程的独立性,每个进程都有一个进程地址空间,都有一个用户级页表。
还有一共内核级页表,所有进程共用一份。
进程地址空间中的3~4GB空间,是不允许用户访问的,因为这1GB空间中的数据等,通过内核级页表和内存中的操作系统相映射,属于内核级别的。因为内存中只存在一份内核,所以所有进程的虚拟地址空间的这1GB空间都通过同一份内核级页表和内存中的内核相映射。
还记得动态链接吗?通过代码段的位置无关码跳转到共享区从内存中映射过来的动态库来执行相应的方法。系统调用和它的原理一样:
此时又有一个问题,为什么我们的代码中不能访问这3~4GB的空间,而系统调用就跳转到这1GB的内核空间中进行访问了呢?我们都是用户的代码啊?
- 因为从代码段跳转到内核空间中后,CPU中的CR3寄存器从3变成了0。
- 意味着进程运行级别从用户态变成了内核态,也就是执行者从用户变成了操作系统,所以可以对这1GB的内核空间进行访问。
所以说,系统调用前一部分是由用户在执行,其余部分由操作系执行。
此时再来理解信号处理的时机—从内核态返回到用户态,这句话的含义:
以我们最熟悉的系统调用为例:
以黑色长线为界,上面是用户态,下面是内核态。
上面过程的伪代码形式:
涉及到的系统调用无需详细了解,只需要知道是通过系统调用实现的即可。
两个独立的流程:
此时就存在了两个流程,一个是main函数所在的执行流程,一个是自定义处理方式的执行流程:
- 上面整个过程可以看成一个无穷大符号加一条线,线的上边是用户态,下边是内核态。
- 每经过一次黑线就会发生一次身份状态的改变,一共改变了四次。
上面这种自定义处理方式是最复杂的情况,如果是SIG_DFL(默认处理方式)和SIG_IGN(忽略方式),以内核态身份就可以处理,然后就可以直接返回到用户代码中系统调用的位置,少了两次身份的转变。
因为默认方式和忽略方式是被写入到操作系统中的,被操作系统所信任的方式。
int signum:信号编号。
act:这是一个结构体变量,结构体中包括多个属性,sa_handler赋值自定义处理方式,暂时将sa_flags都设为0,其他暂时不用管。
oldact:是一个输出型的结构体变量,将原本的捕捉方式放入这个结构体变量中。
返回值:成功返回0,失败返回-1。
代码演示:
#include
#include
#include
#include
#include
using namespace std;
void Count(int cnt)
{
while(cnt){
printf("cnt:%2d\r",cnt);
fflush(stdout);
cnt--;
sleep(1);
}
}
void handler(int signo)
{
cout<<"信号已送达,编号是:"<
运行结果:
在进程开始运行后,我们在10s内发送了很多次2号信号,但是最终只捕获了两次。
注意: 进程处理信号的原则是串行的处理同类型的信号,不允许递归,所以同类型的多个信号同时产生,最多可以处理两个。
运行结果:
在10s内,多次发送2号和3号信号。
在第一个2号信号被捕获的时候,同时阻塞了第二个2号信号和3号信号,此时pending位图的第二个和第三个比特位都是1,但是当第一个2号信号递达完成后,先处理的是第二个2号信号而不是3号信号。
一般一个信号被解除屏蔽的时候,会自动递达这个信号,如果该信号pending位图的比特位是1的话就会递达,是0的话就不做任何处理。
如上图所示链表,在插入节点的时候捕获到了信号,并且该信号的自定义处理方式中也调用了插入节点的函数。
定义全局变量quit,当quit是0的时候,一直进行while循环,当quit变成1的时候,结束循环,进程正常退出。
信号2注册自定义处理方式,在函数中将全局变量改成1,让main函数控制的流程正常结束。
在接收到2号信号后,quit从0变成1,所以main流程也正常结束了,不再循环。
我们的编译器会进行很多的优化,比如debug版本和relase版本中的assert就会被优化。在使用g++编译器的时候,可以指定g++的优化级别。
g++ -o $@ $^ -03 -std=c++11
指定使用级别为3的编译器优化选项。
仍然是上面代码,运行起来后,发送2号信号,quit是从0变成了1,但是进程并没有结束,还是在运行,再次发送2号信号,quit从1变成1,进程还在继续。
此时可以肯定quit被改成了1,但是while(!quit)还是在循环,没有停下来。
上诉现象的原因是什么?肯定是和优化有关,因为我们加了-O3选项。
在没有优化前,CPU每次都是从物理内存中拿到quit的数据,再去指向while循环,所以当quit从0变成1后,CPU中寄存器的数据也会及时从0变成1,所以while循环会停下来。
但是采用优化方案后:
- 在main控制的执行流中,quit没有进行修改,也没有写入,只是被读取,所以在第一次将从物理空间读取到寄存器中便不再读取了,每次执行while时候都是使用的寄存器中的quit值,所以始终都是0。
- 在handler执行流中,对quit进行了修改,所以物理内存中的quit从0变成了1。
导致上面现象的原因就是CPU执行while时的quit和物理内存中的quit不是一个值。
可以看到,此时在handler的执行流中修改了quit值,并且CPU中该值也得到了及时更新,所以程序可以正常结束。
至此,加上上一篇文章,信号的整个生命周期都介绍完了,重点在于新的产生,信号保存,以及信号捕捉上面,其它衍生的知识了解即可。