理解Linux信号产生,接受与处理机制
信号是Linux操作系统中一种用于进程间通信和异步事件处理的机制。在本文中,我们将结合Linux的源码,深入分析信号的产生、发送、接收和处理的底层原理。
文章目录
- 理解Linux信号产生,接受与处理机制
- 什么是信号
- 信号处理相关的数据结构
- 信号的产生与发送
- 信号的接收与处理
- 信号的触发点
- 信号处理函数的执行
什么是信号
信号本质上是在软件层次上对中断机制的一种模拟,其主要有以下几种来源:
- 程序错误:除零,非法内存访问等。
- 外部信号:终端 Ctrl-C 产生 SGINT 信号,定时器到期产生SIGALRM等。
- 显式请求:kill函数允许进程发送任何信号给其他进程或进程组。
目前 Linux 支持64种信号。信号分为非实时信号(不可靠信号)和实时信号(可靠信号)两种类型,对应于 Linux 的信号值为 1-31 和 34-64。
发送信号的系统调用通常包括:
kill(pid, sig)
:向进程ID为pid
的进程发送信号sig
。raise(sig)
:向自己发送信号sig
。alarm(seconds)
:在指定时间后给自己发送SIGALRM
信号。sigqueue(pid, sig, value)
:向进程ID为pid
的进程发送信号sig
,并传递附加值value
。
接收信号的进程可以通过注册信号处理函数来处理收到的信号,比如使用signal()
或者sigaction()
系统调用。
在include/signal.h头文,gnalO函数原型声明如下:
void (*signal(int signr,void (*handler)(int)))(int);|
这个 signal()函数有两个参数。一个指定需要捕获的信号 signr;另外一个是新的信号处理函数指针(新的信号处理句柄)void (*handler)(int)。新的信号处理句柄是一个无返回值且具有一个整型参数的函数指针,该整型参数用于当指定信号发生时内核将其传递给处理句柄。
signal( )函数会给信号值是 signr 的信号安装一个新的信号处理函数句柄handler,该信号句柄可以是用户指定的一个信号处理函数,也可以是内核提供的特定的函数指针SIG_IGN或SIG_DFL。当指定的信号到来时,如果相关的信号处理句柄被设置成SIG_IGN,那么该信号就会被忽略掉。如果信号句柄是SIG_DFL,那么就会执行该信号的默认操作。否则,如果信号句柄被设置成用户的一个信号处理函数,那么内核首先会把该信号句柄被复位成其默认句柄,或者会执行与实现相关的信号阻塞操作,然后会调用执行指定的信号处理函数。signal()函数会返回原信号处理句柄,这个返回的句柄也是一个无返回值且具有一个整型参数的函数指针。并且在新句柄被调用执行过一次后,信号处理句柄又会被恢复成默认处理句柄值SIG_DFL。
信号是异步的,一个进程不必通过任何操作来等待信号的到达。事实上,进程也不知道信号到底什么时候到达。一般来说,我们只需要在进程中设置信号相应的处理函数,当有信号到达的时候,由系统异步触发相应的处理函数即可。如下代码:
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
void sigcb(int signo) {
switch (signo) {
case SIGHUP:
printf("Get a signal -- SIGHUP\n");
break;
case SIGINT:
printf("Get a signal -- SIGINT\n");
break;
case SIGQUIT:
printf("Get a signal -- SIGQUIT\n");
break;
}
return;
}
int main() {
signal(SIGHUP, sigcb);
signal(SIGINT, sigcb);
signal(SIGQUIT, sigcb);
for (;;) {
sleep(1);
}
}
运行程序后,当我们按下 Ctrl+C 后,屏幕上将会打印 Get a signal – SIGINT。当然我们可以使用 kill -s SIGINT pid命令来发送一个信号给进程,屏幕同样打印出 Get a signal – SIGINT 的信息。
信号处理相关的数据结构
在进程管理结构 task_struct 中有几个与信号处理相关的字段,如下:
struct task_struct {
...
int sigpending;
...
struct signal_struct *sig;
sigset_t blocked;
struct sigpending pending;
...
}
int sigpending
: 表示该进程是否有待处理的信号。值为 1 表示有待处理的信号,值为 0 表示没有。struct signal_struct \*sig
: 指向信号处理相关信息的指针。sigset_t blocked
: 表示被屏蔽的信号集,每个位代表一个被屏蔽的信号。struct sigpending pending
: 存储接收到但尚未处理的信号队列。
其实struct signal_struct 是个比较复杂的结构,其 action 成员是个 struct k_sigaction 结构的数组,数组中的每个成员代表着相应信号的处理信息,而 struct k_sigaction 结构其实是 struct sigaction 的简单封装。
#define _NSIG 64
struct signal_struct {
atomic_t count;
struct k_sigaction action[_NSIG];
spinlock_t siglock;
};
typedef void (*__sighandler_t)(int);
struct sigaction {
__sighandler_t sa_handler;//信号的处理方法
unsigned long sa_flags;//信号选项标志
void (*sa_restorer)(void);//信号恢复函数指针(系统内部使用)
sigset_t sa_mask;//信号的屏蔽码,可以阻塞指定的信号集
};
struct k_sigaction {
struct sigaction sa;
};
当信号产生时,内核会将其添加到目标进程的 pending
队列中,表示有待处理的信号。进程在执行过程中会定期检查 pending
队列,如果有待处理信号,则根据信号处理函数的定义执行相应的处理操作。信号处理函数的定义和管理通过 k_sigaction
结构和 struct sigaction
结构实现,其中包括处理函数指针和处理标志等信息。
信号的产生与发送
首先,信号的产生是随机,所以进程是不会专门用一个类似 wait( ) 函数去等待信号的发生,因为在进程运行的整个周期(开始->结束),信号可能根本不会发生,那我们进程是如何接受到信号的呢?
信号的发送,是一个系统调用,这意味着,进程需要从用户态切换到内核态,系统调用sys_kill ( )函数,这个函数的作用是,通过判断信号的种类,找遍历整个进程数组,找到符合条件的进程发送信号,而发送信号的函数用的就是 send_sig( )
sys_kill(int pid, int sig)
{
struct siginfo info;
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_USER;
info.si_pid = current->pid;
info.si_uid = current->uid;
return kill_something_info(sig, &info, pid);
}
sys_kill()
系统调用遍历进程表,找到目标进程后调用send_sig()
函数:
static int send_signal(int sig, struct siginfo *info, struct sigpending *signals)
{
struct sigqueue *q = NULL;
// 检查是否达到最大信号队列数
if (atomic_read(&nr_queued_signals) < max_queued_signals) {
q = kmem_cache_alloc(sigqueue_cachep, GFP_ATOMIC);
}
if (q) {
atomic_inc(&nr_queued_signals);
q->next = NULL;
// 添加到信号队列尾部
*signals->tail = q;
signals->tail = &q->next;
// 根据不同情况设置信号信息
switch ((unsigned long)info) {
case 0:
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_USER;
q->info.si_pid = current->pid;
q->info.si_uid = current->uid;
break;
case 1:
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 (sig >= SIGRTMIN && info && (unsigned long)info != 1 && info->si_code != SI_USER) {
return -EAGAIN;
}
// 将信号添加到信号集中
sigaddset(&signals->signal, sig);
return 0;
}
它首先检查是否达到最大信号队列数,然后分配一个新的 sigqueue
结构并将其添加到 signals
队列中。根据信号的来源和类型,它会设置相应的信号信息,并将信号添加到信号集中。如果无法分配新的 sigqueue
结构,且信号类型为实时信号并且不是用户态产生的,函数将返回错误码 -EAGAIN
。
简单来说就是,send_signal函数会找到符合条件的进程,并将信号记录在其描述结构体中
信号的接收与处理
信号的触发点
上面介绍了怎么发生一个信号给指定的进程,但是什么时候会触发信号相应的处理函数呢?为了尽快让信号得到处理,Linux把信号处理过程放置在进程从内核态返回到用户态前,也就是在 ret_from_sys_call 中检查进程的 sigpending 成员是否等于1,如果是则会调用 do_signal() 函数进行处理。
在ret_from_sys_call
文件中,中断返回时会调用do_signal()
do_signal()函数是内核系统调用(int 0x80)中断处理程序中对信号的预处理程序。在进程每次调用系统调用或者发生时钟等中断时,若进程已收到信号,则该函数就会把信号的处理句柄(即对应的信号处理函数)插入到用户程序堆栈中。这样,在当前系统调用结束返回后就会立刻执行信号句柄程序,然后再继续执行用户的程序
do_signal()
函数实现如下:
int do_signal(struct pt_regs *regs, sigset_t *oldset)
{
siginfo_t info;
struct k_sigaction *ka;
// 如果不在用户态,直接返回
if ((regs->xcs & 3) != 3)
return 1;
if (!oldset)
oldset = ¤t->blocked;
for (;;) {
unsigned long signr;
// 获取一个待处理的信号
spin_lock_irq(¤t->sigmask_lock);
signr = dequeue_signal(¤t->blocked, &info);
spin_unlock_irq(¤t->sigmask_lock);
if (!signr)
break;
ka = ¤t->sig->action[signr-1];
// 如果信号被忽略,继续处理下一个信号
if (ka->sa.sa_handler == SIG_IGN) {
if (signr != SIGCHLD)
continue;
while (sys_wait4(-1, NULL, WNOHANG, NULL) > 0)
/* nothing */;
continue;
}
// 如果信号采用默认处理方法,进行相应处理
if (ka->sa.sa_handler == SIG_DFL) {
int exit_code = signr;
// init 进程特殊处理
if (current->pid == 1)
continue;
switch (signr) {
case SIGCONT: case SIGCHLD: case SIGWINCH:
continue;
case SIGTSTP: case SIGTTIN: case SIGTTOU:
if (is_orphaned_pgrp(current->pgrp))
continue;
current->state = TASK_STOPPED;
current->exit_code = signr;
if (!(current->p_pptr->sig->action[SIGCHLD-1].sa.sa_flags & SA_NOCLDSTOP))
notify_parent(current, SIGCHLD);
schedule();
continue;
case SIGQUIT: case SIGILL: case SIGTRAP:
case SIGABRT: case SIGFPE: case SIGSEGV:
case SIGBUS: case SIGSYS: case SIGXCPU: case SIGXFSZ:
if (do_coredump(signr, regs))
exit_code |= 0x80;
default:
sigaddset(¤t->pending.signal, signr);
recalc_sigpending(current);
current->flags |= PF_SIGNALED;
do_exit(exit_code);
}
}
// 调用自定义的信号处理函数
handle_signal(signr, ka, &info, oldset, regs);
return 1;
}
return 0;
}
do_signal()
函数会检查进程的signal
成员变量,如果发现有未处理的信号,会根据信号处理函数进行处理。
static void handle_signal(unsigned long sig, struct k_sigaction *ka,
siginfo_t *info, sigset_t *oldset, struct pt_regs *regs)
{
if (ka->sa.sa_flags & SA_SIGINFO)
setup_rt_frame(sig, ka, info, oldset, regs);
else
setup_frame(sig, ka, oldset, regs);
if (ka->sa.sa_flags & SA_ONESHOT)
ka->sa.sa_handler = SIG_DFL;
if (!(ka->sa.sa_flags & SA_NODEFER)) {
spin_lock_irq(¤t->sigmask_lock);
sigorsets(¤t->blocked, ¤t->blocked, &ka->sa.sa_mask);
sigaddset(¤t->blocked, sig);
recalc_sigpending(current);
spin_unlock_irq(¤t->sigmask_lock);
}
}
由于信号处理程序是由用户提供的,所以信号处理程序的代码是在用户态的。而从系统调用返回到用户态前还是属于内核态,CPU是禁止内核态执行用户态代码的,那么怎么办?
首先返回到用户态执行信号处理程序,然后执行完信号处理程序后再返回到内核态,再在内核态完成收尾工作。
信号处理函数的执行
为了达到这个目的,Linux经历了一个十分崎岖的过程。我们知道,从内核态返回到用户态时,CPU要从内核栈中找到返回到用户态的地址(就是调用系统调用的下一条代码指令地址),Linux为了先让信号处理程序执行,所以就需要把这个返回地址修改为信号处理程序的入口,这样当从系统调用返回到用户态时,就可以执行信号处理程序了。
所以,handle_signal() 调用了 setup_frame() 函数来构建这个过程的运行环境(其实就是修改内核栈和用户栈相应的数据来完成)。
以下是setup_frame()
函数的关键代码:
static void setup_frame(int sig, struct k_sigaction *ka,
sigset_t *set, struct pt_regs *regs)
{
regs->eip = (unsigned long) ka->sa.sa_handler;// regs是内核栈中保存的寄存器集合
// ...
}
该函数会将信号处理程序的地址设置到内核栈中,并在用户栈中构建一个帧,以便信号处理程序执行完毕后调用`sigreturn()`系统调用返回内核态。
现在可以在内核态返回到用户态时自动执行信号处理程序了,但是当信号处理程序执行完怎么返回到内核态呢?Linux的做法就是在用户态栈空间构建一个 Frame(帧),构建这个帧的目的就是为了执行完信号处理程序后返回到内核态,并恢复原来内核栈的内容。返回到内核态的方式是调用一个名为 sigreturn() 系统调用,然后再 sigreturn() 中恢复原来内核栈的内容。
怎样能在执行完信号处理程序后调用 sigreturn() 系统调用呢?其实跟前面修改内核栈 eip 的值一样,这里修改的是用户栈 eip 的值,修改后跳转到一个执行下面代码的地方(用户栈的某一处):
popl %eax
movl $__NR_sigreturn,%eax
int $0x80
最后,当信号处理函数执行完毕后,会调用sigreturn()
系统调用来恢复信号处理前的状态。sigreturn()
函数会从用户栈中读取原来的内核栈数据,恢复之后继续执行信号处理前的代码。以下是sigreturn()
函数的关键代码:
asmlinkage int sys_sigreturn(unsigned long __unused)
{
struct pt_regs *regs = (struct pt_regs *) &__unused;
struct sigframe *frame = (struct sigframe *)(regs->esp - 8);
sigset_t set;
int eax;
// 省略部分代码
if (restore_sigcontext(regs, &frame->sc, &eax))
goto badframe;
return eax;
badframe:
force_sig(SIGSEGV, current);
return 0;
}
在信号处理函数执行时,内核会修改用户栈,使得返回用户态时首先执行信号处理函数。这是通过在用户栈上创建一个新的栈帧实现的,该栈帧包含信号处理函数的地址。当信号处理函数执行完毕后,恢复原来的栈帧,继续执行原来的程序。
参考文献:
- 《Linux内核完全注释》
- Linux内核信号处理机制介绍
- 深入理解Linux内核信号处理机制原理