欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Linux----详解信号

程序员文章站 2022-07-12 11:18:37
...

信号的概念

信号是进程间通信机制中唯一的异步机制
来看看在Linux中都有哪些信号
kill -l 命令可以查看Linux中的信号列表
Linux----详解信号
我们可以看到每个信号都有一个编号和一个宏定义名称,这些宏定义可以在头文件signal.h中找到。而且可以发现的是没有32、33号信号。1-31号信号叫做普通信号,34-64号信号叫做实时信号
在这里对这些信号就不做详细的解释了,可以查看man手册 man 7 signal
Linux----详解信号


信号的产生

先从我们的生活中说起,我们生活中也有很多信号,“铃声”、“红绿灯”,Linux中的信号也是一样的,是为了提供一个机制在需要的时候告诉某个进程该怎样做。是一种规定,便于系统操作。
信号的发送者有很多,比如终端驱动程序,进程,系统等。而接收者大多是一个进程。
那怎样就算是给某进程发信号呢?事实上,给进程发信号就是修改目标进程PCB结构体中的关于信号的字段。由于进程是否接收到信号本身是一个原子问题。它要么收到,要么没收到。所以PCB中就用位图来表示进程是否收到信号,只需要修改一个比特位(操作系统完成):收到信号就置1,没收到就为0

信号产生条件:

  • 通过终端按键产生信号
  • 硬件异常产生的信号
  • 调用系统函数向进程发送信号
  • 由软件条件产生信号

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在解释信号产生条件之前,我们先来谈谈遇到信号我们该如何处理呢?
我们一共有三种解决方式:

  • 执行该信号的默认处理动作
  • 忽略该信号
  • 执行自定义动作

对于大部分的信号来说,默认处理动作,都是终止进程,当然还有继续、暂停等;
忽略该信号就是继续执行自己的操作,但需要注意的是有两个特殊信号SIGKILL信号和 SIGSTOP信号,这两个信号是不能被忽略的;
自定义动作,就是捕捉该信号,用户本身提供一个信号处理函数,内核在处理这个信号时,切换到用户态去执行这个函数,这就是捕捉一个信号。
来看看信号捕捉函数的原型:

 #include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
//signum:需要捕捉的信号
//handler:该信号需要进行的动作

我们可以发现,sighandler_t是一个函数指针,函数类型是void*,参数是int。接下来在说明信号产生时,我们再来做验证

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
接下来解释一下这四种情况:


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

当我们想让一个正在运行的程序终止时,我们经常会按下组合键 Ctrl+CCtrl+\ ,实际上Ctrl+C是给该进程发送了SIGINT信号,而Ctrl+\是给该进程发送SIGQUIT
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump
我们来验证一下这两个信号:
写个死循环,分别利用Ctrl+C和Ctrl+\终止程序

#include <stdio.h>
#include <stdlib.h>

int main()
{
    while(1)
    {
        printf("hello signal\n");
        sleep(1);
    }
    return 0;
}

Ctrl+C
Linux----详解信号
Ctrl+\ ,我们把代码修改一下,捕捉SIGINT信号,然后再利用Ctrl+\来终止

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>

void handler(int signo)
{
    printf("%d ,you can't kill me...\n",signo);
}

int main()
{
    signal(SIGINT,handler);
    while(1)
    {
        printf("hello signal\n");
        sleep(1);
    }
    return 0;
}

Linux----详解信号
由结果可以发现,我们按下的Ctrl+C确实是2号信号SIGINT,利用同样的方法也可以验证Ctrl+\


2. 硬件异常产生的信号

由硬件异常产生的信号是由硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为发送了SIGSEGV信号(11号信号)给进程,再例如当前进程出现了除零错误,CPU的运算单元会产生异常,内核将这个异常解释为发送了SIGFPE信号(8号信号)给进程。
来验证一下:在刚刚的代码中加入产生异常的代码

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>

void handler(int signo)
{
    printf("recv %d signal.\n",signo);
    exit(1);
}

int main()
{
    signal(SIGSEGV,handler);
    while(1)
    {
        printf("hello signal\n");
        sleep(1);
        int *p=(int*)10;
        *p=20;//访问非法内存
    }
    return 0;
}

Linux----详解信号
我们会发现,进程收到了11号信号SIGSEGV,验证了刚刚的说法


3.系统函数产生的信号

<1> kill函数

之前我们用过kill命令,这个命令可以显示信号列表同样也可以发送信号,这个kill命令内部实际是调用函数kill 实现的,来看看kill函数的原型

#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);
//pid:要给进程为pid的进程发送信号
//sig:要给该进程发送sig信号
//返回值:成功返回0,失败返回-1

了解了这个函数的原理之后,我们就可以根据kill函数来模拟命令kill,写一个mykill
上代码:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>


int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        printf("Usage:%s signo pid\n",argv[0]);
        return 0;
    }
    int signo = atoi(argv[1]);
    int pid = atoi(argv[2]);
    if(kill(pid,signo) < 0)
    {
        perror("kill");
        return -1;
    }
eturn 0;
}

我们再来写一个进程,来验证我们的mykill

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>

void handler(int signo)
{
    printf("Get a %d signal\n",signo);
}

int main()
{
    int i = 0;
    for(i = 1; i <= 31; i++)
    {
        signal(1, handler);
    }
    while(1)
    {
        printf("I am running\n");
        sleep(1);
    }
    return 0;
}

我们将尝试将普通信号全部捕捉,再开启一个终端来查看结果
Linux----详解信号
Linux----详解信号
结果可以很清楚的看到,mykill的效果,同时也验证了之前说的9号信号不能被捕捉

<2> raise函数

#include <signal.h>

int raise(int sig);
//sig:要发送的信号编号
//返回值:成功返回0,出错返回-1

我们可以发现这个函数只有一个该发送那个信号的参数,所以,这个函数的作用就是自己给自己发送信号,相当于这样调用kill函数

kill(getpid(), signo);

来验证一下:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>

void handler(int signo)
{
    printf("Get a %d signal\n",signo);
}

int main()
{
    signal(2,handler);
    while(1)
    {
        sleep(1);
        raise(2);
    }
    return 0;
}

我们利用raise函数给自己发送2号信号,再对2号信号进行捕捉,就会发现下面的结果
Linux----详解信号

<3> abort函数

#include <stdlib.h>

void abort(void);

abort函数没有返回值,这个函数的功能就是给本进程发送6号信号(SIGABRT)用来终止本进程,我们来验证一下

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>

void handler(int signo)
{
    printf("Get a %d signal\n",signo);
}

int main()
{
    signal(6,handler);
    while(1)
    {
        printf("I am running\n");
        sleep(1);
        abort();
    }
    return 0;
}

同样的,利用about函数给本进程发送6号信号,并捕捉它
Linux----详解信号


4.由软件条件产生信号

之前再讲管道的时候,我们谈论过一种场景:
写端在写数据,而读端已经关闭了文件描述符,这时候系统就会直接结束掉该进程
系统是怎么结束掉这个进程的呢?是操作系统给该进程发送了13信号(SIGPIPE),这就是由软件条件产生信号。
再来举一个例子:SIGALRM信号也是由软件条件产生的信号,我们在验证它之前先来说一个函数alarm,调用该函数可以设定一个闹钟,也就是告诉内核seconds秒之后,给进程发送SIGALRM信号,该信号的默认处理动作是终止该进程

#include <unistd.h>

unsigned int alarm(unsigned int seconds);
//seconds:seconds秒之后,闹钟响起,设置为0表示取消闹钟
//返回值:返回是0,或者是以前设定的闹钟还剩下的秒数
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int time=0;

int main()
{
    alarm(1);
    while(1)
    {
        time++;
        printf("time = %d\n",time);
    }
    return 0;
}

Linux----详解信号
在这里就不再捕捉该信号,读者可以自行验证


阻塞信号

信号有三种状态,分别是:信号未决、信号阻塞、信号递达
信号未决:信号从产生到抵达之间的状态
信号阻塞:被阻塞的信号产生时将一直保持在未决状态,直到进程解除对此信号的阻塞, 才执⾏递达的动作
信号递达:实际执行信号的处理动作(三种方式)
注:这里的信号阻塞和忽略是不一样的,阻塞是不让这个信号抵达,而忽略是信号已经递达了,只是递达的动作是忽略

我们再来看看这三种状态在内核中的表示,上张图:
Linux----详解信号
三种状态分别对应三张表

  • block—>信号阻塞
  • pending—>信号未决
  • handler—>信号递达(信号处理函数)

其中,前两张表都是位图来存储的。信号被阻塞就将相应位置1,否则置0。而pending表中,若置1则表示信号存在,0则相反。换句话说,pending表中的数据是判断信号是否存在的唯一依据。而第三张表,我们可以把它看做是一个函数指针数组,数组中的指针都指向信号处理函数。

来解释一下图中三个信号的状态:

  • SIGHUP信号:未阻塞也未产生过,当它递达时执行默认处理动作
  • SIGINT信号:产生过,但正在被阻塞,所以暂时不能递达。它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,进程仍有机会改变处理动作之后再解除阻塞
  • SIGQUIT信号:未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler

信号集及其操作函数

在Linux中,使用一个数据结构—–信号集 sigset_t来表示一组信号,定义如下:

/* A `sigset_t' has a bit for each signal.  */

# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
    unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;

在 /usr/include/目录下的 bits/sigset.h 文件可以找到,由该定义可见,sigset_t实际上是一个长整型数组,数组的每个元素的每个位表示一个信号。这种定义方式和文件描述符集fd_set类似
我们用这个类型可以表示每个信号的“有效”或“无效”状态。在阻塞信号集其含义是该信号是否被阻塞;在未决信号集中就代表该信号是否处于未决状态。
注:阻塞信号集也叫当前进程的信号屏蔽字(Signal Mask)

Linux提供了如下一组函数来设置、修改、删除和查询信号集

#include <signal.h>

//清空信号集
int sigemptyset(sigset_t *set);

//在信号集中初始化所有信号
int sigfillset(sigset_t *set);

//将信号signum添加至信号集set中
int sigaddset(sigset_t *set, int signum);

//将信号signum从信号集set中删除
int sigdelset(sigset_t *set, int signum);

//测试signum是否在信号集set中 
int sigismember(const sigset_t *set, int signum);

//返回值,前四个函数都是成功返回0,失败返回-1
//最后一个函数是判断某个信号是否在信号集中函数
//存在返回1,不存在返回0,出错返回-1

在使用sigset_t类型的变量之前,⼀定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。

除此之外,系统还提供了两个函数用来读取或更改当前进程的信号屏蔽字(block表)和未决信号集(pending表)

读取或更改进程的信号屏蔽字
#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
//how:如何更改
//set:指定新的信号屏蔽字
//oldset:保存原来的信号屏蔽字
//返回值:成功返回0,失败返回-1

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。

来看看how的取值:
Linux----详解信号

读取当前进程的未决信号集

#include <signal.h>

int sigpending(sigset_t *set);
//set:保存当前进程的未决信号集
//成功返回0,失败返回-1

我们来利用这些函数测试一下信号的存储
直接上代码:(注释解释)

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>

//捕捉2号信号,方便我们查看解除阻塞后的信号状态
void handler(int signo)
{
    printf("Get a %d signal\n",signo);
}
//显示未决信号表
void showpendind(sigset_t *set)
{
    int i=0;
    for(i=0;i<32;i++)
    {
        if(sigismember(set,i))//判断信号是否在信号集中
        {
            printf("1");
        }
        else
        {
            printf("0");
        }
        printf(" ");
    }
    printf("\n");
}


int main()
{
    sigset_t set;
    sigset_t oset;
    sigemptyset(&set);//清空信号集
    sigemptyset(&oset);

    sigaddset(&set,2);//将2号信号加入信号集中
    sigprocmask(SIG_BLOCK,&set,&oset);//设置信号屏蔽字,阻塞2号信号
    signal(2,handler);//当2号信号解除阻塞后捕捉它
    sigset_t  pset;
    int count=0;
    while(1)
    {
        sigpending(&pset);//读取信号未决表
        showpendind(&pset);
        sleep(1);
        if(count++==5)//count累加到5时,解除阻塞
        {
            printf("unblock signal\n");
            //恢复信号屏蔽字
            sigprocmask(SIG_SETMASK,&oset,NULL);
        }
    }
    return 0;
}

Linux----详解信号


捕捉信号

在上文中,我们已经了解过信号的一个捕捉函数signal了,现在我们再来学习一个信号捕捉函数 sigaction

#include <signal.h> 
int sigaction(int signo, const struct sigaction *act, 
                struct sigaction *oact);
//signo:要捕捉的信号
//act:若非空,表示根据act修改信号signo的处理动作
//oact:保存该信号原来的处理动作 
//返回值:成功返回0,失败返回-1

我们可以看到后两个参数的类型都是一个结构体,来看看这个结构体的结构:

struct sigaction 
{
       void  (*sa_handler)(int);
       void  (*sa_sigaction)(int, siginfo_t *, void *);
       sigset_t  sa_mask;
       int  sa_flags;
       void  (*sa_restorer)(void);
};

主要解释捕捉普通信号要用到的三个参数:
1.sa_handler 是一个常数,将其赋值为SIG_IGN表示忽略信号;赋值为SIG_DFL 表示执行系统默认动作;赋值为一个函数指针表示用自定义函数捕捉信号
2.sa_mask 说明一些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
3.sa_flags 默认设置设为0。

来看例子:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h> 

void handler(int signo)
{
    printf("Get a %d signal\n",signo);
}

int main()
{
    struct sigaction act;
    struct sigaction oact;

    act.sa_handler=handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags=0;

    sigaction(2,&act,&oact);

    while(1)
    {
        printf("I am running\n");
        sleep(1);
    }
    return 0;
}

Linux----详解信号

我们已经了解了两种信号捕捉的方式了,那在内核中到底是如何实现信号捕捉的呢?上张图帮助理解:
Linux----详解信号


再来了解一个函数pause()

#include <unistd.h>

int pause(void);
//使调用它的进程挂起,直到有信号递达
//返回值:如果信号的处理动作是终止进程则进程终止。pause函数没有机会返回 
//如果信号的处理动作是忽略,则进程继续处于挂起状态。pause不返回 
//如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回-1

在这里就不验证这个函数了,我们在下面可以根据上面了解的函数编写一个*的sleep函数,上代码:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    //捕捉闹钟信号之后什么都不做,因为sleep函数本来就是什么都不做
}

int mysleep(int sec)
{
    struct sigaction act;
    struct sigaction oact;

    act.sa_handler=handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags=0;
    sigaction(SIGALRM,&act,&oact);

    alarm(sec);//设定闹钟,sec秒后发SIGALRM信号给进程
    pause();//挂起进程

    int ret=alarm(0);//取消闹钟
    //恢复闹钟信号以前的处理函数,不能影响别的地方使用
    sigaction(SIGALRM,&oact,NULL);
    return ret;
}

int main()
{
    while(1)
    {
        printf("I am running\n");
        mysleep(1);
    }
    return 0;
}

在这里结果就不演示了,读者可以自行测试

细心的小伙伴会发现,这个程序其实有bug。
如果在设定闹钟之后内核调度优先级更高的进程取代了当前进程执行,sec秒钟之后闹钟超时了,内核发送SIGALRM信号给这个进程,处于未决状态。 这时优先级更高的进程执行完了,内核要调度回这个进程执行。SIGALRM信号递达,执行处理函数之后再次进入内核。 然后返回这个进程的主控制流程,alarm(sec)返回,调用pause()挂起等待。可是SIGALRM信号已经处理完了,还等待什么呢?
产生这个问题的主要原因就是系统运行的时序并不像我们写程序时所设想的那样。虽然看着alarm(sec)紧接着的下一行就是pause(),但是无法保证 pause()一定会在调用alarm(sec)之后的sec秒之内被调用。我们解决这个问题的方法就是想办法把解除信号屏蔽和挂起等待信号这两步能合并成一个原子操作,sigsuspend()函数就具有这个功能。

#include <signal.h>

int sigsuspend(const sigset_t *mask);

调用sigsuspend时,进程的信号屏蔽字由sigmask参数指定,可以通过指定sigmask来临时解除对某个信号的屏蔽,然后挂起等待。
当sigsuspend返回时,进程的信号屏蔽字恢复为原来的值,如果原来对该信号是屏蔽的,从sigsuspend返回后仍然是屏蔽的

来看看利用这个函数实现的mysleep

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{}

int mysleep(int sec)
{
    struct sigaction act;
    struct sigaction oact;

    sigset_t mask;
    sigset_t omask;
    sigset_t pmask;

    act.sa_handler=handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags=0;
    sigaction(SIGALRM,&act,&oact);

    sigemptyset(&mask);
    sigaddset(&mask,SIGALRM);
    sigprocmask(SIG_BLOCK,&mask,&omask);

    alarm(sec);
    pmask=omask;
    sigdelset(&pmask,SIGALRM);
    sigsuspend(&pmask);

    int ret=alarm(0);
    sigaction(SIGALRM,&oact,NULL);
    sigprocmask(SIG_SETMASK,&omask,NULL);
    return ret;
}

int main()
{
    while(1)
    {
        printf("I am running\n");
        mysleep(1);
    }
    return 0;
}