信号捕捉底层机制-机理篇2

文章目录

信号捕捉方法:signalsigaction

在进程信号处理机制中,常见的信号捕捉方法主要有两种:

  • signal
  • sigaction
    前面已经介绍过 signal。它的优点是接口简单、使用方便,因此在基础教学和简单场景中较容易上手。
    进一步介绍 sigaction。它与 signal 的基本用途一致,都是为某个特定信号注册处理动作;但 sigaction 提供了更丰富的控制能力,因此在工程实践中通常更推荐使用。

sigaction 的基本作用

sigaction 的核心功能,是为指定信号设置处理策略。当进程收到该信号时,内核会根据预先注册的规则执行相应动作,例如调用用户自定义的处理函数。

其典型原型如下:

c 复制代码
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

参数说明如下:

  1. signum
    表示要处理的信号编号,例如 SIGINT
  2. act
    输入参数,用于指定新的信号处理方式。
  3. oldact
    输出参数,用于保存该信号之前的处理方式,便于后续恢复。
    返回值规则如下:
  • 成功返回 0
  • 失败返回 -1,并设置 errno
    需要说明的是,sigaction 之所以比 signal 更复杂,主要原因在于它需要配合 struct sigaction 结构体共同使用。也就是说,信号处理策略不是简单传入一个函数指针,而是通过一个结构体统一描述。

struct sigaction 的关键字段

struct sigaction 中包含多个成员,但在普通信号处理场景下,重点只需要关注以下几个字段:

  1. sa_handler
    用于指定信号处理函数,其典型形式为:
c 复制代码
void handler(int signo);

其中 signo 表示当前到达的信号编号。

如果多个信号共用同一个处理函数,可以在函数内部通过 switch (signo) 区分不同信号并执行不同逻辑。

  1. sa_mask

类型为 sigset_t,表示在当前信号处理函数执行期间,需要额外屏蔽的信号集合。它本质上是一个信号集。

  1. sa_flags

用于设置附加行为选项。初学阶段通常可以设置为 0


sigset_t 与信号集

sa_mask 的类型是 sigset_t,它表示一个信号集合。

在信号机制中,信号集通常用于描述如下几类对象:

  • 未决信号集(pending set)
  • 阻塞信号集或信号屏蔽字(blocked set / signal mask)
    从内核角度看,理解信号处理时,通常可以关注以下几类核心逻辑对象:
  • 未决信号集合
  • 阻塞信号集合
  • 信号处理动作表
    因此,当用户态程序希望对某些信号进行屏蔽、解除屏蔽或组合控制时,就需要借助 sigset_t 以及相关接口,例如:
  • sigemptyset
  • sigaddset

sigaction 的基本使用流程

如果要对某个信号进行自定义捕捉,通常步骤如下:

  1. 定义信号处理函数;如果处理逻辑较长,也可以像示例中一样再封装辅助函数,例如 Count
  2. 定义 struct sigaction 结构体对象,例如 actoact,其中 act 用于设置新的处理方式,oact 用于保存旧的处理方式。
  3. 设置 act.sa_handler = handler,指定信号到达时要执行的回调函数。
  4. 设置 act.sa_flags = 0,表示当前不启用额外标志位选项。
  5. 调用 sigaction(SIGINT, &act, &oact),为 SIGINT 注册新的处理动作,并保存该信号原来的处理方式。
  6. 通过 while(true) sleep(1); 让进程持续运行,以便在程序运行期间接收并处理信号。
cpp 复制代码
#include <iostream>
#include <cstdio>
#include <signal.h>
#include <unistd.h>

using namespace std;

void handler(int signo)
{
    cout << "get a signo: " << signo << "正在处理中..." << endl;
    Count(20);
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    act.sa_flags = 0;
    //sigemptyset(&act.sa_mask); // 当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中 %%
    sigaddset(&act.sa_mask, 3);
    sigaction(SIGINT, &act, &oact);
    while(true) sleep(1);
    return 0;

}

在这段示例代码中,程序为 SIGINT 注册了自定义处理函数 handler。当用户按下 Ctrl + C 时,进程不会立即终止,而是进入 handler,输出当前捕捉到的信号编号,并调用 Count(20) 模拟一个持续 20 秒的处理过程。


实验:同类型信号在处理期间是否会重入

为了观察 sigaction 的行为,可以设计如下实验:

让信号处理函数故意执行较长时间,例如在处理函数内部加入 20 秒倒计时。这样就可以观察,在当前信号尚未处理完成时,如果再次发送同类型信号,系统会如何表现。

示例代码如下:

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

using namespace std;

void Count(int cnt)
{
    while(cnt)
    {
        printf("cnt: %2d\r", cnt);
        fflush(stdout);
        cnt--;
        sleep(1);
    }
    printf("\n");
}

void handler(int signo)
{
    cout << "get a signo: " << signo << "正在处理中..." << endl;
    Count(20);
}


int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    act.sa_flags = 0;
    sigaction(SIGINT, &act, &oact);
    while(true) sleep(1);
    return 0;

}

运行程序后,先发送一次 SIGINT,再在处理函数执行期间多次发送 SIGINT


实验现象与结论

实验中可以观察到两个典型现象:

  1. 当进程正在处理 SIGINT 时,后续到达的同类型 SIGINT 不会立即再次进入处理函数,也不会发生递归式重入。
  2. 即使在处理期间连续发送多次 SIGINT,当前处理结束后,通常也只会再额外处理一次,而不会按发送次数逐次补发。
    基于这两个现象,可以得到第一层结论:
  • 当某个信号的处理函数正在执行时,内核会自动将"当前正在处理的该信号"加入进程的信号屏蔽字。
  • 因此,在默认情况下,同类型标准信号在处理函数尚未返回前不能再次立即递送。
  • 同类型标准信号的处理方式是串行的,而不是递归重入的。
    当进程正在捕捉某一个信号期间,同类型信号无法抵达,
    当前信号正在被捕捉,系统自动加入把当前信号加入进程信号屏蔽字,block,当信号完成捕捉动作,系统有会接触对该信号的屏蔽

为什么只会"再处理一次"

这个现象的根本原因在于:标准信号的未决状态通常按位图记录,而不是按次数排队。

其处理过程可以概括为:

  1. 某个信号到达后,先在未决信号集中置位。
  2. 内核开始递送该信号时,会清除对应的未决位,并进入用户注册的处理函数。
  3. 在处理函数执行期间,该信号被自动屏蔽。
  4. 如果此时外部又多次发送同一个标准信号,那么该信号只能再次把对应未决位置为 1;后续重复发送不会额外累积,因为修改的是同一个位。
  5. 当当前处理函数返回、信号解除屏蔽后,内核会再次检查未决信号集;如果发现该信号仍为未决状态,则会再次递送一次。
    因此,即使在处理期间发送了很多次同一种标准信号,通常也只会表现为"当前处理结束后再补处理一次",而不会根据发送次数逐次执行。
    如果在处理期间只发送过第一次信号、没有新的同类信号再次产生,那么处理函数返回后自然也不会再递送第二次,因为未决信号集中已经没有该信号了。

这段程序用 sigactionSIGINT 注册了处理函数 handler,并且在处理 SIGINT 的过程中,又通过

cpp 复制代码
sigaddset(&act.sa_mask, 3);

额外把 3 号信号,也就是 SIGQUIT,加入了附加屏蔽集合。也就是说:

  • 收到 SIGINT 时,会进入 handler
  • handler 执行期间,当前信号 SIGINT 会被系统自动屏蔽
    例如,程序运行后先按一次 Ctrl + C,此时会触发 SIGINT,程序进入 handler,输出:
cpp 复制代码
get a signo: 2 正在处理中...

然后执行 Count(20),开始 20 秒倒计时。

在这 20 秒内,如果你再次连续按很多次 Ctrl + C,表面上看程序没有立刻再次进入 handler。原因是:当前 SIGINT 正在处理,内核已经自动将 SIGINT 加入信号屏蔽字,所以后续同类型信号不能立即再次递送。

但是,这并不意味着这些信号完全"没了"。对于标准信号来说,内核只在未决信号集中用一个比特位记录"这个信号是否还在等待处理",而不记录它到底来了多少次。因此,你在这 20 秒内无论再发送 1 次、5 次还是 50 次 SIGINT,最终效果通常都是一样的:SIGINT 的未决位被重新置为 1

等当前这次 handler 执行结束后,内核会解除对 SIGINT 的自动屏蔽,并检查 SIGINT 是否仍然处于未决状态。如果你刚才在处理期间又发过 SIGINT,那么它的未决位就是 1,于是程序会再次进入一次 handler。所以实验现象往往是:

  • 第一次 Ctrl + C,立即进入处理函数
  • 处理中再按很多次 Ctrl + C,不会递归重入
  • 当前处理结束后,通常只会再补执行一次处理函数,而不是按按键次数执行很多次
    再看你代码里的这句:
cpp 复制代码
sigaddset(&act.sa_mask, 3);

它表示在处理 SIGINT 期间,顺便把 SIGQUIT 也屏蔽掉。

正常情况下,如果程序没有设置这个 sa_mask,你在处理 SIGINT 倒计时时按 Ctrl + \SIGQUIT 会立即递送,程序可能直接退出。

但现在由于 SIGQUIT 被加入了 sa_mask,所以在 handler 执行期间,即使你按了 Ctrl + \,它也不会立刻终止程序,而是会等当前 SIGINT 处理完成后,再看是否递送。

所以结合这段代码,可以把整个过程总结为:

  1. 第一次收到 SIGINT 后,程序进入 handler
  2. handler 执行期间,SIGINT 自动被屏蔽,SIGQUIT 也因 sa_mask 被额外屏蔽。
  3. 这期间再次发送 SIGINT,不会立即重入,只会把 SIGINT 的未决位重新置为 1
  4. 当前处理结束后,解除屏蔽;如果 SIGINT 仍未决,则再递送一次。
  5. 因此,同类型标准信号默认是串行处理的,而不是递归处理的。

一般一个信号被解除屏蔽后会自动递达该信号,如果该信号已经被pending话,没有就不做任何动作

可以这样理解这句话:
一个信号在"被屏蔽期间"如果到来了,它不会立刻递达,而是先进入未决状态;等这个信号以后被解除屏蔽时,内核会立即检查它是否处于 pending 状态,如果是,就马上递达一次;如果不是,就什么也不做。

你的程序正在处理 SIGINT 时,SIGINT 会被系统自动屏蔽;

假设现在程序已经进入 handler(SIGINT),正在执行 Count(20)

  1. 这时 SIGINT 是屏蔽状态。

  2. 如果你在这 20 秒里再次按 Ctrl + C,新的 SIGINT 不会立刻执行。

  3. 但是这个信号也不会丢掉,而是把 SIGINT 在未决信号集中的那一位设为 1,也就是进入 pending 状态。

  4. 等当前 handler 执行完,内核会解除对 SIGINT 的屏蔽。

  5. 一旦解除屏蔽,内核马上检查:SIGINT 现在是不是 pending?

  6. 如果是,就再递达一次 SIGINT,于是你会看到处理函数又执行一次。

  7. 如果不是,就直接继续往下运行,不会再发生任何事。

因此:

  • 解除屏蔽时,内核会自动检查
  • 如果这个信号之前在屏蔽期间来过,已经 pending 了,就补递达一次
  • 如果没有 pending,就不补递达
    程序里,最典型的现象就是:
  • 第一次按 Ctrl + C,立即进入 handler
  • 倒计时期间再按很多次 Ctrl + C
  • 倒计时过程中不会重入
  • 倒计时结束后,如果期间至少又来过一次 SIGINT,程序会再进一次 handler
  • 如果期间一次都没再发,就不会再进第二次

同类型标准信号的处理原则

由上述实验和分析,可以归纳出标准信号处理的基本原则:

  • 同类型标准信号默认按串行方式处理
  • 不允许在处理函数执行期间发生同类型信号的递归式重入
  • 大量重复到达的同类型标准信号,不会被逐次排队保存,而是通过未决位图进行合并记录
  • 当前处理结束、解除屏蔽后,若该信号仍处于未决状态,则会再次递送一次
    这段话的核心意思是:
    同一种标准信号可以反复产生,但内核默认不会让它在处理函数内部一层套一层地递归执行,而是采用串行处理的方式。
    结合代码来理解。
    你的程序给 SIGINT 注册了 handler。当你第一次按 Ctrl + C 时,进程收到一个 SIGINT,于是开始执行:
cpp 复制代码
handler(SIGINT);

handler 里又会执行 Count(20),所以这次处理要持续 20 秒。

问题来了:如果这 20 秒里你又连续按很多次 Ctrl + C,会不会程序一边处理第一次 SIGINT,一边又立刻跳进去处理第二次、第三次、第四次 SIGINT

答案是:不会。

原因是:当内核开始执行某个信号的处理函数时,会自动把"当前这个正在处理的信号"加入信号屏蔽字。也就是说,当前正在处理 SIGINT 时,SIGINT 会暂时被屏蔽。于是后续再来的同类型信号,就不能立刻递达,也就不可能在 handler 里面再次递归进入 handler

这就是"不能递归式处理"。

但是,后面发来的这些 SIGINT 也不是完全消失了。对于标准信号来说,它们会以"未决"的方式先记下来。只是这个记录不是按次数排队,而是用一个位表示"这个信号现在还有没有待处理"。所以你在处理期间发 1 次 SIGINT 和发 100 次 SIGINT,最终效果通常都差不多:最多只是在未决信号集中把 SIGINT 对应那一位重新设成 1

等当前这次 handler 处理完,内核会解除对 SIGINT 的屏蔽。解除屏蔽之后,内核马上检查:SIGINT 现在是不是还处于未决状态?

  • 如果是,说明在处理期间至少又发过一次 SIGINT,那就再递达一次,于是程序会再执行一次 handler
  • 如果不是,说明处理期间没有新的 SIGINT 到来,那程序就继续往下运行,不会再补处理
    所以"串行处理"的意思就是:
  • 一次只处理一个同类型信号
  • 当前这个没处理完,后面的同类型信号不能立刻插进来
  • 必须等当前处理结束后,才有机会再处理下一次
    对于同类型标准信号,内核默认通过"处理期间自动屏蔽当前信号"的方式,防止信号处理函数递归重入,从而保证同类型信号按串行方式处理;若该信号在屏蔽期间再次产生,则仅记录为未决状态,待当前处理结束并解除屏蔽后,再决定是否补递达一次。

sa_mask 的作用

前面讨论的是"当前正在处理的信号会被自动屏蔽"。

sa_mask 的作用,是在此基础上进一步指定:当处理某个信号时,还希望额外屏蔽哪些其他信号。

也就是说:

  • 当前信号的自动屏蔽,是内核默认行为
  • 其他信号的附加屏蔽,则由 sa_mask 控制
    例如,若当前为 SIGINT 注册处理函数,但希望在处理 SIGINT 期间,SIGQUIT 也暂时不要递送,那么就可以将 SIGQUIT 添加到 sa_mask 中。
    示例写法如下:
c 复制代码
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGQUIT);

这表示:当 SIGINT 的处理函数执行时,除了 SIGINT 本身会被自动屏蔽外,SIGQUIT 也会在这一阶段被一并屏蔽。


处理 SIGINT 时额外屏蔽 SIGQUIT

可以继续基于前面的程序做实验:

SIGINT 注册处理函数,并将 SIGQUIT 添加到 sa_mask 中。

示例代码如下:

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

using namespace std;

void Count(int cnt)
{
    while(cnt)
    {
        printf("cnt: %2d\r", cnt);
        fflush(stdout);
        cnt--;
        sleep(1);
    }
    printf("\n");
}

void handler(int signo)
{
    cout << "get a signo: " << signo << "正在处理中..." << endl;
    Count(20);
}


int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    act.sa_flags = 0;
    sigaddset(&act.sa_mask, 3);
    sigaction(SIGINT, &act, &oact);
    while(true) sleep(1);
    return 0;

}

此时可以观察到:

  1. 当程序正在处理 SIGINT 时,SIGINT 本身会自动被屏蔽。
  2. 由于 SIGQUIT 已被加入 sa_mask,因此在处理 SIGINT 期间,SIGQUIT 也不会立即递送。
  3. 如果在此期间既发送了多个 SIGINT,又发送了 SIGQUIT,那么 SIGQUIT 会在解除屏蔽后再决定是否递送。

为什么程序最后可能退出

这一现象在实验中尤其值得注意。
SIGQUIT 不是被你"永久屏蔽"了,它只是在执行 SIGINT 处理函数期间被临时加入屏蔽字

cpp 复制代码
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);   // 即 SIGQUIT
sigaction(SIGINT, &act, &oact);

含义是:

  • SIGINT 到达并进入 handler
  • 内核会临时修改当前进程的信号屏蔽字
  • 自动屏蔽当前正在处理的 SIGINT
  • 同时再把 act.sa_mask 里的 SIGQUIT 也一起屏蔽
    但这个屏蔽只在这个 handler 执行期间有效
    handler 返回时,内核会把进入处理函数之前保存的旧信号屏蔽字恢复回来。这个恢复过程可以理解为:临时加上的屏蔽全部撤销 。所以如果原来 SIGQUIT 并没有被进程主动长期屏蔽,那么 handler 返回后,SIGQUIT 就自然"解除屏蔽"了。
    流程就是:
  1. SIGINT 到达,进入 handler
  2. 内核临时屏蔽 SIGINT,并按 sa_mask 临时屏蔽 SIGQUIT
  3. 这期间你发来的 SIGQUIT 不会立刻递达,只会变成 pending
  4. handler 返回后,内核恢复原来的屏蔽字
  5. 当前 SIGINT 处理结束后,若 SIGINT 仍未决,则会再处理一次 SIGINT
  6. 第二次 SIGINT 处理完成后,解除屏蔽
  7. 此时若 SIGQUIT 仍未决,则会立即递送,SIGQUIT 不再被屏蔽
  8. 内核发现它处于 pending,于是立即递达
  9. 因为 SIGQUIT 没有自定义处理,执行默认动作:终止进程
    所以程序最后退出,不是死循环失效,而是:
    SIGQUIT 在处理 SIGINT 期间被临时阻塞,处理结束后自动恢复原屏蔽字,于是 pending 的 SIGQUIT 被递达并按默认动作终止了进程。
    由于 SIGQUIT 默认动作为终止进程,因此程序最终可能表现为:前面一直在执行 SIGINT 的处理函数,而在全部 SIGINT 处理完成后,进程突然退出。
    这并不是死循环失效,而是因为先前积压的 SIGQUIT 在解除屏蔽后被递送,并按照默认动作终止了进程。

总结

本节围绕 sigaction 主要讲清了以下几点:

  1. signalsigaction 都可以用于信号捕捉。
  2. sigaction 提供了比 signal 更完整、更细粒度的控制能力。
  3. 当某个信号的处理函数被调用时,内核会自动将当前信号加入进程的信号屏蔽字,并在处理函数返回后恢复原有屏蔽状态。
  4. 因此,同类型标准信号默认采用串行处理方式,不会在处理函数执行期间递归式重入。
  5. 标准信号的未决状态通常采用位图记录,因此同一种信号在屏蔽期间即使多次到达,也不会逐次排队保存,而只会在解除屏蔽后至多再次递送一次。
  6. 如果希望在处理当前信号时,额外屏蔽其他信号,可以通过 sa_mask 将对应信号加入附加屏蔽集合。
    为了进一步加深理解,可以做两个实验:
  7. 将多个信号加入信号集,测试哪些信号允许被屏蔽、哪些不允许被屏蔽。
  8. 在信号处理函数执行期间,结合未决信号集相关接口观察:当某个信号被屏蔽后再次产生时,它是否会在未决信号集中保留下来。
相关推荐
盐焗鹌鹑蛋1 小时前
【C++】stack和queue类
c++
秋92 小时前
MySQL 8.0.46 全平台安装与配置详解(Windows/Linux/macOS)
linux·windows·mysql
小康小小涵2 小时前
基于ESP32S3实现无人机RID模块底层源码编译
linux·开发语言·python
CQU_JIAKE2 小时前
4.28~4.30【Q】
linux·运维·服务器
左手厨刀右手茼蒿2 小时前
Linux 内核中的设备驱动开发:从字符设备到网络设备
linux·嵌入式·系统内核
先知后行。2 小时前
Linux 设备模型和platform平台
linux·运维·服务器
郝学胜-神的一滴2 小时前
罗德里格斯旋转公式(Rodrigues‘ Rotation Formula)完整推导
c++·unity·godot·图形渲染·three.js·unreal
lzh200409192 小时前
深入理解进程:从PCB内核结构到写时拷贝的底层实战
linux·c++
Data_Journal2 小时前
如何使用cURL更改User Agent
大数据·服务器·前端·javascript·数据库