信号
1.生活化类比
闹钟、上课铃、敲门声、电话铃声,都是打断当前正在做的事的外部通知;
类比到计算机:信号是发给进程、用来异步通知进程发生某事件的机制
2. 同步 vs 异步区分
- 同步:停下当前任务,等待事件完成再继续(自习等张三回来再讲课)
- 异步 :主线任务正常执行,事件独立发生(上课期间张三去取快递,不打断课堂)信号属于异步事件:信号产生时机和进程运行无固定时序,进程无法预判信号何时到来。
3.四大核心结论
-
**提前预设处理方案。**进程在信号未产生时,就已经规定好收到该信号要做什么。类比:人提前知道门铃响要去开门,不用等门铃响了才思考怎么做。
-
不会立即处理,择机响应。 信号抵达后不会立刻中断进程,进程会运行到合适时机再处理信号,不会抢占 CPU 打断正在执行的原子操作。
-
**内置默认处理逻辑。**操作系统内置了每种信号的默认行为(终止进程、忽略、暂停等),如同人天生 / 后天学会识别各类声音信号;当然我们也可自定义捕获函数覆盖默认行为。
-
**信号来源丰富。**产生信号的源头非常多:键盘快捷键、系统异常、用户手动发送 kill 命令、定时器、硬件报错、子进程状态变化等。
4.信号处理三方式
- 默认处理(系统内置)
- 忽略信号
- 自定义捕获(注册回调函数)
产生信号的方式
1.方式1--键盘产生信号
当我们ctrl+c终止一个进程的时候,其实就是向目标进程发送信号

查看所有的信号
kill -l

- 1~31:标准普通信号
- 34~64:实时信号
在这里我们只用理解前31个信号即可
常见信号对应的作用,其中term和core都是终止(区别后面讲)stop就是暂停,ign就是忽略,
**cont(继续)**唤醒被暂停的进程

Ctrl 组合键对应信号:
Ctrl+C→ SIGINT(2)--TermCtrl+Z→ SIGTSTP(20)--StopCtrl+\→ SIGQUIT(3)--Core
所以ctrl+c就是向目标进程发送了2号信号,进而终止
signal函数---自定义进程收到信号后的处理逻辑


运行结果:

这时我们就发现ctrl+c就不能再杀掉进程了,这是因为2号信号的处理逻辑已经被自定义为去执行hand()函数了,不再是去终止进程了
前台和后台进程
区分方式
- 前台进程:
./test - 后台进程:
./test&(末尾加&)
| 特性 | 前台进程 | 后台进程 |
|---|---|---|
| 标准输入 (stdin) | 独占键盘,可读取键盘输入 | 无法读取键盘输入,读 stdin 会阻塞 / 报错 |
| 标准输出 (stdout) | 可打印到终端 | 可打印到终端(会和前台输出混杂) |
| 键盘信号(Ctrl+C/SIGINT、Ctrl+\、Ctrl+Z) | 终端键盘信号只发给前台进程组 | 不受键盘快捷键影响,Ctrl+C无法杀死 |
| 同一终端数量 | 同一时刻只能有 1 个前台进程组 | 可同时存在多个后台进程 |
同一时刻只能有 1 个前台进程组
因为同一时刻只能有一个进程,所以当./test时bash进程就变成了后台进程,test成为了唯一的前台进程,此时再输入指令比如ls,pwd等等都不会显示了,test已经成为了前台进程,输入的指令都给了test,如果test又没有实现这些指令的方法当然不会做任何响应

eg;


进程前后台切换命令
jobs
作用:查看当前终端下所有后台任务(包含暂停、运行中的后台程序)
输出格式示例:
[1]+ Stopped ./testsig
[2]- Running ./loop &
[1] 就是任务号 ,+ 代表最近操作的任务,- 代表次新任务。
jobs 输出的 [1][2] 是shell 任务号 ,仅当前终端有效;
fg 任务号
作用:把指定后台任务切换到前台运行切前台后,程序会接收键盘信号(Ctrl+C 可终止它)。
Ctrl + Z
将当前前台进程 暂停(发送 SIGTSTP 信号),转入后台暂停状态,再bg既可恢复运行
bg 任务号
让后台暂停的进程恢复运行(后台持续执行)
完整操作流程
# 1. 前台运行程序
./testsig
# 2. 按 Ctrl+Z 暂停,丢到后台(状态Stopped)
# 3. 查看后台任务,获取任务号
jobs
# 4. 让暂停的程序在后台继续跑
bg 1
# 5. 需要交互时切回前台
fg 1
# 6. 前台运行时 Ctrl+C 可直接终止程序
理解给进程发信号
信号不会立刻处理,需要先记录,信号产生后,进程不会马上响应,内核需要先保存该信号,就保存在进程的ask_struct内部
struct task_struct {
unsigned int sigs; // 信号位图
// ...其他进程信息
}
sigs是位图结构:每 1bit 对应一个信号编号- bit 位序号 = 信号编号;bit=1 代表已收到该信号、待处理例:bit2 置 1 → 收到 SIGINT (2);bit3 置 1 → 收到 SIGQUIT (3);bit20 置 1 → 收到 SIGTSTP (20)
发送信号的本质就是操作系统来修改目标进程内核 task_struct 里的信号位图
又因为task_struct 是内核数据对象,修改里面的位图就是修改内核数据,只有操作系统有权限完成,即不管信号怎么产生,发送信号在底层都必须让操作系统发送
2.方式2--系统调用
kill
#include <signal.h>
int kill(pid_t pid, int sig);
pid 参数 4 种取值规则
pid > 0:发送信号给 PID 等于该值的进程pid = 0:发送信号给当前进程同进程组所有进程pid = -1:发送信号给系统内所有有权限发送的进程pid < -1:取绝对值,发送给对应进程组全部进程
返回值
- 成功:返回
0 - 失败:返回
-1,设置 errno(如权限不足、PID 不存在)
raise
#include <signal.h>
int raise(int sig);
给自己当前进程 发送信号,等价于 kill(getpid(), sig)。
返回值
成功返回0,失败返回非 0。
eg:验证不能被自定义捕获的信号

运行结果:

可以看到9号信号(SIGKILL--Term)是不能被自定义捕获的,其实同样的19信号(SIGSTOP--Stop)也不能被捕获,分别对应终止和暂停。Linux 内核出于安全与资源管控设计,保留两套管理员强制操作信号,防止进程恶意屏蔽终止 / 暂停
abort
#include <stdlib.h>
void abort();
主动让进程异常终止,底层固定向自身发送 SIGABRT (6)信号。
3.方式3--硬件异常
两类典型硬件异常信号代码示例
1. SIGFPE (8) 浮点异常
int a = 10;
a /= 0; // 除零运算,CPU算术异常
- 信号:
SIGFPE - Action:Core(终止 + 生成 core 文件)
- 注释:Floating-point exception 浮点运算异常
2. SIGSEGV (11) 段错误
int *p = nullptr;
*p = 100; // 访问空指针,非法虚拟地址
// char *msg = "hello"; *msg = 'H'; // 修改只读常量区也会触发
- 信号:
SIGSEGV - Action:Core
- 注释:Invalid memory reference 非法内存访问
底层完整流程:CPU 异常 → OS 发信号
1. CPU 硬件检测异常
CPU 执行指令时,通过状态寄存器 (EFLAGS)、MMU 内存管理单元识别两类错误:
- 算术异常(除零 / 溢出) 运算单元运算出错,EFLAGS 寄存器溢出标记置 1,CPU 触发硬件异常中断。
- 内存访问异常(野指针 / 空指针) CPU 给出虚拟地址,交给 MMU(集成在 CPU 内部的硬件单元,专门负责虚拟地址 ↔ 物理地址 转换)再通过CR3寄存器找到页表,做虚实地址转换;
- MMU 查询页表,找不到对应物理内存 → 转换失败;
- 或访问权限不匹配(只读段写操作);MMU 向 CPU 触发缺页 / 保护故障中断。
2. 操作系统接管硬件中断
CPU 触发中断后,操作系统作为软硬件资源管理者:
- 读取 CPU 寄存器、CR3 页表寄存器、当前进程
task_struct上下文; - 判断异常类型:除零 → SIGFPE;非法内存访问 → SIGSEGV;
- 主动向当前出错进程发送对应信号(修改进程内核结构体里的信号位图)。
方式4--软件条件
1.管道
读端进程已经关闭,写端继续往管道 write 写数据 ,OS 立刻向写进程发送 SIGPIPE。(os不会做任何浪费时间和空间的事)
2.alarm () 闹钟函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
- 功能:设置秒级闹钟 ,倒计时结束后,内核自动向当前进程发送
SIGALRM(14)信号; - 默认 Action:
Term,进程直接终止。 - 返回值 = 上一次闹钟剩余的倒计时秒数
3.pause () 阻塞等待信号
#include <unistd.h>
int pause(void);
- 作用:永久挂起进程,进入休眠,直到收到任意信号;
- 行为:
- 收到信号并执行完自定义处理函数后,pause 才返回;
- 返回值永远
-1,errno 设为EINTR(被信号中断);
alarm和pause结合的一段demo代码:
using func_t =function<void()>;
vector<func_t>funcs;
void Sched()
{
cout<<"我是进程调度"<<endl;
}
void MemManger()
{
cout<<"我是周期性的内存管理"<<endl;
}
void Flush()
{
cout<<"我是刷新程序"<<endl;
}
void hand(int sig)
{
cout<<"####################"<<endl;
for(auto f:funcs)
{
f();
}
cout<<"####################"<<endl;
alarm(1);// 重置1秒闹钟,实现每秒循环调度
}
int main()
{
//1. 注册三大系统核心功能:调度、内存管理、缓冲区刷新
funcs.push_back(Sched);
funcs.push_back(MemManger);
funcs.push_back(Flush);
// 2. 注册SIGALRM闹钟信号处理函数
signal(SIGALRM,hand);
// 设置1秒定时器,1秒后内核发送SIGALRM
alarm(1);
// 3. 操作系统主循环:永久休眠等待定时信号
while(1)
{
pause();// 进程挂起,收到信号才唤醒
}
return 0;
}
模拟真实 OS 内核逻辑
- 真实操作系统内核也是无限循环 ,依靠硬件时钟中断(对应代码里
alarm软件闹钟)周期性执行调度、内存回收、IO 刷新等后台任务; pause()休眠对应内核空闲时进入低功耗等待中断,有事件 / 时钟中断才工作。
如何理解系统闹钟

系统里会同时存在成千上万个进程设置的闹钟,OS 必须做统一管理:
先描述-- 先用timer_list描述每一个闹钟的到期时间、回调、归属等属性,再用数据结构把它们组织起来。
内核闹钟核心数据结构 struct timer_list
struct timer_list
{
struct list_head entry:链表节点,用来把多个闹钟挂载到内核定时器管理结构里;
unsigned long expires:闹钟的到期时间戳(记录系统时钟走到这个数值时闹钟触发);
void (*function)(unsigned long):闹钟到期后内核执行的回调函数
unsigned long data:传给回调函数的参数,一般用来标识目标进程;
struct tvec_base *base:归属的定时器管理组,内核用来分组管理大量定时器。
}
再组织--最小堆 :堆顶永远是expires最小、最先到期的闹钟,每次中断只需要检查堆顶,就能快速判断有没有闹钟要触发,效率很高;
信号的保存
信号其他相关常概念
• 实际执行信号的处理动作称为信号递达(Delivery)
• 信号从产生到递达之间的状态,称为信号未决(Pending)。
• 进程可以选择阻塞(Block)某个信号。
• 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
• 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达的可选的⼀种处理动作。
在内核中的表示

内核为每个进程维护 3 套独立数据结构,共同管控信号:
block:阻塞位图(屏蔽信号掩码)pending:未处理待递达信号位图handler[]:信号处理函数指针数组。SIG_DFL:默认动作(Term/Core/Stop/Ign)SIG_IGN:忽略该信号
位图统一规则
- bit 下标 = 信号编号(bit2 对应 SIGINT,bit3 对应 SIGQUIT)
- bit 值
1代表生效(阻塞或收到),0代表不生效(不阻塞或没收到)
sigset_t 信号集与配套操作函数
sigset_t 是信号集位图类型,每一个 bit 对应一个信号:
- bit=1:信号有效(存在于集合内)
- bit=0:信号无效(不在集合内)
两种场景含义(pending和block底层都是sigset_t)
- 阻塞信号集block(也叫 信号屏蔽字(SignalMask),):bit=1 → 该信号被阻塞;bit=0即没有被阻塞
- 未决信号集 pending:bit=1 → 进程已收到该信号,待处理,bit=0即没有收到改信号
6 个信号集操作函数(头文件 <signal.h>)
| 函数原型 | 功能说明 |
|---|---|
int sigemptyset(sigset_t *set); |
清空信号集:所有 bit 置 0,集合不含任何信号;初始化函数,必须最先调用 |
int sigfillset(sigset_t *set); |
填满信号集:所有 bit 置 1,集合包含系统全部信号;同样用于初始化 |
int sigaddset(sigset_t *set, int signo); |
向集合添加指定信号:对应 bit 置 1 |
int sigdelset(sigset_t *set, int signo); |
从集合删除指定信号:对应 bit 置 0 |
int sigismember(const sigset_t *set, int signo); |
判断信号是否在集合中:存在返回 1,不存在返回 0,出错返回 - 1 |
这6个函数只改用户内存 ,不影响内核信号屏蔽;只有 sigprocmask 是系统调用,和内核交互,真正生效屏蔽;
Linux 2.6.18 信号内核结构体(展开重点看struct sigpending pending)
struct task_struct {
struct sighand_struct *sighand; // 信号处理函数表(线程组共享)
sigset_t blocked; // 屏蔽位图
struct sigpending pending; // 未决信号队列(和block不一样,还是结构体)
};
struct sighand_struct {
atomic_t count; // 引用计数,多线程共享时用于销毁判断
struct k_sigaction action[_NSIG]; // _NSIG=64,0~63号信号,每个信号一套处理配置
spinlock_t siglock; // 操作信号表的自旋锁,防止并发竞争
};
struct __new_sigaction {
__sighandler_t sa_handler; // 用户注册的信号处理函数(signal()设置的handler)
unsigned long sa_flags; // SA_RESTART、SA_SIGINFO 等行为标志
void (*sa_restorer)(void); // 信号返回恢复上下文(用户态辅助,Linux极少使用)
__new_sigset_t sa_mask; // 信号处理函数执行期间额外屏蔽的信号集
};
struct k_sigaction {
struct __new_sigaction sa;
void __user *ka_restorer;
};
//重点:
struct sigpending {
struct list_head list; // 未决信号链表,存放sigqueue节点
sigset_t signal; // 位图:标记哪些信号存在未决实例,即封装了位图不是直接的位图,和 block不一样
};
signal:位图快速判断 "有没有某个信号待处理";
list:链表保存完整信号事件(携带发送进程、附加数据等)。
当 2 号信号被blocked屏蔽时,内核会把 SIGINT 挂入此链表,直到解除屏蔽后再分发执行 handler。
sigprocmask系统调用
专门用来修改 block 位图的系统调用,增加阻塞或者解除阻塞
插入一个小知识点---0.计算在位图的具体位置(位图准确来说是一个结构体)
struct bits {
int bitmap[10]; // int占32bit,10个int总共320位,可管理1~31标准信号
};
int index = 信号号 / 32; // 算出用数组第几个int
int pos = 信号号 % 32; // 算出该int内部第几位比特
示例:39 号信号
index = 39 / 32 = 1 → 使用bitmap[1]
pos = 39 % 32 = 7 → bitmap[1]的第 7 个比特位
1.函数原型
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how:修改屏蔽字的模式(3 种)set:输入,本次要操作的信号集合oldset:输出,保存修改之前 的原始屏蔽字,可传NULL不保存
| 模式 | 作用 | 等价位运算 | |
|---|---|---|---|
SIG_BLOCK |
新增阻塞:把 set 里的信号添加到屏蔽字,原有阻塞保留 | `mask = mask | set` |
SIG_UNBLOCK |
解除阻塞:把 set 里的信号从屏蔽字清除 | mask = mask & ~set |
|
SIG_SETMASK |
直接覆盖:把当前屏蔽字完全替换成 set 的值 | mask = set |
sigpending 系统调用
#include <signal.h>
int sigpending(sigset_t *set);
- 参数
set:输出型参数 ,函数执行完成后,会把当前进程pending 未决信号位图完整拷贝到该变量中; - 返回值:成功返回 0,失败返回 - 1 并设置 errno;
- 功能:查询当前进程所有处于「未决」的信号集合
这里只是查看未决信号即查看pending,而修改内核 pending 位图就是我们上面讲的4种方式
demo代码
核心原理:先阻塞2号信号,这样在查看pending时2号信号因为阻塞了没有递达,就可以看到比特位2的位置的值是1,等到计数器cnt为10时就解除对2号信号的屏蔽,比特位2的位置又会变回0,因为已经递达了
int cnt=0;
void handler(int sig)
{
cout<<"递达"<<sig<<"信号!"<<endl;
}
void Print(sigset_t &pending)
{
printf("我是一个进程(%d),pending:",getpid());
for(int i=31;i>0;i--)//左到右为高位到低位
{
if(sigismember(&pending,i))
{
cout<<"1";
}
else{
cout<<"0";
}
}
cout<<endl;
}
int main()
{
signal(2,handler);
//1.屏蔽2号信号:
sigset_t myblock,myoblock;
//清空信号集:所有 bit 置 0,集合不含任何信号
sigemptyset(&myoblock);
sigemptyset(&myblock);
//sigemptyset 只是清空你自己定义的局部信号集变量
//不直接操作内核 block/pending 位图,只有调用 sigprocmask 时,
//才会把你准备好的 sigset_t 集合同步到内核 block 屏蔽字
sigaddset(&myblock,2);//添加2号信号,bit置为1
sigprocmask(SIG_SETMASK,&myblock,&myoblock);
//真正修改内核中进程的信号屏蔽位图
//4.循环打印获取到的pending
while(1)
{
//2.获取pending信号集合
sigset_t mypending;
sigpending(&mypending);
//3.打印
Print(mypending);
if(cnt==10)
{
//5.恢复对2号信号的block情况
cout<<"解除对2号信号的屏蔽"<<endl;
sigprocmask(SIG_SETMASK,&myoblock,nullptr);
//这里不关心oblock,所以写nullptr
}
sleep(1);
cnt++;
}
return 0;
}
运行结果:

这里还有一个问题就是pending的2号位置由1->0是在执行完了handler()这个方法之后,还是在之前呢?我们可以这样验证一下:

运行结果:

在执行handler()的Print时handler()函数还没有结束,但此时的结果是全0,所以由1->0是发生在执行完handler()这个函数之前,所以在准备递达某个信号时,会首先在pending信号集的信号位图中,找到该信号的bit序号让其bit值由1->0
信号产生多次怎么办?
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信 号⼀次或多次。Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,而实时信号在递达之 前产生多次可以依次放在⼀个队列里。本章不讨论实时信号。
- 常规信号:信号多次触发未处理时,内核只记录 1 次(丢失重复信号)
- 实时信号:多次触发会依次排队,不会丢失
9号信号不能被屏蔽,也不能被自定义捕捉(这里就不验证了)
Core和Term
Core:程序异常崩溃时,操作系统将进程当前完整内存数据(堆栈、变量、寄存器等)转储到磁盘生成 core 文件,用于事后 gdb 调试定位崩溃根源,属于事后调试方案。
- 文件名区分:
- CentOS:
core.pid.core.1234(带进程 PID) - Ubuntu:
core.XX(默认后缀格式)
- CentOS:
| 对比维度 | Term(普通终止) | Core Dump(带 core 的终止) |
|---|---|---|
| 触发条件 | 1. 正常退出(exit/return)2. 普通信号杀死(SIGINT、SIGKILL、SIGPIPE 等,无 core 标记) | 收到带 core 标志的崩溃信号:SIGSEGV (11)、SIGFPE (8)、SIGABRT (6) 等 |
| 是否生成 core 文件 | 不会 | 生成 core 内存快照文件(ulimit -c>0 前提下) |
| 调试能力 | 无现场,无法定位崩溃现场 | 可 gdb 加载 core 文件,回溯崩溃堆栈、变量 |
| 典型场景 | Ctrl+C 结束程序、kill -9 杀进程、程序正常跑完 | 野指针段错误、除零浮点报错、主动 abort 崩溃 |
为什么大部分服务器看不到 core 文件?
根源:ulimit 资源限制
ulimit -a 查看系统资源限制,其中 core file size 代表 core 文件最大允许大小:
1.默认值 0:禁止生成 core 文件(云服务器默认配置,防止磁盘被超大 core 文件占满)

2.开启命令(临时生效,当前终端会话):
ulimit -c 40960 # 设置core文件最大40960块
ulimit -c unlimited # 不限制core文件大小(推荐调试使用)
复习进程等待时的status
这样就能解释前面进程等待时,status的情况了。生成了core文件core dump标志就为1,没有生成就为0


运行结果:在ulimit -c后,如果生成了core文件,core dump就是1
下面展示一下用Core文件来调试的完整流程
首先得修改一下makefile,添加-g调试功能

比如就是这段代码,在运行后发生了错误,

首先确保ulimit -c 40960或者ulimit -c ulimited才能生成core文件
gdb调试的时候core-file core

