EidosOS 实现之信号量机制
从零实现实时操作系统EidosOS系列!
1、摘要
本文介绍实时操作系统(RTOS)中实现等待和唤醒机制的一种重要手段——信号量机制。信号量是操作系统中任务(线程)用于协调任务(线程)之间对共享资源访问以及实现同步的一种核心机制。
在多任务系统中,任务协作过程中不可避免会涉及对共享资源的竞争与协调。本文将从经典的生产者-消费者问题出发,引出“资源数量”这一核心抽象,进而说明信号量本质上是对资源数量的管理模型,并给出其最小实现。随后,进一步分析资源数量退化为1时的同步语义,说明二值信号量的来源与作用;最后结合实际系统需求,引出带超时的信号量机制及其实现思路。
2、生产者-消费者
在多任务系统中,任务之间的协作往往围绕着共享资源而展开。一个典型的例子是生产者-消费者问题:生产者不断地向缓冲区内写入数据,消费者不断地从缓冲区内读取数据。当缓冲区已满时,生产者应该等待。当缓冲区为空时,消费者应该等待。
从表面上看,这似乎是一种生产者与消费者之间的同步问题,但如果进一步抽象可以发现,它们的核心并不在于谁先执行(具体看时会认为生产者先于消费者),而是在等待资源是否可用。生产者在等待缓冲区的空闲资源,消费者在等待缓冲区的已有数据资源。当资源大于0时,认为可以继续执行,当资源小于0时,认为必须等待。
因此,可以将这一类问题统一抽象为:任务的执行依赖于某种“资源数量”是否满足条件。这一抽象将复杂的任务协作问题转化为对一个整数计数的管理问题,也正是在这一基础上,引出了信号量机制。
3、基础信号量实现
从生成者-消费者的问题可以看出信号量的本质就是解决资源数量的管理问题,所以它的结构体中必须包含一个用于表示资源数量的计数值count。
此外,当资源不可用时,执行等待的任务需要被挂起,但这些任务应该存储在哪里呢?根据前文“状态即位置”的设计思想,任务所处的状态应当由其所在的数据结构来体现。当任务因等待某个资源而阻塞时,就不应再出现在就绪链表中,而应被移动到对应的阻塞队列中。
进一步来看,这里的“阻塞”并不是一个全局统一的状态,而是依赖于具体等待对象的:任务可能在等待某个信号量,也可能在等待其他事件。因此,这些阻塞任务必须按照其等待的对象进行区分和组织。由此可以得出,信号量不仅需要维护资源数量,还需要维护一个与之关联的阻塞链表,用于管理所有等待该信号量的任务。因此,该阻塞链表必须作为信号量结构体的一部分存在。
typedef struct semaphore
{
int count;
vList waitingList; // 等待该信号量的任务链表
} Semaphore_t;
最小的信号量实现
信号量的操作可以抽象为两种基本行为:获取资源(wait)与释放资源(post)。
void semaphoreWait(Semaphore_t *sem);
void semaphoreSignal(Semaphore_t *sem);
当任务进行wait操作时,首先对信号量计数进行减一操作。如果此时资源仍然可用(count ≥ 0),任务可以继续执行;否则,说明当前没有可用资源,任务需要进入阻塞状态。此时,调度器需要将该任务从就绪链表中移除,并挂入该信号量的阻塞链表中,等待后续被唤醒。
void semaphoreWait(Semaphore_t *sem)
{
__disable_irq(); // 进入临界区,禁止中断
sem->count--;
if (sem->count < 0)
{
// 删除当前任务在就绪链表中的状态节点
vListRemove(¤tTCB->stateListItem);
// 将当前任务事件节点添加到事件的等待链表中,按照优先级排序
vListInsert((vList *)&sem->waitingList, ¤tTCB->eventListItem, 0);
// 阻塞当前任务
currentTCB->state = BLOCKED;
// 切换到下一个任务
taskYield();
__enable_irq(); // 退出临界区,允许中断
}
else
{
__enable_irq(); // 退出临界区,允许中断
}
}
post操作用于释放资源,对应地将信号量计数加一。当有任务在等待该信号量时(即阻塞链表非空),应从阻塞链表中取出一个任务,将其移回就绪态,从而完成一次唤醒。
void semaphoreSignal(Semaphore_t *sem)
{
TCB_t *wakeTask = NULL;
__disable_irq(); // 进入临界区,禁止中断
sem->count++;
if (sem->count <= 0)
{
if (sem->waitingList.itemNumber > 0)
{
// 从事件的等待链表中取出一个任务
ListItem_t *waitingItem = sem->waitingList.end.next;
TCB_t *task = (TCB_t *)waitingItem->pvOwner;
// 将任务事件节点从等待链表中移除
vListRemove(waitingItem);
if (vListIsInList(&task->stateListItem)) // 如果任务状态节点还在延时链表中,先移除
{
vListRemove(&task->stateListItem);
}
// 设置任务状态为就绪
task->state = READY;
task->stateListItem.value = task->priority; // 更新节点值为优先级,便于就绪链表排序
// 将任务状态节点添加到就绪链表中
vListInsert(&readyList, &task->stateListItem, 0);
wakeTask = task;
}
}
__enable_irq(); // 退出临界区,允许中断
// 如果新就绪的任务优先级高于当前正在运行的任务,则触发 PendSV 进行任务切换
if (wakeTask && wakeTask->priority > currentTCB->priority)
{
taskYield(); // 触发任务切换
}
}
我对信号量机制理解有歧义的地方,我原以为信号量的值在post之后大于0,就会从等待队列中提取一个任务从阻塞变为就绪态,但其实不是的,count小于0时才表示有阻塞的任务在队列中,所以post之后如果小于等于0,表示之前有阻塞的任务,于是从队列中提取。而如果大于0,表示没有等待的任务,而且可用资源是有的,如果有任务想用资源也可以直接用无需等待。
3、二值信号量(同步机制)
在上述实现中,信号量通过对资源数量的计数,实现了任务在资源不足时的等待和唤醒机制。这种模型适用于多个同类资源的场景,如缓冲区、连接数等。但实际系统中还有一种比较常见的需求,它们不关心资源的数量,只关心事件成不成立。如果从信号量的角度来看,这种需求同样可以用计数值来表示,不过数量被限制为0和1。当计数为1时表示资源可用,当计数为0表示条件尚未满足,需要等待。
也就是说当计数值退化为1与0时,信号量所表达的就不再是资源数量,而是状态标志,从而自然地演化为一种用于任务同步的机制,即二值信号量。
void semaphoreBinaryWait(Semaphore_t *sem)
{
semaphoreWait(sem); // 直接调用普通信号量的等待函数
}
void semaphoreBinarySignal(Semaphore_t *sem)
{
if(sem->count < 1)
{
semaphoreSignal(sem); // 直接调用普通信号量的释放函数
}
}
二值信号量的实现完全是调用基础信号量的实现,只是在释放过程中的count值永远不可能大于1
4、信号量的延时版本
前面的实现中wait会无限等待资源可获得,但是某些任务在等待资源时,需要在限定时间内获得结果,否则就必须继续执行其他逻辑,例如超时处理或错误恢复。否则会导致任务长时间阻塞,甚至影响系统整体的实时性。因此,需要在原有信号量机制的基础上引入“超时”这一约束,即任务在等待资源时,不仅依赖于资源是否可用,还依赖于等待时间是否超过指定阈值。当超时时间到达后,即使资源仍未可用,任务也应被唤醒并恢复执行。
由此,信号量的等待条件从单一的“资源数量”扩展为“资源数量或时间条件”,从而形成带超时的信号量机制。
int semaphoreBinaryWaitTimeout(Semaphore_t *sem, uint32_t timeout)
{
__disable_irq(); // 进入临界区,禁止中断
sem->count--;
if (sem->count < 0)
{
// 删除当前任务在就绪链表中的状态节点
vListRemove(¤tTCB->stateListItem);
// 将当前任务事件节点添加到事件的等待链表中,按照优先级排序
vListInsert((vList *)&event->waitingList, ¤tTCB->eventNode.eventListItem, 0);
// 阻塞当前任务
currentTCB->state = BLOCKED;
// 设置节点的延时值为绝对时间,便于在延迟链表中排序
currentTCB->stateListItem.value = currentTicks + timeout;
// 插入延迟链表,按照剩余时间排序
vListInsert(&delayList, ¤tTCB->stateListItem, 1);
// 切换到下一个任务
taskYield();
__enable_irq();
return 0; // 超时返回0
}
else
{
__enable_irq(); // 退出临界区,允许中断
return 1; // 成功获取信号量返回1
}
}
这里有两个链表,一个是状态链表节点,一个是事件链表节点。信号量机制是事件,而不是状态,所以应该用事件节点添加到链表中,不能用状态节点,状态节点只用于就绪、延时阻塞、挂起等链表。而事件节点只用于信号量、互斥量、事件中,因为一个任务同时只能阻塞在一个事件中,也同时只能有一个状态。
如何避免冲突,解决一致性问题?在加入和延时的信号量机制后会有概率出现延时与信号量同时释放,那么有可能多次删除导致破坏链表,所以需要原子性操作链表,简单方法就是开关中断。这里我犯了一个错误,任务SysTick中断服务函数里不需要开关中断,因为我目前的实现是没有其他中断进行任何信号量的PV操作,但是真正的应用里以及RTOS中会有中断服务函数里PV信号量的可能,所以还是必须做临界区保护。
Systick修改如下:
void SysTick_Handler(void)
{
//...
// 从延迟链表中移除当前任务
vListRemove(&task->stateListItem);
if(vListIsInList(&task->eventNode.eventListItem)) // 如果任务事件节点还在信号量等待链表中,先移除
{
vListRemove(&task->eventNode.eventListItem);
}
//...
}
从延时链表删除状态节点时,判断任务的事件链表节点有没有在链表中,即判断是不是某事件(信号量、互斥、队列等)的延时版本。若是则从事件链表删除。
5、总结
文围绕信号量的核心思想展开——即对“资源数量”的刻画。从生产者-消费者问题出发,引出这一抽象模型,并在信号量结构体中通过count对其进行表达。在此基础上,进一步分析了资源数量限制为1时所对应的同步语义,以及将等待条件扩展为“资源数量或时间约束”的延时机制。至此,信号量从最初的资源计数模型,逐步演化为同时支持资源管理、任务同步以及时间控制的通用等待与唤醒机制。需要指出的是,信号量只是任务等待与唤醒机制的一种典型实现,后续还可以在此基础上进一步演进出更具语义约束的机制。