图 1:第 11 章章首插图
这一篇在干嘛?
信号是最古老的进程间通信机制:一条极简的”编号消息”,却要解决”信号随时可能到达、进程状态不可预知”这个大难题。本章拆解内核的完整解法:产生(记账到进程描述符)与投递(修改执行流)两阶段设计、挂起信号队列、捕获信号时在用户栈上搭”帧”的精巧手法,以及系统调用被信号打断后的自动重启规则。
信号的角色 | 投递时的动作 | 数据结构 | 产生信号 | 投递信号 | 捕获信号 | 系统调用的重新执行 | 信号相关系统调用
信号的角色
信号由第一代 Unix 引入,用于用户态进程之间交互,内核也用它通知进程系统事件。30 年来几乎没怎么变过。
信号是一条非常短的消息:发给一个进程或一组进程,进程得到的通常只是标识信号的一个编号——标准信号没有参数、没有消息正文、没有任何附加信息。信号用 SIG 前缀的宏标识,前文已多次露面:SIGCHLD(值为 17)是子进程停止/终止时发给父进程的信号(见第 3 章);SIGSEGV(值为 11)是非法内存访问时发的信号(见第 9 章)。
信号服务两个目的:①让进程知道某事件发生了;②让进程执行自己代码里的信号处理函数。两者常常兼而有之。
80×86 上 Linux 2.6 处理的前 31 个信号如下(部分信号编号与体系结构相关,个别信号只在特定体系结构定义):
| # | 信号名 | 默认动作 | 含义 | POSIX |
|---|---|---|---|---|
| 1 | SIGHUP | 终止 | 控制终端或进程挂起 | 是 |
| 2 | SIGINT | 终止 | 键盘中断 | 是 |
| 3 | SIGQUIT | 转储 | 键盘退出 | 是 |
| 4 | SIGILL | 转储 | 非法指令 | 是 |
| 5 | SIGTRAP | 转储 | 调试断点 | 否 |
| 6 | SIGABRT/SIGIOT | 转储 | 异常终止 | 是/否 |
| 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 | 转储 | 超过 CPU 限额 | 否 |
| 25 | SIGXFSZ | 转储 | 超过文件大小限额 | 否 |
| 26 | SIGVTALRM | 终止 | 虚拟时钟 | 否 |
| 27 | SIGPROF | 终止 | 性能统计时钟 | 否 |
| 28 | SIGWINCH | 忽略 | 窗口大小改变 | 否 |
| 29 | SIGIO/SIGPOLL | 终止 | 异步 I/O 事件 | 否 |
| 30 | SIGPWR | 终止 | 电源故障 | 否 |
| 31 | SIGSYS/SIGUNUSED | 转储 | 坏系统调用 | 否 |
除常规信号外,POSIX 还引入了实时信号,Linux 编号 32~64。两者关键差异:实时信号总是排队——发多少次就投递多少次;同种常规信号不排队——连发多次只投递一次。Linux 内核自己不用实时信号,但通过一组专用系统调用完整支持 POSIX(见表 11-2)。
| 系统调用 | 说明 |
|---|---|
kill() | 向线程组发信号 |
tkill() | 向进程发信号 |
tgkill() | 向特定线程组中的进程发信号 |
sigaction() / signal() | 改变信号关联的动作 |
sigpending() | 检查是否有挂起信号 |
sigprocmask() | 修改被阻塞信号集合 |
sigsuspend() | 等待信号 |
rt_sigaction() 等 rt_ 系列 | 实时信号版本的对应操作 |
信号最棘手的特性:它可能在任何时刻发给状态不可预知的进程。发给尚未运行进程的信号必须由内核保存到该进程恢复执行;阻塞机制更是雪上加霜。因此内核把信号传输分为两个阶段:
- 产生(generation):内核更新目标进程的数据结构,表示”有一个新信号发来了”;
- 投递(delivery):内核强迫目标进程对信号做出反应——改变执行状态、执行指定的信号处理函数,或两者都做。
每个已产生的信号最多投递一次。信号是消耗性资源:投递完毕后,进程描述符里关于它存在过的信息全部清除。
已产生但未投递的信号叫挂起信号(pending signal)。任意时刻,一个进程对同一种信号最多只有一个挂起实例——同种信号再来的直接丢弃,不排队(实时信号例外,可以多个同类挂起)。
信号可能挂起多久?不可预知,原因有三:
- 信号通常只投递给当前正在运行的进程(current);
- 某类信号可被进程选择性阻塞——解除阻塞之前进程收不到它;
- 进程执行信号处理函数期间通常自动屏蔽该信号本身,所以处理函数不会被同种信号打断,无需可重入。
内核为实现信号机制必须做到:记住每个进程阻塞了哪些信号;几乎每次时钟中断(约每毫秒一次)从内核态切回用户态前都检查有无信号到达;判断信号能否被忽略;以及随时能把进程切进处理函数、返回时还原原始执行上下文。Linux 还得同时兼容 BSD 和 System V 的不同信号语义,并满足 POSIX 颇为繁琐的要求。
一个信号可以被忽略,需同时满足三个条件:
- 目标进程未被跟踪(进程描述符 ptrace 字段的 PT_PTRACED 标志为 0);
- 信号未被目标进程阻塞;
- 目标进程正在忽略该信号(显式设置忽略,或者没改过默认动作而默认动作恰好是”忽略”)。
投递时的动作
进程收到信号有三种回应方式:
- 显式忽略该信号;
- 执行默认动作(内核预定义,随信号类型而异):
- Terminate:进程被杀死;
- Dump:进程被杀死,并尽可能创建一个含执行上下文的 core 文件供调试;
- Ignore:信号被忽略;
- Stop:进程被停止——置入 TASK_STOPPED 状态(见第 3 章);
- Continue:若进程原本停止(TASK_STOPPED),置回 TASK_RUNNING;
- 捕获信号:调用对应的信号处理函数。
注意:阻塞 ≠ 忽略。被阻塞的信号暂不投递,解除阻塞后照样投递;被忽略的信号永远会走投递流程,只是投递后无动作。
SIGKILL 和 SIGSTOP 不能被忽略、捕获或阻塞,默认动作必须执行——它们给了有权限的用户一把无视一切防御的枪:任何进程都能被终止(SIGKILL)或停止(SIGSTOP)。
若投递某信号导致内核杀死进程,则称该信号对该进程是致命的(fatal)。SIGKILL 永远致命;默认动作为 Terminate 且未被捕获的信号也致命。注意:进程捕获了信号、但处理函数自己选择终止进程——这不算致命,因为是进程自己选择退出,不是被内核杀死的。
POSIX 对多线程应用的要求
POSIX 1003.1 对多线程应用的信号处理有严格规定:
- 信号处理函数必须被多线程应用的所有线程共享;但每个线程必须有自己的挂起/阻塞信号掩码;
kill()、sigqueue()等库函数必须把信号发给整个多线程应用而非特定线程;内核产生的信号(SIGCHLD、SIGINT、SIGQUIT 等)同样如此;- 发给多线程应用的信号只投递给一个线程——内核从所有未阻塞该信号的线程中任选一个;
- 致命信号会杀死应用的所有线程,不只是被投递的那个线程。
Linux 2.6 的实现方式(见第 3 章):多线程应用是同一线程组(thread group)里的一组轻量级进程。本章行文约定:“线程组”泛指任何线程组(哪怕只有一个传统进程),“进程”泛指传统进程或轻量级进程。挂起信号相应分两种:发给特定进程的是私有(private)的,发给整个线程组的是共享(shared)的。
数据结构
内核必须为每个进程记录:哪些信号挂起、哪些被屏蔽,以及每个线程组打算如何处理每个信号。这些都挂在进程描述符上:
图 2:与信号处理相关的最重要数据结构(原书图 11-1)
| 类型 | 字段 | 说明 |
|---|---|---|
struct signal_struct * | signal | 指向进程的信号描述符 |
struct sighand_struct * | sighand | 指向进程的信号处理描述符 |
sigset_t | blocked | 被阻塞信号的掩码 |
sigset_t | real_blocked | 被阻塞信号的临时掩码(rt_sigtimedwait() 用) |
struct sigpending | pending | 存放私有挂起信号 |
unsigned long | sas_ss_sp | 备用信号处理栈的地址 |
size_t | sas_ss_size | 备用信号处理栈的大小 |
int (*)(void *) | notifier | 设备驱动用来阻塞进程某些信号的函数指针 |
void * | notifier_data | notifier 函数可能用到的数据 |
sigset_t * | notifier_mask | 设备驱动通过 notifier 阻塞的信号位掩码 |
blocked 字段是 sigset_t 位图,每位对应一种信号:
typedef struct {
unsigned long sig[2];
} sigset_t;每个 unsigned long 32 位,所以 Linux 最多 64 种信号(宏 _NSIG)。信号编号从 1 开始(没有 0 号信号),编号对应 sigset_t 位下标加 1:131 是常规信号,3264 是实时信号。
信号描述符与信号处理描述符
signal 字段指向信号描述符(signal_struct),跟踪共享的挂起信号。它还捎带了一些与信号无关的字段:per-process 资源限额数组 rlim(见第 3 章)、进程组长和会话首进程的 PID(pgrp、session 字段)。原因:信号描述符被同一线程组的所有进程共享(CLONE_THREAD 标志创建的进程),所以”同组进程必须一致”的字段都放在这里。
| 类型 | 字段 | 说明 |
|---|---|---|
atomic_t | count | 信号描述符使用计数 |
atomic_t | live | 线程组中活着的进程数 |
wait_queue_head_t | wait_chldexit | 睡在 wait4() 里的进程的等待队列 |
struct task_struct * | curr_target | 线程组中最后一个收到信号的进程描述符 |
struct sigpending | shared_pending | 存放共享挂起信号 |
int | group_exit_code | 线程组的进程终止码 |
struct task_struct * | group_exit_task | 杀死整个线程组时使用 |
int | notify_count | 杀死整个线程组时使用 |
int | group_stop_count | 停止整个线程组时使用 |
unsigned int | flags | 投递改变进程状态的信号时使用的标志 |
每个进程还引用一个信号处理描述符(sighand_struct),描述线程组对每种信号的处理方式:
| 类型 | 字段 | 说明 |
|---|---|---|
atomic_t | count | 信号处理描述符使用计数 |
struct k_sigaction [64] | action | 数组,每项规定对应信号被投递时执行的动作 |
spinlock_t | siglock | 保护信号描述符和处理描述符的自旋锁 |
CLONE_SIGHAND 标志可让多个进程共享处理描述符,count 记录共享者数量。POSIX 多线程应用中,线程组内所有轻量级进程引用同一个信号描述符和同一个处理描述符。
k_sigaction 与 sigaction
某些体系结构给信号附加只有内核可见的属性,所以属性存放在 k_sigaction 结构里——既含对用户态隐藏的属性,也含用户态可见的 sigaction 结构。80×86 上所有属性都对用户态可见,k_sigaction 就退化成一个 sigaction 结构 sa,包含三个字段:
sa_handler:规定动作类型——指向信号处理函数的指针、SIG_DFL(值 0,执行默认动作)或 SIG_IGN(值 1,忽略信号);sa_flags:一组规定信号如何处理的标志,见表 11-6;sa_mask:sigset_t 变量,规定执行信号处理函数时要屏蔽哪些信号。
| 标志 | 说明 |
|---|---|
SA_NOCLDSTOP | 只对 SIGCHLD 有效;进程被停止时不向父进程发 SIGCHLD |
SA_NOCLDWAIT | 只对 SIGCHLD 有效;进程终止时不产生僵尸进程 |
SA_SIGINFO | 向信号处理函数提供附加信息 |
SA_ONSTACK | 信号处理函数使用备用栈 |
SA_RESTART | 被中断的系统调用自动重启 |
SA_NODEFER / SA_NOMASK | 执行处理函数时不屏蔽该信号 |
SA_RESETHAND / SA_ONESHOT | 处理函数执行完毕后重置为默认动作 |
挂起信号队列
产生信号的系统调用分两类:kill()、rt_sigqueueinfo() 发给整个线程组;tkill()、tgkill() 发给特定进程。于是每个进程挂两条挂起信号队列:
- 共享队列:根在信号描述符的 shared_pending 字段,存放整个线程组的挂起信号;
- 私有队列:根在进程描述符的 pending 字段,存放特定轻量级进程的挂起信号。
队列本体是 sigpending 结构:
struct sigpending {
struct list_head list;
sigset_t signal;
}signal 是标明挂起信号的位图;list 是双向链表头,链上挂着 sigqueue 结构:
| 类型 | 字段 | 说明 |
|---|---|---|
struct list_head | list | 挂起信号队列链表链接 |
spinlock_t * | lock | 指向对应信号处理描述符的 siglock |
int | flags | sigqueue 结构的标志 |
siginfo_t | info | 描述引发信号的事件 |
struct user_struct * | user | 进程属主的 per-user 数据结构 |
siginfo_t 是 128 字节结构,记录一次信号事件:
- si_signo:信号编号;
- si_errno:引发信号的指令的错误码,无错误为 0;
- si_code:标明信号发送者的代码:
| 代码 | 发送者 |
|---|---|
SI_USER | kill() 和 raise() |
SI_KERNEL | 通用内核函数 |
SI_QUEUE | sigqueue() |
SI_TIMER | 定时器到期 |
SI_ASYNCIO | 异步 I/O 完成 |
SI_TKILL | tkill() 和 tgkill() |
- _sifields:联合体,按信号类型存不同信息。比如 SIGKILL 的 siginfo_t 在这里记发送进程的 PID 和 UID;SIGSEGV 的则存引发信号的内存地址。
操作信号数据结构的函数与宏
设 set 是 sigset_t 指针、nsig 是信号编号、mask 是 unsigned long 位掩码:
sigemptyset(set)/sigfillset(set):把 sigset_t 全清 0 / 全置 1;sigaddset(set, nsig)/sigdelset(set, nsig):把对应位置 1 / 清 0。实际展开为:
set->sig[(nsig - 1) / 32] |= 1UL << ((nsig - 1) % 32);
set->sig[(nsig - 1) / 32] &= ~(1UL << ((nsig - 1) % 32));sigaddsetmask(set, mask)/sigdelsetmask(set, mask):按 mask 批量置 1/清 0(仅限 1~32 号信号);sigismember(set, nsig):测试对应位,展开为:
return 1 & (set->sig[(nsig-1) / 32] >> ((nsig-1) % 32));sigandsets(d,s1,s2)/sigorsets/signandsets:对 s1、s2 做按位与/或/与非,结果存 d;sigtestsetmask(set, mask):mask 中任何置 1 位对应的信号位被置位则返回 1(仅限 1~32 号);siginitset(set, mask):用 mask 初始化 132 号信号的位,清 3363 号的位;siginitsetinv相反(取反初始化、置 1);signal_pending(p):进程 p 有未阻塞的挂起信号返回 1,否则 0——只是检查 TIF_SIGPENDING 标志;recalc_sigpending_tsk(t)/recalc_sigpending():查 t 的私有队列(pending->signal)和所属线程组的共享队列(signal->shared_pending->signal),据此设置 TIF_SIGPENDING 标志;后者等价于对 current 操作;rm_from_queue(mask, q):从挂起队列 q 中删除 mask 对应的信号;flush_sigqueue(q):清空队列;flush_signals(t):删除发给进程 t 的所有信号(清 TIF_SIGPENDING,对私有队列和共享队列各调一次 flush_sigqueue)。
产生信号
许多内核函数都会产生信号:它们完成第一阶段(更新进程描述符),并视信号类型和目标进程状态唤醒一些进程迫使它们接收信号。
发给单个进程的信号由表 11-9 的函数产生(最终都汇入 specific_send_sig_info()):
| 函数 | 说明 |
|---|---|
send_sig() | 向单个进程发信号 |
send_sig_info() | 同上,siginfo_t 携带扩展信息 |
force_sig() | 发送不能被进程显式忽略或阻塞的信号 |
force_sig_info() | 同上,带扩展信息 |
force_sig_specific() | 同 force_sig(),为 SIGSTOP/SIGKILL 优化 |
sys_tkill() / sys_tgkill() | 对应系统调用的服务例程 |
发给整个线程组的信号由表 11-10 的函数产生(最终都汇入 group_send_sig_info()):
| 函数 | 说明 |
|---|---|
send_group_sig_info() | 向单个线程组发信号(由其某个成员的描述符标识) |
kill_pg() / kill_pg_info() | 向进程组的所有线程组发信号 |
kill_proc() / kill_proc_info() | 向单个线程组发信号(由 PID 标识) |
sys_kill() | kill() 的系统调用服务例程 |
sys_rt_sigqueueinfo() | rt_sigqueueinfo() 的服务例程 |
specific_send_sig_info()
向特定进程发信号,参数:sig(信号编号)、info(siginfo_t 地址,或三个特殊值——0 表示用户态进程发的、1 表示内核发的、2 表示内核发的 SIGSTOP/SIGKILL)、t(目标进程描述符)。调用前提:本地中断已禁、已持 t->sighand->siglock 自旋锁。步骤:
- 检查忽略:三个忽略条件(未被跟踪、未阻塞、被忽略——后者含显式 SIG_IGN 和隐式情况:sa_handler 为 SIG_DFL 且信号是 SIGCONT/SIGCHLD/SIGWINCH/SIGURG)全部满足则返回 0,不产生信号;
- 去重:非实时信号(sig<32)且私有队列里已有同种挂起信号 → 无事可做返回 0;
- 调
send_signal(sig, info, t, &t->pending)把信号加进私有挂起队列; - 若 send_signal() 成功且信号未被阻塞,调
signal_wake_up()通知进程:置 TIF_SIGPENDING 标志;进程处于 TASK_INTERRUPTIBLE、或处于 TASK_STOPPED 且信号是 SIGKILL 时调 try_to_wake_up()(见第 7 章)唤醒它;唤醒失败说明进程本来就可运行——若它正在别的 CPU 上跑,就向那个 CPU 发处理器间中断强迫重新调度:因为每个进程从 schedule() 返回时都会检查挂起信号,这样能保证目标进程尽快注意到新信号; - 返回 1,信号成功产生。
send_signal()
向挂起信号队列插入新条目,步骤:
- info 为 2:信号是内核经 force_sig_specific() 发的 SIGKILL/SIGSTOP,默认动作立即由内核强制执行,跳到第 9 步(不排队);
- 若进程属主的挂起信号数(t->user->sigpending)未超资源限额(RLIMIT_SIGPENDING),从 slab 分配 sigqueue 结构:
q = kmem_cache_alloc(sigqueue_cachep, GFP_ATOMIC);- 限额超高或分配失败 → 跳第 9 步;
- 递增属主挂起信号计数和 per-user 结构引用计数;
- 把 sigqueue 挂进队列尾部:
list_add_tail(&q->list, &signals->list); - 填 siginfo_t:
if ((unsigned long)info == 0) {
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_USER;
q->info._sifields._kill._pid = current->pid;
q->info._sifields._kill._uid = current->uid;
} else if ((unsigned long)info == 1) {
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_KERNEL;
q->info._sifields._kill._pid = 0;
q->info._sifields._kill._uid = 0;
} else
copy_siginfo(&q->info, info);- 置队列位图中对应信号的位:
sigaddset(&signals->signal, sig);返回 0,信号成功入队; - (第 9 步起)队列放不下、内存不足、或内核立即强制执行——总之没入队。仅当信号是实时信号且经明确要求排队的内核函数发出时才返回 -EAGAIN:
if (sig>=32 && info && (unsigned long) info != 1 && info->si_code != SI_USER)
return -EAGAIN;- 无论如何置位图中的位,返回 0。
为什么队列满了也要让进程收到信号? 设想一个进程内存耗得太多,内核必须保证系统管理员 kill() 它时即使无内存可分配也能成功——否则系统将无从恢复。这就是第 10~11 步”退而求其次:至少置位”的深意。
group_send_sig_info()
向整个线程组发信号,参数同前(p 是线程组某成员的描述符地址):
- 参数检查:
if (sig < 0 || sig > 64) return -EINVAL; - 用户态进程发信号须有权限,满足以下至少一条才放行——发送进程属主有相应权限(通常是系统管理员,见第 20 章);信号是 SIGCONT 且目标进程与发送进程在同一登录会话;两进程属于同一用户。否则返回 -EPERM;
- sig 为 0:立即返回——0 不是合法信号号,它被用来探测发送进程是否有权限向目标线程组发信号。目标进程正在被杀(信号处理描述符已释放)同样直接返回;
- 持
p->sighand->siglock自旋锁、关本地中断; - 调
handle_stop_signal()处理可能抵消其他挂起信号的信号:- 线程组正被杀(SIGNAL_GROUP_EXIT 置位)→ 返回;
- sig 是 SIGSTOP/SIGTSTP/SIGTTIN/SIGTTOU → 从共享队列和全组成员私有队列中删掉 SIGCONT;
- sig 是 SIGCONT → 反过来删掉四个停止信号并唤醒组员:
rm_from_queue(0x003c0000, &p->signal->shared_pending);
t = p;
do {
rm_from_queue(0x003c0000, &t->pending);
try_to_wake_up(t, TASK_STOPPED, 0);
t = next_thread(t);
} while (t != p);掩码 0x003c0000 正好选中四个停止信号;next_thread 宏每轮返回组内另一个轻量级进程(见第 3 章);
- 检查线程组是否忽略该信号(三个条件同前),是则返回 0;
- 非实时信号且共享队列已有同种挂起信号 → 返回 0;
- 调
send_signal()把信号加进共享挂起队列,出错原样返回; - 调
__group_complete_signal()唤醒线程组中一个轻量级进程; - 释放自旋锁、开中断,返回 0。
__group_complete_signal():挑一个幸运儿
它扫描线程组找能接收新信号的进程,候选须满足全部条件:
- 不阻塞该信号;
- 不处于 EXIT_ZOMBIE、EXIT_DEAD、TASK_TRACED、TASK_STOPPED 状态(例外:信号是 SIGKILL 时 TASK_TRACED/TASK_STOPPED 也可);
- 没有正在被杀(PF_EXITING 未置位);
- 正在某个 CPU 上执行,或者 TIF_SIGPENDING 尚未置位——唤醒一个已有挂起信号的进程毫无意义(设置 TIF_SIGPENDING 的那条内核控制路径已唤醒过它);但正在执行的进程应当被通知有新信号。
满足条件的进程可能有多个,挑选规则:
- 参数 p 指定的进程若满足所有条件,直接选它;
- 否则从”线程组上次收到信号的进程”(
p->signal->curr_target)开始扫描组员找合适者。
选中后:信号若是致命的,整个线程组被杀——向组内每个轻量级进程发 SIGKILL;否则调 signal_wake_up() 通知选中进程(步骤同 specific_send_sig_info 第 4 步)。
投递信号
产生阶段只是”记账”:内核改好了接收进程的描述符,若进程当时没在 CPU 上,投递就推迟。真正的投递时机是:内核每次处理完中断或异常、允许进程恢复用户态执行之前,都要检查该进程的 TIF_SIGPENDING 标志(见第 4 章)。
检查到未阻塞的挂起信号后,内核调 do_signal() 处理,参数:
regs:current 的用户态寄存器内容在栈上的保存区地址;oldset:保存被阻塞信号位图的变量地址,无需保存则 NULL。
实际代码塞满了竞态条件处理和各种特殊情况(系统冻结、core 转储、整组停止/杀死……),这里抓主干。
do_signal() 通常只在 CPU 将要返回用户态时被调,所以中断处理程序里调它直接返回:
if ((regs->xcs & 3) != 3)
return 1;
if (!oldset)
oldset = ¤t->blocked;函数主体是循环:反复调 dequeue_signal() 直到私有队列和共享队列里都没有未阻塞的挂起信号。返回 0 表示全部处理完;非零则是待处理信号号。dequeue_signal() 先从编号最小的信号开始扫私有队列,再扫共享队列;清位图、调 recalc_sigpending() 更新 TIF_SIGPENDING,返回信号号。
对每个信号,do_signal() 先看接收进程是否正被别的进程监控,是则调 do_notify_parent_cldstop() 和 schedule() 让监控进程知情。然后取动作表:
ka = ¤t->sig->action[signr-1];显式忽略的信号直接进入下一轮循环:
if (ka->sa.sa_handler == SIG_IGN)
continue;默认动作和处理函数分别在下面两小节展开。
执行默认动作
ka->sa.sa_handler 为 SIG_DFL 时执行默认动作。唯一例外:接收进程是 init(pid==1)——信号直接丢弃:
if (current->pid == 1)
continue;默认动作是”忽略”的(SIGCONT、SIGCHLD、SIGWINCH、SIGURG)同样轻松跳过。
默认动作是”停止”的(SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU)会停住整个线程组:
if (signr==SIGSTOP || signr==SIGTSTP ||
signr==SIGTTIN || signr==SIGTTOU) {
if (signr != SIGSTOP &&
is_orphaned_pgrp(current->signal->pgrp))
continue;
do_signal_stop(signr);
}SIGSTOP 与其余三个有微妙差别:SIGSTOP 无条件停止线程组,其余信号只在非孤儿进程组时才停止。POSIX 规定:只要组里有进程的父进程在同一会话的不同进程组中,该进程组就非孤儿——换句话说,启动它的用户还登录着,父进程死了组也不算孤儿。
do_signal_stop() 检查 current 是不是线程组里第一个被停止的进程;是就发动”组停止”:把信号描述符的 group_stop_count 置正数并唤醒每个组员,组员看到该字段后各自置 TASK_STOPPED、调 schedule()。除非父进程为 SIGCHLD 设了 SA_NOCLDSTOP,还会给线程组长的父进程发 SIGCHLD。
默认动作是”转储”的信号在工作目录创建 core 文件(进程地址空间完整内容和 CPU 寄存器),然后杀线程组。其余 18 个信号默认动作是”终止”:直接调 do_group_exit() 走干净的组退出流程(见第 3 章)。
捕获信号
信号设置了处理函数时,do_signal() 调 handle_signal() 强制执行它:
handle_signal(signr, &info, &ka, oldset, regs);
if (ka->sa.sa_flags & SA_ONESHOT)
ka->sa.sa_handler = SIG_DFL;
return 1;SA_ONESHOT 置位的信号处理一次后重置回默认动作。注意 do_signal() 只处理一个信号就返回,其余挂起信号等下次 do_signal() 再说——这保证了实时信号按正确顺序处理。
为什么捕获这么难?
信号处理函数是用户态函数(在用户态代码段里),而 handle_signal() 跑在内核态。这意味着:进程必须先回用户态执行处理函数,然后才能恢复”正常”执行。麻烦在于——回到用户态再进内核时,内核态栈上不再有被打断程序的硬件上下文,因为每次用户态→内核态切换内核态栈都会清空。
更麻烦的是,信号处理函数自己也可能调系统调用:服务例程执行完后,控制权必须交回信号处理函数,而不是回到被打断程序的原流程。
Linux 的解法
把内核态栈上保存的硬件上下文复制到当前进程的用户态栈;同时改造用户态栈,使处理函数一结束就自动调用 sigreturn() 系统调用,把硬件上下文抄回内核态栈、恢复用户态栈原样。
图 3:捕获一个信号(原书图 11-2)
图 3 展示了完整时序:未阻塞信号发给进程 → 中断/异常使进程进内核态 → 返回用户态前内核执行 do_signal(),它调 handle_signal() 处理信号并调 setup_frame()/setup_rt_frame() 布置用户态栈 → 进程回到用户态,因 eip 被强制改成处理函数入口,开始执行信号处理函数 → 函数结束时执行栈上预置的返回代码,调用 sigreturn()/rt_sigreturn() → 对应服务例程把正常程序的硬件上下文抄回内核态栈、调 restore_sigcontext() 恢复用户态栈原样 → 系统调用结束,正常程序继续执行。
搭帧:setup_frame()
handle_signal() 按信号的 sigaction 表里 SA_SIGINFO 标志二选一:无该标志用 setup_frame(),有则用 setup_rt_frame()(帧里要多放一份 siginfo_t)。
setup_frame(sig, ka, oldset, regs) 往用户态栈压一个叫帧(frame)的 sigframe 结构:
| 字段 | 说明 |
|---|---|
pretcode | 信号处理函数的返回地址,指向 __kernel_sigreturn 标号处的代码 |
sig | 信号编号——处理函数的参数 |
sc | sigcontext 结构:进程进内核态前的硬件上下文(从内核态栈复制),内含常规阻塞信号位图 |
fpstate | _fpstate 结构,可用来保存用户态进程的浮点寄存器(见第 3 章) |
extramask | 实时阻塞信号的位图 |
retcode | 发起 sigreturn() 系统调用的 8 字节代码。旧版 Linux 真的执行它返回;Linux 2.6 里只当签名用——调试器靠它识别信号栈帧 |
图 4:用户态栈上的帧(原书图 11-3)
setup_frame() 先调 get_sigframe() 算帧的首地址——通常就在用户态栈上:
(regs->esp - sizeof(struct sigframe)) & 0xffffff8栈向低地址生长,所以拿当前栈顶减帧大小、8 字节对齐。地址经 access_ok 校验后,反复用 __put_user() 填帧的各字段;pretcode 填 __kernel_sigreturn 的地址——vsyscall 页里的一段胶水代码(见第 10 章)。
最后篡改内核态栈上的 regs 区,确保进程回到用户态时执行流跳进处理函数:
regs->esp = (unsigned long) frame;
regs->eip = (unsigned long) ka->sa.sa_handler;
regs->eax = (unsigned long) sig;
regs->edx = regs->ecx = 0;
regs->xds = regs->xes = regs->xss = __USER_DS;
regs->xcs = __USER_CS;esp 指向帧、eip 指向处理函数、eax 备好信号参数、段寄存器复位默认值。setup_rt_frame() 同理,只是帧换成含 siginfo_t 的 rt_sigframe,pretcode 指向 vsyscall 页里的 __kernel_rt_sigreturn。
评估信号标志
帧搭好后,handle_signal() 检查信号标志。未设 SA_NODEFER 时,处理函数执行期间必须阻塞 sa_mask 里的信号(外加信号本身):
if (!(ka->sa.sa_flags & SA_NODEFER)) {
spin_lock_irq(¤t->sighand->siglock);
sigorsets(¤t->blocked, ¤t->blocked, &ka->sa.sa_mask);
sigaddset(¤t->blocked, sig);
recalc_sigpending(current);
spin_unlock_irq(¤t->sighand->siglock);
}随后 handle_signal() 和 do_signal() 相继返回,进程恢复用户态执行——由于 setup_frame() 的布置,eip 指向处理函数第一条指令、esp 指向用户态栈顶的帧,处理函数开始运行。
终止处理函数
处理函数一返回,栈顶的返回地址(pretcode)指向 vsyscall 页里的代码:
__kernel_sigreturn:
popl %eax
movl $_NR_sigreturn, %eax
int $0x80先 pop 掉帧里的信号编号,再发起 sigreturn() 系统调用。
sys_sigreturn() 从 esp 字段推算并校验帧地址:
frame = (struct sigframe *) (regs.esp - 8);
if (verify_area(VERIFY_READ, frame, sizeof(*frame)) {
force_sig(SIGSEGV, current);
return 0;
}然后把帧 sc 字段里保存的”调用处理函数前”的阻塞信号位图恢复到 current->blocked——处理期间被屏蔽的信号全部解除——再调 recalc_sigpending()。最后调 restore_sigcontext() 完成两件事:把进程硬件上下文从帧的 sc 字段抄回内核态栈、把帧从用户态栈上摘掉恢复原样。此后系统调用结束,正常程序继续执行。走 rt_sigreturn() 路径的实时信号机制相同,只是操作对象是扩展帧。
系统调用的重新执行
内核并不总能立即满足系统调用请求——此时进程被置入 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE 状态。若进程睡在 TASK_INTERRUPTIBLE 上时被信号盯上,内核会把它置回 TASK_RUNNING 而不完成系统调用(见第 4 章),信号在切回用户态时投递。这时服务例程没干完活,返回以下错误码之一:EINTR、ERESTARTNOHAND、ERESTART_RESTARTBLOCK、ERESTARTSYS、ERESTARTNOINTR。
用户态程序实际只能看到 EINTR——系统调用未完成,应用可自查后决定是否重发。其余错误码是内核内部用的,标明”处理函数结束后系统调用能否自动重新执行”:
| 信号动作 \ 错误码 | EINTR | ERESTARTSYS | ERESTARTNOHAND / ERESTART_RESTARTBLOCK | ERESTARTNOINTR |
|---|---|---|---|---|
| 默认动作 | 终止 | 重新执行 | 重新执行 | 重新执行 |
| 忽略 | 终止 | 重新执行 | 重新执行 | 重新执行 |
| 捕获 | 终止 | 取决于 SA_RESTART | 终止 | 重新执行 |
三种结果含义:
- Terminate:不自动重执行;进程在用户态从 int $0x80/sysenter 的下一条指令继续,eax 为 -EINTR;
- Reexecute:内核强迫用户态进程把调用号重新装进 eax、重新执行 int $0x80/sysenter——进程对此毫无知觉,错误码也不会传给它;
- Depends:仅当所投递信号的 SA_RESTART 标志置位才重执行,否则以 -EINTR 终止。
orig_eax:我是不是睡在系统调用里?
投递信号前,内核必须确认进程确实发起过系统调用才能重启它。regs 硬件上下文的 orig_eax 字段是关键,它在处理程序启动时被初始化为:
所以 orig_eax 非负 = 信号唤醒的是一个睡在系统调用里的 TASK_INTERRUPTIBLE 进程,服务例程据此返回上述错误码。
未捕获信号的重启
信号被显式忽略或走默认动作时,do_signal() 按表 11-11 决定是否自动重执行。要重启就篡改硬件上下文,让进程回到用户态时 eip 指向 int $0x80/sysenter、eax 装着调用号:
if (regs->orig_eax >= 0) {
if (regs->eax == -ERESTARTNOHAND || regs->eax == -ERESTARTSYS ||
regs->eax == -ERESTARTNOINTR) {
regs->eax = regs->orig_eax;
regs->eip -= 2;
}
if (regs->eax == -ERESTART_RESTARTBLOCK) {
regs->eax = _NR_restart_syscall;
regs->eip -= 2;
}
}int $0x80 和 sysenter 指令都是 2 字节长,所以 eip 减 2 正好退回发起系统调用的那条指令。
ERESTART_RESTARTBLOCK 特殊:eax 装的是 restart_syscall() 的调用号——用户态进程重启的不是原来那个系统调用!这个错误码只被时间相关的系统调用使用:重启时需要修正用户态参数。典型是 nanosleep()(见第 6 章):进程睡 20 毫秒,10 毫秒时来了信号;若按普通方式重启,总延时将超过 30 毫秒。正确做法:nanosleep() 服务例程把专用重启例程的地址填进 current 的 thread_info 的 restart_block 字段,然后返回 -ERESTART_RESTARTBLOCK;sys_restart_syscall() 执行这个专用例程,它按”原调用到重启之间流逝的时间”修正延时。
捕获信号的重启
信号被捕获时由 handle_signal() 决定,还要参考 SA_RESTART 标志:
if (regs->orig_eax >= 0) {
switch (regs->eax) {
case -ERESTART_RESTARTBLOCK:
case -ERESTARTNOHAND:
regs->eax = -EINTR;
break;
case -ERESTARTSYS:
if (!(ka->sa.sa_flags & SA_RESTART)) {
regs->eax = -EINTR;
break;
}
/* fallthrough */
case -ERESTARTNOINTR:
regs->eax = regs->orig_eax;
regs->eip -= 2;
}
}要重启就跟 do_signal() 一样干;不重启就给用户态返回 -EINTR。
信号相关系统调用
出于历史原因,干同一件事的系统调用有好几套,有些实际从不被调用——比如 sys_sigaction() 和 sys_rt_sigaction() 几乎相同,C 库的 sigaction() 包装函数实际调的是 sys_rt_sigaction()。
kill()
kill(pid, sig) 向常规进程或多线程应用发信号,服务例程 sys_kill()。pid 参数含义随值而变:
- pid > 0:发给 PID 等于 pid 的进程所属线程组;
- pid = 0:发给与调用进程同进程组的所有线程组;
- pid = -1:发给除 swapper(PID 0)、init(PID 1)和 current 之外的所有进程;
- pid < -1:发给进程组 -pid 中的所有线程组。
sys_kill() 先搭一个最小 siginfo_t,再调 kill_something_info():
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_USER;
info._sifields._kill._pid = current->tgid;
info._sifields._kill._uid = current->uid;
return kill_something_info(sig, &info, pid);后者按 pid 值分发:kill_proc_info()(单线程组,经 group_send_sig_info())、kill_pg_info()(扫描目标进程组对每个进程调 send_sig_info())、或对系统里每个进程反复调 group_send_sig_info()(pid 为 -1 时)。
kill() 能发包括实时信号(32~64)在内的所有信号,但不保证入队——同种挂起信号可能丢失。要可靠发实时信号应该用 rt_sigqueueinfo()。System V 和 BSD 还有 killpg()(向进程组发信号),Linux 里是库函数(内部用 kill() 实现);raise() 向当前进程自己发信号,也是库函数。
tkill() 与 tgkill()
向线程组里的特定进程发信号,各 POSIX 兼容 pthread 库的 pthread_kill() 都靠它们实现。tkill(pid, sig) 两个参数;tgkill(tgid, pid, sig) 多一个线程组 ID——服务例程多做一步”确认目标进程确实属于线程组 tgid”。这多的一步解决了竞态:向一个正在被杀的进程发信号时,若另一个多线程应用正在飞速创建轻量级进程,信号可能投给错误的新进程;tgkill 靠”线程组 ID 在应用整个生命周期不变”堵住这个漏洞。
改变信号动作:sigaction() 与 signal()
sigaction(sig, act, oact) 允许用户为信号指定动作;未定义动作时内核执行默认动作。sys_sigaction() 先检查 act 地址有效性,把用户传入的各字段抄进 k_sigaction 局部变量 new_ka:
__get_user(new_ka.sa.sa_handler, &act->sa_handler);
__get_user(new_ka.sa.sa_flags, &act->sa_flags);
__get_user(mask, &act->sa_mask);
siginitset(&new_ka.sa.sa_mask, mask);再调 do_sigaction() 把新表复制到 current->sig->action[sig-1](信号号比数组下标大 1,因为没有 0 号信号):
k = ¤t->sig->action[sig-1];
if (act) {
*k = *act;
sigdelsetmask(&k->sa.sa_mask, sigmask(SIGKILL) | sigmask(SIGSTOP));
if (k->sa.sa_handler == SIG_IGN || (k->sa.sa_handler == SIG_DFL &&
(sig==SIGCONT || sig==SIGCHLD || sig==SIGWINCH || sig==SIGURG)) {
rm_from_queue(sigmask(sig), ¤t->signal->shared_pending);
t = current;
do {
rm_from_queue(sigmask(sig), ¤t->pending);
recalc_sigpending_tsk(t);
t = next_thread(t);
} while (t != current);
}
}两个 POSIX 强制细节:把信号动作设为 SIG_IGN 或”默认动作恰好是忽略”的 SIG_DFL 时,同类的全部挂起信号必须被丢弃;且无论 sa_mask 填了什么,SIGKILL 和 SIGSTOP 永不被屏蔽。
老 System V 的 signal() 系统调用至今仍被广泛使用;新 C 库用 rt_sigaction() 实现它。Linux 为兼容旧库仍提供 sys_signal():
new_sa.sa.sa_handler = handler;
new_sa.sa.sa_flags = SA_ONESHOT | SA_NOMASK;
ret = do_sigaction(sig, &new_sa, &old_sa);
return ret ? ret : (unsigned long)old_sa.sa.sa_handler;检查挂起信号与修改阻塞集合
sigpending(set) 让进程检查”被阻塞的挂起信号”集合——阻塞期间被触发的那些。sys_sigpending() 把私有队列和共享队列取并、再与 blocked 取交,拷回用户变量:
sigorsets(&pending, ¤t->pending.signal,
¤t->signal->shared_pending.signal);
sigandsets(&pending, ¤t->blocked, &pending);
copy_to_user(set, &pending, 4);sigprocmask(how, set, oset) 修改阻塞信号集合(只适用于常规信号)。参数:oset 指向保存旧位图的用户变量;set 指向新位图;how 取三个值——SIG_BLOCK(把 *set 的信号加进阻塞集合)、SIG_UNBLOCK(移出)、SIG_SETMASK(整体替换)。服务例程核心:
if (copy_from_user(&new_set, set, sizeof(*set)))
return -EFAULT;
new_set &= ~(sigmask(SIGKILL)|sigmask(SIGSTOP));
old_set = current->blocked.sig[0];
if (how == SIG_BLOCK)
sigaddsetmask(¤t->blocked, new_set);
else if (how == SIG_UNBLOCK)
sigdelsetmask(¤t->blocked, new_set);
else if (how == SIG_SETMASK)
current->blocked.sig[0] = new_set;
else
return -EINVAL;
recalc_sigpending(current);
if (oset && copy_to_user(oset, &old_set, sizeof(*oset)))
return -EFAULT;
return 0;注意 SIGKILL 和 SIGSTOP 的位先被清掉——用户永远堵不住这两个信号。
挂起进程:sigsuspend()
sigsuspend(mask) 先按 mask 指向的位图阻塞常规信号,然后把进程置入 TASK_INTERRUPTIBLE 睡眠;只有收到非忽略、非阻塞的信号才醒来。sys_sigsuspend():
mask &= ~(sigmask(SIGKILL) | sigmask(SIGSTOP));
saveset = current->blocked;
siginitset(¤t->blocked, mask);
recalc_sigpending(current);
regs->eax = -EINTR;
while (1) {
current->state = TASK_INTERRUPTIBLE;
schedule();
if (do_signal(regs, &saveset))
return -EINTR;
}schedule() 挑别的进程跑;本进程被再次调度时,do_signal() 投递唤醒它的信号,非忽略则系统调用以 -EINTR 结束。
sigsuspend() 看似多余——sigprocmask() 加 sleep() 似乎等价,实则不然。因为进程可能在任意时刻被交错执行:“系统调用做 A + 系统调用做 B” ≠ “单个系统调用先 A 后 B”。具体到本例:sigprocmask() 解除阻塞的信号可能在 sleep() 之前就被投递,进程会永远睡下去等一个”已经来过”的信号。而 sigsuspend() 在解除阻塞到 schedule() 之间不允许其他进程抢到 CPU,窗口不存在——这是经典的原子性问题。
实时信号系统调用
前面讲的系统调用只适用于常规信号,实时信号需要额外一套:rt_sigaction()、rt_sigpending()、rt_sigprocmask()、rt_sigsuspend() 与前述版本类似,不赘述。另有两个操作实时信号队列的:
rt_sigqueueinfo():发送实时信号并确保加入目标进程的共享挂起队列——通常经标准库函数 sigqueue() 调用;rt_sigtimedwait():把一个被阻塞的挂起信号出队而不投递、把信号号返回给调用者;没有阻塞的挂起信号则让 current 睡固定时长——通常经 sigwaitinfo()/sigtimedwait() 库函数调用。
常见坑:把"阻塞"当成"忽略"
阻塞只是推迟投递——解除阻塞后信号照样到;忽略是投递流程里”收到但什么都不做”。写多线程程序时若想”让某信号彻底安静”,应设 SIG_IGN 而不是把它加进 blocked 掩码,否则信号会在解除阻塞时突然冒出来,而且只到一次(常规信号不排队)。
常见坑:以为 kill() 发实时信号是可靠的
kill() 不保证挂起信号入队——send_signal() 在队列满/内存不足时只置位不插入,同种常规信号本来就去重。要用”发几次收几次”的实时语义,必须用 rt_sigqueueinfo()(库函数 sigqueue())。但反过来,内核刻意保证:kill 一个内存耗尽的进程永远成功,否则管理员将无法救活系统。
通关标准
能不看书画出捕获信号的完整时序:信号产生 → TIF_SIGPENDING → do_signal → handle_signal → setup_frame 改用户栈和 regs → 用户态执行处理函数 → pretcode 触发 sigreturn → restore_sigcontext 恢复现场。并能回答:为什么不能直接让处理函数”调用完就回原流程”?——达标。
为什么常规信号不排队,而实时信号排队?不排队会导致什么?
常规信号用一个 sigset_t 位图记”哪种信号挂起”,同种信号再多也只是同一个位,所以连发多次只投一次。实时信号用链表逐个记录 sigqueue 条目,发几次收几次。不排队的后果:信号计数信息丢失(比如 SIGCHLD 连续报告多个子进程退出时,父进程只知道”至少有一个”)。
捕获信号时,为什么必须把硬件上下文复制到用户态栈,而不是留在内核态栈?
因为处理函数在用户态执行,执行完还要发 sigreturn() 系统调用重新进内核——每次用户态进内核,内核态栈都会被清空重来,原先保存的硬件上下文早已不在。所以上下文必须”随身携带”到用户态栈的帧里,sigreturn 时再从帧里抄回内核态栈。同时用户态栈上预置的返回代码保证处理函数结束后自动走 sigreturn 路径,而不是回到被打断的原流程。
SIGKILL 和 SIGSTOP 为什么被设计成不可忽略、不可捕获、不可阻塞?
这是系统管理员的最后手段:无论目标进程的代码怎么自我防御(忽略信号、装处理函数、屏蔽掩码),有权限的用户都必须能终止(SIGKILL)或停止(SIGSTOP)它。为此内核在多处硬编码兜底:do_sigaction 里从 sa_mask 删除它们的位、sys_sigprocmask 开头清掉掩码中它们的位、do_signal 里即使被”忽略”也照样执行默认动作。
系统调用睡在 TASK_INTERRUPTIBLE 上时收到信号,为什么用户程序有时看到 EINTR、有时毫无知觉地被重启?
取决于服务例程返回的内部错误码和信号的处置方式:EINTR 总是终止并返回;ERESTARTSYS 在信号被捕获且未设 SA_RESTART 时也返回 -EINTR,否则自动重启;ERESTARTNOINTR 无条件重启。重启的实现是篡改硬件上下文:eax 恢复为 orig_eax(原调用号)、eip 减 2 退回 int $0x80/sysenter 指令——进程回用户态后重新陷入内核,完全不知道发生过中断。
sigsuspend(mask) 比 sigprocmask + sleep 强在哪?
原子性。sigsuspend 在同一个系统调用里完成”改掩码 + 睡眠”,两者之间进程不会被抢占,信号无法钻空子。而 sigprocmask 解除阻塞后、sleep 前的窗口里,目标信号可能已被投递,随后的 sleep 会永远睡下去等一个不会再来的信号。这是”两条系统调用 ≠ 一条系统调用”的经典案例。