在 Linux 系统中,进程信号是实现进程间异步通信、事件通知和异常处理的核心机制。无论是用户按下Ctrl+C终止进程,还是程序出现段错误崩溃,背后都离不开信号的身影。本文将以 “信号的产生 - 保存 - 处理” 为主线,结合代码实例和内核原理,带你全面掌握 Linux 进程信号的技术细节与实战技巧。
一、信号是什么?从生活场景到技术本质
信号的本质是进程间异步事件通知的软中断机制,它模拟了硬件中断的行为,但作用于进程层面。我们可以通过生活中的 “快递通知” 场景,快速理解信号的核心特性:
| 生活场景(快递) | Linux 信号机制 | 对应技术概念 |
|---|---|---|
| 快递员通知取件 | 操作系统向进程发送信号 | 信号产生 |
| 你记住 “有快递待取” | 进程记录未处理的信号 | 信号未决(Pending) |
| 打完游戏再取快递 | 进程在合适时机处理信号 | 信号递达(Delivery) |
| 拆快递 / 送朋友 / 扔一边 | 进程对信号的处理动作 | 默认 / 自定义 / 忽略 |
从技术角度看,信号具有以下核心特点:
异步性:信号的产生时机不可预知(如用户随机按下Ctrl+C),进程无法主动 “等待” 信号。
预定义处理:进程在信号产生前,已通过内核预设或用户注册,确定了对每种信号的处理方式。
软中断属性:信号会打断进程当前执行流程,转去处理信号,处理完成后再恢复原流程(类似硬件中断)。
二、信号的产生:5 种常见触发方式
Linux 中信号的产生来源多样,既可以是用户操作,也可以是系统事件或硬件异常。以下是 5 种最常见的信号产生方式,每种方式均配套代码示例验证。
2.1 终端按键触发(用户交互)
终端按键是最直观的信号产生方式,系统将按键输入解析为特定信号,发送给前台进程:
Ctrl+C:发送SIGINT(2 号信号),默认动作是终止进程。
Ctrl+\:发送SIGQUIT(3 号信号),默认动作是终止进程并生成core dump文件(用于事后调试)。
Ctrl+Z:发送SIGTSTP(20 号信号),默认动作是暂停前台进程并挂入后台。
代码验证:自定义捕捉SIGINT信号,按下Ctrl+C后进程不终止而是打印信号信息:
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 自定义信号处理函数
void sig_handler(int signo) {
std::cout << "进程[" << getpid() << "]捕获到信号:" << signo << "(SIGINT)" << std::endl;
}
int main() {
std::cout << "进程PID:" << getpid() << ",等待信号(按Ctrl+C测试)..." << std::endl;
// 注册SIGINT信号的处理函数
signal(SIGINT, sig_handler);
// 死循环保持进程运行
while (true) {
sleep(1);
}
return 0;
}
编译运行:
g++ sig_int.cc -o sig_int
./sig_int
# 按下Ctrl+C,进程会打印信号信息而非终止
2.2 系统命令触发(kill命令)
通过kill命令可向指定进程发送任意信号,其本质是调用kill系统函数。常见用法:
kill -信号编号 进程PID(如kill -11 1234发送SIGSEGV信号)。
kill -信号名 进程PID(如kill -SIGKILL 1234发送强制终止信号)。
代码验证:后台运行一个死循环进程,用kill命令发送SIGSEGV(11 号信号,段错误)使其崩溃:
// loop.cc:死循环进程
#include <unistd.h>
int main() {
while (true) {
sleep(1);
}
return 0;
}
操作步骤:
g++ loop.cc -o loop
./loop & # 后台运行,获取进程PID(如1234)
ps ajx | grep loop # 确认进程PID
kill -SIGSEGV 1234 # 发送段错误信号
# 终端会显示:[1]+ Segmentation fault (core dumped) ./loop
2.3 系统函数触发(kill/raise/abort)
除了kill命令,还可通过代码调用系统函数主动产生信号,常用函数如下:
| 函数 | 功能 | 特点 |
|---|---|---|
kill(pid_t pid, int sig) | 向指定 PID 的进程发送信号 | 可跨进程发送 |
raise(int sig) | 向当前进程发送信号 | 等价于kill(getpid(), sig) |
abort(void) | 向当前进程发送SIGABRT(6 号信号) | 强制进程异常终止,无法忽略 |
代码示例:用raise实现 “每秒给自己发送一次SIGINT信号”:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void sig_handler(int signo) {
std::cout << "捕获到信号:" << signo << ",时间:" << time(nullptr) << std::endl;
}
int main() {
signal(SIGINT, sig_handler);
while (true) {
sleep(1);
raise(SIGINT); // 每秒给自己发SIGINT
}
return 0;
}
2.4 软件条件触发(定时器 / 管道断裂)
当系统满足特定软件条件时,会自动向进程发送信号。典型场景包括:
定时器超时:alarm(unsigned int seconds)函数设定定时器,超时后发送SIGALRM(14 号信号)。
管道断裂:向无读端的管道写数据时,会触发SIGPIPE(13 号信号)。
代码验证:用alarm实现 “1 秒后终止进程”,并对比 IO 操作对定时器精度的影响:
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
// 捕捉SIGALRM信号,打印计数
void sig_handler(int signo) {
std::cout << "1秒内计数:" << count << std::endl;
exit(0);
}
int main() {
signal(SIGALRM, sig_handler);
alarm(1); // 1秒后发送SIGALRM
// 无IO操作:计数会非常大(CPU全速执行)
while (true) {
count++;
// 若添加IO操作(如printf),计数会显著减少(IO耗时)
// std::cout << "count: " << count << std::endl;
}
return 0;
}
结论:IO 操作会显著降低程序执行效率,定时器仅保证 “大致超时时间”,无法精确到微秒级。
2.5 硬件异常触发(除零 / 非法内存访问)
硬件异常(如 CPU 运算错误、内存访问错误)会被内核捕获,解释为特定信号发送给进程:
除零错误:CPU 运算单元检测到除零,内核发送SIGFPE(8 号信号)。
非法内存访问:MMU(内存管理单元)检测到无效地址,内核发送SIGSEGV(11 号信号)。
代码验证:模拟非法内存访问(野指针),捕获SIGSEGV信号:
#include <stdio.h>
#include <signal.h>
void sig_handler(int signo) {
printf("捕获到信号:%d(SIGSEGV),非法内存访问!\n", signo);
// 注意:若不退出,进程会反复触发该信号(CPU仍保留异常状态)
exit(1);
}
int main() {
signal(SIGSEGV, sig_handler);
int *p = NULL; // 野指针
*p = 100; // 非法赋值,触发SIGSEGV
return 0;
}
三、信号的保存:内核如何管理未决信号
信号产生后,若进程正在执行高优先级任务(如内核态代码),不会立即处理,而是暂时保存在内核中。这一阶段称为 “信号未决”(Pending),内核通过以下机制管理未决信号:
3.1 核心概念辨析
在理解保存机制前,需先明确三个关键概念:
未决(Pending):信号已产生但未递达(处理)的状态。
阻塞(Block):进程主动屏蔽某信号,即使信号产生也不会递达(阻塞与忽略不同:忽略是递达后的处理动作,阻塞是阻止递达)。
递达(Delivery):信号的处理动作被执行(默认 / 忽略 / 自定义)。
3.2 内核中的信号表示
Linux 内核在task_struct(进程控制块)中,用三个核心数据结构管理信号:
// Linux 2.6.18内核简化结构
struct task_struct {
struct sighand_struct *sighand; // 信号处理函数表
sigset_t blocked; // 阻塞信号集(位图)
struct sigpending pending; // 未决信号集(链表+位图)
};
// 信号处理函数表:每个信号对应一个处理动作
struct sighand_struct {
struct k_sigaction action[_NSIG]; // _NSIG=64(信号总数)
};
// 未决信号集:保存已产生但未递达的信号
struct sigpending {
struct list_head list; // 实时信号链表(本章不讨论)
sigset_t signal; // 非实时信号位图(1表示未决)
};
sigset_t:信号集类型,本质是位图(每个 bit 对应一个信号,1 表示 “有效”)。
阻塞信号集(blocked):bit=1 表示该信号被阻塞。
未决信号集(pending.signal):bit=1 表示该信号已产生但未递达。
3.3 信号集操作函数
用户态程序无法直接操作内核中的blocked和pending,需通过以下系统函数间接管理:
| 函数 | 功能 | 示例 |
|---|---|---|
sigemptyset(sigset_t *set) | 初始化信号集为空(所有 bit=0) | sigemptyset(&block_set); |
sigfillset(sigset_t *set) | 初始化信号集为满(所有 bit=1) | sigfillset(&all_set); |
sigaddset(sigset_t *set, int sig) | 向信号集添加某信号 | sigaddset(&block_set, SIGINT); |
sigdelset(sigset_t *set, int sig) | 从信号集删除某信号 | sigdelset(&block_set, SIGINT); |
sigismember(const sigset_t *set, int sig) | 判断信号是否在集中 | if (sigismember(&pending, SIGINT)) { ... } |
sigprocmask(int how, const sigset_t *set, sigset_t *oset) | 修改进程阻塞信号集 | sigprocmask(SIG_BLOCK, &block_set, &old_set); |
sigpending(sigset_t *set) | 获取当前进程未决信号集 | sigpending(&pending); |
sigprocmask的how参数:控制阻塞信号集的修改方式:
SIG_BLOCK:将set中的信号添加到阻塞集(blocked |= set)。
SIG_UNBLOCK:从阻塞集移除set中的信号(blocked &= ~set)。
SIG_SETMASK:将阻塞集直接设为set(blocked = set)。
3.4 代码实战:观察信号的阻塞与未决
编写程序,阻塞SIGINT信号,按下Ctrl+C后观察未决状态,15 秒后解除阻塞并处理信号:
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 打印未决信号集(31个非实时信号)
void print_pending(sigset_t &pending) {
std::cout << "进程[" << getpid() << "]未决信号:";
for (int sig = 31; sig >= 1; sig--) {
if (sigismember(&pending, sig)) {
std::cout << "1";
} else {
std::cout << "0";
}
}
std::cout << std::endl;
}
// 信号处理函数
void sig_handler(int signo) {
std::cout << "信号[" << signo << "]递达并处理!" << std::endl;
}
int main() {
// 1. 注册SIGINT信号处理函数
signal(SIGINT, sig_handler);
// 2. 初始化阻塞集,添加SIGINT(2号信号)
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set, SIGINT);
// 3. 设置阻塞集(屏蔽SIGINT)
sigprocmask(SIG_BLOCK, &block_set, &old_set);
std::cout << "已阻塞SIGINT信号,15秒后解除阻塞(按Ctrl+C测试)..." << std::endl;
int cnt = 15;
while (cnt--) {
// 4. 每秒打印一次未决信号集
sigset_t pending;
sigpending(&pending);
print_pending(pending);
sleep(1);
// 5. 15秒后解除阻塞
if (cnt == 0) {
std::cout << "解除SIGINT信号阻塞,信号将递达..." << std::endl;
sigprocmask(SIG_SETMASK, &old_set, nullptr);
}
}
return 0;
}
运行现象:
按下Ctrl+C后,未决信号集的第 2 位(SIGINT)变为 1(未决)。
15 秒后解除阻塞,SIGINT信号立即递达,执行sig_handler。
四、信号的处理:从内核态到用户态的切换
当进程从内核态返回用户态时,会检查未决信号集:若存在未阻塞的信号,会触发信号处理流程。若处理动作是用户自定义函数(捕捉信号),则需完成 “用户态 - 内核态 - 用户态” 的切换,流程较为复杂。
4.1 信号捕捉的完整流程
以 “用户注册SIGQUIT处理函数” 为例,信号捕捉的流程如下:
- 用户态执行主流程:进程正在执行
main函数,发生中断 / 异常(如时钟中断)切换到内核态。 - 内核处理中断并检查信号:中断处理完毕后,内核检查进程的未决信号集,发现
SIGQUIT未阻塞且处理动作是自定义函数。 - 切换到用户态执行处理函数:内核不恢复
main函数的上下文,而是构造sig_handler的上下文,切换到用户态执行处理函数。 - 处理函数返回后再次进入内核:
sig_handler执行完毕后,调用特殊系统调用sigreturn再次进入内核态。 - 恢复主流程上下文:内核检查无新信号需递达,恢复
main函数的上下文,回到用户态继续执行。
流程示意图:
用户态(main) → 内核态(中断处理) → 检查信号 → 用户态(sig_handler) → 内核态(sigreturn) → 用户态(main)
4.2 更可靠的信号处理:sigaction函数
signal函数是 ANSI C 标准接口,但其行为在不同系统中存在差异(如信号重入问题)。Linux 推荐使用sigaction函数,它提供更精细的信号处理控制:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
signo:要操作的信号编号(如SIGINT)。
act:非空则设置信号的新处理动作。
oact:非空则传出信号的旧处理动作。
struct sigaction结构:
struct sigaction {
void (*sa_handler)(int); // 处理函数(SIG_DFL/SIG_IGN/自定义函数)
sigset_t sa_mask; // 处理信号时额外阻塞的信号集
int sa_flags; // 标志位(如SA_RESTART:自动重启被中断的系统调用)
void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理函数(本章不讨论)
};
代码示例:用sigaction捕捉SIGINT,并在处理时额外阻塞SIGQUIT:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void sig_handler(int signo) {
std::cout << "处理信号:" << signo << "(SIGINT),期间阻塞SIGQUIT..." << std::endl;
sleep(5); // 处理函数执行期间,按Ctrl+\(SIGQUIT)会被阻塞
std::cout << "信号处理完毕,SIGQUIT解除阻塞..." << std::endl;
}
int main() {
struct sigaction act, oact;
// 1. 设置处理函数
act.sa_handler = sig_handler;
// 2. 初始化sa_mask,添加SIGQUIT(处理SIGINT时阻塞SIGQUIT)
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGQUIT);
// 3. 标志位设为0(默认行为)
act.sa_flags = 0;
// 4. 注册信号处理动作
sigaction(SIGINT, &act, &oact);
std::cout << "进程PID:" << getpid() << ",按Ctrl+C测试(处理时按Ctrl+\\观察阻塞)..." << std::endl;
while (true) {
sleep(1);
}
return 0;
}
运行现象:
按下Ctrl+C,进入sig_handler并睡眠 5 秒。
睡眠期间按下Ctrl+\(SIGQUIT),信号会被阻塞,直到sig_handler执行完毕才递达。
五、关键技术点:可重入函数与volatile关键字
在信号处理中,若处理函数与主流程共享资源,可能引发数据错乱或优化问题。以下两个技术点是信号编程的 “避坑指南”。
5.1 可重入函数:避免信号打断导致的数据错乱
重入场景:信号处理函数与主流程同时调用同一个函数,且该函数访问全局 / 静态资源,导致数据不一致。例如:
主流程调用insert向链表插入节点,刚执行到 “p->next = head” 时,信号触发,处理函数也调用insert插入节点,最终导致链表结构错乱。
可重入函数定义:若一个函数仅访问局部变量或参数,不依赖全局 / 静态资源,且不调用不可重入函数,则该函数是可重入的。
不可重入函数的常见特征:
- 调用
malloc/free(依赖全局堆管理链表)。 - 调用标准 I/O 库函数(如
printf,依赖全局缓冲区)。 - 访问全局 / 静态变量或共享资源。
示例:不可重入函数insert导致的链表错乱:
#include <iostream>
#include <signal.h>
#include <unistd.h>
struct Node {
int data;
Node *next;
} *head = nullptr;
// 不可重入函数:访问全局链表head
void insert(int data) {
Node *p = new Node{data, head}; // 步骤1:创建节点
// 若此时信号触发,处理函数调用insert,会修改head
sleep(1); // 模拟信号打断
head = p; // 步骤2:更新头指针
}
void sig_handler(int signo) {
std::cout << "信号处理:插入节点100" << std::endl;
insert(100); // 调用不可重入函数
}
int main() {
signal(SIGINT, sig_handler);
std::cout << "主流程:插入节点1" << std::endl;
insert(1); // 主流程调用insert,执行到sleep时按Ctrl+C
// 最终head可能指向节点1,节点100丢失(数据错乱)
return 0;
}
5.2 volatile关键字:避免编译器优化导致的可见性问题
编译器优化(如-O2)可能将频繁访问的变量缓存到 CPU 寄存器中,导致信号处理函数修改的变量对主流程不可见。volatile关键字的作用是强制变量每次访问都从内存读取,确保内存可见性。
代码验证:未使用volatile导致的优化问题:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 未加volatile:编译器可能将flag缓存到寄存器
int flag = 0;
void sig_handler(int signo) {
printf("信号处理:flag设为1\n");
flag = 1; // 修改内存中的flag,但寄存器中的flag未更新
}
int main() {
signal(SIGINT, sig_handler);
printf("等待flag变为1(按Ctrl+C)...\n");
// 编译器优化:while检查的是寄存器中的flag(始终为0)
while (!flag);
printf("主流程:flag已变为1,退出\n");
return 0;
}
编译运行问题:
g++ volatile_test.cc -o volatile_test -O2 # 开启O2优化
./volatile_test
# 按下Ctrl+C,信号处理函数打印flag=1,但主流程仍卡在while循环
解决方案:添加volatile关键字:
volatile int flag = 0; // 强制每次访问从内存读取
修复后运行:按下Ctrl+C,主流程能立即检测到flag=1并退出。
六、实战场景:用SIGCHLD清理僵尸进程
在进程管理中,子进程终止后若父进程未及时清理,会变成僵尸进程(Z状态)。SIGCHLD信号(17 号)的作用是:子进程终止时,向父进程发送该信号,父进程可在信号处理函数中调用waitpid清理僵尸进程。
6.1 传统方案的问题
wait阻塞等待:父进程无法处理自身工作,只能等待子进程终止。
轮询waitpid:父进程需频繁检查子进程状态,代码复杂且低效。
6.2 SIGCHLD方案:异步清理
父进程注册SIGCHLD的处理函数,子进程终止时自动触发清理,无需轮询。
代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
// SIGCHLD信号处理函数:清理所有终止的子进程
void sigchld_handler(int signo) {
pid_t child_pid;
// WNOHANG:非阻塞,无终止子进程时立即返回
while ((child_pid = waitpid(-1, NULL, WNOHANG)) > 0) {
printf("清理僵尸进程:%d\n", child_pid);
}
}
int main() {
// 注册SIGCHLD信号处理函数
signal(SIGCHLD, sigchld_handler);
// 创建3个子进程
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid == 0) {
// 子进程:睡眠2秒后终止
printf("子进程%d启动,2秒后终止\n", getpid());
sleep(2);
exit(0);
}
}
// 父进程:专注处理自身工作(无需轮询子进程)
while (true) {
printf("父进程正在工作...\n");
sleep(1);
}
return 0;
}
运行现象:
3 个子进程启动后,父进程每秒打印 “正在工作”。
2 秒后子进程终止,触发SIGCHLD,父进程清理僵尸进程。
用ps aux | grep 进程名查看,不会出现僵尸进程。
七、总结与实践建议
Linux 信号是进程间异步通信的核心机制,掌握其原理和用法是 Linux 开发的必备技能。以下是关键总结和实践建议:
7.1 核心总结
- 信号生命周期:产生(5 种方式)→ 保存(内核位图管理)→ 处理(递达动作:默认 / 忽略 / 自定义)。
- 关键概念:未决(Pending)、阻塞(Block)、递达(Delivery)的区别;可重入函数与
volatile的作用。 - 函数选择:优先使用
sigaction而非signal,sigaction提供更可靠的跨平台支持和精细控制。
7.2 实践建议
- 避免在信号处理函数中做复杂操作:处理函数应简洁,不调用不可重入函数(如
printf、malloc),如需 IO 操作可使用write(可重入)。 - 注意信号屏蔽:在处理关键流程(如数据写入)时,可临时屏蔽重要信号(如
SIGINT),避免流程被打断。 - 清理僵尸进程:务必用
SIGCHLD+waitpid的异步方案,避免僵尸进程占用系统资源。 - 调试信号问题:使用
kill -l查看所有信号;用strace跟踪信号发送和处理流程;开启core dump(ulimit -c unlimited)调试信号导致的崩溃。

被折叠的 条评论
为什么被折叠?



