目录
[2️⃣ 前台进程 & 后台进程](#2️⃣ 前台进程 & 后台进程)
[📌 补充指令](#📌 补充指令)
[2️⃣Core 和 Term有什么区别?](#2️⃣Core 和 Term有什么区别?)
一、认识信号
1.1、你见过哪些信号?
其实在生活中就有非常多的信号,比如:红绿灯,上课铃,肚子叫,敲门声等等;在前面的学习中其实我们也已经接触过信号了,当进程死循环时我们利用 Ctrl + c终止进程;对于孤儿进程(后台进程),我们用kill -9 xxx杀掉进程,其实也是进程收到了信号,只是我们还不清楚信号的底层逻辑。
不难发现,当我们收到某个信号,我们就会记下该信号并在合适的时间点去处理。比如你突然收到下楼取快递的信号,你要么立即去拿;要么等一段时间去拿;要么直接忽略。
1.2、信号的概念
信号是发送给进程,用来通知某种事件的(异步通知)。
**同步:**一件事做完再做另一件事。
**异步:**信号随时来,进程不用等、信号随时可能打断当前进程。
1.3、见一下信号
bash
kill -l

1 ~ 31号信号为非实时信号:每个信号系统固定含义;
34 ~ 64为实时信号:全由用户自定义使用。
信号捕捉方法(系统调用):signal

signum即几号信号(如:Ctrl + c 为2号信号),handler是一个函数指针,指向信号处理方法(所以信号处理方法可以自定义)。
📌基本结论:
- 进程能够识别信号,内核中内置的特性
- 对于收到的信号,内核中内置了对信号的处理方法
- 收到信号后,如果有优先级更高的事,则不会立即处理,而是在合适的时候处理
- 信号处理有三种方式:OS默认的方法,忽略 或者按自定义方式处理。
二、信号的产生
2.1、键盘产生
1️⃣捕捉信号
既然键盘可以产生信号,那么当我们按下 Ctrl + c,能不能捕获这个信号让我们看看呢?
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
// 自定义信号处理方法
void handler(int signum)
{
std::cout << "我是信号: " << signum << std::endl;
}
int main()
{
signal(SIGINT, handler); // 捕捉2号信号
while(true)
{
std::cout << "我的进程pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}

进程捕捉到2号信号,即Ctrl + c,并且转而执行我们自定义的处理方法,也就无法终止进程,我们用Ctrl + \来终止。
2️⃣ 前台进程 & 后台进程
键盘产生的信号只能发送给前台进程,而且键盘作为终端,同一时刻只能绑定一个前台进程。
./test & 将进程转为后台进程(孤儿进程就是一个后台进程),那么该进程将接受不到键盘产生的信号?

我们看到,此时该进程没有执行信号处理方法,也就是没有收到信号。此时shell变为前台进程,打开终端默认shell就是前台进程,当执行ls,pwd等命令,shell把自己暂且放到后台,命令跑完又自动切回前台,等待你从键盘输入命令。
📌补充指令
- 查看后台进程:jobs
bashjobs
- 将后台进程提到前台:fg + 任务号
- 将前台进程提到后台:Ctrl + z
- 让后台进程恢复运行:bg + 任务号
2.2、系统命令
bash
kill -9 pid
前面我们杀掉孤儿进程,用的就是系统命令,只要我们找到其pid,就可以用系统命令干掉进程。
2.3、系统调用
1️⃣用系统调用 kill 让目的进程接受到 sig信号:

cpp
// ./test pid sig
int main(int argc, char **argv)
{
pid_t id = std::stoi(argv[1]);
int signum = std::stoi(argv[2]);
int n = kill(id, signum); // 让目标进程收到signalnum信号
if(n == 0)
{
std::cout << "send " << signum << " to " << id << std::endl;
}
return 0;
}
2️⃣ 利用raise系统调用让进程给自己发信号:

cpp
// 自定义信号处理方法
void handler(int signum)
{
std::cout << "我是信号: " << signum << std::endl;
}
int main()
{
// 捕获1 ~ 31所有信号
for (int i = 1; i <= 31; i++)
signal(i, handlerSig);
for (int i = 1; i <= 31; i++)
{
// 9和19号信号不能被捕捉
if (i == 9 || i == 19)
continue;
raise(i); // 进程向自己发送信号
}
int cnt = 0;
while (true)
{
sleep(1);
}
}

💡我们是不是能够捕获所有31个信号,让进程 不受限制?
并不能,设计的时候已经考虑到了,9号和 19号信号无法被捕获。
📌补充系统调用:abort( )
- 终止进程
- abort本质是给自己发送了一个6号信号。
2.4、软件条件
例如,在用管道通信时,如果关闭读端,那么写端进程就会收到SIGPIPE信号,然后退出。
这种在程序运行时内核检测到软件运行状态发生变化,而由内核自动发送的信号,即软件条件产生的信号(没有硬件参与,纯软件)。
这里我们介绍另一种软件条件产生的信号:SIGALRM
这是通过系统接口 alarm设定一个闹钟,当计时结束就会发送14号SIGALRM信号,该信号的默认处理动作为终止当前进程。
alarm的返回值为0或者设置新的时前一个闹钟还余下的秒数。
cpp
// 自定义信号处理方法
void handler(int signum)
{
std::cout << "我是信号: " << signum << std::endl;
}
int main()
{
alarm(3); // 计时3秒
signal(SIGALRM, handler); // 捕捉14号信号
while(true)
{}
return 0;
}

1️⃣利用alarm验证IO的效率:
cpp
long long cnt = 0;
void handler(int signum)
{
// std::cout << "IO次数cnt = " << cnt << std::endl; // IO次数
std::cout << "运算次数cnt = " << cnt << std::endl; // 仅++
exit(2);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while(true)
{
// std::cout << cnt++ << std::endl; // 向显示器打印
cnt++; // 仅计数
}
}

IO效率并不高!!!
2️⃣设置重复闹钟,简单模拟操作系统的运行逻辑:
还需要再介绍一个系统接口:pause()

他会让程序暂停,直到收到一个信号,然后执行信号的处理方法。操作系统其实就是一个死循环,一直在暂停/等待,只有在收到信号时,才转而去执行信号处理方法,完事后再次循环等待。
cpp
using func_t = std::function<void()>; // 函数包装器
std::vector<func_t> funcs;
void Sched() { std::cout << "我是进程调度" << std::endl; }
void MemManger() { std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl;}
void handler(int sig)
{
// 处理事件
for (auto &func : funcs)
func();
alarm(1); // 继续计时
}
int main()
{
// 注册方法
funcs.push_back(Sched);
funcs.push_back(MemManger);
signal(SIGALRM, handler); // 等待信号
alarm(1);
while (true)
{
pause(); // 暂停等待
}
return 0;
}

📌结论:操作系统是由信号(事件)驱动的。
2.5、硬件异常
发生硬件异常后被硬件检测到并通知内核,内核发送信号给进程。前面遇到的除零和对野指针解引用之所以会导致程序崩溃,就是由于触发了硬件异常,进程收到信号。
1️⃣除零为什么是导致硬件异常?OS怎么知道硬件异常了?又是发送什么信号?
当CPU执行除法指令,检测到除数是0,硬件就会出异常,CPU查询中断向量表,强行跳到操作系统内核预设的异常处理入口,操作系统向进程发送 SIGFPE 信号处理。
cpp
void handler(int signum)
{
std::cout << "我是信号: " << signum << std::endl;
exit(3);
}
int main()
{
for (int i = 1; i <= 31; i++)
signal(i, handler);
sleep(2);
int a = 5;
a /= 0;
return 0;
}

2️⃣野指针如何导致硬件异常?
当CPU执行野指针(虚拟地址)解引用指令时,就会通过MMU(内存管理单元)进行虚拟地址到物理地址的映射,当MMU查页表发现没有对应的物理内存与野指针关联时,就会转换失败,触发硬件异常。CPU查询中断向量表,并跳转到异常处理入口,操作系统向进程发送 SIGSEGV 信号进行处理。
三、信号的保存
3.1、信号相关其他概念
递达:进程收到信号去执行信号处理方法的动作;
**信号未决(Pending):**信号从产生到递达的过程,此时信号被记录在pending表;
**阻塞(Block):**信号可以被阻塞,被阻塞的信号会一直处于未决状态,直到阻塞被取消才会被递达;
***注意:***信号被阻塞和信号处理方法为忽略不同,只要阻塞信号就无法被递达,而忽略是信号递达后选择的一种处理方式
3.2、在内核中的示意图
进程在 task_struct中维护了三张表来记录信号的阻塞,未决和处理方法。

**block表:**阻塞信号集,本质是一个位图,比特位的位置即几号信号,比特位内容标识是否阻塞该信号。
**pending表:**未决信号集,本质也是位图,比特位的位置即几号信号,比特位内容表示是否收到该信号。
**handler表:**函数指针数组,下标即几号信号,指向内容即信号处理方法。SIG_DFL即默认处理方法,SIG_IGN即忽略,也可以是自定义函数。
bash// 查看内核对信号默认的处理方法 man 7 signal
- Core 和 Term都是用来终止进程
- Ign即ignore,忽略
1️⃣信号怎样发送给进程?
发送信号给进程,本质就是修改这三张表,内核会调用相关的系统接口进行修改。
2️⃣Core 和 Term有什么区别?
Core为核心转储模式,进程结束时会在当前路径下形成一个core文件,并且保存到磁盘,对于异常退出的程序,core文件中会保存进程崩溃时的完整内存镜像。
注意:在云服务器上核心转储功能被关闭,原因如下:
服务器进程内存通常很大(GB 级别),一个 core 文件可能占满磁盘
磁盘满了会导致服务挂掉
频繁崩溃时反复写大文件,拖垮 I/O
查看:
bashulimit -a怎么在服务器上打开core dump功能:
bashulimit -c size
核心转储主要是为了Debug(事后调试),当程序异常退出,我们想要查看错误:
cpp
int main()
{
signal(SIGFPE, SIG_DFL);
sleep(2);
int a = 5;
a /= 0;
return 0;
}

gdb -> core-file core -> 直接帮我们定位到出错行
bashgdb core-file core
📌补充:还记不记得我们前面在讲程序运行结束时分为:正常终止 和异常终止!!操作系统中用一个整数的低16个比特位(0~15)来存储进程终止的相关信息。0~6记录终止信号(即进程异常退出信息) ,第7位为core dump标志 以及8~15记录退出状态(即正常终止信息)。
我们可以通过等待子进程获得core dump标志位的信息。
3.3、信号集操作接口
3.3.1、sigset_t
我们先介绍一下------sigset_t,这是一个数据类型,本质是描述"一组信号"的位图容器,本身不执行任何信号操作,只负责存哪些信号在集合里。阻塞信号集和未决信号集本质都是由比特位的0或1决定的,因此可以用sigset_t来描述。
信号被存储在上面的三张表中,所以对信号的操作肯定与block,pending和handler有关。
bash
#include <signal.h>
// 信号集操作函数(操作 block / pending )
// 1. 清空信号集:把 set 里所有信号位 全部置为 0
int sigemptyset(sigset_t *set);
// 2. 填满信号集:把 set 里所有信号位 全部置为 1
int sigfillset(sigset_t *set);
// 3. 向信号集 set 中 添加 一个信号 signum,把该信号的位置为 1
int sigaddset(sigset_t *set, int signum);
// 4. 从信号集 set 中 删除 一个信号 signum,把该信号的位置为 0
int sigdelset(sigset_t *set, int signum);
// 5. 判断 信号 signum 是否在 set 中
// 返回 1 → 在集合中(有效)
// 返回 0 → 不在集合中
int sigismember(const sigset_t *set, int signum);
⚠️ 问题:通过上面的接口是否能够修改内核中的block和pending位图?
显然不能,以上接口只是对sigset_t对象进行操作,并不影响内核中的数据。
3.3.2、sigprocmask
sigprocmask 可以用来获取或修改内核中的阻塞信号集,这才是真正与内核相关的接口。

how参数:
|-----------------|-----------------------------|
| SIG_BLOCK | set 中的信号加入当前阻塞集(并集) |
| SIG_UNBLOCK | set 中的信号移出当前阻塞集 |
| SIG_SETMASK | 用 set 替换当前阻塞集(最常用) |
3.3.3、sigpending

3.4、实例:用SIGINT信号验证内核信号集
我们利用2号信号来测试:
**-**先把二号信号调用 sigprocmask屏蔽(阻塞);
**-**然后从键盘发送二号信号,通过打印 pending表的内容(比特位),应该观察到2位置处的比特位由0变为1;
**-**大概 5s后我们取消对二号信号的屏蔽,应该观察到2号信号被递达。
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void PrintPending(sigset_t pending)
{
printf("我的进程pid: %d, pending: ", getpid());
// 从第31号信号开始输出,比特位为1即收到该信号,反之
for(int sig = 31; sig >= 1; sig--)
{
if(sigismember(&pending, sig)) std::cout << 1;
else std::cout << 0;
}
std::cout << std::endl;
}
void handler(int sig)
{
std::cout << "####################################################" << std::endl;
std::cout << "信号 " << sig << " 递达" << std::endl;
// 验证一个细节问题:pending值是在信号递达前恢复还是递达之后恢复
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
std::cout << "####################################################" << std::endl;
}
int main()
{
signal(SIGINT, handler); // 捕获2号信号
sigset_t block, old_block;
// 对阻塞信号集初始化
sigemptyset(&block);
sigemptyset(&old_block);
sigaddset(&block, SIGINT); // 对block中对应的2号信号的比特位设置为1
// 1. 修改内核阻塞信号集,屏蔽2号信号,并输出old_block的值
sigprocmask(SIG_SETMASK, &block, &block);
// 2. 重复获取pending值和打印pending值的过程,方便观察
int cnt = 0;
while(true)
{
// 获取pending值
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending);
// 打印pending值
PrintPending(pending);
if(cnt == 5)
{
// 3. 解除对2号信号的屏蔽,此时pending应该变为全0
std::cout << "恢复block值" << std::endl;
sigprocmask(SIG_SETMASK, &old_block, nullptr);
}
sleep(1);
cnt++;
}
}

细节问题: pending值是在信号递达前恢复还是递达之后恢复
代码中我们已经验证!!! 在递达之前就被置位为0了。
四、总结

至此,我们对信号的产生和保存就已经全部介绍完毕了,我们已经说了进程会在合适的时候进行信号处理,处理方式我们也已经介绍了:默认,忽略和自定义,那么到底什么时候是合适的时候?下一篇博客我将为大家详细介绍!!!






