信号
认识信号
什么是信号
信号本质上是一种软件中断,用于通知进程发生了特定的事件。进程接收到信号后,会根据信号的类型采取相应的操作。
拿生活中的红绿灯来举例,当你看到红灯的时候你不会过马路,当变为绿灯时才会通过。但是也有可能在等红灯的时候,此时绿灯亮了而你正在打游戏,游戏正处于决胜时刻,这时候你不会选择立即过马路,而是等这局结束再通过。也就是说绿灯亮了就过马路这个行为并不是立即就要执行,而是会在一个合适的时候去执行这个动作。在接收到绿灯这个信号,和执行过马路动作这个期间就有一个时间窗口,在这段时间内你并没有过马路,但是你知道你已经可以通过了。这本质就是你“记住了绿灯已经亮了”。当游戏结束后,你就可以处理绿灯这个信号,这时候我们可以有三种处理方式:1. 默认(通过马路)。2. 忽略(游戏输了很生气,继续开一把游戏)。3. 自定义(来一段舞蹈)。
在进程中,对于信号的处理方式是一样的。当信号来的时候,进程可能在执行更重要的代码,对这个信号不一定会立即处理,但是会在自己的pcb
中保存这个信号,等到合适的时候处理这个信号。
信号的分类
信号分为普通信号和实时信号,我们主要研究的是普通信号。在Linux中[1,31]号信号是普通信号,[34,64]号是实时信号。在命令行中使用kill -l
命令查看。
信号产生
在理解信号产生之前,我们先来看一个系统调用接口signal()
,它用于捕捉信号,设置信号处理方式。
当然,并不是所有信号都能被捕捉。比如说9号信号(SIGKILL),SIGKILL
信号的主要设计目的是用于无条件地终止一个进程。若允许进程捕捉 SIGKILL
信号,一个恶意进程或者出现错误的进程可以在接收到 SIGKILL
信号后,通过自定义的信号处理程序来阻止自身被终止,从而继续占用系统资源或执行一些非法操作。不允许捕捉 SIGKILL
信号,可以确保系统始终保留对进程生死的最终控制权。
函数原型:
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数:
signum
:指定要设置处理方式的信号编号。handler
:指定信号的处理方式,它是一个函数指针,指向一个具有void (*)(int)
类型的函数,即该函数接受一个int
类型的参数(通常就是信号编号signum
),并且没有返回值。有以下几种取值方式:- 自定义处理函数:当传入一个自定义的函数指针时,进程在接收到指定的信号
signum
后,将调用这个自定义的函数来处理信号。 SIG_IGN
:表示忽略指定的信号。不过,有一些信号是不能被忽略的,如SIGKILL
和SIGSTOP
。SIG_DFL
:表示采用信号的默认处理方式。
- 自定义处理函数:当传入一个自定义的函数指针时,进程在接收到指定的信号
返回值:成功返回一个函数指针,指向之前该信号的处理函数;失败返回SIG_ERR
。
信号通常可以通过以下四种方式产生。
键盘发送
在命令行中,我们通常使用ctrl + c
这种快捷方式结束进程。操作系统将我们这个操作识别为2号信号,从而帮我们执行对应的操作。
void handler(int signo)
{
std::cout << "获取到一个信号,编号是: " << signo << std::endl;
}
int main()
{
pid_t pid = getpid();
signal(2, handler);//捕捉2号信号,并重新设置信号的处理方法
while (true)
{
std::cout << "我是一个进程,pid是: " << pid << std::endl;
sleep(1);
}
return 0;
}
在这个例子中,我们对2号信号捕捉,当我们使用ctrl+c
这个快捷键时,他就会执行我们设定的方法。
系统调用
-
kill()
函数原型:
int kill(pid_t pid, int signum);
,表示给任意进程发送任意信号,成功返回0,失败返回-1。 -
raise()
函数原型:
int raise(int signum);
,给自己发送任意信号,成功返回0,失败返回非零值。 -
abort()
函数原型:
void abort();
,给自己发送SIGABRT
信号。
从上面不难发现,通过kill()
这个函数就能实现raise()
和abort()
。
硬件异常
只是举例子,不代表只有这几个。
-
除0错误
int main() { int a = 10; a /= 0; return 0; }
当我们运行上面的程序的时候,会报出
Floating point exception
,这其实就是八号信号。如何证明呢?我们设置对应的捕捉方法,然后再运行,如下:void handler(int signo) { std::cout << "获取到一个信号,编号是: " << signo << std::endl; } int main() { signal(8, handler); int a = 10; a /= 0; return 0; }
到这里又有一个疑问了,设置了自定义的处理方法后,为什么会疯狂的进行输出呢?明明我只执行了一次除零的动作啊。这是因为CPU中有一套寄存器(寄存器中的内容属于当前进程的上下文),其中有一个叫状态寄存器,当发生除零错误的时候,状态寄存器的溢出标志位将会改变(假设是由0变为1)。而我们并没有对这个改变做出修正,每当进程发生切换的时候,就有无数次状态寄存器被保存和回复的过程,所以每一次恢复的时候,就让操作系统识别到了CPU内部的状态寄存器中的溢出标志位是1,所以才会不断地输出。
-
野指针
同样会被操作系统发送信号终止,对应的信号是11号信号。
软件条件
-
管道读端关闭
当读端关闭的时候,操作系统会发信号(SIGPIPE)关闭写端。(管道链接)
-
定时器
可以通过
alarm()
系统调用来给进程设置定时器,当时间到了之后会向调用进程发送SIGALRM
信号。函数原型:
unsigned int alarm(unsigned int seconds);
int main() { pid_t pid = getpid(); alarm(5);//5秒后发送信号 while (true) { std::cout << "我是一个进程,pid是: " << pid << std::endl; sleep(1); } }
信号保存
一个信号是发给进程的,那么在进程的pcb
(task_struct{};
结构体)中一定保存了该信号。那么它是如何保存的呢?在task_struct
中有两个位图和一个函数指针数组,通过这三个结构就能很好的对信号保存和处理。如下图:
信号处理
相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到抵达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
进程如何执行内核级的代码
在进程地址空间(Linux]进程地址空间 - 羡鱼OvO - 博客园)中说过,每个进程都有一个虚拟的地址空间,这个空间被划分为不同的区域,其中3~4G就属于内核空间。用户空间通过用户级页表映射找到对应的物理内存,这张页表是独立的,每个进程都有属于自己的用户级页表;而内核空间同样是通过页表映射的方式找到对应的物理内存,但是这张内核级页表是共享的,只有一张。
在CPU的寄存器中有一个叫做CR3
的寄存器,它表征的是当前进程的运行级别。当进程在执行自己的代码的时候,此时处于用户态;一旦进程遇到了系统调用接口,此时的状态就被切换为内核态。而又由于每个进程都有一个虚拟地址空间,当进程切换为内核态执行内核级别的代码的时候,其实只需要在自己的地址空间上进行跳转就可以了。
信号捕捉流程
- 在执行主控制流程的某条指令时因为中断,异常或者系统调用进入内核。
- 在内核处理完异常准备回到用户模式之前,会先处理当前进程中可以递达的信号。
- 如果处理信号的函数是自定义的,则回到用户态执行对应的信号处理函数。
- 信号处理函数返回时执行特殊的系统调用再次进入内核。
- 从内核态再次返回到用户态,这次是从主控流程被中断的地方开始继续向下执行。
在第三步中,执行完信号处理函数(sighandler
)后,既然已经是用户态了,为什么不直接跳到主控制流程(main
)中,继续向下运行,而是还要转到内核态,然后再从内核态返回用户态呢?因为sighandler
和main
使用不同的内核空间,他们之间不存在调用和被调用的关系,是两个独立的控制流程。当sighanlder
函数执行完毕后,它不知道此时main
函数执行到哪了,所以不能直接从sighanlder
函数跳到main
函数。
信号集
从上图来看,每个信号只有一个bit
的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t
来存储,sigset_t
称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
信号集处理函数
-
int sigemptyset(sigset_t *set);
用来初始化set所指向的信号集,使其中所有信号对应的比特位置0,表示该信号集不包含任何有效信号。
-
int sigfillset(sigset_t *set);
初始化set所指向的信号集,使其中所有信号对应的比特位置1,表示该信号集包括系统支持的所有信号。
-
int sigaddset(sigset_t *set, int signo);
添加某种信号到信号集中。
-
int sigdelset(sigset_t *set, int signo);
从信号集中删除指定的信号。
-
int sigismember(const sigset_t *set, int signo);
判断信号集中是否包含某种信号。
-
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
用于检查和修改进程对应的信号屏蔽字(
block
信号集)。how
:指定信号集的操作方式SIG_BLOCK
:将set
中的信号添加到当前被阻塞的信号集中。就是将set
中的信号和当前阻塞信号集进行 “或” 操作,使这些信号也被阻塞。SIG_UNBLOCK
:将set
中的信号从当前被阻塞的信号集中移除。SIG_SETMASK
:将当前被阻塞的信号集设置为set
中的信号。
oldset
:一个指向sigset_t
类型的指针。如果oldset
非NULL
,函数会将进程当前的信号掩码存储到oldset
所指向的信号集中,以便后续恢复或查看之前的信号掩码状态。
如果调用
sigprocmask()
解除了对当前若干个未决信号的阻塞,则在sigprocmask()
返回前至少将一个信号递达。 -
int sigpending(sigset_t *set);
获取当前处于未决状态的信号集合。
简单的使用示例:
#include <iostream>
#include <signal.h>
#include <vector>
#include <unistd.h>
#define MAX_SIGNUM 31
static std::vector<int> sigarr = {2, 3};
static void show_pending(const sigset_t &pending)
{
for (int signo = MAX_SIGNUM; signo > 0; signo--)
{
//判断指定信号在不在信号集中
if (sigismember(&pending, signo)) std::cout << "1";
else std::cout << "0";
}
std::cout << std::endl;
}
int main()
{
sigset_t block, oblock, pending;
//初始化
sigemptyset(&block);
sigemptyset(&oblock);
sigemptyset(&pending);
//添加要屏蔽的信号
for(const auto &sig : sigarr) sigaddset(&block, sig);
//将屏蔽的信号设置进信号屏蔽字中
sigprocmask(SIG_SETMASK, &block, &oblock);
while (true)
{
//获取处于未决状态的信号集
sigpending(&pending);
//进行打印输出
show_pending(pending);
sleep(1);
}
}
Core Dump
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump。它们都是使一个进程退出,那有什么不一样的地方呢。
首先我们先来认识一下什么是Core Dump。
Core Dump(核心转储)是指在程序异常终止(如由于段错误、非法指令等原因)时,操作系统将进程当时的内存状态(包括程序代码、数据段、栈等)保存到磁盘文件中的操作。这个文件被称为核心转储文件,通常命名为 “core” 或类似的名称。这个文件可以帮助定位导致程序崩溃的原因,例如访问了非法内存地址、栈溢出、内存泄漏等问题。
在生成Core Dump文件之前,要确保Core Dump功能已打开。使用ulimit -a
查看用户资源限制,若选项为0,使用ulimit -c [非0值或unlimited]
打开这个选项。
下面是一个数组越界使用gdb
调试后的结果。(形成的Core Dump
文件的后缀是引起core问题的进程的pid
)
这就是两种退出方式不一样的地方,使用Term
的是直接退出,而Core
模式结束会生成Core Dump
文件。