Linux_进程信号

目录

1、Linux信号的概念

一、信号的产生

1、ctrl+c中断信号

2、信号的处理方式

3、硬中断

4、软中断

5、产生信号的方式

[5.1 测试kill接口](#5.1 测试kill接口)

[5.2 其他产生信号的接口](#5.2 其他产生信号的接口)

6、硬件异常产生信号

7、软件条件产生信号

8、闹钟-alarm

二、信号的保存

1、信号的接收

2、信号的记录方式

三、信号的操作

1、sigset_t

2、信号集操作函数

3、测试信号集操作函数

4、处理信号的流程

5、sigaction

6、信号处理时pending由1置为0

7、使用sigaction屏蔽信号

结语


前言:

信号是一种标识,其目的就是让对象事物能够根据接收到的不同信号做出不同的动作,比如现实生活中的闹钟、电话铃声、上课铃声...,这些都是日常生活中的信号,当我们听到这些信号时,下意识就会做出相应的动作,比如闹钟-起床、电话铃声-接电话,这些动作叫做信号的处理方式。

显然,当我们收到信号时可能不会马上对其进行处理,例如当闹钟响了不意味着马上起床,电话响了不会马上接电话,而是过个几秒钟才处理,因此把收到信号到处理信号的中间间隔叫做时间窗口。

1、Linux进程信号的概念

在Linux下,为了让正在运行的进程做出相应动作,通常会给该进程发送信号以达到目的。之所以对象事物能根据不同信号做出不同动作,是因为对象事物认识该信号,并且知道该信号对应的行为是什么,也就是说,进程认识信号并且清楚收到信号后该如何做,即信号的处理方式属于进程内置功能的一部分,并且进程收到信号到处理信号也存在时间窗口。

本文着重介绍1-31号的普通信号,在Linux下使用kill -l命令查看信号表:

一、信号的产生

1、ctrl+c中断信号

在Linux下,当某些程序进入死循环时,用的最多的终止方法就是ctrl+c,他常常用来终止一个进程,测试代码如下:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    while(1)
    {
        printf("I am a process, pid: %d\n", getpid());
        sleep(1);
    }
}

运行结果:

从结果可以看到,使用ctrl+c后,会打印^C并且终止循环的进程,原因是ctrl+c被系统识别为2号信号,而2号信号具有终止进程的功能,那么如何肯定ctrl+c就是2号信号呢?需要使用自定义的信号处理方式,如下文。

2、信号的处理方式

信号的处理方式有三种:

1、默认使用该信号的处理方式。

2、忽略该信号。

3、使用自定义该信号的处理方式。

当接收到一个信号时,如果没有对信号的处理动作做任何定义,那么进程会执行信号的默认处理方式,当然也可以让进程执行我们所定义的处理方式,需要调用函数signal,具体介绍如下:

cpp 复制代码
#include <signal.h>

typedef void (*sighandler_t)(int);
//sighandler_t是一个函数指针
//他只接受一个int类型的参数

sighandler_t signal(int signum, sighandler_t handler);//更改信号的处理方式
//signum表示要自定义的信号
//sighandler_t表示自定义的处理方式,会改变signum信号的处理方式

验证ctrl+c就是2号信号的思路:用signal对2号信号的默认处理方式进行重定义,然后观察ctrl + c时是否执行的是自定义行为,若是则表示ctrl + c是2号信号。

测试代码如下:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

using namespace std;

void myhandler(int signo)
{
    cout << "process get a signal: " << signo <<endl;
}

int main()
{
    signal(2, myhandler);//对2号信号进行自定义处理

    while(1)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

运行结果:

从结果可以看到,^C表示ctrl + c,并且进程会自动去执行自定义的处理函数myhandler,说明ctrl + c就是2号信号。


但是将2号信号重定义了,则ctrl + c不能终止进程了,因此上述测试采用的是ctrl + \终止进程,ctrl + \表示3号信号,他的作用是退出进程。值得注意的是:不是每个信号都可以被自定义的,若把全部信号比如9号信号都自定义了则该进程就无法退出了,因此例如9号信号就无法被重定义。

3、硬中断

上述的ctrl+c是由键盘产生的信号,而键盘属于硬件,因此ctrl+c本身属于硬中断,而OS底层是如何识别ctrl+c是信号的呢?原因就是ctrl + c这样的键盘指令被拷贝到OS的缓冲区时,并且会对该指令分析是否为组合键,若是组合键则解释为信号,具体示意图如下:

4、软中断

软中断和硬中断不同,他表示的是一种内核机制,比如优先处理某些事物。当我们给进程发送信号,进程之所以可以中断当前的所有任务来处理该信号就是因为软中断的优先处理机制,所以软中断间接完成了进程对信号的处理。

5、产生信号的方式

产生信号不止键盘的组合键(ctrl+c、ctrl+\),还可以使用指令kill signo pid, 以及系统接口kill,接口介绍如下:

cpp 复制代码
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);
//pid表示进程号
//sig表示要发送的信号
//成功返回0,失败返回-1

5.1 测试kill接口

测试代码如下:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>

using namespace std;

void myhandler(int signo)
{
    cout << "process get a signal: " << signo <<endl;
}

int main()
{
    signal(2, myhandler);

    kill(getpid(),2);
    return 0;
}

运行结果:

5.2 其他产生信号的接口

除了kill接口外,还有raise、abort两个接口也可以产生信号,详细介绍如下:

cpp 复制代码
#include <signal.h>
int raise(int sig);//发送一个sig信号给当前进程

#include <stdlib.h>
void abort(void);//给当前进程发送6号信号

以上两个接口底层都是复用了kill接口,特别的是哪怕重定义了abort的处理函数,那么abort还是会终止进程。

6、硬件异常产生信号

何为硬件异常?比如野指针访问、除0错误就属于硬件异常的范畴,当硬件发生异常时,硬件会以某种方式通知内核,然后内核(操作系统)向当前进程发送对应信号。操作系统之所以知道除0错误、野指针访问这些异常,因为cpu里面有一个状态寄存器,当进程发生除0错误的时候状态寄存器就会从0变为1,这时候操作系统就会得知这个消息,并且判断为硬件异常随即发送信号。

测试除0异常的代码:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

using namespace std;

void myhandler(int signo)
{
    cout << "process get a signal: " << signo <<endl;
    sleep(1);
}

int main()
{
    signal(SIGFPE,myhandler);
    int a = 12;
    a/=0;
    cout<<"代码会执行到此处吗?"<<endl;
    return 0;
}

运行结果:

从结果可以发现,程序进入了死循环式的打印,原因就是myhandler被重复调用,可是除0错误却只有一句代码,为什么会让myhandler被重复调用呢?原因就是我们重新定义了8号信号的处理方式,但是自定义的方法没有退出进程,因此该进程还存在,他会等待下一次被cpu调度,结果被调度后cpu又检测到了硬件异常,如此反复就造成了打印屏幕的循环。


小结:所以硬件出现问题时(即有异常时)需要将自定义信号的方法进行exit退出。更改后的代码如下:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

using namespace std;

void myhandler(int signo)
{
    cout << "process get a signal: " << signo <<endl;
    exit(-1);
}

int main()
{
    signal(SIGFPE,myhandler);
    int a = 12;
    a/=0;
    cout<<"代码会执行到此处吗?"<<endl;
    return 0;
}

运行结果:

7、软件条件产生信号

当使用匿名管道通信时,若读端被关闭了,操作系统为了不必要的效率浪费就会发送13号信号给到该进程并退出进程,把这种现象叫做软件层面上的异常,和硬件异常产生信号不一样的点在于哪怕自定义的处理方式没有exit退出,也不会导致死循环式的打印。

软件条件产生信号测试代码如下:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <errno.h>
#include <cstring>
#include <signal.h>
 
using namespace std;
#define NUM 1024
 
// child
void Writer(int wfd)
{
    string s = "你好父进程,我是子进程";
    pid_t self = getpid();
    int number = 0, time = 1;
 
    char buffer[NUM];
    while (time--)
    {
        sleep(3);
 
        // 构建发送字符串
        buffer[0] = 0;
        snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);
        cout << buffer << endl;
        // 发送/写入给父进程, system call
        int n = write(wfd, buffer, strlen(buffer));
        cout<<n<<endl; 
    }
    cout<<"子进程数据发送完成"<<endl;
}
 
// father
void Reader(int rfd)
{
    char buffer[NUM];
 
    while (true)
    {
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1); // 预留\0的位置
        if (n > 0)
        {
            buffer[n] = 0; // 手动添加\0
            cout << "父进程收到子进程的消息[" << getpid() << "]# " << buffer << endl;
        }
        else if (n == 0)
        {
            printf("father read file done!\n");
            break;
        }
        else
            break;
        cout << endl;
    }
}
 
void handler(int signal)
{
    cout<<"将13号信号的方法重定义了"<<endl;
    //exit(-1);
}

int main()
{
    signal(13,handler);
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n == -1)
    {
        perror("pipe");
        return -1;
    }
 
    // 下面实现的是子进程写,父进程读
    pid_t id = fork();
    if (id < 0)
        return -2;
 
    // chlid
    if (id == 0)
    {
        close(pipefd[0]); // 关闭读,子进程只写
        cout << "子进程开始传输数据" << endl;
        Writer(pipefd[1]);
        
        exit(0);
    }
 
    // father
    close(pipefd[0]); // 直接关闭读端
    close(pipefd[1]); // 关闭写,父进程只读

    //下面代码都会被执行
    cout << "父进程开始读取数据" << endl;
    sleep(10);
    cout<<"111111111111111111"<<endl;
    sleep(1);
    cout<<"111111111111111111"<<endl;
    sleep(1);

    return 0;
}

运行结果:

从结果可以看到,软件条件所产生的信号不会出现重复调用handler的情况。

8、闹钟-alarm

alarm是一种软件层面所产生信号,他是一个系统调用,具体介绍如下:

cpp 复制代码
#include <unistd.h>

unsigned int alarm(unsigned int seconds);
//seconds表示多少秒后闹钟会响,seconds为0表示取消之前设置的闹钟
//若之前没有设置闹钟,则返回0。若之前设置的闹钟被取消了,则返回之前闹钟的剩余秒数

他的功能很简单就如同闹钟一样,经过seconds秒后会给该进程发送14号信号,并且闹钟之后响一次。测试闹钟代码如下:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

using namespace std;

void myhandler(int signo)
{
    cout << "该进程接收的信号: " << signo << endl;
    exit(-1);
}

int main()
{
    signal(14, myhandler);
    alarm(3);//设置闹钟

    while (true)
    {
        cout << "这是一个进程" << endl;
        sleep(1);
    }

    return 0;
}

运行结果:

二、信号的保存

1、信号的接收

当给进程发送信号时,进程为了能够表示已收到该信号,会在管理进程的PCB(tast_struct)中将专门用于记录接收信号的位图的对应位置置为1。该位图是一个int类型,有32个bit位,从低位的第一位开始表示1号信号,依次按顺序,一个int类型可以记录31个信号,刚好可以记录1-31号普通信号。

信号接收示意图如下:

所以信号的接收是由进程来维护,而信号的发送、产生是由操作系统来完成的。

2、信号的记录方式

通过对signal位图的bit位置为1或0表示是否收到某个信号,但是收到一个信号并不意味着要对该信号做处理,即使对该信号处理也不意味着使用默认处理方式,所以进程接收到一个信号时,对待该信号有两个状态:1、阻塞(屏蔽),2、待处理,并且待处理的方式是否为默认还是自定义。因此记录一个信号需要3张表,一张位图(记录信号是否屏蔽),一张位图(记录待处理信号),一个函数指针数组(处理信号的方式)。


在内核中表示这三张表的示意图如下:

注意:当block表示中的某个bit为置为1,表示对应信号被阻塞了但是该信号依然可以被写进pending表中,只是暂时不执行该信号罢了。 并且上述的SIG_IGN表示忽略,他跟阻塞表性质不一样,忽略是一种信号抵达后的处理方式,而阻塞表示信号不会抵达,就如同已读不回和未读的区别。

三、信号的操作

1、sigset_t

sigset_t称为信号集,是系统对int位图进行封装后的类型,所以该类型本质上依旧是位图,系统之所以进行封装是为了搭配系统提供的信号集操作函数,目的就是方便用户使用。因此sigset_t的主要用途是充当一个载体去修改或保存进程的信号屏蔽字(即block表),从而实现更改block表的内容以达到屏蔽某种信号。

2、信号集操作函数

常用的操作函数介绍如下:

cpp 复制代码
#include <signal.h>
int sigemptyset(sigset_t *set);
//接收一个sigset_t变量的地址,目的是清0该变量的内容

int sigaddset (sigset_t *set, int signo);
//接收一个sigset_t变量的地址和一个信号
//该函数的作用是根据signo的值将sigset_t变量中对应bit位置为1

int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
//该函数的作用是修改或读取当前进程的信号屏蔽字(即blcok表)

//how表示当set不为空时,如何修改当前进程的信号屏蔽字
//若set不为空,则修改当前进程的信号屏蔽字。
//若oset不为空,则将当前进程的信号屏蔽字备份至oset中。
//若oset和set都不为空,则先备份,然后更改当前进程的信号屏蔽字

//上述函数成功返回0,失败返回-1

sigprocmask的第一个参数的选项如下:

|-------------|------------------------------------------------|
| SIG_BLOCK | 表示将set的值添加到当前的block表中,添加的方式:block=block|set |
| SIG_UNBLOCK | set所保存的内容都是block要去除的值,去除的方式:block=block&~set |
| SIG_SETMASK | 将当前的block表内容设置为set的内容,设置方式:block=set |


sigpending接口可以获取当前进程的pending表,具体介绍如下:

cpp 复制代码
#include <signal.h>

int sigpending(sigset_t *set);
//set是一个输出型参数,该函数会把当前进程的pending表内容写进set中
//调用成功返回0,失败返回-1

此时set内保存了当前进程的pending表信息,但是我们需要把set里的内容打印出来,因为set的类型是sigset_t,虽然他是位图但是不能直接用位操作配合printf来打印,必须调用系统的接口来进行分析打印,因此可以用到接口sigismember,该接口的介绍如下:

cpp 复制代码
#include <signal.h>
int sigismember(const sigset_t *set, int signum);
//该函数用于判断一个信号集的有效信号中是否包含signum信号
//若包含则返回1,不包含则返回0,出错返回-1

根据sigismember函数返回的1或0就可以打印出set的信息了。

3、测试信号集操作函数

将上述的函数统一进行测试,实现将进程的block表屏蔽2号信号后,打印其pending表观察,测试代码如下:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void PrintPending(sigset_t &pending)
{
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << "\n\n";
}

int main()
{
    sigset_t bset, oset; // 创建两个信号集,一个用于更改,一个用于备份
    // 清0
    sigemptyset(&bset);
    sigemptyset(&oset);

    sigaddset(&bset, 2); // 设置2号信号进入bset信号集
    // 将bset信号集设置进当前进程的block表中
    sigprocmask(SIG_SETMASK, &bset, &oset);//此时的block表:0000 ... 0100

    // 打印pending表
    sigset_t pending;
    while (true)
    {
        int n = sigpending(&pending);//将pending表的内容写进变量pending中
        if (n < 0)
            continue;
        PrintPending(pending);
        sleep(1);
        //发送信号
    }

    return 0;
}

运行结果:

从结果可以看到,将block表中的2号信号bit位置为1后,给该进程发送2号信号(ctrl+c)也不会使该进程退出了,但是pending表中对应的bit位确会被置为1,只是暂时不处理该信号而已。


上述代码若不解除信号屏蔽会导致死循环式的打印,若解除信号屏蔽则进程会直接退出,解除信号屏蔽需要用到oset,因为该变量内保存的是屏蔽之前的block表,即全0,解除信息屏蔽代码如下(只需对上面代码修改一部分即可):

cpp 复制代码
// 打印pending表
    int cnt = 0;
    sigset_t pending;
    while (true)
    {
        int n = sigpending(&pending);//将pending表的内容写进变量pending中
        if (n < 0)
            continue;
        PrintPending(pending);
        sleep(1);
        //发送信号
        cnt++;
        if(cnt == 10)
        {
            cout << "unblock 2 signo" << endl;
            sigprocmask(SIG_SETMASK, &oset, nullptr); // 解除2号信号的屏蔽
        }
    }

运行结果:

4、处理信号的流程

在进程接收并处理信号时,这个过程看似只有两步,实际上需要在用户态和内核态反复进行切换(用户态和内核态是两种运行模式,他们相互配合可以在提供安全性的同时又保证了效率),具体示意图如下:

当我们ctrl + c的时候,这时候进程没有任何的系统调用,但是也可以进行内核态,因为cpu时间片会不断的从用户态和内核态之间进行切换,而ctrl + c可以退出进程是因为内核的信号处理函数会直接exit,所以不对信号进行重定义处理函数,则第一次进入内核态时就直接在内核态中退出该进程,因为内核就是OS,OS可以直接对所有进程进行管理,因此内核态有权利直接退出某个进程,那为什么进程没有系统调用却可以进入内核态做信号处理的呢?

cpu时间片切换的原理:当某个进程在cpu上执行固定时间后会被"拿下来"让别的进程进入cpu执行,进行二次调度时该进程又会被拿到cpu上跑,而此时的运行模式是内核态,这个时候会顺便对进程进行信号处理,所以在运行一个进程的时候有很多时候都是内核态到用户态反复来回。

5、sigaction

上文提到若想自定义信号的处理方式可以用函数signal,而现在sigaction函数也可以做到自定义处理方式,该函数的介绍如下:

cpp 复制代码
#include <signal.h>

int sigaction(int signum, const struct sigaction *act,
                struct sigaction *oldact);
//signum表示对象信号,act和oldact是一个指向结构体sigaction的指针
//act和oldact用法和sigprocmask的set和oset相似,即signum的处理方法改成act中的方法
//oldact保存的是原来signum的处理方法

sigaction更改信号处理方式的测试代码如下:

cpp 复制代码
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>

using namespace std;

void myhandler(int signo)
{
    cout << "该进程接收的信号: " << signo << endl;
}

int main()
{
    struct sigaction act, oact;
    memset(&act, 0, sizeof(act));
    memset(&oact, 0, sizeof(oact));

    act.sa_handler = myhandler; 
    sigaction(2, &act, &oact);

    while (true)
    {
        cout << "我是一个进程: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

运行结果:

从结果可以看到,效果和signal一模一样。

6、信号处理时pending由1置为0

当进程接收到某个信号时会在该进程的pending表中对应bit位置为1,当开始执行该信号的处理函数时,会先把pending表的1置为0,测试代码如下:

cpp 复制代码
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>

using namespace std;

void PrintPending()
{
    sigset_t set;
    sigpending(&set);

    for (int signo = 1; signo <= 31; signo++)
    {
        if (sigismember(&set, signo))
            cout << "1";
        else
            cout << "0";
    }
    cout << "\n";
}

void myhandler(int signo)
{
    PrintPending();//此处打印的对应信号的bit位是1还是0?
    cout << "该进程接收的信号: " << signo << endl;
}

int main()
{
    struct sigaction act, oact;
    memset(&act, 0, sizeof(act));
    memset(&oact, 0, sizeof(oact));

    act.sa_handler = myhandler; // SIG_IGN SIG_DFL
    sigaction(2, &act, &oact);

    while (true)
    {
        cout << "我是一个进程: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

运行结果:

从结果可以看到,在调用处理函数前就已经把pending表的对应信号bit位置为0,然后再执行处理函数里的代码。

7、使用sigaction屏蔽信号

sigaction不止可以重定义信号的处理方式,还可以对信号进行屏蔽,原理就是struct sigaction结构体里有sigset_t类型的成员变量,sigset_t就是信号集因此可以更改当前进程的block表。

测试代码如下:

cpp 复制代码
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>

using namespace std;

void PrintPending()
{
    sigset_t set;
    sigpending(&set);

    for (int signo = 1; signo <= 31; signo++)
    {
        if (sigismember(&set, signo))
            cout << "1";
        else
            cout << "0";
    }
    cout << "\n";
}

void myhandler(int signo)
{
    cout << "该进程接收的信号: " << signo << endl;
    while (true)
    {
        PrintPending();
        sleep(2);
    }
}

int main()
{
    struct sigaction act, oact;
    memset(&act, 0, sizeof(act));
    memset(&oact, 0, sizeof(oact));

    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, 1);
    sigaddset(&act.sa_mask, 3);
    sigaddset(&act.sa_mask, 4);
    act.sa_handler = myhandler; // SIG_IGN SIG_DFL
    sigaction(2, &act, &oact);

    while (true)
    {
        cout << "我是一个进程: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

运行结果:

结语

以上就是关于Linux信号的全部讲解,虽然我们平时写代码很难察觉信号的存在,但是其在Linux中扮演着非常重要的角色,信号可以间接的保证代码的安全性,一定程度上提高了操作系统的效率。

最后如果本文有遗漏或者有误的地方欢迎大家在评论区补充,谢谢大家!!

相关推荐
WTT001120 分钟前
2024楚慧杯WP
大数据·运维·网络·安全·web安全·ctf
苹果醋328 分钟前
React源码02 - 基础知识 React API 一览
java·运维·spring boot·mysql·nginx
了一li1 小时前
Qt中的QProcess与Boost.Interprocess:实现多进程编程
服务器·数据库·qt
日记跟新中1 小时前
Ubuntu20.04 修改root密码
linux·运维·服务器
唐小旭1 小时前
服务器建立-错误:pyenv环境建立后python版本不对
运维·服务器·python
码农君莫笑1 小时前
信管通低代码信息管理系统应用平台
linux·数据库·windows·低代码·c#·.net·visual studio
明 庭1 小时前
Ubuntu下通过Docker部署NGINX服务器
服务器·ubuntu·docker
BUG 4041 小时前
Linux——Shell
linux·运维·服务器
007php0071 小时前
Go语言zero项目部署后启动失败问题分析与解决
java·服务器·网络·python·golang·php·ai编程