2. 信号处理基础:信号的概念、分类与生命周期

好,咱们开始聊信号处理的基础。说实话,信号这个概念在微内核里太核心了。你想想看,一个微内核把大部分服务都扔到用户态去了,那内核和进程之间、进程和进程之间怎么通信?怎么通知对方“出事了”?靠的就是信号。

2.1 信号到底是什么?

信号,说白了就是一种异步事件通知机制。它告诉一个进程:某件事情发生了。这个“事情”可能是硬件触发的,也可能是软件主动发的。

我个人习惯把信号理解成“软中断”。为什么这么说?因为它的处理流程和硬件中断非常像:

  • 事件发生 → 信号产生
  • 信号被记录 → 信号挂起
  • 目标进程被调度 → 信号递送
  • 进程执行信号处理函数 → 信号处理

我在项目中遇到过不少新手,把信号和消息队列搞混。其实区别很明显:信号是轻量级的,只传递一个编号;消息队列可以带大量数据。微内核里信号通常只传一个整数,最多带点上下文信息。

核心要点:信号是异步的、轻量级的、带编号的事件通知。它不保证可靠传递(有些信号会丢失),也不带复杂数据。

2.2 信号的分类

信号分两大类:硬件中断和软件信号。嗯,这里要注意,有些教材把硬件中断单独拎出来,但在微内核视角下,它们本质都是信号。

2.2.1 硬件中断

硬件中断是由外部设备或CPU内部触发的。比如:

  • 定时器到期 → 产生时钟中断
  • 键盘按下 → 产生I/O中断
  • 除零错误 → 产生异常(陷阱)
  • 缺页 → 产生缺页异常

在微内核里,硬件中断的处理流程通常是这样的:

// 伪代码:硬件中断到信号的映射
void interrupt_handler(int irq_number) {
    // 1. 保存现场
    save_context();
    
    // 2. 将IRQ映射为信号
    int sig_num = irq_to_signal(irq_number);
    
    // 3. 找到目标进程(通常是当前进程或注册的监听者)
    struct task *target = find_interrupt_consumer(irq_number);
    
    // 4. 发送信号
    send_signal(target, sig_num);
    
    // 5. 恢复现场
    restore_context();
}

我曾经在一个ARM Cortex-A7的微内核项目里,踩过一个坑:硬件中断处理函数里直接调用了信号发送,结果发生了中断嵌套,导致信号丢失。后来我加了一个中断屏蔽位,才把问题解决。

避坑指南:硬件中断处理函数要尽量短。我曾经见过有人在中断里做复杂的信号队列操作,结果导致中断延迟过高,系统响应变慢。记住:中断里只做最必要的事,复杂逻辑放到下半部或任务里做。

2.2.2 软件信号

软件信号是进程主动发起的。常见的场景:

  • 进程间通信:A进程通知B进程“数据准备好了”
  • 用户态请求:调用kill()系统调用发送信号
  • 内核通知:比如子进程退出时,内核给父进程发SIGCHLD
  • 定时器到期:alarm()产生的SIGALRM

软件信号和硬件中断最大的区别是什么?软件信号是同步产生的,但递送是异步的。什么意思?你调用kill()发信号,这个调用本身是同步的(立刻返回),但信号什么时候被目标进程处理,取决于目标进程什么时候被调度。

我建议你把信号分类记成这张表:

特性 硬件中断 软件信号
触发源 硬件设备/CPU异常 进程/内核主动发送
产生方式 异步(外部触发) 同步(调用API)
处理优先级 高(通常立即处理) 低(等待目标进程调度)
可屏蔽性 可屏蔽(关中断) 可屏蔽(信号掩码)
典型例子 时钟中断、缺页异常 SIGKILL、SIGUSR1

2.3 信号的生命周期

一个信号从生到死,经历四个阶段。我画了个图帮你理解:

产生 信号被创建 挂起 等待处理 递送 交付给进程 处理 执行处理函数 硬件中断触发 或kill()调用 信号在PCB中 pending位图标记 进程被调度时 检查并取出信号 执行默认动作 或用户处理函数

2.3.1 第一阶段:产生

信号产生的时机:

  • 硬件事件:比如按键盘、定时器到期、除零
  • 软件事件:调用kill()、raise()、alarm()等
  • 内核内部:比如子进程退出、文件描述符就绪

产生时,内核会做两件事:

  1. 检查目标进程是否存在
  2. 在目标进程的PCB(进程控制块)中标记信号
小技巧:在微内核里,信号产生时通常不分配内存。它只是在PCB的一个位图里置个位。所以信号产生非常快,几乎是常数时间。

2.3.2 第二阶段:挂起

信号产生后,不会立刻被处理。它先被挂起在目标进程里。为什么?因为目标进程可能正在做更重要的事,或者压根没在运行。

挂起状态的核心数据结构:

// 进程控制块中的信号相关字段
struct task_struct {
    // ... 其他字段
    
    // 挂起信号位图(每个bit代表一个信号)
    unsigned long pending_signals;
    
    // 信号掩码(屏蔽哪些信号)
    unsigned long signal_mask;
    
    // 实时信号队列(带数据的信号)
    struct sigqueue *sigqueue;
    
    // 信号处理函数表
    sighandler_t sighandlers[NSIG];
};

这里有个关键点:标准信号不排队。如果同一个信号连续来了两次,第二次会覆盖第一次。实时信号(比如SIGRTMIN+1)会排队,每个信号都保留。

我记得有一次调试一个多线程程序,发现SIGUSR1信号总是丢。查了半天,原来是标准信号不排队,两个SIGUSR1几乎同时到达,第二个把第一个覆盖了。后来改成实时信号才解决。

2.3.3 第三阶段:递送

信号什么时候从挂起状态变成递送?答案是:目标进程被调度到CPU上运行的时候。

具体流程:

  1. 调度器选中目标进程
  2. 在切换上下文之前,检查pending_signals位图
  3. 如果有信号,且没有被mask屏蔽,就取出信号
  4. 将信号从挂起状态移除
  5. 准备执行信号处理

递送时还有一个重要机制:信号掩码检查。如果信号在mask里被置位了,这个信号就不会被递送,继续挂起。

注意:SIGKILL和SIGSTOP这两个信号不能被屏蔽,也不能被捕获。这是系统的最后保障。我曾经见过有人试图屏蔽SIGKILL来保护一个关键进程,结果系统崩溃时那个进程成了僵尸,杀都杀不掉。

2.3.4 第四阶段:处理

信号递送到进程后,有三种处理方式:

处理方式 说明 典型信号
默认动作 执行内核预定义的行为(终止、暂停、忽略等) SIGTERM(终止)、SIGSTOP(暂停)
捕获处理 调用用户注册的信号处理函数 SIGUSR1、SIGALRM
忽略 什么都不做,直接丢弃 SIGCHLD(默认忽略)

处理函数执行时,有一个细节很多人忽略:信号处理函数运行在用户态,但进入和退出是由内核控制的。内核会在用户栈上压入一个特殊的帧(signal frame),里面保存了上下文。处理函数返回时,内核恢复上下文,继续执行原来的代码。

// 信号处理函数的典型实现(用户态)
void sig_handler(int signo) {
    // 注意:这里不能调用不可重入函数
    // 比如malloc、printf等
    write(STDOUT_FILENO, "got signal\n", 11);
}

int main() {
    // 注册信号处理函数
    signal(SIGUSR1, sig_handler);
    
    while(1) {
        pause(); // 等待信号
    }
    return 0;
}
经验之谈:信号处理函数里尽量别做复杂操作。我习惯的做法是:处理函数里只设置一个全局标志位,然后在主循环里检查这个标志位再做具体处理。这样可以避免很多重入问题。

2.4 小结

信号处理是微内核的基石之一。你想想看,没有信号,微内核怎么通知用户态服务?怎么处理硬件中断?怎么实现进程间通信?

这一章我们聊了:

  • 信号是异步事件通知,本质是软中断
  • 分硬件中断和软件信号两大类
  • 生命周期四个阶段:产生→挂起→递送→处理

下一章,我们会深入信号在内核中的具体实现,包括信号表、信号掩码操作、以及那个让人头疼的“可重入”问题。到时候我会分享一个我当年调试信号处理函数时遇到的经典死锁案例,保证让你印象深刻。