036 进程信号 —— 信号的产生

036 进程信号 —— 信号的产生
小米里的大麦进程信号 —— 信号的产生
首先,本节的信号和上一节的信号量没有任何关系!它们的关系就像老婆饼和老婆,没有任何关系!后面的内容主要是根据 信号的产生 → 信号的保存 → 信号的处理 来进行讲解。
1. 信号的概念
1. 生活中的信号
生活中常见的信号,比如:
- 闹钟:闹钟响 = 通知你该起床了。
- 红绿灯:红灯亮 = 告诉你该停下来了。
- 电话:响铃 = 通知你有人呼叫你,需要你接听。
- 警报器:火警 = 紧急中断,要求人立刻撤离。
可见,信号本质就是“通知 + 响应”,核心思想是:不需要你一直盯着,只要有事就异步提醒你。
2. 信号的定义与理解
信号是一种 异步通知机制。用来 通知进程 发生了某种 异步事件。本质:操作系统向一个进程发送一个整数编号(信号编号),告诉它“发生了某件事”,要求它“采取行动”或“做出响应”。
Q:你怎么认识这些信号?
A:有人教我 → 我记住了。认识:① 识别信号 ② 知道信号的处理方法。即便现在没有信号产生,我们也知道信号产生后该干什么。那么我们是谁?站在 OS 层面,我们自然是进程啦~
一个进程必须具备识别和处理信号的能力,这种能力是其自身功能的一部分,即使在没有收到任何信号的情况下,也应事先明确知道每种信号该如何处理。当某个信号实际产生时,进程可能不会立即响应,而是在合适的时机进行处理。因此,在信号产生与信号被真正处理之间,必然存在一个时间窗口。为了保证信号不会丢失,进程需要具备临时记录哪些信号已经发生的能力。
3. 信号的处理方式(3 种)
信号产生了,我们可能并不立即处理这个信号,在合适的时候,因为我们可能正在做更重要的事情,所以,信号产生后会被保存到当前的时间窗口,等待信号处理。可以类比为生活中的“来电提醒”:你正在开会(执行重要任务),手机静音(屏蔽某些信号),有人打电话给你(信号产生),但你不接(不立即处理),手机记录未接来电(未决信号集),会议结束后,你查看未接来电并回电(处理信号)。
每个信号都有默认动作,但你可以选择以下 三种方式 之一来处理它:
处理方式 | 含义 | 举例 |
---|---|---|
默认动作 | 使用系统定义的默认行为 | 红灯亮,人们 默认动作 是等待绿灯 |
忽略 | 显式地忽略该信号(就像没发生一样),但某些信号无法忽略(如 SIGKILL , SIGSTOP ) |
红灯亮,不妨存在闯红灯的人,他们完全 忽视 了红灯的存在 |
自定义动作 | 注册一个信号处理函数(signal handler),当信号到达时调用这个函数处理 | 红灯亮,你可能会看到“大小姐驾到统统闪开”“随我出发”这些社牛人群的 自定义动作 |
4. 深入理解 ctrl + c
认识“作业”(Job)和前后台进程
作业(Job) 这个词听起来抽象,其实就是:我们在一个 shell 会话里启动的一个程序或命令运行单元。关键点:
- 在 bash 里,每个“作业”可以是 一个进程,也可以是 一组进程(进程组)。
- bash 会帮我们记录:当前有多少个作业?哪个是前台作业?哪些是后台作业?
名称 解释 前台作业 当前直接和终端(键盘输入、输出)交互的那组进程 后台作业 在终端后面跑,不占用终端输入,不能直接交互的那组进程 前台进程组 前台作业内部的那组进程,组成一个进程组,shell 管它 后台进程组 后台作业内部的那组进程
比较点 前台作业 后台作业 是否抢占终端输入 是(独占) 否(输入留给 shell) 是否能直接交互 可以 不能 能否直接接收 Ctrl+C 可以 不会收到 能否被 shell 追踪 会被追踪 会被追踪
- “前台进程” ≈ “前台作业对应的进程(或进程组)”
- “后台进程” ≈ “后台作业对应的进程(或进程组)”
因为:bash 管理的单位是 作业(Job),把前后台“作业”映射成前后台“进程组”。但底层作业就是由一个或多个 进程(进程组)组成,所以大家常常口头直接说“前台进程 / 后台进程”,指的其实就是“前台作业 / 后台作业里的进程”。我们只需要记住:“前台作业 / 前台进程 / 前台进程组” 在作业控制场景下是一个概念的不同层面,本质指的就是:当前跟终端交互、能直接被 Ctrl+C 打断的那一组进程。
相关命令:
1
2
3
4
5 >sleep 100 & # 后台
>jobs # 查看
>fg %1 # 拉回前台
>Ctrl+Z # 挂起
>bg %1 # 后台继续
1. Ctrl+C 为什么能杀掉前台进程?
Ctrl+C 的本质:当我们在终端(如 bash)按下 Ctrl+C,它会向 前台进程组 发送一个 SIGINT 信号。SIGINT 是信号编号 2,所以也叫 2 号信号。
谁发的?
- 不是按键直接杀死进程,而是 终端驱动检测到我们按下了 Ctrl+C。
- 然后内核就会让这个终端把 SIGINT 发给 前台进程组。
为什么是前台进程?
- Linux 下,每个终端只能有一个前台进程组。
- 只有前台进程能接收由终端产生的 终端控制信号(比如
Ctrl+C
→ SIGINT,Ctrl+Z
→ SIGTSTP)。
我没能展示一个完美的 Ctrl+C 的硬件到软件底层示意图,只能大致说一下流程了:键盘 → 控制器缓冲区 → 触发中断 → CPU 查向量表 → 键盘驱动读码 → 驱动识别特殊字符 → 内核发 SIGINT → 前台进程结束。
当你在终端中按 Ctrl-c 时会发生什么 | Medium
2. signal
函数原型
1. 作用
用于 设置某个信号的处理方式(3 种):
- 使用自定义的 信号处理函数(handler,自己写函数)。
- 或者设置为 忽略(
SIG_IGN
)。 - 或者恢复为 默认处理(
SIG_DFL
)。
2. 函数原型
1 |
|
3. 参数详解
int signum
: 要处理信号编号:如SIGINT
(2),SIGTERM
(15),SIGKILL
(9)等。这个参数可以直接写信号对应的编号,也能直接写信号(信号本身就是宏)。sighandler_t handler
: 信号处理的三种方式:① 指向你写的自定义处理函数 ②SIG_IGN
(忽略)③SIG_DFL
(默认处理)。
注意点:SIGKILL
(9)和 SIGSTOP
(19)不能被捕获、不能忽略,只能默认由内核强制处理!
4. 返回值
- 成功:返回 之前注册的处理函数指针(方便保存旧处理方式)。
- 失败:返回
SIG_ERR
。
5. 放置位置(通常只设置一次!)
signal
是用来 告诉系统:如果收到了这个信号,要怎么处理,所以——一定要在信号有可能到来之前就设置好!
常见位置 | 说明 |
---|---|
main 一开始就注册 |
最常见、最稳妥:程序一启动就告诉内核“收到某个信号就这么处理”。 |
启动前先注册 | 如果有多线程 / fork 子进程,通常会在主线程或父进程里先注册好,再去干别的活 |
循环前注册 | 千万别把 signal 放在循环里反复调!只需要设置一次就行。 |
3. 验证 Ctrl+C 是 2 号信号
源码一览:证明信号的本质是宏。
列出当前系统支持的所有信号名称:
1 |
|
运行结果示例:程序成功打印出“收到信号编号: 2”的字样,证明 Ctrl+C 其实是 2 号信号 SIGINT。
2. 信号的产生
1. 键盘组合键(终端控制信号)
Ctrl+C
→ 产生 SIGINT(2 号)Ctrl+\
→ 产生 SIGQUIT(3 号)Ctrl+Z
→ 产生 SIGTSTP(20 号)
2. kill 命令
kill -9 PID
→ 给指定进程发 SIGKILL(9 号)kill -15 PID
→ 给指定进程发 SIGTERM(15 号)kill -l
→ 查看所有信号
kill
并不一定“杀”,它本质是“发信号”,什么信号自己选。
3. 系统调用
1. kill
函数
1. 功能
kill
函数用于向指定 进程 或 进程组 发送一个信号。常用于:
- 结束某个进程(如发送
SIGTERM
、SIGKILL
)。 - 给自己或其他进程发送自定义信号。
- 配合信号处理函数(
signal
/sigaction
)实现 IPC。
注意:kill
并不一定会“杀死”进程,它只是发送信号,是否终止要看目标进程是否处理或屏蔽该信号。
2. 函数原型
1 |
|
3. 参数详解
pid_t pid
:目标进程 ID 或进程组 ID。特殊值含义如下:pid > 0
:发送给 PID =pid
的单个进程。pid = 0
:发送给当前进程所在进程组的所有进程。pid = -1
:发送给调用者有权发送信号的所有进程(不推荐危险!),在生产中,不要轻易使用pid = -1
,可能会把整个系统都影响了。pid < -1
:发送给 PGID =-pid
的进程组中所有进程。
int sig
:要发送的信号编号,比如SIGKILL
(9)、SIGTERM
(15)、SIGSTOP
(19)等,也可以是自定义信号(如SIGUSR1
)。
4. 返回值
- 成功:返回
0
。 - 失败:返回
-1
,并设置errno
说明错误原因(如ESRCH
:无此 PID,EPERM
:权限不足等)。
5. 代码示例
下面给你一个小示例,演示父进程使用 kill
给子进程发送 SIGTERM
信号,子进程注册信号处理函数后捕获该信号:
1
2
3
4
5
6
7
8
9
10 >pid_t pid = fork();
>if (pid == 0)
>{
// 这里是子进程,pid=0
>}
>else if (pid > 0)
>{
// 这里是父进程,pid=子进程PID
>}
1 |
|
2. raise
函数
1. 功能
raise
用来让当前程序自己给自己发一个信号。
2. 函数原型
1 |
|
3. 参数详解
int sig
: 要发出的信号编号(如 SIGTERM
、SIGINT
)。
4. 返回值
- 成功:0。
- 失败:非 0。
5. 代码示例
1 |
|
3. abort
函数
1. 功能
abort
用来让程序立刻异常终止,发出 SIGABRT
(6 号信号)。 它是固定发 SIGABRT
,不能换别的。
2. 函数原型
1 |
|
注意:无参数、无返回值,调用后程序就要终止(如果没捕获 SIGABRT)。
3. 代码示例
1 |
|
4. kill
、raise
和 abort
的区别
函数 | 作用 | 典型使用场景 | 信号目标 | 头文件 | 特点 |
---|---|---|---|---|---|
kill |
给 指定进程/进程组 发送信号(谁都能杀) | 父进程给子进程发信号、脚本杀进程 | 任意指定的进程 PID(或进程组) | <signal.h> |
最通用,支持跨进程发信号 |
raise |
给 自己(当前进程) 发送信号(自己吓自己) | 程序内部触发某个信号(模拟外部 kill) | 调用它的当前进程 | <signal.h> |
相当于 kill(getpid(), signum) |
abort |
给 自己 发送 SIGABRT 信号(自己崩溃) |
程序发现不可恢复错误时触发异常中止 | 调用它的当前进程 | <cstdlib> (C++),<stdlib.h> (C) |
只能发 SIGABRT ,直接终止程序并产生 core dump(若开启) |
4. 硬件异常
1. 除以 0 导致异常
- CPU 有除法指令,如果除数是 0,CPU 硬件级别检测到非法操作,硬件立刻触发,这个异常会立刻 中断当前指令执行,由 CPU 抛给操作系统。操作系统收到异常中断后,根据异常类型找对应的 异常处理程序。
- 如果是用户态进程除 0,OS 默认是:给该进程发送
SIGFPE
(Floating Point Exception)信号。如果进程没处理这个信号,就会被 OS 杀掉并回收资源。
所以:除以 0 = 硬件发现错误 → 触发 CPU 异常 → OS 发 SIGFPE → 进程崩溃
1 |
|
运行结果示例:
2. 野指针导致异常
每个进程有自己的 虚拟地址空间,由操作系统 + CPU 的 MMU(内存管理单元,现代计算机会将 MMU 放入 CPU)一起管理。CPU 用 MMU 把“虚拟地址”翻译成“物理地址”;访问未映射或权限不符的地址 → MMU 触发 Page Fault → 内核一看是“合法缺页”就帮你补页,是“非法野指针”就发 SIGSEGV,默认把进程干掉。
所以:非法访问 = MMU 拦截 → Page Fault → OS 判定非法 → 发 SIGSEGV → 进程被杀
1 |
|
运行结果示例:
5. 软件条件
SIGPIPE
就是典型的软件条件信号:管道通信:① 写端:进程 A ② 读端:进程 B。正常流程:A 写 → B 读 → 数据传递。异常场景:
- B 提前关闭读端(
close(fd)
),或者 B 异常退出。- A 还在不停
write(fd, ...)
。
SIGPIPE
= “没人接水龙头却还在放水” → OS 发现了 → 给你发信号 → 不处理就直接 kill。
软件条件的信号本质上是 内核或程序自己产生 的,常用来定时器、子进程管理、超时处理。注意:不是硬件 CPU 抛出的硬件异常,而是 OS 根据进程/资源状态来调度。alarm
是其中最典型的。
1. alarm
函数
1. 功能
alarm
用来在指定的若干秒后,自动向当前进程发送 SIGALRM
信号(14 号信号)。常用于实现简单的超时控制,比如:
- 程序运行超过 N 秒就触发中断。
- 防止阻塞的 IO 卡死。
- 与
signal(SIGALRM, handler)
配合使用。 alarm
通常只能设置一个闹钟,再次调用会覆盖上一次设置。
2. 函数原型
1 |
|
3. 参数详解
seconds
: 多少秒后发出 SIGALRM
,设为 0 则取消之前设置的闹钟。
4. 返回值
- 返回之前设置的剩余秒数(若没有则返回 0)。
- 如果要设置新的闹钟,返回值表示原闹钟还有多久触发。
5. 代码示例
1 |
|
运行结果示例:
1 | [hcc@hcss-ecs-be68 Signal Generation]$ ./alarm |
父子进程 + alarm + signal 代码示例:
1 |
|
运行结果示例:
1 | [hcc@hcss-ecs-be68 Signal Generation]$ ./alarm2 |
2. 常用信号
使用 man 7 signal
命令(偏底部)查看:
做一个简单的小翻译:
信号名 | 数值 | 默认动作 | 中文含义 |
---|---|---|---|
SIGHUP | 1 | Term | 挂起:控制终端关闭或父进程终止 |
SIGINT | 2 | Term | 键盘中断(Ctrl+C) |
SIGQUIT | 3 | Core | 键盘退出(Ctrl+\),并生成 core dump |
SIGILL | 4 | Core | 非法指令(CPU 执行了未定义或错误的指令) |
SIGABRT | 6 | Core | 程序调用 abort() ,异常终止并生成 core dump |
SIGFPE | 8 | Core | 浮点异常(除以 0、溢出)并生成 core dump |
SIGKILL | 9 | Term | 强制终止(不可捕获,不可忽略) |
SIGSEGV | 11 | Core | 无效内存引用(段错误,野指针)并生成 core dump |
SIGPIPE | 13 | Term | 管道破裂:写端写但没人读 |
SIGALRM | 14 | Term | 定时器到期(由 alarm() 触发) |
SIGTERM | 15 | Term | 终止信号(程序优雅退出) |
SIGUSR1 | 30,10,16 | Term | 用户自定义信号 1 |
SIGUSR2 | 31,12,17 | Term | 用户自定义信号 2 |
SIGCHLD | 20,17,18 | Ign | 子进程停止或结束 |
SIGCONT | 19,18,25 | Cont | 恢复已停止的进程 |
SIGSTOP | 17,19,23 | Stop | 停止进程(不可捕获,不可忽略) |
SIGTSTP | 18,20,24 | Stop | 从终端暂停(Ctrl+Z) |
SIGTTIN | 21,21,26 | Stop | 后台进程请求终端输入 |
SIGTTOU | 22,22,27 | Stop | 后台进程请求终端输出 |
默认动作关键说明:
- 终止(Term) :终止进程。
- 核心转储(Core) :终止进程并生成 core dump 文件(用于调试)。注意:一般虚拟机上是启用的,而云服务器上是关闭的!
- 忽略(Ign) :忽略该信号。
- 继续(Cont) :继续已暂停的进程。
- 暂停(Stop) :暂停进程(进程挂起,状态变成 T)。
1. Core dump 是什么?
Core Dump 是程序崩溃时的“事故现场记录仪”,通过它开发者可以精准还原崩溃时的程序状态,是调试复杂问题的关键工具(事后调试)。合理配置 Core Dump 生成规则,并结合 GDB 等工具分析,能显著提升问题排查效率。打开 OS 的 Core Dump 功能后,一旦进程出现异常,OS 会将进程在内存中的运行信息给 Dump(转存)到进程当前目录(磁盘)上形成 core.pid 文件!
相关命令:
1 | ulimit -c # 查看当前进程最大 Core 文件大小(单位:KB) |
Core Dump 还有其他相关命令,这里就不过多赘述了,
1 |
|
在进程等待的章节中讲过
status
的位布局,这里深入理解一下:
1
2
3
4 31 16 15 8 7 7 6 0
+--------------------------------+------------------+-------+----------+
| 其他保留 | exit code(8位) | core? | signal(7位)|
+--------------------------------+------------------+-------+----------+内核会把 子进程的退出信息 打包成一个 int 型整数,放到
status
里,这个整数就像一个 位图,把多个信息整理到了一块。这也是为什么没有 32、33 号信号的原因。这个位图里面放了什么?
位区 意义 举例 高 8 位(位 8~15) 如果子进程是调用 exit
正常退出,这里是返回码exit(42)
→ 高 8 位 = 42低 7 位(位 0~6) 如果子进程是被信号杀死,这里放 信号编号 SIGSEGV
→ 11第 7 位(位 7) 如果是信号杀死,且生成了 core dump,这里是 1 有 core dump 其他 还有一些特定状态(暂停/继续)可用宏判断 WIFSTOPPED
、WIFCONTINUED
用位运算怎么拆?
表达式 意义 (status >> 8) & 0xFF
右移 8 位,取高 8 位 = exit code
status & 0x7F
低 7 位,表示终止信号编号 (status >> 7) & 1
第 7 位,表示是否生成 core dump
运行结果示例:
Core Dump 的调试: 一般会显示信号信息、调用堆栈、寄存器状态、错误行号、变量值等一系列非常详细的信息!
更多调试命令请自行百度,这里点到为止了。
安装调试包命令:
1 >sudo yum install debuginfo-install glibc-2.17-326.el7_9.3.x86_64 libgcc-4.8.5-44.el7.x86_64
2. 为什么 Core Dump 一般虚拟机上是启用的,而云服务器上是关闭的?
在虚拟机上:
调试优先:在本地虚拟机(开发机、测试机),程序挂掉后要调试,最有用的证据就是 core 文件(它保存了崩溃时的内存、堆栈、寄存器)。
单机单人可控:一般是自己用或者小团队用,没有什么安全风险,也不担心磁盘写 core。
调试习惯:开发环境崩溃了,生成 core →
gdb ./a.out core.xxx
→ 定位 bug,常规套路。
在云服务器上:
生产环境以安全和稳定为主,不是以调试为主!
安全隐私风险大: core dump 会把 整个进程内存快照 写到文件里,里面可能包含:明文账号密码、秘钥、用户数据(比如客户表单、交易信息)等,一旦留在磁盘或者被误传出去,就是安全事故。
影响稳定性: 仔细观察会发现:几十行的代码就会生成体积非常庞大的 core 文件。如果线上进程瞬间崩溃大量生成 core(一个 core 可以几个 GB),会把服务器磁盘打满,后果很严重(尤其是在大型服务器集群中非常显著!):
其他服务写文件失败。
数据库宕机。
整个实例卡死。
运维风控: 生产环境一般靠 日志、监控告警 和最小可复现 Demo 来定位问题,不直接看 core dump。有些还存在自动化的专业 debug 工具/脚本等。
3. 普通信号 VS 实时信号(了解)
早期 UNIX:
- 信号是最简单的 IPC,设计得很“轻”:只要告诉你“有事情发生了”,具体什么事情、来了几次都不重要。
- 所以,重复来的同一个信号只保留一个,省开销。
后来的实时场景:
- 有些实时系统(工业控制、航空、机器人)需要精确传递多次事件(每次都重要)。
- 所以 Linux 引入了 实时信号,内核会把多个相同实时信号放到队列里,保证每一个都能送达。
维度 | 普通信号 | 实时信号 |
---|---|---|
范围 | 1 ~ 31 | 32 ~ 64 |
是否排队 | 不排队,同类型只保留 1 个 | 排队,同类型多个也可以同时排队 |
发送顺序 | 不保证顺序 | 保证顺序 FIFO |
优先级 | 没有严格优先级 | 实时信号优先级可自定义 |
用途 | kill、Ctrl+C、kill -9、常见系统信号 | 高实时性要求的 IPC,比如实时控制、进程间发送大量通知 |
标准来源 | 早期 UNIX | POSIX.1b |