调度器是整个 RTOS 的核心,它决定了“谁在跑、什么时候换人”。今天我们深入了解一下 FreeRTOS 的调度器是如何工作的。
1. FreeRTOS调度器
调度器就像是系统的“裁判”,它持续地监控着所有任务的状态(就绪 / 运行 / 阻塞),然后从就绪链表中选出优先级最高的任务来占用 CPU。一旦任务状态发生变化,它就会触发切换。
FreeRTOS 默认采用两种策略并行工作:
- 抢占式调度:在不同优先级的任务之间,高优先级任务可以随时抢占低优先级任务的 CPU 使用权,不管低优先级的任务是否已经执行完毕。
- 时间片轮转:在同一优先级的多个任务之间,每个任务轮流执行一个固定的时间片(默认是 1ms)。
这里有一个核心原则需要牢记:优先级永远高于时间片。也就是说,只要有一个更高优先级的任务就绪,不管当前同优先级的任务时间片是否用完,都会立即发生抢占。
这两种行为由 FreeRTOSConfig.h 中的两个宏独立控制:
/* FreeRTOSConfig.h */
#define configUSE_PREEMPTION 1 /* 1=抢占式,0=合作式(任务必须主动让步) */
#define configUSE_TIME_SLICING 1 /* 1=同优先级时间片轮转,0=关闭轮转 */
当两个宏都开启时,就是我们最常用的“抢占 + 时间片”模式。如果关掉 configUSE_PREEMPTION,系统就会退化为合作式调度,此时任务必须主动调用 taskYIELD() 才会发生切换。
2. 时钟节拍(SysTick)
SysTick 是调度器的“心跳”,它默认以 1000Hz 的频率中断(即每 1ms 中断一次)。每次 SysTick 中断主要完成两件事:将系统时钟节拍 tick 计数加一、检查是否有阻塞任务超时需要唤醒,然后决定是否需要进行上下文切换。

关键的 xTaskIncrementTick() 函数除了处理阻塞任务的唤醒,其内部还包含了时间片轮转的判断逻辑。它会检查当前优先级下是否还有其他就绪任务,如果有,就返回 pdTRUE,从而触发一次切换。

这段代码说明了一个重要事实:时间片轮转并不是一个完全独立的机制,它内嵌在 xTaskIncrementTick() 函数里,每次 SysTick 中断都会顺带进行判断。
3. 抢占式调度
3.1 运行流程
假设系统中有三个任务,优先级分别是:TaskA(高,优先级3)、TaskB(中,优先级2)、TaskC(低,优先级1)。它们的抢占调度流程可以用下面的流程图清晰地展示:

3.2 调度触发时机
调度并不是随机发生的,它只在特定的“调度点”被触发。常见的调度点如下:
| 调度触发点 |
说明 |
| SysTick 中断 |
最常见,默认每 1ms 检查一次 |
| 任务主动阻塞 |
vTaskDelay(), xQueueReceive() 等 |
| 任务创建 / 删除 / 改优先级 |
任务状态发生变化时 |
| ISR 中唤醒高优先级任务 |
中断服务程序中,唤醒任务后触发 |
特别需要注意的是,在中断服务程序 (ISR) 里唤醒了一个高优先级任务后,必须使用 portYIELD_FROM_ISR() 通知调度器。否则,即使高优先级任务已经就绪,也得等到下一个 SysTick 中断到来时才会发生切换。

3.3 核心源码
vTaskSwitchContext() 是上下文切换的核心函数,它内部最关键的是 taskSELECT_HIGHEST_PRIORITY_TASK() 宏。这个宏展开后的等效逻辑如下,它从最高优先级向下查找第一个非空的就绪链表:

当调用 vTaskStartScheduler() 启动调度器时,它会依次完成四件事:创建空闲任务 → 关中断 → 设置 xSchedulerRunning = pdTRUE → 调用 xPortStartScheduler()(这个函数内部会启动 SysTick 定时器,并切换到第一个任务去执行)。
这里提到了空闲任务,它是 FreeRTOS 系统中一个不可或缺的部分。它的作用主要有两个:第一,当所有用户任务都处于阻塞状态时,CPU 仍然有一个任务可以运行,避免 CPU 空转;第二,它负责回收那些被删除任务的 TCB(任务控制块)和栈内存资源。
4. 时间片轮转
4.1 运行流程
对于同一优先级的多个任务,时间片轮转机制会让它们按照就绪链表中的顺序轮流执行。每个任务执行一个时间片,用完后就切换到链表中的下一个任务。下图直观地展示了两个同优先级任务的轮转过程:

4.2 配置与注意事项
时间片的长度由系统节拍频率决定,它在 FreeRTOSConfig.h 中配置:
#define configTICK_RATE_HZ 1000UL /* 时间片 = 1ms;改成 500 则为 2ms */
这里有一个非常重要的实践要点:当使用任务延时函数时,必须使用 pdMS_TO_TICKS() 宏进行转换,而不能直接写死 tick 数值。
vTaskDelay(pdMS_TO_TICKS(100)); // 正确:延时 100ms
vTaskDelay(100); // 错误:如果 tick 频率改变,延时时间会变化
时间片大小的取舍是一个平衡问题:
- 时间片太小:任务切换会非常频繁,上下文保存和恢复带来的 CPU 开销会显著上升。
- 时间片太大:同一优先级任务之间的响应延迟会增大,任务好像“卡住”了一样。
你需要根据实际的应用场景来找到一个合适的平衡点。
5. 上下文切换
无论是由 SysTick 中断、任务主动阻塞还是其他事件触发了调度需求,最终都会走到“上下文切换”这一步。这个过程可以概括为三个主要步骤:

你可能会有疑问,为什么切换不直接在 SysTick 中断里完成,而是要触发一个 PendSV 异常?这是因为 PendSV 的优先级被设置为最低。这样做的好处是,可以让所有更高优先级的硬件中断(比如你的 UART、SPI 中断)都先处理完毕,然后再进行任务切换。这保证了硬件中断的实时性不受任务切换的影响。
PendSV 异常处理程序通常由汇编编写,以追求最高的执行效率。下面是其在 ARM Cortex-M 内核上一个简化版的逻辑:
PendSV_Handler:
/* Step1: 保存当前任务上下文 */
MRS R0, PSP
STMDA R0!, {R4-R11}
LDR R1, =pxCurrentTCB
LDR R2, [R1]
STR R0, [R2]
/* Step2: 切换 pxCurrentTCB 到最高优先级任务 */
BL vTaskSwitchContext
/* Step3: 恢复新任务上下文 */
LDR R1, =pxCurrentTCB
LDR R2, [R1]
LDR R0, [R2]
LDMIA R0!, {R4-R11}
MSR PSP, R0
BX LR

6. 常见Q&A
Q:抢占式调度和时间片轮转的根本区别是什么?
A:抢占式调度作用于不同优先级的任务之间,核心是“优先级高者随时抢占”;时间片轮转作用于同一优先级的任务之间,核心是“按时间片顺序轮流执行”。两者在系统中是同时工作的,并且优先级判断永远优先于时间片轮转。
Q:为什么上下文切换要放在 PendSV 异常里,而不是直接在 SysTick 中断里完成?
A:主要是为了保证中断的实时性。SysTick 的优先级通常设置得比较高,如果直接在其中进行复杂的上下文保存和恢复,可能会延迟对其他更紧急硬件中断的响应。PendSV 优先级最低,可以确保所有“正事”(硬件中断)都处理完后,再来做“家务事”(任务切换)。
Q:空闲任务到底有什么用?可以把它删掉吗?
A:绝对不能删除。空闲任务有两个关键职责:第一,当所有用户任务都阻塞时,为 CPU 提供一个可执行的“背景”任务,防止系统进入未定义状态;第二,它负责在后台回收被 vTaskDelete() 删除的任务所占用的 TCB 和栈内存,这是 FreeRTOS 动态内存管理的重要一环。
7. 常见问题与避坑指南
坑1:修改了 configTICK_RATE_HZ,但代码中的延时还是写死的 tick 数
这是新手常犯的错误。例如,你把系统频率从 1000Hz 改成了 500Hz,以为 vTaskDelay(1000) 还是延时1秒,实际上它变成了2秒。唯一的解决方案就是统一使用 pdMS_TO_TICKS() 宏来转换毫秒时间为 tick 数。
坑2:高优先级任务写成了“死循环”,永不释放 CPU
如果一个高优先级任务在其循环体中没有任何阻塞调用(如 vTaskDelay(), xQueueReceive()),那么它将一直占据 CPU,导致所有低优先级的任务永远得不到执行,系统看似“假死”。设计准则:高优先级任务中必须包含能让出 CPU 的阻塞操作。
坑3:认为同优先级任务之间操作共享资源不需要保护
这是一个危险的误解。时间片轮转只是在宏观上“轮流”执行,但在微观的任一时刻,仍然只有一个任务在访问共享资源(如全局变量、外设)。如果两个同优先级任务可能同时访问该资源,必须使用信号量、互斥锁等机制进行保护,否则会导致数据竞争和系统的不确定性。
理解 FreeRTOS 的调度机制,是进行稳定、高效嵌入式系统开发的基础。希望本文的解析能帮助你更清晰地把握抢占与轮转的精髓。如果你在项目中遇到过其他有趣的调度问题,欢迎在云栈社区的技术论坛与其他开发者交流探讨。