进程信号--Linux

信号?

进程间的通信我们了解到有管道通信,有共享内存的通信。这些都是利用一些内存空间从而实现通信内容的交互,这些信息可以是字符、数字、字符串、甚至可以是结构体或者是类,但今天介绍的信号并没有那么复杂的信息量,信号作为通信方式,有其独特的特点:简洁、含义明确、系统级别……

首先在讲解之前,得首先了解一下信号到底是个什么东西。在生活中,我们可能遇到很多信号:红绿灯、旗帜、烽火(古时传讯)等等,这些信号我们在遇到时候马上就会明白到底有什么含义,因为这些信号在产生的时候就被赋予了特定的含义,并被所有的人所知晓与认可。所以在遇到信号时,根本就没有异议,大家只要是正常人都会按照确定好的含义来处理。所以,我个人认为,信号的定义就是事先约定好的,广为知晓并被接受,用来处理特定事件而产生的唯一辨识现象。

知道了信号的含义,那么将这个含义应用到进程中,就会衍生出一种单独的通信方式。并不像我们自己在程序中定义的某些变量充当信号,由于要被所有的进程所识别,所以这些信号是要被系统收纳并写死的,不可更改。也就是我们作为用户,只是知道这些信号,并且只有使用权,并没有修改权。因此,信号就是属于系统级别的通信了。不同进程只要处理各自的信号,就能知道外界给这个进程传递了什么信息。

kill -l 指令查看所有信号

说起信号,之前在学习进程pid的时候,我们会通过kill -9 +进程pid的指令杀死一个进程,这个过程其实就是像一个进程发送信号,使其终止,该信号就是9。现在我们要学习更多的信号,就需要了解所有常用的信号。具体方式就是在终端上通过指令kill -l来查看:

image-20230113145255604

现在我只需要学习到普通信号,以后有机会的话会更新实时信号的相关知识。

更详细的要查看信号属性的话使用指令:man 7 signal

image-20230113150054299

信号的工作流程

信号的产生是异步的,并不是信号产生就立马就被处理了,进程可能有更加重要的事情要做,因此信号会被放置一段时间再处理。这就会导致信号必定有其特殊的存储结构以及处理流程。

image-20230114141416710

信号产生

1.通过终端按键产生信号

平常我们可以通过ctrl + c来终止正在运行的进程,这其实就是通过键盘产生的信号。而我们知道,ctrl + c的结果就是终止进程,而在信号中SIGINT的作用就是终止进程(默认的处理结果),我们可以试着自定义一个处理动作,这就需要借助系统给我们提供的函数接口了。

image-20230113201543610

函数名: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;
}

image-20230113202523215

可以看出,重新自定义后的信号处理方式已经不再是退出程序了,而是回调用户自己定义的函数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;
}

image-20230113203811712

可以看出除了9号信号,其他信号确实是被重定义了,因此9号信号是保证进程安全的最后一道防线。🔺:9号信号不可被用户自定义处理方式!

2.通过系统调用接口产生信号

系统给我们用户提供了很多个函数来产生信号,其中有kill、raise、abort、exit等等,其中的kill可以给已知pid的进程发送信号,利用这个特性,我们可以模拟一个自己的kill命令。

image-20230114145806696

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进程

image-20230114144443064

再试一下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;
}

image-20230114152855334

3.通过软件产生信号

image-20230114161240761

软件产生信号,可以由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;
}

测试结果:

image-20230114165414384

但是如果是在++的过程中输出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;
}

image-20230114165640543

由此也可以直观地感受出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

image-20230114182153604

而通过man 7 signal指令查看可以发现,8号信号对应的是SIGFPE,意思是浮点运算例外;而11号信号对应的是SIGSEGV,意思是无效内存引用。与对应的问题一致。可见硬件的异常确实会导致信号的产生。

信号接收

解决了信号产生的问题,我们就顺势引出了一个新的问题:信号的来源有了,但是怎样才能将信号传达给对应的进程呢?答案是操作系统负责信号之间的传递。但是被传递信号进程怎么接收信号呢?要想讲清楚这个问题,就涉及到了信号一个进程中是如何被存储起来的。

事实上,信号的存储位置还是在进程的PCB中,用一种名为位图的数据结构来进行信号存储。我们知道无论是什么类型的数据,最终都是由一个个的bit位所构成。信号在最前面就提到过是一种标识,因此一个bit位的大小就能表示一个信号的有无。(0:无;1:有)所以使用位图这种结构无疑是特别省空间的做法。关于信号的存储与后续的处理,是由三个表组成的,分别是block(信号屏蔽字)、pending(信号集)、handler(方法表)。

image-20230115095024326

由上图可以看出,block表和pending表都是位图的数据结构,而handler表则是一个函数指针数组。信号的接收主要是靠pending表,block与handler表主要在信号的处理阶段使用。其中pending表中的0、1就分别表示对应的信号是否存在,而block表中的0、1代表后面的信号是否能够被使用,1表示可以,0表示不可以。上述结构都是在进程PCB中,用户是没有权限直接访问或是修改对应的数据,因此就给我们用户提供了一个专门访问该信号结构的数据类型:sigset_t,同时也衍生出了一些针对该数据类型的操作函数:

image-20230115164213268

🔺注意:由于sigset_t是系统提封装好的数据类型,因此不可以直接通过位移操作去增添删改信号,只能由提供的上述函数进行操作。

对于信号存储的三个表,我们已知可以通过signal对处理方法进行自定义操作,事实上,要想对block表和pending表进行读取和修改,就得利用另外两个函数:sigpending(获取调用该函数进程的pending表)、sigprocmask(获取、修改调用该函数进程的block表)。

image-20230115165044722

其中sigpending的用法比较简单,将传入的set信号集设置成为调用该函数进程的pending表,成功返回0,失败返回-1,一般配合着sigemptyset使用。而sigprocmask函数就有点复杂了,这涉及到其中一个参数how(怎样改变对应进程的block表)。

函数名:sigprocmask

参数:

​ set:若该参数为非空参数,则配合how参数改变调用该函数进程的block表。

​ oldset:若该参数为非空参数,则使调用该函数进程的旧的block表拷贝给oldset信号集;为空的话忽略oldset参数。也就是说, 这个参数为输出型参数。

​ how:有三个选项,分别是SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK,分别带有不同的含义与作用。

image-20230115171434291

作用:获取、修改调用该函数进程的信息屏蔽字。

接下来是测试:

#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;
}

image-20230116101020009

信号处理

现在我们知道信号确实可以PCB中的数据结构所存储,但是信号的处理并不是我们想象中的那么简单。信号的处理称为递达,信号已经被进程接收,但是还没被处理,称之为未决。信号肯定是要被进程处理的,但是何时处理,处理的状态这些都是未知。事实上,信号被处理的时间是内核态切换到用户态的时候。什么是内核态,什么是用户态呢?

内核态:处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之为内核态。

用户态:处于用户态的 CPU 只能受限的访问内存,并且不允许访问外围设备,用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取。

更直观的可以用虚拟地址空间来辅助理解:

image-20230116164218706

内核态可以访问所有的代码和数据,具备更高的权限,而用户态只能访问自己的代码和数据。

内核态与用户态的切换说白了就是页表的切换与CPU状态的切换。CPU中有一个组件为cr3,标识为0时是内核态,标识为3时是用户态。什么时候会进行内核态与用户态之间的转换呢?情况有很多:1.系统调用时;2.时间片到了;……接下来,我们画个图来更见形象的理解信号处理的过程。

image-20230117104109077

现在有一个问题:如果在陷入内核处理信号时,又有信号被发送和接收,那么会不会出现递归式的陷入内核的处理现象呢?答案是不会的!操作系统在被设计的时候就考虑到了这种问题,在处理信号时,操作系统默认将这个正在被处理的信号所对应的block值置1,即拦截所有后续出现的相同的信号,直到处理动作完成,才会将值置为0,又可以重新接收信号。就比如用户一直在给一个进程发送ctrl c这样的指令,但是ctrl c所对应的信号处理方法由于是自定义的,所以耗时很长,后续的ctrl c就只会使pending表中的0置1,但并不会去调用处理方法。前面我们讲了signal这个函数,事实上有一个功能更加完善的函数:sigaction

image-20230117112649683

其中的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;
}

image-20230117130422464

可以看出,将三号信号屏蔽后,后续在处理2号信号时,再发出2号3号信号就不会被递达了,因为之前的2号信号的方法还在被执行!

总结

信号的三个重要知识点:产生、接收、处理。其中衍生出来了许多的附带知识点,重点掌握系统函数以及信号集相关的函数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值