🤖个人主页:晚风相伴-CSDN博客
💖如果觉得内容对你有帮助的话,还请给博主一键三连(点赞💜、收藏🧡、关注💚)吧
🙏如果内容有误或者有写的不好的地方的话,还望指出,谢谢!!!
让我们共同进步
目录
前言
✨信号的阻塞
🔥信号的常见概念
🔥在内核中的表示
❔sigset_t类型
🔥信号集操作函数
sigprocmask
sigpending
示例代码
✨信号的捕捉
🔥理解信号捕捉的流程
🔥sigaction
❔可重入函数编辑
❔volatile
前言
信号的时间线
在上一篇《信号之信号的产生》中已将信号产生讲明白了,本篇就来讲讲信号的保存和处理吧。
✨信号的阻塞
🔥信号的常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
🔥在内核中的表示
信号在内核中的表示示意图
每个信号都有两个标志位分别表示阻塞和未决,还有一个函数指针数组,里面保存的是函数的地址表示处理动作。信号产生时,操作系统在进程控制块(PCB)中设置该信号的未决标志(由0->1),直到信号递达后才清理该标志位(由1->0)。
例如在上面的图中
- SIGHUP信号没有阻塞也没有产生,当它递达时执行默认的处理动作。
- SIGINT产生了,但是Block位图中是1,表示它正在被阻塞着,所以暂时不能递达,虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作在解除阻塞之前。
- SIGQUIT信号没有产生,但是Block位图中是1,所以一旦产生了SIGQUIT信号将会被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这个信号产生了多次,Linux操作系统该如何处理呢?
对于常规信号而言[1-31],操作系统会将它们在递达之前产生多次只计一次。也就是说,即使这个信号在阻塞期间产生了多次,当阻塞被解除并且信号能够传递给进程时,进程只会收到一个这样的信号。
对于实时信号而言[34-64],它们在递达之前产生多次是可以排队的,即多个相同的实时信号在阻塞期间产生会依次放在一个队列里,当阻塞被解除时,它们会按照产生的顺序依次递达给进程。
❔sigset_t类型
每个信号就是由一个比特位来表示其状态,非0即1。从上图可以看出,在阻塞位图和未决位图中都是这样表示的,所以它们可以用相同的数据类型sigset_t来进行存储,sigset_t称为信号集。对于这个类型内部如何存储这些比特位的则依赖于系统实现,用户并不需要关心,这个类型可以表示每个信号“有效”或“无效”的状态,在阻塞位图(阻塞信号集)中“有效”或“无效”就表示该信号是否被阻塞,而在未决位图(未决信号集)中“有效”或“无效”就表示该信号是否产生。
注:阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask)。
🔥信号集操作函数
可以调用以下函数对sigset_t类型进行操作
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(sigset_t *set, int signo);
- sigemptyset函数:初始化set所指向的信号集,使其中所有的信号对应的比特位清0,表示该信号集不包含任何有效信号。
- sigfillset函数:初始化set所指向的信号集,使其中所有的信号对应的比特位置1,表示该信号集包含所有有效信号
- sigaddset函数:在该信号集中添加某个有效信号
- sigdelset函数:在该信号集中删除某个有效信号
- sigismember函数:判断一个信号集的有效信号中是否包括了某个信号
前4个函数的返回值都是成功返回0,失败返回-1。最后一个是若包含则返回1,若不包含则返回0,失败返回-1。
sigprocmask
该函数用于读取或者更改进程的信号屏蔽字(阻塞信号集)
如果oldset是非空指针,则读取进程通过oldset参数传出的当前信号屏蔽字。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。
下面是how参数的可选项
SIG_BLOCK 将set指向的信号集添加到当前进程的信号屏蔽字中 SIG_UNBLOCK 将set指向的信号集从当前进程的信号屏蔽字中移除 SIG_SETMASK 设置当前信号屏蔽字为set所指向的值 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending
该函数用于读取当前进程的未决信号集
返回值:成功成功返回0,失败返回-1
示例代码
将2号信号阻塞掉,并且不断的获取并打印当前进程的pending信号集,如果我们向该进程发送一个2号信号,我们就可以看到在pending信号集中一个比特位由0->1。
#include <iostream>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <cassert>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
void handle(int signum)
{
cout << "捕捉到信号: " << signum << endl;
}
static void printPending(sigset_t &pending)
{
for (int i = 1; i <= 31; i++)
{
if (sigismember(&pending, i))
cout << "1";
else
cout << "0";
}
cout << endl;
}
int main()
{
//捕捉2号信号,不让其退出
signal(2, handle);
// 1、定义信号集对象
sigset_t bset, obset;
sigset_t pending;
// 2、初始化
sigemptyset(&bset);
sigemptyset(&obset);
sigemptyset(&pending);
// 3、添加要屏蔽的信号
sigaddset(&bset, 2 /*SIGINT*/);
// 4、设置set到内核中对应的进程内部
int n = sigprocmask(SIG_BLOCK, &bset, &obset);
assert(n == 0);
(void)n;
cout << "block 2号信号成功……, pid: " << getpid() << endl;
// 5、重复打印当前进程的pending信号集
int count = 0;
while (true)
{
// 获取pending信号集
sigpending(&pending);
// 打印信号集
printPending(pending);
sleep(1);
count++;
//count == 20时解除对2号信号的阻塞
if (count == 20)
{
n = sigprocmask(SIG_SETMASK, &obset, nullptr);
assert(n == 0);
(void)n;
cout << "解除2号信号的block成功" << endl;
}
}
return 0;
}
结果演示
如果我们将所有的信号都进行阻塞掉,我们是不是就写了一个不会被异常或者用户杀掉的进程?
这真的可以吗?用下面的代码进行验证一下
static void printPending(sigset_t &pending)
{
for (int i = 1; i <= 31; i++)
{
if (sigismember(&pending, i))
cout << "1";
else
cout << "0";
}
cout << endl;
}
static void blockSig(int sig)
{
sigset_t bset;
sigemptyset(&bset);
sigaddset(&bset, sig);
int n = sigprocmask(SIG_BLOCK, &bset, nullptr);
assert(n == 0);
(void)n;
}
int main()
{
cout << "pid: " << getpid() << endl;
for (int sig = 1; sig <= 31; sig++)
{
blockSig(sig);
}
sigset_t pending;
while (true)
{
// 获取pending信号集
sigpending(&pending);
// 打印信号集
printPending(pending);
sleep(1);
}
return 0;
}
mykill.cc
#include <iostream>
#include <string>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
using namespace std;
void Usage(string proc)
{
cout << "Usage:\r\n\t" << proc << " processid" << endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[1]);
exit(1);
}
int processid = atoi(argv[1]);
for (int i = 1; i <= 31; i++)
{
if (i == 9 || i == 19)
continue;
kill(processid, i);
cout << "kill -" << i << endl;
sleep(1);
}
kill(processid, 9);
return 0;
}
结果演示
其实在上面的代码中是屏蔽了9号信号和19号信号的,如果你将9号信号放开,这个进程立马就会被终止掉,所以将所有信号阻塞掉,还是可以将这个进程终止的——用9号信号。
19号信号是中止进程
✨信号的捕捉
将所有的信号都捕捉,那么这个进程是不是就不会被异常或者用户杀掉了呢
示例代码
void catchSig(int signum)
{
cout << "获取一个信号: " << signum << endl;
}
int main()
{
for (int i = 1; i <= 31; i++)
signal(i, catchSig);
while (true)
sleep(1);
return 0;
}
结果演示
可见虽然我们自定义捕捉了所有信号,但是9号信号还是有用的,不会失效。
🔥理解信号捕捉的流程
信号产生之后,信号不是被立即处理而是在合适的时候进行处理。
内核态和用户态的概念
当一个进程执行系统调用而陷入内核代码中执行时,则称其处于内核态
当一个进程在执行用户自己的代码时,则称其处于用户态
怎么理解这个合适的时候呢
我们都知道信号的相关数据字段都是保存在进程PCB内部的,而PCB是属于内核的范畴,所以要检测这个信号是否未决,是否产生,一定是在内核状态进行检测的;而进程大部分时间是在执行你写的代码,而这所处的状态叫做用户态。所以当从内核态返回到用户态时,就会对信号进行检测和处理。这就叫做合适的时候。
如何理解用户态和内核态之间的相互转换
在进程的地址空间中0-3GB是用户区,3-4GB是内核区。
- 当进程处于用户态时,在地址空间中的用户区的代码和数据,通过用户级的页表映射到物理内存中,CPU就能拿到代码和数据进行执行
- 当进程处于内核态时,在地址空间中的内核区的代码和数据,用户内核级的页表映射到物理内存中,CPU就能拿到代码和数据进行执行
注意:内核级页表可以被每个进程所看到,因为操作系统只有一个,每个进程通过内核级页表映射到物理内存的同一块区域。
地址空间中的内核区和用户区之间的转换是通过系统调用和上下文切换来实现的。
例如,你在你的代码中使用了系统调用接口open,它就会直接在地址空间中由用户区跳转到内核区,然后再内核区通过内核级页表将代码和数据映射到物理内存上。
注:CPU可以通过寄存器里的值就能知道当前进程是处于用户态还是内核态
当然内核也是可以执行用户的代码的,但是内核不愿意,也不想这样干,因为如果这样干很可能导致内核中的数据和代码被用户给修改了。
信号捕捉的流程图:
假设用户自定义的信号处理函数sighandler,进程当前正在执行用户的代码,这时执行到某条指令时发生了中断,用户态切换到了内核态并保存当前进程的上下文。内核态将中断处理完后需要返回到用户态,在返回之前先检查一下是否有信号需要递达, 如有则要对信号进行处理,如果信号的处理动作是用户自定义的, 那么内核态就要返回到用户态执行sighandler函数(sighandler和主函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程),sighandler函数处理完后,进程又再次返回到内核态,如果没有新的信号需要递达了,那么就需要从内核态返回到用户态并且恢复上下文继续从主逻辑向下执行。
可以简化成下面这幅图
🔥sigaction
sigaction函数可以读取和修改与指定信号相关联的处理动作。和signal作用类似
参数:
- sig:指定信号的编号
- 若act指针非空,则根据act修改该信号的处理动作。
- 若oact指针非空,则通过oact传出该信号原来的处理动作
返回值:成功则返回0,失败则返回-1。
说明:
sa_handler是一个回调函数,被系统所调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本篇的代码把sa_flags设为0,sa_sigaction是实时信号的处理函数,这里不做讨论。
示例代码
static void printPending(sigset_t &pending)
{
for (int i = 1; i <= 31; i++)
{
if (sigismember(&pending, i))
cout << "1";
else
cout << "0";
}
cout << endl;
}
void handler(int signum)
{
cout << "捕捉到一个信号: " << signum << endl;
int count = 20;
sigset_t pending;
while (true)
{
// 获取pending信号集
sigpending(&pending);
// 打印信号集
printPending(pending);
count--;
if (!count)
break;
sleep(1);
}
}
int main()
{
cout << "pid: " << getpid() << endl;
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaddset(&act.sa_mask, 6);
act.sa_handler = handler;
sigaction(2, &act, &oact);
cout << "default action: " << (int)(oact.sa_handler) << endl;
while (true)
sleep(1);
}
结果演示
❔可重入函数
例如在图中的循环单链表
在主函数中调用insert函数向一个链表的头节点插入节点node1,插入操作分为两步,刚做完第一步时,因为硬件中断使进程切换到内核,当内核态会到用户态之前检查到有信号需要处理,于是切换到自定义的信号处理函数,信号处理函数中也调用了insert函数向同一个链表的头节点中插入node2,插入操作的两步都完成了之后,信号处理函数返回到内核态,再次返回到用户态后就从主函数调用的insert函数中继续向下执行,先前只做完了第一步就被打断了,现在继续做插入操作的第二步,但是在自定义的信号处理函数中已经插入了node2了,node2作为新的头结点,node1在完成剩下的一步时,就会导致node2节点丢失,导致内存泄漏等问题。
所以一个函数在一个特定的时间段内被多个执行流重复进入,如果该函数被重复进入不会导致问题那么就叫做可重入函数,否则就叫做不可重入函数
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准的I/O库的很多实现都以不可重入的方式使用全局数据结构。
❔volatile
示例代码
int flag = 0;
void changeFlag(int signum)
{
(void)signum;
cout << "change flag: " << flag;
flag = 1;
cout << "->" << flag << endl;
}
int main()
{
signal(2, changeFlag);
while (!flag);
cout << "进程正常退出后: " << flag << endl;
return 0;
}
没给编译器加优化前
结果演示
给编译器加了优化后
结果演示
导致这样的结果的原因:
- 在没加优化之前,CPU是正常从我们的内存中读取flag的值的,在flag由0->1后,CPU读取到了1,就直接让进程终止掉了。
- 在加了优化之后,CPU在看了main函数中没有对flag进行修改的相关语句,所以CPU就不在从内存中读取flag的值了,而是从自己的寄存器中读取比如说edx寄存器, 所以CPU一直读取到的flag是0,也就不会让进程终止掉了。
加了优化并且加了volatitle后
结果演示
volatitle作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。