文章目录
- [Linux 进程信号](#Linux 进程信号)
-
- 信号入门
-
- 信号基本概念
- [用kill -l命令可以察看系统定义的信号列表](#用kill -l命令可以察看系统定义的信号列表)
- 信号的产生方式
-
- 通过终端按键产生信号
- [CoreDump 与事后调试](#CoreDump 与事后调试)
- 调用系统函数发送信号
- 由软件条件产生信号
- 硬件异常产生信号
- 信号的阻塞、未决与递达
- 信号捕捉与内核处理流程
-
- 内核如何实现信号捕捉
- [sigaction 函数](#sigaction 函数)
- [可重入函数与 volatile](#可重入函数与 volatile)
-
- 可重入函数的概念
- [volatile 关键字与编译器优化](#volatile 关键字与编译器优化)
- [SIGCHLD 信号与子进程回收](#SIGCHLD 信号与子进程回收)
-
- [使用 SIGCHLD 避免轮询等待子进程](#使用 SIGCHLD 避免轮询等待子进程)
Linux 进程信号
信号入门
- 生活角度的信号
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能 "识别快递"
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成 "在合适的时候去取"。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你 "记住了有一个快递要去取"
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
- 技术应用角度的信号
- 用户输入命令,在Shell下启动一个前台进程。
- 用户按
ctrl-c,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程 - 前台进程因为收到信号,进而引起进程退出
- 用户按
- 用户输入命令,在Shell下启动一个前台进程。
c
// 一个最简单的信号演示:进程持续运行,等待终端 Ctrl-C 产生 SIGINT
#include <stdio.h>
#include <unistd.h>
int main(void)
{
while (1) {
printf("I am a process, I am waiting signal!\n");
sleep(1);
}
return 0;
}
注意:
ctrl-c产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像
ctrl-c这种控制键产生的信号。 - 前台进程在运行过程中用户随时可能按下
ctrl-c而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
信号基本概念
信号是 Linux 中进程间异步事件通知机制,可视为"软中断";到达时间不可预知,相对正常控制流程是异步的。信号产生后可忽略、执行默认动作或注册处理函数捕捉,由 OS 管理并在适当时机递达。关键是区分产生、未决、阻塞、递达和处理动作。
用kill -l命令可以察看系统定义的信号列表

- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义
#define SIGINT 2 - 编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明:
man 7 signal

信号的产生方式
通过终端按键产生信号
终端按键是常见信号源。Ctrl-C 产生 SIGINT(默认终止进程),Ctrl-\ 产生 SIGQUIT(终止并可能产生 CoreDump)。因终端键绑定前台进程组,此类信号只能发给前台进程。命令加 & 可后台运行,Shell 不等待,但后台进程通常收不到 Ctrl-C,这就解释了为何后台程序按 Ctrl-C 无反应。
c
// 前台运行后按 Ctrl-C,进程会被 SIGINT 终止
#include <stdio.h>
#include <unistd.h>
int main(void)
{
while (1) {
printf("running...\n");
sleep(1);
}
return 0;
}
CoreDump 与事后调试
CoreDump 是指进程异常终止时,把进程用户空间的内存数据保存到磁盘文件中的机制,文件名通常为 core 或 core.pid。它常用于事后调试,例如程序发生段错误后,可以通过 gdb 加载 core 文件定位出错位置。默认情况下系统可能不允许产生 core 文件,因为 core 文件可能包含密码等敏感信息。开发调试阶段可以用 ulimit -c 修改 core 文件大小限制。注意 ulimit 修改的是 Shell 进程的资源限制,子进程会继承该限制。

c
// 制造段错误,配合 ulimit -c unlimited 可产生 core 文件
#include <stdio.h>
int main(void)
{
int *p = NULL;
*p = 100; // 非法访问,通常触发 SIGSEGV
return 0;
}

注意:如果运行程序未生成到当前目录上可以输入下面这个命令:
bash
# 把 core 文件生成路径改为当前工作目录,文件名就叫 core
sudo sysctl -w kernel.core_pattern=core
除了上述演示以外,我们还可以利用终端按键 Ctrl-/ 来生成core文件。
注意:
- 生产环境通常关闭 CoreDump,避免敏感信息泄漏和磁盘占用。
- CoreDump 只保存用户空间内存,不是完整系统快照;调试时还需要可执行文件和符号信息。
调用系统函数发送信号
除了终端按键,进程还可以通过系统调用主动发送信号。
kill 函数可以向指定进程发送指定信号;
raise 函数可以向当前进程自己发送信号;
c
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
这两个函数都是成功返回0,错误返回-1。
abort 函数会使当前进程异常终止。
c
#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
kill 命令底层通常调用 kill 函数,因此命令行和编程接口在语义上是一致的。使用这些函数时要注意权限:普通进程通常只能向同用户或自己有权限的进程发送信号。
cpp
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
printf("pid = %d\n", getpid());
// 给自己发送 SIGUSR1,若未捕捉则默认终止
raise(SIGUSR1);
// 如果 raise 被忽略,可继续执行;这里演示 abort 的异常终止
abort();
return 0;
}
注意:
kill不是"杀死"进程的同义词,它只是发送信号,具体行为取决于信号类型和处理动作。abort总是使进程异常终止,不返回;不要误以为它可以像exit一样返回退出码给调用者。- 发送信号需要权限,向其他用户进程发送信号可能失败并返回 -1。
由软件条件产生信号
软件条件也可以产生信号,例如 alarm 函数可以设置一个定时器,在指定秒数后向当前进程发送 SIGALRM 信号。SIGALRM 默认动作是终止当前进程。alarm 的返回值是 0 或者之前设置的闹钟剩余秒数;如果传入 0,表示取消之前的闹钟。这个机制常用于实现简单的超时控制。需要注意 alarm 以秒为单位,精度有限,不适合高精度定时。
c
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动
作是终止当前进程。
c
#include <stdio.h>
#include <unistd.h>
int main(void)
{
int count = 14;
alarm(1); // 1 秒后发送 SIGALRM,默认终止进程
for (;; count++) {
printf("count = %d\n", count);
// sleep(1); 可以先不带sleep运行一次,然后再带sleep来观察一下
}
return 0;
}
注意:
alarm只保证"至少大约 seconds 秒后"发送信号,不是精确周期定时器。- 如果程序中已经设置了闹钟,再次调用 alarm 会覆盖旧闹钟,并返回旧闹钟剩余时间。
硬件异常产生信号
硬件异常也可能被内核解释为信号发送给进程。例如执行除以 0 的指令时,CPU 产生异常,内核可能将其解释为 SIGFPE 信号;访问非法内存地址时,MMU 产生异常,内核可能将其解释为 SIGSEGV 信号。这类信号通常表示程序存在严重错误。虽然可以通过信号处理函数捕捉它们,但捕捉后并不代表错误消失,程序往往仍处于不一致状态,因此不建议随意忽略硬件异常信号。
c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
printf("catch a sig: %d\n", sig);
}
int main(void)
{
signal(SIGSEGV, handler); // 捕捉段错误信号,仅用于演示
int *p = NULL;
*p = 100; // 触发 SIGSEGV
while (1) {
sleep(1);
}
return 0;
}
注意:
- 捕捉 SIGSEGV 后直接返回,可能再次执行非法指令,导致反复触发信号。
- 硬件异常信号表示程序错误,正确处理方式通常是修复代码,而不是长期忽略。
信号的阻塞、未决与递达
递达、未决、阻塞的基本概念
信号处理经历:产生 → 未决 (产生到递达之间)→ 递达 (执行处理动作)。进程可阻塞 信号,使其保持未决直至解除;忽略则是信号已递达但不处理,二者不同。内核为每个信号维护阻塞标志、未决标志和处理函数指针。常规信号递达前多次产生只计一次,实时信号可排队(本章主要讨论常规信号)。
阻塞一个信号不等于忽略它,解除阻塞后它仍可能递达。
未决标志通常只表示"有没有",不表示"产生了多少次",常规信号会丢失重复产生信息。
在内核中的表示

每个信号在内核中有三要素:阻塞(block)标志 、未决(pending)标志 和处理动作函数指针。信号产生时置位未决标志,递达时清除。以下通过三个例子说明状态:
- SIGHUP:未阻塞也未产生过,递达时执行默认动作。
- SIGINT :产生过但被阻塞,暂不能递达。注意:即使其处理动作是"忽略",在解除阻塞前也不能忽略,因为进程仍有机会修改处理动作后再解除阻塞。
- SIGQUIT :未产生过,一旦产生将被阻塞,其处理动作是用户自定义函数
sighandler。在解除阻塞前若产生多次:POSIX 允许系统递送一次或多次;Linux 的规则是常规信号只计一次,实时信号排队(本章不讨论实时信号)。
sigset_t
从上图来看,每个信号只有 1 bit 的未决标志(非 0 即 1),不记录产生次数;阻塞标志也是如此。因此,未决和阻塞标志可以用相同的数据类型 sigset_t(信号集)来存储,该类型用来表示每个信号的"有效"或"无效"状态。
需要注意的是,"有效/无效"在不同信号集中含义不同:
- 阻塞信号集 :表示该信号是否被阻塞。
- 未决信号集 :表示该信号是否处于未决状态。
下一节将详细介绍信号集的操作。阻塞信号集也叫做当前进程的信号屏蔽字 (Signal Mask),注意这里的"屏蔽"应理解为"阻塞"而不是"忽略"。
信号集操作函数
sigset_t类型对于每种信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit则依赖于系统 实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做 任何解释,比如用printf直接打印sigset_t变量是没有意义的
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初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。 - 函数
sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。 - 注意,在使用
sigset_t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdel set在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask修改信号屏蔽字
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
c
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
sigprocmask 用于读取或更改进程的信号屏蔽字(阻塞信号集)。参数 how 决定修改方式:SIG_BLOCK(加入阻塞)、SIG_UNBLOCK(移出阻塞)、SIG_SETMASK(直接设置);若 oldset 非空则传出原屏蔽字,若 set 非空则按 how 修改,成功返回 0,失败返回 -1。需注意,解除对未决信号的阻塞后,在 sigprocmask 返回前至少会递达一个信号。该函数常用于保护临界区,避免关键代码段被信号打断。

c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int main(void)
{
sigset_t set, oldset;
sigemptyset(&set);
sigaddset(&set, SIGINT);
// 阻塞 SIGINT
sigprocmask(SIG_BLOCK, &set, &oldset);
printf("SIGINT blocked, sleep 5 seconds\n");
sleep(5); // 此期间按 Ctrl-C,SIGINT 处于未决状态
// 解除阻塞,未决的 SIGINT 可能立即递达
sigprocmask(SIG_SETMASK, &oldset, NULL);
printf("SIGINT unblocked\n");
return 0;
}

第二种使用Ctrl-c,此时终端向进程发送 SIGINT 信号。因为 SIGINT 被屏蔽了,它无法递达(不能杀死进程),只能变成 "未决状态"(Pending ),挂起在操作系统的信号队列里,而 sleep 函数依然在安静地睡。5 秒后,代码执行 sigprocmask(SIG_SETMASK, &oldset, NULL)。
系统发现要解除 SIGINT 的阻塞,并且检查到有一个未决的 SIGINT。系统立刻把 SIGINT 递达给进程。SIGINT 的默认动作是终止进程。所以,程序在 sigprocmask 这一行内部就被杀死了,根本没有机会执行到下一行的 printf("SIGINT unblocked\n");。
sigpending读取未决信号集
c
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。 下面用刚学的几个函数做个实验。程序如下:
c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void print_pending(sigset_t *set)
{
for (int i = 1; i < 32; i++) {
if (sigismember(set, i) == 1) {
putchar('1');
} else {
putchar('0');
}
}
putchar('\n');
}
int main(void)
{
sigset_t set, pending;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, NULL); // 阻塞 SIGINT
while (1) {
sigpending(&pending);
print_pending(&pending);
sleep(1);
}
return 0;
}

注意:
- 未决信号集不是"所有产生过的信号历史记录",信号递达后标志会清除。
- 常规信号重复产生可能只保留一个未决标志,不能依赖未决集统计次数。
信号捕捉与内核处理流程

内核如何实现信号捕捉
如果信号处理动作是用户自定义函数,信号递达时内核会安排执行该函数,即"捕捉信号"。典型过程:程序执行 main 时发生中断/异常进内核,处理完准备返回用户态前,若检查到有信号递达且设有自定义处理函数,内核不会恢复 main 的上下文,而是执行该处理函数;函数返回后执行 sigreturn 再次进内核,若无新信号再恢复 main 继续执行。
注意:
- 信号处理函数并非
main主动调用,而是内核在递达时"插入"执行的;其返回需经过sigreturn才能恢复原流程,这比普通函数调用复杂。 - 信号处理函数与
main使用不同的堆栈空间,并非普通调用关系,而是两个独立的控制流。
sigaction 函数
c
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sigaction 用于读取和修改指定信号的处理动作,比 signal 更可控。signum 是信号编号;act 非空时根据它修改处理动作;oldact 非空时传出原来的处理动作。成功返回 0,失败返回 -1。
struct sigaction 中包含 sa_handler (处理函数)、sa_mask (处理期间额外屏蔽的信号集)和 sa_flags (选项)。sa_handler 可以设为 SIG_IGN 表示忽略,设为 SIG_DFL 表示默认动作,设为函数指针表示自定义捕捉。当某个信号的处理函数被调用时,内核会自动把当前信号加入信号屏蔽字,处理函数返回后恢复,从而避免同一信号在处理期间再次递达。sa_mask 可以额外指定在处理期间要屏蔽的信号。
c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
void handler(int sig)
{
printf("sigaction catch: %d\n", sig);
}
int main(void)
{
struct sigaction act, oldact;
memset(&act, 0, sizeof(act));
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGQUIT); // 处理 SIGINT 时额外屏蔽 SIGQUIT
act.sa_flags = 0;
sigaction(SIGINT, &act, &oldact);
while (1) {
sleep(1);
}
return 0;
}
注意:
- 不要忘记初始化
struct sigaction,导致sa_flags或sa_mask含随机值。 sa_mask并不是永久屏蔽;它只在处理函数执行期间生效,返回后恢复原屏蔽字。
可重入函数与 volatile
可重入函数的概念

可重入函数是指一个函数在被某个控制流程调用且尚未返回时,可以再次被另一个控制流程安全进入,而不会造成数据错乱。信号处理函数可能在 main 函数执行到任意位置时插入执行,如果信号处理函数调用了正在被 main 使用全局数据的函数,就可能造成重入问题。典型例子是链表插入:main 调用 insert 插入 node1,刚完成一部分时被信号打断,信号处理函数又调用 insert 插入 node2,两个流程交错修改同一个链表,最终可能只插入一个节点或破坏链表。只访问自己的局部变量或参数的函数通常是可重入的。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
c
// 不可重入示例:insert 访问全局链表 head
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
struct node {
int data;
struct node *next;
};
struct node *head = NULL;
void insert(struct node *p)
{
p->next = head;
// 如果这里被信号打断,信号处理函数也调用 insert,就可能破坏链表
head = p;
}
void handler(int sig)
{
static struct node node2 = {2, NULL};
insert(&node2);
}
int main(void)
{
static struct node node1 = {1, NULL};
signal(SIGINT, handler);
insert(&node1);
while (1) {
sleep(1);
}
return 0;
}
volatile 关键字与编译器优化
- 该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下
在信号处理场景中,主循环可能通过一个全局标志判断是否退出,信号处理函数负责修改该标志。编译器优化可能把标志变量缓存到 CPU 寄存器中,导致主循环一直读取寄存器旧值,看不到信号处理函数对内存的修改。volatile 告诉编译器该变量可能被程序之外的因素改变,不允许把它优化到寄存器中,每次访问都必须从真实内存读取。它解决的是"内存可见性"和编译器优化问题,不是线程同步问题。
没有加volatile的情况:
c
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
flag = 1;
}
int main(void)
{
signal(SIGINT, handler);
while (!flag) {
// 编译器可能优化为只读取一次 flag
}
printf("process quit normal\n");
return 0;
}
生成exe文件命令:
bash
gcc -O2 test.c -o test

此时运行test时并终端按Ctrl-C,程序没有任何反应,彻底卡死。
这是因为在 -O2 优化下,编译器发现 while(!flag) 循环里没有人修改 flag,于是它只读取了一次内存(此时 flag=0),把它放在 CPU 寄存器里,之后的死循环就在比对寄存器。你按 Ctrl-C,handler 确实把内存里的 flag 改成了 1,但主循环只认寄存器里的旧值,根本看不见内存的变化。
退出方法也很简单:Ctrl-\ 或者 kill -9 [pid]
加了volatile的情况:
c
#include <stdio.h>
#include <signal.h>
volatile int flag = 0;
void handler(int sig)
{
flag = 1;
}
int main(void)
{
signal(SIGINT, handler);
while (!flag) {
// 每次循环都从内存读取 flag
}
printf("process quit normal\n");
return 0;
}
操作和上述一致。

此时你按 Ctrl-C,终端会立刻打印出 process quit normal,程序正常退出。
因为volatile 告诉了编译器:"这个变量随时可能在你看不见的地方(比如信号处理函数、硬件中断)被修改,所以每次使用它,都必须去它真实的内存地址取值,绝不允许放进寄存器缓存"。
SIGCHLD 信号与子进程回收
使用 SIGCHLD 避免轮询等待子进程
子进程终止时,父进程会收到 SIGCHLD 信号,该信号默认动作是忽略。父进程可以自定义 SIGCHLD 处理函数,在处理函数中调用 wait 或 waitpid 回收子进程,从而避免父进程阻塞等待或忙轮询。这样父进程可以继续处理自己的工作,子进程退出时再通过信号通知父进程清理。需要注意,多个子进程可能几乎同时退出,SIGCHLD 信号可能合并,因此处理函数中通常要用循环加 WNOHANG 回收所有已退出子进程。
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
void handler(int sig)
{
pid_t id;
(void)sig;
// 循环回收所有已退出的子进程,避免多个子进程退出时信号合并导致漏回收
while ((id = waitpid(-1, NULL, WNOHANG)) > 0) {
printf("wait child success: %d\n", id);
}
}
int main(void)
{
signal(SIGCHLD, handler);
pid_t cid = fork();
if (cid == 0) {
printf("child: %d\n", getpid());
sleep(3);
exit(1);
}
while (1) {
printf("father proc is doing something\n");
sleep(1);
}
return 0;
}
注意:
- SIGCHLD 可能因为多个子进程同时退出而合并,处理函数中必须循环
waitpid。 - 若将 SIGCHLD 处理动作设为
SIG_IGN,在 Linux 上子进程退出后可能自动清理,不产生僵尸进程;但这是 Linux 特性,不保证所有 Unix 系统一致。
完