LK 博客
RTOS设计与开发(7):二值信号量与优先级等待链表,先把同步原语打通
嵌入式
约 1 分钟阅读 0 赞 0 条评论 鸿蒙黑体

RTOS设计与开发(7):二值信号量与优先级等待链表,先把同步原语打通

Yukikaze
Yukikaze @Yukikaze
累计点赞 0 登录后每个账号只能点一次
内容长度 0 正文词元数
正文
目录会跟随阅读位置移动。
阅读进度

RTOS设计与开发(7):二值信号量与优先级等待链表,先把同步原语打通

调度器、超时等待、PendSV 和 SysTick 都接好之后,RTOS 才终于有资格往上做同步原语。

在当前仓库里,第一块真正落地的对象层能力是二值信号量。它看起来比消息队列简单,但恰恰因为简单,很多设计取舍会显得特别清楚:线程态和 ISR 态如何分工,等待链表如何排序,give 时到底是先记账还是先唤醒 waiter,这些都能在信号量上被看得很透。

先看对象模型本身

当前信号量对象定义如下:

typedef struct os_sem {
    uint32_t magic;
    uint32_t current_count;
    uint32_t max_count;
    list_t   wait_list;
} os_sem_t;

别看 public API 现在只暴露二值语义,内部却不是直接写死成一个 available 布尔值,而是保留了 current_countmax_count

#define OS_SEM_BINARY_MAX_COUNT 1U

这说明当前实现虽然对外是 binary semaphore,但内部已经按 counting core 的思路组织数据了。这样做的好处很明显:以后如果要扩成计数型信号量,核心状态结构不需要推倒重来。

为什么 wait_list 要按“高优先级优先、同优先级 FIFO”排序

这版信号量没有自己重新实现一套等待队列,而是复用了任务层的对象等待链表工具:

os_status_t task_wait_list_insert_priority_ordered(list_t *wait_list, tcb_t *task)
{
    ...
    while (current_node != NULL)
    {
        queued_task = LIST_CONTAINER_OF(current_node, tcb_t, event_node);
        if (task->priority < queued_task->priority)
        {
            ...
            return OS_STATUS_OK;
        }

        current_node = current_node->next;
    }

    if (list_insert_tail(wait_list, new_node) == 0U)
    {
        return OS_STATUS_INSERT_FAILED;
    }

    return OS_STATUS_OK;
}

这里有两个值得注意的语义:

  1. 数值更小的优先级先被服务。
  2. 同优先级 waiter 不插队,保持 FIFO。

这比简单的“谁先等就先醒”更适合 RTOS。因为对象满足时,内核首先应该服从调度优先级;但在同一优先级下,又不应该让后来的任务打破已有等待顺序。

os_sem_take() 为什么先分线程态和中断态

take 的入口很明确:只允许在线程态调用。

os_status_t os_sem_take(os_sem_t *sem, os_tick_t timeout_ticks)
{
    ...
    if (os_port_is_in_interrupt() != 0U)
    {
        return OS_STATUS_INVALID_STATE;
    }
    ...
}

这不是保守,而是语义上必须如此。

因为 take 一旦失败,后面可能要把当前线程挂进等待链表并调用 task_block_current()。ISR 显然不存在“把自己阻塞起来”等对象的合理语义,所以这里直接在 API 边界把用法卡死。

take 的快路径和慢路径是怎么拆的

看一下主流程:

primask = os_port_enter_critical();

if (sem->current_count > 0U)
{
    sem->current_count--;
    os_port_exit_critical(primask);
    return OS_STATUS_OK;
}

if (timeout_ticks == 0U)
{
    os_port_exit_critical(primask);
    return OS_STATUS_TIMEOUT;
}

status = task_wait_list_insert_priority_ordered(&sem->wait_list, current_task);
...
status = task_block_current(sem, timeout_ticks);
...
os_port_exit_critical(primask);

这个结构非常经典:

  1. 有资源,直接拿走,这是快路径。
  2. 没资源且不等待,立即返回 OS_STATUS_TIMEOUT
  3. 没资源但允许等待,就先挂 waiter,再把当前任务送进阻塞态。

关键在于第三步里“先挂 waiter,再阻塞”。

如果顺序反过来,系统就会进入一个危险窗口:任务已经从 runnable 集合离开了,但对象层还找不到它作为合法 waiter。此时只要另一个线程或中断刚好 give,这次信号就可能被错误地“发空”。

所以当前实现明确要求:对象等待链表挂链和任务阻塞提交必须紧挨着发生。

恢复运行后,为什么要靠 wait_result 区分结果

os_sem_take() 在任务恢复运行之后,并不是默认认为自己拿到信号量了,而是检查任务层写回的等待结果:

current_task = task_get_current();
...
if (current_task->wait_result == TASK_WAIT_RESULT_OBJECT)
{
    return OS_STATUS_OK;
}

if (current_task->wait_result == TASK_WAIT_RESULT_TIMEOUT)
{
    return OS_STATUS_TIMEOUT;
}

这一步很重要。

因为从线程视角看,“我从 task_block_current() 返回了”并不能说明为什么返回。可能是对象满足,可能是 timeout 到期。把这个结果放在 tcb_t.wait_result 里,让对象层在恢复后显式辨认,能把“阻塞机制”和“对象语义”清楚拆开。

为什么 give 时不先累加再唤醒 waiter

信号量的核心取舍在 os_sem_give_common()

waiter = task_wait_list_peek_head_task(&sem->wait_list);
if (waiter != NULL)
{
    sem->current_count = 0U;

    status = task_unblock(waiter, TASK_WAIT_RESULT_OBJECT);
    ...
    if (status == OS_STATUS_SWITCH_REQUIRED)
    {
        os_port_trigger_pendsv();
    }

    os_port_exit_critical(primask);
    return OS_STATUS_OK;
}

sem->current_count = sem->max_count;

这段代码的意思非常明确:
只要有 waiter,这次 give 就直接转交给 waiter,而不是先把 current_count 加 1,再把 waiter 唤醒。

为什么这样做?

因为一次 give 只能对应一份资源可用性。如果你先把计数加上去,再把 waiter 叫醒,那么这份资源在账面上和实际语义上就会被算两次:一次记进了 current_count,一次又交给了 waiter。

当前实现通过“有 waiter 就不入账,没 waiter 才恢复 current_count = 1”把这件事处理得很干净。

为什么同时保留 os_sem_give()os_sem_give_from_isr()

线程态和 ISR 态的 give 最终复用同一份公共逻辑:

os_status_t os_sem_give(os_sem_t *sem)
{
    if (os_port_is_in_interrupt() != 0U)
    {
        return OS_STATUS_INVALID_STATE;
    }

    return os_sem_give_common(sem);
}

os_status_t os_sem_give_from_isr(os_sem_t *sem)
{
    if (os_port_is_in_interrupt() == 0U)
    {
        return OS_STATUS_INVALID_STATE;
    }

    return os_sem_give_common(sem);
}

这样设计的好处是:

  1. 同一套对象状态转换逻辑不会因为调用上下文不同而写成两份。
  2. API 语义又依然清晰,不会让线程态误用 ISR 版本,也不会让 ISR 误用线程态版本。

这其实就是当前 RTOS 很值得肯定的一种风格:内部尽量复用,外部接口尽量明确。

当前这版信号量刻意没做什么

既然是在写技术博客,就不能只夸已经有的,还得说清楚当前阶段故意没做的东西。

这版 os_sem 目前还没有:

  1. 计数型信号量的 public API。
  2. 优先级继承,因为它不是 mutex。
  3. take_from_isr(),因为 ISR 等对象本身就不成立。

也就是说,它的目标不是一次做全,而是先把“线程等待对象 + ISR 释放对象”这条最小同步链路打通。

小结

这一阶段的信号量实现有两个特别好的取舍。

第一,它没有急着把所有同步原语一口气做全,而是先在最简单的二值语义上把线程态等待、ISR 释放、等待结果回传这些关键机制做扎实。

第二,它没有把对象层和任务层搅成一团,而是让信号量只负责两件事:

  1. 管理对象自己的 countwait_list
  2. 借任务层的阻塞/唤醒框架管理线程状态。

这个边界一旦清楚,消息队列其实就顺理成章了。因为它和信号量最大的区别,不在于“也会阻塞线程”,而在于它除了管理等待者,还要同时管理一块真正存放数据的缓冲区。

下一篇就看当前这版最小消息队列是怎么落地的。

作者名片

Yukikaze
Yukikaze
@Yukikaze

私にできることなら何でもするから

评论区
文章作者和管理员都可以管理这里的评论。
0 条评论
登录后即可参与评论。 去登录
还没有评论,欢迎留下第一条交流内容。