目录
信号学习我们有三个目标:1,什么是信号? 2,为什么要有信号? 3,信号如何使用?
一,信号预备
1.1 生活中的信号
- 生活中讲到信号首先想到的是“红绿灯”、“转向灯”等各种信号灯,还要我们的闹钟,手机的消息提醒,包括古代的“狼烟”其实也是一种信号
- 对于上面这些熟悉的事物,有一个问题:你为什么认识这些信号呢?因为在我们成长的过程中被灌输了对应场景下的信号,并教会我们面对这些信号发出时,我们要有相应的动作 --> 这叫做我们能够识别信号
- 年纪大的老人和年纪小的小孩可能不知道红绿灯的含义,再比如上个实际的人们不知道现代只能手机的各种信号,因为它们没有,而且就算有了手机也没人教它们手机信号的含义 --> 这叫做无法识别信号 --> 而我们的大脑能够识别并知道某个信号的含义
- 外卖员打电话告诉我外卖放在外卖柜了,但是我们可能不会立马下床去拿 --> 我们收到这个信号的时候,可能不会立即处理这个信号
- 过了十分钟,我想起来了外卖没拿,于是下床去拿 --> 我们无法立即处理信号的时候,该信号也要临时记录下来
1.2 技术应用中的信号
对于Linux来说,用户就是你,操作系统就是快递员,信号就是快递;快递员把快递交给了你,你就必须对这个快递做出相应的动作。
下面写一个死循环代码:
#include <iostream>
#include <unistd.h>
using std::cout, std::endl;
int main()
{
while (true)
{
cout << "hello world" << endl;
sleep(1);
}
return 0;
}
死循环打印的时候,命令行是一直占用的,我们无法输入其它任何命令或进行然后其它的操作,所以我们必须得强行终止死循环,最好的方式就是使用Ctrl+C对其进行终止:
问题:那么Ctrl+C是如何终止死循环的呢?
死循环也是进程,所以终止死循环也是终止进程。实际上用户按Ctrl+C时,键盘输入会产生一个硬件中断,这个中断会被操作系统捕获到并解释成2号信号,然后把2号信号发送给前台进程,前台进程收到2号信号后就会强制进程退出
1.3 signal函数捕捉信号
如标题,我们可以使用signal函数对2号信号进行捕捉,以此可以证明我们按Ctrl+C时确实是收到了2号信号的:
第一个参数就是信号编号,sighandler_t其实是一个返回值为void,参数为int的一个函数指针类型,所以第二个参数其实就是当捕捉到signum信号时要执行的函数名,所以这个函数的作用就是当捕捉到对应信号时,执行其它动作。
我们改造一下上面的死循环,如下代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using std::cout, std::endl;
void CatchSig(int signum)
{
cout << "进程收到了一个信号:" << signum << endl;
}
int main()
{
// signal(2, CatchSig); //第一个参数用数字和名称都可以,但目前建议先用名称
signal(SIGINT, CatchSig); // 这个是捕捉Ctrl+C,2号信号
signal(SIGQUIT, CatchSig); // 这个是捕捉Ctrl+\,3号信号
while (true)
{
cout << "hello world" << endl;
sleep(1);
}
return 0;
}
可以看到我们按Ctrl+C确实是收到了2号信号,但是原本收到2号信号是停止,但是由于我们用signal函数将停止逻辑更改,所以无法停止了,所以我们可以再开一个终端,然后pidof test命令查看pid,然后kill -9 pid就可以终止了,9号信号是一个特殊信号,我们后面解释
1.3 信号的发送与记录
我们可以使用kill -l查看Linux当中的信号列表:
其中1 -- 31号信号是普通信号,34 -- 64号信号都是实时信号,各有31个
问题:信号是如何记录的呢?
当一个进程收到一个信号后,该信号是被记录在该进程的进程控制块中的。而进程控制块在代码层面上就是一个结构体,所以我们可以用一个32位的位图来记录信号的产生:
其中比特位的偏移量就代表信号的编号,该位置由0变为1时就说明产生了对应的信号。
所以一个进程收到了信号,本质就是该进程PCB结构体里的信号位图被修改了,而修改PCB数据的只要操作系统有这个权限,所以信号产生的本质就是“操作系统修改了进程PCB结构体里的信号位图”
1.4 信号的常见处理方式
一般来说有三种:
- 执行该函数的默认处理动作
- 提供一个信号处理函数,要求内核在处理该信号时从内核态切换到用户态执行用户定义的方法,这种方式称为捕捉一个信号,前面演示过
- 什么都不做,忽略该信号
我们也可以查看Linux默认处理的方法:(ubutun版本man 7 signal显示有问题,下面的Centos 7的man 7 signal页面展示)
二,信号的产生
2.1 核心转储
2.1.1 环境配置
我们按Ctrl+C和Ctrl+\都可以终止进程,前面介绍过,但是SIGINT的处理动作是Term,但是SIGQUIT的处理动作是Core:
虽然Term和Core都表示终止进程,但是Core在终止进程的时候会进行一个动作,就是核心转储:
问题:什么是核心转储?
解答:我们的代码出错崩溃了,我们一定会关心出错的原因,可以用退出码来判断。但是如果代码是在运行过程中出错的,我们一般会通过调试来逐步查找进程退出的原因,于是在某些情况下,我们会用到核心转储,它指的是当进程收到操作系统的信号发送退出之前,将当前进程地址空间的内容以及有关进程状态的其它信号提取出来存储到一个磁盘文件里,这个磁盘文件也叫做核心转储文件,一般命名位core.pid,核心转储主要是为了方便调试
一个项目所处的环境有:开发环境,测试环境,发布还款,生产环境
云服务器一般属于生产环境,它的核心转储功能默认是关闭的,可以使用ulimit -a查看系统的一些资源配置:
我们可以用ulimit -c size 的命令来设置core文件的大小:
问题:为什么生产环境一般要关闭核心转储?
解答: 又可能塞满磁盘,造成服务器崩溃
注意:Ubuntu系统和Centos系统对核心转储的设置不一样,简单理解就是Centos生产核心转储文件就是比较随意,直接进程的工作目录生产;但是Ubuntu系统是单独设置了一个路径来集中存放的core文件,下面展示Ubuntu系统改变core文件存储路径方法:
sudo vim /proc/sys/kernel/core_pattern
打开该文件后里面只有一个路径,表示当前终端生成core文件的位置,可以直接修改成你想存储的位置,但是这个是临时修改,只要shell关掉就会恢复到默认位置,所以下面展示永久修改的方法:
sudo vim /etc/sysctl.conf
%e表示进程名,%p表示进程pid,%h表示进程终止的年月日,%t表示进程终止的时间戳
设置完成后我们就能生成core文件了,如下gif:
2.1.2 利用core文件进行调试
我们用下面的除0代码进行错误演示:
#include <iostream>
#include <unistd.h>
using std::cout, std::endl;
int main()
{
cout << "hello!" << endl;
sleep(3);
int i = 1 / 0;
return 0;
}
g++ test.cc -o test -g
-g选项表示开发者选项,要加上
然后我们可以使用gdb工具结合core文件进行调试了,具体步骤如下:
①首先gdb test进入gdb模式
②然后使用core-file core文件可以直接定位到发送错误的地方,如下图:
这种事后用调试器使用core文件以查清错误原因的调试方式叫做”事后调试“
2.1.3 core dump标志
在之前的进程控制博客中,我们讲到waitpid的第二个参数是一个输出型参数:
该参数用户获取子进程的退出状态,是一个整型变量,当时也说过这个int不能简单当作一个int来看待,它的32个比特位所代表的信息不同,如下图:
如果进程是正常终止的,status的低8位就表示进程的退出状态,即退出码,其它位全置0
如果被信号所杀,前8位直接不用也就是不读取,status低7位标识进程收到的信号,第8位就是core dump标志,该比特位主要用来判断进程终止时是否生成核心转储文件,如下代码:
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
using std::cout, std::endl;
int main()
{
if (fork() == 0) // 子进程
{
cout << "我是子进程" << endl;
int i = 1 / 0;
exit(0);
}
else // 父进程
{
int status = 0;
waitpid(-1, &status, 0);
if (WIFEXITED(status)) // 正常退出
{
cout << "子进程正常退出,退出码:" << WEXITSTATUS(status) << endl;
}
else // 被信号所杀
{
cout << "子进程被" << (status & 0x7F) << "号信号所杀" << endl;
if (((status >> 7) & 1) == 1)
{
cout << "core dump标记位为1,生产core文件" << endl;
}
}
}
return 0;
}
我们创建一个子进程,然后子进程除0错误被8号信号所杀,然后我们打印信号,当core dump标志位为1时,生产core文件,如下gif:
2.2 通过系统调用向进程发信号
2.2.1 kill函数
我们也可以在代码中向进程发送信号,可以向本进程发也可以结合上一节的进程间通信向别的进程发,kill不仅仅是命令,操作系统也有kill函数,其实kill命令就是调用的kill函数实现的发送信号:
参数非常简单,第一个就是进程的pid,第二个参数就是要发送的信号,如果发送成功返回0,失败返回1。
下面简单实现一个kill命令,命名为mykill,如下代码:
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
static void Usage(string proc)
{
cout << "Usage:\r\n\t" << proc << " signumber processid" << endl;
}
int main(int argc, char *argv[])
{
if (argc != 3) // kill命令一般都是三部分,如果不是三部分说明输入错误
{
Usage(argv[0]);
exit(1);
}
// atoi:把字符串转化成整数
int signumber = stoi(argv[1]); // 拿到信号号码
int procid = atoi(argv[2]); // 拿到进程pid
kill(procid, signumber);
}
我们现在另一个窗口sleep 1000启动一个“睡眠”进程,然后用下面的查询查询pid,然后使用我们的./mykill 9 pid就可以干掉睡眠进程:
ps ajx | head -1 && ps ajx | grep 'sleep 10000'
2.2.2 raise函数
raise仅用于给当前进程发送信号,成功返回0,失败返回非0,通过下面代码和演示可以展示该函数作用:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "进程收到了一个信号:" << signum << endl;
}
int main()
{
signal(3, handler);
while (1)
{
sleep(2);
raise(3); // 每隔两秒对该进程发送3号信号,随后被捕捉执行打印函数
}
}
2.2.3 abort函数
这是一个无参数的函数,作用是给自己发送SIGABRT信号,也就是6号信号,通常用来终止进程
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "进程收到了一个信号:" << signum << endl;
}
int main()
{
signal(6, handler);
while (1)
{
sleep(2);
abort();
}
}
但是结果显示,收到信号并且执行自定义函数后,进程仍然被终止了,因为abort函数包括exit退出进程的作用,但是exit就是正常终止进程,abort是通过发送信号终止的进程,因此exit函数可能会调用失败,但是abort终止进程总是成功的
2.3 由软件条件产生信号
2.3.1 管道与13号信号
13号信号也就是SIGPIPE信号就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端关闭,写入也就没有意义了,操作系统会及时发现问题,然后就会发送SIGPIPE信号终止写端进程。管道也是软件,因为管道是文件在内存级的实现,所以上面这种情况我们称为:“软件条件不满足”
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <string>
using namespace std;
int main()
{
int fd[2] = {0};
if (pipe(fd) < 0) // 创建匿名管道
{
perror("pipe");
return 1;
}
int pid = fork();
if (pid == 0) // 子进程
{
// 子进程关闭读端,往管道写数据
close(fd[0]);
string s = "hello world";
for (int i = 0; i < 5; i++) // 往管道写5次数据后,子进程关闭写端
{
write(fd[1], s.c_str(), s.size());
sleep(1);
}
close(fd[1]);
exit(0);
}
else // 父进程
{
// 父进程关闭写端,往管道读数据
close(fd[1]);
close(fd[0]); // 父进程直接关闭读端,子进程会受到信号直接终止
int status = 0;
waitpid(pid, &status, 0);
cout << "子进程收到了信号:" << (status & 0x7F) << endl;
return 0;
}
}
2.3.2 alarm函数与14号信号
该函数作用就是让操作系统在seconds秒后给当前进程仿SIGALRM也就是14号信号,14号信号的默认处理动作就是终止进程,对于返回值有下面两个点:
①调用alarm前,如果进程已经设了闹钟,则返回上一个闹钟的剩余时间并覆盖上一个闹钟的设置
②如果是调用alarm之前没有设过闹钟,则返回0
我们可以用闹钟计算CPU的每秒运算次数,如下代码:
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
using namespace std;
int main(int argc, char *argv[])
{
alarm(1);
int count = 0;
while (true)
{
cout << "count: " << count++ << endl;
}
}
当前Ubuntu服务器CPU每秒的运算次数大约4w次,但这肯定不是真实的CPU运算次数,因为我们进行打印的IO操作和网络的长距离传输动作,最后打印到我们屏幕上的数就非常小了,所以我们可以用下面代码单纯计算算力:
#include <iostream>
#include <vector>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <stdlib.h>
#include <functional>
#include <sys/wait.h>
using namespace std;
typedef function<void()> func; // 定义一个包装器
vector<func> callbacks; // 定义一个数组,成员类型为一个函数包装器
uint64_t count = 0;
uint64_t i = 1;
void showCount()
{
cout << "CPU" << i << "秒内的执行次数为: " << count << endl;
i++;
}
void catchSig(int signum) // 每隔一秒就按顺序轮流执行vector里的任务
{
for (auto &f : callbacks)
{
f();
}
alarm(1); // 闹钟只会触发一次,所以在闹钟触发后需要再设一个闹钟
}
void logUser()
{
if (fork() == 0)
{
execl("/usr/bin/who", "who", nullptr);
exit(1);
}
wait(nullptr); // 等待子进程退出
}
int main(int argc, char *argv[])
{
alarm(1);
signal(SIGALRM, catchSig);
callbacks.push_back(showCount);
callbacks.push_back(logUser);
while (true)
count++;
}
g++ test.cc -o test -std=c++11
可以看出来,CPU每秒的计算次数大约5亿次
2.4 硬件异常产生信号
2.4.1 除0错误
先看下列代码:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
using namespace std;
void handler(int signum)
{
sleep(1);
cout << "获得了一个信号:" << signum << endl;
}
int main(int argc, char *argv[])
{
signal(SIGFPE, handler);
int a = 100;
a /= 0;
while (true)
sleep(1);
}
问题:运行后会打印收到了8号信号,但是我不是只执行了一次除0操作吗?为什么会循环打印呢?如何理解除0操作呢?
- 进行算数运算的是CPU,而CPU内部是有很多寄存器的,寄存器类型有很多,其中有一个叫做状态寄存器,它不进行数值保存,只用来保存本次计算的计算状态
- 那么咋知道计算后的结果是对的呢,有没有进位,溢出呢?状态寄存器有对应的状态标记位。假设把状态寄存器当成位图来看,CPU计算完后会把结果写回内存,写入之前OS会先检测状态寄存器的那个溢出标记位是0还是1,如果是0,直接返回,如果是1,OS立马识别到你除0操作了,有溢出问题
- 由于寄存器里装的是目前进程的上下文数据,OS只要找到当前谁在运行提取pid,发送信号,进程会在合适的时候进程处理。所以/0错误本质属于硬件异常
- 一旦出现硬件异常,进程一定会退出吗?不一定,一般默认是退出,但是我们即便不退出,我们也做不了什么
- 为什么会出现死循环?虽然捕捉到信号了,但是寄存器中的异常一直未被解决,所以一直尝试发送8号信号终止进程
2.4.2 指针越界错误
如下代码,给野指针赋值属于段错误,会收到11号信号:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
using namespace std;
void handler(int signum)
{
sleep(1);
cout << "获得了一个信号:" << signum << endl;
}
int main(int argc, char *argv[])
{
signal(SIGSEGV, handler); // 11号信号
int *p = nullptr;
*p = 100;
while (true)
sleep(1);
}
那么操作系统是如何识别到指针越界问题的呢?
- 当我们访问一个变量,一定要先经过页表的映射,将虚拟地址转化为物理地址才能进行访问
- 页表是操作系统维护的,所以它属于软件,而从虚拟地址映射到物理地址得硬件叫做MMU(Memory Manager Unit)它是一种负责处理CPU得内存访问请求的计算机硬件,因此映射工作不是由CPU做的,是由MMU做的(现代计算机已经将MMU高度集中到CPU内部了)
- 同时,不只是CPU有寄存器,几乎所有的外设都会有寄存器,所以MMU在进行映射的时候也会和状态寄存器识别到除0错误一样识别到指针越界,所以也会将错误写到MMU自己的寄存器上被操作系统读到然后进行发送信号的操作
总结:
- C/C++程序出现错误会崩溃,一定是因为程序中出现的问题在底层的某些硬件上有所表现,然后会被操作系统识别到,终止进程
- 通过上面的代码和解释,我们的程序也是可以设定一种方式就是不用死循环的方式去按一定周期去执行某些任务。设定周期性的事件,操作系统也不是根据周期去刷新,当我们设定的闹钟或者其它的事件的限制条件到了,操作系统就会定期对我们内存和缓冲区的使用情况,综合性地去设定某些策略
所有的信号都有它的来源,但最终都是被OS识别,解释和发送的
三,信号的保存
3.1 为什么要保存信号?
- 信号的处理动作有三种:默认,忽略,自定义捕捉,我们把这三个信号地处理动作叫做“信号递达”,我们把信号产生到信号递达的状态叫做“信号未决”
- 信号一旦产生,进程可能忙,无法立即处理这个信号,但是不是不会处理这个信号,放到未来处理。所以收到信号但未来处理,这个时间窗口里信号是存在但没被处理,所以信号需要被临时保存在进程PCB的信号位图当中 --> 这个状态称为“未决状态”
- 进程也可以不理某些信号,只要信号保持在未决状态,就算做未递达,这叫做进程屏蔽了某些信号,也叫做了阻塞了信号,被阻塞的信号直到进程解除对该信号的阻塞才能执行递达的动作
- 问题:忽略信号与阻塞信号有什么区别?阻塞信号是指信号呆在未决状态不处理接触限制后正常递达,忽略是在递达后什么也不做
3.2 PCB中的三个信号位图
在前面我们也学过,操作系统向进程发送信号本质是修改进程的PCB结构体中的信号位图来实现的发送信号,那么信号未被处理的时候也一定存储在信号位图中,而这个位图有三个,如下图:
- 上面有三个表,其中block标识阻塞,pending表示未决,handler表表示信号的处理动作。我们前面谈论的操作系统修改信号位图修改的就是pending表,设置为1表示收到该信号,进程执行完handler表的对应的方法后复原pending表
- 就拿上面的图来说,进程收到了2号信号,但是对应的block的位置是1,表示1号信号正在被阻塞,虽然2号信号的处理动作是SIG_IGN忽略,但是在block没有变成0之前不能直接忽略该信号,因为进程可能会改变handler表的方法,从而让2号信号在解除限制后执行其它的方法
- 对于3号信号,当前进程没收到该信号,但是一旦收到就阻塞,然后它的处理动作是用户的自定义函数sighandler方法。
- 对于handler表:如果信号对应的handler ==0 ,就执行默认动作,如果 == 1,就执行忽略动作,如果不是1也不是0,那么执行用户自定义的捕捉动作,SIG_DFL是一个宏,为0;SIG_IGN也是宏,为1
3.3 sigset_t信号集
基本上高级语言都会给我们用户提供.h和.hpp和语言的自定义类型;同时,操作系统也会给我们提供.h和它自己的类型,比如fork的pid_t类型,共享内存的key_t类型等;操作系统自定义的类型也需要和它提供的.h头文件的一些接口对应,而sigset_t就是操作系统提供的位图类型:
sigset_t类型又被称为信号集,该来信可以表示每个信号的“有效”和“无效”状态;在阻塞信号集中表示是否被阻塞,在未决信号集中表示是否处于未决状态
3.4 信号集函数
sigset_t类型对于每种信号都有一个比特位表示“有效”或“无效”,至于这个类型内部如何存储这些bit则依赖于系统的实现,而对于我们用户,我们没有权利对它的内部数据直接做修改,但是我们又有这个需求,所以操作系统需要为我们用户提供接口来让我们用户能够修改
然后我们来依次介绍一下这些函数:
- sigemptyset:初始化set参数指向的信号集,使其中所有信号的对应比特位变为0,表示目前信号集中不包含任何有效信号
- sigemptyset:和上面那个反着来,这个函数是将所有信号对应的比特位变为1,表示目前信号集支持当前系统的所有信号
- sigaddset:在set参数指向的信号集中添加一个信号,第二个参数signum就是要添加的信号
- sigdelset:和上面的反着来,这个是删除指向信号集中的有效信号
- sigmember:判断set指向的信号集中是否包含某个信号,如果有返回1,没有返回0,调用失败返回-1,上面几个也都是调用成功返回0,失败返回-1
#include <stdio.h>
#include <signal.h>
int main()
{
sigset_t s; // 用户自定义的位图变量
sigemptyset(&s);
sigfillset(&s);
sigaddset(&s, SIGINT);
sigdelset(&s, SIGINT);
sigismember(&s, SIGINT);
return 0;
}
但是上面的操作至少改变了用户自定义的s位图变量,并没有改变进程PCB结构体中的位图变量,所以我们还需要通过一些系统调用才能将对s做的修改同步到进程内核结构体
3.5 sigprocmask函数
这个函数的作用就是用于读取或更改进程的信号屏蔽字也就是阻塞信号集,就是改变上面那个图的block表
- 如果set指针非空,则更改进程的信号屏蔽字,更改为我们自定义的,how表示如何更改
- 如果oldset非空,则读取当前信号屏蔽字并通过oldset参数输出,简单来说就是输出型参数
- 如果两者都非空,则先将进程原来的信号屏蔽字输出到oldset,然后再更改为set指向的信号屏蔽字
假设当前信号屏蔽字为mask,下表说明了how参数的可选值以及含义:
选项 | 含义 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask |= set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask &= (~set) |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
3.6 sigpending函数和测试
这个函数作用很简单,就是读取当前进程PCB结构体里的未决信号集,就是pending表,set做输出型参数。
函数介绍完毕,下面是一个简单的测试:
步骤如下:
- 先定义我们自己的信号屏蔽字也就是block表,初始化后利用sigaddset将2号信号假如信号屏蔽字
- 然后将我们自定义的block表覆盖掉进程的,也就表示当前进程屏蔽2号信号,也就是让2号信号在进程的pending表里呆着不处理
- 然后循环打印pending十秒钟,在这十秒内我们Ctrl+C对进程发送2号信号,可以看到打印的pending的第二个比特位被置1了
- 10秒钟过后,解除2号信号的屏蔽,然后执行我们的signal捕捉函数执行我们的自定义处理方法,然后屏幕上打印的pending位图的第二个比特位又变回了0
代码如下:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <assert.h>
using namespace std;
static void handler(int signum)
{
std::cout << "你好,已捕捉2号信号:" << signum << std::endl;
}
static void showPending(sigset_t &pending)
{
for (int sig = 1; sig <= 32; sig++)
{
if (sigismember(&pending, sig)) // 判断sig当前在不在pending集合里,如果if条件成立,说明在
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
// 0,2号信号屏蔽解除后进行捕捉
signal(2, handler);
// 1,定义信号集对象
sigset_t bset, obset;
sigset_t pending;
// 2,将信号机全部初始化为0
sigemptyset(&bset);
sigemptyset(&obset);
sigemptyset(&pending);
// 3,添加要进行屏蔽的信号
sigaddset(&bset, 2 /*SIGINT*/); // 将block表的2号比特位置1,表示阻塞2号信号
// 4,将我们定义的block位图表覆盖掉进程的block位图
int n = sigprocmask(SIG_BLOCK, &bset, &obset); // 设置成功后该进程将屏蔽2号信号,也就是让2号信号一直待在pending表里不处理
assert(n == 0); // 在release下就没了
(void)n;
std::cout << "2号信号已屏蔽" << std::endl;
// 5,重复打印当前信号的pending信号集
int count = 0;
while (true)
{
sigpending(&pending); // 获取进程中的pending信号集
showPending(pending); // 显示我们的位图到显示屏上
sleep(1);
count++;
if (count == 10)
{
// 在默认情况下,恢复对于2号信号的block的时候,会立即递达,进程也会直接终止掉
// 所以我们需要对2号信号进行自定义捕捉
std::cout << "解除2号信号的屏蔽" << std::endl;
int n = sigprocmask(SIG_SETMASK, &obset, &bset);
assert(n == 0);
(void)n;
}
}
return 0;
}
执行效果如下:
对于sigprocmask函数有个小细节:如果调用sigprocmask解除了一个或者多个未决信号的阻塞,则在sigprocmask函数返回前,至少将一个信号递达, 所以上面的动图里解除2号信号屏蔽后,立马打印了2号信号的捕捉函数
3.7 尝试屏蔽所有信号
有了上面的学习,我们现在可以做一些好玩的东西,如下代码:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <assert.h>
using namespace std;
static void showPending(sigset_t &pending)
{
for (int sig = 1; sig <= 32; sig++)
{
if (sigismember(&pending, sig)) // 判断sig当前在不在pending集合里,如果if条件成立,说明在
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
static void blockSig(int sig) // 屏蔽指定信号
{
sigset_t bset;
sigemptyset(&bset);
sigaddset(&bset, sig);
int n = sigprocmask(SIG_BLOCK, &bset, nullptr);
assert(n == 0);
(void)n;
}
int main()
{
for (int sig = 1; sig <= 31; sig++) // 屏蔽所有信号
{
blockSig(sig);
}
sigset_t pending;
while (true)
{
sigpending(&pending);
showPending(pending);
sleep(1);
}
return 0;
}
脚本文件sendSig.sh如下:
#!/bin/bash
i=1
id=$(pidof test)
while [ $i -le 31 ]
do
if [ $i -eq 9 ]
then
let i++
continue
fi
if [ $i -eq 19 ]
then
let i++
continue
fi
kill -$i $id
echo "kill -$i $id"
let i++
sleep 1
done
效果演示如下:
这个小代码就是通过脚本文件依次给进程发送1到31号信号,可以发现可以全部捕捉到,但是有两个信号我们没有发送,就是9号信号和19号信号。
这两个信号是特殊信号,假设一个进程真的屏蔽了所有信号,那么这个进程将永远退出不了了,这是绝对不允许的,所以9号和19号信号无法被捕捉,是管理员级别的信号
四,信号的处理
前面我们讲过,进程接收到信号可能无法立即处理,所以有了各种对信号的保存机制,那么:1,合适的时候是什么时候? 2,信号处理的整个流程是什么?
4.1 用户空间和内核空间
- 每一个进程都有自己的进程地址空间,而进程地址空间除了用户自己的空间,还有专属于操作系统的内核空间
- 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存建立映射关系
- 而内核空间存储的是操作系统的系统代码和数据,通过内核级页表与物理内存建立映射
- 这个“内核级页表”是一个全局的页表,用来维护操作系统的代码和进程之间的关系。因此在每个进程的地址空间中,用户空间属于当前进程,进程看到的代码和数据是完全不同的
- 但是在内核空间中存放的操作系统的代码和数据都是一样的,因为所有进程的内核空间都是共用的的一个内核级页表
所以一般情况下,进程都是无法访问内核空间的,我们访问用户用户空间时必须处于用户态,访问内核空间时必须处于内核态。
4.2 用户态和内核态
- 内核态通常用来执行操作系统的代码,是一种权限非常高的状态
- 用户态是一个受管控受约束的状态,用来执行用户的普通代码
问题:用户凭什么可以执行系统调用呢?
解答:①CPU寄存器有两套,一套课件,一套不可见,其中不可见那一套有一个叫做CR3寄存器,在代码层面上也可以把它当作位图来看,反正就是有很多比特位表示当前CPU的执行权限,为1时表示内核态,为3时表示用户态。
②将执行open接口的语句变成汇编语言后,open这个函数也会分成多个指令,其中有一条指令叫做“int 80”,就是修改CR3寄存器的状态由用户态变为内核态,后面进行权限审查的时候就可以检测到,从而使用内核级页表而不使用用户级页表
③像这样修改CR3寄存器的对应比特位,就可以让CPU在用户态和内核态来回切换,执行完内核代码就必须立即切换为用户态,因为内核代码大多数事件是严格保护的
从用户态切换为内核态有下面几种情况:
- 进行系统调用时
- 当前进程的时间片到了,导致进程切换
- 产生异常,中断,陷阱等
从内核态切换为用户态有下面几种情况:
- 系统调用返回时
- 进程切换完毕
- 异常,中断,陷阱等处理完成
4.3 信号处理的时机
前面我们一直在强调进程收到信号后可能无法立即处理,所以要保存起来,后面到合适的时候处理,那么合适的时候是什么时候呢?
- 假设CPU正在执行我们的代码,然后我们可能因为一些特殊情况(比如:中断,异常或系统效用)从用户态进入到内核态;假设我们的程序没有调用系统调用,就打个死循环,那么我们的代码是不是永远也不会进入到内核态?
- 不是的,信号处理的时候也是在内核态的,最典型的就是我们运行一个死循环程序,我们依旧可以Ctrl+C杀掉进程。因为进程也会周期性地进入内核,最典型地就是OS发现你这个进程的时间片到了,然后触发对应的进程调度,在当前进程的上下文数据中执行调度函数(由于Linux是用C语言写的,所以调度本质也就是个函数),而调用调度函数也是要嵌入内核的。
- 进入内核后,OS就要根据你进入内核地原因做动作,因为你进入内核一定是要做事的不然不会让你进来;那么当你做的事做完,你就要准备从内核态返回到用户态,让代码从中断的地方继续运行
- 而在从内核态返回到用户态的时候,操作系统就do_signal“顺便”处理异常了,所以处理异常的合适时候就在这里
4.4 信号处理的基本流程
在内核态返回到用户态之前,就进行信号pending表检查,如果发现有未决信号,就再去找该信号的block表,如果没有阻塞,那么就执行该信号的处理动作,就是接着去找handler表。
1,如果要处理的信号的处理动作是默认或者忽略,那么执行完默认动作或忽略动作后还原对应的pending表,如果要递达的信号已经处理完了,就返回用户态,从主代码中断的地方继续向下运行
2,如果处理动作是用户自定义的,那么处理该信号的时候就要先返回用户态执行自定义处理方法,然后再通过特殊的系统调用sigreturn再次进入内核态清除pending表,如果之后没有信号再递达,就返回用户态,从中断的地方继续运行,相当于切换了两次状态
从上面的图可以看出来信号的处理过程就是一个“∞”符号,在无限的交点,就是内核在进行信号pending表检测,而无限与用户态和内核态分界线有4个交点,每个交点代表一次状态的切换,而无限的方向,就是什么态到什么态的切换
问题:当信号处理动作是自定义时,可以直接在内核态执行吗?
解答:一定不能。理论上是可以的,但是不行,因为如果直接在内核态执行,那么用户的一些非法操作就可能危害到操作系统的内核结构,英雌操作系统无法保证用户的代码是否合法,所以操作系统统一不信任任何用户。
就好比你去银行取钱,银行肯定不会让你去金库直接拿,而都是在银行的柜台窗口办理业务
4.5 sigaction信号操作函数
该函数的作用和signal差不多,但是功能比signal函数多,检查或更改信号的处理动作。
- 第一个参数表示要进行操作的信号编号
- 第二个参数是一个结构体指针,做输入性参数,如果act不为空,就根据act修改信号的处理动作
- 第三个参数也是结构体指针,做输出型参数,在act不为空的前提下返回旧的信号处理动作
关于struct sigaction结构体定义如下:
struct sigaction {
void(*sa_handler)(int);
void(*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void(*sa_restorer)(void);
};
- 第一个成员sa_handler:将宏定义常数SIG_IGN传给sigaction函数,表示忽略信号;传SIG_DFL时表示信号默认动作;如果是一个函数指针,表示自定义处理动作。
- 第二个成员sa_sigaction:表示实时信号的处理函数
- 第三个成员sa_mask:首先要说明的是,当一个信号的处理函数被调用时,自动将该信号假如信号屏蔽字,处理完后恢复信号屏蔽字,这样就能保证同时收到两个信号时阻塞住第二个信号。当然,除了屏蔽当前信号外,我们可能还要屏蔽一些其它的信号,就用sa_mask字段来表示,当信号处理函数返回时,自动恢复原来的信号屏蔽字
- 第四个成员sa_flags:该字段我们直接设置为0即可
- 第五个成员sa_restorer:该参数不使用
下面是sigaction函数的基本使用:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "获取了一个信号: " << signum << endl;
}
int main()
{
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = handler;
// 上面的代码中用的虽然是内核数据类型,但是是在用户栈上定义的,所以还需要调用sigaction接口进入到内核态中
sigaction(2, &act, &oact);
cout << "default action:" << (int *)(oact.sa_handler) << endl; // 打印0,说明原来对2号信号的处理动作是“默认”
while (true)
sleep(1);
return 0;
}
五,可重入函数
先看下面的例子:
①下面主函数中调用insert函数像链表头插node1,然后某信号handler函数也调用insert头插node2:
当前链表为:
②首先main函数调用insert函数,将node1插入链表,但插入操作分两笔,假设还没改变head时因为硬件中断使进程切换到内核,切换回用户态之前有信号待处理,就执行handler方法:
③而handler方法中也调用了insert函数,于是执行完handler方法后链表如下:
④handler执行完后,继续执行main函数的insert函数,改变头结点:
最终结果就是,node1和node2都成功的插入到了链表中,但是node2缺少了头结点导致再也找不到node2了,造成内存泄漏。
像上面这样,一个函数在一个特定时间端内,被多个执行流重复进入,叫做“函数重入”,而函数重入后逻辑主题不受影响时,把该函数叫做可重入函数,而重入后逻辑出现问题时,把该函数叫做不可重入函数,其中上面的insert就是不可重入函数。
如果一个函数符合下面条件之一就是不可重入函数:
- 调用了malloc和free,new和delete等内存操作函数,因为这些函数也是用全局链表来管理堆空间的
- 调用了标志I/O库的函数,因为标志I/O库的很多实现都是以不可重入的方式使用全局数据结构
我们目前使用的90%的函数都是不可重入的函数。要保证函数可重入,必须保证函数是独立的,不访问任何全局函数,但是我们目前涉及到的系统接口大多数都跟全局变量有关系,比如很多接口成功返回0失败返回-1,错误码被设置,而这个错误码就是errno,是全局数据
六,volatile关键字
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int flag = 0;
void changFlag(int signum)
{
(void)signum;
cout << "chang flag: " << flag;
flag = 1;
cout << " -> " << flag << endl;
}
int main()
{
signal(2, changFlag);
while (!flag)
{
printf("你好\n");
sleep(1);
}
cout << "进程正常退出后:" << flag << endl;
}
上面的代码中我们堆2号信号进行捕捉,当进程收到2号信号时将全局变量flag从0修改成1,然后会退出死循环:
乍一看好像没啥问题,但是我们在上层看到的现象,在底层其实做了很多的工作的:
- 对于全局变量,内存一定会开空间来保存,假设把flas=0存在内存里,CPU包含各种寄存器,拎出来一个:edx寄存器
- 然后上面的代码编译的时候,main函数里没有改变flag值的语句,只有一个while死循环,而main函数和handler是两个独立的执行流;以往没有优化的时候,CPU寄存器每一次执行都会去内存访问内存中的flag的值,这样做效率太慢了,于是编译器就自作聪明的把flag的值放到edx寄存器里了,然后每次判断就在他自己里面判断,所以当我们发送2号信号捕捉后在自定义捕捉函数修改flag的值,但是编译器不再访问内存里的flag了,只访问edx寄存器里的,所以就死循环了(CPU无法看到内存了)
如果我们在编译的时候假设-O3表示最高级别地优化,运行后再Ctrl+C死循环也不会停止了
g++ test.cc -o test -O3
所以我们为了解决这个问题,所以一些可能会被优化地成员就必须由我们程序员显性地告诉编译器:不要这样搞!在全局的flag前面加上volatile,就可以正常退出了
volatile int flag = 0;
七,SIFCHLD信号(了解)
为了避免出现僵尸进程,父进程需要用wait和waitpid等待子进程结束。在之前的博客中,我们说过一个问题:父进程是如何知道子进程结束的呢?子进程结束时,会给父进程发送SIGCHLD信号,17号信号,该信号的默认处理动作是忽略,并且之一Linux采用了这种策略
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "子进程退出,父进程收到信号:" << signum << endl;
}
int main()
{
signal(SIGCHLD, handler); // 父进程捕捉子进程退出信号
if (fork() == 0)
{
sleep(1);
exit(0);
}
while (true)
sleep(1);
}
假设有10个子进程,都退出,只需要while(wait())就行了;但是如果只有5个退出,这时候我们用常规的wait和waitpid去循环地阻塞式等待,那么只要子进程一直不退出,父进程就会一直卡在这里无法执行其它地逻辑了。
我们也可以采用vector和while循环非阻塞式地去遍历进程主逻辑并回收,也可以在waitpid地第一个参数传入-1,表示我们可以等待任意一个进程。
但是:如果我们不想等待子进程,并且我们还想让子进程退出后,自动释放僵尸进程呢?
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main()
{
// OS默认就是忽略的,但是这东东我们手动设和OS自己搞差别很大,子进程退出时,OS不知道要不要释放资源所以只能给僵尸状态,而我们手动忽略时,是以用户的身份告诉OS,我明确了要回收
signal(SIGCHLD, SIG_IGN); // 手动设置对子进程进行忽略
if (fork() == 0)
{
cout << "child: " << getpid() << endl;
sleep(5);
exit(0);
}
while (true)
{
cout << "parent: " << getpid() << "执行我自己的任务" << endl;
sleep(1);
}
}