前言
RTOS 把程序拆成了多个任务,但任务之间并不是完全独立的。它们往往需要等待同一个资源、传递数据、通知事件、保护临界区。
这些功能一般统称为 IPC,也就是 Inter-Process Communication。虽然在单片机 RTOS 中更准确地说是任务间通信,但大家一般也沿用 IPC 这个说法。
这篇文章先记录 Yuan RTOS 中 IPC 的基础层实现。后面的文章再分别记录信号量、互斥量和队列。
完整代码请访问我的Github仓库,网址为:yuan-rtos。
值得注意是本文的示例代码在你阅读时可能已经进行过修改了,具体实现以仓库中代码为主。
为什么要抽象 IPC 基础层
信号量、互斥锁、队列看起来是不同的东西,但它们都有一个共同点:当条件不满足时,需要让当前任务阻塞;当条件满足时,又需要把等待任务唤醒。
比如:
- 获取信号量失败时,任务需要等待别人释放信号量
- 获取互斥锁失败时,任务需要等待持锁者释放锁
- 队列为空时,接收任务需要等待发送者写入数据
- 队列满时,发送任务需要等待接收者取走数据
如果每个组件都单独维护一套阻塞链表和唤醒逻辑,代码会重复很多,而且行为也容易不一致。因此 Yuan RTOS 中抽出了 yr_ipc_base_t 作为公共基础层。
IPC 基础结构
IPC 基础对象定义如下:
typedef struct yr_ipc_t {
yr_uint8_t is_valid;
yr_uint32_t flag;
yr_list_head_t blocked_list;
} yr_ipc_base_t;
其中:
is_valid:表示 IPC 对象是否有效flag:阻塞任务的排队策略blocked_list:阻塞在该 IPC 对象上的任务链表
目前支持两种排队策略:
typedef enum yr_ipc_flag_t {
YR_IPC_FLAG_NONE = 0,
YR_IPC_FLAG_FIFO,
YR_IPC_FLAG_PRIO,
} yr_ipc_flag_t;
YR_IPC_FLAG_FIFO 表示先阻塞的任务先被唤醒,YR_IPC_FLAG_PRIO 表示优先级高的任务排在前面。
这里仍然要注意,Yuan RTOS 中任务优先级数值越小,优先级越高。
初始化 IPC 对象
IPC 基础对象初始化时,会设置有效标志、排队策略,并初始化阻塞链表:
yr_err_t yr_ipc_init( yr_ipc_base_t *ipc_base, yr_uint32_t flag)
{
ipc_base->is_valid = YR_TRUE;
ipc_base->flag = flag;
yr_list_init( &ipc_base->blocked_list);
return YR_OK;
}
信号量、互斥锁和队列内部都会调用这个函数。
例如信号量中只有一个 IPC 基础对象,而队列中有两个 IPC 基础对象:一个用于发送阻塞,一个用于接收阻塞。这一点在后面的队列文章中再展开。
阻塞任务
任务需要等待某个 IPC 对象时,会调用:
yr_err_t yr_ipc_block_task( yr_ipc_base_t *ipc_base,
yr_task_t *task)
{
yr_sched_remove_task( task);
task->status = YR_TASK_STATUS_BLOCKED;
yr_ipc_insert_blocked_task( ipc_base, task );
return YR_OK;
}
这个函数做了两件事情:
- 把任务从调度器就绪队列中移除
- 把任务插入 IPC 对象的阻塞队列
任务从就绪队列移除后,调度器就不会再选择它运行。之后通常会调用 yr_sched_switch() 切换到其它就绪任务。
阻塞队列的插入策略
真正把任务插入阻塞链表的是内部函数 yr_ipc_insert_blocked_task()。
如果使用 FIFO 策略,就直接插入链表末尾:
yr_list_insert_before( &ipc_base->blocked_list,
&task->list_node);
如果使用优先级策略,就需要按照当前优先级插入:
while( plist != &ipc_base->blocked_list ) {
yr_task_t *ptask = YR_LIST_ENTRY( plist,
yr_task_t,
list_node);
if( task->current_priority < ptask->current_priority ) {
yr_list_insert_before( &ptask->list_node,
&task->list_node);
break;
}
plist = plist->next;
}
这样唤醒任务时,只需要取阻塞链表的第一个节点,就可以得到当前策略下最应该被唤醒的任务。
唤醒所有任务
IPC 对象删除时,通常需要唤醒所有等待它的任务。公共函数如下:
yr_err_t yr_ipc_resume_all( yr_ipc_base_t *ipc_base)
{
while (!yr_list_isempty( list_head ))
{
task = YR_LIST_ENTRY( list_head->next,
yr_task_t,
list_node);
yr_list_delete_self(&task->list_node);
yr_timer_stop(&task->timer);
task->status = YR_TASK_STATUS_READY;
yr_task_set_msg( task,
NULL,
NULL,
YR_TASK_MR_NONE,
YR_TASK_MN_WAIT_IPC_DELETED);
yr_sched_insert_task( task);
}
return YR_OK;
}
唤醒任务时要做几件事:
- 从 IPC 阻塞链表删除任务节点
- 停止任务等待超时用的定时器
- 将任务状态改为
READY - 设置任务的等待结果
- 重新插入调度器就绪队列
这里的等待结果很重要。任务被唤醒不一定表示拿到了资源,也可能是等待超时,或者 IPC 对象被删除。因此任务控制块中有一个 msg_info 字段,用于记录等待原因和唤醒结果。
等待结果
任务控制块中有一组和等待结果相关的字段:
typedef struct yr_task_msg_t {
void *source;
void *msg;
yr_uint8_t reason;
yr_uint16_t notify;
} yr_task_msg_t;
其中:
source:阻塞来源,比如某个 IPC 对象msg:附加消息指针reason:阻塞原因notify:唤醒通知类型
常见的唤醒结果有:
YR_TASK_MN_WAIT_OK
YR_TASK_MN_WAIT_TIMEOUT
YR_TASK_MN_WAIT_IPC_DELETED
因此任务被唤醒后,不能只看“我重新运行了”,还要看这次等待到底是成功、超时,还是等待对象被删除。
阻塞队列重新排序
IPC 基础层还有一个接口:
yr_err_t yr_ipc_reorder_blocked_task( yr_ipc_base_t *ipc_base,
yr_task_t *task)
这个函数用于在任务当前优先级发生变化时,重新整理它在 IPC 阻塞队列中的位置。
为什么会有这种情况?主要是互斥锁的优先级继承可能会修改任务当前优先级。如果 IPC 对象采用优先级排队,那么任务优先级改变后,原来的链表顺序就可能不再正确。因此需要把任务从原位置摘出来,再按新优先级插回去。
IPC 与定时器的关系
IPC 等待和软件定时器是配合使用的。
当任务等待一个 IPC 对象时,如果设置了有限等待时间,那么任务会同时:
- 挂入 IPC 对象的阻塞链表
- 启动任务自己的定时器
如果 IPC 条件先满足,就停止定时器并设置 YR_TASK_MN_WAIT_OK。
如果定时器先超时,就由超时回调把任务从 IPC 阻塞链表中移除,并设置 YR_TASK_MN_WAIT_TIMEOUT。
这也是为什么 IPC 唤醒任务时需要调用 yr_timer_stop(),否则任务可能已经被正常唤醒了,但定时器后续又触发一次超时回调,导致状态混乱。
总结
Yuan RTOS 的 IPC 基础层可以概括为:
- 使用
yr_ipc_base_t抽象公共阻塞队列 - 支持 FIFO 和优先级两种阻塞任务排队策略
- 阻塞任务时,将任务从调度器就绪队列移到 IPC 阻塞队列
- 唤醒任务时,将任务从 IPC 阻塞队列移回调度器就绪队列
- 通过
msg_info记录等待原因和唤醒结果 - 通过任务定时器支持 IPC 等待超时
有了这个基础层,后面的信号量、互斥锁和队列就可以把重点放在各自的资源语义上,而不必重复实现阻塞与唤醒逻辑。