欢迎来到博主的专栏:从0开始linux
博主ID:代码小豪
文章目录
信号
在我们的日常生活中存在各种各样的信号,比如:红绿灯,上课铃声,闹铃等。当人捕捉到这些信号时,会执行各种各样的操作。比如红灯停,绿灯行,上课铃声响了就要去上课。
这里我们得出有关信号的第一个结论:信号传递到进程时,进程会执行对应的操作。
那么第一个问题就来了,人们是对这些信号天生就有反应吗?答案肯定是否,因为在我们成长的过程中,别人一直在说红灯停、绿灯行,人们才会对红绿灯这个信号做出反应。因此对于人来说,关于信号的反应,是要提前约定好的,正是人对信号有了相关的概念,才能做出正确的操作
这里得出第二个结论:系统要在信号发生之前,与进程约定好操作
不同的情况下,对于信号的执行也是不同的,比如正常车辆,在道路交通时,要遵守红灯停、绿灯行。但是警车和救护车在工作时,就不用根据红绿灯的信号来执行操作。因为特殊身份的人,面对信号可以做出特殊处理
这里得出第三个结论:并非所有的进程,都要对信号做出相同的反应。
那么根据上面所讲述的结论,我们展开讲解进程信号。
信号发生
在ubuntu发行版7.1中,linux系统设计的信号有62个。通过kill -l
指令可以看到所有的信号,以及对应的信号值。
其中,信号值(1)~(31)的信号,称为普通信号,(34)~(64)的信号,我们称为实时信号。由于实时信号的使用场景博主还尚未讲解,因此本篇的着重点在信号值(1)~(31)的信号。
接下来我们要讲解的第一点就是,信号是如何产生的?以我们的现实为例,红绿灯信号发生需要让红绿灯通电,因此。我们的进程信号发生,一定要满足某种条件,才会导致信号发生。
键盘发生的信号
如果我们现在有一个常驻进程正在使用,我们可以通过ctrl+c的组合键,将该进程终止,这是博主在很早期讲解linux系统时就提到过的操作,实际上这个说法只提到了表面,我们对于ctrl+c的原理,还是不够了解。
ctrl+c,会向前台进程发送一个SIGINT的信号,其信号值为(2)。
我们可以通过man 7 signal
的方式,查看到linux信号与其信号所执行的操作。
(在后面的文章中,除非必要,不然博主不再将查阅man的结果展示出来,避免篇幅过长。这里介绍是为了让读者可以自行查看)
ctrl+c的组合键会向前台进程发送SIGINT信号。而SIGINT信号的行为为Term,全称为Termination,即终止的意思,也就是说当进程接收到SIGINT信号时,会终止。注意ctrl+c只会对前台进程发生SIGINT信号,而后台进程则对此毫无反应。那么什么是前台进程,什么是后台进程呢?这里博主做一个简单的介绍。
每一个用户终端,有且只有一个前台进程,这个前台进程就是用户当前使用的进程。但是linux是一个支持多进程的操作系统,因此除了前台终端外,还有许多的进程,这些进程则是后台进程。前台进程负责与用户直接交互。比如我们使用windows系统时,打开了qq和微信聊天的窗口。如果点击qq的聊天窗口,那么qq就会变成前台进程,我们键盘输入的信息,都会出现在qq的聊天窗口,而不是微信的聊天窗口,因此若想使用微信的聊天窗口,就要点击微信,使微信成为新的前台窗口。
通常来说,每个用户在登录终端后,都是将bash进程作为前台进程。因为我们需要通过bash进程进行命令行输入。我们通过
./[process]
运行的进程,会替代bash成为前台进程。如果想要让进程作为后台进程来执行,则需要输入命令./[process] &
现在我们打开一个进程,使其分别以前台进程或后台进程的方式运行,在运行的过程中我们尝试按下ctrl+c。可以发现前台进程会被终止,而后台进程不会被终止。这个很简单,博主就不展示了。
这里可能有人就会发现一个问题了,那就是根据我们前面所说的,bash也是前台进程,但是我们在bash中按下ctrl+c,bash进程却不会退出。
这是因为bash进程,对接收到SIGINT信号产生的行为,重新自定义了。因为bash是一个特殊的前台进程,其是用户与内核进行交互的重要一环,如果因为用户误触组合键而导致bash进程终止,那么肯定不是一个好的设计。
那么如何让进程对信号产生的行为进行重定义呢?这里就要用上系统调用了。
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
系统调用signal,可以将signum信号值对应的信号,产生的进程行为发生改变,这个行为需要用户自定义,即创建一个函数,然后通过handler将该函数以函数指针的方式传递给signal函数。这样进程在接收到signum信号值时,产生的行为就不再是原来的默认行为,而是由用户定义的handler行为,而handler行为的函数类型为void (int)。
我们就用这个系统调用来试试改变一个进程,对于SIGINT信号产生的行为从终止变为打印。
#include<iostream>
#include<signal.h>
#include<unistd.h>
void SigintHandle(int signum)
{
std::cout<<"signum:"<<signum<<std::endl;//signum可以接收到信号值
}
int main()
{
::signal(SIGINT,SigintHandle);//将SIGINT的行为重定义
//当进程接收到SIGINT信号值时,就会调用siginthandle函数
while(1)
{
std::cout<<"hello world\n"<<std::endl;
sleep(1);
}
return 0;
}
现在我们已经将该进程接收到SIGINT的行为从终止,变为了打印signum的值。因此在该进程收到SIGINT信号时(即ctrl+c),会将SIGINT的信号值打印出来,即2。
但是并非所有信号值的行为都能重定义,如果可以这样做的话,如果一个进程中将对所有信号值的行为从停止改为其他行为,那这个进程就永远无法关闭了,除了将计算机重启。那么设计师显然是考虑到了这一点的,因此有一些信号值即使重定义了,行为依然不会改变,比如(9)SIGKILL
信号是如何发送给进程的?
在上一个例子中,即使进程在执行I/O操作,但是一旦信号发生,进程会立即执行信号对应的行为。这与我们对于进程的理解大相径庭,因为在之前如果进程在运行时,无论我们如何操作,进程是只会对代码顺序执行的,也就是说执行到什么代码,就会产生代码所具有的操作,但是在上例中,我们一旦发送了SIGINT信号,进程立马就执行了SIGINT对应的行为。那么为什么进程会对信号这么特殊呢?
这是因为,信号并不是发送给进程的,而是发送给操作系统的。在前面的章节中博主提到过,OS是通过PCB(task_struct)来管理进程的,在task_struct中,会存在一个位图,该位图的名字为signalbits。
发送信号的本质是,给OS发送一个信息,即32位的位图,可以表现出32个普通信号的发生情况,比如当SIGINT(2)发生时,将signalbits的第二位变为1,这样就能表示SIGINT信号发生在了该进程中。
那么现在OS知道该进程的信号SIGINT发生了,就要对该进程执行对应的操作,而这些对应的操作也会保存在进程的PCB中。在PCB中存在一个专门保存信号发生时行为的数组,该数组保存的是函数方法,比如当SIGINT被置入时,就执行arr[SIGINT]的方法。这样不就相当于进程一收到信号,就执行信号对应的行为了吗?
在默认行为下,该进程执行SIGINT的行为是终止,而通过signal重定义行为后,就变成了执行SigintHandle。因此signal函数的作用是:将pcb中关于信号方法数组的对应行为,换成用户自定义的函数。
从这里我们也可以得出结论,任何进程信号,不是发送给进程的,而是发送给操作系统的。然后操作系统接收信号后,然进程执行信号对应的行为。
信号是如何发送给系统的?
通过前面的讲解,我们知道了信号并不是发送给进程的,因此向进程发送信号是一个伪命题。信号是发送给操作系统的,更具体的说,是发送给操作系统,让操作系统根据信号来管理进程的,和进程无关。那么此时一个新的问题又产生了。信号又是如何发送给操作系统的呢?
实际上信号发生的情况有很多,我们目前只讲了一个键盘发生,但是没关系,后面我们还会继续补充,因此我们还是拿键盘发送信号为例。
那么一个键盘作为一个硬件,而操作系统是一个软件,一个硬件是如何向软件发送信号呢?这个问题的答案,我们得从硬件层面进行解答。
这是每个计算机都遵循的结构,即冯诺依曼体系结构。我们的键盘,显示器,网卡一类的硬件设备,称为外部设备。而控制器和运算器,在现在都集成为了CPU。虽然CPU不会与外设进行数据交换,但是外设的信号(注意是硬件信号,即电路产生的电信号),是可以传递给CPU的。当外设有数据要发送时,比如我们在键盘输入了数据,又比如我们的网卡接受了数据。此时外设会向CPU发送中断信号。提醒cpu,我们外设已经准备好数据了,快来读我们的数据吧。然后cpu就会让操作系统去从设备当中读取数据。
那么这里就可以解释了,为什么我们按下ctrl+c,操作系统就能收到SIGINT信号。这是因为键盘向CPU发送了中断信号,接着操作系统就会读取键盘的数据,自然而然的读取到了ctrl+c。然后向前台进程的pcb写入SIGINT信号,再让前台进程执行SIGINT信号对应的操作。这样就完成一次信号的发生,接收,与执行了。
其他的信号发生
由系统指令发生的信号
我们可以通过kill -[signum] [pid]
指令,向pid对应的进程,发送[signum]对应的信号。比如kill -9 11111
。即向pid为11111的进程,发送9信号。9信号对应的是SIGKILL。实际上是让操作系统向pid11111的进程PCB的signal位图的第9位置为1,然后再让pid11111的进程执行SIGKILL对应的行为,由于SIGKILL不支持重定义行为。因此pid为11111进程就会被终止。
由系统调用发生的信号
系统调用kill可以向pid进程发送sig信号,其函数原型如下:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
当进程调用kill函数时,会向pid进程,发送sig信号,其实这与kill指令很像对不对,实际上kill指令本身就是进程,而进程又有代码,因此,kill指令当中的代码使用的就是系统调用kill!!!!。我们可以尝试写一个仿kill指令的程序。
void Useage()
{
std::cout<<"Useage: ./mykill [signum] [pid]"<<std::endl;
}
//kill 9 pid
int main(int argc,char* argv[])
{
if(argc!=3)
{
Useage();
exit(1);
}
int signal=std::stoi(argv[1]);
pid_t pid=std::stoi(argv[2]);
::kill(pid,signal);
return 0;
}
我们随便写一个死循环打印“hello world”的进程,然后启动该进程,接着打开另外一个终端,使用mykill进程关闭该进程。
由软件条件引发的信号
这个条件我们可以视为类似于if语句的东西。以管道读写端异常引发信号SIGPIPE。就属于这种类型的信号。
在管道章节中,博主就提到过,当管道读端关闭,而写端依旧向管道写入数据时,就会产生SIGPIPE信号,导致写端的进程被终止。关于SIGPIPE的引发条件,在手册当中也能查看到。
相当于是触发了条件(进程向没有读端的管道写入),就引发了SIGPIPE信号。
除了SIGPIPE以外,还有一个SIGALRM也属于是软件条件引发的信号。其发送信号需要使用系统调用alarm。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
alarm需要传入参数seconds。在调用alarm(seconds)后,经过seconds秒,就会向进程发送信号SIGALRM。SIGALRM的默认行为是终止进程。
我们可以写一个简单的代码来看看alarm函数的作用。
int main()
{
unsigned int second=5;
alarm(second);//进程运行5s后,发送SIGALRM信号
while(true)
{
std::cout<<"last time:"<<second<<'s'<<std::endl;
second--;
::sleep(1);
}
return 0;
}
运行结果:
如果我们使用signal函数,将SIGALRM信号的行为重定义,那么就可以诞生了一个计时程序,比如我们可以写一个程序,每个三秒就刷新数据(这个行为我们通过打印来表示),无论程序运行到什么步骤,都会每个3s执行我们想要进程执行的行为。那么一个定时执行的程序就诞生了。
int cnt=0;
void Handle(int signum)
{
cnt++;
std::cout<<"更新数据:"<<cnt<<std::endl;//表示该行为执行了多少次
alarm(3);
}
int main()
{
signal(SIGALRM,Handle);
unsigned int second=3;
alarm(second);//进程运行5s后,发送SIGALRM信号
while(true)
{
std::cout<<"hello world"<<std::endl;
::sleep(1);
}
return 0;
}
由于进程异常引发的信号
在c/c++编程的过程中,有几种错误引发的程序崩溃相信各位c/c++程序员都屡见不鲜了吧。首先是野指针问题,如果我们错误的使用了野指针,就会引发程序崩溃,但是实际上这个崩溃是因为当进程出现野指针错误时,进程会发送SIGSEGV信号,该信号的默认行为就将该进程终止,并且生成core文件。我们在系统手册当中可以查到。
这里我们发现,SIGSEMV信号的默认行为是Core,而SIGINT的默认行为是Term。但是从我们的视角来看,core和term的行为都是终止进程,很难发现它两的区别。这点我们后面再说。
还有一种是由于除0操作引发的进程错误,即1/0的算术操作,发生了错误,当进程发生除0错误时,操作系统会向进程发送SIGFPE信号。该信号的默认行为也是Core
Core与Term默认行为的差别
那么Core和Term的行为的差别是什么?Term是termination的缩写,即终止进程。而Core除了会终止进程外,还会产生core文件。
那么有人可能就会产生疑问了,引用野指针也不是没做过,但是我从来没见过core文件啊?这是因为,如果使用云服务器来作为linux环境的话。会默认关闭生成core文件这个操作。使用ulimit -a
指令,可以看到core文件的相关信息。
那么如果我们允许生成最大core文件的大小改为10240个块(一个块4kb)。那么就能看到core文件了。指令为ulimit -c 10240
。(不一定要10240,1024也行)
core文件的作用,叫做核心转储,因为当程序由于野指针发生错误时,如果程序是大型程序的话,那么排查工作其实是很困难的,如果我们手动的使用gdb来排查野指针,无疑是在大海捞针,而core文件是在进程发生与Core行为相关的信号时,生成的文件,这个文件会记录发生错误的程序段在哪,以及相关的内存信息。但是要注意,只有debug版本的进程,core文件的信息才有意义,因此我们首先随便写一个野指针的程序。并且将其的编译模式加上-g选项,-g选项表示该编译生成的程序将会是debug版本的。
int main()
{
int * ptr=nullptr;
*ptr=100;
return 0;
}
接着执行该程序,可以发现屏幕上会打印出错误信息,并且生成一个core文件。
那么这个core文件该怎么用呢?我们打开gdb,调试出现core错误的程序test。接着输入core [corefilename]。就能查看到core文件中包含的信息了。