🌈欢迎来到Linux专栏~~信号
- (꒪ꇴ꒪(꒪ꇴ꒪ )🐣,我是Scort🎓
- 🌍博客主页:张小姐的猫~江湖背景
- 快上车🚘,握好方向盘跟我有一起打天下嘞!
- 送给自己的一句鸡汤🤔:
- 🔥真正的大师永远怀着一颗学徒的心
- 作者水平很有限,如果发现错误,可在评论区指正,感谢🙏
- 🎉🎉欢迎持续关注!

Linux 信号
- 🌈欢迎来到Linux专栏~~信号
-
- [一. 信号快速认识](#一. 信号快速认识)
- 二、信号产生
-
- 1️⃣通过终端按键产生信号
- 2️⃣调用系统命令向进程发信号
- 3️⃣使用函数产生信号
- [4️⃣由软件条件产生信号 ~ 通过定时器模块](#4️⃣由软件条件产生信号 ~ 通过定时器模块)
- 5️⃣硬件异常产生信号
- 三、信号保存
- 📢写在最后

一. 信号快速认识
信号不是信号量!二者关系:老婆 vs 老婆饼
🎉生活角度的信号
- 在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能"识别快递"
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是⼀定要立即执行,可以理解成"在合适的时候去取"
结论:
1️⃣也就是说信号的处理方法, 在信号产生之前已经准备好了
2️⃣处理信号,立即处理吗?我可能正在做优先级更高的事情,会在合适的时候处理
3️⃣识别信号是内置的,进程识别信号,是内核程序员写的内置特性。
- 信号到来 | 信号保存 | 信号处理
信号处理 :默认处理(大部分是终止进程)、忽略、自定义动作,三种处理都叫做信号捕捉
🎉信号概念
信号:外部或者其他人或者硬件给进程发送的一种异步的事件通知机制,是告诉进程发送了什么
异步 :多种事件,彼此不影响,同时发生
事件:终止,异常,指令退出等等

之所以信号在合适的时候处理的前提是把信号保存下来
🌠查看信号
信号是从1号开始的,没有0号信号;其中1~31号是普通信号(31个) ,34 ~ 64是实时信号

每个信号都有⼀个编号和⼀个宏
定义名称,这些宏定义可以在signal.h中找到,其中有定义
cpp
define SIGINT 2 //使用时候既可以用名字,也可以用数字
本章只讨论编号34以下的信号,不讨论实时信号。
🌠处理信号:signal 函数
信号只需要调用一次即可,不需要重复调用
cpp
#include <signal.h>
//typedef是把sighandler_t 变成 void func(int)型函数的指针
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数说明:
int:真实收到 的信号编号signosignum: 要捕获的目标信号handler: 处理回调函数 。它可以是:自定义函数指针: 格式必须是void func(int)
返回值: 成功:该信号之前的处理动作(老动作); 失败返回 :-1
cpp
//signo 收到了什么信号,才让我执行的
void handler(int signo)
{
std::cout << "收到了一个信号:" << signo << std::endl;
//默认2号信号终止,自定义处理信号了,就按照自定义的来
}
int main()
{
signal(SIGINT, handler); //2号信号
while(true)
{
std::cout << "test sig ..." << getpid() << std::endl;
sleep(1);
}
return 0;
}

所以得出:ctrl + c 终止进程,是通过键盘给目标进程发送2号信号 ,处理动作就是终止,所以此处自定义后,ctrl + c 也退出不了了
🌠细节问题
1️⃣信号自定义捕捉,如果你捕捉方法,不退出,进程就可能不会退出了。如果我把所有的信号都自定义了:都不退出? 会怎么样
- 那不是怎么样都退出不了?工程师早考虑到了:
kill -9,kill -199号和19号信号不可被自定义 ------ 9号信号是管理员信号(boss)
2️⃣信号处理是谁处理的?有没有创建新信号?
- 信号处理是自己做的

3️⃣默认处理:SIG_IGN;忽略:SIG_DFL(什么都不做,忽略此信号)
cpp
#define SIG_DFL ((__sighandler_t)0) //0
#define SIG_IGN ((__sighandler_t)1) //1
signal(signumber, SIG_DFL); //忽略此信号
二、信号产生
当前阶段:

1️⃣通过终端按键产生信号
1️⃣Ctrl+C :向目标进程发送2号信号 (SIGINT)------ 默认动作终止进程
2️⃣Ctrl+\ :向目标进程发送3号信号 (SIGQUIT)------ 默认动作终止进程
3️⃣Ctrl+Z :19号信号 ,默认发送停止信号,将当前前台进程挂起到后台
我们是怎么样知道信号的默认动作是什么?
- 在
signal(7)中都有详细说明:man 7 signal

1️⃣ 前台进程 vs 后台进程:
前台进程 :./testsig运行,只有前台进程能从键盘读取输入,也就说当你按下 Ctrl+C ,终端驱动程序会将信号发送给当前前台进程组的所有成员
后台进程 :./testsig &运行,它不能从键盘读取输入。后台进程不会收到来自键盘的信号 。这意味着你按 Ctrl+C 无法杀死后台进程。
所以信号只能用来控制前台进程,因为只有前台进程才能获取键盘输入
ps:如果 Ctrl+Z后,在用kill -18,使得进程醒来,该进程就会变成后台进程
2️⃣为什么bash进程自己不对信号做响应呢?信号对bash没有任何作用
- 因为
bash进程把所有的信号都忽略了 ~ 吗? - 记住9号信号是boss信号,bash也无法忽略的
⭐理解OS如何得知键盘有数据


2️⃣调用系统命令向进程发信号
在Linux中,通过系统命令向进程发送信号主要使用 kill 命令
发送指定信号:kill -<信号编号> - <pid>
- 例如:kill -9 1234 或 kill -SIGKILL 1234,向PID为1234的进程发送9号信号,该信号会无条件强制终止进程,进程无权忽略
3️⃣使用函数产生信号
👀kill
kill 命令是调用 kill 函数实现的。 kill函数可以给⼀个指定的进程发送指定的信号
cpp
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
成功返回 0 ,失败返回 -1
kill demo
cpp
#include <iostream>
#include <signal.h>
void Usage(const std::string &cmd)
{
std::cout << "Usage" << cmd << " signumber who" << std::endl;
}
// ./mykill signumber who
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signumber = std:: stoi(argv[1]);
pid_t pid = std:: stoi(argv[2]);
int n = kill(signumber, pid);
(void)n;
return 0;
}
👀raise
raise 函数可以给当前进程发送指定的信号(自己给自己发信号)
cpp
#include <signal.h>
int raise(int sig);
👀abort (邪修)
abort 函数使当前进程接收到信号而异常终止
cpp
#include <stdlib.h>
void abort(void);
其本质等于:给自己发送一个abort信号 ~ 6号信号
abort函数通常用来进行终止进程的!类似于exit
cpp
SIGABRT P1990 Core Abort signal from abort(3)
void abort(void)
{
int n = kill(getpid(), SIGABRT);
(void)n;
}
abort即使你自定义了信号,它也会额外打印abort(core dumped)
4️⃣由软件条件产生信号 ~ 通过定时器模块
SIGPIPE 信号 ~ 管道损毁
SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
接下来主要介绍 alarm 函数和 SIGALRM 信号
alarm:给进程设置闹钟
cpp
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
返回值:
- 返回值是0 (上一个闹钟超时了)或者是以前设定的闹钟时间还余下的秒数
调用 alarm 函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发SIGALRM 信号(14号),该信号的默认处理动作是终止当前进程
🗻alarm验证-体会IO效率问题
程序的作用是1秒钟之内不停地数数,1秒钟到了就被
SIGALRM信号终止。必要的时候,对SIGALRM信号进行捕捉


📌 结论:
- alarm闹钟只会响⼀次,默认终止进程
- 冯诺依曼体系会导致效率的降低:多IO导致效率低
🏀设置重复闹钟
cpp
#include <iostream>
#include <signal.h>
int cnt = 0;
void handler(int sig)
{
std::cout << "进程捕捉到信号:" << sig << " pid: " << getpid()
<< "cnt: " << cnt << std::endl;
// 重设闹钟,会返回上⼀次闹钟的剩余时间
int n = alarm(2);
(void)n;
}
int main(int argc, char *argv[])
{
// ⼀次性的闹钟,超时alarm会⾃动被取消
alarm(2);
while(true)
{
signal(SIGALRM, handler);
std::cout << "进程正在运行: " << cnt << std::endl;
sleep(1);
}
return 0;
}
📌 结论:
- 闹钟设置一次,起效一次
- 重复设置的方法 ------ 在hander方法里设置;不要在循环里设置,否则会导致重复设置,一次都没触发
- 如果时间允许,可以测试⼀下
alarm(0):取消闹钟
当其传入参数为 0 时,其功能就是取消当前进程之前设置的尚未超时的闹钟,使其不再产生SIGALRM信号
🐣如何理解闹钟?
系统闹钟,其实本质是OS必须自身具有定时功能 ,并能让用户设置这种定时功能,才可能实现闹钟这样的技术。
现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:
cpp
struct timer_list {
struct list_head entry;
unsigned long expires; //未来过期的时间
void (*function)(unsigned long);
unsigned long data;
struct tvec_t_base_s *base;
};
我们不在这部分进行深究,为了理解它,我们可以看到:定时器超时时间 expires和处理方法 function。
- 操作系统管理定时器,采用的是时间轮 的做法,但是我们为了简单理解,可以把它在组织成为"最小堆结构 "。(五秒后的闹钟没超时,后面50秒的闹钟会超时吗? 查堆顶)
- 如果一个闹钟超时了,就在"堆"中释放,这样就是一次闹钟
5️⃣硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
如果我们的程序除0,野指针等,程序就会崩溃 ------ 为什么会崩溃?
- 本质是因为:程序因为异常,导致收到了信号,然后让进程终止的!
- 除0是收到了8号信号
SIGFPE,野指针收到了1号信号SIGSEGV
继续扣细节:
1️⃣为什么会收到信号?OS怎么知道当前进程div 0 或者野指针?
- 信号被保存在进程的
task_struct结构体中,是用位图来存储的 - 一号比特位为1就代表收到了一号信号

- 发送信号的本质:就是
OS在修改信号位图 ,虽然发送信号的方式有很多种,但最终只能有OS向目标进程写信号! - div 0 本质
CPU出错了;野指针本质是MMU报错,二者都是硬件,而OS是硬件的管理者,OS肯定知道当前是哪个进程给我的硬件搞破坏了 ,所以才向目标进程去写信号

比如进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问;MMU已经集成到CPU当中了,MMU出错也就是硬件出错
2️⃣为什么div 0 或者野指针,会触发硬件报错?
-
本质上是
CPU中标志寄存器 对当前计算的溢出标志位 设置为1,说明当前的计算不可信。

-
OS是如何知道,当前进程是谁??OS会定义一个全局的指针
current,它永远会指向当前正在运行的进程cppregister struct task_struct *current asm("gr29");
3️⃣为什么会"死循环"的打消息?
收到了8号信号,用户捕捉了这个信号------ 打印,但没有让进程退出 ------ (OS发现了异常但是没有消除就会继续给进程发信号)
- 此时寄存器内容都是当前进程的硬件上下文
- 后续进程的时间片到了,被切换走了后把
cpu里所有的数据(包括标记寄存器 数据等等)都保存起来,一旦恢复出来 ------ 原本的数据就全回来了 (此时还是一直报错 ------ 所以OS就会给你一直发信号) 导致了死循环。
⭐ 本质:程序出错的那一行代码被OS反复执行了
4️⃣子进程退出 core dump?
core dump:当进程因为致命异常信号退出时,内核会(可选地)把进程内存写到文件core ~ 核心转储
- 进程在运行时,大部分数据存在内存里,为了便于用户调试,进程运行时把异常信息 ------ 转储到磁盘中
- 核心转储是为了后续debug ------ 回答了在哪里退出的问题

cpp
ulimit -c 1024 //设置核心转储的大小为...
运行程序core dump,当出现了core文件后,直接cgdb ~ a.out 再core-file core.XXX 即可找出异常 ------ 事后调试
- 云服务器默认关core dump的原因是:防止core临时文件过大,进而占用盘内存被占满的情况。
5️⃣我怎么知道,我当前的进程发生了核心转储呢?
bash ------ 子进程(执行我们的代码) ------ Floating point exception(core dumped) 是bash进程打的

第八位就是记录子进程被杀时,有没有被core dump
cpp
printf("退出码: %d, 退出信号: %d, core dumped: %d", (status >> 8) & 0XFF, status & 0x7F, (status>>7) & 0x1);
总结: 子进程被杀后, 父进程waitpid 拿到了子进程的退出信息,进而拿到了退出码、退出信号以及是否被core dump(core dump标志位)
Term:就是单纯的终止Core:可能会发送核心转储!设置进程退出 statuscore dumped标志位给父进程
三、信号保存

为什么要保存信号? - 由于信号不会被立即处理,产生之后,处理之前就有时间窗口,所以必须被保存起来
🌅信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(
Delivery) - 信号从产生到递达之间的状态,称为信号未决(
Pending) - 进程可以选择阻塞 (
Block)某个信号 - 被阻塞的信号 产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
- 注意,阻塞和忽略是不同的 ,只要信号被阻塞就不会递达,而忽略是在递达之后可选的⼀种处理动作
举个例子
- 信号递达 ------ 学生实际动手写作业
老师布置了作业,学生最终拿起笔开始写作业,这个"执行写作业动作"的过程就是信号的递达 - 信号未决 ------ 作业已经布置,但学生还没开始写
老师刚在讲台上宣布了作业(信号产生),但学生还没动手写,作业处于"放着等写"的状态,这个从布置到动手之间的间隔期就是信号的未决状态 - 信号阻塞 ------ 学生把作业塞进抽屉,规定自己现在坚决不写
学生可以选择把作业本塞进抽屉锁上(阻塞)。只要抽屉没打开(未解除阻塞),哪怕老师布置了再多作业,学生也绝不会动手写(不会递达),这些作业只能一直躺在抽屉里(保持在未决状态),直到学生决定打开抽屉(解除阻塞),才会开始写作业(执行递达) - 阻塞与忽略的区别 ------ "锁抽屉不看 "与"拿出来看后扔进垃圾桶 "的区别。
阻塞 不是处理信号的方式,属于不让信号递达 的做法之一
忽略是处理信号的方式,属于开始处理信号了!
🌅在内核中的表示
信号在内核中的表示意图

1️⃣pending表是位图结构,代表的是信号未决: 0000 ... 0000(只用31位)
- 比特位的位置:信号编号
- 比特位的内容(
1 / 0):是否收到信号
例如0000 ... 0011 :表示1号与2号信号都收到了
2️⃣block表也是位图结构,代表的是信号阻塞:
- 比特位的位置:信号编号
- 比特位的内容(
1 / 0):是否收到阻塞
注意 block 和 是否pending 没有任何关系!只要block了,对应的pending的信号都不能执行的

3️⃣handler 和 signal函数是有关联的,我们可以把handler表看作是一个函数指针数组即可
cpp
sighandler_t handler[32]
- 数组的下标是:信号 - 1
- 直接拿信号去索引
handler表,就知道该怎么处理了
问题一:signal(2,myhandler)底层会做什么?
- 拿着信号编号 去handler表里面找到相应的下标位置,再把
myhandler的地址填入即可,这就是自定义捕捉
问题二:什么叫做忽略, 默认?
cpp
define SIG_DEL ((__sighandler_t) 0)
define SIG_IGN ((__sighandler_t) 1)
把0和1强转成函数指针类型 ,通过地址对比来判断是DFL还是IGN;所以忽略和默认也是函数指针类型,只不过数字是0 和 1
问题三:在信号还没有产生的时候,进程就能识别和处理信号了!!
对于一号信号,识别到 block 和 pending都是0 的情况下,我也能知道它是怎么处理的 ------ 默认处理
- 因为工程师内置了信号的管理和处理方法了!!
总结:进程信号是有三张表:block 对应阻塞,pending对应未决,handler对应抵达
🌳sigset_t
OS需要让用户控制信号,本质就是访问和操作上面的三张表!这三张表属于内核数据结构,也就注定了要提供系统调用(signal访问handler表),还需要提供其他系统调来获取或者修改block 和pending表
为了支持我们做操作,要先认识一个内核级数据类型 sigset_t(OS提供的类型!)
我们把sigset_t 看做是无符号长整形
cpp
typedef unsigned long sigset_t;
🌳信号集操作函数
sigset_t类型对于每种信号用⼀个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释
cpp
#include <signal.h>
int sigemptyset(sigset_t *set); 清空信号集
int sigfillset(sigset_t *set); 信号集全部置1
int sigaddset(sigset_t *set, int signo); 添加一个信号到信号集中
int sigdelset(sigset_t *set, int signo); 把一个信号从信号集里删除
int sigismember(const sigset_t *set, int signo); 判断一个信号是否在信号集里
前四个函数都是成功返回0,出错返回-1 。
sigismember是⼀个布尔函数,用于判断⼀个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1
🌍sigprocmask
调⽤函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)
cpp
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
返回值:若成功则为0,若出错则为-1
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值

SIG_BLOCK:mask 与 set 按位或,相当于新增 ~ 什么与1按位与都是1SIG_UNBLOCK:解除阻塞信号,比如mask 为 111,传入的set是010,set取反后是101,&后变成了101,也就解除了阻塞SIG_SETMASK:把当前的set全部给到mask
其中第二个参数set是输入型的参数,把要设置的信息传入内核中,第三个参数oldset是输出型参数,是对block表写入之前的位图结构带出来 ~ 一旦调用成功,oldset是上一次block表的结构
🌍sigpending
读取当前进程的未决信号集 (pending表),通过set参数传出
cpp
#include <signal.h>
int sigpending(sigset_t *set); //输出型参数
调⽤成功则返回0,出错则返回-1
上一个系统调用既能够获取,又可以修改;sigpending为什么就只能输出?
谁来修改pending表呢?
int kill(pid_t pid,int sig)~ kill 命令- 信号产生的五种方式
无论信号是由另一个进程发送(如 kill() 系统调用),还是由硬件异常触发,内核都会在目标进程的 PCB(进程控制块,即 task_struct)中,将对应的信号位图(Pending Bitmap)中的某一位从 0 置为 1
🌍代码检验
下面用刚学的几个函数做个实验。程序如下
发现: 9号和19号信号不可被捕捉、不可被屏蔽,如果都屏蔽了,进程不是无敌了?工程师也会想到这点
cpp
#include <iostream>
#include <signal.h>
void PrintPendind(sigset_t &pending)
{
for(int signo = 31; signo >= 1; signo--)
{
if(sigismember(&pending, signo))
{
std::cout << 1 ;
}
else
{
std::cout << 0 ;
}
}
std::cout << std::endl;
}
int main()
{
//屏蔽2号信号
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
for(int i = 1; i <= 31; i++)
sigaddset(&block_set, i);//到这里的修改都在栈空间~用户空间中修改,没有修改
int n = sigprocmask(SIG_SETMASK, &block_set, &old_set);//此处才是把信号写入
(void)n;
while(true)
{
//获取pending表
sigset_t pending;
sigemptyset(&pending);
n = sigpending(&pending);
(void)n;
//打印pending表
PrintPendind(pending);
}
return 0;
}
那如果2号信号解除阻塞呢?那么2号在pending 表里会从1 ------> 0,信号抵达了
- 一旦我们解除对某个信号的阻塞,该信号,就会立即被递达 ------ 如果是2号信号就直接终止了,所以我们要对2号信号进行捕捉
signal

那么有个小问题:在解除了阻塞后,是先递达之后再对pending表对应的1 变 0,还是先对pending表对应的1 变 0,再递达? 哪个先后顺序是对的
我们的验证方法是:在handler方法内部,继续获取pending表并打印,如果还是出现1说明是在handler后才变 0。
cpp
void handler(int signo)
{
std::cout << "进入了handle方法里了" << std::endl;
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending);
(void)n;
// 打印pending表
PrintPendind(pending);
std::cout << "处理完成:" << signo << std ::endl; // 把2号信号改为打印
std::cout << "handler 方法结束" << std::endl;
}
结果如下:
说明正确顺序:👉 先从 pending 中清除(1 → 0),再递达(执行 handler)
如果是先递达再清除会怎么样?因为handler 是用户可控的,可能写成这样👇
cpp
void handler(int signo)
{
raise(SIGINT); // 再触发一次
}
👉 内核必须保证:不管用户写什么 handler,语义都一致、不会乱;所以是先清除再递达
面试级回复:
在 Linux 信号递达过程中,内核会先从 pending 集合中移除该信号(清除对应位),再执行信号处理动作(handler)。这样可以保证信号的单次消费语义,避免重复递达或竞态问题。

