一、信号
1. 信号的概念
Linux提供的让用户或进程给其他进程发送异步信息的一种方式,信号由进程发送的,属于软件中断。
2. 信号的作用
- 当 进程执行出现致命错误 或 进程所需的软件条件不具备 时,给操作系统提供的一种及时终止进程的机制
- 当用户想在某一时刻终止进程时,给用户提供的一种终止进程的机制
3. 信号的种类
(1)查看信号的种类
kill -l
(2)1~31:不可靠信号(非实时)
信号编号 | 信号名称 | 作用说明 |
---|---|---|
1 | SIGHUP | 挂起信号,常用于通知进程控制终端已关闭或需要重新初始化 |
2 | SIGINT | 中断信号,通常由用户在终端按下 Ctrl + C 产生,用于请求进程终止 |
3 | SIGQUIT | 退出信号,通常由用户在终端按下 Ctrl + \ 产生,会导致进程产生核心转储并终止 |
4 | SIGILL | 非法指令信号,指示进程执行了非法的机器指令 |
5 | SIGTRAP | 跟踪/断点陷阱信号,常用于调试 |
6 | SIGABRT | 异常终止信号,通常由 abort 函数调用产生 |
7 | SIGBUS | 总线错误信号,通常表示访问内存时出现总线错误 |
8 | SIGFPE | 浮点异常信号,例如除零错误 |
9 | SIGKILL | 强制终止信号,无法被捕获或忽略,用于立即终止进程 |
10 | SIGUSR1 | 用户自定义信号 1,可由用户程序自定义用途 |
11 | SIGSEGV | 段错误信号,通常表示访问非法的内存地址 |
12 | SIGUSR2 | 用户自定义信号 2,可由用户程序自定义用途 |
13 | SIGPIPE | 管道破裂信号,当向一个没有读端的管道写入数据时产生 |
14 | SIGALRM | 闹钟信号,由 alarm 函数设置的定时时间到达时产生 |
15 | SIGTERM | 终止信号,可被进程捕获并进行自定义处理 |
16 | SIGSTKFLT | 栈错误信号 |
17 | SIGCHLD | 子进程状态改变信号,当子进程终止、暂停或恢复时产生 |
18 | SIGCONT | 继续信号,用于恢复被暂停的进程 |
19 | SIGSTOP | 暂停信号,无法被捕获或忽略,用于暂停进程 |
20 | SIGTSTP | 终端停止信号,通常由用户在终端按下 Ctrl + Z 产生 |
21 | SIGTTIN | 后台进程试图从控制终端读取时产生 |
22 | SIGTTOU | 后台进程试图向控制终端写入时产生 |
23 | SIGURG | 紧急数据到达套接字的信号 |
24 | SIGXCPU | 超过 CPU 时间限制信号 |
25 | SIGXFSZ | 超过文件大小限制信号 |
26 | SIGVTALRM | 虚拟定时器信号 |
27 | SIGPROF | 性能分析定时器信号 |
28 | SIGWINCH | 窗口大小改变信号 |
29 | SIGIO | I/O 就绪信号 |
30 | SIGPWR | 电源故障信号 |
31 | SIGSYS | 系统调用错误信号 |
(3)34~64:可靠信号(实时信号,暂不考虑)
(4)可靠信号与不可靠信号的区别点
区别点 | 可靠信号 | 不可靠信号 |
---|---|---|
信号丢失 | 不会丢失 | 可能丢失 |
排队机制 | 支持排队 | 不支持排队 |
信号处理函数阻塞期间 | 新信号不会被丢弃,排队等待处理 | 新信号可能被丢弃 |
发送次数记录 | 准确记录发送次数 | 可能不准确 |
默认处理方式 | 默认终止进程 | 不一定终止进程 |
4. 不同属性的信号对进程的默认操作
(1)查看信号属性
man 7 signal
Term | 默认操作 | 终止进程 |
Ign | 默认操作 | 忽略信号 |
Core | 默认操作 | 终止进程,并核心转储(core dump) |
Stop | 默认操作 | 暂停进程 |
Cont | 默认操作 | 继续执行当前暂停的进程 |
(2)Term 与 Core 的不同之处
Term 是直接终止掉进程,不做其他的处理
Core 在终止进程的同时,会将进程在内存中的核心数据(与 Debug 有关)转储到磁盘中形成 core(Ubuntu)或 core.pid(CentOS)文件,我们就可以通过 core文件 定位到进程为什么退出,以及执行到哪行代码退出的
【注】:
我们目前看不到 Term 与 Core 的区别,是因为云服务器与虚拟机默认将进程的
core dump 功能关闭的
Core功能:
- 确认是否打开 core dump 功能
ulimit -a
- 打开 core dump 功能
ulimit -c size // size 换成大于0的就行(表示核心转储文件的上限,设置为0就是不进行核心存储)
- 关闭 core dump 功能
ulimit -c 0
此时我们就可以测试发现,打开 core dump 功能后,会出现core文件
为什么要默认关闭core dump 功能 ?
防止有未知的 core dump 一直在进行,从而产生大量的 core 文件,将磁盘打满。
(如:一个进程死循环创建子进程,并且在子进程中故意创造致命错误)
所以新版内核为了防止此类事故,将 core 文件统一命名为 core,就可以保证无论怎么进行 core dump ,都永远只是一个 core 文件,只保存最新的出错信息及中断代码行。
core dump 的作用:
协助调试,在 gdb 中,可以使用 core-file core 快速定位出错代码行(事后调试)
(3)知识链接:进程退出码
这里的 core dump 标志位若为1,则表示当前进程已经发生了 core dump 核心转储
core dump 标志位取决于
- 是否开启 core dump 功能
- 是否为 core 退出
5. 进程看待信号的方式
- 进程默认知道信号的种类与默认处理方式(表现在 task_struct 的三张位图)
- 信号到来,可以不立即处理,在合适的时候处理,此时在 task_struct 中保存该信号
- 进程不会等待信号的到来,信号是异步产生的
二、信号的保存形式
1. 基本概念:
- 进程可以选择阻塞信号
- 信号递达:实际执行信号的处理动作,即处理了信号就是递达。
分为:默认处理方法、自定义处理方法、忽略信号 - 信号未决:信号产生 与 信号递达之间的状态,即被阻塞的信号
2. 信号在内核中的保存形式
在OS创建进程时,会先创建 task_struct 同时初始化内部信息,就包括信号的三张位图,这也说明进程在一开始就是认识信号的。
block 位图 | 表示信号是否被屏蔽 | bit 位置:信号编号 bit 内容:是否屏蔽该信号 |
pending 位图 | 表示信号是否被捕捉 | bit 位置:信号编号 bit 内容:信号是否到来 |
handler 位图 | 表示信号的处理方法 | 默认处理方法(SIG_DFL) 忽略处理方法(SIG_IGN) |
3. 相关问题
(1)如果一个信号被阻塞,那么这个信号永远不会被递达处理,除非解除阻塞。
被阻塞的信号处于未决状态。
(2)阻塞是不让信号被递达处理,忽略是信号递达的一种方式
(3)阻塞 与 是否捕捉到信号 有关吗?
无关。因为阻塞位图与未决位图本身就是两个位图,互不影响。其次进程选择阻塞一个信号,无需关心是否收到。
(4)若信号被阻塞,则不管是否收到信号,都不做处理;反之收到信号,直接递达处理
(5)OS 发送信号的本质,是向进程的 pending 位图中将对应编号的信号的内容置为1,至于是否做递达处理,看进程自身是否屏蔽了该信号
(6)9号(SIGKILL)19号信号(SIGSTOP)无法被屏蔽;18号信号(SIGCONT)做了特殊处理
OS 至少要保证有一种信号可以终止进程
(7)对信号做递达处理时,先将 pending 对应 bit 位的内容由 0 置为 1,再进行递达处理
主要是考虑在递达处理的期间,有相同信号到来。而递达也是需要花费时间的,所以先将 pending 的 bit 位的内容置为0,在递达处理时,可以继续收到相同信号,防止信号被覆盖而丢失。
4. 三个位图匹配的操作与系统调用接口
(1)block 位图
int sigprocmask(int how, const sigset_t* set, sigset_t* oldset);
① how
SIG_BLOCK | 添加一个信号到 block 位图中 注意这是添加,即 屏蔽字 = 原本屏蔽的信号 |set |
SIG_UNBLOCK | 解除 set 中屏蔽的信号 |
SIG_SETMASK | 设置当前信号屏蔽字 set |
② sigset_t 类型
- 是一个用户层提供的位图类型,可以代表 block、pending 位图的含义
相关操作函数:
sigemptyset | 初始化信号集,将所有 bit 位的内容置为0 |
sigfillset | 初始化信号集,将所有 bit 位的内容置为1 |
sigaddset | 添加一个信号 |
sigdelset | 删除一个信号 |
sigismember | 判断一个信号是否在信号集中 |
(2)pending 位图
int sigpending(sigset_t *set);
通过参数 set 传出当前进程的未决信号集。如果成功返回 0,若出错则返回-1。
(3)handler 位图
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
用户通过系统调用自定义处理方法,待信号到来时,执行自定义方法
三、信号的生命周期
1. 信号的产生
(1)键盘 产生信号:
ctrl + c (2号信号,SIGINT) |
ctrl + \ (3号信号,SIGQUIT) |
ctrl + z (19号信号,SIGSTOP) |
(2)命令 产生信号:
kill -num pid
kill -sign_name pid
(3)系统调用 产生信号:
kill | 给进程发送信号 |
raise | 给自己发送信号 |
abort | 给自己发送6号信号,SIGABRT |
(4)软件条件 产生信号
a. 软件条件不具备,发送信号
如:管道的读端不读了&&读端关闭了,那操作系统就会向斜段发送13号信号(SIGPIPE),终止写端。
b. alarm闹钟
unsigned int alarm(unsigned int seconds);
- seconds:闹钟开始计时的时长,单位:秒
- 返回值:上一个闹钟剩余的秒数
- alarm( 0 ) :取消闹钟
- 一个进程有且只有一个闹钟时间(若循环调用alarm,则会不断重置闹钟时长,永远不会为0)
在seconds秒后,向当前进程发送14号信号(SIGALRM),并终止当前进程,默认是响一次。
若要求响很多次,我们可以在自定义处理方法中再次设置alarm函数。
设置完alarm之后,不会停留在该函数处,而是继续向后执行。所以这个跟server端设置listen一样,由操作系统来管理,无需用户管理。
为什么alarm是软件条件呢?
首先,OS中一定同时存在许多的定时任务,而这些定时任务都不是由用户自己来管理的,所以OS一定会管理这些定时任务。
如何管理????
先描述
struct alarm
{
pid_t pid; // 设置定时任务的进程pid
uint64_t expired; // 过期时间
// ...其他属性
};
再组织
使用小堆组织这些定时任务,实现每次都让最快到达计时时间的任务第一个被OS拿到,并发送信号给对应进程。
这些用数据结构组织起来的就是软件,即alarm为软件条件
(5)异常产生信号
非法内存的访问 | 11号信号 - SIGSEGV |
除0异常 | 8号信号 - SIGFPE |
2. 关于信号产生的各种情况的理解
(1)键盘输入产生信号
首先键盘是一个字符设备,字符输入 与 组合键输入 本质上都是 字符输入,但是组合键代表的是命令,所以OS一定要对输入的数据来进行判断,是字符还是命令
a. 第一步:在输入的时候,OS必须要将键盘输入的数据拿到键盘文件的文件缓冲区中
当键盘按下按键的时候,会发生硬件中断,向 CPU 的针脚发射高电频,CPU 的 reg 寄存器存放接收到高电频的针脚的编号(中断号),OS 拿到 reg 寄存器中的中断号,再去中断向量表中去找中断号对应的方法,执行该方法,就把键盘输入的数据加载到内存的键盘文件的文件缓冲区中了。
b. 第二步:OS要识别出输入的是 字符 还是 命令
当数据加载到键盘文件缓冲区时,OS 会创建一个辅助进程来读取缓冲区的数据,从而来判定数据是普通字符还是命令。
若为字符:
我们当前运行的进程就会读取里面的内容
若为命令:
辅助进程会将其解释为信号,并发送给当前进程(这个很简单,映射就OK)
什么叫解释成信号?发送给当前进程?
解释成信号:
辅助进程通过组合键与命令的映射关系完成
发送给当前进程:
前面提过,进程可以不立即处理信号,可以在合适的处理。而信号发送给当前进程也不代表进程处理信号了。在信号到来的时候,进程可能暂时不处理信号,所以就必须对信号进行临时保存,在 task_struct 中用 pending 位图来保存,bit 的位置表示信号的编号,bit 位的内容代表信号是否存在。
所以发送给当前进程是指:将当前进程关于信号的位图 bit 内容由0置为1,至于如何处理,就是进程自己的事情。
(2)除0异常发送信号
在 CPU 执行到 a /= 0 的指令时,会将标志位寄存器设置成溢出标记,通知OS有错误发生,OS就会看标志位寄存器中的错误标记,发现是 除0 错误,就会向进程发送 8 号信号
(SIGFPE)
(3)非法内存访问发送信号
虚拟地址到物理地址转换用到的寄存器:
CR2 | 存放导致页表转换错误的虚拟地址 |
CR3 | 保存页表的起始地址 可在虚拟地址到物理地址之间转换时,快速定位页表 |
MMU | 虚拟地址到物理地址的转换 |
我们程序员看到的地址都是虚拟地址,不是物理地址。访问虚拟地址时,在底层 OS 会与 CPU 的 MMU 通过页表将虚拟地址转换为物理地址,而转换一定是对应成功和失败的。所以当我们访问一个非法内存地址时,CR3 寄存器先将页表的起始地址给 MMU,MMU 定位到页表后,再把访问的地址拿到,进行转换,但该地址在页表中没有对应的物理地址,或该地址是只读属性,不允许转化。这两种都会转换失败,此时将错误信息存入到 CR2 寄存器中,交给 OS,OS 得知是非法内存访问的异常,发送 11 号信号(SIGSEGV)终止进程。
【总结】
向进程发送信号,就是将 task_struct 的pending 位图的 bit 位由 0 置为 1,而task_struct 为内核数据结构,只有 OS 有权限来写入,用户若想改变信号位图,就必须通过系统调用。所以无论信号产生的方式有多少种,都是向 task_struct 中的 pending 位图写入,都必须由 OS 写入!
3. 信号的处理
(1)基本概念
① 内核态 与 用户态
- 内核态具有更高的权限,可以直接访问系统硬件资源和执行关键操作
用户态则只可以执行用户的代码。 - 内核态与用户态主要是对 CPU 运行时的权限状态进行划分的,当进程在 CPU 上调度时,CPU 会根据其代码指令的类型,从而选择运行时的权限(用户态或内核态),以便控制进程对系统资源的访问
- CPU 的 CS 寄存器的低 2 个 bit 位是权限标识位,0 代表内核态,3 代表用户态
② 进程地址空间
- 进程地址空间的内核区 [3G, 4G] 映射的就是 OS
- 每个进程都有内核空间,也就都可以找到 OS,访问 OS 的本质就是通过内核区访问的
- 系统调用在底层是用 函数指针数组 组织起来的
在代码执行的期间,若遇到系统调用,CPU 就会提高权限,去访问内核区,执行相关系统调用,执行完毕后,再降低权限,继续执行用户区的代码
(2)信号处理的时期
进程从 内核态转换到用户态之前,OS 会检测进程的 pengind 位图,如果有 bit 的内容为1,则去查看 block 位图,若对应信号被屏蔽了,则不做处理,反之执行 handler 位图的方法
(3)信号处理的流程图
① | 在执行主控制流程的某条指令时,因为硬件中断、异常、系统调用而切换至内核态 |
② | 内核处理硬件中断、异常、系统调用 |
③ | 在处理结束后,返回用户态前,处理当前进程中可以递达的信号,若信号执行的是默认处理方法,则走④;反之,走④ |
④ | 调用 sys_sigreturn ,返回用户态,从主控制流程中被中断的地方继续向下执行 |
④ | 信号选择自定义处理方法,切换到用户态,执行自定义处理方法 |
⑤ | 执行自定义处理方法结束,调用系统调用 sigreturn 切换回内核态 |
⑥ | 调用 sys_sigreturn ,返回用户态,从主控制流程中被中断的地方继续向下执行 |
(4)问题
为什么在信号捕捉时,执行自定义处理方法时,要从内核态转换到用户态,直接内核态不可以吗?
虽然在权限上,内核态确实可以执行用户态的代码,但是OS不相信任何人,万一自定义处理方法中,有越权访问的代码(内核态可以执行,用户态不可以),就会有风险。所以用户态就应该执行用户态的代码,内核态就执行内核态的代码。
为什么在执行完自定义处理方法后,要从用户态切换回内核态?
一方面是在信号处理后,需要进行清理操作,这都是 OS 的任务,所以必须切换回内核态;另一方面是我们自定义方法没有终止进程,则必须要在中断位置恢复进程的上下文数据,重新让 CPU 调度,这也是 OS 的任务。
我们自定义处理方法的时候,内部不退出进程,会发生什么?
一直判定错误,并发送信号
我们将 8 号信号进行捕获,自定义处理方法中不退出进程,并写一段会产生除零异常的代码
过程如下:
在发现除零异常的时候,由用户态转换为内核态,并处理异常,OS 向进程发送 8 号信号,在转换为用户态之前,OS 会检查进程的信号集,此时发现了信号,并去执行自定义处理方法,由于没有退出该进程,处理完信号后,会先转换为内核态,恢复进程的上下文数据,而由于进程没有被终止,所有上下文呢数据都和之前的中断时一样,即标志位寄存器的内容仍然是溢出标志,再换到用户态,所以就会一直判定错误,OS会一直向进程发送 8 号信号。
但是有的 OS 为了保证安全和稳定,通常在恢复上下文数据的时候,要保证寄存器的内容都是正确的,所以会重置寄存器的错误内容,但我们依旧是从中断位置执行,也就会再次遇到除零操作,从而产生除零异常
四、用户捕获信号的方式
1. signal
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
用户通过系统调用自定义处理方法,待信号到来时,执行自定义方法
2. sigaction
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
struct sigaction
{
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
- signum:信号编号
- sa_handler:自定义处理方法
- sigaction:实时信号的自定义处理方法
- sa_flags:设置为0
- sa_restorer:不用,设置为nullptr
sa_mask
在调用该函数时,内核会通过sa_mask添加 signum 对应的信号 到 block 位图中,保证在处理该信号的时候,不会再次被同信号所影响,而导致信号的循环嵌套处理。若是处理完该 signum 信号时,会默认从block 位图中移除。
如果想要去屏蔽其他信号,可以添加到 sa_mask 中。
五、扩展
1. OS 是如何运行的?(简单说明)
OS是一个死循环,不断在接受外部硬件的中断,从而运行的。
硬件会高频率地给CPU发送中断,CPU就会不断地处理中断,reg寄存器存的是中断号,OS就会根据中断号来查中断向量表的对应中断号的方法,最后去执行任务。
2. OS 是如何分辨出各种中断的?
通过在CPU的reg寄存器中读取到的中断号来分辨出不同的中断,再与中断向量表中的中断号与处理方法的映射来执行不同的方法。
3. kill 一个进程的实现流程
kill 一个进程,OS 会通过系统调用来识别出是给哪个进程发送几号信号,OS将信号写入对应进程的 pending 位图中,此时已经完成信号的发送了。
那什么时候进程处理信号呢?
进程在等到CPU的时间片中断时,会从用户态转换为内核态,处理中断,在返回用户态前,OS会查看进程的pending、block、handler表,判断是否处理信号。
4. 异常 从产生到处理的流程
在CPU执行主流程的代码时,发现了除0 / 非法内存的访问,就会将标志位寄存器由0置为1,reg寄存器会保存异常的中断号,从而 OS 通过这两个寄存器的信息,查中断向量表的对应方法,去向该进程发送信号。
那什么时候进程处理信号呢?
此时已经发生了异常,就处于内核态,并且已经处理完异常,直接对信号处理
5. 键盘输入信号到处理的流程
当CPU执行主流程的代码时,键盘的输入会引发硬件中断,向CPU的某个针脚发射高电频,CPU的 reg 寄存器保存该针脚号(中断号),与此同时 OS 去拿到这个中断号,去中断向量表中查到对应方法,从而将键盘输入的数据读取到键盘文件的文件缓冲区中,OS 会创建进程来判断读取到的内容是普通字符还是命令,若读取到的是命令,则该进程会转换为kill 命令,发送给当前进程(就是将进程的 pending 表由 0 置为 1 )。至此处理中断结束
那什么时候进程处理信号?
此时就处于内核态,所以直接处理即可!
六、volatile 关键字
#include <iostream>
#include <signal.h>
int g_flag = 0;
void ChangeFlag(int signum)
{
g_flag = 1;
}
int main()
{
signal(2, ChangeFlag);
while(!g_flag)
{
std::cout << "..." << std::endl;
}
return 0;
}
有的编译器在进行对代码的扫描时,会认为没有对 g_flag 修改,就会将 g_flag 的值放入寄存器中,而当我们发送 2 号信号时,即使内存中 g_flag 的值已经为 1 了,但是也不会退出循环,这是因为 CPU 拿到是寄存器中的 g_flag。
如何解决?
使用 volatile 关键字 保持内存的可见性,即CPU拿到的 g_flag 的值都是从内存中拿的
volatile int g_flag = 0;