FreeRTOS任务调度
FreeRTOS 里的任务,就像是一个不会随便返回的 C 函数。基本形式如下:
1 | void TaskA(void *argument) |
任务函数可以写得像普通函数,但不能像普通函数那样执行完以后直接 return。任务是被调度器管理的执行单元,函数返回后,任务栈和任务控制块仍然在内核的管理范围里,结果不可控。结束任务通常有两种方式:任务自己在退出前调用 vTaskDelete(NULL),或者另一个任务拿到它的句柄后调用 vTaskDelete(taskHandle)。
vTaskDelete() 只是把任务从调度器的各种链表里移除,让它不再参与调度。动态创建任务占用的 TCB 和栈内存,一般由空闲任务后续回收。因此空闲任务需要有运行机会。如果系统里一直有高优先级任务死循环运行,不阻塞、不让出 CPU,被删除任务的内存就可能迟迟无法回收。
一、任务状态
FreeRTOS 的任务状态主要包括就绪、运行、阻塞、挂起。
就绪状态:任务已经满足运行条件,只是 CPU 当前还没有分配给它。运行状态就是正在占用 CPU 的那个任务。单核 MCU 上同一时刻只有一个任务处于运行状态,其他可以运行的任务都在就绪队列中等待调度。
阻塞状态:任务暂时让出 CPU,等待某个条件成立。比如调用
vTaskDelay()、vTaskDelayUntil(),或者调用xSemaphoreTake()等 IPC 函数等待事件。任务进入阻塞后,是在等待时间到、队列有数据、信号量释放等条件。条件满足或者等待超时之后,任务会回到就绪状态,等待调度器再次选中。挂起状态:任务被强制暂停。
vTaskSuspend()可以把一个任务挂起,挂起后的任务不参与调度,也不会因为延时到期自动恢复。恢复必须靠vTaskResume(),或者在中断里使用对应的xTaskResumeFromISR()。实际项目里不建议把任务挂起/恢复当成普通同步手段使用,因为它没有队列、信号量那种“事件保留”的语义,可能出现恢复信号先发生、任务后挂起,然后任务一直停住的情况。
一个任务大致会在这些状态之间走:
1 | 创建 -> 就绪 -> 运行 |
二、优先级和调度
FreeRTOS 原生优先级是数字越大优先级越高。最低优先级是 0,空闲任务就在这个优先级。最高能设置到 configMAX_PRIORITIES - 1,这个宏在 FreeRTOSConfig.h 里定义。CubeMX 生成 CMSIS-RTOS V2 工程时,经常会用 osPriority_t 那套枚举,外面看是 osPriorityNormal、osPriorityAboveNormal 这种名字,最后还是会被封装层转换成 FreeRTOS 的任务优先级。
调度器每次选择任务时,基本原则是:先检查有没有更高优先级的就绪任务,有则运行高优先级任务;同优先级任务再根据时间片、主动让出 CPU、阻塞等情况进行切换。
- 抢占式调度:抢占式调度一般是默认配置,也就是
configUSE_PREEMPTION = 1。这种模式下,高优先级任务一旦进入就绪态,不需要等低优先级任务的时间片用完,会直接抢占 CPU。比如低优先级任务正在运行,串口中断释放了一个信号量,唤醒了高优先级处理任务,那么中断退出后调度器就可以切到高优先级任务。
同优先级任务是否按 Tick 轮流运行,取决于 configUSE_TIME_SLICING。如果时间片打开,同优先级就绪任务会在系统节拍里轮转。关掉时间片后,同优先级任务不会因为 Tick 自动互相切换,正在运行的那个任务会一直跑到它阻塞、主动 taskYIELD(),或者被更高优先级任务抢占。
合作式调度:合作式调度是
configUSE_PREEMPTION = 0。这种模式下 FreeRTOS 不主动抢占当前任务,任务切换主要发生在当前任务阻塞或主动调用taskYIELD()的时候。实际工程里更常用抢占式,因为外设事件、通信处理、控制任务的响应时间更好控制。任务优先级和中断优先级:任务优先级和中断优先级不是一套东西。任务再高,也只是线程级执行;中断到来后,CPU 会先执行 ISR。最低优先级的中断也能打断最高优先级的任务。所以 ISR 里面不能处理太重的逻辑,应该尽量只搬数据、清标志、发通知,把真正耗时的处理交给任务。
三、Tick、SysTick 和 PendSV
FreeRTOS 需要一个周期性节拍,通常在 Cortex-M 上用 SysTick 产生。configTICK_RATE_HZ 决定 Tick 频率,常见配置是 1000,也就是 1ms 一个 Tick。xTaskGetTickCount() 读到的就是从调度器启动后累计的 Tick 计数。
SysTick 中断里会做几件事:增加系统 Tick,检查有没有延时到期的任务,把到期任务从阻塞状态移回就绪状态。如果这个过程发现需要切换任务,就会挂起 PendSV。
真正保存和恢复任务上下文的是 PendSV。PendSV 被设置成很低的中断优先级,这样它不会打断更紧急的外设中断。等当前中断都处理完,CPU 再进入 PendSV,在里面完成任务切换。这个设计很关键:SysTick 负责“时间到了没有、要不要切”,PendSV 负责“真的把现场换过去”。
上下文切换时,当前任务的执行现场会保存在自己的任务栈里,下一次再运行时再从栈里恢复。Cortex-M 进入异常时硬件会自动压入一部分寄存器,PendSV 里再保存剩下的寄存器。平时写业务代码不用记这些细节,但要知道任务栈不是摆设,局部变量、函数调用、上下文保存都吃这个空间。
四、任务创建
动态创建用 xTaskCreate():
1 | BaseType_t xTaskCreate( |
返回 pdPASS 表示创建成功。pxCreatedTask 用来拿任务句柄,如果后面不需要操作这个任务,也可以传 NULL。
usStackDepth 的单位不是字节,而是 StackType_t 的个数。STM32F4 这种 32 位平台上,一个 StackType_t 通常是 4 字节,所以 128 大约就是 512 字节栈空间。不能直接按字节去填,否则栈大小会和预期差很多。
静态创建用 xTaskCreateStatic(),需要自己准备栈数组和 TCB 存储:
1 | static StackType_t taskStack[256]; |
静态创建的好处是内存来源明确,不依赖 FreeRTOS 堆;坏处是要自己管理这些静态对象的生命周期。任务句柄 TaskHandle_t 本质上可以理解为指向 TCB 的引用,后面删除、挂起、改优先级、查状态,都靠这个句柄找到具体任务。
五、延时函数
vTaskDelay() 是相对延时。比如:
1 | vTaskDelay(pdMS_TO_TICKS(1000)); |
它的意思是“从调用这一刻开始,阻塞 1000ms 对应的 Tick 数”。如果任务本身执行耗时不固定,整个循环周期也会跟着变化。
比如任务里先采集数据,再 vTaskDelay(1000ms)。如果采集用了 100ms,那么这一轮周期大约是 1100ms;下一轮采集用了 200ms,周期就变成 1200ms。这种写法适合“隔一会儿做一次,不要求严格周期”的场景。
vTaskDelayUntil() 是绝对节拍延时,更适合周期任务:
1 | void TaskSample(void *argument) |
它不是从函数调用时刻往后数 1000ms,而是基于 previousWakeTime 计算下一次应该醒来的节拍。函数返回时会把 previousWakeTime 推进一个周期。这样即使本轮任务逻辑用了 100ms,下次仍然对齐到原来的周期线上,而不是在“执行时间 + 延时时间”上越积越偏。
需要注意的是,如果任务执行时间已经超过周期,比如周期 1000ms,任务自己运行了 1500ms,那么 vTaskDelayUntil() 不会再额外阻塞,通常会直接返回,让任务尽快进入下一轮。这时需要检查任务负载和优先级设计,而不是只看延时函数。
HAL_Delay() 在任务里也可能可以运行,但它不是 FreeRTOS 的阻塞延时。它通常是轮询 HAL 的毫秒 Tick,任务会一直占着 CPU 等时间过去。使用 RTOS 以后,任务里优先用 vTaskDelay() 或 vTaskDelayUntil()。HAL_Delay() 放在高优先级任务或者某些中断上下文里,还可能造成系统响应变差甚至卡死。
六、任务句柄和任务信息
常用的句柄获取函数有三个:
1 | TaskHandle_t xTaskGetCurrentTaskHandle(void); |
xTaskGetCurrentTaskHandle() 用得最多,用来查询当前任务。xTaskGetIdleTaskHandle() 查询空闲任务句柄,需要打开 INCLUDE_xTaskGetIdleTaskHandle。xTaskGetHandle() 可以按任务名称查询,需要打开 INCLUDE_xTaskGetHandle,而且它要遍历任务列表,运行时间不固定,适合调试时使用,不建议在高频业务路径里反复调用。
改任务优先级用:
1 | UBaseType_t uxTaskPriorityGet(TaskHandle_t xTask); |
查自己时句柄可以传 NULL。如果在 CMSIS-RTOS V2 里拿到的是 osPriority_t 枚举,要注意封装层和 FreeRTOS 原生优先级之间的转换,不要把枚举值当成任何项目里都通用的数字。
调试任务状态可以用 vTaskGetInfo():
1 | TaskStatus_t status; |
TaskStatus_t 里比较有用的是任务名、当前状态、当前优先级、基础优先级、运行时间计数、栈高水位等。栈高水位尤其值得看,它表示任务运行到目前为止,栈剩余空间的最小值。这个值太小说明栈快不够了,不是“现在还剩这么多所以一定安全”,而是历史最紧张时还剩这么多。
一些统计函数只适合调试:
1 | UBaseType_t uxTaskGetNumberOfTasks(void); |
vTaskList()、vTaskGetRunTimeStats() 会引入格式化输出相关代码,程序体积会变大,运行时也不轻。正式版本一般不保留。vTaskGetRunTimeStats() 还需要额外配置运行时间统计时钟,不能只开一个宏就指望它自动有精确数据。
UBaseType_t 可以理解成 FreeRTOS 用来表示“无符号基础整数”的类型,常用于优先级、数量、数组大小这些非负值。在 STM32F4 这类 32 位 Cortex-M 上通常是 32 位。它不是 uint8_t,也不是浮点数,不要为了省一点空间乱替换。
七、临界段和暂停调度
taskENTER_CRITICAL() 不是简单的“暂停任务调度”。在 Cortex-M 移植里,它通常通过 BASEPRI 屏蔽一部分中断,保护这段代码不会被 FreeRTOS 可管理的中断和任务切换打断。退出时用 taskEXIT_CRITICAL() 恢复。
1 | taskENTER_CRITICAL(); |
临界段里一定不要调用可能阻塞的函数,比如 vTaskDelay()、带等待时间的队列/信号量函数。阻塞意味着任务要让出 CPU,但临界段还没退出,中断屏蔽状态和内核状态都可能被拖乱。临界段要短,做完共享数据读写就走。
如果只是想暂停任务切换,但仍允许中断运行,用的是另一套函数:
1 | vTaskSuspendAll(); |
这也不能包住会阻塞的 API。调度器被挂起期间,任务不能因为等待对象而正常切走。三者的边界可以这样理解:临界段保护很短的共享访问;暂停调度保护一段不想被其他任务插进来的代码;互斥量保护可能耗时的共享资源。三者不是一回事。
比如 LCD 或 SPI 屏幕刷新,有时不希望一帧数据发一半被另一个任务插进来。这个场景通常优先考虑互斥量保护外设访问。如果只是改几个全局变量,用临界段。DMA 传输本身由外设搬数据,任务调度不会“打断 DMA 硬件搬运”,但任务可能改掉 DMA 正在用的缓冲区,所以真正要保护的是缓冲区生命周期和外设访问顺序。
八、注意事项
任务函数不要 return,退出前自己 vTaskDelete(NULL)。
任务栈单位通常是 word,不是 byte;STM32F4 上 1 word 多数情况下是 4 字节。
高优先级任务醒来会抢占低优先级任务,但任务永远抢不过中断。ISR 太长,整个系统都会变迟钝。
vTaskDelay() 适合普通延时,vTaskDelayUntil() 适合固定周期任务。周期任务超时运行时,延时函数不会替你解决负载问题。
任务删除后的动态内存靠空闲任务回收,所以不要让空闲任务永远没机会运行。
任务里尽量用 FreeRTOS 的阻塞 API,不要拿 HAL_Delay() 当 RTOS 延时函数用。
临界段、暂停调度、互斥量分别解决不同问题。需要避免在临界段或暂停调度期间调用会阻塞的函数。







