Linux系统--信号(1--准备)
Linux系统–信号(1–准备)
前言:
本文主要讲解了:
-
信号与信号量的区别。
-
什么是异步机制(异步通知)?如何理解异步?
-
从生活场景着手理解信号,自然过渡到计算机中的信号。(这部分其实就是讲解了进程是如何看待信号的,或者说初步搭建了计算机世界里面进程和信号的一个认知框架)
-
之后我们学习了发送信号的方法有哪些。
-
了解了常见的信号(1~31号标准信号)(以及为什么没有0,32,33号信号)
-
学习了进程接收到标准信号时都有哪些默认的处理方式
-
着重讲解了忽略这个处理方式,以及
core
方式,也就是终止进程+生成核心转储文件的处理方式 -
讲解了:核心转储是什么?核心转储的作用是什么?为什么Linux系统默认关闭生成核心转储的功能?
-
用例子告诉了大家我们可以自定义进程处理信号的方式。(这部分内容中包含与信号有关的核心函数讲解的链接)(链接中的内容包含异步安全的讲解)
信号系列讲解顺序图:
细节图:
信号和信号量
这里首先要说的第一点就是:信号和信号量没有关系。
- 信号和信号量没有直接关系。它们的名字相似是历史原因,可以看作是计算机科学中的一次“撞名”。
- 信号是进程间通信的一种机制。它被明确归类为Unix/Linux进程间通信的方式之一。
- 信号本质: 信号是操作系统内核向进程发送的一种异步事件通知机制。它是一种软件中断。
详细对比:信号 vs. 信号量
为了更清晰地理解,我们通过一个表格来对比:
特性 | 信号 | 信号量 |
---|---|---|
本质 | 一种异步通知机制,用于通知进程某个事件已发生。 | 一种同步原语,用于控制多个进程/线程对共享资源的访问。 |
核心用途 | 处理异常、中断、或简单的进程间通信。例如: • SIGINT (Ctrl+C) 中断进程• SIGKILL 强制杀死进程• SIGUSR1 /SIGUSR2 用户自定义信号 | 实现进程/线程间的互斥与同步。例如: • 保护一个共享内存区域 • 实现生产者-消费者模型 |
通信内容 | 只传递一个信号编号(一个整数),不携带其他数据。 | 本身不传递数据,而是通过一个计数器来管理资源的使用权。 |
实现机制 | 由内核维护,当信号产生时,内核会中断接收进程的正常流程,转而执行其信号处理函数。 | 是一个内核维护的整数值,针对这个值的 P/V操作 是原子的。 • P操作(等待/减少):申请资源,若信号量值<=0则阻塞。 • V操作(发送/增加):释放资源,并唤醒等待的进程。 |
所属范畴 | 进程间通信 | 进程/线程同步 |
类比 | 好比是手机的通知。你正在看书,突然来了一条短信(信号),你中断看书去处理短信。 | 好比是公共厕所的钥匙。只有一把钥匙(信号量),谁拿到钥匙谁进去用,用完了挂回去,下一个人才能拿。 |
什么是异步通知机制呢?
异步通知类比现实生活就是:
你去饭店给老板说:“老板,我要吃饭!”(发送信号)。说完就找个位置坐下和朋友聊天或者玩手机了。你不会死等。
死等的意思就是,在等到你的饭之前不干任何事情,就死等,盯着老板,每隔一会就问一下“我的饭好了没?”。
这种发送完信息后就去干自己的事情,不死等结果的,就是异步通知。
异步描述的是一种时间上的不协调关系。在异步操作中:
- 请求的发起和结果的返回发生在不同的时间点,且发起者不会停下来等待结果。
- 调用者发出请求后,立即继续执行后续代码,而不会阻塞等待操作完成。
- 被调用的操作在"后台"执行,完成后通过某种机制(回调函数、事件、信号等)通知调用者。
场景二:去咖啡店买咖啡
同步方式(排队等待):
- 你走到柜台点一杯拿铁。
- 你站在柜台前等待,看着咖啡师制作你的咖啡。
- 在此期间,你不能做其他事情(不能看手机、不能回消息)。
- 3分钟后,咖啡做好,你拿到咖啡,然后离开。
特点: 顺序执行,阻塞等待。一件事做完才能做下一件事。
异步方式(取号等待):
- 你走到柜台点一杯拿铁,拿到一个取餐号。
- 你不需要在柜台前等待,可以立即去找个座位。
- 在等待期间,你可以做其他事情:回微信、看新闻、处理工作。
- 当咖啡做好时,柜台会叫号(或通过震动器通知你)。
- 你听到叫号后,去柜台取咖啡。
特点: 重叠执行,非阻塞。发出请求后立即继续做其他事,通过"通知"机制获知完成。
为什么需要异步?—— 解决"等待"的浪费
计算机中很多操作都很慢:
- 磁盘I/O:毫秒级(百万个CPU周期)
- 网络请求:几十毫秒到几秒
- 数据库查询:毫秒到秒级
- 用户输入:秒级甚至分钟级
如果使用同步方式,CPU在等待这些慢速操作时会完全空闲,这是巨大的资源浪费。
异步的价值:
- 提高资源利用率:在等待I/O时,CPU可以去处理其他任务
- 提高响应性:GUI程序不会"卡死",网络服务器可以同时处理多个连接
- 提高吞吐量:单位时间内可以完成更多工作
重新理解"信号是异步的"
现在回来看:“信号是异步的”。
- 同步通信:进程A向进程B发送消息后,阻塞等待进程B的回复,收到回复后才继续执行。
- 异步通信(信号):进程A向进程B发送信号(如
kill(pid, SIGTERM)
)后,立即继续执行自己的代码,不关心也不等待进程B何时处理、如何处理这个信号。信号的传递和处理由操作系统在"后台"完成。
信号的异步性体现在:
- 发送时机不确定:信号可以在进程执行的任何时刻到达
- 处理时机不确定:进程只有在从内核态返回到用户态时才会处理信号
- 发送者不等待:发送信号的进程不会阻塞等待信号被处理
异步的实现机制
异步操作通常通过以下方式实现:
- 回调函数(Callbacks):最常见的机制,操作完成后调用预设的函数
- Promise/Future:更现代的异步编程模式,代表一个未来才会完成的操作
- 事件循环(Event Loop):Node.js、GUI框架的核心机制,不断检查并处理完成的事件
- 信号/中断(Signals/Interrupts):硬件级别的异步通知机制
总结:异步的核心特征
- 非阻塞:调用立即返回,不等待操作完成
- 延迟处理:实际操作在后台执行,结果稍后可用
- 通知机制:通过回调、事件、信号等方式通知完成
- 时间解耦:请求发起和结果处理在时间上是分离的
- 资源高效:允许在等待期间执行其他有用工作
一句话理解异步: “发起请求,继续干活,完成后通知我。”
信号入门
如何理解操作系统给进程发送的信号呢?我们从生活中的场景入手:
生活场景中的信号
好比如红绿灯就是一个很常见的信号。红绿灯是交通系统给我们发送的信号,当我们看到红灯的时候,我们会停下来,等待红灯过去。当绿灯亮起,我们就会快速通过。
- 我们处理红绿灯信号的方式在我们还没有遇到红绿灯信号的时候,我们就已经明确知道该怎么做了。
那么为什么我们能在红绿灯信号发送给我们之前就知道该如何处理了呢?
- 因为生活中很多信号的处理方式在我们很小的时候,很早的时候,就有人(老师或者父母)教导我们了。相当于在我们脑中设置了很多信号的相应的处理方式。
又或者快递到达的信息也是我们日常很常见的一个信号。当我们接收到快递到达这个信号,我们就一定要马上去拿快递吗?如果我还有更重要的事情,我一定要停下手头的事情马上去拿快递吗?
很明显,并不是这样的。当我们在打游戏或者工作的时候,我们可以选择不立即处理这个信号,我们会选择在脑中记住这个快递到达的信号,等我们处理完手头上的事情,有时间了,我们再去处理这个快递。
-
所以当信号到来的时候,如果我们正在处理更重要的事情,我们能暂时不处理这个到来的信号,我们能先把这个信号进行临时保存。
-
信号到达了,我们可以不立即处理,我们可以在合适的时候处理。
大家再回想一下,你能知道快递什么时候到达吗?很明显,快递送达的时间一般都是无法预测与猜测的。
- 所以,快递到达这个信号是会随时产生的,可能你在拉屎的时候收到,可能在打游戏的收到,可能在上课的时候收到……
- 所以说,我们日常中的信号都是异步发送的。如何理解异步发送呢?日常生活中信号的产生,都是由外界(人或物)产生,外界给我发送信号的时候,不会阻塞等待我们处理完成,并且我们可以在信号发送到达时干别的事情,等我们有时间了再处理,然后再返回结果。
“快递到达”信号的异步处理流程图:
flowchart TDA[主体正常活动<br>(打游戏/工作)] --> B{“快递到达信号<br>(电话铃声)”}B --> C{是否正在处理<br>更重要/不可中断的事?}C -- 否 --> D[立即响应信号<br>接电话,沟通取件]D --> E[处理信号对应任务<br>下楼取快递]E --> F[任务完成<br>快递到手]F -.-> AC -- 是 --> G[信号标记为“未决”<br>心里记下“有快递”]G --> H[继续处理当前关键任务<br>(完成游戏关卡/代码段)]H --> I{关键任务是否完成?}I -- 否 --> HI -- 是 --> J[检查是否有“未决”信号<br>(想起“哦对,有快递要拿”)]J --> K[处理之前延迟的信号<br>下楼取快递]K --> F
操作系统发送给进程的信号
在计算机中,进程 看待/处理 操作系统发送的信号,和我们人类在日常生活中看待/处理外界发送的信号是差不多的:
- 在操作系统还没给进程发送信号之前,进程就已经知道当自己接受到这个信号的时候该如何处理了。
- 进程之所以能做到上一点是因为:
- 每个信号都有内核预设的默认动作(如
SIGTERM
默认终止进程,SIGCHLD
默认忽略)。进程“天生”就知道这些。 - 程序员可以通过
signal()
或sigaction()
提前注册信号处理函数(Handler)。也就是说程序员可以通过代码显式注册自定义函数来告诉进程应该怎么做,覆盖内核给进程预设的默认动作。- 类比现实生活就是,你本来遇到红灯的默认动作是停。但是你脑子中处理红绿灯信号的函数可以被程序员修改,可以变成:遇到红灯的时候往前冲或者往后倒……
- 每个信号都有内核预设的默认动作(如
- 当操作系统给进程发送信号的时候,如果进程正在执行重要的事务,进程可以不立即处理信号,而是先将这个信号临时存储起来。
- 当进程收到信号的时候可以不立即处理,可以等待合适的时机再对信号进行处理。
- 信号是随时产生的,进程无法预测操作系统什么时候会给进程发送信号。进程不会死等操作系统给它发信号,而是自己干自己的,进程需要具备随时响应信号的能力。
发送信号的方法
在 Linux 中,有多个命令可以给进程发送信号。以下是主要的信号发送命令和方法:
1. kill
命令 (最常用)
# 使用信号编号
kill -<signal_number> <pid># 使用信号名称(不带 SIG 前缀)
kill -<signal_name> <pid># 示例:
kill -9 1234 # 发送 SIGKILL (9) 给进程 1234
kill -TERM 5678 # 发送 SIGTERM (15) 给进程 5678
kill -HUP 9012 # 发送 SIGHUP (1) 给进程 9012
**注意:**信号名称不区分大小写(TERM
和 term
等效)
2. killall
命令 (按进程名发送)
killall -<signal> <process_name># 示例:
killall -9 firefox # 强制杀死所有 firefox 进程
killall -HUP nginx # 让所有 nginx 进程重新加载配置
3. pkill
命令 (模式匹配发送信号)
pkill -<signal> <pattern># 示例:
pkill -TERM -f "python script.py" # 杀死匹配命令的进程
pkill -HUP -u username # 给指定用户的所有进程发 SIGHUP
4. 键盘快捷键 (终端中)
快捷键 | 信号 | 作用 |
---|---|---|
Ctrl + C | SIGINT (2) | 中断前台进程 |
Ctrl + \ | SIGQUIT (3) | 退出进程并生成核心转储 |
Ctrl + Z | SIGTSTP (20) | 暂停前台进程 |
5. 特殊命令
-
timeout
- 运行命令并在超时后发送信号:timeout 5s command # 5秒后发送 SIGTERM timeout -s KILL 10s command # 10秒后发送 SIGKILL
-
xkill
(图形界面) - 用鼠标强制关闭无响应窗口(发送 SIGKILL)
6. 编程方式发送
在 C 程序中使用系统调用:
#include <sys/types.h>
#include <signal.h>// 发送 SIGTERM 给进程 1234
kill(1234, SIGTERM); // 发送 SIGUSR1 给进程组
kill(-pgid, SIGUSR1);
常用信号发送示例
目的 | 命令 |
---|---|
优雅终止进程 | kill -15 1234 或 kill 1234 |
强制杀死进程 | kill -9 1234 |
重新加载配置 | kill -HUP 1234 |
暂停进程 | kill -STOP 1234 |
继续暂停的进程 | kill -CONT 1234 |
终止所有同名进程 | killall -9 firefox |
用户所有进程发 SIGHUP | pkill -HUP -u username |
重要提示:
-
权限限制:普通用户只能给自己的进程发信号,root 用户可以给任何进程发信号
-
信号 0:
kill -0 <pid>
不发送实际信号,只检查进程是否存在 -
进程组:
kill -- -<pgid>
给整个进程组发信号(注意--
和-
)kill -TERM -- -1234 # 给进程组 1234 的所有进程发 SIGTERM
常见信号
我们在现实生活中,从小就被父母老师教导,生活中有哪些常见的信号(比如红绿灯),从小就被教导一些常见的信号的处理方式。
那么在我们来到计算机世界的时候,我们也了解一下,操作系统常会给进程发送什么信号,以及这些信号默认的处理方式是什么?
信号类别&&引出标准信号
在Linux系统中,我们可以使用kill -l
这个指令来获取常见信号有哪些:
这些指令当中,我们并不需要了解这么多,我们需要了解的信号并不多:
我们只需要理解图中红色框框圈起来的信号就ok,甚至也不需要了解全,我们只需要知道就ok,就好像我们日常常见的信号其实没多少,操作系统中也是。
图中的数字就是信号的编号,编号旁边的大写字母组成的单词就是信号名称,这些个信号名称其实就是一个个内核中定义的宏。无论是信号编号,还是信号名称都可以唯一标识一个信号,你发信号的时候可以发送信号名称或者使用信号编号都可以。
那么那些没有被红色框框圈起来的信号是什么呢?也就是从34号开始到64号的信号。我给大家简单说一嘴:
这些信号是实时信号,**实时信号在特定的高性能、高可靠性应用场景中非常关键,但在绝大多数普通应用程序中,确实用得不多。**我们就不过多介绍了。
如果大家从1号信号开始往后看,就会发现,为什么没有0,32,33号信号的?
0,32,33号信号
a) 0号信号:NULL
信号
0号信号不是一个真正的信号。 它被称为“空信号”(Null Signal)。它没有对应的信号名称,也不会被递送给进程触发任何处理动作。
它的主要作用是用于“检查”:
当你使用 kill(pid, 0)
时,系统并不是要杀死进程,而是检查目标进程(pid)是否存在,以及当前用户是否有权限向它发送信号。
- 如果进程存在且有权限,
kill(pid, 0)
调用成功(返回0)。 - 如果进程不存在或无权限,调用失败(返回-1,并设置
errno
)。
所以,kill -l
自然就不会列出这个用于“探测”而非“通信”的0号信号。
b) 32号和33号信号
在Linux的glibc实现中,SIGCANCEL
(32) 和 SIGSETXID
(33) 这两个信号是存在的,但它们被glibc(GNU C库)内部使用。
SIGCANCEL
(32): 主要用于实现POSIX线程的取消功能。SIGSETXID
(33): 用于在进程改变其UID/GID(用户ID/组ID)时,同步所有线程的凭证。
为什么 kill -l
不显示它们?
因为这两个信号是库实现细节,并非给应用程序员直接使用的。它们被glibc“保留”用于内部线程同步机制。kill
命令的设计是显示给应用程序开发者使用的信号列表,因此隐藏了这些底层实现细节。
总结一下编号规律:
信号编号 | 类别 | 说明 |
---|---|---|
0 | 特殊信号 | 空信号,用于检查进程是否存在。 |
1 - 31 | 标准信号 | 早期Unix定义的不可靠信号,每个有特定语义。 |
32, 33 | 内部信号 | 被glibc/NPTL线程库保留内部使用,不对外暴露。 |
34 - 64 | 实时信号 | 可靠的、可排队的、可携带数据的现代信号。 |
本文主要是讨论1~31号标准信号。
既然知道了常见的信号有哪些,那么我们也得知道当进程接收到这些常见的信号的时候默认是如何处理的,内核给进程预设了什么的处理方法。
标准信号的默认处理方式
这些信号是 Unix/Linux 系统中经典且最常用的信号,每个都有其特定的用途。
信号编号 | 信号名称 | 默认动作 | 说明 |
---|---|---|---|
1 | SIGHUP | Term | 挂起。当控制终端关闭时发送给进程。也常用于通知守护进程重新读取配置文件。 |
2 | SIGINT | Term | 中断。当用户从键盘输入中断字符(通常是 Ctrl+C )时发送给前台进程组。 |
3 | SIGQUIT | Core | 退出。当用户从键盘输入退出字符(通常是 Ctrl+\ )时发送。会生成核心转储文件。 |
4 | SIGILL | Core | 非法指令。进程试图执行非法、格式错误或未知的指令。 |
5 | SIGTRAP | Core | 跟踪/断点陷阱。由调试器用于在断点处中断进程的执行。 |
6 | SIGABRT | Core | 中止。通常由 abort() 函数发出,表示程序检测到错误并主动终止。 |
7 | SIGBUS | Core | 总线错误。无效的内存访问(如地址对齐错误)。 |
8 | SIGFPE | Core | 浮点异常。发生算术运算错误,如除以零、溢出等。 |
9 | SIGKILL | Term | 杀死。不可捕获、阻塞或忽略的信号,用于立即强制终止进程。 |
10 | SIGUSR1 | Term | 用户定义信号 1。留给用户程序自定义用途。 |
11 | SIGSEGV | Core | 段错误。无效的内存引用(访问不属于自己的内存)。最常见的导致程序崩溃的信号。 |
12 | SIGUSR2 | Term | 用户定义信号 2。留给用户程序自定义用途。 |
13 | SIGPIPE | Term | 管道破裂。向一个没有读端的管道(或套接字)进行写入操作。 |
14 | SIGALRM | Term | 闹钟信号。由 alarm() 或 setitimer() 设置的定时器超时后发出。 |
15 | SIGTERM | Term | 终止。这是 kill 命令默认发送的信号,是一种要求进程优雅终止的请求。 |
16 | SIGSTKFLT | Term | 协处理器栈错误。早期用于数学协处理器错误,现在在大多数架构上已不再使用。 |
17 | SIGCHLD | Ign | 子进程状态改变。当子进程停止、终止或恢复时,内核会向父进程发送此信号。 |
18 | SIGCONT | Cont | 继续。让一个已停止的进程继续运行。此信号不可忽略或阻塞。 |
19 | SIGSTOP | Stop | 停止。不可捕获、阻塞或忽略的信号,用于暂停进程的执行。 |
20 | SIGTSTP | Stop | 终端停止。当用户从键盘输入停止字符(通常是 Ctrl+Z )时发送给前台进程组。 |
21 | SIGTTIN | Stop | 后台进程读终端。当一个后台进程试图从控制终端读取数据时发出。 |
22 | SIGTTOU | Stop | 后台进程写终端。当一个后台进程试图向控制终端写入数据时发出。 |
23 | SIGURG | Ign | 紧急数据。当套接字上收到带外数据或紧急数据时发出。 |
24 | SIGXCPU | Core | 超出CPU时间限制。进程消耗的CPU时间超过了其软限制。 |
25 | SIGXFSZ | Core | 超出文件大小限制。进程试图将文件扩展至超过其软限制。 |
26 | SIGVTALRM | Term | 虚拟定时器报警。由 setitimer() 设置的虚拟时间(进程使用的CPU时间)定时器超时。 |
27 | SIGPROF | Term | 性能分析定时器报警。由 setitimer() 设置的性能分析定时器(CPU时间+系统调用时间)超时。 |
28 | SIGWINCH | Ign | 窗口大小改变。当终端窗口大小发生变化时发出。 |
29 | SIGIO | Term | 异步 I/O 事件。表示一个异步 I/O 事件已完成(文件描述符可读/可写)。 |
30 | SIGPWR | Term | 电源故障。与不间断电源系统相关,表示系统电量不足。 |
31 | SIGSYS | Core | 系统调用错误。进程执行了一个无效的系统调用。 |
默认动作说明:
- Term:终止进程
- Core:终止进程并生成核心转储文件
- Ign:忽略信号
- Stop:停止(暂停)进程
- Cont:继续已停止的进程
- 转储文件的问题在下文有讲解,具体问题那里聊
- SIGSTKFLT (16):“在现代Linux系统中很少使用”
- SIGPWR (30):“在一些系统中可能被用于其他用途”
- SIGSYS (31):可以明确"通常由于错误的系统调用参数或seccomp过滤器触发"
这个表格是学习和使用 Linux 信号的重要参考。其中最常用的几个信号是 SIGINT (2)、SIGKILL (9)、SIGTERM (15) 和 SIGSEGV (11)。
我们可以使用这个指令:man 7 signal
查看这些标准信号的默认处理方式。
这会打开信号手册页,其中包含:
- 所有标准信号的完整列表
- 每个信号的默认行为说明
- 信号的来源和用途
- 信号处理的相关概念
在手册页中,信号的默认行为通常用以下缩写表示:
- Term:终止进程
- Ign:忽略信号
- Core:终止进程并生成核心转储(core dump)
- Stop:停止进程
- Cont:继续已停止的进程
可以发现,这些标准信号中大部分的默认处理行为都是终止进程。其中少量信号的默认动作是停止,以及继续停止的进程。
这些其实我们都可以比较容易的理解,不过在这些默认行为中还有两个行为,大家可能比较疑惑:一个是忽略信号,一个是Core
信号。
忽略行为
Signal Standard Action Comment
SIGCHLD P1990 Ign Child stopped or terminated
1. 忽略是信号的处理方式之一
我们先来说一下这个忽略信号。我们以SIGCHLD
这个信号为例,当进程接收到操作系统发送的SIGCHLD
信号时,进程的默认动作是忽略这个信号,不予理睬。也就是说忽略是处理信号的一个方式。(进程不会对这个信号回复、也不会存储这个信号)
类比现实生活中就像是:有一个人给你说了一句话,这句话就是这个SIGCHLD
信号。你对这个信号的处理方式其中就有忽略掉这个人说的话,不予理睬。
2. 信号阻塞是进程处理信号之前的临时状态
这里我重点提出这个忽略的行为,主要是因为 忽略 这个处理信号的方式比较容易和另一个概念的现象混淆。
这个概念是:当我们接收到信号,但是我们并没有对这个信号立即做出处理,而是将信号临时存储起来。当我们后续处理完重要任务后才对信号做出处理。
上述的概念可以总结为两个词:信号阻塞和未决信号。信号阻塞也就是我们并没有立即处理信号,而是先将信号存储起来的这个行为。未决信号则是这些被阻塞的信号的一个统称:没有被进程立即处理,暂时存储起来的信号。
3. 表面相似性 ≠ 本质相同
当进程没有立即处理信号的时候,进程表面现象也像是进程忽略了这个信号。因为进程表面看来就是没做任何处理,看起来就像是忽略了这个信号一样。
但是信号阻塞和忽略信号本质是完全不一样的。忽略信号是进程对信号的一种处理方式。而信号阻塞虽然在表层看着像是进程忽略一个信号,实际上是进程没有立即处理这个信号,而是将这个信号暂时存储起来,后续再处理。
信号阻塞大家可以暂时理解为进程处理信号前的一种中间状态。
所以这里的最终目标是告诉大家:
忽略是一种进程处理信号方式。(非常简单的类比就是现实生活中,你给某个异性表白,但是那个异性对你发送的表白信号不予理睬,这其实就是一种处理信号的方式,人家已经给你表态了)
信号阻塞是进程处理信号前的一种中间状态。(类比现实生活就是,你接到快递电话,你不鸟他,而是默默记住这个信号–“快递到了”,有时间了你再去拿。你看起来就是不鸟这个信号,但是实际上,你已经记住了这个信号,稍后处理而已。)
阻塞期间信号未被处理,看似像忽略,但内核已记录该信号(未决信号),后续必须处理。而忽略是彻底丢弃。
我们不能把信号阻塞的表层现象和忽略信号这个处理方式的表层现象混为一谈。
4. 总结
特征 | 忽略 (Ignoring) | 阻塞 (Blocking) |
---|---|---|
本质 | 信号的处理方式 | 信号的延迟递送机制 |
内核行为 | 直接丢弃信号 | 将信号加入未决队列 |
是否可恢复 | 否(信号彻底消失) | 是(解除阻塞后重新递送) |
生活类比 | 对表白彻底无视 | 会议中暂存快递,会后处理 |
结果确定性 | 信号永远不被处理 | 信号延迟但必然被处理 |
终止进程 + 生成核心转储
另一个就是Core
信号,默认处理方式是:终止进程并生成核心转储(core dump),这个是啥意思呢?
我们以8号信号SIGFPE
来举例。
SIGFPE P1990 Core Floating-point exception
8号信号是在程序出现浮点异常。也就是发生算术运算错误,如除以零、溢出等异常的时候,操作系统内核会给该进程发送8号信号,告诉当前进程你需要终止进程,并且生成核心转储文件。
我们写了一个简单程序:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>int main()
{int a = 7;int b = 0;pid_t pid = getpid();printf("this process pid is : %d\n",pid);int c = a/b;// 这里我故意写错的// 就是验证除零错误printf("c is :%d\n" , c);return 0;}
观察现象:
程序运行起来后,我们看看会有什么现象:
看起来感觉好像也没啥特别的。不是说进程接收到8号信号的时候,默认行为是终止进程并生成核心转储吗?终止进程我们是看到了,但是好像没有看到什么核心转储昂。
这里我就直接说了,Linux系统默认是关闭了这个生成核心转储的功能的,怎么看呢?我们可以使用这个指令:ulimit -a
来看。
在 Linux 系统中,ulimit -a
命令的作用是显示当前 shell 环境下所有资源限制的当前设置。
这些资源限制用于控制当前用户的 shell 及其子进程所能使用的系统资源(如文件描述符数量、内存大小、进程数量等),防止单个进程过度消耗资源影响系统稳定性。
ulimit -a
输出的内容通常包括以下常见限制项(不同系统可能略有差异):
core file size
:核心转储文件的最大大小(用于程序崩溃时记录内存状态)。data seg size
:进程数据段的最大大小。file size
:单个文件的最大大小。open files
:进程可同时打开的最大文件描述符数量(重要,常涉及 “too many open files” 错误)。max user processes
:当前用户可创建的最大进程 / 线程数量。stack size
:进程栈的最大大小。cpu time
:进程可使用的最大 CPU 时间。virtual memory
:进程可使用的最大虚拟内存大小。
这里我们只讨论core file size
。(我们也可以使用指令ulimit -c
来查看系统对core file
文件大小的限制,大家使用这个指令试出来的结果大概率都是0
。)
我们从图中我们能很明显看到:
core file size (blocks, -c) 0
这里表面的意思是:核心转储文件的最大大小是0
。
文件的最大大小是0,那么本质上不就是表明了:我不允许你生成核心转储文件 嘛。
解开限制:
我们可以使用一下指令来解除系统对转储文件的大小限制:
# 临时开启(当前会话有效)
ulimit -c unlimited # 无大小限制
# 或
ulimit -c 1024 # 限制为 1024KB# 永久生效(写入配置文件)
echo "ulimit -c unlimited" >> ~/.bashrc
source ~/.bashrc
这里我们使用ulimit -c unlimited
来试试水:
能看见,系统已经解除了对转储文件的大小限制。现在我们重新运行程序来观察一下,会有什么新现象:
再次观察现象:
从结果我们能发现,目录中多了一个core文件,这个core文件其实就是核心转储文件。那么核心转储文件到底是什么呢?
什么是核心转储文件?
核心转储文件(通常命名为 core
或 core.<pid>
)是进程崩溃时的完整内存快照。它记录了进程终止瞬间的:
- 内存状态:堆、栈、全局变量等所有数据
- 寄存器值:CPU 寄存器状态(如指令指针 RIP)
- 程序计数器:崩溃时执行的代码位置
- 加载的共享库:动态链接库信息
- 线程信息:所有线程的调用栈
💡 它相当于进程的“死亡现场照片”,保存了崩溃时的完整证据链。
核心转储的作用
1. 精准定位崩溃原因
gdb ./your_program core.1234 # 用GDB分析core文件
执行后你会看到:
Core was generated by `./your_program`.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x0000555555555169 in main () at test.c:15
15 int c = a/b; // 除零错误!
直接定位到崩溃的代码行(如第15行的除零错误)。
**注意:**这里大家使用GDB分析core文件的时候,结果可能并不和我说的一样,而是这样的:(并没有具体的信息)
Core was generated by `./test'.
Program terminated with signal SIGFPE, Arithmetic exception.
#0 0x000055b393b291a5 in main ()
这是为什么呢?大概率是大家编译程序的时候,没有包含调试符号(即没有使用 -g
选项)。
大家应该使用这个指令来编译:gcc -o test test.c -g
(使用 -g 选项添加调试信息)。这样大家在分析core文件的时候就可以发现具体的错误信息,以及具体的错误位置了。
2. 查看崩溃时的变量值
在 GDB 中:
(gdb) print a
$1 = 7
(gdb) print b
$2 = 0 # 发现除数为0!
3. 分析函数调用栈
(gdb) bt # 查看调用栈
#0 0x555555555169 in main () at test.c:15
#1 0x7ffff7e1d082 in __libc_start_main () from /lib/x86_64-linux-gnu/libc.so.6
显示从程序启动到崩溃的完整调用路径。
4. 调试难以复现的崩溃
对随机发生或线上环境的崩溃,core 文件是唯一的现场证据。
为什么需要它?
场景 | 无 core 文件 | 有 core 文件 |
---|---|---|
崩溃定位 | 靠日志猜测可能位置 | 精准定位到崩溃代码行 |
复现概率 | 偶发崩溃无法复现则永远无法修复 | 无需复现,直接分析现场 |
内存问题 | 无法诊断内存泄漏/越界 | 查看崩溃时的堆内存状态 |
多线程问题 | 几乎无法调试竞争条件 | 检查所有线程的栈帧和锁状态 |
生成条件
-
信号触发:进程因
SIGSEGV
(段错误)、SIGABRT
(断言失败)、SIGQUIT
(Ctrl+\)等信号终止 -
系统配置开启:
ulimit -c unlimited # 解除大小限制 sysctl kernel.core_pattern=/tmp/core-%e-%p # 自定义保存路径
-
文件系统权限:进程有权限写入目标目录
实际应用场景
- 线上服务崩溃:自动保存 core 文件,运维人员下载后用 GDB 分析
- 测试环境压力测试:内存泄漏检测(
Valgrind
+ core 文件) - 安全分析:诊断是否遭受缓冲区溢出攻击
🔍 经典案例:2021年某云服务商数据库崩溃,通过分析 core 文件发现是内存页损坏,最终定位到硬件故障。
总结
核心转储是程序崩溃的“黑匣子”:
- 🛠️ 开发者之眼:无需复现问题,直击崩溃现场
- 🔍 调试利器:解决内存破坏、并发竞争等疑难杂症
- 🛡️ 运维保险:线上事故的终极取证工具
开启核心转储(ulimit -c unlimited
),相当于给程序装上事故记录仪!
为什么Linux默认关闭生成核心转储这个功能呢?
Linux 默认关闭核心转储(core dump)功能,主要基于以下五大核心考量,体现了系统设计的平衡智慧:
一、安全风险:敏感数据泄露
- 核心问题:核心转储包含进程的完整内存镜像
- 风险场景:
- 数据库进程崩溃 → 可能泄露用户密码、信用卡号
- 加密服务崩溃 → 可能暴露私钥
- Web服务器崩溃 → 可能包含会话Cookie、用户数据
- 案例:2019年某银行系统因未限制core dump,导致内存中的客户交易记录被写入未加密的core文件
二、磁盘空间:防止系统瘫痪
-
典型core文件大小:
进程类型 内存占用 core文件大小 小型命令行工具 10MB 10MB Nginx服务器 500MB 500MB Java应用 4GB 4GB
我们从这个图中就能看出core文件的大小的恐怖了,就我们这么一个简单的程序,一条简单的除零错误,就花费了380928字节,换算下来就是372KB了。如果再大型一点的程序或者多一两条复杂的错误,core文件的大小就很难想象了。
-
雪崩效应:
# 模拟频繁崩溃的服务 while true; do ./buggy_service; done
- 1小时崩溃20次 × 4GB/次 = 80GB磁盘占用
- 系统关键服务(如日志、数据库)因此瘫痪
在现代较新的 Linux 内核版本中,默认生成的核心转储文件通常命名为
core
无后缀。当进程崩溃时,新生成的 core 文件会直接覆盖旧文件,避免磁盘空间累积。这是通过内核参数kernel.core_pattern
的默认配置实现的。而在早期 Linux 版本中,核心转储文件默认采用
core.<pid>
格式(如core.1234
)。由于每次进程启动后崩溃都会生成带不同 PID 后缀的新文件,多次崩溃会导致大量 core 文件堆积。对于频繁崩溃的服务(如内存泄漏场景),这些大体积文件可能快速耗尽磁盘空间,最终引发系统瘫痪。这种设计差异反映了 Linux 的演进:
- 旧版本:牺牲磁盘空间换取调试便利(保留历史记录)
- 新版本:优先保障系统稳定性(覆盖写入)
- 现代方案:通过
systemd-coredump
实现智能管理(压缩存储 + 自动清理)
三、性能损耗:关键服务的稳定性
-
生成core时的进程冻结:
// 内核生成core的伪代码 freeze_process(); // 暂停所有线程 dump_memory_to_disk(); // 同步写磁盘(耗时!)
-
实际影响:
- 8GB内存进程 → 写盘时间可达10-30秒
- 期间服务完全不可用(违背高可用原则)
-
生产环境教训:某交易所系统因core dump导致交易中断被罚$200万
四、隐私合规:法律红线
-
法规要求:
法规名称 相关条款 GDPR(欧盟) 第32条 - 数据处理安全 HIPAA(美国医疗) §164.312 - 数据保护 PCI DSS(支付卡) 要求3.2 - 敏感数据存储 -
默认关闭是避免企业无意中违反合规要求的第一道防线
五、用户体验:避免普通用户困惑
-
典型用户场景:
$ libreoffice 段错误 (核心已转储) # 非技术用户看到恐慌
-
遗留文件:
- 桌面系统常见
~/core
文件 - 用户误删重要文档后试图用文本编辑器打开core文件
- 大量无效的bug报告充斥开源社区
- 桌面系统常见
设计哲学:安全默认原则 (Secure by Default)
Linux 遵循的安全范式:
默认状态:最小权限 + 最小暴露面↑用户明确需求时↓按需开启(ulimit -c unlimited)
企业级解决方案
对必须开启的场景,Linux 提供安全管控:
# 1. 限制core文件大小
ulimit -c 1000000 # 最大1GB# 2. 加密存储
sysctl kernel.core_pattern="|/usr/sbin/core_encrypt -key mykey > /var/core/%e.%p"# 3. 敏感信息过滤
sysctl kernel.core_pattern="|/usr/bin/strip_secrets > /cores/%e.%p"# 4. 容器环境专用路径
sysctl kernel.core_pattern="/containers/%h/%c/core"
总结:默认关闭的深层逻辑
考量维度 | 用户类型 | 默认策略 | 专业建议 |
---|---|---|---|
安全 | 所有用户 | 关闭 | 生产环境保持关闭 |
稳定性 | 服务器管理员 | 关闭 | 关键服务禁用 |
调试需求 | 开发者 | 按需开启 | 开发环境开启 + 加密 |
合规 | 企业部署 | 关闭 | 审计core文件访问权限 |
Linux 的选择本质是:宁可牺牲事后调试的便利性,也要确保系统默认的安全与稳定。这种设计使得全球90%的服务器能无干预稳定运行数年,而开发者仍能在需要时通过一行命令获得完整调试能力。
在上文,我们讨论了:如何给进程发送信号,操作系统中进程间通信常用的信号有哪些,当进程接收到信号的时候的默认行为都有哪些。
大家还记得嘛,我在前面讲解信号的时候,我提到过,在计算机中,我们可以自定义进程处理信号的方式,也就是我们可以更改进程对一个信号的认知。
就好比如,当进程接收到2号信号SIGINT
的时候,进程处理这个信号的默认行为是中断进程,也就是退出进程。但是现在,我们可以更改这个进程处理2号信号行为,让进程去干一些别的事情。
接下来,让我们来了解一下自定义信号处理(信号捕捉):
自定义信号处理方式(信号捕捉)
在讲解这部分内容之前,我先把信号捕捉相关的函数提供给大家:链接:Linux系统–进程间通信–信号–核心操作函数-CSDN博客
对使用的函数有疑问的,或者想详细了解使用的函数的,可以看这个链接。
这里我给大家补充一下:signal()
函数以及sigaction()
函数,是用来让我们自定义进程处理信号的方式的。在代码执行中,只有进程接收到了信号,我们使用signal()
函数或者sigaction()
函数自定义的信号处理方式才会被执行。
如果没有进程没有接收到信号,那么我们自定义的信号处理函数,它就不会被执行。这个应该很好理解。
总的来说就说,只有当进程接收到信号的时候,进程才会根据你写的自定义处理信号的函数来处理信号。如果没有接收到信号,进程就不会做任何处理嘛。
我们直接使用例子来给大家讲解:(为了方便,我主要使用signal
函数)
例子:(通过这个例子我们也可以验证,当我们使用快捷键ctrl+c
的时候,其实就是向当前运行中的进程发送2号信号)
#include <stdio.h>
#include <signal.h>
#include <unistd.h>void sigint_handler(int sig) {// 向标准输出(默认是屏幕)写入Caught SIGINT!,实际上就是向屏幕写入Caught SIGINT!// 起到类似printf打印的效果。write(STDOUT_FILENO, "Caught SIGINT!\n", 15);
}int main() {// 设置 SIGINT 处理函数if (signal(SIGINT, sigint_handler) == SIG_ERR) {perror("signal");return 1;}printf("Press Ctrl+C to test...\n");pause(); // 等待信号return 0;
}
在 C 语言中,pause()
函数的主要作用是让当前进程进入阻塞(暂停)状态,直到接收到一个可以被捕获的信号(signal)。
当进程接收到信号并处理完后,pause函数就会返回,在本进程中,pause函数返回后,进程就会退出。
我们运行程序可以看到结果是:
我们也可以将程序写为这样:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include<sys/types.h>void sigint_handler(int sig) {// 向标准输出(默认是屏幕)写入Caught SIGINT!,实际上就是向屏幕写入Caught SIGINT!// 起到类似printf打印的效果。write(STDOUT_FILENO, "Caught SIGINT!\n", 15);
}int main() {// 设置 SIGINT 处理函数if (signal(SIGINT, sigint_handler) == SIG_ERR) {perror("signal");return 1;}// 因为我们给程序设置了死循环,而且我们更改了ctrl+c,也就是2号信号的默认处理方式// 所以我们无法使用ctrl+c终止进程了,所以得获取一下当前进程的PID,用别的信号来终止这个进程pid_t pid = getpid();printf("当前进程的PID是: %d\n", pid);printf("Press Ctrl+C to test...\n");while(1) sleep(1);return 0;
}
这个效果就很明显了。
如果大家想保留2号信号退出进程的功能,我们可以这样写:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include<sys/types.h>
#include<stdlib.h>void sigint_handler(int sig) {// 向标准输出(默认是屏幕)写入Caught SIGINT!,实际上就是向屏幕写入Caught SIGINT!// 起到类似printf打印的效果。write(STDOUT_FILENO, "Caught SIGINT!\n", 15);// 执行完write后,就退出进程exit(1);
}int main() {// 设置 SIGINT 处理函数if (signal(SIGINT, sigint_handler) == SIG_ERR) {perror("signal");return 1;}// 因为我们给程序设置了死循环,而且我们更改了ctrl+c,也就是2号信号的默认处理方式// 所以我们无法使用ctrl+c终止进程了,所以得获取一下当前进程的PID,用别的信号来终止这个进程pid_t pid = getpid();printf("当前进程的PID是: %d\n", pid);printf("Press Ctrl+C to test...\n");while(1) sleep(1);return 0;
}
如果大家想进程接收到2号信号的处理方式是忽略这个信号,我们可以这样写:
int main() {// 忽略 SIGINT (Ctrl+C 将无效)if (signal(SIGINT, SIG_IGN) == SIG_ERR) {perror("signal");return 1;}printf("Try pressing Ctrl+C (it will be ignored)...\n");pause();return 0;
}
由于这个代码,我们没有设置死循环,所以,在进程接收到信号后,pause函数返回,进程就会退出了。
我们可以写成这样:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include<sys/types.h>int main() {// 忽略 SIGINT (Ctrl+C 将无效)if (signal(SIGINT, SIG_IGN) == SIG_ERR) {perror("signal");return 1;}// 因为我们给程序设置了死循环,而且我们更改了ctrl+c,也就是2号信号的默认处理方式// 所以我们无法使用ctrl+c终止进程了,所以得获取一下当前进程的PID,用别的信号来终止这个进程pid_t pid = getpid();printf("当前进程的PID是: %d\n", pid);printf("Try pressing Ctrl+C (it will be ignored)...\n");while(1) sleep(1);return 0;
}
通过上面的例子其实大家就能很直观的看到,我们确实是可以修改进程处理信号的方式的。后续还会有很多例子(下一篇文章),这里我们就不再过多的给大家列举了。
大家可以自行尝试自定义进程处理信号的方式,不过我建议大家在尝试之前看看我给大家的链接中的内容,尤其是异步安全部分。
自定义进程对信号的处理方式有一个大家需要注意的就是:
SIGKILL和SIGSTOP这两个信号,我们不能自定义进程对于它两的处理方式:
为什么 SIGKILL
和 SIGSTOP
不能被捕获、阻塞或忽略?
这两个信号在 Linux/Unix 系统中具有 最高优先级的管理权限,其不可拦截的特性源于操作系统设计的核心原则:
一、设计本质:操作系统的终极控制权
操作系统必须保留对进程的 绝对控制能力,这是系统稳定性和安全性的基石:
SIGKILL
(信号 9):强制终止进程(kill -9
)SIGSTOP
(信号 19):强制暂停进程(Ctrl+Z
实际发送SIGTSTP
,而非SIGSTOP
)
它们的不可拦截性确保了:
-
内核永远能终止失控进程
即使进程陷入死循环、死锁或恶意占用资源,管理员仍可通过
SIGKILL
清除它。 -
调试器永远能冻结进程状态
调试工具(如 GDB)依赖
SIGSTOP
暂停进程以检查内存和寄存器。
二、技术实现:内核的硬编码规则
1. 信号处理表屏蔽
在 Linux 内核中,所有信号的处理方式记录在进程的 struct sighand_struct
中:
// 内核源码 (include/linux/sched.h)
struct sighand_struct {atomic_t count;struct k_sigaction action[_NSIG]; // 信号处理函数表spinlock_t siglock;
};
但 SIGKILL
和 SIGSTOP
的处理函数在内核中被 强制锁定:
-
任何尝试修改其处理方式的操作(如
signal()
或sigaction()
)都将失败:// 内核检查逻辑 (kernel/signal.c) if (sig == SIGKILL || sig == SIGSTOP)return -EINVAL; // 返回无效参数错误
2. 信号屏蔽字例外
进程可通过 sigprocmask()
阻塞信号(如 SIGINT
),但 SIGKILL/SIGSTOP
不受影响:
// 信号屏蔽字 (sigset_t) 无法包含 SIGKILL/SIGSTOP
sigset_t block_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGKILL); // 此操作无效!
sigprocmask(SIG_BLOCK, &block_set, NULL); // SIGKILL 仍能杀死进程
三、原理剖析:为什么必须不可拦截?
场景 1:终止失控进程
假设允许捕获 SIGKILL
:
// 恶意进程代码
void handle_kill(int sig) {printf("Ha! You can't kill me!\n");
}
signal(SIGKILL, handle_kill); // 若允许则进程无法被终止
结果:系统失去对恶意进程的控制权,最终资源耗尽崩溃。
场景 2:调试器工作保障
若允许忽略 SIGSTOP
:
// 被调试的进程
signal(SIGSTOP, SIG_IGN); // 若允许则调试器无法暂停进程
结果:调试器无法冻结进程状态,导致无法调试。
四、与可拦截信号的对比
信号类型 | 示例 | 是否可捕获 | 是否可阻塞 | 是否可忽略 | 设计目的 |
---|---|---|---|---|---|
管理信号 | SIGKILL, SIGSTOP | ❌ | ❌ | ❌ | 内核保留的终极控制手段 |
可处理信号 | SIGINT, SIGTERM | ✔️ | ✔️ | ✔️ | 允许程序优雅退出或自定义处理 |
不可忽略信号 | SIGSEGV, SIGILL | ✔️ | ✔️ | ❌ | 强制处理硬件错误 |
💡 注:
SIGTERM
(信号 15) 可被捕获,是程序实现优雅退出的推荐方式。
五、底层机制:内核的强制执行
当内核发送 SIGKILL
或 SIGSTOP
时:
-
绕过用户态处理程序
直接触发内核预定义行为,不查询进程的信号处理表。
-
无视信号屏蔽字
即使进程阻塞所有信号(
sigfillset()
+sigprocmask()
),这两个信号仍能送达。 -
立即生效
无延迟、无排队(实时信号特性)。
六、典型应用场景
1. SIGKILL
的使用
# 尝试优雅终止进程(发送 SIGTERM)
$ kill 1234# 若进程未响应,强制终止(发送 SIGKILL)
$ kill -9 1234
2. SIGSTOP
的使用
# 暂停进程(调试器内部使用)
$ kill -SIGSTOP 1234# 继续运行进程
$ kill -SIGCONT 1234
总结:操作系统设计的底线
SIGKILL
和 SIGSTOP
的不可拦截性体现了操作系统的 安全边界设计:
- 对用户程序:提供灵活的信号处理机制(如自定义
SIGINT
处理) - 对内核自身:保留最后的控制手段,防止系统陷入不可控状态
这种设计确保了 Linux 系统即使在最恶劣的情况下(如进程死锁或恶意攻击),管理员仍能通过 SIGKILL
回收资源,通过 SIGSTOP
诊断问题,维护系统的整体稳定性。