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

文章目录


一、信号类型

信号(signal)是一种软中断,信号机制是进程间通信的一种方式,采用异步通信方式。

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

1.概念

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

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

2.信号表

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

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

对于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 中有几个与信号处理相关的字段,如下:

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

其中signal_struct 结构如下:

cpp 复制代码
#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 的定义如下:

cpp 复制代码
struct sigqueue { 
 	struct sigqueue *next; 
 	siginfo_t info; 
}; 
 
struct sigpending { 
 	struct sigqueue *head, **tail; 
 	sigset_t signal; 
}; 

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

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

1.信号集操作函数

cpp 复制代码
#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)。

cpp 复制代码
#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可以读取当前进程的未决信号集,

cpp 复制代码
#include <signal.h>
int sigpending(sigset_t *set);

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

cpp 复制代码
#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()系统调用发送一个信号给指定的进程为例:

cpp 复制代码
int kill(pid_t pid, int sig); 

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

cpp 复制代码
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函数。

相关推荐
DC_BLOG34 分钟前
IPv6(四)
运维·服务器·网络·ip
shelby_loo35 分钟前
通过 Docker 部署 MySQL 服务器
服务器·mysql·docker
ZBzibing40 分钟前
[游戏技术]L4D服务器报错解决
服务器·游戏
沈艺强40 分钟前
伊犁linux 创建yum 源过程
linux·运维·服务器
拾光师44 分钟前
linux命令行快捷键
linux·运维·服务器
未来可期LJ2 小时前
【C++ 设计模式】单例模式的两种懒汉式和饿汉式
c++·单例模式·设计模式
Trouvaille ~3 小时前
【C++篇】C++类与对象深度解析(六):全面剖析拷贝省略、RVO、NRVO优化策略
c++·c++20·编译原理·编译器·类和对象·rvo·nrvo
little redcap3 小时前
第十九次CCF计算机软件能力认证-乔乔和牛牛逛超市
数据结构·c++·算法
Dola_Pan3 小时前
Linux文件IO(二)-文件操作使用详解
java·linux·服务器
wang_book3 小时前
Gitlab学习(007 gitlab项目操作)
java·运维·git·学习·spring·gitlab