Linux -- 信号【中】
目录
一、信号的保存
1、信号相关概念
1.1 信号递达 (Delivery)
1.2 信号未决 (Pending)
1.3 信号阻塞 (Block)
1.4 阻塞 vs 忽略
1.5 阻塞 vs 未决
编辑
2、信号在内核中的表示
2.1 未决信号表 (pending)
2.2 阻塞信号表 (block)
2.3 信号处理动作表 (handler)
2.4 信号处理示例分析
2.5 内核数据结构
3、sigset_t 信号集
3.1 sigprocmask
3.2 sigpending
二、信号的捕捉
2.1 内核空间与用户空间
2.2 用户态与内核态
2.3 内核如何实现对信号的捕捉
2.4 sigaction 函数
一、信号的保存
# 信号被操作系统发送给进程之后,进程可能并不会立即处理该信号,此时为了让进程之后能够执行相应的信号,我们必须将对应的信号保存下来。
1、信号相关概念
# 在 Linux
中,是通过位图结构保存的,而在了解信号的保存原理之前,我们需要先明白几个重要的概念:
1.1 信号递达 (Delivery)
# 信号递达指的是操作系统实际执行信号处理程序的过程。当信号递达时,系统会根据以下三种可能的处理方式之一来响应:
- 默认处理:执行系统预定义的操作(如终止进程)
- 忽略处理:完全丢弃该信号
- 自定义处理:执行用户注册的信号处理函数
# 例如,当进程收到 SIGINT 信号 (Ctrl+C) 时,默认处理方式是终止进程,但如果用户注册了处理函数,则会执行该函数。
1.2 信号未决 (Pending)
# 信号从产生到递达之间会经历未决状态。这个过程中:
- 信号被记录在进程的未决信号集合中
- 每个信号都有一个对应的未决标志位
- 信号可能因为阻塞而长时间保持未决状态
1.3 信号阻塞 (Block)
# 进程可以通过信号掩码主动阻塞某些信号:
- 阻塞的信号仍可被接收,但不会立即递达
- 被阻塞的信号会一直保持在未决状态
- 常见阻塞场景包括:
- 关键代码段执行期间
- 信号处理函数执行时
- 进程初始化阶段
# 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。信号的阻塞也叫做信号的屏蔽。
1.4 阻塞 vs 忽略
# 需要注意的是,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。
特性 | 阻塞 (Block) | 忽略 (Ignore) |
---|---|---|
作用时机 | 信号递达前 | 信号递达后 |
信号状态 | 保持未决 | 已被处理 |
后续影响 | 解除阻塞后会递达 | 直接丢弃 |
典型应用 | 临时屏蔽关键信号 | 永久忽略无关信号 |
# 例如,在银行交易处理中:
- 阻塞 SIGINT 可防止交易中途被中断
- 忽略 SIGCHLD 可避免处理子进程状态变化
1.5 阻塞 vs 未决
2、信号在内核中的表示
# 信号在内核中是通过两个位图与一个函数指针数组表示的,其中 block 位图每一个比特位代表对应信号是否被阻塞,pending 位图每一个比特位表示对应信号是否未决,handler 数组表示存放每个信号处理的默认或者自定义方法。并且这两个位图结构与函数指针数组也是被我们的进程控制块 task_struct所管理的。
2.1 未决信号表 (pending)
位置:task_struct->pending
作用:一个 unsigned int 的位图,记录已经产生但尚未递达(处理)的信号,从右往左数,比特位的位置表示的是第几个信号,比特示位的内容表示是否收到,1表示收到了信号,0表没有收到信号。
结构:包含一个sigset_t(位图)和一个list_head(链表)
功能:对于实时信号,list_head 用于实现信号队列,可以存储多个相同的信号
2.2 阻塞信号表 (block
)
-
位置:
task_struct->blocked
-
作用:一个 unsigned int 的位图,记录当前被进程阻塞(屏蔽)的信号,从右往左数,比特位的位置表示的是第几个信号,比特位的内容表示是否阻塞,1表示被阻塞,0表示没被阻塞。因此,pending & (~block)后,1表示该信号可以被递达,0表示该信号不可以被递达。
-
类型:
sigset_t
(一个位掩码,每位对应一个信号) -
功能:即使信号产生,如果它在阻塞集中,也不会被递送给进程,直到解除阻塞
2.3 信号处理动作表 (handler)
位置:task_struct->sighand->action[64]
作用:一个 sighandler_t handler[31] 的函数指针数组,定义了对每个信号的处理方式,数组下标就是信号编号
大小:_NSIG(通常为64),对应64种可能的信号
内容:每个元素是一个k_sigaction结构,包含:
sa_handler:信号处理函数指针(可以是SIG_DFL、SIG_IGN或用户自定义函数)
sa_flags:控制信号处理的各种标志
sa_mask:在执行此信号处理函数时,需要阻塞的其他信号集
sa_restorer:恢复函数(通常不由应用程序直接使用)
# 所以我们之前 signal 自定义捕捉方法时传入的 SIGALRM 就是宏定义为14的数组下标,之前写的 handlerSig 方法就是数组元素的函数指针。
2.4 信号处理示例分析
# 我们通过上图中的例子来解释这三种表如何协同工作:
SIGHUP 信号(信号1)
-
阻塞位:0(未阻塞)
-
未决位:0(未产生)
-
处理动作:默认处理动作(
SIG_DFL
) -
行为:当SIGHUP信号产生时,内核会设置未决标志,然后在合适的时候执行默认处理动作
SIGINT 信号(信号2)
-
阻塞位:1(被阻塞)
-
未决位:1(已产生但未处理)
-
处理动作:忽略(
SIG_IGN
) -
行为:虽然处理动作是忽略,但由于信号被阻塞,它暂时不能被处理。进程有机会在解除阻塞前改变处理动作
SIGQUIT 信号(信号3)
-
阻塞位:1(被阻塞)
-
未决位:0(未产生)
-
处理动作:用户自定义函数
sighandler
-
行为:一旦产生SIGQUIT信号,它将被阻塞,直到解除阻塞后才会调用
sighandler
2.5 内核数据结构
// 进程描述符中与信号相关的字段
struct task_struct {// ...struct sighand_struct *sighand; // 指向信号处理表sigset_t blocked; // 阻塞信号表(位图)struct sigpending pending; // 未决信号表// ...
};// 信号处理表结构
struct sighand_struct {atomic_t count; // 引用计数struct k_sigaction action[_NSIG]; // 每个信号的处理动作spinlock_t siglock; // 保护该结构的自旋锁
};// 信号处理动作详情
struct k_sigaction {struct __new_sigaction sa; // 信号处理结构void __user *ka_restorer; // 恢复函数指针
};// 信号处理结构
struct __new_sigaction {__sighandler_t sa_handler; // 信号处理函数指针unsigned long sa_flags; // 标志位void (*sa_restorer)(void); // 恢复函数(通常不使用)__new_sigset_t sa_mask; // 执行处理函数时要阻塞的信号集
};// 未决信号结构
struct sigpending {struct list_head list; // 实时信号的队列sigset_t signal; // 未决信号的位图
};
# Linux 内核通过三张表精细地管理信号:
- 处理动作表决定了信号最终如何被处理
- 阻塞表控制哪些信号暂时不被处理
- 未决表记录已产生但尚未处理的信号
3、sigset_t 信号集
# sigset_t
被称为信号集,也叫做信号屏蔽字(Signal Mask),是操作系统给用户提供的一种数据类型,用来描述和 block
、 pending
一样的位图,其结构具体如下:
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
typedef __sigset_t sigset_t;
# 从下图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。
- 这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,
- 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
- 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
# sigset_t 类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印 sigset_t 变量是没有意义的。
# 于此同时,操作系统还给我们提供了很多信号集操作函数,并且我们只能通过这些函数去修改信号集。
#include <signal.h>
- int sigemptyset(sigset_t *set); // 将位图全部设置为 0
- int sigfillset(sigset_t *set); // 将位图全部都设置为 1
- int sigaddset (sigset_t *set, int signo); // 将位图中的某一位设置为 1
- int sigdelset(sigset_t *set, int signo); // 将位图中的某一位设置为 0
- int sigismember(const sigset_t *set, int signo); // 判断一个信号是否在信号集中,不在返回0,在返回1,出错返回-1
# 但是这些都只是对我们自己定义的变量进行了修改,并没有对我们的内核数据有任何影响,为了能让我们真正意义上修改内核中的 block
与 pending
位图,我们还需要借助以下两个接口。
3.1 sigprocmask
# 我们可以使用 sigprocmask
函数读取或者修改阻塞信号集(block位图),其具体用法如下:
- 函数原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
- 参数:
- 如果 oldset 是非空指针,则读取进程当前的信号屏蔽字,然后通过 oldset 参数传出,是一个输出型参数。
- 如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how决定如何更改。
- 如果 oldset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oldset 里,然后根据 set 和 how 参数更改信号屏蔽字
3. 返回值:如果调用成功返回0,出错返回-1。
# 如果我们假设当前的信号屏蔽字为 mask
,下表说明了 how
参数的可选值。
选项 | 含义 |
---|---|
SIG_BLOCK | set 包含了我们希望添加到当前信号屏蔽字的信号,相当于 mask=mask|set |
SIG_UNBLOCK | set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为 set 所指向的值,相当于 mask=set |
# 如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,至少将其中一个信号递达。
# 示例:阻塞 SIGINT
信号:
sigset_t new_set, old_set;
sigemptyset(&new_set);
sigaddset(&new_set, SIGINT);// 阻塞SIGINT,并保存旧的屏蔽字到old_set
if (sigprocmask(SIG_BLOCK, &new_set, &old_set) == -1) {perror("sigprocmask");
}
// ... 在这段代码中,SIGINT信号会被阻塞 ...
// 恢复旧的屏蔽字(解除对SIGINT的阻塞)
if (sigprocmask(SIG_SETMASK, &old_set, NULL) == -1) {perror("sigprocmask");
}
3.2 sigpending
# 我们同样可以通过 sigpending
函数来读取对应的未决信号集(pending位图),其原型如下:
#include <signal.h>
- 函数原型:int sigpending(sigset_t \*set);
- 作用:读取当前进程的未决信号集,通过 set 参数传出,是一个输出型参数。
- 返回值:调用成功则返回0,出错则返回-1
# 知道如上接口的用法的作用后,我们就可以编写一段程序来验证以下阻塞信号集:
#include<iostream>
#include<cstdio>
#include<vector>
#include<functional>
#include<sys/types.h>
#include<unistd.h>
#include<signal.h>void PrintPending(sigset_t &pending)
{printf("我是一个进程(%d), pending: ", getpid());for(int signo = 31; signo >= 1; signo--){// 在pending表里就输出1,不在输出0if(sigismember(&pending, signo)){std::cout << "1";}else{std::cout << "0";}}std::cout << std::endl;
}void handler(int sig)
{std::cout << "#####################################" << std::endl;std::cout << "递达" << "sig" << "信号" << std::endl;sigset_t pending;int m = sigpending(&pending);PrintPending(pending); // 0000 0010(信号被处理完, 2号才会被设置为0)// 0000 0000(执行handler方法之前,2对应的pending已经被清理了)std::cout << "#####################################" << std::endl;
}int main()
{signal(SIGINT, handler); // 解除对2号的屏蔽后就直接终止进程了,想要看到效果就进行自定义捕捉// 1.屏蔽2号信号sigset_t block, old_block;sigemptyset(&block); // 清空信号集的block位图sigemptyset(&old_block);sigaddset(&block, SIGINT); // 把2号信号添加到block里,但是此时还没有对2号信号进行屏蔽,因为这个block是自定义的,不是进程当前的信号集// for(int i = 1; i < 32; i++)// sigaddset(&block, i);int n = sigprocmask(SIG_SETMASK, &block, &old_block); // 写入到内核中(void)n; // 防止n被定义但未被使用的警告// 4.重复获取打印过程int cnt = 0;while (true){// 2.获取pending信号集sigset_t pending;int m = sigpending(&pending);// 3. 打印PrintPending(pending);if(cnt == 10){// 5. 恢复对2号信号的block情况,解除屏蔽sigprocmask(SIG_SETMASK, &old_block, nullptr);std::cout << "解除对2号的屏蔽" << std::endl;}sleep(1);cnt++;}return 0;
}
# 我们把所有普通信号的阻塞表都设为屏蔽,可以看到我们通过 kill 命令发送信号时,信号被屏蔽了,所以可以看到未决表中记录的已产生但未递达的信号集,我们发送一个信号,该信号集对应位置为1,但是由于9号信号比较特殊,无法被捕获,阻塞和忽略,我们发送9号信号时,进程就被杀死了。
二、信号的捕捉
# 信号由键盘、系统调用、硬件异常、软件条件等方式产生,然后被保存在三张表中,再将信号递达,操作系统有三种处理方式:默认处理、忽略处理、自定义处理。虽然我们现在已经知道这三种处理方式,但是操作系统底层是到底怎么做到在合适的时候处理信号?合适的时候又是什么时候呢?下面我们就来深入了解。
2.1 内核空间与用户空间
# 我们知道每一个进程都有自己的进程地址空间(mm_struct),该进程地址空间其实是由内核空间和用户空间组成的,如果一个进程地址空间的表示范围为4G,那么内核空间占 1G,用户空间占 3G。
- 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
- 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。
# 其中内核级页表是一张全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程,每个进程的代码和数据可能是不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程中都是一样的内容。
2.2 用户态与内核态
# 虽然每一个进程中都有对应的操作系统的代码与数据,但并不是所有进程都能访问的,一般只有处于内核态的进程才能访问操作系统的代码与数据,而处于用户态的进程只能访问对应的用户代码与数据。
- 用户态:用户态是普通程序的运行模式,具有较低的特权级别。在用户态下运行的代码不能直接访问硬件资源和其它受限资源,例如内存管理、设备驱动程序和文件系统等。用户态程序只能通过系统调用与内核态交互,以访问这些受限资源。
- 内核态:内核态是操作系统内核的运行模式,具有较高的特权级别。在内核态下运行的代码可以访问所有系统资源和设备,并可以执行任何指令。内核态负责管理系统资源、硬件设备和用户程序,以及处理系统中断和异常。
# 在现代操作系统中,一个进程根据其运行的代码所处的特权级别,可以在用户态和内核态之间切换。例如,当用户程序通过系统调用请求操作系统服务时,进程将从用户态切换到内核态,以允许内核代码执行相应的服务。当内核完成系统调用服务时,进程将切换回用户态,以便继续执行用户代码。
# 那么问题来了,操作系统为什么不以内核态的方式去执行用户代码呢?
理论上来说是可以的,因为内核态是一种权限非常高的状态,但是绝对不能这样设计。
因为如果允许在内核态直接执行用户空间的代码,那么用户就可以在代码中设计一些非法操作,比如清空数据库,窃取密码等。这种操作在用户态是完全不可行的,但如果以内核态形式去执行,这些非法操作就能被实现。
2.3 内核如何实现对信号的捕捉
# 我们知道前面这些概念之后,我们就能解释内核是如何实现对信号的捕捉的:
# 当我们进程执行主控制流程的某条指令时可能因为中断,异常,或系统调用会陷入内核(变为内核态),在内核处理完毕准备返回用户态时,就会进行未决信号 pending 的检查。
# 查看 pending 位图时,若发现有未决信号且该信号未被阻塞,就需要对该信号进行处理。如果待处理信号的处理动作是默认或者忽略,那么执行该信号的处理动作后清除对应的 pending 标志位,若没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行。
# 但如果待处理信号是自定义捕捉的,即该信号的处理方式是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理操作,先清除对应的 pending 标志位,执行完后再通过特殊的系统调用 sigreturn 再次陷入内核并,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
# 其中需要注意的是:sighandler
和 main
函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
# 四重状态切换的本质:
切换点 | 方向 | CPU特权级 | 触发机制 | 目的 |
---|---|---|---|---|
1 (用户→内核) | 系统调用/中断 | 3→0 | 硬件中断指令 | 进入内核处理事件 |
2 (内核→用户) | 执行handler | 0→3 | 内核修改CS:EIP | 安全执行用户代码 |
3 (用户→内核) | 调用sigreturn | 3→0 | 软中断(int 0x80) | 返回内核恢复上下文 |
4 (内核→用户) | 恢复主程序 | 0→3 | 恢复原CS:EIP | 继续执行被中断代码 |
2.4 sigaction 函数
# 捕捉信号除了我们前面使用过的 signal
函数之外,我们还可以使用 sigaction
函数对信号进行捕捉,sigaction
是 Linux / UNIX 系统中用于精细控制信号行为的核心系统调用,相比传统的 signal
()
函数,它提供了更强大的功能(如信号屏蔽、附加数据传递)和更可靠的行为。其用法如下:
- 函数原型: int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
- 参数:signum 代表指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若 oldact 指针非空,则通过 oldact传出该信号原来的处理动作。
- 返回值:成功返回 0;失败返回 -1。
# 其中 act 与 oldact的类型是一个结构体指针,这个结构体原型如下:
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
和 sa_mask
,其他参数默认设为 0。
sa_handler
:指向自定义的捕捉函数。sa_mask
:一个信号集,里面记录了在处理signum
时需要额外屏蔽掉的信号。
# 其中需要注意的是:当某个信号的处理函数被调用时,内核会在调用之前自动将当前信号加入进程的信号屏蔽字,待信号处理函数返回时又自动恢复原来的信号屏蔽字,以此保证在处理某个信号时,若该信号再次产生会被阻塞到当前处理结束。
# 如果在调用信号处理函数时,除当前信号被自动屏蔽外还希望自动屏蔽另外一些信号,可通过 sa_mask
字段说明这些需额外屏蔽的信号,同样在信号处理函数返回时会自动恢复原来的信号屏蔽字。
# 与 signal()
的区别:
特性 | signal() | sigaction() |
---|---|---|
可移植性 | 不同系统行为不一致 | POSIX 标准,行为一致 |
控制精度 | 有限 | 精细控制 |
信号阻塞 | 自动阻塞当前信号 | 可自定义阻塞信号集 |
系统调用重启 | 依赖具体实现 | 可通过 SA_RESTART 明确控制 |
信号信息 | 只能获取信号编号 | 可获取详细信息(siginfo_t ) |
推荐程度 | 已过时,不推荐在新代码中使用 | 现代程序的首选 |
# 我们可以通过这个函数来验证一下:
void handler(int signum)
{std::cout << "hello signal: " << signum << std::endl;while(true){//不断获取pending表!sigset_t pending;sigpending(&pending);for(int i = 31; i >= 1; i--){if(sigismember(&pending, i))std::cout << "1";elsestd::cout << "0";}std::cout << std::endl;sleep(1);}exit(0);
}int main()
{struct sigaction act, oldact;act.sa_handler = handler;sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask, 3);sigaddset(&act.sa_mask, 4);act.sa_flags = 0;// 默认信号处理逻辑sigaction(SIGINT, &act, &oldact); // 对2号信号进行捕捉while(true){std::cout << "I am a process, pid: " << getpid() << std::endl;sleep(1);}return 0;
}
# 将3号和4号信号加入到阻塞表中,使用 sigaction 来捕获 SIGINT 信号,第一次捕获2号信号时,会执行我们自定义函数 handler,所以第一次捕获到2号信号时,pending 表中2号信号并不处于未决状态,因为已经在执行自定义函数了,然后会在handler中死循环获取 pending 表,所以我们后续不断发送2号,3号和4号信号时,都会被屏蔽,也就是阻塞住,那么 pending 表中这些信号就会因为阻塞处于未决状态。