一、信号入门
什么是信号:信号就是一条消息,它用来通知进程系统中发生了一个某种类型的事件。
信号是多种多样的,并且一个信号对应一个事件,这样才能知道收到一个信号后,到底是一个什么事件,应该如何处理这个信号。
1、信号的一些特性
- 进程在没有收到信号时就已经知道了一个信号应该怎么被处理了,这说明进程能够识别并处理信号。
- 信号对于进程来说是随时都有可能产生的,因此进程与信号是异步的!
- 由于进程与信号是异步的,当信号产生时,进程可能正在执行优先级更高的事情,这时进程并不能立即处理信号,需要在合适的时候再进行处理,因此在这个空窗期内信号要能够被保存起来,这说明进程具有记录信号的能力!
- 进程记录的信号可能有很多个,因此进程需要用一种数据结构去管理所有的信号,在Linux下对于信号的管理采用的是位图结构,比特位的位置代表信号的编号。
- 所以所谓的发送信号本质就是:直接修改特定进程的信号位图中的特定的比特位。(由
0
->1
) - 进程信号的位图结构本质还是属于
task_struct
里面的数据,因此对于进程信号的位图结构里面的数据的修改,只能有操作系统来完成,即无论有多少种信号产生的方式,最终都必须让OS来完成最后的发送过程!
2、信号的处理方式
- 执行默认动作(即操作系统给信号设定的默认动作)
- 忽略信号
- 执行自定义动作(用户修改了操作系统设定的默认动作,改成了自己想要的动作),操作系统为我们提供一个信号处理函数
signal
,可以要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch) 一个信号。
信号捕捉初识
信号捕捉主要是使用signal
函数,该函数内部使用了回调函数。
该函数的作用就是将指定的信号的默认行为更改为执行第二个参数对应的函数,这个函数要求必须是返回值为void
参数是int
的函数。
- 参数:
- 信号的编号。
- 回调函数的函数指针。
- 返回值: 返回先前的信号处理函数指针,如果有错误则返回
SIG_ERR(-1)
。
实例代码:
我们在键盘下按的 Ctrl + C 其实就是2号信号,下面我们尝试对2
号信号进行捕捉。
#include <iostream> #include <signal.h> #include <unistd.h> void hander(int sig) { std::cout << "get a signal " << sig << std::endl; } int main() { signal(2, hander); while (true) { std::cout << "我正在运行...,我的PID是: " << getpid() << std::endl; sleep(1); } return 0; }
运行结果:
可以看到我们使用 Ctrl + C 已将无法终止进程了,变成了我们自定义的动作了!
3、Linux下的信号
在Linux下我们可以使用kill -l
命令列出所有的信号。
仔细观察我们发现,这里面是没有32 ,33
号信号的!其中从1~31
号信号是普通信号,34~64
是实时信号。(这里我们主要讨论普通信号)
- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在
/usr/include/bits/signum.h
中找到。 - 对于普通信号默认的处理动作是什么,在
man 7 signal
中都有详细说明。
二、信号的产生
在Linux下进程信号的产生是有多种方式的,下面我们就来一起了解一下吧!
1、通过终端按键产生信号
在Linux下输入命令可以在Shell下启动一个前台进程,当我们想要终止一个前台进程时,我们可以按下 Ctrl + C 来进行终止这个前台进程,其实这个 Ctrl + C 也是一个信号,它对应的信号的2
号信号SIGINT
,这个信号对应的默认处理动作就是终止当前的前台进程。
- 用户按下 Ctrl-C ,这个键盘输入产生一个硬件中断 ,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出
- Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个
&
可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程,同样这样的后台进程也无法使用Ctrl-C 来进行杀死。 - Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
关于硬件中断:
- 硬件中断是由硬件设备触发的中断,当硬件设备有数据或事件需要处理时,会向CPU发送一个中断请求,CPU在收到中断请求后,会立即暂停当前正在执行的任务,进入中断处理程序中处理中断请求。
关于软中断
- 信号是进程之间事件异步通知的一种方式,属于软中断。
2、调用系统函数向进程发信号
a、kill函数
kill
函数是操作系统给我们提供的一个系统调用,通过它我们能够给指定的进程发送指定的信号。
- 参数:
- 目标进程的
pid
。 - 要发送的信号
signal
。
- 返回值:调用成功就返回
0
,调用失败就返回-1
。
kill
命令其是就是调用kill
函数实现的,下面我们也来模拟实现一下kill
命令。
实例代码:
#include <iostream> #include <string> #include <cstdlib> #include <cerrno> #include <cstring> #include <signal.h> #include <sys/types.h> void Usage(const std::string proc) { std::cout << "Usage:" << std::endl; std::cout << " " << proc << " 信号编号 目标进程" << std::endl; } int main(int argc, char* argv[]) { if (argc != 3) { Usage(argv[0]); exit(-1); } pid_t pid = atoi(argv[2]); int signo = atoi(argv[1]); int return_val = kill(pid, signo); if (return_val == -1) { std::cout << "错误码:" << errno << " 错误信息:" << strerror(errno) << std::endl; } return 0; }
运行结果:
b、raise函数
此函数会向当前进程发送指定的信号
- 参数: 要发送的信号
sig
。 - 返回值:调用成功就返回
0
,调用失败就返回非0
。
实例代码:
我们用raise
函数给当前进程发送暂停信号19
SIGSTOP
,暂停以后我们可以在命令行中给进程发送继续运行18
号SIGCONT
信号
#include <iostream> #include <signal.h> #include <unistd.h> int main() { sleep(1); std::cout << "我要被暂停了,我的PID是:" << getpid() << std::endl; raise(19); std::cout << "我要继续运行了,我的PID是:" << getpid() << std::endl; return 0; }
c、abort函数
abort
函数使当前进程接收到信号而异常终止,abort
函数其实是向进程发送6
号信号SIGABRT
,就像exit
函数一样,abort
函数总是会成功的,所以没有返回值,值得注意的是就算6
号信号被捕捉了,调用abort
函数还是会退出进程。
实例代码:
#include <iostream> #include <cstdlib> #include <signal.h> #include <unistd.h> int main() { std::cout << "begin" << std::endl; abort(); std::cout << "end" << std::endl; return 0; }
这三个函数只有kill
是系统调用,另外两个都是C库函数,它们的功能对比如下:
3. 由软件条件产生信号
SIGPIPE
是一种由软件条件产生的信号,在“管道”中已经介绍过了。这里主要介绍alarm
函数和SIGALRM
信号。
调用alarm
函数可以设定一个闹钟,也就是告诉内核在seconds
秒之后给当前进程发14
号信号SIGALRM
信号, 该信号的默认处理动作是终止当前进程。
- 参数:闹钟的秒数。
- 返回值:这个函数的返回值有一点特殊,它是是上一次设定的闹钟时间还余下的秒数或者是0(0代表上一次的闹钟没有收到干扰,正确的执行完了)
实例代码:
#include <iostream> #include <signal.h> #include <unistd.h> int main() { alarm(1); int count = 0; while (true) { std::cout << count++ << std::endl; } return 0; }
4、硬件异常产生信号
硬件异常产生信号是指硬件产生了错误并以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
例如当前进程执行了除以0
的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE
信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV
信号发送给进程。
例如下面的代码我们进行除0
操作:
#include <iostream> int main() { int a = 10; a /= 0; std::cout << a << std::endl; return 0; }
在编译的时候我们收到了一个警告(除0
问题),然后我们不管接着运行我们的代码,然后我们的程序就崩溃了,系统提示是浮点异常问题,其实这个浮点异常问题对应的就是我们的硬件异常,它对应的信号是8
号信号SIGFPE
大致原理:在计算机内部是有一个状态寄存器的,该寄存器内部是一个位图结构,如果对应的比特位为1
就表示本次计算有数据溢出的情况,说明本次计算结果不正确,CPU执行有误,而操作系统每次调度进程时都会去检查状态寄存器的状态,确保进程的执行的正确性。
当让CPU执行除0
操作就会引发数据溢出的问题,然后状态寄存器里面对应的比特位被置为1
,我们操作系统检测到了状态寄存器中有比特位被置为1
,就会向对应的进程发送SIGFPE
信号终止掉该进程,于是除0
就会导致程序崩溃。
下面我们可以用信号捕捉去验证我们上面的原理和结论。
#include <iostream> #include <cstdlib> #include <signal.h> #include <unistd.h> void handler(int sig) { std::cout << "我是收到 " << sig <<"信号才崩溃了"<< std::endl; } int main() { signal(SIGFPE, handler); int a = 10; a /= 0; std::cout << a << std::endl; return 0; }
运行结果:
可以看到我们的程序出现了死循环的打印,这是因为我们捕捉了8
号信号,将原来的默认动作终止进程修改成了打印动作,当我们的进程处理完信号时,操作系统再次调用该进程时,由于上一次的状态寄存器里面的比特位没有被置0
,所以操作系统再次调用该进程时,看到的状态寄存器的对应比特位为还是1
,于是又向该进程发送8
号信号,而我们的自定义动作始终没有去处理状态寄存器,于是就陷入了死循环当中。
所以我们一般都是捕捉完该信号以后让该进程直接退出。
下面我们来看野指针引起的硬件异常:
#include <iostream> #include <signal.h> #include <unistd.h> int main() { int* p = nullptr; *p = 10; std::cout << "野指针问题" << std::endl; return 0; }
运行结果:
系统提示我们发生了段错误,对于野指针问题,其实也是我们进程收到了操作系统发送的信号而崩溃的,这个信号是11
号信号SIGSEGV
,而这一次硬件异常的是MMU单元(内存管理单元)。
大致原理:由于我们进程使用的地址都是虚拟地址,当我们进程的代码实际被执行时,需要进行虚拟地址到物理地址的转换,而这个转换就要借助MMU这个硬件来进行转换,当我们的MMU在进行地址转换时,MMU单元在页表中寻找地址的映射关系并比较读写权限是否一致,如果在页表中找不到映射或者找到了映射但是进行的操作与读写权限不一致,就会导致转换失败,进而告知操作系统,操作系统识别以后就会向对应的进程发送SIGSEGV
信号,从而终止掉该进程。(注意这里对于这个转换异常,操作系统并没有修复,如果用户捕捉了这个信号,也不修复也不退出,也会导致操作系统一直给该进程发送此信号)
对于0
地址可能操作系统根本没有给0
地址建立映射关系,或者建立了映射关系但是操作系统不会允许0
地址处发生写入!而当我们进行*p = 10
时,是需要进行写入的,MMU在地址转换时发现权限不一致,进而引发给异常,报告给了操作系统,然后操作系统向我们的的进场发送SIGSEGV
信号。
结语
本章讲述的是进程信号的产生,但是只知道这些还是不够的,下一章我们继续深入理解进程信号的保存,提升我们对于信号的理解。
当然如果本篇文章有错误或不足的地方,欢迎评论或私信讨论!那么我们下期见,byebye!