Linux 信号详解

61 阅读15分钟

一、基本概念

信号是Unix、类Unix以及其他POSIX兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统中断了进程正常的控制流程,此时,任何非原子操作都将被中断。
在Linux系统中,信号可能是由于系统中某些错误而产生,也可以是某个进程主动生成的一个信号。
由于某些错误条件而生成的信号例如内存段冲突、浮点处理器错误或非法指令等。
Android由于内核基于Linux,应用在发生native崩溃和anr时,系统也会发送特定信号来杀死进程。因此,我们捕获native崩溃和anr可以通过监听对应信号来实现。

二、信号的类型

通过kill -l命令可以查看当前操作系统中的信号。不同平台上的信号值可能会不一样,实际使用中使用SIG开头的宏。 image.png

非实时信号

Linux信号机制基本上是从Unix系统中继承过来的。早期Unix系统中的信号机制比较简单和原始,后来在实践中暴露出一些问题,因此,把那些建立在早期机制上的信号叫做"不可靠信号",也叫非实时信号。
信号值小于SIGRTMIN的信号都是不可靠信号。这就是"不可靠信号"的来源。它的主要问题是:

  • 进程每次处理信号后,就将对信号的响应设置为默认动作。在某些情况下,将导致对信号的错误处理;因此,用户如果不希望这样的操作,那么就要在信号处理函数结尾再一次调用signal(),重新安装该信号。
  • 信号可能丢失,后面将对此详细阐述。 
    因此,早期unix下的不可靠信号主要指的是进程可能对信号做出错误的反应以及信号可能丢失。

Linux支持不可靠信号,但是对不可靠信号机制做了改进:在调用完信号处理函数后,不必重新调用该信号的安装函数(信号安装函数是在可靠机制上的实现)。因此,Linux下的不可靠信号问题主要指的是信号可能丢失。

非实时信号对应的含义大致如下 image.png

实时信号

实时信号也叫可靠信号,是 Linux 中的扩展信号类型,由整数编号表示,信号值位于SIGRTMIN和SIGRTMAX之间。 实时信号支持排队,不会丢失。实时信号是POSIX标准的一部分,可用于应用进程。

一句话:非实时信号都不支持排队,都是不可靠信号;实时信号都支持排队,都是可靠信号。
本文主要介绍非实时信号。

三、信号的发送

信号有两个来源:

  • 硬件来源(比如我们按下了键盘或者其它硬件故障);
  • 软件来源,最常用发送信号的系统函数是kill, raise, killpg和setitimer以及sigqueue函数,软件来源还包括一些非法运算等操作。

1. kill命令

kill 命令是 Linux 中最常用的发送信号的命令,语法如下:

kill [-signal] PID

其中,-signal 可选参数表示要发送的信号类型,如果省略该参数,则默认发送 SIGTERM 信号。PID 表示接收信号的进程 ID。 例如给如下进程pro3发送kill命令。

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

void segfault(int dummy){
    printf("receive signal %d\n", dummy);
    exit(1);
}

int main(){
    signal(SIGINT, segfault);
    signal(SIGTERM, segfault);
    while(1){
    }
    return 0;
}

加参数发送SIGINT信号 image.png image.png

不加参数 image.png image.png

2. kill函数

函数原型

int kill(pid_t pid, int sig);

第一个参数pid的值,决定了kill函数的不同含义,具体来讲,可以分成以下几种情况。

  • pid>0:发送信号给进程ID等于pid的进程。**
  • pid=0:发送信号给调用进程所在的同一个进程组的每一个进程。**
  • pid=-1:有权限向调用进程发送信号的所有进程发出信号,init进程和进程自身除外。**
  • pid<-1:向进程组-pid发送信号。

当函数成功时,返回0,失败时,返回-1,并置errno。
另外,如果调用kill函数时,第二个参数signo的值为0,kill函数其实并不是真的向目标进程或进程组发送信号,而是用来检测目标进程或进程组是否存在。如果kill函数返回-1且errno为ESRCH,则可以断定我们关注的进程或进程组并不存在。
常见的调用方式如下:

//进程pro4
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void segfault(int dummy){
    printf("receive signal %d\n", dummy);
    exit(1);
}

int main(){
     if (signal(SIGINT, segfault) == SIG_ERR)
    {
        perror("signal SIGINT error");
        exit(EXIT_FAILURE);
    }
     if (signal(SIGTERM, segfault) == SIG_ERR)
    {
        perror("signal SIGTERM error");
        exit(EXIT_FAILURE);
    }
    while(1){
    }
    
    return 0;
}
//进程pro3
int main(int argc, char *argv[])
{
   if(argc!=3){
           cout<<"wrong usage"<<endl;
   }
   kill(atoi(argv[2]), atoi(argv[1]));
   return 0;
}

先启动pro4
image.png

查找pro4的进程号,然后启动pro3,结果如下 image.png image.png

3. raise

raise 函数是一个简单的发送信号的函数,可以用来向当前进程发送信号。raise 函数的原型如下:

int raise(int sig);

示例如下

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

void segfault(int dummy){
    printf("receive signal %d\n", dummy);
    exit(1);
}

int main(){
     if (signal(SIGINT, segfault) == SIG_ERR)
    {
        perror("signal error");
        exit(EXIT_FAILURE);
    }
    if (raise(SIGINT) == -1) {
        perror("raise");
        return 1;
   }

    return 0;
}

image.png

4. killpg

向进程组发送信号,进程组所有进程都会收到信号。

int killpg(int pgrp, int sig);

示例如下

#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
#include<string.h>
#include<signal.h>

void handler(int sig);

int main(int argc, char *argv[])
{
    if (signal(SIGUSR1, handler) == SIG_ERR)
    {
        perror("signal error");
        exit(EXIT_FAILURE);
    }
        
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork error");
        exit(EXIT_FAILURE);
    }

    if (pid == 0)
    {
        /*
            pid = getpgrp(); // 得到进程组pid
            kill(-pid, SIGUSR1); //向进程组发送信号
        */
        killpg(getpgrp(), SIGUSR1);
        exit(EXIT_SUCCESS); // 子进程处理完信号才退出
    }

    int n = 5;
    do
    {
        n = sleep(n); // sleep会被信号打断,返回unslept的时间
    }
    while (n > 0);

    return 0;
}

void handler(int sig)
{
    printf("recv a sig=%d\n", sig);
}

image.png

5. pthread_kill

如果在多线程程序中需要向另一个线程发送信号,可以使用 pthread_kill 函数。pthread_kill 函数的原型如下:

/**
 * 向同一个进程内的线程发送信号。
 * 成功返回0,失败返回错误码。
 * 如果sig = 0,将不发送任何信号,可以用来检测目标进程是否存在,返回错误码为ESRCH。
 * 如果向一个已经退出的线程发送信号,将会引发未定义的行为。比如说段错误等。
 * 编译的时候加上-pthread选项
 *
*/
int pthread_kill(pthread_t thread, int sig);

示例如下

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

void handler(int sig);
  
void *func()/*1秒钟之后退出*/  
{  
    if (signal(SIGTRAP, handler) == SIG_ERR)
    {
        perror("signal error");
        pthread_exit((void *)1);
    }
    while(1){}
    printf("线程(ID:0x%x)退出。\n",(unsigned int)pthread_self());  
    pthread_exit((void *)0);  
}  
  
void test_pthread(pthread_t tid) /*pthread_kill的返回值:成功(0) 线程不存在(ESRCH) 信号不合法(EINVAL)*/  
{  
    int pthread_kill_err;  
    pthread_kill_err = pthread_kill(tid,5);  

    if(pthread_kill_err != 0)  
        printf("给ID为0x%x的线程发送信号失败。\n",(unsigned int)tid);  
    else  
        printf("给ID为0x%x的线程发送信号成功。\n",(unsigned int)tid);  
}  
  
int main()  
{  
    int ret;  
    pthread_t tid;  

    pthread_create(&tid,NULL,func,NULL);  

    sleep(3);

    test_pthread(tid);

    sleep(3);

    exit(0);  
}

void handler(int sig)
{
    printf("recv a sig=%d\n", sig);
}

image.png

6. sigqueue

函数原型如下:

/**
 * 向进程ID等有pid的进程发送信号和数据
 * 成功返回0,失败返回-1并置errno
 * 
 * 第三个参数为伴随数据,可以向目标进程发送一个整形数据或者指针,当目标进程注册
 * 信号处理函数使用了SA_SIGINFO标志位的时候就可以接收到该数据
 *
 *     union sigval {
 *         int   sival_int;
 *         void *sival_ptr;
 *     };
 *
 * 发送sig = 0的信号可以用来检测目标进程是否存在,返回错误码为ESRCH
*/
 
#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval value);

示例如下

//进程pro4
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void segfault(int dummy){
    printf("receive signal %d\n", dummy);
    exit(1);
}

int main(){
     if (signal(SIGINT, segfault) == SIG_ERR) {
        perror("signal");
        return 1;
    }

   while(1){}

    return 0;
}
//进程pro3
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <signal.h>
 
int main(int argc, char *argv[])
{
    pid_t pid = atoi(argv[1]);           // 获取指定进程的 PID
    int signum = atoi(argv[2]);          // 获取要发送的信号编号
 
    printf("pid=%d\n", getpid());        // 打印当前进程的 PID
 
    union sigval value;
    value.sival_int = 666;               // 设置传递的整型值
 
    int ret = sigqueue(pid, signum, value);   // 向指定进程发送信号
    if (ret == -1) {
        perror("发送信号失败");             // 发送信号失败时打印错误信息
        exit(-1);
    }
 
    return 0;
}

先启动pro4

image.png

找到pro4的进程号,然后启动进程pro3 image.png image.png

四、信号的处理

信号发出后,对应的进程或者线程有三种处理方式:

  1. 忽略信号,不做任何处理。
  2. 执行信号的默认行为。
  3. 执行设置的自定义处理函数。

以上三种处理方式都可以在注册信号处理函数时设置。如果不注册信号处理函数,进程接收到信号时会执行默认行为,每个信号对应的默认行为可以通过man -7 signal查看: image.png 我们直观的看到,Action中有:Term、Core、Ign、Cont、Stop。在其中主要是Term、Core两种。Term 就是终止的意思。那么Core呢?Core也是有终止的意思,但是在终止进程前,还会生成一个核心转储(core dump)文件
上面截图中最后也告诉我们,SIGKILL和SIGSTOP两个信号不能被捕获、阻塞和忽略,也就是不允许我们处理,只能执行默认动作。

对信号的处理首先是要注册处理信号的handler,注册函数包括signal和sigaction。

1. signal

函数原型如下

#include <signal.h> 
 
void (*signal(int signum, void (*handler)(int)))(int);

signal函数第一个参数是信号值,第二个参数是处理方式,值包括SIG_IGN、SIG_DFL和自定义函数,对应上面提到的三种处理方式。
signal 函数返回一个函数指针,指向之前注册的信号处理函数。如果注册信号处理函数失败,则返回 SIG_ERR。 示例可以参考上面信号的发送部分代码。
signal函数是早期的处理函数,在不同的Linux版本上有一些区别,可移植性方面不如sigaction,因此官方更推荐使用sigaction。

2. sigaction

早期的unix系统,signal函数有两个问题。

  • 当signal函数注册的handler被调用时,对应信号的行为会被重置为SIG_DFL,即默认行为。
  • 在handler函数没有执行完时对应的信号不会被阻塞,导致如果继续有信号发送过来,可能会导致handler递归调用。

sigaction函数可以规避上述两个问题,而且不存在兼容性问题,更稳定更强大。sigaction函数原型如下:

#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

第一个参数signum表示除了SIGKILL和SIGSTOP外的其他信号值,第二个act表示此次设置的新的处理行为,第三个oldact顾名思义表示上一个处理行为。两个处理行为都是结构体sigaction,sigaction结构如下:

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

sa_handler:指定信号处理函数。
sa_flags:用来设置信号处理的其他相关操作,sa_flags的值如下

SA_RESETHAND:当设置该标记之后,信号发生之后只有第一次触发指定的sa_handler,之后将重置使用系统的默认处理函数SIG_DFL

SA_RESTART:当设置该标记之后,如果信号中断(EINTR)了进程的系统调用,则系统自动重启该系统调用。eg:读取文件或者socket的时候(read),如果发生信号中断,则会read会返回错误,并且设置errno为EINTR。若设置该标记之后则不会产生EINTR错误。

SA_NODEFER :默认情况下,当信号函数运行时,内核将阻塞(不可重入)给定的信号,直至当次处理完毕才开始下一次的信号处理。但是设置该标记之后,那么信号函数将不会被阻塞,此时需要注意函数的可重入安全性。

SA_NOCLDSTOP:如果设置了该标记,则子进程停止的时候不在产生SIGCHILD消息,只有终止的时候才产生。该标记仅仅对SIGCHLD有效。(SIGCHLD一般在子进程暂停或者终止的时候产生)

SA_NOCLDWAIT:如果设置该标记,那么子进程退出的时候将不会进入僵尸状态,此时仍然会收到SIGCHLD信号,只是waitpid将会失败。仅仅对SIGCHLD有效。

SA_ONSTACK:当信号传递时,信号处理程序在进程的堆栈上执行。 如果在sigaction()中使用SA_ONSTACK,则使用不同的堆栈。

SA_SIGINFO:配合sa_sigaction一起使用

sa_sigaction:需要配合SA_SIGINFO一起使用,如果设置了SA_SIGINFO,则信号处理函数将由sa_sigaction代替sa_handler,二者只能赋值其一,否则会以最后一次赋值为准。
sa_restorer:已经被废弃,不再使用。
sa_mask:用来设置在处理该信号时暂时将sa_mask 指定的信号集阻塞。

设置sa_mask的目的

在调用信号处理函数时就能阻塞某些信号,注意仅仅是在信号处理函数正在执行时才能阻塞某些信号,如果信号处理程序执行完了,那么依然能接收到这些信号。
在信号处理函数被调用时,操作系统建立的新信号屏蔽字包括正被递送的信号,也就是说自己也被阻塞,除非设置SA_NODEFER。因此保证了在处理一个给定信号时,如果这个信号在此发生,通常不会将它们排队,如果在某种信号被阻塞时它发生了5次,那么对这种信号解除了阻塞后,其信号处理函数通常只会被调用一次。
对于不同信号
当信号A被捕捉到并且信号A的handler正被调用时,信号B产生了。

  • 如果信号B没有被阻塞,那么正常接收信号B并调用自己的信号处理程序。另外,如果信号A的信号处理程序中有sleep函数,那么当进程接收到信号B并处理完后,sleep函数立即返回(如果睡眠时间足够长的话)。
  • 如果信号B有被设置成阻塞,那么信号B被阻塞,直到信号A的信号处理程序结束,信号B才被接收并执行信号B的信号处理程序。如果在信号A的信号处理程序正在执行时,信号B连续发生了多次,那么当信号B的阻塞解除后,信号B的信号处理程序只执行一次。

对于相同信号
当一个信号A被捕捉到并且信号A的handler正被调用时(未设置SA_NODEFER)又产生了一个信号A,第二次产生的信号被阻塞,直到第一次产生的信号A处理完后才被递送。
如果连续产生了多次信号,当信号解除阻塞后,信号处理函数只执行一次。

我们来看个例子

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

void signal_handler(int sig,siginfo_t *info,void *ctx)
{
    printf("Receive signal %d\n", sig);
    exit(1);
}

int main(int argc, char *argv[])
{
    struct sigaction new_action, old_action;
	new_action.sa_sigaction = signal_handler;
    new_action.sa_flags = SA_SIGINFO;

	// 调用sigemptyset函数将sa_mask成员的所有位初始化为0
	sigemptyset(&new_action.sa_mask);

    if (sigaction(SIGINT, &new_action, &old_action) == -1) {
        perror("sigaction error");
        return 1;
    }

	for (; ; ){	}
	
	return 0;
}

五、其他函数

一个信号可能会被屏蔽,屏蔽意味着信号不会被处理,直到对该信号解除屏蔽。
进程中的每个线程都包含一个信号集,这个信号集是当前被屏蔽的的信号集合。我们可以通过如下函数来操作信号集。

1. sigprocmask

sigprocmask()是一个系统调用,用于设置和修改当前进程的信号屏蔽集。在信号处理程序运行期间,为避免在处理一个信号时被另一个信号打断,可以将某些信号添加到进程的信号屏蔽集中。

> int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

how 参数指定了对信号屏蔽字的修改方式,可以是以下三个值之一:

  • SIG_BLOCK:将 set 中的信号添加到进程的信号屏蔽字中。
  • SIG_UNBLOCK:将 set 中的信号从进程的信号屏蔽字中移除。
  • SIG_SETMASK:将进程的信号屏蔽字设置为 set 中的信号。

2. pthread_sigmask

这个函数与sigprocmask很相似。和sigprocmask的区别是,sigprocmask用于单线程的进程,而pthread_sigmask用于多线程环境。

int pthread_sigmask(int how,const sigset_t *set,sigset_t *oldset);

3. sigset_t相关操作函数

下面是一些对信号集sigset_t的常见操作。

int sigemptyset(sigset_t *set);                          //函数初始化信号集set并将set设置为空
int sigfillset(sigset_t *set);                                 //函数初始化信号集,但将信号集set设置为所有信号的集合
int sigaddset(sigset_t *set,int signo);           //将信号signo加入到信号集中去
int sigdelset(sigset_t *set,int signo);            //从信号集中删除signo信号
int sigismemeber(sigset_t* set,int signo); //判断某个信号是否在信号集中

总结

本文讲解了信号的产生、接收和处理流程,以及信号的屏蔽机制。了解了这些后,我们可以对信号机制有了一个大致的概念,当然还有一些信号的其他相关函数,我们可以在使用中再去深入了解。

总之,信号在Linux中扮演着多种重要角色,使进程之间的通信和控制更加灵活和强大。了解信号的概念和使用方法对于开发、调试和系统管理都非常重要。