对于信号,主要涉及到信号的产生、保存和捕获,之前谈到了信号的产生,这里主要介绍信号产生后如何进行保存和捕捉处理的原理。
一、信号的保存
1.阻塞信号
相关概念
- 实际执行处理信号的动作称为信号递达Delivery
- 信号从产生到递达的过程称为信号未决Pending
- 进程可以阻塞、忽略某个信号。
- 被阻塞就只有产生和未决,忽略是在递达后的处理。
2.内核结构
之前讲过OS发送信号给进行,会在进程PCB表上的位图标记 0-》1,并回调函数指针的方法。
实际上,内核会位PCB的信号维护三张表Block(阻塞表)、Pending(未决)、handler(函数指针数组)
说明:
- Pending表就是标记我们之前谈到的位图,0/1标记某一位是否收到信号。
- Block表也是位图 ,代表某一信号是否被阻塞,对特定的信号进行屏蔽。
- Handler表是函数指针数组,内容 :0 表示默认 1是忽略,还有用户自定回调函数。
描述这一过程:
在信号没有创建之前,Block表中的某一位先会被设置位0和1,标记是否被阻塞。
信号产生时,会在进程控制块的Pengding表中将对应信号位的0-》1。
再校验Block表,如果表上的比特位是1 ,代表被阻塞,将不会递达。直到阻塞被解除。
总结:
进程task_struct中会维护三张表。三张表共同维护信号的识别。
信号的阻塞,不会影响产生。
一个信号没有被递达,并且接收到多次,pending表会默认最后一次发送的信号。
信号的捕捉
信号在什么时候被处理?
进程从内核态到用户态的时候,进行信号的检测和处理。
用户态是一种受控的状态,能访问的资源是有限的。
内核态是OS的一种工作状态,能访问到大部分资源。
系统调用必定发生身份从用户态到内核态的转变,因为我们无法通过用户态进行系统调用,
系统调用是比较费时间的,要避免频繁的系统调用。
用户态和内核态
如何对用户态和内核态进行区分?
在CPU上有一个CR3寄存器,是一个2比特位的。00 01 10 11
1表示内核,3表示用户态
进程如何调用系统调用接口?
用户态只能访问自己的【0,3】GB的内存空间。
内核态能让用户以OS的身份访问【3,4】GB。
进程需要被加载到内存中,OS需要维护进程PCB。实际上我们平时说的页表是【0,3】GB的用户级别页表。每一份进程都需要维护一张
而【3,4】GB有对应的内核级页表。因为内核级的内容不会被用户身份访问,所以只需要维护一张内核级页表,这个页表将给CS寄存器保存。
故如果进程需要调用系统调用,就像我们平常调用库函数一样,就是在进程地址空间中跳跃。
先在CR3寄存器中切换身份,然后通过寄存器CS找到内核级页表,就能找到对应的内核内容。
内核如何实现捕捉
进程的信号在合适的时候被处理,从内核转到用户级,先检测再处理。
描述这一过程
- CPU执行用户代码时,会先以用户态执行,遇到系统调用接口时,会切换身份调用系统调用。执行完成后到进程的PCB内查看信号列表,如果pending表全为0,或者pending为 1 block为 1阻塞也直接返回。
- 如果pending为1,block为0,且handler存在自定义的方法,则会将内核态切换为用户态,调用用户的方法。(这一切换是为了防止内内容被用户破坏)。执行完毕后会现在内核态检测信号的位置,通过特定系统调用返回。
注意:
在信号调用handler方法时,就会将pending表上的1-》0
如果处理完毕后,pending表上还有信号没被处理,则会执行handler方法。
抽象图帮助记忆
一共会经过四次身份切换,只有在第一次身份切换时,才进行信号的检测与处理!
信号操作函数
sigset_t
是位图类型
它在Linux下的定义
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
typedef __sigset_t sigset_t;
- 对于panding表,0表示没接收到信号,1表示接收到信号
- 对于block表,0表示信号没有被阻塞,1表示信号被阻塞
sigset_t函数
因为sigset_t 是位图,它的每一位比特位可以表示pending表和block表的内容,因此我们可以通过逻辑关系的方法修改比特位的内容,但是这样过于繁琐。就由下面这些函数操作位图。
#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函数:将位图表设置为全0,(清0)
- sigfillset函数:将位图上的所有比特位置为有效信号(置位)
- sigaddset函数:将signo的信号置为有效信号
- sigdelset函数:将signo信号置零
- sigisemember函数:判断signo信号是否在位图中。
注意:
sigset_t 创建后,需要初始化(置位/清零)
我们操作的表与进程中的表没有关系。需要继续调用系统调用写入。
sigprocmask
sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数原型如下:
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
一般而言,阻塞和未决表在进行修改时,都会保存上一张表
参数说明:
- oset不为空时,读取当前进程的阻塞表输出到oset表中
- set不为空时,依旧how和set更改当前的阻塞表
- set和oset都不为空时,会保存旧表更改阻塞表
how参函数
想要如何操作信号屏蔽字,此参数有三个可选值:
- 1.SIG_BLOCK:就是把对应信号的bit位改为1,即就是阻塞该信号
- 2.SIG_UNBLOCK:就是把对应信号的bit位改为0,即就是使该信号不阻塞
- 3.SIG_SETMASK:设置当前信号屏蔽字为set所指向的值
返回值:
- 调用成功返回0,失败返回-1
sigpending函数
#include <signal.h>
int sigpending(sigset_t *set);
操作pending表,读取当前进程的pending表,通过set返回
成功返回0,失败返回-1
下面是举例运用
使用sigprocmask函数阻塞2号信号和40号信号
要求:阻塞2号信号和40号信号, 分别给进程发送5次2号信号和5次40号信号,观察结果
1 #include <iostream> 2 #include <sys/types.h> 3 #include <unistd.h> 4 #include <signal.h> 5 6 7 void pendingPrin(sigset_t* s) 8 { 9 for(int i=31;i>0;i--) 10 { 11 if(sigismember(s,i)) 12 { 13 std:: cout<<1; 14 } 15 else std::cout<<0; 16 } 17 std::cout<<std::endl; 18 std::cout<<"----------------------------------------------"<<std::endl; 19 } 20 21 22 void handler(int signo) 23 { 24 std::cout<<"get a signo: "<<signo<<std::endl; 25 } 26 27 int main() 28 { 29 std::cout<<"my pid is: "<<getpid()<<std::endl; 30 //自定义处理2信号和40信号 31 signal(2,handler); 32 signal(40,handler); 33 sigset_t set,s; 34 sigemptyset(&set); 35 sigaddset(&set,2); 36 sigaddset(&set,40); 37 sigprocmask(SIG_BLOCK,&set,nullptr); 38 39 while(true) 40 { 41 sigpending(&s); 42 std::cout<<"pending: "; 43 pendingPrin(&s); 44 std::cout<<" block: "; 45 pendingPrin(&set); 46 sleep(3); 47 } 48 return 0; 49 } ~
sigaction
捕捉信号,除了之前谈到的signal之外,sigaction对特定信号捕捉。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
参数说明:
- signum:要捕捉的信号编号
- act:不为空,则为用户自定行为。
- oldact:导出原信号的处理行为。
act和oldact都是sigacgtion类型的结构体,定义如下
struct sigaction {
void(*sa_handler)(int);
void(*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void(*sa_restorer)(void);
};
- 第一个参数:sa_handler
是一个函数指针 SIG_IGN是,忽略处理, SIG_DEF是默认处理,handler自定义处理。
- 第二个参数:sa_sigaction
实时信号的处理函数。
- 第三个参数是:sa_mask
是阻塞信号集,就和之前谈到的阻塞位图一致‘
- 选项sa_flags
通常设为0
举例使用
1 #include<iostream> 2 #include <signal.h> 3 #include <sys/types.h> 4 #include <unistd.h> 5 6 void handler(int signo) 7 { 8 std::cout<<"get a signo: "<<signo<<std::endl; 9 } 10 11 12 int main() 13 { 14 std::cout<<"get a pid"<<getpid()<<std::endl; 15 struct sigaction ac,oac; 16 ac.sa_handler=handler; 17 ac.sa_flags=0; 18 sigemptyset(&ac.sa_mask); 19 sigaction(2,&ac,&oac); 20 while(true) 21 { 22 std::cout<<"main running ..."<<std::endl; 23 sleep(2); 24 } 25 return 0; 26 } ~
相关知识
信号的主体部分已经介绍完毕,下面还有几个相关知识点:
volatile关键字
保持内存的可见性
程序提高优化级别(例如debug到relase的转变)会使当前只读变量放进寄存器,而如何后续的变量由信号触发变化,信号变化handler是在内存中,这时候就会对一个变量形成俩份,导致寄存器中保持的不受改变。
volatile关键字声明在类型前,告诉编译器不要做过度的优化,保持内存的可见性。
SIGCHLD信号
看上去不那么实用的信号
为了避免出现僵尸进程,子进程在结束后,父进程需要等待,回收资源。等待方式可以是阻塞等待,也可以是轮询等待。但是这俩种方式都有延迟。
GIGCHILD信号,在子进程结束后,立马会去父进程发送信号,让父进程捕捉信号。
但是这个方法也有缺点,假设有多个子进程同时发送信号,但是父进程,没来得急回收,就会导致信号被阻塞,实际最后子进程不能完全被父进程杀掉。
那就必须调用自己的方法,handler函数不断的调用waitpid。
实际上
父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_IGN,子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。故而SIGCHLD是比较不实用的信号。