🌠 作者:@阿亮joy.
🎆专栏:《学会Linux》
🎇 座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
目录
👉信号入门👈
信号是一种软件中断,信号在 Linux 操作系统 中提供了一种处理异步事件的方法,可以很好地在多个进程之间进行同步和简单的数据交互。注:信号和信号是两个东西,没有关系!信号只是用来通知某个进程发生了什么事情,但并不给该进程传递任何数据。
生活中的信号
在生活中,我们会收到很多信号,比如:红绿灯、闹钟、转向灯和狼烟等等。那我们为什么会知道这些生活中的信号呢?其实是我们曾经学习过有关这些生活信号的知识并且记住了对应场景下的信号。有关信号的推论如下:
- 当这些信号产生时,我们就能够识别这些信号,并且执行相应的动作。
- 当特定信号没有产生时,我们依旧知道应该如何处理这个信号。
- 当我们收到信号时,我们可能不会立即处理这个信号。
- 当我们无法立即处理信号的时候,信号也一定要先被临时地记住。
Linux信号
什么是 Linux 信号?Linux 信号本质是一种通知机制,用户或操作系统通过发送一定的信号,通知进程某些时间已经发生了,进程可以在后续进行信号处理。
- 进程要处理信号,那么进程必须具备信号识别的能力(收到信号加上相对应的信号处理动作)。
- 为什么进程能够识别信号呢?进程能够识别信号,肯定是设计操作系统的程序员将常见的信号及信号处理动作内置到进程的代码和属性中。
- 信号产生是随机的,当信号产生时,进程可能正在处理某些任务。所以,信号可能不是立即被进程处理的。
- 信号会被临时地记录下来,方便进程后续进行处理。
- 那进程会在什么时候处理信号呢?合适的时候。
- 一般而言,信号的产生相对于进程而言是异步的。异步指两个或两个以上的对象或事件不同时存在或发生(或多个相关事物的发生无需等待其前一事物的完成)。同步指两个或两个以上随时间变化的量在变化过程中保持一定的相对关系。
- 注:信号也有确定的信号,比如:定下闹钟的时间时,那么闹钟一定会在那个时间点响起来。
- 信号处理的常见方式:
- 默认(进程自带的处理动作,该动作是程序员写好的逻辑)
- 忽略(忽略也是信号处理的一种方式)
- 自定义动作(捕捉信号)
常见信号
kill -l #该命令可以查看常见的信号 man 7 signal #查看信号的相关描述
Linux 内核支持 62 种不同的信号,这些信号都有一个名字,这些名字都以三个字符 SIG 开头。在头文件siganl.h
中你能够,这些信号都被定义为正整数,称为信息编号。其中,编号 1 到 31 的信号称为普通信号,编号 34 到 64 的信号称为实时信号,实时信号对处理的要求比较高。
普通信号和实时信号的关系就像分时操作系统和实时操作系统的关系类似,分时操作系统是基于时间片轮转调度的,而实时操作系统要求要有严格的时序,可以认为是一个队列。将一个任务放入该队列中,那么操作系统就尽量快地将该任务处理完。日常生活中使用最多的就是分时操作系统,而实时操作系统常见于特殊的行业,如军工领域和自动驾驶领域等等。
组合键转化成信号
Ctrl + C 的本质就是给进程发送了 2 信号,进程接收到 2 号信号后的默认处理动作是结束进程。
那如何理解组合键变成信号呢?其实键盘的工作方式是通过中断方式进行的。键盘是槽位的,每个槽位都会对应一个编号。因为有键盘驱动,操作系统是能够识别这些编号的。只要按下了一些键,操作系统立马就能够识别到。那么当你按下组合键,操作系统也是可以识别到的。操作系统既然都识别到了你按下了组合键,那么操作系统给特定的进程发送信号,也就是轻而易举的事情了。
既然进程要接收操作系统发送过来的信号,那么进程必须要具有保存信号的相关数据结构,而该数据结构就是位图(unsigned int),使用比特位信息就可以表示操作系统是否有给进程发送信号。比如:最低位比特位为 1,则说明操作系统给该进程发送了 1 号信号;反之,则操作系统没有给该进程发送 1 号信号。注:该位图结构保存在进程的内核数据结构 task_struct 中,只有操作系统才能修改 task_struct。信号产生的方式有很多种,但其发送的本质就是操作系统向目标进程写信号,操作系统修改 task_struct 中的位图结构,完成信号发送的过程。
那么组合键能够转化成信号也就很好理解了。当你按下组合键 Ctrl + C 时,操作系统识别到该组合键并解释该组合键,然后查找到在前台运行的进程,最后操作系统将 Ctrl + C 对应的信号写入到进程内部的位图结构中就完成了信号发送。现在进程已经将操作系统发给它的信号记录下来了,进程就会在合适的时候处理该信号。
注意:
- Ctrl + C 产生的信号只能发给前台进程。一个命令后面加个 & 可以放到后台运行,这样 shell 不必等待进程结束就可以接受新的命令,启动新的进程。
- shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl + C 这种组合键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl + C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous) 的。
👉信号产生👈
通过终端按键产生信号
在上面的内容已经提及到,按下组合键 Ctrl + C 可以前台进程发送 2 号信号,那我们可以通过 signal 函数来验证一下。
signal 函数的原型如下:
使用 signal 函数后,当进程接收到 signum 信号时,进程会调用 handler 函数(handler 是回调函数,handler 是 函数指针类型,该函数的返回值是 void,参数是 int)并将 signum 传递给 handler 函数,其实 signal 函数相当于可以自定义捕捉某个信号。signal 函数的返回值是对于 signum 信号的旧的处理方法。
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; void catchSignal(int signal) { cout << "捕捉到了一个信号: " << signal << endl; } int main() { // signal(2, catchSignal); // 这种写法也可以 // catchSignal是自定义捕捉 signal(SIGINT, catchSignal); // 特定信号的处理动作一般只有一个 while(true) { cout << "我是一个进程,我的pid是: " << getpid() << endl; sleep(2); } return 0; }
注:signal 函数仅仅是修改进程对特定信号的后续处理动作,并不是直接调用对应的处理动作。而是当进程接收到特定信号时,才会去调用对应的处理动作。如果后续没有产生 SIGINT 信号,catchSignal 函数就不会被调用,signal 函数往往放在最前面,先注册特定信号的处理方法。
现在就无法通过 Ctrl + C(2 号信号)终止该进程了,那么我们可以通过 Ctrl + \ (3 号信号)终止该进程。如果你也将 3 号信号也自定义捕捉了,那么可以发生 8 号信号(浮点数异常)来终止进程。
核心转储
首先解释什么是核心转储(Core Dump)。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core,这种行为就叫做核心转储(Core Dump)。
进程异常终止通常是因为有 Bug,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。一个进程允许产生多大的 core 文件取决于进程的 Resource Limit(这个信息保存在 PCB 中)。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。 首先用 ulimit 命令改变 shell 进程的 Resource Limit,允许 core 文件最大为1024K:$ ulimit -c 1024。
一般而言,云服务器(生产环境)的核心转储功能是关闭的。程序员写代码的环境称为开发环境,测试人员的环境是测试环境(测试 Realease 版本),产品上线后用户可以使用的环境就成为生产环境(有对应的服务器)。我们所购买的云服务器是集开发、测试、发布、部署于一体的机器。
打开云服务器的核心转储功能后,我们来验证一下是否真的会产生 core 文件。
注:只有核心转储才会生成 core 文件。
core 文件是以进程 ID 作为后缀,通常该文件是比较大的。生产环境一般会关闭核心转储功能是为了防止生成大量的 core 文件占用磁盘空间。如果磁盘中充满大量的 core 文件,可能会导致服务器无法重启或操作系统挂掉。
通过生成的 core 文件来进行 Debug
验证进程等待中的 core dump 标记位
#include <iostream> #include <signal.h> #include <unistd.h> #include <sys/wait.h> #include <cassert> using namespace std; // 验证进程等待中的core dump标记位 int main() { int id = fork(); // 子进程 if(id == 0) { sleep(2); int a = 100; a /= 0; exit(0); } int status = 0; int ret = waitpid(id, &status, 0); assert(ret != -1); (void)ret; cout << "父进程: " << getpid() << " 子进程: " << id << " exit signal: " \ << (status & 0x7F) << " is core: " << ((status >> 7) & 1) << endl; // 父进程 return 0; }
将核心转储功能关闭,就不会生成 core 文件,core dump 标记位始终为 0;当进程不是收到核心转储信号终止进程的,也不会生成 core 文件,core dump 的标记位也始终为 0。
调用系统函数向进程发信号
通过系统调用实现 mykill 命令
系统调用 kill 函数可以想指定的进程发送指定的信号。
// mykill.cc #include <iostream> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <cstring> #include <stdlib.h> using namespace std; static void Usage(string proc) { cout << "Usage:\r\n\t" << proc << " -SignalNumber ProcessID" << endl; } // 通过系统调用向进程发送信号(设计mykill命令) // ./mykill -2 pid int main(int argc, char* argv[]) { if(argc != 3) { Usage(argv[0]); exit(1); } int signal = atoi(argv[1] + 1); int id = atoi(argv[2]); kill(id, signal); return 0; }
raise 函数可以给调用该函数的进程发信号,
raise(sig)
等价于kill(getpid(), sig)
。
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; int main() { cout << "我正在运行中..." << endl; sleep(2); raise(8); return 0; }
abort 函数给调用该函数的进程发送6号信(SIGABRT)终止进程,6 号信号会引起核心转储,通常用来终止进程。就像 exit 函数一样,abort 函数总是会成功的,所以没有返回值。
#include <iostream> #include <unistd.h> #include <stdlib.h> using namespace std; int main() { cout << "我正在运行中..." << endl; sleep(2); abort(); return 0; }
如何理解通过系统调用向进程发信号?用户调用系统接口,执行操作系统对应的系统调用代码,操作系统提取参数或设置特定的数值(信号编号和进程 ID),操作系统向目标进程写信号(修改对应进程的位图结构),进程后续处理信号执行相应的处理动作。
由软件条件产生信号
学习管道的时候,我们说过:当管道读端关闭,写端一直在写,操作系统会自动终止对应的写端进程。操作系统是通过发送 13 号信号(SIGPIPE)来终止写端进程的!
那现在我们来按照一下步骤来验证一下!
- 创建匿名管道
- 让父进程进行读取,子进程进行写入
- 父子进程通行一段时间(该步骤可以省略)
- 让父进程先关闭读端,子进程只有一直写入就行
- 父进程通过 waitpid 等待子进程拿到子进程的退出信息
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/wait.h> #include <sys/types.h> #include <cassert> #include <string> #include <cstring> using namespace std; int main() { // 创建匿名管道 int pipefd[2] = {0}; int n = pipe(pipefd); assert(n != -1); (void)n; cout << "创建匿名管道成功" << endl; // 创建子进程 int id = fork(); if(id == 0) { // 子进程 // 关闭子进程的读端 close(pipefd[0]); char send_buffer[128] = {'\0'}; string s = "我是子进程,我正在给你发消息"; int count = 0; while(1) { // 构造变化的字符串 snprintf(send_buffer, sizeof(send_buffer), "%s id:%d %d", s.c_str(), getpid(), count++); write(pipefd[1], send_buffer, strlen(send_buffer)); sleep(1); } } // 父进程 // 关闭父进程的写端 close(pipefd[1]); char read_buffer[128] = {'\0'}; int count = 0; while(1) { ssize_t s = read(pipefd[0], read_buffer, sizeof(read_buffer) - 1); if(s > 0) { read_buffer[s] = '\0'; ++count; cout << "父进程:" << getpid() << " 收到消息" << read_buffer << endl; } else { cout << "写端已经关闭,读取结束" << endl; break; } // 循环5次,关闭读端 if(count == 5) { close(pipefd[0]); cout << "父进程的读端已关闭!" << endl; break; } } // 获取子进程的退出信息 int status = 0; int ret = waitpid(id, &status, 0); assert(ret != -1); cout << "等待子进程成功 " << "子进程id:" << id << " signal:" << (status & 0x7F) << endl; return 0; }
父进程的读端已经关闭,子进程的写端再进行写入也没有任何的意义,那么操作系统就向子进程发送 13 号信号(SIGPIPE)。像管道的读端关闭写端还在写的这样情况,其实就是不符合软件条件(管道通信的条件,管道也是一种软件),那么操作系统就会向不符合软件条件的进程发送特定的信号终止进程。
alarm 函数可以设定一个闹钟,也就是告诉操作系统在 seconds 秒后给当前进程发送 14 号信号(SIGALRM),该信号的默认处理动作是终止当前进程。
这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为 30 分钟之后响,20 分钟后被人吵醒了,还想多睡一会儿。于是重新设定闹钟为 15 分钟之后响,以前设定的闹钟时间还余下的时间就是 10 分钟。如果 seconds 值为 0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
#include <iostream> #include <unistd.h> using namespace std; int main() { alarm(1); int count = 0; // 验证1s内,count++会进行多少次 // cout + 网络 = IO while(true) { cout << "count: " << count++ << endl; } return 0; }
通过上图可以看到,count 一定被加加了 7w+ 次,这次数是比较少的,其实是由 cout 和网络传输数据慢导致的。如果想单纯看看计算的算力,可以通过下面的程序。
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; unsigned int count = 0; void catchSignal(int signal) { cout << "count:" << count << endl; } int main() { signal(SIGALRM, catchSignal); alarm(1); while(true) { ++count; } return 0; }
注:设定了一个闹钟,这个闹钟一旦被处罚,就会自动被移除。
下面的代码可以做到每隔一秒就发送 SIGALRM
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; unsigned int count = 0; void catchSignal(int signal) { cout << "count:" << count << endl; alarm(1); } int main() { signal(SIGALRM, catchSignal); alarm(1); while(true) { ++count; } return 0; }
以上的代码就简单地实现了定时器的功能,每隔一秒钟做指定的一件事。
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/wait.h> #include <sys/types.h> #include <functional> #include <vector> using namespace std; unsigned int count = 0; typedef function<void ()> func; // func为函数类型 vector<func> callBacks; void showCount() { cout << "count:" << count << endl; } void showLog() { cout << "这个是日志功能" << endl; } void logUser() { // 创建子进程执行who命令 if(fork() == 0) { execl("/usr/bin/who", "who", nullptr); exit(1); } wait(nullptr); } void catchSignal(int signal) { for(auto &func : callBacks) { func(); } cout << "-------------------------" << endl; alarm(1); } int main() { signal(SIGALRM, catchSignal); alarm(1); callBacks.push_back(showCount); callBacks.push_back(showLog); callBacks.push_back(logUser); while(true) ++count; return 0; }
如何理解软件条件给进程发送信号?操作系统先识别到某种软件条件触发或者不满足,然后操作系统构建信号发送给指定的进程。注:闹钟也是结构体,操作系统通过特定的数据结构来管理闹钟。当闹钟超时了,操作系统就会给闹钟结构体中存储的进程 id 发送 SIGALRM 信号。
硬件异常产生信号
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void handler(int signum) { sleep(1); cout << "收到了一个信号: " << signum << endl; } int main() { signal(SIGFPE, handler); int a = 100; a /= 0; while(1) sleep(1); return 0; }
将程序运行起来,就会发现程序在死循环打印语句。那为什么会这样呢?如何理解除零呢?进行计算的是 CPU 这个硬件,CPU 内部是有寄存器的,其中有一个寄存器是状态寄存器。该寄存器不进行数值保存,它只用来保存 CPU 本次计算的状态,其结构也是位图,有着对应的状态标记位(溢出标记位)。当状态寄存器的溢出标记位为 0,操作系统就将计算结果写回到内存中;而当溢出标记位为 1时,操作系统就会意识到有除零错误(溢出问题),操作系统会找到当前哪个进程在运行,向该进程发送 SIGFPE 信号,进程会在合适的时候处理该信号。
当出现硬件异常时,进程不一定会退出!一般默认是退出,但是我们即使不退出,我们也做不了什么!那为什么上面的程序会死循环呢?虽然我们捕捉了 SIGFPE 信号也处理了该信号,但是寄存器中的异常一直没有被解决!寄存器中的数据是进程的上下文,当进行进程切换的时候,寄存器的数据也被保存下来了。当该进程被调度时,操作系统又立马就识别到该进程出现了异常,所以就一直给进程发送 SIGFPE 信号,那么就出现了死循环打印的现象。
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void handler(int signum) { sleep(1); cout << "收到了一个信号: " << signum << endl; } int main() { // SIGSEGV 段错误(11号信号) signal(SIGSEGV, handler); int a = 100; a /= 0; while(1) sleep(1); return 0; }
如何理解野指针或者越界访问问题呢?
- 无论是野指针还是越界访问,都必须通过地址来找到目标位置
- 语言层面上的地址全部都是虚拟地址。当对某个数据进行访问时,首先要将虚拟地址转化成物理地址
- 虚拟地址通过页表和 MMU(Memory Manager Unit 内存管理单元,硬件)来转换成物理地址
- 当野指针或越界访问时,使用的地址都是非法地址,那么 MMU 进行转化的时候,就一定会报错。只有 MMU 报错,操作系统就能识别当前进程出现了硬件异常,将该硬件异常转化成对应的信号发送给进程。
- 出现死循环的原因和除零错误出现死循环的原因类似
小总结:所有的信号都有它的来源,但最终全部都是被操作系统识别、解释并发送给对应的进程的。
👉阻塞信号👈
信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery),信号处理动作有默认、忽略、自定义捕捉。
- 信号从产生到递达之间的状态,称为信号未决(Pending),也就是进程收到了一个信号但该信号还未被处理,信号被保存在位图(Pending 位图)中。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核中的表示
为了表示信号递达、未决和阻塞三个概念,那么操作系统就要用一定的结构去表示它们。操作系统就使用了三张表来表示这三个概念,如下图所示:
其中 pending 表就是保存信号的位图结构(unsigned int),1 表示收到了信号,0 表示没有收到信号;handler 表是函数指针数组,数组的下标就是信号编号,数组中存的是信号的处理动作;block 表也是位图结构,1 表示该信号被阻塞,0 表示该信号未被阻塞。
信号处理的过程:操作系统给目标进程就是修改 pending 位图,这样信号就完成发送了。进程在合适的时候处理信号,遍历 pending 位图看哪些比特位为 1。当发现比特位为 1 时,就去看对应的 block 位图上的比特位是否为 1。如果是 1,则说明该信号被阻塞着,进程不会去处理该信号,也不会将 pending 位图的比特位从 1 改成 0;而如果是 0,则说明该信号没有被阻塞,进程可以处理该信号,处理完成后还需要将 pending 位图上的比特位从 1 改成 0,表示该信号已经处理完成。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1 允许系统递送该信号一次或多次。Linux 是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,本篇博客不讨论实时信号。
sigset_t
- 编程语言都会给我们提高 .h 或者 .hpp 和语言本身的定义类型;操作系统也会给我们提供 .h 和操作系统自定义的类型,像 pid_t 和 key_t 等。如果要访问硬件,那么语言类的头文件也会包含对应的系统调用接口,将系统调用封装起来给我们使用。
- sigset_t 也是操作系统自定义的类型,该类型是位图结构,用以表示上图的 pending 表和 block 表。用户不能直接通过位操作来修改位图(unsigned int),需要使用操作系统提供的方法来修改位图。
- 用户可以直接使用 sigset_t 类型,和使用内置类型和自定义类型没有任何差别。
- 每个信号只有一个比特位的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来表示。sigset_t 称为信号集,这个类型可以表示每个信号的有效或无效状态,在阻塞信号集中有效和无效的含义是该信号是否被阻塞,而在未决信号集中有效和无效的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的屏蔽应该理解为阻塞而不是忽略。
信号集操作函数
sigset_t 类型对于每种信号用一个比特位表示有效或无效状态,至于这个类型内部如何存储这些比特位则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 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 所指向的信号集,使其中所有信号的对应比特位清零,表示该信号集不包含任何有效信号。
- 函数 sigfillset 初始化 set 所指向的信号集,使其中所有信号的对应比特位置 1,表示该信号集的有效信号包括系统支持的所有信号。
- sigaddset 函数将 signo 信号对应的比特位置为 1,sigdelset 函数将 signo 信号对应的比特位置为 0。
- sigismember 函数可以判断 signo 信号是否在信号集中,如果 signo 信号在信号集中,返回 1;如果不在,返回 0;出错则返回 -1。
- 注意:在使用 sigset_ t 类型的变量之前,一定要调用sigemptyset 或 sigfillset 函数做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号。
sigpending
sigpending 函数通过输出型参数 set 获取当前进程的未决信号集,调用成功返回 0,出错则返回 -1。
sigprocmask
sigprocmask 函数可以帮助我们读取或更改进程的信号屏蔽字(阻塞信号集),调用成功返回 0,出错则返回 -1。
如果 oldset 是非空指针,则读取进程的当前信号屏蔽字通过 oldset 参数传出。如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。如果 oldset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oldset 里,然后根据 set 和 how 参数更改信号屏蔽字。假设当前的信号屏蔽字为 mask,下表说明了 how 参数的可选值。
如果我们对所有的信号都进行了信号捕捉,那我们是不是就写了一个不会被异常终止或者用户杀掉的进程呢?我们通过代码来验证一下!
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; void catchSig(int signum) { cout << "捕捉到了一个信号: " << signum << endl; } int main() { for(int i = 1; i <= 31; ++i) signal(i, catchSig); while(1) sleep(1); return 0; }
操作系统的设计者也考虑到了上述的情况,所以就让 9 号信号无法捕捉,9 号信号是管理员信号。
如果我们将 2 号信号 block 掉,并且不断地获取并打印当前进程的 pending 信号集。如果我们突然发送一个 2 号信号,我们应该就能看到 pending 信号集中 2 号信号的比特位由 0 变成 1。
#include <iostream> #include <signal.h> #include <unistd.h> #include <cassert> using namespace std; static void showPending(sigset_t& pending) { for(int signal = 31; signal >= 1; --signal) { if(sigismember(&pending, signal)) cout << '1'; else cout << '0'; } cout << endl << "----------------" << endl; } int main() { // 定义并初始化信号集 sigset_t set, oldset; sigemptyset(&set); sigemptyset(&oldset); // 将2号信号添加到信号屏蔽集中 sigaddset(&set, 2); // 将信号屏蔽集设置到当前进程的PCB中 // 默认情况下,进程不会对任何信号进行block int n = sigprocmask(SIG_BLOCK, &set, &oldset); assert(n == 0); // assert本质是一个宏 (void)n; cout << "block 2 号信号成功......" << endl; // 重复打印当前进程的pending信号集 sigset_t pending; sigemptyset(&pending); while(true) { // 获取当前进程的pending信号集 sigpending(&pending); // 打印pending信号集 showPending(pending); sleep(2); } return 0; }
给当前进程发送 2 号信号时,可以看到该进程的 pending 表中 2 号信号的比特位由 0 变成 1。如果我们在若干秒后,解除对 2 号信号的 block,那么 2 号信号就会被递达而终止当前进程。如果我们对 2 号信号进程捕捉,那么进程也就不会被终止了。
没有捕捉 2 号信号
#include <iostream> #include <signal.h> #include <unistd.h> #include <cassert> using namespace std; static void showPending(sigset_t& pending) { for(int signal = 31; signal >= 1; --signal) { if(sigismember(&pending, signal)) cout << '1'; else cout << '0'; } cout << endl << "----------------" << endl; } int main() { // 定义并初始化信号集 sigset_t set, oldset; sigemptyset(&set); sigemptyset(&oldset); // 将2号信号添加到信号屏蔽集中 sigaddset(&set, 2); // 将信号屏蔽集设置到当前进程的PCB中 // 默认情况下,进程不会对任何信号进行block int n = sigprocmask(SIG_BLOCK, &set, &oldset); assert(n == 0); // assert本质是一个宏 (void)n; cout << "block 2 号信号成功......" << "id:"<< getpid() << endl; // 重复打印当前进程的pending信号集 sigset_t pending; sigemptyset(&pending); int count = 0; while(true) { // 获取当前进程的pending信号集 sigpending(&pending); // 打印pending信号集 showPending(pending); sleep(2); ++count; if(count == 6) { cout << "解除对 2 号信号的 block" << endl; // 默认情况下,解除对于2号信号的block的时候,2号信号确实会递达 // 但是2号信号的默认处理动作是终止进程! n = sigprocmask(SIG_SETMASK, &oldset, nullptr); assert(n == 0); (void)n; } } return 0; }
#include <iostream> #include <signal.h> #include <unistd.h> #include <cassert> using namespace std; static void showPending(sigset_t& pending) { for(int signal = 31; signal >= 1; --signal) { if(sigismember(&pending, signal)) cout << '1'; else cout << '0'; } cout << endl << "----------------" << endl; } void catchSig(int signum) { cout << "捕捉到了一个信号: " << signum << endl; } int main() { signal(2, catchSig); // 定义并初始化信号集 sigset_t set, oldset; sigemptyset(&set); sigemptyset(&oldset); // 将2号信号添加到信号屏蔽集中 sigaddset(&set, 2); // 将信号屏蔽集设置到当前进程的PCB中 // 默认情况下,进程不会对任何信号进行block int n = sigprocmask(SIG_BLOCK, &set, &oldset); assert(n == 0); // assert本质是一个宏 (void)n; cout << "block 2 号信号成功......" << "id:"<< getpid() << endl; // 重复打印当前进程的pending信号集 sigset_t pending; sigemptyset(&pending); int count = 0; while(true) { // 获取当前进程的pending信号集 sigpending(&pending); // 打印pending信号集 showPending(pending); sleep(2); ++count; if(count == 5) { cout << "解除对 2 号信号的 block" << endl; n = sigprocmask(SIG_SETMASK, &oldset, nullptr); assert(n == 0); (void)n; } } return 0; }
- 打印解除 block 语句和捕捉的顺序就是一个打印的顺序问题。
- 所有的信号发送方式,都是修改 pending 位图的过程,我们只需要通过 sigpending 接口来获取 pending 位图即可。
如果我们将所有的信号都进行 block,我们是不是就写了一个不会被异常终止或者用户杀掉的进程呢?我们也通过代码来验证一下!
#include <iostream> #include <signal.h> #include <unistd.h> #include <cassert> using namespace std; static void showPending(sigset_t& pending) { for(int signal = 31; signal >= 1; --signal) { if(sigismember(&pending, signal)) cout << '1'; else cout << '0'; } cout << endl << "----------------" << endl; } static void blockSig(int sig) { sigset_t set; sigemptyset(&set); sigaddset(&set, sig); int n = sigprocmask(SIG_BLOCK, &set, nullptr); assert(n == 0); (void)n; } int main() { // block所有信号 for(int i = 1; i <= 31; ++i) { blockSig(i); } sigset_t pending; while(1) { sigpending(&pending); showPending(pending); sleep(1); } return 0; }
自动给进程发送信号的脚本语言
#!/bin/bash i=1 id=$(pidof mysignal) while [ $i -le 31 ] do kill -$i $id echo "kill -$i $id" let i++ sleep 1 done
#!/bin/bash i=1 id=$(pidof mysignal) while [ $i -le 31 ] do if [ $i -eq 9 ];then let i++ continue fi if [ $i -eq 19 ];then let i++ continue fi kill -$i $id echo "kill -$i $id" let i++ sleep 1 done
注: 9 号和 19 号信号是无法被 block 的,20 号信号的默认处理动作是忽略。
👉捕捉信号👈
内核如何实现信号的捕捉
在上面提及到,信号产生之后,进程可能无法立即处理,进程需要在合适的时候去处理信号。那这个合适的时候是什么呢?带着这个问题,我们来探究一下信号处理的整个流程!
信号相关的数据字段是在进程的 PCB 内部,PCB 内部属于内核范畴,普通用户无法对信号进行检测和处理。那么要对信号进行处理,就需要在内核状态。当执行系统调用或被系统调度时,进程所处的状态就是内核态;不执行操作系统的代码时,进程所处的状态就是用户态。现在我们已经知道需要在内核态下进行信号处理,那究竟具体是什么时候呢?结论:在内核态中,从内核态返回用户态的时候,进行信号的检测和处理!如何进入内核态呢?进行系统调用或产生异常等。汇编指令int 80
(80 是中断编号)可以进程进入内核态,也就是将代码的执行权限从普通用户转交给操作系统,让操作系统去执行!注:汇编指令int 80
内置在系统调用函数中。
sigaction
- sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0,出错则返回 -1。signum 是指定的信号编号。若 act 不为空,则根据 act 修改该信号的处理动作。若 oldact 不为空,则通过 oldact 传出该信号原来的处理动作。act 和 oldact 指向 sigaction 结构体。
- 将 sa_handler 赋值为常数 SIG_IGN 传给 sigaction 表示忽略信号;赋值为常数 SIG_DFL 表示执行系统默认动作;赋值为一个函数指针表示用自定义函数捕捉信号或者说向内核注册了一个信号处理函数。该函数返回值为 void,参数为 int,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被 main 函数调用,而是被系统所调用。
- sa_sigaction 是实时信号的处理方法,不需要关心。
Makefile
mysignal:mysignal.cc g++ $^ -o $@ -std=c++11 -fpermissive .PHONY:clean rm -f mysignal
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void handler(int signum) { cout << "捕捉到了一个信号: " << signum << endl; } int main() { // signal(2, SIG_IGN); // 一下内核数据结构变量是在用户栈定义的 // 需要将它们设置进进程的内核中 struct sigaction act, oldact; act.sa_flags = 0; // 实时信号的标记位 sigemptyset(&act.sa_mask); act.sa_handler = handler; // 设置进当前进程的PCB中 sigaction(SIGINT, &act, &oldact); cout << "default action: " << (int)(oldact.sa_handler) << endl; while(true) sleep(1); return 0; }
处理信号、执行自定义动作的时候,如果在处理信号期间,又来了同样的信号,操作系统该如何处理呢?Linux 的设计方案是在任何时候,操作系统只能处理一层信号,不允许出现信号正在处理又来信号再被处理的情况。操作系统无法决定信号什么时候来,但可以决定什么时候去处理信号。接下来要一起探讨的是为什么要有信号屏蔽字 block!
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags 字段包含一些选项,本篇博客的代码都把 sa_flags 设0,sa_sigaction 是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的伙伴可以再了解一下。
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void showPending(sigset_t* pending) { for(int i = 31; i >= 1; --i) { if(sigismember(pending, i)) cout << '1'; else cout << '0'; } cout << endl << "----------------" << endl; } void handler(int signum) { cout << "捕捉到了一个信号: " << signum << endl; cout << "捕捉到了一个信号: " << signum << endl; cout << "捕捉到了一个信号: " << signum << endl; // 验证2号信号被捕捉期间,再次发送2号信号不会去处理 sigset_t pending; int c = 6; while(1) { sigpending(&pending); showPending(&pending); --c; if(!c) break; sleep(1); } } int main() { // 一下内核数据结构变量是在用户栈定义的 // 需要将它们设置进进程的内核中 struct sigaction act, oldact; act.sa_flags = 0; // 实时信号的标记位 sigemptyset(&act.sa_mask); act.sa_handler = handler; // 设置进当前进程的PCB中 sigaction(SIGINT, &act, &oldact); cout << "default action: " << (int)(oldact.sa_handler) << endl; while(true) sleep(1); return 0; }
处理 2 信号的同时,屏蔽 3 ~ 7 号信号
#!/bin/bash i=2 id=$(pidof mysignal) while [ $i -le 7 ] do kill -$i $id echo "kill -$i $id" let i++ sleep 1 done
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void showPending(sigset_t* pending) { for(int i = 31; i >= 1; --i) { if(sigismember(pending, i)) cout << '1'; else cout << '0'; } cout << endl << "----------------" << endl; } void handler(int signum) { cout << "捕捉到了一个信号: " << signum << endl; cout << "捕捉到了一个信号: " << signum << endl; cout << "捕捉到了一个信号: " << signum << endl; // 验证2号信号被捕捉期间,再次发送2号信号不会去处理 sigset_t pending; int c = 7; while(1) { sigpending(&pending); showPending(&pending); --c; if(!c) break; sleep(1); } } int main() { // 一下内核数据结构变量是在用户栈定义的 // 需要将它们设置进进程的内核中 cout << "id:" << getpid() << endl; struct sigaction act, oldact; act.sa_flags = 0; // 实时信号的标记位 sigemptyset(&act.sa_mask); act.sa_handler = handler; // 处理2号信号期间,3 4 5 6 7号信号也被block sigaddset(&act.sa_mask, 3); sigaddset(&act.sa_mask, 4); sigaddset(&act.sa_mask, 5); sigaddset(&act.sa_mask, 6); sigaddset(&act.sa_mask, 7); // 设置进当前进程的PCB中 sigaction(SIGINT, &act, &oldact); cout << "default action: " << (int)(oldact.sa_handler) << endl; while(true) sleep(1); return 0; }
👉可重入函数👈
- 信号捕捉并没有创建新的进程或线程。
- main 函数调用 insert 函数向一个链表 head 中插入节点node1,插入操作分为两步。刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到 sighandler 函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main 函数和 sighandler 先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
- 像上例这样,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,问它的同一个局部变量或参数就不会造成错乱?
- 可重入和不可重入是函数的一种特征,目前我们用的函数,90% 是不可重入的。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了 malloc 或 free,因为 malloc 也是用全局链表来管理堆的。
- 调用了标准 I/O 库函数。标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。
👉volatile👈
该关键字在 C 语言当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下。
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; int flag = 0; void changeFlag(int signum) { (void)signum; cout << "flag: " << flag << endl; flag = 1; cout << "flag: " << flag << endl; } int main() { signal(2, changeFlag); while(!flag); cout << "进程正常退出后: " << flag << endl; }
编译器有时候会自动地给我们进行代码优化!
我们看到即使对 flag 进行了修改,也没有办法结束进程。这是为什么呢?正常情况下,每次循环通过 flag 进行检测时,都需要到内存中去数据;但是编译优化(编译的时候已经进行了优化)后,编译器认为 main 函数里的代码没有对 flag 进行修改,所以为了提高效率,第一次过后就不去内存中取数据了,而是直接读取寄存器中的值来进行循环检测。而实际情况是内存中 flag 的值早就被改成了 1 了,所以就出现上图的情况了。
编译器优化会让 CPU 无法看到内存,而关键字 volatile 就是为了保持内存的可见性,每次都取内存中取数据。
👉SIGCHLD信号👈
进程一章讲过用 wait 和 waitpid 函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。
事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 或 signal 将 SIGCHLD 的处理动作置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用。
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void handler(int signum) { cout << "子进程退出: " << signum << " father: " << getpid() << endl; } // 证明:子进程退出,会向父进程发送信号 int main() { signal(SIGCHLD, handler); if(fork() == 0) { cout << "child pid: " << getpid() << endl; sleep(1); exit(0); } while(true) sleep(1); }
自动等待子进程
#include <iostream> #include <signal.h> #include <sys/wait.h> #include <sys/types.h> #include <unistd.h> using namespace std; void handler(int sig) { pid_t id; // -1表示等待任意一个子进程 while ((id = waitpid(-1, nullptr, WNOHANG)) > 0) { printf("wait child success: %d\n", id); } printf("child is quit! %d\n", getpid()); } int main() { signal(SIGCHLD, handler); if (fork() == 0) { // child printf("child : %d\n", getpid()); sleep(3); exit(1); } while (1) { printf("father proc is doing some thing!\n"); sleep(1); } return 0; }
#include <iostream> #include <signal.h> #include <sys/wait.h> #include <sys/types.h> #include <unistd.h> using namespace std; // 如果我们不想等待子进程,并且我们还想让子进程退出之后,自动释放僵尸子进程 int main() { // OS 默认就是忽略的 signal(SIGCHLD, SIG_IGN); // 手动设置对子进程进行忽略 if(fork() == 0) { cout << "child: " << getpid() << endl; sleep(5); exit(0); } while(true) { cout << "parent: " << getpid() << " 执行我自己的任务!" << endl; sleep(1); } }
👉总结👈
本篇博客主要讲解了什么是信号、信号如何产生、阻塞信号、捕捉信号、可重入函数以及 volatile 关键字和 SIGCHLD 信号等。那么以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家!💖💝❣️