一、预备知识
1.1、引入信号
什么叫做信号 日常生活中一个人接受了一个信号 往往需要中断此时在做的事 所以信号是一种异步通知机制
什么是异步 那就要先谈谈同步了 同步就是一个事件等待到另一个事件之后两者一起运作 那么异步就是两个事件的运作互不干扰影响
所以信号相对于进程的运行来说是一种异步通知 (也就是进程的运行和信号的产生是异步的 进程在接收到信号后会有一些处理)
1.2、基本结论
- 信号处理:进程在信号没有产生时 就知道了信号应该如何处理
- 进程接收到信号之后可以等一会再处理 可以不是立即处理
- OS设计的进程早就内置了对信号的识别和处理方式
- 信号源非常多 也就是产生信号的方式非常多
二、信号的产生
2.1、键盘产生信号带出概念
信号产生的方式非常多 这里用键盘产生信号引入一些概念 之后再介绍更多的信号产生方式
看一个代码 它的运行就是一直hello world和一个递增的计数器
cpp
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
int cnt = 0;
while(true)
{
cout << "hello world , " << cnt++ << endl;
sleep(1);
}
return 0;
}
在进程运行时CTRL+c给目标进程发送信号 那么这个进程就终止了 这就是键盘发送信号的一个例子

2.1.1、信号有哪些
看一看信号有哪些 kill -l 命令查看
只有1~31是普通信号 后面的都是时序信号(收到时序信号立即做出反应 和基本结论2相反) 这里我们不关心
其中ctrl+c就是第二个SIGINT 所以从键盘上输入ctrl+c实际上就是给进程发送信号2
2.1.2、进程处理信号的方式
其实进程收到信号处理信号有三种方式
- 大多数信号默认处理就是终止进程
- 可以自定义设置收到信号之后进程做出的反应
- 进程忽视收到的信号
证明
尝试更改进程收到信号之后的默认处理动作 自定义处理动作
需要用到一个接口
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
RETURN VALUE
signal() returns the previous value of the signal handler, or SIG_ERR on error. In the event of an error, errno is set to indicate
the cause.
当传入信号给进程时 第一个参数指定捕捉的信号值比如SIGUNT 这个函数指针就是用来自定义收到信号之后的操作的 并且会将指定的信号值传给这个函数的参数
看代码
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void sighindler(int signum)
{
cout << "收到一个信号 : " << signum << endl;
}
int main()
{
signal(SIGINT, sighindler);
int cnt = 0;
while(true)
{
cout << "hello world , " << cnt++ << endl;
sleep(1);
}
return 0;
}

可以观察到现在ctrl+c杀不掉进程了 因为我们自定义了进程收到这个信号之后的处理 是打印 不是默认处理的终止 可以使用ctrl+\来终止进程 这也是一种信号
收到信号的三种处理方式都涉及到信号的捕捉,而这个叫做自定义捕捉
2.1.3、前台/后台进程
前面说到给目标进程发送信号 那么什么是目标进程
这就要提到前台进程和后台进程了
还是上面的testsig.cc代码 当运行时 从键盘上输入信号 这个进程运行被影响 当运行时./testsig & 在运行命令后面加上一个& 再观察运行时从键盘上ctrl+c发送信号之后进程运行情况

此时发现按ctrl+c没用了 这是因为加上&之后这个进程就变成了后台进程现在只能在另一个终端来杀掉这个进程kill -9 加进程号实际上这个命令也是向进程发送信号
那么前台和后台进程的区别就是:
只有前台进程才能接收从键盘输入的信号 因为前台进程只能有一个 而键盘输入的信息也只能发送给一个进程 前台进程的本质就是从键盘获取信息的
而后台进程可以有多个 不能接收从键盘输入的信号
再来看一组现象 众所周知 自定义shell进程是我们打开终端就有的 它默认就可以接收键盘的输入 那么它就是一个前台进程
证明前台进程只能有一个 现在./testsig启动这个进程 那么这个进程就是前台进程了 按理说shell接受不了从键盘上的输入了 看看

发现确实pwd ll这些命令没用了 这是因为前台进程只能有一个 当testsig变成前台之后 shell就是后台进程了 而后台进程接受不了从键盘输入的信号
若是./testsig &再输入pwd或者ll呢

的确可以接收pwd ll等命令这是因为shell此时还是前台进程
看一些关于前台进程和后台进程的命令
jobs查看所有的后台进程

这个[1]就是任务号
fg 加上任务号将后台进程变为前台进程

ctrl+z将前台进程放到后台

bg 加上任务号 让后台进程恢复运行

2.1.4、什么叫做给进程发送信号(第一次理解)
首先信号实际上就是一个一个整形数字
之前有个结论 就是进程收到信号之后不一定要立即处理 那么如果要等会处理 要需要先把这个信号记录下来 并且可能有很多信号 也需要记录下来 那么是如何记录的呢
在进程的PCB中存在一个整形数字 这个数字来记录信号 使用位图的方式 使用31个比特位来记录信号
那么发送信号的本质就是向目标进程写入信号 也就是修改PCB中的内容 修改里面的一个位图
但是修改进程内核数据结构只有OS才能完成 普通用户不能完成 所以不管如何发送信号本质上都需要先通过操作系统完成 也就是OS拿到信号之后发给进程并且修改进程PCB中的位图
例如一个前台进程 ctrl+c先要让OS拿到 给后台进程发送信号使用kill 加信号值 加进程PID实际上是告诉OS应该给哪个进程发送哪个信号 前台不需要指定PID是因为前台进程只有一个
所以什么叫做给进程发送信号 给进程发送信号都需要借助操作系统之手去完成 我们普通用户想要给后台进程发送信号那就需要系统接口调用借助OS的帮助
2.2、系统调用发送信号
2.2.1、kill
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
RETURN VALUE
On success (at least one signal was sent), zero is returned. On error, -1 is returned, and errno is set appropriately.
在另一个进程中使用系统调用kill 第一个参数为目标进程PID 第二个参数为发送信号的信号值
代码示例
cpp
#include <iostream>
#include <string>
#include <signal.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
// 出错打印错误信息并且退出
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main(int argc, char *argv[])
{
if (argc != 3)
{
// 不是三个参数退出
cout << "kill proecsspid signum" << endl;
return 1;
}
int signum = stoi(argv[1]);
pid_t pid = stoi(argv[2]);
int n = kill(pid, signum);
if (n == -1)
{
ERR_EXIT("kill");
}
cout << "send signum " << signum << " to process " << pid << endl;
return 0;
}
此时只需要在另一个终端输入./kill signum processid即可使用系统调用给进程发送信号了
先使用kill命令发现可以发送信号

再使用./kill进程发送 但是必须要有三个参数 因为kill系统调用需要知道目标进程的pid

2.2.2、raise
先写一个可以自定义捕捉绝大部分信号的代码


ctrl+c或者+z或者\都不能终止 这是因为自定义化了
但是这样的话难道一个进程不能被杀死吗 为了保护安全性 有一个信号可以杀死就是 九号信号SIGKILL
在另一个终端查到进程pid 使用命令kill -9 杀死即可

现在介绍raise
#include <signal.h>
int raise(int sig);
当前进程给自己发送信号
用一段代码测试这个接口 每次发送一个信号给自己 这个信号会被捕捉 执行自定义动作
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
using namespace std;
void sighandler(int signum)
{
cout << "收到一个信号 : " << signum << endl;
}
int main()
{
for (int i = 1; i <= 31; i++)
{
signal(i, sighandler);
}
for(int i = 0; i <= 31; i++)
{
sleep(1);
raise(i);
}
int cnt = 0;
while (true)
{
sleep(1);
cout << "hello world , " << cnt++ << ", pid : " << getpid() << endl;
}
return 0;
}

其实还有一个信号可以不被自定义捕捉 强制终止进程
现在跳过raise(9)看看


就是十九号 跳过9和19看看还有没有


没了 也就是说9和19信号不会被自定义捕捉 可以强制杀死或者停止进程
2.2.3、abort
准确地说这个接口不是系统调用而是封装在glib.c中地
#include <stdlib.h>
void abort(void);
调用这个接口试试呢
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
using namespace std;
void sighandler(int signum)
{
cout << "收到一个信号 : " << signum << endl;
}
int main()
{
// signal(SIGINT, sighandler);
for (int i = 1; i <= 31; i++)
{
signal(i, sighandler);
}
// for(int i = 0; i <= 31; i++)
// {
// sleep(1);
// if(i == 9 || i == 19) continue;
// raise(i);
// }
int cnt = 0;
while (true)
{
sleep(1);
cout << "hello world , " << cnt++ << ", pid : " << getpid() << endl;
abort();
}
return 0;
}

这个接口给自己发送且只发送一个信号 六号 但是之前依次发送信号值地时候为什么不会终止 这是因为abort特殊处理过
2.3、kill命令发送信号
2.4、硬件发送信号
硬件发送信号一般是出现了异常 最常见地异常就两种 除0或者野指针引用
这里写一个代码设计这两种错误看OS检测到硬件错误会给进程发送什么信号
cpp
void sighandler(int signum)
{
cout << "收到一个信号 : " << signum << endl;
exit(13);
}
// 硬件发送信号
int main()
{
for (int i = 1; i <= 31; i++)
{
signal(i, sighandler);
}
int cnt = 0;
while (true)
{
sleep(1);
cout << "hello world , " << cnt++ << ", pid : " << getpid() << endl;
int a = 0, b = 10;
int c = b / a;
}
return 0;
}

除0异常OS给进程发送8号信号
看一下野指针引用地错误会发送什么信号


发送11号信号 
OS系统是如何知道出现了异常呢 这需要看看硬件
首先除0

CPU中存在一个寄存器状态寄存器 记录进程的上下文 当出现除0错误时 OS给进程发送8号信号
至于野指针引用

指针的引用需要地址映射找到物理地址通过当前指针的虚拟地址通过页表映射找到物理内存从而更改指针指向的值 CPU中会存在一个CR3寄存器会记录页表的物理地址 MMU拿着ptr虚拟地址和CR3中的物理地址从页表中找到ptr的物理地址从而完成值的修改
但是可能出现转换失败 也就是野指针的问题 那么此时OS会检测到这个问题 从而发送11号信号给进程
至于OS具体是怎么检测硬件错误的 后面将会提及
2.5、软件条件发送信号
什么是软件条件发送信号 即出发了用软件实现的某个程序的条件之后OS检测到之后向进程发送信号
比如管道 两个进程通过管道进行通信 当读端关闭时 此时写端必然会关闭 那么是由OS检测到读端关闭向写端进程发送SIGPIPE信号 让写端退出的
还有一个重要的软件条件发送信号 就是闹钟
首先看看这个系统接口调用
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
RETURN VALUE
alarm() returns the number of seconds remaining until any previously scheduled alarm was due to be delivered, or zero if there was no
previously scheduled alarm.
参数为设定的秒数
返回值为上一个闹钟剩下的时间 比如alarm(5)过了3秒之后重新启动闹钟alarm(10)那么alarm(10)的返回值就是5-3=2 若是只有alarm(5)那么5s之后这个闹钟返回值为0
取消闹钟alarm(0)
简单使用
cpp
// 软件条件发送信号------闹钟
int main()
{
int cnt = 0;
alarm(1); // 1s之后发送SIGALRM信号给这个进程
while(true)
{
cout << "cnt : " << cnt++ << endl;
sleep(1);
}
}

1s之后发送SIGALRM进程终止
闹钟就是当设定的时间到了OS检测到之后向进程发送信号SIGALRM 进程收到这个信号之后默认处理是终止进程
用闹钟来测试IO的效率低于CPU效率 顺便熟悉一下闹钟的使用
用闹钟当作进程终止条件 1s内cnt能加成多少
先看IO的 也就是每次都打印cnt 因为这里是在云服务器上 所以每次都要通过网络打印到本地显示器 所以存在IO
cpp
int main()
{
int cnt = 0;
alarm(1); // 1s之后发送SIGALRM信号给这个进程
while(true)
{
cout << "cnt : " << cnt++ << ", pid : " << getpid() << endl;
}
}

三万多次
再看没有大量IO的 也就是1s之后再打印cnt不是每次都打印
cpp
int cnt = 0;
void sighandler(int signum)
{
cout << "收到一个信号 : " << signum << ", pid : " << getpid() << endl;
cout << "cnt : " << cnt << endl;
exit(1);
}
// 软件条件发送信号------闹钟
int main()
{
// 自定义捕捉
signal(SIGALRM, sighandler);
alarm(1); // 1s之后发送SIGALRM信号给这个进程
while (true)
{
// cout << "cnt : " << cnt++ << ", pid : " << getpid() << endl;
cnt++;
}
}

可见IO效率和CPU相比还是比较低的 就算是和本地的文件IO(这里是和远端云服务器通过网络IO)也远低于CPU
用闹钟当作触发条件 每次发送闹钟OS检测到之后向进程发送任务
先看一个函数 这个函数可以暂停进程 只有进程接收到信号时才能让进程继续运行
#include <unistd.h>
int pause(void);
不发送任何信号给进程 使用pause 进程暂停


使用一次alarm 可以观察到暂停1s后 进程重新运行 但是还有pause所以又会停止 所以pause暂停进程之后确实需要发送信号来解决


可以让闹钟配合pause来控制进程的运行 一直pause只有闹钟发送信号之后 进程才运行
让程序一直pause 在main函数里面调用依次alram(1) 1s之后自定义处理 此时进程解除pause 自定义处理时又调用alarm(1) 在这1s内进程一直在while循环里面pause 等到1s后开始运行 这样就使用alarm和pause控制了进程的运行


那么如何发送任务呢 首先需要定义几个任务用functional包装任务 之后注册任务 之后每次alarm唤醒进程之后开始执行任务
cpp
// 包装器 让函数有类型
using func_t = function<void()>;
// 收集任务
vector<func_t> func;
// 任务
void f1()
{
cout << "f1()" << endl;
}
void f2()
{
cout << "f2()" << endl;
}
void f3()
{
cout << "f3()" << endl;
}
void sighandler(int signum)
{
cout << "*******************************" << endl;
for(auto& f : func) f();
cout << "*******************************" << endl;
alarm(1);
}
int main()
{
// 注册任务
func.push_back(f1);
func.push_back(f2);
func.push_back(f3);
// 自定义捕捉
signal(SIGALRM, sighandler);
alarm(1); // 1s之后发送SIGALRM信号给这个进程
int cnt = 0;
while (true)
{
pause();
}
}

简单理解系统层面的闹钟

闹钟需要被先描述再组织 因此需要对应的内核数据结构 结构体里面存在一个expires用来记录这个闹钟的超时时间 也就是当时间超过这个之后 这个闹钟开始启动
那么这个闹钟启动会怎么样呢 这个结构体里面还有一个函数指针 作用就是让OS给进程发送闹钟信号
闹钟组织的实现可以用小根堆来时间 也就是按照expires来进行建堆 堆顶的一定时超时时间最小的 当进程时间超过这个最小时间之后 堆顶删除 并且发送对应信号 这就是系统层面闹钟的理解
###########################################################################
总结一下信号的发送
实际上就是借助OS的帮助给进程发送一个整形数字 改变进程PCB中的位图 无论五种方法中的哪一种都是如此
三、信号的保存
3.1、信号保存中常见的概念
实际执⾏信号的处理动作称为信号递达(Delivery)
信号从产⽣到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动
作。
信号递达:即进程收到信号之后开始处理的动作 有三个 默认 自定义 忽略
进程收到信号之后不是立即处理 那么肯定先要将信号保存起来 保存的地方是就是PCB中的位图 那么更改了位图 此时的信号就叫信号未决 等待处理
阻塞就是信号未决并且永远不会被处理 也就是永远不会信号递达 除非解除阻塞
阻塞和忽略的区别 阻塞是连处理都不会 而忽略是信号递达之后的一种处理动作 阻塞在前而忽略在后
阻塞也叫屏蔽 默认情况下不对进程任何信号阻塞
3.2、信号在内核中的表示

实际上在进程PCB中存在三个表 前两个表是位图 第一个block也就是表示是否阻塞的信号 下标为信号值的映射 01表示是否阻塞 第二个表为pending表示进程收到了这个信号没有 也就是表示信号未决的状态 当信号递达之后在pending表中对应下标位置被置为0
第三个表为handler 这个表需要借助signal(signum, sighandler)来理解 首先signum表示这个信号值同时映射到handler对应下标位置 而后面的函数指针会被填在handler表中对应下标位置 也就完成了自定义处理
最初结论为什么说 在信号没有传递前进程就知道信号该怎么处理了 这是因为 handler表中记录了处理的函数指针 真正处理时直接查表即可
在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。
SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻
塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数
sighandler。
SIG_DFL表示默认处理 SIG_IGN表示忽略处理
这两个值设置在signal函数的第二个参数表示进程收到信号之后的处理方式
看一份代码来理解
自定义处理之后将收到signum之后的处理方式改为默认处理方式
cpp
// SIG_DEL && SIG_IGN
void sighandler(int signum)
{
cout << "收到一个信号 : " << signum << endl;
//signal(signum, SIG_DFL); // 自定义处理之后将收到signum之后的处理方式改为默认处理方式
signal(signum, SIG_IGN); // 自定义处理之后将收到signum之后的处理方式改为忽略处理方式
}
int main()
{
signal(SIGINT, sighandler);
int cnt = 0;
while (true)
{
cout << "cnt : " << cnt++ << endl;
sleep(1);
}
return 0;
}
自定义处理之后再按ctrl+c就是默认的终止处理了

自定义处理之后将收到signum之后的处理方式改为忽略处理方式
之后按ctrl+c没反应 这是因为处理方式已经变为忽略

3.3、sigset_t && 信号集的操作函数
//内核结构2.6.18
struct task_struct {
...
/* signal handlers */
struct sighand_struct *sighand;
sigset_t blocked
struct sigpending pending;
...
struct sighand_struct {
atomic_t count;
struct k_sigaction action[_NSIG]; // #define _NSIG 64
spinlock_t siglock;
};
struct __new_sigaction {
__sighandler_t sa_handler;
unsigned long sa_flags;
void (*sa_restorer)(void); /* Not used by Linux/SPARC */
__new_sigset_t sa_mask;
};
struct k_sigaction {
struct __new_sigaction sa;
void __user *ka_restorer;
};
/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);
struct sigpending {
struct list_head list;
sigset_t signal;
};
从上图来看,每个信号只有⼀个bit的未决标志, ⾮0即1, 不记录该信号产⽣了多少次,阻塞标志也是这样表⽰的。因此, 未决和阻塞标志可以⽤相同的数据类型sigset_t来存储, sigset_t称为信号集, 这个类型可以表⽰每个信号的"有效"或"⽆效"状态, 在阻塞信号集中"有效"和"⽆效"的含义是该信号
是否被阻塞, ⽽在未决信号集中"有 效"和"⽆效"的含义是该信号是否处于未决状态。下⼀节将详
细介绍信号集的各种操作。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask) 这⾥的"屏
蔽"应该理解为阻塞⽽不是忽略。
这里将sigset_t理解为一个整形即可
信号屏蔽字可以理解为掩码一样umask
3.3.1、操作信号集的函数
#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初始化set所指向的信号集,使其中所有信号的对应bit清零,表⽰该信号集不包含任何有效信号。
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表⽰ 该信号集的有效信号包括系 统⽀持的所有信号。
注意,在使⽤sigset_ t类型的变量之前,⼀定要调 ⽤sigemptyset或sigfillset做初始化,使信号集处于确定的 状态。初始化sigset_t变量之后就可以在调⽤sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是⼀个布尔函数,⽤于判断⼀个信号集的有效信号中是否包含 某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
两个重要函数
3.2.2、sigprocmask
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
这个函数是对block处理的函数
第一个参数表明应该做哪种处理
一般使用第三种处理方式 因为更加简单 一次性直接处理
第二个参数表明具体处理的实现 是一个输入型参数
第三个参数是一个输出型参数 当我们需要知道处理之前的block时 就设置第三个参数 这个参数会把处理之前的block记录下来
3.2.3、sigpending
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1
这个参数是一个输出型参数 用来记录pending表
那应该如何操作pending表呢 操作这个表之前就认识过 系统调用传递信号会将pending表中的对应signum下标处置为1 当这个位置的信号递达之后 重新被置为0 比如kill(signum, pid)
3.2.4、代码验证阻塞之后发送信号后pending表变化
写一个阻塞二号信号的代码 观察pending表 需要使用信号集操作函数 具体看代码
cpp
// 测试阻塞并且发送信号之后的pending表变化
void Print(sigset_t &pending)
{
cout << "pending : ";
for (int i = 31; i >= 1; i--)
{
if (sigismember(&pending, i))
cout << '1';
else
cout << '0';
}
cout << endl;
}
int main()
{
sigset_t block, oblock; // 记录新、旧block表
sigemptyset(&block);
sigemptyset(&oblock);
// 将二号信号置为1
sigaddset(&block, SIGINT);
// 设置进进程的block表
sigprocmask(SIG_SETMASK, &block, &oblock);
// 此时2号信号阻塞开始发送信号观察pending表变化
// 打印pending表 先获取
while (true)
{
sigset_t pending;
sigpending(&pending);
Print(pending);
sleep(1);
}
}

发送二号信号之后因为block表阻塞的原因 这个信号一直处于未决状态 不会递达 那么就可以观察到pending表中二号信号位置值由0变为1
现在当运行到一定时间之后 恢复二号信号的阻塞状态为正常状态 也就是改一下block表 这就要用到之前记录的oblock 这个表中全部不阻塞


那么此时进程会收到二号信号 默认终止进程 我们看不到pending表变化 应该自定义捕捉二号进程 方便我们观察pending表


由0变1之后再变为0
pending变化在递达之前
那pending表中变化是在递达前还是递达后呢 也就是说变化是在处理之前还是处理之后 为了观察 因为这里的处理是自定义处理 所以在自定义函数里面写代码测试 就打印一边pending表 若是处理时就已经变为0那么就说明是在递达前pending就变化了 反之则是在递达之后变化
cpp
// 测试阻塞并且发送信号之后的pending表变化
void Print(sigset_t &pending)
{
cout << "pending : ";
for (int i = 31; i >= 1; i--)
{
if (sigismember(&pending, i))
cout << '1';
else
cout << '0';
}
cout << endl;
}
void sighandler(int signum)
{
cout << "***********************************" << endl;
cout << "捕捉到信号 : " << signum << endl;
sigset_t pending;
sigpending(&pending);
Print(pending);
cout << "***********************************" << endl;
}
int main()
{
signal(SIGINT, sighandler);
sigset_t block, oblock; // 记录新、旧block表
sigemptyset(&block);
sigemptyset(&oblock);
// 将二号信号置为1
sigaddset(&block, SIGINT);
// 设置进进程的block表
sigprocmask(SIG_SETMASK, &block, &oblock);
// 此时2号信号阻塞开始发送信号观察pending表变化
// 打印pending表 先获取
int cnt = 0;
while (true)
{
sigset_t pending;
sigpending(&pending);
Print(pending);
sleep(1);
cnt++;
if (cnt == 10) // 恢复
{
cout << "恢复二号信号阻塞为正常状态" << endl;
sigprocmask(SIG_SETMASK, &oblock, nullptr);
}
}
}

所以信号递达之前pending表就变化了、
九号信号不能被捕捉也不能被阻塞
并不是所有信号都能被阻塞 否则不安全 这个例外就是九号信号
看代码演示

3.3、细节问题
3.3.1、若是某一个进程阻塞了 一直发送这个信号 在pending表中只会记录依次
3.3.2、Core和Term

简单理解这两个都代表进程被终止
但是Core不仅仅是这样
当是Core类型的终止时 会在当前目录下生成一个文件 这个文件从内存中拷贝进程在内核中的核心数据 这种技术叫做核心转储
而Term是直接退出
看一下Core类型的退出

那这种技术的作用是什么 为了方便调试
但是此时的目录下没有 Core文件 这是因为云服务器不支持这个 因为
- 性能与资源消耗:生成 core 文件会占用大量 CPU 和内存,尤其在高并发场景下,可能导致服务短暂卡顿或资源耗尽,影响云服务器的整体稳定性。
- 安全风险:core 文件会完整记录进程崩溃时的内存数据,可能包含密码、密钥、用户隐私等敏感信息。若被未授权人员获取,会直接引发数据泄露风险。
- 存储管理压力:core 文件体积通常很大(可能与进程占用内存相当),若频繁生成且未及时清理,会快速占用云服务器的存储空间,甚至导致磁盘满而影响服务运行。
可以用ulimit -a查看支持的core文件大小 默认是0 不过可以改

unlimit -c 指定文件大小 之后core支持了 可以生成core文件 不过这种改变不是永久的 要一直保持这个大小需要修改配置文件

此时再运行testsig看出现了core文件
此时用gdb testsig 之后再用file-core core就可以快速看到是哪里出错了
实际上进程退出时的status中也记录了 当是被信号杀的且是Core类型的那么此时的exit code为0 core dump为1 高八位为终止信号
也就是进程退出时status中core dump用01记录了是否需要核心转储

四、信号捕捉
前面结论知道处理信号不一定是立即处理 可以等一会在合适的时候处理 那么合适的时候是什么时候 这里涉及到具体信号处理的内容
4.1、信号处理的过程

代码在执行时因为系统调用或者别的进入内核中 在内核中完成相关任务之后准备返回用户代码继续执行时会先检查一下PCB中的三张表 这个时候就是处理信号的时候 (若是阻塞或者pending为0那么直接返回用户代码 若是不阻塞且pending为1那么handler中对应为忽略时 修改pending为0之后直接返回即可 若是为默认处理 默认处理比如终止进程或者暂停 那么因为此时本身就在内核中 可以直接由OS杀死或者调度进程之后返回)如果不阻塞并且pending为1并且handler为我们自定义的处理方式 那么此时就会从内核跳转到用户代码区调用相关处理的函数 之后再返回内核区 最后再由内核区返回到用户代码区继续执行
有一张图可以很形象地描述这个过程

几个小细节:
当从内核区跳转到用户区调用相关方法时 此时执行自定义捕捉函数地角色是用户 不是内核 因为自定义捕捉方法可能出现bug是我们自己写的 但是OS不能执行出错的代码 否则这个操作系统的设计就出了问题了
我们的代码一定会进入内核 不管有没有系统调用 因为OS会调度我们的进程 比如时间片到了之后要把这个进程放到等待队列 或者正常地被调度去运行
4.2、操作系统是怎么运行的?(硬件中断)
当写一个这样的代码 int x = 0; scanf("%d", &x); printf("%d\n", x); 显然易见在没有在键盘上输入数据之前这个进程会阻塞住 因为它要等待键盘文件中出现数据之后读这个文才能往下运行件 那么此时要通过操作系统来判断键盘文件中有没有数据 那么操作系统是怎么检查的呢? 难道是一直轮询所有进程吗 不是! 当出现了大量进程 操作系统是忙不过来的 它还有其他的事做 因此不是这样的
这种情况是硬件中断通知操作系统的
中断向量表就是操作系统的⼀部分,启动就加载到内存中了
通过外部硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询
由外部设备触发的,中断系统运⾏流程,叫做硬件中断

当外部硬件就绪(比如上述中从键盘输入了数据)就会向中断控制器发送中断 中断控制器中会存在寄存器 (不一定只有CPU中才有寄存器外部设备也有) 并且编号对应着不同硬件设备 此时中断控制器会向CPU发送通知 CPU中设计了针脚连物理地接着各个硬件设备
插入一点 : 冯诺依曼体系中 CPU只和内存打交道不和外部设备打交道 也就是CPU只从内存中读取数据 但是呢在这里外部设备会向CPU发送通知 这个通知不是数据 也可以说成是一种控制信号
CPU收到之后知道了某个硬件准备就绪了 因此会从中断控制器中获取就绪设备的中断号 之后再去内存中查看中断向量表(这个中断向量表实际上就是OS的一部分 而OS在电脑开机之后就会加载到内存 中断向量表可以理解为一个函数指针数组)由中断号充当下标调用相关方法 在这里会去读取键盘文件的数据
那么OS就不需要一直注意着硬件设备了 而是会由外部设备就绪之后通知自己
这个场景实际上在思想上和信号一模一样 外部设备就是信号源 就绪之后就会发送信号也就是中断号 那么中断控制器就是pending表 里面地寄存器中的数据可以表示pending地一个个下标是否为1 中断向量表就是handler中地方法 其实中断还可以阻塞 可以这样说信号的思想就是中断 信号本质上是用软件模拟中断机制的
4.2.1、当中断没有到来时 操作系统在做什么?
上面说到OS因为硬件中断之后会调用相关方法进行处理 那么中断没有来时OS系统在干嘛呢?此时操作系统是暂停的 在内核源代码中可查

那么OS一直暂停的话那是怎么运作的呢?它还要进行进程调度
实际上在硬件中会有一个时钟源 这个时钟源会定期给中断处理器发送中断 对应着存在一个中断号 中断向量表中对应下标有一个方法就是进程调度 当CPU收到中断处理器的中断时会检测到这个中断号然后调用进程调度的方法
也就是说OS是在硬件中断的驱动下工作的 也就说OS是基于中断进行工作的软件
现在 因为担心进程调度的任务会和其他硬件中断争夺资源 在CPU内部集成了一个时钟源 这个时钟源让CPU每隔1ns就会自动调用中断向量表中进程调度的方法 这也是所谓的主频 有了这个主频就可以计算历史中频率之后通过时间戳转而就可以计算当前时间了

针对于进程来说 task_struct里面有一个count记录这个进程的时间片 每次1ns调度这个进程(也就是这个进程代码跑了1ns)之后count-- 当count为0时 转而调度其他的进程


4.2.2、基于软件的中断让OS运作

之前说信号的时候 信号源可以是软件 也就是代码出错 OS检测到向进程发送信号
实际过程是这样的 软件异常被规定为一种CPU内部触发的中断 当进程在执行自己的代码时触发除0错误CPU会到内存中找到软件异常的中断号的方法调用 这个调用的结果就是向进程发送信号
或者有缺页中断时 也就是只有虚拟地址空间没有物理地址空间 CPU也会根据此时触发的中断调用相关的方法去申请物理内存
这些软中断都是让CPU触发异常的中断 并不是主动地软中断 下面看看真正地软中断:
在CPU中存在一套指令集int(x86下 中断号一般为0x80) 、syscall(x64)
指令集的概念:我们写的代码本质上就是会被翻译为指令和数据 指令集就是让CPU读懂的
CPU中的这两个指令集是让CPU主动触发软中断的关键 也就是在写的代码中调用使用这两个指令 CPU就会进入操作系统的中断向量表中找到对应的方法 也就是软中断
现在深挖一个问题 : 当我们使用系统调用的时候 具体是怎么进入操作系统之后完成系统调用的过程的 毕竟CPU只有一个不可能还做那么多的工作
这个问题和软中断息息相关 :
触发软中断调用的中断向量表中 也就是软中断在表中下标位置 存在一张系统调用表

这张表作用是什么 实际上这张表包含了绝大部分的系统调用 当软中断触发 CPU调用对应方法 具体地 会拿到一个系统调用号 这个系统调用号对应的就是各种系统调用在系统调用表中的位置 之后用这个系统调用号去调用函数

这是在内核中 用户层面系统调用是怎么弄的呢?

假设现在使用open 那么再翻译为指令 CPU读的时候实际上将一个对标着open这个系统调用的系统调用号传到了eax这个寄存器中 之后触发软中断 CPU接收软中断 调用0x80位置的方法 这个方法会先将eax中的系统调用号拿出来并且记录下来 之后拿着这个号去系统调用表中调用相关的函数
在前面的学习过程中 open我们理解就是OS中的系统调用啊 为什么现在还要折腾这么多 那是因为我们使用的open准确来说并不是真正的系统调用 OS不给直接我们提供系统调用 而是只提供系统调用号 这些接口是被glibc封装过的 封装方法:将调用的接口名称转为一个系统调用号 之后将这个号写入寄存器 之后使用int或者syscall触发软中断 后面和上段话流程一样

看看具体封装内容


#define SYS_ify(syscall_name) _NR##syscall_name :是⼀个宏定义,⽤于将系
统调⽤的名称转换为对应的系统调⽤号。⽐如: SYS_ify(open) 会被展开为 __NR_open
⽽系统调⽤号,不是 glibc 提供的,是内核提供的,内核提供系统调⽤⼊⼝函数 man 2
syscall ,或者直接提供汇编级别软中断命令 int or syscall ,并提供对应的头⽂件或者开
发⼊⼝,让上层语⾔的设计者使⽤系统调⽤号,完成系统调⽤过程
看这两张图最后两行 都是将一个我们使用的接口名先展开替换成对应系统调用号 之后放入寄存器之后软中断
这个宏(也就是系统调用号)是OS提供的 在我们常常使用的unistd.h文件中有
源代码路径:linux-2.6.18\linux-2.6.18\include\asm-x86_64\unistd.h
/* at least 8 syscall per cacheline */
#define __NR_read 0
__SYSCALL(__NR_read, sys_read)
#define __NR_write 1
__SYSCALL(__NR_write, sys_write)
#define __NR_open 2
__SYSCALL(__NR_open, sys_open)
#define __NR_close 3
__SYSCALL(__NR_close, sys_close)
#define __NR_stat 4
__SYSCALL(__NR_stat, sys_newstat)
#define __NR_fstat 5
__SYSCALL(__NR_fstat, sys_newfstat)
#define __NR_lstat 6
__SYSCALL(__NR_lstat, sys_newlstat)
#define __NR_poll 7
__SYSCALL(__NR_poll, sys_poll)
#define __NR_lseek 8
__SYSCALL(__NR_lseek, sys_lseek)
#define __NR_mmap 9
__SYSCALL(__NR_mmap, sys_mmap)
#define __NR_mprotect 10
__SYSCALL(__NR_mprotect, sys_mprotect)
#define __NR_munmap 11
__SYSCALL(__NR_munmap, sys_munmap)
#define __NR_brk 12
__SYSCALL(__NR_brk, sys_brk)
#define __NR_rt_sigaction 13
__SYSCALL(__NR_rt_sigaction, sys_rt_sigaction)
#define __NR_rt_sigprocmask 14
__SYSCALL(__NR_rt_sigprocmask, sys_rt_sigprocmask)
#define __NR_rt_sigreturn 15
__SYSCALL(__NR_rt_sigreturn, stub_rt_sigreturn)
..................
总结一下:
所以系统调用实际上是通过软中断驱动的 glibc中封装open等一类接口 拿着OS提供的系统调用号写到寄存器之后触发软中断在中断向量表中找到0x80下标对应方法之后查系统调用表利用系统调用号调用真正的系统调用接口完成系统调用
缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,
然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来
处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。
操作系统就是躺在中断处理例程上的代码块!
CPU内部的软中断,比如int 0x80或者syscall,我们叫做陷阱
CPU内部的软中断,比如除零/野指针等,我们叫做 异常。(所以,能理解"缺⻚异
常"为什么这么叫了吗?)
4.3、如何理解用户态和内核态
首先认识用户以及内核可以被我们使用看见的结构 实际上就是进程地址空间 看一张图

进程地址空间前3~4GB就是内核区, 0~3BG是用户区 用户区通过页表映射到物理内存的对应位置 内核区也是
在用户区代码区的代码在执行时 调用哪个函数就会在进程地址空间内通过地址进行跳转调用比如共享区 栈区等等
每个进程的虚拟地址空间都存在一个内核区 并且这个内核区映射到内存中的区域是所有进程共享的 也就是说无论怎么调度进程 这个进程都可以拿到内核 也就是都可以找到操作系统
但是现在有个问题 在进程地址空间中 内核区和用户区都在0~4GB这个块内用户区代码可以跳转 那么岂不是也可以跳转到内核区了?这不是和很久之前说的相冲突了吗 之前说到OS不相信任何人 普通用户无法访问OS 除非通过系统调用才可以 不冲突 因为在CPU内部使用了某种方法使得用户只能通过系统调用来找到OS(实际上此时拿到并且使用这个方法的用户已经是内核态了) 下面介绍
在CPU内核有一个寄存器 Cs 这个寄存器记录了当前是用户态还是内核态 当我们的代码在跑的时候这个寄存器指向代码区的地址 并且里面的值为11 也就是3 表示此时是用户态 这时代码若是访问内核区那么OS是不允许的会直接杀死进程 因为不是内核态 当代码中调用系统调用时 这个寄存器会指向内核区地址 并且值修改为00 也就是0 此时可以找到操作系统 切换为了内核态
那么为什么只有通过系统调用才能做到呢 之前说过系统调用被封装了之后调用会使用int或者syscall指令集 这个指令集发挥了关键作用 它让CPU将Cs寄存器的修改了 此时切换为了内核态 但是即使此时为内核态 也不能随便访问内核区因为可能非法访问 还要确保此时传递了正确的系统调用号 只有这样才能调用操作系统的系统调用
这就是内核态和用户态的切换简略的切换过程
4.4、sigaction捕捉信号
这也是一个信号捕捉的系统调用 和signal类似 但是这个接口有一个独特的地方
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction*oact);
RETURN VALUE
sigaction() returns 0 on success; on error, -1 is returned, and errno
is set to indicate the error.
第一个参数表明要捕捉的信号号
第二个参数是一个输入型参数 这个参数可以指明自定义捕捉之后的处理方法
第三个参数是一个输出型参数记录上一次旧的处理方法
这两个参数都是一个结构体
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
这里只看两个属性
第一个属性和signal的第二个参数意思一样 表明具体的处理方法
第三个属性为信号集
当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么 它会被阻塞到当前处理结束为止。 如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字
也就是说sigaction这个函数捕捉一个信号之后 在处理这个信号时 若是再发送这个信号 block表会自动阻塞这个信号 让pending表中这个信号值为1无法递达 除非上一个信号处理完了才会递达 这样做是为了安全 因为可能一次性发送很多相同信号 此时系统可能崩溃
那信号集是什么呢 信号集就是使用这个接口时 可以指定更多的信号屏蔽 使用sa_mask即可
#############################################################################
首先来使用这个接口 看是不是可以屏蔽 只需要在处理这个第一个接收的信号时一直打印pending表即可 若是第二次发送2号信号 那么pending表2号信号的位置一定是1
前面验证了 处理信号之前pending表已经改变 由1变0 那么若是处理时还是1 说明block对应位置为1 说明sigaction进行了屏蔽
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signum)
{
cout << "Signum : " << signum << endl;
// 不断获取pending表
while(true)
{
sigset_t pending;
sigpending(&pending);
for(int i = 31; i >= 1; i--)
{
if(sigismember(&pending, i)) cout << "1";
else cout << "0";
}
cout << endl;
sleep(1);
}
exit(0);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
// 对二号信号进行捕捉
sigaction(SIGINT, &act, &oact);
while(true)
{
cout << "hello world, " << getpid() << endl;
sleep(1);
}
return 0;
}

若是一次想屏蔽很多信号 使用信号集 验证一下


五、扩展
5.1、可重入函数

main函数调⽤insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步,刚做完第⼀步的
时候,因为硬件中断使进程切换到内核,再次回⽤⼾态之前检查到有信号待处理,于是切换 到
sighandler函数,sighandler也调⽤insert函数向同⼀个链表head中插⼊节点node2,插⼊操作的
两步都做完之后从sighandler返回内核态,再次回到⽤⼾态就从main函数调⽤的insert函数中继续
往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后 向
链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了。
像上例这样,insert函数被不同的控制流程调⽤,有可能在第⼀次调用还没返回时就再次进⼊该函
数,这称为重⼊,insert函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为 不可
重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊(Reentrant) 函数。
不可重入函数
涉及到全局的 比如:
调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。
调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。
可重入函数
函数只有自己的临时变量的
5.2、volatile
volatile这个关键字的作用是保证变量在内存中的可见性
下面分析一段代码引出概念
cpp
#include <iostream>
#include <signal.h>
using namespace std;
int flag = 0;
void handler(int signum)
{
cout << "捕捉signum : " << signum << endl;
cout << "更改全局变量 : "<< flag << "->" << flag << endl;
flag = 1;
}
int main()
{
signal(SIGINT, handler);
while(!flag);
cout << "process exit normal" << endl;
return 0;
}

程序运行时 CPU每次访存内存中数据 若是修改则修改完返回给内存
不做编译优化时 按照常规步骤来 也就是while一直检查CPU的工作就是一直访存检查flag 当发送二号信号之后 捕捉信号 修改flag的值 此时while再次检查 发现条件为假 进程正常退出
编译器优化级别较高时 因为main函数中不做flag的修改只是一直检查flag 所以直接做这样的优化 将内存中的flag记录一份到CPU中的一个寄存器中 这样每次检查就不需要访存了 但是此时一旦发送二号信号 捕捉信号 更改flag 内存中的flag变为1 但是while检查时因为优化的原因还是检查的寄存器中的flag 那么进程就不会退出了

g++后面可以指定编译器级别 -O1最低
这样一来内存相当于不可见了
为了避免这种错误 可以在变量前面加上关键字volatile 这样就保证了内存的可见性
添加之后无论编译器级别怎样高 还是可以看见内存中的flag

5.3、SIGCHLD
这个也是信号的一种 是十七号信号 这个信号是子进程退出时向父进程发送的信号
先来看一下是不是这样的
cpp
// 验证子进程退出时会向父进程发送信号
void handler(int signum)
{
cout << "捕捉到信号 : " << signum << endl;
// 捕捉到就直接回收
waitpid(-1, nullptr, 0);
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
cout << "我是一个子进程 : " << getpid() << endl;
exit(1); // 子进程退出会发送信号
}
// 父进程
while(true)
{
sleep(1);
cout << "我是一个父进程 : " << getpid() << endl;
}
return 0;
}

这个发送信号有什么作用 首先父进程收到这个信号默认的处理动作也就是SIG_DFL是忽略 也就是父进程收到了这个信号的处理动作是忽略
我们使用这个信号可以在捕捉到信号之后在自定义捕捉函数中等待子进程的退出信息 避免僵尸进程
看代码
cpp
#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
// SIGCHLD等待子进程子进程退出信息避免僵尸进程
void Waitall(int signum)
{
cout << "捕捉到信号 : " << signum << endl;
// 捕捉到就直接回收
while (true)
{
pid_t n = waitpid(-1, nullptr, WNOHANG);
if(n == 0)
{
break;
}
else if(n < 0)
{
cout << "等待出错" << endl;
break;
}
}
}
int main()
{
signal(SIGCHLD, Waitall);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
sleep(3);
cout << "我是一个子进程 : " << getpid() << endl;
exit(1); // 子进程退出会发送信号
if(i == 6) break;
}
}
// 父进程
while (true)
{
sleep(1);
cout << "我是一个父进程 : " << getpid() << endl;
}
return 0;
}
只等待6个进程

收到6个子进程退出信息后 父进程继续走因为由WNOHANG所以不会阻塞

waitpid第一个参数使用-1表示等待所有退出进程 第三个参数设置为WNOHANG表示非阻塞轮询 因为waitpid是会一直等待的 若是第三个参数设置为0 那么假设10个子进程只退出6个那么代码就会阻塞在waitpid那个地方无法运行 使用WNOHANG可以解决这个
总而言之这个可以很好的回收子进程退出信息 但是还有一个更好的方法 就是信号捕捉时更改默认处理动作 signal(SIGSHLD, SIG_IGN);这样写捕捉到子进程退出时发送的信号之后会将父进程等到子进程的工作交给系统 让系统完成
SIG_DFL是默认处理动作 默认处理动作就是忽略 而SIG_IGN是我们设置的忽略处理动作 这个动作会将等待子进程的任务交给系统完成
这个方法在我们不需要用到子进程退出信息时很好用 直接交给父进程就可以了



可以看到子进程产生之后确实有回收的动作
