
RTOS设计与开发(5):SysTick、临界区与PendSV优先级,把抢占时基做扎实
RTOS设计与开发(5):SysTick、临界区与PendSV优先级,把抢占时基做扎实
前面几篇文章把 ready queue、时间片和 PendSV 切换链路搭起来之后,内核其实已经“能切任务”了。
但“能切”和“能稳定切”不是一回事。只要真正接入 SysTick、中断上下文和线程态 API,之前很多默认成立的前提都会开始失效:谁先改 ready queue,谁负责 pend PendSV,什么时候允许开中断,都会变成必须被精确定义的问题。
这一阶段的代码,解决的正是这个问题。
先看这层到底要补什么
当前仓库里的 port 层新增了四个关键能力:
- 用
os_port_systick_init()把系统时基正式接进来。 - 用
os_port_enter_critical()/os_port_exit_critical()给内核共享数据加最小临界区。 - 用
os_port_is_in_interrupt()区分线程态 API 和 ISR API。 - 把
SysTick和PendSV的优先级关系明确固定下来。
这里最核心的设计点其实只有一句话: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 高一档,这样每次节拍到来时,系统可以先完整做完三件事:
- 递增全局 tick。
- 唤醒本 tick 到期的任务。
- 结算当前任务这一个 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();
}
为什么这样做已经足够重要?
因为现在内核里已经有多条路径会同时访问同一批全局状态:
- 线程态的
task_create()、task_delay()、task_block_current()。 - 中断态的
SysTick_Handler()。 - 以后对象层里的
os_sem_give_from_isr()、os_queue_send_from_isr()。
这些路径共享的不是一两个标志位,而是整套调度数据结构:ready queue、timed-wait list、g_current_task、g_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 的时间片消耗。
这个顺序最终保证了两个语义同时成立:
- 到期任务不会多等一个 tick。
- 当前任务也不会少算一个 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 完成中断,本质上都只是额外多出一些“可能让更高优先级任务就绪”的触发点。它们最终依然只需要做两件事:
- 改动对象和调度器状态。
- 如果需要,就 pend 一次
PendSV。
线程态 API 为什么开始区分 ISR 版本
随着 SysTick 和对象中断路径接进来,os_port_is_in_interrupt() 就变得很有必要:
uint8_t os_port_is_in_interrupt(void)
{
return (uint8_t)(__get_IPSR() != 0U);
}
它看起来只是一行代码,但它让接口语义开始变得严格。
比如:
task_delay()、task_block_current()、os_sem_take()、os_queue_send()都只允许在线程态调用。os_sem_give_from_isr()、os_queue_send_from_isr()则明确只允许在异常上下文调用。
这意味着内核不再依赖“调用者自觉”,而是开始在 API 边界上保护自己的运行模型。
小结
这一阶段最重要的收获,不是单纯多了一个 SysTick_Handler()。
真正关键的是,RTOS 开始建立起一套可靠的抢占时序:
SysTick先结算 tick 带来的状态变化。- 线程态和中断态都通过最小临界区保护共享调度状态。
- 所有“需要切换”的结果,都统一折叠成一次
PendSV请求。
只有这套时序稳定下来,后面的延时、阻塞、信号量和消息队列才不会变成一堆偶发 race condition。
下一篇会进入任务层新增的另一块核心能力:延时、阻塞、超时唤醒、idle 任务和任务删除,看看这个内核是怎么把“任务生命周期”真正闭环起来的。