收发信号思想是 Linux 程序设计特性之一,一个信号可以认为是一种软中断,通过用来向进程通知异步事件。
本文讲述的 信号处理内容源自 Linux man。本文主要对各 API 进行详细介绍,从而更好的理解信号编程。
遵循 POSIX.1,特别说明除外
Linux 机制 POSIX 可靠信号(我们成为标准信号),也支持 POSIX 实时信号。
信号处置方法
每个信号都有一个定义如何处理信号的处置方法。
下面列表中 “行为” 列定义了每个信号的默认处置行为:
信号 | 默认行为 |
Term | 默认行为是终止进程 |
Ign | 默认行为是忽略这个信号 |
Core | 默认行为是终止进程并转储内核(dump core) |
Stop | 默认行为是停止进程 |
Cont | 默认行为是在进程停止后继续执行该进程 |
进程可以通过 sigaction(2) 或者 signal(2) 接口修改信号的处置方法。(后者在建立信号处理函数时移植性差一些,参考 signal(2) 获得详细信息。)通过这些系统调用,在信号发生时,进程可以选择以下行为处理信号:
默认情况下,信号处理函数是在普通进程栈上调用的,不过也可以设置它使用其他可选的栈,参考 sigaltstack(2) 查看一些关于如何使用以及这样做有哪些好处的讨论。
信号处置是每个进程的属性:在多线程应用中,信号处置函数对于所有线程都是一样的。
通过 fork(2) 创建的子进程会复制一份父进程的信号处置方法,在 execve(2) 执行的过程中,进程想要处理的信号处置方法会被设置为默认值,而处置方法为忽略的信号保持不变。
发送信号
下面系统调用和库函数提供了调用者发送信号的相关接口:
raise(3)
向调用线程发送一个信号
kill(2)
向指定进程、指定进程组下的所有进程、系统的所有进程发送信号
pidfd_send_signal(2)
向 PID 文件描述符指定的进程发送一个信号
killpg(3)
向指定进程组所有成员发送一个信号
pthread_kill(3)
向调用者所在进程中其他 pthread 线程发送一个信号
tgkill(2)
向指定进程中的特定线程发送一个信号(这是用来实现 pthread_kill(3) 的系统调用)
sigqueue(3)
向指定进程发送一个福袋数据的实时信号
等待捕捉信号
下面系统调用会暂停当前调用线程直至捕捉到一个信号(或者无法处理的信号终止该进程):
pause(2)
暂停直至捕捉到一个信号
sigsuspend(2)
临时修改信号屏蔽值并暂停执行直至捕捉到任意未屏蔽的信号
同步接收信号
除了通过信号处理函数异步处理信号,我们还可以通过同步接收信号,也就是说程序会阻塞运行直至接收到信号,同时也是在这一点内核会返回调用者一些关于信号的信息。通常有两种方式实现同步信号接收:
信号屏蔽和等待信号
一个信号是可以被屏蔽的,也就是说只有在打开屏蔽后才会得到分发。其实我们上面说的信号发生更确切的说是信号分发到了进程。那么信号在生成和被分发之前的状态我们成为等待(pending)。
一个进程中的每个线程都有一个独立的信号屏蔽(signal mask),或者也可以称为掩码,它只是了线程当前屏蔽掉的信号集合。线程可以通过 pthread_sigmask(3) 来操作信号掩码,在传统的单线程应用中可以通过 sigprocmask(2) 来操作信号掩码。
通过 fork(2) 创建的子进程复制一份父进程的信号掩码,信号掩码在整个 execve(2) 过程中会一直保留。
一个信号可以是发给进程的也可以是发给线程的。发给进程的信号通常是由内核产生的而不是由特定硬件异常产生的,通常是由类似 kill(2) 或者 sigqueue(3) 产生的。而发给线程的信号可能是因为执行了一些特定机器语言指令导致硬件异常(比如非法内存访问的 SIGSEGV 信号,或者数学错误的 SIGFPE)导致的,或者是线程执行了特定接口,比如 tgkill(2) 或者 pthread_kill(3)。
发给进程的信号是可以被分发到进程内任何没有屏蔽整个信号的线程的。如果不止一个线程没有屏蔽该信号,那么内核会随便选一个线程分发该信号。
线程可以通过 sigpending(2) 接口来获得当前处于等待状态的信号集,信号集是一个进程等待信号和线程等待信号的共用体。
通过 fork(2) 创建的进程初始化为控的等待信号集,在整个 execve(2) 过程中,该等待信号集保持不变。
信号处理函数执行
在每次内核模式到用户模式的切换时(也就是一个系统调用返回或者调用一个线程到 CPU 上运行),内核都会检查该进程建立信号处理函数的信号的非屏蔽信号的等待状态。如果有这样的信号存在,那么就会进行以下几步:
(1)内核会在执行信号处理函数前做一些准备工作:
(1.1)信号从等待信号集合中移除
(1.2)如果信号处理函数是通过 sigaction(2) 安装并指定了 SA_ONSTACK 标记,集线程定义了一个候补信号栈(使用 sigaltstack(2)),那么内核就会安装这个栈
(1.3)信号相关的一些上下文片段会被封装到栈上的特定帧里,保存的信息包括:
a)程序计数寄存(也就是处理函数退出后主程序要执行的下一条指令)
b)一些为恢复被打断程序运行的架构相关的寄存器
c)线程当前的信号掩码
d)线程的备用信号栈设置
(如果信号是通过 sigaction(2) SA_SIGINFO 标记安装的,那么上述信息是可以通过 ucontex_t 对象获取的,这个对象由信号处理函数的第三个参数指定。
(2)内核在栈上为信号处理函数构建一个栈帧,内核设置线程的 PC 为信号处理函数的第一条指令,并配置信号处理函数的返回地址到一个称为信号蹦床的用户空间代码片段(在 sigreturn(2) 中有描述)。
(3)内核将控制权返还给用户空间,这样就会从信号处理函数的开始执行。
(4)当信号处理函数返回时,控制器交给信号蹦床代码。
(5)信号蹦床调用 sigreturn(2),sigreturn(2) 是一个系统调用,用来使用步骤 1 中创建的栈帧来恢复信号发生之前的线程状态。信号掩码和备用信号栈也会在这个过程中得到恢复。在完成 sigreturn(2) 调用后,内核将控制权交给用户空间,线程会接着从被信号打断的点继续执行。
值得注意的是,如果信号处理函数不返回(比如通过 siglongjmp(3) 将控制权交给了处理函数外或者处理函数通过 execve(2) 执行了一个程序),那么最后的步骤就不会执行。这种情况下,程序设计者应该特别注意,如果想要恢复在进入信号处理函数时屏蔽掉的信号的话,就必须自己恢复这些信号掩码(使用 sigprocmask(2))。(注意:siglongjmp(3) 可能恢复信号掩码,但是这个要依赖于 sigsetjmp(3) 的 savesigs 值。
从内核的角度来看,执行信号处理函数和执行其他任何用户空间代码没有什么区别,也就是说内核并不会记录线程当前是否在执行信号处理函数。所有的状态都被保存到了用户空间寄存器和用户空间栈里了,所以信号处理函数的嵌套深度完全取决于用户栈大小(以及合理的软件设计)。
标准信号
Linux 支持的标准信号入下表所示。表中第二列标明了信号出现的规范:“P1990” 表示信号是在原先的 POSIX.1-1990 标准中描述的;“P2001” 表示信号是在 SUSv2 和 POSIX.1-2001 中添加的。
信号 | 标准 | 行为 | 备注 |
SIGABRT | P1990 | Core | abort(3) 的终止信号 |
SIGALRM | P1990 | Term | alarm(2) 的定时器信号 |
SIGBUS | P2001 | Core | 总线错误(错误内存访问) |
SIGCHLD | P1990 | Ign | 子进程停止或者终止了 |
SIGCLD | - | Ign | 同 SIGCHLD |
SIGCONT | P1990 | Cont | 如果停止了,就继续执行 |
SIGEMT | - | Term | 模拟器陷入 |
SIGFPE | P1990 | Core | 浮点异常 |
SIGHUP | P1990 | Term | 控制终端上检测到挂机或者控制进程死掉了 |
SIGILL | P1990 | Core | 非法指令 |
SIGINFO | - | 同 SIGPWR | |
SIGINT | P1990 | Term | 键盘中断 |
SIGIO | - | Term | I/O 现在可用(4.2 BSD) |
SIGIOT | - | Core | IOT 陷入,同 SIGABRT |
SIGKILL | P1990 | Term | 杀死信号 |
SIGLOST | - | Term | 文件锁丢失(不用了) |
SIGPIPE | P1990 | Term | 破坏的管道:写一个没有读者的管道,参考 pipe(7) |
SIGPOLL | P2001 | Term | 可查询事件(Sys V),同 SIGIO |
SIGPROF | P2001 | Term | 分析定时器超时 |
SIGPWR | - | Term | 电源失效(System V) |
SIGQUIT | P1990 | Core | 键盘上的退出 |
SIGSEGV | P1990 | Core | 无效的内存引用 |
SIGSTKFLT | - | Term | 协处理器栈错误(不用了) |
SIGSTOP | P1990 | Stop | 停止进程 |
SIGTSTP | P1990 | Stop | 终端输入的停止信号 |
SIGSYS | P2001 | Core | 非法的系统调用(SVr4),参考 seccomp() |
SIGTERM | P1990 | Term | 终止信号 |
SIGTRAP | P2001 | Core | 追踪/断电 陷入 |
SIGTTIN | P1990 | Stop | 后台进程终端输入 |
SIGTTOU | P1990 | Stop | 后台进程终端输出 |
SIGUNUSED | - | Core | 同 SIGSYS |
SIGURG | P2001 | Ign | 套接字上的紧急事件(4.2 BSD) |
SIGUSR1 | P1990 | Term | 用户定义的信号 1 |
SIGUSR2 | P1990 | Term | 用户定义的信号 2 |
SIGVTALRM | P2001 | Term | 虚拟定时器时钟(4.2 BSD) |
SIGXCPU | P2001 | Core | 超过 CPU 时间限制,参考 setrlimit(2) |
SIGXFSZ | P2001 | Core |
文件大小超过限制(4.2 BSD),参考 setrlimit(2) |
SIGWINCH | - | Ign | 窗口大小重设信号(4.3 BSD,Sun) |
SIGKILL 和 SIGSTOP 不能被捕捉或者忽略。
Linux 2.2 前,SIGSYS/SIGXCPU/SIGXFSZ(除了 SPARC 和 MIPS 架构)以及 SIGBUS 的默认行为是终止进程(并没有内核转储)。(在一些 UNIX 的系统上,SIGXCPU 和 SIGXFSZ 的默认行为也是不转储的终止。)Linux 2.4 遵循 POSIX.1-2001 对这些信号的要求,会终止进程并进行内核转储。
SIGEMT 并没有在 POSIX.1-2001 中描述,但是至少出现在了大多数 UNIX 系统中,并且其默认行为是终止进程并内核转储。
SIGPWR 也没有在 POSIX.1-2001 中描述,它在起源的一些 UNIX 系统中的默认行为是忽略。
SIGIO 也一样,在一些 UNIX 系统上的默认行为是忽略。
标准信号的排队和分发语义
如果多个标准信号正在等待某个进程,那么信号的顺序并没有指定。
标准信号没有排队,如果一个标准信号的多个实例在信号屏蔽期间到达,那么只有一个信号会被设置为等待状态(信号只能在其取消屏蔽后会分发给进程)。如果一个信号已经在等待,和这个信号关联的 siginfo_t 结构(参考 sigaction(2))并不会被后面的同一信号的其他实例覆盖。因此,进程会接收到第一个达到的信号实例。
标准信号编码
下表列出了各个标准信号的号码。在表中我们可以看出,一些信号在不同架构上的号码是不一样的。- 表示对应架构上没有该信号。
信号 | x86/ARM 以及其他大多数 | Alpha/SPARC | MIPS | PARISC | 备注 |
SIGHUP | 1 | 1 | 1 | 1 | |
SIGINT | 2 | 2 | 2 | 2 | |
SIGQUIT | 3 | 3 | 3 | 3 | |
SIGILL | 4 | 4 | 4 | 4 | |
SIGTRAP | 5 | 5 | 5 | 5 | |
SIGABRT | 6 | 6 | 6 | 6 | |
SIGIOT | 6 | 6 | 6 | 6 | |
SIGBUS | 7 | 10 | 10 | 10 | |
SIGEMT | - | 7 | 7 | - | |
SIGFPE | 8 | 8 | 8 | 9 | |
SIGKILL | 9 | 9 | 9 | 9 | |
SIGUSR1 | 10 | 30 | 16 | 16 | |
SIGSEGV | 11 | 11 | 11 | 11 | |
SIGUSR2 | 12 | 31 | 17 | 17 | |
SIGPIPE | 13 | 13 | 13 | 13 | |
SIGALRM | 14 | 14 | 14 | 14 | |
SIGTERM | 15 | 15 | 15 | 15 | |
SIGSTKFLT | 16 | - | - | - | |
SIGCHLD | 17 | 20 | 18 | 18 | |
SIGCLD | - | - | 18 | - | |
SIGCONT | 18 | 19 | 25 | 26 | |
SIGSTOP | 19 | 17 | 23 | 24 | |
SIGTSTP | 20 | 18 | 24 | 25 | |
SIGTTIN | 21 | 21 | 26 | 27 | |
SIGTTOU | 22 | 22 | 27 | 28 | |
SIGURG | 23 | 16 | 21 | 29 | |
SIGXCPU | 24 | 24 | 30 | 12 | |
SIGXFSZ | 25 | 25 | 31 | 30 | |
SIGVTVLRM | 26 | 26 | 28 | 20 | |
SIGPROF | 27 | 27 | 29 | 21 | |
SIGWINCH | 28 | 28 | 20 | 23 | |
SIGIO | 29 | 23 | 22 | 22 | |
SIGPOLL | |||||
SIGPWR | 30 | 29/- | 19 | 19 | |
SIGINFO | - | 29/- | - | - | |
SIGLOST | - | - | - | - | |
SIGSYS | 31 | 12 | 12 | 31 | |
SIGUNUSED | 31 | - | - | 31 |
注意:
实时信号
从 Linux 2.2 开始,Linux 开始支持 POSIX.1b 实时扩展定义的实时信号(目前包含在 POSIX.1-2001 中)。支持的实时信号范围由宏 SIGRTMIN 和 SIGRTMAX 定义,POSIX.1-2001 要求至少支持 _POSIX_RTSIG_MAX(8) 个实时信号。
Linux 内核支持 33 个不同的实时信号,号码是从 32 到 64。然而,glibc 的 POSIX 线程使用了 2个(对于 NPTL)或者 3 个(对于 LinuxThreads)实时信号(参考 pthreads(7)),所以调整 SIGRTMIN 为 34 或者 35。正因为实时信号的范围随着 glibc 多线程的实现不同而不同(这个在运行时会根据内核和 glibc 而定),并且事实上也会根据不同 UNIX 系统不同而不同,所以程序不应该使用硬编码值指定实时信号,而应该使用 SIGMIN+n 这种形式来引用实时信号,并且需要在运行时检查其是否超过了 SIGRTMAX。
和标准信号不同,实时信号没有预定义的含义,即整个实时信号集都可以被应用使用。
实时信号的默认处理行为是终止接收进程。
实时信号通过以下方式来区分:
如果一个进程有标准信号和实时信号一起处在等待状态,那么 POSIX 并没有指定应该先发送谁。Linux 以及很多其他实现会优先标准信号。
根据 POSIX 规定,系统实现应该最少支持 _POSIX_SIGQUEQUE_MAX (32) 个实时信号等待,然而 Linux 却采用了不同的方式。Linux 2.6.7 之前,只暴漏了对所有进程总共实时信号数的系统级限制,这个限制可以通过(需要特权)/proc/sys/kernel/rtsig-max 文件查看或者修改。可以通过 /proce/sys/kernel/rtsig-nr 查看当前有多少个实时信号正在等待处理。在 Linux 2.6.8,/proc 接口被 RLIMIT_SIGPENDING 资源限制取代,这是一个每个用户下排队信号的限制,可以参考 setrlimit(2)。
实时信号要求信号集结构(sigset_t)的长度从 32位变为 64 位。所以各个系统调用被新的系统调用取代,下面是接口的对照:
Linux 2.0 and earlier Linux 2.2 and later
sigaction(2) rt_sigaction(2)
sigpending(2) rt_sigpending(2)
sigprocmask(2) rt_sigprocmask(2)
sigreturn(2) rt_sigreturn(2)
sigsuspend(2) rt_sigsuspend(2)
sigtimedwait(2) rt_sigtimedwait(2)
信号处理函数打断系统调用和库函数调用
如果在系统调用或者函数调用过程中调用信号处理函数,那么:
至于采用哪种行为主要取决于具体的接口以及建立信号处理函数时是否使用了 SA_RESTART 标记(参考 sigaction(2))。具体行为会随着不同的 UNIX 而不同,下面主要描述 Linux 平台上的相关细节。
如果如下接口中的一个阻塞调用被信号处理函数自动打断,那么如果使用了 SA_RESTART 标记,那么调用会在处理函数返回后自动重启,或者会返回 EINTR 错误:
下面接口在被信号处理函数打断时不会重启,无论是否设置 SA_RESTART 标记,会返回 EINTR 错误:
usleep(3) 在被信号处理函数打断时不会重启,会成功返回睡眠剩余时间的秒数。
在一些情况下,sseccomp(2) 用户空间通知特性会导致通过 SA_RESTART 配置不会重启的系统调用重启,具体可以参考 seccomp_unotify(2)。
停止信号打断系统调用和库函数调用
在 Linux 上,即使没有信号处理函数,一些阻塞接口也可能在收到停止信号停止在收到 SIGCONT 信号恢复时返回 EINTR 错误。这个行为并没有受到 POSIX.1 的限制,并不会在其他系统上发生。
Linux 上会显示出这种行为的接口有:
对于安全的异步信号函数,可以参考 sigal-safety(7)。
/proc/pid/task/tid/status 文件包含了一些用于显示一个线程正处于阻塞(SigBlk)、捕获(SigCgt)或者忽略(SigIgn)的字段。(进程中所有线程的阻塞和忽略信号相同。)其他字段展示了发给线程(SigPnd)或者进程(ShdPnd)的等待信号。/proc/pid/status 展示了关于主线程的信息。参考 proc(5) 获得详细信息。
一共有 6 中关于硬件异常的信号:SIGBUS/SIGEMT/SIGFPE/SIGILL/SIGSEGV/SIGTRAP。至于这些信号哪些会真的分发,文档中并没有给出描述,并且也并不总是有效。
比如,一个架构非法地址访问可能导致 SIGSEGV,但是在其他架构上可能会导致 SIGBUS,反过来也是一样。
再比如,使用 x86 的 int 指令携带了禁止参数(除了 3 和 128 的任意数)会导致 SIGSEGV,尽管这时发送 SIGILL 会更合适,因为它是在向内核报告禁止操作。
下面是一个 sigaction 的信号处理例程。
#include
#include
#include
#include
void
handler(int signo, siginfo_t *info, void *context)
{
struct sigaction oldact;
if (sigaction(SIGSEGV, NULL, &oldact) == -1
|| (oldact.sa_flags & SA_UNSUPPORTED)
|| !(oldact.sa_flags & SA_EXPOSE_TAGBITS))
{
_exit(EXIT_FAILURE);
}
_exit(EXIT_SUCCESS);
}
int
main(void)
{
struct sigaction act = { 0 };
act.sa_flags = SA_SIGINFO | SA_UNSUPPORTED | SA_EXPOSE_TAGBITS;
act.sa_sigaction = &handler;
if (sigaction(SIGSEGV, &act, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
raise(SIGSEGV);
}