0.前言
信号是进程通信的一种方式。如同我们按下ctrl+c就能终止一个进程,实际上就
是bash进程向子进程发送了一个终止信号。和手机信号,wifi信号等类似,进程之间也可以通过信号来传递某种信息。不同的是,进程之间的信号本身就是一种共享资源。信号是如何产生的?被谁产生的?又是怎么获取信号的? 本篇文章将从这几个问题展开叙述,详细讲解信号产生的原理。
1.信号的基本概念
在前言中我们只是知道了信号是实现进程通信的一种机制,那么于其它通信方式来说,信号又有着什么样的特性呢?
在Linux操作系统中,信号是一种软件中断机制,用于通知进程某些事件的发生。信号通常用于处理异步事件。信号可以由操作系统、其它进程、或者同一进程内的其它线程发送。
根据上面的概述给出以下知识扩展:
1.1中断
中断是指处理器响应硬件或软件事件的机制(其本身就是一个信号)。中断分为软件中断和硬件中断,这两者在来源和处理方式上有所不同。
1.1.1 软中断
软件中断又叫软中断,是操作系统生成的中断,用于处理进程间通信或者操作系统内部的事件,比如调度、资源管理等。软中断可以通过执行特定的指令来认为生成(如kill指令),或者由操作系统内部事件触发。不依赖于硬件。
软中断的特性
- 处理延迟性:软中断不需要立即被响应,可以按照系统的调度策略进行处理
- 上下文:软中断通常在操作系统系统的上下文中执行,不会直接与硬件交互
- 灵活性:软中断可以由操作系统根据需求灵活生成
1.1.2硬中断
硬中断是由硬件设备产生的中断,用于通知处理器一些外部事件的发生。比如键盘在输入消息时会向cpu发送一个硬件中断,告诉cpu键盘要输入了。此外鼠标、网卡等都可以通过中断请求线(IRQ)发送到cpu。硬中断依赖硬件。
硬中断特性:
- 即时性:硬件中断通常需要cpu立即响应
- 优先级:不同硬件中断有不同的优先级,处理器根据优先级来决定响应哪一个中断,通常硬盘中断优先级是最高的
- 中断处理程序:每个硬中断都有一个专门的中断处理程序。
1.2异步
异步是指一个过程不需要等待其它任务完成的情况下继续进行的能力。不同于依赖同步机制的管道通信,必须要有数据写入,读端进程才会去读。发送信号的一端并不关心接收端此刻有没有做好接收的准备,也不关心信号发送之后会不会被处理。异步处理是提高程序效率和响应性的一种常用方法。
1.2.1异步和同步的比较
特性 | 异步 | 同步 |
执行流 | 非阻塞,允许并发执行 | 阻塞,按照顺序执行 |
资源使用 | 高效,可以等待时执行其他任务 | 效率较低,cpu可能在等待时闲置 |
复杂度 | 高,需要处理竞态条件、死锁等问题 | 低,因为操作按顺序执行,易于理解和实现 |
根据以上信息得出信号的以下几种用途
2.信号的主要用途
- 异常处理:如当程序执行除0操作时,系统会发送一个
SIGFPE
浮点异常信号给进程 - 外部中断:比如键盘发送的中断信号例如
ctrl+c
,进程会收到一个SIGINT
信号。 - 进程控制:比如有些信号可以暂停进程,有些信号用来终止进程。
同样根据信号的定义,我们能得到以下关于信号的特点:
3.信号的特点
- 异步性:信号可以在进程的任何石刻发送或者接收。
- 通知:信号只起到通知作用,接收信号之后怎么处理跟信号本身无关。
- 自定义处理方式:对于一个进程来说,对待一个信号有三种处理方式:
1.忽略信号不做处理 2.默认处理方式,即让操作系统自己去处理 3.自已定义信号的处理方式(自定义函数)
4.查看信号
在linux中我们可以使用kill -l
指令来查看系统定义的信号列表
对于以上信号列表:
- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h里面找到,例如其中有定义
#define SIGINT 2
- 常见的信号是1-31,其中34-64都是实时信号,实时信号不在本文讨论范围内。每个信号都有自己默认的处理动作,在signal(7)中都有详细说明,指令
man 7 signal
可以查看:
在谈信号的产生之前,我们可以先学习如何捕捉信号,因为捕捉信号能让我们更好的理解信号的产生。
4.1Core和Term的区别
查看信号的默认响应行为我们可以发现发现,大多信号都是Core或者Term,且这两种信号都表示终止进程。那这两种终止进程的方式有什么区别呢?
其中Term就是普通的终止进程,之后没有其他动作。而Core不仅会终止进程,还会生成一个核心转储文件。
核心转储(Core Dump)文件包含了进程终止时的内存映像,和关于进程状态的详细信息,也就意味着,通过这个核心转储文件我们就能知道这个进程在终止之前发生了什么。这对于开发者来说是非常宝贵的调试信息(可以借助gdb调试器加载其中的信息并调试)。
4.2生成Core文件
系统默认进程终止时不生产Core文件,因为core文件中可能包含用户密码等隐私信息,不安全。也就意味着,即使某个进程收到SIGQUIT信号(默认产生core文件)也不会生成core文件。但是在开发调试阶段可以使用ulimit指令改变这个限制,允许产生core文件。此外core文件的大小取决于进程的Resource Limit(这个信息保存 在PCB中,默认是0)。
此外,如果子进程终止之后生成了core文件,那么子进程的退出码中的core_dump标记位就会置为1。
- 首先使用
ulimit -c 1024
指令改变core文件的大小为1024字节,再使用-a
选项查看
- 写一个死循环的程序:
#include <iostream> #include <sys/stat.h> #include <sys/types.h> #include <unistd.h> using namespace std; int main() { while (true) { cout << "pid: " << getpid() << endl; sleep(1); } return 0; }
3.运行之后在终端按下Ctrl+\(SIGQUIT信号):
如果你发现没有看到这个core文件,那可能是默认的核心转储的位置不在当前目录,可以使用以下指令修改:
echo 'core.%e.%p.%t' | sudo tee /proc/sys/kernel/core_pattern
%e表示程序名,%p表示进程pid,%t表示时间戳。生成的core文件后缀就是.%e.%p.%t格式。可自行修改。
我们可以用gdb加载core文件信息并调试,在gdb中使用core-file指令可以加载core文件:
5.初识捕捉信号
5.1signal函数
signal函数(库函数)是用于设置处理某个信号时所调用的处理函数,也被称为信号处理器。由头文件signal.h提供,这个头文件属于c标准库的一部分。该函数允许程序自定义特定信号的响应行为。这一点也证实了信号的特征。
#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
sighandler_t
是一个函数指针(参数为int),作为signal的参数signum
表示的就是要捕捉的信号- 返回值是一个指向之前处理该信号的函数的指针,或者在错误情况下返回 SIG_ERR。
signal
函数并不是系统调用,但是其内部封装了系统调用sigaction
。一旦我们使用signal函数捕捉了某个信号,该进程响应该信号的方式就可以自己决定了。但是值得注意的是,有一些特殊的信号的响应方式并不会完全被sighandler
替代。
6.产生信号的方式
6.1.通过终端按键产生信号
ctrl+c
表示一个终止信号这个好理解,但是如何证明就是产生了信号SIGINT(2)
呢?我们可以用signal函数捕捉SIGINT
函数,然后在自定义该信号的响应方式,最后在进程运行时按下ctrl+c观察。给出实验代码:
#include <iostream> #include <signal.h> #include <unistd.h> #include <sys/types.h> using namespace std; void myhandler(int sig) { cout <<"进程"<<getpid()<< " 接收到了sig信号: " << sig << endl; } int main() { signal(2, myhandler); while (true) { cout << "hello" << endl; sleep(1); } return 0; }
观察运行结果:
当我们按下ctrl+c之后发现并没有终止进程,而是执行了自定义的myhandler函数。
6.2.调用系统函数向进程发送信号
其实就是我们常用的kill指令发送信号给指定进程。kill指令本质上是调用了系统调用kill函数。kill系统调用可以发信号到某一个进程,也可以发送到某一组进程。
kill函数
#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);
pid
表示要信号发送到哪一个进程sig
表示发送信号的类型- 发送成功返回0,否则-1.
于是,我们可以在代码中使用kill函数发送信号了。此外再介绍一个raise函数,raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
raise函数
#include<signal.h> int raise(int sig);
- sig表示发送信号的类型
- 发送成功返回0,否则-1。
abort函数
#include <stdlib.h> void abort(void);
这个函数没有参数,并且它也没有返回值。调用 abort 函数后,程序会立即异常终止。本质上这个函数调用之后,操作系统就会先该进程发送一个SIGABRT信号,这个信号会终止当前进程,且通常生成core文件。(其实就是调用raise发送一个SIGABRT信号)
6.3.由软件条件产生信号
软件条件产生信号其实就是软中断的一种,就是因为某种软事件或程序内部逻辑触发的信号。比如管道读端关闭之后,写端就会收到一个SIGPIPE
信号进而终止。 下面介绍alarm
函数和SIGALRM
信号。
6.3.1alarm函数
alarm
函数是一个定时器,可以设置一个时间,这个定时器会在未来的一个时刻发送一个SIGALRM
信号给当前进程(该信号默认处理动作是终止进程)。alarm函数原型具体如下:
#include<unistd.h> unsigned int alarm(unsigned int seconds);
- seconds表示的是一个时间,单位为秒。如果seconds为0,表示取消以前设置的定时器。
- 返回值是0或者是以前设定的闹钟时间还余下的秒数。
6.4硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。具体这个异常是怎么被检测出来的我们无需关心,我们只要知道发生硬件异常之后操作系统会向该进程发送一个信号来终止进程就行了。
为什么说除0是硬件异常?因为除0这个操作是CPU在执行的,除0之后会CPU触发异常信号。访问野指针也是类似。
下面来模拟一下野指针异常,给出实验代码:
#include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int sig) { printf("catch a sig : %d\n", sig); sleep(1); } int main() { signal(SIGSEGV, handler); sleep(1); int *p = NULL; *p = 100; return 0; }
观察以上实验代码我们就会发现,我们访问了野指针之后会触发一个信号,这个信号是什么类型,我们可以通过hanler函数来显示。
信号11就是SIGSEGV
信号。但是我们发现一个很奇怪的现象:为什么这个handler函数会一直执行下去呢?
按理来说,调用了一次signal函数捕捉了异常信号,hander应该只会执行一次。那为什么之前我们在捕捉Ctrl+c(信号2)时却只执行了一次呢?
代码int *p = NULL; *p = 100; 显然会引发一个 SIGSEGV 信号,因为它尝试向 NULL 指针所指向的内存地址写入一个值,这是非法的内存访问。之后CPU触发一个硬件异常信号,执行handler函数之前操作系统会保存触发信号的指令地址于上下文中,执行完handler之后,操作系统又会回到之前保存的地址中去,即又回到了信号发生时的状态,于是就又重新执行*p这个代码。于是就产生了死循环。
那这种问题该如何解决呢?
可以在在handler函数中调用exit()函数或者_exit()函数确保进程终止。
7总结
- 所有的信号本质上都是又操作系统产生的 。只不过是进程委托操作系统将这个信号发送给另一个进程(也可以是自己)。
- 所有的信号都是起到一个作用:告诉某个进程发生了什么。怎么做由开发者决定。