文章目录
- 一、信号的概念
- 1.信号概念
- 2.前台与后台进程
- 3.信号的处理
- 4.硬件层面
- 5.信号与我们的代码是异步的
- 二、信号的产生
- 1.产生的方式
- 2.键盘组合键
- 3.kill命令
- 4.系统调用
- 4.1 kill系统调用
- 4.2 raise
- 4.3 abort
- 5.异常软件条件
- 5.1 异常产生信号
- 5.2 alarm(软件条件产生信号)
- 6.core dump
一、信号的概念
1.信号概念
我们常见的信号有信号弹、下课上课铃声、求偶、红绿灯、快递发短信取件码等等…
a.那么我们是怎么认识这些信号的??
当然是有人教我们,最后我们记住了
这个认识,首先我们要识别信号,其次还要知道信号的处理方法。最后记住他们
b.即便是我们现在没有信号产生,我也知道信号产生了之后,我该干什么
c.信号产生了,我们可能并不立即处理这个信号,在合适的时候,因为我们可能正在做更重要的事情。 — 所以,信号产生后一直到信号处理时,中间一定有一个时间窗口。在这个时间窗口内,我们必须记住信号到来!
上面这些我们指代的就是进程!
所以
- 进程必须识别 + 能够处理信号 — 即便信号没有产生,也要具有处理信号的能力 — 信号的处理能力,属于进程内置功能的一部分
- 进程即便是没有收到信号,也能知道哪些信号该怎么处理
- 当进程真的收到了一个具体的信号的时候,进程可能并不会立即处理这个信号,在合适的时候会进行处理
- 一个进程必须当信号产生,到信号开始被处理,就一定会有时间窗口,所以进程具有临时保存哪些信号已经发生了的能力
如下图所示,我们前面所作的相当于是一个信号的预备部分
2.前台与后台进程
我们还记得,我们使用CTRL + C可以杀掉前台进程。(像如下所示的,就是前台进程,该进程运行时,shell不会接收其他命令了)
但是如果我们这样做,它就是一个后台进程了,即在程序后面加上一个&即可,而且我们还发现,我们直接CTRL + C已经杀不掉它了。
在我们的Linux中,一次登录中,一个终端,一般会配上一个bash。每一个登录只允许一个进程是前台进程,可以允许多个进程是后台进程。
如果我们要杀掉后台进程,我们只能使用kill -9命令了
一般来说,前台进程和后台进程的区别就是谁能获取键盘输入
键盘输入首先是被前台进程收到的
那么既然一开始bash是前台进程,那么为什么使用CTRL+C时候,bash不退出呢?
这当然是因为bash在里面对这个信号做了特殊处理
CTRL +C 本质是被进程解释成为收到了信号,2号信号
我们知道我们的系统一共有62个信号(没有0号,32号,33号)
我们将前31个信号称之为普通信号。后面的34~64我们称之为实时信号
一旦信号产生,不立即处理就是普通信号,立即处理是实时信号
这些信号本质就是一些数字,在linux中它们是以宏的方式定义的,就是这些数字。
3.信号的处理
信号的处理方式
- 默认动作
- 忽略
- 自定义动作
我们现在可以验证一下,进程收到2号信号的默认动作,就是终止自己!
我们先来看一下这个函数
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
这个函数的作用是设置对于signum信号的处理方法,处理方法为handler
这个signal函数是一个系统调用
它可以修改进程对于特定信号的处理动作
我们用如下代码来验证
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
}
int main()
{
signal(SIGINT, myhandler);
while(true)
{
cout << "I am a crazy process" << endl;
sleep(1);
}
return 0;
}
运行结果为
可见我们已经证明了2号信号的默认动作
我们也可以设置让2号信号可以退出
运行结果为
对于这个signal函数,只需要设置一次即可,往后都有效
只有收到了对应的信号,才会调用这个方法
注意:不是所有的信号都可以自定义的。有些信号不能自定义
4.硬件层面
键盘数据是如何输入给内核的,CTRL + C又是如何变成信号的?
键盘被摁下,肯定是OS先知道的
那么OS怎么知道键盘上有数据了???
如下图所示,是冯诺依曼体系结构
我们知道,Linux下一切皆文件。键盘也是,它也有自己对应的struct file。以及缓冲区
往键盘输入数据本质就是把输入的数据拷贝到缓冲区上。所以操作系统就知道了
所以我们就可以用read,write通过文件的方式把数据读到进程当中
那么操作系统要把键盘上的数据拷贝到缓冲区中。在这个过程中,操作系统怎么知道键盘上有数据了?
其实CPU上是有很多针脚的。我们的CPU是直接插到主板上的。而键盘是可以间接的和CPU直接物理上连接到的。虽然CPU不从键盘读数据。但是键盘可以给CPU发送一个硬件中断。
也就是说,一旦外设有数据,就可以给CPU发送一个中断,从而让操作系统去完成文件拷贝。键盘、网卡等都可以给CPU发送中断。
可是外设一多,CPU如何知道是谁给发送的中断呢?
这就会有一个中断号的概念。他们会通过这些针脚,直接将中断号发送给CPU
一旦硬件CPU知道键盘上有数据了。
CPU的寄存器凭什么能保存数据呢??
其实这个本质就是充放电的过程。如果是高电平代表充电了,就有1了。
在软件层面上,操作系统一启动,就会形成一张,中断向量表。里面放的是方法的地址。这些方法是直接访问外设的方法—主要是磁盘,显示器,键盘
然后最后这个读取键盘的方法,才是将键盘的数据放到缓冲区的方法
所以其实整个流程就是,键盘一旦有数据,会通过中断将中断号给CPU,CPU会利用这个中断号,让操作系统直接去通过中断向量表,找到对应的读取键盘的方法,然后通过这个方法就会让数据从键盘拷贝到这个文件缓冲区上。
所以键盘这个外设是通过中断来工作的。这个就是硬件中断
而我们前面所说的信号,也是通过一堆数字来进行控制。这两者其实比较相似,但是没有关系。一个是软硬件结合的,一个是纯软件行为。
我们所用的信号,就是用软件的方式,对进程模拟的硬件中断
当我们键盘读取的是CTRL + C这样的组合键的时候,操作系统其实还会对键盘上的数据进行判断。判断是数据还是控制,如果是控制,比如CTRL+ C会把这个转化为2号信号发送给进程。而不是放到缓冲区中。所以进程就收到了2号信号
像我们之前所谓的输入数据后往显示器上回显的过程其实是这样的,先将数据放到键盘的缓冲区,然后将键盘的缓冲区的数据放到显示器的缓冲区,最后就能输出了
如果这里不给显示器的缓冲区,就是不回显
当然,在这个过程中,也会有其他的进程给显示器的缓冲区上放数据
所以即便在显示器上的是乱的,但是我们还是能成功的执行指令
5.信号与我们的代码是异步的
信号的产生的和我们自己的代码的运行是异步的
同步就是发生一件事后等这件事发生完了才继续做我们的事情
异步就是这件事发生后我们不管这个事情,继续做我们的事情
信号是进程之间事件异步通知的一种方式,属于软中断
二、信号的产生
1.产生的方式
- 键盘组合键
- kill命令
- 系统调用
- 异常软件条件
以上是信号产生的方式!但是无论信号如何产生,最终一定是谁发送给进程的?
当然是OS
那么是为什么呢?
操作系统是进程的管理者!
2.键盘组合键
比如CTRL+C是2号信号
我们可以试一下捕捉三号信号
使用CTRL + \即可捕捉3号信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
//exit(1);
}
int main()
{
//signal(SIGINT, myhandler);
signal(3, myhandler);
while(true)
{
cout << "I am a crazy process" << endl;
sleep(1);
}
return 0;
}
CTRL + Z是19号信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
//exit(1);
}
int main()
{
//signal(SIGINT, myhandler);
//signal(3, myhandler);
signal(19, myhandler);
while(true)
{
cout << "I am a crazy process" << endl;
sleep(1);
}
return 0;
}
如下所示,我们似乎会发现,我们上面似乎并没有将19号信号用自定义的方法进行处理
其实这是因为不是所有的信号,都是可以被signal捕捉的,比如19,9号信号
我们可以用下面的代码进行测试。这里就不做演示了
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
//exit(1);
}
int main()
{
//signal(SIGINT, myhandler);
//signal(3, myhandler);
//signal(19, myhandler);
for(int i = 1; i <= 31; i++)
{
signal(i, myhandler);
}
while(true)
{
cout << "I am a crazy process" << endl;
sleep(1);
}
return 0;
}
19号是用来暂停进程的,9号信号是用来杀掉进程的。
这两个都是跟执行相关的。这是为了预防进程出现意外所设计的。不能被捕捉的
3.kill命令
kill -信号 进程pid
4.系统调用
4.1 kill系统调用
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
它的两个参数分别是pid和信号的编号。与命令行中的kill是很相似的
如果成功返回0,失败返回-1
我们可以简单的利用这个系统调用接口实现一个kill命令
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string>
#include <sys/types.h>
using namespace std;
void Usage(string proc)
{
cout << "Usage:\n\t" << proc << "signum pid\n\n";
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signum = stoi(argv[1]);
pid_t pid = stoi(argv[2]);
int n = kill(pid, signum);
if(n == -1)
{
perror("kill");
exit(2);
}
return 0;
}
测试结果如下所示
4.2 raise
#include <signal.h>
int raise(int sig);
它的作用,发送一个信号给调用本方法者
我们可以用如下代码进行测试
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string>
#include <sys/types.h>
using namespace std;
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
exit(1);
}
int main()
{
signal(2, myhandler);
int cnt = 5;
while(true)
{
cout << "I am a process, pid: " << getpid() << endl;
sleep(1);
cnt--;
if(cnt == 0) raise(2);
}
return 0;
}
运行结果为
这个raise相当于
kill(getpid(), 2);
4.3 abort
它的作用是引起一个正常的进程直接终止
它相当于给自己发送一个6号信号
我们先用下面代码进行测试
int main()
{
int cnt = 5;
while(true)
{
cout << "I am a process, pid: " << getpid() << endl;
sleep(1);
cnt--;
if(cnt == 0) abort();
}
return 0;
}
如果我们继续将代码改为下面的,让6号信号可以被捕捉
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
//exit(1);
}
int main()
{
signal(6, myhandler);
int cnt = 5;
while(true)
{
cout << "I am a process, pid: " << getpid() << endl;
sleep(1);
cnt--;
if(cnt == 0) abort();
}
return 0;
}
那么结果为
我们会注意到,虽然我们捕捉了以后,并没有将他给终止,但是abort依然将他给终止了。
我们可以再来观察一下
在下面的代码中,我们不让他自己abort了,我们现在在命令行上发送6号信号
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
//exit(1);
}
int main()
{
signal(6, myhandler);
int cnt = 5;
while(true)
{
cout << "I am a process, pid: " << getpid() << endl;
sleep(1);
cnt--;
//if(cnt == 0) abort();
}
return 0;
}
我们发现进程并没有被终止
所以其实是abort里面封装了一层,必须让进程终止
5.异常软件条件
5.1 异常产生信号
我们先使用如下代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main()
{
cout << "div before" << endl;
sleep(1);
int a = 10;
a /= 0;
cout << "div after" << endl;
sleep(1);
return 0;
}
运行结果为
像这种情况就是收到了信号了
收到的是8号信号
我们可以用七号手册
man 7 signal
往下翻就可以找到这个信号详情了,可以看到确实是八号信号
如果我们用下面的代码进行测试
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signal, number: " << signo << endl;
}
int main()
{
signal(SIGFPE, handler);
cout << "div before" << endl;
sleep(1);
int a = 10;
a /= 0;
cout << "div after" << endl;
sleep(1);
return 0;
}
那么运行结果为。打印很多的收到八号信号
而且这个过程中进程是不会退出的
这里的都还是比较好解释的:
不过我们可能比较好奇的是为什么在这里他会一直发送这个八号信号呢?
即信号为什么会一直被触发?
我们先看下面的代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main()
{
//signal(SIGFPE, handler);
cout << "point error before" << endl;
sleep(1);
// int a = 10;
// a /= 0;
int* p = nullptr;
*p = 10;
cout << "point error after" << endl;
sleep(1);
return 0;
}
运行结果为
这里也是代码没有跑完直接崩溃了,这个本质也是收到了信号。收到的是11号信号
我们在捕捉一下11号信号
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signal, number: " << signo << endl;
}
int main()
{
signal(SIGSEGV, handler);
cout << "point error before" << endl;
sleep(1);
// int a = 10;
// a /= 0;
int* p = nullptr;
*p = 10;
cout << "point error after" << endl;
sleep(1);
return 0;
}
运行结果为
和前面十分类似,会不断的捕捉11号信号,而且还不会退出
所以进程收到异常信号,不一定会退出。但是一定会执行异常处理方法
虽然上面捕捉后一直在打印,没有退出,不过其实我们大概率还是要将他们给退出的。因为一直打印也没什么用
运行结果为
为什么 /0 ,野指针会给进程发送信号?导致进程崩溃?
这里是因为,除零,野指针会给系统带来问题,操作系统识别到这些问题,然后OS给进程发送的信号。信号的默认处理动作就是终止自己,所以就崩了
操作系统为什么能检测到除零,野指针呢?
首先如下所示,如果是除零错误
在CPU上有一个eip/pc寄存器可以用来记录当前执行的是哪一行代码
还有一种寄存器是状态寄存器。它里面的每一位都有特定的含义
其中有一个就是溢出标志位
一旦我们的代码除零了,这个溢出标志位就变为1了
像我们在CPU的这些数据,都是这个进程的上下文
也就是说,这里虽然我们修改的是CPU内部的状态寄存器,但是这里只影响我们自己。不会影响其他进程,进程切换的时候,其他进程会将自己的上下文数据放上去
在这里操作系统一定会知道这里出错了。因为CPU是硬件,OS是硬件的管理者。
所以操作系统才会向进程发送信号
如果是野指针异常
如下图所示,在CPU里面有一个内存管理单元,因为直接查页表太慢了,所以有一个MMU硬件来进行查表。
一旦异常,也就是地址转化失败了。虚拟到物理转化失败了。
在CPU内还有一个寄存器,一旦转化失败了。它会把转化失败的虚拟地址放在这里
也就是说,一旦转化失败,CPU也能识别
而且因为他们是用的不同的寄存器,所以CPU也能区分出来是哪种报错,操作系统也就知道了
这里我们进程出异常以后,我们本应该退出。但是如果我们非要不退出。
意味着这个进程一直被调度运行。
硬件一直存在这个问题。我们也没有修正。随着我们的调度。操作系统一直在检测到这个异常,然后我们也一直在捕捉这个信号。所以就一直打印。
所以捕捉异常其实就不是让我们不让进程退出的,而是让我们知道我们这个进程是怎么死掉的
那么异常只能由硬件产生吗?
当然不是。
比如我们之前的管道,如果一开始读写端都打开,但是我们突然关闭了读端。那么写端进程就会被杀掉。会收到一个SIGPIPE(13)号信号。这就是一种软件异常。
也有的异常,操作系统只是会返回值出错的形式进行处理
运行结果为
5.2 alarm(软件条件产生信号)
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
alarm
系统调用用于设置一个定时器,当定时器计时器达到指定的时间时,内核会发送一个 SIGALRM
信号(14号信号)给调用进程。这可以用于实现定时器功能,例如在一定时间间隔内执行某个特定的操作或执行定时任务
seconds
参数表示定时器的秒数。如果 seconds
参数为非零值,表示设置定时器,在指定秒数后会发送 SIGALRM
信号给进程。如果 seconds
参数为零,则表示取消之前设置的定时器。
返回值是剩余的未完成的定时器秒数。如果之前有一个定时器已经设置,调用 alarm
会取消之前的定时器,并返回剩余的秒数。如果没有之前的定时器,或者之前的定时器已经到期,返回值为 0。
比如如下的代码中
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main()
{
int n = alarm(5);
while(1)
{
cout << "proc is running..." << endl;
sleep(1);
}
return 0;
}
运行结果为
我们可以捕捉一下这个信号
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signal, number: " << signo << endl;
//exit(1);
}
int main()
{
signal(SIGALRM, handler);
int n = alarm(5);
while(1)
{
cout << "proc is running..." << endl;
sleep(1);
}
return 0;
}
这里并不是异常导致的,所以不会循环式的疯狂捕捉。
我们也可以下面这样做,就可以每隔三秒打印一次了
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signal, number: " << signo << endl;
int n = alarm(3);
//exit(1);
}
int main()
{
signal(SIGALRM, handler);
int n = alarm(3);
while(1)
{
cout << "proc is running..." << endl;
sleep(1);
}
return 0;
}
有了这个闹钟,我们就可以在执行主要任务的同时,去定时完成其他任务了。像下面这样即可
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void work()
{
cout << "print log ..." << endl;
}
void handler(int signo)
{
work();
//cout << "get a signal, number: " << signo << endl;
int n = alarm(3);
//exit(1);
}
int main()
{
signal(SIGALRM, handler);
int n = alarm(3);
while(1)
{
cout << "proc is running..." << endl;
sleep(1);
}
return 0;
}
关于它的返回值,我们可以这样做
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signal, number: " << signo << endl;
int n = alarm(5);
cout << "剩余时间: " << n << endl;
}
int main()
{
signal(SIGALRM, handler);
int n = alarm(50);
while(1)
{
cout << "proc is running..., pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
运行结果如下
理解alarm
我们知道每个进程都可以使用alarm设置闹钟,所以操作系统中一定有大量的闹钟。所以操作系统要管理闹钟,所以闹钟就会用struct结构体描述,然后用链表等数据结构管理起来。这样所谓的闹钟管理就变成了对链表等的增删查改。
这个alarm结构体里面,一定有pid,或者pcb的指针。
操作系统底层中alarm的底层所用的时间用的是时间戳。这样最简单。
只要系统的当前时间大于等于里面设置的时间,就会发信号。
不过我们遍历链表的时候是比较浪费时间的。所以用一个小堆是最简单的。
6.core dump
我们可以看一下信号的详细信息手册
我们可以注意到,常见的信号中大部分是终止信号的。还有一些是暂停(Stop),继续(Cont),忽略(Ign)等。
我们可以注意到终止信号中,有一些是Core,有一些是Term
在我们当时提到进程退出的时候,有一个这个字段core dump标志
这个是用来表示是Core方式被杀还是Term方式
接下来我们使用这段代码来看看这个标志位
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 500;
while (cnt)
{
cout << "I am a chid process, pid: " << getpid() << "cnt: " << cnt << endl;
sleep(1);
cnt--;
}
exit(0);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
cout << "child quit info, rid: " << rid << " exit code: " << ((status>>8)&0xFF)
<< " exit signal: " << (status&0x7F) << " core dump: " << ((status>>7)&1) << endl;
}
}
如果被2号信号所杀,如下所示,这个标志为为0。而2号信号对应的Term方式
如果我们用8号信号所杀,我们发现这个core dump 标志位还是0。但是8号信号对应的是Core。
我们发现这两个都没有什么变化。
其实如果我们当前用的是虚拟机的话。如果进程崩掉的话。我们会发现当前目录会形成一个临时文件,这个临时文件是XXX.core文件。而我们当前的云服务器上会发现没有,这是因为默认云服务器上的core功能是被关闭的
那么这是为什么呢?是什么呢?怎么办呢?
我们使用下面这个命令,它可以查看系统当中一些标准的配置
ulimit -a
其中这个core file size选项,我们可以使用下面指令去查
ulimit -c
我们可以看到结果为0,也就是说这个,core默认是被关掉的
我们可以使用下面指令去设置它
ulimit -c 10240(要设置的大小)
如下所示,也就是说,最大是这么大
上面就是开启core
如果要关闭的话,我们可以直接设置为0
当我们设置好了以后,我们再去使用2号信号和8号信号,结果就不一样了
现在此时,八号信号的core dump就是1了
更关键的是,我们发现现在确实有这个临时文件了
这个很明显就是刚刚的pid
打开系统的core dump功能
一旦进程出现异常,OS会将进程在内存中的运行信息,给我dump(转储)到进程的当前目录(磁盘),形成core.pid文件。
这就是核心转储(core dump)
而这个功能在云服务器上默认是被关闭的
那么为什么要进行核心转储呢?
上面的错误一定是运行时错误。
此时我们要知道什么原因错误了。在哪一行错误了。
所以我们要用core dump来进行定位我们的原始在运行时哪里出错了
我们可以先生成调试版本的可执行程序
#include <iostream> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> using namespace std; int main() { int a = 10; int b =0; a /= b; cout << "a = " << a << endl; }
运行结果如下所示
此时很显然,已经帮我们核心转储了
然后我们使用gdb调试时候,可以直接使用下面命令
core-file core.23652
我们发现就可以直接定位出错原因了
所以core可以直接复现问题之后,直接定位到出错行
也就是说,先运行,在core-file,是事后调试
所以它就可以为这些最常出现的问题,有一个core功能去终止。
所以这个就相当于Term + Core
为什么这个功能云服务器是关闭的呢?
因为core dump功能消耗的内存比较大。而我们的服务器一般一旦挂掉就会自动重启。计算机的速度是很快的。如果重启后又挂掉了。这样瞬间会冲击磁盘。磁盘被写满后,可能操作系统也会挂掉。
此时问题就严重了。所以一般都要禁掉这个功能的。