49、RTOS任务管理:任务创建、调度、同步,FreeRTOS源码分析
RTOS,说白了就是让单片机学会「多线程思考」。我早年做裸机开发时,一个while循环里塞满各种标志位,代码改起来跟拆炸弹似的。后来切到FreeRTOS,才明白什么叫「任务即函数,调度即人生」。
今天咱们就掰开揉碎,聊聊FreeRTOS的任务管理。我会带着你从API使用一路杀到源码实现,中间穿插几个我踩过的坑。
任务创建:从函数到线程的蜕变
任务在FreeRTOS里,本质上就是一个永不返回的C函数。你想想看,一个while(1)循环,加上适当的阻塞,就成了一个独立的任务。
void vTask1(void *pvParameters)
{
while(1)
{
// 干活
vTaskDelay(pdMS_TO_TICKS(100));
}
}
创建任务的API是xTaskCreate()。它的原型长这样:
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, // 任务函数指针
const char * const pcName, // 任务名(调试用)
configSTACK_DEPTH_TYPE usStackDepth, // 栈深度,单位是字
void *pvParameters, // 参数
UBaseType_t uxPriority, // 优先级
TaskHandle_t *pxCreatedTask // 任务句柄
);
这里有个坑——栈深度。我见过新手把栈设成32,结果任务一跑就崩。为什么?因为任务切换时要保存上下文,浮点寄存器一压栈,32个字根本不够用。
我曾经在STM32F4上调试一个任务,栈深度设了128,结果跑着跑着就HardFault。查了两天才发现,任务里用了一个递归函数,栈溢出了。后来我养成了习惯:每个任务至少给256字的栈,复杂任务给512以上。
任务调度:谁先跑,谁后跑?
FreeRTOS用的是抢占式调度,加上时间片轮转。说白了就是:高优先级的任务随时可以打断低优先级的;同优先级的任务,大家轮流跑一个时间片。
调度器的核心数据结构是就绪列表。每个优先级对应一个链表,链着所有处于就绪态的任务。
// FreeRTOS源码中的就绪列表
PRIVILEGED_DATA static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
调度发生时,系统从最高优先级的链表里取出第一个任务,切换过去。这个过程叫上下文切换。
上下文切换干了三件事:
- 保存当前任务的寄存器到它的栈里
- 找到下一个要运行的任务
- 从新任务的栈里恢复寄存器
嗯,这里要注意。上下文切换是由PendSV中断触发的。为什么不用SysTick直接切?因为SysTick优先级太高,如果在临界区里被打断,数据就乱套了。PendSV是「最低优先级中断」,等所有高优先级中断处理完了,它才干活。
我个人习惯:在任务里尽量少用taskYIELD()主动让出CPU。除非你明确知道下一个该谁跑,否则让调度器自己决定,反而更高效。
任务同步:信号量与互斥量
多任务最头疼的问题就是共享资源。两个任务同时写一个全局变量,结果就是数据错乱。
FreeRTOS提供了几种同步机制:
| 机制 | 适用场景 | 特点 |
|---|---|---|
| 二值信号量 | 任务与中断同步 | 只能取0或1,适合「事件发生」通知 |
| 计数信号量 | 资源池管理 | 可以计数,适合「还有几个空位」 |
| 互斥量 | 保护共享资源 | 带优先级继承,防止优先级反转 |
| 队列 | 任务间数据传递 | 先进先出,支持多生产者多消费者 |
我重点说说互斥量。它和信号量最大的区别是:谁拿了谁释放。而且互斥量有优先级继承机制。
什么是优先级反转?举个例子:
- 任务A(高优先级)和任务C(低优先级)共享一个互斥量
- 任务C先拿到了互斥量,然后被任务B(中优先级)抢占了
- 任务A想拿互斥量,但被任务C占着,而任务C又被任务B抢占了
- 结果:高优先级的A,被中优先级的B「反转」了
互斥量的优先级继承,就是当高优先级任务等互斥量时,临时把持有者的优先级提升到和自己一样。这样任务C就能尽快跑完,释放互斥量。
避坑指南:中断服务函数里绝对不要用互斥量。因为互斥量的优先级继承需要任务上下文,中断里没有。中断里只能用信号量或队列的FromISR版本。
FreeRTOS源码分析:任务切换的幕后
咱们看看调度器到底怎么工作的。核心函数是vTaskSwitchContext():
void vTaskSwitchContext( void )
{
if( uxSchedulerSuspended != 0 )
{
// 调度器被挂起,标记需要切换
xYieldPending = pdTRUE;
return;
}
// 找到最高优先级的就绪任务
taskSELECT_HIGHEST_PRIORITY_TASK();
}
taskSELECT_HIGHEST_PRIORITY_TASK()是个宏,展开后:
#define taskSELECT_HIGHEST_PRIORITY_TASK() \
{ \
UBaseType_t uxTopPriority = uxTopReadyPriority; \
// 从就绪列表中找到第一个非空链表 \
while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) ) \
{ \
uxTopPriority--; \
} \
// 取出链表头部的任务控制块 \
pxCurrentTCB = listGET_OWNER_OF_HEAD_ENTRY( &( pxReadyTasksLists[ uxTopPriority ] ) ); \
}
你看,它从最高优先级往下找,找到第一个有任务的优先级,然后取出链表头的任务。这就是「最高优先级任务先运行」的实现。
那时间片轮转呢?在xTaskIncrementTick()里:
if( xConstTickCount == xNextTaskUnblockTime )
{
// 检查是否有阻塞超时的任务,移到就绪列表
prvCheckTasksWaitingTermination();
prvCheckDelayedTasks();
}
// 时间片轮转:如果当前任务跑完了一个时间片
#if ( configUSE_TIME_SLICING == 1 )
{
if( listCURRENT_LIST_LENGTH( &( pxReadyTasksLists[ pxCurrentTCB->uxPriority ] ) ) > 1 )
{
// 把当前任务移到链表尾部,让下一个同优先级任务跑
taskYIELD();
}
}
#endif
嗯,这里有个细节。时间片轮转只在同优先级有多个任务时才触发。如果当前优先级只有一个任务,它就一直跑,不会被切走。
我在项目中遇到过:一个通信任务优先级设得比较高,里面有个while循环在等数据。结果它把CPU占死了,低优先级的界面任务根本跑不起来。后来我给通信任务加了个vTaskDelay(1),问题解决。记住:高优先级任务一定要有阻塞点,否则低优先级任务会「饿死」。
任务状态机:从生到死
每个任务在FreeRTOS里都有四种状态:
- 运行态:正在使用CPU
- 就绪态:能跑,但CPU被别的任务占着
- 阻塞态:在等某个事件(延时、信号量、队列)
- 挂起态:被
vTaskSuspend()暂停,只能由vTaskResume()恢复
状态转换图我画了个SVG,你一看就明白:
你看,任务在四个状态间流转。核心思想就是:能跑的任务排队等CPU,不能跑的任务去睡觉。
任务控制块:任务的身份证
每个任务在FreeRTOS里都有一个TCB_t结构体,里面存着任务的所有信息:
typedef struct tskTaskControlBlock
{
volatile StackType_t *pxTopOfStack; // 栈顶指针
ListItem_t xStateListItem; // 状态链表节点
ListItem_t xEventListItem; // 事件链表节点
UBaseType_t uxPriority; // 当前优先级
StackType_t *pxStack; // 栈底指针
char pcTaskName[ configMAX_TASK_NAME_LEN ]; // 任务名
// ... 还有更多字段
} tskTCB;
pxTopOfStack是最关键的字段。它指向当前任务的栈顶。上下文切换时,CPU的寄存器就保存在这个栈里。切换回来时,从栈里恢复。
我当年看源码时,最震撼的就是这个设计——用栈来保存任务上下文。每个任务有自己的栈,切换时只需要改pxTopOfStack和PSP(进程栈指针),效率极高。
我个人习惯:调试时给每个任务起个有意义的名字,比如"CommTask"、"SensorTask"。这样在调试器里看任务列表,一眼就知道哪个任务在跑。别用"Task1"、"Task2",三个月后你自己都忘了哪个是哪个。
实战建议:任务划分的黄金法则
任务怎么划分?我总结了几条经验:
- 按功能划分:一个功能一个任务。比如通信、显示、传感器采集各一个任务。
- 按实时性划分:实时性要求高的给高优先级,比如电机控制;实时性低的给低优先级,比如日志打印。
- 按触发源划分:周期性任务用
vTaskDelayUntil(),事件驱动型任务用信号量或队列等待。 - 不要过度拆分:任务太多,上下文切换开销就大。一般单片机系统,5-10个任务就够了。
我曾经接手过一个项目,里面建了30多个任务。每个任务就干一点点活,然后切走。结果CPU大部分时间都在切任务,真正干活的时间不到20%。后来我合并成8个任务,性能直接翻倍。
记住:任务不是越多越好,够用就行。
好了,关于FreeRTOS的任务管理,咱们就聊到这儿。从任务创建到调度机制,从同步手段到源码实现,核心就一句话:任务就是函数,调度就是排队,同步就是等信号。你把这些想明白了,RTOS就不再神秘。