0%

【Linux内核】深入理解信号处理机制

最近在面试时被问到了 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)

处理信号

我们是可以自定义一些信号的处理方式,需要注意的是,SIGKILLSIGSTOP 是两个特殊的信号,它们不允许被忽略、处理和阻塞。

sigaction

1
2
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);

这是较新的一个信号处理函数,它的作用是,对一个信号注册一个新的信号处理方式,并获取以前的信号处理方式,成功返回0,失败返回-1

  • 第一个参数 signum ,用来指定信号的编号(需要设置哪个信号)

  • 第二个参数 act 用来指定注册的新的信号处理方式

  • 第三个参数 oldact 不为 null 时,可以用来获取该信号原来的处理方式

当参数 actnulloldact 不为 null 时,这个函数可以用来只获取信号当前的处理方式

sigaction 的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct sigaction {
union {
signalfn_t _sa_handler;
void (*_sa_sigaction)(int, struct siginfo *, void *);
} _u;
sigset_t sa_mask;
unsigned long sa_flags;
void (*sa_restorer)(void);
};

typedef void (*signalfn_t)(int);

#define sa_handler _u._sa_handler
#define sa_sigaction _u._sa_sigaction

当参数 sa_mask 中含有 SA_SIGINFO 的时候,回调的是 _sa_sigaction 函数,当没有这个参数时,回调的是_sa_handler 这个旧版本函数

sa_handler 可以被赋值成 SIG_DFLSIG_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_structpending 队列中,此时,进程并不知道有新的信号过来了,这也就是异步的意思。那么进程什么时候才能得知并处理这个信号呢?有两个时机,一个是进程从内核态返回到用户态时,一个是进程从睡眠状态被唤醒。让信号看起来是一个异步中断的关键就是,正常的用户进程是会频繁的在用户态和内核态之间切换的,所以信号能很快的得到执行

下面是内核中对进程有关信号的组织:

image-20240815113108355

信号发送原理

从 libc 库函数出发

我们以 kill 函数为例,看看信号是如何发送的,它被定义在 tools/include/nolibc/nolibc.h

1
2
3
4
5
6
7
8
9
10
int kill(pid_t pid, int signal)
{
int ret = sys_kill(pid, signal);
...
}

int sys_kill(pid_t pid, int signal)
{
return my_syscall2(__NR_kill, pid, signal);
}

可以看到,这里使用了系统调用,在 Linux 内核中,每个 syscall 都对应着唯一的系统调用号,kill 函数的系统调用号为 __NR_kill,它被定义在 tools/include/uapi/asm-generic/unistd.h

1
2
#define __NR_kill 129
__SYSCALL(__NR_kill, sys_kill)

x86_64 架构的机器上,my_syscall2 是这样被定义的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define my_syscall2(num, arg1, arg2)                                          \
({ \
long _ret; \
register long _num asm("rax") = (num); \
register long _arg1 asm("rdi") = (long)(arg1); \
register long _arg2 asm("rsi") = (long)(arg2); \
\
asm volatile ( \
"syscall\n" \
: "=a"(_ret) \
: "r"(_arg1), "r"(_arg2), \
"0"(_num) \
: "rcx", "r11", "memory", "cc" \
); \
_ret; \
})

这里涉及到了扩展内联汇编,syscall指令需要一个系统调用号和一些参数,在x86_64架构中,系统调用号需要存放在rax寄存器中,参数依次存放在rdi, rsi, rdx, r10, r8, r9寄存器中,执行syscall指令后,进入内核,内核会通过系统调用号去从系统调用表找到对应函数的入口

内核系统调用

1
2
3
4
5
6
7
8
9
10
11
/**
* sys_kill - send a signal to a process
* @pid: the PID of the process
* @sig: signal to be sent
*/
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
struct kernel_siginfo info;
prepare_kill_siginfo(sig, &info);
return kill_something_info(sig, &info, pid);
}

内核会根据系统调用号找到 sys_kill 函数,像上面的代码展示的那样,该函数是使用SYSCALL_DEFINE2 宏隐式定义的,末尾的 2 表示系统调用的参数数量

可以看到,sys_kill 系统调用的核心函数为 kill_something_info ,Linux 中很多功能的实现都是分层实现的,所有我们会看到从上层函数到真正的核心函数需要经过层层函数,这里我们直接看发送信号给一个进程的核心函数

1
2
3
4
5
6
7
8
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);
...
}

这里省略复杂的函数调用链,直接看最后的 __send_signal 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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;

result = TRACE_SIGNAL_IGNORED;
//判断是否可以忽略信号
if (!prepare_signal(sig, t, force))
goto ret;

//选择信号pending队列
//线程组共享队列(t->signal->shared_pending) 或 进程私有队列(t->pending)
pending = (type != PIDTYPE_PID) ? &t->signal->shared_pending : &t->pending;

result = TRACE_SIGNAL_ALREADY_PENDING;
//如果该信号是不可靠信号,且已经在padding队列中,则忽略这个信号
if (legacy_queue(pending, sig))
goto ret;

result = TRACE_SIGNAL_DELIVERED;

//对SIGKILL信号和内核进程跳过信号的pending
if ((sig == SIGKILL) || (t->flags & PF_KTHREAD))
goto out_set;

//实时信号可以突破队列大小限制,否则丢弃信号
if (sig < SIGRTMIN)
override_rlimit = (is_si_special(info) || info->si_code >= 0);
else
override_rlimit = 0;

//新分配一个sigqueue,并将其加入pending队尾
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) {
...
} else {
...
}

out_set:
signalfd_notify(t, sig);
sigaddset(&pending->signal, sig);
...
//设置 _TIF_SIGPENDING
complete_signal(sig, t, type);
ret:
return ret;
}

从代码里我们可以看出来,和我们之前说的原理是一样的,新分配了一个sigqueue,并将其加入到对应进程task_structpending队列队尾

接下来调用的 complete_signal 函数会调用 signal_wake_up。这个函数会将线程的 TIF_SIGPENDING 标志设为 1。这样后面就可以快速检测是否有未处理的信号了。

信号传递原理

arm64 中,无论是 系统调用还是中断,当进程想要从内核退出进入用户空间时,都会调用 prepare_exit_to_user_mode 函数,紧接着会调用 do_notify_resume 检测未完成的工作,该函数会检查当前的进程状态位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void do_notify_resume(struct pt_regs *regs, unsigned long thread_flags)
{
do {
if (thread_flags & _TIF_NEED_RESCHED) {
schedule();
} else {

...
if (thread_flags & (_TIF_SIGPENDING | _TIF_NOTIFY_SIGNAL))
do_signal(regs);
...

}
...

} while (thread_flags & _TIF_WORK_MASK);
}

由于在信号发送时,complete_signal 函数设置了 _TIF_SIGPENDING 状态位,所以,在进程下一次由于中断异常系统调用返回用户空间时,会调用 do_signal

1
2
3
4
5
6
7
8
9
10
static void do_signal(struct pt_regs *regs)	
...

if (get_signal(&ksig)) {
handle_signal(&ksig, regs);
return;
}
...

}

内核信号处理

其中,在 get_signal 函数中,会处理用户未自定义的信号处理函数,此时不会返回用户空间,这里我们以 SIGKILL 信号的处理为例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
bool get_signal(struct ksignal *ksig)
{
...
for (;;) {

/* Has this task already been marked for death? */
if ((signal->flags & SIGNAL_GROUP_EXIT) ||
signal->group_exec_task) {

...
goto fatal;
}
}

fatal:
...
if (sig_kernel_coredump(signr)) {
...
do_coredump(&ksig->info);
}

/*
* Death signals, no core dump.
*/
do_group_exit(ksig->info.si_signo);
/* NOTREACHED */
...
}

对于标记为 SIGNAL_GROUP_EXIT 的进程,最后会调用 do_group_exit 函数,使整个进程组退出:

1
2
3
4
5
6
7
void __noreturn
do_group_exit(int exit_code)
{
...
do_exit(exit_code);
/* NOTREACHED */
}

然后调用 do_exit 使当前进程退出,这会对进程的资源进行回收,并进行进程调度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;

...
exit_mm();
exit_sem(tsk);
exit_shm(tsk);
exit_files(tsk);
exit_fs(tsk);
...

do_task_dead();
}

void __noreturn do_task_dead(void)
{
...
__schedule(SM_NONE);
BUG();
}

用户自定义信号处理

handle_signal 函数中,最重要的一步是内核会设置返回到用户空间的栈帧,这是通过 set_rt_frame 函数实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static int setup_rt_frame(int usig, struct ksignal *ksig, sigset_t *set,
struct pt_regs *regs)
{
...
if (get_sigframe(&user, ksig, regs))
return 1;

frame = user.sigframe;

__put_user_error(0, &frame->uc.uc_flags, err);
__put_user_error(NULL, &frame->uc.uc_link, err);

err |= __save_altstack(&frame->uc.uc_stack, regs->sp);
err |= setup_sigframe(&user, regs, set);
if (err == 0) {
setup_return(regs, &ksig->ka, &user, usig);
if (ksig->ka.sa.sa_flags & SA_SIGINFO) {
err |= copy_siginfo_to_user(&frame->info, &ksig->info);
regs->regs[1] = (unsigned long)&frame->info;
regs->regs[2] = (unsigned long)&frame->uc;
}
}
}

该函数会在用户栈备份 用户的状态 可以理解成 pt_regs 的备份,随后,修改 pt_regs 的寄存器:

1
2
3
4
5
6
7
8
9
10
static void setup_return(struct pt_regs *regs, struct k_sigaction *ka,
struct rt_sigframe_user_layout *user, int usig)
{
...
regs->regs[0] = usig;
regs->sp = (unsigned long)user->sigframe;
regs->regs[29] = (unsigned long)&user->next_frame->fp;
regs->pc = (unsigned long)ka->sa.sa_handler;
...
}

这些工作完成后,内核会继续它的退出流程,从 pt_regs 中还原用户运行状态,但是由于 pt_regs 被修改,所以实际上会跳转到用户自定义的信号处理函数,执行完该信号处理函数后,库函数会自动设定为一个调用rt_sigreturn 的函数。于是,最终,rt_sigreturn 系统调用被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
asmlinkage long sys_rt_sigreturn(void)
{
...
frame = (struct rt_sigframe __user *)(regs->sp - sizeof(long));
if (__copy_from_user(&set, &frame->uc.uc_sigmask, sizeof(set)))
goto badframe;

if (restore_sigcontext(regs, &frame->uc.uc_mcontext))
goto badframe;

if (restore_altstack(&frame->uc.uc_stack))
goto badframe;
...
}

这里代码含义非常清晰。进行一系列地恢复操作即可。首先恢复寄存器到陷入内核态之前的状态,然后恢复栈。这就是完整的信号生命周期。