初识Linux · 信号产生
目录
前言:
预备知识
信号产生
前言:
前文已经将进程间通信介绍完了,介绍了相关的的通信方式。在本文介绍的是信号部分,那么一定有人会有问题是:信号和信号量之间的关系是什么呢?答案是,它们之间的区别就是老婆和老婆饼之间一样,没有关系。
对于信号部分,我们分为四个阶段来介绍,一个是信号的预备知识,一个是信号产生,一个是信号保存,一个是信号处理。
在本文中,介绍信号的预备知识和信号产生。那么话不多说,直接进入主题吧!
预备知识
对于信号来说,我们平常生活中时时刻刻都在接收,比如红灯停绿灯行,就是一种信号,比如闹钟响了,也是一种信号,比如外卖员打电话来了,我们知道要拿外卖,这是我们知道信号怎么处理。
从上面我们可以得出来的结论是:
信号是随时产生的,要处理信号的前提条件是能认识这个信号。
那么,如果外卖员打电话的时候,我们正在打游戏,那么外卖员发出的信号我们应该如何处理呢?我们可以选择终止我们正在打游戏这个行为,我们也可以忽略外卖员的信号,我们也可以有其他反应。
以上是信号在生活中的例子,那么有意思了,如果我们将我们换成进程呢?
似乎就关联起来了?
我们其实在进程部分也是使用过信号的,比如9号信号是直接杀死进程,我们可以使用kill -l查看所有的信号:
那么,我们可以注意到一个点是信号是从1开始的,而不是从0开始的,并且在1-31是一个梯队,34到64是一个梯队。其中,34往后的信号都是实时信号,我们暂时先不用管。我们在信号这个主题要介绍的信号是前面31个信号,叫做普通信号。
所以,现在我们对信号有了一个基本的概念认识。
信号:Linux提供的一种向指定进程发送处理某种特定事件的方式。
所以信号实际上是一种处理方式,那么信号是同步的还是异步的呢?
信号产生是异步的,我们通过一个例子对同步和异步理解:
老师上课的时候,让小王出去拿东西,但是老师不会因为小王出去拿东西停止自己讲课这个行为,并且老师给小王发送的信号是出去拿东西,所以是异步的。
我们通过man的7号手册查看signal:
就可以看到如上这么多信号。
对于信号来说,预备知识部分我们通过外卖员的例子,可以知道信号有3种处理方式,一种是默认行为,一种是忽略,一种是自定义行为,其中的默认行为实际上就是终止当前进程。
对于第三列有Core Term的信号,都是代表如果接受到的该信号,默认行为都是终止。
那么我们先不管,我们先试试:
#include <iostream>
#include <unistd.h>
int main()
{
while(true)
{
std::cout << "Hello signal" << " pid is :" << getpid() << std::endl;
sleep(1);
}
return 0;
}
其实可以发现,不管是发送哪个信号都会终止该进程。
对于默认行为我们有了一定了解,忽略我们暂时先不考虑,我们先介绍自定义行为,使用到的函数是signal,这个是在2号手册,也就是系统调用,其实,对应的参数是,信号以及函数指针,该函数的意思是如果该进程接受到了信号signum,那么就执行函数指针handler对应函数。
直接试试:
#include <iostream>
#include <unistd.h>
void Handler(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
}
int main()
{
while (true)
{
signal(2,Handler);
std::cout << "Hello signal" << " pid is :" << getpid() << std::endl;
sleep(1);
}
return 0;
}
我们每次往这个进程发送2号信号,就会调用函数Handler,就不会终止该进程了,并且,在进程章节我们介绍了信号实际上是个宏,所以我们可以把2写成宏也是可以的。
那么我们现在来看一个有趣的现象:
我们直接ctrl + c,会奇妙的发现进程并没有终止,而是调用的函数,这说明什么,这说明ctrl + c就是2号信号!!!
所以,现在我们就知道了信号不仅可以通过kill指令发出,也可以通过键盘发出。
这里的3号同理,可以使用CTRL + \验证出来,也是一种终止进程的方式。
现在我们不妨浅显的理解信号的理解和保存:
对于Linux中的任意文件,都是先描述再组织,每个进程也就是task_struct,里面有一个成员变量是uint32_t signals,可是一个成员变量如何表示所有信号呢?
不要忘了,普通信号有31个,一个32位的整型,一共有32位比特位,因为没有0号信号,所以从第1位比特到到第31位比特位都是用来表示信号的,如果接受到了信号,那么对应的比特位就变成1,这也是位图的应用。
那么提问,进程是内核数据结构对象,谁有资格修改内核数据结构对象中的值呢?
当然只有OS了。
信号产生
以上是信号的预备知识,现在,我们来深究信号产生的原理,
信号可以怎么样产生呢?
第一种方式是命令行参数,是用kill -signum pid即可,第二种方式是键盘输出输入,第三种方式是系统调用。我们目前使用到的函数的是signal,我们还可以使用的函数有kill,还可以使用abort。
我们先来试试kill指令,
参数是对应的pid,另一个是signum,使用起来基本上没有什么难度,但是如果我们在代码里面操作,显得就比较笨拙了,所以我们可以使用命令行参数,所以使用int argc, char* argv[]:
int main(int argc, char* argv[])
{
if(argc != 3)
{
return 1;
}
pid_t pid = std::stoi(argv[2]);
int signum = std::stoi(argv[1]);
kill(pid,signum);
return 0;
}
这是kill的用法,需要多个文件协作。
对于函数abort,底层调用的是函数raise函数。
对于该函数的描述是,abort函数发送的是SIGABRT信号,也就是碰到异常事件直接终止该进程。
void handler(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
}
int main()
{
int cnt = 0;
// signal(SIGABRT, handler);
for(int i = 1; i <= 31; i++)
signal(i, handler);
while (true)
{
sleep(1);
std::cout << "hello bit, pid: " << getpid() << std::endl;
abort();
}
}
通过函数我们可以发生发送的信号是6。
可是,如果我们将所有的信号都自定义了,是不是这个进程就变成流氓进程了?
void Handler(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
}
int main()
{
for(int i = 1; i <= 31; i++)
signal(i,Handler);
while (true)
{
std::cout << "Hello signal" << " pid is :" << getpid() << std::endl;
sleep(1);
}
return 0;
}
试试9号:
9号信号就是不能被自定义的,所以得出结论,不是所有的信号都可以自定义。6号信号 SIGABRT 可以被自定义捕捉处理,但是捕捉后仍然会立即退出进程,比较特殊
现在我们从新的角度来看待信号,信号发送的软件条件是什么呢?如果没有输入输出的话,信号还能够输入输出吗?当然是不可以的,所以我们现在要做一个事儿就是,验证IO的速度,验证IO之前,我们要介绍一个信号是14号信号,14号信号是闹钟信号,和我们平常理解的闹钟是一个样子的:
当时间一到,alarm函数就发送SIGALRM信号,该信号是第14信号,和我们平时理解的闹钟一样的,不过,碰到了该函数,就进程就结束了:
int main()
{
std::cout << "begin " << std::endl;
alarm(1);
sleep(2);
std::cout << "end " << std::endl;
return 0;
}
验证IO之前,我们先使用alarm验证多次使用alarm会怎么样:
int main()
{
signal(SIGALRM, handler);
alarm(6); // 设定1S后的闹钟 -- 1S --- SIGALRM
sleep(4);
int n = alarm(0); // alarm(0): 取消闹钟, 上一个闹钟的剩余时间
std::cout << "n : " << n << std::endl;
return 0;
}
对于这种情况,alarm(0)代表的情况是取消闹钟,返回的值是上一个闹钟的剩余时间。
那么我们试试1秒的闹钟里面,定义一个变量,能++多少次:
int main()
{
alarm(1); // 设定1S后的闹钟 -- 1S --- SIGALRM
int cnt = 0;
while (true)
{
std::cout << "cnt: " << cnt << std::endl;
cnt++;
}
return 0;
}
一秒钟内,大部分区间都是在60000到80000左右,看起来是不是非常快了?
当我们将cnt变量定义为全局变量之后:
int cnt = 0;
void handler(int sig)
{
std::cout << "cnt: " << cnt << " get a sig: " << sig << std::endl;
}
int main()
{
signal(SIGALRM, handler);
alarm(1); // 设定1S后的闹钟 -- 1S --- SIGALRM
while (true)
{
cnt++;
}
return 0;
}
现象是:
这差别可以说是天差地别了。结论就是,加入了IO,比如cout等,效率就非常低了,并且闹钟会响一次,进程终止。
那么提问,OS里面的闹钟是非常非常多的,那么OS怎么管理闹钟呢?同样,是先描述再组织,但是闹钟不像共享内存那样,拥有所谓的id或者是key什么的,它要做的不过的到时间了就给进程发信号而已,虽然会先描述再组织,但是相对没有那么麻烦。
以上是软件引发的信号。
那么,对于异常部分?
我们从两个问题探讨,一个是/0问题,一个是越界访问的问题:
int main()
{
int a = 10;
a /= 0;
// int* p = nullptr;
// *p = 10;
return 0;
}
对于/0问题,bash进程给的报错是:
Floating point exception,那么我们在signal那个表里面查看有没有对应的描述:
就是这个,SIGFPE,对应的就是OS发给该进程的信号。
那么为什么程序会崩溃呢?本质就是因为OS给该进程发送了对应的信号,那么我们看看越界访问:
同理,在signal表里面查看:
对应的信号是SIGSEGV信号,对应的描述是Invalid memort reference。也就是非法的内存访问。
我们知道进程结束的原因是因为OS发送了信号,那么OS发送了信号之后,进程是直接终止的,那么可以不退出进程吗?
就像这样:
void Handler(int signum)
{
std::cout << "get a sig: " << signum << std::endl;
}
int main()
{
signal(SIGSEGV,Handler);
int* p = nullptr;
*p = 10;
return 0;
}
结果是:
一直打印信号,也就是说没有退出,那么为什么我们自定义了这个信号就会造成这种情况呢?
/0和越界的原理是一样的。
对于/0来说,cpu是执行计算的吧?执行的计算可以分为是执行算数运算还是逻辑运算,对于算数运算来说,在cpu里面存在一个状态寄存器,叫做eflag,在这个寄存器里面存在一个位置叫做状态标记位,如果发生了溢出,比如/0错误,该标志位变成1,此时OS检测到了,就给进程发送信号SIGFPE即可。
可是,为什么会一直打印呢?在进程部分,我们介绍了cpu有一套寄存器,而进程的运行时间不是一直存在的,涉及到了调度问题,而对于进程来说,因为时间问题,寄存器会存储多个进程的内容,也就是,/0的内容给了寄存器之后,轮询到这个进程的时候还是这个数据,所以会导致一直打印的情况,因为本来,OS发送的信号是要直接终止的,结果我们自己自定义为了打印,所以打印进程的资源一直释放不出去,从而导致了一直打印的情况。
对于越界的问题同理,涉及到的寄存器是cr寄存器,cr2 cr3,还有MMU寄存器,对于MMU寄存器来说是将虚拟地址转换为物理地址的,而在访问失败后,CR2这个寄存器放的就是错误的数据,因为CR2是页故障线性地址寄存器,和/0一样,存放的错误数据一直没有释放,所以一直轮询,从而导致了一直打印的情况。
以上是异常的现象解释。
打一个小小的回旋镖吧,在进程部分:
core dump是什么呢?
留个疑问吧,现在能知道的就是通过core dump可以得到一个文件是core,我们通过这个文件,使用gdb可以直接定位到出错的地方。
和云服务器有关,使用到的命令是ulimit -c 10240等,后面咱们再会咯~
感谢阅读!