目录
一、信号概念
二、信号的作用
三、信号的特性
四、信号捕捉初识
五、信号产生
(一)通过终端按键产生信号
(二)硬件中断
(三)系统调用产生信号
1. kill 函数
2. raise 函数
3. abort 函数
(四)由软件条件产生信号
1. alarm 设置闹钟
2. 测试算力
(五)硬件异常产生信号
1. 除 0 导致异常
2. 状态寄存器
3. 野指针导致异常
(六)核心转储
1. 核心转储概念
2. 打开关闭核心转储
3. 核心转储作用
六、信号保存
(一)概念
(二)具象理解
(三)在内核中的表示
(四)sigset_t 信号集
(五)信号集操作函数
1. sigset_t 类型的操作函数
2. sigprocmask
3. sigpending
七、信号处理
(一)信号的处理时机
(二)何为"合适"的时机
(三)用户态和内核态
1. 概念
2. 内核态与用户态的转化
3. 重谈进程地址空间
(四)信号的处理流程
(五)信号的捕捉
1. 内核如何实现信号的捕捉?
2. 信号捕捉函数 - sigcation
八、小结
信号 是信息传递的承载方式,一种信号往往代表着一种执行动作,用来通知进程系统中发生了一个某种类型的事件。比如:
信号是多种多样的,并且一个信号对应一个事件,这样才能知道收到一个信号后,到底是一个什么事件,应该如何处理这个信号。
当然这些都是生活中的 信号,当产生这些 信号 时,我们会立马想到对应的 动作 ,这是因为 我们认识并能处理这些信号。
但是对于计算机来说,是死的,不具有意识常态。于是程序员们给操作系统植入了一批 指令,一个指令表示一种特殊动作,而这些指令就是 信号(进程信号)。
通过 kill -l 查看当前系统中的信号集合表:
这些就是当前系统中的 进程信号,一共62个,其中1-31号信号为 普通信号,用于 分时操作系统;剩下的34-64号信号为 实时信号,用于 实时操作系统。二者区别:
普通信号只保存它有无产生,实时信号可以保持很长时间。
因为我们的系统属于 分时操作系统,所以本篇博客只介绍普通信号,不涉及实时信号。 当然也不是全部研究,部分信号只做了解即可。
在之前也是略微使用过了信号:
就连常用的 ctrl + c 和 ctrl + d 热键本质上也是 信号。
查看信号对应的信息指令:
man 7 signal
具体功能可以查阅本篇博客: Linux中的31个普通信号
进程信号由 信号编号 + 执行动作 构成,一个信号对应一种动作,对于进程来说,动作无非就这几种:终止进程、暂停进程、恢复进程,3 个信号就够用了啊,为什么要搞这么多信号?
并且 普通信号 就 31 个,这就是意味着所有普通信号都可以通过位图数据结构存储在一个 int
中,表示是否收到该信号(信号的保存)
所以信号被细化了,不同的信号对应不同的执行动作,虽然大部分最终都是终止进程。
信号有这么多个,并且多个进程可以同时产生多个信号,操作系统为了管理,先描述、再组织,在 PCB
中增加了 信号相关的数据结构:signal_struct
,在这个结构体中,必然存在一个 位图结构 uint32_t signals
存储 1~31
号信号的有无信息
//信号结构体源码(部分)
struct signal_struct {
atomic_t sigcnt;
atomic_t live;
int nr_threads;
wait_queue_head_t wait_chldexit; /* for wait4() */
/* current thread group signal load-balancing target: */
struct task_struct *curr_target;
/* shared signal handling: */
struct sigpending shared_pending;
/* thread group exit support */
int group_exit_code;
/* overloaded:
* - notify group_exit_task when ->count is equal to notify_count
* - everyone except group_exit_task is stopped during signal delivery
* of fatal signals, group_exit_task processes the signal.
*/
int notify_count;
struct task_struct *group_exit_task;
/* thread group stop support, overloads group_exit_code too */
int group_stop_count;
unsigned int flags; /* see SIGNAL_* flags below */
/*
* PR_SET_CHILD_SUBREAPER marks a process, like a service
* manager, to re-parent orphan (double-forking) child processes
* to this process instead of 'init'. The service manager is
* able to receive SIGCHLD signals and is able to investigate
* the process until it calls wait(). All children of this
* process will inherit a flag if they should look for a
* child_subreaper process at exit.
*/
unsigned int is_child_subreaper:1;
unsigned int has_child_subreaper:1;
//……
};
信号的处理方式:
进程的对于信号的执行动作是可自定义的,默认为系统预设的 默认动作
signal
,可以要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch) 一个信号。
- 进程在没有收到信号时就已经知道了一个信号应该怎么被处理了,这说明进程能够识别并处理信号。
- 信号对于进程来说是随时都有可能产生的,因此进程与信号是异步的!当信号产生时,进程可能正在执行优先级更高的事情,这时进程并不能立即处理信号,需要在合适的时候再进行处理,因此在这个空窗期内信号要能够被保存起来,这说明进程具有记录信号的能力!
- 进程记录的信号可能有很多个,因此进程需要用一种数据结构去管理所有的信号,在Linux下对于信号的管理采用的是位图结构,比特位的位置代表信号的编号。
- 所以所谓的发送信号本质就是:直接修改特定进程的信号位图中的特定的比特位。(由0 -> 1)
- 进程信号的位图结构本质还是属于task_struct里面的数据,因此对于进程信号的位图结构里面的数据的修改,只能有操作系统来完成,即无论有多少种信号产生的方式,最终都必须让OS来完成最后的发送过程!
- 信号并不是立即处理的,它会在合适的时间段进行统一处理
所以 进程信号 可以分为三步:信号产生 —> 信号保存 —> 信号处理
signal 函数可以用来 修改信号的执行动作,也叫注册自定义执行动作
signal 调用成功返回上一个执行方法的值(其实就是下标,后面介绍),失败则返回 SIG_ERR
,并设置错误码。
参数1 就是信号编号,为 int ,单纯地传递 信号名也是可以的,因为信号名其实就是信号编号的宏定义。
参数2 是一个函数指针,意味着需要传递一个 参数为 int ,返回值为空的函数对象。
void handler(int) //其中的函数名可以自定义
显然,signal 函数是一个 回调函数,当信号发出时,会去调用相应的函数,也就是执行相应的动作
我们先对 2
号信号注册新动作,在尝试按下 Ctrl + C,看看它发出的究竟是不是 2
号信号
void handler(int signo)
{
cout << " catch a signal " << signo << endl;
}
int main()
{
signal(2, handler);
while (true)
{
cout << "我是一个进程,我正在运行..... | pid: "<< getpid() << endl;
sleep(1);
}
return 0;
}
当我们修改 2
号信号的执行动作后,再次按下 Ctrl + C尝试终止前台进程,结果失败了!执行动作变成了我们设定的新动作,而不是原先的终止进程。
这足以证明 Ctrl + C 就是在给前台进程发出 2
号信号,Ctrl + C 失效后,可以通过 Ctrl + \ 终止进程,发出的是 3
号信号(3
号信号在发出后,会生成 核心转储 文件)
普通信号只有 31 个,如果把所有普通信号的执行动作都改了,会发生什么呢?难道会得到一个有着 金刚不坏 之身的进程吗?
void handler(int signo)
{
cout << " catch a signal " << signo << endl;
}
int main()
{
// 给所有普通信号设定自定义方法
for(int i=1;i<32;i++) signal(i, handler);
while (true)
{
cout << "我是一个进程,我正在运行..... | pid: "<< getpid() << endl;
sleep(1);
}
return 0;
}
大部分信号的执行动作都被修改了,但 9
号信号没有,因为 9
号信号是 SIGKILL
,专门用于杀死进程,只要是进程,他都能干掉 。
19
号信号 SIGSTOP
也无法修改执行动作,所以前面说过,9
号 SIGKILL
和 19
号 SIGSTOP
信号是很特殊的,经过特殊设计,不能修改其执行动作!
通俗来说就是命令行操作。
在Linux下输入命令可以在Shell下启动一个前台进程,当我们想要终止一个前台进程时,我们可以按下 Ctrl + C 来进行终止这个前台进程,其实这个 Ctrl + C 也是一个信号,它对应的信号的2
号信号SIGINT,这个信号对应的默认处理动作就是终止当前的前台进程。
示例一段死循环代码:
#include
#include
using namespace std;
int main()
{
while(true)
{
cout << "我是一个进程,我正在运行…… | PID: " << getpid() << endl;
sleep(1);
}
return 0;
}
用户按下 Ctrl + C,这个键盘输入产生一个硬件中断 ,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。
Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl + C 这种控制键产生的信号。Ctrl + C 终止的是当前正在运行的前台进程,如果在程序运行时加上
&
表示让其后台运行,成为后台进程,此时会发现无法终止进程:像这种后台进程 Ctrl + C 是无法终止的,可以通过
kill -9 PID
发出9
信号终止它。
当我们从键盘按下 Ctrl + C 时,发生了这些事:CPU
获取到键盘 “按下” 的信号,调用键盘相应的 “方法” ,从键盘中读取数据,读取数据后解析,然后发出 2
号信号。
其中 CPU
捕获键盘 “按下” 信号的操作称为 硬件中断。
CPU
中有很多的针脚,不同的硬件对应着不同的针脚,每一个针脚都有自己的编号,硬件与针脚一对一相连,并通过 中断控制器(比如 8259
)进行控制,当我们按下键盘后:
CPU
发送信息,包括键盘对应的针脚号CPU
将获取到的针脚号(中断号)写入 寄存器 中这样 CPU
就知道是 键盘 发出的信号,然后就会去调用 键盘 的执行方法,通过键盘的读取方法,读取到 Ctrl + C 这个信息,转化后,就是 2
号信号,执行终止前台进程的动作。
键盘被按下 和 键盘哪些位置被按下 是不一样的
CPU
确定对应的读取方法读取方法
从键盘中读取数据注:键盘读取方法如何进行读取,这是驱动的事,我们不用关心
硬件中断 的流程与 进程信号 的流程相同,同样是 先检测到信号,然后再去执行相应的动作,不过此时发送的是 中断信号,执行的是 调用相应方法罢了。
信号 与 动作 的设计方式很实用,操作系统只需要关注是否有信号发出,发出后去中断向量表中调用相应的方法即可,不用管硬件是什么样、如何变化,做到了 操作系统 与 硬件 间的解耦。
返回值:成功返回 0
,失败返回 -1
并设置错误码
参数1:待操作进程的 PID
参数2:待发送的信号
下面来简单用一下,程序运行 5
秒后,自己把自己杀死:
#include
#include
#include
using namespace std;
int main()
{
int n = 1;
while(true)
{
cout << "我是一个进程,运行了" << n << "秒 | PID: " << getpid() << endl;
sleep(1);
n++;
if(n > 5) kill(getpid(), SIGKILL);
}
return 0;
}
kill 函数当然也可以发送其他信号,这里就不一一展示了,其实命令行中的 kill 命令就是对 kill 函数的封装,kill 信号编号 PID
其中的参数2、3不正是 kill 函数所需要的参数吗?所以我们可以尝试自己搞一个 mykill 命令:
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
void Usage(const std::string proc)
{
cout << "Usage:" << std::endl;
cout << " " << proc << " 信号编号 目标进程" << std::endl;
}
int main(int argc, char *argv[])
{
// 参数严格限制
if (argc != 3)
{
Usage(argv[0]);
exit(-1);
}
pid_t pid = atoi(argv[2]);
int signo = atoi(argv[1]);
int return_val = kill(pid, signo);
if(return_val == -1)
{
cout << "错误码:" << errno << " 错误信息:" << strerror(errno) << endl;
}
return 0;
}
发送信号的还有一个 raise 函数,这个函数比较奇怪,只能 自己给自己发信号:
返回值:成功返回 0
,失败返回 非0
就只有一个参数:待发送的信号
可以这样理解:raise 是对 kill 函数的封装,每次传递的都是自己的 PID
int main()
{
sleep(1);
cout << "我要被暂停了,我的PID是:" << getpid() << endl;
raise(19);
cout << "我要继续运行了,我的PID是:" << getpid() << endl;
return 0;
}
我们用 raise 函数给当前进程发送暂停信号 19 SIGTOP,暂停以后我们可以在命令行中给进程发送继续运行 18 SIGCONT 信号:
abort 是 C
语言提供的一个函数,它的作用是 给自己发送 6
号 SIGABRT
信号
abort 函数使当前进程接收到信号而异常终止,abort 函数其实是向进程发送6
号信号SIGABRT
,就像exit
函数一样,abort 函数总是会成功的,所以没有返回值,值得注意的是就算6
号信号被捕捉了,调用abort 函数还是会退出进程。
int main()
{
int n = 1;
while (true)
{
cout << "我是一个进程,已经运行了 " << n << " 秒 PID: " << getpid() << endl;
sleep(1);
n++;
if(n > 5) abort();
}
return 0;
}
同样是终止进程,C
语言 还提供了一个更好用的函数:exit
,所以 abort 用的比较少,了解即可.
总的来说,系统调用中举例的这三个函数关系是:kill 包含 raise ,raise 包含 abort ,作用范围是在逐渐缩小的。
其实这种方式我们之前就接触过了:管道读写时,如果读端关闭,那么操作系统会发送信号终止写端,这个就是 软件条件 引发的信号发送,发出的是 13
号 SIGPIPE
信号。这里主要介绍 alarm 函数和SIGALRM
信号。
系统为我们提供了 闹钟(报警):alarm ,这个 闹钟 可不是用来起床的,而是用来 定时 的
返回值:如果上一个闹钟还有剩余时间,则返回剩余时间,否则返回 0
参数:想要设定的时间,单位是秒
调用alarm 函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发14
号信号SIGALRM
信号, 该信号的默认处理动作是终止当前进程。
int main()
{
alarm(5); // 设定一个五秒的闹钟
int n = 1;
while (true)
{
cout << "我是一个进程,已经运行了 " << n << " 秒 PID: " << getpid() << endl;
sleep(1);
n++;
}
return 0;
}
闹钟默认是一次性的,如果想要多次使用,则可以使用自举的方式:更改 14
号 SIGALRM
信号的执行动作,让闹钟不断响起(自举)
void handler(int signo)
{
cout << " catch a signal: " << signo << endl;
int n = alarm(10);
cout << "上一个闹钟剩余时间: " << n << endl;
}
int main()
{
signal(SIGALRM, handler);
alarm(10);// 设定一个十秒的闹钟
while (true)
{
cout << "我是一个进程,我正在运行..... | pid: "<< getpid() << endl;
sleep(1);
}
return 0;
}
系统中不止一个闹钟,所以 OS
需要 先描述,再组织,将这些闹钟管理起来。
可以借助闹钟,简单测试一下当前服务器的算力。如何简单粗暴的测试算力? 设个 1
秒后响起的闹钟,看看程序能将一个值累加至多少:
int main()
{
// 测试算力
alarm(1);
int n = 0;
while(true)
{
cout << n++ << endl;
}
return 0;
}
可以看到累加到了十几万次,这个值是不固定的。其实还可以累加到更多,主要是因为当前程序涉及了 IO
,这是非常耗时间的,可以取消 IO
并修改 SIGALRM
的执行动作为打印变量,看看能累加多少次:
int n = 0;
void handler(int signo)
{
cout << n << endl;
exit(1);
}
int main()
{
// 测试算力
signal(SIGALRM, handler);
alarm(1);
while(true) n++;
return 0;
}
可以看到,取消 IO
后,累加了 5
亿多次,接近 4000 倍的差距。
通过这个简单的小程序证明了一件事:IO
是非常慢的,能不 IO
就不 IO
注:因为当前是云服务器,存在 网络延迟 的影响,所以实际差异更大
注意: 闹钟是一次性的,只能响一次
硬件异常产生信号是指硬件产生了错误并以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如:在写程序最常遇到的各种报错,比如 除 0、野指针。
当前进程执行了除以0
的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。
int main()
{
// 除0异常
int a = 10;
a /= 0;
return 0;
}
在编译的时候我们收到了一个警告(除0
问题),然后我们不管接着运行我们的代码,然后我们的程序就崩溃了,系统提示是浮点异常问题,其实这个浮点异常问题对应的就是我们的硬件异常,它对应的信号是8
号信号SIGFPE。
我们是否可以设定自定义方法,让除0异常合法?
void handler(int signo)
{
cout << "程序除0,但是不终止进程" << endl;
// exit(1);
}
int main()
{
// 除0异常
signal(SIGFPE, handler);
int a = 10;
a /= 0;
return 0;
}
结果:一直在死循环似的发送信号,明明只发生了一次 除 0 行为
想要明白背后的原理,需要先认识一下 状态寄存器。
在 CPU
中,存在很多 寄存器,其中大部分主要用来存储数据信息,用于运算,除此之外,还存在一种特殊的 寄存器 =》 状态寄存器,这个 寄存器 专门用来检测当前进程是否出现错误行为,如果有,就会把 状态寄存器(位图结构)中对应的比特位置 1
,意味着出现了 异常。
比如上面的 除 0 代码,发生异常后,CPU
将 状态寄存器 修改,变成 异常状态,操作系统检测到 异常 后会向进程发送 8
号信号,即使我们将原来 8
号信号默认的终止进程动作修改了打印动作,但 因为状态寄存器比特位仍然为1,处于异常状态,所以操作系统才会不断发送 8
号信号,所以才会死循环式的打印。
能让 状态寄存器 变为 异常 的都不是小问题,需要立即终止进程,然后寻找、解决问题。
毕竟如果让 除 0 变为合法,那最终的结果是多少呢?所以操作系统才会不断发送信号,目的就是 终止进程的运行。
int main()
{
int* ptr = nullptr;
*ptr = 10;
return 0;
}
系统提示我们发生了段错误,对于野指针问题,其实也是我们进程收到了操作系统发送的信号而崩溃的,这个信号是11
号信号SIGSEGV,而这一次硬件异常的是MMU单元(内存管理单元):
野指针问题主要分为两类:
由于我们进程使用的地址都是虚拟地址,当我们进程的代码实际被执行时,需要进行虚拟地址到物理地址的转换,而这个转换就要借助 MMU 这个硬件来进行转换:
C
语言中对于越界 读 的检查不够严格,属于抽查行为,因此野指针越界读还不一定报错,但越界写是一定会报错的。是否命中 / RW
等权限。当发生操作与权限不匹配时,比如 nullptr
只允许读取,并不允许其他行为,此时解引用就会触发 MMU 异常,操作系统识别到后,同样会对对应的进程发出终止信号。页表中的属性:
RW
权限UK
权限(不必关心)所以对于0
地址可能操作系统根本没有给0
地址建立映射关系,或者建立了映射关系但是操作系统不会允许0
地址处发生写入!而当我们进行*p = 10
时,是需要进行写入的,MMU 在地址转换时发现权限不一致,进而引发给异常,报告给了操作系统,然后操作系统向我们的的进场发送SIGSEGV信号。
总结:一旦引发硬件层面的问题,操作系统会直接发信号,立即终止进程。
Linux
中提供了一种系统级别的能力,当一个进程在出现异常的时候,OS可以将该进程在异常的时候,核心代码部分进行 核心转储,即将内存中进程的相关数据,全部 dump 到磁盘中,一般会在当前进程的运行目录下,形成 core .pid
这样的二进制文件(核心转储 文件)。
对于某些信号来说,当终止进程后,需要进行 core dump ,产生核心转储文件
比如:3号 SIGQUIT、4号 SIGILL、5号 SIGTRAP、6号 SIGABRT、7号 SIGBUS、8号 SIGFPE、11号 SIGSEGV、24号 SIGXCPU、25号 SIGXFSZ、31号 SIGSYS 都是可以产生核心转储文件的。
不同信号的动作(
Action
)
- Trem-> 单纯终止进程
- Core-> 先发生核心转储,生成核心转储文件(前提是此功能已打开),再终止进程
但在前面的学习中,我们用过 3
、6
、8
、11
号信号,都没有发现 核心转储 文件啊
难道是我们的环境有问题吗?
确实,当前环境确实有问题,因为它是 云服务器,而 云服务器 中默认是关闭核心转储功能的。
通过指令 ulimit -a
查看当前系统中的资源限制情况
ulimit -a
可以看到,当前系统中的核心转储文件大小为 0
,即不生成核心转储文件
通过指令手动设置核心转储文件大小
ulimit -c [指定大小]
现在可以生成核心转储文件了
就拿之前的 野指针 代码测试,因为它发送的是 11
号信号,会产生 core dump
文件:
核心转储文件是很大的,而有很多信号都会产生核心转储文件,所以云服务器一般默认是关闭的
云服务器上是可以部署服务的,一般程序发生错误后,需要立刻被云服务器中的检测程序发现,并及时重启。
如果打开了核心转储,一旦程序 不断挂掉、又不断重启,每一次进程挂掉都会生成一个 core 文件,进而导致硬盘占满,导致系统 IO 异常,最终会导致整个服务器挂掉的。
还有一个重要问题是 core 文件中可能包含用户密码等敏感信息,不安全。
关闭核心转储很简单,设置为 0
就好了
ulimit -c 0
此大的核心转储文件有什么用呢?
答案是 调试
没错,核心转储文件可以调试,并且直接从出错的地方开始调试
这种调试方式叫做 事后调试
调试方法:
之前在 进程这一篇章中,我们谈到了 当进程异常退出时(被信号终止),不再设置退出码,而是设置 core dump
位 及 终止信号
父进程可以借此判断子进程是否产生了 核心转储 文件,当系统生成 core 文件时,标志位就被置 1 ,否则被置 0 。
信号从产生到执行,并不会被立即处理,这就意味着需要一种 “方式” 记录信号是否产生,对于 31 个普通信号来说,一个 int 整型就足以表示所有普通信号的产生信息了;信号还有可能被 “阻塞”,对于这种多状态、多结果的事物,操作系统会将其进行描述、组织、管理,这一过程称为 信号保存 阶段。
信号 传递过程:信号产生 -> 信号未决 -> 信号递达
Produce
):由四种不同的方式发出信号。Pending
):信号从产生到递达之间的状态。Delivery
):进程收到信号后,实际执行信号的处理动作。信号阻塞 是一种手段,可以发生在 信号处理 前的任意时段。进程可以选择阻塞 (Block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。二者的效果差不多:什么都不干,但前者是 干不了,后者则是 不干了,需要注意区分。
将 信号传递 的过程比作 网上购物
可以抽象出以下概念:
只要你下单了,你的手机上肯定会有 物流信息(未决信息已记录),当 快递送达后(信号递达),物流记录 不再更新。
而 堵车 是一件不可预料的事情,也就是说:在下单后,快递可能一会儿送达(没有阻塞),可能五天送达(阻塞 -> 解除阻塞),有可能永不送达,因为快递可能永远堵车(阻塞)。
堵车也有可能在你下单前发生(信号产生前阻塞)。
至于 信号递达后的处理动作 如何理解呢?
当然,用户自定义的情况可以有很多种,也有可能是直接把快递扔了。
综上,网购的整个过程可以看作 信号传递过程,本文探讨的是 信号保存阶段,即 物流信息。
对于传递中的信号来说,需要存在三种状态表达:
在内核中,每个进程都需要维护这三张与信号状态有关的表:block
表、pending
表、handler
表:在操作系统内核中有三张表,两张是位图结构(block
和 pending
),一张是函数指针数组结构。
如何记录信号已产生 -> 未决表中对应比特位置置为 1 ?
对于信号的状态修改,其实就是修改 位图 中对应位置的值(
0/1
)。
- 假设已经获取到了信号的 pending 表
- 只需要进行位运算即可:pending |= (1 << (signo - 1))
- 其中的 signo 表示信号编号,-1 是因为信号编号从 1 开始,需要进行偏移
如果想要取消 未决 状态也很简单:pending &= (~(1 << (signo - 1)))
至于 阻塞 block表,与 pending 表 一模一样。
对于上图的解读:
整个三张表里面,数据在逻辑上是横向传递的。每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
如果在进程解除对某信号的阻塞之前,这种信号产生过多次,将如何处理?
POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
信号里面的SIG_DFL
表示的是执行默认动作,SIG_IGN
表示的是执行忽略动作。它们的定义如下:
/* Type of a signal handler. */
typedef void (*__sighandler_t) (int);
/* Fake signal functions. */
#define SIG_ERR ((__sighandler_t) -1) /* Error return. */
#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
默认动作就是将 0
强转为函数指针类型,忽略动作则是将 1
强转为函数指针类型,分别对应 handler
函数指针数组表中的 0
、1
下标位置;除此之外,还有一个 错误 SIG_ERR
表示执行动作为 出错。
无论是 block
表 还是 pending
表,都是一个位图结构,依靠 除、余 完成操作,为了确保不同平台中位图操作的兼容性,将信号操作所需要的 位图 结构封装成了一个结构体类型,其中是一个 无符号长整型数组:
/* A `sigset_t' has a bit for each signal. */
# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
#endif
注:_SIGSET_NWORDS
大小为 32
,所以这是一个可以包含 32
个 无符号长整型 的数组,而每个 无符号长整型 大小为 4
字节,即 32
比特,至多可以使用 1024
个比特位。
sigset_t 是信号集,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,可以通过信号集操作函数进行获取对应的信号集信息;信号集 的主要功能是表示每个信号的 “有效” 或 “无效” 状态:在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
block
表 通过信号集称为 阻塞信号集或信号屏蔽字(屏蔽表示阻塞),pending
表 通过信号集中称为 未决信号集
如何根据 sigset_t 位图结构进行比特位的操作?
XXX
1
:XXX._val[3] |= (1 << 31)
0
:XXX._val[3] &= (~(1 << 31))
所以可以仅凭 sigset_t 信号集,对 1024
个比特位进行任意操作。
对于 信号 的 产生或阻塞 其实就是对 block
和 pending
两张表的 增删改查:
|
操作,将比特位置为 1
&
操作,将比特位置为 0
|
或 &
操作,灵活变动1
即可sigset_t 类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下系统接口来操作sigset_t 变量,而不应该直接操作它的内部数据:
#include
int sigemptyset(sigset_t *set); //初始化信号集
int sigfillset(sigset_t *set); //初识化信号集
int sigaddset(sigset_t *set, int signum); //增
int sigdelset(sigset_t *set, int signum); //删
int sigismember(const sigset_t *set, int signum); //查
这些函数都是 成功返回 0
,失败返回 -1
至于参数,非常简单,无非就是 待操作的信号集变量、待操作的比特位。
sigprocmask
调用函数可以读取或更改进程的信号屏蔽字(阻塞信号集)。
返回值:成功返回 0
,失败返回 -1
并将错误码设置
参数1:对 屏蔽信号集 的操作
参数 | 功能 |
SIG_BLOCK | set 包含了我们希望添加到当前信号屏蔽字的信号,相当于mask = mask | set |
SIG_UNBLOCK | set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask &= (~set) |
SIG_SETMASK | 设置当前进程的 block 表为 set 信号集中的 block 表,相当于 mask = set |
参数2:就是一个信号集,主要从此信号集中获取屏蔽信号信息。
参数3:也是一个信号集,是一个输出型参数,系统在给block
信号集设置新的信号集时,会将老的信号集的内容提取出来将拷贝到oldset
里面。(相当于给你操作后,反悔的机会)
演示程序1:将 2
号信号阻塞,尝试通过 键盘键入 发出 2
信号
#include
#include
#include
#include
using namespace std;
int main()
{
sigset_t set, oset;
// 初始化信号集
sigemptyset(&set);
sigemptyset(&oset);
// 阻塞2号信号
sigaddset(&set, 2);
// 设置当前进程的 block 表
sigprocmask(SIG_BLOCK, &set, &oset);
// 死循环
while (true)
{
cout << "我是一个进程,正在运行" << endl;
sleep(1);
}
return 0;
}
显然,当 2
号信号被阻塞后,是 无法被递达 的,进程也就无法终止了.
演示程序2:在程序运行五秒后,解除阻塞状态,打印当前进程的信号屏蔽字:
#include
#include
#include
#include
using namespace std;
static void showBlock(const sigset_t *set)
{
int signo = 1;
for (; signo <=31; signo++)
{
if(sigismember(set, signo)) cout << "1";
else cout << "0";
}
cout << endl;
}
int main()
{
// 创建信号集
sigset_t set, oset;
// 初始化信号集
sigemptyset(&set);
sigemptyset(&oset);
// 阻塞2号信号
sigaddset(&set, 2);
//设置进入进程,谁调用,设置谁
sigprocmask(SIG_SETMASK, &set, &oset);//1、2号信号没有反应
//2、老的block位图应该是全0
cout << "老的block位图是:";
showBlock(&oset);
int cnt = 1;
while (true)
{
cout << "目前的block位图是:";
showBlock(&set);
if(cnt++ == 5)
{
// 恢复原来的block位图
sigprocmask(SIG_SETMASK, &oset, &set);
}
sleep(1);
}
return 0;
}
现象:在 2
号信号发出、程序运行五秒解除阻塞后,信号才被递达,进程被终止;但为什么没有打印原来的block位图?这个问题稍后再说。
如何证明信号已递达?
n == 5
时,解除阻塞状态,程序立马结束对于pending表我们无法修改,只能通过系统调用进行读取当前进程的未决信号集。
返回值:成功返回 0
,失败返回 -1
并将错误码设置
参数:待获取的 未决信号集
如何根据 未决信号集 打印
pending
表
- 使用函数
sigismember
判断当前信号集中是否存在该信号,如果存在,输出1
,否则输出0
- 如此重复,将
31
个信号全部判断打印输出即可
static void showPending(const sigset_t &pending)
{
int signo = 1;
for (; signo <=31; signo++)
{
if(sigismember(&pending, signo)) cout << "1";
else cout << "0";
}
cout << endl;
}
int main()
{
// 创建信号集
sigset_t set, oset;
// 初始化信号集
sigemptyset(&set);
sigemptyset(&oset);
// 1、阻塞2号信号
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oset);
int cnt = 1;
// 2、获取进程的pending信号集合
while (true)
{
// 获取进程的 未决信号集
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending);
assert(n == 0);
(void)n;
cout << "目前pending位图是:";
showPending(pending);
if(cnt++ == 5)
{
// 恢复原来的pending位图
sigprocmask(SIG_SETMASK, &oset, &set);
}
sleep(1);
}
return 0;
}
结果:当 2
号信号发出后,当前进程的 pending
表中的 2
号信号位被置为 1
,表示该信号属于 未决 状态,并且在五秒之后,阻塞结束,信号递达,进程终止。
疑问:当阻塞解除后,信号递达,应该看见 pending
表中对应位置的值由 1
变为 0
,但为什么没有看到?即原来的pending表。现象同上述的block表一样。
2
号信号的执行动作为终止进程,进程都终止了,当然看不到2
号信号先注册一个自定义动作即可(别急着退出进程)#include
#include
#include
#include
using namespace std;
static void showBlock(const sigset_t *set)
{
int signo = 1;
for (; signo <=31; signo++)
{
if(sigismember(set, signo)) cout << "1";
else cout << "0";
}
cout << endl;
}
static void showPending(const sigset_t &pending)
{
int signo = 1;
for (; signo <=31; signo++)
{
if(sigismember(&pending, signo)) cout << "1";
else cout << "0";
}
cout << endl;
}
static void handler(int signo)
{
cout << signo << " 号信号已经递达了" << endl;
}
int main()
{
// 修改2号信号的执行动作
signal(2, handler);
// 创建信号集
sigset_t set, oset;
// 初始化信号集
sigemptyset(&set);
sigemptyset(&oset);
// 1、阻塞2号信号
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oset);
int cnt = 1;
// 2、获取进程的pending信号集合
while (true)
{
// 获取进程的 未决信号集
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending);
assert(n == 0);
(void)n;
cout << "目前pending位图是:";
showPending(pending);
if(cnt++ == 5)
{
// 恢复原来的pending位图
cout << "恢复原来的信号屏蔽字:";
sigprocmask(SIG_SETMASK, &oset, &set);
showPending(pending);
}
sleep(1);
}
return 0;
}
先将信号 阻塞,信号发出后,无法 递达,始终属于 未决 状态;当阻塞解除后,信号可以 递达,信号处理之后,未决表中不再保存信号相关信息,因为已经处理了。
综上,信号在发出后,在处理前,都是保存在 未决表 中的
注意:
sigprocmask
、sigpending
这两个函数的参数都是 信号集,前者是 屏蔽信号集,后者是 未决信号集分两种情况:
普通情况:指 信号没有被阻塞,直接产生,记录未决信息后,再进行处理;
在这种情况下,信号是不会被立即递达的,也就无法立即处理,需要等待"合适"的时机。
特殊情况:当信号被 阻塞 后,信号 产生 时,记录未决信息,此时信号被阻塞了,也不会进行处理;当阻塞解除后,信号会被立即递达,此时信号会被立即处理。
信号的产生是 异步 的
也就是说,信号可能随时产生,当信号产生时,进程可能在处理更重要的事,此时贸然处理信号显然不够明智。因此信号在 产生 后,需要等进程将 更重要 的事忙完后(合适的时机),才进行 处理。
合适的时机:进程从 内核态 返回到 用户态 时,会在操作系统的指导下,对信号进行检测及处理。至于处理动作,分为:默认动作、忽略、用户自定义。
我们需先熟悉何为内核态跟用户态才能明白合适的时机:
用户态:执行用户所写的代码时,就属于 用户态
内核态:执行操作系统的代码时,就属于 内核态
自己写的代码被执行很好理解,操作系统的代码是什么?
- 操作系统也是一款软件,而且是一款专注于搞管理的软件,也是由大量代码构成的。
- 在对进程进行调度、执行系统调用、异常、中断、陷阱等,都需要借助操作系统之手。
- 此时执行的就是操作系统的代码,此时进程便处于内核态。
也就是说,用户态 与 内核态 是两种不同的状态,必然存在相互转换的情况:
用户态 切换为 内核态:
open、close、read、write
等内核态 切换为 用户态:
信号的处理时机就是 内核态 切换为 用户态,也就是 当把更重要的事做完后,进程才会在操作系统的指导下,对信号进行检测、处理。
进程地址空间 是虚拟的,依靠 页表+MMU
机制 与真实的地址空间建立映射关系,并且每个进程都有自己的 进程地址空间,不同 进程地址空间 中地址可能冲突,但实际上地址是独立的。
在以前我们只讨论了[0, 3]G的用户空间,并没有对[3, 4]G的内核空间进行讨论。在谈论用户空间时提到,用户空间的地址要经过页表映射到物理地址,这个用户空间的页表其实其真实名称是用户级页表。
对于内核空间来说也有一张页表,也负责将内核空间的地址映射到物理地址中,这个页表的名称是内核级页表。
为什么要区分 用户态 与 内核态?
- 内核空间中存储的可是操作系统的代码和数据,权限非常高,绝不允许随便一个进程对其造成影响。
- 区域的合理划分也是为了更好的进行管理。
内核空间里面存放的是操作系统代码和数据, 所以执行操作系统的代码及系统调用,其实就是在使用这 1 GB 的内核空间。
进程间具有独立性,比如存在用户空间中的代码和数据是不同的,难道多个进程需要存储多份 操作系统的代码和数据 吗?
- 当然不用,内核空间比较特殊,所有进程最终映射的都是同一块区域,也就是说,进程只是将 操作系统代码和数据 映射入自己的 进程地址空间 而已。
- 而 内核级页表 不同于 用户级页表,专注于对 操作系统代码和数据 进行映射,是很特殊的。
当我们执行诸如 open
这类的 系统调用 时,会跑到 内核空间 中调用对应的函数,而 跑到内核空间 就是 用户态 切换为 内核态 了。(用户空间切换至内核空间)
由于操作系统的代码和数据是不能够被轻易访问的,所以在代码中如果要执行操作系统的代码和数据,需要先进行状态转化,由用户态转化为内核态,才能成功执行,那么这个状态转换是怎么实现的呢?
在 CPU
中,存在一个 CR3
寄存器,这个 寄存器 的作用就是用来表征当前处于 用户态 还是 内核态:
3
时:表示正在执行用户的代码,也就是处于用户态。0
时:表示正在执行操作系统的代码,也就是处于内核态。通过一个 寄存器,表征当前所处的 状态,修改其中的 值,就可以表示不同的 状态:
重谈 进程地址空间 后,得到以下结论:
那么进程又是如何被调度的呢?
- 操作系统的本质
- 操作系统也是软件啊,并且是一个死循环式等待指令的软件。
- 存在一个硬件:操作系统时钟硬件,每隔一段时间向操作系统发送时钟中断。- 进程被调度,就意味着它的时间片到了,操作系统会通过时钟中断,检测到是哪一个进程的时间片到了,然后通过系统调用函数 schedule() 保存进程的上下文数据,然后选择合适的进程去运行。
当在 内核态 完成某种任务后,需要切回 用户态,此时就可以对信号进行 检测 并 处理 了
情况1:信号被阻塞,信号产生/未产生
信号都被阻塞了,也就不需要处理信号,此时不用管,直接切回 用户态 就行了。
下面的情况都是基于 信号未被阻塞 且 信号已产生 的前提
情况2:当前信号的执行动作为 默认
大多数信号的默认执行动作都是 终止进程,此时只需要把对应的进程干掉,然后切回 用户态 就行了
情况3:当前信号的执行动作为 忽略
当信号执行动作为 忽略 时,不做出任何动作,直接返回 用户态
情况4:当前信号的执行动作为用户自定义
这种情况就比较麻烦了,用户自定义的动作位于 用户态 中,也就是说,需要先切回 用户态,把动作完成了,重新坠入 内核态,最后才能带着进程的上下文相关数据,返回 用户态。
在 内核态 中,也可以直接执行 自定义动作,为什么还要切回 用户态 执行自定义动作?
- 因为在 内核态 可以访问操作系统的代码和数据,自定义动作 可能干出危害操作系统的行为。
- 在 用户态 中可以减少影响,并且可以做到溯源。
为什么不在执行完 自定义动作 直接后返回进程?
- 因为 自定义动作 和 待返回的进程 属于不同的堆栈,是无法返回的。
- 并且进程的上下文数据还在内核态中,所以需要先坠入内核态,才能正确返回用户态。
注意: 用户自定义的动作,需要先切换至 用户态 中执行,执行结束后,还需要坠入 内核态。
如果信号的执行动作为 用户自定义动作,当信号 递达 时调用 用户自定义动作,这一动作称为 信号捕捉。
用户自定义动作 是位于 用户空间 中的
当 内核态 中任务完成,准备返回 用户态 时,检测到信号 递达,并且此时为 用户自定义动作,需要先切入 用户态 ,完成 用户自定义动作 的执行;因为 用户自定义动作 和 待返回的函数 属于不同的 堆栈 空间,它们之间也不存在 调用与被调用 的关系,是两个 独立的执行流,需要先坠入 内核态 (通过 sigreturn() 坠入),再返回 用户态 (通过 sys_sigreturn() 返回)。
该函数是一个系统调用,功能与signal()
函数类似但是功能会更加强大,sigaction
函数可以读取和修改指定信号相关联的处理动作,比 signal()
功能更丰富
这个函数的主要看点是 sigaction
结构体
重点可以看看第三个字段 sa_mask
,是一个信号屏蔽集,当信号在执行 用户自定义动作 时,可以将部分信号进行屏蔽,直到 用户自定义动作 执行完成。
也就是说,我们可以提前设置一批 待阻塞 的 屏蔽信号集,当执行 signum
中的 用户自定义动作 时,这些 屏蔽信号集 中的 信号 将会被 屏蔽(避免干扰 用户自定义动作 的执行),直到 用户自定义动作 执行完成。
#include
#include
#include
#include
#include
using namespace std;
static void showPending(const sigset_t pending)
{
// 打印 pending 表
cout << "当前进程的 pending 表为: ";
int i = 1;
while (i < 32)
{
if (sigismember(&pending, i)) cout << "1";
else cout << "0";
i++;
}
cout << endl;
}
static void handler(int signo)
{
cout << signo << " 号信号确实递达了" << endl;
// 最终不退出进程
int n = 10;
while (n--)
{
// 获取进程的 未决信号集
sigset_t pending;
sigemptyset(&pending);
int ret = sigpending(&pending);
assert(ret == 0);
(void)ret; // 欺骗编译器,避免 release 模式中出错
showPending(pending);
sleep(1);
}
}
int main()
{
cout << "当前进程: " << getpid() << endl;
//使用 sigaction 函数
struct sigaction act, oldact;
//初始化结构体
memset(&act, 0, sizeof(act));
memset(&oldact, 0, sizeof(oldact));
//初始化 自定义动作
act.sa_handler = handler;
//初始化 屏蔽信号集
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
//给 2号 信号注册自定义动作
sigaction(2, &act, &oldact);
// 死循环
while (true);
return 0;
}
当 2
号信号的循环结束(10
秒),3、4、5
信号的 阻塞 状态解除,立即被 递达,进程就被干掉了 。
注意: 屏蔽信号集 sa_mask
中已屏蔽的信号,在 用户自定义动作 执行完成后,会自动解除 阻塞 状态。
信号产生阶段:有四种产生方式,包括 键盘键入、系统调用、软件条件、硬件异常。
信号保存阶段:内核中存在三张表,blcok
表、pending
表以及 handler
表,信号在产生之后,存储在 pending
表中。