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,调用对应的处理函数。
注意事项:信号队列的容量是有限的。如果队列满了,新来的信号该怎么办?我建议的做法是:丢弃最低优先级的信号,或者直接返回错误。千万不要无限制地增长队列,否则内存会被吃光。
4.5 设计中的权衡
讲到这里,我想聊聊设计中的一些权衡。你可能会问:为什么不用数组而用链表?为什么掩码只支持 64 种信号?
嗯,这些问题我在项目里都遇到过。用链表是因为信号队列需要频繁地插入和删除,数组在这种场景下效率太低。至于 64 种信号,这是我在性能和灵活性之间做的妥协——64 位刚好是一个机器字长,位运算非常快。如果你需要支持更多信号,可以用多个 64 位掩码组合,但那样会增加复杂度。
我个人习惯是:先做简单版本,跑通了再优化。不要一开始就想着支持 1000 种信号,那只会让你陷入过度设计的泥潭。
| 数据结构 | 核心作用 | 关键字段 | 常见坑点 |
|---|---|---|---|
| SCB | 信号的元数据描述 | id, type, priority, data | 忘记初始化 data 指针 |
| 信号队列 | 信号的排队与缓冲 | head, tail, count, lock | 忘记加锁导致数据竞争 |
| 信号掩码 | 信号的屏蔽与过滤 | mask, pending | 屏蔽后忘记恢复 |
好了,这一章的内容就到这里。信号数据结构的核心就是这三个东西:SCB 管身份,队列管排队,掩码管过滤。下一章我会讲信号的分发机制,到时候你会看到这些数据结构是怎么被实际使用的。