《从零手写操作系统 (31):信号进阶——sigaction/sigprocmask与可靠信号处理》
前言:从“粗暴中断”到“精确异步控制”
在前面的章节中,我们的信号系统仅支持最原始的signal()注册和简单的投递。这在玩具系统中尚可工作,但在真实场景中却危机四伏:handler执行期间若同一信号再次到达,是重新触发还是丢弃?如何安全地在临界区屏蔽SIGCHLD以防止waitpid竞态?如何区分SIGSEGV是来自非法内存访问还是mprotect权限不足?signal()的语义在不同Unix变种间互不兼容,且无法传递任何上下文信息。
POSIX信号API(sigaction/sigprocmask)的诞生,正是为了解决“不可靠信号”的历史债务。它将信号处理从“全局副作用回调”升级为“带掩码、带上下文、可原子操作的异步事件机制”。本章我们将彻底重构信号子系统,让你的OS拥有生产级的异步事件处理能力。
本章里程碑:
- ✅
struct sigaction完整支持:sa_handler/sa_sigaction/sa_mask/sa_flags - ✅
sys_sigaction实现:原子替换handler+mask,无竞态窗口 - ✅
sys_sigprocmask:阻塞/解除/查询信号集,支持SIG_BLOCK/UNBLOCK/SETMASK - ✅ 信号队列化:实时信号排队 vs 标准信号合并语义
- ✅ handler执行时自动阻塞sa_mask中的信号,返回后恢复
- ✅
SA_SIGINFO支持:向handler传递ucontext_t与siginfo_t - ✅ 验证:SIGCHLD在waitpid临界区被正确屏蔽,handler重入安全
核心概念:可靠信号不是“更多功能”,而是“消除未定义行为”
signal()的致命缺陷:handler安装与执行的竞态
旧式signal(signum, handler)等价于“读取当前handler → 设置新handler → 返回旧handler”三步非原子操作。如果在读取和设置之间信号到达,进程会使用旧的(可能是SIG_DFL或已卸载的)handler处理该信号。更严重的是,许多早期Unix实现会在进入handler前自动将handler重置为SIG_DFL,要求handler内部第一行就重新调用signal()重装自己。这创造了一个经典的竞态窗口:若在重装前信号再次到达,进程将以默认动作(通常是终止)响应。
sigaction()通过单次系统调用原子地完成“读取旧配置+写入新配置”,彻底消除了这个窗口。同时,sa_mask字段允许指定“在执行此handler期间额外阻塞哪些信号”,由内核在进入handler前自动应用、退出时自动恢复,无需用户态手动sigprocmask。
⚠️ 关键洞察:信号掩码是每线程/每进程的私有状态,而非全局属性。
sigprocmask只修改调用者的pending/blocked集合。这意味着多线程程序中,一个线程屏蔽SIGINT不影响其他线程接收它。如果你的实现把blocked mask放在全局变量里,就会破坏线程隔离性。在教学单线程OS中,可以暂时将其放在task_struct/process_t中,但数据结构设计必须预留per-thread扩展空间。
标准信号vs实时信号:合并与排队的根本分歧
POSIX将信号分为两类,语义截然不同:
| 特性 | 标准信号 (1-31) | 实时信号 (SIGRTMIN-SIGRTMAX) |
|---|---|---|
| 重复投递 | 合并(pending位图) | 排队(链表/计数器) |
| 携带数据 | 否(仅signum) | 是(siginfo_t.si_value) |
| 优先级 | 编号小者优先 | 编号小者优先 |
| 典型用途 | 异常/通知/作业控制 | IPC/定时器/异步I/O完成 |
标准信号的合并语义意味着“丢失信息”:如果父进程快速fork+exit三次,子进程可能只收到一次SIGCHLD。这就是为什么可靠的SIGCHLD处理必须用waitpid(WNOHANG)循环收割,而非假设每个SIGCHLD对应一个子进程。实时信号的排队语义则保证了“发送N次=接收N次”,适合需要精确计数的场景。
SA_SIGINFO:从“知道发生了什么”到“知道为什么发生”
简单handler只知道“收到了SIGSEGV”,但不知道是哪个地址、什么操作触发的。SA_SIGINFO标志使内核向handler传递siginfo_t结构体,包含故障地址(si_addr)、错误码(si_code)、发送者PID(si_pid)等元信息。这对于实现调试器、JIT编译器、用户态缺页处理器至关重要。没有siginfo的信号处理就像医生只知道“病人疼”却不知道疼在哪里——只能做粗粒度的兜底处理,无法做精确的诊断和修复。
实战代码
信号相关数据结构
// include/signal.h
#define NSIG 64
#define SIG_BLOCK 0
#define SIG_UNBLOCK 1
#define SIG_SETMASK 2
#define SA_NOCLDSTOP 1
#define SA_SIGINFO 4
#define SA_RESTART 8
typedef uint64_t sigset_t; // 64位足以覆盖NSIG=64
typedef struct {
int si_signo;
int si_code;
pid_t si_pid;
uid_t si_uid;
void *si_addr;
union sigval si_value;
} siginfo_t;
typedef struct {
union {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
} __union;
sigset_t sa_mask; // handler执行期间额外阻塞的信号
int sa_flags; // SA_SIGINFO, SA_RESTART等
} struct sigaction;
#define sa_handler __union.sa_handler
#define sa_sigaction __union.sa_sigaction
// per-process信号状态
typedef struct {
struct sigaction actions[NSIG];
sigset_t blocked; // 当前阻塞掩码
sigset_t pending; // 待处理信号集
// 实时信号队列(简化:固定深度)
siginfo_t rt_queue[32];
int rt_head, rt_tail;
spinlock_t lock;
} signal_state_t;
sys_sigaction与sys_sigprocmask
// kernel/signal.c
#include "signal.h"
#include "process.h"
// ★ sigaction: 原子读取旧配置+写入新配置
int sys_sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact) {
if (signum < 1 || signum >= NSIG) return -EINVAL;
// SIGKILL/SIGSTOP不可捕获
if (signum == SIGKILL || signum == SIGSTOP) return -EINVAL;
signal_state_t *sig = ¤t_process->signal;
spin_lock(&sig->lock);
// 先保存旧配置(原子性的关键:读写在同一临界区内)
if (oldact) {
*oldact = sig->actions[signum];
}
// 再写入新配置
if (act) {
sig->actions[signum] = *act;
// POSIX: sa_mask不应包含SIGKILL/SIGSTOP
sig->actions[signum].sa_mask &=
~((1ULL << SIGKILL) | (1ULL << SIGSTOP));
}
spin_unlock(&sig->lock);
return 0;
}
// ★ sigprocmask: 修改阻塞掩码
int sys_sigprocmask(int how, const sigset_t *set, sigset_t *oldset) {
signal_state_t *sig = ¤t_process->signal;
spin_lock(&sig->lock);
if (oldset) *oldset = sig->blocked;
if (set) {
switch (how) {
case SIG_BLOCK:
sig->blocked |= *set;
break;
case SIG_UNBLOCK:
sig->blocked &= ~*set;
break;
case SIG_SETMASK:
sig->blocked = *set;
break;
default:
spin_unlock(&sig->lock);
return -EINVAL;
}
// SIGKILL/SIGSTOP永远不能被阻塞
sig->blocked &= ~((1ULL << SIGKILL) | (1ULL << SIGSTOP));
}
spin_unlock(&sig->lock);
// ★ 解除阻塞后立即检查是否有pending信号可投递
check_and_deliver_signals();
return 0;
}
信号投递与handler执行框架
// kernel/signal.c (续)
// ★ 核心投递逻辑:在syscall返回或中断返回前调用
void check_and_deliver_signals(void) {
signal_state_t *sig = ¤t_process->signal;
retry:
spin_lock(&sig->lock);
// 计算可投递信号 = pending & ~blocked
sigset_t deliverable = sig->pending & ~sig->blocked;
if (deliverable == 0) {
spin_unlock(&sig->lock);
return;
}
// 选择最高优先级信号(低位优先)
int signo = __builtin_ctzll(deliverable);
// 清除pending位(标准信号合并语义)
sig->pending &= ~(1ULL << signo);
struct sigaction *sa = &sig->actions[signo];
// 默认动作处理
if (sa->sa_handler == SIG_DFL) {
handle_default_action(signo);
spin_unlock(&sig->lock);
goto retry; // 继续检查下一个pending信号
}
if (sa->sa_handler == SIG_IGN) {
spin_unlock(&sig->lock);
goto retry;
}
// ★ 构造handler执行帧
// 1. 保存当前用户态寄存器到ucontext
// 2. 将sa_mask | (1<<signo) 临时加入blocked
sigset_t saved_blocked = sig->blocked;
sig->blocked |= sa->sa_mask | (1ULL << signo);
// 3. 修改用户栈和EIP,使返回时跳转到trampoline
setup_signal_frame(current_process, signo, sa, saved_blocked);
spin_unlock(&sig->lock);
}
// ★ 用户态信号trampoline(由内核映射到用户空间)
// 位于vDSO或固定地址,负责调用handler后执行sigreturn
__attribute__((naked)) void signal_trampoline(void) {
asm volatile (
"push %%eax\n" // signum已在eax中
"call *%%ecx\n" // ecx = handler地址
"add $4, %%esp\n"
"mov $%0, %%eax\n" // SYS_sigreturn
"int $0x80\n" // sigreturn系统调用
:: "i"(SYS_sigreturn)
);
}
// ★ sys_sigreturn: 从handler返回,恢复原始上下文
int sys_sigreturn(void) {
// 1. 从用户栈恢复ucontext(寄存器、EFLAGS、ESP、EIP)
// 2. 恢复saved_blocked掩码
// 3. 返回到被中断的代码点
restore_signal_frame(current_process);
return 0; // 返回值被忽略,EAX已从ucontext恢复
}
SA_SIGINFO支持
// kernel/signal.c (setup_signal_frame内)
if (sa->sa_flags & SA_SIGINFO) {
// 构造siginfo_t
siginfo_t info = {0};
info.si_signo = signo;
// 根据信号类型填充不同字段
if (signo == SIGSEGV || signo == SIGBUS) {
info.si_addr = fault_address; // 从page fault handler传入
info.si_code = fault_code;
} else if (signo == SIGCHLD) {
info.si_pid = exited_child_pid;
info.si_status = exit_status;
}
// 将siginfo_t和ucontext压入用户栈
// handler签名: void(int signo, siginfo_t *info, void *context)
push_to_user_stack(sizeof(ucontext_t));
ucontext_t *uc = user_stack_top;
save_ucontext(uc, regs);
push_to_user_stack(sizeof(siginfo_t));
memcpy(user_stack_top, &info, sizeof(siginfo_t));
push_to_user_stack(signo);
// EIP指向trampoline,ECX指向sa_sigaction
regs->ecx = (uint32_t)sa->sa_sigaction;
} else {
// 传统handler: void(int signo)
push_to_user_stack(signo);
regs->ecx = (uint32_t)sa->sa_handler;
}
regs->eip = (uint32_t)signal_trampoline;
关键细节解析
1. 为什么handler执行时要自动阻塞自身信号?
考虑SIGINT handler中调用printf,而printf内部可能被另一个SIGINT打断。如果handler未屏蔽SIGINT,就会递归进入自身,导致栈溢出或重入不安全函数崩溃。POSIX规定:除非显式设置SA_NODEFER,否则handler执行期间该信号自动被阻塞。这由内核在setup_signal_frame时将(1<<signo)加入临时blocked mask实现,sigreturn时自动恢复。用户无需手动处理,也避免了忘记恢复的风险。
2. 为什么sigreturn必须是系统调用而非普通函数返回?
因为handler执行完毕后,需要恢复的不仅是EIP/ESP,还有信号掩码。如果让用户态代码手动调用sigprocmask恢复,存在两个问题:①多一次syscall开销;②在sigprocmask调用前如果有新信号到达,会以错误的掩码处理。sigreturn作为单一原子操作,同时恢复寄存器上下文和信号掩码,保证了从handler返回的瞬时状态与被中断时完全一致。这也是为什么signal_trampoline必须以汇编实现——它不能依赖任何C运行时状态,因为此时栈和寄存器已被handler修改。
3. 为什么实时信号需要排队而标准信号只需位图?
历史原因与设计哲学的双重结果。标准信号源于硬件中断和异常通知,语义是“某类事件发生了”,多次发生等同于一次(如多次缺页只需处理最后一次)。实时信号源于IPC需求,语义是“发送了一条消息”,每条消息都携带独立数据,丢失任何一条都是数据损坏。实现上,标准信号用64位pending位图O(1)检查,实时信号用环形缓冲区FIFO队列。混合调度时,先扫描标准信号位图(低编号优先),再 dequeue 实时信号队列,保证了两类信号的优先级语义。
调试Checklist:信号系统排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| handler从未被调用 | pending位未设置/blocked掩码误包含该信号/check_and_deliver未在返回路径调用 | kprintf send_signal确认pending置位;dump blocked掩码hex值;确认syscall_return和interrupt_return都调用了check_and_deliver |
| handler被重复/递归调用 | sa_mask未包含自身信号/SA_NODEFER被意外设置/trampoline未正确跳转sigreturn | dump sa->sa_mask确认(1<<signo)位为1;验证setup_signal_frame中blocked OR操作;hexdump用户栈确认trampoline地址正确 |
| sigreturn后crash | ucontext保存/恢复不完整/栈对齐破坏/saved_blocked未恢复 | dump sigreturn前后的完整寄存器快照对比;验证push_to_user_stack保持16字节对齐;确认restore_signal_frame恢复了blocked字段 |
| SIGCHLD丢失导致僵尸进程 | handler中只wait一次/WNOHANG未使用/标准信号合并语义误解 | 确认handler内while(waitpid(-1,&s,WNOHANG)>0)循环;kprintf每次SIGCHLD投递和waitpid返回值;测试快速连续exit多个子进程 |
| sigprocmask后信号仍未投递 | check_and_deliver未在sigprocmask返回前调用/pending位被错误清除 | 确认sys_sigprocmask末尾调用check_and_deliver;dump unblock前后的pending和blocked交集;验证SIG_UNBLOCK操作符为&=~ |
| SA_SIGINFO handler参数错乱 | siginfo_t/ucontext压栈顺序错/对齐填充缺失/handler签名不匹配 | hexdump用户栈确认三个参数的位置和值;验证push顺序为context→info→signo(cdecl从右到左);测试简单sigaction打印si_signo/si_addr |
🔧 黄金法则:信号调试的终极武器是信号状态全量快照函数。实现
debug_dump_signal_state(task),同时打印:①actions数组中所有非SIG_DFL/SIG_IGN的条目(signo, handler地址, sa_mask, sa_flags);②blocked和pending的64位hex值及可读信号名列表;③rt_queue的head/tail和内容摘要;④最近10次信号事件的日志(时间戳、signo、来源、投递/阻塞/丢弃)。信号bug几乎总是“时序相关的状态不一致”:handler执行期间masked值错误、sigreturn恢复的EIP偏移4字节、pending位在投递后被意外清零。只有完整的时间线+状态快照才能重现这些瞬态错误。不要试图用printf调试信号handler——printf本身不是async-signal-safe的,它的内部锁和缓冲区会掩盖甚至制造信号bug。
本章小结与下一步
今天我们让信号系统从“粗暴中断”进化为“可靠异步控制”:
- ✅ 实现了完整的sigaction/sigprocmask POSIX API
- ✅ 信号投递原子化,消除handler安装竞态
- ✅ handler执行期间自动屏蔽,返回时原子恢复
- ✅ SA_SIGINFO支持传递故障上下文
- ✅ 标准信号合并与实时信号排队双轨语义
- ✅ sigreturn系统调用保证上下文+掩码原子恢复
从此,你的操作系统拥有了生产级的异步事件处理能力。当你第一次在SIGCHLD handler中安全地循环waitpid、用sigprocmask保护临界区、通过siginfo定位段错误地址时,你见证的是OS从“能响应中断”到“能构建可靠异步程序”的根本跃迁。
下一章预告:《内存映射进阶:mmap/munmap/mprotect与VMA管理》
当前的内存管理仅支持简单的brk堆扩展。下一章将实现完整的虚拟内存区域(VMA)管理系统,支持文件映射、匿名映射、权限保护和共享内存,为动态链接、共享库和高效I/O奠定基础。
参考资料
- POSIX.1-2017: Signal Concepts, sigaction(), sigprocmask()
- Linux Kernel:
kernel/signal.c,arch/x86/kernel/signal.c - Stevens & Rago, APUE Ch.10 Signals (尤其§10.14 Reliable Signal Functions)
- Ulrich Drepper, "Signal Handling in glibc" (技术博客)
- 本系列完整代码:[你的GitHub仓库链接](Commit:
s1g2a3c)
📝 作者注:这是《从零手写操作系统》系列的第31篇。信号是整个教程中并发语义最微妙、ABI约束最严格的章节。用户态栈帧布局、寄存器保存约定、trampoline代码位置,任何一个字节的偏差都会导致handler返回后随机crash。强烈建议先用最简单的SIGUSR1+空handler验证投递→trampoline→sigreturn完整通路,确认寄存器恢复正确后再添加sa_mask、SA_SIGINFO等复杂特性。把“信号投递”、“栈帧构造”、“sigreturn恢复”分成三个独立里程碑,并用寄存器dump反复验证每一步的用户态状态一致性,是避免在异步执行泥潭中窒息的关键纪律。 下一章,我们让内存管理从“线性堆”走向“虚拟地址空间”!


openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)