最近在面试时被问到了 Linux 中的信号处理机制,之前对于信号的了解不够深入,这篇文章就来回顾一下 Linux 中信号的使用与底层实现机制。
使用信号
信号的产生
信号可以由内核产生,也可以由用户产生,这边举几个例子:
- 用户在终端输入
ctrl + c时,会产生一个SIGINT信号 - 在程序中对一个数除0,会产生一个异常,最终由内核产生一个
SIGFPE信号 - 在程序中非法访问一段内存,会由内核产生一个
SIGBUS信号 - 在终端或程序中手动发送一个信号
- 终端:比如说
kill -9 [pid] - 程序:调用
kill函数,raise函数等
- 终端:比如说
信号种类
在 Linux 中,信号被分为不可靠信号和可靠信号,一共64种,可以通过 kill -l 命令来查看
- 不可靠信号:也称为非实时信号,不支持排队,信号可能会丢失,比如发送多次相同的信号,进程只能收到一次,信号值取值区间为1~31
- 可靠信号:也称为实时信号,支持排队,信号不会丢失,发多少次,就可以收到多少次,信号值取值区间为32~64
在早期的 Linux 中,只定义了前面的不可靠信号,随着时间的发展,发现有必要对信号机制加以改进和扩充,又由于原先定义的信号已有应用,出于兼容性考虑,不能再做改动,于是又新增了一部分信号,这些信号被定义为可靠信号。
在 arch/x86/include/uapi/asm/signal.h 中,我们可以发现这些信号的定义。
发送信号
之前提过,用户是可以手动向一个进程发送信号的,我们可以使用以下一些函数:
kill
1 | int kill(pid_t pid, int sig); |
这个函数的作用是向指定进程(或进程组)发送一个信号,成功返回0,失败返回-1
其中的pid参数:
- 当
pid > 0时,发送信号给pid对应的进程 - 当
pid = 0时,发送信号给本进程组中的所有进程 - 当
pid = -1时,发送信号给所有调用进程有权给其发送信号的进程,除了init进程 - 当
pid < -1时,发送信号给进程组 id 为-pid的所有进程
当sig参数为0时,不会发送任何信号,但仍然会进行参数检测,我们可以用这种方法检查pid对应进程是否存在或允许发送信号。
raise
1 | int raise(int sig); |
这个函数的作用是向本进程或本线程发送信号,成功返回 0,失败返回 -1
这个函数对于主线程来说,相当于kill(getpid(), sig),对于子线程来说,相当于pthread_kill(pthread_self(), sig)
处理信号
我们是可以自定义一些信号的处理方式,需要注意的是,SIGKILL 和 SIGSTOP 是两个特殊的信号,它们不允许被忽略、处理和阻塞。
sigaction
1 | int sigaction(int signum, const struct sigaction *act, |
这是较新的一个信号处理函数,它的作用是,对一个信号注册一个新的信号处理方式,并获取以前的信号处理方式,成功返回0,失败返回-1
第一个参数
signum,用来指定信号的编号(需要设置哪个信号)第二个参数
act用来指定注册的新的信号处理方式第三个参数
oldact不为null时,可以用来获取该信号原来的处理方式
当参数 act 为 null,oldact 不为 null 时,这个函数可以用来只获取信号当前的处理方式
sigaction 的结构如下:
1 | struct sigaction { |
当参数 sa_mask 中含有 SA_SIGINFO 的时候,回调的是 _sa_sigaction 函数,当没有这个参数时,回调的是_sa_handler 这个旧版本函数
sa_handler 可以被赋值成 SIG_DFL 或 SIG_IGN,它们分别对应着默认处理和忽略信号,需要注意的时,它们只是一个 int 值,是不能被直接调用的
signal
1 | sighandler_t signal(int signum, sighandler_t handler); |
这个函数的作用是,设置下一次的信号处理函数(只生效一次),成功返回上一次设置的信号处理函数,失败返回SIG_ERR
这个函数在新版本中实际上是通过sigaction函数实现的,推荐使用更加强大的sigaction函数
阻塞信号
信号有几种状态,首先是信号的 产生 (Genertion) ,而实际执行信号处理动作时,状态为 递达 (Delivery),信号在 产生 到 递达 中的状态被称为 未决 (Pending)
进程可以选择 阻塞 (Blocking) 某些信号,被 阻塞 的信号在产生后将保持在 未决 状态,直到进程解除对该信号的 阻塞,才执行 递达 的动作
我们可以用信号集函数改变当前进程的 信号屏蔽字(Signal Mask) ,控制信号的阻塞与否
sigpromask
1 | int sigpromask(int how, const sigset_t *set, sigset_t *oldset) |
这个函数通过指定的方法和信号集修改进程的信号屏蔽字,成功返回0,失败返回-1
第一个参数 how 有3种取值:
SIG_BLOCK:将set中的信号添加到信号屏蔽字中(不改变原有已存在信号屏蔽字,相当于用set中的信号与原有信号取并集设置)SIG_UNBLOCK:将set中的信号移除信号屏蔽字(相当于用set中的信号的补集与原有信号取交集设置)SIG_SETMASK:使用set中的信号直接代替原有信号屏蔽字中的信号
第二个参数 set 是一个信号集,怎么使用和参数how相关
第三个参数 oldset,如果不为null,会将原有信号屏蔽字的信号集保存进去
sigpending
1 | int sigpending(sigset_t *set); |
这个函数的作用是获得当前进程的信号屏蔽字,将结果保存到传入的set中,成功返回0,失败返回-1
信号原理
信号是一种异步通信机制,它是在软件层面上对中断机制的一种模拟,该怎么理解这句话呢?
当我们对一个进程发送信号后,会将这个信号暂时存放到这个进程所对应的 task_struct 的 pending 队列中,此时,进程并不知道有新的信号过来了,这也就是异步的意思。那么进程什么时候才能得知并处理这个信号呢?有两个时机,一个是进程从内核态返回到用户态时,一个是进程从睡眠状态被唤醒。让信号看起来是一个异步中断的关键就是,正常的用户进程是会频繁的在用户态和内核态之间切换的,所以信号能很快的得到执行
下面是内核中对进程有关信号的组织:

信号发送原理
从 libc 库函数出发
我们以 kill 函数为例,看看信号是如何发送的,它被定义在 tools/include/nolibc/nolibc.h 中
1 | int kill(pid_t pid, int signal) |
可以看到,这里使用了系统调用,在 Linux 内核中,每个 syscall 都对应着唯一的系统调用号,kill 函数的系统调用号为 __NR_kill,它被定义在 tools/include/uapi/asm-generic/unistd.h 中
1 |
|
在 x86_64 架构的机器上,my_syscall2 是这样被定义的
1 |
这里涉及到了扩展内联汇编,syscall指令需要一个系统调用号和一些参数,在x86_64架构中,系统调用号需要存放在rax寄存器中,参数依次存放在rdi, rsi, rdx, r10, r8, r9寄存器中,执行syscall指令后,进入内核,内核会通过系统调用号去从系统调用表找到对应函数的入口
内核系统调用
1 | /** |
内核会根据系统调用号找到 sys_kill 函数,像上面的代码展示的那样,该函数是使用SYSCALL_DEFINE2 宏隐式定义的,末尾的 2 表示系统调用的参数数量
可以看到,sys_kill 系统调用的核心函数为 kill_something_info ,Linux 中很多功能的实现都是分层实现的,所有我们会看到从上层函数到真正的核心函数需要经过层层函数,这里我们直接看发送信号给一个进程的核心函数
1 | static int kill_something_info(int sig, struct kernel_siginfo *info, pid_t pid) |
这里省略复杂的函数调用链,直接看最后的 __send_signal 函数:
1 | static int __send_signal(int sig, struct kernel_siginfo *info, struct task_struct *t, |
从代码里我们可以看出来,和我们之前说的原理是一样的,新分配了一个sigqueue,并将其加入到对应进程task_struct的pending队列队尾
接下来调用的 complete_signal 函数会调用 signal_wake_up。这个函数会将线程的 TIF_SIGPENDING 标志设为 1。这样后面就可以快速检测是否有未处理的信号了。
信号传递原理
在 arm64 中,无论是 系统调用还是中断,当进程想要从内核退出进入用户空间时,都会调用 prepare_exit_to_user_mode 函数,紧接着会调用 do_notify_resume 检测未完成的工作,该函数会检查当前的进程状态位:
1 | void do_notify_resume(struct pt_regs *regs, unsigned long thread_flags) |
由于在信号发送时,complete_signal 函数设置了 _TIF_SIGPENDING 状态位,所以,在进程下一次由于中断 、异常或系统调用返回用户空间时,会调用 do_signal :
1 | static void do_signal(struct pt_regs *regs) |
内核信号处理
其中,在 get_signal 函数中,会处理用户未自定义的信号处理函数,此时不会返回用户空间,这里我们以 SIGKILL 信号的处理为例子:
1 | bool get_signal(struct ksignal *ksig) |
对于标记为 SIGNAL_GROUP_EXIT 的进程,最后会调用 do_group_exit 函数,使整个进程组退出:
1 | void __noreturn |
然后调用 do_exit 使当前进程退出,这会对进程的资源进行回收,并进行进程调度:
1 | void __noreturn do_exit(long code) |
用户自定义信号处理
在 handle_signal 函数中,最重要的一步是内核会设置返回到用户空间的栈帧,这是通过 set_rt_frame 函数实现的:
1 | static int setup_rt_frame(int usig, struct ksignal *ksig, sigset_t *set, |
该函数会在用户栈备份 用户的状态 可以理解成 pt_regs 的备份,随后,修改 pt_regs 的寄存器:
1 | static void setup_return(struct pt_regs *regs, struct k_sigaction *ka, |
这些工作完成后,内核会继续它的退出流程,从 pt_regs 中还原用户运行状态,但是由于 pt_regs 被修改,所以实际上会跳转到用户自定义的信号处理函数,执行完该信号处理函数后,库函数会自动设定为一个调用rt_sigreturn 的函数。于是,最终,rt_sigreturn 系统调用被调用。
1 | asmlinkage long sys_rt_sigreturn(void) |
这里代码含义非常清晰。进行一系列地恢复操作即可。首先恢复寄存器到陷入内核态之前的状态,然后恢复栈。这就是完整的信号生命周期。