
🔥铅笔小新z:个人主页
🎬博客专栏:Linux学习
💫滴水不绝,可穿石;步履不休,能至渊。

一、信号基础概念与认识
1. 什么是信号
信号(Signal) 是进程之间事件异步通知的一种方式,属于软中断。
- 信号是从纯软件角度模拟硬件中断的行为
- 硬件中断是发给 CPU 的,而信号是发给进程的
- 信号相对于进程的控制流程来说是异步(Asynchronous) 的
生活角度理解信号
| 生活场景 | 信号机制 |
|---|---|
| 你识别快递 | 进程识别信号(内核内置特性) |
| 收到快递通知但暂时不取 | 信号产生但暂不处理(在合适的时候处理) |
| 记住有快递要取 | 信号被保存(未决状态) |
| 取快递并处理 | 信号递达并执行处理动作 |
关于信号处理的核心结论
- 识别信号是内置的 --- 进程识别信号的能力由内核程序员编写在内核中
- 处理方法预先知道 --- 即使信号没有产生,进程也知道该如何处理该信号
- 处理时机 --- 信号不会立即被处理,而是在"合适的时候"处理
- 处理方式有三种 :
- 默认动作(SIG_DFL) --- 执行系统默认处理
- 忽略(SIG_IGN) --- 忽略该信号
- 自定义捕捉 --- 用户注册信号处理函数,信号到来时回调执行
2. 信号的基本概念
查看信号
每个信号都有一个编号 和一个宏定义名称 ,这些宏定义可以在 <signal.h> 中找到:
c
#define SIGINT 2 // 终端中断信号
#define SIGQUIT 3 // 终端退出信号
#define SIGKILL 9 // 杀死进程信号
#define SIGSEGV 11 // 段错误信号
#define SIGALRM 14 // 闹钟信号
#define SIGTERM 15 // 终止信号
// ... 等等
编号 34 以上的是实时信号,常规信号(1~31)在递达之前产生多次只计一次,实时信号可以排队。
查看所有信号的详细信息:man 7 signal
信号处理动作的三种方式
c
// 源码中的定义
#define SIG_DFL ((__sighandler_t) 0) /* 默认处理动作 */
#define SIG_IGN ((__sighandler_t) 1) /* 忽略信号 */
/* 信号处理函数类型 */
typedef void (*__sighandler_t)(int);
实际上 SIG_DFL 和 SIG_IGN 就是把 0 和 1 强制转换为函数指针类型。
3. signal 函数 --- 注册信号处理动作
c
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数说明
| 参数 | 说明 |
|---|---|
signum |
信号编号(如 SIGINT=2) |
handler |
函数指针,表示更改信号的处理动作。收到对应信号时,回调执行 handler 方法 |
返回值
返回之前的信号处理函数指针,出错返回 SIG_ERR。
重要注意
signal 函数仅仅是设置了特定信号的捕捉行为处理方式 ,并不是直接调用处理动作。如果后续特定信号没有产生,设置的捕捉函数永远也不会被调用!
4. 示例代码
4.1 基础示例 --- 捕捉 SIGINT 信号
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 自定义信号处理函数
void handler(int signumber)
{
// signumber 参数为收到的信号编号
std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
// 注册 SIGINT (2号信号) 的自定义处理函数
signal(SIGINT, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
运行结果 :按下 Ctrl+C 后,进程不再退出,而是打印信号编号后继续运行。
4.2 忽略信号
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
// SIG_IGN 表示忽略该信号
signal(SIGINT, SIG_IGN);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
运行结果 :按下 Ctrl+C 没有任何反应,信号被忽略。
4.3 恢复默认处理
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
// SIG_DFL 表示恢复默认处理动作(终止进程)
signal(SIGINT, SIG_DFL);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
运行结果 :按下 Ctrl+C 后进程终止,即默认动作。
5. 关于前台进程与后台进程
Ctrl+C产生的信号只能发给前台进程- 一个命令后面加
&可以放到后台运行 - Shell 可以同时运行一个前台进程和任意多个后台进程
- 只有前台进程 才能接到像
Ctrl+C这种控制键产生的信号 - 可以通过
&和nohup命令控制进程的前后台运行
6. 核心要点总结
- 信号是软中断 --- 模拟硬件中断机制,但层级不同
- 异步通知 --- 信号相对于进程控制流程是异步的
- 三种处理方式 --- 默认、忽略、自定义捕捉
signal只注册不调用 --- 只是设置处理方式,信号没来时不会触发- 前台进程才能接收终端信号 --- 务必注意前台/后台的区别
知识拓展:深入理解信号
1. SIGKILL 和 SIGSTOP 的特殊性
在所有 Linux 信号中,有两个"不可捕捉、不可阻塞、不可忽略"的特权信号:
- SIGKILL (9号) :必须终止进程,是系统管理员杀"失控进程"的最后手段。
kill -9是最常用的强制终止命令。 - SIGSTOP (19号):必须暂停进程,与 SIGTSTP(20) 不同------SIGTSTP 可以被捕捉(比如 Shell 的后台进程组可以通过捕捉 SIGTSTP 避免被暂停)。
只有这两个信号具有最高优先级的强制力,其余所有信号都可以被进程选择忽略或自定义处理。
2. signal 函数的可移植性问题
历史上 signal 在不同 UNIX 标准中有两种语义:
- SysV 语义:信号处理函数执行完毕后,自动重置为 SIG_DFL,需再次调用 signal 重新注册,存在信号丢失风险
- BSD 语义:处理函数注册后持续有效,不会自动重置
Linux 遵循 BSD 语义,但 POSIX 标准明确推荐使用 sigaction 替代 signal,因为 sigaction 在所有类 UNIX 系统上行为一致,且功能更强大。
3. 信号处理函数的异步安全限制
信号处理函数是异步执行的,可能在主程序的任意指令处被中断。因此信号处理函数中只能调用 Async-Signal-Safe 函数(如 write、_exit),不能调用 printf、malloc、free 等非可重入函数。这是很多信号处理 bug 的来源。
总结:什么是信号?
问题:请谈谈你对 Linux 信号的理解。
信号是操作系统提供的一种进程间异步通知机制,本质上是一种软中断------从纯软件角度模拟硬件中断的行为。与硬件中断的关键区别在于:硬件中断是发给 CPU 的,信号是发给进程的。
信号的核心特征包括:
- 异步性:信号相对于进程的控制流是完全异步的,进程无法预知信号何时到来
- 内置识别:进程识别信号的能力由内核提供,每个进程天生就知道如何处理各种信号(信号产生前处理方式就已确定)
- 延迟处理:信号不会立即被处理,而是在"合适的时候"------即进程从内核态返回用户态之前------统一检查并递达
- 三种处理方式:默认动作(SIG_DFL,如 Term/Core/Stop/Cont/Ign)、忽略(SIG_IGN)、自定义捕捉(通过 signal/sigaction 注册回调函数)
特殊限制:SIGKILL(9) 和 SIGSTOP(19) 不能被捕捉、阻塞或忽略,这是系统提供的最强控制手段。signal 函数仅仅是注册处理方式,信号没来时处理函数永远不会被调用。信号处理函数设计时必须考虑异步安全问题,避免调用非可重入函数。
二、信号的产生方式
概述
信号产生的五种方式:
- 通过终端按键产生信号
- 调用系统命令向进程发信号
- 使用函数产生信号(kill, raise, abort)
- 由软件条件产生信号(alarm, SIGPIPE)
- 由硬件异常产生信号(除零、野指针)
所有信号的产生,最终都要由操作系统来执行,因为操作系统是进程的管理者。
1. 通过终端按键产生信号
| 按键 | 信号 | 编号 | 默认动作 | 说明 |
|---|---|---|---|---|
Ctrl + C |
SIGINT | 2 | Term(终止进程) | 中断信号 |
Ctrl + \ |
SIGQUIT | 3 | Core(终止+生成core文件) | 退出信号,用于事后调试 |
Ctrl + Z |
SIGTSTP | 20 | Stop(停止进程) | 将前台进程挂起到后台 |
1.1 Ctrl+\ --- SIGQUIT 示例
c
#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(3号信号)
signal(SIGQUIT, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
- 捕捉时:按
Ctrl+\会打印信号编号,进程继续运行 - 不捕捉时(注释掉
signal行):按Ctrl+\进程终止并提示Quit (core dumped)
1.2 Ctrl+Z --- SIGTSTP 示例
c
#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(20号信号,暂停信号)
signal(SIGTSTP, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
- 捕捉时:按
Ctrl+Z打印信号编号 - 不捕捉时:按
Ctrl+Z进程被挂起(Stopped),可通过fg命令恢复前台运行
1.3 OS如何得知键盘有数据
当用户在键盘上按下按键时,键盘硬件产生硬件中断,操作系统获取到该输入后,将其解释为对应的信号,发送给目标前台进程。
2. 调用系统命令向进程发信号
kill 命令
bash
# 语法
kill -<信号名/信号编号> <进程PID>
# 示例:向进程发送 SIGSEGV(段错误信号)
kill -SIGSEGV 213784
# 等价于
kill -11 213784
注意:通过
kill发送 SIGSEGV 信号也能使正常进程产生段错误,段错误不一定只由非法内存访问产生。
3. 使用函数产生信号
3.1 kill 函数
c
#include <sys/types.h>
#include <signal.h>
/*
* 功能:向指定进程发送指定信号
* 参数:
* pid - 目标进程PID
* sig - 要发送的信号编号
* 返回值:
* 成功返回0,失败返回-1并设置errno
*/
int kill(pid_t pid, int sig);
示例:实现自己的 mykill 命令
c
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
/*
* mykill 命令用法:./mykill -<信号编号> <进程PID>
* 例如:./mykill -11 12345 # 向PID为12345的进程发送11号信号(SIGSEGV)
*/
int main(int argc, char *argv[])
{
// 检查命令行参数个数是否正确
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
return 1;
}
// 解析信号编号(去掉 "-" 前缀)
int number = std::stoi(argv[1] + 1);
// 解析目标进程 PID
pid_t pid = std::stoi(argv[2]);
// 向指定进程发送信号
int n = kill(pid, number);
return n;
}
3.2 raise 函数
c
#include <signal.h>
/*
* 功能:给当前进程发送指定的信号(自己给自己发信号)
* 参数:sig - 要发送的信号编号
* 返回值:成功返回0,失败返回非零
*/
int raise(int sig);
示例:每隔1秒自己给自己发信号
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 信号处理函数
void handler(int signumber)
{
// 整个代码只有这一处打印
std::cout << "获取了一个信号: " << signumber << std::endl;
}
int main()
{
// 先对2号信号(SIGINT)进行捕捉
signal(2, handler);
// 每隔1秒,自己给自己发送2号信号
while (true)
{
sleep(1);
raise(2);
}
}
3.3 abort 函数
c
#include <stdlib.h>
/*
* 功能:使当前进程接收到 SIGABRT 信号而异常终止
* 说明:和 exit 一样,abort 总是会成功,没有返回值
*/
void abort(void);
示例
c
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "获取了一个信号: " << signumber << std::endl;
}
int main()
{
// 捕捉 SIGABRT(6号信号)
signal(SIGABRT, handler);
while (true)
{
sleep(1);
abort(); // 发送 SIGABRT 信号
}
}
重要特性 :即使捕捉了 SIGABRT 信号,abort 函数仍然会终止进程(打印 Aborted)。这是因为 abort 的机制是在信号处理后仍然终止进程。
4. 由软件条件产生信号
4.1 alarm 函数
c
#include <unistd.h>
/*
* 功能:设定一个闹钟,告诉内核在 seconds 秒之后给当前进程发送 SIGALRM 信号
* 参数:seconds - 定时秒数(0 表示取消以前的闹钟)
* 返回值:以前设定的闹钟时间还余下的秒数,如果没有则返回0
*/
unsigned int alarm(unsigned int seconds);
- SIGALRM 信号的默认处理动作是终止当前进程
- alarm 是一次性的:闹钟响一次后自动取消
示例1:测试 IO 效率问题
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
/*
* 带 IO 操作的情况:1秒内计数
* 因为 cout 有大量 IO 操作,CPU 被 IO 拖慢
*/
int main()
{
int count = 0;
alarm(1); // 1秒后发送 SIGALRM
while (true)
{
// IO 操作非常耗时,会拖慢计数速度
std::cout << "count : " << count << std::endl;
count++;
}
return 0;
}
示例2:去掉 IO 操作,CPU 纯计算
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signumber)
{
// 到达1秒时打印最终的计数值(可观的数量级差异)
std::cout << "count : " << count << std::endl;
exit(0);
}
int main()
{
// 捕捉 SIGALRM 信号
signal(SIGALRM, handler);
alarm(1); // 1秒后触发 SIGALRM
while (true)
{
count++; // 纯 CPU 运算,速度极快
}
return 0;
}
对比结论:
- 带 IO 的版本:1秒计数约10万级别
- 纯 CPU 的版本:1秒计数可达数亿级别
- 可见 IO 操作极大地降低了程序效率
示例3:设置重复闹钟(模拟硬件定时中断)
c
#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; // 存储需要周期性执行的任务
// 信号处理函数:相当于时钟中断处理程序
void hanlder(int signo)
{
// 执行所有注册的任务
for (auto& f : gfuncs)
{
f();
}
std::cout << "gcount : " << gcount << std::endl;
// 重设闹钟,实现周期性触发
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); // 启动1秒闹钟
signal(SIGALRM, hanlder); // 注册信号处理函数
while (true)
{
pause(); // 等待信号到来
std::cout << "我醒来了..." << std::endl;
gcount++;
}
}
c
#include <unistd.h>
/*
* 功能:等待信号,使调用进程挂起直到收到信号
* 返回值:仅当收到信号且信号处理函数返回后,pause 返回 -1,errno 设为 EINTR
*/
int pause(void);
4.2 SIGPIPE 信号
由软件条件产生的信号,在"管道"中介绍过:向一个已关闭读端的管道写入数据时产生。
4.3 如何理解软件条件
操作系统内部存在很多软件状态和条件,当这些条件满足时,OS 会向进程发送相应信号。例如:
- 定时器超时 --- alarm 设定的时间到达 → SIGALRM
- 管道异常 --- 向已关闭的管道写数据 → SIGPIPE
4.4 系统闹钟的本质
系统闹钟的本质是操作系统必须自身具备定时功能。内核中的定时器数据结构:
c
// Linux 内核中的定时器结构
struct timer_list {
struct list_head entry; // 链表节点,用于组织定时器
unsigned long expires; // 超时时间(jiffies)
void (*function)(unsigned long); // 超时处理函数
unsigned long data; // 传递给处理函数的参数
struct tvec_t_base_s *base; // 所属的定时器基础结构
};
操作系统管理定时器通常采用时间轮的方式组织,可简单理解为"堆结构"来管理所有定时器。
5. 由硬件异常产生信号
硬件异常被硬件检测到并通知内核,内核向当前进程发送适当的信号。
| 硬件异常 | 信号 | 编号 | 名称 |
|---|---|---|---|
| 除0(CPU运算单元检测) | SIGFPE | 8 | 浮点异常 |
| 非法内存访问(MMU检测) | SIGSEGV | 11 | 段错误 |
5.1 模拟除0
c
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
// 不捕捉时:默认动作产生 Core Dump,进程终止
// signal(SIGFPE, handler); // 8) SIGFPE
sleep(1);
int a = 10;
a /= 0; // 除零操作,CPU运算单元产生异常
while (1);
return 0;
}
5.2 模拟野指针(非法内存访问)
c
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
// 捕捉 SIGSEGV 信号后,进程不会退出,而是不断收到信号
// signal(SIGSEGV, handler);
sleep(1);
int* p = NULL;
*p = 100; // 对空指针解引用,MMU检测到非法访问
while (1);
return 0;
}
5.3 为什么捕捉异常信号后会一直收到信号
当发生除零或野指针等异常时:
- CPU 的控制和状态寄存器会记下异常状态(即位图中的状态标记位)
- 如果捕捉了信号但没有清理异常状态(如没有关闭文件、切换进程等)
- CPU 中保留的上下文数据和寄存器内容仍标记着异常状态
- 因此每次检查都会发现异常未处理,从而不断发出信号
6. Core Dump(核心转储)
6.1 什么是 Core Dump
当一个进程异常终止 时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core,这叫做 Core Dump。
- 用于事后调试(Post-mortem Debug) --- 通过调试器检查 core 文件以查清错误原因
- core 文件中可能包含用户密码等敏感信息,默认不允许产生
6.2 查看与设置 Core Dump
bash
# 查看当前 core 文件限制
ulimit -a
# 允许 core 文件最大为1024KB
ulimit -c 1024
# 查看结果
core file size (blocks, -c) 1024
6.3 子进程退出与 Core Dump
c
#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;
a /= 0; // 除零异常,子进程异常终止
exit(0);
}
// 父进程等待子进程退出
int status = 0;
waitpid(-1, &status, 0);
// 解析退出状态
// status 低7位:终止信号编号
// status 第8位:是否为 core dump
printf("exit signal: %d, core dump: %d\n",
status & 0x7F, // 终止该进程的信号编号
(status >> 7) & 1); // 是否产生了 core dump
return 0;
}
6.4 信号的默认处理动作分类
通过 man 7 signal 查看信号的默认动作:
| 动作 | 说明 |
|---|---|
| Term | 终止进程 |
| Core | 终止进程并产生 Core Dump |
| Ign | 忽略该信号 |
| Stop | 停止进程(挂起) |
| Cont | 如果进程已停止则继续运行 |
常见信号动作示例:
- SIGINT → Term
- SIGQUIT → Core
- SIGKILL → Term
- SIGSEGV → Core
- SIGPIPE → Term
- SIGCHLD → Ign
7. 核心要点总结
- 信号产生五途径:终端按键、系统命令、库函数、软件条件、硬件异常
- 信号本质是软件中断,由 OS 统一管理和分发
- alarm 是一次性的,要实现重复闹钟需在信号处理函数中重设
- 硬件异常(除零、野指针)在系统层面被当作信号处理
- Core Dump 用于事后调试,默认关闭,需
ulimit -c开启 - 信号不立即处理,而是在"合适的时候"(从内核返回用户态前)处理
知识拓展:深入理解信号产生
1. 标准信号 vs 实时信号
Linux 信号分为两大类:
- 标准信号 (1~31):不可靠信号,pending 位图只有 0/1 两种状态,递达前多次产生只计一次,不排队
- 实时信号 (34~64, SIGRTMIN~SIGRTMAX) :可靠信号,支持排队(由
struct list_head list管理),保证递达顺序
信号 32(SIGCANCEL)、33(SIGSETXID) 被 glibc 内部使用,不暴露给用户。
2. 硬件异常信号的"死循环"问题
捕捉 SIGFPE 或 SIGSEGV 后,如果信号处理函数中没有修复异常原因(修正除零、建立合法内存映射等),返回后 CPU 会再次检测到同样的异常,导致信号被不断发送。这就是为什么代码中捕捉 SIGFPE 后会持续收到信号------CPU 的状态寄存器标记未被清除。
3. Core Dump 的实战用法
bash
ulimit -c unlimited # 开启 core dump,不限制大小
./a.out # 运行程序,崩溃后产生 core 文件
gdb ./a.out core # GDB 加载 core 文件调试
(gdb) bt # 查看崩溃时的完整调用栈
(gdb) info locals # 查看局部变量
(gdb) frame 3 # 切换到对应栈帧
Core Dump 是定位程序崩溃原因的利器,默认关闭的原因是 core 文件可能包含用户密码等敏感信息。
4. alarm 函数的使用细节
alarm(0)取消之前设置的闹钟,返回剩余秒数- 一个进程同时只能有一个闹钟,新 alarm 会覆盖旧 alarm
- alarm 默认动作是终止进程(需捕捉 SIGALRM 才能自定义处理)
- 返回值是上一次闹钟剩余的秒数,如果没有则为 0
总结:信号是如何产生的?
问题:请列举 Linux 中信号有哪几种产生方式?背后原理是什么?
Linux 中信号有五种产生方式,所有信号的产生最终都由操作系统统一执行:
-
终端按键:用户按 Ctrl+C(→SIGINT)、Ctrl+\(→SIGQUIT)、Ctrl+Z(→SIGTSTP)。按下按键时键盘产生硬件中断 → OS 捕获中断 → 解释为信号 → 发送给前台进程组。
-
系统命令 :
kill -<信号> <PID>命令通过kill()系统调用向任意进程发送信号。注意 kill 不仅可以发送终止信号,可以发送任意信号(如kill -SIGSEGV让正常进程段错误)。 -
库函数 :
kill(pid, sig)向指定进程发信号;raise(sig)给自己发信号;abort()发送 SIGABRT 并强制终止------即使捕捉了 SIGABRT,abort 仍会终止进程。 -
软件条件 :
alarm(seconds)设定定时器,到期发送 SIGALRM(默认终止进程);向已关闭读端的管道写数据触发 SIGPIPE;资源限制超时也以信号方式通知。 -
硬件异常:除零(CPU 运算单元检测)→ SIGFPE;非法内存访问(MMU 检测)→ SIGSEGV。硬件异常被 CPU 检测后通知内核,内核转发为信号给进程。
信号最终以位图形式记录在进程 PCB(task_struct)的 sigpending 结构中,在适当时机递达。
三、信号的保存与阻塞
1. 信号相关概念
| 概念 | 英文 | 说明 |
|---|---|---|
| 信号递达 | Delivery | 实际执行信号的处理动作 |
| 信号未决 | Pending | 信号从产生到递达之间的状态 |
| 信号阻塞 | Block | 进程选择阻塞某个信号,被阻塞的信号产生后将保持在未决状态 |
| 信号屏蔽字 | Signal Mask | 即阻塞信号集 |
重要区分:阻塞 vs 忽略
- 阻塞:信号被阻塞就不会递达(根本不会去处理)
- 忽略:是信号递达之后的一种可选处理动作
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
2. 信号在内核中的表示
信号在内核中的表示示意图:

2.1 内核数据结构
在 Linux 内核中,信号相关的 PCB 结构如下:
c
// 进程控制块(PCB),定义在 include/linux/sched.h
struct task_struct {
// ...
/* 信号处理函数表 */
struct sighand_struct *sighand;
/* 阻塞信号集(信号屏蔽字) */
sigset_t blocked;
/* 未决信号集 */
struct sigpending pending;
// ...
};
// 信号处理函数描述结构
struct sighand_struct {
atomic_t count; // 引用计数
struct k_sigaction action[_NSIG]; // 信号处理动作数组,_NSIG=64
spinlock_t siglock; // 自旋锁
};
// 新版信号处理动作结构
struct __new_sigaction {
__sighandler_t sa_handler; // 信号处理函数指针
unsigned long sa_flags; // 标志位
void (*sa_restorer)(void); // 恢复函数(暂未使用)
__new_sigset_t sa_mask; // 处理函数执行期间需要额外屏蔽的信号集
};
// 内核信号动作封装
struct k_sigaction {
struct __new_sigaction sa;
void __user *ka_restorer;
};
// 未决信号集结构
struct sigpending {
struct list_head list; // 信号链表(用于实时信号排队)
sigset_t signal; // 信号集位图(标记哪些信号处于未决状态)
};
/* 信号处理函数类型 */
typedef void (*__sighandler_t)(int);
2.2 信号在内核中的存储方式
每个信号在内核中对应两个标志位和一个函数指针:
block(阻塞位图) pending(未决位图)
信号 1 (SIGHUP) → 0 0
信号 2 (SIGINT) → 1 1 ← 被阻塞,暂时不能递达
信号 3 (SIGQUIT) → 0 0
... ... ...
关键规则:
- 信号产生时,内核在 PCB 中设置该信号的 pending 标志(置1)
- 直到信号递达才清除 pending 标志
- 被阻塞的信号即使产生了 pending 标志,也不会递达
- 常规信号在递达之前产生多次只计一次(pending 只有 0/1 两种状态)
- 实时信号在递达之前可以依次放在队列中(由
struct list_head list管理)
2.3 示例图解说明
假设当前进程的状态:
- SIGHUP(1号信号):未阻塞、未产生、默认处理动作
- SIGINT(2号信号):已阻塞、已产生处于未决状态、处理动作是忽略(但必须解除阻塞后才能忽略)
- SIGQUIT(3号信号):未阻塞、未产生、自定义处理函数
注意:虽然 SIGINT 的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
3. sigset_t --- 信号集类型
c
#include <signal.h>
sigset_t 是一个不透明数据类型,对于每种信号用一个 bit 表示"有效"或"无效"状态。
- 阻塞信号集中"有效"的含义:该信号被阻塞
- 未决信号集中"有效"的含义:该信号处于未决状态
使用者只能调用标准函数来操作
sigset_t,不应该对它的内部数据做任何解释(比如用 printf 直接打印是没有意义的)。
信号集操作函数
c
#include <signal.h>
/*
* 清空信号集:将所有信号的对应 bit 清零
* 返回值:成功0,失败-1
*/
int sigemptyset(sigset_t *set);
/*
* 填满信号集:将所有信号的对应 bit 置位
* 返回值:成功0,失败-1
*/
int sigfillset(sigset_t *set);
/*
* 添加信号:将指定信号的对应 bit 置位
* 返回值:成功0,失败-1
*/
int sigaddset(sigset_t *set, int signo);
/*
* 删除信号:将指定信号的对应 bit 清零
* 返回值:成功0,失败-1
*/
int sigdelset(sigset_t *set, int signo);
/*
* 检查信号:判断指定信号是否在信号集中
* 返回值:包含返回1,不包含返回0,出错返回-1
*/
int sigismember(const sigset_t *set, int signo);
注意 :在使用
sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
4. sigprocmask --- 读取/更改信号屏蔽字
c
#include <signal.h>
/*
* 功能:读取或更改进程的信号屏蔽字(阻塞信号集)
* 参数:
* how - 如何修改屏蔽字
* set - 新的屏蔽字(非空时表示要修改)
* oset - 输出旧的屏蔽字(非空时表示要读取)
* 返回值:成功0,失败-1
*/
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
how 参数的三种取值
| 宏 | 值 | 含义 |
|---|---|---|
SIG_BLOCK |
0 | 将 set 中的信号添加到当前屏蔽字中(mask = mask | set) |
SIG_UNBLOCK |
1 | 从当前屏蔽字中移除 set 中的信号(mask = mask & ~set) |
SIG_SETMASK |
2 | 将当前屏蔽字设置为 set(mask = set) |
重要特性
如果调用 sigprocmask 解除 了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,至少将其中一个信号递达。
5. sigpending --- 读取未决信号集
c
#include <signal.h>
/*
* 功能:读取当前进程的未决信号集
* 参数:set - 输出未决信号集
* 返回值:成功0,失败-1
*/
int sigpending(sigset_t *set);
6. 综合示例:阻塞信号、查看未决信号集
c
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
/*
* 打印当前进程的未决信号集(从31号到1号)
* 每位数字表示对应信号是否处于未决状态
*/
void PrintPending(sigset_t &pending)
{
std::cout << "curr process[" << getpid() << "]pending: ";
// 从31到1遍历所有常规信号
for (int signo = 31; signo >= 1; signo--)
{
// sigismember 判断该信号是否在未决信号集中
if (sigismember(&pending, signo))
{
std::cout << 1; // 该信号处于未决状态
}
else
{
std::cout << 0; // 该信号未产生或已递达
}
}
std::cout << "\n";
}
// 信号处理函数
void handler(int signo)
{
std::cout << signo << " 号信号被递达!!!" << std::endl;
std::cout << "-------------------------------" << std::endl;
// 查看递达时的未决信号集(此时该信号应该已被清除)
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
std::cout << "-------------------------------" << std::endl;
}
int main()
{
// 0. 捕捉2号信号(SIGINT),使用自定义处理函数
signal(2, handler); // 自定义捕捉
// signal(2, SIG_IGN); // 忽略一个信号
// signal(2, SIG_DFL); // 信号的默认处理动作
// 1. 屏蔽2号信号
sigset_t block_set, old_set;
sigemptyset(&block_set); // 清空 block_set
sigemptyset(&old_set); // 清空 old_set
sigaddset(&block_set, SIGINT); // 将 SIGINT 添加到 block_set 中
// 1.1 将 block_set 设置到进程的 Block 表中
// SIG_BLOCK 表示将 block_set 中的信号添加到当前屏蔽字
sigprocmask(SIG_BLOCK, &block_set, &old_set);
// 此时2号信号已被屏蔽!
int cnt = 15;
while (true)
{
// 2. 获取当前进程的 pending 信号集
sigset_t pending;
sigpending(&pending);
// 3. 打印 pending 信号集
PrintPending(pending);
cnt--;
// 4. 计数到0时,解除对2号信号的屏蔽
if (cnt == 0)
{
std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
// SIG_SETMASK:直接用 old_set 替换当前屏蔽字
sigprocmask(SIG_SETMASK, &old_set, &block_set);
}
sleep(1);
}
}
运行结果分析
curr process[448336]pending: 0000000000000000000000000000000
curr process[448336]pending: 0000000000000000000000000000000
^C curr process[448336]pending: 0000000000000000000000000000010 ← 按了Ctrl+C,2号信号pending置1
curr process[448336]pending: 0000000000000000000000000000010 ← 因为被阻塞,一直未决
curr process[448336]pending: 0000000000000000000000000000010 ← ...
curr process[448336]pending: 0000000000000000000000000000010
... ← 每1秒打印一次
curr process[448336]pending: 0000000000000000000000000000010
- 程序运行时,每秒钟把各信号的未决状态打印一遍
- 阻塞了 SIGINT(2号信号),按
Ctrl+C会使 SIGINT 处于未决状态 - 按
Ctrl+\(SIGQUIT)仍然可以终止程序,因为 SIGQUIT 未被阻塞 - 解除屏蔽后,SIGINT 信号被递达(执行 handler 函数)
7. 核心要点总结
- 三个核心概念:递达(Delivery)、未决(Pending)、阻塞(Block)
- 阻塞 ≠ 忽略:阻塞阻止信号递达,忽略是递达后的处理方式
- 每个信号两个 bit:block 位 + pending 位,存放在 PCB 中
- 常规信号不排队:产生多次只计一次
- 操作信号集的步骤 :初始化(
sigemptyset/sigfillset)→ 增删(sigaddset/sigdelset)→ 应用到进程(sigprocmask) - 解除阻塞时立即递达:解除对未决信号的阻塞后,至少一个信号会立即被递达
知识拓展:深入理解信号阻塞与未决
1. sigsuspend --- 原子地等待信号
c
int sigsuspend(const sigset_t *sigmask);
sigsuspend 的功能:暂时用 sigmask 替换进程屏蔽字 → 挂起进程等待信号 → 信号处理函数返回后自动恢复原屏蔽字。
与 sigprocmask(SIG_SETMASK, &new, &old) + pause() 组合相比,sigsuspend 的原子性可以避免竞态条件------如果在 sigprocmask 解除阻塞后 pause() 之前信号到达,则 pause() 会永久阻塞。sigsuspend 将这两步合并为原子操作,杜绝了这个问题。
2. 信号阻塞的典型应用场景
- 临界区保护:在操作关键全局数据结构时阻塞可能产生影响的信号,防止信号处理函数并发访问
- 初始化阶段:子进程在 fork 后 exec 前阻塞所有信号,确保初始化过程不被中断
- 父子进程同步:fork 前阻塞 SIGCHLD,完成子进程设置后再解除阻塞,避免信号处理逻辑混乱
3. 多线程中的信号处理
- 每个线程有独立的信号屏蔽字(sigprocmask 仅影响调用线程)
- 所有线程共享信号处理动作(signal/sigaction 对整个进程生效)
- 未决信号是进程级的:信号产生时写入进程的 pending 位图
- 信号递达时内核选择一个不阻塞该信号的线程来处理
4. sigset_t 操作的内核实现
内核中使用位图(bitmap)存储信号集,每个信号对应一个 bit。例如 sigset_t 在 64 位系统上占 8 字节(64 bits),足以覆盖 64 个信号。sigaddset 就是 OR 操作,sigdelset 是 AND NOT 操作。
总结:阻塞、未决与递达三者的关系
问题:请解释信号的阻塞(Block)、未决(Pending)、递达(Delivery)三个概念,以及阻塞与忽略的区别。
这是信号处理最核心的三个概念,理解它们的区别是理解整个信号机制的基础。
概念定义:
- 递达(Delivery):实际执行信号处理动作的时刻
- 未决(Pending):信号从产生到递达之间的中间状态------信号已产生但尚未被处理
- 阻塞(Block):进程主动设置信号屏蔽字,阻止指定信号被递达,被阻塞的信号一直保持在未决状态
阻塞 vs 忽略的本质区别:
- 阻塞是阻止信号递达的"过程控制"------信号根本不会被处理,保持在未决状态,直到解除阻塞
- 忽略是递达之后的"结果处理"------信号仍然完成了递达,只不过递达时执行的动作是忽略(SIG_IGN)
内核实现(PCB 中的三个字段):
- block 位图(阻塞信号集):标记哪些信号被阻塞(1=阻塞)
- pending 位图(未决信号集):标记哪些信号已产生但未递达(1=待处理)
- handler 数组:每个信号对应的处理函数指针(SIG_DFL/SIG_IGN/用户自定义)
处理流程:信号产生 → 内核设置 pending 位 → 递达前检查 block 位 → block=1 保持 pending,block=0 执行 handler。常规信号在 pending 期间多次产生只计一次(位图只有 0/1),实时信号支持排队。
关键特性:解除对未决信号的阻塞时,至少有一个信号会在 sigprocmask 返回前立即被递达。
四、信号的捕捉与处理
1. 信号捕捉的完整流程

当信号的处理动作是用户自定义函数 时,在信号递达时调用这个函数,这称为捕捉信号(Catch)。
信号处理函数的代码在用户空间,处理过程较为复杂:
┌─────────────────────────────────────────┐
│ ① 用户程序 main 函数正在执行 │
│ (当前位于用户态) │
└──────────────┬──────────────────────────┘
│ 发生中断/异常/系统调用
▼
┌─────────────────────────────────────────┐
│ ② 进程切换到内核态 │
│ 内核处理完中断/异常/系统调用后 │
│ 准备返回用户态 │
└──────────────┬──────────────────────────┘
│ 检查 signal pending 位图
▼
┌─────────────────────────────────────────┐
│ ③ 发现有信号递达 │
│ 且 handler 不是 SIG_DFL/SIG_IGN │
└──────────────┬──────────────────────────┘
│ 不恢复 main 上下文
▼
┌─────────────────────────────────────────┐
│ ④ 返回用户态,执行 sighandler 函数 │
│ 使用独立堆栈(非 main 函数栈) │
└──────────────┬──────────────────────────┘
│ sighandler 执行完毕
▼
┌─────────────────────────────────────────┐
│ ⑤ 自动调用 sigreturn 系统调用再次入内核 │
└──────────────┬──────────────────────────┘
│ 检查是否还有信号待处理
▼
┌─────────────────────────────────────────┐
│ ⑥ 没有新信号,恢复 main 函数上下文继续执行 │
└─────────────────────────────────────────┘
关键理解点
sighandler和main函数使用不同的堆栈空间- 它们之间不存在调用和被调用的关系,是两个独立的控制流程
- 回调机制:信号处理函数不是被 main 函数调用,而是被系统所调用

2. sigaction 函数
sigaction 是比 signal 更强大、更可靠的信号处理函数接口。
c
#include <signal.h>
/*
* 功能:读取和修改与指定信号相关联的处理动作
* 参数:
* signo - 指定信号的编号
* act - 非空时,根据 act 修改该信号的处理动作
* oact - 非空时,通过 oact 传出该信号原来的处理动作
* 返回值:成功0,出错-1
*/
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction 结构体
c
struct sigaction {
void (*sa_handler)(int); // 信号处理函数(或 SIG_DFL / SIG_IGN)
void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理函数
sigset_t sa_mask; // 处理函数执行期间额外屏蔽的信号集
int sa_flags; // 标志位
void (*sa_restorer)(void); // 恢复函数(已废弃,不应使用)
};
sa_handler 的三种赋值
| 赋值 | 含义 |
|---|---|
SIG_IGN |
忽略信号 |
SIG_DFL |
执行系统默认动作 |
| 函数指针 | 用自定义函数捕捉信号(回调函数) |
sa_mask 的自动屏蔽特性
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字 ,当信号处理函数返回时自动恢复原来的信号屏蔽字。
这保证了:在处理某个信号时,如果这种信号再次产生,它会被阻塞到当前处理结束为止。
如果除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明。
3. sigaction 使用示例
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstring>
void handler(int sig)
{
std::cout << "捕获到信号: " << sig << std::endl;
}
int main()
{
struct sigaction act, oact;
// 清空 act 结构体
memset(&act, 0, sizeof(act));
// 设置信号处理函数
act.sa_handler = handler;
// 清空 sa_mask,表示处理 SIGINT 时只自动屏蔽 SIGINT 本身
sigemptyset(&act.sa_mask);
// 设置标志位(0 表示使用 sa_handler)
act.sa_flags = 0;
// 注册 SIGINT 的处理动作,并保存旧的动作到 oact
sigaction(SIGINT, &act, &oact);
while (true)
{
pause();
}
return 0;
}
常用的 sa_flags 选项
| 标志 | 说明 |
|---|---|
SA_NOCLDSTOP |
子进程停止时不产生 SIGCHLD 信号 |
SA_NOCLDWAIT |
子进程终止时自动清理,不产生僵尸进程 |
SA_SIGINFO |
使用 sa_sigaction 而非 sa_handler |
SA_RESTART |
被信号中断的系统调用自动重启 |
4. 核心要点总结
- 信号捕捉的六步流程:用户态运行 → 中断/异常进内核 → 检查信号 → 返回用户态执行 sighandler → sigreturn 回内核 → 恢复 main 上下文
sighandler和main使用独立堆栈 ,是两个独立的控制流程- sigaction 比 signal 更强大:支持额外屏蔽信号、多种标志位
- 自动屏蔽当前信号:处理信号期间,同种信号再次产生会被阻塞
- sigreturn 系统调用:信号处理函数返回后通过它重新进入内核
知识拓展:深入理解信号捕捉
1. 慢速系统调用与 EINTR 错误
慢速系统调用(如 read 从终端/管道/网络读取、pause、wait)可能使进程永久阻塞。当进程阻塞其间收到信号时:
- 系统调用被中断,返回 -1
errno设为EINTR- 调用
sigaction时设置SA_RESTART标志,内核会自动重启被中断的系统调用,程序员无需手动处理 EINTR
典型场景:网络服务器中 read 被信号中断后返回 EINTR,未处理该错误会导致服务器异常关闭。SA_RESTART 可以透明地解决这个问题。
2. 异步信号安全函数列表
信号处理函数中可以安全调用的函数(Async-Signal-Safe):
write、read、open、close(不是 printf/fprintf!)wait、waitpidsigaction、sigprocmask、sigpending、sigsuspendexit、_exit、_Exitgetpid、getuid、getgidabort、raise
禁止在信号处理函数中调用的函数:
printf/fprintf/sprintf(使用标准 I/O 全局缓冲区)malloc/free(使用全局链表管理堆)pthread_*系列函数- 任何可能持有锁的函数(可能死锁)
3. SA_SIGINFO 标志与 siginfo_t
设置 sa_flags |= SA_SIGINFO 后使用 sa_sigaction 而非 sa_handler:
c
void handler(int sig, siginfo_t *info, void *context);
siginfo_t 包含详细信息:发送者 PID(si_pid)、发送者 UID(si_uid)、信号原因(si_code,如 SI_USER/SI_KERNEL/SI_QUEUE),甚至硬件异常地址(si_addr,对 SIGSEGV 可定位到具体哪个地址访问非法)。
总结:信号捕捉的完整流程
问题:当信号被捕捉时,从产生到处理完成经历哪些步骤?请详细描述。
信号捕捉(用户自定义处理函数)的完整流程分为六步:
- 用户态正常运行:main 函数的控制流程在用户态正常执行
- 进入内核态:发生中断/异常/系统调用,进程从用户态切换到内核态
- 检查信号:内核处理完毕后准备返回用户态前,检查进程的 pending 信号集,发现有信号需要递达且处理动作为用户自定义函数
- 执行 sighandler:内核不直接恢复 main 的上下文,而是先返回用户态,在独立堆栈空间中执行用户注册的信号处理函数
- sigreturn 回内核 :sighandler 执行完毕后自动调用
sigreturn系统调用重新进入内核态 - 恢复 main 上下文:内核再次检查信号,若没有新信号则恢复 main 函数的上下文,继续执行
关键理解:
sighandler和main使用不同的堆栈空间 ,是两个独立的控制流程- 它们之间不存在调用与被调用关系------sighandler 是被内核回调的
sigaction比signal更强大的原因:支持SA_RESTART(自动重启中断的系统调用)、sa_mask(处理期间额外屏蔽其他信号)、SA_SIGINFO(获取信号的详细来源信息)- 自动屏蔽当前信号:处理信号期间内核自动将该信号加入屏蔽字,同种信号再次产生会被阻塞,处理完成后自动恢复
五、操作系统运行机制(中断、系统调用、内核态/用户态)
1. 概述
要深入理解信号,必须理解操作系统是如何运行的。操作系统的运行依赖中断机制,可以分为三类:
| 中断类型 | 触发方式 | 示例 | 类比 |
|---|---|---|---|
| 硬件中断 | 外部硬件设备触发 | 键盘输入、硬盘读写完成 | 快递员按门铃 |
| 时钟中断 | 定时器周期性触发 | 进程调度的时间片轮转 | 闹钟定时响起 |
| 软中断(陷阱) | CPU 内部软件触发 | int 0x80 系统调用 |
主动打电话给物业 |
| 异常 | CPU 内部错误触发 | 除零、缺页、野指针 | 家里水管爆了 |
一句话总结:操作系统就是躺在中断处理例程上的代码块!
2. 硬件中断

外部设备触发中断时,CPU 暂停当前工作,执行对应的中断处理程序。
c
// Linux 内核 0.11 --- 中断向量表初始化
void trap_init(void)
{
int i;
// 设置各种异常的中断向量处理函数
set_trap_gate(0, ÷_error); // 除零错误
set_trap_gate(1, &debug); // 调试异常
set_trap_gate(2, &nmi); // 非屏蔽中断
set_system_gate(3, &int3); // 断点指令
set_system_gate(4, &overflow); // 溢出
set_system_gate(5, &bounds); // 边界检查
set_trap_gate(6, &invalid_op); // 无效操作码
set_trap_gate(7, &device_not_available); // 设备不可用
set_trap_gate(8, &double_fault); // 双重错误
set_trap_gate(9, &coprocessor_segment_overrun);
set_trap_gate(10, &invalid_TSS);
set_trap_gate(11, &segment_not_present);
set_trap_gate(12, &stack_segment);
set_trap_gate(13, &general_protection); // 通用保护
set_trap_gate(14, &page_fault); // 缺页异常
set_trap_gate(15, &reserved);
set_trap_gate(16, &coprocessor_error);
// int 17~48 暂时设为 reserved
for (i = 17; i < 48; i++)
set_trap_gate(i, &reserved);
set_trap_gate(45, &irq13); // 协处理器
set_trap_gate(39, ¶llel_interrupt); // 并行口
}
中断向量表是操作系统的一部分,系统启动时就加载到内存中了。
通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。
3. 时钟中断 --- 操作系统的"心跳"

问题:操作系统自己被谁推动执行?
答案:时钟中断 --- 硬件定时器(如 Intel 8253/8254 PIT)会每隔固定时间产生一次中断,这个中断会触发操作系统的调度程序。
c
// Linux 内核 0.11 --- main.c
void main(void)
{
// ...
sched_init(); // 调度程序初始化
// ...
}
// 调度程序的初始化
void sched_init(void)
{
// ...
// 设置时钟中断门(IRQ0,中断向量0x20)
set_intr_gate(0x20, &timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调用中断门
set_system_gate(0x80, &system_call);
// ...
}
时钟中断的处理流程
assembly
; system_call.s 中的时钟中断处理
_timer_interrupt:
; ... 保存寄存器状态
call _do_timer ; 调用 do_timer 函数
; ... 恢复寄存器
c
// 时钟中断处理函数
void do_timer(long cpl)
{
// ...
schedule(); // 进程调度入口
}
// 进程调度函数
void schedule(void)
{
// ...
switch_to(next); // 切换到下一个任务运行
}
核心结论:
- 操作系统在硬件时钟的推动下自动调度
- 时钟中断是操作系统的"心跳"
- 时间片就是在这个机制下实现的------每个进程运行一段时间后,时钟中断触发,切换到下一个进程
4. 操作系统的本质 --- 死循环
c
// Linux 内核 0.11 --- main.c
void main(void)
{
// ... 各种初始化操作 ...
/*
* 注意!! 对于任何其它的任务,'pause()'意味着我们必须等待收到一个信号才会返
* 回就绪运行态。任务0 在任何空闲时间里都会被激活,当没有其它任务在运行时,
* 我们回到这里,一直循环执行'pause()'。
*/
for (;;)
pause(); // 永远循环等待中断
}
// end main
操作系统的本质:一个死循环 + 中断处理。
- 操作系统自己不做任何事情
- 需要什么功能,就向中断向量表中添加方法即可
- 平时处于
pause()睡眠状态,中断到来时才被唤醒执行对应的处理
与 CPU 主频的关系
- CPU 主频决定了时钟中断的频率
- 主频越快,单位时间内时钟中断次数越多
- OS 在每个时钟中断中有机会检查和调度进程
- 所以 CPU 主频是 OS 调度执行速度的参考之一
5. 软中断(陷阱)--- 系统调用的实现
5.1 什么是软中断
不是由外部硬件触发,而是由软件(CPU 内部)主动触发的中断逻辑。
CPU 为此设计了专门的汇编指令:
int 0x80(x86 传统方式)syscall(x86-64 现代方式)
5.2 系统调用的完整过程
用户程序调用库函数(如 fopen)
│
▼
glibc 库函数封装
│
▼
__open()
│
▼
INLINE_SYSCALL(open, 3, ...)
│
▼
执行 syscall 或 int 0x80 指令(软中断)
│
▼
CPU 触发软中断 → 进入内核态
│
▼
根据系统调用号在 sys_call_table 中查表
│
▼
执行对应的内核函数(如 sys_open)
│
▼
返回结果给用户态
5.3 系统调用号与跳转表
c
// include/linux/sys.h --- 系统调用函数指针表
// 用于系统调用中断处理程序(int 0x80),作为跳转表
extern int sys_setup();
extern int sys_exit();
extern int sys_fork();
extern int sys_read();
extern int sys_write();
extern int sys_open();
extern int sys_close();
// ... (约70个系统调用)
// 系统调用函数指针数组
fn_ptr sys_call_table[] = {
sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid,
sys_creat, sys_link, sys_unlink, sys_execve,
// ... 更多系统调用
};
系统调用号的本质就是数组下标!
assembly
; 系统调用入口汇编代码 _system_call
_system_call:
; 检查调用号是否超出范围
cmp eax, nr_system_calls - 1
ja bad_sys_call
; 保存原段寄存器值
push ds
push es
push fs
; ebx, ecx, edx 中存放系统调用的参数
push edx
push ecx
push ebx
; 设置内核数据段
mov edx, 10h
mov ds, dx
mov es, dx
; 根据系统调用号调用对应的处理函数
; sys_call_table + eax * 4 = 对应处理函数的地址
call [_sys_call_table + eax * 4]
; ... 检查进程状态,必要时重新调度 ...
5.4 系统调用编号定义
c
// linux-2.6.18/include/asm-x86_64/unistd.h
/* 至少每个 cache line 8 个系统调用 */
#define __NR_read 0
__SYSCALL(__NR_read, sys_read)
#define __NR_write 1
__SYSCALL(__NR_write, sys_write)
#define __NR_open 2
__SYSCALL(__NR_open, sys_open)
#define __NR_close 3
__SYSCALL(__NR_close, sys_close)
// ...
#define __NR_rt_sigaction 13
__SYSCALL(__NR_rt_sigaction, sys_rt_sigaction)
#define __NR_rt_sigprocmask 14
__SYSCALL(__NR_rt_sigprocmask, sys_rt_sigprocmask)
// ...
5.5 glibc 如何封装系统调用
用户程序从不需要直接写 int 0x80 或 syscall,因为 glibc 将几乎所有系统调用都封装好了。
c
// glibc 中系统调用的底层封装
#define INTERNAL_SYSCALL_NCS(name, err, nr, args...) \
({ \
unsigned long int resultvar; \
LOAD_ARGS_##nr(args) \
LOAD_REGS_##nr \
asm volatile ( \
"syscall\n\t" \
: "=a" (resultvar) \
: "0" (name) ASM_ARGS_##nr \
: "memory", "cc", "r11", "cx"); \
(long int) resultvar; \
})
系统调用号由 Linux 内核提供(
__NR_xxx),不是 glibc 定义的。glibc 通过SYS_ify(syscall_name)宏将系统调用名称转换为对应的系统调用号。
6. 缺页中断与异常处理
c
void trap_init(void)
{
// ...
set_trap_gate(14, &page_fault); // 缺页异常处理
// ...
}
操作系统中的各种问题最终都会被转换成 CPU 内部的软中断/异常,走中断处理例程:
- 缺页中断 → CPU 触发 page fault → 内核申请内存、填充页表、建立映射
- 内存碎片处理 → 内核在适当时机进行内存整理
- 除零/野指针 → CPU 触发异常 → 内核向进程发送 SIGFPE/SIGSEGV 信号
当程序出现除零或者野指针错误时,虽然我们平时在 C/C++ 层面看到的是"异常"或"信号",但系统底层的实现机制就是 CPU 的异常处理机制。
CPU 内部的软中断(如
int 0x80或syscall)叫做陷阱(Trap) 。CPU 内部的软中断(如除零、野指针等)叫做异常(Exception) 。
现在可以理解为什么叫"缺页异常"了------它本质上就是一种 CPU 内部异常。
7. 内核态与用户态
7.1 特权级别
CPU 指令集有权限分级,以 Intel CPU 为例,划分为 4 个级别(Ring 0 ~ Ring 3):
| 级别 | 名称 | 权限 | 说明 |
|---|---|---|---|
| Ring 0 | 内核态 | 最高 | 可使用所有 CPU 指令集,直接操作硬件 |
| Ring 1 | --- | 较高 | 一般不使用 |
| Ring 2 | --- | 较低 | 一般不使用 |
| Ring 3 | 用户态 | 最低 | 仅能使用常规指令,不能直接操作硬件 |
Linux 系统仅使用 Ring 0(内核态)和 Ring 3(用户态)。
CPU 中有一个标志字段(CPL --- Current Privilege Level)标志着线程的当前特权级别:
- CPL = 0:内核态
- CPL = 3:用户态
7.2 内存空间划分

以 32 位 Linux 为例,每个进程的 4GB 虚拟地址空间划分为:
0xFFFFFFFF ┌─────────────────────┐
│ │
│ 内核空间 (1GB) │ ← 所有进程共享,仅内核态可访问
│ 0xC0000000~ │
0xC0000000 ├─────────────────────┤
│ │
│ 用户空间 (3GB) │ ← 每个进程独立,用户态可访问
│ 0x00000000~ │
0x00000000 └─────────────────────┘
| 状态 | 可访问地址范围 | 说明 |
|---|---|---|
| 用户态 | 0x00000000 ~ 0xBFFFFFFF(低 3GB) | 每个进程独有 |
| 内核态 | 0x00000000 ~ 0xFFFFFFFF(全部 4GB) | 0xC0000000 以上只能内核态访问 |
7.3 三种切换到内核态的场景
1. 系统调用(主动切换)
用户态进程主动通过系统调用向 OS 申请资源:
- 执行
int 0x80或syscall软中断指令 - 这是用户程序主动进入内核的唯一方式
2. 异常(被动切换)
CPU 在执行用户态进程时发生未预知的错误:
- 缺页异常 → 内核分配物理内存
- 除零异常 → 内核发 SIGFPE 信号
- 段错误 → 内核发 SIGSEGV 信号
3. 中断(被动切换)
外围设备完成操作后向 CPU 发出中断信号:
- 硬盘读写完成
- 网络数据到达
- 键盘输入
7.4 切换时 CPU 的工作
- 提权:从 Ring 3 切换到 Ring 0,CPU 指令集权限提升
- 切换栈 :从用户栈切换到内核栈
- CPU 从 TSS(任务状态段)中取出 SS0 和 ESP0
- 将这些值放入 ss 和 esp 寄存器
- 保存现场:保存用户态的寄存器状态到内核栈
- 执行内核代码:在内核栈上执行对应的内核方法
- 恢复现场:内核方法执行完毕后,降权回 Ring 3,恢复用户态上下文
用户态切换到内核态时涉及现场保存与恢复,还要进行安全检查,是比较耗费资源的。
7.5 追踪 fopen 到系统调用的完整路径
fopen() ← 用户程序调用
↓
__fopen_internal() ← glibc 内部
↓
_IO_new_file_fopen() ← glibc 文件操作
↓
_IO_file_open() ← glibc 文件操作
↓
open(Name, Flags, Prot) ← 宏展开为 __open
↓
__open() ← glibc 封装
↓
INLINE_SYSCALL(open, 3, file, oflag, mode) ← 内联系统调用
↓
INTERNAL_SYSCALL_NCS(__NR_open, ...) ← 使用系统调用号
↓
syscall ← CPU 执行 syscall 指令
↓ (软中断,进入内核态)
sys_open() ← 内核中的实际实现
8. 核心要点总结
- 操作系统的本质:一个死循环,在硬件时钟的推动下运行
- 中断是操作系统的驱动力:硬件中断、时钟中断、软中断、异常
- 系统调用通过软中断实现 :
int 0x80/syscall→ 查表跳转 → 内核函数 - 系统调用号 = 数组下标 :
sys_call_table[系统调用号] - glibc 封装了系统调用:用户程序不直接接触软中断指令
- 内核态 vs 用户态:Ring 0 vs Ring 3,本质是指令集权限的区分
- 三种进入内核态的方式:系统调用(主动)、异常(被动)、中断(被动)
- 用户态/内核态切换代价大:涉及提权、栈切换、现场保存与恢复
知识拓展:深入理解操作系统运行机制
1. x86-64 的 syscall/sysret 指令
在 x86-64 架构中,syscall 取代了传统 int 0x80,性能更高:
syscall通过 MSR(Model-Specific Register)存储内核入口地址,比查中断向量表更快- 系统调用号通过 RAX 传递,参数通过 RDI、RSI、RDX、R10、R8、R9 传递(6 个参数)
syscall自动将 RIP 切换到内核入口,RSP 切换到内核栈sysret返回用户态,零 Rings 切换的 CPL 恢复
2. 上下文切换的完整代价
用户态 ↔ 内核态切换的成本构成:
- 模式切换:CPL 从 3 切换为 0(提权),涉及 CS 段寄存器更新
- 栈切换:CPU 从 TSS 中读取 SS0/ESP0,切换到内核栈
- 现场保存:通用寄存器、段寄存器、标志寄存器压入内核栈
- 安全检查:内核验证用户态传入的指针(必须在用户空间地址范围)
- TLB/缓存影响:切换可能影响 TLB 和 L1 缓存命中率
- 返回恢复:恢复所有寄存器、切换回用户栈、降权
这也是为什么频繁系统调用会显著降低性能------每次调用都有数百纳秒到微秒级的开销。
3. 中断上下文 vs 进程上下文
- 中断上下文:运行硬件中断处理程序(ISR),不可睡眠,不可被调度,不能访问用户空间
- 进程上下文:运行系统调用,可睡眠,可被调度,可访问用户空间
这就是为什么 printk(内核打印)可以在任何上下文使用,而 copy_from_user 必须在进程上下文使用的原因。
4. Linux 0.11 与现代内核的对比
虽然学习 Linux 0.11 有助于理解核心原理,但现代内核(5.x/6.x)有显著变化:
- 使用 C 语言重写了大部分汇编代码
- 支持 SMP 多核,使用 RCU 锁等复杂并发机制
sys_call_table被更复杂的 dispatch 机制取代- 进程调度从 O(n) 变为 O(1),再变为 CFS(完全公平调度)
总结:操作系统的运行机制
问题:请描述操作系统是如何运行的?用户态和内核态之间如何切换?
操作系统的本质可以用一句话概括:"一个死循环 + 中断处理" 。内核初始化完成后进入无限循环,平时处于 pause() 等待状态,只有中断到来时才被唤醒执行对应的处理函数。
操作系统的运行完全依赖中断机制,分为四类:
- 硬件中断:外部设备(键盘、硬盘)触发,CPU 被通知,是"由外向内"的被动响应
- 时钟中断:硬件定时器周期性触发,是 OS 的"心跳",推动进程调度和时间片轮转
- 软中断/陷阱(Trap) :CPU 执行
int 0x80/syscall指令主动触发,用于系统调用,是"由内自发"的主动请求 - 异常(Exception):CPU 执行指令时检测到错误(除零、缺页),被动触发
用户态到内核态的切换(完整流程):
- 提权:CPL(Current Privilege Level)从 Ring 3 切换为 Ring 0,指令集权限提升
- 栈切换:CPU 从 TSS 中读取 SS0 和 ESP0 值,切换到内核栈
- 保存现场:用户态的 CS、EIP、EFLAGS 等寄存器压入内核栈
- 执行内核代码 :查系统调用表(
sys_call_table)或中断向量表,执行对应函数 - 恢复与返回:恢复保存的寄存器,降权回 Ring 3,返回用户态继续执行
三种进入内核态的场景:系统调用(主动)、异常(被动)、外部中断(被动)。切换开销较大(涉及模式切换、栈切换、现场保存与恢复),因此高频系统调用应尽可能合并。
六、可重入函数与 volatile 关键字
1. 可重入函数(Reentrant Function)
1.1 什么是重入
当一个函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入(Reentrancy)。
1.2 不可重入的例子
main 函数 sighandler 函数
│ │
▼ ▼
调用 insert(&head, node1) 调用 insert(&head, node2)
│ │
├─ 步骤1: node1->next = head ├─ 步骤1: node2->next = head
├─ 步骤2: head = node1 ← 中断 ├─ 步骤2: head = node2
│ │
(此时 head = node1) (此时 head = node2)
│
▼
(从中断处恢复,执行步骤2: head = node1)
结果:main 函数和 sighandler 先后向链表中插入两个节点,最后只有一个节点真正插入链表中(node1 覆盖了 node2)。
1.3 可重入函数 vs 不可重入函数
| 类型 | 定义 | 特点 |
|---|---|---|
| 可重入函数 | 只访问自己的局部变量或参数 | 无论被谁调用、何时调用都不会出错 |
| 不可重入函数 | 访问全局数据结构 | 重入时可能造成数据错乱 |
1.4 不可重入函数的条件
如果一个函数符合以下条件之一,则是不可重入的:
- 调用了
malloc或free--- 因为malloc使用全局链表来管理堆 - 调用了标准 I/O 库函数 --- 标准 I/O 库的很多实现以不可重入的方式使用全局数据结构
- 使用了全局或静态变量 --- 多个控制流程同时访问同一个全局变量可能导致数据不一致
1.5 可重入函数的编写原则
- 只使用局部变量
- 不调用不可重入的函数
- 不访问全局数据结构(或者用互斥机制保护,但互斥本身在信号处理中也要谨慎使用)
2. volatile 关键字
2.1 问题场景
考虑下面的代码,在信号处理函数中修改全局变量 flag,但 main 中的循环却检测不到变化:
c
#include <stdio.h>
#include <signal.h>
int flag = 0; // 全局标志位
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1; // 在信号处理函数中修改 flag
}
int main()
{
signal(2, handler); // 注册 SIGINT 的处理函数
while (!flag) // 主循环等待 flag 变为 1
{
// 空转等待
}
printf("process quit normal\n");
return 0;
}
正常情况(无优化编译):
bash
$ gcc -o sig sig.c # 默认编译(无优化)
$ ./sig
^Cchage flag 0 to 1
process quit normal # 正常退出
开启优化后:
bash
$ gcc -o sig sig.c -O2 # 开启 O2 优化
$ ./sig
^Cchage flag 0 to 1
^Cchage flag 0 to 1
^Cchage flag 0 to 1 # 永远不会退出!
2.2 原因分析
编译器在开启 -O2 优化时:
- 看到
while(!flag)循环中没有对flag的修改 - 将
flag的值加载到 CPU 寄存器中 - 后续每次循环判断都直接从寄存器读取,不再访问内存
- 信号处理函数修改的是内存中的
flag,寄存器中的值没有更新 - 导致
while循环永远检测不到变化
这就是数据二异性问题:while 检测的 flag 并不是内存中最新的 flag。
2.3 volatile 的作用
volatile 关键字的作用:保持内存的可见性。
c
#include <stdio.h>
#include <signal.h>
volatile int flag = 0; // ← 使用 volatile 修饰
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag) // 现在每次都会从内存读取 flag
{
}
printf("process quit normal\n");
return 0;
}
bash
$ gcc -o sig sig.c -O2 # 即使开启 O2 优化
$ ./sig
^Cchage flag 0 to 1
process quit normal # 正常退出
volatile 告知编译器:
- 被该关键字修饰的变量不允许被优化
- 对该变量的任何操作,都必须在真实的内存中进行
- 每次访问都必须从内存读取,不能使用寄存器中的缓存值
2.4 volatile 的适用场景
- 信号处理函数中修改的全局标志位
- 多线程间共享的全局变量(不过多线程场景更推荐使用原子操作)
- 硬件寄存器的映射地址(MMIO --- 内存映射 IO)
- 与异步事件相关的全局变量
3. 核心要点总结
- 可重入函数只使用局部变量,不依赖全局数据结构
- 不可重入的原因:调用 malloc/free、标准 I/O、使用全局/静态变量
- 重入的风险:不同控制流程交错执行,破坏共享数据的一致性
- volatile 保证内存可见性:禁止编译器优化,强制从内存读取
- 信号处理函数中修改全局标志位,必须用 volatile 修饰
- 使用
volatile时配合-O2及以上优化级别才能体现出作用
知识拓展:深入理解可重入与 volatile
1. sig_atomic_t --- 信号安全的原子类型
C 标准提供 sig_atomic_t 类型,保证对该类型变量的读写是原子的(单条指令完成,不可被中断拆分):
c
#include <signal.h>
volatile sig_atomic_t flag = 0;
sig_atomic_t 通常是 int 类型,跨平台保证信号处理函数中安全读写。超过该类型大小的变量(如 long long、结构体)在多条指令中完成,不是原子的。
2. 编译器屏障 vs CPU 内存屏障
- 编译器屏障(Compiler Barrier) :
volatile的本质是一种编译器屏障,阻止编译器将变量优化到寄存器。更通用的编译器屏障:asm volatile("" ::: "memory")。 - CPU 内存屏障(Memory Barrier) :CPU 可能乱序执行指令,
volatile无法阻止 CPU 级别的乱序。多核场景下需要使用 CPU 内存屏障指令(如mfence、__sync_synchronize)。
结论:volatile 只解决单线程异步重入(信号处理场景)的编译器优化问题,不解决多核场景的 CPU 乱序问题。
3. 可重入 vs 线程安全
- 可重入(Reentrant) :函数执行中被中断后再次进入,结果仍正确。关注单线程内异步重入(信号处理函数)。必须只使用局部变量。
- 线程安全(Thread-Safe) :多个线程同时调用,结果仍正确。关注多线程并发。可以通过互斥锁实现。
一个函数可以既线程安全又可重入(只操作局部变量),也可以线程安全但不可重入(持有锁,但锁本身在中断中不可用)。
4. GCC attribute((noinline)) 与优化观察
在测试 volatile 效果时,可通过以下手段观察编译器是否将变量优化到寄存器:
bash
gcc -O2 -S test.c # 生成汇编代码,检查是否存在 load 指令
objdump -d a.out | less # 反汇编,观察变量访问指令
总结:可重入函数和 volatile
问题:什么是可重入函数?信号处理函数中修改全局变量为什么要用 volatile 修饰?
可重入函数:一个函数在被不同的控制流程调用时,如果第一次调用还没返回就再次进入该函数,执行结果仍然正确,则称该函数是可重入的。
不可重入的条件(满足任一即可):
- 调用了
malloc/free------因为 malloc 使用全局链表管理堆内存 - 调用了标准 I/O 库函数------标准 I/O 的全局缓冲区不是重入安全的
- 使用了全局或静态变量------多个控制流程同时访问导致数据不一致
典型例子:main 和 sighandler 同时调用链表插入函数,sighandler 在 main 执行到一半时插入节点,返回后 main 继续执行导致 node2 被覆盖。
volatile 关键字 :告知编译器该变量不允许被优化到寄存器中,每次访问都必须从真实内存中读取。
为什么需要 volatile :编译器开启 -O2 优化时,发现 while(!flag) 循环中未修改 flag,会将 flag 加载到 CPU 寄存器中,后续循环判断直接从寄存器读取。而信号处理函数修改的是内存中的 flag,导致"数据二异性"------while 检查的 flag 不是内存中最新的值,造成死循环。
用 volatile 修饰后,编译器强制每次循环都从内存读取 flag,即使开启 -O2 优化也能正确检测到信号处理函数的修改。
注意 :volatile 只解决编译器优化问题,不解决 CPU 乱序执行问题。多核环境还需原子操作或内存屏障。在信号处理场景中,更推荐使用 volatile sig_atomic_t 类型。
七、SIGCHLD 信号
1. 背景问题
在进程控制中,父进程需要清理子进程退出后的资源(避免僵尸进程)。传统方式有两种:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 阻塞等待(wait/waitpid 阻塞调用) | 逻辑简单 | 父进程被阻塞,无法处理自己的工作 |
| 非阻塞轮询(WNOHANG 循环检查) | 父进程可以工作 | 需要定时轮询,实现复杂 |
SIGCHLD 信号的出现完美解决了这个问题。
2. SIGCHLD 信号
基本特性
| 特性 | 说明 |
|---|---|
| 触发条件 | 子进程终止时,自动向父进程发送 |
| 默认动作 | 忽略(Ign) |
| 信号编号 | 17(Linux 上) |
| 作用 | 通知父进程有子进程结束,需要处理 |
工作原理
- 父进程自定义 SIGCHLD 信号的处理函数
- 父进程专心处理自己的工作,不必关心子进程
- 子进程终止时,内核自动向父进程发送 SIGCHLD 信号
- 父进程收到信号后,在信号处理函数中调用 wait 清理子进程
3. 示例代码
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
// SIGCHLD 信号处理函数
void handler(int sig)
{
pid_t id;
/*
* 使用 WNOHANG 非阻塞方式回收所有已退出的子进程
* waitpid 返回 > 0 表示成功回收一个子进程
* 循环回收是为了防止多个子进程同时退出时信号丢失
*/
while ((id = waitpid(-1, NULL, WNOHANG)) > 0)
{
printf("wait child success: %d\n", id);
}
printf("child is quit! %d\n", getpid());
}
int main()
{
// 注册 SIGCHLD 信号的处理函数
signal(SIGCHLD, handler);
pid_t cid;
if ((cid = fork()) == 0) // 子进程
{
printf("child : %d\n", getpid());
sleep(3); // 子进程睡眠3秒
exit(1); // 子进程退出,会发送 SIGCHLD 给父进程
}
// 父进程继续执行自己的工作
while (1)
{
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}
关键设计要点
- 使用
WNOHANG:确保 waitpid 不会阻塞信号处理函数 - 循环回收 :使用
while循环而非if,因为多个子进程同时退出时,SIGCHLD 信号可能合并(常规信号不排队),一次处理函数调用要回收所有已退出的子进程 - 信号处理函数要简短:信号处理函数中应尽快完成工作返回
4. 避免僵尸进程的另一种方法
除了自定义 SIGCHLD 处理函数外,还有一种更简单的做法:
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
int main()
{
// 将 SIGCHLD 的处理动作置为 SIG_IGN
// 这样 fork 出来的子进程在终止时会自动清理
signal(SIGCHLD, SIG_IGN);
pid_t cid;
if ((cid = fork()) == 0) // 子进程
{
printf("child : %d\n", getpid());
sleep(3);
exit(1);
}
// 父进程不需要 wait 也不会产生僵尸进程
while (1)
{
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}
重要说明
- 将 SIGCHLD 的处理动作置为
SIG_IGN,子进程终止时会自动清理,不会产生僵尸进程 - 此方法通过系统默认的忽略动作实现,是
/signal.h中的一个特例 - 通常情况下,用户自定义的忽略和系统的默认忽略没有区别
- 此方法在 Linux 上可用,但不保证在其他 UNIX 系统上都可用
5. 两种方式对比
| 方式 | 优点 | 缺点 |
|---|---|---|
| 自定义处理函数 + wait | 可以获取子进程退出状态,更灵活 | 代码稍多 |
| SIG_IGN | 代码最简单 | 无法获取子进程退出状态;非 POSIX 标准,可移植性差 |
6. 核心要点总结
- 子进程终止时自动给父进程发 SIGCHLD 信号
- SIGCHLD 的默认处理动作是忽略
- 自定义 SIGCHLD 处理函数可以让父进程专心工作,子进程结束时自动通知
- 使用循环 + WNOHANG 回收子进程,避免信号丢失
- 设置 SIGCHLD 为 SIG_IGN 可以自动清理子进程,不产生僵尸进程(Linux 特例)
- 父进程应使用
while循环调用waitpid(-1, NULL, WNOHANG),确保一次处理所有已退出的子进程
知识拓展:深入理解 SIGCHLD
1. SIG_IGN 与 wait 的互斥性
如果进程将 SIGCHLD 设置为 SIG_IGN,则后续调用 wait/waitpid 会阻塞直到所有子进程退出,然后返回 -1 并设置 errno 为 ECHILD(没有子进程可等待)。这是因为内核在 SIG_IGN 时已经自动清理了子进程资源,父进程已无子进程可以回收。
2. SA_NOCLDSTOP 与 SA_NOCLDWAIT 标志
使用 sigaction 注册 SIGCHLD 时可附加高级标志:
c
struct sigaction act;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_NOCLDSTOP | SA_NOCLDWAIT;
sigaction(SIGCHLD, &act, NULL);
- SA_NOCLDSTOP:子进程暂停(SIGSTOP/SIGTSTP)时不产生 SIGCHLD,仅在终止时通知
- SA_NOCLDWAIT:类似 SIG_IGN 的效果,子进程终止时自动清理,不产生僵尸进程
3. wait 的竞争条件与信号丢失
常规信号不排队,多个子进程同时退出时 SIGCHLD 可能合并为一次递达。这就是为什么处理函数中必须用 while 循环 waitpid(-1, NULL, WNOHANG) ------ 确保在一次信号处理中回收所有已退出的子进程。如果用 if 而非 while,会造成部分子进程未被回收,形成僵尸进程。
4. SIGCHLD 的跨平台差异
- Linux:默认动作是 Ign(忽略)
- 某些 UNIX 系统:默认动作可能是 SIG_DFL(丢弃信号,不产生僵尸进程)
- POSIX 标准对 SIGCHLD 的默认动作没有强制规定
- 将 SIGCHLD 设为 SIG_IGN 并自动清理子进程是 Linux 特有的行为,非 POSIX 标准,跨平台代码中推荐使用 waitpid + WNOHANG 方式
总结:SIGCHLD 信号
问题:什么是 SIGCHLD 信号?如何用它避免僵尸进程?
SIGCHLD(编号 17)是子进程状态变化(终止、暂停、继续)时由内核自动发送给父进程的信号,默认处理动作是忽略(Ign)。
SIGCHLD 解决了父进程既要处理自己的工作又要回收子进程资源的矛盾。传统方式有两种缺陷:阻塞等待让父进程无法工作,非阻塞轮询又增加复杂度。SIGCHLD 提供了优雅的异步通知方案。
SIGCHLD 处理的最佳实践:
c
void handler(int sig) {
pid_t pid;
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
// 成功回收一个子进程
}
}
三个关键设计要点:
- WNOHANG:确保 waitpid 不会阻塞信号处理函数
- while 循环:常规信号不排队,一次处理函数调用要回收所有已退出的子进程,避免僵尸进程残留
- waitpid(-1, ...):回收任意子进程
另一种方式:将 SIGCHLD 设为 SIG_IGN,内核自动清理子进程,不会产生僵尸进程,也无需 wait 调用:
c
signal(SIGCHLD, SIG_IGN);
这是 Linux 特例,简单直接,但非 POSIX 标准,且无法获取子进程退出状态。跨平台代码应使用 waitpid + WNOHANG 方式。
