Linux 信号(三):信号的保存

在前两篇文章中,我们已经认识了 Linux 信号,并介绍了信号的各种产生方式。

我们知道,信号可以通过终端按键、系统调用、硬件异常以及软件条件等方式产生。但信号产生之后,并不一定会被进程立即处理。

例如:

  • 进程可能正在执行关键代码;
  • 进程可能暂时屏蔽了某些信号;
  • 多个信号可能在短时间内同时到达。

那么问题来了:

当一个信号产生后,而进程暂时无法处理它时,操作系统会将这个信号保存到哪里?

为了回答这个问题,本节将深入探讨 Linux 信号的保存机制,包括信号的未决(Pending)状态、信号集以及相关的数据结构。

1 信号的保存

1.1 引入信号的相关常见概念

• 实际执行信号处理动作的过程称为信号递达。

理解:信号被进程执行的时候称为信号递达,如默认处理、忽略、自定义捕捉。

• 信号从产生到递达之间的状态称为信号未决。

理解:信号已经产生,并且已经被进程记录下来,但尚未被进程处理,此时该信号处于未决状态。

• 进程可以选择阻塞某个信号。

理解:当一个信号被阻塞后,即使该信号已经产生,也不会立即递达给进程,而是先保持在未决状态中,等待解除阻塞后再递达。

• 阻塞和忽略是不同的

理解:阻塞:阻止信号递达,信号会暂时保存在未决集中。忽略:允许信号递达,但递达后的处理动作是不做任何处理。

1.2 信号在内核中的表示

对于一个进程而言,内核维护了一组与信号相关的数据结构,其中包括 pending 表、block 表以及 handler 表,用于记录信号是否产生、是否被阻塞以及如何处理。其中 block 表和 pending 表都是位图结构,handler 表是一个函数指针数组。

block 表:比特位的位置:信号的编号,比特位的状态:1 表示阻塞,0 表示未阻塞

pending 表:比特位的位置:信号的编号,比特位的状态:1 表示对应编号的信号处于未决状态,0 表示对应编号的信号未处于未决状态

handler 表:数组下标:信号的编号,数组的内容:信号的处理方式。handler 表记录每种信号对应的处理方式,可以是默认处理(SIG_DFL)、忽略(SIG_IGN)或用户自定义处理函数。
理解信号产生 -> 信号保存 -> 信号处理的过程

由于某种事件的发送,OS 得知并处理后产生对应的信号,OS 在进程控制块中(task_struct)设置该信号的未决标志(将对应的比特位设置为 1),将该信号保存下来,如果该信号未被阻塞,进程将在合适的时候处理该信号(内核会先将pending表中对应的比特位设置为 0 ,然后执行对应的处理动作)。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?

POSIX.1 允许操作系统产生该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
**信号的保存本质上就是:

  1. pending 表记录哪些信号已经产生
  2. block 表记录哪些信号暂时不能递达
  3. handler 表记录信号应该如何处理
    内核通过这三张表完成信号从产生到处理的全过程管理。**
cpp 复制代码
// 内核结构
struct task_struct
{
    ...
    /* signal handlers */
    struct sighand_struct *sighand;
    sigset_t blocked;
    struct sigpending pending;
    ...
} 

struct sighand_struct
{
    atomic_t count;
    struct k_sigaction action[_NSIG]; // #define _NSIG 64
    spinlock_t siglock;
};

struct __new_sigaction
{
    __sighandler_t sa_handler;
    unsigned long sa_flags;
    void (*sa_restorer)(void); /* Not used by Linux/SPARC */
    __new_sigset_t sa_mask;
};

struct k_sigaction
{
    struct __new_sigaction sa;
    void __user *ka_restorer;
};

/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);

struct sigpending
{
    struct list_head list;
    sigset_t signal;
};

1.3 sigset_t

由于未决标志和阻塞标志本质上都是按照信号编号组织的位图,因此 Linux 使用统一的数据类型 sigset_t 来表示这类信号集合(Signal Set,信号集)。

sigset_t 本质上是一个位图结构,每一个 bit 对应一种信号:

  • bit 为 1,表示该信号在当前信号集中存在;
  • bit 为 0,表示该信号在当前信号集中不存在。

在未决信号集(Pending Set)中:

  • 1 表示该信号处于未决状态;
  • 0 表示该信号不处于未决状态。

在阻塞信号集(Block Set)中:

  • 1 表示该信号被阻塞;
  • 0 表示该信号未被阻塞。

阻塞信号集也称为当前进程的信号屏蔽字(Signal Mask)。这里的"屏蔽"应理解为阻塞,而不是忽略。被屏蔽的信号不会立即递达,但仍然会保存在 Pending 集合中;而忽略属于信号递达后的一种处理动作。

cpp 复制代码
typedef __sigset_t sigset_t;

# define _SIGSET_NWORDS	(1024 / (8 * sizeof (unsigned long int)))
typedef struct
  {
    unsigned long int __val[_SIGSET_NWORDS];
  } __sigset_t;

1.4 sigset_t 操作函数

sigset_t 类型使用位图的方式记录信号状态,每一种信号对应一个 bit 位,用于表示该信号在当前信号集中是否存在。

至于这些 bit 位在内存中具体如何组织和存储,则由系统实现决定。对于使用者而言,无需关心 sigset_t 的内部实现细节,而应该通过系统提供的接口来操作它。

因此,我们通常使用 sigemptyset、sigemptyset、sigaddset、sigdelset、sigismember 等函数对信号集进行管理,而不应该直接访问或解释 sigset_t 内部的数据。

换句话说,使用者只需要将 sigset_t 看作一个抽象的数据类型,而不必关心其底层存储结构。例如,直接使用 printf 打印一个 sigset_t 变量通常是没有意义的,因为其内部表示方式并不是标准化的,且可能因系统实现而异。

1.4.1 sigemptyset

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

int sigemptyset(sigset_t *set);

功能:将信号集初始化为空集,即不包含任何信号。 本质是将所有信号的 bit 位全部置为 0。

1.4.2 sigfillset

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

int sigfillset(sigset_t *set);

功能:将信号集初始化为全集,即包含所有信号。本质是将所有信号的 bit 位全部置为 1。

1.4.3 sigaddset

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

int sigaddset(sigset_t *set, int signum);

功能:向信号集中添加一个指定信号。本质是将对应信号编号的 bit 位置置为 1。

1.4.4 sigdelset

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

int sigdelset(sigset_t *set, int signum);

功能:向信号集中删除一个指定信号。本质是将对应信号编号的 bit 位置置为 0。

这四个函数的返回值都是成功返回 0,失败返回 -1。

1.4.5 sigismember

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

int sigismember(const sigset_t *set, int signum);

功能:判断指定信号是否存在信号集中。本质是判断指定信号对应的 bit 位置是否为 1。

返回值:指定信号存在返回 1,不存在返回 0,出错返回 -1。

注意,在使用 sigset_ t 类型的变量之前,一定要调用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种信号。

1.5 sigprocmask

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

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

功能:检查或修改当前进程的信号屏蔽字(阻塞信号集、block 表)。本质是修改 block 位图。

参数说明:

how:修改方式

SIG_BLOCK:添加阻塞信号

SIG_UNBLOCK:解除阻塞信号

SIG_SETMASK:直接替换信号屏蔽字

set:要操作的信号集

oldset:输出型参数,保存原来的信号屏蔽字,如果不使用可以传 NULL。

返回值:成功返回 0,失败返回 -1。

1.6 sigpending

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

int sigpending(sigset_t *set);

功能:获取当前进程中的未决信号集(pending 表)。本质是读取 pending 位图。

参数说明:set:输出型参数,用于保存当前进程的 pending 表。

返回值:成功返回 0,失败返回 -1。

1.7 样例:巩固所学的信号集相关的函数

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

// 打印 pending 表
void PrintPending(sigset_t& pending)
{
    std::cout << "pending 表: ";
    for (int i = 31; i >= 1; --i)
    {
        if (sigismember(&pending, i))
        {
            std::cout << 1;
        }
        else
        {
            std::cout << 0;
        }
    }
    std::cout << std::endl;
}

void handler(int signum)
{
    std::cout << signum << "号信号未决" << std::endl;
    std::cout << "--------------------------" << std::endl;

    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);
    
    std::cout << "--------------------------" << std::endl;
}

int main()
{
    // 捕捉 2 号信号
    signal(SIGINT, handler);
    // 屏蔽 2 号信号
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigaddset(&block_set, SIGINT);
    sigprocmask(SIG_SETMASK, &block_set, &old_set);

    int cnt = 10;

    while (true)
    {
        sigset_t pending;
        sigpending(&pending);

        PrintPending(pending);
        sleep(1);
        --cnt;
        if (cnt == 0)
        {
            std::cout << "2号信号阻塞解除" << std::endl;
            sigprocmask(SIG_SETMASK, &old_set, nullptr);
        }
    }

    return 0;
}