前言:从“粗暴中断”到“精确异步控制”

        在前面的章节中,我们的信号系统仅支持最原始的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 = &current_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 = &current_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 = &current_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未正确跳转sigreturndump sa->sa_mask确认(1<<signo)位为1;验证setup_signal_frame中blocked OR操作;hexdump用户栈确认trampoline地址正确
sigreturn后crashucontext保存/恢复不完整/栈对齐破坏/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反复验证每一步的用户态状态一致性,是避免在异步执行泥潭中窒息的关键纪律。 下一章,我们让内存管理从“线性堆”走向“虚拟地址空间”!

Logo

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

更多推荐