鸡叫当然也算信号啦~
文章目录
- 前言
- 一、认识信号量
- 二、信号的产生
- 1.调用系统函数向进程发信号
- 2.由软件条件产生信号
- 3.硬件异常产生信号
- 总结
前言
信号在我们生活中很常见,下面我们举一举生活中信号的例子:
一、认识信号量
感性的认识:
信号量也被称为信号灯,本质上就是一个描述资源数量计数器,下面我们举个生活中的例子来理解信号量:
在生活中我们会去电影院看电影,但是在看电影之前我们必须先买票,而买票的本质功能有两个,第一个是对座位资源的预订机制,第二个是确保不会因为多放出去特定的座位资源而导致座位冲突。而信号量其实对应的就是买票,因为任何一个执行流,想访问临界资源中的任何一个子资源的时候是不能直接访问的,必须得先申请信号量资源(也就是买票),而我们前面说过信号量的本质就是个计数器,所以我们在申请信号量资源的时候只需要让这个计数器加加或减减即可(如果申请成功,那么计数器需要--,因为我们的信号量资源少了一个。如果申请成功后不想用了,那么就让计数器++,代表有人将我们的信号量资源归还了)。也就是说只要我们申请信号量成功,我就一定能在未来拿到一个子资源。同样的例子,如果我们的电影院只有一个座位仅供专属VIP座,那么这个情况就叫做互斥,因为在这期间只有一个VIP能使用这个座位,没有其他的人来抢座位。刚刚我们说了信号量本质是个计数器,既然是计数器就必须让所有的进程都看到,否则无法保证自己的操作是原子的。可以理解为:让不同的进程看到同一份资源(这个资源就是信号量)。
下面我们来认识一下信号量的接口:
首先第一个接口是获取信号量semget:
如果看了我们上一篇共享内存的文章的话,一定可以认识semget这个接口的参数,因为和获取共享内存接口shmget一模一样。第二个参数nsems的含义是代表信号量的个数,也就是说我们一次可以申请多个信号量。要查看我们的信号量的命令是ipcs -s:
同样和共享内存一样,删除某个信号量的指令是ipcrm -s +semid。下面我们看看删除信号量的系统调用接口,不出意外的话就是semctl这个函数了:
这个函数与共享内存的删除接口不一样的地方是多了一个可变参数列表,第二个参数semnum是代表对哪一个信号量做操作(因为刚刚我们说过了可以同时申请多个信号量)。
semop这个函数可以完成对信号量的计数器-1+1操作:
这个函数的第二个参数结构体就是完成我们对信号量的-1+1操作的,下面我们看看这个结构体:
比如说我们要对一个信号量做减操作,那么就可以在num这个下标填0(num是一个数组),sem_op填-1(因为要减减),flag默认即可。
对于信号量的接口我们差不多已经看完了,下面我们来理解一下IPC:
我们可以发现不管是共享内存还是信号量,系统用来描述他们的结构体都是XXXid_ds:
那么操作系统是分开管理这些IPC资源的还是一起管理的呢?
我们以左边三个结构体为例,在操作系统中有一个这个结构体类型的指针数组,这个数组按下标依次存放右边三个不同的ipc结构体的地址,对于这个指针数组来讲,要保存其他类型的ipc结构体只需要将这个结构体类型强转为系统用于管理的这个结构体指针类型,这样就完成了将内核中的所有ipc资源统一以数组的方式进行管理。以上就是操作系统管理这个IPC资源的原理,上面的操作不知道有没有看出是什么原理呢,其实这就是多态!
二、信号的产生
红绿灯,闹钟,下课铃都是信号,而这些信号被看懂前是需要我们被培养过,比如说有人告诉我们红灯停,所以我们知道红灯要停下,我们可以把进程比作自己,信号就是一个数字,进程在没有收到信号的时候其实进程早就知道该如何处理信号了(因为这是程序员教的,程序员写代码让进程认识信号),而由于信号可能会随时产生,所以在信号产生前,进程可能在做优先级更高的事情,这个时候进程是可以不用立马处理这个信号的,但是要在后续合适的时间处理刚刚没有处理的信号,由于这样的原因所以我们必须将信号保存起来,这样即使当时没有处理信号也能在后续的时间处理这个信号。总结:进程收到信号的时候,如果没有立马处理这个信号,需要进程具有记录信号的能力。
首先我们要知道查看信号的命令 kill -l:
在这些信号中,只有1-31是我们要学的,因为1-31叫做基本信号,34-64叫做实时信号,而我们现在的操作系统都是分时的,所以我们只学习基本信号。因为信号的产生对于进程来说是异步的,那么进程该如何记录对应产生的信号呢?答案是先描述再组织。怎么描述呢?简单的说0 1就能描述一个信号,用位图来管理这个信号。如下图:
下面我们用代码来对信号进行简单的测试:
#include <iostream>
#include <unistd.h>
int main()
{
while (true)
{
std::cout<<"我是一个进程,我正在运行 ...,pid:"<<getpid()<<std::endl;
sleep(1);
}
return 0;
}
下面我们将程序运行起来试一试信号:
首先我们看到的现象是我们成功用9号信号杀死了一个进程,这就是通过指令的方式发信号。
当然对于前台进程而言,我们可以从键盘上输入ctrl +c 终止前台进程:
而如何将一个进程变为后台进程我们也说过了,就是在后面加上&符号:
后台进程是无法被ctrl+c这样的命令杀死的,所以最后我们用kill-9杀死了这个进程。其实ctrl+c也是操作系统像进程发信号,只不过我们看不到,下面我们通过signal函数的方式查看操作系统给进程发的信号:
signal这个函数的第一个参数为信号编号,第二个参数为如果操作系统像这个进程发了一个信号,这个函数会将这个信号拿走用于自定义的功能,而不是再像以前一样听取操作系统的指令。下面我们演示一下:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{
std::cout<<"get a signal: "<<sig<<std::endl;
}
int main()
{
signal(2,handler);
while (true)
{
std::cout<<"我是一个进程,我正在运行 ...,pid:"<<getpid()<<std::endl;
sleep(1);
}
return 0;
}
这段代码的意思是当操作系统向我们发送2号信号的时候(ctrl + c 就发送的2号信号)我们不在执行原来的终止程序,而是去打印出来signal信号:
也就是说我们通过signal函数成功捕获了操作系统向我们发送的2号信号(这个2号信号就是我们按下ctrl+c的时候操作系统转化为信号发送给进程的)当然如果上面的图片还是没看懂那么我们也可以这样:
下面这两张图就清楚的证明了我们发送的ctrl+c信号就是2号信号,因为我们发送2号信号不会中断程序,ctrl+c也不会中断程序。下面我们要说一下,在我们用回调函数的时候,就像我们上面的代码,在调用signal函数的时候是不会调用handler函数的,这里只是更改了2号信号的处理动作,并没有调用handler方法。比如下面这样:
在我们调用show方法的时候是不会调用print的函数的,下面我们将代码运行起来:
我们可以看到只打印了hello show,那么如何在调用show的时候还调用print函数呢?其实很简单,在show中调用函数指针即可:
这也就证明了我们调用signal函数的时候是不会调用handler函数的。
下面我们将所有信号都自定义捕捉,这样是不是这个进程就无敌了没有指令可以杀掉这个进程了呢?
int main()
{
//signal(2,handler);
for (int i = 1;i<=31;i++)
{
signal(i,handler);
}
while (true)
{
std::cout<<"我是一个进程,我正在运行 ...,pid:"<<getpid()<<std::endl;
sleep(1);
}
return 0;
}
我们可以看到其他信号确实都被捕捉了,但是kill -9还是会杀掉进程,因为操作系统不允许有进程不被杀死。
下面我们讲一下信号的产生原理:
我们平时在输入的时候,计算机怎么知道我从键盘输入了数据呢?键盘是通过硬件中断的方式通知系统我们的键盘已经被按下了。:
上图中的圆圈代表CPU,边上的毛代表CPU的针脚,而键盘会通过中断控制器找到对应与CPU的针脚:
当我们从键盘输入指令后cpu的寄存器会存储键盘的中断号,然后CPU通过中断号去中断向量表中查找与之中断号相对应的函数方法,这样就完成了我们从键盘输入ctrl + c然后转化为2号信号并且杀死进程的操作。
下面我们将上面所讲的知识先小小的总结一下:
当然除了上面我们用数字当信号,也可以用宏来使用:
1.调用系统函数向进程发信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <string>
#include <sys/types.h>
void Usage(std::string proc)
{
std::cout<<"Usage: \n\t";
std::cout<<proc<<"信号编号 目标进程\n"<<std::endl;
}
int main(int argc,char* argv[])
{
if (argc!=3)
{
Usage(argv[0]);
exit(1);
}
return 0;
}
我们要完成的工作是写一个和kill-9命令一样的函数,所以我们在main函数中判断如果用户使用我们的kill命令用的参数不对的话,就给用户发一个使用手册然后退出程序,这个使用手册就是教用户如何使用这个kill函数:
当我运行程序参数用的不对就会给我发一个使用手册,下一步我们完善代码:
在使用kill接口前我们先看看kill接口需要的参数和返回值:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <string>
#include <sys/types.h>
#include <cerrno>
#include <cstring>
void Usage(std::string proc)
{
std::cout<<"Usage: \n\t";
std::cout<<proc<<"信号编号 目标进程\n"<<std::endl;
}
int main(int argc,char* argv[])
{
if (argc!=3)
{
Usage(argv[0]);
exit(1);
}
int signo = atoi(argv[1]);
int target_id = atoi(argv[2]);
int n = kill(target_id,signo);
if (n!=0)
{
std::cerr<<errno<<" : "<<strerror(errno)<<std::endl;
exit(2);
}
return 0;
}
首先我们要用的参数都在参数列表中,所以我们需要拿到用户提供的参数,但是由于参数列表为char*类型,所以我们需要将字符串式的信号转为整数,所以我们用了atoi函数,这个函数可以将字符串转为整数,拿到了信号和进程编号后我们就可以使用kill函数了,由于函数成功后返回0,失败返回-1所以我们用了if条件判断,然后我们再写一个死循环的程序等会让这个程序挂着然后用我们的kill程序杀死这个进程:
同时因为要生成两个可执行程序所以我们将makefile修改一下:
下面我们将程序运行起来:
程序运行起来后我们可以看到我们写的程序成功杀死了一个进程。下面我们看一下raise函数:
raise函数是谁调用我我就给谁传几号命令,这里的命令是参数,下面我们演示一下:
我们可以看到raise函数的作用确实是谁调用了我我就给谁发信号。
下面我们再看一下abort函数:
abort这个函数的含义是给自己发送指定的结束信号:
我们可以看到本来应该打印的end由于abort被迫停止,所以abort是给自己发送指定的结束信号。
2.由软件条件产生信号
软件条件产生信号其实我们在学管道的时候就学过了,我们在学管道的时候讲过,如果管道的读端关闭了写端一直在写,这个时候操作系统就会给管道发送13号信号关闭管道,这就是由软件条件产生信号。下面我们主要讲解alarm函数,这个函数的意思是:
下面我们用这个代码测试1秒钟CPU可以记多少数:
当我们计数了7万多后程序被alarm函数叫停,而这个数据是真实的吗?CPU1秒才能记这么多数吗?其实不是,因为我们计算要打印出来包含了IO包含了网络,有很多影响的因素,所以这样的测试并不准确,如果测最准确的呢?如下代码:
首先我们定义一个全局的count计数器,然后用signal捕捉alarm函数,在这期间计数器会一直加加,当1秒达到后alarm发送命令然后被myhandler方法捕捉打印出信号和计数器的数值:
这次的计数才是正常的五亿多。
3.硬件异常产生信号
不知道大家有没有发现,当我们写代码有对空指针进行解引用,或者数组越界,或者右除0操作时,编译的时候都会提示我们出现错误,那么错误是如何被发现的呢?下面用代码来验证:
首先我们重新创建一个文件,然后写一段简单的代码:
#include <iostream>
using namespace std;
int main()
{
int a = 10;
a/=0; //进行除0操作引发异常
cout<<"div zero ..... here"<<endl;
return 0;
}
接下来我们直接编译一下:
可以看到在编译的时候直接给我们发出警告了,当然我们还是可以继续编译的:
运行后我们发现输出了一行Floating point exception也就是浮点数异常,下面我们讲一下原理:
如上图所示,代码是在内存中的某个位置存储,假设a/=0这个代码存储在内存的某个位置,而在CPU中有各种各样的寄存器,我们的代码在运行的时候会被加载到CPU当中,然后CPU会将刚刚那个代码加载到寄存器里,比如上图:将a加载到一个寄存器,将0加载到另一个寄存器,而在CPU做计算时是有一个状态寄存器的,这个状态寄存器会报错我们本次计算是否会有溢出问题,一旦溢出了,那么状态寄存器中的溢出标志位就被置为1了,只要被置为1就说明计算有问题,CPU就立马告知操作系统,一旦操作系统发现确实状态寄存器中的标志位被置为1了,那么操作系统就会向目标进程发送信号,这个信号就是Floating point exception浮点数异常,我们可以在信号中查看这个这个是几号信号:
经过查询我们发现8号信号就是浮点数异常,因为信号的后三位字母是刚刚报错信号的每个单词的首元素。当然我们也可以验证一下,直接捕捉信号即可:
当然我们也可以先看看这个信号的作用:
我们以前用的九号信号作用就是终止进程,term就是terminal的缩写终止的意思,core是什么呢我们等会再讲:
#include <iostream>
#include <signal.h>
using namespace std;
void handler(int signo)
{
cout<<"我们的进程确实收到了"<<signo<<"号信号"<<endl;
}
int main()
{
signal(8,handler);
int a = 10;
a/=0; //进行除0操作引发异常
cout<<"div zero ..... here"<<endl;
return 0;
}
当我们将异常信号捕捉后程序就不会停止了:
运行后一直死循环打印,下面我们在捕捉的时候让这个进程退出:
运行后不再死循环了并且打印了我们要求的返回值。下面我们再试试其他异常:
我们发现程序正常编译但是同样直接结束了,下面我们讲一下关于地址的问题:
首先有一个0000~FFFF的进程地址空间,进程地址空间上的红色方框是指针的虚拟地址,实际上是在最右边的物理内存上开辟空间的,当我们对空指针解引用的时候其实访问的是进程地址空间的0号地址,比如向0号地址写100,要经过页表转化到物理内存中,但是页表实际上是做KV关系的,做转化的动作不是由软件完成的,而是由硬件完成的,这个硬件叫MMU,MMU被称为内存管理单元,所以从虚拟地址转化到物理地址采用软硬件结合的方式(以上方式是正常情况),而我们对空指针进行解引用首先指针与页表没有对应的映射关系,对0号地址是没有写权限的,所以我们对空指针写入是非法的。*p = 100这句代码第一步并不是写入,而是首先进行虚拟到物理地址的转换,在转换的时候要进程地址空间是否和页表有映射关系,如果没有映射则MMU会硬件报错,如果有映射还需要看是否有对应的权限,如果没有权限也会报错,如果MMU报错也就是硬件报错操作系统就会识别到,然后操作系统向当前进程的PCB发送信号,以上就是对空指针解引用的报错原理。
总结
以上就是linux信号产生的所有知识,下一篇我们将详细讲解linux信号是如何保存和处理的。