信号阻塞
信号集
我们主要讨论的是非实时信号
,当一个进程收到非实时信号后,不是立马处理的,而是会挑选合适的时候进行处理,那么既然信号会被延时处理,就要有一个机制来保存进程之前收到的信号。
在了解这个保存信号的机制前,我们先了解一些信号的相关概念:
信号递达(Delivery)
:进程处理信号的过程称为递达
,递达可以是执行默认处理函数,或者执行自定义的信号处理函数,忽略信号Ign
也是一种处理信号的方式,也算递达信号未决(Pending)
:当进程收到一个信号,但是还没有处理这个信号,称为未决
信号阻塞(Block)
:当一个信号被阻塞,就会一直保留在未决
状态,不会执行任何处理函数
注意:忽略信号Ign
与信号阻塞不同,忽略信号是一种递达方式,即进程处理这个信号的方式就是忽略它;而阻塞则是相当于进程根本收不到这个信号,信号被阻挡了。
对应未决
,阻塞
,递达
三个信号的状态,Linux
内核中,进程的PCB
维护了三张表pending
,block
,handler
:
pending
:该表的本质是一个位图
,也称为未决信号集
。当进程接收到一个信号,会把对应的比特位修改为1
,表示进程已经接收到该信号block
:该表的本质是一个位图
,也称为阻塞信号集
。当进程收到信号,在pending
中把对应的位修改1
,此时就要经过block
,如果block
中对应的位为1
,表示该信号被阻塞,不会被递达
,penidng
上的该位一直保持为1
;如果block
中对应的位为0
,表示该信号未被阻塞,进程挑选合适的时候递达
该信号。handler
:该表本质是一个函数指针数组
,指向信号的处理函数。如果时机合适,进程会检测pending
表和block
表,然后检测出已经接收到的信号,若该信号未被阻塞
,执行对应信号的处理函数,并把pending
中的该位变回0
,表示该信号已经处理完了。
以上表还有以下特性:
- 当用户通过
signal
修改信号的默认处理方式,其实就是在修改这个handler
内部的函数指针。 - 如果连续收到多个相同的非实时信号,此时
pending
位图只会记录一次,如果是实时信号,则会把收到的所有信号放进队列中,每个信号都会被处理。
操作信号集
简单了解了这三张表后,我们又要如何操纵这三种表呢?
对于handler
表来说,其实就是通过signal
函数来修改内部的处理函数,而对于block
和pending
表,有另外一套系统调用来处理。
block
和pending
表它们都叫做信号集
,本质都是一张位图
,要做的无非就是修改某一个位是0
还是1
,因此这两个表的操作是一样的。
操作这两个信号集,都依赖一个类型sigset_t
,其包含在<signal.h>
中,Linux
中该类型的源码如下:
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;//注意这前面有两个下划线
typedef __sigset_t sigset_t;//这次typedef后,前面没有下划线了
也就是说,sigset_t
本质是一个结构体,结构体内部只有一个成员,且该成员是一个数组。这个数组就是用于存储位图的工具。从宏观上看,你可以理解为sigset_t
就是一个位图,不过这不太严谨。
想要操作这张信号集
,需要通过以下五个函数,这些函数也需要头文件<signal.h>
,函数原型如下:
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
sigemptyset
:使信号集set
中的所有比特位变为0
sigfillset
:使信号集set
中的所有比特位变为1
sigaddset
:使信号集set
的第signum
位变为1
sigdelset
:使信号集set
的第signum
位变为0
sigismember
:检测信号集set
的第signum
位是0
还是1
前四个函数的返回值都是:如果成功返回0
,失败返回-1
。
我们可以通过以上函数,来操作信号集这个位图,但要注意,我们通过这个函数操作的信号集,既不是block
也不是pending
,它目前只是一个进程中的变量而已。
接下来要做的,就是把我们自己创建并设置的信号集,与block
和pending
交互。
sigporcmask
sigprocmask
函数用于读取或者更改进程的block
,需要头文件<signal.h>
,函数原型如下:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how
:以何种方式调用该函数,有以下选项
SIG_BLOCK
:将set
中为1
的比特位添加到block
中,相当于block = block | set
SIG_UNBLOCK
:将set
中为1
的比特位,从block
中删除,相当于block = block & ~set
SIG_SETMASK
:直接将block
设置成当前set
的样子,相当于block = set
set
:指向自己维护的信号集sigse_t
的指针
oldset
:输出型参数,用于接收修改前的block
sigpending
sigpending
函数用于读取进程的pending
,需要头文件<signal.h>
,函数原型如下:
int sigpending(sigset_t *set);
参数:
set
:输出型参数,将pending
传入到set
中
我们综合以上的所有接口,进行几个实验:
- 证明
block
确实可以阻塞信号,信号确实保存在pending
中
int main()
{
sigset_t set;
sigemptyset(&set);
sigaddset(&set, 2);
sigprocmask(SIG_BLOCK, &set, nullptr);
while (true)
{
sigset_t pending_set;
sigpending(&pending_set);
for (int i = 31; i > 0; i--)
{
if (sigismember(&pending_set, i))
cout << "1";
else
cout << "0";
}
cout << endl;
sleep(1);
}
return 0;
}
首先定义了一个sigset_t类型的变量set,由于不清楚这个变量会被初始化为什么样子所以要用sigemptyset将其全部初始化为0;
此处我们用(2) SIGINT做检测,先通过sigaddset(&set, 2)把set中的第二位变为1,随后通过sigprocmask(SIG_BLOCK, &set, nullptr)将set添加到block中,由于我们并不想知道旧的block是什么样,所以第三个参数设为nullptr。
接着进程陷入一个死循环,循环体内通过sigpending
获取当前进程的pending
,随后通过一个for
循环将这个pending
输出,此处要注意:不能直接通过循环输出结构体内部的数组,必须通过sigismember
检测一个位是0
还是1
。
这个pending是用于保存进程中的未决信号的,我们已经把(2) SIGINT阻塞了,如果预测没有错误的话,那么输入ctrl + C时,pending的第二位会变成1,这就说明我们已经接收到该信号了。但是block把(2) SIGNAL给阻塞了,导致其一直处于pending中,无法被递达,所以pending的第二位会一直是1。
2.检测是否所有信号可以被阻塞
void showSet(sigset_t *pset)
{
for (int i = 31; i > 0; i--)
{
if (sigismember(pset, i))
cout << "1";
else
cout << "0";
}
cout << endl;
}
int main()
{
sigset_t set, old_set;
sigemptyset(&old_set);
sigfillset(&set);
sigprocmask(SIG_BLOCK, &set, &old_set);
cout << "old_set: ";
showSet(&old_set);
sigprocmask(SIG_BLOCK, &set, &old_set);
cout << "new_set: ";
showSet(&old_set);
return 0;
}
以上代码中,我将输出信号集的各个比特位的功能,封装为了一个函数showSet,然后定义了两个信号集set和old_set。我们的目的是:将set的所有位变成1,然后添加到block中,在将block提取到old_block中。最后观察old_block,就可以知道是否block被成功设置为了全1。
首先通过sigemptyset把old_set设为全0,通过sigfillset把set设为全1。接着通过sigprocmask(SIG_BLOCK, &set, &old_set);把set添加到block中,把初始的block添加到old_block中,随后通过showSet输出old_set。
此处要注意,第一次old_set提取到的,不是设置后的block而是设置前的block,也就是说现在old_set拿到的是block的默认值。
现在我们要拿到block被添加了全1后的值,所以要再进行一次sigprocmask(SIG_BLOCK, &set, &old_set);,这次拿到的是上一次的block的值,也就是添加了全1后的值,再通过showSet输出。
以上代码我们输出了两次old_set,此处再强调一遍:第一次输出的是block的默认值,第二次输出的是全1设置后的block。
- 第一次输出为
全0
,说明block
的默认值是全0
,不阻塞任何信号 - 信号
(9) SIGKIILL
和(19) SIGSTOP
不允许被阻塞。
3.信号被递达时,
block
表和pending
表是什么状态
void showSet(sigset_t *pset)
{
for (int i = 31; i > 0; i--)
{
if (sigismember(pset, i))
cout << "1";
else
cout << "0";
}
cout << endl;
}
void handler(int sig)
{
cout << "get sig:" << sig << endl;
sigset_t pending_set;
sigemptyset(&pending_set);
sigpending(&pending_set);
cout << "pending: ";
showSet(&pending_set);
sigset_t tmp;
sigemptyset(&tmp);
sigset_t block_set;
sigemptyset(&block_set);
sigprocmask(SIG_BLOCK, &tmp, &block_set);
cout << "block: ";
showSet(&block_set);
exit(0);
}
int main()
{
signal(2, handler);
while (true);
return 0;
}
以上代码,先通过signal(2, handler);设置信号(2) SIGINT的处理函数,随后进程陷入while的死循环。
在handler中,会先通过sigpending(&pending_set);获取当前的pending,随后输出这个pending。再通过sigprocmask(SIG_BLOCK, &tmp, &block_set);,获取当前的block,存到block_set中。
通过ctrl + C
给进程发送(2) SIGINT
,毫无疑问pending
的第二位会被设置为1
。
进入handler
后,发现pending
的第二位为0,
说明在处理handler
前就已经把pending
变回0
了!也就是先清除信号,后递达。
另外的,我们发现block
的第二位变成了1
,我们明明没有阻塞(2) SIGINT
,为什么显示block
的第二位是1
呢?
这是为了防止信号在handler
中自己触发自己,导致信号的嵌套调用
- 信号确实是被保存在
pending
中的,block
确实可以阻塞信号递达
block
的默认值为全0
,不阻塞任何信号,信号(9) SIGKIILL
和(19) SIGSTOP
不允许被阻塞- 信号是先清除,后递达的
- 操作系统在处理信号的时候,把对应的位阻塞,防止无限递归
sigaction
操作系统提供了sigaction
接口,可以让我们在处理信号的时候,自定义要阻塞哪些信号=!
sigaction
用于设置信号处理的自定义函数和block
,需要头文件<signal.h>
,函数原型如下:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
第一个参数signum
用于指定该处理函数作用于哪一个信号。第二个参数和第三个参数的类型都是struct sigaction。
该结构体用于描述信号的处理方式,定义如下:
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
:一个函数指针,指向自定义信号处理函数handler
。sa_sigaction
:不管,使用的时设为空指针
即可sa_mask
:被设置的block
信号集sa_flags
:不管,使用时设为0
即可sa_restorer
:不管,使用的时设为空指针
即可
第二个参数act
用于传入信号处理方式,oldact
用于接收老的信号处理方式。
void showSet(sigset_t *pset)
{
for (int i = 31; i > 0; i--)
{
if (sigismember(pset, i))
cout << "1";
else
cout << "0";
}
cout << endl;
}
void handler(int sig)
{
cout << "get sig:" << sig << endl;
sigset_t block_set;
sigset_t tmp;
sigemptyset(&block_set);
sigprocmask(SIG_BLOCK, &tmp, &block_set);
cout << "block: ";
showSet(&block_set);
exit(0);
}
int main()
{
struct sigaction act;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
for (int i = 1; i <= 5; i++)
sigaddset(&act.sa_mask, i);
sigaction(2, &act, nullptr);
while (true);
return 0;
}
以上示例中,先定义了struct sigaction act
,用于传入信号处理方式。其sa_handler
用于传入处理函数handler
,sa_flags
设为0
即可。然后通过sigemptyset
把sa_mask
全部位变成0
,再通过一个for
循环把前五位变成1
。
最后通过sigaction(2, &act, nullptr)
,设置2
号信号的处理方式。在handler
内部,获取并输出当前的block
信号集。
最后通过sigaction(2, &act, nullptr)
,设置2
号信号的处理方式。在handler
内部,获取并输出当前的block
信号集。
按照预期,在处理handler
的时候,block
的前5
位会被设置成1
。sigaction
的功能与signal
的功能有重合的部分,那就是自定义信号处理函数,但是sigaction
可以额外设置处理信号时的block
信号集。
信号捕捉
用户态与内核态
Linux
操作系统是一个多用户、多任务的操作系统,为了安全性和资源管理,它将系统划分为 用户态
和 内核态
两种运行模式。
每个进程都有自己独立的进程地址空间:
进程地址空间被分为两部分:用户空间
与内核空间
,每个空间都有自己的页表去映射内存,内核空间使用的页表叫做内核级页表
,用户空间使用的页表叫做用户级页表
。内核级页表
在整个内存中只保留一份。
内核空间中的虚拟地址,指向了内存中的操作系统的代码和数据,操作系统本身也是一个软件,也要有自己的代码和数据,任何用户访问操作系统的本质,其实都是去执行操作系统的代码。用户访问操作系统,其实就是通过地址空间的内核空间
区域来访问的!当一个进程想要访问操作系统,就可以通过自己的地址空间的内核部分来访问。
每个进程都有自己的独立的地址空间,那么每个进程都有自己独立的内核空间
,因此每个进程都可以访问到操作系统!也就是说,内核空间
存在的意义在于,不论当前哪一个进程在调度,都可以随时通过该进程的内核空间
来找到操作系统!
当进程执行用户空间
的代码,此时就处于用户态
,当进程执行内核空间
的代码,此时就处于内核态
。当然,其实此处并不是进程自己去执行内核空间
的代码,而是唤醒操作系统去执行。
最简单的例子就是系统调用
,当进程调用系统调用的时候,此时需要更高级别的权限来访问内核的底层数据。毫无疑问普通的进程是没有这个权限的,当进程进行系统调用,此时就会唤醒操作系统去执行内核空间
的代码,此时就完成了用户态
到内核态
的切换。
进程从用户态切换到内核态主要有以下几种情况:
1. 系统调用(System Call)
- 用户态程序需要执行一些需要内核权限的操作,比如读写文件、创建进程、访问网络等,就需要通过系统调用进入内核态。
2. 硬件中断(Hardware Interrupt)
- 当硬件设备(例如磁盘、网络接口、键盘等)发生中断时,会触发硬件中断,将控制权从用户态转移到内核态。
- 内核会根据中断类型进行处理,并可能需要调用相应的驱动程序来处理硬件事件。
3. 时钟中断(Clock Interrupt)
- 内核会设置定时器,定期触发时钟中断,用于执行一些周期性任务,例如进程调度、内存管理等。
简单来说,进程不仅仅是系统调用的时候会进入内核态,只要是进程需要操作系统提供的服务时,都会进入内核态,比如收到硬件中断,发送时钟中断,需要进行进程调度。
从用户态切换到内核态的过程叫做陷入内核态
,而从内核态切换到用户态的过程叫做返回用户态
。
陷入内核是一个非常频繁的操作,操作系统会不断地进行用户态和内核态之间的切换,以保证系统的正常运行。
- 权限: 用户态程序只能访问有限的系统资源,例如用户空间内存、文件系统等,而内核态程序可以访问所有系统资源,包括硬件设备和内核数据结构。
- 资源访问: 用户态程序只能访问用户空间的内存,而内核态程序可以访问所有内存空间,包括用户空间和内核空间。
- 代码执行: 用户态程序是指用户编写的程序,例如浏览器、文本编辑器等,而内核态程序是指操作系统内核代码,负责管理系统资源和处理系统核心功能。
- 硬件访问: 用户态程序无法直接访问硬件设备,需要通过系统调用向内核请求访问,而内核态程序可以直接访问硬件设备。
- 安全性: 用户态程序的权限有限,因此安全性相对较高,而内核态程序拥有完全的系统权限,安全性相对较低。
简而言之,用户态其实就是给出更低的权限,执行用户自己写的代码,保证操作系统的安全;而内核态则持有极高的权限,几乎可以做到任何事情,因此这部分不能直接给用户使用,而是在内核态中执行操作系统自己的代码,保证安全。
信号捕捉的时机
在从
内核态
返回用户态
之前,操作系统会处理信号
当操作系统因为某些原因陷入内核后,会先处理用户的需求,当处理完需求后,就会检测当前是否有需要处理的信号。也就是上图的C
部分,该过程就是检测是否有信号要处理。
检测的结果有三种:
- 没有要处理的信号,直接返回用户态
- 有要处理的信号,且该信号的处理方式是默认处理函数,那么直接在内核态处理该信号,处理完毕后返回(C->D->A)
- 有要处理的信号,且该信号的处理方式是用户自定义函数,那么要先切换回用户态执行自定义函数(E),执行完函数后再到内核态(F),最后再切换回用户态(A)
如果信号的处理方式是默认处理方式,此时直接在内核态
执行代码,主要有两个原因:
- 信号的默认处理方式,是操作系统自己提供的,因此不会有安全性问题,可以直接以
内核态
的高级权限执行 - 大部分信号的默认处理方式,是直接杀掉当前进程,杀掉进程的行为,需要
内核态
的权限,因此直接在内核态
就可以杀掉这个进程
当信号的处理方式是用户自定义函数,那么要先切换回用户态执行,这是因为用户自定义的handler
函数,其安全性是不确定的,如果贸然给这个函数一个内核态
的权限,用户有可能会拿高级权限去做不安全的事情,所以不能给用户自定义的函数内核态
权限,而是回到用户态
执行这个函数。
那么下一个问题就是,执行完handler
函数后,E
已经在用户态了,为什么还要回到内核态
,在回到用户态
?
比如说某个时刻,进行了A -> B
的陷入内核过程,那么当B
执行完毕后,就要回到A
。所以在内核态B
中一定会存储一条信息(准确来说叫做上下文),指明之前A
执行到那一行代码,从而在B -> A
的时候,可以知道跳转回哪里。
-
当用户态进程陷入内核态时,内核会保存用户态进程的上下文信息,包括:
- 寄存器值:例如程序计数器(PC)、堆栈指针(SP)、通用寄存器等。
- 内存状态:例如内存页表、虚拟地址空间等。
- 其他状态:例如进程状态、信号掩码等。
-
内核通过保存这些上下文信息,可以记录用户态进程执行到哪个位置,以及该进程的运行状态。
-
当内核处理完用户态进程的请求后,会恢复用户态进程的上下文信息,并将控制权返回给用户态进程。
-
用户态进程恢复执行后,会从之前中断的位置继续执行,而不会意识到自己曾经陷入内核态。
也就是说,陷入内核态之后,只有内核态知道之前的用户态执行到哪里,所以E
状态下不能直接跳转回原来执行的地方,必须先回到内核态
,去找到原先执行的位置,在返回用户态。
每一次在从
内核态
返回用户态
之前,操作系统都会处理信号
在此要额外强调一个每一次。
上图中,发生了几次内核态
返回用户态
?
一共发生了两次,也就是我标红的这两个箭头C->E
,F->A
,这两个时候都会检测并处理信号。
比如说某一次在E
状态下处理完毕一个信号后,回到F
,再准备回到A
的时候,操作系统就会再做一次检测,检测还有没有要处理的信号,如果有,继续处理。