3. 实时操作系统核心概念

在 MCX 这类 Cortex-M 微控制器上运行实时操作系统(RTOS),最核心的抽象不是“函数”,而是“任务”。理解任务及其周边的一整套机制——线程、调度器、上下文切换、优先级、就绪队列、阻塞与挂起——是写出可预测实时系统的前提。本节把这些概念串成一条完整的运行链,并结合 FreeRTOS 在 MCX 上的典型行为给出可观察、可验证的说明。

任务与线程:调度的基本单位

任务是 RTOS 中可被独立调度的一段执行流,它拥有自己的栈空间、程序计数器、寄存器现场和状态。在 FreeRTOS 中,任务由 xTaskCreate() 创建,每个任务对应一个 TaskHandle_t 句柄。任务函数通常是无限循环结构:

void vSensorTask(void *pvParameters)
{
    for (;;) {
        uint16_t raw = ADC_Read();
        xQueueSend(xAdcQueue, &raw, portMAX_DELAY);
        vTaskDelay(pdMS_TO_TICKS(10));   /* 周期 10 ms */
    }
}

“线程”与“任务”在多数嵌入式 RTOS 语境下是同义词,都指可独立调度的执行单元。区别在于:线程更强调与同一进程内其他线程共享地址空间,而 MCX 上的 RTOS 通常没有 MMU 隔离,所有任务共享同一片 SRAM,因此“任务”一词更贴切。每个任务独立栈的大小由创建时指定,例如 configMINIMAL_STACK_SIZE 通常为 128 字(512 字节),实际工程中传感器任务常给 256~512 字,避免栈溢出。

调度器:决定“下一个跑谁”

调度器是 RTOS 的心脏,它维护所有任务的状态,并在合适的时机选出优先级最高的就绪任务投入运行。FreeRTOS 默认采用抢占式优先级调度:一旦有更高优先级任务进入就绪态,调度器立即切换过去。调度器在以下时机被触发:任务调用阻塞 API(如 vTaskDelay、xQueueReceive)、中断服务程序调用 xQueueSendFromISR 等带 FromISR 后缀的 API、以及 taskYIELD() 显式让出 CPU。

调度器本身不是任务,它由 vTaskStartScheduler() 启动,之后通过 PendSV 异常完成上下文切换。在 MCX 的 Cortex-M33 内核上,SysTick 提供系统节拍(通常 1 kHz),PendSV 负责实际的寄存器保存与恢复,二者配合实现“节拍驱动 + 事件驱动”的调度。

上下文切换:保存与恢复现场

上下文切换是调度器把 CPU 从一个任务交给另一个任务的过程。它必须保存当前任务的寄存器现场(R0–R3、R12、LR、PC、xPSR 由硬件自动压栈,R4–R11 由软件压栈),再恢复目标任务的现场。整个过程发生在 PendSV 异常中,耗时通常在几微秒量级。

上下文切换的开销与任务数量无关,只与栈操作和寄存器数量有关。在 MCX 上,一次切换约 1~3 µs(取决于主频和是否启用 FPU)。如果系统频繁切换,CPU 有效算力会被大量消耗,因此应避免“忙等”式任务,尽量用阻塞 API 让出 CPU。

优先级:实时性的排序依据

FreeRTOS 中优先级数值越大表示优先级越高,范围由 configMAX_PRIORITIES 决定,通常设为 5~32。MCX 工程中常见的分配策略是:

  • 优先级 5:硬实时中断处理任务(如电机换相、保护逻辑)
  • 优先级 3:周期性控制任务(如 1 ms 电流环)
  • 优先级 2:通信与协议栈任务(如 CAN、UART 解析)
  • 优先级 1:人机交互、日志、状态上报
  • 优先级 0:空闲任务(由 RTOS 自动创建)

相同优先级的任务采用时间片轮转(configUSE_TIME_SLICING 为 1 时),每个节拍切换一次。优先级分配的核心原则是:截止时间越紧、越不能延迟的任务,优先级越高;但高优先级任务不能长期占用 CPU,否则低优先级任务会饿死。

就绪队列:调度器的候选池

就绪队列是调度器用来管理“已具备运行条件、等待 CPU”的任务的数据结构。FreeRTOS 为每个优先级维护一个就绪列表,并用一个位图(uxTopReadyPriority)记录哪些优先级非空。调度器只需查找位图中最高置位,即可在 O(1) 时间内选出下一个任务。

任务进入就绪队列的典型场景包括:被创建后、从阻塞中唤醒、被更高优先级任务抢占后重新就绪、以及时间片轮转到它。就绪队列不包含正在运行的任务和阻塞/挂起的任务。

阻塞与挂起:任务不占 CPU 的两种方式

阻塞(Blocked)是任务主动等待某个事件或超时而进入的状态。例如 vTaskDelay(100) 让任务阻塞 100 个节拍,xQueueReceive(xQueue, &data, portMAX_DELAY) 让任务阻塞直到队列有数据。阻塞中的任务不参与调度,不消耗 CPU,但仍在就绪队列之外被超时链表管理。

挂起(Suspended)是任务被显式暂停的状态,通过 vTaskSuspend() 进入,vTaskResume() 恢复。挂起的任务既不会被调度,也不会因超时自动唤醒,必须由其他任务或中断显式恢复。挂起常用于调试、低功耗模式或临时关闭某个功能模块。

阻塞与挂起的区别:阻塞有“等待目标”,超时后会自动回到就绪态;挂起没有等待目标,必须被显式恢复。阻塞是任务主动让出 CPU,挂起是外部强制暂停。

状态迁移全景

把上述概念放在一起,任务在生命周期中会在就绪、运行、阻塞、挂起四个状态之间迁移。下图展示了 FreeRTOS 中任务状态迁移的完整路径,以及触发每次迁移的典型 API 或事件。

就绪 Ready 在就绪队列中等待 CPU 运行 Running 占用 CPU 执行 阻塞 Blocked 等待事件或超时 挂起 Suspended 被显式暂停,不参与调度 调度器选中 被抢占 / 时间片到 vTaskDelay / 等队列 超时 / 事件到达 → 回到就绪队列 vTaskSuspend() vTaskResume() vTaskSuspend() vTaskResume() 后仍等待事件 状态说明: 就绪 运行 阻塞 挂起 调度器始终从就绪队列中选最高优先级任务运行;阻塞与挂起都不占用 CPU。

在 MCX 上观察任务状态

FreeRTOS 提供了 vTaskList() 和 vTaskGetRunTimeStats() 两个调试接口,可以把每个任务的状态、优先级、剩余栈和 CPU 占用率打印出来。在 MCX 工程中启用它们需要打开 configUSE_TRACE_FACILITY 和 configGENERATE_RUN_TIME_STATS,并提供一个高精度计时源(例如另一个定时器)。

下面是一段典型的任务状态输出示例(数据为教学假设,非实测):

Name          State   Prio  Stack  Num
SensorTask    R       3     120    1
CtrlTask      B       5     200    2
CommTask      B       2     180    3
LogTask       S       1     150    4
IDLE          R       0     80     5

其中 R 表示就绪/运行,B 表示阻塞,S 表示挂起。通过观察 Stack 列可以判断栈是否接近耗尽,通过 State 列可以确认任务是否按预期进入阻塞而不是忙等。如果发现某个高优先级任务长期处于 R 状态而低优先级任务得不到执行,就说明优先级分配或阻塞策略需要调整。

常见误区:把“阻塞”当成“挂起”。阻塞是任务在等待事件,超时后会自动恢复;挂起必须由其他代码显式恢复。若误用 vTaskSuspend() 代替阻塞等待,任务将永远不会被超时唤醒,导致逻辑死锁。

掌握任务、调度器、上下文切换、优先级、就绪队列以及阻塞/挂起状态之间的关系后,就能在 MCX 上设计出响应可预测的实时任务集。下一步的关键是把这些概念落到具体的优先级分配和栈预算上,并通过运行时统计验证设计是否符合预期。