目录标题
- 如何理解计算机中的信号
- 如何查看计算机中的信号
- 初步了解信号的保存和发送
- 如何向目标进程发送信号
- 情景一:使用键盘发送信号
- 情景二:系统调用发送信号
- 情景三:硬件异常产生信号
- 情景四:软件条件产生信号
- 核心转储
- 信号的两个问题
- 问题一
- 问题二
如何理解计算机中的信号
我们首先通过生活中的信号来理解一下计算机中的信号,生活中会遇到很多的信号,比如说运动会上的发令枪,晚上睡觉前定的闹钟,过马路开车需要查看的红绿灯,以及手机在收到信息发出的响声这也是信号,手机发出的响声是一个信号,并且我们人是可以识别信号的,这里的识别指是人能够认识这个信号,并且我们能因为这个信号而做出对应的行为,那我们为什么能够识别红绿灯呢?为什么当听到手机发出响声之后知道是手机接收到一些信息的呢?原因是有人教育过你有经验告诉过你,当这些信号产生的时候意味着一些事情要被处理,通过教育的手段和以往经验的积累,让你在大脑里面记住了一些信号的属性(为什么会产生这样的信号?)和与之对应处理行为(我们人该如何处理这个信号),比如说我们开车的过程中发现红灯亮了起来,那我们就得在合适的地方将车停下来了,红灯就是一个信号,将车停下来就是对这个信号的处理,那为什么会有红灯这个信号呢?原因是为了维持社会交通的便利,那为什么我们知道红灯是一个信号遇到红灯是就得停车呢?原因是从小爸爸妈妈和老师就教育我们遇到红灯的时候就得停止向前运动直到红灯变成了绿灯,这是一个教育的过程让我们知道了红灯是一个信号,以及信号的处理行为,再比如说手机发出的响声就是一个信号,当手机发出声响的时候我们就知道有人或者有软件给我们发了消息,我们就可以打开手机对消息进行查看,那么我们是如何知道手机发出声响是一个信号的呢?原因是在之前使用手机的过程中每当有人或者有软件给我们发消息时手机发出声响,即使我们不用手机将他放到一边当收到消息的时候他也会发出声响,这样我们人就会积攒经验知道手机发出声响就意味着我们收到了消息,该消息可能要被处理,那么这就是我们为什么会认识信号的原因,那接收到了信号就一定得立即对其进行处理吗?答案是不是的,因为信号时可以随便产生的,但是在信号产生的时候我们可能有更重要的事情要做,比如说手机叮咚响了一声我们就必须得马上打开手机进行查看吗?对吧!没必要我们可以选择忽略这个信号直到我们忙完了再查看这个信号都不晚。信号到来的时候我们不一定要立马处理这个信号,因为我们当前可能得做着更重要的事情,所以信号产生到信号被处理的过程中存在一个时间窗口,所以在这个时间窗口里面我们必须得记住这个信号,那么如何保存信号就是我们后面要学习的内容,我们知道什么是信号是因为有人教育过我们,那对信号的处理是固定的吗?我们可不可以直接忽略掉这个信号,比如说红灯亮起的时候我们依然选着向前移动,手机不停地响但是我们依然选继续干正在做的事,答案是可以的在处理信号的时候我们可以选择直接忽略掉信号,那么同样的道理我们可以修改以往对信号处理的行为吗?比如说之前有人一直教育我们遇到红灯就得停止向前运动,这是一个默认的信号处理行为,我们可以对这个行为进行修改,当红点亮了我们也对对其进行做出行为,但是这个行为是将运动向前变成向后,同样的道理当手机响的时候默认动作是打开手机并查看消息,那么我们能对这个行为进行修改,当手机响了之后我们可以直接将手机进行关机,那么这就是对信号默认行为的修改,看到这里想必大家应该能够理解信号,但是大家心里一定存在个疑问,人可以被教育可以从经验中总结结果,那计算机又是如何认识信号的呢?进程识别信号的方式就是:认识信号+处理信号,而信号是发送给进程的,那进程如何识别信号的呢?人能够认识信号是因为人受到过教育,进程能够认识信号则是因为进程本身是程序员编写的属性和逻辑的集合,这些都是程序员编码完成的,所以我们写的程序能够识别到信号,当进程收到信号的时候,进程可能正在执行更重要的代码,所以信号不一定会被立即处理,所以进程本身必须要有对信号的保存能力,进程在处理信号的时候一般有三种动作(默认,自定义,忽略),我们把处理信号的动作称为信号捕捉。那么接下来我们就来逐步逐步的理解信号。
如何查看计算机中的信号
kill -l可以查看所有的信号
数字就表示信号的编号,数字后面的名称就是数字对应的宏,所以未来我们既可以使用编号也可以使用宏来操控信号,通过观察我们可以看到一共有62个信号,我们把1-31称为普通信号 34-64称为实时信号(这个不学)。
初步了解信号的保存和发送
在前面的介绍中我们知道信号发送之后可能不会立即处理,所以得将信号保存起来,那信号应该保存在哪里呢?答案是保存在task_struct里面,那该保存什么内容呢?答案是保存是否收到了指定的信号,如果收到了指定的信号就保存1没有收到就保存0,那么这里有31个信号,所以在task_struct里面就有一个unsigned int signal来存储是否收到了信号,比特位的位置代表信号的编号,比特位的内容,代表是否收到了该信号,0表示没有,1表示有,既然存储信号的方法是通过PCB中的一个位图来进行存储,那么发送信号的本质就是修改PCB中的信号位图,将位图中的某个比特位由0变成1,PCB是内核维护的数据结构对象,所以PCB的管理者是操作系统,那谁有权利修改PCB的内容呢?答案是操作系统,所以无论未来我们学习了多少种发送信号的方式,本质都是通过操作系统向目标进程发送信号,所以操作系统必须要提供发送信号处理型号的相关系统调用,我们使用的kill命令底层一定是调用了对应的系统调用,比如说我们提供了下面这样的程序:
#include<iostream>
#include<unistd.h>
using namespace std;
int main()
{while(1){cout<<"我是一个死循环的进程"<<endl;sleep(1);}return 0;
}
生成可执行文件
然后运行一下程序就可以看到下面这样的场景:
可以看到这里在死循环的打印语句,那么想要结束这个进程我们就可以按下键盘上的ctrl+c,然后我们就可以程序终止了:
那么ctrl+c就是一个热键—本质就是一个组合键,操作系统会将这个组合键识别成成为二号信号发送给进程,通过man 7 signal可以查看所有信号的信息和与之对应的默认操作:
signal是信号的名称,value表示信号的值,action表示信号的动作,comment是信号的描述,通过这张图片我们便可以知道二号信号的名称就是sigint,动作是Term对这个信号的描述就是用键盘来中断当前运行的进程,所以我们可以看到程序停止向屏幕上进行打印了,那么为了验证这一点我们可以用signal函数来进行判断,signal函数的作用就是修改信号的处理动作
signal函数的第一个参数表示信号的编号也就是你要对哪个信号进行修改,第二个参数的类型为sighandler_t通过上面显示我们知道sighandler是一个重命名类型,该类型的本质就是一个函数指针,函数的返回类型是void参数就是一个整形,该参数的作用就是将信号原来的默认动作修改成函数指针指向的函数,比如说下面的程序:
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
using namespace std;
void handler(int sign)
{cout<<"我收到了信号,信号的编号为:"<<sign<<endl;
}int main()
{signal(2,handler);while(1){cout<<"我是一个死循环的进程,我的pid为:"<<getpid()<<endl;sleep(1);}return 0;
}
运行的结果如下:
可以看到程序的运行是没有问题的,然后当我们按下ctrl c给进程发送信号的时候程序并不会终止,而是执行了我们之前设置好的内容,打印了一句话:
打印完这句话后接着执行死循环:
那么这也就验证了,当我们使用键盘输入ctrl c的时候本质上就是给当前的进程发送2号信号,2号信号的作用就是将当前的程序终止。
如何向目标进程发送信号
情景一:使用键盘发送信号
通过前面的学习我们知道使用组合键ctrl c可以向前台进程发送信号从而终止进程:
那么这里还有一个ctrl \的快捷键,他的作用就是给前台进程发送3号信号从而终止进程,那么这里的代码如下:
那么这就是通过键盘向进程发送信号。
情景二:系统调用发送信号
1.kill函数
我们学习的第一个函数就是kill,该函数的声明形式如下:
第一个参数表示向哪个进程发送信号,第二个参数表示向进程发送几号信号,我们再看看这个函数的返回值:
可以看到当信号发送成功就返回0,如果失败就返回-1,那么这就是该函数的作用,那么接下来我们就可以写一个人程序,专门用来给指定的进程发送信号,程序使用的方法就是运行程序的时候传递两个参数第一个参数表示进程的pid第二个参数用来表示发送的具体信号比如说这样:./myproc 进程的pid 信号的编号
,那么我们的程序就可以这样设计,因为运行程序的时候会传递参数,所以该程序中的main函数得包含两个参数:
#include<iostream>
using namespace std;
int main(int argc,char*argv[])
{}
然后在程序的开始我们得判断一下该程序的使用是否正确,如果传递argc的值不等于3的话我们就得调用函数来告诉使用者该程序的正确使用方法,所以该函数就得有一个参数用来告诉使用者是哪个进程的使用方法:
#include<iostream>
#include<string>
using namespace std;
void usage(const string& proc)
{cout<<"\nusage:"<<proc<<"pid signo"<<endl;
}
int main(int argc,char*argv[])
{if(argc!=3){usage(argv[0]);exit(1);}
}
然后我们就可以通过main函数的第二个参数来获取对应的pid和信号,然后将其转换成为整形最后就可以调用kill函数发送对应的信号即可:
#include<iostream>
#include<string>
#include<signal.h>
#include<stdlib.h>
#include<sys/types.h>
using namespace std;
void usage(const string& proc)
{cout<<"\nusage:"<<proc<<"pid signo\n"<<endl;
}
int main(int argc,char*argv[])
{if(argc!=3){usage(argv[0]);exit(1);}pid_t pid=atoi(argv[1]);int signo=atoi(argv[2]);kill(pid,signo);return 0;
}
那么接下来我们就可以先测试一下myproc.cc文件是否运行正常:
可以看到当前的程序是可以正常运行的,那么这里我们就可以先运行一下死循环程序:
然后再打开一个回话,并运行myproc程序并传递20261和2号信号:
然后就可以看到程序自动的终止了,同样的道理我们还可以发送3号信号给进程,这样我们就可以看到打印出来quit的字样:
那么这就是kill函数的用法。
第二个:raise
该函数的声明如下:
这个函数也可以给进程发送信号,但是他不能指定任意进程,他只能给自己发送对应的信号,比如说下面的代码:
int main()
{int cnt=1;while(true){cout<<"cnt的值为: "<<cnt<<endl;cnt++;if(cnt==4){raise(2);}}return 0;
}
那么这里我们就可以看到屏幕上打印了3句话话之后就自动的结束了进程,运行的结果如下:
那么这就是raise函数的用法。
第三个:abort
kill函数能够对任意的进程发送任意的信号,raise函数能够给本进程发送任意的信号,那么abort函数就只能给本进程发送指定的信号也就是6号新号,我们来看看这个函数的介绍:
我们可以看看6号新号的名字:
可以看到6号信号对应的宏就是SIGABRT,那么这里就不再介绍,大家理解了即可。
情景三:硬件异常产生信号
信号的产生,不一定非得用户显示的发送,比如说除0会终止进程的时候会终止进程,比如说下面的代码:
int main()
{int i=10;int j=0;int c=i/0;while(true){cout<<"如果没有收到信号就会死循环"<<endl;}return 0;
}
将程序运行起来便可以看到下面的场景:
可以看到这里没有死循环的打印语句,而是报出了异常的内容,那么这就说明当出现除0情况是进程是收到信号的,那这个信号是谁发送的呢?答案是当前进程会受到来自操作系统的信号并且是8号SIGFPF信号,那么这里我们可以使用signal函数修改一下对应的8号信号,修改的操作就是不终止信号并输出信息:
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
using namespace std;
void handler(int sign)
{cout<<"我收到了信号,信号的编号为:"<<sign<<endl;
}int main()
{signal(8,handler);int i=10;int j=0;int c=i/0;while(true){cout<<"如果没有收到信号就会死循环"<<endl;}return 0;
}
程序的运行代码如下:
可以看到出现/0错误的时确实会收到8号新号,并且会不停的执行8号信号的处理方法,那么这里就存在一个问题我们只除了一次0,为什么会不停的捕捉呢?操作系统又是如何得知应该给当前进程发送8号信号的呢?那么要想解决这里的问题我们就得了解一下cpu的构造,cpu中存在着大量的寄存器,这些寄存器会存储大量的数据比如说各种表达式或者函数的计算结果,寄存器不仅得存储计算的结果还得保存表达式的计算状态,所以有一种寄存器称为状态寄存器,该寄存器中存在一个比特位称为溢出标记位,10除以0得到的结果是无穷大,那么无穷大这个结果就会导致状态寄存器中的溢出由0变成了1,虽然这次表达式计算出现了问题,但是寄存器中依然会存储一些数据(这里就体现出溢出标记位的作用,因为都会保存一些值,那么这个值是否是对的就有状态寄存器来决定),cpu出现运算异常,那么操作系统是肯定要知道,所以操作系统就可以通过状态寄存器上的标记位知道发生了什么异常?然后再查询当前运行的是哪个进程以及进程运行的位置这样便知道是哪个进程的哪里出现了异常,然后操作系统修改标记位并发送信号,那为什么会一直打印信息呢?我们之前说过收到信号进程不一定会退出,没有退出的话就说明该进程还会被调度,cpu内部的寄存器只有一份,但是寄存器中的内容属于当前进程的上下文,一旦出现了异常了我们修改之后的动作有能力或者行为来修改这个问题吗?答案是没有的,所以进程会被多次切换,与之对应的寄存器的内容就会被多次保存或者回复,所以每一次回复的时候操作系统就会识别到状态寄存器中的溢出标记位,然后修改进程pcb中的标记位并发送信号所以就可以看到当我们对8号信号的动作进行修改时会不停执行8号信号的处理动作。同样场景也会出现在对野指针解引用上,当我们对野指针进行解引用时也会出现崩溃,结束进程,比如说对nullptr地址进行解引用,那么这个时候就会发送11号信号,那操作系统为什么会知道出现野指针的错误呢?答案是虚拟地址转换到物理地址需要页表加上mmu,mmu是内存管理单元这是一个硬件集成在cpu上,当我们访问0号地址时,虚拟地址空间会拒绝我们的访问,然后mmu就会因为我们的越界访问出现了异常,当一个硬件发生了异常时,操作系统便会得知在地址转换的过程中出现了异常,然后操作系统就会发送11号信号,按摩这就是硬件层面上的异常。
情景四:软件条件产生信号
我们之前学习过进程之间的通信,在那里我们知道了管道文件的特点,当管道的读端关闭,写段一直写的时候,操作系统就会通过发送sigpipe信号的方式来终止进程,那么这就是一个经典的软件条件产生的异常,那么这里我们再举一个例子:alarm函数:
alarm函数的作用就是让程序运行一段时间,时间到了就会发送14号-信号来结束进程,那么alarm的参数就表示在多少秒之后发送信号,所以我们就可以使用闹钟来实现下面这样的代码:
int main()
{alarm(1);//程序1秒后会被信号终止int cnt=0;while(true){cout<<"cnt: "<<cnt++<<endl;}return 0;
}
然后程序的运行结果如下:
可以看到循环执行了6w多次,那么这段代码的意义就是统计1s左右,我们的计算机能够将数据累加多少次,但是大家不难发现1秒访问6万多次好想有点少,那么这里的原因就是在循环里面要不停的访问外设显示屏,所以运行的速度非常的慢,那么下面我们可以对代码进行修改将cnt改成全局变量,循环里面就不往屏幕上打印数据而是直接对变量++
int cnt=0;
void handler(int sign)
{cout<<"我收到了信号,信号的编号为:"<<sign<<endl;cout<<"cnt的值为: "<<cnt<<endl;exit(1);
}
int main()
{signal(SIGALRM,handler);alarm(1);//程序1秒后会被信号终止while(true){++cnt;}return 0;
}
代码的运行结果如下:
可以看到这时程序的运行速度就变得十分的快,但是我们为什么把这个函数发出的异常称为软件异常呢?因为闹钟其实就是用软件实现的,任意一个进程都能通过alarm系统调用在内核中设置闹钟,os中可能会存在很多的闹钟,那么操作系统要不要对这些闹钟进行管理呢?答案是要的,管理的方式就是先描述再组织,所以操作系统就会有对应的结构体来描述闹钟,然后操作系统中就会有数据结构来管理这些结构体,比如说链表,堆,操作系统就会不停的检查这些对象中是否有哪些闹钟超时,超时了操作系统就会给对应的进程发送信号,检查闹钟是否超时是操作系统这样的软件来执行的,条件就是现在超时这个条件,所以闹钟就是软件条件。
核心转储
在信号的行为中我们知道Term和Core都是将进程终止,那这两种终止方式有什么区别呢?term的终止表示是正常的结束,操作系统不会做额外的操作,而core的终止表示不是正常的终止,操作系统会做出额外的操作,比如说SIGFPF就是一个Core类型的终止,当我们写出这样的代码时操作系统就会给我们发送该类型的异常:
int main()
{while(true){int arr[10];arr[10000]=100;}return 0;
}
运行的结果如下:
可以看到这里确实发生了段错误,但是在云服务器上如果进程是core终止我们暂时看不到明显的现象,如果想看到我们得打开一个选项:ulimit -a查看云服务器的各种选项
这里会显示当前云服务器的各种性质,比如说管道的大小,最多能打开的文件个数,其中有个属性为core file size,他表示表示核心转储的大小,如果大小为0的话就表示云服务器默认关闭了核心转储,如果想要看到的话 ulimit -c 1024 表示打开核心转储并设计一个大小为1024的数据块
再运行一下当前的程序就会出现core dumped的标识
这个core dumped就是核心转储的意思,并且当前路径下还多出来了一个文件,文件名后面有一串数字表示引起核心转储的进程的pid
核心转储的意思就是:当进出现异常的时候,我们将进程对应的时刻在内存中的有效数据转储到磁盘中就称为核心转储。我们可以查看一下文件里面的内容:
文件里面的内容我们是看不懂的,那为什么要有核心转储呢?原因是当进程崩溃的时候,我们想知道进程为什么会崩溃,在哪里崩溃,所以操作系统为了方便我们查看程序崩溃的原因,操作系统就会该进程上下文的数据保存到磁盘中用来支持我们调试,那如何查看这些数据呢?答案是先调试我们的文件
然后输入core-file 新生成的核心转储文件
按下回车然后就会显示当前出错的原因:
大家仔细的观察一下便可以看到上面显示了在程序的第18行出现了异常,第18行的内容为arr[10000]=100;那么这就是核心转储的功能,他可以告诉我们程序在哪出现了异常。
信号的两个问题
问题一
通过上面的图片我们可以看到很多信号的处理行为都是直接将进程终止,那既然处理的行为都是终止的话那为什么还要分这么多种信号呢?意义是什么呢?答案是不同的信号代表不同的事件,但是对于不同的事件处理的动作可以是一样的,这就意味着虽然都是直接将进程终止,但是出现了不同的信号可以让我们知道是哪些原因导致了信号的发出,然后我们就可以根据这些原因来更改程序,那么这就是不同信号的意义。
问题二
我们能不能对所有的信号都进行自定义捕捉?比如说下面的程序:
void handler(int sign)
{cout<<"我收到了信号,信号的编号为:"<<sign<<endl;
}
int main()
{for(int i=0;i<=31;i++){signal(i,handler);}while(true){cout<<"我是一个进程,我的pid为:"<<getpid()<<endl;}return 0;
}
如果我们对所有的信号都做捕捉,那是不是就不能杀掉这个进程了呢?程序运行的结果如下:
我们之前讲过2号新号和3号新号都可以结束进程,那现在还能够结束掉吗?我们来尝试一下:
可以看到是不行的,并且其他的一些信号也不行
那么这是不是就说明这个进程无法被终止掉了呢?不会的我们使用9号信号依然可以将其 终止掉:
所以操作系统为了能够结束恶意进程是不允许修改某些信号的处理方法的我们把这样信号称为管理员信号,那么这就是本篇文章的全部内容希望大家能够理解。