4. FreeRTOS任务创建与管理:任务控制块、状态机与延时机制

任务管理是FreeRTOS的核心。说白了,你写的每个任务,系统都得有个“档案”来管着它。这个档案就是任务控制块(TCB)。我刚开始用FreeRTOS时,总觉得TCB是个黑盒子,后来调试一个优先级反转的问题,才真正把它看透了。

4.1 任务控制块(TCB)——任务的“身份证”

每个任务被创建时,FreeRTOS都会分配一个TCB。它存了任务的所有关键信息。你可以把TCB想象成任务的“身份证”。

TCB成员 作用 我的经验
pxTopOfStack 栈顶指针 栈溢出时,这个值会异常
uxPriority 任务优先级 数值越大优先级越高
pxStack 栈起始地址 调试时看这个能定位栈使用量
eTaskState 任务状态 就绪、运行、阻塞、挂起
xStateListItem 状态链表节点 任务切换全靠它
调试小技巧: 在STM32CubeIDE中,你可以直接查看TCB的成员。我习惯在断点停下时,打开Expressions窗口,输入任务句柄->pxTopOfStack,就能看到当前栈指针位置。

4.2 任务状态机——任务的一生

任务不是一直在跑的。它有四种状态:运行、就绪、阻塞、挂起。我画了张图,帮你理清它们的关系。

FreeRTOS任务状态机 运行态 Running 就绪态 Ready 阻塞态 Blocked 挂起态 Suspended 任务切换 vTaskDelay vTaskSuspend 延时到 vTaskResume 状态转换说明 • 运行态 → 就绪态:更高优先级任务就绪,当前任务被抢占 • 运行态 → 阻塞态:调用 vTaskDelay 或等待信号量/队列 • 运行态 → 挂起态:调用 vTaskSuspend • 阻塞态 → 就绪态:延时时间到,或等待的资源可用

你想想看,一个任务刚被创建时,它处于就绪态。调度器一瞅,哎,这个任务优先级最高,那就让它跑。跑着跑着,它调用了vTaskDelay,就进入阻塞态。等延时时间到了,它又回到就绪态,等着下次被调度。

核心要点: 只有就绪态的任务才能被调度器选中进入运行态。阻塞态和挂起态的任务,调度器根本不会考虑它们。

4.3 任务优先级——谁先跑谁后跑

FreeRTOS支持抢占式调度。优先级高的任务,会抢优先级低任务的CPU。数值越大,优先级越高。

我在项目中遇到过一个问题:一个低优先级的任务,因为要处理一个紧急事件,但一直被高优先级任务抢占,导致事件响应超时。后来我用了优先级继承机制才解决。

优先级范围 典型用途 注意事项
最高(configMAX_PRIORITIES-1) 中断级任务、时间关键任务 不要长时间占用CPU
中等 常规控制任务、数据处理 注意优先级反转
最低(0) 后台任务、空闲任务 空闲任务优先级固定为0
避坑指南: 我曾经把两个任务的优先级设成一样,结果它们轮流跑,但有一个任务总是得不到足够的CPU时间。后来我意识到,同优先级任务采用时间片轮转,如果你的任务需要确定性的响应时间,最好用不同的优先级。

4.4 vTaskDelay vs vTaskDelayUntil——延时也有讲究

这两个函数都能让任务阻塞,但用法完全不同。我刚开始用的时候,也搞混过。

4.4.1 vTaskDelay——相对延时

vTaskDelay是相对延时。它让任务阻塞指定的tick数,从调用时开始算起。

void vTaskFunction(void *pvParameters)
{
    for(;;)
    {
        // 执行任务操作
        GPIO_ToggleBits(GPIOA, GPIO_Pin_5);
        
        // 延时1000个tick
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

这里有个坑:如果任务执行时间不稳定,那么两次执行之间的间隔就不固定。比如任务执行花了200ms,延时1000ms,那实际周期就是1200ms。时间长了,误差会累积。

4.4.2 vTaskDelayUntil——绝对延时

vTaskDelayUntil是绝对延时。它保证任务以固定的频率执行。

void vTaskFunction(void *pvParameters)
{
    TickType_t xLastWakeTime = xTaskGetTickCount();
    
    for(;;)
    {
        // 执行任务操作
        GPIO_ToggleBits(GPIOA, GPIO_Pin_5);
        
        // 延时到下一个1000ms时刻
        vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(1000));
    }
}

你想想看,不管任务执行花了多少时间,vTaskDelayUntil都会让任务在固定的时间点醒来。这对于需要精确周期的任务,比如ADC采样、PWM控制,特别有用。

特性 vTaskDelay vTaskDelayUntil
延时类型 相对延时 绝对延时
周期精度 受任务执行时间影响 固定周期
适用场景 非周期性延时、按键消抖 周期性任务、采样、控制
参数 延时tick数 上次唤醒时间指针 + 周期tick数
我的习惯: 对于需要固定周期的任务,我永远用vTaskDelayUntil。比如一个100Hz的PID控制循环,用vTaskDelayUntil能保证每次执行间隔正好是10ms,不会因为某次计算超时而导致周期漂移。

4.5 任务创建实战——从零开始

说了这么多,我们来写一个实际的任务创建代码。

// 任务句柄
TaskHandle_t xTask1Handle = NULL;
TaskHandle_t xTask2Handle = NULL;

// 任务1:LED闪烁,使用vTaskDelay
void vTask1(void *pvParameters)
{
    for(;;)
    {
        HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

// 任务2:精确周期任务,使用vTaskDelayUntil
void vTask2(void *pvParameters)
{
    TickType_t xLastWakeTime = xTaskGetTickCount();
    const TickType_t xFrequency = pdMS_TO_TICKS(100);  // 10Hz
    
    for(;;)
    {
        // 采集传感器数据
        uint32_t adcValue = HAL_ADC_GetValue(&hadc1);
        
        // 处理数据
        ProcessSensorData(adcValue);
        
        // 精确延时
        vTaskDelayUntil(&xLastWakeTime, xFrequency);
    }
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_ADC1_Init();
    
    // 创建任务1:优先级2,栈大小128字
    xTaskCreate(vTask1, "Task1", 128, NULL, 2, &xTask1Handle);
    
    // 创建任务2:优先级3,栈大小256字
    xTaskCreate(vTask2, "Task2", 256, NULL, 3, &xTask2Handle);
    
    // 启动调度器
    vTaskStartScheduler();
    
    // 正常情况下不会执行到这里
    for(;;);
}
关键点: 任务栈大小要合理。我见过有人给任务分配了1024字的栈,但实际只用了几十个字,浪费内存。也见过栈太小导致溢出,系统莫名其妙重启。我的建议是:先用uxTaskGetStackHighWaterMark()查看实际使用量,再调整栈大小。

嗯,到这里,任务创建与管理的核心内容就讲完了。记住:TCB是任务的身份证,状态机决定了任务怎么切换,优先级决定了谁先跑,延时函数选对了能让你的系统更稳定。这些基础打牢了,后面讲任务间通信和同步时,你会觉得轻松很多。