LK 博客
RTOS设计与开发(5):SysTick、临界区与PendSV优先级,把抢占时基做扎实
嵌入式
约 1 分钟阅读 0 赞 0 条评论 鸿蒙黑体

RTOS设计与开发(5):SysTick、临界区与PendSV优先级,把抢占时基做扎实

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

RTOS设计与开发(5):SysTick、临界区与PendSV优先级,把抢占时基做扎实

前面几篇文章把 ready queue、时间片和 PendSV 切换链路搭起来之后,内核其实已经“能切任务”了。

但“能切”和“能稳定切”不是一回事。只要真正接入 SysTick、中断上下文和线程态 API,之前很多默认成立的前提都会开始失效:谁先改 ready queue,谁负责 pend PendSV,什么时候允许开中断,都会变成必须被精确定义的问题。

这一阶段的代码,解决的正是这个问题。

先看这层到底要补什么

当前仓库里的 port 层新增了四个关键能力:

  1. os_port_systick_init() 把系统时基正式接进来。
  2. os_port_enter_critical() / os_port_exit_critical() 给内核共享数据加最小临界区。
  3. os_port_is_in_interrupt() 区分线程态 API 和 ISR API。
  4. SysTickPendSV 的优先级关系明确固定下来。

这里最核心的设计点其实只有一句话:SysTick 负责“先结算本 tick 该发生的事”,PendSV 负责“在异常尾部做真正的任务切换”。

为什么 SysTick 必须高于 PendSV

先看 port 层对异常优先级的约束:

#define OS_PORT_LOWEST_INTERRUPT_PRIORITY ((1UL << __NVIC_PRIO_BITS) - 1UL)
#define OS_PORT_SYSTICK_PRIORITY        (OS_PORT_LOWEST_INTERRUPT_PRIORITY - 1UL)

static OS_PORT_USED void os_port_configure_exception_priorities(void)
{
    NVIC_SetPriority(PendSV_IRQn, OS_PORT_LOWEST_INTERRUPT_PRIORITY);
    NVIC_SetPriority(SysTick_IRQn, OS_PORT_SYSTICK_PRIORITY);
}

这个设计很关键。

PendSV 必须足够低,才能保证真正重要的外设中断不会被上下文切换抢先打断。SysTick 又必须比 PendSV 高一档,这样每次节拍到来时,系统可以先完整做完三件事:

  1. 递增全局 tick。
  2. 唤醒本 tick 到期的任务。
  3. 结算当前任务这一个 tick 的时间片。

只有这三件事都做完了,系统才有资格去问“现在该切给谁”。所以 SysTick 不是切换中断,它更像是“调度输入的结算点”。

os_port_systick_init() 为什么把参数收得很紧

当前实现没有用“差不多能跑”的方式初始化 SysTick,而是故意收紧了参数边界:

os_status_t os_port_systick_init(uint32_t cpu_clock_hz, uint32_t tick_hz)
{
    uint32_t reload = 0U;

    if ((cpu_clock_hz == 0U) || (tick_hz == 0U))
    {
        return OS_STATUS_INVALID_PARAM;
    }

    if (cpu_clock_hz < tick_hz)
    {
        return OS_STATUS_INVALID_PARAM;
    }

    if ((cpu_clock_hz % tick_hz) != 0U)
    {
        return OS_STATUS_INVALID_PARAM;
    }

    reload = cpu_clock_hz / tick_hz;
    if ((reload == 0U) || (reload > (SysTick_LOAD_RELOAD_Msk + 1UL)))
    {
        return OS_STATUS_INVALID_PARAM;
    }

    os_port_configure_exception_priorities();

    if (SysTick_Config(reload) != 0U)
    {
        return OS_STATUS_INVALID_PARAM;
    }

    NVIC_SetPriority(SysTick_IRQn, OS_PORT_SYSTICK_PRIORITY);
    return OS_STATUS_OK;
}

这里最值得注意的是“要求整除”这一点。

很多最小 RTOS 会直接做整数除法,把余数默默吞掉。但一旦这样做,节拍就不再是一个稳定的整数时间基,而是一个持续累积漂移的近似值。当前实现宁可直接返回 OS_STATUS_INVALID_PARAM,也不接受一个长期会漂的时基。

这个取舍很工程化:先保证 tick 语义绝对稳定,再考虑以后要不要支持分数频率补偿。

临界区为什么采用“最小 PRIMASK 版本”

目前这版代码没有做复杂的嵌套计数和调度锁,而是先引入一个最小临界区:

uint32_t os_port_enter_critical(void)
{
    uint32_t primask = __get_PRIMASK();

    __disable_irq();
    __DSB();
    __ISB();

    return primask;
}

void os_port_exit_critical(uint32_t primask)
{
    __set_PRIMASK(primask);
    __DSB();
    __ISB();
}

为什么这样做已经足够重要?

因为现在内核里已经有多条路径会同时访问同一批全局状态:

  1. 线程态的 task_create()task_delay()task_block_current()
  2. 中断态的 SysTick_Handler()
  3. 以后对象层里的 os_sem_give_from_isr()os_queue_send_from_isr()

这些路径共享的不是一两个标志位,而是整套调度数据结构:ready queuetimed-wait listg_current_taskg_next_task。只要“摘链 + 重调度 + pend PendSV”这几个动作被打断,内核状态就很容易撕裂。

所以当前实现强调的不是“大而全的锁框架”,而是先把最关键的原子区段关住。

task_system_tick() 为什么先处理超时,再处理时间片

节拍真正进入任务层之后,逻辑集中在 task_system_tick()

os_status_t task_system_tick(void)
{
    os_status_t status = OS_STATUS_OK;
    uint32_t primask = 0U;

    if (g_task_system_initialized == 0U)
    {
        return OS_STATUS_NOT_INITIALIZED;
    }

    primask = os_port_enter_critical();

    g_os_tick++;

    status = task_wake_timed_tasks_locked();
    if ((status != OS_STATUS_NO_CHANGE) && (status != OS_STATUS_SWITCH_REQUIRED))
    {
        os_port_exit_critical(primask);
        return status;
    }

    status = task_handle_time_slice_tick_locked();
    os_port_exit_critical(primask);

    return status;
}

这里的顺序不是随便摆的。

先唤醒超时任务,意味着“本 tick 到期恢复 runnable 的任务”可以立刻参与这一次调度计算。然后再去结算当前任务这一个 tick 的时间片,意味着当前运行者不会因为“刚好发生了 timeout”就逃掉本 tick 的时间片消耗。

这个顺序最终保证了两个语义同时成立:

  1. 到期任务不会多等一个 tick。
  2. 当前任务也不会少算一个 tick。

为什么 SysTick_Handler() 不直接切任务

port 层的节拍处理函数非常克制:

void SysTick_Handler(void)
{
    os_status_t status = OS_STATUS_OK;

    status = task_system_tick();
    if (status == OS_STATUS_SWITCH_REQUIRED)
    {
        os_port_trigger_pendsv();
    }
}

它只做一件事:根据任务层给出的调度结果,决定要不要 pend 一次 PendSV

也就是说,SysTick 负责产生“需要切换”的事实,PendSV 负责消费这个事实。

这个边界非常值钱。因为一旦以后有更多事件源,比如信号量 give_from_isr()、消息队列 send_from_isr()、DMA 完成中断,本质上都只是额外多出一些“可能让更高优先级任务就绪”的触发点。它们最终依然只需要做两件事:

  1. 改动对象和调度器状态。
  2. 如果需要,就 pend 一次 PendSV

线程态 API 为什么开始区分 ISR 版本

随着 SysTick 和对象中断路径接进来,os_port_is_in_interrupt() 就变得很有必要:

uint8_t os_port_is_in_interrupt(void)
{
    return (uint8_t)(__get_IPSR() != 0U);
}

它看起来只是一行代码,但它让接口语义开始变得严格。

比如:

  1. task_delay()task_block_current()os_sem_take()os_queue_send() 都只允许在线程态调用。
  2. os_sem_give_from_isr()os_queue_send_from_isr() 则明确只允许在异常上下文调用。

这意味着内核不再依赖“调用者自觉”,而是开始在 API 边界上保护自己的运行模型。

小结

这一阶段最重要的收获,不是单纯多了一个 SysTick_Handler()

真正关键的是,RTOS 开始建立起一套可靠的抢占时序:

  1. SysTick 先结算 tick 带来的状态变化。
  2. 线程态和中断态都通过最小临界区保护共享调度状态。
  3. 所有“需要切换”的结果,都统一折叠成一次 PendSV 请求。

只有这套时序稳定下来,后面的延时、阻塞、信号量和消息队列才不会变成一堆偶发 race condition。

下一篇会进入任务层新增的另一块核心能力:延时、阻塞、超时唤醒、idle 任务和任务删除,看看这个内核是怎么把“任务生命周期”真正闭环起来的。

作者名片

Yukikaze
Yukikaze
@Yukikaze

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

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