信号量与信号
文章目录
六. 信号处理的准备
常见的信号
数字和名字都可以标识信号,没有0,32,33号信号
红色部分为实时信号 – 不做处理
信号的处理方式
a. 默认动作
b. 忽略信号 – 是处理了信号吗?是!
c. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉
(Catch)一个信号 – signal
七. 信号的处理
1. 信号是什么时候被处理的
信号会在合适的时候被处理,合适的时候是什么时候?
即:信号的递达发生在内核态切换回用户态的时候,会进行信号相关信息检测。
当我们写代码时我们总会使用到系统调用,当代码在调用系统调用接口时,进程就会从当前的用户态切换到内核态去进行,这个过程也叫陷入内核。进程之所以要转到内核态去调用接口就涉及到了权限的问题,系统调用接口属于操作系统的代码,进程不能直接去访问,所以就必须由用户态切换到内核态执行。
当进程切换到内核态时,我们就可以理解为当前进程的角色就是操作系统,而不再是系统进程。
2. 信号捕捉的基本过程
由上面这张图我们可以清晰的知道,信号的捕捉有5大过程:
-
进程执行系统调用,由用户态进入内核态
-
进程在内核态处理用系统调用后,检测进程中是否有信号需要被处理
-
进行信号检测(遍历task_struct),检测结果无非三种:
- 未接收到信号,此时返回进入内核态前的上下文环境中继续执行代码,即返回过程1
- 接收到信号,但当前信号被屏蔽了,处理方法同上
- 接收到信号,信号未被屏蔽,此时执行信号的递达动作,即执行过程4
-
执行信号的递达动作,切换回用户态执行自定义函数捕捉方法
-
信号的处理方法执行完之后,在返回用户态之前的上下文环境中,即返回过程1
在信号处理过程中,一共有四次的状态切换(内核和用户)
这时候我们就有一个问题,为什么我们在信号捕捉的时候,执行用户的方法,还要从内核态切换回内核态,是多此一举吗?
答:操作系统不会相信任何人,表现为:当我们访问操作系统的某些数据,函数等我们需要通过系统调用接口来访问,同理,操作系统也为了防止执行某些用户的越权代码,也不会直接从内核态去访问用户的代码,而是切换回用户态去访问
3. 键盘输入数据的过程
操作系统如何知道我们按下键盘呢?肯定不能是每一时刻都进行检查,这样消耗太大!
在CPU中,键盘按下时会向cpu发送硬件中断,CPU就会读取中断号读到寄存器中,CPU会告诉OS,后续通过软件来读取寄存器。
内存中,操作系统在启动时就会维护一张函数指针数组(中断向量表),数组下标是中断号,数组内容是读磁盘函数,读网卡函数等方法。每个硬件都有自己的中断号,键盘也是。按下键盘时,向CPU发送中断信号,然后调用键盘读取方法,将键盘数据读取到内存中!这样就不需要轮询检查键盘是否输入了!
4. 如何理解操作系统是怎么运行的
根据我们使用电脑的经验,电脑开机到关机的过程中,本质一定是一个死循环。那这死循环是如何工作的呢?
是因为CPU内部有一个时钟,可以不断向CPU发送中断(例如每隔10纳秒),所以CPU可以被硬件推动下在死循环内部不断执行中断方法。来看Linux内核:
在操作系统的主函数中,首先是进行一些初始化(包括系统调用方法),然后就进入到了死循环!
操作系统本质是一个死循环 + 时钟中断 (不断调度系统任务)
那么系统调用时什么东西呢?
在操作系统内部,操作系统提供给我们一张表:系统调用函数表,实际上就是一个函数指针数组
平时我们用户层使用的fork , getpid , dup2...
等都对应到底层的sys_fork , sys_getpid ...
。只有我们找到特定数组下标(系统调用号)的方法,就能执行系统调用了!
回到之前的函数指针数组,我们在这里再添加一个新方法,用来调度任何的系统调用。使用系统调用就要有:
- 系统调用号
- 系统调用函数指针表(操作系统内部)
用户层面如何使用到操作系统中的函数指针表呢?
这就要回到CPU中来谈,CPU中两个寄存器,假设叫做X
和 eax
,当用户调用fork
时,函数内部有类似
mov 2 eax //将系统调用号放入寄存器中
而所谓的中断不也是让CPU中的寄存器储存一个中断号来进行调用吗!那CPU内部可不可以直接写出数字呢?可以,当eax
获取到数字时,寄存器X就会形成对应的数字,来执行操作系统的系统调用。
通过这种方法就可以通过用户的代码跳转到内核,来执行系统调用。但操作系统不是不相信任何用户吗?怎么就直接跳转了呢?用户是无法直接跳转到内存中的内核空间(3~4GB)。那么就有几个问题:
操作系统如何阻止用户直接访问?系统调用最终是可以被调用的,又是如何做到的?
在操作系统中,解决这两种问题是非常复杂的!有很多概念,所以简单单来讲:做到这些需要硬件CPU配合,在CPU中存在一个寄存器code semgent
记录代码段的起始与终止地址。就可以通过两个cs寄存器来分别储存用户与操作系统的代码!CS寄存器中单独设置出两个比特位来记录是OS还是用户,这样就要区分了内核态和用户态。运行代码时就会检测当前权限与代码权限是否匹配,进而做到阻止用户
直接访问。而当我们调用系统调用(中断,异常)时,会改变状态,变成内核态,此时就可以调用系统调用
5. 如何进行信号的捕捉
信号捕捉的一个系统调用
sigaction
NAME
sigaction, rt_sigaction - examine and change a signal action
SYNOPSIS
#include <signal.h>
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;
// void (*sa_restorer)(void);
};
通常我们在使用时,只需要设置三个参数,*sa_handler、sa_mask、sa_flags(通常为0)
参数:
int signum : 表示要对哪个信号进行捕捉
const struct sigaction *act : 输入型参数,表示要执行的结构体方法
struct sigaction *oldact: 输出型参数,获取更改前的数据
代码样例:
// 创建一个进行,进入死循环
// 对2号信号进行自定义捕捉
void handler(int signum)
{
std::cout << "get a sig : " << signum << " pid: " << getpid() << std::endl;
}
int main()
{
struct sigaction act, oact;
// 自定义捕捉方法
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(2, &act, &oact);
while (true)
{
std::cout << "I am a process... pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
这样就成功捕捉了2号信号!用起来和之前的signal很类似!那么我们介绍这个干什么呢?我们慢慢来说:
首先信号处理有一个特性,比如我们在处理二号信号的时候,默认会对二号信号进行屏蔽!对2号信号处理完成的时候,会自动解除对2号信号的屏蔽!也就是操作系统不允许对同一个信号进行递归式的处理!!!
sa_mask:
/* Additional set of signals to be blocked. */
__sigset_t sa_mask;
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字中
如果我们处理完对应的信号,该信号默认也会从信号屏蔽字中移除
目的就是为了不想让信号,嵌套式进行捕捉处理
我们来简单验证一下:我们在handler方法中进行休眠,看看传入下一个2号信号是否会进行处理
void handler(int signum)
{
std::cout << "get a sig : " << signum << " pid: " << getpid() << std::endl;
sleep(100);
}
可见进程就屏蔽了对2号信号的处理!
我们之前学习过三张表:阻塞,未决和抵达
既然操作系统对信号进行来屏蔽,那么再次传入的信号应该就会被记录到未决表(pending表)中,我们打印这个表来看看:
void Print(sigset_t &pending)
{
for (int sig = 31; sig > 0; sig--)
{
if (sigismember(&pending, sig))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << std::endl;
}
void handler(int signum)
{
std::cout << "get a sig : " << signum << " pid: " << getpid() << std::endl;
while (true)
{
// 建立位图
sigset_t pending;
// 获取pending
sigpending(&pending);
Print(pending);
}
}
可以看的我们在传入2号信号时就进入到了未决表中!处理信号完毕,就会解除屏蔽!
接下来我们既可以来介绍sa_mask了,上面只是对2号信息进行了屏蔽,当我传入3号新号ctrl + \时就正常退出了,那么怎么可以在处理2号信号时屏蔽其他信号呢?就是通过sa_mask,将想要屏蔽的信号设置到sa_mask中,就会在处理2号信号的时候,屏蔽所设置的信号!
int main()
{
struct sigaction act, oact;
// 自定义捕捉方法
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
//向sa_mask中添加3号信号
sigaddset(&act.sa_mask , 3);
act.sa_flags = 0;
sigaction(2, &act, &oact);
while (true)
{
std::cout << "I am a process... pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
当然如果把所有信号都屏蔽了,肯定是不行的,所以有一部分信号不能被屏蔽,比如9号信号永远都不能屏蔽!!!
6. 可重入函数
介绍一个新概念:可重入函数。
我们先来看一个情景:
这是一个链表,我们的inser函数会进行一个头插,头插会有两行代码:
void insert(node_t* p)
{
p->next = head;
//------在这里接收到信号-----
head = p;
}
我们进行头插时,进行完第一步之后,突然来了一个信号,但是我们之前说过:信号处理时在用户态到内核态进行切换时才进行处理,这链表的头插没有进行状态的切换啊?其实状态的切换不一定只能是系统调用方法,在时间片到了(时钟中断)之后,也进行了状态的切换。
而且恰好,该信号的自定义捕捉方法也是insert这时就导致node2插入到了链表中,信号处理完之后,头指针又被掰到node1了,就造成node2丢失了(内存泄漏了)!!!
这就叫做insert函数被重入了!!!
在重入过程中一旦造成了问题,就叫做不可重入函数!!!(因为一旦重入就造成了问题,那当然不能重入了)
绝大部分函数都是不可重入函数!
7. volatile关键字
我们今天在信号的角度再来重温一下:
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作保持数据可见性!
#include <iostream>
#include <signal.h>
int flag = 0;
void changdata(int signo)
{
std::cout << "get a sig : " << signo << " change flag 0->1" << std::endl;
flag = 1;
}
int main()
{
signal(2 , changdata);
while(!flag);
std::cout << "process quit normal" << std::endl;
}
主函数会一直进行死循环,只有接收到了2号信号才会退出!
但当我们进行编译优化时(因为如果进程不接受到2号信号,那么flag就没有人来修改,编译器就认为没有任何代码对flag进行修改),共同有四级优化00 01 02 03
而while(!flag)是一个逻辑运算,CPU 一般进行两种类别计算:算术运算和逻辑运算!会从内存进行读取,然后进行运算
我们再次运行,却发现,进程不会结束了?!这是为什么!因为优化直接将数据优化到寄存中,因为编译器认为后续不会进行修改,所以寄存器中的值不会改变,程序只会读到寄存器中的值。所以就有了volatile关键字解决了这样的问题!!!
参考文档:http://t.csdnimg.cn/LDqmF