LK 博客
RTOS设计与开发(6):延时、阻塞、超时与任务删除,把生命周期真正闭环
嵌入式
约 1 分钟阅读 1 赞 0 条评论 鸿蒙黑体

RTOS设计与开发(6):延时、阻塞、超时与任务删除,把生命周期真正闭环

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

RTOS设计与开发(6):延时、阻塞、超时与任务删除,把生命周期真正闭环

如果说前一阶段主要解决的是“时基和抢占什么时候发生”,那么这一阶段任务层真正补上的,是“任务从创建到退出,中间所有状态迁移到底怎么收口”。

这版代码已经不只是一个能 round-robin 的最小调度器了。它开始具备完整生命周期语义:

  1. 系统启动时自动创建 idle 任务。
  2. 任务可以进入 delay 睡眠。
  3. 任务可以进入对象等待阻塞,并带超时返回。
  4. 任务可以被删除,甚至可以自删。

这四件事一旦成立,任务系统才算真正从“会调度”走向“会管理任务”。

为什么必须有 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;

它承载的对象有两类:

  1. task_delay() 进入 TASK_SLEEPING 的任务。
  2. 等待对象且带 timeout 的 TASK_BLOCKED 任务。

这比“睡眠一条链表、对象超时再一条链表”的设计更有意思。

因为在调度器视角里,这两类任务共享同一个本质属性:它们都对应一个绝对 wake_tick,都要在未来某个 tick 点重新回到 runnable 集合。既然如此,就没必要为“为什么在等”各维护一套超时结构。

所以当前实现把“等待原因”和“超时唤醒机制”拆开了:

  1. state / wait_obj 负责说明“为什么在等”。
  2. 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);
    ...
}

这段代码特别重要,因为它把“事件先到”和“超时先到”这两条路径统一成了同一套恢复逻辑:

  1. 先从 timed-wait list 摘掉。
  2. 再从对象等待链表摘掉。
  3. 清空等待元数据。
  4. 尾插回 ready queue。

这样一来,内核就不需要为“从哪个入口醒来”分别维护多套清理逻辑,自然也更不容易留下重复唤醒入口。

任务删除为什么被做成两条语义

当前代码把删除分成了两类:

  1. 删除别的任务,走 task_delete()
  2. 当前任务自删,走 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,而是任务系统终于拥有了完整的状态迁移闭环:

  1. 没有普通任务 runnable 时,有 idle 兜底。
  2. 任务可以因为延时或等待对象离开 ready queue。
  3. 任务可以因为超时或对象满足重新回到 runnable 集合。
  4. 任务还可以被外部删除,或者自己主动结束生命周期。

当这些语义都稳定之后,同步原语才真正有地方可落。因为信号量和消息队列说到底,并不是“自己会阻塞线程”,而是“借任务层这套等待与唤醒机制来阻塞线程”。

下一篇就看二值信号量是怎样接上这套等待框架的。

作者名片

Yukikaze
Yukikaze
@Yukikaze

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

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