Linux | 信号
目录
前言
一、信号基础概念
1、生活中的信号
2、Linux中的信号
二、信号的产生
1、接口介绍
2、信号产生的方式
(1)终端按键的方式产生信号
(2)系统调用接口
a、kill
b、raise
c、abort
(3)由软件条件产生信号
a、由管道产生SIGPIPE信号
b、alarm函数产生的信号
(4)硬件异常产生的信号
a、除零产生的信号
b、野指针产生的信号
3、核心转储
三、信号保存
1、概念补充
2、内核结构
3、sigset_t
4、信号集操作函数
(1)sigset_t相关接口
(2)sigprocmask
(3)sigpending
5、测试代码
四、信号处理
1、用户态与内核态
2、什么时候处理信号
3、处理信号的整个流程
前言
本章主要介绍Linux信号相关内容,主要从信号产生、信号保存、信号处理三个方面详细介绍信号的整个生命周期。
一、信号基础概念
1、生活中的信号
在日常生活中,有各种信号,如我们红绿灯、下课铃、电话铃等等;那么我就有以下几个问题来引入我们今天的话题;
问题一:我们是怎么认识这些信号的?
这个问题可能有很多答案,可能是我们幼儿园老师教我们的,也有可能是父母从小告诉我们的等等诸多答案;
问题二:我们认识这些信号是否会处理这些信号?
这不是必然吗?既然认识,我们当然也会处理这些信号,我们从认识这些信号开始,就有人会告诉我们如何对这些信号进行处理,或我们自己对这些信号有主观认识,自己判断告诉我们自己如何处理这些信号,完成对应的动作;
问题三:我们接收到某种信号后是否会马上处理这些信号呢?
答案当然也是否定的,我们接收到某些信号后,并不会也马上处理这些信号。比如我们在打游戏的时候,突然电话响了,对方告诉我们是外卖到了,而我们此刻可能是这把游戏最重要的时候,我们此时可能就告诉外卖小哥就放在门口,也就是说,我们不会立刻处理这个信号,至于什么时候处理这个信号得看我们什么时候有合适的时间,这个合适的时间可能在我们这把游戏结束,也可能我们打完这把游戏忘记了这个事情,以至于不处理这个信号;
2、Linux中的信号
我们Linux中的信号也与生活中的信号相关,分别以上面三个问题的形式同样回答Linux中的信号;在生活中收到信号的主体是人,也就是我们自己,而在程序中,收到信号的主体也就是那个进程;
1、进程是如何认识这些信号的?
要知道操作系统是程序员写的,信号当然也是程序员进行定义,我们可以通过 kill -l 来查看Linux中有哪些信号,如下图所示,其中1到31号是我们的普通信号,34到64是实时信号,本章不做重点讲解;
2、进程是否会立即处理这些信号?
当然也可能不会,可能当前有比处理这个信号优先级更高的事情需要处理,因此我们需要后续一个合适的时间进行处理,既然我们需要后面再做处理,那么我们肯定也需要将这个信号保存起来,不然信号会丢失;这个信号会保存在我们进程的PCB中,那么怎么保存呢?我们的信号有31个,且我们要保存起来,不难想到,我们可以用位图进行保存,这样最少仅需31个比特位就可以保存这些信号,我们可以用 1表示收到这个信号,0表示未收到;
3、处理信号有哪几种方式?
三种,分别为 默认方式 、 忽略 、 用户自定义;
二、信号的产生
1、接口介绍
在正式介绍信号的产生前,首先我们先认识一个系统调用 --- signal;
该系统调用的主要功能是使一个一个信号到来时执行指定的自定义动作;接着我们来看参数;
参数一:信号数字(这个参数我们可以通过kill -l 查看,也可以通过man 7号手册查询 signal),这个参数我们可以填大写的宏,也可以直接填数字;
参数二:这个参数是一个函数指针,为指定信号注册一个函数动作,这个参数就是要执行的自定义动作;
返回值:若调用成功,这个函数返回原来的信号动作,若失败则返回SIG_ERR,错误码被设置;
2、信号产生的方式
(1)终端按键的方式产生信号
我们可以通过键盘在中终端上输入特定的组合键产生信号,给我们当前进程发送信号;如下面一段程序;
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main()
{
while(true)
{
cout << "我正在运行..., pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
我们可以发现该程序为一个死循环,但是我们可以可以通过组合键 ctrl + c 终止该程序,其实ctrl + c 便是向当前前台进程发送一个 2 号信号,也就是SIGINT(中断);我们按下组合键 ctrl + \ 向当前前台进程发送 3 号信号退出当前进程;
我们再更改一下代码,使效果看起来更加明显;我们捕捉一下 2 号信号,使其完成我们指定的动作;
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "signum: " << signum << endl;
}
int main()
{
// 注册2号信号的捕捉方法
signal(2, handler);
while(true)
{
cout << "我正在运行..., pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
我们编译运行,发现结果如下;
当我们使用 ctrl + c 发送2号信号时,由于我们提前对2号信号进行了自定义动作,因此每次我们发送时都会给我们打印当前信号值;而我们发送3号信号时,程序就退出了,因为我们没有对3号信号进行捕捉,注册自定义动作,因此3号信号完成的是默认动作,退出程序;
总结:如何理解终端键盘输入产生的信号?
首先我们通过键盘输入引起中断,我们操作系统得到键盘的输入后,对输入组合键进行解析,然后查找进程列表,找到当前在前台运行的进程,操作系统就在进程的PCB中写入特定的信号,也就是将保存信号的位图上特定的比特位置为1;
(2)系统调用接口
a、kill
这个就很简单了,我们之前学过一个命令行指令kill,实际上,该指令就是向指定进程发送指定的信号;还是上面的程序;
实际上,我们不止命令行可以向进程发送信号,我们也可以通过系统调用,有一个同名系统调用kill,如下所示;
这个系统调用我们甚至看一眼都会使用,第一个参数为进程的pid,也就是我们想向哪一个进程发送信号,第二个参数为信号编号;若调用成功返回0,调用失败则返回-1,错误码被设置;我们可以通过这个系统调用封装一个我们 kill 命令行指令;
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <signal.h>
#include <sys/types.h>
// 我们kill使用格式: kill pid signum
int main(int argc, char* args[])
{
if(argc != 3)
{
std::cout << args[0] << " pid signum" << std::endl;
exit(1);
}
pid_t id = atoi(args[1]);
int signum = atoi(args[2]);
int n = kill(id, signum);
if(n == - 1)
{
perror("kill");
exit(2);
}
return 0;
}
b、raise
我们还可以使用 raise 系统调用发送信号,与kill不同的是,raise是只给当前进程发送信号,并不能给指定进程发送信号,这个系统调用的使用就更简单了;如下所示;
该系统调用只有一个参数就是信号值,返回值与kill返回值相同,若成功返回0,若失败返回-1,错误码被设置;下面我们编写一个小程序,使当前进程5秒后给自己发送3号信号;
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "signum: " << signum << endl;
}
int main()
{
// 注册2号信号的捕捉方法
signal(2, handler);
int count = 0;
while(true)
{
cout << "我正在运行..., pid: " << getpid() << endl;
sleep(1);
count++;
if(count == 5)
{
raise(3);
}
}
return 0;
}
我们不断按 ctrl + c 由于被捕捉了,执行了自定义动作,所以没有退出,而 5 秒后,由于该程序自己给自己发送 3 号信号,所以退出了;
c、abort
接下来这个函数类似于exit,给我们当前进程发送 6 号信号,使我们的进程退出;该参数无参数,由于总是成功,所以也没有返回值;我们可以将上面代码进程更改,5秒后调用abort函数;如下所示;
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "signum: " << signum << endl;
}
int main()
{
// 注册2号信号的捕捉方法
signal(2, handler);
int count = 0;
while(true)
{
cout << "我正在运行..., pid: " << getpid() << endl;
sleep(1);
count++;
if(count == 5)
{
//raise(3);
abort();
}
}
return 0;
}
总结:如何理解我们通过系统调用产生的信号
首先用户调用系统调用,执行系统调用代码,然后操作系统提取系统调用的参数,如进程pid、信号等,然后操作系统找到进程PCB控制块,并向其位图里写入对应的信号;
(3)由软件条件产生信号
a、由管道产生SIGPIPE信号
前面我们在学习匿名管道时做过一个小实验,连接管道的双方;若写端管道文件描述符关闭,则读端会读到文件的末尾,返回0;若读端管道文件描述符关闭,写端进程则直接被终止!写端进程是如何被终止的呢?实际上,就是我们OS向写端发送了一个SIGPIPE信号,不信,我们可以做如下实验;
实验描述:我们对13号信号(SIGPIPE)进行捕获,使其不会退出进程,而是打印信息,我们让子进程作为读端,父进程作为写端,5秒后,子进程关闭写端管道文件;我们查看父进程是否会执行我们自定义捕捉动作即可;
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "pid:" << getpid() << ", 收到了信号: " << signum << std::endl;
}
int main()
{
// 注册SIGPIPE方法
signal(SIGPIPE, handler);
// 创建匿名管道
int pipefd[2];
pipe(pipefd);
// 创建子进程
int id = fork();
if(id == 0)
{
// 子进程(读端)
close(pipefd[1]); // 关闭写端
int count = 0;
char buf[1024];
while(true)
{
ssize_t sz = read(pipefd[0], buf, sizeof(buf) - 1);
buf[sz] = '\0';
std::cout << "from father# " << buf << " , 我的pid: " << getpid() << std::endl;
sleep(1);
count++;
if(count == 5)
{
// 关闭子进程读端
std::cout << "子进程要关闭管道读端啦" << std::endl;
close(pipefd[0]);
sleep(3); // 子进程不马上退出
break;
}
}
std::cout << "子进程退出" << std::endl;
exit(0);
}
// 父进程(写端)
close(pipefd[0]); // 关闭读端
const char* buf = "我是父进程,我在给你发信息";
while (true)
{
std::cout << "写入中...." << std::endl;
write(pipefd[1], buf, strlen(buf));
sleep(1);
}
// 进程等待
wait(nullptr);
return 0;
}
我们发现,当读端关闭后,每次我们想像管道中写入数据,都会收到我们的13号信号;原本13号信号会使父进程退出,而我们对13号信号的动作进行自定义捕捉,故没有退出,因为我们一直向管道文件中写入数据,故我们会不断收到13号信号;
b、alarm函数产生的信号
这个系统调用类似一个闹钟,每隔一段时间都会向当前进程发送14号信号(SIGALRM),该系统调用声明如下;
这个系统调用只有一个参数,参数为秒数,若干秒后会向当前进程发送14号信号,默认动作是终止进程;该函数返回值一般为0,若上次使用alarm的时间还没有到,再次调用时,重新设置闹钟时间,并返回上次设置还剩下的时间;我们可以通过该系统调用简单的实现一个定时任务的程序,如下所示;
#include <iostream>
#include <vector>
#include <functional>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
// 定义一个任务类型
using task_t = std::function<void()>;
// 定义一个任务数组
std::vector<task_t> tasks;
// 数据库任务
void mysqlTask()
{
std::cout << "正在执行数据库任务" << std::endl;
}
// 刷新磁盘
void flushDiskTask()
{
std::cout << "正在执行刷新磁盘任务" << std::endl;
}
// 加载任务进任务数组
void load()
{
tasks.push_back(mysqlTask);
tasks.push_back(flushDiskTask);
}
void handler(int signum)
{
for(auto& t : tasks)
t();
sleep(1);
// 设置下次执行任务时间
alarm(3);
}
int main()
{
load(); // 加载任务队列
// 对14号信号方法进行录入
signal(SIGALRM, handler);
alarm(5);
// 防止主进程退出
while(true);
return 0;
}
可以发现,每隔3秒都会定时完成我们布置的任务;类似这样的程序我们还可以写出很多;
总结:如何理解软件条件产生的信号?
首先操作系统识别某种软件条件是否满足或触发,若满足或触发则找到对应进程的PCB控制块,向其内部位图将特定信号的比特位置为1;
(4)硬件异常产生的信号
a、除零产生的信号
对于初学编程的同学,经常听到说不能除零,会引起除零错误,但是我们其实根本不知道什么叫除零错误,除零错误是如何引发的,我们可能都以为除零错误是我们程序代码引起的软件错误,实际上是一种硬件错误,接下来我们用代码来演示除零错误,除零错误会发出8号信号(SIGFPE);
#include <iostream>
#include <vector>
#include <functional>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "收到信号signum: " << signum << endl;
sleep(1);
}
int main()
{
signal(SIGFPE, handler);
int a = 10;
a /= 0;
// 防止程序退出
while(true);
return 0;
}
我们对8号信号进行了捕捉,改变其行为,但是我们发现了一个很神奇的现象,我们只除零了一次,却不断的收到8号信号;这又是为什么呢?这与硬件的设计有关了;
首先,我们要明白的一点是我们的计算是由CPU来进行了,我们的编译器将我们的代码翻译成机器语言,我们的CPU只会傻傻的一直取机器指令执行,我们的CPU内实际上有两种类型寄存器,一种是我们程序员能够使用的如eax、ebx等,另一种是状态寄存器,当我们CPU发现有除零时,会将我们状态寄存器某个比特位置为1,表示结果异常,此时,我们的操作系统会在CPU计算完后检测寄存器状态,发现特定的比特位被置为1了,知道计算结果有问题,于是找到当前进程的PCB,往PCB中的储存信号特定的比特位置为1,完成信号发送;可是由于我们CPU硬件上状态寄存器特定的比特位还是1,因此会一直不断的发送8号信号,所以会有上面的现象;
b、野指针产生的信号
野指针错误相信对每个C/C++程序员来说是一个经典错误了,几乎每一个C/C++程序员都会犯的错误,但你是否真的了解野指针错误呢?实际野指针错误也是一种硬件错误,我们的野指针被识别后,操作系统发送11号信号(SIGSEGV)给当前进程,使当前进程退出,接下来我们通过下面代码进行测试;
#include <iostream>
#include <vector>
#include <functional>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "收到信号signum: " << signum << endl;
sleep(1);
}
int main()
{
signal(SIGSEGV, handler);
int* p = nullptr;
*p = 100;
// 防止程序退出
while(true);
return 0;
}
与我们的除零错误一样,也是不断的收到11号信号,这是怎么回事呢?想必聪明的你多少也能猜到一点吧,这肯定也跟我们的硬件有关;
有了前面地址空间的学习,我们都知道我们目前看到的所有地址以及CPU看到的地址都是虚拟地址,都需要通过页表映射找到真实的物理空间,实际上,我们还需要用到一个硬件 ---- MMU,我们是通过 页表+MMU 完成虚拟地址与物理地址的相互转化的,如上述代码,当我们传入一个非法地址时,我们MMU检测到这是一个非法地址,将这个错误记录下来,当需要取回结果时,操作系统检测到MMU异常,因此会找到当前进程的PCB,并将PCB指定的的位置置为1,完成信号产生;而这个过程并不会将我们硬件错误的记录销毁,因此也会不断的发出指定信号给当前进程;
信号发送总结:
我们可以发现所有的信号发送,都是我们的操作系统首先识别到信号,然后往指定进程的PCB控制块写入指定信号;
3、核心转储
我们发现,我们上述学的所有信号默认行为都是退出,那么它们之间是否存在区别呢?我们通过指令 man 7 signal 查询信号;如下图所示;
我们可以找到上述这张表,其中signal是指哪个信号,value指的是该信号对应的值,Action指的是对应的默认动作,comment指的是该信号对应的描述;我们仔细惯出Action,我们可以找到以下几类行为;
Term:退出当前进程
Core:退出当前进程并产生核心转储文件
Ign:忽视
Stop:暂停当前进程
Cont:继续运行当前进程
看到这个Core dump 不知道你是否想起什么了呢?这便是我们之前讲解进程等待留下的一个伏笔,当时有一个core dump标记位,若不清楚的可查看下面这篇文章;
Linux | 进程终止与进程等待-CSDN博客
这个终止信号想必到这大家都明白了是什么了吧,就是当前进程因什么信号而退出;关于这个core dump标记位,我们首先明白什么是核心转储;
核心转储:当我们的进程出现某种异常时,操作系统将当前进程在内存中的核心数据转存到磁盘中去;
core dump文件:所谓core dump文件就是核心转储过程中转存到磁盘的文件;
注意:对于云服务器来说,核心转储功能默认是关闭的,我们需要将这个功能打开;我们可以通过 ulimit -a 查看核心转储是否关闭;
我们可以看到core file size大小为0,也就是说我们核心转储功能是关闭的;我们通过 ulimit -c 大小 来设置核心转储文件大小,设置后就打开这个文件了;如下所示;
我们接下来使用3号信号来测试核心转储功能,首先,我们还是将core file size 大小设置为 0;然后运行下面代码;
#include <iostream>
#include <vector>
#include <functional>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main()
{
pid_t id = fork();
if(id == 0)
{
// 子进程
int a = 10;
a /= 0; // OS会对子进程发送8号信号
// 正常退出
exit(0);
}
// 父进程
int status = 0;
waitpid(id, &status, 0);
if(WIFEXITED(status))
{
// 正常退出
cout << "exit code: " << WEXITSTATUS(status) << endl;
}
else
{
// 异常退出
cout << "exit signal: " << (status & 0x7F) << ", core dump: " << ((status >> 7) & 1) << endl;
}
return 0;
}
我们发现core dump 标记为为 0, 且退出信号确实是SIGFPE--- 8号信号,符合我们预期,因为core dump 文件大小被我们设置成0,不会生成core dump 文件;接下来我们将core dump 文件大小设置为 10240,再次运行查看答案;
这次我们core dump 标记位变成了1,且我们生成了一个core dump 文件,这个文件是以core. + 进程pid命名;那么这个core dump 文件到底有什么用呢?
我们可以使用core dump 文件进行调试,我们给g++编译选项加上 -g,然后再次进行编译;
我们在gdb中输入core-file + core dump文件名可以快速定位到我们出错的那一行代码;那么我们云服务器为什么还默认关闭核心转储功能呢?不难发现,我们的 core dump 文件是十分大的,而我们的服务器上运行的程序一般是不会停的,就算程序挂掉了,也可能会有自动重启程序进行重启,假如我们程序一直重启,同时也一直生成core dump文件,最后我们的磁盘很快就会被核心转储文件占满了;
三、信号保存
1、概念补充
在正式学习信号保存这部分内容之前,我们先学习一组概念;
信号递达:实际执行信号的处理动作;
信号未决:信号产生到递达的这段之间的状态;
信号阻塞:给某个信号标识,表示该信号即使已经产生,但不允许递达,只有将这个阻塞标识取消,才可以递达,被阻塞的信号处于未决状态;
注意:阻塞和忽略是不同的,信号阻塞指不能被递达的信号,而忽略是一种处理信号的默认动作;
2、内核结构
前面我们讲过信号会被保存在PCB中的一个位图中,实际上,PCB除了会保存信号产生位图,还会保存阻塞位图与处理动作的函数指针数组;如下图所示;
当我们要处理信号时,首先会遍历查看pending位图,发现SIGHUP信号产生了,接着我们查看block位图对应比特位,发现没有阻塞,我们就可以去handler数组中找到对应的处理方法,发现是默认处理动作,进行默认处理;对于SIGINT的分析也是如此,首先查看pending位图发现其信号产生了,我们再看其阻塞位图,发现这个信号同时也被阻塞了,故不调用,继续往后遍历查找pending位图;
3、sigset_t
从上面图中可得知,每个信号都可用0或1来表示其是否阻塞、是否发生;因此我们可以用位图来表示,内核给我们提供了一种数据类型sigset_t,我们便是用这种数据类型来存储block与pending,sigset_t也称信号集,阻塞信号集也称做信号屏蔽字;
4、信号集操作函数
(1)sigset_t相关接口
#include <signal.h>
int sigemptyset(sigset_t *set); // 将信号集所有比特位置为0
int sigfillset(sigset_t *set); // 将信号集所有比特位位置为1
int sigaddset (sigset_t *set, int signo); // 将某个信号对应比特位置为1
int sigdelset(sigset_t *set, int signo); // 将某个信号对应比特位置为0
int sigismember(const sigset_t *set, int signo); // 查看某个信号对应比特位是否被置为1
(2)sigprocmask
这个函数可以读取或更改阻塞信号集;
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数一:这个参数决定我们要对阻塞信号集做哪些操作,主要有以下几个选项;
SIG_BLOCK | 将我们要添加到信号屏蔽字的信号添加到参数二的信号集中 |
SIG_UNBLOCK | 将我们要从信号屏蔽字中解除的信号放到参数二的信号集中 |
SIG_SETMASK | 将我们要参数二的信号集设置进信号屏蔽字中 |
参数二:与参数一相关;
参数三:返回原来的信号屏蔽字(输出型参数);
返回值:若函数调用成功则返回0,失败则返回-1,错误码被设置;
(3)sigpending
这个系统调用主要用于读取未决信号集;通过参数set返回;
int sigpending(sigset_t *set);
参数一:返回未决信号集(输出型参数);
返回值:若函数调用成功则返回0,失败则返回-1,错误码被设置;
5、测试代码
1、我想验证我将所有信号量都阻塞和捕捉了那么是否该进程无法退出了;
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int signum)
{
std::cout << "signum: " << signum << std::endl;
}
// 打印未决位图
void showSignal()
{
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
for(int i = 1; i < 32; i++)
{
// 捕捉
signal(i, handler);
// 设置屏蔽字
if(sigismember(&pending, i))
std::cout << "1";
else
std::cout << "0";
}
std::cout << ", pid: " << getpid() << std::endl;
}
int main()
{
// 内存级别设置信号屏蔽字
sigset_t block;
sigemptyset(&block);
for(int i = 0; i < 32; i++)
{
sigaddset(&block, i);
}
// 将信号集设置进内核
sigprocmask(SIG_SETMASK, &block, nullptr);
// 死循环
while (true)
{
showSignal();
sleep(1);
}
return 0;
}
显然易见,我们的9号信号是无法被捕捉和阻塞的!这是一个管理员信号,防止恶意程序导致无法终止进程;
在上述所有接口中,我们似乎没有发现修改内核中pending位图的接口,可能你会觉得疑惑,实际上,我们根本不需要修改pending位图的接口,因为我们可以通过kill等接口给进程发送信号;
四、信号处理
前面我们学习了信号的捕捉函数signal,这个函数可以改变信号到来时所做动作;即信号处理的动作;但是我们从始至终我们都没有搞清楚一个问题,信号是在什么时候被处理的呢?我们前面只提了一个很模糊的概念,在合适的时候,那么这个合适的时候又是什么时候呢?这里我们就来探索这个问题;
1、用户态与内核态
在介绍之前,我们得补充一组概念;我们操作系统在执行代码的时候,其实有两种状态,分别为用户态和内核态;当操作系统执行用户编写的代码的时候,通常是以用户态的状态运行,当操作系统执行内核代码的时候,通常是以内核态的状态运行;内核态对于用户态来说拥有更高的权限;
前面我们在介绍虚拟地址空间的时候,我们讲过在32位机器下, 1-3G属于用户空间,3-4G属于内核空间;这里再补充一个知识,我们用户空间的代码和数据是通过页表来建立映射到真实物理空间的,这个页表我们称为用户级页表,每个进程都有一个自己的用户及页表;而我们的内核空间也是通过页表映射到物理内存中的,只不过我们用的这张页表是内核级页表,内核级页表是所有进程共享的;
这样我们就能实现在同一个进程地址空间内执行用户代码和内核代码;
2、什么时候处理信号
有了上面知识的铺垫,接下来的内容就容易很多了;我们处理信号本质上是什么?本质上就是处理遍历pending位图,然后查看对应信号屏蔽字是否被设置,若没有则在handler数组中找对应处理方法,进行回调;这些数据结构都是在PCB控制块内的!因此我们处理信号就是在内核中,故我们是在内核态中处理信号的!
接下来我们就应该研究什么时候会到内核态,也就是我们什么时候可以处理信号!而一般情况下,我们只用使用系统调用、中断等情况下会进入内核态;而我们一般在出内核态之前进行信号检测与处理;
可能有一些小伙伴们就有问题了,那要是我整个代码就是一个死循环,没有任何别的操作,我也不调用系统调用,那么我是不是不会进入内核态了?显然,这是错误的,我们要知道我们现在的计算机大部分都是分时操作系统,而通常使用时间片轮转的方式进行调度进程,而我们一旦要进程调度,就必然会进入内核态中,所以这个问题不用担心!
3、处理信号的整个流程
处理信号实际上有三个动作,前面我们也有提过,分别为 默认动作、忽略 以及 用户自定义捕捉;实际上,这三种中,前两种操作系统已经给提供了;如下图所示;
我们可以直接将这两个参数填到 signal 的参数二中,其中我们主要重点讲解第三种方式的信号处理所有流程;
对于前两种来说,当我们使用 signal 注册了方法,信号一旦到来且没有阻塞,此时由于中断、调用系统调用等原因进入内核态中时,我们执行完内核代码后,准备回到用户态前,会进行信号检测与处理,当我们发现信号处理动作为默认动作或忽略时,我们直接可以顺手做了,然后再返回用户态;
对于用户自定义捕捉处理动作来说,这个过程可能略微复杂;如下图所示;
这里可能有小伙伴会有一些细节上的问题,下面我来列出几个;
问题一:第三步到第四步中,我们直接再内核态处理用户设定的处理方法不可以吗?内核态的权限不是比用户态大吗?
是的,内核态的权限比用户态大,故但凡用户态能够执行的代码,我们内核态也一定能执行,但是我们是否应该完全相信用户自己写的信号处理方法?若用户实现的处理方法有一些非法请求,而我们内核态恰好有执行权限,那么这不就会破坏内核吗?故我们要切回用户态执行用户写的信号处理方法;
问题二:第四步到第五步,我们为什么还要特意返回内核态,不直接返回上一次陷入内核前执行的代码处?
我们返回内核态的原因就是因为要切回陷入内核前执行的代码,而我们再用户态是无法做到的,所以我们必须先回到内核态,然后再从内核态返回用户态;
问题三:当我们处理完一个信号后,我们要返回用户态时,也就是第五步到第一步,此时我们又有信号来了怎么办?若是一种有信号来,我们的进程反复切换用户态和内核态来处理信号了吗?
当我们处理完一个信号后,若有其他信号带来,我们会提前将其他信号临时设置为阻塞,然后返回用户态,只有下一次进入内核态时,我们才会处理后续到来的信号;
关于上述图,我们可以使用下面的方法进行记忆;