3. RTOS任务模型:FreeRTOS任务创建与管理、任务优先级与调度、任务间通信机制

各位同学,咱们今天聊聊RTOS里的核心——任务模型。说白了,一个嵌入式实时系统,就是一堆任务在抢CPU时间片。你想想看,电机要转、编码器要读、通信要处理,这些事儿如果都挤在一个大循环里,迟早要出乱子。我刚开始做运动控制那会儿,就吃过这个亏。

3.1 任务创建与管理:从零开始造一个“小线程”

在FreeRTOS里,任务就是一个无限循环的函数。创建它,你得给它分配栈空间、指定优先级、传个参数。我个人习惯用xTaskCreate(),简单直接。

// 创建一个控制电机速度的任务
void vMotorControlTask(void *pvParameters) {
    int32_t target_speed = *(int32_t*)pvParameters;
    while(1) {
        // 读取编码器,计算PID,输出PWM
        vTaskDelay(pdMS_TO_TICKS(1)); // 1ms周期
    }
}

// 在main里创建
xTaskCreate(
    vMotorControlTask,    // 任务函数
    "MotorCtrl",          // 任务名(调试用)
    256,                  // 栈深度(字,不是字节!)
    (void*)&speed,        // 参数
    3,                    // 优先级(0最低,configMAX_PRIORITIES-1最高)
    NULL                  // 任务句柄(不需要就NULL)
);

嗯,这里要注意:栈大小别给少了。我在项目中遇到过,一个任务莫名其妙跑飞,查了半天发现是栈溢出。FreeRTOS有个uxTaskGetStackHighWaterMark(),可以帮你看看还剩多少栈空间。我建议每个任务至少留20%的余量。

避坑指南: 我曾经在STM32上把栈设成128字,结果任务里调了个printf,直接崩了。printf的栈消耗比你想象的大得多。运动控制任务里,千万别用printf,用日志队列代替。

3.2 任务优先级与调度:谁先跑,谁后跑?

FreeRTOS用的是抢占式调度。说白了,高优先级的任务一旦就绪,立马抢走CPU。低优先级的?等着吧。

在运动控制里,优先级怎么设?我一般这么分:

任务类型 优先级 典型周期 说明
电流环/位置环 最高(4-5) 50-100μs 硬实时,不能被打断
速度环/插补计算 高(3-4) 1-10ms 周期固定,抖动要小
通信处理(CAN/EtherCAT) 中(2-3) 1-10ms 丢包可以重传,但别太慢
人机界面/日志 低(1-2) 100ms+ 慢点无所谓

你想想看,如果电流环被日志任务打断了,电机可能就抖一下。所以,优先级不是越高越好,而是“够用就行”。我见过有人把所有任务都设成最高优先级,结果系统直接卡死——高优先级任务互相抢,低优先级的永远没机会跑。

核心原则: 实时性要求越高的任务,优先级越高。但别忘了,同优先级的任务用时间片轮转,不同优先级的才用抢占。

3.3 任务间通信机制:让任务们好好说话

任务之间怎么传数据?FreeRTOS给了你几样工具:队列、信号量、互斥量、事件组。我重点说说队列和信号量,运动控制里最常用。

3.3.1 队列:数据搬运工

队列就是个FIFO缓冲区。一个任务往里写,另一个任务往外读。我习惯用它来传递运动指令:

// 定义队列句柄
QueueHandle_t xMotionCmdQueue;

// 创建队列(每个元素是32位整数,队列深度10)
xMotionCmdQueue = xQueueCreate(10, sizeof(int32_t));

// 发送任务(比如上位机发来的目标位置)
void vCmdReceiverTask(void *pvParameters) {
    int32_t target_pos;
    while(1) {
        // 从CAN总线接收指令
        target_pos = receive_from_can();
        // 发送到队列(如果队列满了,等100ms)
        xQueueSend(xMotionCmdQueue, &target_pos, pdMS_TO_TICKS(100));
    }
}

// 接收任务(插补器)
void vInterpolatorTask(void *pvParameters) {
    int32_t cmd;
    while(1) {
        // 从队列读取(如果队列空,阻塞等待)
        if(xQueueReceive(xMotionCmdQueue, &cmd, portMAX_DELAY) == pdPASS) {
            // 执行插补计算
            interpolate_to(cmd);
        }
    }
}

为什么用队列而不是全局变量?因为队列是线程安全的。全局变量?两个任务同时写,数据就乱了。我在项目中遇到过,编码器读数用全局变量传,结果位置环读到的是半新半旧的数据,电机直接震荡。从那以后,所有跨任务的数据我都用队列。

小技巧: 队列的深度别设太大。运动控制里,指令通常是周期性的,队列深度设成2-3就够了。设太大反而浪费内存。

3.3.2 信号量与互斥量:资源锁与事件通知

信号量分两种:二值信号量和计数信号量。二值信号量就像个“旗子”,用来通知事件发生。计数信号量像个“令牌桶”,用来管理资源数量。

互斥量呢?它带优先级继承,能防止优先级反转。我举个例子:

// 共享资源:电机参数结构体
typedef struct {
    float kp;
    float ki;
    float kd;
} MotorParams_t;

MotorParams_t g_motor_params;
SemaphoreHandle_t xParamMutex;

// 初始化互斥量
xParamMutex = xSemaphoreCreateMutex();

// 写参数任务(优先级3)
void vParamWriterTask(void *pvParameters) {
    while(1) {
        xSemaphoreTake(xParamMutex, portMAX_DELAY);
        g_motor_params.kp = 1.5;
        g_motor_params.ki = 0.1;
        // ... 写操作
        xSemaphoreGive(xParamMutex);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

// 读参数任务(优先级2)
void vParamReaderTask(void *pvParameters) {
    float kp;
    while(1) {
        xSemaphoreTake(xParamMutex, portMAX_DELAY);
        kp = g_motor_params.kp;
        xSemaphoreGive(xParamMutex);
        // 使用kp
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

嗯,这里要注意:互斥量的持有时间一定要短。我曾经在互斥量里调了个延时函数,结果高优先级任务被堵住,系统响应变慢。互斥量只用来保护“读-改-写”这种原子操作,别在里面干别的事。

避坑指南: 中断服务函数里,只能用xQueueSendFromISR()xSemaphoreGiveFromISR(),不能用普通版本。我刚开始做时忘了这个,结果中断里调了xQueueSend(),系统直接死机。记住:带FromISR的才是中断安全的。

3.4 知识体系总览

说了这么多,咱们用一张图来总结一下RTOS任务模型的核心逻辑:

RTOS任务模型核心逻辑 任务创建 xTaskCreate() 任务管理 栈/优先级/状态 任务调度 抢占式/时间片 任务间通信机制 队列(Queue) 信号量/互斥量 事件组(Event Group) 图:RTOS任务模型核心逻辑——从创建到通信的完整链路

这张图把咱们今天讲的内容串起来了。任务创建是起点,管理是过程,调度是规则,通信是纽带。你想想看,没有通信机制,任务之间就是孤岛,运动控制根本跑不起来。

好了,今天就到这儿。记住:任务模型是RTOS的骨架,优先级是灵魂,通信是血脉。把这三点吃透了,你的运动控制代码就能跑得稳、跑得快。