目录

Linux-信号

Linux 信号


1. 基本概念

信号是进程之间 事件

异步

通知的一种方式。

在目标进程运行时,操作系统(OS)可以将 特定信号

“传递”

给该进程, 执行对应的动作

查看Linux系统中有那些信号: kill -l 【宏】 https://i-blog.csdnimg.cn/direct/7e85177ec334452a861d8a9d1fc09821.png

发现:没有 0, 32, 33号信号

(本文的介绍专注于 1-31 号信号类型的学习)

执行对应的动作 即 怎么处理:

1. 默认行为 (OS制定)详情可使用 man 7 signal 阅览 (大多数为终止这个进程)

2. 提供一个信号处理函数 — 这种方式称为 捕捉(Catch) 一个信号 【要求内核在处理该信号时切换到用户态执行这个处理函数(后面详细介绍)】

3. 忽略此信号

系统调用接口: https://i-blog.csdnimg.cn/direct/90959a794f8943f9bcb0a20f18bb996f.png

或者使用 更加灵活的 sigaction , 但使用的细致和复杂度也随之增加

注意:SIGKILL(9) 和 SIGSTOP(19) 不能捕捉!

2. 几种产生方式

1. 键盘 https://i-blog.csdnimg.cn/direct/08cfbec6233e4f1eb884c79151fdbe66.png

Ctrl + c 产生的信号【SIGINT(2)】只能发给前台进程。一般的程序代码不做特殊处理,在Shell启动的都是前台进程; 一个命令后面加个&可以放到后台运行,这样Shell不必等待进程 结束就可以接受新的命令,启动新的进程。

Shell可以 同时 运行一个前台进程和任意多个后台进程,只有前台进程才能接到像这样的信号,比如还有 **Ctrl + ** 表示【SIGQUIT (3)】, Ctrl + z 表示【SIGSTOP(19)】

前台进程在运行过程中用户随时可能按下这些快捷键 或者 后面提到的方式, 这个进程都能收到 指定信号 , 并在用户空间代码执行 对应的动作 , 所以信号相对于进程的控制流程来说是异步 (Asynchronous) 的。

2. 调用函数接口

https://i-blog.csdnimg.cn/direct/fa434f79278144e4b951336d82dedbf3.png

https://i-blog.csdnimg.cn/direct/f787ffd959564f34a50eb81d60a3fc9c.png

https://i-blog.csdnimg.cn/direct/83aa6e4e4e1d461c9ee9e28f91647066.png

3. 软件条件

通常表示程序运行中的异常 或 需要特定响应的事件。

举例:

  1. 管道的同步机制:读端关闭,写端尝试写入 已经没有意义,OS发送 SIGPIPE(13)终止写端

2. “闹钟”

#include <unistd.h>

       unsigned int alarm(unsigned int seconds);

调用这个函数可以设定一个 “闹钟” 也就是告诉内核在 seconds 秒之后给当前进程 发送 SIGALRM(14) 信号,该信号的默认处理动作是终止当前进程。

它的返回值为0 或者 前一个闹钟剩余的秒数

当设置参数 seconds == 0时 表示 取消该闹钟

4. 硬件异常产生信号

硬件异常以某种方式可以 被硬件检测 到并通知内核,然后内核向当前进程发送适当的信号。

例如,当前进程执行了 除以0 的指令,CPU的运算单元会产生异常,内核将这个异常解释为 SIGFEP(8) 信号发送给进程,默认终止进程。

再比如,当前进程访问了 非法内存地址 (越界,空指针/野指针的解引用, …), MMU会产生异常,内核将这个异常解释 成 SIGSEGV(11) 号信号发送给进程,默认终止进程。

. . . . . .

3. Core Dump

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上, 文件名通常是core (不同的系统可能不同), 这叫做Core Dump。

进程异常终止通常是因为有Bug, 比如非法内存访问导致段错误, 事后可以 用调试器检查core文件以查清错误原因 ,这叫做Post-mortem Debug (事后调试)

一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。

默认是不允许产生core文件的, 因为core文件中可能包含用户密码等敏感信息, 不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许 产生core文件。

首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024KB:

ulimit -c 查看 core file size 大小

修改: ulimit -c 1024

然后尝试写一个异常代码,或者给它发送信号比如 SIGFEP

运行失败后,就可以看到对应的 core文件

使用以下命令启动 GDB 并加载可执行文件和 core 文件:

db your_program(崩溃的可执行文件) core

查看程序崩溃位置:

(gdb) bt

4. 内核结构和操作接口

首先,对上面提到的内容做总结表述

  1. 实际执行信号的处理动作称为 信号递达(Delivery)

  2. 信号从产生到递达之间的状态称为 信号未决(Pending)

  3. 进程可以选择 阻塞(Block) 某个信号,即 屏蔽 :可以收到,但不 递达

  4. 被阻塞的信号将始终保持在未决状态,直到进程解除对此信号的阻塞, 才递达

需要

注意

的是:忽略 也是递达的方式之一,和阻塞不是同一个概念!

信号在内核中的表示示意图:

https://i-blog.csdnimg.cn/direct/8e6fbabf564c4cbaa7a35f088d4a19da.png

开头所说, 操作系统(OS)可以将 特定信号

“传递”

给某个进程 ,其 “传递” 的意思是:OS修改该进程对应的内核数据结构(如上),记录信号状态。

每个信号有两个标志位分别表示 阻塞(Block)和 未决(Pending),还有一个函数指针 指向 递达动作。

收到信号时,内核在进程控制块中设置该信号的未决标志,直到信号递达才修改该标志。

从上图来看, 每个信号只有一个bit的未决标志,非0即1, 不记录该信号产生了多少次 , 阻塞标志也是这样表示的。

因此, 未决和阻塞标志可以用 相同的数据类型sigset_t 来存储, sigset_t 称为 信号集 , 这个类型可以表示每个信号 的 “有效”“无效”状态 , 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中 “有 效”和“无效”的含义是该信号是否处于未决状态。

阻塞信号集也叫做当 前进程的信号屏蔽字(Signal Mask)。

至于这个类型内部如何存储这些bit则依赖于系统实现, 从使用者的角度是不必关心的, 使用者只能调用以下函数来操作sigset_ t变量, 而不应该对它的内部数据做任何解释, 比如用printf直接打印sigset_t变量是没有意义的:

#include <signal.h>
 int sigemptyset(sigset_t *set);
 int sigfillset(sigset_t *set);
 int sigaddset (sigset_t *set, int signo);
 int sigdelset(sigset_t *set, int signo);
 int sigismemberconst sigset_t *set, int signo);

函数 sigemptyset 初始化set所指向的信号集, 使其中所有信号的对应bit清零, 表示该信号集不包含 任何有 效信号。

函数 sigfillset 初始化set所指向的信号集, 使其中所有信号的对应bit为1,表示该信号集的有效信号包括系统支持的所有信号。

注意 , 在使用sigset_ t类型的变量之前, 一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

这四个函数都是成功返回0,出错返回-1。

sigismember是一个布尔函数, 用于判断一个信号集的有效信号中是否包含某种信号, 若包含则返回1, 不包含则返回0, 出错返回-1。

对 sigset_t 类型的变量做好设置后,就可以用下面的两个函数接口,把 它数据 修改到进程内核结构中,影响进行的执行:

1. sigprocmask

#include <signal.h>
 int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1

如果oset是非空指针, 则读取进程的当前信号屏蔽字通过oset参数传出。

如果set是非空指针, 则更改进程的信号屏蔽字, 参数 how 指示如何更改:

SIG_BLOCK : 当前集合和set参数的并集

SIG_UNBLOCK :set 信号集中的信号从当前进程的信号屏蔽字中移除

SIG_SETMASK : 重新设置当前进程的信号屏蔽字为 set

2. sigpending

#include <signal.h>

    int sigpending(sigset_t *set);

读取当前进程的未决信号集,通过set参数传出; 调用成功则返回0,出错则返回-1。

5.  捕捉的原理

到这,我们还有最后一个问题没有解决:信号的处理(递达)时机?

—— 通常是内核态 到 用户态的切换过程中

关于什么是 内核态,什么是用户态,简单来说就是:

用户态执行受限的操作,访问受限的资源

内核态可执行所以操作,访问所有资源

举个例子:

比如涉及到:read(), write(), wait() 等操作;进程调度切换时,重新获得CPU,进行上下文切换时;…… 都会导致进程进入内核态

因为硬件上有相关机制来标识和支持两种状态,通常是寄存器,比如序状态字寄存器( PSW )或状态寄存器( S R)等,其中的某些位可以用来表示当前处理器处于用户态还是内核态。

至于为什么这样做的根本原因就是:OS作为软硬件资源的管理者,负责整个计算机的安全和稳定,用户的一切操作都不能跨过它,特别是涉及到底层文件数据的敏感性操作,软硬件需要统筹提供一些安全策略和机制。

下面是一张过程示意图: https://i-blog.csdnimg.cn/direct/7ca026c71ac04c66a157f1a8be3e648b.png

这里需要特别解释的是 :信号的产生是异步的,它可以在进程执行的任何时刻发生,与进程当前正在执行的代码路径(如 main 函数中的代码)没有直接的顺序关系。 也就是说 sighandlermain 函数使用 不同的堆栈空间 , 它们之间不存在调用和被调用的关系, 是 两个独立的控制流程, 所以:递达动作结束后,还要返回内核态,读取内核保存的当前进程上下文数据,保证返回用户态后从正确的位置继续。

至于是怎么做到的,你还记得下面的 地址空间图吗: https://i-blog.csdnimg.cn/direct/d4da0263f9294ddfb3dc4c4b11a88563.png

6. SIGCHLD (17)信号

用wait和waitpid函数清理僵尸进程, 父进程可以阻塞等待子进程结束, 也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。

采用第一种方式, 父进程阻塞了就不能处理自己的工作了; 采用第二种方式, 父进程在处理自己的工作的同时还要记得时不时地轮询一 下, 程序实现复杂。

其实, 子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略, 父进程可以自 定义SIGCHLD信号的处理函数, 这样父进程只需专心处理自己的工作, 不必关心子进程了, 子进程 终止时会通知父进程, 父进程在信号处理函数中调用wait清理子进程即可。

但是事实上, 由于 UNIX 的历史原因, 要想不产生僵尸进程还有另外一种办法: 父进程调用sigaction将SIGCHLD的处理动作置为 SIG_IGN , 这样fork出来的子进程在终止时会自动清理掉, 不会产生僵尸进程,也不会通知父进程。

系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的, 但这是一个 特例 。此方法对于Linux可用, 但不保证在其它UNIX系统上都可用。

本篇分享到这就结束了,如果对你有所帮助,就是对小编最大的鼓励,可以的话,点赞+收藏并分享给你的小伙伴一起学习吧!

关注小编,持续更新中……