4. 信号数据结构:信号控制块(SCB)、信号队列、信号掩码的设计

好,咱们进入第四章。这一章我打算聊聊信号的数据结构设计。说实话,这是整个信号机制里最核心的部分,也是我当年在项目中踩坑最多的地方。

你想想看,一个信号从产生到被处理,中间要经过多少环节?发送、排队、掩码检查、分发、处理……每个环节都得有数据结构来支撑。如果设计得不好,轻则性能瓶颈,重则死锁崩溃。嗯,咱们今天就把这三个核心数据结构掰开揉碎了讲清楚。

4.1 信号控制块(SCB)—— 每个信号的“身份证”

信号控制块,我习惯叫它 SCB(Signal Control Block)。说白了,它就是每个信号的元数据描述。在微内核里,每产生一个信号,内核就会给它分配一个 SCB。

我个人习惯把 SCB 设计成固定大小的结构体,这样方便内存池管理。你想想看,如果 SCB 大小不固定,分配和释放就会变得很麻烦,还容易产生碎片。

// 信号控制块结构体
typedef struct scb {
    uint32_t        scb_id;         // 信号唯一标识符
    signal_type_t   type;           // 信号类型(硬件中断、软件信号、异常等)
    uint32_t        sender_pid;     // 发送者进程ID
    uint32_t        target_pid;     // 目标进程ID
    uint64_t        timestamp;      // 信号产生时间戳
    void*           data;           // 信号附带数据指针
    uint32_t        data_len;       // 附带数据长度
    uint8_t         priority;       // 信号优先级(0-255,值越小优先级越高)
    uint8_t         flags;          // 标志位(是否紧急、是否同步等)
    struct scb*     next;           // 链表指针,用于信号队列
} scb_t;

这里我要特别说一下 priority 字段。我在项目中遇到过一个问题:某个高优先级的中断信号被一个低优先级的软件信号阻塞了,导致系统响应延迟。后来我加上了优先级字段,并且在信号分发时做优先级排序,问题就解决了。

核心要点:SCB 的设计要兼顾通用性和效率。固定大小 + 链表指针,是我用过的最稳的方案。

4.2 信号队列 —— 信号的中转站

信号队列,说白了就是信号的缓冲区。每个进程都有一个自己的信号队列,内核把信号塞进去,进程再从中取出来处理。

我建议用双向链表来实现信号队列。为什么?因为双向链表在插入和删除时都很方便,尤其是当我们需要在队列中间插入一个高优先级信号时。

// 信号队列结构体
typedef struct signal_queue {
    scb_t*      head;           // 队列头指针
    scb_t*      tail;           // 队列尾指针
    uint32_t    count;          // 当前队列中的信号数量
    uint32_t    max_count;      // 队列最大容量
    spinlock_t  lock;           // 自旋锁,保护队列操作
} signal_queue_t;

这里有个细节:lock 字段。信号队列是会被多个执行流同时访问的——发送者往里放,接收者往外取。如果不加锁,数据竞争是必然的。我曾经在一个项目中忘了加锁,结果信号队列里的指针乱飞,系统直接崩溃。嗯,从那以后我再也不敢省略锁了。

信号入队和出队的核心逻辑是这样的:

// 信号入队(带优先级插入)
int signal_enqueue(signal_queue_t* queue, scb_t* scb) {
    spinlock_acquire(&queue->lock);
    
    if (queue->count >= queue->max_count) {
        spinlock_release(&queue->lock);
        return -ENOSPC;  // 队列满了
    }
    
    // 按优先级插入
    scb_t** pp = &queue->head;
    while (*pp != NULL && (*pp)->priority <= scb->priority) {
        pp = &(*pp)->next;
    }
    
    scb->next = *pp;
    *pp = scb;
    
    if (scb->next == NULL) {
        queue->tail = scb;
    }
    
    queue->count++;
    spinlock_release(&queue->lock);
    return 0;
}

// 信号出队(取队首)
scb_t* signal_dequeue(signal_queue_t* queue) {
    spinlock_acquire(&queue->lock);
    
    if (queue->head == NULL) {
        spinlock_release(&queue->lock);
        return NULL;
    }
    
    scb_t* scb = queue->head;
    queue->head = scb->next;
    
    if (queue->head == NULL) {
        queue->tail = NULL;
    }
    
    queue->count--;
    spinlock_release(&queue->lock);
    return scb;
}

小技巧:入队时按优先级插入,出队时直接从队首取。这样既能保证高优先级信号被优先处理,又不会增加出队的复杂度。

4.3 信号掩码 —— 信号的“过滤器”

信号掩码,说白了就是进程告诉内核:“哪些信号我现在不想处理,你先帮我挡着。” 这在很多场景下都很有用,比如进程正在执行临界区代码时,不希望被信号打断。

我习惯用位图来实现信号掩码。每个信号对应一个 bit,1 表示屏蔽,0 表示放行。这样既节省空间,又方便做位运算。

// 信号掩码结构体
typedef struct signal_mask {
    uint64_t    mask;       // 64位掩码,最多支持64种信号
    uint64_t    pending;    // 待处理信号位图(被屏蔽的信号暂存于此)
} signal_mask_t;

// 常用操作宏
#define SIGNAL_MASK_SET(mask, sig)      ((mask) |= (1ULL << (sig)))
#define SIGNAL_MASK_CLEAR(mask, sig)    ((mask) &= ~(1ULL << (sig)))
#define SIGNAL_MASK_ISSET(mask, sig)    ((mask) & (1ULL << (sig)))

这里有个关键点:pending 字段。当一个信号被屏蔽时,它不会直接丢弃,而是暂存在 pending 位图中。等到进程取消屏蔽后,内核会检查 pending 位图,把积压的信号重新投递。

我曾经遇到过一个 bug:某个进程屏蔽了 SIGINT 信号,然后忘记取消了。结果用户按 Ctrl+C 完全没反应,还以为系统死机了。后来我加了一个机制:如果某个信号被屏蔽太久,内核会主动提醒进程。嗯,这算是一个工程上的小补丁吧。

4.4 三者之间的关系

SCB、信号队列、信号掩码,这三者是怎么配合工作的?我画个图你就明白了。

信号数据结构关系图 信号产生 SCB 分配 (创建信号元数据) 掩码检查 (是否屏蔽?) 是 暂存 pending 否 信号队列 (按优先级排队) 信号分发 (调用处理函数) SCB:信号的元数据,贯穿整个生命周期 信号队列:按优先级排队,先进先出 信号掩码:控制哪些信号被屏蔽/放行

整个流程是这样的:信号产生后,内核分配一个 SCB。然后检查目标进程的信号掩码——如果被屏蔽了,就把信号信息暂存在 pending 位图中;如果没被屏蔽,就把 SCB 插入到进程的信号队列中。进程在合适的时机从队列中取出 SCB,调用对应的处理函数。

注意事项:信号队列的容量是有限的。如果队列满了,新来的信号该怎么办?我建议的做法是:丢弃最低优先级的信号,或者直接返回错误。千万不要无限制地增长队列,否则内存会被吃光。

4.5 设计中的权衡

讲到这里,我想聊聊设计中的一些权衡。你可能会问:为什么不用数组而用链表?为什么掩码只支持 64 种信号?

嗯,这些问题我在项目里都遇到过。用链表是因为信号队列需要频繁地插入和删除,数组在这种场景下效率太低。至于 64 种信号,这是我在性能和灵活性之间做的妥协——64 位刚好是一个机器字长,位运算非常快。如果你需要支持更多信号,可以用多个 64 位掩码组合,但那样会增加复杂度。

我个人习惯是:先做简单版本,跑通了再优化。不要一开始就想着支持 1000 种信号,那只会让你陷入过度设计的泥潭。

数据结构 核心作用 关键字段 常见坑点
SCB 信号的元数据描述 id, type, priority, data 忘记初始化 data 指针
信号队列 信号的排队与缓冲 head, tail, count, lock 忘记加锁导致数据竞争
信号掩码 信号的屏蔽与过滤 mask, pending 屏蔽后忘记恢复

好了,这一章的内容就到这里。信号数据结构的核心就是这三个东西:SCB 管身份,队列管排队,掩码管过滤。下一章我会讲信号的分发机制,到时候你会看到这些数据结构是怎么被实际使用的。