目录
- [一. 阻塞信号](#一. 阻塞信号)
-
- [1.1 信号的相关概念](#1.1 信号的相关概念)
- [1.2 在内核中的表示](#1.2 在内核中的表示)
- [1.3 sigset_t及其相关操作](#1.3 sigset_t及其相关操作)
-
- [1.3.1 sigset_t介绍](#1.3.1 sigset_t介绍)
- [1.3.2 sigprocmask详解](#1.3.2 sigprocmask详解)
- [1.3.3 sigpending详解](#1.3.3 sigpending详解)
- [1.3.4 阻塞信号实验](#1.3.4 阻塞信号实验)
- 信号保存总结:
- [二. 信号的捕捉](#二. 信号的捕捉)
-
- [2.1 什么时候处理信号呢?](#2.1 什么时候处理信号呢?)
- [2.2 重谈进程地址空间](#2.2 重谈进程地址空间)
- [2.3 信号的处理时机](#2.3 信号的处理时机)
- [2.4 sigaction函数](#2.4 sigaction函数)
- [三. 可重入函数](#三. 可重入函数)
- [四. volatile关键字](#四. volatile关键字)
- [五. SIGCHLD信号](#五. SIGCHLD信号)
一. 阻塞信号
1.1 信号的相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
1.2 在内核中的表示
- 在内核中进程的信号保存是用一个pending位图来保存的,每个比特位表示不同的信号,类似于数组下标,比特位为1表示收到该比特位对应的信号,为0表示没有收到
- 阻塞信号也是用位图表示的,和pending位图结构一样,0表示没有阻塞该信号,1表示阻塞该信号
- 进程需要对每个信号有自己的处理动作,在内核中用函数指针数组保存着,每个下标对应这对该信号的处理动作,如果我们没有设置,就是信号的默认处理动作,而我们设置了的话,就是我们设置的动作,所以其实我们之前调用signal系统调用的本质就是修改这个函数指针数组
- 信号被阻塞后,即使收到该信号也不会被递达,但是pending位图会被置1,直到取消阻塞状态才会被递达,pending位图才能被置0.
- 由上图可得SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
总结一下:
所以我们接下来对信号的操作系统调用,本质就是对这3张表进程操作
1.3 sigset_t及其相关操作
1.3.1 sigset_t介绍
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。
接下来将详细介绍信号集的各种操作。
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。
#include <signal.h>
int sigemptyset(sigset_t *set); //将信号集合set清空
int sigfillset(sigset_t *set); //将信号集set设置所有信号都存在
int sigaddset (sigset_t *set, int signo); //将指定信号signo添加进信号集set
int sigdelset(sigset_t *set, int signo); //将指定信号signo从信号集set中删除
int sigismember(const sigset_t *set, int signo);//判断signo是否存在信号集set中,存在返回1否则0
注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
1.3.2 sigprocmask详解
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
参数详解:
how and set
SIG_BLOCK: 将set中的信号集合添加到当前的信号屏蔽字中,相当于,mask = mask | set
SIG_UNBLOCK:将set中的信号集合从当前的信号屏蔽字中移除, 相当于,mask = mask & ~set
SIG_SETMASK: 将set中的信号集合替换为当前的信号屏蔽字, mask = set
oset
之前的信号屏蔽字
1.3.3 sigpending详解
NAME
sigpending - examine pending signals
SYNOPSIS
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
1.3.4 阻塞信号实验
实验一:
实验思路:
- 我们先对2号信号自定义捕捉,为了继续做实验准备
- 将2号信号屏蔽,持续20s,20s后解除屏蔽
- 在20s内先没有收到信号之前,打印信号全为0
- 收到2号信号后,2号信号对应的比特位为1,而且一直没有被递达,所以20s之内一直保持1
- 20s后,恢复原来的位图结构即解除对2号信号的屏蔽,信号递达,递达行为即步骤1,之后打印全为0
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void PrintPending(sigset_t &pending)
{
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << "\n\n";
}
void handler(int signo)
{
cout << "catch a signo: " << signo << endl;
}
int main()
{
sigset_t bset, oset;
sigemptyset(&bset);
sigemptyset(&oset);
// 0. 对2号信号进行自定义捕捉
signal(2, handler);
// 1. 先对2号信号进行屏蔽 --- 数据预备
sigset_t bset, oset; // 在哪里开辟的空间???用户栈上的,属于用户区
sigemptyset(&bset);
sigemptyset(&oset);
sigaddset(&bset, 2); // 我们已经把2好信号屏蔽了吗?并没有设置进入到你的进程的task_struct
// 1.2 调用系统调用,将数据设置进内核
sigprocmask(SIG_SETMASK, &bset, &oset); // 我们已经把2好信号屏蔽了吗?ok
// 2. 重复打印当前进程的pending 0000000000000000000000000
sigset_t pending;
int cnt = 0;
while (true)
{
// 2.1 获取
int n = sigpending(&pending);
if (n < 0)
continue;
// 2.2 打印
PrintPending(pending);
sleep(1);
cnt++;
// 2.3 解除阻塞
if(cnt == 20)
{
cout << "unblock 2 signo" << endl;
sigprocmask(SIG_SETMASK, &oset, nullptr); // 我们已经把2好信号屏蔽了吗?ok
}
}
// 3 发送2号 0000000000000000000000010
return 0;
}
实验现象:
实验二:
实验思路:
直接把所有普通信号都给屏蔽了
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void PrintPending(sigset_t &pending)
{
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << "\n\n";
}
void handler(int signo)
{
cout << "catch a signo: " << signo << endl;
}
int main()
{
// 4. 我可以将所有的信号都进行屏蔽,信号不就不会被处理了吗? 肯定的!9
sigset_t bset, oset;
sigemptyset(&bset);
sigemptyset(&oset);
for (int i = 1; i <= 31; i++)
{
sigaddset(&bset, i); // 屏蔽了所有信号吗???
}
sigprocmask(SIG_SETMASK, &bset, &oset);
sigset_t pending;
while (true)
{
// 2.1 获取
int n = sigpending(&pending);
if (n < 0)
continue;
// 2.2 打印
PrintPending(pending);
sleep(1);
}
return 0;
}
实验现象:和之前signal实验现象相似,9和19号信号无法屏蔽,其他可以
信号保存总结:
sigset是用户层的位图结构,通过系统调用接口,可以让sigset和进程的block、pending、handler表建立联系,获取或修改它们。
二. 信号的捕捉
2.1 什么时候处理信号呢?
之前说过,信号会在合适的时候被处理,那么究竟是什么时间呢?从内核态切换到用户态的时候,会进行信号的监测和处理。
但是,内核态、用户态又是什么呢?
内核态: 内核态代码运行在处理器特权级别最高 (通常是 0 级) 的模式下。这意味着内核态代码可以直接访问所有硬件资源,包括内存、I/O 设备、CPU 指令和寄存器等。它不受任何限制,可以执行任何操作,包括修改操作系统自身。
用户态: 用户态代码运行在较低的特权级别 (通常是 3 级)。这意味着用户态程序只能访问操作系统分配给它的资源,而不能直接访问硬件。它受到操作系统的严格控制,以防止恶意程序或错误程序损害系统。 用户态程序试图执行特权操作(例如直接访问内存地址)会导致操作系统中断其执行。
那么我们什么时候进行用户态与内核态之间的切换呢?
系统调用,时间片,异常中断等。
拿系统调用来说:操作系统给我们提供了一些系统调用,让我们执行操作系统的代码和数据,但是操作系统不相信任何人,这两句话不是互相矛盾嘛?所以当我们执行系统调用的时候需要进行身份切换,由用户态切换成内核态。
我们怎么知道我们是处于内核态还是用户态呢?
这需要cpu来配合,在cpu中,有一些寄存器保存着进程的资源,比如存入插入寄存器cr3保存着进程的页表,而有一个特殊的寄存器ecs,它的后两个比特位存储着表示进程是用户态还是内核态,其中00表示内核态,11表示用户态
2.2 重谈进程地址空间
- 主机开机的时候,操作系统会首先被加载进内存
- 每个进程都有自己独立的用户代码和数据,但是操作系统的代码和数据都一样,所以有多少个进程就有多少个用户级页表,而内核级页表只有一份
- 当发送进程切换的时候,进程地址空间的3-4g属于内核空间,每个进程都一样,所以不用切换内核空间
- 当我们使用系统调用的时候,本质就是访问操作系统的代码和数据,操作系统不相信任何人,所以需要将身份由用户态切换为内核态
2.3 信号的处理时机
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
可以发现当我们执行自定义捕捉函数sighandler的时候,从内核态切换为了用户态,这是为什么?
其实如果操作系统想要这么做的话很容易,但是为了安全性考虑,操作系统没有选择这样做,因为操作系统不相信任何人,万一sighandle函数内部做了异常的行为呢?
上图简单记忆法:
**处理信号时,pending位图中的比特位什么时候由1变0,处理前还是处理后? **
处理前,代码验证
c
#include <iostream>
#include <unistd.h>
#include <cstring>
#include <signal.h>
using namespace std;
void PrintPending()
{
sigset_t set;
memset(&set, 0, sizeof(set));
sigpending(&set);
for (int i = 31; i >= 1; --i)
{
if (sigismember(&set, i))
{
cout << '1';
}
else
{
cout << '0';
}
}
cout << endl;
}
void handler(int signum)
{
cout << "catch a sig: " << signum << endl;
while(true)
{
PrintPending();
sleep(1);
}
}
int main()
{
signal(2, handler);
while (true)
{
cout << "i am a process : " << getpid() << endl;
sleep(1);
}
return 0;
}
可以看到,我们处理信号时,pending位图已经置0,所以是处理前
2.4 sigaction函数
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo
是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传
出该信号原来的处理动作。act和oact指向sigaction结构体:
struct sigaction定义如下
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: 指定信号signo的处理方式
sa_sigaction:用来处理实时信号的,本文不考虑
mask:当正在处理signo信号期间,signo会自动加入信号屏蔽字,mask也可以再屏蔽更多的信号
其他两个不考虑
实验:
思路:
对2号信号进行自定义捕捉,并设置mask为12(默认自带的)34, 处理动作为不断打印oending位图,并且打死循环,若第一次收到2号信号,将会进入打印pending位图的死循环,若再发1.2.3.4信号,pending位图对应位置变1,而且一直不递达
cpp
#include <iostream>
#include <unistd.h>
#include <cstring>
#include <signal.h>
using namespace std;
void PrintPending()
{
sigset_t set;
memset(&set, 0, sizeof(set));
sigpending(&set);
for (int i = 31; i >= 1; --i)
{
if (sigismember(&set, i))
{
cout << '1';
}
else
{
cout << '0';
}
}
cout << endl;
}
void handler(int signum)
{
cout << "catch a sig: " << signum << endl;
while(true)
{
PrintPending();
sleep(1);
}
}
int main()
{
struct sigaction sa;
struct sigaction osa;
memset(&sa, 0, sizeof(sa));
memset(&osa, 0, sizeof(osa));
sa.sa_handler = handler;
sigset_t mask;
memset(&mask, 0, sizeof(mask));
sigaddset(&mask, 1);
sigaddset(&mask, 3);
sigaddset(&mask, 4);
sigaddset(&mask, 5);
sa.sa_mask = mask;
sigaction(2, &sa, &osa);
while (true)
{
cout << "i am a process : " << getpid() << endl;
sleep(1);
}
return 0;
}
现象:
三. 可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
四. volatile关键字
该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下
c
//test.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int flag = 0;
void handler(int signum)
{
cout << "flag 0 to 1 " << endl;
flag = 1;
}
int main()
{
signal(2, handler);
//在优化条件下,flag可能会被优化到寄存器中
while(!flag);
cout << "process quit normal" << endl;
return 0;
}
//makefile
mytest:test.cc
g++ -o $@ $^ -g -std=c++11
.PHONY:clean
clean:
rm mytest
标准情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 , while 条件不满足,退出循环,进程退出
g++编译器默认优化为O1即不优化,若改为更高的O2或O3,编译器认为main执行流中flag不会被修改,就优化到了寄存器,之后的修改是对内存中flag的修改,不影响寄存器中的值,造成了while一直不退出
c
//makefile
mytest:test.cc
g++ -o $@ $^ -g -O2 -std=c++11
.PHONY:clean
clean:
rm mytest
此时现象:
如何解决呢?很明显需要 volatile来保持内存的可见性,防止优化flag
c
//test.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
volatile int flag = 0;
void handler(int signum)
{
cout << "flag 0 to 1 " << endl;
flag = 1;
}
int main()
{
signal(2, handler);
//在优化条件下,flag可能会被优化到寄存器中
while(!flag);
cout << "process quit normal" << endl;
return 0;
}
五. SIGCHLD信号
- 之前进程控制讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
- 其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,进程在信号处理函数中调用wait清理子进程即可。
测试:
cpp
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <ctime>
using namespace std;
void handler(int signo)
{
pid_t rid;
//当收到一次17号信号时,在这期间其实又有子进程退出,也能通过waitpid非阻塞回收
//从而解决了此时信号信号屏蔽字带来的问题
while((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
{
cout << "wait child success: " << rid << endl;
}
}
int main()
{
signal(17, handler);
srand(time(nullptr));
for(int i = 0; i < 10; ++i)
{
pid_t id = fork();
if(id == 0)
{
//模拟不同子进程再不同的时间退出
sleep(rand() % 5 + 3);
cout << "i am child process, pid: " << getpid() << " ppid: " << getppid() << endl;
_exit(0);
}
}
while(true)
{
cout << "i am father process, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
- 事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。请编写程序验证这样做不会产生僵尸进程。
c
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <ctime>
using namespace std;
// void handler(int signo)
// {
// pid_t rid;
// //当收到一次17号信号时,在这期间其实又有子进程退出,也能通过waitpid非阻塞回收
// //从而解决了此时信号信号屏蔽字带来的问题
// while((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
// {
// cout << "wait child success: " << rid << endl;
// }
// }
int main()
{
signal(17, SIG_IGN);
srand(time(nullptr));
for(int i = 0; i < 10; ++i)
{
pid_t id = fork();
if(id == 0)
{
//模拟不同子进程再不同的时间退出
sleep(rand() % 5 + 3);
cout << "i am child process, pid: " << getpid() << " ppid: " << getppid() << endl;
_exit(0);
}
}
while(true)
{
cout << "i am father process, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
可以看到,此时子进程没有僵尸进程状态。