Linux进程信号详解

↘锁芯ラ 提交于 2019-12-28 06:16:10

信号是什么

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件

信号是多种多样的,并且一个信号对应一个事件,这样才能做到收到一个信号后,知道到底是一个什么事件,应该如何处理(但是要保证必须识别这个信号)

信号的种类

使用kill-l命令查看信号种类

查看信号种类

一共62种,其中131是非可靠信号,3464是可靠信号(非可靠信号是早期Unix系统中的信号,后来又添加了可靠信号方便用户自定义信号,这二者之间具体的区别在下文中会提到)

信号的生命周期

产生》》进程中的注册》》进程中的注销》》捕获处理

信号的产生

硬件事件举例:

  • 如果一个进程试图除以0,那么内核就发送给它一个SIGFPE信号(序号8)
  • 如果一个进程执行一条非法指令,那么内核就发送给它一个SIGILL信号(序号4)
  • 如果进程进行非法存储器引用(野指针、段错误),内核就发送给它一个SIGSEGV信号(序号11)

软件事件举例:

  • ctrl+c 中断信号——20) SIGTSTP

  • ctrl+| 退出信号——3) SIGQUIT

  • ctrl+z 停止信号——2) SIGINT

  • kill命令:kill -signum pid

    当kill命令不带-signum参数时(kill pid),默认的信号是15) SIGTERM

    kill -9 pid则是一个强大的“强杀”命令,能杀死kill pid杀不掉的处于T状态的进程

  • int kill(pid_t pid, int sig);

    kill命令的系统调用接口(在代码中使用kill)

    #include <sys/types.h>
    #include <signal.h>
    int kill(pid_t pid, int sig);
    //kill()系统调用可用于将任何信号发送到任何进程组或进程
    //第一个参数是一个进程的PID,第二个参数则是信号编号
    

    示例:

    #include <stdio.h>
    #include <unistd.h>
    #include <signal.h>
    int main(){ 
      kill(getpid(),SIGQUIT); //可以通过getpid()的方式获取自身的PID然后发信号给自己
      printf("hello/n");
      return 0;
    }
    

    运行结果:

    ubuntu@VM-0-7-ubuntu:/home/zeno/c_practice$ ./217_kill
    Quit (core dumped)
    
  • int raise(int signum);

    raise是一个库函数(#include <signal.h>),作用是发送信号到调用这个函数的进程/线程

    在单线程程序中,它等效于kill(getpid(), sig);(也就和上面的示例一样)

    在多线程程序中,它等效于pthread_kill(pthread_self(), sig);

  • void abort();

    abort是一个库函数(#include <stdlib.h>),作用是造成进程异常中止

    在进程中调用abort()就相当于调用了raise(3)

  • unsigned int alarm(unsigned int seconds);

    alarm是一个系统调用接口(#include <unistd.h>),在seconds秒后会将SIGALRM信号传递到调用进程

信号的注册

在pcb中有一个未决(pending)信号集合(未决(pending)的意思是信号产生了但还没有决定怎么做),信号的注册就是指在这个pending集合中标记对应信号数值的二进制位为1

上面的话有些难以理解,我们先来看看在linux内核源码里一个进程的信号是如何保存的

在linux内核源码sched.h中的task_struct结构体里有这样一段关于信号的内容:

/* signal handlers */
        struct signal_struct *signal;
        struct sighand_struct *sighand;

        sigset_t blocked, real_blocked;
        sigset_t saved_sigmask; /* restored if set_restore_sigmask() was used */
        struct sigpending pending;

上面最后一行的sigpending结构体定义在signal.h中:

struct sigpending {
        struct list_head list;
        sigset_t signal;
};

这里的signal就是用来做信号标记的,给一个进程发送一个信号说白了就是在signal里标记一下这个信号曾经来过

那么signal是如何进行标记的呢?还得继续了解一下sigset_t这个结构体

在bits/sigset.h中进行了以下定义:

/* 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;

注:这里的__sigset_t其实就是sigset_t,只是一个类型名的重定义

在这个结构体中只有一个数组成员,这个数组里存放着一些数作为位图,位图的每一个二进制位就代表了一种信号,0表示未曾收到这个信号,1表示已经收到这个信号

这里需要注意的是,真正存放信号的是数组中某个数的某个二进制位,数组的存在只是因为单独一个数的二进制位存不下这么多种类的信号

现在我们就可以理解,当使用上述方式对某一个进程发送一个信号时,操作系统就会将该进程对应的pending集合中表示相应信号的位图的二进制位由0改为1

但是非可靠信号和可靠信号的注册还有一点区别

为了理解这种区别我们还应该了解一下list_head链表和signal.h中的sigqueue结构体

list_head是linux内核提供的一个用来创建双向循环链表的结构,由于这个结构是没有数据域的所以较为复杂,在这里不做深究,有兴趣可以通过这篇博客详细了解

我们需要知道的是,内核通过一个以list为表头的链表将所有产生的信号都串在了一起,链表中的每个节点的结构是一个sigqueue:

/*
 * Real Time signals may be queued.
 */
struct sigqueue {
        struct list_head list;
        int flags;
        siginfo_t info;
        struct user_struct *user;
};

这个结构体保存信号所携带的信息

现在我们就可以对非可靠信号和可靠信号的区别有一定的了解了

  • 1~31非可靠信号的注册:

    当试图对一个进程发送一个非可靠信号时,若发现位图上对应的位为0,则置为1,并在list_head链表里加入一个sigqueue节点;若发现位图上对应的位已经为1,则直接返回。简单地说就是若信号还未注册,则注册一下,若已经注册,则什么都不做

  • 34~64可靠信号的注册:

    当试图对一个进程发送一个可靠信号时,若发现位图上对应的位为0,则置为1,并在list_head链表里加入一个sigqueue节点;若发现位图上对应的位已经为1,对该位不进行操作但依旧在链表里加入一个节点。也就是说,每次对进程发送一个可靠信号时,不管该进程之前是否收到过相同的信号,总是会在list_head链表里加入sigqueue节点

对于信号来说,位图只是用来标记有没有待处理信号的,而节点才是信号真正注册的信息

信号的注销

看上文中信号的生命周期会发现,在处理信号之前,会先销毁信号的信息

信号注销存在的目的就是为了抹除信号存在的痕迹,防止对同一个信号进行多次处理

删除要处理的信号sigqueue节点:

  • 若信号是非可靠信号,则直接将位图置0(非可靠信号在没有处理之前只会注册一次)
  • 若信号是可靠信号,则删除后需要判断是否还有相同节点,没有的话才会重置位图为0

信号的捕获处理

在学习信号的捕获和处理之前我们还需要了解一下信号的阻塞

信号的阻塞

信号的阻塞就是阻止一个信号的抵达,当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞

在pcb中,有一个阻塞信号集合(blocked位图,实现方式与pending相同),凡是添加到这个集合中的信号,都表示需要阻塞,暂时不处理

那么该如何实现对一个进程的某个信号进行阻塞呢?

我们可以通过sigprocmask函数显式地阻塞和取消阻塞选择的信号:

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数中的how表示了当前要对blocked集合进行的操作,它的值可从下面三个宏定义中选择一个填入:

  • SIG_BLOCK:添加set中的信号到blocked中(blocked = blocked | set)
  • SIG_UNBLOCK:从blocked中删除set中的信号(blocked = blocked & ~set)
  • SIG_SETMASK:blocked = set

在这个函数中,如果oldset非空,blocked位图以前的值会保存在oldset中

捕获信号与处理信号

接着我们就可以来研究一下捕获信号

当内核准备将控制传递给一个进程时,它会检查该进程的未被阻塞的待处理信号的集合,也就是存在于pending集合中同时又不存在于blocked集合中(pending & ~blocked),如果这个集合为空(通常情况下),那么内核将控制传递到该进程中的下一条指令里

然而,如果该集合是非空的,那么内核会选择集合中的某个信号(通常是序号最小的信号),并且强制该进程接收该信号,收到这个信号会触发进程的某种行为。一旦进程完成了这个行为,那么控制就传递回该进程中的下一条指令

这里的“行为”,就是进程对信号的处理

处理的实现是调用一个信号处理函数signal:

#include <signal.h>
typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

这里的sighandler_t是一个函数指针类型,signal函数的第一个参数就是信号的序号,我们就可以通过第二个参数来改变处理信号signum的方式:

  • 如果handler是SIG_IGN,那么忽略类型为signum的信号
  • 如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为
  • 在其它情况下,handler是一个用户定义的函数的地址,也就是指向一个信号处理程序的函数指针,只要进程收到一个类型为signum的信号,就会调用这个函数

需要注意的是,在所有信号中,有两个信号不可被阻塞,不可被自定义修改处理方式,也不可被忽略,这两个信号分别是9) SIGKILL19) SIGSTOP

一般情况下对于信号的捕获和处理都是一起被提到的,上文中对“捕获”和“处理”的分界可能并不是特别准确,在《深入理解计算机系统》中对捕获信号和处理信号的定义如下:

调用信号处理程序称为捕获信号,执行信号处理程序称为处理信号

现在我们通过一个具体的例子来感受一下信号的阻塞与接触阻塞的操作和信号的捕获处理以及可靠信号与非可靠信号的区别:

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

void sigcb(int signum){ 
  printf("receive a signal:%d\n",signum);
}
int main(){ 
  signal(SIGINT, sigcb);//修改停止信号(序号2,键盘ctrl+z)的处理方式为调用sigcb函数
  signal(40, sigcb);//修改信号40的处理方式为调用sigcb函数

  //阻塞所有信号
  sigset_t set, old;
  sigemptyset(&set);//清空信号集合
  sigemptyset(&old);//清空信号集合

  //sigaddset(int signum, sigset_t *set)将指定信号添加到集合
  sigfillset(&set);//将所有的信号都添加到set集合中
  sigprocmask(SIG_BLOCK, &set, &old);//将set中的信号添加到blocked中造成信号阻塞

  printf("presse enter to continue:\n");
  getchar();//在按下回车之前,程序卡在这里

  sigprocmask(SIG_UNBLOCK, &set, NULL);//解除阻塞
  return 0;
}

运行程序并分别通过ctrl+c和kill命令多次发送2号和40号信号:

zeno@VM-0-7-ubuntu:~$ ./mask
presse enter to continue:
^C^C^C^C^C^C^C^C^C^C
zeno@VM-0-7-ubuntu:~$ ps -ef | grep mask | grep -v grep
zeno     29043 27880  0 14:09 pts/10   00:00:00 ./mask
zeno@VM-0-7-ubuntu:~$ kill -40 29043
zeno@VM-0-7-ubuntu:~$ kill -40 29043
zeno@VM-0-7-ubuntu:~$ kill -40 29043
zeno@VM-0-7-ubuntu:~$ kill -40 29043

可以看到对于该进程,不论是非可靠信号(2)还是可靠信号(40),都被阻塞导致无法处理,但是接下来按下回车,所有阻塞都会被解除:

zeno@VM-0-7-ubuntu:~$ ./mask 
presse enter to continue:
^C^C^C^C^C^C^C^C^C^C
receive a signal:40
receive a signal:40
receive a signal:40
receive a signal:40
receive a signal:2

在这里我们就可以发现,虽然信号2和信号40都曾发送多次,但是只有信号40也就是可靠信号被处理了多次,而信号2也就是可靠信号只调用了一次信号处理函数,这也就印证了我们上文中所提到的可靠信号与非可靠信号的区别

至此我们就已经大致了解了什么是进程信号和信号的工作过程

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!