在 Linux 的世界里,进程就像生活在城市中的人,它们需要相互沟通来协调行动。而信号机制呢,就像是一种神奇的 “信号弹”,用于进程之间的交流。当一个进程有重要消息要传达给另一个进程时,就会发射出这样的 “信号弹”。这就是 Linux 信号机制,它是管理进程间通信的一把 “金钥匙”,让我们一起深入了解它是如何发挥作用的吧。
一、概述
Linux 的信号机制作为进程间通信的重要方式,发挥着关键作用。它本质上是一种软件中断,能够异步地通知进程发生了特定事件。信号的全称为软中断信号,简称软中断,在头文件<signal.h>中定义了 64 种信号,这些信号的名字都以SIG开头,且都被定义为正整数,称为信号编号。可以用 “kill -l” 命令查看信号的具体名称。
其中,编号为 1~31 的信号为早期 Linux 所支持的信号,是不可靠信号(非实时的),编号为 34~63 的信号时后来扩充的,称为可靠信号(实时信号)。不可靠信号与可靠信号的区别在于前者不支持排队,可能会造成信号丢失,而后者的注册机制是每收到一个可靠信号就会去注册这个信号,不会丢失。
信号机制可以类比为硬件中断,当某个事件发生时,就像硬件中断一样,能够打断进程的正常执行流,迫使进程去处理特定的事件。例如,当用户在终端按下Ctrl+C时,会产生SIGINT信号,表示进程应被终止;当控制终端被关闭时,会发送SIGHUP信号,常用于通知守护进程重新读取配置。信号机制为进程间的通信和交互提供了一种灵活且有效的方式,使得不同进程能够在特定事件发生时做出相应的反应。
二、信号基本原理
信号机制是UNIX系统最古老的机制之一,它不仅是内核处理程序在运行时发生错误的方式,还是终端管理进程的方式,并且还是一种进程间通信机制。信号机制由三部分构成,首先是信号是怎么产生的,或者说是谁发送的,然后是信号是怎么投递到进程或者线程的,最后是信号是怎么处理的。下面我们先看一张图:
图片
从图中我们可以看到信号的产生方式也就是发送方有三种。首先是终端发送,比如我们在终端里输入Ctrl+C快捷键时,终端会给当前进程发送SIGINT信号。其次是内核发送,这里的内核发送是指内核里的异常处理的信号发送,比如进程非法访问内存,在异常处理中就会给当前线程发送SIGSEGV信号。最后是进程发送,也就是一个进程给另一个进程发送或者是进程自己给自己发送。这里有很多接口函数可以选择,有的可以发给线程,有的可以发给进程,有的可以发给进程组甚至会话组。
下一个过程就是信号是如何从发送方发送到目标进程或者线程的信号队列里的,这个过程叫做投递。不同的发送方,其发送方式和投递过程是不同的,这个后面会展开讲。
最后是信号的处理过程,这个最复杂牵涉问题最多。信号发送可以发送给进程或者线程,但是信号的处理是在线程中进行的,因为线程是代码执行的单元。线程首先处理自己队列里的信号,自己的处理完了再去处理进程队列里的信号。处理的时候要考虑信号掩码(mask),被掩码阻塞的信号暂时不处理,还放回原队列中去。信号处理方式有三种,如果程序什么也没设置的话,走默认处理(default)方式。默认处理有五种情况,不同的信号,其默认处理方式不同。这五种情况分别是ignore(忽略)、term(终结进程也就是杀死进程)、core(coredump内存转储并杀死进程)、stop(暂停进程)、cont(continue恢复执行进程)。还有两种方式是进程提前通过接口函数signal或者sigaction设置了处理方式,设置IGN来忽略信号,或者设置一个信号处理函数handler来处理信号。大家注意,默认处理中的忽略和进程主动设置的忽略,两者的逻辑是不同的,一个是默认处理是忽略,一个是进程主动要求要忽略。你想要忽略一个默认处理不是忽略的信号,就必须要主动设置忽略。
三、信号的分类与产生
我们明白了信号的基本原理之后,就要进一步追问,系统都有哪些信号呢,这些信号有什么不同呢?刚开始的时候,UNIX系统只有1-31总共31个信号,这些信号每个都有特殊的含义和特定的用法。这些信号的实现有一个特点,它们是用bit flag实现的。这就会导致当一个信号还在待决的时候,又来了一个同样的信号,再次设置bit位是没有意义的,所以就会丢失一次信号。为了解决这个问题,后来POSIX规定增加32-64这33个信号作为实时信号,并规定实时信号不能丢失,要用队列来实现。我们把之前的信号1-31叫做标准信号,由于标准信号会丢失,所以标准信号也叫做不可靠信号,由于标准信号是用bit flag实现的,所以标准信号也叫做标记信号(flag signal)。由于实时信号不会丢失,所以实时信号也叫作可靠信号,由于实时信号是用队列实现的,所以实时信号也叫做排队信号(queue signal)。我们平常遇到的SIGSEGV、SIGABRT等信都是标准信号。
3.1信号的分类
可靠信号与不可靠信号、实时信号与非实时信号在很多方面存在区别。
可靠信号与不可靠信号:不可靠信号主要来自早期的 Unix 系统,其存在一些问题。例如,进程每次处理完信号后,系统会自动将该信号的处理方式恢复为默认操作,这就需要在信号处理函数的末尾再次调用signal()函数重新绑定处理函数,增加了编程复杂性。而且,不可靠信号可能会丢失,当进程正在处理一个信号时,如果相同类型的另一个信号到达,第二个信号可能会被直接丢弃。而可靠信号支持排队,即使进程在处理某个信号时有新的信号到达,这些信号也不会丢失,而是被加入队列,待当前信号处理完成后再依次处理。Linux 引入了新的信号发送函数sigqueue()和信号绑定函数sigaction()来增强信号处理的灵活性和可靠性。
实时信号与非实时信号:非实时信号一般指编号在 1 到 31 之间的信号,不支持排队,处理时没有严格的顺序保证,且如果在处理某个信号时有相同类型的新信号到达,后者可能会被忽略或丢失,所以也被称为不可靠信号。实时信号是编号在 34 到 64 之间的信号,支持排队,即使在处理某个信号期间有新的相同类型的信号到达,这些信号也不会被丢弃,而是按照到达的顺序依次处理,因此被称为可靠信号。
信号是单线程时代的产物。在单线程时代,一个进程就只有一个线程(就是主线程),所以进程就是线程,线程就是进程。信号所有的属性既是进程全局的又是线程私有的,因为这两者没有区别。但是到了多线程时代,这两者就有区别了,进程是资源分配与管理的单元,线程是程序执行的单元。一个进程往往有多个线程,那么信号的这些属性究竟应该是进程全局的还是线程私有的呢?这还真不好处理的。经过一番慎重的分析与思考,UNIX系统做出了如下的决定。
信号的发送既可以发送给进程,也可以发送给线程,但是同步信号(也就是和当前线程执行相关而产生的信号)应当发送给当前线程。进程发送信号可以选择不同的接口函数,有的接口是发给进程的,有的接口是发给线程的。线程信号队列中的信号只能由线程自己处理,进程信号队列中的信号由进程中的线程处理,具体是由哪个线程处理是不确定的。
信号掩码(mask)的设置是线程私有的,每个线程都可以设置不同的信号掩码。
信号处理方式的设置是进程全局的,后面线程设置的方式会覆盖前面线程的设置。
信号处理的效果是进程全局的。
我们先说默认处理的几种情况:忽略一个信号是指整个进程忽略这个信号,而不是说某个线程忽略了其它线程还可以去处理。终结是终结的整个进程,而不只终结一个线程。内存转储是整个进程进行内存转储并终结整个进程。Stop是暂停整个进程而不是只暂停一个线程。Cont是恢复执行整个进程而不是只恢复执行一个线程。
非默认处理有两种情况:如果进程设置了忽略某个信号,则是整个进程都忽略这个信号,而不是某个线程忽略这个信号。如果进程设置了信号处理函数handler,则handler的执行效果是进程全局的。这点怎么理解呢?可以从两方面来理解,一是如果信号是发送给进程的,则每个线程都有可能来执行这个handler;二是handler虽然是在某个线程中执行的,但是对于线程来说,只有线程栈是线程私有的,其它内存是整个进程共享的,handler对线程栈的影响是线程私有的,handler返回之后它的栈帧就销毁了,handler只有对全局内存的影响才会留下来,所以它的影响是进程全局的。
我们再来总结一下:信号可以发送给进程也可以发送给线程。发送给线程的信号只能由线程处理,如果线程阻塞了信号则信号会一直pending,直到线程解除阻塞然后就会去处理该信号。发送给进程的信号可以由该进程中的任意一个未阻塞该信号的线程来处理,具体哪个线程是不确定的,如果所有线程都阻塞该信号,则该信号一直pending,直到任一线程解除阻塞。信号无论是怎么发送和处理的,信号的处理效果都是进程全局的。
3.2信号类型详解
⑴标准信号与实时信号的区别
我们知道信号分为标准信号和实时信号,它们之间最大的区别就是在信号处于待决的状态下又来了同样的信号会怎么处理。除此之外,它们还有以下三点不同。
实时信号如果使用接口sigqueue发送的话,可以携带一个额外的整数信息或者指针信息。
实时信号有优先级,数值越小优先级越高,优先级高的优先处理,同等优先级的按照先来后到的顺序处理。
标准信号都是预定义信号,每个信号都有特定的含义,而实时信号则没有预定义的含义。
根据特点3,两个进程可以使用实时信号来达到进程间通信的目的。因为实时信号没有特定的含义,所以系统不会使用实时信号,进程之间可以自行约定某个信号的含义。而且不同的进程之间可以约定不同的含义而不会相互影响。不过glibc的pthread实现使用了32、33这两个实时信号,所以大家不要用这两个实时信号。
⑵信号的属性特征
可阻塞:我们可以通过某些接口来阻塞(暂时屏蔽)一个信号。但是有的信号可以阻塞,有的信号无法阻塞。有的信号虽然可以成功设置阻塞,但是其信号会被强制发送,所以最终还是阻塞不了。比如内核在异常处理时会强制发送信号,所以是阻塞不了的。但是同样的信号你用kill来发,阻塞还是生效的,因为kill不是强制发送。信号阻塞,有很多地方会叫做信号屏蔽,两者都是一样的。但是屏蔽容易被人和忽略理解混了,所以本文里用阻塞。阻塞,含义明确,就是阻塞住了,后面不阻塞了信号还是会到来的。
可忽略:有些信号默认处理就是忽略的,但是有些信号默认处理不是忽略。如果我们想忽略这些信号的话,可以通过一些接口设置来忽略它。有些信号是可以设置忽略的,但是有些接口无法设置忽略。有的信号虽然可以设置忽略成功,但是内核在异常处理时会强制发送信号,这时忽略是无效的。不过同样的信号用kill来发,忽略就是有效的,因为kill不是强制发送。大家注意忽略和阻塞不同,阻塞是暂时不处理,而忽略其实也是一种处理,相当于是空处理。
可捕获:我们可以通过一些接口来设置信号处理函数handler来处理信号,这个行为叫做捕获。有些信号是能捕获的,有些信号是不能捕获的。与可阻塞和可忽略不同的是,强制发送的信号也是可捕获的。但是可捕获存在一个特殊情况,有些时候是不能二次捕获的。有两个信号SIGSEGV、SIGABRT是不能二次捕获的,后面会进行讲解。
默认处理:默认处理是当我们没有设置忽略和捕获函数时,内核对信号的默认处理方式。前面已经介绍过有五种处理方式,这里就不再赘述了。由于大部分的信号处理是terminate或者coredump,都是会导致进程死亡的,所以信号发送命令叫做kill。其实kill并不会杀死进程,它只是给进程送了个信号而已。
发送者:这里指的是信号在一般情况是从哪里发送的,表明了信号使用的场景。
发给:这里是指信号一般情况下是发给进程还是线程,表明了信号是和整个进程相关还是和某个线程相关。一般由某个线程自己触发的信号会发送给这个线程自己,让它自己来处理,但是这个信号的含义如果是进程全局的就会发送给进程来处理,进程里的任何一个线程都有可能会被选择来处理。无论是发送给进程还是线程,信号的处理效果都是进程全局的。
含义:这个信号的含义,代表什么时候该使用它,如果收到了它就意味着遇到了什么情况。
⑶标准信号详解
下面让我们通过一张图来看看所有信号的相关信息:
图片
我们先来解释一下信号0,其实0不算是一个信号,但是也可以算作是半个信号。因为发送信号0给一个进程或者线程,它会走发送检测过程,但是并不会真的投递给进程或者线程。检测流程会检测发送者是否有权限发送、进程是否存在,如果遇到问题就返回错误值。所以发送信号0可以用作检测进程是否存在的方法。
我们再来看一下实时信号,因为实时信号没有特定的含义,所以比较简单。实时信号的默认处理是终结进程,相关属性是可阻塞,可忽略,可捕获。它的一般使用方法都是进程发给其它进程或者线程来作为进程间通信的方法。其中32-33被glibc的pthread使用了。
标准信号一共有1-31共31个,我们按照它们的特点不同分类进行讲解:
首先说一下SIGKILL和一些暂停、继续相关的信号。其中SIGKILL和SIGSTOP是POSIX标准规定的不可阻塞、不可忽略、不可捕获的信号,它们的语义一定会得到执行。SIGCONT信号官方没有特别规定,它的实现上是不可阻塞、不可忽略的,虽然能捕获,但是相当于没捕获。因为捕获的意思是执行其信号处理函数就不再执行其默认处理了,但是SIGCONT的默认语义一定会得到执行。其它三个暂停信号SIGTSTP、SIGTTIN、SIGTTOU是不能阻塞的,但是可以忽略可以捕获,忽略或者捕获之后,它们的默认语义暂停程序就不会得到执行。
SIGSTOP、SIGCONT,进程在想要暂停、恢复执行其它进程的时候可以发送这两个信号,内核里面再需要暂停、恢复执行进程的时候也会发送这两个信号。SIGTSTP是当在终端输入Ctrl+Z快捷键时,终端驱动会给当前进程发送这个信号。SIGTTIN是当后台进程读取终端的时候,终端会向进程发送的。SIGTTOU是在后台进程想要向终端输出的时候,终端会向进程发送的。这几个信号都是直接发送给进程的,因为它们的语义就是要操作整个进程。
下面我们再来看6个标记紫色的信号,这几个信号都是和当前线程正在执行时发生异常有关。内核里单独把这6个信号放在一起成为同步信号。因为它们都是强制发送的,会忽略阻塞和忽略设置,所以图中把它们都看做是不可忽略不可阻塞的。但是它们是可以捕获的,让它们可以捕获的原因是因为这样可以让进程知道自己出错的原因,让进程可以在临死之前可以做一些记录工作,为程序员解BUG多提供一些信息。捕获了之后,原先默认的语义就不会执行,所以信号函数执行完之后它们还会继续执行。
但是一般情况下这么做是没有意义的,所以一般都会在信号函数里退出进程。SIGSEGV的可捕获前面加了个[不],代表的是不能二次捕获,也就是说如果在信号处理函数里面又发生了SIGSEGV,则这个SIGSEGV就不可捕获了,会走默认语义发生coredump并杀死进程。这些信号的发送方都是内核里异常处理相关的代码,信号都会发送给线程,因为是这些线程引起的这些问题,放到原线程里去处理比较好。
我们再接着看SIGABRT信号,这个信号比较特殊。它的目的是给库程序来用的。当库程序发现程序出现了不可挽回的错误,就会调用函数abort,这个函数会给当前线程发送信号SIGABRT。SIGABRT信号本身没什么特殊的,但是abort函数比较特殊。POSIX规范要求abort函数执行完成之后,进程一定要被杀死。于是abort函数的实现就是这样的,先取消阻塞SIGABRT信号,然后给当前线程发信号SIGABRT。无论SIGABRT信号是被忽略还是被捕获了,最后还是要返回到abort函数里面,然后abort函数就把SIGABRT信号的处理方式设置为默认,然后再发一个SIGABRT,这下进程就一定会死了。
也就是说你可以捕获SIGABRT信号,但是进程最后还是一定会死。所以上图里说SIGABRT是不可阻塞、不可忽略、不可二次捕获的([不]可捕获代表的是不可二次捕获)。SIGABRT的不可二次捕获和SIGSEGV的不可二次捕获情形不太一样。如果是手工发送的SIGABRT信号,它就是一个普通的信号,没有前面说的逻辑。不过手工发送SIGABRT信号没有意义,一般都是使用abort函数来发送。其实遇到abort函数的SIGABRT信号也不是必死,有一种不规范的做法可以避免一死,那就是在信号处理函数中使用longjmp。但是这种做法没有意义,因为程序现在已经处于不一致状态了,coredump之后结束进程,然后好好地解bug才是最好的选择。
下面我们再看一下与终端相关的4个信号,SIGINT、SIGHUP、SIGQUIT、SIGTERM。你在终端上输入Ctrl+C,终端驱动就会给当前进程发送SIGINT,默认处理是杀死进程。你用kill命令给一个进程发信号,默认发的就是SIGTERM信号,默认处理也是杀死进程。当终端脱离进程的时候会给进程发SIGHUP,默认处理也是杀死进程。脱离终端有三种情况:一是物理终端与大型机断开了连接,现在已经没有物理终端了,所以这种情况不会有了;二是终端模拟器(也就是命令行窗口)被关闭了;三是我们通过ssh等工具连接到了网络终端,如果此时网络断了或者客户端程序死了。这三种情况终端驱动都会给关联的进程发送SIGHUP信号。最后一个信号是SIGTERM,当你在终端输入Ctrl+\的时候,终端驱动就会给当前进程发送SIGTERM信号,默认处理是coredump并杀死进程。
3.3信号的产生来源
⑴硬件来源
比如我们按下Ctrl+C,会产生SIGINT信号。当用户在终端按下某些键时,终端驱动程序会发送信号给前台进程。这是一种常见的硬件来源产生信号的方式。
硬件故障也可能产生信号,例如内存访问错误等情况可能会产生相应的信号,如SIGBUS(非法地址,包括内存地址对齐出错)、SIGSEGV(试图访问未分配给自己的内存,或试图往没有写权限的内存地址写数据)等信号。
⑵软件来源
调用系统函数
kill函数可以给一个指定的进程发送指定的信号。例如,kill(pid_t pid, int sig),其中pid为进程的 pid,你要向哪个进程发送信号,就写哪个进程的 pid;sig就是你要发送的信号的编号。成功返回 0,失败返回 -1。
raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
abort函数使当前进程接收到信号而异常终止。
用户命令:通过命令向进程发送信号。例如在一个终端下,可以使用kill -9 <进程的 PID>向指定的进程发送信号 9(SIGKILL),这个信号的默认功能是停止进程。
软件条件:主要介绍alarm函数和SIGALRM信号。调用alarm(unsigned int seconds)函数可以设定一个闹钟,也就是告诉内核在seconds秒后给当前进程发送SIGALRM信号,该信号的默认处理动作是终止当前进程。这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。
四、信号的发送
现在我们来看一下信号发送,主要是看发送场景。具体的发送过程在下一章信号的投递里面讲解。信号发送场景比较典型的有三种,一是终端发送,也就是我们在命令行运行程序时会遇到的情况;二是内核发送,内核也很庞大,里面的情况也很多,我们这里主要讲的是异常处理发送信号;三是进程发送,就是一个进程给另一个进程发。
4.1 终端发送
我们看一下伪终端是如何发送信号的:linux-src/drivers/tty/pty.c
/* Send a signal to the slave */
static int pty_signal(struct tty_struct *tty, int sig)
{
struct pid *pgrp;
if (sig != SIGINT && sig != SIGQUIT && sig != SIGTSTP)
return -EINVAL;
if (tty->link) {
pgrp = tty_get_pgrp(tty->link);
if (pgrp)
kill_pgrp(pgrp, sig, 1);
put_pid(pgrp);
}
return 0;
}
linux-src/drivers/tty/sysrq.c
static void sysrq_handle_term(int key)
{
send_sig_all(SIGTERM);
console_loglevel = CONSOLE_LOGLEVEL_DEBUG;
}
/*
* Signal sysrq helper function. Sends a signal to all user processes.
*/
static void send_sig_all(int sig)
{
struct task_struct *p;
read_lock(&tasklist_lock);
for_each_process(p) {
if (p->flags & PF_KTHREAD)
continue;
if (is_global_init(p))
continue;
do_send_sig_info(sig, SEND_SIG_PRIV, p, PIDTYPE_MAX);
}
read_unlock(&tasklist_lock);
}
linux-src/drivers/tty/tty_io.c
static void __tty_hangup(struct tty_struct *tty, int exit_session)
{
refs = tty_signal_session_leader(tty, exit_session);
}
linux-src/drivers/tty/tty_jobctrl.c
int tty_signal_session_leader(struct tty_struct *tty, int exit_session)
{
struct task_struct *p;
int refs = 0;
struct pid *tty_pgrp = NULL;
read_lock(&tasklist_lock);
if (tty->ctrl.session) {
do_each_pid_task(tty->ctrl.session, PIDTYPE_SID, p) {
spin_lock_irq(&p->sighand->siglock);
if (p->signal->tty == tty) {
p->signal->tty = NULL;
/*
* We defer the dereferences outside of
* the tasklist lock.
*/
refs++;
}
if (!p->signal->leader) {
spin_unlock_irq(&p->sighand->siglock);
continue;
}
__group_send_sig_info(SIGHUP, SEND_SIG_PRIV, p);
__group_send_sig_info(SIGCONT, SEND_SIG_PRIV, p);
put_pid(p->signal->tty_old_pgrp); /* A noop */
spin_lock(&tty->ctrl.lock);
tty_pgrp = get_pid(tty->ctrl.pgrp);
if (tty->ctrl.pgrp)
p->signal->tty_old_pgrp =
get_pid(tty->ctrl.pgrp);
spin_unlock(&tty->ctrl.lock);
spin_unlock_irq(&p->sighand->siglock);
} while_each_pid_task(tty->ctrl.session, PIDTYPE_SID, p);
}
read_unlock(&tasklist_lock);
if (tty_pgrp) {
if (exit_session)
kill_pgrp(tty_pgrp, SIGHUP, exit_session);
put_pid(tty_pgrp);
}
return refs;
}
这是终端驱动发送信号的几个场景,代码就不具体分析了。
4.2 内核发送
我们最常遇到的信号SIGSEGV,一般都是在缺页异常里,如果我们访问的虚拟内存是未分配的虚拟内存,则会发生SIGSEGV。下面我们看一下代码。
X86的缺页异常的代码如下:linux-src/arch/x86/mm/fault.c
DEFINE_IDTENTRY_RAW_ERRORCODE(exc_page_fault)
{
unsigned long address = read_cr2();
irqentry_state_t state;
prefetchw(¤t->mm->mmap_lock);
if (kvm_handle_async_pf(regs, (u32)address))
return;
state = irqentry_enter(regs);
instrumentation_begin();
handle_page_fault(regs, error_code, address);
instrumentation_end();
irqentry_exit(regs, state);
}
static __always_inline void
handle_page_fault(struct pt_regs *regs, unsigned long error_code,
unsigned long address)
{
trace_page_fault_entries(regs, error_code, address);
if (unlikely(kmmio_fault(regs, address)))
return;
if (unlikely(fault_in_kernel_space(address))) {
do_kern_addr_fault(regs, error_code, address);
} else {
do_user_addr_fault(regs, error_code, address);
local_irq_disable();
}
}
static inline
void do_user_addr_fault(struct pt_regs *regs,
unsigned long error_code,
unsigned long address)
{
struct vm_area_struct *vma;
struct task_struct *tsk;
struct mm_struct *mm;
vm_fault_t fault;
unsigned int flags = FAULT_FLAG_DEFAULT;
tsk = current;
mm = tsk->mm;
if (unlikely((error_code & (X86_PF_USER | X86_PF_INSTR)) == X86_PF_INSTR)) {
/*
* Whoops, this is kernel mode code trying to execute from
* user memory. Unless this is AMD erratum #93, which
* corrupts RIP such that it looks like a user address,
* this is unrecoverable. Don't even try to look up the
* VMA or look for extable entries.
*/
if (is_errata93(regs, address))
return;
page_fault_oops(regs, error_code, address);
return;
}
/* kprobes don't want to hook the spurious faults: */
if (WARN_ON_ONCE(kprobe_page_fault(regs, X86_TRAP_PF)))
return;
/*
* Reserved bits are never expected to be set on
* entries in the user portion of the page tables.
*/
if (unlikely(error_code & X86_PF_RSVD))
pgtable_bad(regs, error_code, address);
/*
* If SMAP is on, check for invalid kernel (supervisor) access to user
* pages in the user address space. The odd case here is WRUSS,
* which, according to the preliminary documentation, does not respect
* SMAP and will have the USER bit set so, in all cases, SMAP
* enforcement appears to be consistent with the USER bit.
*/
if (unlikely(cpu_feature_enabled(X86_FEATURE_SMAP) &&
!(error_code & X86_PF_USER) &&
!(regs->flags & X86_EFLAGS_AC))) {
/*
* No extable entry here. This was a kernel access to an
* invalid pointer. get_kernel_nofault() will not get here.
*/
page_fault_oops(regs, error_code, address);
return;
}
/*
* If we're in an interrupt, have no user context or are running
* in a region with pagefaults disabled then we must not take the fault
*/
if (unlikely(faulthandler_disabled() || !mm)) {
bad_area_nosemaphore(regs, error_code, address);
return;
}
/*
* It's safe to allow irq's after cr2 has been saved and the
* vmalloc fault has been handled.
*
* User-mode registers count as a user access even for any
* potential system fault or CPU buglet:
*/
if (user_mode(regs)) {
local_irq_enable();
flags |= FAULT_FLAG_USER;
} else {
if (regs->flags & X86_EFLAGS_IF)
local_irq_enable();
}
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS, 1, regs, address);
if (error_code & X86_PF_WRITE)
flags |= FAULT_FLAG_WRITE;
if (error_code & X86_PF_INSTR)
flags |= FAULT_FLAG_INSTRUCTION;
#ifdef CONFIG_X86_64
/*
* Faults in the vsyscall page might need emulation. The
* vsyscall page is at a high address (>PAGE_OFFSET), but is
* considered to be part of the user address space.
*
* The vsyscall page does not have a "real" VMA, so do this
* emulation before we go searching for VMAs.
*
* PKRU never rejects instruction fetches, so we don't need
* to consider the PF_PK bit.
*/
if (is_vsyscall_vaddr(address)) {
if (emulate_vsyscall(error_code, regs, address))
return;
}
#endif
/*
* Kernel-mode access to the user address space should only occur
* on well-defined single instructions listed in the exception
* tables. But, an erroneous kernel fault occurring outside one of
* those areas which also holds mmap_lock might deadlock attempting
* to validate the fault against the address space.
*
* Only do the expensive exception table search when we might be at
* risk of a deadlock. This happens if we
* 1. Failed to acquire mmap_lock, and
* 2. The access did not originate in userspace.
*/
if (unlikely(!mmap_read_trylock(mm))) {
if (!user_mode(regs) && !search_exception_tables(regs->ip)) {
/*
* Fault from code in kernel from
* which we do not expect faults.
*/
bad_area_nosemaphore(regs, error_code, address);
return;
}
retry:
mmap_read_lock(mm);
} else {
/*
* The above down_read_trylock() might have succeeded in
* which case we'll have missed the might_sleep() from
* down_read():
*/
might_sleep();
}
vma = find_vma(mm, address);
if (unlikely(!vma)) {
bad_area(regs, error_code, address);
return;
}
if (likely(vma->vm_start <= address))
goto good_area;
if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
bad_area(regs, error_code, address);
return;
}
if (unlikely(expand_stack(vma, address))) {
bad_area(regs, error_code, address);
return;
}
/*
* Ok, we have a good vm_area for this memory access, so
* we can handle it..
*/
good_area:
if (unlikely(access_error(error_code, vma))) {
bad_area_access_error(regs, error_code, address, vma);
return;
}
/*
* If for any reason at all we couldn't handle the fault,
* make sure we exit gracefully rather than endlessly redo
* the fault. Since we never set FAULT_FLAG_RETRY_NOWAIT, if
* we get VM_FAULT_RETRY back, the mmap_lock has been unlocked.
*
* Note that handle_userfault() may also release and reacquire mmap_lock
* (and not return with VM_FAULT_RETRY), when returning to userland to
* repeat the page fault later with a VM_FAULT_NOPAGE retval
* (potentially after handling any pending signal during the return to
* userland). The return to userland is identified whenever
* FAULT_FLAG_USER|FAULT_FLAG_KILLABLE are both set in flags.
*/
fault = handle_mm_fault(vma, address, flags, regs);
if (fault_signal_pending(fault, regs)) {
/*
* Quick path to respond to signals. The core mm code
* has unlocked the mm for us if we get here.
*/
if (!user_mode(regs))
kernelmode_fixup_or_oops(regs, error_code, address,
SIGBUS, BUS_ADRERR,
ARCH_DEFAULT_PKEY);
return;
}
/*
* If we need to retry the mmap_lock has already been released,
* and if there is a fatal signal pending there is no guarantee
* that we made any progress. Handle this case first.
*/
if (unlikely((fault & VM_FAULT_RETRY) &&
(flags & FAULT_FLAG_ALLOW_RETRY))) {
flags |= FAULT_FLAG_TRIED;
goto retry;
}
mmap_read_unlock(mm);
if (likely(!(fault & VM_FAULT_ERROR)))
return;
if (fatal_signal_pending(current) && !user_mode(regs)) {
kernelmode_fixup_or_oops(regs, error_code, address,
0, 0, ARCH_DEFAULT_PKEY);
return;
}
if (fault & VM_FAULT_OOM) {
/* Kernel mode? Handle exceptions or die: */
if (!user_mode(regs)) {
kernelmode_fixup_or_oops(regs, error_code, address,
SIGSEGV, SEGV_MAPERR,
ARCH_DEFAULT_PKEY);
return;
}
/*
* We ran out of memory, call the OOM killer, and return the
* userspace (which will retry the fault, or kill us if we got
* oom-killed):
*/
pagefault_out_of_memory();
} else {
if (fault & (VM_FAULT_SIGBUS|VM_FAULT_HWPOISON|
VM_FAULT_HWPOISON_LARGE))
do_sigbus(regs, error_code, address, fault);
else if (fault & VM_FAULT_SIGSEGV)
bad_area_nosemaphore(regs, error_code, address);
else
BUG();
}
}
static void
__bad_area_nosemaphore(struct pt_regs *regs, unsigned long error_code,
unsigned long address, u32 pkey, int si_code)
{
struct task_struct *tsk = current;
if (likely(show_unhandled_signals))
show_signal_msg(regs, error_code, address, tsk);
set_signal_archinfo(address, error_code);
if (si_code == SEGV_PKUERR)
force_sig_pkuerr((void __user *)address, pkey);
else
force_sig_fault(SIGSEGV, si_code, (void __user *)address);
local_irq_disable();
}
处理用户空间缺页异常的函数是do_user_addr_fault,在这个函数里面会检测各种错误情况并最终调用函数__bad_area_nosemaphore给当前线程发送信号SIGSEGV。
4.3 进程发送
进程如果想要向另外一个进程\线程或发送信号的话,可以使用系统提供的一些接口函数。如下所示:
图片
我们最常用的接口函数就是kill,它有两个参数,一个是进程标识符pid,一个是信号的值sig,就是把信号sig发给进程pid。raise函数给自己也就是当前线程发信号,它只有一个参数sig。killpg是给整个进程组发信号,在实现上是给进程组的每个进程都发信号。pthread_kill是给同一个进程中的某个线程发信号。tgkill可以给其它进程中的某个线程发信号。sigqueue是用来发实时信号的,实时信号可以多带一个附加数据,当然可以用来发普通信号,但是这样附加数据就会被忽略。
五、信号的投递
5.1 信号待决队列
每个进程都有一个信号队列,每个线程也有一个信号队列。信号队列的数据结构如下所示:linux-src/include/linux/signal_types.h
struct sigpending {
struct list_head list;
sigset_t signal;
};
可以看到信号队列非常简单,sigset是个bit flag,代表当前队列里有哪些信号,list是信号列表的头指针。下面我们来看一下信号队列里的条目。
struct sigqueue {
struct list_head list;
int flags;
kernel_siginfo_t info;
struct ucounts *ucounts;
};
每发送一次信号都会生成一个sigqueue,sigqueue里面包含了很多和信号相关的信息。
在Linux里面,每个task_struct都代表一个线程,里面包含了一个sigpending 。Linux里面没有直接代表进程的结构体,但是一个进程的所有线程都共享同一个signal_struct。signal_struct里面也包含了一个sigpending,这个sigpending代表进程的信号队列。
5.2 信号投递流程
我们前面说了很多发送信号的方法,总体上可以分为两类,普通发送和强制发送。异常处理发送信号都是用的强制发送,其它的基本上都是用的普通发送,但也有一些其它情况用的是强制发送。这两类方法方法最终都会调用同一个函数来发送信号,我们来看一下:linux-src/kernel/signal.c
static int send_signal(int sig, struct kernel_siginfo *info, struct task_struct *t,
enum pid_type type)
{
/* Should SIGKILL or SIGSTOP be received by a pid namespace init? */
bool force = false;
if (info == SEND_SIG_NOINFO) {
/* Force if sent from an ancestor pid namespace */
force = !task_pid_nr_ns(current, task_active_pid_ns(t));
} else if (info == SEND_SIG_PRIV) {
/* Don't ignore kernel generated signals */
force = true;
} else if (has_si_pid_and_uid(info)) {
/* SIGKILL and SIGSTOP is special or has ids */
struct user_namespace *t_user_ns;
rcu_read_lock();
t_user_ns = task_cred_xxx(t, user_ns);
if (current_user_ns() != t_user_ns) {
kuid_t uid = make_kuid(current_user_ns(), info->si_uid);
info->si_uid = from_kuid_munged(t_user_ns, uid);
}
rcu_read_unlock();
/* A kernel generated signal? */
force = (info->si_code == SI_KERNEL);
/* From an ancestor pid namespace? */
if (!task_pid_nr_ns(current, task_active_pid_ns(t))) {
info->si_pid = 0;
force = true;
}
}
return __send_signal(sig, info, t, type, force);
}
static int __send_signal(int sig, struct kernel_siginfo *info, struct task_struct *t,
enum pid_type type, bool force)
{
struct sigpending *pending;
struct sigqueue *q;
int override_rlimit;
int ret = 0, result;
assert_spin_locked(&t->sighand->siglock);
result = TRACE_SIGNAL_IGNORED;
if (!prepare_signal(sig, t, force))
goto ret;
pending = (type != PIDTYPE_PID) ? &t->signal->shared_pending : &t->pending;
/*
* Short-circuit ignored signals and support queuing
* exactly one non-rt signal, so that we can get more
* detailed information about the cause of the signal.
*/
result = TRACE_SIGNAL_ALREADY_PENDING;
if (legacy_queue(pending, sig))
goto ret;
result = TRACE_SIGNAL_DELIVERED;
/*
* Skip useless siginfo allocation for SIGKILL and kernel threads.
*/
if ((sig == SIGKILL) || (t->flags & PF_KTHREAD))
goto out_set;
/*
* Real-time signals must be queued if sent by sigqueue, or
* some other real-time mechanism. It is implementation
* defined whether kill() does so. We attempt to do so, on
* the principle of least surprise, but since kill is not
* allowed to fail with EAGAIN when low on memory we just
* make sure at least one signal gets delivered and don't
* pass on the info struct.
*/
if (sig < SIGRTMIN)
override_rlimit = (is_si_special(info) || info->si_code >= 0);
else
override_rlimit = 0;
q = __sigqueue_alloc(sig, t, GFP_ATOMIC, override_rlimit, 0);
if (q) {
list_add_tail(&q->list, &pending->list);
switch ((unsigned long) info) {
case (unsigned long) SEND_SIG_NOINFO:
clear_siginfo(&q->info);
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_USER;
q->info.si_pid = task_tgid_nr_ns(current,
task_active_pid_ns(t));
rcu_read_lock();
q->info.si_uid =
from_kuid_munged(task_cred_xxx(t, user_ns),
current_uid());
rcu_read_unlock();
break;
case (unsigned long) SEND_SIG_PRIV:
clear_siginfo(&q->info);
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_KERNEL;
q->info.si_pid = 0;
q->info.si_uid = 0;
break;
default:
copy_siginfo(&q->info, info);
break;
}
} else if (!is_si_special(info) &&
sig >= SIGRTMIN && info->si_code != SI_USER) {
/*
* Queue overflow, abort. We may abort if the
* signal was rt and sent by user using something
* other than kill().
*/
result = TRACE_SIGNAL_OVERFLOW_FAIL;
ret = -EAGAIN;
goto ret;
} else {
/*
* This is a silent loss of information. We still
* send the signal, but the *info bits are lost.
*/
result = TRACE_SIGNAL_LOSE_INFO;
}
out_set:
signalfd_notify(t, sig);
sigaddset(&pending->signal, sig);
/* Let multiprocess signals appear after on-going forks */
if (type > PIDTYPE_TGID) {
struct multiprocess_signals *delayed;
hlist_for_each_entry(delayed, &t->signal->multiprocess, node) {
sigset_t *signal = &delayed->signal;
/* Can't queue both a stop and a continue signal */
if (sig == SIGCONT)
sigdelsetmask(signal, SIG_KERNEL_STOP_MASK);
else if (sig_kernel_stop(sig))
sigdelset(signal, SIGCONT);
sigaddset(signal, sig);
}
}
complete_signal(sig, t, type);
ret:
trace_signal_generate(sig, info, t, type != PIDTYPE_PID, result);
return ret;
}
send_signal做了一些简单的处理,然后直接调用__send_signal。__send_signal先调用prepare_signal,prepare_signal对暂停恢复类的信号先做了一下预处理,然后查看信号是否被忽略。然后根据PID类型决定是把信号放到进程队列里还是线程队列里。然后会判断信号是不是传统信号(也就是标准信号),对于传统信号,如果信号队列里已经有一个了,就不再接收了,这么做是为了兼容过去。然后调用__sigqueue_alloc分配一个信号条目sigqueue,分配好之后填充各种数据,然后把它加入到队列中去。最后调用complete_signal,此函数会选择一个合适的线程来唤醒,一般会唤醒当前线程。唤醒的线程很可能醒来就去进行信号处理。
①强制发送:强制发送的入口函数是force_sig_info_to_task,它会先把信号的阻塞和忽略取消掉,然后再调用函数send_signal进行发送。代码如下:linux-src/kernel/signal.c
static int
force_sig_info_to_task(struct kernel_siginfo *info, struct task_struct *t,
enum sig_handler handler)
{
unsigned long int flags;
int ret, blocked, ignored;
struct k_sigaction *action;
int sig = info->si_signo;
spin_lock_irqsave(&t->sighand->siglock, flags);
action = &t->sighand->action[sig-1];
ignored = action->sa.sa_handler == SIG_IGN;
blocked = sigismember(&t->blocked, sig);
if (blocked || ignored || (handler != HANDLER_CURRENT)) {
action->sa.sa_handler = SIG_DFL;
if (handler == HANDLER_EXIT)
action->sa.sa_flags |= SA_IMMUTABLE;
if (blocked) {
sigdelset(&t->blocked, sig);
recalc_sigpending_and_wake(t);
}
}
/*
* Don't clear SIGNAL_UNKILLABLE for traced tasks, users won't expect
* debugging to leave init killable. But HANDLER_EXIT is always fatal.
*/
if (action->sa.sa_handler == SIG_DFL &&
(!t->ptrace || (handler == HANDLER_EXIT)))
t->signal->flags &= ~SIGNAL_UNKILLABLE;
ret = send_signal(sig, info, t, PIDTYPE_PID);
spin_unlock_irqrestore(&t->sighand->siglock, flags);
return ret;
}
内核又封装了几个函数来辅助强制发送,分别是force_sig_info、force_sig、force_fatal_sig、force_exit_sig、force_sigsegv、force_sig_fault_to_task、force_sig_fault,它们的代码就不再具体介绍了。
②普通发送:do_send_sig_info先对send_signal进行了一次封装,然后do_send_specific、group_send_sig_info又分别对其进行了封装。do_send_specific代表发送到线程,group_send_sig_info代表发送到进程。给线程发信号的接口函数最终都是调用的do_send_specific。给进程发信号的接口函数最终都是调用的group_send_sig_info。下面我们看一下kill和tgkill的调用流程。
先看kill接口函数的流程:linux-src/kernel/signal.c
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
struct kernel_siginfo info;
prepare_kill_siginfo(sig, &info);
return kill_something_info(sig, &info, pid);
}
static int kill_something_info(int sig, struct kernel_siginfo *info, pid_t pid)
{
int ret;
if (pid > 0)
return kill_proc_info(sig, info, pid);
/* -INT_MIN is undefined. Exclude this case to avoid a UBSAN warning */
if (pid == INT_MIN)
return -ESRCH;
read_lock(&tasklist_lock);
if (pid != -1) {
ret = __kill_pgrp_info(sig, info,
pid ? find_vpid(-pid) : task_pgrp(current));
} else {
int retval = 0, count = 0;
struct task_struct * p;
for_each_process(p) {
if (task_pid_vnr(p) > 1 &&
!same_thread_group(p, current)) {
int err = group_send_sig_info(sig, info, p,
PIDTYPE_MAX);
++count;
if (err != -EPERM)
retval = err;
}
}
ret = count ? retval : -ESRCH;
}
read_unlock(&tasklist_lock);
return ret;
}
static int kill_proc_info(int sig, struct kernel_siginfo *info, pid_t pid)
{
int error;
rcu_read_lock();
error = kill_pid_info(sig, info, find_vpid(pid));
rcu_read_unlock();
return error;
}
int kill_pid_info(int sig, struct kernel_siginfo *info, struct pid *pid)
{
int error = -ESRCH;
struct task_struct *p;
for (;;) {
rcu_read_lock();
p = pid_task(pid, PIDTYPE_PID);
if (p)
error = group_send_sig_info(sig, info, p, PIDTYPE_TGID);
rcu_read_unlock();
if (likely(!p || error != -ESRCH))
return error;
/*
* The task was unhashed in between, try again. If it
* is dead, pid_task() will return NULL, if we race with
* de_thread() it will find the new leader.
*/
}
}
下面再来看一下tgkill函数的流程:linux-src/kernel/signal.c
SYSCALL_DEFINE3(tgkill, pid_t, tgid, pid_t, pid, int, sig)
{
/* This is only valid for single tasks */
if (pid <= 0 || tgid <= 0)
return -EINVAL;
return do_tkill(tgid, pid, sig);
}
static int do_tkill(pid_t tgid, pid_t pid, int sig)
{
struct kernel_siginfo info;
clear_siginfo(&info);
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_TKILL;
info.si_pid = task_tgid_vnr(current);
info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
return do_send_specific(tgid, pid, sig, &info);
}
六、信号的储存与处理
6.1信号的存储方式
在 Linux 内核中,信号的存储主要通过三张表来实现:pending 表、block 表和 handler 表。
Pending 表是通过位图来储存的,一共有 31 位,每个比特位代表信号编号,比特位的内容代表信号是否收到。当进程收到信号但未递达时,对应编号的比特位就会由 0 改为 1。
Block 表也是通过位图来储存,其结构与 Pending 表类似。每个比特位代表信号编号,比特位的内容代表信号是否阻塞。如果某个信号被阻塞,那么阻塞位图结构中对应的比特位(信号编号)就会置为 1,在此信号阻塞未被解除之前,会一直处于信号未决状态。
Handler 表是一个函数指针数组。数组的下标是对应的信号编号,数组下标中的内容就是对应信号的处理方法(函数指针)。当调用signal(signo,handler)时,就会把信号对应的处理方法设置为自定义方法,内核中就是将数组下标(信号编号)中的内容(处理方法)设置为自定义方法的函数指针,从而在递达后执行处理方法。
sigset_t类型是 Linux 给用户提供的一个用户级的数据类型,禁止用户直接修改位图。每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的 “有效” 或 “无效” 状态,在阻塞信号集中 “有效” 和 “无效” 的含义是该信号是否被阻塞,而在未决信号集中 “有效” 和 “无效” 的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字,这里的 “屏蔽” 应该理解为阻塞而不是忽略。
6.2信号的阻塞与未决状态
信号的阻塞、未决和递达是理解 Linux 信号机制的重要概念。执行信号的处理动作称为信号递达(Delivery),信号从产生到递达之间的状态,称为信号未决(Pending)。进程可以选择阻塞(Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。注意,阻塞和忽略是不同,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核中的表示可以看作是这样的:在 PCB 进程控制块中有信号屏蔽状态字(block)、信号未决状态字(pending)以及是否忽略标志(或是信号处理函数)。block 状态字和 pending 状态字都是 64bit。信号屏蔽状态字(block)中,1 代表阻塞、0 代表不阻塞;信号未决状态字(pending)的 1 代表未决,0 代表信号可以抵达了。它们都是每一个 bit 代表一个信号,比如,bit0 代表信号 SIGHUP。
可以使用信号集操作函数来操作信号集。例如:
int sigemptyset(sigset_t *set);:将信号集清空,共 64bits。
int sigfillset(sigset_t *set);:将信号集置 1。
int sigaddset(sigset_t *set, int signum);:将 signum 对应的位置为 1。
int sigdelset(sigset_t *set, int signum);:将 signum 对应的位置为 0。
int sigismember(const sigset_t *set, int signum);:判断 signum 是否在该信号集合中,如果集合中该位为 1,则返回 1,表示位于在集合中。
还有一个函数可以读取更改屏蔽状态字的 API 函数 int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);。参数 how 有下面三种取值:
SIG_BLOCK:将参数 set 指向的信号集中设置的信号添加到现在的屏蔽状态字中,设置为阻塞。
SIG_UNBLOCK:将参数 set 指向的信号集中设置的信号添加到现在的屏蔽状态字中,设置为非阻塞,也就是解除阻塞。
SIG_SETMASK:将参数 set 指向的信号集直接覆盖现在的屏蔽状态字的值。如果 oset 是非空指针,则读取进程的当前信号屏蔽字通过 oset 参数传出。若成功则为 0,若出错则为 -1。
还有一个函数可以读取未决状态字(pending)信息:int sigpending(sigset_t *set);。它读取当前进程的未决信号集,通过 set 参数传出。调用成功则返回 0,出错则返回 -1。
6.3信号的捕捉与阻塞
在 Linux 中,可以使用 signal 和 sigaction 系统调用来自定义信号处理函数,实现对特定信号的捕捉和处理。
signal 函数较为简单,其函数原型为 typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);。它主要用于处理前 32 种非实时信号,不支持信号的传递信息。例如,当使用 signal(SIGINT, my_func) 函数调用时,其中 my_func 是自定义函数。应用进程收到 SIGINT 信号时,会跳转到自定义处理信号函数 my_func 处执行。在 Linux 系统中,signal 函数已被改写,由 sigaction 函数封装实现。
sigaction 函数则更加强大,它可以读取和修改与指定信号相关联的处理动作。函数原型为 int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)。其中,signum 代表指定信号的编号;若 act 指针非空,则根据 act 修改该信号的处理动作;若 oldact 指针非空,则通过 oldact 传出该信号原来的处理动作。struct sigaction 结构体成员解释如下:
sa_handler:如果为 SIG_IGN,表示忽略信号;如果为 SIG_DFL,表示执行系统默认动作;如果为自定义的函数指针,表示用自定义函数捕捉信号,即向内核注册了一个信号处理函数。所注册的信号处理函数的返回值为 void,参数为 int,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。
sa_mask:当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号。
sa_flags:包含一些选项,通常设置为 0,表示使用默认属性。
例如,以下代码用 sigaction 函数对 2 号信号进行了捕捉,将 2 号信号的处理动作改为了自定义的打印动作,并在执行一次自定义动作后将 2 号信号的处理动作恢复为原来默认的处理动作:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
struct sigaction act, oact;
void handler(int signo) {
printf("get a signal:%d\n", signo);
sigaction(2, &oact, NULL);
}
int main() {
// 先把两个结构体变量的成员都初始化为 0
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(2, &act, &oact);
while (1) {
printf("I am a process\n");
sleep(1);
}
return 0;
}
6.4异步信号安全
我们可以通过设置信号处理函数来捕获信号,那信号处理函数能像普通函数一样什么接口函数都能调用吗?不能,我们只能调用异步信号安全的函数。很多常用的函数都不是信号安全函数,不能在信号处理函数里面调用,比如printf。那要是想在信号处理函数里面输出数据该咋办呢?可以使用write接口函数,这个函数是异步信号安全的。
6.5信号处理流程
信号处理是在线程从内核空间返回用户空间的时候处理的。而从内核空间返回用户空间是和架构相关的,所以这一部分的代码是在架构代码里面的。下面我们以x86为例讲解一下(代码进行了删减)。
linux-src/kernel/entry/common.c
static unsigned long exit_to_user_mode_loop(struct pt_regs *regs, unsigned long ti_work)
{
while (ti_work & EXIT_TO_USER_MODE_WORK) {
if (ti_work & (_TIF_SIGPENDING | _TIF_NOTIFY_SIGNAL))
handle_signal_work(regs, ti_work);
}
return ti_work;
}
static void handle_signal_work(struct pt_regs *regs, unsigned long ti_work)
{
if (ti_work & _TIF_NOTIFY_SIGNAL)
tracehook_notify_signal();
arch_do_signal_or_restart(regs, ti_work & _TIF_SIGPENDING);
}
linux-src/arch/x86/kernel/signal.c
void arch_do_signal_or_restart(struct pt_regs *regs, bool has_signal)
{
struct ksignal ksig;
if (has_signal && get_signal(&ksig)) {
handle_signal(&ksig, regs);
return;
}
restore_saved_sigmask();
}
可以看出线程在返回到用户空间之前不断地检查有没有信号要处理。如果有的话就使用函数get_signal取出一个信号,然后在函数handle_signal里面去执行。get_signal的代码我们就不贴出来了,在这里讲一下它的大概逻辑。
get_signal会先看有没有STOP相关的信号,如果有的话执行处理。然后去取一个信号出来,先取同步信号,同步信号只从当前线程的信号队列里去取,这里的同步信号是指前面讲的异常处理的6个信号。
如果没有同步信号的话就去取其它信号,其它信号先从线程的信号队列里面去取,如果没有的话就再去进程的信号里面去取。如果取到的信号的处理设置是忽略,或者是默认处理但默认处理方式也是忽略,则继续取下一个信号。
如果取到的信号没有设置信号处理函数,则在这里执行其默认处理,终结进程或者coredump之后再终结进程。如果没有取到信号则get_signal返回值为0,如果取到了信号,且信号设置了信号处理函数则返回值为1,且输出参数ksig会包含相应信号的相关的信息。然后把ksig传递给函数handle_signal来处理。下面我们看一下handle_signal函数的实现,linux-src/arch/x86/kernel/signal.c
static void
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
bool stepping, failed;
struct fpu *fpu = ¤t->thread.fpu;
if (v8086_mode(regs))
save_v86_state((struct kernel_vm86_regs *) regs, VM86_SIGNAL);
/* Are we from a system call? */
if (syscall_get_nr(current, regs) != -1) {
/* If so, check system call restarting.. */
switch (syscall_get_error(current, regs)) {
case -ERESTART_RESTARTBLOCK:
case -ERESTARTNOHAND:
regs->ax = -EINTR;
break;
case -ERESTARTSYS:
if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
regs->ax = -EINTR;
break;
}
fallthrough;
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2;
break;
}
}
/*
* If TF is set due to a debugger (TIF_FORCED_TF), clear TF now
* so that register information in the sigcontext is correct and
* then notify the tracer before entering the signal handler.
*/
stepping = test_thread_flag(TIF_SINGLESTEP);
if (stepping)
user_disable_single_step(current);
failed = (setup_rt_frame(ksig, regs) < 0);
if (!failed) {
/*
* Clear the direction flag as per the ABI for function entry.
*
* Clear RF when entering the signal handler, because
* it might disable possible debug exception from the
* signal handler.
*
* Clear TF for the case when it wasn't set by debugger to
* avoid the recursive send_sigtrap() in SIGTRAP handler.
*/
regs->flags &= ~(X86_EFLAGS_DF|X86_EFLAGS_RF|X86_EFLAGS_TF);
/*
* Ensure the signal handler starts with the new fpu state.
*/
fpu__clear_user_states(fpu);
}
signal_setup_done(failed, ksig, stepping);
}
这段代码虽然看起来不太复杂,但是实际上却非常难以理解。setup_rt_frame为了使线程返回用户空间后能执行信号处理函数便开始伪造用户线程栈帧。栈帧首先保存一些线程当前的状态到栈上,然后再伪造出仿佛是一个蹦床函数调用了信号处理函数一样。然后再伪造出仿佛是信号处理函数通过系统调用进入了内核一样。
然后线程从内核返回用户空间就会执行信号处理函数,信号处理函数执行完返回的时候时候会返回到蹦床函数。蹦床函数会调用sigreturn系统调用进入内核,sigreturn会读取蹦床函数的栈帧,因为这上面保持的是之前的线程执行信息。然后把这些信息进行恢复,这样线程再回到用户空间的时候就又回到了线程之前执行的地方。
七、信号处理的同步化
对于异步信号来说,有很多的问题,比如你不确定你正在干啥的时候它来了,还有就是在异步信号的处理函数里面有很多的函数不能调用。为此我们可以把异步信号转化为同步信号。我们前面说过,同步信号、异步信号是指信号的发送是同步的还是异步的,那异步信号肯定不可能转化为同步信号啊。我们此处所说的转化是指把信号的处理从异步转化为同步。
转化的方法就是用一个函数来等信号,这样信号和线程执行的相对性就是固定的了,就相当于是同步信号了。等的方式有两种,一种是等待信号被处理,信号还是走前面所说的处理流程,另一种是等待信号并截获信号,信号被我们偷走了,不会再走前面所说的信号处理流程了。
7.1 信号等待
信号等待的接口函数有两个pause和sigsuspend,它们的接口是:
int pause(void);
int sigsuspend(const sigset_t *mask);
7.2 信号截获
除了等待信号被处理之外,我们还可以等待并截获信号,信号就不会走正常的处理流程,我们可以对截获到的信号进行相应的处理。信号截获一共有四个接口函数,我们先来讲三个。
int sigwait(const sigset_t *restrict set, int *restrict sig);
int sigwaitinfo(const sigset_t *restrict set, siginfo_t *restrict info);
int sigtimedwait(const sigset_t *restrict set, siginfo_t *restrict info, const struct timespec *restrict timeout);
接口函数sigwait有两个参数,第一个参数是要等待的信号集,第二个参数是输出参数,是等待并截获到的信号。函数返回之后,我们就可以根据sig的值进行相应的处理。接口函数sigwaitinfo也有两个参数,第一个参数和前面的是一样的,第二个参数是输出参数,类型是siginfo_t,能获得更多信号相关的信息。接口函数sigtimedwait和sigwaitinfo差不多,只是多个了时间参数,如果等了这么长时间之后还没有等来信号就会直接返回。
还有一个接口函数,它把要等待的信号信息转化为了fd,等信号直接变成了read fd的操作。其接口如下:
int signalfd(int fd, const sigset_t *mask, int flags);
第二个参数代表要等待的信号集。第一个参数如果是-1,代表要创建一个新的fd,如果是一个已有的signalfd,代表修改已经fd的信号集。然后我们就可以对这个fd进行read操作了,read的缓存区至少要有 sizeof(struct signalfd_siginfo)个字节。Read每次返回都会读取若干个struct signalfd_siginfo结构体。最关键的是我们还可以对这个fd进行select、poll操作。
八、应用场景与总结
8.1应用场景举例
⑴使用 “ctrl+c” 中止程序
当用户在终端运行程序时,按下 “Ctrl+C” 会产生SIGINT信号。这个信号通常会被发送给前台进程,以请求终止进程。例如,在一个长时间运行的计算任务中,如果用户发现结果不符合预期或者想要提前终止程序,就可以通过按下 “Ctrl+C” 来发送SIGINT信号。当进程接收到这个信号后,会根据其对SIGINT信号的处理方式来做出响应。如果进程没有自定义信号处理函数,那么通常会采用默认的处理动作,即终止进程。
⑵kill 命令杀进程
在 Linux 系统中,kill命令是一个常用的工具,用于向进程发送信号以终止它们。例如,kill -9 <进程的 PID>会向指定的进程发送SIGKILL信号。SIGKILL信号是一种强制终止信号,无法被捕捉、忽略或阻塞。当进程接收到SIGKILL信号时,会立即终止。这种方式通常用于终止那些无法正常退出的进程,或者在系统出现问题时强制关闭某些进程以恢复系统的稳定性。
除了终止进程,信号机制还可以用于进程间的通信。例如,一个进程可以向另一个进程发送特定的信号,以通知它某个事件的发生。这种通信方式虽然比较简单,但在某些情况下非常有用。
8.2总结信号机制的重要性
Linux 信号机制在编写健壮程序中具有至关重要的意义。首先,它提供了一种灵活的方式来处理异步事件。在复杂的多进程或多线程环境中,程序可能会面临各种不可预测的情况,如硬件故障、用户输入、系统资源变化等。通过信号机制,程序可以及时响应这些事件,采取适当的措施,避免出现不可预料的错误或崩溃。
其次,信号机制使得进程间的通信更加多样化。相比于传统的管道、共享内存等通信方式,信号通信更加轻量级和高效。它可以用于简单的事件通知,让不同的进程之间能够协调工作,提高系统的整体性能和稳定性。
深入理解信号机制还可以帮助程序员更好地调试和优化程序。当程序出现异常情况时,通过分析信号的产生和处理过程,可以快速定位问题所在。同时,合理地利用信号机制可以优化程序的资源管理,例如在程序退出时及时清理资源,避免资源泄漏。