【Linux】进程信号

本文主题内容

  • 理解 Linux 信号的概念与处理方式
  • 掌握信号产生的常见方式
  • 理解信号产生、保存和递达
  • 掌握信号集与信号屏蔽字的操作
  • 使用 sigaction 捕捉信号
  • 理解信号处理函数的执行流程
  • 理解中断、异常、系统调用与信号的关系
  • 认识可重入函数、volatile 与异步信号安全
  • 使用 SIGCHLD 回收子进程

引言:按下 Ctrl+C 为什么能终止前台进程?除零和野指针为什么最终表现为进程收到信号?阻塞信号与忽略信号有什么区别?信号处理函数为什么不能随便调用 printfmalloc?这些问题的背后,是操作系统对异步事件的统一管理。本文从信号的使用开始,逐步分析内核中的保存方式、捕捉流程和并发安全问题。

一、信号的基本概念

1.1 什么是信号

信号是进程之间进行异步事件通知的一种方式,也可以看作操作系统提供给进程的软件中断机制。

信号具有下面几个特点:

  • 每个信号都有编号和宏定义名称
  • 信号可能在进程执行到任意位置时产生
  • 信号的处理方式在信号到来前就已经确定
  • 信号产生后,可以暂时保存,未必立即处理
  • 常规信号主要表示某类事件是否发生,不适合传递大量数据

信号处理可以分为信号产生、信号保存和信号递达三个阶段。

1.2 查看系统信号

例:

bash 复制代码
kill -l
man 7 signal

常见信号包括:

信号 编号 常见来源 默认动作
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 三种处理方式

进程对信号可以选择:

  1. 执行默认动作
  2. 忽略信号
  3. 执行自定义处理函数

对应的特殊处理值是:

c 复制代码
SIG_DFL
SIG_IGN

SIGKILLSIGSTOP 不能被捕捉、阻塞或忽略,内核必须保留最终控制进程的能力。

二、初步捕捉信号

2.1 signal 接口

c 复制代码
#include <signal.h>

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

signal 用于设置某个信号的处理方式。它只是注册处理函数,并不会直接调用函数。

2.2 捕捉 SIGINT

例:

c 复制代码
#include <signal.h>
#include <unistd.h>

void Handler(int signal_number)
{
    const char message[] = "get SIGINT\n";
    write(STDOUT_FILENO, message, sizeof(message) - 1);
}

int main()
{
    signal(SIGINT, Handler);

    while (1)
    {
        pause();
    }
    return 0;
}

按下 Ctrl+C 后,终端驱动和内核向当前前台进程组发送 SIGINT。由于程序把默认动作改成了自定义处理函数,所以进程不会直接终止。

2.3 忽略与恢复默认动作

例:

c 复制代码
signal(SIGINT, SIG_IGN);

此时进程忽略 SIGINT

恢复默认动作:

c 复制代码
signal(SIGINT, SIG_DFL);

注意:忽略表示信号递达后不执行其他动作;阻塞表示信号暂时不能递达。二者不是同一个概念。

三、信号的产生

3.1 终端按键产生信号

Shell 中常见控制键包括:

  • Ctrl+C:向前台进程组发送 SIGINT
  • Ctrl+\:向前台进程组发送 SIGQUIT
  • Ctrl+Z:向前台进程组发送 SIGTSTP

控制键只作用于前台进程组。后台进程不会因为当前终端按下 Ctrl+C 而直接收到同样的终端信号。

3.2 kill 命令

例:

bash 复制代码
kill -SIGTERM 进程ID
kill -15 进程ID

kill 命令的名字容易让人误解,它的本质是发送信号。发送的信号是否终止进程,取决于信号类型和目标进程的处理方式。

3.3 kill 函数

c 复制代码
#include <signal.h>
#include <sys/types.h>

int kill(pid_t pid, int sig);

例:实现一个简化版 kill 命令。

c 复制代码
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>

int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        fprintf(stderr, "Usage: %s signal pid\n", argv[0]);
        return 1;
    }

    int signal_number = atoi(argv[1]);
    pid_t id = (pid_t)atoi(argv[2]);
    if (kill(id, signal_number) < 0)
    {
        perror("kill");
        return 2;
    }
    return 0;
}

3.4 raise 与 abort

raise 用于给当前进程发送信号。

c 复制代码
int raise(int sig);

例:

c 复制代码
raise(SIGINT);

abort 会使当前进程异常终止,通常表现为收到 SIGABRT

c 复制代码
#include <stdlib.h>

void abort(void);

3.5 alarm 产生信号

c 复制代码
#include <unistd.h>

unsigned int alarm(unsigned int seconds);

调用 alarm(5) 后,内核会在大约 5 秒后向当前进程发送 SIGALRM。每个进程只有一个传统 alarm,重新设置会覆盖之前的倒计时。

例:

c 复制代码
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t timeout = 0;

void AlarmHandler(int signal_number)
{
    timeout = 1;
}

int main()
{
    signal(SIGALRM, AlarmHandler);
    alarm(1);

    while (!timeout)
    {
        //执行其他计算
    }
    return 0;
}

alarm 默认只触发一次。需要周期定时,可以在处理函数外重新设置,或使用 setitimer、POSIX Timer、timerfd 等接口。

3.6 软件条件产生信号

某些信号由内核检测到的软件条件产生。例如:

  • 向所有读端已经关闭的管道写数据,产生 SIGPIPE
  • 子进程退出或状态变化,父进程收到 SIGCHLD
  • 闹钟到期,进程收到 SIGALRM

3.7 硬件异常转化为信号

程序执行除零、非法地址访问等指令时,CPU 先产生异常并进入内核。内核判断异常与当前用户进程有关,随后把它转化为信号,在返回用户态前进行处理。

因此,信号本身仍然由操作系统发送。硬件异常只是触发内核处理的一种来源。

四、Core Dump

4.1 什么是 Core Dump

某些信号的默认动作不仅终止进程,还会尝试生成 Core 文件。Core 文件保存进程异常时的部分内存、寄存器和线程状态,可用于事后调试。

4.2 开启与查看限制

例:

bash 复制代码
ulimit -c
ulimit -c unlimited

是否真正生成 Core,还受到系统配置、资源限制、目录权限、容器环境等因素影响。

4.3 使用 GDB 分析

例:

bash 复制代码
gcc test.c -g -o test
./test
gdb ./test core

进入 GDB 后可以使用:

gdb 复制代码
bt
info registers
frame 0

注意:Core 文件可能包含口令、密钥、请求数据等敏感内容,生产环境中应当控制保存位置和访问权限。

五、信号产生、未决与递达

5.1 三个状态

  • 信号产生:事件发生,内核决定向进程发送信号
  • 信号未决:信号已经产生,但还没有执行处理动作
  • 信号递达:进程实际执行默认、忽略或自定义动作

如果信号被阻塞,它会保持未决状态,直到解除阻塞后才有机会递达。

5.2 内核中的表示

内核需要为每个进程保存:

  • 哪些信号正在被阻塞
  • 哪些信号处于未决状态
  • 每个信号对应什么处理动作

常规信号通常使用位图表示。某个常规信号在递达前产生多次,未决位仍然只是 1,因此一般不会累计次数。实时信号则支持排队,但本文不展开。

5.3 阻塞与忽略

假设 SIGINT 已经被阻塞,此时用户按下 Ctrl+C

  1. 内核把 SIGINT 记为未决
  2. 因为阻塞位有效,暂时不递达
  3. 进程以后可以修改信号处理动作
  4. 解除阻塞后,内核按照当时的处理动作递达

所以,即使处理动作暂时设置为忽略,只要信号仍被阻塞,内核也不能简单地把未决状态当作已经完成处理。

六、信号集

6.1 sigset_t

sigset_t 用于表示一组信号。其内部实现由系统决定,不应该直接把它当整数操作。

常用函数:

c 复制代码
int sigemptyset(sigset_t* set);
int sigfillset(sigset_t* set);
int sigaddset(sigset_t* set, int signal_number);
int sigdelset(sigset_t* set, int signal_number);
int sigismember(const sigset_t* set, int signal_number);

使用信号集前必须初始化。

例:

c 复制代码
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGQUIT);

6.2 sigprocmask

c 复制代码
int sigprocmask(int how, const sigset_t* set, sigset_t* oldset);

how 常见取值:

取值 作用
SIG_BLOCK set 中的信号加入当前屏蔽字
SIG_UNBLOCK 从当前屏蔽字中移除 set 中的信号
SIG_SETMASK 直接用 set 替换当前屏蔽字

例:阻塞 SIGINT

c 复制代码
sigset_t set;
sigset_t oldset;

sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, &oldset);

恢复原来的屏蔽字:

c 复制代码
sigprocmask(SIG_SETMASK, &oldset, NULL);

6.3 sigpending

c 复制代码
int sigpending(sigset_t* set);

该函数读取当前进程的未决信号集。

例:打印 1 到 31 号信号的未决状态。

c 复制代码
void PrintPending(void)
{
    sigset_t pending;
    sigpending(&pending);

    for (int signal_number = 1; signal_number <= 31; signal_number++)
    {
        int value = sigismember(&pending, signal_number);
        printf("%d", value == 1 ? 1 : 0);
    }
    printf("\n");
}

6.4 阻塞实验

例:

c 复制代码
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
    sigset_t set;
    sigset_t oldset;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);
    sigprocmask(SIG_BLOCK, &set, &oldset);

    for (int count = 10; count > 0; count--)
    {
        sigset_t pending;
        sigpending(&pending);
        printf("SIGINT pending: %d\n", sigismember(&pending, SIGINT));
        sleep(1);
    }

    printf("unblock SIGINT\n");
    sigprocmask(SIG_SETMASK, &oldset, NULL);
    return 0;
}

运行期间按下 Ctrl+CSIGINT 会进入未决状态。恢复屏蔽字时,如果仍然采用默认动作,信号递达后进程会终止。

七、使用 sigaction 捕捉信号

7.1 sigaction 接口

sigactionsignal 提供更明确、更完整的控制。

c 复制代码
#include <signal.h>

int sigaction(int signal_number,
              const struct sigaction* action,
              struct sigaction* old_action);

结构中的常用字段:

  • sa_handler:普通信号处理函数
  • sa_mask:执行处理函数期间额外阻塞的信号
  • sa_flags:行为选项

7.2 注册处理函数

例:

c 复制代码
#include <signal.h>
#include <string.h>
#include <unistd.h>

void Handler(int signal_number)
{
    const char message[] = "signal delivered\n";
    write(STDOUT_FILENO, message, sizeof(message) - 1);
}

int main()
{
    struct sigaction action;
    memset(&action, 0, sizeof(action));
    action.sa_handler = Handler;
    sigemptyset(&action.sa_mask);
    action.sa_flags = 0;

    if (sigaction(SIGINT, &action, NULL) < 0)
    {
        return 1;
    }

    while (1)
    {
        pause();
    }
    return 0;
}

7.3 sa_mask 的作用

处理某个信号时,内核会自动暂时阻塞当前信号,避免同类信号立即重入。如果还希望处理期间阻塞其他信号,可以加入 sa_mask

例:处理 SIGINT 时同时阻塞 SIGQUIT

c 复制代码
sigemptyset(&action.sa_mask);
sigaddset(&action.sa_mask, SIGQUIT);

处理函数返回后,原来的信号屏蔽字会自动恢复。

八、信号处理函数的执行流程

8.1 用户态与内核态切换

信号处理函数位于用户空间,但信号状态由内核维护。一个典型流程是:

  1. 用户程序正在执行
  2. 因中断、异常或系统调用进入内核
  3. 内核完成当前工作,准备返回用户态
  4. 内核检查到有可递达信号
  5. 内核构造新的用户态上下文,先执行信号处理函数
  6. 处理函数结束后通过 sigreturn 再次进入内核
  7. 内核恢复被打断的用户态上下文,原程序继续运行

信号处理函数不是由 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 常见的不安全操作

信号处理函数中不应随意调用:

  • printfstd::cout 等缓冲 IO
  • mallocfreenewdelete
  • 大多数使用内部锁或全局状态的库函数

因为主流程可能正好在这些函数内部被打断,处理函数再次调用会破坏数据或产生死锁。

POSIX 定义了一组异步信号安全函数,write 是常用成员之一。

10.3 安全的设计方式

信号处理函数应当尽量短,只做下面这类操作:

  • 设置 volatile sig_atomic_t 类型的标志
  • 向预先创建的管道写入一个简单通知
  • 调用明确属于异步信号安全集合的函数

把真正的业务处理留给主循环完成。

十一、volatile 与 sig_atomic_t

11.1 volatile 的作用

编译器可能把循环中反复读取的普通变量缓存到寄存器。如果信号处理函数异步修改该变量,主循环未必会按预期重新读取内存。

volatile 告诉编译器,每次访问都应当按照可观察内存操作处理,不要把值长期假定为不变。

11.2 信号标志的写法

例:

c 复制代码
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t quit = 0;

void Handler(int signal_number)
{
    quit = 1;
}

int main()
{
    signal(SIGINT, Handler);
    while (!quit)
    {
        //执行主循环
    }
    return 0;
}

sig_atomic_t 保证该类型的简单读写适合用于信号处理场景。

注意:volatile 不是线程同步工具,不提供复合操作原子性、内存顺序或互斥。多线程并发应使用 C/C++ 原子类型、互斥锁等机制。

十二、SIGCHLD 与子进程回收

12.1 SIGCHLD

子进程退出、停止或继续时,父进程可能收到 SIGCHLD。其默认动作通常是忽略,但这不等于子进程一定会自动被回收。

父进程可以注册处理函数,在收到通知后调用 waitpid 回收已经退出的子进程。

12.2 为什么要循环 waitpid

常规信号不保证按产生次数排队。多个子进程几乎同时退出时,父进程可能只观察到一次 SIGCHLD 递达,因此处理函数中要循环回收。

例:

c 复制代码
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>

void ChildHandler(int signal_number)
{
    int old_errno = errno;
    while (waitpid(-1, NULL, WNOHANG) > 0)
    {
    }
    errno = old_errno;
}

int main()
{
    struct sigaction action;
    action.sa_handler = ChildHandler;
    sigemptyset(&action.sa_mask);
    action.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    sigaction(SIGCHLD, &action, NULL);

    for (int i = 0; i < 3; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            _exit(0);
        }
    }

    while (1)
    {
        pause();
    }
    return 0;
}

保存和恢复 errno,可以避免处理函数改变主流程正在观察的错误码。

十三、常见问题

13.1 pause 的竞态条件

下面的思路存在竞态:

c 复制代码
while (!flag)
{
    pause();
}

如果信号恰好在判断 flag 之后、调用 pause 之前递达,程序可能错过唤醒并永久阻塞。需要原子地更换屏蔽字并等待时,应使用 sigsuspend

13.2 被信号中断的系统调用

阻塞系统调用可能因为信号递达而返回 -1,同时把 errno 设为 EINTR。程序要根据接口语义选择重试,或者为 sigaction 设置合适的 SA_RESTART

13.3 不要在处理函数中做复杂工作

处理函数执行期间,主流程处于被打断状态。复杂日志、内存分配、锁操作和业务回调都会扩大重入风险。最可靠的策略是只记录事件,再由正常执行流处理。

十四、总结

信号是操作系统向进程异步通知事件的机制。信号可以由终端按键、命令与系统调用、软件条件或硬件异常产生。产生后的信号先进入未决状态,内核根据进程的阻塞集合与处理动作,在合适的返回用户态时机完成递达。

信号处理有默认、忽略和自定义三种方式。sigset_tsigprocmasksigpending 用于观察并控制信号集合,sigaction 用于可靠地注册处理函数和临时屏蔽其他信号。信号处理函数属于异步控制流,必须考虑可重入性和异步信号安全,通常只设置 volatile sig_atomic_t 标志或发送简单通知。

真正理解信号,需要把用户态程序、内核中的 pending/block/handler 信息以及中断返回流程连在一起,而不是只记住几个信号编号。

相关推荐
zhangrelay4 小时前
ROS 2 Lyrical 第4章 URDF机器人建模与Gazebo Garden仿真
linux·笔记·学习·ubuntu·机器人·ros2
wuminyu4 小时前
JVM利用io_uring优化堆外内存性能原理剖析
java·linux·c语言·jvm·c++
cakeism8254 小时前
Gitee DevOps 平台定位、核心能力与选型指南
运维·gitee·devops
xianyuCcCcCCCcc4 小时前
Nginx 深度解析:从基础架构到反向代理与负载均衡实战全解
linux·运维·nginx·bash·负载均衡
qq_25294131684 小时前
列车车轮缺陷智能检测数据集:800张图像、4大类别,助力铁路安全运维
运维·人工智能·安全·yolo·目标检测·计算机视觉·视觉检测
爱折腾的小码农4 小时前
解决Navicat 17 Premium Lite在Linux上运行报错“LIBSYSTEMD_251‘ not found”问题
linux·运维
Doraemomo5 小时前
Linux编程-标准IO和系统IO
linux·运维·服务器
骇客野人5 小时前
Linux 查看 Java 进程常用命令
java·linux·运维
睡一觉就好了。5 小时前
Linux 信号机制
linux·运维·网络