
文章目录
- 引言
- [1. 信号快速认识](#1. 信号快速认识)
-
- [1.1 从生活角度理解信号](#1.1 从生活角度理解信号)
- [1.2 信号的基本概念](#1.2 信号的基本概念)
- [2. Ctrl+C 背后发生了什么](#2. Ctrl+C 背后发生了什么)
-
- [2.1 最简单的等待信号程序](#2.1 最简单的等待信号程序)
- [2.2 signal 函数初步认识](#2.2 signal 函数初步认识)
- [2.3 捕捉 Ctrl+C 信号](#2.3 捕捉 Ctrl+C 信号)
- [2.4 前台进程和后台进程](#2.4 前台进程和后台进程)
- [3. 常见信号与处理方式](#3. 常见信号与处理方式)
-
- [3.1 查看系统信号](#3.1 查看系统信号)
- [3.2 信号的三种处理动作](#3.2 信号的三种处理动作)
- [3.3 忽略 SIGINT](#3.3 忽略 SIGINT)
- [3.4 恢复默认处理动作](#3.4 恢复默认处理动作)
- [3.5 SIG_DFL 和 SIG_IGN 的本质](#3.5 SIG_DFL 和 SIG_IGN 的本质)
- [4. 信号是如何产生的](#4. 信号是如何产生的)
- [5. 通过终端按键产生信号](#5. 通过终端按键产生信号)
-
- [5.1 Ctrl+C 产生 SIGINT](#5.1 Ctrl+C 产生 SIGINT)
- [5.2 Ctrl+\ 产生 SIGQUIT](#5.2 Ctrl+\ 产生 SIGQUIT)
- [5.3 Ctrl+Z 产生 SIGTSTP](#5.3 Ctrl+Z 产生 SIGTSTP)
- [6. 使用命令向进程发送信号](#6. 使用命令向进程发送信号)
-
- [6.1 kill 命令发送信号](#6.1 kill 命令发送信号)
- [7. 使用函数产生信号](#7. 使用函数产生信号)
-
- [7.1 kill 函数](#7.1 kill 函数)
- [7.2 实现自己的 kill 命令](#7.2 实现自己的 kill 命令)
- [7.3 raise 函数](#7.3 raise 函数)
- [7.4 abort 函数](#7.4 abort 函数)
- [8. 由软件条件产生信号](#8. 由软件条件产生信号)
-
- [8.1 alarm 函数](#8.1 alarm 函数)
- [8.2 alarm 基本验证:IO 多的情况](#8.2 alarm 基本验证:IO 多的情况)
- [8.3 alarm 基本验证:IO 少的情况](#8.3 alarm 基本验证:IO 少的情况)
- [8.4 设置重复闹钟](#8.4 设置重复闹钟)
- [9. 硬件异常产生信号](#9. 硬件异常产生信号)
-
- [9.1 除 0 产生 SIGFPE](#9.1 除 0 产生 SIGFPE)
- [9.2 野指针产生 SIGSEGV](#9.2 野指针产生 SIGSEGV)
- [10. Core Dump 理解](#10. Core Dump 理解)
-
- [10.1 什么是 Core Dump](#10.1 什么是 Core Dump)
- [10.2 查看 core 文件限制](#10.2 查看 core 文件限制)
- [10.3 开启 core dump](#10.3 开启 core dump)
- [10.4 父进程如何判断子进程是否 core dump](#10.4 父进程如何判断子进程是否 core dump)
- [11. 信号保存:未决和阻塞](#11. 信号保存:未决和阻塞)
-
- [11.1 信号递达、未决、阻塞](#11.1 信号递达、未决、阻塞)
- [11.2 信号在内核中的表示](#11.2 信号在内核中的表示)
- [11.3 sigset_t 的理解](#11.3 sigset_t 的理解)
- [12. 信号集操作函数](#12. 信号集操作函数)
-
- [12.1 基本信号集函数](#12.1 基本信号集函数)
- [12.2 sigprocmask 函数](#12.2 sigprocmask 函数)
- [12.3 sigpending 函数](#12.3 sigpending 函数)
- [12.4 验证信号阻塞和未决](#12.4 验证信号阻塞和未决)
- [13. 捕捉信号](#13. 捕捉信号)
-
- [13.1 信号捕捉流程](#13.1 信号捕捉流程)
- [13.2 sigaction 函数](#13.2 sigaction 函数)
- [14. 操作系统运行与中断理解](#14. 操作系统运行与中断理解)
-
- [14.1 硬件中断](#14.1 硬件中断)
- [14.2 时钟中断](#14.2 时钟中断)
- [14.3 死循环为什么不会卡死整个系统](#14.3 死循环为什么不会卡死整个系统)
- [14.4 软中断与信号](#14.4 软中断与信号)
- [15. 可重入函数](#15. 可重入函数)
-
- [15.1 什么是可重入](#15.1 什么是可重入)
- [15.2 为什么信号处理函数要谨慎](#15.2 为什么信号处理函数要谨慎)
- [16. volatile 关键字](#16. volatile 关键字)
-
- [16.1 volatile 的作用](#16.1 volatile 的作用)
- [17. SIGCHLD 信号](#17. SIGCHLD 信号)
-
- [17.1 SIGCHLD 的作用](#17.1 SIGCHLD 的作用)
- [17.2 子进程退出通知父进程](#17.2 子进程退出通知父进程)
- [17.3 SIGCHLD 的常见误区](#17.3 SIGCHLD 的常见误区)
- [18. 高频技术题 / 面试题](#18. 高频技术题 / 面试题)
-
- [18.1 什么是 Linux 信号?](#18.1 什么是 Linux 信号?)
- [18.2 Ctrl+C 为什么能终止进程?](#18.2 Ctrl+C 为什么能终止进程?)
- [18.3 kill 函数是不是一定会杀死进程?](#18.3 kill 函数是不是一定会杀死进程?)
- [18.4 阻塞信号和忽略信号有什么区别?](#18.4 阻塞信号和忽略信号有什么区别?)
- [18.5 pending 信号集是干什么的?](#18.5 pending 信号集是干什么的?)
- [18.6 普通信号产生多次会记录多次吗?](#18.6 普通信号产生多次会记录多次吗?)
- [18.7 为什么信号处理函数要尽量简单?](#18.7 为什么信号处理函数要尽量简单?)
- [18.8 什么是 Core Dump?](#18.8 什么是 Core Dump?)
- [18.9 子进程退出后为什么会产生 SIGCHLD?](#18.9 子进程退出后为什么会产生 SIGCHLD?)
- [18.10 为什么除 0 和野指针会变成信号?](#18.10 为什么除 0 和野指针会变成信号?)
- [所以 C/C++ 中的某些运行时错误,在操作系统层面上是通过信号机制处理的。](#所以 C/C++ 中的某些运行时错误,在操作系统层面上是通过信号机制处理的。)
- 结语

文章目录
- 引言
- [1. 信号快速认识](#1. 信号快速认识)
-
- [1.1 从生活角度理解信号](#1.1 从生活角度理解信号)
- [1.2 信号的基本概念](#1.2 信号的基本概念)
- [2. Ctrl+C 背后发生了什么](#2. Ctrl+C 背后发生了什么)
-
- [2.1 最简单的等待信号程序](#2.1 最简单的等待信号程序)
- [2.2 signal 函数初步认识](#2.2 signal 函数初步认识)
- [2.3 捕捉 Ctrl+C 信号](#2.3 捕捉 Ctrl+C 信号)
- [2.4 前台进程和后台进程](#2.4 前台进程和后台进程)
- [3. 常见信号与处理方式](#3. 常见信号与处理方式)
-
- [3.1 查看系统信号](#3.1 查看系统信号)
- [3.2 信号的三种处理动作](#3.2 信号的三种处理动作)
- [3.3 忽略 SIGINT](#3.3 忽略 SIGINT)
- [3.4 恢复默认处理动作](#3.4 恢复默认处理动作)
- [3.5 SIG_DFL 和 SIG_IGN 的本质](#3.5 SIG_DFL 和 SIG_IGN 的本质)
- [4. 信号是如何产生的](#4. 信号是如何产生的)
- [5. 通过终端按键产生信号](#5. 通过终端按键产生信号)
-
- [5.1 Ctrl+C 产生 SIGINT](#5.1 Ctrl+C 产生 SIGINT)
- [5.2 Ctrl+\ 产生 SIGQUIT](#5.2 Ctrl+\ 产生 SIGQUIT)
- [5.3 Ctrl+Z 产生 SIGTSTP](#5.3 Ctrl+Z 产生 SIGTSTP)
- [6. 使用命令向进程发送信号](#6. 使用命令向进程发送信号)
-
- [6.1 kill 命令发送信号](#6.1 kill 命令发送信号)
- [7. 使用函数产生信号](#7. 使用函数产生信号)
-
- [7.1 kill 函数](#7.1 kill 函数)
- [7.2 实现自己的 kill 命令](#7.2 实现自己的 kill 命令)
- [7.3 raise 函数](#7.3 raise 函数)
- [7.4 abort 函数](#7.4 abort 函数)
- [8. 由软件条件产生信号](#8. 由软件条件产生信号)
-
- [8.1 alarm 函数](#8.1 alarm 函数)
- [8.2 alarm 基本验证:IO 多的情况](#8.2 alarm 基本验证:IO 多的情况)
- [8.3 alarm 基本验证:IO 少的情况](#8.3 alarm 基本验证:IO 少的情况)
- [8.4 设置重复闹钟](#8.4 设置重复闹钟)
- [9. 硬件异常产生信号](#9. 硬件异常产生信号)
-
- [9.1 除 0 产生 SIGFPE](#9.1 除 0 产生 SIGFPE)
- [9.2 野指针产生 SIGSEGV](#9.2 野指针产生 SIGSEGV)
- [10. Core Dump 理解](#10. Core Dump 理解)
-
- [10.1 什么是 Core Dump](#10.1 什么是 Core Dump)
- [10.2 查看 core 文件限制](#10.2 查看 core 文件限制)
- [10.3 开启 core dump](#10.3 开启 core dump)
- [10.4 父进程如何判断子进程是否 core dump](#10.4 父进程如何判断子进程是否 core dump)
- [11. 信号保存:未决和阻塞](#11. 信号保存:未决和阻塞)
-
- [11.1 信号递达、未决、阻塞](#11.1 信号递达、未决、阻塞)
- [11.2 信号在内核中的表示](#11.2 信号在内核中的表示)
- [11.3 sigset_t 的理解](#11.3 sigset_t 的理解)
- [12. 信号集操作函数](#12. 信号集操作函数)
-
- [12.1 基本信号集函数](#12.1 基本信号集函数)
- [12.2 sigprocmask 函数](#12.2 sigprocmask 函数)
- [12.3 sigpending 函数](#12.3 sigpending 函数)
- [12.4 验证信号阻塞和未决](#12.4 验证信号阻塞和未决)
- [13. 捕捉信号](#13. 捕捉信号)
-
- [13.1 信号捕捉流程](#13.1 信号捕捉流程)
- [13.2 sigaction 函数](#13.2 sigaction 函数)
- [14. 操作系统运行与中断理解](#14. 操作系统运行与中断理解)
-
- [14.1 硬件中断](#14.1 硬件中断)
- [14.2 时钟中断](#14.2 时钟中断)
- [14.3 死循环为什么不会卡死整个系统](#14.3 死循环为什么不会卡死整个系统)
- [14.4 软中断与信号](#14.4 软中断与信号)
- [15. 可重入函数](#15. 可重入函数)
-
- [15.1 什么是可重入](#15.1 什么是可重入)
- [15.2 为什么信号处理函数要谨慎](#15.2 为什么信号处理函数要谨慎)
- [16. volatile 关键字](#16. volatile 关键字)
-
- [16.1 volatile 的作用](#16.1 volatile 的作用)
- [17. SIGCHLD 信号](#17. SIGCHLD 信号)
-
- [17.1 SIGCHLD 的作用](#17.1 SIGCHLD 的作用)
- [17.2 子进程退出通知父进程](#17.2 子进程退出通知父进程)
- [17.3 SIGCHLD 的常见误区](#17.3 SIGCHLD 的常见误区)
- [18. 高频技术题 / 面试题](#18. 高频技术题 / 面试题)
-
- [18.1 什么是 Linux 信号?](#18.1 什么是 Linux 信号?)
- [18.2 Ctrl+C 为什么能终止进程?](#18.2 Ctrl+C 为什么能终止进程?)
- [18.3 kill 函数是不是一定会杀死进程?](#18.3 kill 函数是不是一定会杀死进程?)
- [18.4 阻塞信号和忽略信号有什么区别?](#18.4 阻塞信号和忽略信号有什么区别?)
- [18.5 pending 信号集是干什么的?](#18.5 pending 信号集是干什么的?)
- [18.6 普通信号产生多次会记录多次吗?](#18.6 普通信号产生多次会记录多次吗?)
- [18.7 为什么信号处理函数要尽量简单?](#18.7 为什么信号处理函数要尽量简单?)
- [18.8 什么是 Core Dump?](#18.8 什么是 Core Dump?)
- [18.9 子进程退出后为什么会产生 SIGCHLD?](#18.9 子进程退出后为什么会产生 SIGCHLD?)
- [18.10 为什么除 0 和野指针会变成信号?](#18.10 为什么除 0 和野指针会变成信号?)
- [所以 C/C++ 中的某些运行时错误,在操作系统层面上是通过信号机制处理的。](#所以 C/C++ 中的某些运行时错误,在操作系统层面上是通过信号机制处理的。)
- 结语
引言
刚开始学 Linux 信号的时候,我其实一直把它理解得很简单:按下 Ctrl+C,程序就退出;程序访问野指针,就会段错误;子进程退出,父进程可以 wait。这些现象我以前都见过,也能大概说出来是什么结果。
但是后来真正往底层看,我才发现:这些看起来很零散的现象,背后其实都绕不开一个核心机制------信号。
信号不是单纯的"通知一下进程",它里面包含了很多操作系统思想:异步事件、进程控制、软中断、内核态和用户态切换、信号阻塞、信号递达、信号捕捉、可重入函数、竞态条件、僵尸进程回收等。
一开始我最容易想错的地方是:我以为信号来了之后,进程会立刻处理。但后来才发现,信号的完整过程其实更像:
text
信号产生 -> 信号保存 -> 信号递达 -> 信号处理
也就是说,信号产生和信号处理之间并不一定是同步发生的,中间可能会经历"未决""阻塞""解除阻塞"等状态。
这篇博客就是我学习 Linux 进程信号时整理出来的一份笔记,重点不是死记 API,而是尽量把每个现象背后的操作系统逻辑串起来。
1. 信号快速认识
1.1 从生活角度理解信号
刚开始理解信号时,我觉得"快递通知"这个例子特别好。
假设我在网上买了很多东西,快递什么时候到我并不能完全确定。但是我知道:只要快递员打电话或者发消息,我就能识别出"快递到了"这个事件。
收到通知以后,我也不一定马上下楼取快递。比如我正在打游戏,可能要等 5 分钟之后才去取。
在这 5 分钟里,快递并没有被我真正处理,但是我心里已经知道:"有一个快递到了,待会儿要处理"。
💡信号就像快递通知。通知来了并不代表马上处理,但我必须先知道它来了,并且把这个事情记下来。
这个过程可以对应到 Linux 信号:
text
快递到了 -> 信号产生
我知道快递到了 -> 信号被记录
我暂时不去取 -> 信号处于未决状态
我有空去取 -> 信号递达并处理
信号处理方式一般有三种:
- 执行默认动作;
- 忽略信号;
- 执行自定义处理函数。
在 Linux 中,第三种方式通常叫做信号捕捉。
1.2 信号的基本概念
信号是进程之间进行事件异步 通知的一种方式,本质上可以理解为一种软中断。
这里我觉得有两个关键词特别重要:
-
第一个是 异步 :
信号不一定按照程序原本的执行流程出现。比如程序正在执行循环,用户突然按下
Ctrl+C,此时程序就可能收到SIGINT信号。 -
第二个是 软中断 :
硬件中断通常是硬件设备通知 CPU,而信号更像是操作系统从软件层面对进程发出的通知。
💡硬件中断像是门铃直接响在 CPU 那边,信号更像是操作系统转告某个进程:"你有个事情需要处理"。
2. Ctrl+C 背后发生了什么
2.1 最简单的等待信号程序
先写一个最普通的程序:
cpp
// sig.cc
#include <iostream> // 用于 std::cout 输出
#include <unistd.h> // 用于 sleep 函数
int main()
{
while (true)
{
// 每隔 1 秒打印一次,表示当前进程还在运行
std::cout << "I am a process, I am waiting signal!" << std::endl;
// 让进程休眠 1 秒,避免疯狂刷屏
sleep(1);
}
return 0;
}
编译运行:
bash
g++ sig.cc -o sig
./sig
运行结果类似:
bash
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C
当我按下 Ctrl+C 后,前台进程直接退出了。
以前我只是知道 Ctrl+C 可以终止程序,现在重新理解后发现:
- 用户按下
Ctrl+C; - 键盘输入产生硬件中断;
- 操作系统捕获这个输入;
- 操作系统把它解释成
SIGINT信号; - 操作系统把
SIGINT发送给当前前台进程; - 进程执行
SIGINT的默认处理动作:终止进程。

2.2 signal 函数初步认识
Linux 中可以使用 signal 函数修改某个信号的处理动作。
函数原型如下:
c
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数含义:
signum:要处理的信号编号;handler:函数指针,表示收到该信号后执行的处理函数;- 返回值:成功时返回之前的信号处理函数,失败返回
SIG_ERR。
这里要特别注意:signal 只是设置处理方式,不是直接调用处理函数。
cpp
signal(SIGINT, handler);
// 这不是调用 handler,
// 而是告诉内核:以后当前进程收到 SIGINT 时,执行 handler。
💡这就像我提前设置手机铃声。设置铃声不代表现在有人给我打电话,而是以后电话来了才会响这个铃声。
2.3 捕捉 Ctrl+C 信号
下面这段代码验证 Ctrl+C 的本质是给前台进程发送 SIGINT,也就是 2 号信号。
cpp
#include <iostream> // 用于 std::cout 输出
#include <unistd.h> // 用于 getpid 和 sleep
#include <signal.h> // 用于 signal 和 SIGINT
// 自定义信号处理函数
// signumber 表示当前捕捉到的信号编号
void handler(int signumber)
{
std::cout << "我是: " << getpid()
<< ", 我获得了一个信号: "
<< signumber << std::endl;
}
int main()
{
// 打印当前进程 PID,方便观察信号发送对象
std::cout << "我是进程: " << getpid() << std::endl;
// 捕捉 SIGINT 信号,也就是 Ctrl+C 产生的 2 号信号
// 注意:这里不是直接调用 handler,而是注册处理动作
signal(SIGINT, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
return 0;
}
编译运行:
bash
g++ sig.cc -o sig
./sig
可能输出:
bash
我是进程: 212569
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C我是: 212569, 我获得了一个信号: 2
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C我是: 212569, 我获得了一个信号: 2
这个现象说明:
Ctrl+C确实会产生 2 号信号;- 进程没有退出,是因为默认动作被我们改成了自定义处理函数;
- 信号处理函数执行完成后,进程会继续原来的执行流程。
这里我一开始容易想错:既然收到了 Ctrl+C,程序为什么不退出?
真正原因是:Ctrl+C 对应的是 SIGINT,而 SIGINT 的默认动作是终止进程。但现在我们用 signal 改掉了它的处理方式,所以进程不会执行默认终止动作。
2.4 前台进程和后台进程
Ctrl+C 产生的信号只能发送给前台进程。
比如:
bash
./sig &
这样程序会在后台运行,Shell 不必等待它结束,可以继续接收新的命令。
一个 Shell 可以同时管理:
- 一个前台进程;
- 任意多个后台进程。
但是像 Ctrl+C 这种控制键产生的信号,只会发送给当前前台进程。
💡这就像教室里老师点名提问时,通常是在问当前站起来回答问题的人,而不是教室里所有人一起回答。
3. 常见信号与处理方式
3.1 查看系统信号
可以使用下面的命令查看 Linux 中的信号:
bash
kill -l
常见信号包括:

其中比较常见的几个:
| 信号 | 编号 | 常见含义 |
|---|---|---|
SIGINT |
2 | Ctrl+C 产生,默认终止进程 |
SIGQUIT |
3 | Ctrl+\ 产生,默认终止并可能 core dump |
SIGABRT |
6 | abort 产生,异常终止 |
SIGFPE |
8 | 算术异常,例如除 0 |
SIGKILL |
9 | 强制终止,不能被捕捉或忽略 |
SIGSEGV |
11 | 非法内存访问 |
SIGPIPE |
13 | 管道读端关闭后继续写 |
SIGALRM |
14 | alarm 定时器到期 |
SIGCHLD |
17 | 子进程退出或停止时通知父进程 |
SIGSTOP |
19 | 停止进程,不能被捕捉或忽略 |
SIGTSTP |
20 | Ctrl+Z 产生,暂停前台进程 |
3.2 信号的三种处理动作
信号处理动作一般有三种:
- 默认处理;
- 忽略处理;
- 自定义捕捉。
3.3 忽略 SIGINT
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid()
<< ", 我获得了一个信号: "
<< signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
// SIG_IGN 表示忽略该信号
// 这里把 SIGINT 的处理动作设置为忽略
signal(SIGINT, SIG_IGN);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
return 0;
}
运行后按 Ctrl+C,程序不会退出。
这是因为 SIGINT 被设置成了忽略。
bash
^C^C^C
I am a process, I am waiting signal!
3.4 恢复默认处理动作
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid()
<< ", 我获得了一个信号: "
<< signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
// SIG_DFL 表示使用该信号的默认处理动作
// 对 SIGINT 来说,默认动作就是终止进程
signal(SIGINT, SIG_DFL);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
return 0;
}
这里再按 Ctrl+C,程序就会退出。
3.5 SIG_DFL 和 SIG_IGN 的本质
在源码中可以看到类似定义:
c
#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);
也就是说,SIG_DFL 和 SIG_IGN 本质上是两个特殊值,被强转成了函数指针类型。
这也能看出:信号处理动作在内核中通常是通过"处理函数指针"这一类方式来表达的。
4. 信号是如何产生的
信号产生方式主要有几类:
- 终端按键产生;
- 系统命令产生;
- 函数调用产生;
- 软件条件产生;
- 硬件异常产生。
5. 通过终端按键产生信号
5.1 Ctrl+C 产生 SIGINT
Ctrl+C 会产生 SIGINT 信号,默认动作是终止前台进程。
这个前面已经验证过。
5.2 Ctrl+\ 产生 SIGQUIT
Ctrl+\ 会产生 SIGQUIT 信号,默认动作是终止进程,并可能产生 core dump 文件。
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid()
<< ", 我获得了一个信号: "
<< signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
// 捕捉 SIGQUIT,也就是 Ctrl+\ 产生的 3 号信号
signal(SIGQUIT, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
return 0;
}
运行时按下 Ctrl+\:
bash
^\我是: 213056, 我获得了一个信号: 3
如果注释掉 signal(SIGQUIT, handler);,则会执行默认动作:
bash
^\Quit
5.3 Ctrl+Z 产生 SIGTSTP
Ctrl+Z 会产生 SIGTSTP 信号,默认动作是停止当前前台进程。
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid()
<< ", 我获得了一个信号: "
<< signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
// 捕捉 SIGTSTP,也就是 Ctrl+Z 产生的信号
signal(SIGTSTP, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
return 0;
}
如果不捕捉它,按下 Ctrl+Z 后可能出现:
bash
^Z
[1]+ Stopped ./sig
jobs
[1]+ Stopped ./sig
💡
Ctrl+Z像是把正在前台回答问题的人先叫停,让他站到旁边等着,并不是直接让他离开教室。
6. 使用命令向进程发送信号
6.1 kill 命令发送信号
先写一个后台运行的死循环程序:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
while (true)
{
// 程序什么也不做,只是保持运行
sleep(1);
}
return 0;
}
编译并后台运行:
bash
g++ sig.cc -o sig
./sig &
查看进程:
bash
ps ajx | head -1 && ps ajx | grep sig
假设进程 PID 是 213784,可以发送 SIGSEGV:
bash
kill -SIGSEGV 213784
也可以使用编号:
bash
kill -11 213784
这里有个很有意思的现象:程序本身没有访问非法内存,但是我们给它发送 SIGSEGV,它也会表现为段错误。
这说明:段错误这个现象不一定只能由非法内存访问直接触发。只要进程收到了 SIGSEGV,并执行默认处理动作,也会异常终止。
7. 使用函数产生信号
7.1 kill 函数
kill 命令底层会调用 kill 函数。
函数原型:
c
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数解释:
pid:目标进程 ID;sig:要发送的信号编号;- 返回值:成功返回
0,失败返回-1,并设置errno。
这里要注意:kill 不是单纯的"杀进程函数",它的本质是向指定进程发送指定信号。
💡
kill更像是"给某个人发一条通知",至于那个人收到通知后是退出、暂停、忽略,取决于信号本身和处理动作。
7.2 实现自己的 kill 命令
cpp
#include <iostream> // 用于输入输出
#include <unistd.h> // 提供部分 POSIX API
#include <sys/types.h> // 提供 pid_t 类型
#include <signal.h> // 提供 kill 函数和信号相关定义
// 使用方式:
// ./mykill -signumber pid
// 例如:./mykill -2 12345
int main(int argc, char *argv[])
{
// 命令行参数必须是 3 个:
// argv[0] 程序名
// argv[1] 信号编号,例如 -2
// argv[2] 目标进程 pid
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
return 1;
}
// argv[1] 形如 "-2"
// argv[1] + 1 表示跳过第一个字符 '-',只解析后面的数字
int number = std::stoi(argv[1] + 1);
// 把字符串形式的 pid 转换成 pid_t
pid_t pid = std::stoi(argv[2]);
// 向指定 pid 的进程发送 number 信号
int n = kill(pid, number);
// kill 成功返回 0,失败返回 -1
return n;
}
代码理解:
这个程序的核心就是把命令行参数解析成"信号编号"和"进程 PID",然后调用 kill(pid, number)。
如果写错 PID,kill 会失败。如果权限不够,也会失败。
7.3 raise 函数
raise 函数用于给当前进程发送指定信号,也就是自己给自己发信号。
函数原型:
c
#include <signal.h>
int raise(int sig);
返回值:
- 成功返回
0; - 失败返回非 0。
示例:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 信号处理函数
void handler(int signumber)
{
// 整个程序只有这里打印,所以看到输出就说明信号确实被捕捉到了
std::cout << "获取了一个信号: " << signumber << std::endl;
}
int main()
{
// 先对 2 号信号 SIGINT 进行捕捉
signal(2, handler);
while (true)
{
sleep(1);
// 每隔 1 秒,当前进程给自己发送 2 号信号
raise(2);
}
return 0;
}
运行结果:
bash
g++ raise.cc -o raise
./raise
获取了一个信号: 2
获取了一个信号: 2
获取了一个信号: 2
7.4 abort 函数
abort 函数会使当前进程接收到 SIGABRT 信号并异常终止。
函数原型:
c
#include <stdlib.h>
void abort(void);
特点:
abort没有返回值;- 它会让当前进程异常终止;
- 通常产生
SIGABRT,也就是 6 号信号。
示例:
cpp
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void handler(int signumber)
{
// 捕捉到 SIGABRT 后打印信号编号
std::cout << "获取了一个信号: " << signumber << std::endl;
}
int main()
{
// 捕捉 SIGABRT,也就是 abort 通常产生的 6 号信号
signal(SIGABRT, handler);
while (true)
{
sleep(1);
// abort 会导致当前进程异常终止
// 即使捕捉了 SIGABRT,abort 最终仍然会让进程退出
abort();
}
return 0;
}
运行现象:
bash
获取了一个信号: 6
Aborted
这里要注意:abort 和普通信号捕捉有点不同。即使捕捉了 SIGABRT,它最终仍然会导致进程异常终止。
8. 由软件条件产生信号
8.1 alarm 函数
alarm 可以设置一个闹钟,让内核在指定秒数后向当前进程发送 SIGALRM 信号。
函数原型:
c
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
参数:
seconds:几秒之后发送SIGALRM;- 如果传入
0,表示取消之前设置的闹钟。
返回值:
- 如果之前没有设置闹钟,返回
0; - 如果之前设置过闹钟,返回上一次闹钟剩余的秒数。
💡
alarm(30)就像设置 30 秒后的闹钟。如果 10 秒后又重新设置alarm(20),那么旧闹钟还剩 20 秒,这个剩余时间可能会作为返回值告诉你。
8.2 alarm 基本验证:IO 多的情况
cpp
// IO 多
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
int count = 0;
// 设置 1 秒后的闹钟
// 1 秒后内核会向当前进程发送 SIGALRM
// SIGALRM 默认动作是终止进程
alarm(1);
while (true)
{
// 每次循环都进行输出
// 输出本身是 IO 操作,效率比较低
std::cout << "count : " << count << std::endl;
count++;
}
return 0;
}
运行结果可能是:
bash
count : 107148
count : 107149
Alarm clock
可以看到,1 秒之后进程被 SIGALRM 默认终止。
8.3 alarm 基本验证:IO 少的情况
cpp
// IO 少
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int count = 0;
// 捕捉 SIGALRM
void handler(int signumber)
{
// 只在最后输出一次,减少 IO 操作
std::cout << "count : " << count << std::endl;
// 正常退出进程
exit(0);
}
int main()
{
// 捕捉 SIGALRM
signal(SIGALRM, handler);
// 设置 1 秒后的闹钟
alarm(1);
while (true)
{
// 循环中只做内存中的自增操作,不频繁输出
count++;
}
return 0;
}
运行结果可能是:
bash
count : 492333713
这两个程序对比后,我对 IO 效率有了更直观的认识。
第一个程序每次循环都输出,频繁进行 IO,速度很慢;第二个程序只在最后输出一次,中间只是内存自增,所以 1 秒内能循环非常多次。
💡这就像背单词时,每背一个单词都跑去打印一张纸,和先在草稿纸上记数量,最后统一打印一次,效率完全不一样。
8.4 设置重复闹钟
alarm 设置的闹钟默认只生效一次。如果想周期性触发,就需要在信号处理函数里重新设置。
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>
using func_t = std::function<void()>;
int gcount = 0;
// 保存一组需要定期执行的任务
std::vector<func_t> gfuncs;
// SIGALRM 的处理函数
void handler(int signo)
{
// 遍历执行所有注册的任务
for (auto &f : gfuncs)
{
f();
}
std::cout << "gcount : " << gcount << std::endl;
// 重新设置 1 秒后的闹钟
// alarm 返回上一次闹钟剩余的时间
int n = alarm(1);
std::cout << "剩余时间 : " << n << std::endl;
}
int main()
{
// 可以把一些周期性任务注册进来
// 例如刷新内核数据、检查进程时间片、内存管理等
/*
gfuncs.push_back([](){
std::cout << "我是一个内核刷新操作" << std::endl;
});
gfuncs.push_back([](){
std::cout << "我是一个检测进程时间片的操作,如果时间片到了,我会切换进程" << std::endl;
});
gfuncs.push_back([](){
std::cout << "我是一个内存管理操作,定期清理操作系统内部的内存碎片" << std::endl;
});
*/
// 设置第一次闹钟
alarm(1);
// 捕捉 SIGALRM
signal(SIGALRM, handler);
while (true)
{
// pause 会让进程挂起,直到收到一个信号
pause();
// 当信号处理函数执行完返回后,pause 被打断,继续执行这里
std::cout << "我醒来了..." << std::endl;
gcount++;
}
return 0;
}
pause 函数原型:
c
#include <unistd.h>
int pause(void);
作用:
- 让调用进程挂起;
- 直到收到一个信号;
- 如果信号处理函数返回,则
pause返回-1,并设置errno为EINTR。
这里我觉得很关键:alarm 可以模拟一种"周期性中断"的感觉。操作系统内部很多定时任务,本质上也需要依赖定时机制。

9. 硬件异常产生信号
9.1 除 0 产生 SIGFPE
硬件异常也可能被操作系统解释成信号。
比如除 0:
c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
// 如果打开这一行,就会捕捉 SIGFPE
// SIGFPE 通常对应 8 号信号,表示算术异常
// signal(SIGFPE, handler);
sleep(1);
int a = 10;
// 整数除 0,会触发 CPU 运算异常
// 内核会把这个异常解释成 SIGFPE 发送给当前进程
a /= 0;
while (1)
{
// 如果异常没有导致进程退出,程序可能一直循环
}
return 0;
}
这里的底层过程可以理解为:
- CPU 执行除法指令;
- 发现除数为 0;
- CPU 产生硬件异常;
- 操作系统接管异常;
- 操作系统向当前进程发送
SIGFPE; - 进程执行对应处理动作。
9.2 野指针产生 SIGSEGV
c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
// 如果打开这一行,就会捕捉 SIGSEGV
// signal(SIGSEGV, handler);
sleep(1);
int *p = NULL;
// 对空指针解引用,访问非法地址
// MMU 会发现该地址不合法,产生异常
// 内核把异常解释成 SIGSEGV 发送给当前进程
*p = 100;
while (1)
{
}
return 0;
}
默认运行结果:
bash
Segmentation fault (core dumped)
如果捕捉 SIGSEGV,可能会不断打印:
bash
catch a sig : 11
catch a sig : 11
catch a sig : 11
这里我一开始也比较疑惑:为什么捕捉之后会一直收到 11 号信号?
后来理解是:非法访问这个问题并没有被修复。信号处理函数返回后,程序仍然回到出错现场,继续执行非法内存访问,于是又触发异常。
💡这就像车轮陷进坑里了,你只是告诉司机"车陷坑了",但没有把车拖出来。司机继续踩油门,车还是会继续陷在坑里。

10. Core Dump 理解
10.1 什么是 Core Dump
当一个进程异常终止时,操作系统可以把进程用户空间的内存数据保存到磁盘文件中,这个文件通常叫做 core 文件,这个过程叫做 Core Dump。
Core Dump 的作用是方便事后调试,也叫 Post-mortem Debug。
比如程序段错误后,可以用 gdb 加载 core 文件,查看当时的调用栈、变量值和崩溃位置。
10.2 查看 core 文件限制
bash
ulimit -a
其中:
bash
core file size (blocks, -c) 0
表示默认不允许产生 core 文件。
这是因为 core 文件可能包含敏感信息,例如用户输入、密码、内存数据等,所以默认限制为 0。
10.3 开启 core dump
bash
ulimit -c 1024
表示允许生成最大 1024KB 的 core 文件。
再次查看:
bash
ulimit -a
可能看到:
bash
core file size (blocks, -c) 1024
这里还有一个细节:Shell 进程设置了 Resource Limit 后,Shell 创建出来的子进程会继承这个限制。
💡这就像宿舍管理员给一个宿舍设置了"最多放 1024KB 大小的异常记录文件"规则,这个规则会影响从这个宿舍里出去执行任务的程序。
10.4 父进程如何判断子进程是否 core dump
cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
int main()
{
// 创建子进程
if (fork() == 0)
{
sleep(1);
int a = 10;
// 子进程制造除 0 异常
// 这会产生 SIGFPE
a /= 0;
exit(0);
}
int status = 0;
// 父进程等待任意子进程退出
// status 用于保存子进程退出信息
waitpid(-1, &status, 0);
// status 的低 7 位表示终止信号
// 第 7 位表示是否产生 core dump
printf("exit signal: %d, core dump: %d\n",
status & 0x7F,
(status >> 7) & 1);
return 0;
}
这里需要理解 status 的位结构:
text
低 7 位:终止信号编号
第 7 位:是否 core dump
高位:正常退出码等信息
这也是为什么 Linux 中父进程可以通过 waitpid 拿到子进程的退出原因。
11. 信号保存:未决和阻塞
11.1 信号递达、未决、阻塞
几个概念非常关键:
- 信号递达:实际执行信号处理动作;
- 信号未决:信号已经产生,但还没有被处理;
- 信号阻塞:进程暂时不允许某个信号递达。
注意:阻塞和忽略不一样。
- 阻塞:信号暂时不能递达;
- 忽略:信号递达后选择不处理。
💡阻塞像是手机开了"勿扰模式",消息来了但先不提醒;忽略像是你看到了消息,但决定不回复。
11.2 信号在内核中的表示
每个进程的 PCB 中会保存信号相关信息。
可以抽象成三个部分:
text
block pending handler
阻塞表 未决表 处理动作表
内核中相关结构大致如下:
c
// Linux 内核中 task_struct 的部分结构示意
struct task_struct {
// ...
// 指向信号处理动作表
struct sighand_struct *sighand;
// 当前进程的阻塞信号集
sigset_t blocked;
// 当前进程的未决信号集
struct sigpending pending;
// ...
};
信号处理动作结构:
c
struct sighand_struct {
atomic_t count;
// 每个信号都有对应的处理动作
struct k_sigaction action[_NSIG];
// 用于保护信号相关结构的自旋锁
spinlock_t siglock;
};
信号处理动作:
c
struct __new_sigaction {
// 信号处理函数指针
__sighandler_t sa_handler;
// 信号处理标志位
unsigned long sa_flags;
// 恢复函数,部分平台使用
void (*sa_restorer)(void);
// 执行信号处理函数期间需要额外屏蔽的信号集
__new_sigset_t sa_mask;
};
未决信号结构:
c
struct sigpending {
// 实时信号可能需要队列
struct list_head list;
// 普通信号用位图表示是否未决
sigset_t signal;
};
11.3 sigset_t 的理解
sigset_t 可以理解成一个位图。
每个信号对应一个 bit:
- 在阻塞信号集中,bit 为 1 表示该信号被阻塞;
- 在未决信号集中,bit 为 1 表示该信号处于未决状态。
普通信号在递达之前产生多次,通常只记录一次。
💡这就像签到表上某个人的名字只需要打一个勾。即使他来了好几次,普通签到表也不会记录来过几次,只知道"他来过"。

12. 信号集操作函数
12.1 基本信号集函数
c
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
函数解释:
sigemptyset:初始化信号集,使所有 bit 清 0;sigfillset:初始化信号集,使所有 bit 置 1;sigaddset:把某个信号加入信号集;sigdelset:从信号集中删除某个信号;sigismember:判断某个信号是否在信号集中。
使用这些函数的原因是:sigset_t 的内部实现和系统有关,不应该直接操作它的内部数据。
12.2 sigprocmask 函数
sigprocmask 用于读取或修改当前进程的信号屏蔽字。
c
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数解释:
how:修改方式;set:新的信号集;oldset:保存原来的信号屏蔽字。
how 常见取值:
| how | 含义 |
|---|---|
SIG_BLOCK |
把 set 中的信号添加到阻塞信号集 |
SIG_UNBLOCK |
从阻塞信号集中移除 set 中的信号 |
SIG_SETMASK |
直接用 set 替换当前阻塞信号集 |
12.3 sigpending 函数
sigpending 用于获取当前进程的未决信号集。
c
#include <signal.h>
int sigpending(sigset_t *set);
参数:
set:输出型参数,用于保存当前未决信号集。
返回值:
- 成功返回
0; - 失败返回
-1。
12.4 验证信号阻塞和未决
下面这个程序可以帮助理解"阻塞"和"未决"。
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 打印当前 pending 信号集
void PrintPending(const sigset_t &pending)
{
// 普通信号一般看 1~31
for (int signo = 1; signo <= 31; signo++)
{
// sigismember 判断 signo 是否在 pending 集合中
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
sigset_t blockSet;
sigset_t oldSet;
// 初始化 blockSet 为空集
sigemptyset(&blockSet);
// 把 SIGINT 加入 blockSet
// 这一步只是修改变量,还没有真正影响进程
sigaddset(&blockSet, SIGINT);
// 把 SIGINT 添加到当前进程的阻塞信号集中
// oldSet 保存修改前的阻塞信号集
sigprocmask(SIG_BLOCK, &blockSet, &oldSet);
int count = 0;
while (true)
{
sigset_t pendingSet;
// 获取当前进程的未决信号集
sigpending(&pendingSet);
// 打印 pending 位图
PrintPending(pendingSet);
sleep(1);
count++;
// 10 秒后解除 SIGINT 阻塞
if (count == 10)
{
std::cout << "解除对 SIGINT 的阻塞" << std::endl;
// 恢复原来的阻塞信号集
sigprocmask(SIG_SETMASK, &oldSet, nullptr);
}
}
return 0;
}
运行后,在前 10 秒内按 Ctrl+C,程序不会退出,但可以看到 pending 中对应位变成 1。
10 秒后解除阻塞,SIGINT 才会递达,此时进程执行默认动作退出。
这个实验把"信号不是马上处理"体现得很明显。
13. 捕捉信号
13.1 信号捕捉流程
信号捕捉并不是简单地在当前位置调用一个函数。
大致流程是:
- 进程正在用户态执行代码;
- 由于中断、系统调用或异常进入内核态;
- 内核发现有信号需要递达;
- 内核准备让进程返回用户态时执行用户自定义处理函数;
- 用户态执行信号处理函数;
- 处理函数返回;
- 再通过特殊机制回到原来的执行位置。
也就是说,信号处理函数虽然是用户写的,但它的调用时机是由内核安排的。

13.2 sigaction 函数
相比 signal,sigaction 更标准、更可靠。
函数原型:
c
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数解释:
signum:要处理的信号编号;act:新的信号处理动作;oldact:保存旧的信号处理动作。
struct sigaction 常见成员:
c
struct sigaction {
void (*sa_handler)(int);
sigset_t sa_mask;
int sa_flags;
};
其中:
sa_handler:信号处理函数;sa_mask:执行处理函数期间额外屏蔽的信号;sa_flags:控制信号处理行为的标志位。
示例:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstring>
void handler(int signo)
{
std::cout << "捕捉到信号: " << signo << std::endl;
}
int main()
{
struct sigaction act;
// 清空结构体,避免未初始化字段造成问题
memset(&act, 0, sizeof(act));
// 设置处理函数
act.sa_handler = handler;
// 初始化 sa_mask 为空
// 表示执行 handler 时,不额外屏蔽其他信号
sigemptyset(&act.sa_mask);
// 默认标志
act.sa_flags = 0;
// 捕捉 SIGINT
sigaction(SIGINT, &act, nullptr);
while (true)
{
std::cout << "process is running..." << std::endl;
sleep(1);
}
return 0;
}
14. 操作系统运行与中断理解
14.1 硬件中断
硬件中断是硬件设备通知 CPU 的方式。
比如:
- 键盘输入;
- 鼠标移动;
- 网卡收到数据;
- 磁盘 IO 完成;
- 时钟中断。
硬件中断让 CPU 不需要一直轮询设备状态,而是设备有事时主动通知 CPU。
💡如果没有中断,CPU 就像一个人不停问:"键盘有输入吗?网卡有数据吗?磁盘好了没?"这样效率会非常低。
14.2 时钟中断
时钟中断是操作系统实现进程调度的重要基础。
操作系统需要知道:
- 当前进程运行多久了;
- 时间片是否用完;
- 是否需要切换进程;
- 定时器是否到期;
alarm是否应该触发。
如果没有时钟中断,操作系统就很难稳定地进行时间管理。
14.3 死循环为什么不会卡死整个系统
一个用户程序写死循环:
c
while (1)
{
}
它不会让整个操作系统彻底卡死,原因之一就是有时钟中断。
时钟中断会周期性打断当前正在运行的进程,让操作系统重新获得 CPU 控制权,然后进行调度。
所以即使某个进程死循环,操作系统仍然可以在时间片到期后切换到其他进程。
14.4 软中断与信号
信号可以理解为软件层面的中断。
硬件中断是硬件通知 CPU,信号是操作系统通知进程。
它们不是同一个层级的东西,但思想上很相似:都是打断原本执行流,让系统去处理某个异步事件。
15. 可重入函数
15.1 什么是可重入
可重入函数指的是:一个函数在执行过程中被打断后,再次进入执行,也不会产生错误结果。
在信号处理函数中,要特别注意可重入问题。
因为信号可能在程序执行任意位置到来。如果信号处理函数中调用了不可重入函数,就可能破坏原本的数据状态。
15.2 为什么信号处理函数要谨慎
例如程序正在执行某个库函数,这个库函数内部可能正在修改全局缓冲区。此时信号突然到来,信号处理函数也调用了同一个库函数,就可能造成数据混乱。
💡这就像一个人正在整理书架,刚把书拿出来还没放回去,另一个人突然也来整理同一个书架,两个人都以为自己在整理,结果书反而乱了。
因此信号处理函数中应该尽量:
- 简短;
- 不做复杂逻辑;
- 避免调用不可重入函数;
- 必要时只设置一个全局标志位,让主流程后续处理。
16. volatile 关键字
16.1 volatile 的作用
volatile 用来告诉编译器:这个变量可能会被当前执行流之外的因素修改,不要对它做过度优化。
在信号场景中,信号处理函数可能修改某个全局变量,而主流程也会读取它。
如果没有 volatile,编译器可能认为变量没有变化,从而进行优化,导致主流程看不到变化。
示例:
c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// volatile 表示该变量可能被异步修改
volatile int flag = 0;
void handler(int signo)
{
// 信号处理函数中修改 flag
flag = 1;
}
int main()
{
signal(SIGINT, handler);
while (!flag)
{
// 主流程一直等待 flag 变化
}
printf("收到 SIGINT,flag 被修改,程序退出\n");
return 0;
}
这里 flag 可能被信号处理函数异步修改,所以使用 volatile 更安全。
17. SIGCHLD 信号
17.1 SIGCHLD 的作用
当子进程退出、停止或继续运行时,父进程会收到 SIGCHLD 信号。
这个信号常用于父进程回收子进程,避免僵尸进程。
17.2 子进程退出通知父进程
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <stdlib.h>
void handler(int signo)
{
std::cout << "父进程收到 SIGCHLD: " << signo << std::endl;
// 使用 waitpid 回收子进程
// -1 表示等待任意子进程
// WNOHANG 表示非阻塞等待,避免 handler 卡住
while (waitpid(-1, nullptr, WNOHANG) > 0)
{
std::cout << "成功回收一个子进程" << std::endl;
}
}
int main()
{
// 捕捉 SIGCHLD
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
// 子进程
std::cout << "我是子进程: " << getpid() << std::endl;
sleep(3);
exit(0);
}
// 父进程
while (true)
{
std::cout << "我是父进程: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
这里使用 while (waitpid(-1, nullptr, WNOHANG) > 0) 是因为可能有多个子进程几乎同时退出,而普通信号可能不会记录多次,所以要循环回收。
17.3 SIGCHLD 的常见误区
误区一:子进程退出后会自动完全消失。
实际上,子进程退出后,如果父进程不回收,它会变成僵尸进程。
误区二:收到一次 SIGCHLD 只需要回收一个子进程。
如果多个子进程同时退出,父进程可能只收到有限次数的 SIGCHLD,所以处理函数里应该循环 waitpid。
误区三:在信号处理函数中使用阻塞 wait。
如果没有子进程可回收,阻塞等待可能导致程序卡住,所以更推荐使用 WNOHANG。

18. 高频技术题 / 面试题
18.1 什么是 Linux 信号?
信号是 Linux 中进程之间进行异步事件通知的一种机制,也可以理解为一种软中断。
它可以由终端按键、系统调用、软件条件、硬件异常等产生。信号产生后,内核会在进程相关数据结构中记录信号状态,并在合适时机让信号递达。
信号处理动作主要有三种:
- 默认处理;
- 忽略;
- 自定义捕捉。
18.2 Ctrl+C 为什么能终止进程?
Ctrl+C 会被终端驱动和操作系统解释为 SIGINT 信号,然后发送给当前前台进程。
SIGINT 的默认处理动作是终止进程,所以程序会退出。
如果程序使用 signal(SIGINT, handler) 修改了处理动作,那么 Ctrl+C 就不会再执行默认终止动作,而是执行自定义函数。
18.3 kill 函数是不是一定会杀死进程?
不是。
kill 的本质是向指定进程发送指定信号。
c
int kill(pid_t pid, int sig);
如果发送的是 SIGTERM、SIGKILL 等信号,进程可能会终止。但如果发送的是其他信号,或者该信号被捕捉、忽略、阻塞,进程不一定退出。
需要特别注意:SIGKILL 和 SIGSTOP 不能被捕捉,也不能被忽略。
18.4 阻塞信号和忽略信号有什么区别?
阻塞是信号产生后暂时不递达,信号会保持未决状态。
忽略是信号递达后的一种处理动作,表示进程选择不处理它。
所以:
text
阻塞:先不让我看到
忽略:我看到了,但我不处理
这是两个完全不同的概念。
18.5 pending 信号集是干什么的?
pending 信号集用于记录已经产生但尚未递达的信号。
如果某个信号被阻塞,那么它产生后不会立即处理,而是会在 pending 中被标记为未决。
当该信号解除阻塞后,内核会在合适时机让它递达。
18.6 普通信号产生多次会记录多次吗?
普通信号在递达之前产生多次,通常只记录一次。
因为普通信号的 pending 状态一般用 bit 位表示,一个 bit 只能表示"有没有",不能表示"有几个"。
实时信号则不同,实时信号可以排队记录多次。
18.7 为什么信号处理函数要尽量简单?
因为信号是异步到来的,可能在程序执行任意位置打断当前流程。
如果信号处理函数中调用了不可重入函数,可能破坏原本正在使用的数据结构,导致程序出现不可预期的问题。
所以信号处理函数最好只做简单操作,比如设置标志位,然后让主流程处理复杂逻辑。
18.8 什么是 Core Dump?
Core Dump 是进程异常终止时,操作系统把进程用户空间内存保存到磁盘文件中的过程。
它常用于事后调试。比如程序段错误后,可以用 gdb 分析 core 文件,查看崩溃时的调用栈和变量值。
默认情况下,系统可能不允许生成 core 文件,可以通过:
bash
ulimit -c 1024
设置允许生成 core 文件的大小。
18.9 子进程退出后为什么会产生 SIGCHLD?
当子进程退出时,内核需要通知父进程去回收子进程资源,于是会向父进程发送 SIGCHLD 信号。
父进程可以捕捉 SIGCHLD,然后调用 waitpid 回收子进程,避免僵尸进程。
推荐写法是:
c
while (waitpid(-1, NULL, WNOHANG) > 0)
{
}
这样可以一次回收多个已经退出的子进程,并且不会阻塞。
18.10 为什么除 0 和野指针会变成信号?
除 0 会被 CPU 检测为算术异常,非法地址访问会被 MMU 检测为内存访问异常。
这些硬件异常会进入内核,内核再把它们解释成对应信号:
- 除 0:通常对应
SIGFPE; - 野指针或非法地址访问:通常对应
SIGSEGV。
所以 C/C++ 中的某些运行时错误,在操作系统层面上是通过信号机制处理的。
结语
学完 Linux 信号之后,我最大的感受是:很多以前看起来"理所当然"的现象,其实背后都有一套完整的操作系统机制在支撑。
以前我看到 Ctrl+C,只知道它能终止程序;看到段错误,只知道可能是指针写错了;看到子进程退出,只知道父进程要 wait。但现在再看这些现象,会发现它们都可以被统一放到信号机制里理解。
信号让我重新认识了 Linux 进程控制的异步性。
一个信号从产生到处理,中间可能经历保存、阻塞、未决、递达、捕捉等多个阶段。它不是简单的函数调用,也不是普通的同步流程,而是操作系统在合适时机对进程进行的一种事件通知。
同时,信号也把很多知识串了起来:终端控制、系统调用、硬件异常、内核态和用户态切换、进程等待、僵尸进程、可重入函数、竞态条件等。
这部分内容一开始确实比较绕,但真正理解之后,我感觉它对后面继续学习操作系统、进程控制、网络编程和服务器编程都很有帮助。因为只要程序运行在 Linux 上,就不可能完全绕开信号。