目录
前言
一、生活角度的信号
1.1、我们来见一见信号:
1.2、模拟一下 kill 指令
二、信号的处理
三、产生信号的5种方法
3.1、kill命令
3.2、键盘可以产生信号
3.3、3种系统调用
3.4、软件条件
3.5、异常
四、比较 core 和 Term
五、键盘信号产生
六、信号的保存
6.1、信号其他相关常见概念
6.2、在内核中的表示
6.3、sigset_t
6.4、信号集操作函数
6.4.1、sigprocmask
6.4.2、sigpending
6.5、提一个场景
七、信号的处理
7.1、信号什么时候被处理?
7.2、信号捕捉
7.3、用户态和内核态 --- 进程地址空间
7.4、操作系统是如何正常运行的
7.5、内核如何实现信号的捕捉
7.6、捕捉信号的其它方式:sigaction
八、可重入函数
九、volatile
十、SIGCHLD信号 - 选学了解
总结
前言
世上有两种耀眼的光芒,一种是正在升起的太阳,一种是正在努力学习编程的你!一个爱学编程的人。各位看官,我衷心的希望这篇博客能对你们有所帮助,同时也希望各位看官能对我的文章给与点评,希望我们能够携手共同促进进步,在编程的道路上越走越远!
提示:以下是本篇文章正文内容,下面案例可供参考
一、生活角度的信号
- 信号在生活中,随时可以产生 ---> 信号的产生和进程是:异步的;
- 进程认识这个信号;
- 进程们直到信号产生了,信号该怎么处理 ---> 进程能识别并处理这个信号;
- 进程可能正在做着更重要的事情,把到来的信号暂不处理 ---> 但是进程得记得这个信号,并在合适得时候处理信号。
信号:Linux系统提供得一种,向指定进程发送特定事件的方式,做识别和处理。信号的产生是异步的。
我们把1~31号的信号叫为普通信号,将34~64的信号叫为实时信号,我们只考虑1~31号的普通信号。
1.1、我们来见一见信号:
typedef void(*sighandler_t)(int);
// 返回值为void,参数为int的函数指针类型
sighandler_t signal(int signum,sighandler_t handler);
// 捕捉对应的信号,进程收信号,会执行handler函数对应的动作
// handler:对指定信号进行捕捉的方法
void handler(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
}
int main()
{
signal(2, handler);
while (true)
{
std::cout << "hello bit, pid: " << getpid() << std::endl;
sleep(1);
}
}
- 在命令行解释器上执行 kill -2 pid(该进程的pid),向该进成发送2号信号,原来的2号进程是来终止进程的,但是signal()函数捕捉到了2号信号之后,便不会终止进程,反而执行handler()函数的动作。
- signal()捕捉到对应的信号,就将捕捉到信号的编号传到handler()函数对应的参数里。
对信号的自定义捕捉,我们只要捕捉一次,后续一直有效。
我们用signal()函数捕捉2号信号,那么2号信号一直不产生呢?
- 一直不产生2号信号,那么signal()函数捕捉不到对应的信号,handler()函数对应的动作也就永远不会被执行。
可不可以对更多的信号进行捕捉?
- 可以使用多个signal()函数对不同的信号进行捕捉。
2)SIGINT默认是什么动作呢?
- 终止进程
2) SIGINT是什么呢?
- ctrl+c --- 给目标进程发送2号信号,SIGINT默认是什么动作呢?终止进程
ctrl + \ ---> 3)SIGQUIT (终止信号)
1.2、模拟一下 kill 指令
man 2 kill
int kill(pid_t pid,int sig);
// 向指定的进信发送指定的信号
process.cc
void handler(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
}
int main()
{
signal(2, handler);
while (true)
{
std::cout << "hello bit, pid: " << getpid() << std::endl;
sleep(1);
}
}
testsig.cc
// ./mykill 2 1234
int main(int argc, char *argv[])
{
// 对命令行参数作处理
if(argc != 3)
{
std::cerr << "Usage: " << argv[0] << " signum pid" << std::endl;
return 1;
}
pid_t pid = std::stoi(argv[2]);
int signum = std::stoi(argv[1]);
kill(pid, signum);
}
打开两个终端,一个终端执行myprocess可执行程序,另一个终端执行mykill可执行程序(./mykill 2 pid(myprocess进程的pid))。
二、信号的处理
- 默认动作 ---> 进程处理信号,都是默认的 --- 默认动作通常:终止自己,暂停,忽略...;
- 忽略动作;
- 自定义处理 --- 信号的捕捉。
man 7 signal
// 查看信号的默认动作
如何理解信号的发送和保存?
- 进程需要被OS管理起来,task_struct管理进程,进程的结构体中有一个整数变量 uint32_t pending 对应的是32个比特位,也叫32个位图,用位图来进行保存收到的信号。
- 发送信号:修改指定进程PCB中的信号的指定位图,将0改为1,也可以叫写信号。
- 内核数据结构对象,只有OS有资格修改内核数据结构对象中的值。
三、产生信号的5种方法
3.1、kill命令
通过kill命令,向指定的进程发送指定的信号。
3.2、键盘可以产生信号
比如:ctrl + c ---> 2)SIGINT、ctrl + \ ----> 3)SIGQUIT
3.3、3种系统调用
man 2 kill
int kill(pid_t pid,int sig);
// 向指定的进信发送指定的信号
man raise
int raise(int sig);
// 谁调用raise()函数,就给谁发一个指定的信号
int main()
{
while (true)
{
sleep(1);
std::cout << "hello bit, pid: " << getpid() << std::endl;
raise(3);
// 本来就是每隔1秒给本进程发送一个3号信号,进程直接终止
}
}
// 被signal()函数捕捉之后,进程便不会终止了,而是会执行hnadler()函数动作
有一种特殊的情况:如果我把所有的信号都捕捉了?
void handler(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
}
int main()
{
// 将31种信号全部捕捉,换成handler()函数的方法
for(int i = 1; i <= 31; i++)
signal(i, handler);
while (true)
{
sleep(1);
std::cout << "hello bit, pid: " << getpid() << std::endl;
}
}
如果我们把所有的信号都捕捉了,比如:2、3信号默认是执行终止进程的操作的,但是我们让signal()函数对信号进行捕捉,那么信号原来的默认动作就变为handler()函数的动作了,但是此时handler()函数什么都不做,那么再次发送终止信号(kill -2 pid等),进程是退不出去的,那就出问题了。系统也考虑到了这一点,就让9号信号不允许自定义捕捉,9号信号可以杀掉进程。
man abort
// 一般终止一个进程时,用man()函数中的return;
// exit()正常终止;
// 也可以使用abort()函数把进程异常的终止掉。
void handler(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
}
int main()
{
int cnt = 0;
signal(SIGABRT, handler);
while (true)
{
sleep(1);
std::cout << "hello bit, pid: " << getpid() << std::endl;
abort();
// abort()函数异常终止进程,尽管用signal()函数捕捉SIGABRT信号,该进程依然要被终止
}
}
3.4、软件条件
我们之前所学的管道,把读端关闭,写端一直在进行,OS会给写端的进程发送 13)SIGPIPE的信号,软件条件触发了信号的产生。
我们再来讲解一个alarm()系统调用的接口。
unsigned int alarm(unsigned int seconds);
// 过seconds秒之后,会给进程发送一个 14) SIGALRM 的信号
void handler(int sig)
{
alarm(1);// 每隔1秒就再次触发一次闹钟(实现了闹钟循环)
std::cout << "cnt: " << cnt << " get a sig: " << sig << std::endl;
// std::cout << "get a sig: " << sig << std::endl;
//exit(1);
}
int main()
{
signal(SIGALRM, handler);
alarm(1); // 设定1S后的闹钟 -- 1S --- SIGALRM(终止的意思)
// sleep(4);
// 在我们所在的代码中闹钟只能设置一次,再次设置闹钟,就是对上一次设置的闹钟进行重置
// int n = alarm(2); // alarm(0): 表示取消闹钟, n:返回上一个闹钟的剩余时间
// std::cout << "n : " << n << std::endl;
// sleep(10);
while (true)
{
std::cout << "cnt: " << cnt << std::endl;// 边计算边打印,只能打印八万多次
cnt++;// 只做++,是一个纯内存级的数据递增,1秒过后,打印显示的结果,cnt++之后有五亿左右次
sleep(1);
}
}
3.5、异常
void handler(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
}
int main()
{
// 程序为什么会崩溃???非法访问、操作(?), 导致OS给进程发送信号啦!! --- 为什么
// signal(SIGSEGV, handler);
// signal(SIGFPE, handler);
// 崩溃了为什么会退出?默认是终止进程
// 可以不退出吗?可以,捕捉了异常, 推荐终止进程(为什么?) --- 为什么?
// int *p = nullptr;
// *p = 100; // 11) SIGSEGV
int a = 10;
a /= 0; // 8) SIGFPE
while (true)
{
std::cout << "hello bit, pid: " << getpid() << std::endl;
sleep(1);
}
}
程序为什么会崩溃???非法访问、操作(?), 导致OS给进程发送信号啦!!
程序崩溃,OS为什么会给目标进程发送信号,OS怎么知道进程崩溃了?
我们来拿下面的代码看一下:
int a = 10;
10 /= 0; // 除零操作
算数运算和逻辑运算一般是CPU来进行运算,
一般进程级别的数据运算都是交给CPU,CPU中有许多的寄存器,有一个状态寄存器eflag,CPU对数据进行运算,数据是来源于用户的,所以就注定了有些运算是正确的,有些运算是错误的,那么CPU如何得知运算是正常的还是异常的呢?
CPU里面有状态寄存器(eflag),eflag里面有溢出标记位(比特位),为0表示没有溢出,为1表示溢出了。CPU在运算10/0的除法运算会被转换成加法,CPU内的加法器会一直做累加,累加到一定程度后,会发生数据溢出,所以溢出标记位被置为1。如果CPU将10/0运算结束后,溢出标记位为0,没有溢出,那么10/0的运算是可信的,所以会把计算结果再写回内存;反之,溢出了,那么CPU会标定出10/0的运算出错了。
因为OS是软硬件资源的管理者,eflag状态寄存器中有溢出标记位,有错误信息,OS要随时处理这种硬件的问题,因此OS会向目标进程发送信号,发送信号就是修改结构体对象的变量中8号信号所对应的位图,将0~1。
崩溃了为什么会退出?
默认是终止进程。
可以不退出吗?可以,捕捉了异常,但是终端会一直无限死循环的打印异常信号对应的编号,为什么?
- 寄存器只有一套,但是寄存器里面的数据是属于每一个进程的 --- 所以当进程切换时,要把对应的CPU中寄存器里面的数据要保存起来,保存的目的,是为了将来进程再次被切换回来的时候,再恢复回来(硬件上下文的保存和恢复)。CPU内寄存器里面的数据保存在进程所对应的PCB里面。
- 所以捕捉了信号,进程不退出也正常,本来默认是终止,但是因为signal()函数捕捉信号,来实现handler()函数的打印,意味着进程不退出,进程就要被调度,进程就要被切换,就要把CPU内寄存器里面的数据做保存和恢复,那么每次保存异常进程,都要把寄存器的数据保存起来,包括eflag寄存器里的溢出标记位,等数据恢复到寄存器的时候,也包括eflag状态寄存器里的溢出标记位(错误的数据),所以OS会一直触发溢出标记位的错误数据,因为进程一直在被调度。
我们再来看下面的代码:
int *p = nullptr;
*p = 100;
// 野指针的访问
// 11) SIGSEGV
- 页表的映射关系是通过一个硬件电路(MMU)来转换的,MMU是被集成在CPU内部的,CPU内部的CR3寄存器保存的是页表的起始地址,MMU+CR3就可以找到页表,然后将页表中的地址交给MMU,CPU将读到的虚拟地址也交给MMU,MMU将其转换成物理地址。
- 因为虚拟地址是用户给的,所以MMU可能会存在将虚拟地址 + 页表地址 转换失败,没有形成物理地址,那么虚拟地址就会被存入CR2寄存器内,进程捕捉异常,没有终止进程,当进程切换的时候,数据恢复到CR2寄存器后,OS总能发现CR2硬件有问题,总会给该进程发送11号信号,因此signal()函数总能捕捉到异常信息,从而无线死循环打印信息对应的编号。
程序当中出现的异常,最终都会体现在硬件上,而硬件上的问题最终都会被操作系统识别到。
四、比较 core 和 Term
- Term:异常终止。
- core:异常终止,但是他会帮我们形成一个debug文件。
默认在云服务器上,向我们的系统当中形成的debug文件,功能默认是关闭的,怎么查看它是被关闭的呢?
ulimit -a
// 查看OS对于普通用户能使用资源对应的一些限制
打开被关闭的core文件:
block:是单位 -c:是core文件的选项
ulimit -c 10240(大小)
再次执行文件时,会发现错误信号后面多了一个(core dumped标记位),而且在当前目录下形成了一个core文件一旦把core file size文件打开了,就允许我们在服务器上进行core dumped。
core文件是什么呢?
当一个进程在运行时,出现了异常(除零/野指针等),进程其实没有退出,OS会把当前进程出异常的整个内存级的核心的代码和数据给我们dump(转储)到当前进程的工作目录上(磁盘),形成一个core文件,这个功能叫核心转储(core dump)。
core dump标志位只有一个比特位,为0表示没有核心转储;为1表示有核心转储。
服务器为什么要默认关闭核心存储?
在一些老的ubuntu版本中,用core动作来终止异常的程序,并打开core文件,那么每一次执行程序时,都会发生核心转储,都会生成core.pid的文件,举一个例子:一般程序挂掉了之后,程序就得立马重启,那么此时程序一重启,就会报错,core动作终止程序,如果重启了一晚上,那么生成了不知道多少个core.pid的文件,内存会被耗干,机器可能都会开不起来了,所以一般OS会将core file size文件关闭的,不过现在新的版本有改进,就算是将core file size文件打开了,也只会核心转储到一个core文件,文件不会再加pid为后缀,只要发生一次核心转储,就会将core文件更新一下。
如何打开Linux的core功能呢?
通过 ulimit -a 查看到 core file size 文件的大小的选项,用 ulimit -c 10240(用一个合适的大小) 打开core文件大小的选项,从而打开了Linux中的core功能,开启了Linux的core dump功能。
为什么有core的功能(就是core文件)?
想通过core定位到进程为什么退出,以及执行到哪行代码退出的。
什么是core的功能(core文件)?
将进程在内存运行中的核心数据(与调试有关)转储到磁盘中,以core、core.pid文件的形式保存下来,通过这两个文件可以定位到进程为什么退出,以及执行到哪行代码退出的。
core的功能(core文件)有什么用呢?
协助我们进行调试。
- gdb + 可执行程序:将程序跑起来;
- 然后直接加载当前目录下的core文件 ---> core-file core,按回车;
- 就会给我们显示进程收到的信号,并显示该错误在第几行。
我们的笔记本电脑或者是台式电脑,我们将电源关闭之后,停两天之后,再次打开,会发现我们电脑上的时间依旧是正确的,为什么呢?
我们将电脑的电源断开之后,我们电脑的主板上,还有一个小拇指盖大小的纽扣电池的,这颗电池是一直维护电脑的时间,给主板供电,会一直统计时间,利用时间戳来进行时间的比对。
时间戳:从1970年的1月1日开始,时间呈线性递增的。
五、键盘信号产生
- 按键按下了;
- 哪些键被按下了;
- 字符输入(键盘是字符设备),组合键输入(输入的是命令)。
如何区分键盘输入的是字符,还是命令?
键盘驱动和OS进行联合解释的。
OS怎么知道键盘在输入数据了?
通过一种硬件中断的技术。
- OS在开机的时候,会给我们注册一张中断向量表,其时就是一个函数指针数组,表中提前注册了很多软硬件操作的方法。比如:表中的2号下标对应的可能是从键盘中读取数据的方法,CPU此时可能执行OS的代码,中断向量表也属于OS里的数据;用户在键盘中按了一些键并回车,数据只是硬件层面上输入了,因为冯诺依曼体系,CPU只会与内存直接打交道,CPU内部有很多的针脚,这些针脚是物理性的,在主板上可以直接与键盘连接的,每个针脚都有编号1、2、3...未来在键盘中按键回车的话,会在CPU中对应的针脚触发硬件中断,那么CPU就会知道那个针脚上有高电平,会识别对应的针脚。一个设备与CPU中的哪一个针脚连接是固定的,键盘会向CPU中特定的针脚发送一个高电平,CPU能识别那个针脚有高电平,并把对应的针脚的编号(中断号)存入寄存器中,至此硬件的功能就做完了。
- 此时,CPU直接让OS拿着对应的中断号,去中断向量表中查找对应的方法,根据这个方法去键盘中读取数据到内存中(键盘文件的文件缓冲区里)。
OS不是主动去键盘中读取数据的,而是被动读取的。
通过读取键盘数据的方法可以读取到键盘的数据,之后让OS来进行判定,是字符的话,就拷贝到文件对应的文件级缓冲区中;是控制命令的话,比如:ctrl + c 解释成为2号信号,OS把2号信号给对应的进程,让该进程终止。
六、信号的保存
6.1、信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)【默认、忽略、自定义】
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。--- 忽略已读不会,阻塞根本看不见。
pending位图
0000 0000 0000 0000 0000 0000 0000 000 0
比特位的位置,表示信号编号,比特位的内容,是否收到指定的信号。
block位图
0000 0000 0000 0000 0000 0000 0000 000 0
比特位的位置,表示信号的编号,比特位的内容,是否阻塞该信号。
如果一个信号被阻塞(屏蔽),则该信号永远不会被递达处理,除非解除阻塞。
阻塞一个信号,和是否收到了指定的信号,有关系吗?没关系。
6.2、在内核中的表示
每个进程在自己的PCB内,都维护了三张表:block表、pending表、方法表。
当我们收到了一个指定的信号之后,我们会根据信号的编号,直接去索引函数指针表,就可以找到对应信号的处理方法。
信号在内核中的表示示意图
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前,不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。现在不讨论实时信号。
6.3、sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
6.4、信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印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(const sigset_t *set, int signo);
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
- 这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
// 有没有涉及到将数据设置进内核中呢?没有!!!
// sigset_t是一个OS提供的数据类型(结构体类型),int double float class没有差别
sigset_t s; // 用户栈上开辟了空间
sigemptyset(&s);
sigaddset(&s, 2);// 这里的函数也只是修改了s变量
6.4.1、sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
第一个参how数有3个选择:
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
6.4.2、sigpending
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
6.5、提一个场景
- 屏蔽2号信号;
- 未来我们给目标进程发送2号信号 --- 屏蔽2号信号 --- 2号信号不会被递达 --- 2号信号会一直在pending位图中;
- 获取进程的pending位图;
- 打印所有的pending位图中的信号。
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cassert>
#include <sys/wait.h>
void PrintSig(sigset_t& pending)
{
std::cout << "Pending bitmap: ";
for (int signo = 31; signo > 0; signo--)
{
// 判断指定的信号signo是否在指定的pending信号集中
if (sigismember(&pending, signo))
{
std::cout << "1";// 在
}
else
{
std::cout << "0";// 不在
}
}
std::cout << std::endl;
}
void handler(int signo)
{
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending); // 我正在处理2号信号哦!!
assert(n == 0);
// 3. 打印pending位图中的收到的信号
std::cout << "递达中...: ";
PrintSig(pending); // 0: 递达之前,pending 2号已经被清0. 1: pending 2号被清0一定是递达之后
std::cout << signo << " 号信号被递达处理..." << std::endl;
}
int main()
{
// 对2号信号进行自定义捕捉 --- 不让进程因为2号信号而终止
signal(2, handler);
// 1. 屏蔽2号信号
sigset_t block, oblock;// 设置两个信号集
// block:新的信号集用于添加信号 oblock:回收老的信号集
sigemptyset(&block);
// 因为在栈区中设置的block信号集里面都是随机值,所以要先清空指定的信号集
sigemptyset(&oblock);
sigaddset(&block, 2);
// 将2号信号添加到指定的block信号集中
// 2) 和 SIGINT 是一样的 --- 添加信号的操作根本就没有设置进当前进程的PCB block位图中
// (只是在栈区的变量block位图结构中添加了一个2号比特位为1)
// 0. for test: 如果我屏蔽了所有信号呢???
// for(int signo = 1; signo <= 31; signo++) // 9 和 19) SIGSTOP 信号无法被屏蔽, 18号信号会被做特殊处理
// sigaddset(&block, signo); // SIGINT --- 根本就没有设置进当前进程的PCB block位图中
// 1.1 开始屏蔽2号信号,其实就是设置进入内核中
int n = sigprocmask(SIG_SETMASK, &block, &oblock);
// SIG_SETMASK:重置成新的信号屏蔽集
// 参数3:将原先在 block 位图中的信号屏蔽字放在 oblock 信号集中保存起来,未来恢复可以使用
// 成功返回0,失败返回-1
assert(n == 0); // debug版本是执行的,release版本是直接优化了
// 使用断言证明sigprocmask()函数执行成功
// (void)n; // 骗过编译器,不要告警,因为我们后面用了n,不光光是定义
std::cout << "block 2 signal success" << std::endl;
std::cout << "pid: " << getpid() << std::endl;
int cnt = 0;
// 刚开始获得到的pending位图中的比特位都是0,后来在block位图中添加了一个2号信号,因为2号信号被屏蔽掉了,
// 所以pending位图中的2号比特位始终为1
while (true)
{
// 2. 获取进程的pending位图
sigset_t pending;
sigemptyset(&pending);
n = sigpending(&pending); // 将进程的pending位图通过输出型参数带出来
assert(n == 0);
// 3. 打印pending位图中收到的信号
PrintSig(pending);
cnt++;
// 4. 解除对2号信号的屏蔽
if (cnt == 20)
{
std::cout << "解除对2号信号的屏蔽" << std::endl;
n = sigprocmask(SIG_UNBLOCK, &block, &oblock); // 2号信号会被立即递达, 默认处理是终止进程
assert(n == 0);
}
// 我还想看到pending 2号信号 1->0 : 递达二号信号!
sleep(1);
}
return 0;
}
细节:
- 递达信号的时候,就一定会把对应的pending位图清零;
- 先清零,再递达,还是先递达,再清零?是先清零,再递达。
七、信号的处理
7.1、信号什么时候被处理?
进程从内核态,切换回用户态的时候,信号会被检测并处理。
我们写代码的时候,会直接或间接的使用过系统调用,那么进程陷入内核,OS执行系统调用,结果给用户。
- 比如:OS执行read()系统调用,根据文件描述符表找到对应的文件级缓冲区,将文件缓冲区中对应的数据拷贝到用户级缓冲区buffer中,此时在OS执行系统调用的状态叫内核态。
- 完全运行自己编译的代码,就叫用户态。
我自己写的代码里没有任何的系统调用,此时属于用户态,
但是进程是会被调度的,因为进程是有时间片的,那么当时间到了之后,进程就得把CPU资源出让出来,让OS进行一个对应的调度运行,此时出让CPU的过程,就是让进程从用户态陷入到内核态,OS执行它的调度算法,当进程在被唤醒到CPU上时,进程就从内核态切换到用户态,继续让进程运行起来,所以说即使代码中没有系统调用,进程依然有大量的内核态和用户态之间的切换。
7.2、信号捕捉
在信号处理的过程(捕捉)中,一共会有4次状态切换(内核态和用户态)。
以前我们系统调用,都是执行OS给我们提供的方法;
今天能不能不做状态变化了,让OS执行用户的自定义方法呢?(不改变进程的内核态)
从技术角度:OS以内核态的身份,想执行你自定义写的代码,是可以做到的,OS的权限很高,OS能检测进程内核级的数据结构,内核的数据结构中的handler表中就包含了自定义方法的地址;
可以为什么不让内核态执行自定义的方法呢?
自定义的方法是用户写的,OS是不相信任何人的,直接表现就是:不让任何人直接访问内核的数据结构,只能让用户通过系统调用来访问,同时OS也不会以内核态的身份去执行你写的自定义的代码,万一用户所写的信号捕捉的方法是越权的非法操作呢?所以让用户态和内核态各管各的。
信号 --- 杀掉这个进程 --- SIG_DFL (大部分的信号都是杀掉进程的,还有一些是暂停和忽略进程)
通过信号杀掉进程,为什么不直接杀掉这个进程,还要向进程的pending位图里对应位置写一个1,在合适的时候,由进程自己来决定要不要退出?
退出一个进程时,这个进程可能在做更重要的事情,直接杀掉进程可能会导致一些未定义的错误。
7.3、用户态和内核态 --- 进程地址空间
OS也是软件,是第一个被加载到物理空间中的,被加载的OS是通过内核级页表映射到进程地址空间的[3,4]G的内核空间中。所以之前访问的内核数据结构都是通过内核级页表的映射关系来进行查找的。
OS内部的系统调用本质其实是一个函数指针数组。数组中的每一个元素都指向系统调用。数组的下标称为系统调用号。
执行一个系统调用:
- 在OS里找到函数指针数组表(系统调用表);
- 根据下标找到系统调用(下标在内核当中叫做系统调用号)。
比如:在我们的正文代码当中,调用了系统调用,将来在跳转时,拿着系统调用的编号以及函数指针数组表的起始虚拟地址,就可以跳转到OS内部,在系统调用表中索引,找到对应的系统调用,让OS执行对应的方法,执行完毕之后,再返回正文代码部分,这就完成了一个系统调用了。
OS内会有多个进程,每个进程都有自己的PCB、进程地址空间、代码、数据和用户级页表,是互不相关的;但是OS的代码和数据只有一份,所以对于不同的进程,通过进程地址空间中的内核空间可以访问同一份的内核级页表,使用同一份OS的代码和数据。
结论:
- 任何一个进程都可以进行系统调用。
- 进程无论如何切换,总能找到OS。我们访问OS,本质就是通过我的进程虚拟地址空间的[3,4]GB来访问即可。
进程是有时间片的,到一定时间之后,进程将出让CPU资源,CPU怎么出让资源的呢?
找到OS,执行OS内相关的代码,把CPU里的上下文数据保存到该进程的PCB中,并把该进程状态设置为S,放到其它的队列里,再让CPU执行新的进程。
7.4、操作系统是如何正常运行的
信号技术本来就是通过软件的方式,来模拟的硬件中断。
谁让OS运行起来的呢?
硬件非常高频率的,每隔非常短的时间,就给CPU发送中断 --- CPU不断地进行处理中断。
中断来了,OS就会把当前进程停下来,然后通过当前的进程地址空间,找到物理内存中OS提供的中断向量表,OS根据中断号找到执行方法,调度该方法。我们把非常高频率的给CPU发送中断,成为OS的周期时钟中断。
操作系统是一个死循环,不断的在接受外部的其它硬件中断。
CPU中的CR3寄存器指向整个当前任何一个进程对应的页表,CPU中的CS寄存器中的低两位当中有两个比特位,这两个比特位我们称之为权限标识位,两个比特位为0,表示:内核态;为3,表示:用户态。CPU加系统整体会为我们标识当前进程是处于用户态,还是内核态,有了标识,当我们执行系统调用跳转的时候,OS会对当前的用户身份进行审核,如果是0,就允许访问OS的代码和数据,反之,则不能。
写实拷贝只会发生在进程地址空间中的用户空间中的。
7.5、内核如何实现信号的捕捉
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:用户程序注册了SIGQUIT信号的处理函数sighandler。当前正在执行 main函数,这时发生中断或异常切换到内核态。在中断处理完毕后要返回用户态的main函数之前检查到有信号 SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler 和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。如果没有新的信号要递达,这次再返回用户态就是恢复 main函数的上下文继续执行了。
7.6、捕捉信号的其它方式:sigaction
#include <signal.h>
int sigaction(int signo, const struct sigaction* act, struct sigaction* oact);
- 对指定的信号,进行自定义捕捉
- 参数2:输入参数,设置对信号新的的捕捉方法
- 参数3:输出参数,返回老的对信号捕捉的方法
- sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo 是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体。
- 将sa_handler(sigaction结构体中处理信号的方法)赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask(sigaction结构体中的信号集,信号集的是类型sigset_t)字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。
如何证明某个信号的的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字?
void Print(sigset_t &pending)
{
std::cout << "curr process pending: ";
for(int sig = 31; sig >= 1; sig--)
{
// 判断指定的信号sig是否在指定的pending信号集中
if(sigismember(&pending, sig)) std::cout << "1";
else std::cout << "0";
}
std::cout << "\n";
}
// 自定义类型的方法
void handler(int signo)
{
std::cout << "signal : " << signo << std::endl;
// 不断获取当前进程的pending信号集合并打印
sigset_t pending;
sigemptyset(&pending);// 对pending位图清空,所以打印出来的都是0(原来打印出来的2号比特位为1)
while(true)
{
// 获取当前进程的pending信号集
sigpending(&pending);
Print(pending);
sleep(1);
}
}
int main()
{
struct sigaction act, oact; // 创建两个结构体对象
act.sa_handler = handler; // 你要执行的自定义类型的方法
act.sa_flags = 0;
sigemptyset(&act.sa_mask); // 对指定的信号集清空
sigaddset(&act.sa_mask, 3); // sa_mask信号集:默认处理一个信号时,当前信号也会自动屏蔽
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
// 在指定的信号集sa_mask中添加3、4、5号信号,在处理2号信号时,就会把2、3、4、5信号都屏蔽掉
sigaction(2, &act, &oact); // 用act结构体中的handler()方法对2号信号进行捕捉
// 修改的是进程当中的handler()表,拿着2号作为数组下标,
// 直接把你对应的handler方法设置进2号下标对应的函数指针数组当中,就可以对2号信号进行自定义的捕捉
while(true) sleep(1);
return 0;
}
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,如果我们处理完对应的信号,该信号默认也会从信号屏蔽字中移除。是为了不想让信号,嵌套式进行插足处理。
比如:我正在处理2号信号,2号信号的处理方法特别长,2号的处理方法里面也有系统调用,陷入内核,再检测,那么在处理2号信号期间,不断有大量的2号信号发来,呈递归式的处理,OS不允许这样的事情发生(可能会发生栈溢出);所以,在处理2号信号时,将2号信号屏蔽掉,那么在处理2号期间,就不会再有2号信号递达。这样的话,对2号信号的处理就变成了串行处理。
八、可重入函数
- insert()函数被main()函数执行流和捕捉信号的执行流重复进入了,导致了节点2丢失的问题,所以insert()函数我们叫做不可重入函数;
- 否则,被重复进入时,但是没有出现问题,那么该函数叫做可重入函数。
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从 sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
九、volatile
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
// volatile int g_flag = 0;
int g_flag = 0; // 任何变量的定义都在内存当中的
void changeflag(int signo)
{
// 我们不想使用signo这个变量,但是不使用编译器会报错,所以可以写成下面这个形式,用来骗过编译器。
(void)signo;
printf("将g_flag,从%d->%d\n", g_flag, 1);
g_flag = 1;// 修改的是内存当中的值
}
int main()
{
signal(2, changeflag);
// 本来g_flag为0,取反后为1;现在g_flag为1,取反后为0,可以退出循环
// 我们读取g_flag时,不想再从内存当中读取了;
// 我们把g_flag放在寄存器当中,每次进行while()循环判断时,就直接从寄存器中读取,不会再重复的在内存中读取数据了;
// 所以编译器会做优化,一旦进行优化,可能会产生意料之外的变化
while (!g_flag); // 故意写成这个样子, 编译器默认会对我们的代码进行自动优化!
// {
// printf("hello world\n");
// sleep(1);
// }
printf("process quit normal\n");
return 0;
}
- 编译器在编译的时候,会发现,当前的主执行流当中,并没有代码改这个g_flag变量,所以编译器觉得每次都要从内存里读取,还有进行访存,太麻烦了,所以就将g_flag默认放在寄存器中,while循环未来再进行检测的时候,直接检测该寄存器内部的值,这样也就不用每次再进行对应的访存了,能提高代码运行的效率。
- 当修改了内存当中的g_flag值,可是while循环读检测的永远都是寄存器当中的值,所以while循环不访存了,直接读寄存器的这种情况,叫做寄存器屏蔽了内存。这样的优化代码,影响了我们的代码逻辑。
那么如何解决这种问题呢?
采用volatile关键字,volatile修饰g_flag全局变量,作用:编译器不管怎么优化,都禁止编译器把g_flag优化到寄存器里,往后检测和访问g_flag只能从内存中读取数据了,不能从CPU的寄存器中读取,这叫做保持内存的可见性。
man gcc
// 查看gcc中有很多的优化级别
// 比如:gcc test.c -O1 编译器对test.c代码进行O1级别的优化
十、SIGCHLD信号 - 选学了解
子进程退出,父进程不wait,子进程就会僵尸。子进程退出,不是默默的退出的。会在退出的时候,向父进程发送信号的,发送SIGCHLD信号,17) SIGCHLD。
如何证明?
void handler(int signo)
{
std::cout << "child quit, father get a signo: " << signo << std::endl;
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 5;
while (cnt--)
{
std::cout << "I am child process: " << getpid() << std::endl;
sleep(1);
}
std::cout << "child process died" << std::endl;
exit(0);
}
// father
while (true)
sleep(1);
}
用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号 的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
编写一个程序完成以下功能:父进程fork出子进程,父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。
// 清理子进程
void CleanupChild(int signo)
{
// v1版本
if (signo == SIGCHLD)
{
// -1:等待任意一个子进程
pid_t rid = waitpid(-1, nullptr, 0);
if (rid > 0)
{
std::cout << "wait child success: " << rid << std::endl;
}
}
std::cout << "wait sub process done" << std::endl;
// v2版本
if (signo == SIGCHLD)
{
// 回收100个子进程
while (true)
{
pid_t rid = waitpid(-1, nullptr, 0); // -1 : 回收任意一个子进程
if (rid > 0)
{
std::cout << "wait child success: " << rid << std::endl;
}
else if(rid <= 0) break;
}
}
std::cout << "wait sub process done" << std::endl;
// v3版本
if (signo == SIGCHLD)
{
while (true)
{
// WNOHANG:非阻塞等待
pid_t rid = waitpid(-1, nullptr, WNOHANG); // -1 : 回收任意一个子进程
if (rid > 0)
{
std::cout << "wait child success: " << rid << std::endl;
}
else if (rid <= 0)
break;
}
}
std::cout << "wait sub process done" << std::endl;
}
// 假如创建了10个子进程,10个子进程统一全部退出,就意味着10个子进程都会给父进程发送SIGCHLD,
// pending位图每次只会记录一个SIGCHLD,在处理期间,不会管其它的子进程,因此大多子进程的SIGCHLD信号都丢失了。
int main()
{
// 方法一:V1版本、V2版本、V3版本
signal(SIGCHLD, CleanupChild);
// signal(SIGCHLD, SIG_IGN);// 方法2:SIGCHLD的处理动作置为SIG_IGN,子进程退出,系统就自动把子进程回收
// 50个退出,50个没有
for (int i = 0; i < 100; i++)
{
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 5;
while (cnt--)
{
std::cout << "I am child process: " << getpid() << std::endl;
sleep(1);
}
std::cout << "child process died" << std::endl;
exit(0);
}
}
// father
while (true)
sleep(1);
}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证 在其它UNIX系统上都可用。上面的方法2就是此法。
总结
好了,本篇博客到这里就结束了,如果有更好的观点,请及时留言,我会认真观看并学习。
不积硅步,无以至千里;不积小流,无以成江海。