linux操作系统:信号,应急处理机制

信号

在某些情况下,我们需要给进程发一个信号,紧急处理一些事情。

这种方式有点儿像运维一个线上系统,为了应对一些突发事件,往往需要制定应急预案。就像下面的列表中的一样。一旦发生了突发事件,马上能够找到负责人,根据处理步骤进行紧急响应,并且在有限的事件内搞定
在这里插入图片描述
我们现在就按照应急预案的设计思路,来看一看 Linux 信号系统的机制。

首先,第一件要做的事情就是,整个团队要想一下,线上到底能够产生哪些异常情况,越全越好。于是,就有了上面这个很长的列表。

在linux操作系统中,为了响应各种各样的事件,也定义了很多信号。我们可以通过kill -l命令,查看所有的信号

# kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

这些信号都是什么作用呢?我们可以通过 man 7 signal 命令查看,里面会有一个列表。

Signal     Value     Action   Comment
──────────────────────────────────────────────────────────────────────
SIGHUP        1       Term    Hangup detected on controlling terminal
                              or death of controlling process
SIGINT        2       Term    Interrupt from keyboard
SIGQUIT       3       Core    Quit from keyboard
SIGILL        4       Core    Illegal Instruction
 
 
SIGABRT       6       Core    Abort signal from abort(3)
SIGFPE        8       Core    Floating point exception
SIGKILL       9       Term    Kill signal
SIGSEGV      11       Core    Invalid memory reference
SIGPIPE      13       Term    Broken pipe: write to pipe with no
                              readers
SIGALRM      14       Term    Timer signal from alarm(2)
SIGTERM      15       Term    Termination signal
SIGUSR1   30,10,16    Term    User-defined signal 1
SIGUSR2   31,12,17    Term    User-defined signal 2
……

就像应急预案里面给出的一样,每个信号都有唯一的ID,还有遇到这个信号的时候的默认操作。

一旦有信号产生,我们就有下面几种,用户进程对信号的处理方式:

  • 执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 Term,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里。
  • 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
  • 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。

信号的处理流程

接下来,我们来看一下信号最常见的处理流程。这个过程主要分为两步,第一步是注册信号,第二步是发送信号

注册信号处理函数

如果我们不想让某个信号执行默认操作,一种方法是通过signal函数对特定的信号注册相应的信号处理函数

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

这其实是定义一个方法,并且将这个方法和某个信号关联起来。当这个进程遇到这个信号的时候,就执行这个方法。

如果我们在 Linux 下面执行 man signal 的话,会发现 Linux 不建议我们直接用这个方法,而是改用 sigaction。定义如下:

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

这两者的区别在哪里呢?其实它还是将信号和一个动作进行关联,只不过这个动作由一个结构struct sigaction表示了

struct sigaction {
	__sighandler_t sa_handler;
	unsigned long sa_flags;
	__sigrestore_t sa_restorer;
	sigset_t sa_mask;		/* mask last for extensibility */
};

和 signal 类似的是,这里面还是有 __sighandler_t。但是,其他成员变量可以让你更加细致地控制信号处理的行为。而 signal 函数没有给你机会设置这些。这里需要注意的是,signal 不是系统调用,而是 glibc 封装的一个函数。这样就像 man signal 里面写的一样,不同的实现方式,设置的参数会不同,会导致行为的不同。

例如,我们在 glibc 里面会看到了这样一个实现:

#  define signal __sysv_signal
__sighandler_t
__sysv_signal (int sig, __sighandler_t handler)
{
  struct sigaction act, oact;
......
  act.sa_handler = handler;
  __sigemptyset (&act.sa_mask);
  act.sa_flags = SA_ONESHOT | SA_NOMASK | SA_INTERRUPT;
  act.sa_flags &= ~SA_RESTART;
  if (__sigaction (sig, &act, &oact) < 0)
    return SIG_ERR;
  return oact.sa_handler;
}
weak_alias (__sysv_signal, sysv_signal)

在这里面,sa_flags 进行了默认的设置。

  • SA_ONESHOT是什么意思呢?意思就是,这里设置的信号处理函数,仅仅起作用一次。用完了一次后,就设置回默认行为。这其实并不是我们想看到的。毕竟我们一旦安装了一个信号处理函数,肯定希望它一直起作用,直到我显式地关闭它。
  • SA_NOMASK是什么意思呢?
    • 我们通过 __sigemptyset,将 sa_mask 设置为空。这样的设置表示在这个信号处理函数执行过程中,如果再有其他信号,哪怕相同的信号到来的时候,这个信号处理函数会被中断。如果一个信号处理函数真的被其他信号中断,其实问题也不大,因为当处理完了其他的信号处理函数后,还会回来接着处理这个信号处理函数的,但是对于相同的信号就有点尴尬了,这就需要这个信号处理函数写的比较有技巧了。
    • 比如,对于这个信号的处理过程中,要操作某个数据结构,因为是相同的信号,很可能操作的是同一个实例,这样的话,死锁、同步这些都要想好。其实一般的思路应是,当某一个信号的信号处理函数运行的时候,我们暂时屏蔽这个信号。注意,屏蔽并不意外着信号丢失,而是暂存。这样能够做到信号处理函数对于相同的信号,处理完一个再处理下一个,这样信号处理逻辑就简单得到
  • SA_INTERRUPT是什么意思?清楚了SA_RESYTART
    • 我们知道,信号的到来时间是不可预期的,有可能程序正在调用某个漫长的系统调用的时候,这个时候一个信号来了,会中断这个信号调用,去执行信号处理函数,那执行完了之后呢?系统调用怎么办?这时候有两种处理方法:
    • 一种是SA_INTERRUPT,也就是系统调用被中断了,就不再重试这个系统调用了,而是返回一个EINTR常量,告诉调用方,这个系统调用被信号中断了,但是怎么处理你看着办。如果是这样的话,调用方可以根据自己的逻辑,重新调用或者直接返回
    • 一种是SA_RESTART,这个时候系统调用会被自动重新启动,不需要调用方自己写代码。当然也可能存在问题,例如从终端读入一个字符,这个时候用户在终端输入一个’a’字符,在处理’a’字符的时候被信号中断了,等信号处理完毕,再次读入一个字符的时候,如果用户不再输入,就停在那里了,需要用户再次输入同一个字符。

因而,建议你使用 sigaction 函数,根据自己的需要定制参数。

接下来,我们来看 sigaction 具体做了些什么。

glibc 里面有个文件 syscalls.list。这里面定义了库函数调用哪些系统调用,在这里我们找到了 sigaction。

sigaction    -       sigaction       i:ipp   __sigaction     sigaction

接下来,在 glibc 中,__sigaction 会调用 __libc_sigaction,并最终调用的系统调用是 rt_sigaction。

int
__sigaction (int sig, const struct sigaction *act, struct sigaction *oact)
{
......
  return __libc_sigaction (sig, act, oact);
}
 
 
int
__libc_sigaction (int sig, const struct sigaction *act, struct sigaction *oact)
{
  int result;
  struct kernel_sigaction kact, koact;
 
 
  if (act)
    {
      kact.k_sa_handler = act->sa_handler;
      memcpy (&kact.sa_mask, &act->sa_mask, sizeof (sigset_t));
      kact.sa_flags = act->sa_flags | SA_RESTORER;
 
 
      kact.sa_restorer = &restore_rt;
    }
 
 
  result = INLINE_SYSCALL (rt_sigaction, 4,
                           sig, act ? &kact : NULL,
                           oact ? &koact : NULL, _NSIG / 8);
  if (oact && result >= 0)
    {
      oact->sa_handler = koact.k_sa_handler;
      memcpy (&oact->sa_mask, &koact.sa_mask, sizeof (sigset_t));
      oact->sa_flags = koact.sa_flags;
      oact->sa_restorer = koact.sa_restorer;
    }
  return result;
}

我们的库函数虽然调用的是 sigaction,到了系统调用层,调用的可不是系统调用 sigaction,而是系统调用 rt_sigaction。

SYSCALL_DEFINE4(rt_sigaction, int, sig,
		const struct sigaction __user *, act,
		struct sigaction __user *, oact,
		size_t, sigsetsize)
{
	struct k_sigaction new_sa, old_sa;
	int ret = -EINVAL;
......
	if (act) {
		if (copy_from_user(&new_sa.sa, act, sizeof(new_sa.sa)))
			return -EFAULT;
	}
 
 
	ret = do_sigaction(sig, act ? &new_sa : NULL, oact ? &old_sa : NULL);
 
 
	if (!ret && oact) {
		if (copy_to_user(oact, &old_sa.sa, sizeof(old_sa.sa)))
			return -EFAULT;
	}
out:
	return ret;
}

在rt_sigaction里面,我们将用户态的struct
sigaction结构,拷贝为内核态的k_sigaction,然后调用do_sigaction。do_sigaction也很简单,进程内核的数据结构里,struct task_struct里面有一个成员sighand,里面有一个action。这是一个数组,下表是信号,内核就是信号处理函数,do_sigaction就是设置sighand里的信号处理函数

int do_sigaction(int sig, struct k_sigaction *act, struct k_sigaction *oact)
{
	struct task_struct *p = current, *t;
	struct k_sigaction *k;
	sigset_t mask;
......
	k = &p->sighand->action[sig-1];
 
 
	spin_lock_irq(&p->sighand->siglock);
	if (oact)
		*oact = *k;
 
 
	if (act) {
		sigdelsetmask(&act->sa.sa_mask,
			      sigmask(SIGKILL) | sigmask(SIGSTOP));
		*k = *act;
......
	}
 
 
	spin_unlock_irq(&p->sighand->siglock);
	return 0;
}

总结

整个注册信号处理函数的过程如下图所示:

  • 在用户程序里面,有两个函数可以调用,一个是 signal,一个是 sigaction,推荐使用 sigaction。
  • 用户程序调用的是 Glibc 里面的函数,signal 调用的是 __sysv_signal,里面默认设置了一些参数,使得 signal 的功能受到了限制,sigaction 调用的是 __sigaction,参数用户可以任意设定。
  • 无论是 __sysv_signal 还是 __sigaction,调用的都是统一的一个系统调用 rt_sigaction。
  • 在内核中,rt_sigaction 调用的是 do_sigaction 设置信号处理函数。在每一个进程的 task_struct 里面,都有一个 sighand 指向 struct sighand_struct,里面是一个数组,下标是信号,里面的内容是信号处理函数。

在这里插入图片描述

信号的发送

一般什么情况下会产生信号呢?

有时候,我们在终端输入某些组合键的时候,会给进程发送信号,例如,Ctrl+C 产生 SIGINT 信号,Ctrl+Z 产生 SIGTSTP 信号。

有的时候,硬件异常也会产生信号。比如,执行了除以0的指令,CPU就会产生异常,然后把SIGFPE发送给进程。比如,进程访问了非法内存,内存管理模块就会产生异常,然后把SIGSEGV发送给进程

同样是硬件产生的,对于中断和信号的比对有什么不同呢?

信号与中断的相似点:
(1)采用了相同的异步通信方式;
(2)当检测出有信号或中断请求时,都暂停正在执行的程序而转去执行相应的处理程序;
(3)都在处理完毕后返回到原来的断点;
(4)对信号或中断都可进行屏蔽。
信号与中断的区别:
(1)中断有优先级,而信号没有优先级,所有的信号都是平等的;
(2)信号处理程序是在 用户态 下运行的,而中断处理程序是在 核心态 下运行;
(3)中断响应是及时的,而信号响应通常都有较大的时间延迟。

对于硬件触发的,无论是中断,还是信号,肯定是先到内核的,然后内核对于中断和信号处理方式不同。一个是完全在内核里面处理完毕,一个是将信号放在对应的进程 task_struct 里信号相关的数据结构里面,然后等待进程在用户态去处理。当然有些严重的信号,内核会把进程干掉。但是,这也能看出来,中断和信号的严重程度不一样,信号影响的往往是某一个进程,处理慢了,甚至错了,也不过这个进程被干掉,而中断影响的是整个系统。一旦中断处理中有了 bug,可能整个 Linux 都挂了。

有时候,内核在某些情况下,也会给进程发送信号。比如,向读端已经关闭的管道写数据时产生SIGPIPE信号,当子进程退出的时候,我们要给父进程发送SIG_CHLD信号。

最直接的发送信号的方法就是,通过命令 kill 来发送信号了。例如,我们都知道的 kill -9 pid 可以发送信号给一个进程,杀死它。

另外,我们还可以通过 kill 或者 sigqueue 系统调用,发送信号给某个进程,也可以通过 tkill 或者 tgkill 发送信号给某个线程。虽然方式多种多样,但是最终都是调用了 do_send_sig_info 函数,将信号放在相应的 task_struct 的信号数据结构中。

  • kill->kill_something_info->kill_pid_info->group_send_sig_info->do_send_sig_info
  • tkill->do_tkill->do_send_specific->do_send_sig_info
  • tgkill->do_tkill->do_send_specific->do_send_sig_info
  • rt_sigqueueinfo->do_rt_sigqueueinfo->kill_proc_info->kill_pid_info->group_send_sig_info->do_send_sig_info

do_send_sig_info 会调用 send_signal,进而调用 __send_signal。

SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
	struct 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());
 
	return kill_something_info(sig, &info, pid);
}
 
 
static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,
			int group, int from_ancestor_ns)
{
	struct sigpending *pending;
	struct sigqueue *q;
	int override_rlimit;
	int ret = 0, result;
......
	pending = group ? &t->signal->shared_pending : &t->pending;
......
	if (legacy_queue(pending, sig))
		goto ret;
 
	if (sig < SIGRTMIN)
		override_rlimit = (is_si_special(info) || info->si_code >= 0);
	else
		override_rlimit = 0;
 
	q = __sigqueue_alloc(sig, t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
		override_rlimit);
	if (q) {
		list_add_tail(&q->list, &pending->list);
		switch ((unsigned long) info) {
		case (unsigned long) SEND_SIG_NOINFO:
			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));
			q->info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
			break;
		case (unsigned long) SEND_SIG_PRIV:
			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);
			if (from_ancestor_ns)
				q->info.si_pid = 0;
			break;
		}
 
		userns_fixup_signal_uid(&q->info, t);
 
	} 
......
out_set:
	signalfd_notify(t, sig);
	sigaddset(&pending->signal, sig);
	complete_signal(sig, t, group);
ret:
	return ret;
}

从上面可以看出关键是 task_struct 里面的 sigpending。我们先是要决定应该用哪个sigpending,这就要看我们发送的信号,是给进程的还是线程的。如果是 kill 发送的,也就是发送给整个进程的,就应该发送给 t->signal->shared_pending。这里面是整个进程所有线程共享的信号;如果是 tkill 发送的,也就是发给某个线程的,就应该发给 t->pending。这里面是这个线程的 task_struct 独享的。

struct sigpending 里面有两个成员,一个是一个集合sigset_t,表示都收到了哪些信号,还有一个链表,也表示收到了哪些信号。它的结构如下:

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

如果都表示收到了信号,这两者有什么区别呢?我们接着往下看 __send_signal 里面的代码。接下来,我们要调用 legacy_queue。如果满足条件,那就直接退出。那 legacy_queue 里面判断的是什么条件呢?我们来看它的代码。

static inline int legacy_queue(struct sigpending *signals, int sig)
{
	return (sig < SIGRTMIN) && sigismember(&signals->signal, sig);
}
 
 
#define SIGRTMIN	32
#define SIGRTMAX	_NSIG
#define _NSIG		64

当信号小于 SIGRTMIN,也即 32 的时候,如果我们发现这个信号已经在集合里面了,就直接退出了。这样会造成什么现象呢?就是信号的丢失。例如,我们发送给进程 100 个 SIGUSR1(对应的信号为 10),那最终能够被我们的信号处理函数处理的信号有多少呢?这就不好说了,比如总共 5 个 SIGUSR1,分别是 A、B、C、D、E。

如果这五个信号来得太密。A 来了,但是信号处理函数还没来得及处理,B、C、D、E 就都来了。根据上面的逻辑,因为 A 已经将 SIGUSR1 放在 sigset_t 集合中了,因而后面四个都要丢失。 如果是另一种情况,A 来了已经被信号处理函数处理了,内核在调用信号处理函数之前,我们会将集合中的标志位清除,这个时候 B 再来,B 还是会进入集合,还是会被处理,也就不会丢。

这样信号能够处理多少,和信号处理函数什么时候被调用,信号多大频率被发送,都有关系,而且从后面的分析,我们可以知道,信号处理函数的调用时间也是不确定的。看小于32的信号如此不靠谱,我们就称为不可靠信号

如果大于32的信号是什么情况呢?我们接着看,接下来,__sigqueue_alloc 会分配一个struct sigqueue对象,然后通过list_add_tail挂在struct sigpending里面的链表上,这样就靠谱多了。如果发送过来100个信号,变成了链表上的100项,都不会丢,哪怕相同的信号发送多遍,也处理多遍。因此,大于32的信号叫做可靠信号。当然,队列的长度是有限制的,如果我们执行ulimit命令,可以看到,这个限制 pending signals (-i) 15408。

当信号挂到了task_struct结构之后,最后我们需要调用complete_signal。这里面的逻辑也很简单,就是说,既然这个进程有了一个新的信号,就赶紧找一个线程处理一下吧

static void complete_signal(int sig, struct task_struct *p, int group)
{
	struct signal_struct *signal = p->signal;
	struct task_struct *t;
 
	/*
	 * Now find a thread we can wake up to take the signal off the queue.
	 *
	 * If the main thread wants the signal, it gets first crack.
	 * Probably the least surprising to the average bear.
	 */
	if (wants_signal(sig, p))
		t = p;
	else if (!group || thread_group_empty(p))
		/*
		 * There is just one thread and it does not need to be woken.
		 * It will dequeue unblocked signals before it runs again.
		 */
		return;
	else {
		/*
		 * Otherwise try to find a suitable thread.
		 */
		t = signal->curr_target;
		while (!wants_signal(sig, t)) {
			t = next_thread(t);
			if (t == signal->curr_target)
				return;
		}
		signal->curr_target = t;
	}
......
	/*
	 * The signal is already in the shared-pending queue.
	 * Tell the chosen thread to wake up and dequeue it.
	 */
	signal_wake_up(t, sig == SIGKILL);
	return;
}

当找到了一个进程或者线程的task_struct之后,我们要调用signal_wake_up,来企图唤醒它,signal_wake_up会调用signal_wake_up_state

void signal_wake_up_state(struct task_struct *t, unsigned int state)
{
	set_tsk_thread_flag(t, TIF_SIGPENDING);
 
 
	if (!wake_up_state(t, state | TASK_INTERRUPTIBLE))
		kick_process(t);
}

signal_wake_up_state里面主要做了两件事情。第一,就是给这个线程设置TIF_SIGPENDING,这就说明其实信号的处理和进程的调度是采用一种类型的机制。

  • 当一个进程应该被调用的时候,我们并不直接把它赶下来,而是设置一个标识位TIF_NEED_RESCHED,表示等待调度,然后等待系统调用结束或者中断处理结束,从内核态返回用户态的时候,调用schedule函数进行调度。‘
  • 信号也是类似的,当信号来的时候,我们并不直接处理这个信号,而是设置一个标识位TIF_SIGPENDING,来表示已经有信号在等待处理。同样,等系统调用结束,或者中断处理结束,从内核态返回用户态的时候,再进行信号的处理

signal_wake_up_state 的第二件事情,就是试图唤醒这个进程或者线程。wake_up_state 会调用 try_to_wake_up 方法。这个函数会将这个进程或者线程设置为 TASK_RUNNING,然后放在运行队列中,这个时候,当随着时钟不断的滴答,迟早会被调用。如果 wake_up_state 返回 0,说明进程或者线程已经是 TASK_RUNNING 状态了,如果它在另外一个 CPU 上运行,则调用 kick_process 发送一个处理器间中断,强制那个进程或者线程重新调度,重新调度完毕后,会返回用户态运行。这是一个时机会检查 TIF_SIGPENDING 标识位。

信号的处理

好了,信号已经发送到位了,什么时候真正处理它呢?

就是系统调用或者中断返回的时候。无论是从系统调用返回还是从中断返回,都会调用exit_to_usermode_loop,这里会有一个_TIF_SIGPENDING 标识位。

static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
	while (true) {
......
		if (cached_flags & _TIF_NEED_RESCHED)
			schedule();
......
		/* deal with pending signal delivery */
		if (cached_flags & _TIF_SIGPENDING)
			do_signal(regs);
......
		if (!(cached_flags & EXIT_TO_USERMODE_LOOP_FLAGS))
			break;
	}
}

如果在前一个环节中,已经设置了 _TIF_SIGPENDING,我们就调用 do_signal 进行处理。

void do_signal(struct pt_regs *regs)
{
	struct ksignal ksig;
 
	if (get_signal(&ksig)) {
		/* Whee! Actually deliver the signal.  */
		handle_signal(&ksig, regs);
		return;
	}
 
	/* Did we come from a system call? */
	if (syscall_get_nr(current, regs) >= 0) {
		/* Restart the system call - no handlers present */
		switch (syscall_get_error(current, regs)) {
		case -ERESTARTNOHAND:
		case -ERESTARTSYS:
		case -ERESTARTNOINTR:
			regs->ax = regs->orig_ax;
			regs->ip -= 2;
			break;
 
		case -ERESTART_RESTARTBLOCK:
			regs->ax = get_nr_restart_syscall(regs);
			regs->ip -= 2;
			break;
		}
	}
	restore_saved_sigmask();
}

do_signal 会调用 handle_signal。按说,信号处理就是调用用户提供的信号处理函数,但是这事儿没有看起来这么简单,因为信号处理函数是在用户态的。

系统调用时:

  • 这个进程当时在用户态执行到某一行 Line A,调用了一个系统调用,在进入内核的那一刻,在内核 pt_regs 里面保存了用户态执行到了 Line A。
  • 现在我们从系统调用返回用户态了,按说应该从 pt_regs 拿出 Line A,然后接着 Line A 执行下去,但是为了响应信号,我们不能回到用户态的时候返回 Line A 了,而是应该返回信号处理函数的起始地址。
static void
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
	bool stepping, failed;
......
	/* Are we from a system call? */
	if (syscall_get_nr(current, regs) >= 0) {
		/* If so, check system call restarting.. */
		switch (syscall_get_error(current, regs)) {
		case -ERESTART_RESTARTBLOCK:
		case -ERESTARTNOHAND:
			regs->ax = -EINTR;
			break;
		case -ERESTARTSYS:
			if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
				regs->ax = -EINTR;
				break;
			}
		/* fallthrough */
		case -ERESTARTNOINTR:
			regs->ax = regs->orig_ax;
			regs->ip -= 2;
			break;
		}
	}
......
	failed = (setup_rt_frame(ksig, regs) < 0);
......
	signal_setup_done(failed, ksig, stepping);
}

这个时候,我们就需要干预和自己来定制 pt_regs 了。这个时候,我们要看,是否从系统调用中返回。如果是从系统调用返回的话,还要区分我们是从系统调用中正常返回,还是在一个非运行状态的系统调用中,因为会被信号中断而返回。

我们这里解析一个最复杂的场景,从一个 tap 网卡中读取数据:

static ssize_t tap_do_read(struct tap_queue *q,
			   struct iov_iter *to,
			   int noblock, struct sk_buff *skb)
{
......
	while (1) {
		if (!noblock)
			prepare_to_wait(sk_sleep(&q->sk), &wait,
					TASK_INTERRUPTIBLE);
 
		/* Read frames from the queue */
		skb = skb_array_consume(&q->skb_array);
		if (skb)
			break;
		if (noblock) {
			ret = -EAGAIN;
			break;
		}
		if (signal_pending(current)) {
			ret = -ERESTARTSYS;
			break;
		}
		/* Nothing to read, let's sleep */
		schedule();  //发现没有数据的时候,就调用 schedule,自己进入等待状态,然后将 CPU 让给其他进程。
	}
......
}
  • 首先,我们把当前进程或者线程的状态设置为TASK_INTERRUPTIBLE,这样,才能是使得这个系统调用可以被中断
  • 其次,可以被中断的系统调用往往是比较慢的调用,并且会因为数据不就绪而通过 schedule 让出 CPU 进入等待状态。在发送信号的时候,我们除了设置这个进程和线程的 _TIF_SIGPENDING 标识位之外,还试图唤醒这个进程或者线程,也就是将它从等待状态中设置为 TASK_RUNNING。
  • 当这个进程或者线程再次运行的时候,我们根据进程调度第一定律,从 schedule 函数中返回,然后再次进入 while 循环。由于这个进程或者线程是由信号唤醒的,而不是因为数据来了而唤醒的,因而是读不到数据的,但是在 signal_pending 函数中,我们检测到了 _TIF_SIGPENDING 标识位,这说明系统调用没有真的做完,于是返回一个错误 ERESTARTSYS,然后带着这个错误从系统调用返回。
  • 然后,我们到了 exit_to_usermode_loop->do_signal->handle_signal。在这里面,当发现出现错误 ERESTARTSYS 的时候,我们就知道这是从一个没有调用完的系统调用返回的,设置系统调用错误码 EINTR。

接下来,我们就开始折腾 pt_regs 了,主要通过调用 setup_rt_frame->__setup_rt_frame。

static int __setup_rt_frame(int sig, struct ksignal *ksig,
			    sigset_t *set, struct pt_regs *regs)
{
	struct rt_sigframe __user *frame;
	void __user *fp = NULL;
	int err = 0;
 
	frame = get_sigframe(&ksig->ka, regs, sizeof(struct rt_sigframe), &fp);
......
	put_user_try {
......
		/* Set up to return from userspace.  If provided, use a stub
		   already in userspace.  */
		/* x86-64 should always use SA_RESTORER. */
		if (ksig->ka.sa.sa_flags & SA_RESTORER) {
			put_user_ex(ksig->ka.sa.sa_restorer, &frame->pretcode);
		} 
	} put_user_catch(err);
 
	err |= setup_sigcontext(&frame->uc.uc_mcontext, fp, regs, set->sig[0]);
	err |= __copy_to_user(&frame->uc.uc_sigmask, set, sizeof(*set));
 
	/* Set up registers for signal handler */
	regs->di = sig;
	/* In case the signal handler was declared without prototypes */
	regs->ax = 0;
 
	regs->si = (unsigned long)&frame->info;
	regs->dx = (unsigned long)&frame->uc;
	regs->ip = (unsigned long) ksig->ka.sa.sa_handler;
 
	regs->sp = (unsigned long)frame;
	regs->cs = __USER_CS;
......
	return 0;
}

frame 的类型是 rt_sigframe。frame 的意思是帧,就是栈帧。

我们在 get_sigframe 中会得到 pt_regs 的 sp 变量,也就是原来这个程序在用户态的栈顶指针,然后 get_sigframe 中,我们会将 sp 减去 sizeof(struct rt_sigframe),也就是把这个栈帧塞到了栈里面,然后我们又在 __setup_rt_frame 中把 regs->sp 设置成等于 frame。这就相当于强行在程序原来的用户态的栈里面插入了一个栈帧,并在最后将 regs->ip 设置为用户定义的信号处理函数 sa_handler。这意味着,本来返回用户态应该接着原来的代码执行的,现在不了,要执行 sa_handler 了。那执行完了以后呢?按照函数栈的规则,弹出上一个栈帧来,也就是弹出了 frame。

那如果我们假设 sa_handler 成功返回了,怎么回到程序原来在用户态运行的地方呢?玄机就在 frame 里面。要想恢复原来运行的地方,首先,原来的 pt_regs 不能丢,这个没问题,是在 setup_sigcontext 里面,将原来的 pt_regs 保存在了 frame 中的 uc_mcontext 里面。

另外,很重要的一点,程序如何跳过去呢?在 __setup_rt_frame 中,还有一个不引起重视的操作,那就是通过 put_user_ex,将 sa_restorer 放到了 frame->pretcode 里面,而且还是按照函数栈的规则。函数栈里面包含了函数执行完跳回去的地址。当 sa_handler 执行完之后,弹出的函数栈是 frame,也就应该跳到 sa_restorer 的地址。这是什么地址呢?

咱们在 sigaction 介绍的时候就没有介绍它,在 Glibc 的 __libc_sigaction 函数中也没有注意到,它被赋值成了 restore_rt。这其实就是 sa_handler 执行完毕之后,马上要执行的函数。从名字我们就能感觉到,它将恢复原来程序运行的地方。

在 Glibc 中,我们可以找到它的定义,它竟然调用了一个系统调用,系统调用号为 __NR_rt_sigreturn。

RESTORE (restore_rt, __NR_rt_sigreturn)
 
#define RESTORE(name, syscall) RESTORE2 (name, syscall)
# define RESTORE2(name, syscall) \
asm                                     \
  (                                     \
   ".LSTART_" #name ":\n"               \
   "    .type __" #name ",@function\n"  \
   "__" #name ":\n"                     \
   "    movq $" #syscall ", %rax\n"     \
   "    syscall\n"                      \
......

我们可以在内核里面找到 __NR_rt_sigreturn 对应的系统调用

asmlinkage long sys_rt_sigreturn(void)
{
	struct pt_regs *regs = current_pt_regs();
	struct rt_sigframe __user *frame;
	sigset_t set;
	unsigned long uc_flags;
 
	frame = (struct rt_sigframe __user *)(regs->sp - sizeof(long));
	if (__copy_from_user(&set, &frame->uc.uc_sigmask, sizeof(set)))
		goto badframe;
	if (__get_user(uc_flags, &frame->uc.uc_flags))
		goto badframe;
 
	set_current_blocked(&set);
 
	if (restore_sigcontext(regs, &frame->uc.uc_mcontext, uc_flags))
		goto badframe;
......
	return regs->ax;
......
}

在这里面,我们把上次填充的那个 rt_sigframe 拿出来,然后 restore_sigcontext 将 pt_regs 恢复成为原来用户态的样子。从这个系统调用返回的时候,应用还误以为从上次的系统调用返回的呢。

至此,整个信号处理过程才全部结束。

总结

下图为一个信号的发送和处理流程

  1. 假设我们有一个进程A,main函数里面调用系统调用进入内核
  2. 按照系统调用的原理,会将用户态栈的信息保存在pt_regs里面,也就是记住原来用户态是运行到了line A的地方
  3. 在内核中执行系统调用读取数据
  4. 当发现没有什么数据可以读取的时候,只好进入睡眠状态,并且调用schedule让出CPU,这是进程调度第一定律
  5. 将进程状态设置为TASK_INTERRUPTIBLE,可中断的睡眠状态。也就是如果有信号到来的话,是可以唤醒它的
  6. 其他的进程或者shell发送一个信号,有四个函数可以调用 kill,tkill,tgkill,rt_sigqueueinfo
  7. 四个发送信号的函数,在内核中最终都是调用do_send_sig_info
  8. do_send_sig_info 调用 send_signal 给进程 A 发送一个信号,其实就是找到进程 A 的 task_struct,或者加入信号集合,为不可靠信号,或者加入信号链表,为可靠信号
  9. do_send_sig_info 调用signal_wake_up唤醒进程A
  10. 进程 A 重新进入运行状态 TASK_RUNNING,根据进程调度第一定律,一定会接着 schedule 运行。
  11. 进程 A 被唤醒后,检查是否有信号到来,如果没有,重新循环到一开始,尝试再次读取数据,如果还是没有数据,再次进入 TASK_INTERRUPTIBLE,即可中断的睡眠状态。
  12. 当发现有信号到来的时候,就返回当前正在运行的系统调用,并返回一个错误表示系统调用被中断了
  13. 系统调用返回的时候,会调用exit_to_usermode_loop,这是一个处理信号的时机
  14. 调用do_signal开始处理信号
  15. 根据信号,得到信号处理函数 sa_handler,然后修改 pt_regs 中的用户态栈的信息,让 pt_regs 指向 sa_handler。同时修改用户态的栈,插入一个栈帧 sa_restorer,里面保存了原来的指向 line A 的 pt_regs,并且设置让 sa_handler 运行完毕后,跳到 sa_restorer 运行。
  16. 返回用户态,由于 pt_regs 已经设置为 sa_handler,则返回用户态执行 sa_handler。
  17. sa_handler 执行完毕后,信号处理函数就执行完了,接着根据第 15 步对于用户态栈帧的修改,会跳到 sa_restorer 运行
  18. sa_restorer 会调用系统调用 rt_sigreturn 再次进入内核。
  19. 在内核中,rt_sigreturn 恢复原来的 pt_regs,重新指向 line A。
  20. 从 rt_sigreturn 返回用户态,还是调用 exit_to_usermode_loop。
  21. 这次因为 pt_regs 已经指向 line A 了,于是就到了进程 A 中,接着系统调用之后运行,当然这个系统调用返回的是它被中断了,没有执行完的错误
    在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值