【Linux】进程信号详解

一、信号

1. 信号的概念

       Linux提供的让用户或进程给其他进程发送异步信息的一种方式,信号由进程发送的,属于软件中断。

2. 信号的作用

  • 当 进程执行出现致命错误进程所需的软件条件不具备 时,给操作系统提供的一种及时终止进程的机制
  • 用户想在某一时刻终止进程时,给用户提供的一种终止进程的机制

3. 信号的种类

(1)查看信号的种类

kill -l

(2)1~31:不可靠信号(非实时)

信号编号信号名称作用说明
1SIGHUP挂起信号,常用于通知进程控制终端已关闭或需要重新初始化
2SIGINT中断信号,通常由用户在终端按下 Ctrl + C 产生,用于请求进程终止
3SIGQUIT退出信号,通常由用户在终端按下 Ctrl + \ 产生,会导致进程产生核心转储并终止
4SIGILL非法指令信号,指示进程执行了非法的机器指令
5SIGTRAP跟踪/断点陷阱信号,常用于调试
6SIGABRT异常终止信号,通常由 abort 函数调用产生
7SIGBUS总线错误信号,通常表示访问内存时出现总线错误
8SIGFPE浮点异常信号,例如除零错误
9SIGKILL强制终止信号,无法被捕获或忽略,用于立即终止进程
10SIGUSR1用户自定义信号 1,可由用户程序自定义用途
11SIGSEGV段错误信号,通常表示访问非法的内存地址
12SIGUSR2用户自定义信号 2,可由用户程序自定义用途
13SIGPIPE管道破裂信号,当向一个没有读端的管道写入数据时产生
14SIGALRM闹钟信号,由 alarm 函数设置的定时时间到达时产生
15SIGTERM终止信号,可被进程捕获并进行自定义处理
16SIGSTKFLT栈错误信号
17SIGCHLD子进程状态改变信号,当子进程终止、暂停或恢复时产生
18SIGCONT继续信号,用于恢复被暂停的进程
19SIGSTOP暂停信号,无法被捕获或忽略,用于暂停进程
20SIGTSTP终端停止信号,通常由用户在终端按下 Ctrl + Z 产生
21SIGTTIN后台进程试图从控制终端读取时产生
22SIGTTOU后台进程试图向控制终端写入时产生
23SIGURG紧急数据到达套接字的信号
24SIGXCPU超过 CPU 时间限制信号
25SIGXFSZ超过文件大小限制信号
26SIGVTALRM虚拟定时器信号
27SIGPROF性能分析定时器信号
28SIGWINCH窗口大小改变信号
29SIGIOI/O 就绪信号
30SIGPWR电源故障信号
31SIGSYS系统调用错误信号

(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功能:

  1. 确认是否打开 core dump 功能 
    ulimit -a
  2. 打开 core dump 功能
    ulimit -c size
    // size 换成大于0的就行(表示核心转储文件的上限,设置为0就是不进行核心存储)
  3. 关闭 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 标志位取决于

  1. 是否开启 core dump 功能
  2. 是否为 core 退出

5. 进程看待信号的方式

  1. 进程默认知道信号的种类与默认处理方式(表现在 task_struct 的三张位图)
  2. 信号到来,可以不立即处理,在合适的时候处理,此时在 task_struct 中保存该信号
  3. 进程不会等待信号的到来,信号是异步产生的

二、信号的保存形式

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;
  • 27
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

终将向阳而生

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值