🌎 Linux进程信号详【下】
文章目录:
Linux信号详解
核心转储
信号保存
信号的三种状态
信号集操作函数
sigset_t类型接口
sigprocmask接口
sigpending接口
使用场景及理解
信号处理
信号处理时间
信号处理流程
捕捉信号的其他方式
可重入函数
volatile关键字
SIGCHLD信号
🚀核心转储
在进程等待这一章节,有一张图我没有详细解释:
当时在 进程等待 这一章节里我们并没有详细说明 Core dump标志,而我们通过man手册查看signal,会发现大部分的信号的作用都是 终止进程,而终止进程的动作却又分为 Core 和 Term(termination) 两个动作。
那么它们两个有什么区别呢?实际上,在云服务器上默认将进程core退出,进行了特殊的设定,默认core是关闭的。
查看core功能
通过使用 ulimit -a
命令查看系统中的core 文件打开情况:
打开core功能
要打开core功能使用 ulimit -c core_size
命令打开core dump,其中 core_size 表示指定core文件大小:
这个时候就开起了Linux的 Core dump 功能。当没有开起core dump功能时运行下面代码,会正常给出报错:
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
int a = 10;
a /= 0;// SIGFPE信号
while(true) sleep(1);
return 0;
}
未打开core dump功能时,正常报错,当打开core dump时:
使用了core dump报错则会生成一个 core 文件,当然这个文件在不同的系统表现形式可能不同,在 ubuntu 下文件名为 core
。而在 centos 下文件名为 core.pid
后面跟一串数字,这串数字是报错进程的进程pid。
core文件的内容的实际上是 将进程在内存中保存的核心数据(与调试有关)转储到磁盘中形成的core文件【core dump:核心转储】。这样,当进程退出的时候我们就可以通过core定位到进程为什么退出,以及执行到哪步代码退出的。所以,core文件的作用就是帮助我们调试。
core文件可辅助调试,比如还拿上面那段除零错误代码,并且打开核心转储,生成core文件,进入gdb,使用core-file core
命令,即可查看进程出错原因:
这种辅助调试被称为 事后调试方案,我们使用man 手册查看的signal手册中的所有信号只要执行动作为core都可以打开core dump进行事后调试。
而我们云服务器中核心转储功能是默认关闭的,是为了防止未知的core dump一直进行,不断生成core文件,从而使服务器资源被占满。把core的大小设置为0即可关闭core dump功能。如果用户打开该功能忘记关闭了其实也不用太过担心,因为重启时core dump会默认关闭。
🚀信号保存
✈️信号的三种状态
- 实际执行信号的处理动作称为 信号递达(Delivery)
- 信号从产生到递达之间的状态,称为 信号未决(Pending)。
- 进程可以选择 阻塞 (Block ) 某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
其中信号递达有三个处理动作(默认执行、忽略、自定义),这个在【上】中提到过。信号未决表示信号已经写入到进程当中,但是并未处理。信号阻塞 也叫做 信号屏蔽,跟pending位图一样,会提供一个带有屏蔽数的位图,当屏蔽比特位为1则表示信号屏蔽。
在上一篇说过,信号写入的位置在进程中,所以pending位图和block位图也都在task_struct中:
task_struct
{
unsigned int pending;//未决位图
unsigned int block;//阻塞位图
//...
}
那么,如果一个信号被阻塞(屏蔽),那么这个信号将永远不会被递达,除非解除阻塞。
阻塞和忽略的区别:
忽略是信号递达的一种执行动作,阻塞仅仅是不让对应的信号进行递达。形象一点理解:忽略是boss上已读不回,阻塞是根本就没看你的简历。
经过上面的学习,我们知道task_struct 中有两张位图表,实际上在task_struct 中还有一张表:
前31个信号则有31种默认处理方法,而这些默认处理方法则是调用对应的函数接口,图中SIG_DFL以及SIG_IGN等宏,都为函数指针数组的数组下标。
- 每个信号都有两个标志位分别表示 阻塞(block) 和 未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作即SIG_DFL。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此, 未决和阻塞标志可以用相同的 数据类型 sigset_t 来存储,sigset_t称为信号集,这个类型可以 表示每个信号的 “有效” 或 “无效” 状态,在 阻塞信号集 中 “有效” 和 “无效” 的含义是 该信号是否被阻塞,而在 未决信号集 中 “有效” 和 “无效” 的含义是 该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
✈️信号集操作函数
🚩sigset_t类型接口
有了sigset_t类型我是是否可以直接操作进程中的信号位图呢?答案是否定的,其属于内核数据结构,并不会让用户直接访问,为了支持用户访问一些位图结构,操作系统给我们提供了系统调用接口。
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。
🚩sigprocmask接口
sigprocmask函数原型:
int sigpricmask(int how, const sigset_t *set, sigset_t *oset);
- 作用:可读取或更改进程的信号屏蔽字(阻塞信号集)。
- 返回值:若成功则为0,若出错则为-1。
- set与oset指针:如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
- how参数:用来指示更改或读取进程信号屏蔽字的动作,通常使用以下几种选项:
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
🚩sigpending接口
函数原型:
int sigpending(sigset_t *set);//set为输出型参数
- 作用:获取当前进程的pending位图。
- 返回值:成功返回0,否则返回-1。
- set参数:读取当前进程的未决信号,通过set传出给用户。
🚩使用场景及理解
通过前面的学习,我们知道了信号的写入是在进程当中的,task_struct 中有三张表,分别是 未决位图,阻塞位图,对应信号的默认处理动作。我们之前也学习了 signal
接口,可以自定义信号捕捉动作,对应第三张表。而sigprocmask和sigpending接口分别对应第二和第一张表:
三个接口分别控制三张表,为了更好理解这些接口,准备应用以下场景:
1、屏蔽2号信号。2、给目标进程发送2号信号。3、获取pending位图。4、打印所有pending位图中的信号。
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <assert.h>
#include <signal.h>
void PrintSig(sigset_t &pending)
{
std::cout << "Pending bitmap:";
for(int signo = 31; signo > 0; signo--)
{
if(sigismember(&pending, signo))//检测pending位图中的信号是否存在
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
//1.屏蔽2号信号
sigset_t block, oblock;//栈上定义的变量可能为随机值
sigemptyset(&block);// 清空信号集
sigemptyset(&oblock);
sigaddset(&block, 2);// 此时并未将2号信号写入到pcb block位图中,而是在栈中
// 开始屏蔽2号信号,即设置进入内核中
int x = sigprocmask(SIG_SETMASK, &block, &oblock);
(void)x;// 取消无返回值接收报警
std::cout << "block 2 signal sucess" << std::endl;
while(true)
{
//2.获取进程的pending位图
sigset_t pending;
sigemptyset(&pending);
x = sigpending(&pending);
assert(x == 0);
//3.打印pending位图中收到信号
PrintSig(pending);
sleep(1);
}
return 0;
}
注意:有一些信号是不能被用户屏蔽的,9号信号 和 19号信号 时无法被屏蔽的,而18号信号会做出特殊处理,如果手动屏蔽 18号信号 可能会释放出其他被屏蔽信号。
在前面代码的基础上,我们想要将2号信号最后递达处理:
#include <iostream>
#include <unistd.h>
#include <sys/type.h>
#include <sys/wait.h>
#include <assert.h>
#include <signal.h>
void PrintSig(sigset_t &pending)
{
std::cout << "Pending bitmap:";
for(int signo = 31; signo > 0; signo--)
{
if(sigismember(&pending, signo))//检测pending位图中的信号是否存在
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
//1.屏蔽2号信号
sigset_t block, oblock;//栈上定义的变量可能为随机值
sigemptyset(&block);// 清空信号集
sigemptyset(&oblock);
sigaddset(&block, 2);// 此时并未将2号信号写入到pcb block位图中,而是在栈中
std::cout << "proc pid: " << getpid() << std::endl;
// 开始屏蔽2号信号,即设置进入内核中
int x = sigprocmask(SIG_SETMASK, &block, &oblock);
(void)x;// 取消无返回值接收报警
std::cout << "block 2 signal sucess..." << std::endl;
int cnt = 0;
while(true)
{
//2.获取进程的pending位图
sigset_t pending;
sigemptyset(&pending);
x = sigpending(&pending);
assert(x == 0);
//3.打印pending位图中收到信号
PrintSig(pending);
cnt++;
//4.解除2号信号的屏蔽
if(cnt == 10)
{
std::cout << "unblock 2 signal sucess..." << std::endl;
x = sigprocmask(SIG_UNBLOCK, &block, &oblock);//解除屏蔽,2号信号则会立刻递达(执行)
assert(n == 0);
}
sleep(1);
}
return 0;
}
10次以后,解除block位图,pending位图中2号信号立即开始递达,进程立马终止。为了不让进程立马终止,我们对2号信号进行自定义捕捉:
这个时候的动作就变为了自定义捕捉,并且在信号解除阻塞时,pending位图会立马清零,然后再进行递达处理。
🚀信号处理
✈️信号处理时间
我们知道,用户设置的信号需要再内核中进行处理,而信号的处理时间是在 进程从内核态切换回用户态 时被处理。
我们信号处理一般遵循下面这张表:
单看这张图可能你一时半会不能很好理解,我以一个系统调用为例解释说明:
一个程序在正常的执行自己的代码,但是突然收到一个系统调用,这时就会陷入内核执行系统调用,而执行完系统调用时并不会立即返回用户态,而是对block bitmap和pending bitmap进行遍历检测:
如果没有信号则返回用户态。如果信号为忽略或者默认执行,那么无外乎终止或者暂停信号,则把进程杀死或者将进程的状态设置为暂停状态,并且放入等待队列中。
如果信号为自定义捕捉,那么在内核中检测到信号需要自定义捕捉,则会切换回用户态执行捕捉函数,但是这时并不会在用户态就结束了。而是返回内核态从上次被中断的地方继续向下执行,最后再返回用户态。
我们都知道,内核中拥有进程的代码和数据,那么这时你可能就会有疑问了:既然进程拥有我们的代码,为何还要从内核态转换为用户态再执行自定义捕捉函数呢??
实际上,因为自定义捕捉是由用户来写的,而内核并不知道你这个用户究竟是不是病毒,会不会危害OS的安全,所以对用户默认是有害的,这样,不在内核中执行自定义捕捉,到用户态执行,就算崩溃了也会减少对操作系统的影响。
那么信号的捕捉,可以简化为下面这张图:
✈️信号处理流程
为了更好的理解信号在操作系统中从产生到执行的过程,我们有必要深入理解 用户态 和 内核态 这两个概念。
要理解信号的处理流程,还得从进程地址空间说起,在操作系统中,进程地址空间分为用户空间([0-3GB])和 内核空间([3-4GB])。我们知道,电脑开机时最先加载到物理内存的软件是操作系统,而进程要与操作系统产生联系,这就需要用到进程的内核空间,内核空间和操作系统由 内核级页表 进行映射。
操作系统中页表可分为 用户级页表 和 内核级页表,在此之前我们所提到的页表皆是用户级页表,内核级页表用来映射OS和进程的,这样进程就可以调用操作系统的系统调用。注意,这两个页表其实是一个页表,只不过是根据其特性进行的划分。
而 系统调用的本质是 函数指针数组。而我们把这个 数组的下标 称为 系统调用号,我们使用系统调用或者访问系统数据,其实还是在进程地址空间内跳转的。
而操作系统中存在许多进程,而每个进程都有自己的代码和数据,所以每个进程都拥有自己的用户级页表。而操作系统对进程来说只有一份,所以 操作系统中内核级页表也只有一个。也就是说,每个进程的地址空间0-3GB(用户级)都不一样,3-4GB(内核级)都一样,所以每个进程都可以调用系统调用。
以上的过程意味着,在操作系统当中,无论进程如何切换,总是能找到操作系统。所以我们所访问操作系统,其实是通过进程地址空间的3-4GB来访问OS的。
那么操作系统又是如何运行的呢?我们前面说过,硬件中断的问题,键盘通过硬件中断被CPU的针脚识别从而调用中断向量表对应的中断方法,不过这是硬件层面。而Linux信号技术,本身就是 通过软件的方式来模拟硬件中断。
而在OS中,每隔非常短的时间,就给CPU发送中断,CPU就需要通过中断向量表不断的处理中断,这种高频间隔中断被称为 操作系统的周期时钟中断。而操作系统就是一个死循环,在不断接收外部的其他硬件中断。
而我们所用的系统调用实际上也被封装过,比如我们调用read接口,则会把 系统调用号 保存到寄存器里,然后陷入内核,根据read中保存的中断方法地址,从而去执行对应的方法。说白了就是通过数组下标 调用数组元素。
还有一个至关重要的问题,既然进程地址空间中3-4GB的空间可以直接访问OS,那么为什么我们还需要陷入内核调用呢?前面我们说过,操作系统对用户默认是不信任的,如果用户写了一段病毒,来访问3-4GB的地址空间,那不就危险了?所以,OS就必须要区分当前用户的运行模式,也就是 用户态 和 内核态。
在CPU中有一个寄存器叫做 CS寄存器,CS寄存器最后两位比特位表示 进程的权限标志位,为0表示内核态,为3表示用户态。当然具体的情况要很复杂,如果感兴趣还请自行查阅资料。
✈️捕捉信号的其他方式
除了signal 自定义捕捉以外,Linux还提供了一种其他自定义捕捉方法:sigaction函数:
- 功能:sigaction函数可以读取和修改与指定信号相关联的处理动作。
- 返回值:调用成功则返回0,出错则返回- 1。
- signo参数:signo是指定信号的编号。
- act 和 oact参数:若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体。
其中结构体中第二个成员处理的是实时信号,不需要管,第四个成员设置为0即可,第五个成员也不需要管。所以我们使用这个结构体只需要把第一个参数与第三个参数设置好即可。
为什么会有mask参数?实际上,当某个信号的处理函数被调用时,内核自动将当前信号加入到进程的信号屏蔽字。而当我们处理完信号之后,该信号也会从阻塞状态解除。OS这么做的目的是禁止一个信号被嵌套捕捉,只允许一个信号进行串行处理。我们做个实验验证一下:
#include <iostream>
#include <signal.h>
#include <unistd.h>
void Print(sigset_t &pending)
{
std::cout << "curr process pending: ";
for(int sig = 31; sig >= 1; sig--)
{
if(sigismember(&pending, sig)) std::cout << "1";
else std::cout << "0";
}
std::cout << std::endl;
}
void handler(int signo)
{
std::cout << "signo: " << signo << std::endl;
//不断获取当前进程pending信号集合
sigset_t pending;
sigemptyset(&pending);
while(1)
{
sigpending(&pending);
Print(pending);
}
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);// 有什么用?
sigaction(2, &act, &oact);
while(1) sleep(1);
return 0;
}
我们不断对2号信号进行自定义捕捉并且无间断的执行,这时我们无论怎么按Ctrl-C 都毫无相应,因为此时当前进程正在处理2号信号,2号信号被屏蔽,故别的进程无法使用2号信号。
上面代码中还有一个疑问的点,sigaction函数照这样看来不是和signal函数没两样吗?为什会更复杂?实际上,sa_mask参数可以额外屏蔽其他信号。使用时可将需要额外屏蔽的信号设置到函数当中。
🚀可重入函数
可重入函数与链表相关,如果数据结构还没学过的建议看一看链表。这里只是简单认识一下,具体过程将会在线程篇详细解读。
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
简单来说,就是在head节点后插入一个新节点,但是在插入过程中需要从用户态转内核态,而前面说了,进程在内核态的时候会顺便检查信号,这时刚好收到信号,执行自定义捕捉,而自定义捕捉也是在head后插入一个节点。handler完成后,main函数依旧在刚才插入那步,最后head = p,使得头结点指向第一个被插入的节点,而自定义捕捉方法内插入的节点就会丢失。
- 像上例这样,insert函数被不同的控制流程调用, 有可能在 第一次调用还没返回时就再次进入该函数,这称为 重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为 可重入(Reentrant) 函数。
🚀volatile关键字
如果你学过C语言或者C++,那么你一定听说过volatile关键字,但是你可能并不能记得它的具体作用。
#include <stdio.h>
#include <signal.h>
int g_flag = 0;
void changeflag()
{
g_flag = 1;
printf("将g_flag, 从%d->%d\n", g_flag, 1);
}
int main()
{
signal(2, changeflag);
while(!g_flag);// 有时候编译器会对其进行自动优化,全局变量原因
printf("process quit normal!\n");
return 0;
}
编译时带上 -O
选项,表示优化程度,其中gcc编译器分为4个优化级别,分别是 O0, O1, O2, O3
其中 O0 表示编译时不带任何优化。
现在的编译器可能会对一些地方进行优化,但是有时候我们并不想让其被优化,比如全局变量g_flag,现代编译器,为了优化代码,因为编译器认为全局变量访问概率大,大概率会把g_flag放置到寄存器当中,每次需要访问g_flag时,只需要从寄存器内取即可,但是今天我们需要修改g_flag的值,修改的却是内存中的g_flag的值,而保存在寄存器中的g_flag却不曾改变。
我们对全局变量使用了volatile关键字,这样,无论编译器怎么优化,都不会影响g_flag的值了:
这样,在怎么优化,都不会把我们预期动作改变了。
🚀SIGCHLD信号
我们在学习进程的时候曾经说过,僵尸进程出现的原因是父进程没有回收子进程,实际上 子进程在终止时会发送 SIGCHLD 信号给父进程,而该 信号的默认处理动作是忽略,父进程可以对该信号进行自定义捕捉,这样就没必要浪费资源对子进程进行等待了。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
void handler(int signo)
{
// v1
if(signo == SIGCHLD)
{
pid_t rid = waitpid(-1, nullptr, 0);
if(rid > 0)
{
std::cout << "wait child sucess: " << rid << std::endl;
}
}
std::cout << "wait child sucess done" << 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(1) sleep(1);
return 0;
}
我们对SIGCHLD信号做了捕捉回调,一旦子进程退出就回收子进程。
这样,通过信号处理就不需要父进程在将资源用在监视子进程是否退出这件事上。但是这种代码却是一种错误的代码。
我们说过,pending位图如果收到同一个信号多次,只会记录一次,那么如果有个场景是多个子进程在同时运行,最后子进程都结束了,发送了多次的SIGCHLD信号,但是pending位图只记录一次,所以这个时候我们只能处理一个子进程,剩下的子进程会变为僵尸。
把子进程回收改为如上图所示,就解决了所有问题,无论是100个子进程退出,还是100个只有50个子进程退出,这样就都可正常将子进程回收了。
文章到此结束,如果这篇文章对您有帮助的话还望三连~~