EidosOS实现(4)-信号量机制

Posted by WHX on April 3, 2026

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(&currentTCB->stateListItem);
        // 将当前任务事件节点添加到事件的等待链表中,按照优先级排序
        vListInsert((vList *)&sem->waitingList, &currentTCB->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(&currentTCB->stateListItem);
        // 将当前任务事件节点添加到事件的等待链表中,按照优先级排序
        vListInsert((vList *)&event->waitingList, &currentTCB->eventNode.eventListItem, 0);
        // 阻塞当前任务
        currentTCB->state = BLOCKED;
        // 设置节点的延时值为绝对时间,便于在延迟链表中排序
        currentTCB->stateListItem.value = currentTicks + timeout;
        // 插入延迟链表,按照剩余时间排序
        vListInsert(&delayList, &currentTCB->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时所对应的同步语义,以及将等待条件扩展为“资源数量或时间约束”的延时机制。至此,信号量从最初的资源计数模型,逐步演化为同时支持资源管理、任务同步以及时间控制的通用等待与唤醒机制。需要指出的是,信号量只是任务等待与唤醒机制的一种典型实现,后续还可以在此基础上进一步演进出更具语义约束的机制。