【Linux进阶系列】:信号(下)

🔥 本文专栏:Linux

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录 选择大于努力,但选择本身就是一种努力。

★★★ 本文前置知识:

信号(上)


引入

在上一篇博客中,我们已经对信号建立了基本的概念。本文将进一步串联上篇所介绍的内容,构建一个完整的信号知识体系。

要理解信号,首先需要了解信号产生的原因及其意义。信号涉及两个主要对象:发送方与接收方。在操作系统中,信号的发送方是操作系统本身,而接收方则是进程。

信号的第一个作用是管理接收信号的对象,具体方式是通过使进程执行信号对应的默认动作。这可以类比现实中的交通信号灯:司机作为信号的接收方,在看到红灯(信号)时,必须立即停车。交通信号灯正是通过这种方式来管理交通参与者。

在 Linux 系统中,信号的接收对象是进程。进程的每个行为都对应底层的代码执行。当进程执行了非法操作,例如非法访问内存,操作系统便会向该进程发送一个信号。进程在接收到信号后,需执行该信号对应的默认行为。例如,若操作系统向进程发送 9 号信号(SIGKILL),其默认动作是终止进程。进程一旦收到该信号,就会被立即终止,从而无法继续执行非法行为。通过这种方式,操作系统能够有效管理进程,维护系统稳定。这是信号的一种典型应用场景。

除了管理进程行为、防止非法操作之外,信号的第二个应用场景是进程间通信。在多进程协同完成任务时,进程之间需要了解彼此的执行状态,因此存在通信需求。我们之前已经学过进程间通信(IPC)的其他方式,如匿名管道、命名管道和共享内存等。与这些传统通信方式相比,信号属于一种轻量级的通信机制。它只能简单通知某个事件已发生(例如"我完成了"),而无法像共享内存或管道那样传递大量数据或具体内容。因此,信号通常适用于异步通知的场景。所谓异步通知,是指进程在未收到信号时可以持续执行自身任务,直到信号到达时才中断当前处理流程去响应信号。在等待期间,进程不会被阻塞,也无需主动查询信号状态。不过,由于传统 IPC 机制在功能上更为强大和灵活,信号在进程通信中的应用相对较少。

了解了信号的意义与使用场景后,接下来我们将从信号的产生信号的保存,以及信号的处理这三个阶段,系统性地介绍信号处理的完整流程,帮助你全面掌握信号机制。

信号的产生

那么有因必有果。操作系统不会无缘无故地向进程发送信号,其发送信号必然存在特定原因,这些原因对应信号产生的五种主要方式,分别是:硬件异常键盘输入特定组合(如 Ctrl+C)、执行kill命令调用kill系统调用,以及软件条件

其中,信号产生最常见的一种方式是硬件异常。进程执行的各种行为或动作,实际上对应底层已编写好的一行行代码,这些代码由程序员编写,因此进程的行为本质上反映了程序员的逻辑。需要注意的是,代码经过编译后形成指令,但这些指令只是向 CPU 发出的请求,并不保证一定会被执行。原因在于,某些指令可能涉及非法操作,例如解引用野指针或除数为零的算术错误。

以解引用野指针为例,当 CPU 执行到该指令时,即执行一条取地址指令,CPU 首先解析指令操作码,识别为取地址操作,随后持有虚拟地址并将其交给内存管理单元(MMU)。MMU借助页表将虚拟地址转换为物理地址,从而在内存中定位目标数据并加载到 CPU 寄存器中。

在此过程中,MMU首先会检查虚拟地址的合法性。若地址不在进程的有效地址范围内,或进程无权访问,MMU会向 CPU 发送异常信号,并将触发异常的虚拟地址存入CR2 寄存器。CPU 识别到异常后,会从用户态切换至内核态。切换前,当前进程的上下文信息------包括 CPU 各寄存器的值,如程序计数器(EIP)、状态寄存器、栈寄存器及错误码等------会被压入该进程的内核栈。随后,操作系统获取异常号,并跳转至中断向量表。异常号(或称中断号)对应中断向量表的下标,而中断向量表本质上是一个函数指针数组。此时,系统将执行相应的异常处理逻辑,将程序计数器的值设置为对应处理函数的入口地址。

操作系统根据 CR2 中存储的虚拟地址及错误码分析错误类型,判断是由于缺页中断,还是访问无效地址所致。若为缺页中断,操作系统会重新分配内存页并建立页表映射,随后恢复进程上下文;若非缺页中断所致,系统则会向进程发送相应信号(如 SIGSRGV),并执行其默认处理动作,即终止进程。

以上即为信号产生的本质原因之一------硬件异常。在上述流程中,读者可能对内核栈这一概念存在疑问:它究竟是什么?在过程中扮演什么角色?具有哪些意义?

首先给出定义:内核栈是操作系统为每个进程分配的一个或多个内核级内存页。所谓内核级,指这些内存页位于操作系统内核地址空间,而非用户地址空间。内核栈用于保存进程的上下文,即当前时刻 CPU 运行该进程时各寄存器的值,其中最关键的是程序计数器和栈寄存器等。将这些寄存器值压入内核栈的原因,可从所保存内容中窥见一斑。以程序计数器为例,CPU 不可能始终完整执行某个进程从开始到结束的全部指令。由于系统中存在多个进程,操作系统为提升并发性从而推动多个进程的运行,会为每个进程分配一个 CPU 时间片。一旦时间片用完,进程将被移出 CPU,等待下一次调度。

因此,当进程被切换出去时,若其程序尚未执行完毕,操作系统必须确保下次被调度时,CPU 能从之前中断的位置继续执行。类比观看视频时中途上厕所,可以先将视频暂停,回来后可从暂停时刻继续播放,操作系统同样需要保存进程在中断时刻的运行状态(即上下文)。该状态与多个 CPU 寄存器的内容密切相关:程序计数器存有下一条待执行指令的地址,栈寄存器(如 ESP、EBP)则维护当前函数调用的栈帧信息。

为此,操作系统在内核空间中为每个进程分配一个或多个内存页作为其内核栈,用于保存这些寄存器值。这一机制类似于警方通过拍照记录犯罪现场状态。每次从用户态切换至内核态时,当前进程的 CPU 各寄存器值都会被写入其内核栈。由于内核栈位于内核地址空间,进程无权直接访问,仅操作系统具备访问各进程内核栈的权限。

cpp 复制代码
// 内核栈结构
struct kernel_stack {
    // 保存的寄存器上下文
    uint32_t eip;    // 程序计数器 - 最关键!
    uint32_t esp;    // 用户栈指针
    uint32_t ebp;    // 基址指针
    uint32_t eax;    // 通用寄存器
    uint32_t ebx;
    uint32_t ecx;
    uint32_t edx;
    uint32_t eflags; // 状态寄存器
    // ... 其他寄存器
    uint32_t error_code; // 错误码(如有)
};

同理,若代码中包含除法指令且除数为零,当 CPU 执行至该指令时,会先将运算涉及的操作数存入寄存器。若寄存器检测到除数为零,将触发异常信号,并在状态寄存器中记录异常信息。CPU 识别该硬件异常后,会暂停当前指令的执行,切换至内核态。在此之前,当前进程的上下文将被保存至其内核栈。随后,系统根据异常号定位中断向量表中的对应处理函数,并向进程发送相应信号。


那么除了硬件异常产生,那么还有就是kill系统调用或者输入kill指令来产生信号,而kill指令本质上就是在特定路径下保存的可执行文件,那么这里kill指令对应的程序本质上就是调用了kill系统调用接口,而一旦调用了kill系统调用接口,那么此时调用系统调用就会切换为内核态,然后操作系统会获取传递给kill系统调用的参数,也就是信号编号和进程pid,然后给对应的进程发送信号,至于这个信号是如何发送给对应的进程,那么我会在后文进行详细的讲解,并且也会同时引出一个能够产生信号的一个软件条件,便是定时器,这里我先埋下一个伏笔


信号的保存

在上文中,我们详细探讨了信号的产生过程。接下来,我们将讨论信号的保存环节。实际上,在信号产生与保存之间,可以存在一个信号的发送 或称为信号的写入 步骤。但一旦信号被写入,通常就意味着信号已经被保存下来。因此,在描述信号处理的常规流程时,一般不会特意强调信号的写入 这一独立环节。

关于信号的保存 ,我们首先要思考的是:为什么需要保存信号?我们知道,操作系统向进程发送信号,意味着进程在接收到信号后,理论上可以立即执行该信号的默认动作。然而,既然存在信号保存 这一概念,说明从信号被写入到信号被处理之间,存在一个时间窗口。也就是说,进程在接收到信号后,并不会立即处理该信号。

我们可以通过一个生活场景来理解这一点:假设我们正在打游戏,而妈妈已经做好了饭并叫我们吃饭。由于游戏尚未结束,我们可能认为完成当前游戏更为紧迫,于是会先在脑中记下要吃饭 这件事,等游戏结束后再去吃饭。

信号的处理机制与上述场景类似,本质上是一种 异步处理机制。进程在某一时刻可能正在执行优先级更高的任务,例如向日志文件写入数据或连接数据库等,这些操作通常不允许被中断。因此,操作系统会先将信号保存起来,待进程完成当前关键任务后,再选择合适的时机处理信号。这就是信号保存的意义所在。

如前所述,在信号保存之前,存在一个信号的发送(或写入)步骤。当我们通过系统调用或触发硬件异常产生特定编号的信号时,操作系统的任务是将信号传递给目标进程。尽管我们在代码中可能并未编写任何接收或处理信号的逻辑,进程仍会接收到信号并执行其默认动作(如终止运行)。这是因为信号的写入与处理并非在用户态完成,而是在内核态中实现的。

那么,操作系统是如何将信号写入进程的呢?每个进程都有一个对应的 task_struct结构体,操作系统通过管理这些结构体来实现对进程的控制。在 task_struct 中,有一个关键字段指向signal_struct 结构体,该结构体包含与信号相关的属性集合。其中,signal_struct 包含一个struct sigpending 结构体,用于记录待处理信号的信息。

cpp 复制代码
struct task_struct {
    ...
    struct signal_struct *signal;      // 信号相关结构
};

struct signal_struct {
    ...
    struct sigpending pending;        // 待处理信号(含标准信号位图+实时信号链表)
    ...
};

struct sigpending 结构体包含一个用于保存标准信号(1 ~31)的位图(bitmap),以及一个用于保存实时信号(32~64)的链表。Linux 系统中总共定义了 64 种信号,其中前 32 个为标准信号,其余为实时信号。标准信号通过位图存储:用一个 32 位的整数表示,每一位对应一个特定编号的信号。若某一位为 1,表示进程收到了对应信号;为 0 则表示未收到。实时信号则通过链表管理,每收到一个实时信号,就会在链表中创建一个节点。

cpp 复制代码
struct sigpending {
    struct list_head list;    // 实时信号链表
    sigset_t signal;          // 标准信号位图(这个sigset_t类型会在后文进行详细讲解)
};

由此,我们可以解释操作系统向进程发送信号的具体底层机制:若发送的是标准信号,操作系统会定位目标进程的task_struct,获取其中的信号位图,并通过位运算将对应比特位置 1,完成信号的写入。若是实时信号,则会创建节点并插入链表。实时信号的使用场景相对较少,此处我们仅作了解。

操作系统将信号写入进程的task_struct中的信号位图,不仅完成了信号的发送,也实现了信号的保存。只要位图中某一位保持为 1,就表示该信号已被保存但尚未处理。一旦信号被处理(即执行其默认动作),对应位会被重新置 0。

接下来关键的问题是:进程在何时处理这些信号?如前所述,操作系统不应随意中断进程的执行,因为进程可能正在处理更高优先级的任务。同时,CPU 不可能一直执行同一进程的代码,操作系统会为每个进程分配时间片。当时间片用完,进程会被移出 CPU,即从用户态切换至内核态。此时,操作系统会调度就绪队列中的其他进程。当该进程再次被调度时,会从内核态切换回用户态继续执行。

因此,操作系统不会在进程运行期间强行插入信号处理流程,尽管操作系统理论上可在任意时刻中断进程以处理信号,但最合适的时机是在从内核态返回用户态之前。所以从内核态返回用户态之前,操作系统会检查其进程对应的task_struct中的待处理信号(标准信号位图与实时信号链表)。如果存在未处理的信号,操作系统会在此刻执行该信号的默认处理函数。

但是,我们知道,当进程收到信号时,即其对应的task_struct结构体中的标准信号位图(standard signal bitmap)或实时信号链表(real-time signal list)中存在待处理信号,那么在该进程被调度执行、从内核态返回用户态的时刻------这一时刻正是信号处理的时机------操作系统会检查进程的 task_struct中的标准信号位图与实时信号链表。如果存在待处理信号,系统将立即执行该信号对应的处理动作。

然而,如果我们希望即使进程已收到信号(即信号已递达进程),并且操作系统在检查时确认存在对应信号,也不立即执行信号处理动作,那么就需要引入下文将要讨论的信号阻塞机制。

信号的阻塞

信号阻塞的本质是,即使操作系统在检查进程的task_struct结构体时发现标准信号位图中对应比特位为 1,或实时信号链表中存在节点,系统仍选择不处理该信号。这类似于自习室的管理:如果当前我们在自习室当中,而其他人会不断开门检查自习室是否有人,没人就占据该自习室,而我们不希望他人进入打扰,那么就只需在门口悬挂"自习室有人,请勿进入"的标识。他人看到标识后,便会自动忽略该自习室。

信号阻塞的机制与此类似。在 task_struct中,用于记录信号相关属性的 signa_struct结构体内维护了一个关键字段: 阻塞信号位图(blocked signal bitmap)。该位图与标准信号位图不同,它不仅覆盖标准信号,也涵盖实时信号。阻塞位图中的每一位对应一个特定编号的信号,其值表示该信号是否被阻塞:若比特位值为 1,则对应信号被阻塞;若为 0,则未被阻塞。而注意标准信号中的9号信号以及19号信号是规定无法被阻塞的。

cpp 复制代码
struct task_struct {
    ...
    struct signal_struct *signal;      // 信号相关结构
};

struct signal_struct {
    ...
    struct sigpending pending;        // 待处理信号(含标准信号位图+实时信号链表)
    sigset_t blocked;                 // 阻塞信号位图(核心字段!)
    ...
};

通过 阻塞位图,系统能够实现信号的暂存与 异步处理机制。例如,当进程正在执行高优先级或关键任务时,我们可以在任务开始前 阻塞可能接收的特定信号。需要注意的是,阻塞信号的实现涉及对 task_struct中阻塞位图的修改,而只有 操作系统才有权限访问和修改这些内核数据结构,因此信号的阻塞操作必须在 内核态中完成。

此时,读者可能会产生疑问:进程不可能始终占据 CPU 资源,每个进程都有固定的时间片,时间片用尽后会被切换出 CPU。为保证下次调度时能从中断点继续执行,系统通过内核栈(内核为每个进程分配的一个或多个物理内存页)保存进程的上下文信息,包括程序计数器栈寄存器等寄存器的值。

从我们程序员的主观视角来看, CPU 当前可能正在执行该程序优先级更高或者更重要的任务,但 是站在 CPU的视角来看,由于CPU 本身仅按指令执行,无法感知其任务的重要性。而一旦时间片用尽后,进程会从用户态切换至内核态,进行上下文切换,意味着此时CPU正在执行优先级更高或者更重要的任务的过程中,会发生了进程的切换;待进程再次被调度时,又从内核态返回用户态。在返回用户态前,系统会检查进程task_struct 中是否存在未被阻塞且未处理的信号。若没有,则恢复内核栈中保存的上下文,继续执行用户态指令。

那么,如果进程没有触发任何硬件异常(如非法内存访问或算术异常),理论上即使它在执行关键任务时被切换,也能在重新调度后无缝继续执行,似乎信号的阻塞机制并无必要?

需要指出的是,信号的产生方式不仅限于硬件异常。例如,通过 kill命令或 kill系统调用也可向进程发送信号。考虑以下场景:进程正在执行关键任务,时间片耗尽后切换到另一个进程,后者通过 kill系统调用向目标进程发送 2 号信号(SIGINT,默认动作为终止进程)。当目标进程再次被调度,从内核态返回用户态时,系统检查到存在未被阻塞且未处理的 SIGINT 信号,便会立即执行默认动作------终止进程。如果此时进程正在向日志文件写入关键数据,突然终止可能导致数据丢失或系统状态不一致,后果严重。

plaintext 复制代码
开始
  |
  v
+-----------------------------+
|    进程在用户态执行         |
+-----------------------------+
  |
  v
+-----------------------------+
|       时间片用尽?          |
+-----------------------------+
  |               |
  |否             |是
  |               |
  v               v
(继续执行)        +-----------------------------+
                 |       切换到内核态         |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 |     保存上下文(上下文切换)   |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 |    进程被移出运行队列       |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 |  调度器选择其他进程运行     |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 |  其他进程(如进程B)运行      |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 | 进程B调用kill发送SIGINT信号 |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 | 内核标记进程A有未处理信号   |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 |  进程A被重新调度            |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 | 切换到内核态(恢复进程A)     |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 | 检查task_struct中的信号     |
                 +-----------------------------+
                           |
                           v
                 +-----------------------------+
                 | 有未阻塞且未处理信号?       |
                 +-----------------------------+
                           |
             +-------------+-------------+
             |                           |
            是                           否
             |                           |
             v                           v
+-----------------------------+   +-----------------------------+
|     处理信号(终止进程)      |   |       恢复上下文           |
+-----------------------------+   +-----------------------------+
             |                           |
             v                           v
+-----------------------------+   +-----------------------------+
|         进程终止            |   |       返回用户态           |
+-----------------------------+   +-----------------------------+
                                             |
                                             v
                                      (继续从断点执行)

因此,信号的异步处理机制意味着进程在任何时刻都可能收到信号,且无法预知信号何时到达、何时被处理。由于进程间具有独立性,且受操作系统调度的影响,我们只能通过 阻塞机制来规避信号异步处理可能带来的风险。阻塞信号确保了关键执行流程不受外部信号干扰,从而增强系统的可靠性与可控性。


前文提到,信号的阻塞是在内核态实现的,因此操作系统提供了相关的系统调用接口,允许我们修改内核中
task_struct 结构体的阻塞位图字段。在介绍这些系统调用接口之前,首先需要认识系统提供的一个类型:sigset_t

sigset_t 类型本质上可以是一个 long 类型的整数或一个结构体,其中封装了一个 long 类型的位图数组。在现代 Linux 操作系统下,通常采用结构体类型,内部封装一个 long 类型的数组。这是因为信号数量可能增加(超过 64 个),此时单个 long 类型无法表示所有信号。不过,程序员无需关心 sigset_t 的具体实现细节,该类型对用户而言是不透明的。我们可以将 sigset_t 理解为一个信号的集合。

cpp 复制代码
/* 来自 /usr/include/x86_64-linux-gnu/bits/types/__sigset_t.h */
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct {
    unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;

typedef __sigset_t sigset_t;

在有了 sigset_t 类型之后,操作系统还提供了一些系统调用接口来帮助我们初始化这个信号集合。首先是
sigemptyset 接口,该接口接收一个指向 sigset_t 类型的指针参数,并通过位运算将 sigset_t 中所有的比特位设置为 0:

  • sigemptyset
  • 头文件:<signal.h>
  • 函数声明:int sigemptyset(sigset_t* set)
  • 返回值:成功时返回 0,失败时返回 -1,并设置 errno

除了该接口,操作系统还提供了 sigfillset接口。该接口同样接收一个指向 sigset_t 类型的指针参数,并通过位运算将其所有的比特位设置为 1:

  • sigfillset
  • 头文件:<signal.h>
  • 函数声明:int sigfillset(sigset_t* set)
  • 返回值:成功时返回 0,失败时返回 -1,并设置 errno

接着是 sigaddset 接口。该接口接收一个指向 sigset_t 类型的指针参数以及一个 int 类型的参数(对应信号编号),并通过位运算将 sigset_t 中对应信号编号的比特位设置为 1:

  • sigaddset
  • 头文件:<signal.h>
  • 函数声明:int sigaddset(sigset_t* set,int signum)
  • 返回值:成功时返回 0,失败时返回 -1,并设置 errno

上述 sigemptysetsigfillsetsigaddset接口用于对 sigset_t 类型(即信号集合位图)进行初始化。一旦初始化完成,若需修改进程对应的 task_struct 结构体中的阻塞位图,就需要调用 sigprocmask 系统调用接口:

  • sigprocmask
  • 头文件:<signal.h>
  • 函数声明:int sigprocmask(int how,const sigset_t* set,sigset_t old_set)
  • 返回值:成功时返回 0,失败时返回 -1,并设置 errno

sigprocmask 系统调用接口接收三个参数。第一个参数是 int 类型,指定了 sigprocmask 的行为。系统提供了三个选项对应的整型宏:

SIG_BLOCK :将 set中的信号添加到当前信号屏蔽字(即阻塞 set 中的信号)

SIG_UNBLOCK :从当前信号屏蔽字中移除 set中的信号(即解除阻塞)

SIG_SETMASK :直接将当前信号屏蔽字替换为 set (忽略之前的状态)

第二个参数是我们初始化好的指向 sigset_t 类型的指针。如果指定的选项是 SIG_BLOCK ,操作系统会使用第二个参数 set ,与当前进程task_struct 结构体的阻塞位图进行按位或运算(OR)。按位或运算的规则是:只要对应比特位为 1,结果位即为 1。因此,该操作相当于将 set 中指定的信号添加到进程的阻塞信号集合中: current_block = old_block |(*set)

如果执行的是SIG_UNBLOCK ,操作系统会使用set 参数,将其取反 (按位非运算NOT),然后与task_struct 结构体的阻塞位图进行按位与运算 (AND)。按位与运算的规则是:只要运算的比特位为0,结果位即为0。因此,该操作相当于在task_struct 结构体中解除 set中特定信号的阻塞:current_block = old_block & (~(*set))

最后一个选项SIG_SETMASK 则更为直接:操作系统直接使用set参数的内容覆盖 进程task_struct 结构体中的阻塞位图:current_block = (*set)

sigprocmask 的第一个参数how 配合第二个参数set 一起使用。第三个参数oldset 是一个输出型参数。如果我们想获取调用sigprocmask 之前进程task_struct 结构体的阻塞位图状态,可以提供此参数(非空指针),函数会将原阻塞位图保存到oldset 指向的sigset_t 变量中。这就是通过sigprocmask 接口设置要阻塞的特定信号的原理。

因此,在引入信号阻塞机制后,我们可以进一步完善进程处理信号的具体流程。信号的处理时机是在从内核态返回到用户态时进行的。当信号通过硬件异常、kill系统调用或kill命令产生时,信号一旦产生,进程便会从用户态陷入内核态。随后,操作系统将信号写入目标进程的task_struct 结构体。具体来说,操作系统根据信号编号决定是设置标准信号位图中对应的比特位为1,还是在实时链表中创建一个节点。

信号写入完成后,当目标进程被操作系统调度并准备返回用户态时,在返回之前,操作系统会检查该进程的task_struct 结构体,确认是否存在未被阻塞且未被处理的信号。此时,操作系统会执行位运算:首先对阻塞位图取反(blocked),使得被阻塞信号对应的比特位为0,其他位为1;然后将其与标准信号位图进行与运算((blocked) & pending)。与运算的规则是,只有当两个比特位均为1时,结果才为1;否则为0。因此,如果运算结果非零,则表示存在未被阻塞的信号。随后,操作系统会按照信号编号从小到大的顺序依次处理这些信号,因为编号较小的信号具有较高的优先级。

plaintext 复制代码
┌─────────────────────────────────────────────────────────────────────────────┐
│                           信号处理流程图                                      │
└─────────────────────────────────────────────────────────────────────────────┘

                        ┌─────────────┐
                        │  信号产生     │
                        │ (硬件异常/    │
                        │ kill调用/命令)│
                        └──────┬──────┘
                               ↓
                        ┌─────────────┐
                        │ 进程陷入内核态 │
                        │ (用户态→内核态)│
                        └──────┬──────┘
                               ↓
             ┌─────────────────────────────────┐
             │   操作系统写入信号到task_struct   │
             └─────────────────┬─────────────────┘
                 ┌─────────────┼─────────────┐
                 ↓                           ↓
          ┌─────────────┐             ┌─────────────┐
          │  标准信号     │             │  实时信号     │
          │ 设置位图比特位 │             │ 创建链表节点   │
          └─────────────┘             └─────────────┘
                 └─────────────┬─────────────┘
                               ↓
                        ┌─────────────┐
                        │ 进程被调度   │
                        │ 准备返回用户态 │
                        └──────┬──────┘
                               ↓
                        ┌─────────────┐
                        │ 检查信号     │
                        │ (~blocked)  │
                        │    &        │
                        │   pending   │
                        └──────┬──────┘
                               ↓
                 ┌─────────────────────┐
                 │ 位运算结果是否非零?   │
                 └─────────┬───────────┘
               ┌───────────┴─────────────┐
               ↓                         ↓
        ┌─────────────┐           ┌─────────────┐
        │ 结果非零     │           │ 结果为零     │
        │ 有信号需处理 │           │ 无信号需处理 │
        └──────┬──────┘           └──────┬──────┘
               ↓                         ↓
        ┌─────────────┐           ┌─────────────┐
        │ 按编号顺序    │           │ 直接返回     │
        │ 处理信号     │           │ 用户态      │
        └──────┬──────┘           └─────────────┘
               ↓
        ┌─────────────┐
        │ 返回用户态   │
        └─────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│                              关键说明                                       │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. 信号处理时机:从内核态返回用户态前                                        │
│ 2. 位运算逻辑:                                                             │
│    - 对阻塞位图取反:~blocked (被阻塞位变0,其他位变1)                      │
│    - 与pending位图与运算:(~blocked) & pending                             │
│    - 结果非零表示有未阻塞信号需处理                                         │
│ 3. 处理顺序:信号编号从小到大(编号小优先级高)                               │
└─────────────────────────────────────────────────────────────────────────────┘

信号的处理

前文已阐述了信号流程的前两个阶段。 信号的处理 是整个信号生命周期的最终环节。完成此环节的讲解后,我们将尝试整合先前所学的信号相关知识,构建一个从信号产生到处理完毕的完整流程框架。

在上篇博客中,我们指出信号存在三种处理方式: 执行默认动作忽略信号 以及 执行自定义动作 。其中,自定义动作可通过调用 signal 函数实现。本文将聚焦于执行默认动作和忽略和执行自定义动作这三种行为在底层的具体实现原理。

进程能够识别特定编号的信号并执行其对应的默认动作,其能力源于该进程对应的 task_struct 结构体。
task_struct 内部维护了一个用于存储信号相关属性的结构体 struct signal_struct ,以及一个关键字段:指向 sighand_struct 结构体的指针。对于本文讨论的核心问题,我们只需关注 sighand_struct 中的一个核心字段:一个指向结构体数组的指针。该数组的长度为 64,与 Linux 系统支持的标准信号总数一致。数组中的每个元素是一个结构体(struct k_sigaction),其核心字段是一个函数指针(__sighandler_t handler )。因此,特定编号的信号对应数组特定下标处的结构体元素。此数组即为信号处理向量表。

注:上篇博客中,为便于初次接触信号的读者理解,我们曾将信号向量表简化为函数指针数组模型。该模型虽有助于掌握信号处理的多数应用场景、逻辑及 signal 系统调用接口的原理,但需指出其本质实为 struct k_sigaction数组。该结构体除核心的函数指针外,还包含其他关键属性(如标志位 sa_flags 、执行处理函数时要阻塞的信号集 sa_mask 等)。

cpp 复制代码
struct task_struct {
    // ...
    /* Signal handlers: */
    struct signal_struct *signal;
    struct sighand_struct *sighand;
    // ...
};
struct sighand_struct {
    atomic_t        count;
    struct k_sigaction action[_NSIG]; // 信号处理向量表 (长度为64)
    spinlock_t      siglock;
    wait_queue_head_t   signalfd_wqh;
};
// 内核中表示信号处理动作的结构
struct k_sigaction {
        __sighandler_t handler;         // 💡💡💡信号处理函数指针 (类型: void (*)(int))
        unsigned long sa_flags;          // 标志位 (如 SA_SIGINFO, SA_RESTART)
        sigset_t sa_mask;                // 执行处理函数时要阻塞的信号集
       ...
};

信号处理方式(三种之一)的判定,以及操作系统如何知晓当前应执行何种处理方式,均与该函数指针
handler的值密切相关。内核定义了特定的常量值来表示默认动作和忽略:

  • SIG_DFL:一个类型为 void (*)(int)、值为 0 的函数指针常量,表示执行默认动作。
  • SIG_IGN:一个类型为 void (*)(int) 、值为 1 的函数指针常量,表示忽略信号。

注:选择 01 作为特殊值,是因为它们对应的地址空间( NULL 地址附近)通常不存储有效代码或数据,便于内核进行特殊判断。

signal 函数要求用户自定义处理函数的原型必须为 void handler(int) ,正是为了匹配 struct k_sigactionhandler指针的类型。

因此,操作系统判断信号处理方式的逻辑如下:若未调用 signal 设置自定义处理函数,进程收到的信号将默认执行默认动作。当进程被调度、即将从内核态返回用户态时,操作系统会检查其 task_struct的相关字段。首先检查阻塞信号集 (hlocked) 和待处理信号集 (pending),判断是否存在未被阻塞且待处理的信号。若存在,则通过信号编号索引到信号向量表 (action 数组) 中的对应 struct k_sigaction元素,获取其 handler字段的值:

  1. handler == SIG_DFL:执行默认动作。
  2. handler == SIG_IGN:忽略信号。
  3. handler为其他值(即指向用户空间函数的有效地址):执行自定义处理函数。

读者可能会产生疑问:当 handler == SIG_DFL(即值为 0)时,操作系统如何执行具体的默认动作(如终止进程、核心转储等)?毕竟地址 0附近并无有效代码。

根据信号处理的原理,可以合理推测内核维护了一个机制来执行默认动作。虽然信号默认动作种类有限(主要是终止、终止并核心转储、停止、继续等),但内核并非通过一个全局的默认动作函数指针数组来实现。更可能的实现方式是:内核内部定义一个处理默认动作的核心函数(例如 handle_signal_default),该函数通过 switch_case语句,根据传入的信号编号 sig执行预定义的行为。此函数仅在判定 handler == SIG_DFL后被调用。

cpp 复制代码
// 伪代码:信号处理核心流程 (从内核态返回用户态时触发)
void signal_handling_on_return_to_user(struct pt_regs *regs) {
    struct task_struct *tsk = current;

// 1. 计算未阻塞的待处理信号集
sigset_t unblocked_pending = tsk->pending.signal & ~tsk->blocked;

// 2. 若无未阻塞待处理信号,直接返回
if (sigisempty(unblocked_pending))
    return;

// 3. 遍历信号编号 (1 到 64)
for (int sig = 1; sig <= 64; sig++) {
    if (!sigismember(unblocked_pending, sig))
        continue;

// 4. 获取该信号的处理配置
struct k_sigaction *ka = &tsk->sighand->action[sig];

// 5. 处理忽略 (SIG_IGN)
if (ka->sa.sa_handler == SIG_IGN) {
    sigdelset(&tsk->pending.signal, sig); // 清除信号
    continue;
}

// 6. 处理默认动作 (SIG_DFL)
if (ka->sa.sa_handler == SIG_DFL) {
    handle_signal_default(sig); // 🔥 调用内部函数执行默认动作
    // 执行默认动作(如终止)后通常不会返回此处
}

// 7. 处理自定义处理函数
// 🔥 设置用户态堆栈和寄存器,使返回用户空间时跳转至处理函数
setup_signal_frame(tsk, ka, sig, regs);

// 8. 清除该信号 (实时信号处理逻辑可能不同)
sigdelset(&tsk->pending.signal, sig);
break; // 一次通常只处理一个信号

}

}

// 伪代码:处理默认动作的核心函数 (示意内核实现逻辑)
static void handle_signal_default(int sig) {
    // 🔥 通过 switch-case 执行预定义的默认行为
    switch (sig) {
    case SIGCONT:
    case SIGCHLD:
    case SIGWINCH:
    case SIGURG:
        // 默认动作为忽略的信号 (尽管 handler 是 SIG_DFL,但行为是忽略)
        break; // 无操作,仅清除信号

case SIGSTOP:
case SIGTSTP:
case SIGTTIN:
case SIGTTOU:
    // 停止进程
    do_signal_stop(sig);
    break;

case SIGQUIT:
case SIGILL:
case SIGTRAP:
case SIGABRT:
case SIGFPE:
case SIGSEGV:
case SIGBUS:
case SIGSYS:
case SIGXCPU:
case SIGXFSZ:
    // 终止进程并生成核心转储
    do_coredump(...); // 传递相关信息
    // 注意:生成核心转储后仍会终止进程,故继续执行 ↓
    /* fallthrough */
case SIGHUP:
case SIGINT:
case SIGKILL:
case SIGPIPE:
case SIGALRM:
case SIGTERM:
case SIGUSR1:
case SIGUSR2:
case SIGPOLL:
case SIGPROF:
case SIGVTALRM:
    // 终止进程
    do_group_exit(sig); // 终止进程(或线程组)
    break;

default:
    // 实时信号 (SIGRTMIN 到 SIGRTMAX) 的默认处理是终止
    if (sig >= SIGRTMIN && sig <= SIGRTMAX) {
        do_group_exit(sig);
    }
    break;
}

}

在理解了默认信号处理函数从判定到执行的流程后,信号自定义捕捉的底层实现原理对我们而言也就较为清晰了。信号的自定义捕捉首先通过调用 signal 系统调用接口实现。调用该系统调用会触发从用户态到内核态的切换,操作系统内核随后获取传入的两个参数:信号编号和自定义处理函数的指针。接着,内核会在当前进程的 task_struct 结构体中定位到对应的信号向量表,并根据信号编号找到相应的表项,最后将自定义函数的指针填入该表项的函数指针字段中。

明确了默认动作与自定义捕捉的基本原理后,有一个关键点值得注意:由于默认动作和自定义动作本质上都是函数,调用它们就需要为其建立栈帧。此时的核心问题是,这两类函数对应的栈帧建立在哪里?针对默认动作函数与自定义动作函数的栈帧位置,可能的答案只有两种: 用户地址空间 ,或内核地址空间

首先可以明确的是,默认动作必然在内核地址空间中执行。常见的默认动作包括终止进程、核心转储等,这些操作通常涉及对内核数据结构(如 task_struct )的访问和修改。而只有操作系统内核才有权限操作这些结构,因此默认动作必须在内核态下执行,其对应的栈帧自然也建立在进程的内核栈中。

对于自定义信号处理函数的执行位置,我们可以通过编写代码进行验证。在展开验证之前,需要先补充一个相关的前置知识------ 定时器 (Timer),后续代码将基于这一概念进行实验。

定时器

在开篇讨论信号产生时,我们提到信号有五种产生方式,其中之一是软件条件。定时器正是通过软件条件产生信号的一种方式。具体而言,定时器功能通过调用 alarm 系统调用接口实现。

alarm 系统调用接口用于设置一个定时器。其函数声明如下:

  • 函数名: alarm
  • 头文件: <unistd.h>
  • 函数声明: unsigned int alarm(unsigned int sec)
  • 返回值: 返回之前设置的定时器的剩余时间(秒)。若之前未设置定时器,则返回 0。
    该接口接收一个 unsigned int 类型参数 sec ,用于设定定时器的响应时间(单位:秒)。从调用 alarm 的时刻开始计时,经过 sec 秒后,操作系统会向当前进程发送一个 14 号信号 SIGALRM

SIGALRM 信号的默认处理动作是终止进程。这意味着在设定的时间到达后,该进程将被终止。

在深入探讨 alarm 接口的具体原理之前,我们可以先编写一段代码观察其效果:

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

int main() {
    alarm(5); // 设置5秒后发送SIGALRM
    int n = 1;
    while (1) {
        std::cout << "waiting for alarm...: " << n << std::endl;
        sleep(1);
        n++;
    }
    return 0;
}

从运行结果可以清晰地观察到 alarm 接口的效果:当到达设定的时间(5秒)时,alarm 触发的 SIGALRM 信号(14号)生效,进程执行默认动作终止。

了解了 alarm 系统调用接口的基本用法后,接下来分析其原理。alarm 是一个系统调用接口。当用户进程调用 alarm 时,进程会陷入内核态。操作系统接收到传递的参数(定时时长 sec )后,会为该进程创建一个定时器,其在内核中对应一个 struct timer_list 结构体对象。

该结构体包含几个关键字段:

  • expires:存储定时器到期时刻的绝对时间戳(基于 jiffies)。
  • function :一个函数指针,指向内核预定义的回调函数。
  • data:存储关联进程的 task_struct 结构体地址。
  • entry:用于将结构体组织到内核管理的定时器集合中。

内核维护一个全局变量 jiffies,记录系统启动以来的时钟滴答数(时间戳)。当创建定时器时,操作系统会将当前的 jiffies值加上参数 sec 转换成的滴答数,计算出到期时间戳并存入 expires 字段。data 字段指向设置该定时器的进程的 task_structfunction 字段指向的内核预定义函数,其功能是接收 data参数(即进程的 task_struct ),并向该进程发送 SIGALRM (14号) 信号。

cpp 复制代码
struct timer_list {
    struct list_head entry;       // 用于链表管理
    unsigned long expires;        // 到期时间 (jiffies)
    void (*function)(unsigned long); // 回调函数,参数是 data
    unsigned long data;           // 存储指向关联进程的task_struct结构体的地址
    u32 flags;
};

由于系统中可能存在多个进程调用 alarm ,内核需要管理这些进程关联的 struct timer_list 对象。内核通常使用链表、哈希表、最小堆或红黑树等数据结构来组织这些定时器对象。entry字段的作用就是将这些结构体对象链接到内核管理的数据结构中。

系统需要及时处理所有到期的定时器。计算机底层有一个硬件定时器,它周期性地(例如每秒
HZ次)向 CPU 发送时钟中断信号。CPU 收到信号后,通过中断控制器将其转换为中断号,随后切换到内核态执行对应的中断处理程序。

在时钟中断处理程序中,内核会执行以下关键操作:

  1. 更新全局时间戳 jiffies(通常递增)。
  2. 检查当前运行进程的时间片是否耗尽,若耗尽则触发调度器选择下一个进程。
  3. 处理定时器到期: 内核检查其管理的定时器集合(如最小堆或红黑树)。它会比较当前的 jiffies值与集合中最早到期定时器(如最小堆的堆顶或红黑树最左侧节点)的 expires 值。如果
    jiffies >= expires,表明该定时器已到期。内核会将其从集合中移除,并执行其 function 指向的回调函数(传入 data 参数),从而向关联进程发送 SIGALRM 信号。该进程 task_struct 中信号位图的第 14 位(对应 SIGALRM )会被置为 1。

从上述原理可知,在 Linux 操作系统中,用户态进程会频繁地切换到内核态,时钟中断信号的存在是导致这种切换的常见原因之一。

关于 alarm 系统调用的返回值,其行为是:如果进程之前已调用 alarm 设置了一个定时器,且该定时器尚未到期(即未超时),此时再次调用 alarm 设置新的定时器,则此次调用的返回值将是上一次调用
alarm 所设定时器的剩余时间。这是因为一个进程只能管理一个未过期的定时器。其底层机制会解除之前未过期的定时器,并将其替换为本次调用新创建的定时器,同时返回被解除定时器的剩余时间。

我们可以通过以下代码验证这一行为。代码逻辑如下:

  1. 使用 signal 函数注册自定义的 SIGINT (2号信号) 处理函数。
  2. 通过键盘输入 Ctrl+Ckill 命令均可产生 SIGINT 信号。
  3. 在主函数中,首先调用 alarm(5) 设置一个 5 秒后触发的定时器。
  4. 在定时器到期之前,通过 Ctrl+C 向进程发送 SIGINT 信号。
  5. 进程收到 SIGINT 信号后,执行自定义信号处理函数。
  6. 在信号处理函数中,再次调用 alarm(10) 设置一个新的 10 秒定时器。
  7. 此时 alarm(10) 的返回值即为前一个 5 秒定时器的剩余时间,将其打印出来。
  8. 主循环每秒打印一次计数信息,便于观察时间流逝并计算剩余时间,用以验证返回值是否匹配。
cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<signal.h>

void headler(int signum)
{
  int n=alarm(10);
   std::cout<<"remain time:"<<n<<std::endl;
}
int main()
{
    alarm(5);
    signal(SIGINT,headler);
    int n=1;
    while(n<6)
    {
        std::cout<<"waiting for alarm..."<<n<<std::endl;
        n++;
        sleep(1);
    }
   return 0;
}

定时器的应用场景在于执行需要在特定时间点触发或经过特定时间间隔后触发的任务。可通过设置定时器(例如使用 alarm 产生 SIGALRM 信号)并结合信号处理机制来实现此类需求。

然而, alarm 系统调用接口的定时精度仅为秒级。实际上,操作系统还提供了精度更高且功能更为丰富的定时器接口,例如 setitimer 。相比之下,alarm 是最基础、也是最简单的定时器实现。像 setitimer 这样的接口,其使用方法及底层实现机制自然比 alarm 复杂得多。不过,限于篇幅,本文不再对其展开讨论,感兴趣的读者可自行查阅相关资料以进一步了解。

sigaction

上文补充了定时器的前置知识,此处还需补充另一个关键的前置知识: sigaction 系统调用接口。

sigaction 系统调用的功能与 signal 系统调用类似,均用于为特定信号编号注册自定义处理函数。前文已提及 信号向量表 (signal vector table)的模型,该表并非一个简单的函数指针数组,而是一个结构体数组。数组中的每个结构体包含一个核心字段(函数指针)以及其他辅助字段。

sigaction 系统调用接口为用户提供了一个名为 struct sigaction 的数据结构类型。该结构体名称与系统调用接口名称相同,设计初衷是为了便于用户理解和使用。

  • 函数名: sigaction
  • 头文件: <signal.h>
  • 函数声明:int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • 返回值: 成功返回 0;失败返回 -1并设置 errno

接下来,我们查看系统提供的 struct sigaction结构体的主要字段定义:

cpp 复制代码
struct sigaction {
    void     (*sa_handler)(int);         // 基本信号处理函数(功能类似 signal() 注册的函数)
    void     (*sa_sigaction)(int, siginfo_t *, void *);  // 扩展信号处理函数(需设置 SA_SIGINFO 标志)
    sigset_t   sa_mask;                  // 在执行信号处理函数期间需阻塞的信号集
    int        sa_flags;                 // 控制信号行为的标志位(如 SA_RESTART, SA_SIGINFO 等)
};

除了核心字段 sa_handler (函数指针)外,另一个需要重点理解和掌握的关键字段是 sa_mask 。该字段类型为 sigset_t ,代表一个信号集合,其作用是指定在执行信号处理函数期间需要被阻塞(屏蔽)的信号。

要深入理解 sigaction 的底层原理,可将其与基础的 signal 系统调用进行对比。 signal 的原理是:用户提供信号编号和指向自定义处理函数的指针;操作系统在目标进程的 task_struct中找到对应信号编号的信号处理表项,将其函数指针字段替换为用户提供的函数指针。

如前所述,信号处理表的每个表项是一个结构体,包含多个字段。 signal 调用仅修改其中的函数指针字段。而 sigaction 调用则不同:用户提供一个填充好的 struct sigaction实例;操作系统在执sigaction 时陷入内核态,获取用户提供的信号编号和 struct sigaction结构体数据;内核根据信号编号定位到目标进程 task_struct中信号处理表的对应表项(该表项在内核中通常表示为 struct k_sigaction或类似结构),并将用户提供的 struct sigaction 中的各个字段值复制或映射到内核结构体的相应字段中。

这意味着内核的信号处理表结构体(如 struct k_sigaction)同样包含一个 sa_mask 字段。该字段的作用是定义在执行此特定信号的处理函数期间需要阻塞的信号集合。

此时读者可能会产生疑问:前文介绍过 sigprocmask 系统调用接口,它也是用于设置进程的阻塞信号集(信号掩码)。那么 sigaction 中的 sa_masksigprocmask 设置的阻塞信号集有何区别?

  • sigprocmask的作用: 调用 sigprocmask会直接修改进程 task_struct中的阻塞信号掩码(
    blocked 信号集)。用户提供的阻塞信号集在调用成功后立即生效,并持续作用直到被后序的sigprocmask 调用显式修改或进程结束。在此期间,被屏蔽(阻塞)的信号即使递送给进程,也不会被处理(处于未决状态),除非后续解除对其的阻塞。
  • sigactionsa_mask 的作用: 当用户调用 sigaction 设置某个信号的处理方式时,提供的 struct sigaction中的 sa_mask 字段并不会被立即添加到进程的阻塞信号掩码(blocked)中。它仅作为该信号处理函数执行期间的临时附加阻塞规则存储在信号处理表项中。

核心区别在于生效时机和作用范围:

sigprocmask 设置的是进程全局的、持久的阻塞信号掩码。

sa_mask 定义的是特定信号处理函数执行期间的、临时的、额外的阻塞信号集。

执行流程详解:

当进程收到一个信号(假设为 signum ),内核准备将其递送给用户空间处理时(即从内核态返回用户态执行处理函数前),会执行以下关键步骤:

  1. 检查该进程 task_struct中是否存在未阻塞(blocked掩码中未置位)且未处理的信号。

  2. 若存在,则根据信号编号 signum 定位到信号处理表(向量表)中对应的表项。

  3. 检查该表项中的 sa_handler 函数指针值,确定处理动作(忽略、默认、自定义)。

  4. 若为自定义处理函数( SIG_DFLSIG_IGN 除外),内核会临时修改进程的阻塞信号掩码:将进程当前的阻塞信号掩码(old_blocked )保存。

    计算新的临时阻塞掩码: new_blocked = old_blocked | sa_mask 即将 sa_mask 中指定的信号添加到当前阻塞掩码中。

    同时,内核通常也会将当前正在处理的信号 signum 本身添加到 new_blocked 中。这是为了防止同一信号在处理期间再次中断自身,导致递归调用处理函数。

    5.将进程的阻塞信号掩码设置为这个新的临时掩码new_blocked

    6.切换到用户态执行信号处理函数。

    7.当信号处理函数执行完毕返回时,内核再将阻塞信号掩码恢复为 old_blocked

    因此, sa_mask 提供了一种机制,允许用户指定在执行某个特定信号的处理函数期间,除了进程当前的阻塞信号外,还应额外阻塞哪些信号,从而为该处理函数的执行提供一个更可控、避免嵌套中断的上下文环境。其效果仅限于该信号处理函数的执行期间

而sigprocmask的第三个参数就是作为输出型参数,用来保存信号在信号向量表对应的结构体覆盖之前的内容


为了验证 sigaction 的行为特性,即其在执行自定义信号处理函数之前会阻塞当前信号并将 sa_mask 指定的信号集添加到进程的阻塞信号集(Blocked Signal Set)中,并在处理函数执行结束后解除对 sa_mask 中信号及当前信号的阻塞,我们可以编写以下代码进行验证。

代码逻辑如下:

  1. 创建一个 struct sigaction 结构体 sa
  2. 初始化 sa 的各个字段:
    • sa_handler 设置为自定义处理函数 handler
    • 使用 sigemptyset 初始化 sa_mask
    • 使用 sigaddset 将信号 SIGQUIT (通常对应编号 3) 添加到 sa_mask 中。
  3. 调用
    sigaction 接口,为信号 SIGINT (通常对应编号 2) 设置自定义处理动作(即 sa )。
  4. 自定义处理函数 handler 的主要功能是:
    • 打印进程当前的阻塞信号集(Blocked Signal Set)。
    • 打印进程当前的未决信号集(Pending Signal Set)。
    • 为了更清晰地观察现象,在 handler 中设置一个定时器( alarm(5) ),并在 5 秒后触发 SIGALRM 信号。
    • SIGALRM 信号设置自定义处理函数 send
    • SIGQUIT 信号设置自定义处理函数 check
    • 在一个循环中(10 秒)持续打印当前的未决信号集和阻塞信号集。
  5. 函数 send 的作用是调用 kill(getpid(), SIGQUIT)向自身发送 SIGQUIT信号。
  6. 函数 check 的作用是打印接收到 SIGQUIT信号的信息。
  7. 函数 printblocked用于打印阻塞信号集:
    • 调用 sigprocmask (0, NULL, &blocked) 。第一个参数 0 表示不更改当前阻塞信号集(此时其值无实际意义,通常设为 SIG_BLOCK 、 SIG_UNBLOCK 或 SIG_SETMASK 之外的任意值, 0 是常见做法)。第二个参数 NULL 表示不提供新的信号集。第三个参数 &blocked 是输出型参数,用于获取当前的阻塞信号集。
    • 遍历信号编号 1 到 31(通常的标准信号范围),使用 sigsimember 检查每个信号是否在阻塞集中,并打印对应的比特位(1 表示阻塞,0 表示未阻塞)。
  8. 函数 printpending 用于打印未决信号集:
    • 调用 sigpending(&set) 获取当前未决信号集。
    • 同样遍历信号编号 1 到 31,使用 sigsimember 检查每个信号是否在未决集中,并打印比特位(1 表示未决,0 表示非未决)。
  9. 在主函数 main 中:
    • 完成信号处理设置后,进入一个循环。
    • 在接收到 SIGINT 信号之前,持续打印当前的阻塞信号集(printblocked)和未决信号集合( printpending )。
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstring> // 包含 memset 的头文件

// 发送 SIGQUIT 信号的处理函数
void send(int signum) {
    std::cout << "sending signal: SIGQUIT (" << SIGQUIT << ")" << std::endl;
    kill(getpid(), SIGQUIT); // 向自身发送 SIGQUIT 信号
}

// 打印当前进程的阻塞信号集 (Blocked Signal Set)
void printblocked() {
    sigset_t blocked;
    // SIG_BLOCK: 0 (不修改阻塞集), NULL (不提供新信号集), &blocked (输出当前阻塞集)
    sigprocmask(0, NULL, &blocked);
    // 打印信号 31 到 1 的状态 (高位在前)
    for (int i = 31; i >= 1; i--) {
        if (sigismember(&blocked, i)) {
            std::cout << 1;
        } else {
            std::cout << 0;
        }
    }
    std::cout << std::endl;
}

// 打印当前进程的未决信号集 (Pending Signal Set)
void printpending() {
    sigset_t set;
    sigpending(&set); // 获取当前未决信号集
    // 打印信号 31 到 1 的状态 (高位在前)
    for (int i = 31; i >= 1; i--) {
        if (sigismember(&set, i)) {
            std::cout << 1;
        } else {
            std::cout << 0;
        }
    }
    std::cout << std::endl;
}

// SIGQUIT 信号的自定义处理函数
void check(int signum) {
    std::cout << "received SIGQUIT: " << signum << std::endl;
}

// SIGINT 信号的自定义处理函数
void handler(int signum) {
    std::cout << "received signal SIGINT (" << signum << "). Blocked set: ";
    printblocked(); // 打印进入处理函数时的阻塞集
    std::cout << "Pending set: ";
    printpending(); // 打印进入处理函数时的未决集

    alarm(5); // 设置 5 秒后发送 SIGALRM
    signal(SIGALRM, send); // 设置 SIGALRM 的处理函数为 send
    signal(SIGQUIT, check); // 设置 SIGQUIT 的处理函数为 check

    int n = 10;
    while (n--) {
        std::cout << "Pending: ";
        printpending(); // 打印当前未决集
        std::cout << "Blocked: ";
        printblocked(); // 打印当前阻塞集
        sleep(1);
    }
}

int main() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa)); // 初始化 sa 结构体
    sa.sa_handler = handler;    // 设置处理函数为 handler
    sigemptyset(&sa.sa_mask);   // 初始化 sa_mask 为空集
    sigaddset(&sa.sa_mask, SIGQUIT); // 将 SIGQUIT 添加到 sa_mask
    sigaction(SIGINT, &sa, NULL); // 为 SIGINT 设置处理动作

    // 主循环,在收到 SIGINT 前持续打印阻塞集状态
    while (1) {
        std::cout << "Waiting for signal. Blocked set: ";
        printblocked();
        std::cout << "Waiting for signal. pending set: ";
        printpending();
        sleep(1);
    }
    return 0;
}

关于 sigsimember 函数:

函数名: sigsimember

头文件: <signal.h>

函数声明: int sigismember(const sigset_t *set, int signo);

返回值:若信号 signo 在信号集 set 中,返回 1 。若信号 signo 不在信号集 set 中,返回 0。调用失败返回 -1 并设置 errno

预期运行结果与分析:

  1. handler执行前: 主循环打印的阻塞信号集( printblocked)和未决信号集(在主循环中未调用
    printpending ,但可通过逻辑推断)应全为 0(即所有信号均未被阻塞,且无未决信号)。
  2. 进入 handler后立即打印:
    • 阻塞集 (printblocked): 应显示信号 SIGINT (2) 和 SIGQUIT(3) 对应的比特位为 1。这表明在执行自定义处理函数前,系统自动阻塞了当前信号 ( SIGINT ) 和 sa_mask中指定的信号 (SIGQUIT)。
    • 未决集 ( printpending ): 应全为 0。这表明在调用处理函数之前,系统清除了当前信号 (SIGINT) 的未决状态。
  3. handler中等待期间 (alarm触发前): 阻塞集状态保持不变(SIGINTSIGQUIT 保持阻塞)。未决集应保持为 0。
  4. alarm(5)触发 SIGALRM: 执行 send函数,向自身发送 SIGQUIT
  5. 发送 SIGQUIT 后 (handler仍在执行):
    • 由于 SIGQUIT 在阻塞集中(在 sa_mask 中指定),它会被添加到未决信号集。
    • 后续在 handler循环中调用 printpending 时,应能看到信号 SIGQUIT (3) 对应的比特位变为1。
    • 阻塞集状态不变(SIGINTSIGQUIT 保持阻塞)。
    • SIGQUIT 的处理函数 check 不会 被立即调用,因为该信号被阻塞。
  6. handler执行结束后:
    • 系统会自动解除对 SIGINT(当前信号) 和 sa_mask 中信号 ( SIGQUIT ) 的阻塞。
    • 此时,之前处于未决状态的 SIGQUIT 信号会被递送,其处理函数 check 将被执行,打印 received SIGQUIT: 3
    • 观察主循环恢复后打印的阻塞集,应恢复为 handler执行前的状态(全 0 或程序设置的初始状态)。

总结观察到的现象:

  • 在自定义信号处理函数 handler执行之前,系统自动阻塞了当前信号 (SIGINT) 和 sa_mask 中指定的信号 (SIGQUIT ),并清除了当前信号的未决状态。
  • 在处理函数执行期间,发送被阻塞的信号 (SIGQUIT ) 会导致该信号被标记为未决(Pending),但不会被递送。
  • 在处理函数执行结束后,系统自动解除了对当前信号 (SIGINT) 和 sa_mask 中信号 ( SIGQUIT 的阻塞。此时,之前处于未决状态的 SIGQUIT 信号被递送,其自定义处理函数 check 得以执行。

在掌握上述两个前置知识的基础上, 我们即可验证前文提出的关键结论:自定义信号处理函数的执行环境位置。为此, 我们设计以下实验:

  1. 自定义 SIGINT(2号信号) 处理函数: 通过 signal 系统调用注册自定义处理函数 handler
  2. handler函数内部操作: 在该函数中定义一个局部变量 val,并打印其地址 &val
  3. 地址空间分析: 随后, 我们将检查打印出的地址值,判断其是位于用户进程地址空间还是内核进程地址空间。
  4. 触发机制: 为了触发 SIGINT 信号, 我们设置一个定时器并自定义 SIGALRM(14号信号) 处理函数
    sendsend函数的核心作用是向当前进程发送 SIGINT信号。

实验代码如下:

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

void handler(int signum)
{
  int val;
  std::cout<<&val<<std::endl;
}
void send(int signum)
{
  std::cout<<"send signal"<<std::endl;
  kill(getpid(),SIGINT);
}
int main()
{
  signal(SIGINT,handler);
  signal(SIGALRM,send);
  alarm(5);
   int n=5;
   while(n--)
   {
    std::cout<<"waiting signal:"<<std::endl;
    sleep(1);
   }

  return 0;
}

实验结果分析:

在典型的32位Linux系统中, 内核地址空间的范围是 0xC0000000 ~ 0xFFFFFFFF(共1GB),位于虚拟地址的高端。观察程序运行结果, 打印出的局部变量地址明显落在用户地址空间范围内(即0xC0000000)。

因此, 我们可以得出结论:自定义信号处理函数 handler 的栈帧是在用户栈中分配的。 这验证了信号处理函数是在用户态执行的核心机制。

再次认识操作系统以及进程地址空间以及系统调用

在上文中,我们已经完整阐述了信号的整个流程------从信号的产生、保存到最终的处理,并对每个环节的具体内容与实现细节进行了说明。至此,万事俱备,只欠东风。在最终构建一个完整的信号处理体系之前,我们还需对操作系统作进一步介绍。

截至目前的学习,相信大多数读者对操作系统已不再陌生。我们了解到,操作系统是软硬件资源的管理者,其基本管理单位是进程。对进程的管理,本质上是对其资源分配的管理,包括为进程分配内存页、协助进程打开文件等操作。

在上文中,我们曾多次提到 从用户态切换到内核态这一概念。具体来说,在切换发生之前,当前进程的上下文(即CPU各寄存器的值)会被保存至内核栈,随后才执行实际的权限切换。这时很可能会引发一个疑问:这一 切换 操作在底层具体包含哪些步骤,才能使用户态真正转变为所谓的内核态?

需要明确的是,操作系统与进程在结构上非常相似,但我们不能简单地将操作系统等同于一个进程。之所以说它们相似,是因为操作系统同样具有需要被CPU执行的代码段,以及属于自己的内存页。操作系统为管理每个进程,会为其创建对应的进程描述符(即 task_struct 结构体);同样,在管理打开的文件时,也会创建相应的 file 结构体。这些进程描述符的集合,即组织 task_struct 的数据结构,连同操作系统自身的代码段,都需要分配物理内存页来存储。

进程同样拥有自己的用户态代码段和用户态数据,并依赖物理内存页存储这些内容,同时也必须由CPU执行才能正常运行。既然进程与操作系统如此相似,从CPU的视角来看,它仅负责逐条执行指令,并不关心当前执行的指令是来自操作系统还是普通进程。我们知道,内核地址空间位于进程地址空间的高地址区域,且每个进程共享同一份内核地址空间。那么,若进程中有指令试图访问内核地址空间的内容,CPU会正常执行该指令吗?

显然,CPU无法执行此类指令,必然会触发硬件异常。但问题在于,如果CPU当前正在执行操作系统的代码,那么操作系统访问内核数据是合理的行为。此时,CPU是通过什么机制来识别当前执行实体的"身份"呢?

我们可以借助一个现实生活的类比来理解:公司仅允许员工进入,闲杂人员不得入内。每个人进入公司前需进行身份验证,如刷身份证并与公司数据库进行比对。若数据库中存在该人员的身份证记录,则允许其进入并访问公司资源;否则拒绝进入。

CPU采用了类似的机制。具体来说,CPU内部设有一个 cs 寄存器(在32位平台下为32位宽),其低2位用于表示当前执行权限。两个二进制位可组合为00、01、10、11四个值,其中00代表内核态(最高权限,对应操作系统),11代表用户态。因此,CPU通过识别 cs 寄存器最低2位的值,实现权限的区分与控制。

基于上述机制,我们可以更清晰地理解用户态到内核态切换的底层原理:首先将当前进程的上下文(即CPU各寄存器的值)压入内核栈;随后,将权限切换为内核态,即修改 cs 寄存器的低2位为对应内核态的值;最后,修改程序计数器(PC),使其指向内核中待执行函数的入口地址。

所以,CPU 通过 cs 寄存器的低两位来识别当前特权级别(CPL),即判断是处于内核态还是用户态。在理解用户态与内核态切换的基本机制之后,接下来我们需要进一步审视进程地址空间的结构。我们知道,进程地址空间由两部分组成:位于低地址的 3GB 用户地址空间,以及位于高地址的 1GB 内核地址空间。

当创建一个进程时,除了将磁盘中存储的用户态数据加载到内存,系统还会为该进程创建对应的进程描述符,即 task_struct 结构体。在初始化 task_struct 结构体的相关字段的同时,也会初始化进程地址空间,这对应一个 mm_struct 结构体,其中记录了进程各数据段的有效地址范围。此外,系统还会创建页表,建立虚拟地址到物理地址的映射条目。当 CPU 需要访问内存中存储的用户态数据时,必须借助内存管理单元(MMU),通过页表将虚拟地址转换为物理地址。

值得注意的是,每个进程能够看到完整的 4GB 地址空间,包括内核空间。这里需要提及一个关键寄存器------ CR3 寄存器。在上文讨论硬件异常时,我们提到CR2 寄存器用于存放引发异常的非法虚拟地址;而 CR3 寄存器则存放当前进程页表的物理基地址。

每个进程都拥有自己独立的页表。即使父进程创建子进程,子进程也会拥有自己的页表,只不过其页表条目最初是从父进程拷贝而来,这是写时复制(Copy-on-Write)机制的作用。另一方面,操作系统自身也需要访问其在内存中存储的数据,因此操作系统也应具备一个内核级页表。假设系统中有 n 个进程,那么理论上可能存在 n 份页表,但内核级页表究竟有多少份呢?

实际上,内核级页表只有一份。操作系统会维护一个全局唯一的内核级页表,而每个进程的用户态页表在创建时,会通过拷贝的方式嵌入或包含这份内核级页表。这样设计的主要原因在于,当从用户态切换到内核态时,系统会修改cs 寄存器的低两位以提升特权级,但 CR3 寄存器的值通常不会改变。这是因为用户态页表中已包含内核空间的映射,使得在同一进程内进行模式切换时无需切换页表。由于时钟中断等机制的存在,用户态到内核态的切换实际上非常频繁。这种设计能够避免在每次模式切换时重新加载 CR3 寄存器,从而减少对内存的频繁访问,提升性能。只有在进程切换、调度到下一进程时,CR3 寄存器的值才会被更新。


最后,我们需要再次梳理系统调用的机制。系统调用是进程陷入内核态的一种重要方式。从本质上看,系统调用是一个由操作系统提供的函数,但其执行过程涉及从用户态到内核态的切换。

实现这一切换的关键在于一条特殊指令------在 x86 架构中通常是 syscallint 80h 。当 CPU 执行到该指令时,会识别出需要进行特权级切换,进而将当前进程的上下文从用户态转换至内核态。具体来说,会将代码段寄存器(cs)的低两位设置为 0,从而将权限提升至操作系统级别。

每个系统调用都对应一个唯一的系统调用号。在发起系统调用时,相应的接口会将系统调用号存入 rax 寄存器。进入内核态后,操作系统会从 rax 中读取该系统调用号,并据此在内部维护的全局函数指针数组中进行查找。该数组以系统调用号作为索引,每个元素指向对应系统调用的内核处理函数。操作系统通过该系统调用号定位到相应的函数入口地址,并修改程序计数器(PC),使指令流程跳转至目标系统调用函数的起点。

需要注意的是,尽管 syscall 指令能够触发陷入内核的操作,但这并不代表用户程序可以随意通过执行该指令就获得内核权限并访问受保护的内核空间。实际上,操作系统会在此过程中进行一系列安全检查与权限验证,以防止非法的越权操作。若读者对具体的安全机制感兴趣,可进一步查阅相关底层实现文档或内核源码进行深入了解。

补充知识

那么上文我们已经构建了一个完整的信号体系,那么接下来的内容,就是补充一些与信号相关的额外的知识:

重入函数

上文提到,信号的自定义处理步骤,即通过调用 signal()sigaction() 系统调用为该信号在进程的信号处理函数表(signal disposition table)中注册一个用户态的处理函数。这个用户态的处理函数在用户栈上执行。信号本身是一种异步通知机制,从进程的视角看,它无法预知信号何时会被递达(delivered)以及何时会被处理。信号的处理理论上可以发生在任意时刻,这取决于操作系统的调度策略和时钟中断等事件。

由于自定义处理函数位于用户地址空间,它可以访问用户态的数据。因此,我们可以在自定义处理函数内部调用其他用户态函数。信号的处理机制在某种程度上类似于 C++ 的异常处理:一旦信号被递达并处理,就会调用其注册的自定义处理函数,从而中断当前的执行流。关键在于,自定义处理函数与 main 函数(或任何其他函数)之间不存在直接的调用关系,因此其被调用的时机是不可预测的。

考虑以下场景:假设自定义处理函数 handler 调用了 insert 函数,该函数的功能是向链表头部插入一个新节点。同时,假设主程序也正在执行 insert 函数的内容。当主程序执行流刚创建好要插入的新节点,并让新节点的 next 指针指向当前头节点的后继节点(即完成 new_node->next = head->next;)时,恰在此时信号被递达。这导致当前执行流被中断,转而执行 handler 函数。

handler 函数内部也调用了 insert 函数。它同样会创建一个新节点,让新节点的 next 指针指向头节点当前指向的节点,然后更新头节点使其指向这个新节点(即head->next = sig_node;)。处理完成后,执行流返回到主程序被中断的位置。

然而,对于进程而言,它并不知道信号处理已经发生。主程序会继续执行其代码逻辑,将 head->next" 指向它自己创建的新节点(即执行 head->next = new_node;)

cpp 复制代码
主线程执行流:

1. Node* new_node = malloc(sizeof(Node)); // 分配新节点
2. new_node->data = 42;                   // 初始化数据
3. new_node->next = head->next;           // 新节点指向原首节点
   ──────────────── 信号到达!切换到handler执行流 ────────────────
   Handler执行流:
4. Node* sig_node = malloc(sizeof(Node)); // 分配信号处理的新节点
5. sig_node->next = head->next;           // 新节点指向原首节点
6. head->next = sig_node;                 // 头节点指向新节点 (链表头插完成)
   ──────────────── 信号处理结束,返回主线程执行流 ────────────────
   主线程继续:
7. head->next = new_node;                 // 头节点指向主线程的新节点 (覆盖了handler的插入)

因此,这里的问题在于:handler 函数执行完毕后,它成功地将自己创建的新节点( sig_node )插入到了链表头部。但当主执行流恢复后,它会继续执行 head->next = new_node;,这将导致 head->next"指向主线程创建的新节点( new_node ),而 handler 插入的节点(sig_node )失去了前驱节点的有效引用,最终导致 sig_node 成为孤立节点,造成内存泄漏。

这个例子清晰地展示了信号的异步执行特性以及自定义处理函数能够访问用户态数据所带来的风险:即数据不一致或程序状态混乱的问题。由此引出一个关键概念:可重入函数(Reentrant Function)。如果一个函数(如 insert )能够同时在主执行流和信号处理函数 handler中被安全地调用,而不会引发任何问题(如数据损坏、资源泄漏),则该函数是可重入的。反之,则是不可重入函数(Non-reentrant Function)。

基于此定义和上述分析,C++ 标准模板库(STL)中容器的成员函数,由于其内部状态管理(如堆内存分配、内部指针、计数器等)通常不具备可重入性,绝大多数都属于不可重入函数。在信号处理函数中调用它们是不安全的。

SIGCHLD信号

这里讨论的内容与进程的退出和等待机制相关。我们知道,父进程可以通过调用 fork() 系统接口创建子进程。当子进程退出时(例如调用了 exit() 接口),操作系统会回收其大部分资源,包括内存页和已打开的文件描述符等。然而,操作系统会保留子进程的进程描述符(即 task_struct 结构体),并将其状态标记为僵尸状态(Zombie state)。同时,子进程的退出码(exitcode)以及导致其终止的信号(如果有)会被写入其 task_struct 中的特定字段(例如 exit_codeexit_state)。

对于父进程而言,它可以调用 wait()waitpid()系统调用来获取已终止子进程的退出状态信息。如果父进程选择阻塞等待(blocking wait),并且其等待的子进程尚未退出,则该父进程将进入阻塞状态。此时,操作系统会将其放入一个特定的等待队列(wait queue)。父进程的 task_struct 结构体中通常包含指向该等待队列的指针(例如 wait_childexit 或类似字段),表明它因等待子进程退出而阻塞。

当操作系统完成对子进程状态的更新(标记为僵尸状态并写入退出信息)后,它会通过进程间的关联指针(子进程的 task_struct 有一个指针字段,指向父进程的 task_struct )定位到父进程。操作系统随后检查父进程的状态及其指向等待队列的指针。如果该指针非空(表明父进程因调用 wait()waitpid() 而阻塞),操作系统会将父进程从等待队列中移除,并将其重新置入就绪队列(ready queue)。当父进程再次被调度执行时,它将从之前因 wait() / waitpid() 调用而中断的位置继续执行。此时(对于 waitpid() ),父进程会读取子进程 task_struct 中的 exit_codeexit_state字段,将这些信息写入其输出型参数,并最终释放子进程的 task_struct 结构体。

反之,如果父进程采用非阻塞轮询(non-blocking polling)(例如使用 WNOHANG 选项调用 waitpid() ),或者根本未调用 wait()waitpid() 系统调用,那么父进程不会进入阻塞状态,它将始终位于就绪队列中。因此,其指向因等待子进程而阻塞的队列的指针为空。在这种情况下,操作系统会向父进程发送一个
SIGCHLD 信号。

然而,在编程实践中,我们观察到:即使父进程在 fork() 创建子进程后没有调用 wait()waitpid() ,父进程代码通常也能继续正常运行。这似乎与上述机制相矛盾,因为按照原理,父进程应该会收到 SIGCHLD 信号。 那么,为什么没有明显的现象发生呢?

原因在于,SIGCHLD 信号的默认处理动作(default action) 是忽略(ignore)。需要特别注意:这里的"忽略"并非指该信号在信号处理表中的处理函数被设置为 SIG_IGN 显式忽略)。实际上,其默认处理函数仍是 SIG_DFL (默认动作),但 SIGCHLD 的默认动作恰好就是忽略该信号。也就是说,虽然指向处理函数的指针是 SIG_DFL ,但 SIG_DFL 对于SIGCHLD 信号的具体实现效果等同于忽略(SIG_IGN )。理解这一点至关重要。因此,该信号的默认行为是丢弃接收到的 SIGCHLD 信号,不会导致父进程终止或产生其他可见效果,也不会自动清理僵尸子进程。

信号是一种异步通知机制,使得程序能够正常执行,直至信号被递达后再进行相应处理。因此,我们可以基于信号实现子进程回收机制。具体地,通过调用 fork 系统调用,并依据其返回值区分父子进程的执行流。在父进程的执行流中,使用 sigaction 系统调用,将自定义的用户态函数 handler 注册到 SIGCHLD 信号的处理表中。

其中, handler 函数通过调用 waitpid 以非阻塞方式轮询并回收所有终止的子进程(注:非阻塞轮询可避免处理函数长时间阻塞)。对于子进程的执行流,其逻辑为循环打印五次消息后退出。由此,我们实现了一个简单的基于信号的子进程回收机制。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <cstdio>
#include <cstdlib>
#include <cstring> 

void handler(int signum) {
    pid_t pid;
    int status;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status)) {
            std::cout << "Child " << pid << " exited with status: " 
                      << WEXITSTATUS(status) << std::endl;
        } else if (WIFSIGNALED(status)) {
            std::cout << "Child " << pid << " killed by signal: " 
                      << WTERMSIG(status) << std::endl;
        }
    }
    exit(0);  
}

int main() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa)); 
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);   
    

pid_t id=fork();
if(id<0) {
    perror("fork");
    exit(1);
}
if(id==0) {
    int n=5;
    while(n--) {
        std::cout<<"I am child process"<<getpid()<<std::endl;
        sleep(1);
    }
    exit(0);
} else {
    sigaction(SIGCHLD,&sa,NULL);
    while(true) {
        std::cout<<"I am a father process: "<<getpid()<<std::endl;
        sleep(1);
    }
}
return 0;

}

但需要注意的是,一旦父进程接收到信号,当前控制流会被中断,转而执行自定义的信号处理函数 handler 。然而,在 handler 执行期间,如果此时有其他子进程退出并向父进程发送 SIGCHLD 信号,由于在进入 handler 之前内核会自动阻塞该信号,并在处理结束后解除阻塞,而 SIGCHLD 属于标准信号(不可靠信号),若该信号已经处于递达状态,则在 handler 执行过程中发送的后续 SIGCHLD 信号可能会被丢弃。这将导致部分子进程无法被回收,进而进入僵尸状态。关于这一问题的具体解决方案,将在后续内容中进行讲解。在当前所学知识范围内,我们暂时无法彻底处理这一场景。

cpp 复制代码
时间线:
t0: 子进程A退出 → 发送SIGCHLD
t1: 父进程进入handler处理A
t2: 子进程B退出 → SIGCHLD被标记为pending
t3: 子进程C退出 → 由于已有pending SIGCHLD,不再记录
t4: handler处理完A返回
t5: 内核递送pending的SIGCHLD(只代表B)
t6: handler再次执行,回收B
t7: C成为永久僵尸进程

Core

而本文最后需要补充的知识点便是 SIGQUIT 信号对应的 Core 终止动作。我们注意到,不同信号的默认处理动作各异:有的默认终止进程 (对应 SIGTERM 等信号的 Term ),有的默认忽略( IGN ),而 SIGQUIT 的默认动作则是 Core

Core 本质上也是一种进程终止动作,但它区别于普通的 Term 终止。其核心差异在于: Core 动作会在进程终止前生成一个 核心转储文件 (core dump file)。该文件通常命名为 core . (其中 是进程ID),它包含了进程终止瞬间的关键状态信息,主要用于事后调试分析,具体内容通常包括:

进程内存映像 (Process Memory Image):进程地址空间的完整快照。

寄存器状态 (Register State):CPU 寄存器在信号产生时的值。

调用栈回溯 (Stack Backtrace):函数调用栈信息,有助于定位崩溃点。

共享库信息 (Shared Library Information):进程加载的共享库及其映射地址。

信号上下文 (Signal Context):导致进程终止的信号相关信息。

此外,在父进程使用 waitpid (或其相关函数 wait , wait4 ) 回收子进程状态时,需要传入一个输出型参数 status 。该参数是一个整型值,其位域结构解析如下:

低8位 (bits 0-7):存储导致子进程终止的信号编号 ( signal number )。

第8位 (bit 8): core dump 标志位。若此位被置位 (值为 1),表明子进程产生了核心转储文件(即因 Core 动作终止)。

高8位(bits 16-23):存储子进程的退出状态 ( exit status ),仅当进程是正常退出(非信号终止)时有效。

cpp 复制代码
 31                    15                       8 7                     0
+-----------------------+-----------------------|-|----------------------+
     
                           退出码                core      信号编号

而我们知道我们的调试分为事中调试和事后调试,那么core就是支持事后调试,那么让我们可以知道程序运行的哪行代码发生异常,但是由于我的Linux是采取的是云服务器而不是虚拟机,所以这里自动给我关闭了core功能

我们可以通过执行 ulimit -a 命令来检查系统是否启用了核心转储(core dump)功能。若输出中 core file size 的值为 0 ,则表示当前禁止生成核心转储文件。此时,可通过 ulimit -c <size> 命令设置核心转储文件的大小上限(单位为 MB),例如 ulimit -c 1024 表示允许生成最大 1GB 的核心转储文件。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
    int a = 10;
    int b = 0;
    int c = a / b;  // 此处触发除零错误(SIGFPE信号)

return 0;

}

当运行存在严重错误(如上述代码中的整数除零操作)的进程时,操作系统会向该进程发送相应的信号(此处为 SIGFPE )。若进程因此信号终止且核心转储功能已启用,终端通常会显示 (core dumped) 提示信息。执行 ll(或 ls -l)命令可查看当前工作目录下生成的核心转储文件(通常命名为 corecore <pid> )。

注意:要利用核心转储文件进行源码级调试,必须在编译程序时添加 -g 选项以生成调试信息,例如:

bash 复制代码
g++ -g -o test test.cpp

思维导图

text 复制代码
├── 产生阶段
│   ├── 硬件异常
│   ├── 键盘输入
│   ├── kill命令
│   ├── 系统调用
│   └── 软件条件
│       └── 定时器(alarm)
│           └── SIGALRM信号
├── 保存阶段
│   └── task_struct
│       ├── pending位图
│       └── 实时信号链表
└── 处理阶段
    ├── 默认动作
    ├── 忽略信号
    └── 自定义处理
        ├── 用户栈执行
        └── sigaction结构体
            ├── sa_mask阻塞集
            └── sa_flags标志位

结语

这就是本文关于信号的全部内容,那么信号算是 Linux中期 的内容了,那么信号这个章节结束,那么我们就正式进入了 Linux中后期 内容的学习,而此前,我们已经跨越了Linux的两座大山,分别是 进程 以及 文件系统 ,而Linux的第三座大山便是 线程 ,也感谢耐心看到这里的读者,恭喜你们已经成功跨过了一个半山腰,那么下一期博客我会更新 线程 ,我会持续更新,希望你能够多多关注,如果本文有帮组到你的话,还请三连加关注,你的支持就是我创作的最大动力!

相关推荐
21号 13 小时前
21.事务和锁(重点)
开发语言·数据库
zzzsde4 小时前
【C++】stack和queue:使用&&OJ题&&模拟实现
开发语言·c++
TU^4 小时前
C语言习题~day27
c语言·数据结构·算法
☆璇4 小时前
【Linux】传输层协议UDP
linux·运维·udp
m0_748233644 小时前
C++与Python:内存管理与指针的对比
java·c++·python
孤廖4 小时前
面试官问 Linux 编译调试?gcc 编译流程 + gdb 断点调试 + git 版本控制,连 Makefile 都标好了
linux·服务器·c++·人工智能·git·算法·github
终焉代码4 小时前
【Linux】进程初阶(1)——基本进程理解
linux·运维·服务器·c++·学习·1024程序员节
我想吃余4 小时前
Linux进程间通信:管道与System V IPC的全解析
linux·服务器·c++
紫荆鱼4 小时前
设计模式-备忘录模式(Memento)
c++·后端·设计模式·备忘录模式