
RTOS设计与开发(7):二值信号量与优先级等待链表,先把同步原语打通
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_count 和 max_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;
}
这里有两个值得注意的语义:
- 数值更小的优先级先被服务。
- 同优先级 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);
这个结构非常经典:
- 有资源,直接拿走,这是快路径。
- 没资源且不等待,立即返回
OS_STATUS_TIMEOUT。 - 没资源但允许等待,就先挂 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);
}
这样设计的好处是:
- 同一套对象状态转换逻辑不会因为调用上下文不同而写成两份。
- API 语义又依然清晰,不会让线程态误用 ISR 版本,也不会让 ISR 误用线程态版本。
这其实就是当前 RTOS 很值得肯定的一种风格:内部尽量复用,外部接口尽量明确。
当前这版信号量刻意没做什么
既然是在写技术博客,就不能只夸已经有的,还得说清楚当前阶段故意没做的东西。
这版 os_sem 目前还没有:
- 计数型信号量的 public API。
- 优先级继承,因为它不是 mutex。
take_from_isr(),因为 ISR 等对象本身就不成立。
也就是说,它的目标不是一次做全,而是先把“线程等待对象 + ISR 释放对象”这条最小同步链路打通。
小结
这一阶段的信号量实现有两个特别好的取舍。
第一,它没有急着把所有同步原语一口气做全,而是先在最简单的二值语义上把线程态等待、ISR 释放、等待结果回传这些关键机制做扎实。
第二,它没有把对象层和任务层搅成一团,而是让信号量只负责两件事:
- 管理对象自己的
count和wait_list。 - 借任务层的阻塞/唤醒框架管理线程状态。
这个边界一旦清楚,消息队列其实就顺理成章了。因为它和信号量最大的区别,不在于“也会阻塞线程”,而在于它除了管理等待者,还要同时管理一块真正存放数据的缓冲区。
下一篇就看当前这版最小消息队列是怎么落地的。