Linux信号机制-3

转自:深入理解Linux内核——signals | linkthinking

信号很早就在 unix 系统中出现了,它用于用户进程之间的交互,几十年以来,变化都不大。信号是一个发送给进程或者进程组的消息,它只是一个数字,没有参数或者其他辅助的信息。以 SIG 为前缀的宏定义表示着这些不同的信号数字。进程的信号主要有2个目的:

  • 告知进程一个特定的事件产生了
  • 触发进程去执行程序代码中处理信号的 signal handler

信号的最大特点是它是异步的,也就是在进程执行的任意时刻信号都可能产生,此时进程的状态是不可知的。发送给进程的信号如果没有被执行,就会由内核将它保存起来,直到进程被唤醒开始执行。如果某个信号被进程配置为屏蔽,那么这个信号就会一直被挂起而不会被触发,直到它被 unblock。

Linux 内核对信号传递过程中的2个阶段进行了区分:

  • signal generation: 产生信号,此时由内核在目标进程中的对应数据结构中更新信号的状态,也就是挂个名先
  • signal delivery: 信号被进程响应,内核将进程的执行流转到信号触发的 signal handler 中

信号在进程中已经生成,不过还没有被响应的情况,我们称之为 pending signal。同一个类型的信号只会存在一个被挂起的情况,所以也就是在信号没有被响应前,重复发送多次的信号,进程也最终也只会响应一次。通常情况下,一个信号会被阻塞多久时间是不可知的,所以需要考虑信号的这些特点:

  • 进程只有在处于运行状态的时候才能够响应信号
  • 发送给进程的信号可能被进程主动选择屏蔽,这种情况下进程将不会响应这个信号
  • 一个进程在执行信号处理函数时,通常会将这个信号屏蔽直到处理结束,这样保证了不会同时响应相同的信号,信号的处理函数也就不必要求是可重入的

进程对一个信号的响应存在下面3种情况:

  • 显式地忽略
  • 执行默认的流程,每个信号都有一个默认的处理方式,它们可能是:terminate、dump(终止并生成 core file)、ignore、stop、continue
  • 捕获信号,执行程序设定好的处理流程

信号被 block 和 ignore 是不一样的,blocked 是进程没有收到信号,而 ignore 是收到了信号,而没有做任何处理。类似于一个人收到了信,不过直接将它丢进了垃圾桶,那就是 ignore,而如果直接拒收那份信,就是 blocked。

SIGKILL & SIGSTOP 是不能被 ignore、capture、block 的,它们的默认流程必须被执行,那就是杀死这个进程。

按照 POSIX 要求,信号在多线程进程中的特性如下:

  • 信号应该被进程中的所有线程共享,不过每个线程有自己独有的 mask,用于配置 block 哪些信号
  • kill() & sigqueue() 库函数是对所有的线程发送信号,而不是特定的信号
  • 信号只会被其中一个线程响应,这个线程是所有没有屏蔽此信号的线程中的任意一个
  • 如果是致命信号,那么所有的线程都会被杀死

实时信号

除了 Linux 中使用的 1~31 的常规信号,POSIX 标准还定义了一个实时信号集,它们在 Linux 中的编号是 32~64。实时信号相比较于常规的信号,差别是常规的信号在同一时间同一类型的信号只允许触发一次,也就是重复发多次信号,进程只响应一次。而实时信号则是可以多个相同的信号在队列中排队,也就是重复发多少次信号,进程就会想要多少次。Linux 系统不使用实时信号,不过它通过一些特殊的系统调用来满足 POSIX 的标准。

信号相关的数据结构

下面这张图列出了信号相关的数据之间的关系:

Linux信号机制-3_第1张图片

从图中可以看到,一个进程的信号等待队列有2个,一个是所有线程组共享的,存放在 signal 的 shared_pending 队列中,另一个则是私有的,存放在进程的 pending 队列中。之所以采用2个队列,是因为有的系统调用是给一个所有的线程发信号,比如 kill() & rt_sigqueueinfo(),有的则是给指定的特定进程发送信号,比如 tkill() & tgkill()。

信号的产生

许多的内核函数能够产生信号,它们通过修改进程描述符中关于信号相关的数据实现,剩下进程如何响应这个信号,它们不管。根据不同的信号类型,内核会选择是否立即唤醒进程并执行信号处理流程。不管信号是由内核生成还是由其他进程发起,都是内核调用相关的信号生成的函数,用于产生信号的函数很多:

  • send_sig
  • send_sig_info
  • force_sig
  • force_sig_info
  • sys_tkill
  • sys_tgkill

我们追踪 send_sig 这个函数,看信号生成的实现细节,它大概的调用流程如下图所示:

Linux信号机制-3_第2张图片

从系统调用 sys_kill 看用户空间向一个进程发送信号时,信号的产生过程:

static inline void prepare_kill_siginfo(int sig, struct kernel_siginfo *info)
{
    clear_siginfo(info);
    info->si_signo = sig;
    info->si_errno = 0;
    info->si_code = SI_USER;
    info->si_pid = task_tgid_vnr(current);
    info->si_uid = from_kuid_munged(current_user_ns(), current_uid());
}

/**
 *  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);
}

首先,这里通过 prepare_kill_siginfo 填充 kernel_siginfo 这个结构体,这里面包含的是发送这个信号的一些信息,比如发送的来源、发起进程的 pid/uid 等。kill_something_info 中根据 pid 参数的值来确定它的操作:

  • pid > 0,发送信号给对应这个 pid 的进程
  • pid = 0,发送信号给当前进程组中的所有进程
  • pid = -1,发送给当前用户拥有权限的所有进程( init 进程除外)
  • pid < -1,发送信号给当前进程组中对应 -pid 的进程

最终,都是调用 send_signal 这个函数,根据以上 pid 参数不同的值,选择 send_signal 函数不同的输入参数。主要是选择是对哪个 task 进行信号操作,以及这个 pid 所代表的 type,也就是信号的作用范围是怎样的,下面就是 pid 几种类型,信号传递的范围按照这个类型由小及大:

enum pid_type
{
    PIDTYPE_PID,
    PIDTYPE_TGID,
    PIDTYPE_PGID,
    PIDTYPE_SID,
    PIDTYPE_MAX,
};

__send_signal 中,通过 __sigqueue_alloc 创建一个信号队列元素,然后添加到 pending 队列中,如果是 PIDTYPE_PID,就使用前面提及的私有的 pending 队列,否则就使用共享的 pending 队列。signalfd_noftify 则是通知 signalfd 文件,有信号发送过来了(signalfd 是将信号通过一个文件描述符进行接受,然后就可以在 select 这样的接口中监控它,具体信息请查看)。

sigpending 的数据结构如下:

struct sigpending {
    struct list_head list;
    sigset_t signal;
};

第一项 list 就是信号等待队列的头,而第二项 signal 则是一个 64bit 的一个数据,它的每一个 bit 代表着对应编号的一个信号。如果对应的 bit 位被置位,那么就表示对应进程收到了这个序号的信号。__send_signal 中的 sigaddset 就是操作 signal,在对应的 bit 位上写1。

complete_signal 通过 signal_wake_up 在需要接受信号的 task 中设置标志位 TIF_SIGPENDING,完成信号的产生。如果是一个致命信号,那么这个线程所在的所有线程都需要被杀死,所以这个进程中的每一个进程都会被标记 TIF_SIGPENDING。

信号的响应

内核在每次跳转到用户空间之前,都会检查一下当前这个 task 的标志状态,如果前面所说的 TIF_SIGPENDING 标志被置位,那么就说明当前这个 task 有信号需要处理,内核就会开始进行信号的响应处理。下面是内核跳转到用户空间前的汇编代码 ret_to_user

/*
 * Ok, we need to do extra processing, enter the slow path.
 */
work_pending:
    mov    x0, sp                // 'regs'
    bl    do_notify_resume
#ifdef CONFIG_TRACE_IRQFLAGS
    bl    trace_hardirqs_on        // enabled while in userspace
#endif
    ldr    x1, [tsk, #TSK_TI_FLAGS]    // re-check for single-step
    b    finish_ret_to_user
/*
 * "slow" syscall return path.
 */
ret_to_user:
    disable_daif
    gic_prio_kentry_setup tmp=x3
    ldr    x1, [tsk, #TSK_TI_FLAGS]
    and    x2, x1, #_TIF_WORK_MASK
    cbnz    x2, work_pending
finish_ret_to_user:
    enable_step_tsk x1, x2
#ifdef CONFIG_GCC_PLUGIN_STACKLEAK
    bl    stackleak_erase
#endif
    kernel_exit 0

and x2, x1, #_TIF_WORK_MASK 这条指令判断当前的 task 判断标志,如果有需要处理的标志,那么就跳转到 work_pending,在这里判断如果有收到信号,那么就调用 do_signal 进行信号的响应。在 do_signal 函数定义前的注视说的比较清楚:

/*
 * Note that 'init' is a special process: it doesn't get signals it doesn't
 * want to handle. Thus you cannot kill init even with a SIGKILL even by
 * mistake.
 *
 * Note that we go through the signals twice: once to check the signals that
 * the kernel can handle, and then we build all the user-level signal handling
 * stack-frames in one go after that.
 */
static void do_signal(struct pt_regs *regs)

init 进程是不接受信号的,所以它是杀不死的。do_signal 中会扫描看哪些信号是内核中就可以处理的,这样就需要为跳转到用户空间而创建一个处理信号用的用户空间栈帧。下面是它的调用流程:

Linux信号机制-3_第3张图片

内核中对于信号的响应,主要做了2件事情,第一个是将本次响应的信号从队列中移除,另一个则是为用户空间的信号处理准备好环境。

get_signal 中就是从队列中将信号取出,将它存储到一个 ksignal 的结构数据中:

struct ksignal {
    struct k_sigaction ka;
    kernel_siginfo_t info;
    int sig;
};

这里面就包含了我们在响应信号时所需要的3个信息:信号编号、信号的处理方式、发送信号的附加信息。__dequeue_signal 从队列中移除此次信号,其中 sigdelset 是对标示信号的比特位进行清零操作(如果是实时信号,则可能不需要进行清空,因为运行多个相同的信号存在队列中),__sigqueue_free 则是对队列元素进行释放。完成了出队操作后,则通过 recalc_sigpending 来判断此进程是否还有在等待响应的信号,如果没有了,那么就清除 进程的 TIF_SIGPENDING 标志。

handle_signal 则是为用户空间准备好处理信号的环境。这个环境包含3个部分:一是保存进程被信号打断时的位置,这样以便信号处理完成后继续程序的执行;二是将进程的执行点转移到信号处理流程中,进行真正的信号响应;三是等用户空间的信号处理完毕后,它需要再次回到内核空间,让内核再把用户进程切换到信号中断前的位置处。当然许多的信号响应不需要进入用户空间进行处理,因为很多默认为 ignore 行为的信号直接在 get_signal 阶段就完成了,还有一下致命信号,直接就由内核去处理终止流程了。

没当进程从用户空间进入内核空间的时候,用户空间当前的寄存器状态就会被保存在内核空间的栈中(这个是由异常处理入口处的汇编指令完成入栈),在内核中就是通过一个名为 pt_regs 结构数据指示着用户空间在进入内核前的寄存器。在信号处理响应流程中,当进程第一个从用户空间进入内核空间时,pt_regs 就包含着进程被信号打断前的上下文。而当内核回到用户空间的时候,这个上下文就会丢失,因为每次由用户空间进入内核空间的时候,内核的栈都是清空状态的。所以被信号打断前的硬件上下文就需要保存在用户空间的堆栈中,也就是我们准备环境的第一个部分,这部分的处理流程由 setup_sigframe完成。

对于第二和第三部分的环境准备则是通过 setup_return 完成(下面是 ARM64 架构的实现):

static void setup_return(struct pt_regs *regs, struct k_sigaction *ka,
             struct rt_sigframe_user_layout *user, int usig)
{
    __sigrestore_t sigtramp;

    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;

    if (ka->sa.sa_flags & SA_RESTORER)
        sigtramp = ka->sa.sa_restorer;
    else
        sigtramp = VDSO_SYMBOL(current->mm->context.vdso, sigtramp);

    regs->regs[30] = (unsigned long)sigtramp;
}

信号中断前的硬件上下文已经通过 setup_sigframe 保存到了 user->subframe 中,所以在这个函数中,我们通过修改 pt_regs 来改变跳转到用户空间时的执行流。首先看 pc 寄存器被设置为了用户的信号处理函数,栈寄存器 sp 从 sigframe 开始。regs[0] 存储信号编号,按照 ARM64 的 ABI,它将作为信号处理函数的第一个输入参数。 regs[29] 是 ARM64 栈帧指针,regs[30] 则是 LR 寄存器,也就是函数执行完毕后的返回地址,这里指向 VDSO 中的 rt_sigreturn 系统调用(定义在 arch/arm64/kernel/vdso/sigreturn.S 文件中)。也即是,用户执行完信号处理流程就会通过 rt_sigreturn 系统调用重新回到内核空间,再由内核调用 restore_sigframe 将用户进程的执行流程恢复到被信号中断时的状态。整个信号的处理流程入下图所示:

Linux信号机制-3_第4张图片

前面我们说过 setup_sigframe 函数对用户的栈增加了一个栈帧用于保存恢复信号中断前的状态,接下来我们就看看这个栈帧的结构。这个新增栈帧的结构由 get_sigframe 函数规划,其结构通过结构体 rt_sigframe_user_layout 存储:

struct rt_sigframe_user_layout {
    struct rt_sigframe __user *sigframe;
    struct frame_record __user *next_frame;

    unsigned long size;    /* size of allocated sigframe data */
    unsigned long limit;    /* largest allowed size */

    unsigned long fpsimd_offset;
    unsigned long esr_offset;
    unsigned long sve_offset;
    unsigned long extra_offset;
    unsigned long end_offset;
};

在 ARM64 架构上,signal frame 的结构如下图所示:

Linux信号机制-3_第5张图片

你可能感兴趣的:(linux,app,java,linux,服务器)