一篇文章带你彻底弄懂Linux中的信号


一、信号类型

信号(signal)是一种软中断,它本质上是在软件层次上对硬件中断机制的一种模拟。信号传递的信息量相对较少,但便于管理和使用,可以用于系统管理相关的任务,例如通知进程终结、中止或者恢复等。信号机制是进程间通信的一种方式,采用异步通信方式。

Linux系统定义了64种信号,分为两类:可靠信号与不可靠信号,前32种信号为不可靠信号,后32种为可靠信号。

1.概念

  • 不可靠信号: 也称为非实时信号,不支持排队,信号可能会丢失, 比如发送多次相同的信号, 进程只能收到一次,信号值取值区间为1~31。

  • 可靠信号: 也称为实时信号,支持排队, 信号不会丢失, 发多少次, 就可以收到多少次. 信号值取值区间为32~64。

2.信号表

在终端,可通过kill -l查看所有的signal信号。

在这里插入图片描述

取值名称解释默认动作
1SIGHUP挂起
2SIGINT中断
3SIGQUIT退出
4SIGILL非法指令
5SIGTRAP断点或陷阱指令
6SIGABRTabort发出的信号
7SIGBUS非法内存访问
8SIGFPE浮点异常
9SIGKILLkill信号不能被忽略、处理和阻塞
10SIGUSR1用户信号1
11SIGSEGV无效内存访问
12SIGUSR2用户信号2
13SIGPIPE管道破损,没有读端的管道写数据
14SIGALRMalarm发出的信号
15SIGTERM终止信号
16SIGSTKFLT栈溢出
17SIGCHLD子进程退出默认忽略
18SIGCONT进程继续
19SIGSTOP进程停止不能被忽略、处理和阻塞
20SIGSTP进程停止
21SIGTTIN进程停止,后台进程从终端读数据时
22SIGTTOU进程停止,后台进程向终端写数据时
23SIGURGI/O有紧急数据到达当前进程默认忽略
24SIGXCPU进程的CPU时间片到期
25SIGXFSZ文件的大小超出上限
26SIGVTALRM虚拟时钟超时
27SIGPROFprofile时钟超时
28SIGWINCH窗口大小改变默认忽略
29SIGIOI/O相关
30SIGPWR关机默认忽略
31SIGSYS系统调用异常

对于signal信号,绝大部分的默认处理都是终止进程或停止进程,或dump内核映像转储。 上述的31的信号为非实时信号,其他的信号32-64 都是实时信号。

Core Dump(核心转储)

首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。

打开 core dump 功能:

  • 在终端中输入命令 ulimit -c ,输出的结果为 0,说明默认是关闭 core dump 的,即当程序异常终止时,也不会生成 core dump 文件。
  • 我们可以使用命令 ulimit -c unlimited 来开启 core dump 功能,并且不限制 core dump 文件的大小; 如果需要限制文件的大小,将 unlimited 改成你想生成 core 文件最大的大小,注意单位为 blocks(KB)。
  • 用上面命令只会对当前的终端环境有效,如果想永久生效,可以修改文件 /etc/security/limits.conf文件。

二、信号产生

信号来源分为硬件类和软件类:

1.硬件方式

  • 用户输入:比如在终端上按下组合键ctrl+C,产生SIGINT信号;
  • 硬件异常:CPU检测到等异常,通知内核生成相应信号,并发送给发生事件的进程;

2.软件方式

通过系统调用,发送signal信号:kill(),raise(),sigqueue(),alarm(),setitimer(),abort()

  • kernel,使用 kill_proc_info()等
  • native,使用 kill() 或者raise()等
  • java,使用 Procees.sendSignal()等

三、信号的注册和注销

1.注册

在进程task_struct结构体中有一个未决信号的成员变量 struct sigpending pending。每个信号在进程中注册都会把信号值加入到进程的未决信号集。

  • 非实时信号发送给进程时,如果该信息已经在进程中注册过,不会再次注册,故信号会丢失;
  • 实时信号发送给进程时,不管该信号是否在进程中注册过,都会再次注册。故信号不会丢失;

2.注销

  • 非实时信号:不可重复注册,最多只有一个sigqueue结构;当该结构被释放后,把该信号从进程未决信号集中删除,则信号注销完毕;
  • 实时信号:可重复注册,可能存在多个sigqueue结构;当该信号的所有sigqueue处理完毕后,把该信号从进程未决信号集中删除,则信号注销完毕;

四、信号处理

相关概念:

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

接下来我们分析一下Linux对信号处理机制的实现原理。

在进程管理结构 task_struct 中有几个与信号处理相关的字段,如下:

struct task_struct { 
    ... 
    int sigpending; //表示进程是否有信号需要处理(1表示有,0表示没有)
    ... 
    struct signal_struct *sig; //表示信号相应的处理方法,其类型是 struct signal_struct
    sigset_t blocked; //表示被屏蔽的信息,每个位代表一个被屏蔽的信号
    struct sigpending pending; //用pending队列来接收信号
    ... 
} 

其中signal_struct 结构如下:

#define  _NSIG  64 
 
struct signal_struct { 
	 atomic_t  count; 
	 struct k_sigaction action[_NSIG]; 
	 spinlock_t  siglock; 
}; 
 
typedef void (*__sighandler_t)(int); 
 
struct sigaction { 
	 __sighandler_t sa_handler; 
	 unsigned long sa_flags; 
	 void (*sa_restorer)(void); 
	 sigset_t sa_mask; 
}; 
 
struct k_sigaction { 
 	struct sigaction sa; 
}; 

可以看出,signal_struct 是个比较复杂的结构,其 action 成员是个 struct k_sigaction 结构的数组,数组中的每个成员代表着相应信号的处理信息,而 struct k_sigaction 结构其实是 struct sigaction 的简单封装。

我们再来看看 struct sigaction 这个结构,其中 sa_handler 成员是类型为 __sighandler_t 的函数指针,代表着信号处理的方法。

最后我们来看看 struct task_struct 结构的 pending 成员,其类型为 struct sigpending,存储着进程接收到的信号队列,struct sigpending 的定义如下:

struct sigqueue { 
 	struct sigqueue *next; 
 	siginfo_t info; 
}; 
 
struct sigpending { 
 	struct sigqueue *head, **tail; 
 	sigset_t signal; 
}; 

当进程接收到一个信号时,就需要把接收到的信号添加 pending 这个队列中。

以上的这些数据结构组织起来便如下图所示:

在这里插入图片描述

1.信号集操作函数

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo); 
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
  • 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置1,表示该信号集的有效信号包括系统支持的所有信号。
  • 在使用sigset_ t类型的变量之前,一定要调用sigemptysetsigfillset 做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddsetsigdelset在该信号集中添加或删除某种有效信号。
  • 这四个函数都是成功返回0,出错返回-1。
  • sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。

2.其它操作函数

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(block)。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1 

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

SIG_BLOCK:将set指向信号集中的信号,添加到进程阻塞信号集;
SIG_UNBLOCK:将set指向信号集中的信号,从进程阻塞信号集删除;
SIG_SETMASK:将set指向信号集中的信号,设置成进程阻塞信号集;

调用函数sigpending可以读取当前进程的未决信号集,

#include <signal.h>
int sigpending(sigset_t *set);

现在我们用上述函数来测试一下信号递达的过程:首先是对SIGINT信号进行阻塞,然后通过ctrl+c 发送SIGINT 信号,发现SIGINT信号在pending位图中别标记为1,但是信号未决,直到解除对SIGINT信号的屏蔽,SIGINT信号递达,后续再发送SIGINT信号,会被直接递达,因为ISGINT并没有被阻塞。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>

using namespace std;

#define MAX_SIGNUM 31
vector<int> sigarr = {2};

void handler(int signo)
{
    cout<<signo<<"已经被递达!"<<endl;
}

void show_pending(const sigset_t &s)
{
    for (int signo = MAX_SIGNUM; signo >= 1; signo--)
    {
        if (sigismember(&s, signo))
            cout<<"1";
        else
            cout<<"0";
    }
    cout<<endl;
}

int main()
{
    for(const auto &sig : sigarr) signal(sig,handler);

    sigset_t block, oblock, pending;
    
    // sigset_t类型的数据使用前要初始化
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);

    // 添加屏蔽的信号
    for (const int &sig : sigarr)
        sigaddset(&block, sig);

    // 开始屏蔽,设置进内核
    sigprocmask(SIG_SETMASK, &block, &oblock);

    // 打印block信号集
    cout<<"最初的block集:\n";
    show_pending(block);
    cout<<"--------------------------"<<endl;


    int cnt = 5;
    while (true)
    {
    	//读取并打印pending信号集
        sigpending(&pending);
        show_pending(pending);
        sleep(1);
        if(cnt-- == 0)
        {
            sigprocmask(SIG_SETMASK,&oblock,&block);
            cout<<"恢复对信号的屏蔽,block位图如下:"<<endl;
            show_pending(oblock);
            cout<<"--------------------------"<<endl;
        }
    }

    return 0;
}

代码运行结果如下:

在这里插入图片描述

3.信号捕捉

对于上述代码,信号捕捉是一个很重要的中间过程,接下来我们看看信号是如何被捕捉然后递达的。

我们借用kill()系统调用发送一个信号给指定的进程为例:

int kill(pid_t pid, int sig); 

参数 pid 指定要接收信号进程的ID,而参数 sig 是要发送的信号。kill() 系统调用最终会进入内核态,并且调用内核函数 sys_kill(),代码如下:

sys_kill(int pid, int sig) 
{ 
	 struct siginfo info; 
	 
	 info.si_signo = sig; 
	 info.si_errno = 0; 
	 info.si_code = SI_USER; 
	 info.si_pid = current->pid; 
	 info.si_uid = current->uid; 
	 
	 return kill_something_info(sig, &info, pid); 
} 

这里需要注意,此时OS会从用户态进入内核态,然后调用内核函数!

对于后续的一些细节,我们做部分省略,只保留主干过程:

上面介绍了怎么发送信号给指定的进程,但是什么时候会触发信号相应的处理函数呢?为了尽快让信号得到处理,Linux把信号处理过程放置在进程从内核态返回到用户态前,也就是ret_from_sys_call 处,其中细节忽略不计,由于信号处理程序是由用户提供,所以信号处理程序的代码是在用户态的。而从系统调用返回到用户态前还是属于内核态,CPU是禁止内核态执行用户态代码的,那么怎么办?

答案先返回到用户态执行信号处理程序,执行完信号处理程序后再返回到内核态,再在内核态完成收尾工作。听起来有点绕,事实也的确是这样。

在这里插入图片描述

我们可以用更形象的图来理解这个过程:

在这里插入图片描述

我们再将这个图抽象一下:

在这里插入图片描述

上述便是信号从产生到捕捉再到被递达的所有过程!我们下面可以用如何避免僵尸进程的例子来加深对信号的理解。

1.在fork后调用wait/waitpid函数取得子进程退出状态。
2.调用fork两次(第一次调用产生一个子进程,第二次调用fork是在第一个子进程中调用,同时将父进程退出(第一个子进程退出),此时的第二个子进程的父进程id为init进程id(注意:新版本Ubuntu并不是init的进程id))。
3.在程序中显示忽略SIGCHLD信号(子进程退出时会产生一个SIGCHLD信号,我们显示忽略此信号即可)。
4.捕获SIGCHLD信号并在捕获程序中调用wait/waitpid函数。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值