1. 软件条件产生信号
我们在管道那一节说过,管道的读端关闭管道会导致管道的写端退出。管道的写端退出实际上是接收到了由软件条件产生的信号SIGPIPE。
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。
这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。
简而言之,软件条件是因操作系统内部或外部软件操作而触发的信号产生。
1.1 alarm函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
功能:
设置单次定时器:调用
alarm(seconds)
后,内核会在seconds
秒后向当前进程发送 SIGALRM 信号(默认行为是终止进程)。取消定时器:若参数
seconds
设为 0,则取消先前设置的闹钟,并返回剩余时间。
参数:seconds
:定时器时长(秒)。若为 0,表示取消之前的定时器。
返回值:成功:返回之前定时器的剩余时间(秒);若之前无定时器,返回 0。
我们可以简单地验证一下这个函数的功能:
void SigHandler(int sigid)
{
std::cout << "获得信号: " << sigid << std::endl;
exit(sigid);
}
int main()
{
for(int i = 1; i < 32; i++)
signal(i, SigHandler);
alarm(1);
int cnt = 0;
while(true)
{
std::cout << "count: " << cnt++ << std::endl;
}
return 0;
}
可以看到本来是死循环的程序,在打印了约53314条消息之后收到第14号信号而被终止了,而14号信号正是SIGALRM。
1.2 重复闹钟
alarm函数有什么作用呢?例如,我们可以用它来实现定时执行一些任务的程序:
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <functional>
#include <vector>
using task_t = std::function<void()>;
std::vector<task_t> Tasks;
void SigHandler(int sigid)
{
int n = Tasks.size();
std::cout << "pid: " << getpid() << ", 获得信号: " << sigid << ", ";
Tasks[rand() % n]();
alarm(1);
}
int main()
{
srand((unsigned int)time(nullptr));
signal(SIGALRM, SigHandler);
for(int i = 0; i < 32; i++)
{
Tasks.push_back([i](){
std::cout << "正在执行任务: [" << i << "]" << std::endl;
});
}
alarm(1);
while(true)
{
pause();
// std::cout << "......" << std::endl;
// sleep(1);
}
return 0;
}
不同于Windows下的pause函数,Linux中pause函数的作用是等待一个信号。
我们使用pause函数让程序阻塞等待alarm函数产生的SIGALRM信号,并自定义捕获这个信号。在SigHandler中,首先从任务列表中随机抽取一个任务执行,再设置新的闹钟。
可以看到,我们的进程每隔一秒就会随机选择一个任务来执行:
上述程序当中展示的,将SIGALRM捕获,并在自定义捕获函数当中重新设置闹钟的用法就叫做重复闹钟。
2. 硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址, MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
在Linux笔记---信号(上)-CSDN博客中我们已经见识过这种产生信号的方式了,这里我们要着重说的是这类信号的默认处理方式---核心转储。
2.1 核心转储
核心转储( Core Dump) 是程序异常终止时操作系统自动生成的内存快照文件,记录程序崩溃时的内存状态、寄存器值、堆栈信息等,是调试崩溃问题的重要工具。
当我们的程序因为信号的默认处理动作Core而异常终止时,报错信息会显示core dumped,并在当前目录下生成一个core文件:
这个core文件可以在调试时帮我们找到引发异常的行(当然需要debug版本):
在使用gdb调试时,输入:
core-file <对应的core文件>
gdb就能帮助我们找到产生异常的原因及所在行。
2.2 云服务器上的限制
可以看到,即使是一个极简单的程序所产生的core文件的大小也是非常大的,云服务器作为生产环境而非工作环境,如此大量地占用磁盘空间显然是不可接受的。
所以,在许多的云服务器上会配置不允许用户生成core文件。
limit -a # 查看终端限制
可以看到,当前允许用户生成的core文件的大小为0,即不允许生成core文件。
ulimit -c <数据块数> # 修改core文件大小限制
此时,我们再次运行程序,等待它出现异常退出,就会生成对应的core文件了。
2.3 子进程core dump
不知道大家还记不记得我们在Linux笔记---进程:进程等待-CSDN博客中讲过waitpid的输出型参数status。当子进程被信号所终止,且该信号的默认处理为core dump时,core dump标志位会被置为1,表示进行了核心转储。
我们可以尝试验证一下:
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int a = 10;
a /= 0;
}
int status = 0;
waitpid(id, &status, 0);
printf("Signal: %d, ExitCode: %d, CoreDump: %d\n", status&0x7F, (status >> 8)&0xFF, (status >> 7) & 1);
return 0;
}
- core file size == 0时:
- core file size == 40960时:
3. 信号保存
我们在前面探讨了信号产生的各种原因,但是信号是如何产生呢,其本质是什么?进程是如何看到信号的?进程不一定会立即对信号进行相应,那么未被相应的信号如何保存呢?
相关概念:
- 递达(Delivery):执行信号的处理动作称为信号递达。当信号产生后,经过内核的处理,最终被发送到目标进程,进程执行相应的信号处理函数,这个过程就是信号递达。
- 未决(Pending):信号从产生到递达之间的状态称为信号未决。在这个阶段,信号已经产生,但由于某些原因(如信号被阻塞),还没有被递达和处理。
- 阻塞(Block):进程可以选择阻塞某个信号,被阻塞的信号在解除阻塞之前不会被递达和处理。每个进程都有一个阻塞信号集,用来描述哪些信号递送到进程时将被阻塞。
阻塞和忽略的区别:阻塞是消息免打扰,忽略是已读不回。
实际上信号的存在与状态是由进程PCB当中的三个字段进行描述的,在task_struct
结构体中,block
、pending
和handler
这三个字段用于描述信号相关的信息:
3.1 block(阻塞信号集)
- 功能:用于表示进程当前阻塞的信号。每个信号在该位图中都有对应的位,若某位为1,则表示对应的信号被阻塞,进程不会对该信号进行处理,直到解除阻塞。
- 数据类型:通常是一个位图结构,使用
sigset_t
类型来表示。
相关系统调用
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
功能:信号阻塞与解除阻塞。通过指定不同的操作标志和信号集,可以实现对特定信号的阻塞或解除阻塞。
参数:
how:操作标志,决定如何修改信号屏蔽字,有以下几种取值:
SIG_BLOCK:把
set
指向的信号集中的信号添加到当前信号屏蔽字中,即增加要阻塞的信号。SIG_UNBLOCK:从当前信号屏蔽字中移除
set
指向的信号集中的信号,即解除对某些信号的阻塞。SIG_SETMASK:用
set
指向的信号集替换当前信号屏蔽字,即重新设置阻塞信号集。set:指向要修改的新信号集的指针,如果为
NULL
,则表示不改变信号屏蔽字,只进行检测操作。oldset:如果不为
NULL
,则用于存储之前的信号屏蔽字,以便后续恢复或查看。
返回值:
成功时返回
0
。失败时返回
-1
,并设置errno
以指示错误类型。
使用方法:首先创建一个sigset_t类型的变量,使用相关系统调用进行修改,然后作为参数传入。
用于修改sigset_t类型变量的系统调用为sigismember家族:
#include <signal.h>
// 置空
int sigemptyset(sigset_t *set);
// 置满
int sigfillset(sigset_t *set);
// 加入指定信号量
int sigaddset(sigset_t *set, int signum);
// 删除指定信号量
int sigdelset(sigset_t *set, int signum);
// 判断set中是否包含signum
int sigismember(const sigset_t *set, int signum);
注意:sigset_t并不是对简单类型重命名得到的,不可以将其看作整形家族并对其直接进行位操作,一定要使用上述函数。
3.2 pending(未决信号集)
- 功能:用于记录进程已经收到但尚未处理的信号。当进程收到一个信号时,内核会将该信号对应的位设置为1,表示该信号处于未决状态。
- 数据类型:同样是位图结构,使用
sigset_t
类型表示。
相关系统调用
#include <signal.h>
int sigpending(sigset_t *set);
功能:获取未决信号集。该函数用于获取当前进程中已经产生但尚未处理的信号集合,这些信号处于未决状态,通常是因为被阻塞而无法立即递达。
参数:set:指向一个sigset_t
类型的变量,该变量将被填充为当前进程的未决信号集。
返回值:
成功时返回
0
。失败时返回
-1
,并设置errno
以指示错误类型。
3.3 handler(信号处理函数表)
- 功能:是一个函数指针数组,用于存储每种信号的处理函数。数组的下标对应信号的编号,数组元素是指向信号处理函数的指针。
- 数据类型:通常定义为
typedef void (*handler_t)(int); handler_t handler;
,表示有31种信号的处理函数指针。
相关系统调用
即signal函数,在上一节Linux笔记---信号(上)-CSDN博客当中已经讲解过,这里就不再多说。
3.4 综合应用示例
在这个例子当中,我们首先设置SIGINT信号阻塞,然后捕捉SIGINT信号(为了在其递达时打印对应的信息),接着每隔一秒打印一次pending的内容,10秒后解除阻塞,查看现象:
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void PrintPending(sigset_t *pendingptr)
{
printf("进程[%d], pending: ", getpid());
for(int i = 31; i >= 1; i--)
{
if(sigismember(pendingptr, i))
std::cout << 1;
else
std::cout << 0;
}
std::cout << std::endl;
}
void SigHandler(int sig)
{
std::cout << "信号[" << sig << "]递达" << std::endl;
// 验证:pending清零时刻--->递达之前
// sigset_t pending;
// sigpending(&pending);
// PrintPending(&pending);
// 重新发送信号,让其完成默认行为
signal(sig, SIG_DFL);
raise(sig);
}
int main()
{
// 设置block
sigset_t mask_set, mask_bak;
sigemptyset(&mask_set);
sigemptyset(&mask_bak);
// 屏蔽SIGINT
sigaddset(&mask_set, SIGINT);
sigprocmask(SIG_BLOCK, &mask_set, &mask_bak);
signal(SIGINT, SigHandler);
// 获取pending并打印
sigset_t pending;
int count = 0;
while(true)
{
sigpending(&pending);
PrintPending(&pending);
count++;
// 解除对SIGINT的屏蔽
if(count == 10)
{
std::cout << "解除对SIGINT的屏蔽" <<std::endl;
sigprocmask(SIG_UNBLOCK, &mask_set, &mask_bak);
// 或者sigprocmask(SIG_SETMASK, &mask_bak, nullptr);
}
sleep(1);
}
return 0;
}
从下面的结果中可以看到,我们在第三次打印之前按下了ctrl + c(发送SIGINT信号),于是在第四次及之后的打印当中,pending的SIGINT对应位都为1。
但是,此时SIGINT信号并没有立即递达,说明SIGINT被屏蔽了。在10次打印之后,屏蔽解除,SIGINT立即递达,进程退出。
注意:可以验证pending当中的对应位是在信号递达前清零的,而不是在递达结束之后清零的,上面SigHandler函数中的注释部分可以帮助验证。至于为什么可以验证,读者可以自己思考一下。
4. 信号捕捉的流程
信号是何时递达的呢?信号在递达是是如何转向信号处理函数的?信号处理函数执行完成之后怎么回到进程原本的执行流程的?
我们通过下面这张图来解释:
更准确地说,第5步结束返回用户程序的过程,与第3步到第4步的过程之间是有一个交点的:
我们称这个交点为内核态返回用户态前的检查点。在检查点处,操作系统检查进程的pending表,查找是否有处于未决状态的信号,进而决定是执行某个信号默认处理动作,还是回到用户态执行某个信号自定义处理动作,还是直接回到进程原本的执行流程(没有信号处于未决状态且未被阻塞或忽略)。
总结来说,信号的递达发生在用户程序因异常而进入内核态之后,从内核态返回的过程当中。
但是用户的程序为什么会进入内核态呢?
除了进程主动请求系统服务(系统调用)和发生硬件异常之外,CPU会定期收到来自时钟源发出的时钟中断,进而通知操作系统对进程进行调度。当进程被某个调度时,就会发生从内核态到用户态的转变。
因此,即使程序当中一次系统调用都没有,也能频繁的经过检查点从而对信号进行相应。