
RTOS设计与开发(6):延时、阻塞、超时与任务删除,把生命周期真正闭环
RTOS设计与开发(6):延时、阻塞、超时与任务删除,把生命周期真正闭环
如果说前一阶段主要解决的是“时基和抢占什么时候发生”,那么这一阶段任务层真正补上的,是“任务从创建到退出,中间所有状态迁移到底怎么收口”。
这版代码已经不只是一个能 round-robin 的最小调度器了。它开始具备完整生命周期语义:
- 系统启动时自动创建
idle任务。 - 任务可以进入
delay睡眠。 - 任务可以进入对象等待阻塞,并带超时返回。
- 任务可以被删除,甚至可以自删。
这四件事一旦成立,任务系统才算真正从“会调度”走向“会管理任务”。
为什么必须有 idle 任务
先看任务系统初始化:
os_status_t task_system_init(void)
{
os_status_t status = OS_STATUS_OK;
if (g_task_system_initialized != 0U)
{
return OS_STATUS_OK;
}
ready_queue_init(&g_task_ready_queue);
list_init(&g_task_timed_wait_list);
g_os_tick = 0U;
g_current_task = NULL;
g_next_task = NULL;
status = task_create_idle_task();
if (status != OS_STATUS_OK)
{
return status;
}
g_task_system_initialized = 1U;
return OS_STATUS_OK;
}
这里最关键的新增不是 g_task_timed_wait_list,而是 task_create_idle_task()。
为什么 idle 任务必须内建?
因为只要普通任务都阻塞或者睡眠,系统仍然必须存在一个合法的 runnable 目标。否则调度器虽然还能运行,但它会进入一种非常尴尬的状态:理论上需要选任务,实际上 ready queue 为空。
当前实现直接把最低优先级保留给 idle:
#define OS_IDLE_TASK_PRIORITY ((uint8_t)(OS_MAX_PRIORITIES - 1U))
static os_status_t task_create_idle_task(void)
{
task_init_config_t idle_config = {
.entry = task_idle_entry,
.param = NULL,
.stack_base = g_idle_task_stack,
.stack_size = OS_IDLE_TASK_STACK_DEPTH,
.name = OS_IDLE_TASK_NAME,
.priority = OS_IDLE_TASK_PRIORITY,
.time_slice = 0U,
};
status = task_init(&g_idle_task, &idle_config, 1U);
...
}
这让普通任务和内核保底任务的边界一下就清楚了:用户任务永远不能占用最后一个优先级槽位。
为什么要单独引入 timed-wait list
当前任务层新增了这样一个全局链表:
static list_t g_task_timed_wait_list;
它承载的对象有两类:
task_delay()进入TASK_SLEEPING的任务。- 等待对象且带 timeout 的
TASK_BLOCKED任务。
这比“睡眠一条链表、对象超时再一条链表”的设计更有意思。
因为在调度器视角里,这两类任务共享同一个本质属性:它们都对应一个绝对 wake_tick,都要在未来某个 tick 点重新回到 runnable 集合。既然如此,就没必要为“为什么在等”各维护一套超时结构。
所以当前实现把“等待原因”和“超时唤醒机制”拆开了:
state/wait_obj负责说明“为什么在等”。g_task_timed_wait_list负责说明“什么时候该醒”。
为什么超时比较要限制在“半个 tick 空间”
任务层用了一个很典型但也很容易被忽视的技巧:基于有符号差值做回绕安全比较。
#define OS_TICK_COMPARE_HALF_RANGE ((os_tick_t)0x80000000UL)
static uint8_t task_timeout_is_supported(os_tick_t timeout_ticks)
{
if (timeout_ticks == OS_WAIT_FOREVER)
{
return 1U;
}
return (uint8_t)((timeout_ticks > 0U) && (timeout_ticks < OS_TICK_COMPARE_HALF_RANGE));
}
static uint8_t task_tick_is_due(os_tick_t current_tick, os_tick_t target_tick)
{
return (uint8_t)(((int32_t)(current_tick - target_tick)) >= 0);
}
这套比较成立的前提,不是“tick 永不回绕”,而是“当前 tick 与 deadline 的距离始终严格小于 0x80000000”。
所以代码没有假装自己支持任意长度超时,而是明确拒绝会破坏这条前提的 timeout 值。这种做法很值得肯定,因为它把一个本来容易变成隐含 bug 的数学边界,直接收成了公开接口约束。
task_prepare_wait_locked() 为什么是等待路径的核心
真正把任务送出 runnable 集合的关键函数,是 task_prepare_wait_locked():
static os_status_t task_prepare_wait_locked(tcb_t *task,
task_state_t wait_state,
void *wait_obj,
os_tick_t timeout_ticks)
{
...
ready_queue_remove(&g_task_ready_queue, task);
if (task->sched_node.owner != NULL)
{
return OS_STATUS_INVALID_STATE;
}
task->state = wait_state;
task->wait_obj = wait_obj;
task->wait_result = TASK_WAIT_RESULT_NONE;
task->time_slice = task->time_slice_reload;
if (timeout_ticks != OS_WAIT_FOREVER)
{
task->wake_tick = (os_tick_t)(g_os_tick + timeout_ticks);
timed_wait_list_insert_ordered(task);
if (task->sched_node.owner != &g_task_timed_wait_list)
{
return OS_STATUS_INSERT_FAILED;
}
}
else
{
task->wake_tick = 0U;
}
return task_schedule();
}
这里的设计非常干净。
先从 ready queue 摘链,再写入等待元数据,再决定要不要插入 timed-wait list,最后统一重新调度。也就是说,从“当前运行”迁移到“等待中”的全过程,被压缩成了一个很明确的原子状态转换。
这比在多个 API 里各写一半逻辑要稳得多。以后无论是 task_delay() 还是对象阻塞,都只是这个公共等待迁移器的不同入口。
为什么 task_delay() 和 task_block_current() 都要“先 pend PendSV,再开中断”
两个接口都用了同一个关键修正:
if (status == OS_STATUS_SWITCH_REQUIRED)
{
os_port_trigger_pendsv();
os_port_exit_critical(primask);
return OS_STATUS_OK;
}
这个顺序非常重要。
当前任务一旦已经被摘出 runnable 集合,它在语义上就不应该再继续往前执行用户代码。如果先开中断,再去 pend PendSV,那么中间就会出现一个危险窗口:任务已经处于阻塞或睡眠语义,但 CPU 还没切走它。
这时如果再来一个对象中断或者一次 SysTick,就可能把同一个任务的等待状态改写成一种非常难调试的中间态。
所以当前实现选择了最稳妥的方式:先把 PendSV 置 pending,再恢复中断,让切走动作尽快在异常尾部发生。
超时唤醒为什么统一走 task_make_runnable_locked()
到期任务的恢复路径也被统一收口了:
static os_status_t task_make_runnable_locked(tcb_t *task, task_wait_result_t wait_result)
{
if (task_is_in_timed_wait_list(task) != 0U)
{
(void)list_remove(&g_task_timed_wait_list, &task->sched_node);
}
if (task->event_node.owner != NULL)
{
(void)list_remove(task->event_node.owner, &task->event_node);
}
task->wake_tick = 0U;
task->wait_obj = NULL;
task->wait_result = wait_result;
task->time_slice = task->time_slice_reload;
ready_queue_insert_tail(&g_task_ready_queue, task);
...
}
这段代码特别重要,因为它把“事件先到”和“超时先到”这两条路径统一成了同一套恢复逻辑:
- 先从
timed-wait list摘掉。 - 再从对象等待链表摘掉。
- 清空等待元数据。
- 尾插回 ready queue。
这样一来,内核就不需要为“从哪个入口醒来”分别维护多套清理逻辑,自然也更不容易留下重复唤醒入口。
任务删除为什么被做成两条语义
当前代码把删除分成了两类:
- 删除别的任务,走
task_delete()。 - 当前任务自删,走
task_exit_current()。
这不是重复实现,而是两种完全不同的控制流。
删除别的任务时,调用者还能继续执行,所以可以正常返回:
os_status_t task_delete(tcb_t *task)
{
...
status = task_mark_deleted_locked(task);
...
status = task_schedule();
...
if ((status == OS_STATUS_SWITCH_REQUIRED) && (scheduler_running != 0U))
{
os_port_trigger_pendsv();
}
...
return OS_STATUS_OK;
}
但当前任务自删时,函数理论上就不应该返回给原调用者了:
void task_exit_current(void)
{
...
status = task_mark_deleted_locked(current_task);
...
status = task_schedule();
...
os_port_trigger_pendsv();
os_port_exit_critical(primask);
while (1)
{
}
}
这也是为什么 port 层把任务函数 return 收敛到了 task_exit_current():
任务生命周期一旦闭环,任务结束就不再是“未定义行为”,而是“自动进入删除路径并切走”。
小结
这一阶段最重要的,不是简单多了几个 API,而是任务系统终于拥有了完整的状态迁移闭环:
- 没有普通任务 runnable 时,有
idle兜底。 - 任务可以因为延时或等待对象离开 ready queue。
- 任务可以因为超时或对象满足重新回到 runnable 集合。
- 任务还可以被外部删除,或者自己主动结束生命周期。
当这些语义都稳定之后,同步原语才真正有地方可落。因为信号量和消息队列说到底,并不是“自己会阻塞线程”,而是“借任务层这套等待与唤醒机制来阻塞线程”。
下一篇就看二值信号量是怎样接上这套等待框架的。