文章目录
- 1. 信号入门
- 1.1 进程与信号的相关知识
- 1.2 技术应用角度的信号
- 1.3 注意
- 1.4 信号概念
- 1.5 信号处理常见方式概览
- 2. 产生信号
- 2.1 通过终端按键产生信号
- 2.2 调用系统函数向进程发信号
- 2.3 由软件条件产生信号
- 2.4 硬件异常产生信号
- 2.5 信号保存
- 3. 阻塞信号
- 3.1 信号其他相关常见概念
- 3.2 在内核中的表示
- 3.3 sigset_t
- 3.4 信号集操作函数
- sigprocmask
- sigpending
1. 信号入门
1.1 进程与信号的相关知识
进程 必须 识别+能够处理信号(信号没有产生,也要具备处理信号的能力)信号的处理能力,属于进程内置功能的一部分
进程即便是没有收到信号,也能知道哪些信号该怎么处理
当进程真的收到了一个具体的信号的时候,进程可能并不会立即处理这个信号,需要等到合适的时候
一个进程,当信号产生到信号开始被处理,就一定会有时间窗口,进程具有临时保存哪些信号已经发生了的能力
1.2 技术应用角度的信号
用户输入命令,在Shell下启动一个前台进程。
用户按下Ctrl+C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程
前台进程因为收到信号,进而引起进程退出
ctrl+c为什么能够杀掉我们前台进程呢?
Linux中,一次登陆中,一个终端一般会配上一个bash,每一个登陆,只允许一个进程是前台进程,可以允许多个进程是后台进程。
键盘输入首先是被前台进程收到的。(这是前台进程和后台进程的本质区别)
ctrl +c本质是被进程解释成为收到了信号。ctrl+c 会触发SIGINT信号(信号编号2),然后终端驱动程序捕获这个按键组合,将SIGINT信号发送给前台进程组的所有进程。
前台进程特性
与终端关联
能够接收终端输入
属于当前终端的前台进程组
只能终止前台进程的原因
终端只与前台进程组关联
后台进程组收不到终端产生的信号
关键点:ctrl+c 本质是通过信号机制来终止进程的,而不是直接"杀死"进程。
1-31是普通信号,34-64是实时信号。
信号的处理方式:
默认动作
忽略
自定义动作(信号的捕捉)
例如红灯亮了就等绿灯是默认动作,不管红灯闯红灯就是忽略,红灯了唱歌跳舞就是自定义动作。
进程收到2号信号的默认动作,就是终止自己。
不是所有的信号都是可以被signal捕捉的,比如:9,19。
但是无论信号如何产生,最终一定是谁发送给进程的?
OS,因为OS是进程的管理者。
1.3 注意
- Ctrl+C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl+C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl+C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
1.4 信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
1.5 信号处理常见方式概览
(sigaction函数稍后详细介绍),可选的处理动作有以下三种:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
2. 产生信号
2.1 通过终端按键产生信号
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump。
Core Dump
首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。
进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。
默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。
2.2 调用系统函数向进程发信号
首先在后台执行死循环程序,然后用kill命令给它发SIGSEGV信号。
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。
raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
这两个函数都是成功返回0,错误返回-1。
abort函数使当前进程接收到信号而异常终止。
#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
2.3 由软件条件产生信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
2.4 硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。以下是几种常见的硬件异常及其对应的信号:
- 除零异常(SIGFPE):
当程序执行除以0的操作时,CPU的算术逻辑单元会检测到这个异常。例如:
int a = 1;
int b = 0;
int c = a / b; // 触发SIGFPE信号
- 段错误(SIGSEGV):
当程序访问了非法内存地址时,内存管理单元(MMU)会产生异常。例如:
int *p = NULL;
*p = 1; // 访问空指针,触发SIGSEGV信号int arr[10];
arr[10000] = 1; // 数组越界,可能触发SIGSEGV信号
- 非法指令(SIGILL):
当CPU执行了非法指令时产生此信号:
void (*bad_func_ptr)() = (void (*)())0x12345678;
bad_func_ptr(); // 执行非法地址的代码,触发SIGILL信号
- 总线错误(SIGBUS):
当访问未对齐的内存地址时可能产生此信号:
char *ptr = (char *)0x12345;
int *iptr = (int *)ptr;
*iptr = 1; // 可能触发SIGBUS信号
在系统层面,这些硬件异常的处理流程是:
- 硬件检测到异常
- 触发CPU中断
- CPU切换到内核态
- 内核将硬件异常转换为相应的信号
- 内核向进程发送信号
- 如果进程注册了信号处理函数,则执行该函数
- 如果没有注册处理函数,则执行信号的默认处理动作(通常是终止进程)
这就是为什么C/C++中的很多运行时错误(如除零、空指针解引用、数组越界等)最终都表现为进程收到信号并终止。这种机制让操作系统能够及时发现并处理程序中的严重错误,防止错误程序继续运行可能造成的更大危害。
2.5 信号保存
为什么要信号保存?
进程收到信号之后,可能不会立即处理这个信号。信号不会被处理,就要有一个时间窗口。
3. 阻塞信号
3.1 信号其他相关常见概念
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
3.2 在内核中的表示
信号在内核中的表示示意图
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
3.3 sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
3.4 信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些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置位,表示 该信号集的有效信号包括系统支持的所有信号。
注意:在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending
#include <signal.h>
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
代码:
void printsigset(sigset_t *set)
{// 打印信号集中的信号状态,用1表示信号在集合中,0表示不在for(int i=1; i<32; i++) {if (sigismember(set, i)) { // 判断信号i是否在信号集set中putchar('1');} else {putchar('0');}}puts("");
}int main()
{sigset_t s, p;sigemptyset(&s); // 初始化信号集s为空集sigaddset(&s, SIGINT); // 将SIGINT信号添加到信号集s中,Ctrl+Csigprocmask(SIG_BLOCK, &s, NULL); // 设置信号屏蔽字,阻塞SIGINT信号while(1) {sigpending(&p); // 获取未决信号集printsigset(&p); // 打印未决信号集sleep(1);}return 0;
}
程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl+C将会使SIGINT信号处于未决状态。按Ctrl+\仍然可以终止程序,因为SIGQUIT信号没有阻塞。
代码:
// 打印未决信号集的函数
void PrintPending(sigset_t &pending)
{// 从31号信号到1号信号逐个检查for (int signo = 31; signo >= 1; signo--){if (sigismember(&pending, signo))cout << "1"; // 信号处于未决状态elsecout << "0"; // 信号不在未决集中}cout << "\n\n";
}// 信号处理函数
void handler(int signo)
{cout << "catch a signo: " << signo << endl;
}int main()
{// 4. 屏蔽所有可屏蔽信号sigset_t bset, oset;sigemptyset(&bset); // 清空信号集sigemptyset(&oset); // 清空旧信号集for (int i = 1; i <= 31; i++){sigaddset(&bset, i); // 将所有信号添加到屏蔽集}sigprocmask(SIG_SETMASK, &bset, &oset); // 设置信号屏蔽字// 循环检测未决信号sigset_t pending;while (true){int n = sigpending(&pending); // 获取未决信号集if (n < 0)continue;PrintPending(pending); // 打印未决信号集sleep(1);}// // 0. 对2号信号进行自定义捕捉// signal(2, handler);// // 1. 先对2号信号进行屏蔽 --- 数据预备// sigset_t bset, oset; // 在哪里开辟的空间???用户栈上的,属于用户区// sigemptyset(&bset);// sigemptyset(&oset);// sigaddset(&bset, 2); // 我们已经把2好信号屏蔽了吗?并没有设置进入到你的进程的task_struct// // 1.2 调用系统调用,将数据设置进内核// sigprocmask(SIG_SETMASK, &bset, &oset); // 我们已经把2好信号屏蔽了吗?ok// // 2. 重复打印当前进程的pending 0000000000000000000000000// sigset_t pending;// int cnt = 0;// while (true)// {// // 2.1 获取// int n = sigpending(&pending);// if (n < 0)// continue;// // 2.2 打印// PrintPending(pending);// sleep(1);// cnt++;// // 2.3 解除阻塞// if(cnt == 20)// {// cout << "unblock 2 signo" << endl;// sigprocmask(SIG_SETMASK, &oset, nullptr); // 我们已经把2好信号屏蔽了吗?ok// }// }// // 3 发送2号 0000000000000000000000010return 0;
}
被注释的代码:
// 0. 设置2号信号(SIGINT)的处理函数
signal(2, handler);// 1. 先对2号信号进行屏蔽 --- 数据预备
sigset_t bset, oset; // 在用户栈上创建信号集
sigemptyset(&bset); // 初始化为空集
sigemptyset(&oset); // 保存旧的信号屏蔽字
sigaddset(&bset, 2); // 只添加2号信号到屏蔽集
// 调用系统调用,将数据设置进内核
sigprocmask(SIG_SETMASK, &bset, &oset);// 2. 监控未决信号状态
// 重复打印当前进程的pending 0000000000000000000000000
sigset_t pending;
int cnt = 0;
while (true)
{int n = sigpending(&pending); // 获取未决信号if (n < 0)continue;PrintPending(pending); // 打印未决信号状态sleep(1);cnt++;// 20秒后解除2号信号的屏蔽if(cnt == 20){cout << "unblock 2 signo" << endl;// 恢复原来的信号屏蔽字,即解除屏蔽sigprocmask(SIG_SETMASK, &oset, nullptr);}
}
// 3 发送2号 0000000000000000000000010
两个场景的区别:
- 当前执行的代码:
- 屏蔽所有可屏蔽信号
- 持续监控所有信号的未决状态
- 信号会一直保持在未决状态
- 注释掉的代码:
- 只屏蔽SIGINT(2号)信号
- 设置了SIGINT的自定义处理函数
- 20秒后解除屏蔽,让信号能够被处理
- 可以观察到SIGINT信号从未决变为已处理的过程
注释中的重要说明:
task_struct
:进程描述符,在内核中保存进程的信号屏蔽字- 信号集虽然在用户栈上定义,但实际的屏蔽操作是在内核中完成
- 通过注释分步骤展示了信号屏蔽、监控和解除屏蔽的完整流程