文章目录
- 信号?
- kill -l 指令查看所有信号
- 信号的工作流程
- 信号产生
- 1.通过终端按键产生信号
- 2.通过系统调用接口产生信号
- 3.通过软件产生信号
- 4.硬件异常产生信号
- 信号接收
- 信号处理
- 总结
信号?
进程间的通信我们了解到有管道通信,有共享内存的通信。这些都是利用一些内存空间从而实现通信内容的交互,这些信息可以是字符、数字、字符串、甚至可以是结构体或者是类,但今天介绍的信号并没有那么复杂的信息量,信号作为通信方式,有其独特的特点:简洁、含义明确、系统级别……
首先在讲解之前,得首先了解一下信号到底是个什么东西。在生活中,我们可能遇到很多信号:红绿灯、旗帜、烽火(古时传讯)等等,这些信号我们在遇到时候马上就会明白到底有什么含义,因为这些信号在产生的时候就被赋予了特定的含义,并被所有的人所知晓与认可。所以在遇到信号时,根本就没有异议,大家只要是正常人都会按照确定好的含义来处理。所以,我个人认为,信号的定义就是事先约定好的,广为知晓并被接受,用来处理特定事件而产生的唯一辨识现象。
知道了信号的含义,那么将这个含义应用到进程中,就会衍生出一种单独的通信方式。并不像我们自己在程序中定义的某些变量充当信号,由于要被所有的进程所识别,所以这些信号是要被系统收纳并写死的,不可更改。也就是我们作为用户,只是知道这些信号,并且只有使用权,并没有修改权。因此,信号就是属于系统级别的通信了。不同进程只要处理各自的信号,就能知道外界给这个进程传递了什么信息。
kill -l 指令查看所有信号
说起信号,之前在学习进程pid的时候,我们会通过kill -9 +进程pid的指令杀死一个进程,这个过程其实就是像一个进程发送信号,使其终止,该信号就是9。现在我们要学习更多的信号,就需要了解所有常用的信号。具体方式就是在终端上通过指令kill -l来查看:
现在我只需要学习到普通信号,以后有机会的话会更新实时信号的相关知识。
更详细的要查看信号属性的话使用指令:man 7 signal
信号的工作流程
信号的产生是异步的,并不是信号产生就立马就被处理了,进程可能有更加重要的事情要做,因此信号会被放置一段时间再处理。这就会导致信号必定有其特殊的存储结构以及处理流程。
信号产生
1.通过终端按键产生信号
平常我们可以通过ctrl + c来终止正在运行的进程,这其实就是通过键盘产生的信号。而我们知道,ctrl + c的结果就是终止进程,而在信号中SIGINT的作用就是终止进程(默认的处理结果),我们可以试着自定义一个处理动作,这就需要借助系统给我们提供的函数接口了。
函数名:signal(信号捕捉函数)
参数:
signum:信息序号
handler:处理方法,一个参数为int返回值为void类型的函数指针。
返回值:函数指针
可以看出,由于传参的原因,我们自定义处理方法时,只能回调系统写死的函数类型,即参数为int返回值为void类型的函数。注意:handler函数属于回调函数,只有在接收到对应的信号时才会调用该函数。
以下是对自定义信号处理方法的测试:
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int signo)
{
cout<<"接收到信号:"<<signo<<endl;
}
int main()
{
signal(SIGINT,handler);
while(1)
{
cout<<"等待捕捉信号……"<<endl;
sleep(1);
}
return 0;
}
可以看出,重新自定义后的信号处理方式已经不再是退出程序了,而是回调用户自己定义的函数handler。那如果我们将1-31的信号全部都自定义,会不会出现一个无法以信号关闭的进程呢?
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int signo)
{
cout<<"接收到信号:"<<signo<<endl;
}
int main()
{
//signal(SIGINT,handler);
for(int i=1;i<=31;++i)
{
signal(i,handler);//将1-31的普通信号的处理方法都给重新自定义一下,包括9号信号
}
while(1)
{
cout<<"等待捕捉信号……"<<endl;
sleep(1);
}
return 0;
}
可以看出除了9号信号,其他信号确实是被重定义了,因此9号信号是保证进程安全的最后一道防线。🔺:9号信号不可被用户自定义处理方式!
2.通过系统调用接口产生信号
系统给我们用户提供了很多个函数来产生信号,其中有kill、raise、abort、exit等等,其中的kill可以给已知pid的进程发送信号,利用这个特性,我们可以模拟一个自己的kill命令。
kill:
参数:pid:进程标识符,sig:信号
返回值:返回0代表成功,失败返回-1
作用:向某个指定进程发送某个信号
raise:
参数:sig:信号
返回值:成功返回0,失败返回非零数字。
作用:对调用该函数的进程发送某个信号
abort:
参数:无
返回值:无
作用:使调用该函数的进程退出。
exit:
参数:status:退出状态
返回值:无
作用:使得调用该函数的进程正常中止,status & 0377 的值被返回给父进程。注意区别返回值,这里返回给父进程的值类似于信号一类的信息。
正常运行的程序:
#include<iostream>
#include<unistd.h>
using namespace std;
int main()
{
while(1)
{
cout<<getpid()<<" is running"<<endl;
sleep(1);
}
return 0;
}
模拟的kill命令:
#include<iostream>
#include<string>
#include<cstring>
#include<cstdlib>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
using namespace std;
void Usage(const string& str)
{
cerr<<"Usage:\t"<<str<<" + signo + pid"<<endl;
}
int main(int argc, char* argv[])
{
if(argc<3)
{
Usage(argv[0]);
}
if(kill(static_cast<pid_t>(atoi(argv[2])),atoi(argv[1]))==-1)
{
cerr<<"kill: "<<strerror(errno)<<endl;
exit(2);
}
return 0;
}
通过mykill进程杀掉myproc进程
再试一下raise的使用结果:
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int signo)
{
cout<<"接收到信号:"<<signo<<endl;
}
int main()
{
for(int i=1;i<=31;++i)
{
signal(i,handler);
}
while(1)
{
cout<<getpid()<<" is running"<<endl;
sleep(1);
raise(2);
}
return 0;
}
3.通过软件产生信号
软件产生信号,可以由alarm函数来实现。
参数:seconds(设置的秒数)
返回值:返回任何先前计划的警报之前剩余的秒数。如果之前没有计划的警报,则为零。
作用:安排在数秒内将SIGALRM信号传递到调用进程。一般而言SIGALRM信号会使得进程退出。
借助这个功能,我们可以做许多定时的工作,比如统计一秒内一个变量能够++多少次:
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
int cnt=1;
void handler(int signo)
{
cout<<"接收到信号:"<<signo<<" cnt:"<<cnt<<endl;
exit(2);
}
int main()
{
signal(SIGALRM,handler);
alarm(1);
while(1)
{
cnt++;
}
return 0;
}
测试结果:
但是如果是在++的过程中输出cnt,++次数就会变得很少:
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
int cnt=1;
int main()
{
alarm(1);
while(1)
{
printf("%d\n",cnt++);
}
return 0;
}
由此也可以直观地感受出IO流确实特别慢,输出所耗费的时间严重影响了++的次数。
4.硬件异常产生信号
除了上述系统提供的一些接口,还有一些特殊的情况也会产生异常,比较直观的现象就是程序崩溃了。至于为什么会崩溃,代码本身格式问题除外,一般都是逻辑上的问题:除零、野指针、越界……
下面我们尝试着把所有的信号都自定义一下,然后再写出上述问题中的某些问题,看是否产生信号,以及产生了哪些信号。
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int signo)
{
cout<<"接收到信号:"<<signo<<endl;
exit(2);
}
int main()
{
for(int i=1;i<=31;++i)
{
signal(i,handler);
}
//int a=1/0;//除零问题
int *ptr=nullptr;
*ptr=10;//野指针问题
return 0;
}
经测试,除零以及野指针问题的信号分别是:8、11
而通过man 7 signal指令查看可以发现,8号信号对应的是SIGFPE,意思是浮点运算例外;而11号信号对应的是SIGSEGV,意思是无效内存引用。与对应的问题一致。可见硬件的异常确实会导致信号的产生。
信号接收
解决了信号产生的问题,我们就顺势引出了一个新的问题:信号的来源有了,但是怎样才能将信号传达给对应的进程呢?答案是操作系统负责信号之间的传递。但是被传递信号进程怎么接收信号呢?要想讲清楚这个问题,就涉及到了信号一个进程中是如何被存储起来的。
事实上,信号的存储位置还是在进程的PCB中,用一种名为位图的数据结构来进行信号存储。我们知道无论是什么类型的数据,最终都是由一个个的bit位所构成。信号在最前面就提到过是一种标识,因此一个bit位的大小就能表示一个信号的有无。(0:无;1:有)所以使用位图这种结构无疑是特别省空间的做法。关于信号的存储与后续的处理,是由三个表组成的,分别是block(信号屏蔽字)、pending(信号集)、handler(方法表)。
由上图可以看出,block表和pending表都是位图的数据结构,而handler表则是一个函数指针数组。信号的接收主要是靠pending表,block与handler表主要在信号的处理阶段使用。其中pending表中的0、1就分别表示对应的信号是否存在,而block表中的0、1代表后面的信号是否能够被使用,1表示可以,0表示不可以。上述结构都是在进程PCB中,用户是没有权限直接访问或是修改对应的数据,因此就给我们用户提供了一个专门访问该信号结构的数据类型:sigset_t,同时也衍生出了一些针对该数据类型的操作函数:
🔺注意:由于sigset_t是系统提封装好的数据类型,因此不可以直接通过位移操作去增添删改信号,只能由提供的上述函数进行操作。
对于信号存储的三个表,我们已知可以通过signal对处理方法进行自定义操作,事实上,要想对block表和pending表进行读取和修改,就得利用另外两个函数:sigpending(获取调用该函数进程的pending表)、sigprocmask(获取、修改调用该函数进程的block表)。
其中sigpending的用法比较简单,将传入的set信号集设置成为调用该函数进程的pending表,成功返回0,失败返回-1,一般配合着sigemptyset使用。而sigprocmask函数就有点复杂了,这涉及到其中一个参数how(怎样改变对应进程的block表)。
函数名:sigprocmask
参数:
set:若该参数为非空参数,则配合how参数改变调用该函数进程的block表。
oldset:若该参数为非空参数,则使调用该函数进程的旧的block表拷贝给oldset信号集;为空的话忽略oldset参数。也就是说, 这个参数为输出型参数。
how:有三个选项,分别是SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK,分别带有不同的含义与作用。
作用:获取、修改调用该函数进程的信息屏蔽字。
接下来是测试:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signo)
{
cout << "接收到信号:" << signo << endl;
}
void showSigset(sigset_t *sigset)//通过调用sigismember函数来查看信号集。
{
cout<<getpid()<<" sigset: ";
for (int sig = 1; sig <= 31; ++sig)
{
if (sigismember(sigset, sig))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int main()
{
sigset_t bsig,obsig;
sigemptyset(&bsig);
sigemptyset(&obsig);
sigfillset(&bsig);//屏蔽所有的信号
sigprocmask(SIG_SETMASK,&bsig,&obsig);//obsig拿到了之前的信号屏蔽字
for (int i = 1; i <= 31; ++i)
{
signal(i, handler);//将所有的信号都自定义一下
}
sigset_t pendings;
int times=0;
while (true)
{
sigemptyset(&pendings);
if (sigpending(&pendings) == 0)
{
showSigset(&pendings);//输出当前的pending表
}
sleep(1);
times++;
if(times==20)//20秒之后解除所有信号的屏蔽。
{
sigprocmask(SIG_SETMASK,&obsig,nullptr);
}
}
return 0;
}
信号处理
现在我们知道信号确实可以PCB中的数据结构所存储,但是信号的处理并不是我们想象中的那么简单。信号的处理称为递达,信号已经被进程接收,但是还没被处理,称之为未决。信号肯定是要被进程处理的,但是何时处理,处理的状态这些都是未知。事实上,信号被处理的时间是内核态切换到用户态的时候。什么是内核态,什么是用户态呢?
内核态:处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之为内核态。
用户态:处于用户态的 CPU 只能受限的访问内存,并且不允许访问外围设备,用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取。
更直观的可以用虚拟地址空间来辅助理解:
内核态可以访问所有的代码和数据,具备更高的权限,而用户态只能访问自己的代码和数据。
内核态与用户态的切换说白了就是页表的切换与CPU状态的切换。CPU中有一个组件为cr3,标识为0时是内核态,标识为3时是用户态。什么时候会进行内核态与用户态之间的转换呢?情况有很多:1.系统调用时;2.时间片到了;……接下来,我们画个图来更见形象的理解信号处理的过程。
现在有一个问题:如果在陷入内核处理信号时,又有信号被发送和接收,那么会不会出现递归式的陷入内核的处理现象呢?答案是不会的!操作系统在被设计的时候就考虑到了这种问题,在处理信号时,操作系统默认将这个正在被处理的信号所对应的block值置1,即拦截所有后续出现的相同的信号,直到处理动作完成,才会将值置为0,又可以重新接收信号。就比如用户一直在给一个进程发送ctrl c这样的指令,但是ctrl c所对应的信号处理方法由于是自定义的,所以耗时很长,后续的ctrl c就只会使pending表中的0置1,但并不会去调用处理方法。前面我们讲了signal这个函数,事实上有一个功能更加完善的函数:sigaction
其中的oldact是获取之前的信号处理方法,与sigprocmask中的oldset是一样的作用,属于输出型参数。使用效果:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void showSigset(sigset_t *sigset)
{
cout<<getpid()<<" sigset: ";
for (int sig = 1; sig <= 31; ++sig)
{
if (sigismember(sigset, sig))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
void handler(int signo)
{
cout << "接收到信号:" << signo << endl;
sigset_t pendings;
while(true)//不会退出handler
{
sigemptyset(&pendings);
sigpending(&pendings);
showSigset(&pendings);
sleep(1);
}
}
int main()
{
struct sigaction act,oact;
act.sa_flags=0;
act.sa_handler=handler;//自定义
//act.sa_handler=SIG_DFL;//默认
//act.sa_handler=SIG_IGN;//忽略
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask,3);//添加屏蔽信号
sigaction(2,&act,&oact);
while(true)
{
cout<<"main running"<<endl;
sleep(1);
}
return 0;
}
可以看出,将三号信号屏蔽后,后续在处理2号信号时,再发出2号3号信号就不会被递达了,因为之前的2号信号的方法还在被执行!
总结
信号的三个重要知识点:产生、接收、处理。其中衍生出来了许多的附带知识点,重点掌握系统函数以及信号集相关的函数。