☰
Linux系统编程(四)——进程信号
2026/10/24 6:35:51 网站建设 项目流程

1. 前言

上一篇讲进程间通信时,通信双方都是约定好再交流:管道要先建立,共享内存要先挂接,数据的收发时机是可控的。但还有一种通知完全不受进程控制——无论进程此刻执行到哪一行代码,按下 Ctrl-C 它都会终止。这种异步通知机制就是信号(Signal),它是 Linux 中进程之间事件异步通知的一种方式。

本章主要目标:

  1. 理解信号的概念与生命周期,掌握信号递达时的三种处理方式。
  2. 理解信号在内核中的表示(三张表),掌握信号集操作与 sigprocmask 的使用。
  3. 掌握 sigaction 信号捕捉,并通过 volatile 与 SIGCHLD 两个典型应用理解信号的实际用途。

2. 信号的基本概念

2.1 信号是什么

一句话定义:信号是进程之间事件异步通知的一种方式,属于软中断。

两个关键词:

  1. 异步:信号什么时候到来,进程无法预测,它相对于进程自身的控制流是异步发生的。进程执行到任何一行代码时都可能收到信号。
  2. 软中断:信号是软件层面模拟中断机制的产物,区别于键盘、网卡等外设触发的硬件中断。比如按下 Ctrl-C 时,键盘输入先通过硬件中断到达操作系统,操作系统再把它解释成信号发给前台进程。

信号本身只携带一个编号,告诉进程"某件事发生了",不传输业务数据。 使用kill -l可以查看系统支持的全部信号:

kill -l # 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP # ...

131 号是普通信号,3464 号是实时信号,本章重点讨论普通信号。查看信号详细说明可以执行man 7 signal。

2.2 信号的生命周期

一个信号从产生到处理完毕,经历三个阶段:

产生 → 未决(Pending) → 递达(Delivery)
  • 未决(Pending):信号已经产生,但还没有被处理,处于等待状态。这段时间里信号被记录在进程的 PCB 中。
  • 递达(Delivery):实际执行信号的处理动作。

信号递达时有三种处理方式,三选一:

  1. 执行默认动作(SIG_DFL):每个信号出厂自带的处理行为,大多数信号的默认动作是终止进程。
  2. 忽略此信号(SIG_IGN):递达后什么都不做。
  3. 自定义捕捉:注册一个处理函数,内核在递达该信号时调用它。这是回调机制,handler函数不是main主动调用,而是操作系统触发调用。

2.3 常见信号汇总

编号名称触发场景默认动作
2SIGINTCtrl-C终止进程
3SIGQUITCtrl-\终止 + Core Dump
8SIGFPE除零等算术异常终止 + Core Dump
9SIGKILLkill -9终止进程
11SIGSEGV野指针等非法内存访问终止 + Core Dump
13SIGPIPE管道读端关闭后继续写终止进程
14SIGALRMalarm 设定的闹钟到点终止进程
17SIGCHLD子进程终止时通知父进程忽略
19SIGSTOPkill -19暂停进程

重点:SIGKILL(9) 和 SIGSTOP(19) 比较特殊:不能被捕捉、不能被阻塞、不能被忽略,后面会解释底层原因。

2.4 阻塞与忽略的区别

进程可以选择**阻塞(Block)**某个信号:被阻塞的信号产生后会一直停留在未决状态,直到解除阻塞才能递达。

一句话区分:阻塞是信号还没递达就被拦住(不许进门),忽略是信号已经递达后选择不处理(进门了装作没看见)。 阻塞决定信号"能不能"被处理;忽略只是递达之后的一种处理策略。

3. 信号的产生

信号共有四种产生方式:终端按键、系统调用、软件条件、硬件异常。

3.1 终端按键

终端按键产生的信号,只会发送给前台进程。命令末尾加&可以把进程放到后台运行,后台进程无法接收键盘信号,只能使用kill命令发送信号。

按键信号默认动作
Ctrl-CSIGINT(2)终止
Ctrl-\SIGQUIT(3)终止 + Core Dump
Ctrl-ZSIGTSTP(20)暂停

3.2 系统调用产生信号

#include <signal.h> int kill(pid_t pid, int sig); // 向指定进程发信号,kill 命令的底层实现 int raise(int sig); // 向自己发信号,等价于 kill(getpid(), sig) #include <stdlib.h> void abort(void); // 向自己发 SIGABRT(6),异常终止进程

3.3 软件条件产生信号

程序运行满足软件条件,内核自动发送信号,属于软件层面触发。 典型例子:

  1. SIGPIPE:管道读端关闭,写端继续调用write写数据,内核向写进程发送SIGPIPE(13),默认终止进程。
  2. SIGALRM闹钟信号:
#include <unistd.h> unsigned int alarm(unsigned int seconds); // 告诉内核 seconds 秒后向自己发 SIGALRM(14) // 返回值:0,或上一个闹钟的剩余秒数;alarm(0) 取消之前的闹钟

3.4 硬件异常产生信号

CPU/MMU检测到硬件异常,内核捕获异常,翻译成信号发送给当前进程:

  • 整数除以 0:CPU运算单元检测,产生 SIGFPE(8)
  • 访问非法内存(解引用空指针、越界访问):MMU检测页表异常,产生 SIGSEGV(11)

核心结论:C/C++里面遇到的除零、内存越界崩溃,底层都是硬件异常被内核转为信号处理。程序崩溃链路:MMU发现非法访问 → 通知内核 → 内核向进程发送SIGSEGV → 默认动作终止进程。

4. 信号在内核中的表示

4.1 内核三张表

信号能否被处理、是否已经产生、如何处理,全部记录在进程PCB(task_struct)内部的三张表:

block 表(阻塞信号集) pending 表(未决信号集) handler 表(处理动作) 1 0 0 1 ... 0 1 0 0 ... SIG_DFL / SIG_IGN / 函数指针

三条核心规则:

  1. 信号产生时,内核把 pending 表对应bit置1;信号递达处理完成后,该bit清零。发送信号的本质,仅仅是修改pending位图的bit位,没有任何数据拷贝。
  2. 如果信号被block阻塞,就算信号产生,pending置1,也停留在未决状态,不会递达。
  3. handler处理动作,在信号到来之前就提前登记,进程预先知道每个信号该如何处理。

三张表底层都是位图,数据类型为sigset_t,不能直接用普通位运算操作,必须调用系统提供的信号集函数。

4.2 信号集操作函数

函数功能
sigemptyset清空信号集,全部置0
sigfillset将信号集全部bit置1
sigaddset向集合中添加指定信号
sigdelset从集合中删除指定信号
sigismember判断指定信号是否在集合中,存在返回1,不存在返回0

4.3 sigprocmask 与 sigpending

#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oset); // 读写block阻塞信号集 // set不为空:根据how参数修改阻塞掩码;oset不为空:输出修改前旧的阻塞信号集 int sigpending(sigset_t *set); // 读取pending未决信号集,把当前未决信号放入set

how参数三种取值:

how含义
SIG_BLOCK将set里的信号追加到阻塞集(按位或,新增阻塞)
SIG_UNBLOCK从阻塞集中,解除set内信号的阻塞
SIG_SETMASK使用set整体直接替换当前阻塞掩码

4.4 实验:阻塞 SIGINT 观察未决信号

// block_sig.c #include <stdio.h> #include <signal.h> #include <unistd.h> void printpending(sigset_t *set) { for (int i = 1; i <= 31; i++) { if (sigismember(set, i)) putchar('1'); else putchar('0'); } putchar('\n'); } int main() { sigset_t iset, oset, pset; sigemptyset(&iset); sigaddset(&iset, SIGINT); // 集合中加入 SIGINT sigprocmask(SIG_BLOCK, &iset, &oset); // 阻塞 SIGINT while (1) { sigpending(&pset); // 读取未决信号集并打印 printpending(&pset); sleep(1); } return 0; }

编译运行:

gcc block_sig.c -o block_sig ./block_sig # 0000000000000000000000000000000 # 0000000000000000000000000000000 # 按下 Ctrl-C 后 # 0100000000000000000000000000000 # 0100000000000000000000000000000

按下Ctrl-C,SIGINT对应的bit变为1并保持不变:信号已经产生,pending标记置1,但是被阻塞,无法递达。此时按下Ctrl-\发送SIGQUIT,该信号没有被阻塞,正常递达终止进程。

5. 信号的捕捉

5.1 signal 简易接口

#include <signal.h> void (*signal(int signum, void (*handler)(int)))(int); // 用法:signal(信号编号, 处理函数) // 处理函数原型必须是 void func(int)

捕捉SIGINT示例代码:

// catch_sig.c #include <stdio.h> #include <signal.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { signal(2, handler); while (1); return 0; }
gcc catch_sig.c -o catch_sig ./catch_sig ^Ccatch a sig : 2 ^Ccatch a sig : 2

捕捉后,Ctrl-C不再终止进程,每次按下触发handler打印。此时只能用Ctrl-\退出,因为SIGQUIT没有注册捕捉函数,执行默认动作终止。

5.2 硬件异常捕捉实验

// segv_sig.c #include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { signal(SIGSEGV, handler); sleep(1); int *p = NULL; *p = 100; // 对空指针写入,触发 SIGSEGV while (1); return 0; }
gcc segv_sig.c -o segv_sig ./segv_sig # catch a sig : 11 # catch a sig : 11 # ...无限刷屏

现象分析:捕捉SIGSEGV之后进程不会退出,持续循环打印信号编号。 原因:handler执行结束后,CPU回到触发异常的那条非法指令*p=100重新执行。内存非法访问问题本身没有修复,指令再次执行,再次触发SIGSEGV,形成死循环。

重点:信号捕捉只是接住通知,不会自动修复异常。想要跳出,需要在handler内部使用非局部跳转(longjmp)跳出错误代码。

5.3 sigaction(推荐标准接口)

signal接口简单,但是存在跨平台行为差异,正式开发优先使用posix标准接口sigaction。

#include <signal.h> int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact); struct sigaction { void (*sa_handler)(int); // 处理动作:SIG_DFL / SIG_IGN / 自定义函数 sigset_t sa_mask; // handler执行期间,额外屏蔽的信号集 int sa_flags; // 选项,一般置0 };

使用示例:

struct sigaction act; act.sa_handler = handler; sigemptyset(&act.sa_mask); // sa_mask使用前必须初始化清空 act.sa_flags = 0; sigaction(SIGINT, &act, NULL);

两个核心细节:

  1. handler执行的时候,当前触发的信号会自动屏蔽,防止递归触发;sa_mask用来指定handler运行期间额外要屏蔽的其他信号。handler函数返回后,阻塞信号集自动恢复。
  2. 自定义handler运行在用户态,和main属于同一个进程,没有函数调用关系,是两条独立控制流,栈空间互相独立。

5.4 SIGKILL 和 SIGSTOP 不能被捕捉

SIGKILL(9) 和 SIGSTOP(19),无法捕捉、无法阻塞、无法忽略。 设计原因:操作系统必须保留对进程的最高管控权限。如果进程可以屏蔽所有信号,恶意程序就会拒绝被终止,操作系统失去控制权,系统卡死。kill -9永远可以杀掉正常存活的进程,就是这个原理。

6. 两个典型应用

6.1 volatile:保持内存可见性

// volatile_sig.c #include <stdio.h> #include <signal.h> int flag = 0; // 修改为 volatile int flag = 0; void handler(int sig) { flag = 1; } int main() { signal(2, handler); while (!flag); printf("process quit normal\n"); return 0; }

编译运行,开启O2优化:

gcc -O2 volatile_sig.c -o volatile_sig ./volatile_sig # 按下Ctrl-C,handler执行,但是循环不会退出 # 修改为 volatile int flag = 0; 重新编译,按下Ctrl-C,程序正常退出

原理:编译器优化时,发现main函数内部没有修改flag,把flag缓存到CPU寄存器。while循环每次读取寄存器副本,永远是0;但是handler修改的是内存中的flag,寄存器没有同步更新,循环无法退出。

volatile作用:保持内存可见性,告诉编译器,该变量读写必须访问内存,禁止缓存到寄存器。

注意:volatile只保证可见性,不保证原子性,i++这种复合操作依旧需要加锁保证线程安全。

6.2 SIGCHLD:异步回收子进程

回收子进程传统两种方案:阻塞wait(父进程卡住等待)、轮询非阻塞waitpid(不断循环查询,浪费CPU)。 借助SIGCHLD信号:子进程退出时,内核主动发送SIGCHLD通知父进程,父进程在信号handler里面完成回收,不阻塞业务逻辑。

// wait_sig.c #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> #include <sys/wait.h> void handler(int sig) { pid_t id; // 必须while,不能if:普通信号不排队,多个子进程同时退出只会置一次pending位 // while循环一次性把所有已经退出的子进程全部回收干净 while ((id = waitpid(-1, NULL, WNOHANG)) > 0) { printf("wait child success: %d\n", id); } } int main() { signal(SIGCHLD, handler); pid_t cid = fork(); if (cid == 0) { // 子进程:3 秒后退出 sleep(3); exit(1); } while (1) { // 父进程继续执行自己的业务任务 printf("father working...\n"); sleep(1); } return 0; }
gcc wait_sig.c -o wait_sig ./wait_sig # father working... # father working... # father working... # wait child success: 7340 # father working...

关键点:

  1. 使用waitpid(-1, NULL, WNOHANG),非阻塞回收,没有可回收子进程立刻返回0,避免handler卡住。
  2. 必须while循环:普通信号pending只是位图,多个子进程同时退出只会记录一次信号,if只能回收一个子进程,while保证全部僵尸子进程被回收。

7. 本章小结

  1. 信号是进程之间事件异步通知的一种方式,属于软中断;生命周期:产生 → 未决(Pending)→ 递达(Delivery)。
  2. 三种处理方式:SIG_DFL 默认动作、SIG_IGN 忽略、自定义函数捕捉;阻塞发生在递达之前拦截信号,忽略是递达之后选择不处理,二者阶段不同。
  3. 信号4种产生方式:终端按键、系统调用、软件条件、硬件异常;C/C++除零、内存越界底层都会转化为信号。
  4. 内核依靠PCB内block/pending/handler三张表管理信号;发送信号本质是修改pending位图对应bit,无数据拷贝。
  5. sigaction捕捉信号:handler运行在用户态,与main是两条独立控制流;handler执行期间当前信号自动屏蔽;SIGKILL(9)与SIGSTOP(19)不可捕捉、阻塞、忽略。
  6. volatile保证内存可见性,阻止编译器把变量缓存寄存器;SIGCHLD配合while+waitpid(WNOHANG)异步回收子进程,避免僵尸进程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询