本文主要参考文献是正点原子的C应用编程指南,很多内容与正点原子文档中的一致。作为初学者,为了和大家一起学习所以准备写这一系列文章,也是作为自己学习的一个笔记,如果有错误欢迎大家提出来一起讨论。
信号的基本概念
信号是 Linux 系统提供的一种软件中断机制 ,用于通知进程某个事件已经发生 。它是处理异步事件的经典方法------进程无法预测信号到达的准确时间,信号会打断进程当前的正常执行流程,类似于硬件中断打断 CPU 的正常指令执行。
信号的本质
信号在代码中表现为整数编号 (从 1 开始),每个编号对应一个唯一的信号名(如 SIGINT 对应编号 2)。这些信号名以宏定义的形式定义在 <signum.h> 头文件中,以便程序员使用可读的名称而非数字编号。编号 0 是保留值 ,不代表任何信号,但在某些系统调用(如 kill())中有特殊用途(用于检查进程是否存在)。SIGKILL(编号 9)和 SIGSTOP(编号 19)是两个特殊信号------它们不能被进程忽略、阻塞或捕获,向内核和超级用户提供了可靠地终止或暂停进程的方法。
信号的来源(谁可以产生信号?)
信号可以由多种不同的事件和实体产生:
- 硬件异常 :执行了异常的机器语言指令(如除零错误、非法内存访问),硬件检测到后通知内核,内核向当前运行的进程发送相应的信号(如
SIGFPE、SIGSEGV)。 - 终端输入 :用户在终端按下特殊组合键,如
Ctrl+C(产生SIGINT,终止前台进程)或Ctrl+Z(产生SIGTSTP,暂停前台进程)。 - 系统调用 :进程调用
kill()系统调用向另一个进程或进程组发送信号(需要有相应的权限)。 - 命令行工具 :用户通过
kill命令向指定进程发送信号(其内部实现原理正是调用kill()系统调用)。 - 软件条件:进程设置的定时器超时、子进程退出等软件事件触发信号。
进程对信号的三种处理方式
当一个信号到达进程后,进程会根据该信号的配置执行以下三种操作之一:
- 忽略信号 :进程对该信号不做任何处理,就像没发生过一样。但
SIGKILL和SIGSTOP不能被忽略,这是系统保留的可靠控制手段。 - 捕获信号 :进程为该信号注册一个自定义处理函数 。当信号到达时,内核会中断进程当前的执行流,跳转到该处理函数执行,处理完成后恢复原来的执行点。这通过
signal()或sigaction()系统调用实现。 - 执行系统默认操作 :进程不对信号做特殊处理,由内核执行该信号的默认动作。大多数信号的默认操作是终止进程 (
term),有些信号还会生成核心转储文件(core)用于调试。
如果忽略某些由硬件异常产生的信号,则进程的运行行为是未定义的。 因为硬件异常(如除零、非法内存访问)发生时,处理器的状态已经被破坏(例如当前指令无法继续执行)。如果进程选择忽略这类信号(即不让进程终止),那么 CPU 将处于一个不可恢复的异常状态,后续执行的任何指令都可能产生不可预测的结果。因此,对于
SIGFPE、SIGSEGV、SIGILL、SIGBUS等硬件异常信号,正确的做法是让系统执行默认操作(终止进程)或捕获后做清理并主动退出,而不应该将其忽略。
信号的异步特性
信号是异步事件 的典型实例------信号产生的时刻对于进程来说是不可预测的 ,进程无法通过简单地检查一个变量或调用一个函数来判断信号是否即将到达。信号可能在进程执行的任意时间点打断当前的控制流,这与硬件中断在任意时刻打断 CPU 指令执行的机制完全一致。
信号的分类
信号可以从两个不同维度进行分类:可靠性 (是否支持排队,多次收到信号时是否会被丢失)和实时性(是否有序)。
可靠信号与不可靠信号
早期的 UNIX 信号机制存在两个主要缺陷:一是进程处理完信号后,内核会将响应方式重置为系统默认操作(需用户手动再次绑定);二是信号可能丢失(处理信号时又来了新的同种信号,会被丢弃)。而Linux中做了一定的改进,即在调用完信号处理函数后,不必重新调用signal()。因此,Linux 下的不可靠信号问题主要指的是信号可能丢失。
- 不可靠信号(非实时信号) :Linux 中编号为 1 ~ 31 的信号属于不可靠信号。它们不支持排队,意味着如果在信号被阻塞期间,同一种信号被发送了多次,最终只会被进程接收到一次(其余的被丢弃)。
- 可靠信号(实时信号) :Linux 中编号为 34 ~ 64 的信号属于可靠信号。它们支持排队 ,由
sigqueue()发送,通过sigaction()绑定处理函数(需设置SA_SIGINFO标志),能够确保发送的多个信号都能被接收。
实时信号与非实时信号
非实时信号就是不可靠信号(标准信号),实时信号就是可靠信号。实时信号保证了信号的排队和顺序传递。
常见信号与默认行为
Linux 标准信号的编号为 1 ~ 31。每个信号都有其系统默认操作 ,进程可以选择忽略、捕获或执行默认操作。但 SIGKILL(9)和 SIGSTOP(19)不能被捕获、忽略或阻塞,这是系统提供的可靠终止/暂停手段。
默认操作类型说明:
term:终止进程core:终止进程并生成核心转储文件(用于调试)ignore:忽略该信号cont:使停止的进程继续运行stop:停止进程(暂停运行,而非终止)
常见信号列表
| 信号名称 | 编号 | 描述 | 系统默认操作 |
|---|---|---|---|
SIGINT |
2 | 终端中断符(Ctrl+C) | term |
SIGQUIT |
3 | 终端退出符(Ctrl+\) | core |
SIGILL |
4 | 非法硬件指令 | core |
SIGABRT |
6 | 异常终止(abort) | core |
SIGBUS |
7 | 内存访问错误(总线错误) | core |
SIGFPE |
8 | 算术异常(如除零) | core |
SIGKILL |
9 | 终极终止信号 | term |
SIGUSR1 |
10 | 用户自定义信号1 | term |
SIGSEGV |
11 | 无效的内存引用(段错误) | core |
SIGUSR2 |
12 | 用户自定义信号2 | term |
SIGPIPE |
13 | 管道关闭(向已关闭的管道写入) | term |
SIGALRM |
14 | 定时器超时(alarm) | term |
SIGTERM |
15 | 终止进程(优先考虑的终止进程手段) | term |
SIGCHLD |
17 | 子进程终止或停止 | ignore |
SIGCONT |
18 | 使停止的进程继续运行 | cont |
SIGSTOP |
19 | 停止进程(必停) | stop |
SIGTSTP |
20 | 终端停止符(Ctrl+Z) | stop |
SIGXCPU |
24 | 超过 CPU 时间限制 | core |
SIGVTALRM |
26 | 虚拟定时器超时(setitimer) | term |
SIGWINCH |
28 | 终端窗口尺寸发生变化 | ignore |
SIGPOLL/SIGIO |
29 | 异步 I/O 事件 | term / ignore |
SIGSYS |
31 | 无效系统调用 | core |
SIGCHLD的默认操作是ignore,如果父进程需要获知子进程的状态变化(终止、暂停或恢复),应捕获此信号并在处理函数中调用waitpid()回收子进程,以避免产生僵尸进程。
进程对信号的处理
进程接收到信号后,可以采取三种处理方式:忽略信号 、捕获信号 (执行用户自定义的处理函数)或执行系统默认操作 。Linux 提供了 signal() 和 sigaction() 两个系统调用来设置信号的处理方式。其中 sigaction() 是 signal() 的增强版,提供了更精细的控制能力和更好的可移植性。
signal 函数(系统调用)
signal() 是 Linux 系统下设置信号处理方式最简便的接口,用于为指定的信号绑定处理方式(捕获、忽略或默认)。
c
#include <signal.h>
typedef void (*sig_t)(int);
sig_t signal(int signum, sig_t handler);
-
signum:需要设置的信号编号(可使用信号名或数字编号,推荐使用信号名)。 -
handler:一个sig_t类型的函数指针,指向信号处理函数。该函数接受一个int参数(即触发该函数的信号编号),返回void。handler也可以设置为以下特殊值:SIG_IGN:忽略该信号(但SIGKILL和SIGSTOP不可忽略)。SIG_DFL:恢复为该信号的系统默认操作。
-
返回值 :成功返回之前的信号处理函数指针 (即该信号原来绑定的处理方式);失败返回
SIG_ERR,并设置errno。
signal() 的使用方法简单,但存在一些可移植性问题(不同 UNIX 系统对信号处理函数执行期间是否自动阻塞同种信号的行为不一致)。因此,推荐使用 sigaction() 替代 signal() 。
sigaction 函数(系统调用)
sigaction() 是 signal() 的增强版本,提供了更精确的控制、更好的可移植性,并允许单独获取当前信号的处理方式而不修改它 (通过将 act 参数设置为 NULL,仅传入 oldact 参数来实现纯粹的查询操作)。
c
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
signum:需要设置的信号(SIGKILL和SIGSTOP除外)。act:指向struct sigaction结构体的指针,用于指定新的信号处理方式。如果为NULL,表示不修改当前配置(仅用于查询)。oldact:指向struct sigaction结构体的指针,用于返回之前的信号处理方式 。如果对该信息不感兴趣,可设置为NULL。注意,即使act为NULL,只要oldact非空,仍然可以获取当前配置(即"单独获取"功能)。- 返回值 :成功返回
0;失败返回-1,并设置errno。
struct sigaction 结构体
c
struct sigaction {
void (*sa_handler)(int); /* 信号处理函数 */
void (*sa_sigaction)(int, siginfo_t *, void *); /* 替代处理函数(可获取更多信息) */
sigset_t sa_mask; /* 信号掩码(执行处理函数期间额外阻塞的信号集) */
int sa_flags; /* 控制信号处理行为的标志 */
void (*sa_restorer)(void); /* 已过时,不再使用 */
};
-
sa_handler:与signal()的handler参数相同,指定信号处理函数。 -
sa_sigaction:替代的信号处理函数,通过siginfo_t参数可以获取发送进程的 PID、UID 以及伴随数据等信息。sa_handler和sa_sigaction是互斥的,不能同时设置。通过sa_flags中的SA_SIGINFO标志选择使用哪一个。 -
sa_mask:指定一组信号,在执行sa_handler(或sa_sigaction)期间,这些信号会被添加到进程的信号掩码中 ,从而在信号处理函数执行期间被阻塞。当处理函数返回后,这些信号会自动从掩码中移除。注意:当前正在处理的信号默认会自动加入掩码 (除非设置了SA_NODEFER标志),以避免信号处理函数的"嵌套"导致竞态条件。即重复收到了两个相同的信号。
一组信号的设置后续会进行介绍。核心的作用就是在执行信号处理函数的时候,指定某一些的信号会被暂时无视,而并非消除,即在执行完信号处理函数后,这些被无视的信号(如果有的话)将会被陆续处理。
-
sa_flags:控制信号处理行为的标志,可用或来多标志组合,常用值包括:SA_SIGINFO:使用sa_sigaction而非sa_handler作为处理函数。SA_NODEFER:在信号处理函数执行期间,不自动阻塞当前处理的信号(即允许信号嵌套)。SA_RESETHAND:执行完信号处理函数后,将信号的处理方式恢复为系统默认操作。SA_RESTART:被该信号中断的某些系统调用,在信号处理完成后会自动重新发起(而不是返回EINTR)。
siginfo_t 结构体(当使用 SA_SIGINFO 时)
当 sa_flags 包含 SA_SIGINFO 标志时,信号处理函数为 void (*sa_sigaction)(int, siginfo_t *, void *)。siginfo_t 结构体提供了丰富的附加信息,其常用字段包括:
si_signo:触发该函数的信号编号。si_errno:错误码(如果有)。si_code:信号的来源码(如SI_USER表示来自kill(),SI_QUEUE表示来自sigqueue())。si_pid:发送进程的 PID。si_uid:发送进程的实际用户 ID。si_value:伴随数据(sigval_t类型,用于实时信号)。si_fd:与异步 I/O 信号相关的文件描述符。
对于异步 I/O 信号(如
SIGIO),si_code字段的值(如POLL_IN、POLL_OUT)可以告知应用程序文件描述符上发生了什么事件(可读、可写或错误),si_fd字段则指示对应的文件描述符编号。
信号处理函数应设计得尽可能简单,类似于中断处理函数------执行时间越短越好。不应在信号处理函数中执行大量消耗 CPU 的操作,以免延长信号被阻塞的时间
向进程发送信号
进程可以通过 kill() 系统调用向另一个进程或进程组发送信号,也可以通过 raise() 库函数向自身发送信号。kill() 是 kill 命令的底层实现,而 raise() 等价于 kill(getpid(), sig)。
kill 函数(系统调用)
kill() 用于向指定的进程或进程组发送信号。
c
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
-
pid:指定接收信号的进程或进程组,其取值含义如下:pid > 0:将信号发送给进程 ID 等于pid的进程。pid = 0:将信号发送给当前进程所属进程组中的所有进程。pid = -1:将信号发送给当前进程有权发送信号的所有进程(进程 1 init 除外)。pid < -1:将信号发送给进程组 ID 等于-pid的进程组中的所有进程。
-
sig:需要发送的信号编号。如果设置为0,则不发送任何信号,但会执行错误检查(可用于检测目标进程是否存在)。 -
返回值 :成功返回
0;失败返回-1,并设置errno。
当
sig = 0时,kill()不发送信号,但仍会检查权限和进程是否存在。如果目标进程不存在,kill()返回-1并将errno设置为ESRCH。这常用于测试一个特定进程是否还在运行。
普通用户进程只能向实际用户 ID 或有效用户 ID 相同的进程发送信号。超级用户(root)可以向任何进程发送信号。这是为了防止恶意进程干扰其他用户的程序。
pid = -1表示向"当前进程有权发送信号的所有进程"发送信号,但 init 进程(PID = 1)除外,因为 init 进程是系统关键进程,不能被随意信号干扰。
-pid指的实际上是进程组ID,pid < -1时,会取其绝对值,并将该绝对值解释为进程组 ID,而不是进程 ID。
raise 函数(库函数)
raise() 用于向当前进程自身发送信号。
c
#include <signal.h>
int raise(int sig);
sig:需要发送的信号编号。- 返回值 :成功返回
0;失败返回非零值。
raise(sig) 在内部等价于 kill(getpid(), sig)。当进程需要向自己发送信号时(如在错误处理中触发超时信号,或测试信号处理函数),使用 raise() 比 kill() 更简洁。
相关函数
killpg(pid_t pgrp, int sig):向进程组发送信号,等价于kill(-pgrp, sig)。在需要精确控制进程组信号发送时使用。
alarm() 和 pause() 函数
alarm() 和 pause() 是两个配合使用的系统调用,常用于实现简单的定时功能和进程挂起。alarm() 用于设置一个定时器,超时后内核向进程发送 SIGALRM 信号;pause() 则使进程挂起(休眠),直到捕获到一个信号才返回。
alarm 函数(系统调用)
alarm() 用于设置一个一次性 的实时定时器(闹钟),当定时时间到达时,内核向当前进程发送 SIGALRM 信号。
c
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
seconds:定时时间(以秒为单位)。如果该参数为0,表示取消之前设置的alarm闹钟(如果有的话)。- 返回值 :返回之前设置的闹钟剩余的秒数 。如果之前没有设置过闹钟,或者闹钟已经超时,则返回
0。
每个进程只能同时存在一个
alarm闹钟。如果调用alarm()时,之前已经设置了一个闹钟且尚未超时,则旧的闹钟会被新的替换,返回值是旧闹钟的剩余秒数。
SIGALRM信号的系统默认操作是终止进程 。因此,如果程序中使用alarm()只是为了延时,而不希望进程被终止,则必须捕获SIGALRM信号并绑定一个自定义处理函数(或将其忽略)。
alarm()设定的定时器是一次性 的,只会触发一次。如果需要周期性定时,必须在SIGALRM信号处理函数中再次调用alarm()来重新设定。
pause 函数(系统调用)
pause() 使当前进程进入休眠状态(挂起) ,直到捕获到一个信号为止。
c
#include <unistd.h>
int pause(void);
- 参数:无。
- 返回值 :
pause()总是返回-1,并将errno设置为EINTR(被信号中断)。它不会正常返回 0,因为它的设计目的就是挂起进程,只有当信号中断时才会恢复执行。
pause()只有在捕获到信号(即信号处理函数执行完毕并返回) 后才会返回。如果信号被忽略或阻塞,pause()将永远阻塞,不会返回。其返回值无法区分"错误"和"被唤醒",因此通常不需要检查其返回值,仅用于等待信号发生。
pause()与alarm()常组合使用,用于实现简单的"定时等待"功能:先调用alarm()设定超时时间,然后调用pause()挂起进程,等待SIGALRM信号唤醒。但该组合存在竞争条件 ------如果alarm超时信号在pause()调用之前就到达了,pause()将永远不会被唤醒(因为信号已经丢失)。
示例
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
static void sig_handler(int sig)
{
puts("Alarm timeout");
}
int main(int argc, char *argv[])
{
struct sigaction sig = {0};
int second;
if (2 > argc)
exit(-1);
/* 为 SIGALRM 信号绑定处理函数 */
sig.sa_handler = sig_handler;
sig.sa_flags = 0;
if (-1 == sigaction(SIGALRM, &sig, NULL)) {
perror("sigaction error");
exit(-1);
}
second = atoi(argv[1]);
printf("定时时长: %d 秒\n", second);
alarm(second); /* 设置定时器 */
pause(); /* 挂起进程,等待 SIGALRM 唤醒 */
puts("休眠结束");
exit(0);
}
如果在
alarm定时期间,进程收到了其他信号(如用户按下的Ctrl+C产生的SIGINT),pause()会被SIGINT唤醒并返回,导致模拟的sleep功能提前结束,完全无法保证休眠时长。
信号集
信号集(sigset_t 类型)是 Linux 中用于表示一组信号 的数据结构。它本质上是一个位掩码(bitmask) ------每一位对应一个信号编号。但在应用程序中,sigset_t 被视为一个不透明类型 ,绝对禁止 直接操作其内部位(如直接读写 __val 数组),因为其内部结构随硬件平台而不同。必须通过以下标准 API 来初始化、添加、删除和测试信号集。
初始化信号集
sigemptyset 函数(库函数)
sigemptyset() 将指定的信号集初始化为空集(即不包含任何信号)。
c
#include <signal.h>
int sigemptyset(sigset_t *set);
set:指向需要初始化的信号集变量的指针。- 返回值 :成功返回
0;失败返回-1,并设置errno。
sigemptyset()或sigfillset()必须 在信号集首次使用之前调用。未初始化的sigset_t变量包含随机数据,直接使用会导致不可预测的行为。
sigfillset 函数(库函数)
sigfillset() 将指定的信号集初始化为全集(即包含所有信号,包括编号 34~64 的实时信号)。
c
#include <signal.h>
int sigfillset(sigset_t *set);
set:指向需要初始化的信号集变量的指针。- 返回值 :成功返回
0;失败返回-1,并设置errno。
sigfillset()会将所有 支持的信号(包括实时信号)都加入到集合中。如果只需要包含标准信号(1~31),需要调用sigfillset()后再用sigdelset()移除不需要的实时信号,或者使用sigemptyset()+sigaddset()逐一添加。
向信号集中添加/删除信号
sigaddset 函数(库函数)
sigaddset() 向信号集中添加一个指定的信号。
c
#include <signal.h>
int sigaddset(sigset_t *set, int signum);
set:指向信号集的指针。signum:需要添加的信号编号(如SIGINT)。- 返回值 :成功返回
0;失败返回-1,并设置errno。
sigdelset 函数(库函数)
sigdelset() 从信号集中移除一个指定的信号。
c
#include <signal.h>
int sigdelset(sigset_t *set, int signum);
set:指向信号集的指针。signum:需要移除的信号编号。- 返回值 :成功返回
0;失败返回-1,并设置errno。
在调用
sigaddset()或sigdelset()之前,set必须已经通过sigemptyset()或sigfillset()初始化过。向未初始化的信号集添加信号会导致不可预期的结果。
测试信号是否在信号集中
sigismember 函数(库函数)
sigismember() 测试一个指定的信号是否存在于信号集中。
c
#include <signal.h>
int sigismember(const sigset_t *set, int signum);
set:指向信号集的指针。signum:需要测试的信号编号。- 返回值 :如果信号在集合中,返回
1;如果不在集合中,返回0;如果发生错误(如传入无效信号编号),返回-1。
sigismember()常用于条件判断,例如在处理信号时检查某个信号是否被阻塞。它不会修改信号集本身。
使用方法
c
sigset_t sig_set;
/* 1. 初始化信号集为空 */
sigemptyset(&sig_set);
/* 2. 向集合中添加 SIGINT 和 SIGTERM */
sigaddset(&sig_set, SIGINT);
sigaddset(&sig_set, SIGTERM);
/* 3. 测试 SIGINT 是否在集合中 */
if (1 == sigismember(&sig_set, SIGINT)) {
printf("SIGINT 在信号集中\n");
}
/* 4. 从集合中移除 SIGTERM */
sigdelset(&sig_set, SIGTERM);
/* 此时 sig_set 只包含 SIGINT */
获取信号的描述信息【了解即可】
Linux 为每个信号都预定义了一串简短的描述信息 (如 SIGINT 对应 "Interrupt"),用于说明该信号的含义。这些描述信息可以通过直接访问全局数组 sys_siglist,或通过 strsignal() 和 psignal() 两个库函数来获取。
sys_siglist 全局数组
sys_siglist 是一个 char * 类型的全局数组,数组中的每一个元素指向一个描述信息字符串。它定义在 <signal.h> 头文件中,可以直接通过信号编号索引访问。
c
#include <signal.h>
extern char *sys_siglist[];
- 使用方式:
sys_siglist[SIGINT]返回指向"Interrupt"字符串的指针。 - 该数组是只读的,应用程序不应修改其内容。
- 与
strsignal()相比,直接访问数组更高效,但不进行边界检查 ,如果传入无效的信号编号(如1000),会导致访问越界,产生不可预测的结果。
由于
sys_siglist不提供边界检查,传入无效信号编号会导致访问非法内存。在需要处理可能无效的信号编号时,推荐使用strsignal()代替,因为后者会对无效编号进行安全检查。
strsignal 函数(库函数)
strsignal() 根据给定的信号编号返回对应的描述信息字符串。与直接访问 sys_siglist 不同,该函数会对信号编号进行有效性检查,更加安全。
c
#include <string.h>
char *strsignal(int sig);
sig:需要获取描述信息的信号编号(如SIGINT、SIGQUIT)。- 返回值 :返回指向描述信息字符串的指针。如果传入的
sig无效(如1000),则返回"Unknown signal"。
strsignal()是线程安全的吗?根据 POSIX 标准,strsignal()的返回值可能指向一个静态分配的缓冲区,在多线程环境中调用它可能会被覆盖。但 Linux 的 glibc 实现中,strsignal()通常是线程安全的(使用线程局部存储),但为了最大可移植性,在多线程程序中可考虑使用sys_siglist(直接索引数组,无函数调用开销且无需担心静态缓冲区覆盖)。文档并未深入讨论其可重入性,但通常情况下使用strsignal()是安全的。
psignal 函数(库函数)
psignal() 将信号描述信息直接输出到标准错误(stderr) ,并允许调用者附加一条自定义前缀信息。
c
#include <signal.h>
void psignal(int sig, const char *s);
sig:需要输出描述信息的信号编号。s:自定义前缀字符串。该函数会按"s: 描述信息\n"的格式输出:先输出s字符串,接着输出冒号、空格、信号描述信息,最后输出换行符。- 返回值:无返回值。
psignal()与perror()用法相似,后者用于输出错误码errno对应的描述信息,前者用于输出信号编号对应的描述信息。两者都输出到stderr,常用于调试和错误报告场景。
psignal()不对sig参数进行有效性检查。如果传入无效信号编号,它会输出 "Unknown signal"。它直接输出到stderr,且不提供缓冲区溢出风险(因为直接终端输出),适合快速调试。
使用示例
c
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
/* 方法一:直接访问 sys_siglist 数组 */
printf("SIGINT 描述信息: %s\n", sys_siglist[SIGINT]);
/* 方法二:使用 strsignal(推荐) */
printf("SIGQUIT 描述信息: %s\n", strsignal(SIGQUIT));
/* 传入无效信号编号,strsignal 返回 "Unknown signal" */
printf("编号为 1000 的描述信息: %s\n", strsignal(1000));
/* 方法三:使用 psignal 直接输出到 stderr */
psignal(SIGBUS, "SIGBUS 信号描述信息");
exit(0);
}
信号掩码
信号掩码(Signal Mask)是内核为每个线程 维护的一组信号集合。当进程(或线程)接收到一个属于信号掩码中的信号 时,该信号不会被立即递送 ,而是被内核阻塞(挂起) ,直到该信号从信号掩码中移除后,内核才会将其递送给进程处理。这一机制用于保护关键代码段不被信号中断。
注意:信号掩码是线程层面的属性,在多线程程序中,每个线程拥有自己独立的信号掩码。此处仅讨论单线程场景。
三种向信号掩码添加信号的方式
方式一:调用 signal() 或 sigaction() 设置处理函数时自动添加(默认行为)
当为某个信号注册处理函数时,内核自动将该信号本身 添加到信号掩码中。这确保了在处理该信号期间,同种信号不会再次打断当前处理函数(防止递归重入)。注意 :signal() 只能阻塞当前处理的信号本身,无法阻塞其他信号;而 sigaction() 除了默认阻塞当前信号外,还可以通过 sa_mask 额外指定一组信号,在处理函数执行期间一并阻塞。
方式二:通过 sigaction() 的 sa_mask 字段额外添加
在 struct sigaction 中设置 sa_mask 字段,指定一组信号。当信号处理函数执行时,这组信号会临时 被添加到信号掩码中,处理函数返回后自动恢复。signal() 没有此功能。
方式三:显式调用 sigprocmask() 动态操作
在程序的任意位置,通过 sigprocmask() 系统调用主动添加或移除信号,这是最灵活的方式。
sigprocmask 函数(系统调用)
sigprocmask() 用于显式地获取或修改当前进程的信号掩码。
c
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
-
how:指定对信号掩码执行的操作,可取值如下:SIG_BLOCK:将set指向的信号集添加到当前的信号掩码中(并集操作)。SIG_UNBLOCK:将set指向的信号集从当前的信号掩码中移除。SIG_SETMASK:将当前的信号掩码直接设置为set指向的信号集(覆盖)。
-
set:指向一个信号集。如果为NULL,表示不修改当前掩码,仅用于查询(此时how被忽略)。 -
oldset:如果不为NULL,内核会将修改前的旧信号掩码 保存在oldset指向的信号集中。这常用于"保存-恢复"场景。 -
返回值 :成功返回
0;失败返回-1,并设置errno。
sigprocmask()只能用于单线程 程序。在多线程程序中,应使用pthread_sigmask(),因为每个线程有独立的信号掩码。
阻塞SIGKILL和SIGSTOP是无效的,这两个信号不能被阻塞、忽略或捕获 。sigprocmask()尝试阻塞它们会被内核静默忽略。
使用示例
以下代码演示了如何使用 sigprocmask() 阻塞 SIGINT,验证信号掩码的效果:
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
static void sig_handler(int sig)
{
printf("执行信号处理函数...\n");
}
int main(void)
{
struct sigaction sig = {0};
sigset_t sig_set;
/* 注册信号处理函数 */
sig.sa_handler = sig_handler;
sig.sa_flags = 0;
if (-1 == sigaction(SIGINT, &sig, NULL))
exit(-1);
/* 构建信号集:只包含 SIGINT */
sigemptyset(&sig_set);
sigaddset(&sig_set, SIGINT);
/* 向信号掩码中添加 SIGINT(即阻塞 SIGINT) */
if (-1 == sigprocmask(SIG_BLOCK, &sig_set, NULL))
exit(-1);
/* 向自己发送 SIGINT(此时信号被阻塞,不会立即递送) */
raise(SIGINT);
/* 休眠 2 秒------在这期间,信号处理函数不会执行 */
sleep(2);
printf("休眠结束\n");
/* 从信号掩码中移除 SIGINT(解除阻塞) */
if (-1 == sigprocmask(SIG_UNBLOCK, &sig_set, NULL))
exit(-1);
/* 此时之前发送的 SIGINT 被递送,处理函数执行 */
exit(0);
}
当信号被阻塞时,内核会将其标记为挂起(pending) 状态。解除阻塞后,内核会立即检查挂起信号集,并递送之前被阻塞的信号。
如果同一个信号在阻塞期间被发送多次,对于标准信号(1 ~ 31)只会记录一次;对于实时信号(34 ~ 64)会排队。这是标准信号与实时信号的重要区别。
阻塞等待信号 sigsuspend()
sigsuspend() 用于原子地替换进程的信号掩码并挂起进程 ,直到捕获到一个信号才返回。它解决了"先恢复信号掩码、再调用 pause()"这两个操作之间存在的竞态 ,即如果在恢复掩码和进入休眠之间,目标信号恰好到达,则信号会被丢失,导致 pause() 永远阻塞。
调用 sigsuspend() 相当于以不可中断的原子操作方式执行以下步骤:
- 将进程的信号掩码设置为
mask指定的值。 - 挂起进程(进入休眠),直到捕获到一个信号。
- 从信号处理函数返回后,将进程的信号掩码恢复为调用前的值。
c
#include <signal.h>
int sigsuspend(const sigset_t *mask);
mask:指向一个信号集。在进程挂起期间,进程的信号掩码会被临时替换 为此参数指向的信号集。重要 :mask中不应 阻塞你期望唤醒进程的那个信号,否则该信号将被阻塞,无法唤醒sigsuspend()。通常的做法是:将其他所有信号都阻塞,只解除对目标信号的阻塞。- 返回值 :
sigsuspend()总是返回-1,并将errno设置为EINTR(表示被信号中断)。它不会正常返回0,因为它的设计目的就是挂起进程,只有当信号中断时才会恢复执行。如果调用失败(如传入无效指针),也会返回-1,并设置其他errno值(如EFAULT)。
sigsuspend()是原子操作,这是它最关键的特性。它保证了"恢复信号掩码"和"进入休眠"之间不会被信号插入,从而避免了丢失信号的竞态条件。
传入sigsuspend()的mask信号集通常应阻塞所有不需要的信号,并解除对期望信号的阻塞 。例如,如果只想等待SIGINT,应构造一个包含所有信号(sigfillset)然后删除SIGINT的信号集,传入sigsuspend()。这样,SIGINT不会被阻塞,可以在挂起期间递送并唤醒进程;而其他所有信号都会被阻塞,不会干扰等待过程。
当sigsuspend()因捕获到信号而返回后,进程的信号掩码会被自动恢复为调用前的值 ,无需手动恢复。这使得它非常适合在关键代码段前后配合sigprocmask()使用:进入临界区前阻塞所有干扰信号,退出临界区后调用sigsuspend()原子地等待目标信号并恢复掩码。这种临界区前关闭所有中断的操作类似于FREERTOS在执行特定命令时开启临界区的操作,都是关闭中断,防止被打断。
使用场景:保护关键代码段
当需要执行一段不能被特定信号(如 SIGINT)中断 的代码时,可以结合 sigprocmask() 和 sigsuspend() 实现:
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
static void sig_handler(int sig)
{
printf("执行信号处理函数...\n");
}
int main(void)
{
struct sigaction sig = {0};
sigset_t new_mask, old_mask, wait_mask;
/* 构造 wait_mask:在挂起期间,不阻塞任何信号(即允许所有信号唤醒) */
sigemptyset(&wait_mask);
/* 注册信号处理函数 */
sig.sa_handler = sig_handler;
sig.sa_flags = 0;
if (-1 == sigaction(SIGINT, &sig, NULL))
exit(-1);
/* 构建 new_mask:只包含 SIGINT */
sigemptyset(&new_mask);
sigaddset(&new_mask, SIGINT);
/* 1. 阻塞 SIGINT,保护关键代码段 */
if (-1 == sigprocmask(SIG_BLOCK, &new_mask, &old_mask))
exit(-1);
/* 执行受保护的关键代码(此期间 SIGINT 被阻塞,不会打断) */
puts("执行保护代码段");
/* 2. 原子地:恢复旧掩码 + 挂起等待信号 */
/* 这里传入 &wait_mask(空集),意味着挂起期间不阻塞任何信号 */
/* 但由于我们已经阻塞了 SIGINT,且需要等待它,实际上应传入阻塞了除 SIGINT 外所有信号的掩码 */
/* 但为了简洁说明,此处直接使用空集,表示任何信号都能唤醒 */
if (-1 != sigsuspend(&wait_mask))
exit(-1); /* 正常情况下 sigsuspend 不会返回 0 */
/* 3. 信号到达并处理完成后,sigsuspend 返回,信号掩码已恢复为 old_mask */
/* 此时 SIGINT 已解除阻塞 */
exit(0);
}
在实际应用中,
wait_mask通常设置为阻塞除目标信号以外的所有信号(例如sigfillset(&wait_mask); sigdelset(&wait_mask, SIGINT);),以确保sigsuspend()只 被SIGINT唤醒,而不会因为其他信号(如SIGTERM)误唤醒。否则,如果其他信号到达,sigsuspend()也会返回,导致保护逻辑提前退出。
实时信号
实时信号(编号 34 ~ 64)是对标准信号(1 ~ 31)的增强,解决了标准信号不支持排队、无法携带伴随数据等缺陷。它们通过 sigqueue() 发送,并通过 sigaction() 配合 SA_SIGINFO 标志捕获,以获取更丰富的信号信息。
实时信号与标准信号
| 对比 | 标准信号(1~31) | 实时信号(34~64) |
|---|---|---|
| 排队支持 | 不支持。多个相同信号合并为一次递送 | 支持。多次发送会多次递送 |
| 伴随数据 | 无法携带附加信息 | 可携带一个整数或指针(sigval_t) |
| 传递顺序 | 无顺序保证 | 编号越小的信号优先级越高;同种信号按发送顺序递送 |
| 用户自定义数量 | 仅 SIGUSR1、SIGUSR2 两个信号可被用户自定义 |
31 个(SIGRTMIN ~ SIGRTMAX) |
sigpending 函数(系统调用)
sigpending() 用于获取当前进程被阻塞且尚未递送 的信号集合(即挂起信号集)。对于标准信号,挂起信号集只是一个位掩码,仅能表明"该信号是否发生过",不能表示发生的次数。这是标准信号与实时信号在排队机制上的本质区别。
c
#include <signal.h>
int sigpending(sigset_t *set);
set:指向信号集的指针。调用成功后,当前处于挂起状态的信号会被存放在该信号集中。- 返回值 :成功返回
0;失败返回-1,并设置errno。
sigpending()返回的是整个进程 的挂起信号集合(包括线程级别的信号)。在多线程程序中,它返回的是所有线程挂起信号的并集。它常用于调试或确认某个信号是否被阻塞,例如在调用sigprocmask()阻塞信号后,通过sigpending()检查信号是否确实被挂起。
对于标准信号,sigpending()只能告诉你"某个信号是否处于挂起状态",而无法区分该信号被阻塞期间发生了多少次。如果需要精确计数,应使用实时信号配合sigqueue()。
sigqueue 函数(系统调用)
sigqueue() 用于向指定进程发送实时信号 ,并携带伴随数据。与 kill() 不同,sigqueue() 支持信号排队和伴随数据传递。
c
#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval value);
-
pid:接收信号的进程 PID。与kill()的pid参数不同,sigqueue()只能发送给单个进程,不支持发送给进程组(即不能使用负值)。 -
sig:需要发送的信号编号。可以是实时信号(推荐使用SIGRTMIN+n)或标准信号(但标准信号不支持排队,即使通过sigqueue()发送也不会排队)。 -
value:伴随数据(union sigval类型),可携带一个整数或一个指针:
c
typedef union sigval {
int sival_int; /* 整型数据 */
void *sival_ptr; /* 指针数据 */
} sigval_t;
接收进程在信号处理函数中可通过 siginfo_t 的 si_value 字段获取该数据。
- 返回值 :成功返回
0;失败返回-1,并设置errno。常见错误包括EINVAL(无效信号编号)、ESRCH(目标进程不存在)、EPERM(权限不足)。
struct(结构体) :每个成员拥有独立的内存空间 。结构体的总大小通常是所有成员大小之和(加上内存对齐的填充字节)。修改一个成员不会影响其他成员。union(联合体) :所有成员共享同一块内存空间 。联合体的总大小等于其最大成员 的大小。修改一个成员会覆盖其他成员的值,因为它们在同一个内存地址上存储。
sigqueue()发送的标准信号 (编号 < 34)不会排队 ,即使通过sigqueue()发送,其行为与kill()相同。只有发送实时信号(编号 ≥ 34)时,排队机制才会生效。
使用sigqueue()发送实时信号时,必须 在接收进程中使用sigaction()并设置SA_SIGINFO标志,使用sa_sigaction而非sa_handler作为处理函数,否则伴随数据将无法被接收。
接收实时信号的完整示例
发送端示例 :
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
int main(int argc, char *argv[])
{
union sigval sig_val;
int pid;
int sig;
if (3 > argc)
exit(-1);
pid = atoi(argv[1]);
sig = atoi(argv[2]);
printf("pid: %d\nsignal: %d\n", pid, sig);
sig_val.sival_int = 10; /* 伴随数据:整数 10 */
if (-1 == sigqueue(pid, sig, sig_val)) {
perror("sigqueue error");
exit(-1);
}
puts("信号发送成功!");
exit(0);
}
接收端示例 :
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
static void sig_handler(int sig, siginfo_t *info, void *context)
{
sigval_t sig_val = info->si_value; /* 获取伴随数据 */
printf("接收到实时信号: %d\n", sig);
printf("伴随数据为: %d\n", sig_val.sival_int);
}
int main(int argc, char *argv[])
{
struct sigaction sig = {0};
int num;
if (2 > argc)
exit(-1);
num = atoi(argv[1]);
/* 使用 sa_sigaction 而非 sa_handler,并设置 SA_SIGINFO 标志 */
sig.sa_sigaction = sig_handler;
sig.sa_flags = SA_SIGINFO;
if (-1 == sigaction(num, &sig, NULL)) {
perror("sigaction error");
exit(-1);
}
for ( ; ; )
sleep(1); /* 挂起进程,等待信号 */
exit(0);
}
异常退出 abort() 函数
abort() 用于异常终止 当前进程的运行。它通过向调用进程发送 SIGABRT 信号来触发进程终止,该信号的系统默认操作是终止进程并生成核心转储文件 (用于调试)。与 exit() 或 _exit() 等正常退出方式不同,abort() 通常用于程序检测到不可恢复的错误(如内部数据结构损坏、断言失败)时,立即终止执行并提供调试信息。
c
#include <stdlib.h>
void abort(void);
- 参数:无。
- 返回值 :无返回值。
abort()调用永远不会返回给调用者,因为它会强制终止进程。
abort()会向调用进程发送SIGABRT信号。即使进程捕获了SIGABRT信号并在处理函数中执行了某些操作,进程仍然会被终止 ------因为在处理函数返回后,abort()会再次发送SIGABRT信号,确保进程最终被终止。这是abort()的关键特性:无论阻塞或忽略SIGABRT信号,abort()调用均不受影响,总会成功终止进程。
当abort()被调用时,它还会刷新stdio流的缓冲区(与exit()类似),以确保已缓冲的输出数据在终止前被写入。但在多线程环境中,这一行为可能会导致其他线程的状态不一致,因此abort()主要设计用于单线程或临界错误场景。
如果进程捕获了SIGABRT并在处理函数中调用exit()或_exit(),则进程可能会避免双重终止,但这样做不会生成核心转储文件。标准做法是在处理函数中执行必要的清理(如资源释放、日志记录),然后主动调用_exit()或允许abort()完成其终止流程。
核心转储文件(core dump)可以通过gdb等调试工具进行分析,用于定位程序崩溃时的调用栈和变量状态。生成核心转储需要系统配置允许(如ulimit -c unlimited),否则abort()只会终止进程而不会生成 core 文件。