前言:
为什么 ctrl+c 能终结任务?为什么除 0 会导致程序崩溃?这是我们前边看似常见却不懂原理的事情,下面就让我们看看它是怎么回事!在看这篇之前,务必要学习过文章:【进程】:时间片上的舞者,状态机里的棋子
目录
[1. 信号概念的引入](#1. 信号概念的引入)
[2. 信号的查看](#2. 信号的查看)
[3. 信号的处理](#3. 信号的处理)
[1. 通过终端按键产生](#1. 通过终端按键产生)
[① 前台进程 (Foreground Process)](#① 前台进程 (Foreground Process))
[② 后台进程 (Background Process)](#② 后台进程 (Background Process))
[③ 相关命令](#③ 相关命令)
[④ 具体现象解释](#④ 具体现象解释)
[2. 通过系统命令发信号](#2. 通过系统命令发信号)
[3. 在代码中通过系统调用函数产生信号](#3. 在代码中通过系统调用函数产生信号)
[4. 由软件条件产生信号](#4. 由软件条件产生信号)
[① 函数原型与信号类型](#① 函数原型与信号类型)
[② 返回值逻辑](#② 返回值逻辑)
[③ 核心特性](#③ 核心特性)
[① 什么是 pause?](#① 什么是 pause?)
[② OS 的工作模式](#② OS 的工作模式)
[① OS 自身具有定时功能](#① OS 自身具有定时功能)
[② alarm计时](#② alarm计时)
[③ 闹钟的异步反馈](#③ 闹钟的异步反馈)
[① 内核管理alarm的方法](#① 内核管理alarm的方法)
[② 内核源码的分析](#② 内核源码的分析)
[③ 软实时](#③ 软实时)
[5. 由硬件异常产生信号](#5. 由硬件异常产生信号)
[(3)Core Dump](#(3)Core Dump)
[① 概念](#① 概念)
[② 作用](#② 作用)
[③ 用waitpid验证](#③ 用waitpid验证)
[④ 具体设置](#④ 具体设置)
[1. 有关信号概念的补充](#1. 有关信号概念的补充)
[2. block,pending,handle](#2. block,pending,handle)
[3. sigset_t](#3. sigset_t)
[4. 信号集操作函数 与 demo测试](#4. 信号集操作函数 与 demo测试)
[(1)sigprocmask:操控 Block 表](#(1)sigprocmask:操控 Block 表)
[(2)sigpending:查看 Pending 表](#(2)sigpending:查看 Pending 表)
[1. 硬件中断](#1. 硬件中断)
[2. 时钟中断](#2. 时钟中断)
[① 更新系统时间](#① 更新系统时间)
[② 维护定时器](#② 维护定时器)
[③ 检查进程的时间片(核心功能)](#③ 检查进程的时间片(核心功能))
[3. 软中断](#3. 软中断)
[(2)int 0x80 / syscall 指令](#(2)int 0x80 / syscall 指令)
[4. 用户态与内核态](#4. 用户态与内核态)
[5. 信号的捕捉流程(重点)](#5. 信号的捕捉流程(重点))
[第四阶段:信号的"归位与重生"(内核态 → 用户态)](#第四阶段:信号的“归位与重生”(内核态 → 用户态))
[6. 中断与信号的关系(关键理解)](#6. 中断与信号的关系(关键理解))
[7. sigaction函数](#7. sigaction函数)
[(1)struct sigaction](#(1)struct sigaction)
[1. 作用解析](#1. 作用解析)
[2. 回收进程改进](#2. 回收进程改进)
[1. 概念](#1. 概念)
[2. 判定标准](#2. 判定标准)
[3. 信号在这里的用途](#3. 信号在这里的用途)
一、信号的快速认识
1. 信号概念的引入
你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递,也就是你能 "识别快递"。当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递,那么在这5min之内,你并没有下去取快递,但是你是知道有快递到来了,也就是取快递的行为并不是一定要立即执行 ,可以理解成**"在合适的时候去取"。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了** ,本质上是你**"记住了有一个快递要去取"。当你时间合适,顺利拿到快递之后,就要开始处理快递** 了,而处理快递一般方式有三种 :①执行默认动作(幸福的打开快递,使用商品)② 执行自定义动作(快递是零食,你要送给你你的女朋友)③ 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)。快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。
这就是信号的大体过程,我们在前面也提到过,比如什么kill -9,ctrl+c,这都是信号。所以,我要说出信号的概念了,但你一定看不懂,不过等你看完这篇文章之后,就是小菜一蝶了。
信号是 Linux/Unix 系统中一种软件中断机制 ,用于异步通知进程 发生了某个事件。它也是进程间通信的一种简单方式,但主要传递的是"事件"而非"数据"。在 Linux 中,信号是软件层面的中断 。 当硬件发生异常(如除以零)或系统发生特定事件(如按下 Ctrl+C)时,内核会向进程发送一个通知。进程在接收到信号后,会暂时中断当前的执行流,转而去处理这个信号,处理完毕后再返回原处继续执行。

怎么理解同步通知与异步通知?
信号被视为异步通知的典型代表,因为它具有以下特性:
进程无法预知信号何时到来
当你的程序正在执行一个复杂的数学运算(如循环累加)时,用户突然在终端按下了
Ctrl+C。此时,内核会立刻向你的进程发送SIGINT信号。如果这是同步 的,程序必须运行到类似于check_for_signals()(检查是否有信号)这样的代码行才会处理;但因为它是异步的,无论程序当前运行到哪一行代码,内核都会强行切断当前的执行路径,转去执行信号处理函数。
2. 信号的查看
- 用kill -l 查看所有信号

- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #define SIGINT 2。

- 编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明:man 7 signal。

3. 信号的处理
(1)signal函数详解
cpp
#include <csignal>
#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
// 其实SIG_DFL和SIG_IGN就是把 0,1 强转为函数指针类型
typedef void (*sighandler_t)(int);
//sighandler_t是一个函数指针
sighandler_t signal(int signum, sighandler_t handler);
参数解析:
signum: 想要处理的信号编号(如SIGINT,SIGTERM)。handler: 处理动作,它可以是以下三种值之一:SIG_IGN,忽略该信号;SIG_DFL,恢复该信号的系统默认处理动作;自定义函数指针 : 指向一个格式为void func(int)的函数。当信号发生时,内核会自动调用它,并将信号编号作为参数传入。
返回值(了解):
-
成功 :返回该信号上一次的处理程序指针。
-
失败 :返回
SIG_ERR,并设置errno。
(2)进程的三种处理方式
-
执行默认动作 (Default): 每个信号都有系统预设的行为(如
SIGINT终止进程,SIGCHLD被忽略)。 -
忽略信号 (Ignore): 进程接收到信号后直接丢弃,不执行任何动作。但
SIGKILL(9) 和SIGSTOP(19) 无法被忽略(也不能被自定义),这是为了保证管理员始终能控制进程。 -
自定义信号 (Catch): 程序员提供一个自定义函数(信号处理程序)。当信号发生时,内核暂停当前程序,调用该函数
cpp
#include <iostream>
#include <csignal> // signal 头文件
#include <unistd.h> // getpid, sleep 头文件
// 1. 自定义处理函数 (Catching)
void myhandler(int sig)
{
std::cout << "\n[自定义捕获] 接收到信号 " << sig << " (SIGINT),但我选择不退出!" << std::endl;
}
int main()
{
std::cout << "当前进程 PID: " << getpid() << std::endl;
// A. 自定义捕获 (SIGINT: Ctrl+C)
// 当按下 Ctrl+C 时,不再终止进程,而是执行 my_handler
signal(SIGINT, myhandler);
// B. 忽略信号 (SIGQUIT: Ctrl+\)
// 当按下 Ctrl+\ 时,系统原本会终止进程,现在会完全没反应
signal(SIGQUIT, SIG_IGN);
// C. 恢复默认动作 (SIGTERM: 默认终止)
// 假设我们之前修改过 SIGTERM,现在将其改回系统默认动作
// 注意点:后执行的 signal 调用会覆盖先前的设置。信号的处理动作始终以最后一次成功注册的结果为准。
signal(SIGTERM, SIG_DFL);
while (true)
{
sleep(1);
}
return 0;
}
二、信号的产生
1. 通过终端按键产生
(1)操作方法
这是开发调试中最常用的方式。当你在终端按下特定组合键时,终端驱动程序会捕捉到这些按键,并将其解释为信号发送给前台进程组。
-
Ctrl + C: 产生SIGINT(2)。默认动作为终止进程。 -
Ctrl + \: 产生SIGQUIT(3)。默认动作为终止并产生 Core Dump(之后讲)。 -
Ctrl + Z: 产生SIGSTOP(19)。默认动作为暂停进程(将其挂起至后台)。

(2)理解OS如何知道键盘上有数据产生

- 当你按下键盘上的某个键时,键盘就像是一个"报信员",它意识到有任务来了,必须立刻通知最高统帅部(CPU)。
- 报信员(键盘)并不能直接跟操作系统对话,它通过物理线路向 CPU 发送了一个"硬件中断"**。**你可以理解为报信员按响了 CPU 办公室门外的紧急门铃。
- CPU 的反应: 它不需要去看屏幕,只要针脚上有电压变化,它就知道:"有人按门铃了,而且是键盘那个房间的门铃。"
- CPU 接收到信号后,会立刻放下手里正在干的活(比如正在计算的逻辑),去内存里的"急诊手册"(中断向量表)中查找:如果键盘门铃响了,我该运行哪段代码?这段代码就是操作系统(OS)里的驱动程序。CPU 开始跑这段代码,去处理这个突发的键盘事件。
- 在 CPU 的指挥下,操作系统停下原本的任务,伸出"手"去键盘的寄存器里把这个按键的具体信息(比如你按的是 'A' 还是 'Ctrl+C')抓取出来,放进内存的缓冲区 里。 如果是普通字符,就等你的程序来读;如果是
Ctrl+C,操作系统就会立刻给你的进程发一个"红牌"(信号),让进程退出。
++我知道你有点懵,没关系,我们在硬件中断那里着重讲++
(3)前台进程与后台进程
① 前台进程 (Foreground Process)
前台进程是当前直接与用户交互的进程。
-
交互权: 它独占终端的输入(标准输入
stdin)。你在键盘上输入的任何字符都会直接发给它。 -
信号敏感度: 它是终端信号的第一接收者 。当你按下
Ctrl+C或Ctrl+\时,内核会精准地将信号发送给当前的前台进程组。 -
数量限制: 在同一个终端中,同一时刻只能有一个前台进程(组)。
-
启动方式: 直接输入命令运行,如
./myshell。(平时我们运行起来后它们就是前台)
② 后台进程 (Background Process)
后台进程是在"幕后"运行的进程。
-
交互权: 它不占用终端输入。即便它正在运行,你依然可以在终端输入其他命令。
-
输出表现: 默认情况下,后台进程依然会将结果打印到屏幕(标准输出
stdout),这可能会干扰你当前的屏幕显示。通常建议将后台进程的输出重定向到文件。 -
信号屏蔽: 后台进程免疫 键盘产生的信号。你按
Ctrl+C无法杀掉后台进程,必须使用kill命令通过 PID 来手动发送信号。 -
启动方式: 在命令末尾加上
&符号,如./myshell &

③ 相关命令
-
jobs: 查看当前终端下所有的后台任务及其状态。 -
Ctrl + Z: 将一个正在运行的前台进程暂停并送入后台。 -
fg [job_id]: 将后台进程调回前台。 -
bg [job_id]: 让处于暂停状态的后台进程在后台继续运行。
bash
yhz@VM-0-5-ubuntu:~/sign-study$ ./demo
进程 [3546817] 正在运行...
进程 [3546817] 正在运行...
^Z
[1]+ Stopped ./demo
yhz@VM-0-5-ubuntu:~/sign-study$ jobs
[1]+ Stopped ./demo
yhz@VM-0-5-ubuntu:~/sign-study$ bg 1
[1]+ ./demo &
进程 [3546817] 正在运行...
yhz@VM-0-5-ubuntu:~/sign-study$ 进程 [3546817] 正在运行...
进程 [3546817] 正在运行...
进程 [3546817] 正在运行...
fg 1 #切换至前台
./demo
进程 [3546817] 正在运行...
进程 [3546817] 正在运行...
^C
yhz@VM-0-5-ubuntu:~/sign-study$
④ 具体现象解释
- 显示器为什么会出现数据混乱?
所谓的"混乱",本质是输出数据的原子性 被破坏了,即未对资源进行保护;无论你有多少个进程,无论是前台还是后台,它们最终输出的目的地都是同一个终端设备文件,即显示器是共享资源,内核虽然负责调度进程,但通常不会对多个进程同时向终端写入的数据进行顺序编排。谁拿到CPU时间片,谁就往缓冲区里写。
- bash是什么进程?
▶ 当你输入命令并运行时: 比如你执行 ./task,此时 bash 会调用 waitpid 阻塞住自己,并将 ./task 提升为前台进程 。这时,bash 变成了后台进程(或者说是不活跃的前台)。
▶ 当没有程序运行时: 此时 bash 就是当前的前台进程,它正在等待你的输入。
- 孤儿进程为什么用ctrl+c杀不掉?
Ctrl+c产生的信号由内核精准投递至当前终端的前台进程组,而孤儿进程在失去父进程后通常会转入后台或脱离终端关联 ,脱离了信号投递的"准星"范围。因此,要清理孤儿进程,必须通过 ps 获取其 PID 后,使用 kill 命令进行手动清除。
- 为什么僵尸进程
kill -9都无能为力?
你不能杀死一个已经死掉的东西。 信号的作用是通知一个"活着的"进程去做某事。僵尸进程已经不是一个活着的代码执行流了,它只是一份留在内核里的结构体数据(墓碑)。它不处理信号,也没有 CPU 执行权。
2. 通过系统命令发信号
这个我们只要学会kill即可
bash
kill [参数] [进程PID]
-
不带参数: 默认发送
SIGTERM(15)。 -
带参数: 可以通过编号或名称指定信号,如
kill -9 1234或kill -SIGKILL 1234。
其他用法:
-
killall: 根据进程名发送信号。示例:killall myshell会杀掉所有名为 myshell 的进程。 -
pkill: 更加灵活,支持正则表达式或匹配特定用户。示例:pkill -u yhz会杀掉用户 yhz 的所有进程\ -
kill-l:它会列出系统中所有的信号名称和对应的编号。
3. 在代码中通过系统调用函数产生信号
(1)kill
kill 是最通用的函数,可以向系统中的任何进程或进程组发送信号。
-
原型:
int kill(pid_t pid, int sig); -
逻辑:
-
pid > 0:发送给指定 PID 的进程。 -
pid == 0:发送给与调用者同进程组的所有进程。 -
pid == -1:发送给所有有权限发送信号的进程(慎用)。 -
pid < -1:发送给进程组 ID 为|pid|的所有进程。
-
(2)raise
raise 用于向当前调用进程发送信号。
-
原型:
int raise(int sig); -
逻辑: 它的底层等价于
kill(getpid(), sig)。通常用于程序在检测到某种特定状态时,主动触发自己的信号处理逻辑。返回0表示成功,非0不成功。
(3)abort
abort 是一个标准库函数,用于异常终止当前进程。
-
原型:
void abort(void); -
逻辑: 它向进程发送
SIGABRT(6) 信号。即使该信号被捕获并在 handler 中返回,abort依然会强制终止进程。 它的目的是确保程序在发生严重错误时产生 Core Dump 并退出。
(4)demo实验
cpp
void handler(int sig)
{
std::cout << "捕获到信号: " << sig << std::endl;
fflush(stdout);
}
int main(int argc, char *argv[])
{
std::string mode = argv[1];
signal(SIGINT, handler); // 为 kill/raise 测试准备
signal(SIGABRT, handler); // 为 abort 测试准备
if (mode == "kill")
{
pid_t id = fork();
if (id == 0)
{
std::cout << "子进程运行中..." << std::endl;
while (true)
sleep(1);
}
else
{
sleep(2);
std::cout << "父进程通过 kill 发送 SIGINT 给子进程 " << id << std::endl;
kill(id, SIGINT);
sleep(1);
kill(id, SIGKILL); // 彻底杀掉子进程
wait(nullptr);
}
}
else if (mode == "raise")
{
std::cout << "通过 raise 触发 SIGINT..." << std::endl;
sleep(1);
raise(SIGINT);
std::cout << "raise 后的代码继续执行" << std::endl;
}
else if (mode == "abort")
{
std::cout << "准备调用 abort..." << std::endl;
sleep(1);
abort();
std::cout << "这一行永远不会被打印" << std::endl;
}
return 0;
}

4. 由软件条件产生信号
(1)alarm函数详解
① 函数原型与信号类型
-
头文件:
<unistd.h> -
原型:
unsigned int alarm(unsigned int seconds); -
发送信号:
SIGALRM(14)。

- 默认动作: 如果进程没有捕获(Catch)这个信号,进程会直接终止。
② 返回值逻辑
alarm 的返回值并不是成功或失败,而是上一个闹钟剩余的秒数。
-
场景 A: 之前没设过闹钟。调用
alarm(10),返回0。 -
场景 B: 设了
alarm(10),跑了 3 秒后,你又调用了alarm(5)。此时,旧闹钟被取消,新闹钟(5秒)开始计时。返回 7(即旧闹钟剩下的时间)。 -
场景 C: 想取消闹钟。调用
alarm(0)。内核会取消当前的闹钟,并返回剩余秒数。
③ 核心特性
- 一次性:
alarm注册的闹钟响过一次后就失效了。如果你想实现"每隔 5 秒响一次",必须在信号处理函数handler里重新调用一次alarm(5)。 - 异步触发: 闹钟是在内核中计时的,与你程序里是否在忙碌(比如在算 1+1 或在执行
sleep)无关。哪怕程序阻塞了,时间一到,信号照样送达。 - 单闹钟机制: 一个进程在同一时刻只能拥有一个闹钟。新闹钟会无情地覆盖掉旧闹钟。
④ 两个demo小测试
- 测IO对速度的影响
cpp
void testhaveIO()
{
int count = 0;
alarm(1);
while (true)
{
std::cout << "count : " << count << std::endl;
count++;
}
}
int count = 0;
void handler1(int signumber)
{
std::cout << "count : " << count << std::endl;
exit(0);
}
void testnoIO()
{
signal(SIGALRM, handler1);
alarm(1);
while (true)
{
count++;
}
}
bash
#有IO
yhz@VM-0-5-ubuntu:~/sign-study$ g++ alarm_test.cpp -o test
yhz@VM-0-5-ubuntu:~/sign-study$ ./test
count : 0
#...
count : 62659
Alarm clock
#无IO
yhz@VM-0-5-ubuntu:~/sign-study$ g++ alarm_test.cpp -o test
yhz@VM-0-5-ubuntu:~/sign-study$ ./test
count : 406462684
- alarm覆盖性测试
cpp
void handler(int sig)
{
std::cout << "闹钟响了!收到信号: " << sig << std::endl;
}
int main()
{
signal(SIGALRM, handler);
std::cout << "设定 5 秒闹钟" << std::endl;
alarm(5);
sleep(2);
std::cout << "改设 4 秒闹钟。" << std::endl;
unsigned int remaining = alarm(4);
std::cout << "旧闹钟剩下的时间是: " << remaining << " 秒" << std::endl;
// 此时程序会等待 4 秒后触发 handler
while (true)
pause(); // 让进程挂起等待信号
return 0;
}
(2)初步认识OS的工作模式
其实上面的第二个代码就可以简单看成OS工作的模式。
① 什么是 pause?
pause 是一个系统调用,它的唯一作用是:让进程进入睡眠状态,直到接收到一个信号。
-
在没有信号到达时,进程不占用 CPU 资源(不处于死循环状态)。
-
一旦有信号递达并执行完处理函数,
pause才会返回。
② OS 的工作模式
操作系统的核心运行逻辑是:"平时睡眠,由中断唤醒,处理任务,继续睡眠。"
cpp
while (true) {
alarm(5); // 1. 预设一个"未来中断"(模拟时钟中断)
pause(); // 2. 进程交出 CPU,进入睡眠(模拟 OS 的等待状态)
// 3. 当 5 秒到,SIGALRM 到达,唤醒进程
// 4. 执行信号处理函数 handler (模拟 OS 执行中断处理程序)
// 5. 信号处理完,从 pause 返回,回到这里
do_something(); // 模拟 OS 执行调度或管理任务
}
OS 并不是一个时刻在空转的死循环。如果没有任何任务,OS 实际上处于一种"低功耗挂起"状态。它完全依赖中断来驱动。硬件中断, 你动了一下鼠标、敲了一下键盘、网卡收到了一个数据包。时钟中断, 硬件计时器每隔几毫秒敲一下门,提醒 OS 该起来看看有没有进程跑超时了。软件中断(系统调用), 你的程序想打开一个文件(调用 open),通过特殊的指令"敲门"进入内核态。
简单来说,OS 是计算机的厂长,它不拧螺丝(不跑业务),只负责排班(调度)、发钥匙(分内存)和维持秩序(安全),通过控制硬件和管理进程来确保工厂的运转。在宏观上看,OS 是一个"极其被动"的管理者。 如果没有外界的"催促"(中断),操作系统就像一台关掉了电源的发动机,或者是一个陷入永恒沉睡的管家。如果没有各种 "中断",OS什么都不干。
(3)alarm计算时间的方法
① OS 自身具有定时功能
操作系统自己是不会计时的,它必须依赖硬件。主板上有一个物理硬件(比如高精度定时器 HPET 或 APIC)。这个硬件就像一个节拍器,每隔一段极其微小的时间(比如 1/1000 秒)就给 CPU 发一个电信号(时钟中断)。每次电信号一来,内核代码就会被强行唤醒。OS 靠着数这些"节拍"的次数,才知道现在是几点几分几秒。
在 Linux 内核里,有一个非常著名的全局变量,叫作 jiffies。它是一个极其简单的整数(无符号长整型)。通常在系统启动时被初始化为 0。硬件每产生一次时钟中断,它的值就精确地自增 1。

- OS 在启动时会读取一次主板电池供电的实时时钟,获取一个初始的现实时间。之后,OS 就不再去读那个慢吞吞的 RTC 硬件了,而是直接用算出来的秒数来推算出当前的精确时间。
② alarm计时
当你调用 alarm(5) 时,内核其实是在做加法:
-
内核读取现在的
jiffies是 10000。 -
内核计算出:10000 + (5 * 1000HZ) = 15000。
-
内核把
15000这个数字填进你的进程管理卡片里。 -
每一毫秒中断来时,内核在
jiffies++之后,都会顺便比对一下:"现在的 jiffies 达到 15000 了吗?" 到了,就发信号。
③ 闹钟的异步反馈
"闹钟"这个接口实现了权力的传递 ,它精髓不在于计时,而在于"异步通知"。
进程设置完闹钟后,可以去干任何事。当 OS 的"节拍器"数到了那个约定的数字,OS 会主动停下当前的工作,跳到该进程的地址空间里去执行那段信号处理代码。这就是你之前说的"被动管理"。用户设置(主动),OS 计时并踢醒用户(被动响应)。
(4)简单了解alarm在内核中的实现
① 内核管理alarm的方法
Linux 内核在实现中经历了几个阶段:
-
早期(简单的有序链表): 插入慢(O(N)),查找快(O(1))。进程多了之后,每次
alarm都要遍历整个链表找插入位置,效率太低。 -
中期 : 使用了**时间轮,**类似于哈希表,把时间分成不同的"桶"。这是 Linux 长期使用的高效算法,适合处理海量的、精度要求不高的定时任务。
-
现代: 使用了红黑树 。红黑树是一种平衡二叉搜索树,它的最左侧节点就是最小值。它在插入、删除、查找最小值上都能稳定在 O(log N)。(和小根堆一个意思,但堆删除节点时比较麻烦)
内核通过一个全局的定时器列表(如时间轮或红黑树)管理所有闹钟。当你调用 alarm(),内核按到期时间排好序(这就是数据结构的意义)。每次时钟中断响起,内核只看列表最前面的任务:如果时间到了就发信号,否则直接返回,从不遍历全部。
② 内核源码的分析
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记录的是该定时器应该在什么时候到期。 expires = jiffies + (5 * HZ),内核每次时钟中断
jiffies++后,就会拿当前的jiffies和这个expires比较。如果jiffies >= expires,说明时间到了! - *function函数指针,时间一到,内核该做的事情
- entry内核通过这个把成千上万个
timer_list串在一起(按照expires的先后顺序排队) - data是
function的参数,如发信号时,得知道发给哪个进程(PID),或者发哪个信号,这些信息可以存在这里。 - *base标记这个定时器属于哪个 CPU 核心(不做了解)
③ 软实时
由于操作系统采用离散的时钟节拍(jiffies)计时,且信号的递送依赖于进程调度,alarm(5)的实际触发时间往往会略微超过 5 秒 。这种误差由计时颗粒度、系统调用开销以及进程排队等待 CPU 的调度延迟共同构成。在 Linux 等分时操作系统中,这种"尽力而为"的定时机制被称为软实时 ,它保证了任务不会早于预定时间执行,但无法规避因系统负载而产生的滞后。所以,软件实现与物理真实之间存在偏差!
了解 timer_create
它支持纳秒级精度,并且允许每个进程创建多个定时器实例,还能通过
sigevent结构体自定义通知方式,比如触发线程或发送特定的实时信号。就是原理和alarm大差不差,但比alarm牛b。
(5)SIGPIPE
这是一种典型的通信异常产生的软件条件,我们学通信时已经说过,这里简单提及。
触发条件
- 进程 A 向进程 B 建立了一个管道(Pipe)。
- 进程 B(读端)已经关闭,但进程 A(写端)仍在尝试写入。
- 后果: 这种行为在逻辑上是无意义的,内核会立即产生
SIGPIPE(13) 信号发给进程 A。
为什么这叫软件条件?
因为这不涉及硬件损坏,纯粹是内核在管理文件描述符和管道缓冲区时,发现"读端不存在"这一逻辑条件成立,从而通过信号通知写端进程。
(6)理解软件条件
-
定义: 信号的产生不源于外部的物理撞击或内部的电路报错,而是因为某种预设的软件逻辑、状态或规则达到了触发点。
-
本质: 它是内核在检查自身维护的各种数据结构时,发现某个逻辑条件成立而主动发起的异步通知。
系统调用vs软件条件
系统调用(即时指令),当你调用
kill、raise或abort时,你是在下达一个明确的命令。
因果关系: 调用函数就是产生信号的直接原因。
逻辑: 只要你执行了这一行代码,内核进入系统调用流程后,二话不说,立刻去修改目标进程的 PCB(进程控制块)里的信号位图。
软件条件(逻辑达成),当你调用
alarm(5)时,该函数本身并不产生信号。
因果关系: 调用函数只是设定了一个条件,产生信号的原因是"时间流逝"。
逻辑:
alarm执行完就结束了,信号可能在 5 秒后才产生。在这 5 秒间,内核并没有在执行alarm函数,而是时钟中断在不断地"数节拍"。直到逻辑条件(jiffies >= expires)满足,信号才被"顺便"触发。
5. 由硬件异常产生信号
(1)模拟除0
cpp
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
for (int i = 0; i <= 31; i++)
{
signal(i, handler);
}
sleep(1);
int a = 10;
a /= 0;
while (1)
;
return 0;
}

(2)模拟野指针
cpp
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
exit(1);
}
int main()
{
for (int i = 0; i <= 31; i++)
{
signal(i, handler);
}
sleep(1);
int *p = NULL;
*p = 100;
while (1)
;
return 0;
}

如果没有exit(1),一直有8号/11号信号产生被捕获,这是为什么呢?
上面我们只提到CPU运算异常后,如何处理后续的流程,实际上 OS 会检查应用程序的异常情况,其实在CPU中有一些控制和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应着一些状态标记位、溢出标记位。OS 会检测是否存在异常状态,有异常存在就会调用对应的异常处理方法。除零异常后,我们并没有清理内存,关闭进程打开的文件,切换进程等操作,所以CPU中还保留上下文数据以及寄存器内容,除零异常会一直存在,就有了我们看到的一直发出异常信号的现象。访问非法内存其实也是如此。
++至于硬件中断的详细信息,我们在信号处理那块讲解。++
(3)Core Dump
① 概念
当一个进程因为某些严重错误(通常是硬件异常引发的信号)而崩溃时,操作系统会把该进程此时此刻在内存中的状态 (包括变量的值、函数的调用栈、寄存器的数值等)完整地"倾倒"出来,保存为一个磁盘文件。这个文件就叫 Core 文件。(中文名叫核心转储)

② 作用
程序崩溃往往是一瞬间的事,你来不及观察它的内部状态。Core dump 记录了这个瞬间的完整"案发现场",包括:程序计数器 :崩溃时执行到哪一行代码?栈信息 :函数调用关系是怎样的(调用栈)?各函数的局部变量是什么?内存数据 :关键的全局变量、静态变量的值是什么?寄存器状态:CPU 当时各个寄存器的值。有了这些信息,开发者可以在程序运行结束后,像"回放录像"一样还原崩溃时的场景。

③ 用waitpid验证

cpp
#include <iostream>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
sleep(1);
int *p = nullptr;
*p = 1234; // 野指针
exit(0);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
if (WIFSIGNALED(status))
{
int sig = WTERMSIG(status); // 获取信号编号
// 通过位运算提取第 7 位 (core dump 标志位)
int core_dump_flag = (status >> 7) & 0x1;
std::cout << "子进程被信号 " << sig << " 终止。" << std::endl;
std::cout << "Core Dump: " << core_dump_flag << std::endl;
}
}
return 0;
}

④ 具体设置
现在的云服务器一般不会开启Core dump,Core 文件的大小等于进程运行时的内存大小。如果一个占用了 8GB 内存的程序崩了,瞬间生成一个 8GB 的文件,硬盘可能会被塞爆。
开启方法(短期有效):
bash
yhz@VM-0-5-ubuntu:~/sign-study$ ulimit -a
real-time non-blocking time (microseconds, -R) unlimited
core file size (blocks, -c) 0 #表示允许的大小
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 7378
max locked memory (kbytes, -l) 251336
max memory size (kbytes, -m) unlimited
open files (-n) 1048576
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 7378
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited
yhz@VM-0-5-ubuntu:~/sign-study$ ulimit -c 1024 #修改大小
yhz@VM-0-5-ubuntu:~/sign-study$ echo "core.%e.%p" | sudo tee /proc/sys/kernel/core_pattern
core.%e.%p
#修改路径到当下
三、信号的保存
看这个之前一定要了解位图,我在这里讲过:【哈希hash】:程序的"魔法索引",实现数据瞬移
1. 有关信号概念的补充
- 实际执行信号的处理动作称为信号递达(所以说信号有三种递达方式)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
2. block,pending,handle

(1)三张表的"三位一体"结构
① Block 表(屏蔽字/阻塞位图)
-
本质: 一个
sigset_t类型的位图。 -
逻辑: 这里的位如果是 1,表示对应的信号被屏蔽(阻塞)了。
-
含义: "如果这个信号来了,先给我拦住,不许处理,直到我解除阻塞为止。"
② Pending 表(未决位图)
-
本质: 是一个结构体,但我们可以认为它就是一个位图。
-
逻辑: 这里的位如果是 1,表示信号已经产生 ,但还没被处理。(未决状态)
-
含义: "收件箱"里有一封还没拆的信。
③ Handler 表(处理方法表)
-
本质: 一个函数指针数组。
-
逻辑:
void (*handler[32])(int); -
**含义:**和我们前面signal函数的第二个参数意思一样。
(2)信号产生后内核的处理流程
我们向OS发送2号信号:
-
产生阶段: 信号产生后,内核直接把进程 Pending 表 的第 2 位改成 1。
-
检查阶段: 内核会先看 Pending ,发现第 2 位是 1(有信号来了)再看 Block,检查第 2 位是不是 1。
-
判定逻辑: ①如果
Block[2] == 1:对不起,信号被阻塞了,虽然它在收件箱里(Pending 为 1),但进程绝对不去处理 ,直到用户手动把 Block 设为 0。②如果Block[2] == 0:绿灯通行!内核去 Handler 数组 找对应的下标 2,执行里面的函数。 -
处理阶段: 在执行 Handler 之前,内核通常会先将 Pending 表 的第 2 位清零(0),表示"这封信我拆开处理了"。
易错点:
- Pending 决定了"有没有"(状态),Block 决定了"准不准"(权限)。
- 即使一个信号被 Block(阻塞)了,它产生时依然会把 Pending 置为 1。只不过它会一直卡在 Pending 状态,等着被释放。
- 信号被"忽略"(Handler = SIG_IGN)和被"阻塞"(Block = 1)是两码事。忽略 是已经处理了,处理动作就是"无视"。阻塞是还没处理,堵在门口不让进。
(3)发送多个相同的信号的处理办法
假设多次发送信号 SIGINT (2号),且进程目前正处于某种状态
-
第一个
SIGINT到达,Pending 位图置 1。 -
内核准备处理,Pending 位图清 0,转而执行 Handler 函数。
-
如果在 Handler 函数执行期间,第二个
SIGINT又来了,内核会自动将当前的SIGINT加入 Block 集(防止同一个信号处理函数被嵌套触发导致栈溢出)。 -
第三个、第四个
SIGINT接着来,此时由于SIGINT已被阻塞,它们只能把 Pending 位图置为 1。 -
结果: 无论后面补了多少个
SIGINT,Pending 位图只能记录"有",不能记录"次数"。当 Handler 执行完,内核解除阻塞,刚才憋着的多次信号最终只会触发一次 。这就是普通信号的丢失(不可靠性)。
上面我说的都是自定义捕捉的处理办法,信号产生时,内核直接检查 Handler 表。如果发现是 SIG_IGN,内核会直接把这个信号丢弃 ,甚至都不会去修改 Pending 位图。当第一个默认信号(如 SIGINT)产生并递达时,内核直接接管。由于默认动作是"杀死进程",内核会立刻执行清理工作并关闭进程,它一般不会动Block。本质原因是默认和忽略是内核自己处理,内核代码是受控的,不需要这种"护盾"。
如何处理普通信号的丢失?
cppstruct sigpending { struct list_head list; sigset_t signal; }; struct sigpending pending;要想彻底解决必须要用实时信号,这也是为什么pending要用结构体!链表(
struct list_head list)的作用是,① 信息载体 :信号不仅仅是一个编号。有时候信号会携带额外信息(比如谁发的、发送时的具体数据、错误原因等)。这些信息存储在struct sigqueue节点里,挂在这个链表上。② 支持排队(针对实时信号) :对于 34-64 号实时信号,如果有 10 个信号发过来,内核会分配 10 个sigqueue节点挂在链表上。这样解除阻塞后,就能按顺序处理 10 次。稍微了解即可
3. sigset_t
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储 ,这个类型sigset_t称为**信号集,**可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。(类似权限那里的umask)
sigset_t类型对于每种信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
| 函数 | 作用 | 本质逻辑 |
|---|---|---|
sigemptyset(sigset_t *set) |
清空 | 将位图所有位设为 0 |
sigfillset(sigset_t *set) |
全填 | 将位图所有位设为 1 |
sigaddset(sigset_t *set, int signum) int signum 就是信号编号 |
添加 | `set |
sigdelset(sigset_t *set, int signum) |
删除 | set &= ~(1 << (signum-1)) |
sigismember(const sigset_t *set, int signum) |
查询 | 判断某一位是否为 1 |
在使用
sigset_t变量之前,必须先调用sigemptyset或sigfillset初始化!
4. 信号集操作函数 与 demo测试
(1)sigprocmask:操控 Block 表
cpp
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
① how (怎么改):这是一个动作指示,有三个选项:
-
SIG_BLOCK:相当于mask |= set(把set里的信号加到黑名单)。 -
SIG_UNBLOCK:相当于mask &= ~set(把set里的信号从黑名单移除)。 -
SIG_SETMASK:相当于mask = set(直接覆盖,最常用)。
② set:准备好的新位图。
③ oldset:内核会把修改之前的 Block 位图存进去。如果你不关心原来的状态,传 NULL。
(2)sigpending:查看 Pending 表
cpp
int sigpending(sigset_t *set);
set 是一个输出型参数。内核会把当前的 Pending 位图拷贝一份,填到你提供的这个变量里。
(3)demo测试
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <iomanip>
// 打印位图
void printSigSet(sigset_t *set)
{
for (int i = 31; i >= 1; i--)
{
if (sigismember(set, i))
std::cout << "1";
else
std::cout << "0";
}
}
// 同时读取并打印 Block 和 Pending
void showTables()
{
sigset_t b, p;
sigprocmask(SIG_BLOCK, NULL, &b); // 获取当前 Block
sigpending(&p); // 获取当前 Pending
std::cout << " Block:";
printSigSet(&b);
std::cout << " Pending:";
printSigSet(&p);
std::cout << std::endl;
}
// 自定义信号处理函数
void handler(int signo)
{
std::cout << "\n>>> 捕捉到信号: " << signo << std::endl;
// 在 Handler 执行期间,观察 Block 位图的变化
for (int i = 0; i < 3; i++)
{
std::cout << "信号" << signo;
showTables();
sleep(1);
}
std::cout << ">>> 信号 " << signo << " 处理完毕,退出 Handler" << std::endl;
}
int main()
{
signal(SIGINT, handler); // 2号信号
signal(SIGQUIT, handler); // 3号信号
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT); // 初始时我们先手动屏蔽 SIGINT
sigprocmask(SIG_BLOCK, &set, NULL);
std::cout << "PID: " << getpid() << "\n";
std::cout << "初始情况";
showTables();
int count = 0;
while (true)
{
std::cout << "进入循环 ";
showTables();
sleep(1);
count++;
// 3. 10秒后解除屏蔽
if (count == 5)
{
std::cout << ">>> 倒计时结束:解除 SIGINT 屏蔽!" << std::endl;
sigemptyset(&set);
sigprocmask(SIG_SETMASK, &set, NULL);
}
}
return 0;
}
四、信号的捕捉
1. 硬件中断

(1)CPU针脚
CPU 的背面那一排排细密的金针或触点就是针脚 。从逻辑上讲,它们是 CPU 的物理接口, 比如地址总线/数据总线针脚 ,就是负责传地址和传数据。其中最关键的一根叫 INTR(Interrupt Request) 。你可以把 INTR 针脚想象成一根**电线,**当外设想发起中断时,就往这根线上通个电(高电平)。CPU 每执行完一条指令,都会去"看一眼"这根针脚上有没有电压。

(2)中断控制器与中断号
CPU 只有一两个 INTR(中断请求) 引脚,但电脑里有几十个硬件设备:键盘、鼠标、网卡、显卡、磁盘、定时器......如果每个设备都直接把电线连到 CPU 的引脚上,不仅引脚不够用,而且当两个设备同时发信号时,CPU 会直接"疯掉",它不知道该先理谁。这时就要有**中断控制器(APIC)**了。
多路复用:把几十路设备的信号汇聚到一两根线上,再传给 CPU。
优先级仲裁:如果键盘和网卡同时来了信号,它根据预设的优先级(比如磁盘优先级高于键盘),决定先放谁进去。
排队机制:没排上的信号先在控制器里挂起,等 CPU 忙完了再送下一个。
中断号是一个简单的整数(0~255),用来区分是哪个设备在闹事。APIC会计算出该硬件中断的中断号n,然后在CPU询问时把中断号发给它。
(3)硬件的寄存器
每个硬件(键盘、网卡、显卡)内部都有自己的小存储单元,叫寄存器 。在中断流程中,它们扮演两个角色:① 状态寄存器 :记录"发生了什么"。比如网卡寄存器里某一位置 1,表示"数据包已到齐";键盘寄存器里置 1,表示"有按键按下"。② 数据寄存器:存放具体内容。比如你按下的到底是 'A' 还是 'B',就存在键盘的数据寄存器里。
在中断流程中的作用 : 当 CPU 被中断引脚"叫醒"后,它并不知道具体发生了什么。它必须通过 in / out 指令去读硬件的寄存器,才能把具体的按键值或数据包拿出来。
(4)中断向量表
它在内存中就是一个数组 。数组的下标就是中断号 ,数组的内容是一个结构体。 结构体的核心内容就是中断对应的处理函数在内核中的起始地址。这是从硬件跳转到软件代码的核心桥梁。
(5)全流程阐述
-
物理触发:你按下了键盘(比如
Ctrl+C)。 -
针脚通电:键盘控制器将电信号发给 中断控制器 (APIC) 。APIC 经过优先级仲裁,通过 CPU 针脚 (INTR) 向 CPU 发起高电平信号。
-
中断号确定:CPU 执行完当前指令,感知到针脚电位变化,向 APIC 回复确认。APIC 通过数据总线送回一个 中断号 (例如
0x21)。 -
硬件动作:CPU 立即停止当前的
main任务,自动将当前的 EIP/RIP (指令指针) 、CS (代码段寄存器) 和 SS/SP (栈指针) 压入该进程的内核栈(现场保护)。 -
查表 (IDT):CPU 根据 IDTR 寄存器 找到内存中的 中断向量表 (IDT) ,以中断号
0x21为索引,找到键盘驱动程序的入口地址,这时他就会陷入内核,内核驱动程序访问键盘控制器的 数据寄存器,读取数据,并执行方法 。 -
执行完毕,恢复现场
硬件中断的源头是外部设备,它是异步的;
硬件异常的源头是内部硬件,它是同步的。
(这是信号的产生层面,和信号本身无关)
2. 时钟中断
时钟中断也属于硬件中断的一种,它的优先级是极高的。
(1)硬件源头
在主板上,有一个专门的硬件芯片,它是一个晶体振荡器 。它以极高的频率产生脉冲,内核在启动时会编程设定它的频率(比如每秒跳 100 次或 1000 次)。每当计数值减到 0,它就会通过电线(针脚)给 CPU 发送一个硬件中断信号。这个信号对应的中断号通常是 0x20。
(2)该中断程序的作用
① 更新系统时间
内核维护着自 1970 年 1 月 1 日以来的秒数。每跳一次,内核就累加这个计数器。具体方法在alarm中已经讲过。
② 维护定时器
alarm函数等,上面讲过
③ 检查进程的时间片(核心功能)
时钟中断在不断地强行切任务是你的电脑能同时跑很多程序的原因!

CPU的主频
它是 CPU 内部执行指令的**最小节拍,**决定了指令跑得有多快。主频非常快(纳秒级),而时钟中断的频率相对慢得多(毫秒级),主频负责"让指令跑起来",时钟中断负责"让内核醒过来"。时钟中断提供了固定的调度周期,而 CPU 主频决定了该周期内指令执行的密度;因此,它们虽然是两个独立的时钟源,却是 OS 评估进程任务进度和进行多核负载均衡的关键参考。
3. 软中断
我们这里谈的全部为广义软中断。
(1)概念
有人一定会问,那只有硬件有中断吗,那肯定不是,毕竟OS要通过中断"前进"。广义软中断 是一个操作系统教材中常用的分类概念,指所有由软件(而非外部硬件)触发的中断类事件。它是相对于"硬中断"(外部设备触发)的一个相对的概念。它主要包括:
| 概念 | 触发方式 | 运行位置 | 主要用途 |
|---|---|---|---|
| Trap (陷阱) | int 0x80 / syscall 指令 |
内核态 | 系统调用,主动进入内核。 |
| Exception (异常) | 除 0、非法内存访问 | 内核态 | 硬件报错,被动进入内核。 |
| Softirq (内核软中断) | 内核代码调用 raise_softirq |
内核态 | 中断下半部,内核内部异步任务。 |
为什么硬件异常也属于软中断
在广义的分类中,我们将"中断"分为两类,硬中断 :来自 CPU 外部 (通过电平跳变捅针脚),软中断(同步异常) :来自 CPU 内部 (在执行指令时由于逻辑或指令要求触发)。因为它不是外部设备触发的,而是 CPU 在执行你那行
*ptr = 10;指令时,内部逻辑走不通了,被迫停下来跳进内核,但发现它的人是硬件。
(2)int 0x80 / syscall 指令
① int 0x80(经典陷阱指令)
-
本质 :这是一条汇编指令。
int是 Interrupt 的缩写,0x80是一个十六进制的中断号(128号)。 -
原理 :在早期的 x86 架构中,Linux 选择使用 128 号中断作为系统调用的入口。当 CPU 执行到
int 0x80时,它会像处理硬件中断一样,去 IDT里查 128 号对应的函数地址。 -
缺点:太慢了。因为它要像硬件中断一样,经历复杂的查表、权限检查、压栈过程。
② syscall(现代指令)
-
本质 :这是 AMD/Intel 后来专门为系统调用设计的 CPU 指令。
-
原理:它不再去查 IDT 表。CPU 内部有一个特殊的寄存器(MSR 寄存器),里面直接存着内核系统调用的入口地址。
-
优点:极快。它精简了大量的保护性检查和压栈动作,直接"一键切入"内核,是现在 64 位 Linux 的默认选择。
(3)陷阱的处理流程

- 在 C++ 调用
read()时,标准库会把系统调用号 (比如read是 0)放进eax寄存器,把参数放进ebx,ecx等寄存器。 - 程序执行
int 0x80。CPU 硬件感知到这是一个"陷阱(Trap)"指令。 - CPU 权限从 Ring 3(用户态)切换到 Ring 0(内核态),并保护现场。
- CPU 查 IDT 表中的第
0x80项,跳到内核定义的通用入口函数 - 内核根据系统调用号 ,去查一张叫
sys_call_table的函数指针数组,数组下标就是系统调用号, 最终调用真正的内核函数sys_read()。 - 内核跑完
sys_read,把结果放回rax,然后回到用户态。
(4)检测信号的时机
从内核态返回用户态前是检测信号的绝对核心,无论是系统调用结束、硬件中断处理完毕,还是异常恢复,只要CPU准备从内核回到用户空间,都必须经过这个"安检口"检查pending位图,如果没有其他干扰,检查到就处理。
各种中断冲突了怎么办?(了解)
各种中断不会真的"撞车",因为硬件有优先级裁决,软件有屏蔽和分阶段处理机制。高优先级的可以打断低优先级的(嵌套),内核可以在关键时刻关门谢客(屏蔽),并通过"上半部"快速登记、"下半部"(狭义软中断)慢慢处理的方式,确保系统既不会丢中断,也不会卡死。
4. 用户态与内核态
(1)概念及原因
想象一下,如果任何一个普通的 C++ 程序都能直接操作硬盘、修改内存分配、或者关闭 CPU 的中断针脚,那么一个写错的死循环或者一个恶意木马就能让整个系统瞬间崩溃。
为了防止这种情况,CPU 硬件设计了不同的特权级别
-
Ring 0 = 内核态:拥有至高无上的权力,可以执行任何 CPU 指令,访问任何硬件。
-
Ring 3 = 用户态:受限的权力,只能访问属于自己的内存空间,严禁直接操作硬件。
当前运行在哪个 Ring,由 CS 寄存器的最后2个比特位(RPL)决定:
00是 Ring 0(内核态),11是 Ring 3(用户态),具体不深究。
| 维度 | 用户态 (User Mode) | 内核态 (Kernel Mode) |
|---|---|---|
| 指令权限 | 只能执行普通运算指令(加减乘除)。 | 可以执行特权指令(如关中断、操作 MMU、停机)。 |
| 内存访问 | 只能访问进程自己的虚拟内存。 | 可以访问所有物理内存和内核空间。 |
| 崩溃后果 | 顶多是该进程"段错误"被杀掉。 | 整个操作系统崩溃。 |
(2)转换机制
-
系统调用(主动申请) :通过
syscall或int 0x80指令,像客户向柜员申请取钱一样,主动进入内核。 -
异常(被动陷入):比如程序除零、野指针。CPU 硬件强制把权限提升到内核态,让内核来处理这个"肇事者"。
-
硬件中断(外力介入):时钟中断、键盘中断。不管你愿不愿意,CPU 强行切入内核态去处理硬件请求。
(3)内存布局

用户态 只能看到用户空间 ,而内核态 能看到整个地址空间。 每个进程有自己的用户页表,但所有进程共享同一份内核页表, 都指向相同的物理地址。还有一个概念是内核栈, 每个进程都有两个栈,用户栈和内核栈,一旦切换到内核态,CPU 会立刻自动换用内核栈,把用户态的"书签"(寄存器、返回地址)压进去,这就是保护现场。

(4)扩展


5. 信号的捕捉流程(重点)

第一阶段:信号的"起源、涂色与实时发现"(内核态/硬中断)
- 物理触发:键盘产生电信号,APIC 捅 CPU 针脚。
- 硬中断入核 :CPU 查 IDT 表,通过
0x21号找到键盘驱动入口。 - 身份转换 :CPU 从 Ring 3 切换到 Ring 0,自动换上当前进程的内核栈,压入用户态现场。
- 涂黑位图 :驱动程序识别出
Ctrl+C。内核调用send_signal,在当前进程task_struct的 Pending 位图 里把SIGINT涂成 1。 - 回程例行检查 :驱动逻辑跑完,进入中断返回路径(如
ret_from_intr)。内核执行test_thread_flag,发现位图刚才被涂黑了。 - 感知与分流 :内核意识到"有信号要处理",不再执行直接返回用户态的原计划 ,而是原地转向执行
do_signal()。
第二阶段:信号的"出鞘与跳转"(跨越态切换)
- 入核判定(第 1 次切换):其实就是刚才那个键盘中断引发的入核。它既是"产生信号"的入口,也成了"检测信号"的契机
- 构造用户栈帧 :内核不能在内核态跑你的
handler(权限太高,不安全)。内核会在你的用户栈 上,强行压入一个伪造的现场(包括信号处理完后要去的地址sigreturn,这是函数栈帧的知识)。 - 修改返回地址 :内核把保存好的、原本指向你自己代码的
RIP临时改成你的handler函数地址。 - 返回用户态(第 2 次切换) :执行
IRET。CPU 以为自己回到了断点,结果一睁眼,发现自己在跑你的handler。
第三阶段:信号的"处理与回执"(用户态)
- 执行业务 :你的
handler打印了一句"捕捉到信号"。 - 收尾工作 :
handler执行完毕,它会跳向之前内核在用户栈里埋伏好的sigreturn(这又是一个系统调用/软中断)。
第四阶段:信号的"归位与重生"(内核态 → 用户态)
- 最后入核(第 3 次切换) :通过
sigreturn再次进入内核。 - 现场还原 :内核把之前保存在内核栈里的、真正的
while(1)断点数据(原始的RIP和EFLAGS)拿出来,覆盖掉那个临时的handler现场。 - 最终返回(第 4 次切换) :执行
IRET。CPU 这次真的回到了你的代码上。
默认和忽略的流程是什么?
当信号产生中断进入内核,准备"涂色"时,内核会先看一眼该进程的
sighand_struct(信号行为表),如果是 SIG_IGN,或者是默认动作且默认即为忽略 (比如SIGCHLD),它直接拒收信号,位图(Pending)连 1 都不会被涂上,更不会有后续的 4 次切换。如果是终止类信号(比如SIGKILL9号), 内核根本不写位图等待,直接在此时此刻就开始执行销毁进程的逻辑。所以,这种情况在内核态直接"闭环"处理了,用户态进程完全感知不到。而其他的大多数信号如果是
SIG_DFL(默认终止/暂停),它和自定义捕捉的处理方式一样,只不过在执行函数时如果是**默认终止,**内核在安检口直接把进程送进"坟墓",不再返回用户态。
6. 中断与信号的关系(关键理解)
同学们,大家有没有发现信号的捕捉流程与中断的处理机制非常相似,它们都先是打断当前任务,保存上下文, 一个查中断向量表 ,另一个查**handler表,**然后去各自的地方去处理,最后来恢复现场。这印证了
++所以,信号一定是由硬中断或者软中断引起的,但是,不是所有中断都一定会产生信号,注意我说的是信号的产生,信号本身和中断几乎没有关系,我们之所以说它是软件层面的中断,是因为它和中断的处理机制极其相似,而且只发生在软件层上,它的处理和中断的唯一关系就是,中断会给信号处理带来契机。另外,信号的产生与递达是两个执行流程,它们是完全解耦的,你别看上面所说的四次切换好像信号在产生完就被立马处理了,那是因为产生信号本身就是一个契机,如果产生的那个信号现在被阻塞了,那本次中断返回时也不会去递达信号,或者A进程给B进程发信号,可B进程因为时间片耗尽压根没在跑,那也不会递达。++
7. sigaction函数
(1)struct sigaction
cpp
struct sigaction
{
void (*sa_handler)(int); // 方式 A:传统的处理函数
void (*sa_sigaction)(int, siginfo_t *, void *); // 方式 B:携带详细信息的处理函数
sigset_t sa_mask; // 关键!信号屏蔽字(排队机制)
int sa_flags; // 行为标志位,了解即可
void (*sa_restorer)(void); // 不用管
};
- sa_handler:传统的信号处理函数,只接收信号编号
- sa_sigaction:增强版处理函数,接收更多信息(需设置sa_flags |= SA_SIGINFO,尽了解,上面和这个二选一)
cpp
//sa_sigaction
void handler(int sig, siginfo_t *info, void *context)
{
// sig: 信号编号
// info: 信号详细信息,siginfo_t 结构体里包含了 si_pid(发送者进程号)、si_addr(触发异常的内存地址)等宝贵数据。
// context: 用户上下文(很少用)
}
sa_mask:防止"信号套娃",这是 sigaction 最强大的地方。
-
默认行为 :当你在执行
SIGINT的处理函数时,内核会自动阻塞SIGINT,防止它递归嵌套。 -
扩展行为 :如果你希望在处理
SIGINT(Ctrl+C)时,连SIGQUIT(Ctrl+\)也暂时不要打断我,就把SIGQUIT加入到sa_mask中。 -
物理本质 :当 CPU 准备切回用户态跑 handler 前,内核会把
sa_mask叠加到该进程的 Block 位图里;等 handler 跑完,再恢复。
(2)sigaction函数
cpp
#include <signal.h>
int sigaction(int signum,
const struct sigaction *act,
struct sigaction *oldact);
| 参数 | 含义 |
|---|---|
signum |
要操作的信号编号(SIGINT、SIGTERM 等,除 SIGKILL/SIGSTOP) |
act |
新的处理方式(若 NULL,则不改变) |
oldact |
旧的的处理方式(若 NULL,则不获取),输出型参数 |
| 返回值 | 成功返回 0,失败返回 -1 并设置 errno |
(3)demo测试
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <iomanip>
// 打印位图
void printSigSet(sigset_t *set)
{
for (int i = 31; i >= 1; i--)
{
if (sigismember(set, i))
std::cout << "1";
else
std::cout << "0";
}
}
// 同时读取并打印 Block 和 Pending
void showTables()
{
sigset_t b, p;
sigprocmask(SIG_BLOCK, NULL, &b); // 获取当前 Block
sigpending(&p); // 获取当前 Pending
std::cout << "Block:";
printSigSet(&b);
std::cout << " Pending:";
printSigSet(&p);
std::cout << std::endl;
}
void my_handler(int signo)
{
std::cout << "捕捉到信号: " << signo << std::endl;
// SIGINT (2) 和我们在 sa_mask 里设定的信号都会在 Block 位图中显示为 1
for (int i = 0; i < 3; ++i)
{
showTables();
sleep(2);
}
std::cout << "捕捉" << signo << "号信号完成" << std::endl;
}
int main()
{
struct sigaction act;
act.sa_handler = my_handler;
act.sa_flags = 0; // 默认
// 在处理 SIGINT 时,强制屏蔽 SIGQUIT (3号信号)
sigemptyset(&act.sa_mask); // 先初始化
sigaddset(&act.sa_mask, SIGQUIT);
// 注册两个信号
sigaction(SIGINT, &act, nullptr);
sigaction(SIGQUIT, &act, nullptr);
std::cout << "PID: " << getpid() << std::endl;
while (true)
{
pause();
}
return 0;
}

五、SIGCHLD信号
1. 作用解析
子进程正常退出或被信号干掉时,他会给父进程发送SIGCHLD(17号信号),他的默认动作是忽略。我们先来验证一下:
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
// 1. 定义监控函数
void handler(int signo)
{
std::cout << " 父进程捕捉到了信号: " << signo << std::endl;
// 清理尸体
while (waitpid(-1, nullptr, WNOHANG) > 0)
{
std::cout << " 已通过 waitpid 回收子进程资源。" << std::endl;
}
}
int main()
{
signal(17, handler);
// 创建子进程
int count = 0;
while (count < 10)
{
pid_t pid = fork();
if (pid == 0 && count != 5)
{
sleep(2);
_exit(0);
}
std::cout << "父进程正在忙碌中... " << count++ << std::endl;
sleep(1);
}
return 0;
}
为什么 waitpid 要加循环和阻塞?
由于信号位图无法记录相同信号产生的次数(多次触发仅记录一次),父进程可能仅因一次信号检测而进入 Handler。如果父进程正在处理第一个儿子的 SIGCHLD,此时第二个、第三个儿子也死了。Pending 位图已经被第一个信号涂成 1 了。后续的信号再来,发现位图已经是 1,就直接丢弃了。所以当你的 handler 跑完回到用户态,内核只带你进了一次 handler。如果你只调一次 wait,剩下的两个儿子就成了永久的僵尸,所以在 handler 里必须用 while 循环把所有"已死"的儿子全部捞出来。
如果不加WNOHANG,
waitpid会死等。 万一你还有 2 个儿子活得好好的,while循环转到第 3 次时,waitpid就会卡死 在handler里,直到那两个活着的儿子死掉。
2. 回收进程改进
cpp
int main()
{
signal(SIGCHLD, SIG_DFL);
int count = 0;
while (count < 10)
{
pid_t pid = fork();
if (pid == 0 && count != 5)
{
sleep(5);
_exit(0);
}
std::cout << "父进程正在忙碌中... " << count++ << std::endl;
sleep(1);
}
}

在 Linux 中,如果你显式将 SIGCHLD 设为 SIG_IGN,内核会触发特殊逻辑:**子进程退出时直接释放资源,不再转为僵尸进程,也不通知父进程。**注意:在 Linux 内核里,SIG_DFL(默认忽略)和 SIG_IGN(显式忽略)虽然"动作"一样(我们之前说过),但触发的"资源回收策略"完全不同,它默认忽略时会产生僵尸,但显示设置却不会,你可以理解为特殊处理。
六、可重入函数
1. 概念
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。一般来说每个控制流程(进程/线程)都有自己的私有栈,但对信号处理函数来说,虽然main 和 handler 是两个不同的控制流程,但它们共享同一个栈空间!但不可重入的根本原因还是"多个执行流访问同一份可变数据"(通常是全局/静态变量)。
2. 判定标准
-
使用了全局变量或静态变量 (如上面的链表、
static int count)。 -
调用了标准 I/O 库函数 (如
printf,malloc,free)。 -
调用了不可重入的系统调用(如获取全局状态的函数)。
3. 信号在这里的用途
这时的信号屏蔽字有了大用
- 保护临界区 :在操作链表前,调用
sigprocmask把可能干扰你的信号全部封锁(Block)。 - 执行操作:安全地修改链表。
- 解除封锁:操作完后再把信号放出来。
通过修改 task_struct 里的 Block 位图,让内核在"回程检查"时,即使 Pending 位图是 1,也因为 Block 位图的拦截而暂时不执行捕捉动作。
七、volatile
先上代码看现象:
cpp
#include <cstdio>
#include <csignal>
// int quit = 0;
volatile int quit = 0;
void handler(int signo)
{
printf("捕捉到信号\n");
quit = 1; // 信号捕捉函数修改全局变量
}
int main()
{
signal(SIGINT, handler);
while (!quit)
{
// 执行一些任务
}
printf("进程安全退出\n");
return 0;
}
bash
# int quit=0;
yhz@VM-0-5-ubuntu:~/sign-study$ g++ volatile.cpp -o test
yhz@VM-0-5-ubuntu:~/sign-study$ ./test
^C捕捉到信号
进程安全退出
yhz@VM-0-5-ubuntu:~/sign-study$ g++ volatile.cpp -o test -O1 #一级优化
yhz@VM-0-5-ubuntu:~/sign-study$ ./test
^C捕捉到信号
^C捕捉到信号
^\Quit
# volatile int quit = 0;
yhz@VM-0-5-ubuntu:~/sign-study$ g++ volatile.cpp -o test
yhz@VM-0-5-ubuntu:~/sign-study$ ./test
^C捕捉到信号
进程安全退出
yhz@VM-0-5-ubuntu:~/sign-study$ g++ volatile.cpp -o test -O3 #三级优化
yhz@VM-0-5-ubuntu:~/sign-study$ ./test
^C捕捉到信号
进程安全退出
当你开启编译器优化(如 g++ -O1)时,编译器会盯着 main 函数看:它发现 while 循环里根本没有修改 quit 的代码。它心想:"既然循环里没改 quit,那 !quit 永远是真的,我为了提速,直接把 quit 的值一次性装载到 CPU 寄存器里,以后每次判断直接看寄存器,不用再去内存找了。"所以你第二次怎么发都不改。
当你把声明改为 volatile int quit = 0; 时,你给编译器下达了死命令:
-
禁止寄存器缓存 :每次用到这个变量,必须去内存地址里读取。
-
禁止指令重排:编译器不能为了优化性能而随意挪动涉及这个变量的代码顺序。
所以,C语言关键字的最后一个现在也学完了:volatile,保证内存可见性!
后记:
从内核态的"出鞘",到用户态 Handler 的"回执",再到最后 sigreturn 的"重生",每一个步骤都严丝合缝。学习信号处理,本质是在学习权力的边界, 进程何时必须放下手头的工作去响应系统的号召。而文章所讲的中断是你提升水平,拔高思维的重要一环,希望大家可以认真研究,如果该文章对大家有帮助,麻烦点个小心心支持一下吧!
