Linux-信号
Linux 信号
1. 基本概念
信号是进程之间 事件
异步
通知的一种方式。
在目标进程运行时,操作系统(OS)可以将 特定信号
“传递”
给该进程, 执行对应的动作 。
查看Linux系统中有那些信号:
kill -l
【宏】
发现:没有 0, 32, 33号信号
(本文的介绍专注于 1-31 号信号类型的学习)
执行对应的动作 即 怎么处理:
1. 默认行为 (OS制定)详情可使用 man 7 signal 阅览 (大多数为终止这个进程)
2. 提供一个信号处理函数 — 这种方式称为 捕捉(Catch) 一个信号 【要求内核在处理该信号时切换到用户态执行这个处理函数(后面详细介绍)】
3. 忽略此信号
系统调用接口:
或者使用 更加灵活的 sigaction , 但使用的细致和复杂度也随之增加
注意:SIGKILL(9) 和 SIGSTOP(19) 不能捕捉!
2. 几种产生方式
1. 键盘
Ctrl + c 产生的信号【SIGINT(2)】只能发给前台进程。一般的程序代码不做特殊处理,在Shell启动的都是前台进程; 一个命令后面加个&可以放到后台运行,这样Shell不必等待进程 结束就可以接受新的命令,启动新的进程。
Shell可以 同时 运行一个前台进程和任意多个后台进程,只有前台进程才能接到像这样的信号,比如还有 **Ctrl + ** 表示【SIGQUIT (3)】, Ctrl + z 表示【SIGSTOP(19)】
前台进程在运行过程中用户随时可能按下这些快捷键 或者 后面提到的方式, 这个进程都能收到 指定信号 , 并在用户空间代码执行 对应的动作 , 所以信号相对于进程的控制流程来说是异步 (Asynchronous) 的。
2. 调用函数接口
3. 软件条件
通常表示程序运行中的异常 或 需要特定响应的事件。
举例:
- 管道的同步机制:读端关闭,写端尝试写入 已经没有意义,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. 内核结构和操作接口
首先,对上面提到的内容做总结表述
实际执行信号的处理动作称为 信号递达(Delivery)
信号从产生到递达之间的状态称为 信号未决(Pending)
进程可以选择 阻塞(Block) 某个信号,即 屏蔽 :可以收到,但不 递达
被阻塞的信号将始终保持在未决状态,直到进程解除对此信号的阻塞, 才递达
需要
注意
的是:忽略 也是递达的方式之一,和阻塞不是同一个概念!
信号在内核中的表示示意图:
开头所说, 操作系统(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 sigismember(const 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作为软硬件资源的管理者,负责整个计算机的安全和稳定,用户的一切操作都不能跨过它,特别是涉及到底层文件数据的敏感性操作,软硬件需要统筹提供一些安全策略和机制。
下面是一张过程示意图:
这里需要特别解释的是
:信号的产生是异步的,它可以在进程执行的任何时刻发生,与进程当前正在执行的代码路径(如
main
函数中的代码)没有直接的顺序关系。 也就是说
sighandler
和
main
函数使用
不同的堆栈空间
, 它们之间不存在调用和被调用的关系, 是
两个独立的控制流程,
所以:递达动作结束后,还要返回内核态,读取内核保存的当前进程上下文数据,保证返回用户态后从正确的位置继续。
至于是怎么做到的,你还记得下面的 地址空间图吗:
6. SIGCHLD (17)信号
用wait和waitpid函数清理僵尸进程, 父进程可以阻塞等待子进程结束, 也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。
采用第一种方式, 父进程阻塞了就不能处理自己的工作了; 采用第二种方式, 父进程在处理自己的工作的同时还要记得时不时地轮询一 下, 程序实现复杂。
其实, 子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略, 父进程可以自 定义SIGCHLD信号的处理函数, 这样父进程只需专心处理自己的工作, 不必关心子进程了, 子进程 终止时会通知父进程, 父进程在信号处理函数中调用wait清理子进程即可。
但是事实上, 由于 UNIX 的历史原因, 要想不产生僵尸进程还有另外一种办法: 父进程调用sigaction将SIGCHLD的处理动作置为 SIG_IGN , 这样fork出来的子进程在终止时会自动清理掉, 不会产生僵尸进程,也不会通知父进程。
系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的, 但这是一个 特例 。此方法对于Linux可用, 但不保证在其它UNIX系统上都可用。
本篇分享到这就结束了,如果对你有所帮助,就是对小编最大的鼓励,可以的话,点赞+收藏并分享给你的小伙伴一起学习吧!
关注小编,持续更新中……