Linux 进程信号详解
进程信号
信号是进程之间事件异步通知的一种方式,属于软中断。
kill -l //查看不同信号代表的事件
执行kill -l 可以看到共有62种信号,其中:
0-31号信号为非可靠信号(这部分信号借鉴于UNIX系统的信号);
34-64号信号为可靠信号(linux自己扩充的信号)。
· 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2
· 编号34以上的是实时信号,本文只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal
信号的产生
硬件产生:
1.ctrl+c :2号信号 SIGINT(键盘当中按下ctrl+c结束一个进程的时候,其实是进程收到了2号信号。2号信号导致了进程的退出。 )
2.ctrl+z :20号信号 SIGTSTP ,停止一个进程
3.ctrl+| :3号信号 SIGQUIT 退出一个进程
4.kill命令向进程发送信号:
kill -[信号值] [pid]
软件产生:
· kill函数:#include<signal.h> int kill(pid_t pid,int sig);
pid:进程号,给哪个进程发就填这个进程的进程号
sig:要发送的信号的值
· raise函数:int raise(int sig);
谁调用给谁发信号
sig:要发送的信号值
该函数的实现实际时调用kill函数
int raise(int sig){ return kill(getpid(),sig); }
kill -[num] [pid] //可以给进程发信号
信号的处理
可选的处理动作有以下三种:
1. 忽略此信号。
2. 执行该信号的默认处理动作。
3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
· 实际执行信号的处理动作称为信号递达(Delivery)
· 信号从产生到递达之间的状态,称为信号未决(Pending)。· 进程可以选择阻塞 (Block )某个信号。
· 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
·注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号的处理时机:分两种情况
普通情况:所谓的普通情况就是指 信号没有被阻塞,直接产生,记录未决信息后,再进行处理,在这种情况下,信号是不会被立即递达的,也就无法立即处理,需要等待合适的时机
特殊情况:当信号被 阻塞 后,信号 产生 时,记录未决信息,此时信号被阻塞了,也不会进行处理当阻塞解除后,信号会被立即递达,此时信号会被立即处理
信号的产生是 异步 的
当信号产生时,进程可能在处理更重要的事,此时贸然处理信号显然不够明智,因此信号在 产生 后,需要等进程将 更重要 的事忙完后(合适的时机),才进行 处理。
合适的时机:进程从 内核态 返回 用户态 时,会在操作系统的指导下,对信号进行检测及处理
至于处理动作,分为:默认动作、忽略、用户自定义
用户态与内核态
概念:
用户态:执行用户所写的代码时,就属于 用户态
内核态:执行操作系统的代码时,就属于 内核态
谈谈进程地址空间 :
- 进程地址空间 是虚拟的,依靠 页表+
MMU
机制 与真实的地址空间建立映射关系- 每个进程都有自己的 进程地址空间,不同 进程地址空间 中地址可能冲突,但实际上地址是独立的
- 进程地址空间 可以让进程以统一的视角看待自己的代码和数据
在 进程地址空间 中,存在 1 GB
的 内核空间,每个进程都有,而这 1 GB
的空间中存储的就是 操作系统 相关 代码 和 数据,并且这块区域采用 内核级页表 与 真实地址空间 进行映射 。
区分用户态和内核态的目的:
- 内核空间中存储的可是操作系统的代码和数据,权限非常高,绝不允许随便一个进程对其造成影响
- 区域的合理划分也是为了更好的进行管理
所谓的 执行操作系统的代码及系统调用,就是在使用这 1 GB
的内核空间
当我们执行诸如 open
这类的系统调用时,会跑到内核空间中调用对应的函数而跑到内核空间就是用户态切换为内核态了。
如何实现的呢?
在 CPU
中,存在一个 CR3
寄存器,这个 寄存器 的作用就是用来表征当前处于 用户态 还是 内核态
- 当寄存器中的值为
3
时:表示正在执行用户的代码,也就是处于 用户态- 当寄存器中的值为
0
时:表示正在执行操作系统的代码,也就是处于 内核态
· 所有进程的用户空间 [0, 3] GB 是不一样的,并且每个进程都要有自己的 用户级页表 进行不同的映射
· 所有进程的内核空间 [3, 4] GB 是一样的,每个进程都可以看到同一张内核级页表,从而进行统一的映射,看到同一个 操作系统
· 操作系统运行 的本质其实就是在该进程的 内核空间内运行的(最终映射的都是同一块区域)
· 系统调用 的本质其实就是在调用库中对应的方法后,通过内核空间中的地址进行跳转调用
· 操作系统的本质
- 操作系统也是软件,并且是一个死循环式等待指令的软件
- 存在一个硬件:操作系统时钟硬件,每隔一段时间向操作系统发送时钟中断
· 进程被调度,就意味着它的时间片到了,操作系统会通过时钟中断,检测到是哪一个进程的时间片到了,然后通过系统调用函数 schedule() 保存进程的上下文数据,然后选择合适的进程去运行
信号的处理过程
情况1:信号被阻塞,信号产生/未产生
信号都被阻塞了,也就不需要处理信号,此时不用管,直接切回 用户态 就行了
情况2:当前信号的执行动作为 默认
大多数信号的默认执行动作都是 终止 进程,此时只需要把对应的进程干掉,然后切回 用户态 就行了
情况3:当前信号的执行动作为 忽略
当信号执行动作为 忽略 时,不做出任何动作,直接返回 用户态
情况4:当前信号的执行动作为 用户自定义
这种情况就比较麻烦了,用户自定义的动作位于 用户态 中,也就是说,需要先切回 用户态,把动作完成了,重新坠入 内核态,最后才能带着进程的上下文相关数据,返回 用户态
1.在 内核态 中,也可以直接执行 自定义动作,为什么还要切回 用户态 执行自定义动作?
· 因为在 内核态 可以访问操作系统的代码和数据,自定义动作 可能干出危害操作系统的事
在 用户态 中可以减少影响,并且可以做到溯源
2.为什么不在执行完 自定义动作 直接后返回进程?· 因为 自定义动作 和 待返回的进程 属于不同的堆栈,是无法返回的
并且进程的上下文数据还在内核态中,所以需要先坠入内核态,才能正确返回用户态
信号捕捉
概念:如果信号的执行动作为用户自定义动作,当信号递达时调用用户自定义动作,这一动作称为信号捕捉。(用户自定义动作 是位于 用户空间 中的)
当 内核态 中任务完成,准备返回 用户态 时,检测到信号 递达,并且此时为 用户自定义动作,需要先切入 用户态 ,完成 用户自定义动作 的执行;因为 用户自定义动作 和 待返回的函数 属于不同的 堆栈 空间,它们之间也不存在 调用与被调用 的关系,是两个 独立的执行流,需要先坠入 内核态 (通过 sigreturn() 坠入),再返回 用户态 (通过 sys_sigreturn() 返回)
signal
#include <signal.h>typedef void (*__sighandler_t) (int);
__sighandler_t signal(int signo, __sighandler_t handler);
参数:
第一个参数为信号名
第二个参数为:
- SIG_IGN,内核忽略该信号,除了两个不能忽略;
- SIG_DFL,采用系统默认方式处理该信号;
- 函数指针,按照用户的方式处理该信号;
返回值:
- 如果出错,返回SIG_ERR;
- 如果成功,返回上一个处理程序的指针;
下面是一个简单的信号捕捉:
void hander(int sig)
{cout << "get a sig: " << sig << endl;
}int main(){ // 对信号的自定义捕捉,我们只要捕捉一次,后续一直有效// 2 SIGINT默认是什么动作呢?终止进程// 2 SIGINT是什么呢?ctrl+c --- 给目标进程发送2号信号,SIGINT默认是什么动作呢?终止进程signal(2, hander);while (true){cout << "hello bit,pid:" << getpid() << endl;sleep(1);}}
sigaction
sigaction
也可以 用户自定义动作,比 signal
功能更丰富
#include <signal.h>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; //一些选项,一般设为 0void (*sa_restorer)(void); //实时信号相关,不用管
};
参数:
参数1:待操作的信号
参数2:
sigaction
结构体,具体成员如上所示参数3:保存修改前进程的
sigaction
结构体信息
返回值:成功返回
0
,失败返回-1
并将错误码设置
这个函数的主要看点是 sigaction
结构体,其中部分字段不需要管,因为那些是与 实时信号 相关的,我们这里不讨论,重点可以看看 sa_mask
字段
sa_mask
:当信号在执行 用户自定义动作 时,可以将部分信号进行屏蔽,直到 用户自定义动作 执行完成
也就是说,我们可以提前设置一批 待阻塞 的 屏蔽信号集,当执行 signum
中的 用户自定义动作 时,这些 屏蔽信号集 中的 信号 将会被 屏蔽(避免干扰 用户自定义动作 的执行),直到 用户自定义动作 执行完成
#include <iostream>
#include <cassert>
#include <cstring>
#include <signal.h>
#include <unistd.h>using namespace std;static void DisplayPending(const sigset_t pending)
{// 打印 pending 表cout << "当前进程的 pending 表为: ";int i = 1;while (i < 32){if (sigismember(&pending, i))cout << "1";elsecout << "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 模式中出错DisplayPending(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
表中· 信号处理阶段:信号在 内核态 切换回 用户态 时,才会被处理
可重入函数
可以被重复进入的函数称为 可重入函数
我们学过的函数中,90%
都是不可重入的,函数是否可重入是一个特性,而非缺点,需要正确看待
不可重入的条件:
- 调用了内存管理相关函数
- 调用了标准
I/O
库函数,因为其中很多实现都以不可重入的方式使用数据结构