【Linux】进程信号
本文主题内容
- 理解 Linux 信号的概念与处理方式
- 掌握信号产生的常见方式
- 理解信号产生、保存和递达
- 掌握信号集与信号屏蔽字的操作
- 使用
sigaction捕捉信号 - 理解信号处理函数的执行流程
- 理解中断、异常、系统调用与信号的关系
- 认识可重入函数、
volatile与异步信号安全 - 使用
SIGCHLD回收子进程
引言:按下
Ctrl+C为什么能终止前台进程?除零和野指针为什么最终表现为进程收到信号?阻塞信号与忽略信号有什么区别?信号处理函数为什么不能随便调用printf、malloc?这些问题的背后,是操作系统对异步事件的统一管理。本文从信号的使用开始,逐步分析内核中的保存方式、捕捉流程和并发安全问题。
一、信号的基本概念
1.1 什么是信号
信号是进程之间进行异步事件通知的一种方式,也可以看作操作系统提供给进程的软件中断机制。
信号具有下面几个特点:
- 每个信号都有编号和宏定义名称
- 信号可能在进程执行到任意位置时产生
- 信号的处理方式在信号到来前就已经确定
- 信号产生后,可以暂时保存,未必立即处理
- 常规信号主要表示某类事件是否发生,不适合传递大量数据
信号处理可以分为信号产生、信号保存和信号递达三个阶段。
1.2 查看系统信号
例:
kill-lman7signal常见信号包括:
| 信号 | 编号 | 常见来源 | 默认动作 |
|---|---|---|---|
SIGHUP | 1 | 终端断开或会话变化 | 终止 |
SIGINT | 2 | Ctrl+C | 终止 |
SIGQUIT | 3 | Ctrl+\ | 终止并可能产生 Core |
SIGABRT | 6 | abort | 终止并可能产生 Core |
SIGFPE | 8 | 算术异常 | 终止并可能产生 Core |
SIGKILL | 9 | 强制终止 | 终止 |
SIGSEGV | 11 | 非法内存访问 | 终止并可能产生 Core |
SIGPIPE | 13 | 向无读端管道写入 | 终止 |
SIGALRM | 14 | 闹钟到期 | 终止 |
SIGTERM | 15 | 请求进程正常终止 | 终止 |
SIGCHLD | 17 | 子进程状态变化 | 忽略 |
SIGSTOP | 19 | 强制暂停 | 暂停 |
SIGTSTP | 20 | Ctrl+Z | 暂停 |
不同架构上的部分信号编号可能存在差异,程序应优先使用SIGINT这样的宏,而不是直接写数字。
1.3 三种处理方式
进程对信号可以选择:
- 执行默认动作
- 忽略信号
- 执行自定义处理函数
对应的特殊处理值是:
SIG_DFL SIG_IGNSIGKILL和SIGSTOP不能被捕捉、阻塞或忽略,内核必须保留最终控制进程的能力。
二、初步捕捉信号
2.1 signal 接口
#include<signal.h>typedefvoid(*sighandler_t)(int);sighandler_tsignal(intsignum,sighandler_thandler);signal用于设置某个信号的处理方式。它只是注册处理函数,并不会直接调用函数。
2.2 捕捉 SIGINT
例:
#include<signal.h>#include<unistd.h>voidHandler(intsignal_number){constcharmessage[]="get SIGINT\n";write(STDOUT_FILENO,message,sizeof(message)-1);}intmain(){signal(SIGINT,Handler);while(1){pause();}return0;}按下Ctrl+C后,终端驱动和内核向当前前台进程组发送SIGINT。由于程序把默认动作改成了自定义处理函数,所以进程不会直接终止。
2.3 忽略与恢复默认动作
例:
signal(SIGINT,SIG_IGN);此时进程忽略SIGINT。
恢复默认动作:
signal(SIGINT,SIG_DFL);注意:忽略表示信号递达后不执行其他动作;阻塞表示信号暂时不能递达。二者不是同一个概念。
三、信号的产生
3.1 终端按键产生信号
Shell 中常见控制键包括:
Ctrl+C:向前台进程组发送SIGINTCtrl+\:向前台进程组发送SIGQUITCtrl+Z:向前台进程组发送SIGTSTP
控制键只作用于前台进程组。后台进程不会因为当前终端按下Ctrl+C而直接收到同样的终端信号。
3.2 kill 命令
例:
kill-SIGTERM进程IDkill-15进程IDkill命令的名字容易让人误解,它的本质是发送信号。发送的信号是否终止进程,取决于信号类型和目标进程的处理方式。
3.3 kill 函数
#include<signal.h>#include<sys/types.h>intkill(pid_tpid,intsig);例:实现一个简化版kill命令。
#include<signal.h>#include<stdio.h>#include<stdlib.h>#include<sys/types.h>intmain(intargc,char*argv[]){if(argc!=3){fprintf(stderr,"Usage: %s signal pid\n",argv[0]);return1;}intsignal_number=atoi(argv[1]);pid_tid=(pid_t)atoi(argv[2]);if(kill(id,signal_number)<0){perror("kill");return2;}return0;}3.4 raise 与 abort
raise用于给当前进程发送信号。
intraise(intsig);例:
raise(SIGINT);abort会使当前进程异常终止,通常表现为收到SIGABRT。
#include<stdlib.h>voidabort(void);3.5 alarm 产生信号
#include<unistd.h>unsignedintalarm(unsignedintseconds);调用alarm(5)后,内核会在大约 5 秒后向当前进程发送SIGALRM。每个进程只有一个传统 alarm,重新设置会覆盖之前的倒计时。
例:
#include<signal.h>#include<unistd.h>volatilesig_atomic_ttimeout=0;voidAlarmHandler(intsignal_number){timeout=1;}intmain(){signal(SIGALRM,AlarmHandler);alarm(1);while(!timeout){//执行其他计算}return0;}alarm默认只触发一次。需要周期定时,可以在处理函数外重新设置,或使用setitimer、POSIX Timer、timerfd等接口。
3.6 软件条件产生信号
某些信号由内核检测到的软件条件产生。例如:
- 向所有读端已经关闭的管道写数据,产生
SIGPIPE - 子进程退出或状态变化,父进程收到
SIGCHLD - 闹钟到期,进程收到
SIGALRM
3.7 硬件异常转化为信号
程序执行除零、非法地址访问等指令时,CPU 先产生异常并进入内核。内核判断异常与当前用户进程有关,随后把它转化为信号,在返回用户态前进行处理。
因此,信号本身仍然由操作系统发送。硬件异常只是触发内核处理的一种来源。
四、Core Dump
4.1 什么是 Core Dump
某些信号的默认动作不仅终止进程,还会尝试生成 Core 文件。Core 文件保存进程异常时的部分内存、寄存器和线程状态,可用于事后调试。
4.2 开启与查看限制
例:
ulimit-culimit-cunlimited是否真正生成 Core,还受到系统配置、资源限制、目录权限、容器环境等因素影响。
4.3 使用 GDB 分析
例:
gcc test.c-g-otest./test gdb ./test core进入 GDB 后可以使用:
bt info registers frame 0注意:Core 文件可能包含口令、密钥、请求数据等敏感内容,生产环境中应当控制保存位置和访问权限。
五、信号产生、未决与递达
5.1 三个状态
- 信号产生:事件发生,内核决定向进程发送信号
- 信号未决:信号已经产生,但还没有执行处理动作
- 信号递达:进程实际执行默认、忽略或自定义动作
如果信号被阻塞,它会保持未决状态,直到解除阻塞后才有机会递达。
5.2 内核中的表示
内核需要为每个进程保存:
- 哪些信号正在被阻塞
- 哪些信号处于未决状态
- 每个信号对应什么处理动作
常规信号通常使用位图表示。某个常规信号在递达前产生多次,未决位仍然只是1,因此一般不会累计次数。实时信号则支持排队,但本文不展开。
5.3 阻塞与忽略
假设SIGINT已经被阻塞,此时用户按下Ctrl+C:
- 内核把
SIGINT记为未决 - 因为阻塞位有效,暂时不递达
- 进程以后可以修改信号处理动作
- 解除阻塞后,内核按照当时的处理动作递达
所以,即使处理动作暂时设置为忽略,只要信号仍被阻塞,内核也不能简单地把未决状态当作已经完成处理。
六、信号集
6.1 sigset_t
sigset_t用于表示一组信号。其内部实现由系统决定,不应该直接把它当整数操作。
常用函数:
intsigemptyset(sigset_t*set);intsigfillset(sigset_t*set);intsigaddset(sigset_t*set,intsignal_number);intsigdelset(sigset_t*set,intsignal_number);intsigismember(constsigset_t*set,intsignal_number);使用信号集前必须初始化。
例:
sigset_tset;sigemptyset(&set);sigaddset(&set,SIGINT);sigaddset(&set,SIGQUIT);6.2 sigprocmask
intsigprocmask(inthow,constsigset_t*set,sigset_t*oldset);how常见取值:
| 取值 | 作用 |
|---|---|
SIG_BLOCK | 把set中的信号加入当前屏蔽字 |
SIG_UNBLOCK | 从当前屏蔽字中移除set中的信号 |
SIG_SETMASK | 直接用set替换当前屏蔽字 |
例:阻塞SIGINT。
sigset_tset;sigset_toldset;sigemptyset(&set);sigaddset(&set,SIGINT);sigprocmask(SIG_BLOCK,&set,&oldset);恢复原来的屏蔽字:
sigprocmask(SIG_SETMASK,&oldset,NULL);6.3 sigpending
intsigpending(sigset_t*set);该函数读取当前进程的未决信号集。
例:打印 1 到 31 号信号的未决状态。
voidPrintPending(void){sigset_tpending;sigpending(&pending);for(intsignal_number=1;signal_number<=31;signal_number++){intvalue=sigismember(&pending,signal_number);printf("%d",value==1?1:0);}printf("\n");}6.4 阻塞实验
例:
#include<signal.h>#include<stdio.h>#include<unistd.h>intmain(){sigset_tset;sigset_toldset;sigemptyset(&set);sigaddset(&set,SIGINT);sigprocmask(SIG_BLOCK,&set,&oldset);for(intcount=10;count>0;count--){sigset_tpending;sigpending(&pending);printf("SIGINT pending: %d\n",sigismember(&pending,SIGINT));sleep(1);}printf("unblock SIGINT\n");sigprocmask(SIG_SETMASK,&oldset,NULL);return0;}运行期间按下Ctrl+C,SIGINT会进入未决状态。恢复屏蔽字时,如果仍然采用默认动作,信号递达后进程会终止。
七、使用 sigaction 捕捉信号
7.1 sigaction 接口
sigaction比signal提供更明确、更完整的控制。
#include<signal.h>intsigaction(intsignal_number,conststructsigaction*action,structsigaction*old_action);结构中的常用字段:
sa_handler:普通信号处理函数sa_mask:执行处理函数期间额外阻塞的信号sa_flags:行为选项
7.2 注册处理函数
例:
#include<signal.h>#include<string.h>#include<unistd.h>voidHandler(intsignal_number){constcharmessage[]="signal delivered\n";write(STDOUT_FILENO,message,sizeof(message)-1);}intmain(){structsigactionaction;memset(&action,0,sizeof(action));action.sa_handler=Handler;sigemptyset(&action.sa_mask);action.sa_flags=0;if(sigaction(SIGINT,&action,NULL)<0){return1;}while(1){pause();}return0;}7.3 sa_mask 的作用
处理某个信号时,内核会自动暂时阻塞当前信号,避免同类信号立即重入。如果还希望处理期间阻塞其他信号,可以加入sa_mask。
例:处理SIGINT时同时阻塞SIGQUIT。
sigemptyset(&action.sa_mask);sigaddset(&action.sa_mask,SIGQUIT);处理函数返回后,原来的信号屏蔽字会自动恢复。
八、信号处理函数的执行流程
8.1 用户态与内核态切换
信号处理函数位于用户空间,但信号状态由内核维护。一个典型流程是:
- 用户程序正在执行
- 因中断、异常或系统调用进入内核
- 内核完成当前工作,准备返回用户态
- 内核检查到有可递达信号
- 内核构造新的用户态上下文,先执行信号处理函数
- 处理函数结束后通过
sigreturn再次进入内核 - 内核恢复被打断的用户态上下文,原程序继续运行
信号处理函数不是由main普通调用的,二者是两个独立的控制流程。处理完成后,程序通常从被打断的位置附近继续执行。
8.2 信号为什么是异步的
用户程序无法预知信号具体在何时递达。它可能发生在一次表达式计算、内存分配或链表修改的中间。因此信号处理函数与主流程之间存在类似并发的重入问题。
注意:信号可能在主流程执行到几乎任意位置时打断它,编写处理函数时必须按照异步环境考虑安全性。
九、中断、异常与系统调用
9.1 硬件中断
键盘、网卡、磁盘等外设可以通过中断通知 CPU。CPU 根据中断向量进入内核预先注册的处理程序,操作系统不需要持续轮询每个设备。
9.2 时钟中断
时钟设备周期产生中断,为操作系统提供时间基准。内核可以在时钟中断中更新时间、统计时间片并触发调度,使多个进程轮流使用 CPU。
9.3 异常
除零、缺页、非法指令、访问越界等事件由 CPU 在执行当前指令时检测,属于同步异常。内核接管后决定怎样处理:
- 合法缺页:分配或加载页面,更新页表后继续执行
- 非法内存访问:向当前进程产生
SIGSEGV - 除零:向当前进程产生
SIGFPE
9.4 系统调用
用户程序不能直接执行所有特权操作,需要通过syscall等机制主动进入内核。系统调用号用于选择具体的内核服务,参数通过寄存器或用户缓冲区传递。
9.5 统一理解
可以把三者区分为:
- 中断:由外部设备异步触发
- 异常:由当前指令执行结果同步触发
- 系统调用:用户程序主动请求进入内核
它们都会导致 CPU 从用户态进入内核态,但触发来源和处理语义不同。信号经常是内核处理这些事件之后,通知用户进程的一种结果。
十、可重入函数与异步信号安全
10.1 什么是重入
假设主流程正在修改一个全局链表,只完成了一半就被信号打断;处理函数又调用同一个链表插入函数。第二次调用结束后,主流程继续执行,链表可能已经被破坏。
同一个函数在前一次调用尚未结束时再次进入,叫作重入。如果函数依赖共享可变状态,重入就可能导致错误。
10.2 常见的不安全操作
信号处理函数中不应随意调用:
printf、std::cout等缓冲 IOmalloc、free、new、delete- 大多数使用内部锁或全局状态的库函数
因为主流程可能正好在这些函数内部被打断,处理函数再次调用会破坏数据或产生死锁。
POSIX 定义了一组异步信号安全函数,write是常用成员之一。
10.3 安全的设计方式
信号处理函数应当尽量短,只做下面这类操作:
- 设置
volatile sig_atomic_t类型的标志 - 向预先创建的管道写入一个简单通知
- 调用明确属于异步信号安全集合的函数
把真正的业务处理留给主循环完成。
十一、volatile 与 sig_atomic_t
11.1 volatile 的作用
编译器可能把循环中反复读取的普通变量缓存到寄存器。如果信号处理函数异步修改该变量,主循环未必会按预期重新读取内存。
volatile告诉编译器,每次访问都应当按照可观察内存操作处理,不要把值长期假定为不变。
11.2 信号标志的写法
例:
#include<signal.h>#include<unistd.h>volatilesig_atomic_tquit=0;voidHandler(intsignal_number){quit=1;}intmain(){signal(SIGINT,Handler);while(!quit){//执行主循环}return0;}sig_atomic_t保证该类型的简单读写适合用于信号处理场景。
注意:volatile不是线程同步工具,不提供复合操作原子性、内存顺序或互斥。多线程并发应使用 C/C++ 原子类型、互斥锁等机制。
十二、SIGCHLD 与子进程回收
12.1 SIGCHLD
子进程退出、停止或继续时,父进程可能收到SIGCHLD。其默认动作通常是忽略,但这不等于子进程一定会自动被回收。
父进程可以注册处理函数,在收到通知后调用waitpid回收已经退出的子进程。
12.2 为什么要循环 waitpid
常规信号不保证按产生次数排队。多个子进程几乎同时退出时,父进程可能只观察到一次SIGCHLD递达,因此处理函数中要循环回收。
例:
#include<errno.h>#include<signal.h>#include<sys/wait.h>#include<unistd.h>voidChildHandler(intsignal_number){intold_errno=errno;while(waitpid(-1,NULL,WNOHANG)>0){}errno=old_errno;}intmain(){structsigactionaction;action.sa_handler=ChildHandler;sigemptyset(&action.sa_mask);action.sa_flags=SA_RESTART|SA_NOCLDSTOP;sigaction(SIGCHLD,&action,NULL);for(inti=0;i<3;i++){pid_tid=fork();if(id==0){_exit(0);}}while(1){pause();}return0;}保存和恢复errno,可以避免处理函数改变主流程正在观察的错误码。
十三、常见问题
13.1 pause 的竞态条件
下面的思路存在竞态:
while(!flag){pause();}如果信号恰好在判断flag之后、调用pause之前递达,程序可能错过唤醒并永久阻塞。需要原子地更换屏蔽字并等待时,应使用sigsuspend。
13.2 被信号中断的系统调用
阻塞系统调用可能因为信号递达而返回-1,同时把errno设为EINTR。程序要根据接口语义选择重试,或者为sigaction设置合适的SA_RESTART。
13.3 不要在处理函数中做复杂工作
处理函数执行期间,主流程处于被打断状态。复杂日志、内存分配、锁操作和业务回调都会扩大重入风险。最可靠的策略是只记录事件,再由正常执行流处理。
十四、总结
信号是操作系统向进程异步通知事件的机制。信号可以由终端按键、命令与系统调用、软件条件或硬件异常产生。产生后的信号先进入未决状态,内核根据进程的阻塞集合与处理动作,在合适的返回用户态时机完成递达。
信号处理有默认、忽略和自定义三种方式。sigset_t、sigprocmask和sigpending用于观察并控制信号集合,sigaction用于可靠地注册处理函数和临时屏蔽其他信号。信号处理函数属于异步控制流,必须考虑可重入性和异步信号安全,通常只设置volatile sig_atomic_t标志或发送简单通知。
真正理解信号,需要把用户态程序、内核中的 pending/block/handler 信息以及中断返回流程连在一起,而不是只记住几个信号编号。
