最近在面试时被问到了 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) |
这里代码含义非常清晰。进行一系列地恢复操作即可。首先恢复寄存器到陷入内核态之前的状态,然后恢复栈。这就是完整的信号生命周期。