目录
前言
上一期就介绍了,信号产生后可能并不会立即的执行,而是等到合适的时候去执行!这样就意味着,在被执行之前需要将信号给保存起来!我们上一期只是很粗糙的说他是用一张位图保存的,本期我们将详细的介绍保存数据信号的数据结构!
一、信号保存
上一期我么已经对信号的产生做了介绍,并了解了信号的生命周期有了整体的认识:信号的产生 -> 信号的保存 -> 信号的处理!我们下面在此基础上,先来对一些概念进行校正!
1、重谈信号的概念
• 实际执行信号的处理动作称为信号的递达(Deliver)
• 信号从产生到递达之间的状态称为信号的未决(Pending)
• 使信号处于"停滞"状态,无论是否有信号产生,都无法进行递达的状态称为信号阻塞 Block)
• 进程是可以选择阻塞某个信号的!
通俗的解释就是,递达就是实际执行信号对应的handler方法;未决就是信号产生了但是还没有处理的那种状态;阻塞就是不挂你有没有这个信号,我先把这个信号给拉黑,不让你递达!
下面用一个例子理解:
午饭时间到了,你妈给你打电话通知你要吃饭了(信号产生),你收到通知去吃饭的路上这个过程就是信号未决;但是现在不巧,你妈刚刚打电话之前,你开了一把CF,你收到你妈的通知后,你并没有立刻去吃饭,而是先打完CF再去吃饭,此时你将你妈的通知"停滞"往后了,此时你妈的通知就是信号的阻塞!你怕你妈又打天花催你,于是你就把你妈给拉黑了,这属于还没有收到信号,就把信号给阻塞了!
注意:
• 信号的阻塞可以发生在递达前的任意时候
• 被阻塞的信号产生时将保持在未决的状态,直到进程解除此信号的阻塞,才可以执行递达
• 阻塞和忽略是不一样的;阻塞是可能收到了信号但是干不了即不能递达,而忽略是收到信号的一 种处理方式,只不过这种处理方式是啥都不干!
2、信号在内核中的表示
对于一个信号来说,无非需要存储三种状态:
1、信号是否阻塞
2、信号是否未决
3、信号递达时的执行动作
所以在内核中,每个进程都维护了三张表:block 表、pending 表、handler 表
• 如图,block ,表示信号是否被阻塞; pending ,表示是否未决; handler ,表示是否递达;
• 其中,block 和 pending 表,其实是两张位图!31个普通信号正好可以用一个int来表示!其中,位图的每一位表示对应的信号,里面的值 0/1 表示是否阻塞/未决!
• 而,handler 表,其实是一个函数指针数组!其中数组的下标表示的是对应的信号,数组中的元素表示的是该信号的递达方法!
OK,介绍到这里我们也就可以明白,我们上一期使用signal(int signum, handler);就是通过signum找到handler表,将自定义的处理函数的地址放进去,然后在执行的时候我们就可以使用自己的递达方法了!
3、sigset_t
根据上面的介绍,每个信号只有一个bit的未决标志,不记录该信号产生了多少次,其中阻塞也是一样的!因此,未决和阻塞的表示可以用相同的数据类型sigset_t来存储,sigset_t称为信号集!
sigset_t类型可以表示每个信号对应的"有效"和"无效";其中阻塞信号集中,有效就是阻塞,无效就是非阻塞;未决信号集中,有效就是有信号,无效就是没有收到信号!
阻塞信号集也叫做当前进程的信号屏蔽字(signal mask),和以前的权限掩码(权限屏蔽字)一样!
这就是sigset_t的定义
#ifndef ____sigset_t_defined
#define ____sigset_t_defined
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
#endif
4、信号集操作函数、
• sigset_t相关的接口
对信号集的操作其实就是对 block 和 pending 两张表的 增删查改!
上面刚介绍了,他两本质就是位图,你想操作你可以直接用一个变量获取,然后自己使用各种位运算操作!理论上是没有问题的,但是我们不推荐这样,而且OS也不同意让你操作,因为block 和 pending 是进程PCB中的字段,除了OS谁也操作不了!所以,OS就提供了一批操作他两的系统调用!
#include <signal.h>
int sigemptyset(sigset_t *set); //初始化信号集set,全部置0
int sigfillset(sigset_t *set); //将信号集set,全部置1
int sigaddset(sigset_t *set, int signum); //将signum信号增加到set
int sigdelset(sigset_t *set, int signum); //将signum信号从set中删除
int sigismember(const sigset_t *set, int signum); // 判断signum信号是否在set中
这批函数的返回值都是,成功,返回0; 失败返回-1
小Tips:在创建信号集, sigset_t的变量后,需要使用sigemptyset
进行做初始化操作,保证信号集的合法性!
• sigpromask
作用:调用 sigpromask 可以读取或更改进程的信号屏蔽字/阻塞信号集(block)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
返回值
成功,返回0;失败, 返回-1, errno被设置
参数
1、参数1:how,对屏蔽字的更改操作
• SIG_BLOCK :set包含了我们希望添加到屏蔽字的信号,相当于mask=mask|set
• SIG_UNBLOCK :set中包含了我们希望从当前的信号屏蔽字中删除的阻塞信号,相当
mask=mask & ~set
• SIG_SETMASK : 设置当前的信号屏蔽字为set,相当于mask = set
2、参数2: set, 对屏蔽字的内容更改
3、参数3: 获取原先没有被修改的屏蔽字内容,目的是为了恢复
• sigpending
作用:获取未决信号集
参数
输出型参数,获取到的pending 表的值
返回值
成功,返回0;失败, 返回-1, errno被设置
OK,我们可以阻塞2号信号,然后再给他发2号信号,看两点现象:1、当发送2号信号时,没有终止 2、当检测其pending表时,我们发现当我们发送完2号信号后对应的位机会从0 变 1
#include <iostream>
#include <signal.h>
#include <unistd.h>
void PrintPending(sigset_t &pending)
{
std::cout << "cur process[" << getpid() << "]pending! ";
for (int i = 31; i > 0; i--)
{
if (sigismember(&pending, i))
{
std::cout << '1';
}
else
{
std::cout << '0';
}
}
std::cout << std::endl;
}
int main()
{
// 1、创还能sigset_t
sigset_t block_set, old_set;
// 2、初始化
sigemptyset(&block_set);
sigemptyset(&old_set);
// 3、将2号信号给设置到block_set
sigaddset(&block_set, 2);
// 4、将block_set的数据写到block表
sigprocmask(SIG_BLOCK, &block_set, &old_set);
while (true)
{
// 5、获取pending表
sigset_t pending;
sigpending(&pending);
// 隔一秒打印一次pending
PrintPending(pending);
sleep(1);
}
return 0;
}
上面的结果我们看到,首先2好信号已经不起作用了,因为2号被阻塞了,一旦被阻塞,就不再执行对应的handler方法了,阻塞收到信号后会在pending表中记录,上面也看到了由0到1!
当解除阻塞后,该进程会立马去执行,信号对应的handler方法!如何解除?我们之前不是保存了原先的block表吗,可以将old_set覆盖当前修改过的block:
2号信号的默认就是终止进程,所以这里解除之后就直接终止了!
如果你想看到他变成再由1变0,你可以捕捉:
当然,这里还可以验证一下,我们在执行handler前对pending位图的对应位进行修改,但是在执行前修改,还是执行后修改呢?其实很好验证:
void handler(int sig)
{
std::cout << sig << "号信号被递达了...." << std::endl;
std::cout << "-------------------------------" << std::endl;
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
std::cout << "-------------------------------" << std::endl;
}
因为这个handler方法执行完就是递达结束了!如果执行完没有变0就是执行完再修改的,反之如果在没执行完handler方法就变0了,就是在执行前修改的!
说明在执行handler方法前就修改了!
二、信号处理
前面介绍了,进程收到了信号后,可能不会立即处理,而是在合适的时间处理!现在的问题是到底什么时候才算是合适的时候呢?换句话说就是OS是在什么时候去处理pending中已经收到信号呢?
在正式回答这个问题前,我们先来介绍一个前置知识:用户态和内核态!
1、再谈地址空间
Linux操作系统是一个多用户、多任务的操作系统,为了安全性和资源管理,它将系统划分为用户态和内核态两种模式运行!(注意:用户态和内核态是针对CPU的)
每一个进程在都会有自己独立的虚拟的地址空间,但是我们注意到虚拟地址空间分为两部分:用户空间和内核空间,用户空间是给普通进程用的,那内核空间呢?当然是给OS用的!
操作系统也是软件,他启动起来也是进程;是进程就有代码和数据!它的代码和数据也和普通进程一样,会通过内核级页表映射到每个进程虚拟地址空间中的内核空间!
• 注意:OS中有很多的进程,但是并不是每个进程启动时,都要为其从磁盘加载一分内核的那部分OS的代码和数据!而是在内存中只有一份,内核级的页表也只有一份,多个进程只是在他们各自的内核空间中映射了同一份内核级的页表!
由于每个进程有独立的地址空间,所以每个进程都可以看到并访问操作系统!而访问OS的本质就是,特定情况下CPU跳转到当前进程地址空间的内核区访问!所以,内核空间的意义在于,无论哪一个进程在调度,随时都可以找到OS!
注意:虽然你每一个进程都可以看到OS,但是并不意味着你可以随意的访问!
2、用户态与内核态
• 概念
根据上面的介绍,我们可以简单的理解用户态和内核态为:
注意:用户态和内核态是针对CPU的!
用户态:CPU执行用户空间代码和数据的状态;
内核态:CPU执行内核空间代码和数据的状态;
• 权限与特点
用户态的权限较低,只能访问受限制的系统资源,无法直接访问硬件等!
内核态的权限最高,拥有访问所有软硬件资源的权限!
用户态与内核态的区别主要如下:
特性 | 用户态 | 内核态 |
---|---|---|
权限 | 受限 | 全权 |
资源访问 | 有限 | 所有 |
代码执行 | 用户程序 | 操作系统内核 |
硬件访问 | 受限 | 可直接访问 |
安全性 | 较高 | 较低 |
• 内核态和用户态的切换
我当前进程代代码中使用了系统调用,当该进程被CPU调度起来时,执行到系统调用,此时CPU会保存该进程的上下文,然后修改内部的寄存器例如 ecs/psw 等的标记位,从用户态切换为内核态, 此时CPU有最高的权限,就可以去当前进程地址空间中的内核区执行系统调用的代码了!执行完内核态的代码,此时CPU再一次修改内部寄存器 ecs/psw 的标记位,切换为用户态!然后将在切换到内核前保存的CPU上下文数据恢复,继续执行用户态的代码!
总结:用户切换到内核,前首先保存当前CPU的上下文,然后修改寄存器变成内核态,执行完内核代码,修改CPU内寄存器的标记位,恢复切换前的CPU上下文,继续执行用户态!
• 用户态切换为内核态的几种情况
1、系统调用(System Call)
• 用户执行一些需要内核权限的操作,例如:读写文件、创建进程、访问网络等;
2、硬件中断 (Hardware Interrupt)
• 当硬件设备(例如磁盘、网络接口、键盘等)发生中断时,会触发硬件中断,将控制权从用户态转移到内核态。内核会根据中断类型进行处理,并可能需要调用相应的驱动程序来处理硬件事件。
3、时钟中断 (Clock Interrupt)
• 内核会设置定时器,定期触发时钟中断,用于执行一些周期性任务,例如进程调度、内存管理等。
4、异常(Exception)
• 异常是指CPU在执行运行在用户态下的程序时,发生了某些事先不可知的错误或异常情况,如缺页异常、算术异常(如整数除零)、非法指令等。
注意:不仅是上述的三种情况才会变成内核态,而是只要你需要操作系统提供的服务,都会切换进入到内核态!
其中:
• 将用户态切换为内核态称为 陷入内核;
• 将内核态切换为用户态称为 返回用户态;
陷入内核是一个非常频繁的操作,操作系统会不断地进行用户态和内核态之间的切换,以保证系统的正常运行。
3、信号的捕捉时机
上面哔哔了半天的内核态和用户态,他和信号有嘛关系呢??
其实:信号的捕捉时机就是发生在 内核态 切换为 用户态 之前!
它的执行图如下:
如果在切换回用户态前,检测发现是有信号的,并且信号处理的函数是默认/忽略,此时内核态会执行完默认的方法,然后直接切换回用户态!
上面的执行完默认处理函数很好理解,但是,这里
1、为什么使用户自定义的处理方式时,为什么要切回用户态?
其实原因很简单,不切换用户态在技术角度肯定可以做到!但是如果不切换成用户态,此时OS不知道你自定的处理方法干了啥,万一你是 rm -rf /* 呢?所以如果直接用内核态执行,可能会被用户利用内核态的高权限"为所欲为"!所以,要从内核态切换为用户态,你用户的代码用户执行!这样最起码保证OS的安全!
2、在信号检测时,在做啥?
在检测信号时,其实就是在检查PCB中的那三张表:block、pending、handler表
首先,检查 pending 的每一位,看是否是1;如果是1, 再看 block表,如果是1,就代表阻塞,如果是0,就去看handler 是默认还是自定义;自定义就去用户态执行,自定义就以内核态的身份执行完了,切换回用户态继续往下执行!
3、为什么执行完用户自定义的处理方法后,需要切换回内核在切换回用户继续执行呢?
因为handler是内核的系统调用,调的!和在用户态的主执行流没有直接的调用关系,所以执行完用户自定义的方法后是没有办法回到主执行流的!但是内核是知道的,所以当只想完用户的handler后,通过sigreturn 的特殊系统调用回到内核,然后在通过sys_sigreturn 返回用户,继续向下执行主控制流!
4、sigaction
捕捉信号除了signal 还可以使用sigaction对信号进行捕捉!
OS提供这个系统调用的目的是为了让我们可以在处理信号时,自定义哪些信号要被阻塞!
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; //一些选项,一般设为 0
void (*sa_restorer)(void); //实时信号相关,不用管
};
这个结构体中,我们只需要关心,第一个参数,他是我们自己指定处理signum信号的处理方法!
sa_mask 是屏蔽哪些信号,用户自定义完成;
sa_flags 一般设置为0, 其他都是和实时信号有关系的,这里不管!
所以,我们如果有需求,可以将sa_mask屏蔽掉一批信号,然后执行自己自定义的那一个,这样可以避免其他信号对signum的干扰:
#include <iostream>
#include <signal.h>
#include <unistd.h>
void Print(sigset_t &pending)
{
for (int sig = 31; sig > 0; sig--)
{
if (sigismember(&pending, sig))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << std::endl;
}
void handler(int signum)
{
std::cout << "get a sig: " << signum << std::endl;
while (true)
{
sigset_t pending;
sigpending(&pending);
Print(pending);
sleep(1);
}
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
act.sa_flags = 0;
sigaction(2, &act, &oact);
while (true)
{
std::cout << "I am a process, pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
此时由于2好信号没有结束,当再次收到其他信号时,会先屏蔽起来,所以发2号和3号信号都会被阻塞,上面也看到了!当该信号阻塞结束后,才会取消对其他信号的屏蔽!
注意:31个普通信号只有少部分可以被屏蔽,其他的不能屏蔽!
三、扩展知识
1、可重入函数
可重入函数可以简单的理解为,可以被重新进入的函数!
比如单链表的头插场景中,节点node1还未完成插入时,假设刚执行了一步,此时信号被捕捉了,而且处理方式还是自定义的,而且处理方法是将node2也进行头插,此时先执行的是node2的头插,当给node2执行完之后,会在回执行node1的头插,此时会导致node2内存泄漏:
此时导致内存泄漏的本质是,node1和node2在操作时同时并发访问了同一个单链表,且对这个单链表没有做任何的保护!因此在并发时就出现可重入导致的内存泄漏,此时的单链表就是临界资源!
我们以前学过的90%的函数都不是可重入函数!
不可重入的条件:
- 调用了内存管理相关函数
- 调用了标准
I/O
库函数,因为其中很多实现都以不可重入的方式使用数据结构
2、volatile
volatile是C/C++的一个关键字,它的作用是避免编译器的优化,保证内存的可见性!
我我们举一个栗子:
第一步,我们先使用一个全局变量设计一个死循环的场景,在此之前对2号信号进行自定义捕捉,在自定义捕捉的函数体内,实现将flag赋值为1!
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdio>
int flag = 0; // 一开始为假
void handler(int signo)
{
printf("%d号信号已经成功发出了\n", signo);
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag); // 故意不写 while 的代码块 { }
printf("进程已退出\n");
return 0;
}
OK,没问题,符合预期!我们知道编译器会优化,g++一般有个优化等级:
man gcc
/O1
如果你不指定就是O0, 我们这里就优化的小一点O1:
我们发现哎不就是优化了一下吗,这咋就不行了呢?即使疯狂的发送2号信号也不结束了!
其实,这就是编译器把我们的代码给优化了!
我们一般的代码的数据,首先会加载到内存,然后CPU调度时将数据加载到寄存器,这样做是没问题的!但是现在我一优化,编译器一看主函数你就没有对flag做处理,且只有你用了falg!(不用handler十不调的),所以此时会将flag直接设置进寄存器里面:
等到后面信号处理时,即使修改了flag,也不会同步到寄存器了!所以此时主函数那个循环一直就是0你发2好信号他就收到一次,但是就是不结束!
如何解决这个问题呢?将Volatile将在全局的flag前,就可以避免了:
此时就OK了!
3、SIGCHLD
前面在介绍进程等待的时候,介绍过为了避免子进程僵尸,父进程是要以阻塞或者非阻塞轮询的方式等待子进程的退出的!
现在的问题是:父进程如何知道子进程退出了呢?
其实,子进程再退出的时候,会给父进程发送 SIGCHLD 的信号!该信号的默认动作时 忽略!
所以,我们可以不再是阻塞是的等到他,而是可以把 SIGCHLD 信号,给捕捉了,让他在自定义的函数体内进行等待!
我们举个例子:让子进程三秒后直接退出,用信号等待:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
void handler(int sig)
{
std::cout << "get a signal: " << sig << " pid: " << getpid() << std::endl;
pid_t rid = waitpid(-1, nullptr, 0); // 阻塞式等待
if (rid > 0)
{
std::cout << "wait child success, rid: " << rid << std::endl;
}
}
void DoOtherThing()
{
std::cout << "DoOtherThing()~" << std::endl;
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
// child
std::cout << "I am child process, pid: " << getpid() << std::endl;
sleep(3);
exit(1);
}
// father
while (true)
{
DoOtherThing();
sleep(1);
}
return 0;
}
没问题!可以等待成功!
可是现在又有问题,你上面是等待了任意一个子进程,且退出的只有一个!那我10个子进程同时退出呢?
我们可以的自定义的方法里面,循环的等待:
那我如果,10个子进程,5个退出5个时钟不退呢?上面这样的代码不就是,一直阻塞了吗?
其实,了可以在处理信号的函数里将其设置为,不要hang住:
void handler(int sig)
{
std::cout << "get a signal: " << sig << " pid: " << getpid() << std::endl;
while (true)
{
pid_t rid = waitpid(-1, nullptr, WNOHANG); // 非阻塞式等待
if (rid > 0)
{
std::cout << "wait child success, rid: " << rid << std::endl;
}
else if (rid < 0)
{
std::cout << "wait child success done " << std::endl;
break;
}
else
{
std::cout << "wait child success done " << std::endl;
break;
}
}
}
我现在就是不想产生僵尸,又不想等,你结束了就自己退回吧,咋办呢?
事实上 , 由于 UNIX 的历史原因 , 要想不产生僵尸进程还有另外一种办法 : 父进程调用 sigaction 将 SIGCHLD 的处理动作置为SIG_IGN, 这样 fork 出来的子进程在终止时会自动清理掉 , 不会产生僵尸进程 , 也不会通知父进程。系统默认的忽略动作和用户用sigaction 函数自定义的忽略通常是没有区别的 , 但这是一个特例。此方法对于 Linux 可用 , 但不保证在其它UNIX 系统上都可用。请编写程序验证这样做不会产生僵尸进程。
4、浅谈键盘输入数据的过程
我们前面介绍过,键盘等外设的数据都是由操作系统调用他们各自的驱动程序读取的!这是我们以前介绍的!但是这样说太过于笼统了,我下面介绍一下它的大概流程!
我们前面一直说的数CPU和外设在数据层面上不打交道,可没说在控制上也不打交道!键盘等外设都有一个自己的中断号,当键盘输入数据时,会由 8259等类似的芯片通过总线(USB等)给CPU发送中断,CPU收到中断后,会读取键盘的中断号存在内部的寄存器里面, 并保存当前任务的上下文,OS在一开始加载的时候会在内存加载一张函数指针数组的表,也称中断向量表!他里面每个元素都是提前设计好的,例如:读磁盘、读网卡、读键盘、等!上述的外设的中断号可以简单的理解为该中断向量表的下标!CPU就会拿着这个中断号,在中断向量表中索引,当找到中断号对应的下标,就去调度OS执行对应的方法!当执行完后,会执行一条中断返回指令,继续执行原来任务!
介绍完这个东西,你可能会觉得这不就是 和我们的信号一样吗?是的!但是,是先有的中断,信号是模拟中断产生的!信号是纯软件,中断是软件+硬件!
5、浅理解OS是如何正常运行的
• 如何理解系统调用
系统调用的本质是一张函数指针数组!它里面就是所有系统调用的函数名!我们平时的调用系统调用时,本质底层是CPU拿着系统调用号,到OS的系统调用的函数指针数组执行相关的方法!
这个系统调用号,从哪里来?
其实当你执行系统调用时,它的内部一定会将相对应的系统调用号,move到相关的寄存器,然后通过0x80等发生硬件中断,让CPU保存上下文,然后切换为内核态,按照寄存器里面的值索引到相关的方法,然后执行!
当然真实的系统调用比这复杂的多,我们简单的这样理解一下即可!
• OS 是如何运行的
操作系统的本质就是一个死循环+时钟中断, 不停的调度系统的任务!
外部的硬件时钟,会隔一定的时间(很短)相CPU发送时钟中断,CPU收到时钟中断后,获取中断号,然后检查当前任务的时间片,如果任务的时间片没有结束,继续执行该任务,如果时间片结束了,切换为内核态,按照中断号找到中断向量表中的对应方法即调度其OS的其他任务!
当然这是最简单的理解,真实的比这个复杂的多!!!
OK,本期分享就到这里!好兄弟,我是cp我们下期再见!
结束语:愿写尽代码千行,头发依旧如当初模样!