Linux学习:信号的概念与产生方式
目录
- 1. 关于信号的基本认识
- 1.1 信号的概念
- 1.2 Linux操作系统中的常见信号
- 1.3 常用信号的处理方式
- 2. 信号的产生方式
- 2.1 kill命令产生信号
- 2.2 键盘快捷键产生信号
- 2.3 系统调用接口产生信号
- 2.4 软件条件产生信号
- 2.5 异常产生信号
- 3. 信号产生方式的深层理解
- 3.1 指令与系统调用接口
- 3.2 键盘组合键产生信号
- 3.3 异常产生信号
- 3.3.1 除0异常
- 3.3.2 野指针异常
1. 关于信号的基本认识
1.1 信号的概念
过马路时,斑马线旁的红绿灯,红灯停绿灯行;学校里,每当上课时就嗡嗡作响的上课铃;工作日,早晨固定响起的手机闹钟。这些都是信号,而这些信号中往往都蕴含着一些信息。红灯亮起,表示着斑马线就不能继续通行;上课铃响,表示着要回到教室座位做好上课的准备;闹钟响起,表示着需要起床开始新一天的生活。可见,信号是信息传递的载体,当这些蕴含着信息的信号出现时,我们就要做出相对应的行为动作。
Linux操作系统中也存在并设计了信号的概念与相关功能,上述实例中的我们,就类比于操作系统的进程。而操作系统中的信号其功能作用与上述实例中的信号本质上并无不同,只是操作系统有一套自己实现应用信号的机制体系。
在正式开始对信号的学习之前,我们先来添加几个对操作系统中信号的基本认识:
- 概念:Linux操作系统中信号是提供给用户来让其给其他进程发送
异步信息
的一种方式 - 1. 在信号没有发生时,进程就已经知道信号发生时应该如何处理
- 2. 信号能够被进程认识,操作系统已经提前设置号了识别特定信号的方式
- 3. 信号到来时,若进程正在处理更为重要的事情,暂不能及时处理到来的信号时,进程会将到来的信号做临时保存
- 4. 信号到来时,可以不立即进行处理,而是可以在合适的时机进行处理
- 5. 信号的产生是随时的,无法准确预料,所以我们称信号是
异步
的
异步: 信号是由其他用户、进程产生的,收到信号的进程也一直在做自己的事情,与产生信号的进程并发的运行,这样的两个进程之间关系被称之为异步。
信号的意义与作用: 信号的设计是操作系统为了让进程在运行过程中能够具有随时响应外部的能力,在进程收到信号后,紧接着就可以做出对应的处理动作。
1.2 Linux操作系统中的常见信号
指令kill -l
:查看操作系统中的常见信号
Linux操作系统中的信号共有62个,从1开始没有0号信号,其可以分为1 ~ 31
号的普通信号与34 ~ 64
号的实时信号。上图中,使用系统指令打印出的信号表,其使用一个数和一个名称共同描述表示一个信号,在使用时,表中的数字与名称(宏)都可以标识信号。日常中,我们所使用的操作系统多用于互联网应用,此类操作系统都是分时操作系统,会基于时间片调度轮转尽可能地公平均衡处理每一个任务。分时操作系统种一般最常用也只使用普通信号,其对实时信号一般不做处理。常用信号中,有接近60%
左右的信号产生的效果都是终止进程。
实时信号一般应用于另一种操作系统,实时操作系统,其与分时操作系统的不同点在于其若是收到34 ~ 64
的实时信号,就必须立即处理。即使因为不可避免地原因暂时无法处理,后续在进行处理时必须一次性将之前未处理的信号处理完成。此种操作系统常被用做车载系统。
1.3 常用信号的处理方式
进程收到信号之后,对信号的处理方式一般有三种,默认动作、自定义处理信号、忽略信号。
- 默认动作: 操作系统设置的进程收到信号后应该执行的操作
- 自定义处理信号: 用户通过系统调用接口自定义的,进程在收到对应信号的处理方式
- 忽略信号: 在收到信号后,什么都不做选择忽略
信号的默认动作是操作系统提前就可已经设置好的,但我们应该如何自己去实现对信号自定义处理与忽略呢?操作系统提供了signal
这一系统调用接口,具体实现方法如下:
#include <signal.h>typedef void (*sighandler_t)(int)sighandler_t signal(int signum, sighandler_t handler);
- 参数1
int signum
: 信号数字或宏 - 参数2
sighandler_t handler
: 自定义的信号处理方式 - 返回值
sighandler_t
: 成功时,返回上一个信号处理函数指针,失败时,返回SIG_ERR:void(*-1)()
,将错误码写入errno
自定义信号处理方式:
void handler(int signo)
{cout << "handler signal success... , signum is : " << signo << endl;
}int main()
{//自定义处理信号,设置一次,整个进程运行期间都生效signal(SIGINT, handler);while(1){cout << "process is activing... , pid : " << getpid() << endl;sleep(1);}return 0;
}
signal
函数是对信号的处理方式进行重新设置,调用完成后handler
方法并不会被立即执行,而是只有当收到对应信号后才会被执行。若进程运行期间没有收到对应的信号,那么handler
方法也就一直不会被调用。
忽略信号:
int main()
{//忽略指定信号signal(SIGINT, SIG_IGN);//宏参数SIG_IGN,对指定信号进行忽略while(1){cout << "process is activing... , pid : " << getpid() << endl;sleep(1);}return 0;
}
2. 信号的产生方式
2.1 kill命令产生信号
指令: kill -signo pid
signo
:信号数字pid
:想要对其发送信号的进程pid
2.2 键盘快捷键产生信号
- 快捷键
Ctrl + C
: 发送SIGINT:2
信号 - 快捷键
Ctrl + \
: 发送SIGQUIT:3
信号 - 快捷键
Ctrl + Z
: 发送SIGSTP:20
信号,暂停进程,可通过指令fg
再将进程启动到前台,指令bg
启动到后台
2.3 系统调用接口产生信号
1. 系统调用接口kill
产生信号: 可以对任意进程发送任意信号
#include <sys/types.h>
#include <signal.h>int kill(pid_t pid, int sig);
- 参数1
pid_t pid
: 向之发送信号的指定进程pid - 参数2
int sig
: 发送几号信号 - 返回值
int
: 成功返回0,失败返回-1,错误码写入errno中
操作系统中的kill命令
底层也是使用相应系统调用接口编写的代码,而后形成的可执行程序。这里我们来试着使用kill系统调用接口
模拟实现一下kill命令
。
#include <sys/types.h>
#include <signal.h>
#include <iostream>
using namespace std;
#include <cerrno>
#include <cstring>
#include <stdlib.h>int main(int argc, char* argv[])
{if(argc != 3){cout << "Usage: " << argv[0] << " -signum " << "pid" << endl;exit(1);}pid_t pid = stoi(argv[2]);int signo = stoi(argv[1] + 1);//地址往后位移1字节,忽略'-'int n = kill(pid, signo);if(n < 0){cout << "mykill error , errno : " << errno << " string error : " << strerror(errno) << endl;}return 0;
}
指令echo $?
:查看程序退出码
2. 系统调用接口raise
产生信号: 对当前进程(自己)发送任意信号
#include <signal.h>int raise(int sig);
- 参数1
int sig
: 需要发送的信号 - 返回值
int
: 成功时,返回0,失败时,返回非零值(通常为-1)
3. 系统调用接口abort
产生信号: 对自己发送指定信号SIGABRT:6
#include <stdlib.h>void abort(void);
2.4 软件条件产生信号
1. 管道通信场景:
管道通信时,读端关闭操作系统就认定此管道为Broken pipe
,其不具备通信的软件条件,所以就会发出SIGPIPE:13
信号将写端也就进行关闭。
2. 闹钟:
#include <unistd.h>unsigned int alarm(unsigned int seconds);
- 参数1
unsigned int
: 设定闹钟多少秒后触发 - 返回值
unsigned int
: 当上一个闹钟时间耗尽触发过了,再次调用此接口返回值为0;当上一个闹钟时间未耗尽,再次调用此接口返回上一闹钟剩余时间
系统调用接口alarm
会在当前进程中,设定一个闹钟,此闹钟会在设定时间耗尽时,对当前进程发出SIGALRM:14
号信号。
闹钟设置成功的情况下,程序不退出,闹钟只会响一次。进程中,可以通过自定义闹钟信号处理函数的方式,让每次闹钟触发后,都重新定义一个闹钟,来达到让闹钟多次触发的效果。
void handler(int signo)
{cout << "alarm triger..." << endlalarm(5);
}int main()
{alarm(5);sleep(100);return 0;
}
将闹钟时间设置为0,alarm(0)
,就是将上一个设置的闹钟进行了取消。
int main()
{alarm(5);//设置闹钟alarm(0);//取消闹钟return 0;
}
操作系统内核中的闹钟:
闹钟函数本身就是一个系统调用,给进程设定闹钟这一操作,本身就是通过操作系统去实现的。而在内核中,闹钟不止一个,操作系统需要对这些闹钟进行管理,这些闹钟会被描述为下面这样一个结构体。
struct alarm
{uint64_t expired; //闹钟的过期时间:时间戳 + 设定时间pid_t pid; //哪个进程设置此闹钟//闹钟到时后应该做什么操作...
};
而后,内核中会将这些struct alarm
按过期时间顺序升序排列,用小堆来进行存储管理。当闹钟时间过期时,就从堆顶取出这个struct alarm
变量,向对应的进程发出闹钟信号,然后将其销毁。
虽然实验机器是远程云服务器,打印的开销比本地打印要大,但仍可见打印的资源消耗是十分恐怖的。
2.5 异常产生信号
除0异常:
当代码中出现除0的运算操作时,操作系统会向相应进程发送SIGFPE:8
号信号终止进程。
void handler(int signo)
{cout << "除0错误 , SIGFPE : 8" << endl;exit(1);
}//除0异常
int main()
{signal(SIGFPE, handler);int a = 10;a /= 0;return 0;
}
野指针异常:
当对野指针进行野指针解引用修改操作时,操作系统会向相应进程发送SIGSEGV:11
号信号终止进程。
void handler(int signo)
{cout << "野指针错误 , SIGSEGV : 11" << endl;exit(1);
}int main()
{signal(SIGSEGV, handler);int* p = nullptr;*p = 100;return 0;
}
指令man 7 signal
:查看7号手册,信号详细信息
3. 信号产生方式的深层理解
3.1 指令与系统调用接口
指令产生信号的本质,是操作系统使用相应的系统调用接口实现了一个内置程序。所以,可以认为指令产生信号的方式与系统调用接口产生信号的方式相同。
3.2 键盘组合键产生信号
键盘是计算机中的一个硬件结构,其也属于一种计算机资源,对操作系统而言,会将所有计算机资源视作文件。关于键盘存在如下几个问题:
- 1. 操作系统是如何判断用户操作时按下了哪些按键
- 2. 键盘是一个字符设备,也就是说其产生的数据,类型都是字符,那么, 操作系统是如何读取键盘的字符输入
- 3. 操作系统是如何确定键盘有没有输入数据
- 4. 对于字符与组合键需要进行的处理操作是不同的,操作系统是如何分别判断处理的
上述问题不仅仅涉及操作系统,同时,也涉及到了硬件设备的驱动程序,必须结合二者才能学习理解。
问题1、问题2,本质上都属于数据输入,即键盘数据读方法的问题范畴。这两点的功能是由相应硬件的驱动程序实现的。
问题4,本质上,是将读取到的数据进行判断,判断完成后再进行对应的处理操作。这属于操作系统需要考虑与实现的问题范畴。
问题3,如何确定键盘有没有输入数据,操作系统应该何时去对键盘输入的数据进行读取。这确实一个值得考虑的问题,如果让操作系统定期去对键盘做检测,观察其是否有数据输入这无疑是非常浪费时间与资源的。在实际设计中,对于键盘输入何时读取采用了中断号加中断向量表的方式来进行实现解决。
CPU上有有许多针脚其与各个硬件设备直接相连,键盘就是其中之一,键盘所对应的是2号针脚。
- 1. 当键盘中有写入时,会向2号针脚发送高电频,通知CPU键盘中有数据写入,而后就会触发相应的硬件中断。
- 2. CPU会将键盘对应的中断号写入
req
寄存器中,再通知操作系统根据req
寄存器中的中断号,查询中断向量表调用对应的函数方法。 - 3. 此处调用从键盘中读取数据的方法,将键盘的数据读取到操作系统内,操作系统紧接着进行判断。
- 4. 若数据是普通字符将数据写入文件缓冲区,若是信号组合键,则解析后向指定进程发送相应的信号。
深入向指定进程发送与信号的临时保存:
之前的学习,我们了解到信号是以异步方式发送的,也就是说信号在发送给指定进程后,进程不会一定会直接进行处理。而是会将信号保存起来,等待后续处理完手头的重要任务,然后再做处理。可发送给进程的信号,进程是如何接收到又是如何保存的呢?
事实上,进程PCB中存在着这样一个变量uint32_t pending
,其共有32个bit位,每一个bit位都代表着一个信号。当对应bit位上的数据为0时,代表进程没有收到对应的信号,而当对应bit位上的数据为1时,则代表进程收到了对应的信号,需要做出相应处理。因此,对于信号产生与发送,本质上可以理解为操作系统为对应进程PCB的pending
变量的指定bit位写入数据。
3.3 异常产生信号
3.3.1 除0异常
进程将相应运算数据发送给CPU后,CPU在运算时会触发除0异常,在标志寄存器中写入溢出标志位。从而发出信号通知操作系统,运算时发生了异常,操作系统而后检查CPU中的标志寄存器。确定了异常类型后,操作系统向进行此运算操作的进程发出SIGFPE:8
号信号中断进程。
寄存器中有些寄存器时可见的,而有些寄存器是用于CPU内部管理工作的,外部不可见。
int cnt = 0;void handler(int signo)
{cout << "除0异常 , SIGFPE : " << signo << " cnt : " << cnt++ << endl;
}int main()
{signal(SIGFPE, handler);int a = 10;a /= 0;return 0;
}
从上述程序的运行结果可见,当我们对除0异常的信号SIGFPE:8
自定义处理不做退出时,进程会一直触发8号信。这是因为,CPU中状态寄存器中的溢出标记位,会作为进程的上下文数据随着进程一直跟着时间片轮转调度,不断的进行保护、恢复。而只要进程中,此异常标记位一直存在,进程被调度时,就会一直识别到异常,不断地向操作系统发送信号。
除非将状态寄存器中的数据清0,否则,操作系统就会一直向进程发送信号。但将寄存器中数据清0的操作,只有CPU自己能够实现,所以,当进程遇到异常时,就只能退出将进程的上下文数据直接全部丢弃,即进程崩溃了。
CPU中的寄存器只有一套,每个寄存器只有一个,但寄存器中的数据可以有很多,这些进程运行时在寄存器中存储的数据,就被称之为进程的上下文数据。
3.3.2 野指针异常
- CR2寄存器:存储页表错误的线性地址(无法进行虚拟地址到物理地址的转换)
- CR3寄存器:存储页表的物理起始地址
- MMU硬件组件:集成在CPU上,配合页表负责虚拟地址到物理地址的映射转换
当进程中执行到将野指针解引用的语句时,CPU中MMU硬件单元会将存储在通用寄存器eax中的地址配合页表,将其转换为物理地址然后进行解引用赋值操作。但空指针(nullptr)地址0,在页表中不存在对应的映射关系(或对应映射关系权限被设为只读)。当进行此地址映射转换时,会将这一有问题的地址写入CR2寄存器中,之后触发异常通知操作系统来检测异常种类。在操作系统检测完成后,再向对应触发异常的进程发送SIGSEGV:11
号信号。
观察学习各种信号产生方式的底层细节时,可以发现,无论哪一种产生信号的方式,对进程发出信号(在进程PCB中写入信号)的最后步骤都必须由操作系统来完成。