文章目录
Linux进程信号
信号入门
生活角度的信号
第一个例子:取快递
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
第二个例子:过马路
- 当我们在过马路时遇到了红绿灯,如果碰到了红灯我们就会停下来等待,如果当前是绿灯我们就可以走过去了。
- 这里的红绿灯也是一种信号,我们并不知道我们什么时候会碰到红绿灯,但是我们知道碰到红灯或者碰到绿灯应该怎么做,这是因为小的时候我们的老师或者父母告诉过我们,碰到红灯了要停下来等待,直到变成了绿灯我们才可以走过去。
技术应用角度的信号
在我们前面学习进程控制的时候,如果我们在Shell下启动了一个前台进程,我们可以通过在键盘上使用Ctrl+c
组合键终止该进程。我想当时大家心里面可能有一个疑问:为什么使用这个组合键就可以终止我们的进程呢?
这是因为用键盘使用Ctrl+c组合键之后会产生一个硬件中断,被OS获取,解释成信号,发送2号信号给前台进程,前台进程收到信号后便终止了。
下面我们来一下这个现象:
#include<stdio.h>
#include<unistd.h>
int main()
{
while(1)
{
printf("hello world\n");
sleep(1);
}
return 0;
}
运行结果:
可以看到我们这里的进程本来是在正常运行的,但是当我们使用Ctrl+c
组合键之后,该进程就被终止了。
我这里又有一个问题:你说使用Ctrl+c
组合键之后,会终止掉前台进程这里我看到了,但是你说使用Ctrl+c
组合键会产生一个硬件中断,被OS获取,解释成信号,然后发送2号信号给前台进程,从而导致前台进程被终止。那你怎么证明这里的前台进程收到了2号信号才被终止的呢?
接下来就来为大家介绍一个函数——signal函数
//功能:对一个信号进行捕捉,执行自定义处理方式
//函数原型:
#include<signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum,sighandler_t handler);
//参数:
//signum:表示需要捕捉的信号编号
//handler:表示对捕捉信号的处理方法,该方法的参数是int,返回值是void
下面我们就来使用一下signal函数,看一下我们使用组合键之后进程是否收到了二号信号
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void handler(int sig)
{
printf("catch a signal;%d\n",sig);
}
int main()
{
//注册一个对特定信号的处理动作
signal(2,handler);
while(1)
{
printf("hello world\n");
sleep(1);
}
return 0;
}
运行结果:
我们可以看到,这一次当我们启动一个前台进程之后再使用Ctrl+c
组合键并没有将该进程终止,反而使用组合键的时候,我们通过signal函数捕捉到了2号信号(改变了信号的默认处理方式)。如此一来就证明了上面说的:使用Ctrl+c
组合键其实是OS给前台进程发送了一个2号信号,从而导致了该进程终止。
注意:
- Ctrl-C产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。我们可以通过
bg
指令,将一个前台进程放到后台,我们还可以通过fg
指令,将一个后台进程放到前台。 - Shell可以同时运行一个前台进程和任意多个后台进程.在一个bash当中只允许一个前台进程,前台进程:后台进程 = 1:n。只有前台进程才能接到像
Ctrl+C
这种控制键产生的信号。 - 前台进程在运行过程中用户随时可能按下Ctrl-C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步的。
通过生活角度和技术应用角度了解了信号之后,我们就可以将生活例子与信号处理的过程相结合,来解释一下信号处理的过程:
进程就相当于是你,然后操作系统就是快递员,信号就是快递,操作系统给进程发信号就好比快递员给你送快递,而我们收快递的方式也就相当于进程处理信号的方式。
总结: 信号是进程之间时间异步通知的一种方式。
查看信号
我们可以通过kill-l指令来查看系统定义的信号列表
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到。
这里一共有62个信号,其中1-31号属于普通信号,34-64属于实时信号,博主这篇文章只讲普通信号。
如果想了解某个普通信号的产生条件以及默认处理动作是上面,我们可以通过man 7 signal
指令查看。
[root@izuf65cq8kmghsipojlfvpz Code]# man 7 signal
信号的常见处理方式
一般而言,进程收到信号的处理方案有以下三种:
- 忽略此信号——是一种信号处理的方式,只不过处理动作就是什么也不干。
- 执行该信号的默认处理动作——大部分是终止自己,暂停等。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
产生信号
通过终端按键(键盘)产生信号
在上面我们说过,我们可以通过在键盘上按Ctrl+c可以终止一个进程,这里终止进程的本质是按Ctrl+c产生了一个硬件中断,被OS获取,解释成信号,然后发送2号信号给前台进程,从而导致前台进程被终止。除了Ctrl+c之外,我们还可以按Ctrl+\或者Ctrl+z来终止一个前台进程。
#include<stdio.h>
#include<unistd.h>
int main()
{
while(1)
{
printf("hello world\n");
sleep(1);
}
return 0;
}
运行结果:
我们可以看到通过按Ctrl+\或者Ctrl+z我们终止的一个前台进程。那我这里有一个问题:通过终端按键可以产生一个信号,操作系统给该前台进程发信号从而导致前台进程被终止,那这里的Ctrl+\与Ctrl+z的按键分别会产生几号信号呢?
Ctrl+\与Ctrl+z分别会产生3和20信号,我们可以通过signal函数来捕捉然后查看一下结果:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void handler(int sig)
{
printf("catch a signal;%d\n",sig);
}
int main()
{
//注册一个对特定信号的处理动作
signal(3,handler);
signal(20,handler);
while(1)
{
printf("hello world\n");
sleep(1);
}
return 0;
}
运行结果:
这个时候我又有了一个问题:既然Linux中有这么多的信号,然后我们又可以用signal函数捕捉信号,那是不是所有的普通信号都能被捕捉呢?
并不是所有的普通信号都能被捕捉,其中9号信号就不能够被捕捉!!!
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void handler(int sig)
{
printf("catch a signal;%d\n",sig);
}
int main()
{
//注册一个对特定信号的处理动作
signal(9,handler);
while(1)
{
printf("hello world\n");
sleep(1);
}
return 0;
}
运行结果:
可以看到我们即使通过signal函数捕捉了9号信号,但是我们当向一个进程发9号信号时,该进程并不会打印捕捉到9号信号,而是执行收到9号信号的默认处理动作,终止该进程。
这个时候你可能会问了: 那为什么9号进程不能被捕捉呢?
注: 这是因为在Linux下有些信号是不能够被捕捉的,比如我们这里的9号信号。大家想一想如果所有的信号都能被捕捉,那么这也就说明了一个进程就可以将所有的信号给捕捉起来然后自定义这些信号的处理方式。那如果真的是这样的话,此时如果有一些不怀好意的人在我们的电脑中植入一个病毒,病毒只需要将所有的信号捕捉然后让其处理方式变成忽略,然后该进程无法被杀死,那我们的电脑可以说是任人宰割,此时即便是操作系统也无能为力。因此在Linux中并不是所有的信号是都能够被捕捉的。
通过系统调用向进程发信号
除了上面通过终端按键来产生信号之和,我们还可以通过系统调用接口来向一个进程发送信号。下面我们就来介绍一下这些系统调用:
在之前我们想要杀掉一个进程一般都是通过使用kill命令向一个进程发送信号从而杀掉该进程。
比如说首先在前台执行死循环程序,然后使用kiil命令给它发送3号信号
但是大家知道为什么我们使用kill命令向某个进程发送一个信号就能终止它嘛?
接下来我来为大家介绍一个函数:kill函数
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。
kill函数的函数原型如下:
#include<signal.h>
int kill(pid_t pid,int signo);
//返回值:成功返回0,失败返回-1
kill函数用于向进程ID为pid的进程发送一个signo信号,如果信号发送成功,则返回0,失败则返回-1.
下面我们就来使用一下kill函数吧
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
int main()
{
int count = 0;
while(1)
{
sleep(1);
printf("I am process:%d\n",getpid());
if(count++==3)
{
printf("kill myself\n");
kill(getpid(),9);
}
}
return 0;
}
运行结果:
现在听你这么一说我大概知道kill函数的用法了,那你上面说kill命令是调用kill函数实现的,那你能不能用kill函数来模拟一下kill命令呢?
没问题,下面我们就用kill函数来模拟一下kill命令。
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<stdlib.h>
void Usage(char* proc)
{
printf("Usage: %s signo pid\n",proc);
}
int main(int argc,char* argv[])
{
if(argc!=3)
{
Usage(argv[0]);
return 1;
}
int signo = atoi(argv[1]);
pid_t pid = atoi(argv[2]);
kill(pid,signo);
return 0;
}
运行结果:
可以看到如此一来我们便用kill函数模拟了kill命令。
除了可以使用kill函数向进程发信号外,我们还可以使用另外两个系统调用向进程发信号:rasie函数与abort函数,下面我就来为大家介绍一下这两个函数。
raise函数可以给当前进程发送指定的信号,即自己给自己发送信号。
raise函数的函数原型如下:
#include<signal.h>
int raise(int signo);
//返回值:成功返回0,失败返回-1
下面我们来使用一下raise函数,每隔一秒给自己发送一个3号信号,同时我们捕捉3号信号。
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
void handler(int sig)
{
printf("catch a signal;%d\n",sig);
}
int main()
{
//注册一个对特定信号的处理动作
signal(3,handler);
while(1)
{
printf("hello world\n");
raise(3);
sleep(1);
}
return 0;
}
运行结果:
可以看到当前进程每隔1秒就会收到一个3号信号。
下面我们再来介绍一下abort函数
abort函数使当前进程接收到信号而异常终止,本质就是给当前进程发送6号信号(SIGABRT),从而使得当前进程异常终止。
abort函数的函数原型如下:
#include<stdlib.h>
void abort(void);
//无返回值,就像exit函数一样,abort函数总是会成功的,所以没有返回值。
下面我们来使用一下abort函数,每隔一秒给自己发送一个6号信号,同时我们捕捉6号信号。
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
void handler(int sig)
{
printf("catch a signal;%d\n",sig);
}
int main()
{
//注册一个对特定信号的处理动作
signal(6,handler);
while(1)
{
printf("hello world\n");
abort();
sleep(1);
}
return 0;
}
运行结果:
我们发现这次的运行结果和raise函数的运行结果不一样,这里我们捕捉了6号信号,但是它并没有像上面raise函数那样每隔一秒打印一次捕捉到了信号,而是该进程直接就被终止了,这是为什么呢?
注意: abort函数的作用是使给当前进程发送SIGABRT信号从而使得当前进程异常终止,exit函数是正常终止一个进程。就像exit函数一样,使用abort函数终止进程总是会成功的,因此即使捕捉了SIGABRT信号,但还是会终止该进程。
由软件条件产生信号
接下来我们再来介绍一下由软件条件产生信号。不知道大家是否还记得进程间通信匿名管道那里:当我们的读端不读并且关闭读端文件描述符,写端会受到操作系统发来的信号从而终止写端进程。
下面我们再来写一下这段代码回顾一下吧
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int fd[2];
int ret = pipe(fd);
pid_t id = fork();
if(ret<0)
{
perror("pipe");
return 1;
}
if(id==0)
{
//child
//子进程关闭读端
close(fd[0]);
const char* msg = "I am a child\n";
while(1)
{
write(fd[1],msg,strlen(msg));
sleep(1);
}
}
else
{
//parent
//父进程关闭写端
close(fd[1]);
char buffer[64];
int count = 0;
while(1)
{
ssize_t s = read(fd[0],buffer,sizeof(buffer));
if(s>0)
{
buffer[s] = 0;
printf("father get a message: %s",buffer);
sleep(1);
}
if(count++==5)
{
close(fd[0]);
break;
}
}
int status = 0;
waitpid(id,&status,0);
printf("child get a signal:%d\n",status&0x7f);
}
return 0;
}
运行结果:
可以看到我们的程序运行后,我们的子进程在退出时收到的是13号信号,即SIGPIPE信号。
除了上面这种情况是由软件条件的产生信号之外,我们还有一种情况也是由软件条件产生的信号。
下面我们来介绍一下 alarm函数
调用alarm函数可以设定一个闹钟,也即是告诉内核在seconds秒之和给当前进程发送SIGALRM信号,该信号的默认处理动作时终止当前进程。
alarm函数的函数原型如下:
#include<unistd.h>
unsigned int alarm(unsigned int seconds);
//返回值:
//1.如果在调用alarm函数之前,进程没有设置脑子,则返回值为0.
//2.如果在调用alarm函数之前,进程已经设置过闹钟了,那么函数的返回值就是以前设定的闹钟时间还剩余的秒数.
下面我们使用一下alarm函数来计数,我们一秒内不断的让一个变量进行自增然后打印它,看看一秒后可以将count变量打印多少次
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<stdlib.h>
int main()
{
int count = 0;
alarm(1);
while(1)
{
count++;
printf("count:%d\n",count);
}
return 0;
}
运行结果:
我们可以看到在一秒内我们不断的让一个变量进行自增然后打印它,我们可以让它从0自增到10w。
下面我们再来看一段代码,同样使用alarm进行计数,只不过这一次我们不是边自增边打印,而是1秒过后再来看它的打印结果是多少,同时捕捉SIGALRM信号。
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<stdlib.h>
int count = 0;
void handler(int signo)
{
printf("catch a signal:%d\n",signo);
printf("%d\n",count);
exit(1);
}
int main()
{
alarm(1);
while(1)
{
count++;
}
return 0;
}
运行结果:
我们可以看到这一次该变量居然从0自增到了5亿多接近6亿,而第一次我们只是从0自增到了10w,两者之间居然相差了有整整5w倍,这究竟是为什么呢?
这是因为,我们第一次的代码是在这1秒内变量是边自增边打印的,而第二次代码是在这1秒内变量只自增,等1秒过后我们再打印变量的值。我们知道将结果打印到显示器上面其实这是在进行IO操作,而我们也知道计算机与外设进行IO操作时速度是很慢的。因此我们就可以知道 IO操作对于程序的运行效率的影响是非常大的。
硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
下面我主要为大家介绍两种硬件异常:
- CPU产生异常
- MMU产生异常
下面先来为大家介绍一下第一种硬件异常:CPU产生异常
我们都知道在冯诺依曼体系结构中,CPU是唯一具有运算能力的设备,在CPU中有着许多的寄存器。当我们想对两个变量进行算术运算时,我们首先需要将这两个变量从内存放到相应的寄存器中进行算术运算,当运算完成后会将运算结果写一个寄存器中,并将这两个变量放回内存。我们的CPU还有一组寄存器叫做状态寄存器,它可以用来记录当前指令执行结果的状态信息,比如有无进位、有无溢出、结果正负等。
操作系统是软硬件的管理者,在程序运行的过程中,当进程执行除以0的指令,即浮点数运算出现错误时,在CPU当中就会出异常并且会有一个溢出标志位它就会被设置。因为OS是软硬件的管理者,那么它就都得对软硬件的健康进行负责,因此操作系统马上就会识别到是哪个进程导致的错误,并将识别到的硬件错误信息解释成信号发送给进程,从而终止该进程。
比如说当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为8号信号(SIGFPE信号)发送给进程。
下面我们通过代码来看一下吧
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<stdlib.h>
int main()
{
int a = 1;
a/=0;
return 0;
}
运行结果:
下面我们再来介绍一下第二种硬件异常:MMU产生异常
我们前面学过进程虚拟地址空间就知道,在语言层面上面看到的地址其实都是虚拟地址,我们如果想访问一个变量,我们需要先经过页表的映射,将该变量的虚拟地址转化成物理地址,然后才能进行相应的访问操作。
在页表进行虚拟地址的映射时,还需要一个硬件的帮忙——MMU,它可以将虚拟地址映射到物理地址以及物理地址访问权限的管理。当我们需要进行虚拟地址到物理地址的映射时,页表将左侧的虚拟地址导给MMU,MMU会根据这个虚拟地址计算出对应的物理地址,然后我们可以通过这个物理地址进行访问。
当我们要通过虚拟地址访问数据时,虚拟地址需要先经过页表转换成物理地址,但假如我们此时要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换就会出现错误,然后操作系统识别到错误就会找到对应的进程给它发信号终止该进程。
比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为11号信号(SIGSEGV信号)发送给进程。
下面我们通过代码来看一下
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<stdlib.h>
int main()
{
int* p;//未初始化的指针
*p = 100;
return 0;
}
运行结果:
总结
- 软件上面的错误,通常会体现在硬件或者其他软件上。操作系统是硬件的管理者,既然是硬件的管理者就得对他们的健康进行负责,当某个硬件出现错误时,操作系统马上就会识别到是哪个进程导致的错误,并将识别到的硬件错误信息解释成信号发送给进程,从而终止该进程。
- 在windows或者Linux下进程崩溃的本质是进程收到了对应的信号,然后进程执行信号的默认处理动作(终止进程)
- 信号的处理并不是立即处理的,而是在合适的时候。
CoreDump
在讲下面的内容之前我先来问大家一个问题:当进程崩溃的时候,你最想知道什么呢?
崩溃的原因?崩溃时收到了哪个信号?这些我们都可以通过waitpid()和status来获取,但是我想你们最想知道的肯定是你的程序是在哪一行崩溃的!!!
接下来就来为大家介绍一个东西: Core Dump
不知道大家是否还记得进程等待时的这一幅图:
当时我们就见过core dump,但是那个时候我们说暂时不用管它,今天我们就来讲一下它。
- 在Linux当中,当一个进程退出的时候,它的退出码和退出信号都会被设置(正常情况)
- 当一个进程异常的时候,进程的退出信号会被设置,表明当前进程退出的原因。如果必要,OS会设置退出信息中的core dump标志位,并将进程在内存中的数据转储到磁盘当中文件名通常是core,方便我们后期调试。
你说进程异常时,OS会设置退出信息中的core dump标志位,可是在上面执行进程异常的代码时我并没有看到core dump啊,这是为什么呢?
这是因为我们使用的是云服务器,云服务器是线上环境,这个功能是关闭的,并且core文件的大小为0我们可以通过 ulimit-a
指令来查看一下:
既然这个功能是被关闭的,那如何打开呢?我们可以通过ulimit -c size 命令来打开这个功能并且设置core 文件的大小
下面我们再来运行一下我们上面执行过的代码
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<stdlib.h>
int main()
{
int a = 1;
a/=0;
return 0;
}
运行结果:
可以看到此时进程异常时后面会显示core dump,并且在当前路径下会生成一个core文件,该文件后面的那串数字其实是我们刚刚core dump进程的PID。
大家这个时候可能会问了:那这个core dump有什么用呢?
当我们的程序在运行过程中崩溃了,我们一般会通过调试去逐步查找程序崩溃的原因。但是在某些特殊情况下我们就不能够去逐步查找,而是需要用到我们的core dump,比如说你是某个服务器的后台开发人员,此时你们启动服务器不到一个小时该服务器就挂掉了,再启动又隔了不到一个小时又挂掉,这个时候你不可能说去逐步查找错误,因为你的服务器是要给别人提供服务的,如果你逐步查找错误,每启动一次每隔半小时服务器挂一次,那么对于用户来说体验感是极差的,以后别人就不想再使用你们公司的服务器了。这个时候我们就需要使用core dump了,当第一次服务器挂了之后,我们可以对程序进程调试,然后使用core-file core文件
命令加载core文件,然后我们就可以快速的知道该程序是因为什么原因退出以及在哪一行崩溃的,从而解决该错误。
下面我们就来使用一下gdb对可执行程序进行调试,然后使用core-file文件命令加载core文件判断刚刚程序退出的原因以及它是在哪一行崩溃的
注: 这种调试方式称为事后调试
下面还要再来强调一下
当进程正常退出时,status的次低8位表示的是进程的退出状态,即退出码。如果进程是被信号所杀,那么status的次低7位表示的是终止该进程的信号,而第8位就是core dump,但并不是所有的信号都会core dump,但只要你的进程是因为信号而终止的,该信号的就会被设置,但是有没有core dump由satuts低8位中的第8位所决定
下面我们通过代码来验证一下程序崩溃时core dump标志位是被设置的事实
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
if(fork()==0)
{
//child
printf("I am child...\n");
int* p;
*p = 100;
exit(1);
}
//father
int status = 0;
waitpid(-1,&status,0);
printf("exitcode:%d,coredump:%d,signal:%d\n",(status>>8)&0xff,(status>>7)&1,status&0x7f);
return 0;
}
运行结果:
可以看到status低8位中的第8个比特位core dump被设置成了1,此时可以说明当前子进程被异常终止(程序崩溃)时是会形成core文件的。
因此现在我们就可以知道,core dump标志位实际上是用于表示进程被异常终止时是否会形成core dump。
阻塞信号
信号其他相关常见概念
信号的流程可以用下面的这张图来表示:
下面再来介绍一些概念:
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号的保存和发送
经过上面的学习我们知道信号的产生有四种方式分别是键盘产生、硬件异常产生、通过系统调用产生以及软件条件产生信号。信号产生的方式种类虽然非常多,但是无论产生信号的方式是什么,但是最终一定是通过OS想目标进程发送的信号。
那么此时我就有一个问题了:如何理解OS给进程发送信号呢?
不知道大家有没有发现,信号的编号是有规律的,普通信号是从1-31号的,大家对于这个可以联想到什么?
我想大家这时候可能会联想到数组下标,这种感觉很好。
在进程的task_struct中,它里面有进程的各种属性,而我们的普通信号是从1-31号的,它里面一定要有对应的数据变量,来保存记录是否收到对应的信号,那采用什么数据变量,来标识是否收到信号呢?
通过采用位图结构来标识该进程是否收到信号。
所谓的比特位的位置(第几个),代表的就是哪一个信号,比特位的内容(0,1)代表的就是当前进程是否收到了该信号。
现在我们就可以明白: OS给进程发送信号本质是OS向指定进程的task_struct中的信号位图中写入比特位设置为1,即完成了信号的发送。
信号在内核中的表示
信号在内核中的表示示意图如下:
注:
- block位图与pending位图的结构一样,但是比特位的内容代表的含义是不一样的。
- block位图:比特位的位置,代表信号的编号,比特位的内容,代表该信号是否被阻塞。block位图也叫做信号屏蔽字。
- pending位图:比特位的位置,代表信号的编号,比特位的内容,表示该进程是否收到过该信号。
- handler表:存储函数指针的数组,表示某个信号处理时的默认动作,SIG_DFL表示默认处理,SIG_IGN表示忽略该信号,其它表示自定义处理
下面来分析一下上面图中的三个信号:
- SIGHUP信号未被阻塞且该进程未收到过SIGHUP信号,如果后面收到SIGHUP信号,该信号递达时执行其默认处理动作
- 该进程收到了SIGINT信号但是SIGINT信号被阻塞,所有暂时不能被递达,当解除对SIGINT信号的阻塞之后,该信号递达时它的处理动作为忽略。
- 该进程没有收到过SIGQUIT信号,一旦收到了SIGQUIT信号,该信号将被阻塞,当解除对该信号的阻塞,该信号递达时它的处理动作为用户自定义动作。
sigset_t
每隔信号的未决和阻塞都是由一个比特位来表示的,非0即1.因此未决和阻塞标志可以使用同一个数据类型sigset_t来进行存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。
因此我们就可以通过函数来修改这个信号集然后填入block表与pending表中修改这两个表。
- 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞
- 在未决信号记中有效”和“无效”的含义是该信号是否处于未决状态。
信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的
下面我们来看一下信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
注意: 对于sigset类型的变量,我们不能够直接使用位运算来进行操作(因为不同的系统sigset_t的实现方式不同),因此我们必须使用上面的这些函数来进行操作。
下面来介绍一下这些函数:
- sigemptyset函数:初始化set指向的信号集,使其中所有信号的对应bit位置为0,表示该信号集不包含 任何有效信号。
- sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit位置成1,表示该信号集的有效信号包括系统支持的所有信号。
- sigaddset函数:在set所指向的信号集中,将signum信号对应的比特位由0置1
- sigdelset函数:在set所指向的信号集中,将signum信号对应的比特位由1置0
- sigismember函数:判断sigum信号是否在set所指向的信号集中(若在该信号集中返回1,不在则返回-1)
注意: 在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
下面我们来使用一下这些函数
#include<stdio.h>
#include<signal.h>
#include<stdlib.h>
#include<unistd.h>
int main()
{
sigset_t set;
sigemptyset(&set);
sigfillset(&set);
sigdelset(&set,2);
sigdelset(&set,3);
sigdelset(&set,5);
sigaddset(&set,2);
sigismember(&s,3);
return 0;
}
注意: 我们这里定义的sigset_t 类型的变量set,跟我们之前在定义的局部变量一样,它们都是在栈上。因此尽管后面我们使用了信号集操作函数对set进行操作,但这只是对用户空间的变量set做了修改,并不会影响到进程内部的block表与pending表。如果我们想将set的数据设置到block表与pending表中,我们还需要使用一些系统调用才行。
sigpromask
sigprocmask函数可以读取或更改进程的信号屏蔽字(阻塞信号集),该函数的原型如下:
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
注:
- 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
- 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
- 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
选项 | 含义 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字位set所指向的值,相当于mask=set |
注意: 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending
sigpending函数的作用是读取当前进程的pending信号集,该函数的原型如下:
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
下面我们通过上面所学的几个函数来做一个实验
- 调用上述函数对2号信号进行屏蔽,并捕捉2号信号,每隔一秒打印一次pending信号集
- 使用Ctrl+c组合键向进程发送2号信号
- 此时2号信号被阻塞,无法被递达,因此会处于未决状态。
- 过10秒后我们解除对2号信号的屏蔽,此时2号信号就会被递达,我们打印一下pending信号集看一下会发生什么变化
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
int count = 0;
void handler(int signo)
{
printf("catch a signal:%d\n",signo);
}
void Printpending(sigset_t pending)
{
for(int i = 1;i<=31;i++)
{
if(sigismember(pending,i))
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
}
int main()
{
//捕捉2号信号
signal(2,handler);
sigset_t set,oldset,pending;
sigemptyset(&set);
sigemptyset(&oldset);
sigemptyset(&pending);
//阻塞2号信号
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK,&set,&oldset);
while(1)
{
sigpending(&pending);
Printpending(&pending);
sleep(1);
if(count++==10)
{
sigprocmask(SIG_SETMASK,&oldset,NULL);
printf("恢复信号屏蔽字\n");
}
}
return 0;
}
运行结果:
我们可以看到pending表中的第二个数字由0变1,再由1变0。这是因为刚开始我们对2号信号进行了屏蔽,即使我们收到了2号信号它也不会递达,因此我们会看到pending表由0变1,当过了10秒之后,我们解除了对2号信号的阻塞之后,2号信号就会立即递达执行用户自定义动作,因此此时pending表又会由1变0。
捕捉信号
内核态与用户态
在前面我们提到过,当一个进程收到一个信号时,信号不是被立即处理的,而是在合适的时候
.这个时候可能就会有人问了:为什么要在合适的时候呢?那什么又是合适的时候呢?
因为信号的产生是异步的,当前进程有可能正在做着更重要的事情,因此我们需要在合适的时候去处理它。至于这个合适的时候就是 进程从内核态切换回用户态的时候进行信号检测与信号的处理
这个时候可能就会有人问了:那什么是内核态?什么又是用户态呢?他们之间的区别又是什么呢?
下面我们先通过一副简图来帮大家简单的认识一下用户态和内核态
下面就来说一下什么是内核态、用户态以及他们之间的区别:
- 内核态:执行OS的代码和数据时,计算机所处的状态就叫做内核态,OS的代码的执行去不都是再内核态
- 用户态:就是用户代码和数据被访问或者执行的时候,所处的状态。我们自己写的代码全部都是在用户态执行的
- 主要区别: 在于权限
现在我知道了用户态、内核态以及他们之前的区别之后,我现在又有一个问题:执行用户的代码时,用户的代码一定要被加载进内存,那么OS的数据和代码需要加载内存嘛?
也是一定要被加载进内存的,但是我们的电脑一般都只有一个CPU,OS的代码是怎么被执行到的呢?
不知道大家是否还记得这张图:
我们都知道每个进程都有它自己的虚拟地址空间,用户所写的代码和数据位于用户空间,通过用户级页表(每个进程都有一份)与物理内存之间建立映射关系。你说的这些我都明白,可是我们的OS的代码和数据呢?
可以看到上面这幅图,每个虚拟地址空间是由2部分组成的:3G的用户空间以及1G的内核空间,这3G的用户空间里面存放的是每个进程的代码与数据,每个进程通过各自的用户级页表就可以将自己代码和数据映射到不同的物理内存中。而我们的OS只有一个,它的代码和数据是存放在内核空间的,这个1G的内核空间是被所有进程所共享的,OS通过内核页表就可以将它的代码和数据映射到物理内存中,其中内核页表是被所有进程共享的
因此这也就说明了进程具有了地址空间是能够看到用户和内核的所有内容的,但是并不一定能够访问操作系统的内容。
现在我又有一个问题: 那CPU进行调度的时候,它又怎么知道当前进程是处于用户态还是内核态的呢?
在CPU内部有一个CR3寄存器
它会保存当前进程的状态,通过CR3寄存器我们就可以知道当前进程处于用户态还是内核态。
注意:
- 用户态使用的是用户级页表, 只能 访问用户的数据和代码
- 内核态使用的是内核级页表, 只能 访问内核级的数据和代码。
总结:
- 进程直接无论如何切换,我们能够保证我们一定能够找到同一个OS,因为我们每个进程都有3-4G的地址空间,使用同一张内核页表
- 所谓的系统调用:就是进程的身份转化成内核态,然后根据内核页表找到系统函数,执行就行了
- 在大部分情况下,实际上我们OS都是可以在进程的上下文中直接运行的。
内核如何实现信号的捕捉
上面了解了内核态与用户态之后,接下来我来为大家讲解一下内核是如何实现信号的捕捉的。
当我们在执行主控制流程的某条指令时,可能因为中断、异常或者系统调用和陷入内核,当内核处理完异常准备返回用户态时,就需要先检查一下pending位图,如果在pending位图中发现有未决信号,并且该信号没有被阻塞,那么我们就需要对该信号进行处理。
如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作之后,将该信号在pending表中的标志位由1置0,如果pending位图中没有未决信号,或者该未决信号被阻塞了,我们直接返回到用户态,从主控制流中上次被中断的位置继续向下执行。
如果待处理信号的处理动作是自定义,即该信号的处理动作是用户所提供的,那么处理该信号时就要由内核态返回用户态去执行信号处理函数。信号处理函数返回时再执行特殊的系统调用sigreturn再次进入内核,最终调用sys_sigreturn函数返回用户态,从主控制流中中上次被中断的位置继续向下执行。
下面通过一张图片来展示信号捕捉的流程:
大家可能觉得上面这幅图有点不好记,但是我还是希望大家尽可能的把上面那幅图给理解了然后记住它。下面我用一副简图来帮大家更好的记忆:
这个图形有点像我们数学里面的无穷大∞,我们可以利用无穷大∞来帮助我们记忆信号捕捉的过程,其中∞与直线的交点就代表着一次状态切换,箭头的方向就表示状态切换的方向,图中无穷大∞的交点就代表着 信号的检测
此时我有一个小问题:你说过内核态的权限是很高的,但是为什么一定要切换到用户态,才能够执行信号的捕捉方法呢,内核态直接执行不可以嘛?
理论上OS是可以执行用户的代码的,但是OS不相信任何人!如果有一些恶意用户在信号处理函数的代码中封装了删除数据库的操作。那么此时OS如果去执行了这些代码就会导致数据库被删除(删库跑路hhhh)。因此不能够让OS去直接执行用户的代码,因为OS不能保证用户的代码都是合法或者没有恶意的。
sigaction
处理signal函数可以捕捉信号外,下面我们要介绍的这个函数同样可以捕捉信号——sigaction函数
sigaction函数可以读取和修改与指定信号相关联的处理动作。
sigaction函数的原型如下:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
//返回值
//调用成功则返回0,出错则返回- 1。
说明:
- signo表示的是信号编号
- 若act指针非空,则根据act修改该信号的处理动作。
- 若oact指针非 空,则通过oact传出该信号原来的处理动作。
act和oact指向sigaction结构体,下面我们来看一下sigaction这个结构体的定义
struct sigaction {
void (*sa_handler)(int);
void(*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
结构体的第一个成员sa_handler
- 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号
- 将sa_handler赋值为常数SIG_DFL表示执行系统默认动作,
- 将sa_handler赋值为一个函数指针表示用自定义函数捕捉信号者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然这也是一个回调函数,不是被main函数调用,而是被系统所调用。
结构体的第二个成员sa_sigaction
sa_sigactionshi是实时信号的处理函数我们这里不关心。
结构体的第三个成员:sa_mask
当处理某个信号是时,屏蔽的信号字,默认用函数设置为0.
敲黑板!!!: 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
结构体的第四个成员:sa_flags
该成员我们不关心,默认设置为0
结构体的第五个成员:sa_restorer
该成员我们这里也不关心。
下面我们就来使用以下sigaction函数吧
#include<stdio.h>
#include<signal.h>
#include<stdlib.h>
#include<unistd.h>
void handler(int signo)
{
cout<<"catch a signo"<<signo<<endl;
}
int main()
{
struct sigaction act,oact;
act.sa_flags = 0;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
sigaction(2,&act,&oact);
while(1)
{
sleep(1);
printf("hello world\n");
}
return 0;
}
运行结果:
可重入函数
我们先来看一张图片:
图中main函数调用insert函数向一个链表head中插入节点node1,信号处理函数也调用了insert函数向链表中插入节点node2,这么一看好像没什么问题。
但是我们来仔细分析一下:
insert函数的插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了,此时就会造成内存泄漏问题。
上述例子中,各函数的执行的先后顺序如下:
向上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。
insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile
volatile是我们C语言中的一个关键字,它的作用 是保持内存的可见性。告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
下面我们来看一段代码
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
int flag = 0;
void handler(int signo)
{
printf("change flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2,handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
运行结果:
可以看到当我们使用Crtl+c组合键,2号信号被捕捉,执行自定义动作flag由0变成1,此时循环条件不满足进程退出。
下面我们在给Makefile中给gcc带上优化-O2选项
运行结果:
优化情况下,使用Crtl+c组合键向进程发送2号信号,2号信号被捕捉,执行自定义动作,修改 flag
=1,但是while条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显,while循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。
那如何解决这个问题呢?我们可以使用volatile关键字来解决这个问题
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
volatile int flag = 0;
void handler(int signo)
{
printf("change flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2,handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
运行结果:
可以看到当我们的flag变量被volatile关键字修饰时,尽管在我们的Makefile中给gcc带上优化-O2选项,当进程收到2号信号,执行信号处理函数将内存中的flag变量从0置1时,main函数的执行流也能够检测到内存中的flag变量的变化,从而跳出死循环进程退出。
以上就是本篇文章的所有内容了,码文不易,如果觉得该文章对你有帮助的话可以三连一波支持一下作者。