在Linux进程管理中,信号(Signal)是最基础、最核心的进程间通信方式,也是内核向进程传递"事件通知"的唯一途径。无论是我们日常使用的Ctrl+C终止程序,还是子进程退出时通知父进程,亦或是程序异常崩溃(如段错误),本质上都是信号在发挥作用。结合之前学的fork、exec进程控制,以及系统调用与库函数的区别,本节课将彻底讲透Linux信号------从信号的本质、常见信号,到信号的处理方式、实操函数,再到信号在进程回收、异常处理中的实际应用,帮大家打通进程控制的最后一环,为后续网络编程、后台服务开发打下基础。
一、先搞懂:什么是信号?(通俗+本质)
简单来说,信号是Linux内核发送给进程的"通知",用来告知进程发生了某个事件,要求进程做出相应的响应。信号就像我们生活中的"门铃"------不用一直盯着门口(轮询),门铃响了(收到信号),就知道有人来了(有事件发生),再去开门(处理信号)。
(一)信号的核心本质
信号的本质是一个整数编号 (每个信号对应唯一的编号),内核通过向进程发送这个"编号",传递事件信息。进程收到信号后,会根据自身的配置,选择"忽略信号""执行默认动作"或"执行自定义处理函数",整个过程是异步的------进程无需主动等待信号,正常执行自身逻辑即可,收到信号后再中断当前逻辑,处理信号。
关键补充:信号是"软中断",与硬件中断(如键盘输入、磁盘IO)类似,会中断进程当前的执行流程,优先处理信号,处理完成后再回到原来的执行位置(除非信号导致进程终止)。
(二)信号的产生场景(高频场景)
日常开发和使用中,信号的产生主要有5种场景,结合我们已学知识,很容易理解:
-
用户输入触发 :最常见的场景,比如
Ctrl+C(发送SIGINT信号)终止前台进程,Ctrl+\(发送SIGQUIT信号)终止进程并生成核心转储文件。 -
进程异常触发:程序运行出错时,内核自动发送信号,比如非法内存访问(段错误,SIGSEGV)、除零错误(SIGFPE)、总线错误(SIGBUS)。
-
进程间主动发送:通过系统调用(kill、raise),一个进程向另一个进程发送信号,比如父进程发送SIGTERM信号,优雅终止子进程。
-
定时器触发:通过alarm系统调用设置定时器,超时后内核向进程发送SIGALRM信号,默认终止进程。
-
内核通知触发:进程触发某些内核事件时,内核发送信号,比如子进程退出时,内核向父进程发送SIGCHLD信号(默认忽略,需手动处理以避免僵尸进程)。
(三)信号的生命周期(核心流程)
一个信号从产生到被处理,分为4个阶段,理解这个流程,能避免后续学习信号处理时踩坑:
-
产生:通过上述5种场景,内核生成一个信号(本质是整数编号),标记给目标进程。
-
递达:内核将信号传递给目标进程,此时进程知道有信号需要处理(信号处于"待处理"状态)。
-
阻塞:进程可以通过设置"信号屏蔽字",暂时阻止某个信号被递达(信号会被暂存,直到阻塞解除后再递达)。注意:阻塞≠忽略,忽略是信号递达后不处理,阻塞是信号不递达。
-
处理:进程收到递达的信号后,执行预设的处理动作(默认、忽略、自定义),处理完成后,恢复原执行流程。
核心提醒:有两个信号无法被阻塞、忽略、自定义处理------SIGKILL(编号9)和SIGSTOP(编号19),这两个信号只能执行默认动作(SIGKILL强制终止进程,SIGSTOP暂停进程),目的是为了让管理员能强制控制进程(比如杀死无法正常终止的进程)。
二、Linux常见信号(必记,面试高频)
Linux系统中共有64种信号(编号1~64),其中前31种是"标准信号"(常用、稳定),后32种是"实时信号"(用于实时系统,日常开发较少用到)。我们重点掌握前31种中最常用的10种,结合其编号、含义、默认动作记忆,搭配场景理解更高效。
(一)常用标准信号汇总表
| 信号编号 | 信号名称 | 核心含义 | 默认动作 | 常见场景 |
|---|---|---|---|---|
| 1 | SIGHUP | 终端挂起或进程脱离终端 | 终止进程 | 终端关闭时,通知后台进程终止 |
| 2 | SIGINT | 中断信号(用户触发) | 终止进程 | 按下Ctrl+C,终止前台进程 |
| 3 | SIGQUIT | 退出信号(用户触发) | 终止进程 + 核心转储 | 按下Ctrl+\,终止进程并生成core文件(用于调试) |
| 9 | SIGKILL | 强制终止信号 | 强制终止进程 | sudo kill -9 PID,强制杀死进程(无法拦截) |
| 11 | SIGSEGV | 段错误(非法内存访问) | 终止进程 + 核心转储 | 指针越界、访问空指针、非法修改只读内存 |
| 14 | SIGALRM | 定时器超时信号 | 终止进程 | alarm(5)设置5秒超时,超时后触发 |
| 15 | SIGTERM | 优雅终止信号 | 终止进程 | kill PID(默认发送此信号),允许进程清理资源后终止 |
| 17 | SIGCHLD | 子进程状态改变(退出/暂停) | 忽略信号 | 子进程退出时,内核通知父进程(需手动处理回收僵尸进程) |
| 19 | SIGSTOP | 暂停进程 | 暂停进程 | kill -19 PID,暂停进程,用SIGCONT(18)恢复 |
| 18 | SIGCONT | 恢复暂停的进程 | 恢复进程运行 | kill -18 PID,恢复被SIGSTOP暂停的进程 |
(二)关键补充:核心转储(Core Dump)
上述信号中,SIGQUIT、SIGSEGV等信号的默认动作包含"核心转储"(Core Dump),这里重点补充:
核心转储是指进程异常终止时,内核将进程当前的内存快照(代码段、数据段、堆、栈、CPU寄存器状态等)保存到一个名为core(或core.PID)的文件中,用于后续调试------通过gdb工具分析core文件,可以快速定位程序崩溃的原因(比如段错误的具体位置)。
注意:Linux系统默认关闭核心转储功能(避免生成过大的core文件占用磁盘),可通过以下命令临时启用:
bash
ulimit -c unlimited # 临时允许生成core文件(大小无限制)
ulimit -c 10240 # 可选:设置core文件最大大小(单位:块,1块=512字节)
查看core文件是否启用:ulimit -c,返回0表示未启用,返回unlimited表示已启用。
(三)查看所有信号的命令
日常开发中,可通过以下命令查看系统所有信号的详细信息(编号、名称、默认动作):
bash
kill -l # 查看所有信号的编号和名称(最常用)
man 7 signal # 查看信号的详细手册(包含默认动作、场景说明)
三、信号的三种处理方式(核心重点)
进程收到信号后,有且只有三种处理方式,开发者可根据需求选择,这也是信号实操的核心:
(一)默认处理(SIG_DFL)
这是最常见的处理方式------进程不对信号做任何自定义配置,收到信号后,执行内核预设的默认动作(如终止、暂停、忽略、核心转储)。
示例:我们编写一个死循环程序,按下Ctrl+C(发送SIGINT信号),进程会执行默认动作(终止),这就是默认处理。
cpp
#include <stdio.h>
#include <unistd.h>
int main() {
printf("进程运行中,PID:%d,按下Ctrl+C终止\n", getpid());
while (1) { // 死循环,等待信号
sleep(1);
}
return 0;
}
(二)忽略信号(SIG_IGN)
进程通过配置,忽略指定的信号------收到信号后,不执行任何动作,继续执行自身逻辑,相当于"没收到信号"。
注意:SIGKILL和SIGSTOP无法被忽略,即使设置忽略,内核也会执行默认动作。
示例:忽略SIGINT信号(Ctrl+C),按下Ctrl+C后,进程不会终止,继续运行:
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
int main() {
// 设置忽略SIGINT信号
signal(SIGINT, SIG_IGN);
printf("进程运行中,PID:%d,按下Ctrl+C无效(已忽略SIGINT)\n", getpid());
while (1) {
sleep(1);
}
return 0;
}
补充:此时若想终止进程,需使用kill -9 PID(发送SIGKILL信号),因为SIGKILL无法被忽略。
(三)自定义处理(捕捉信号)
这是开发中最常用的处理方式------进程自定义一个"信号处理函数",收到指定信号后,不执行默认动作,而是执行我们编写的处理函数,实现灵活的信号响应(如资源清理、日志记录、进程重启)。
实现方式:通过signal或sigaction系统调用,将信号与自定义处理函数绑定(sigaction比signal更可靠、灵活,推荐使用)。
示例:捕捉SIGINT信号,按下Ctrl+C后,不终止进程,而是打印提示信息:
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
// 自定义信号处理函数(参数是收到的信号编号)
void sigint_handler(int signum) {
printf("\n收到SIGINT信号(编号:%d),不终止进程,继续运行!\n", signum);
}
int main() {
// 绑定SIGINT信号和自定义处理函数
signal(SIGINT, sigint_handler);
printf("进程运行中,PID:%d,按下Ctrl+C测试自定义处理\n", getpid());
while (1) {
sleep(1);
}
return 0;
}
运行结果:按下Ctrl+C后,不会终止进程,而是打印提示信息,进程继续执行死循环。
四、信号处理实操:核心函数(signal & sigaction)
Linux中,处理信号的核心是两个系统调用:signal(简单易用,有局限性)和sigaction(功能强大,推荐使用)。结合之前学的系统调用与库函数区别,这两个都是系统调用,用于配置信号的处理方式。
(一)signal函数(简单入门)
1. 函数原型
cpp
#include <signal.h>
// 定义信号处理函数的类型(参数:信号编号,返回值:无)
typedef void (*sighandler_t)(int);
// 函数功能:绑定信号与处理方式
// 参数1:signum:要处理的信号编号(如SIGINT、SIGCHLD)
// 参数2:handler:处理方式(SIG_DFL默认、SIG_IGN忽略、自定义函数指针)
// 返回值:成功返回之前的处理方式,失败返回SIG_ERR
sighandler_t signal(int signum, sighandler_t handler);
2. 局限性(重点)
signal函数虽然简单,但存在两个明显局限性,导致在实际开发中(尤其是多线程、高并发场景)不推荐使用:
-
不可靠:在某些系统中,信号处理函数执行一次后,会自动重置为默认处理方式(SIG_DFL),需要重新绑定。
-
功能有限:无法设置信号屏蔽字、无法获取信号的详细信息(如发送信号的进程PID)、无法控制被信号中断的系统调用是否重启。
(二)sigaction函数(推荐使用,功能强大)
sigaction是signal函数的增强版,解决了signal的局限性,支持设置信号屏蔽字、获取信号详细信息、控制系统调用重启等,是实际开发中处理信号的首选。
1. 函数原型与核心结构体
cpp
#include <signal.h>
// 信号处理结构体(描述信号的处理方式)
struct sigaction {
// 信号处理函数(两种形式,二选一)
union {
void (*sa_handler)(int); // 基本处理函数(仅接收信号编号)
void (*sa_sigaction)(int, siginfo_t *, void *); // 扩展处理函数(获取信号详细信息)
} __sigaction_handler;
sigset_t sa_mask; // 信号屏蔽字:处理当前信号时,需要阻塞的其他信号
int sa_flags; // 控制信号处理行为的标志位(如是否重启系统调用、是否使用扩展处理函数)
void (*sa_restorer)(void); // 已废弃,无需使用
};
// 函数功能:配置信号的处理方式(比signal更灵活)
// 参数1:signum:要处理的信号编号
// 参数2:act:指向sigaction结构体的指针,描述新的处理方式(NULL表示仅查询)
// 参数3:oldact:指向sigaction结构体的指针,保存之前的处理方式(NULL表示不保存)
// 返回值:成功返回0,失败返回-1,设置errno
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
2. 核心参数说明
-
sa_handler / sa_sigaction:
-
sa_handler:基本处理函数,与signal的自定义函数用法一致,仅接收信号编号。
-
sa_sigaction:扩展处理函数,需设置sa_flags = SA_SIGINFO,可获取信号的详细信息(如发送信号的进程PID、信号产生的原因)。
-
-
sa_mask:信号屏蔽字,设置处理当前信号时,需要阻塞的其他信号(避免多个信号同时处理导致混乱)。比如处理SIGINT时,阻塞SIGQUIT,防止两个终止信号同时触发。
-
sa_flags:常用标志位(重点掌握):
-
SA_RESTART:被信号中断的系统调用(如read、write、sleep)会自动重启,避免返回EINTR错误。
-
SA_SIGINFO:使用扩展处理函数sa_sigaction,可获取信号详细信息。
-
SA_NOCLDWAIT:处理SIGCHLD信号时,子进程退出后会被内核自动回收,不会产生僵尸进程。
-
3. 实操示例:用sigaction捕捉SIGINT信号
实现功能:捕捉SIGINT信号,处理时阻塞SIGQUIT信号,设置系统调用自动重启,打印信号编号和发送信号的进程PID:
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
// 扩展信号处理函数(获取信号详细信息)
void sigint_handler(int signum, siginfo_t *info, void *context) {
printf("\n收到SIGINT信号(编号:%d)\n", signum);
printf("发送信号的进程PID:%d\n", info->si_pid); // 获取发送信号的进程PID
}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa)); // 初始化结构体
// 设置扩展处理函数
sa.sa_sigaction = sigint_handler;
// 设置标志位:使用扩展处理函数 + 自动重启系统调用
sa.sa_flags = SA_SIGINFO | SA_RESTART;
// 初始化信号屏蔽字,添加SIGQUIT(处理SIGINT时,阻塞SIGQUIT)
sigemptyset(&sa.sa_mask);
sigaddset(&sa.sa_mask, SIGQUIT);
// 绑定SIGINT信号
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction failed");
return 1;
}
printf("进程运行中,PID:%d,按下Ctrl+C测试\n", getpid());
while (1) {
sleep(1); // 系统调用,被信号中断后会自动重启
}
return 0;
}
五、信号的实际应用(结合已学知识,重点)
信号不是孤立的,结合之前学的fork、exec、僵尸进程等知识,信号在实际开发中有两个核心应用场景,也是面试高频考点。
(一)应用1:处理SIGCHLD信号,回收僵尸进程
之前我们学过,子进程退出后,若父进程未调用wait()/waitpid()回收,会产生僵尸进程(状态为Z),消耗PID资源。而子进程退出时,内核会向父进程发送SIGCHLD信号(默认忽略),因此我们可以通过捕捉SIGCHLD信号,在信号处理函数中调用waitpid(),自动回收僵尸进程,避免资源泄漏。
实操示例:捕捉SIGCHLD信号,自动回收子进程,避免僵尸进程:
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <string.h>
// SIGCHLD信号处理函数:回收僵尸进程
void sigchld_handler(int signum) {
// 循环回收所有退出的子进程(避免多个子进程同时退出,只回收一个)
pid_t pid;
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
printf("子进程(PID:%d)退出,已自动回收,避免僵尸进程\n", pid);
}
}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigchld_handler;
sa.sa_flags = SA_RESTART; // 自动重启被中断的系统调用
sigemptyset(&sa.sa_mask);
// 绑定SIGCHLD信号
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction failed");
return 1;
}
// 创建3个子进程
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
}
if (pid == 0) {
// 子进程:运行1秒后退出
printf("子进程(PID:%d)运行中,1秒后退出\n", getpid());
sleep(1);
exit(0);
}
}
// 父进程:死循环,等待SIGCHLD信号
while (1) {
sleep(1);
}
return 0;
}
运行结果:3个子进程依次退出,父进程收到SIGCHLD信号后,自动调用waitpid()回收子进程,不会产生僵尸进程(可通过ps -ef | grep Z查看)。
(二)应用2:优雅终止进程(SIGTERM vs SIGKILL)
日常开发中,我们需要终止进程时,优先使用SIGTERM信号(kill PID,默认发送SIGTERM),而不是SIGKILL信号(kill -9 PID),原因如下:
-
SIGTERM是"优雅终止"信号,进程收到后,可以执行自定义处理函数(如关闭文件、释放内存、保存数据),然后再终止,避免资源泄漏。
-
SIGKILL是"强制终止"信号,无法被捕捉、忽略,进程会被立即终止,来不及清理资源,可能导致文件损坏、数据丢失。
实操示例:捕捉SIGTERM信号,实现优雅终止:
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>
// 优雅终止处理函数
void sigterm_handler(int signum) {
printf("\n收到SIGTERM信号(优雅终止),开始清理资源...\n");
// 模拟资源清理:关闭文件、释放内存等
sleep(2);
printf("资源清理完成,进程正常终止\n");
exit(0); // 主动终止进程
}
int main() {
// 绑定SIGTERM信号
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigterm_handler;
sa.sa_flags = SA_RESTART;
if (sigaction(SIGTERM, &sa, NULL) == -1) {
perror("sigaction failed");
return 1;
}
printf("进程运行中,PID:%d,发送kill %d测试优雅终止\n", getpid(), getpid());
while (1) {
sleep(1);
}
return 0;
}
测试方法:运行程序后,在另一个终端执行kill 进程PID(发送SIGTERM信号),程序会先清理资源,再正常终止;若执行kill -9 进程PID(发送SIGKILL信号),程序会立即终止,不会执行清理逻辑。
六、信号操作常用命令(实操必备)
除了编程中处理信号,日常使用Linux时,也常用命令发送信号、查看信号相关信息,重点掌握以下4个命令:
-
kill:向指定进程发送信号(最常用)。kill PID # 向PID对应的进程发送SIGTERM信号(默认) ``kill -9 PID # 向进程发送SIGKILL信号,强制终止(无法拦截) ``kill -19 PID # 向进程发送SIGSTOP信号,暂停进程 ``kill -18 PID # 向进程发送SIGCONT信号,恢复暂停的进程 ``kill -l # 查看所有信号的编号和名称 -
killall:向指定名称的所有进程发送信号(批量终止进程)。killall -9 test # 强制终止所有名为test的进程 ``killall -15 nginx # 优雅终止所有nginx进程 -
pkill:根据进程名称、PID等条件,向进程发送信号(更灵活)。pkill -9 test # 强制终止所有名为test的进程 ``pkill -u user # 终止所有属于user用户的进程 -
ps:查看进程状态,判断进程是否被信号影响(如僵尸进程、暂停进程)。ps -ef | grep Z # 查看所有僵尸进程(状态为Z) ``ps -o pid,ppid,state # 查看进程PID、父进程PID、状态(T表示暂停)
七、常见问题与避坑要点(重点)
-
信号丢失问题:标准信号(1~31)不支持排队,若多个相同信号同时发送,进程可能只处理一次(比如同时发送多个SIGINT信号,进程只执行一次处理函数);实时信号(32~64)支持排队,不会丢失。
-
信号屏蔽字的坑:设置sa_mask时,仅在当前信号处理期间,阻塞指定信号;处理完成后,阻塞自动解除,不会影响后续信号的递达。
-
系统调用被中断:某些系统调用(如read、write、sleep)会被信号中断,返回EINTR错误;设置sa_flags = SA_RESTART,可让被中断的系统调用自动重启,避免手动处理错误。
-
无法捕捉的信号:SIGKILL(9)和SIGSTOP(19)无法被捕捉、忽略、修改处理方式,只能执行默认动作,这是系统预留的"强制控制进程"的信号。
-
僵尸进程的误区:即使捕捉了SIGCHLD信号,若处理函数中使用wait()(而非waitpid(-1, NULL, WNOHANG)),父进程会被阻塞,无法处理其他信号;使用waitpid的WNOHANG选项,可实现非阻塞回收。
八、与前序知识点的关联
与进程控制(fork/exec):子进程退出时发送SIGCHLD信号,父进程通过捕捉该信号回收子进程,避免僵尸进程;exec替换进程后,原进程的信号处理配置会被新程序覆盖,需在新程序中重新配置信号。
与系统调用:signal、sigaction、kill、waitpid都是系统调用,底层由内核实现;库函数(如stdio.h中的函数)可能会被信号中断,需通过sa_flags = SA_RESTART优化。
与IO密集型/计算密集型:信号处理是异步的,适合处理IO密集型场景中的事件通知(如网络连接断开、文件读写完成);计算密集型场景中,需避免频繁触发信号,以免中断计算流程,影响效率。
与引用计数:进程收到信号终止时,内核会自动释放进程的资源(文件描述符、内存等),本质是减少资源的引用计数,当引用计数为0时,资源被彻底释放。
九、实操案例(巩固练习)
结合本节课知识点,通过3个实操案例,巩固信号的处理方式和实际应用,可直接在Linux环境中练习:
-
案例1:信号捕捉与忽略。编写程序,忽略SIGQUIT信号,捕捉SIGINT信号,按下Ctrl+C时打印提示信息,按下Ctrl+\时无反应,用kill -9 PID终止程序。
-
案例2:自动回收僵尸进程。创建5个子进程,子进程随机睡眠1~5秒后退出,父进程通过捕捉SIGCHLD信号,用waitpid()非阻塞回收所有子进程,确保无僵尸进程产生。
-
案例3:优雅终止与资源清理。编写程序,模拟打开一个文件,捕捉SIGTERM信号,收到信号后关闭文件、打印清理日志,再正常终止;测试kill PID和kill -9 PID的区别。
十、总结
本节课重点讲解了Linux信号的核心知识点,核心要点总结如下:
信号是内核向进程传递的"事件通知",本质是整数编号,异步触发,分为标准信号(1~31)和实时信号(32~64),SIGKILL和SIGSTOP无法被捕捉、忽略。
信号的三种处理方式:默认处理(SIG_DFL)、忽略处理(SIG_IGN)、自定义处理(捕捉信号),实际开发中常用自定义处理实现灵活响应。
信号处理的核心函数:signal(简单但有局限性)、sigaction(功能强大,推荐使用),可配置信号处理函数、信号屏蔽字、处理标志位。
信号的核心应用:捕捉SIGCHLD信号回收僵尸进程、捕捉SIGTERM信号实现优雅终止,这两个场景是面试高频考点,也是实际开发必备技能。
信号与进程控制、系统调用、引用计数密切相关,是Linux进程间通信的基础,也是后续学习网络编程、后台服务开发的关键。
本节课的重点是"理解信号的异步特性、掌握信号处理函数的用法、学会解决实际应用中的问题",建议多编译运行代码,测试不同信号的处理效果,熟悉sigaction结构体的配置,避免踩坑。下一篇笔记,我们将讲解Linux进程间通信的其他方式(管道、消息队列),进一步完善进程通信的知识体系。