第4章:任务间通信-队列

队列这东西,说白了就是RTOS里的「消息管道」。我刚开始做运动控制时,总觉得全局变量传参挺方便的,直到有一次多轴联动时数据被意外覆盖,电机直接「抽风」了...嗯,从那以后我再也不敢轻视队列了。

4.1 队列的工作原理

队列本质上是一个先进先出(FIFO)的数据缓冲区。你可以把它想象成一个管道——数据从一头塞进去,从另一头取出来。RTOS内核负责管理这个管道的读写指针、数据存储和同步机制。

核心要点:队列是「生产者-消费者」模型的典型实现。一个任务往里写,另一个任务往外读,中间由RTOS保证数据完整性和同步。

我画了一张队列工作的流程图,你看一眼就明白了:

生产者任务 xQueueSend 队列缓冲区 指令1 指令2 指令3 ↑ 写指针 ↑ 读指针 xQueueReceive 消费者任务 队列长度 = 4(可存储4条指令) 当前已用 = 3,空闲 = 1 关键特性:阻塞等待 | 超时机制 | 多任务安全 | 数据拷贝

队列内部维护了两个关键指针:写指针读指针。写指针指向下一个可存放数据的位置,读指针指向下一个可读取数据的位置。当两个指针相遇时,队列要么是空的,要么是满的——RTOS通过一个计数器来区分这两种状态。

我的经验:队列的深度设置很讲究。设太小容易溢出,设太大浪费内存。我一般按「最大突发数据量 × 1.5」来估算。比如运动指令最多一次发20条,我就设30。留点余量,但别太奢侈。

4.2 队列的创建与发送/接收消息

在FreeRTOS中,队列的操作API非常直观。我带你过一遍最常用的几个函数:

4.2.1 创建队列

// 创建一个能存放10条运动指令的队列
// 每条指令大小 = sizeof(MotionCmd_t)
QueueHandle_t xMotionQueue;

xMotionQueue = xQueueCreate(10, sizeof(MotionCmd_t));

if (xMotionQueue == NULL) {
    // 创建失败,通常是内存不足
    // 我遇到过堆空间不够的情况,后来把堆大小从4096改到8192就好了
    Error_Handler();
}

xQueueCreate有两个参数:队列长度和每个元素的大小。注意,队列内部是拷贝数据而不是传递指针。这意味着你发送的是数据的副本,原始数据怎么改都不影响队列里的内容。

4.2.2 发送消息

// 从ISR中发送(中断服务函数里要用这个)
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xQueueSendFromISR(xMotionQueue, &cmd, &xHigherPriorityTaskWoken);

// 从任务中发送
// 第三个参数是等待时间:如果队列满了,等多久?
xQueueSend(xMotionQueue, &cmd, pdMS_TO_TICKS(10));

注意:中断服务函数里绝对不能xQueueSend(不带FromISR后缀的版本)。我曾经在一个定时器中断里误用了普通版本,结果系统直接死机。调试了两天才发现是这个问题。

4.2.3 接收消息

MotionCmd_t receivedCmd;

// 阻塞等待,直到收到指令
// 第二个参数是超时时间:portMAX_DELAY表示无限等待
if (xQueueReceive(xMotionQueue, &receivedCmd, portMAX_DELAY) == pdPASS) {
    // 成功收到指令,开始执行运动控制
    ExecuteMotion(&receivedCmd);
}

接收时,如果队列为空,任务会进入阻塞状态。RTOS会自动把CPU让给其他就绪的任务。一旦有数据入队,内核会立即唤醒等待的任务。这就是RTOS的「事件驱动」机制——比轮询高效得多。

4.3 队列在运动指令传递中的应用

运动控制系统中,队列扮演着「指令缓冲器」的角色。我一般这样设计架构:

层级 任务 职责 队列角色
上层 指令解析任务 解析上位机指令,生成运动参数 生产者
中层 运动规划任务 插补计算、速度规划 消费者+生产者
底层 执行器任务 驱动电机、读取编码器 消费者

你看,队列可以串联多个任务。指令解析任务把原始指令塞进队列,运动规划任务取出来做插补,再把插补后的微步指令塞进另一个队列,最后执行器任务取出来驱动电机。这种「流水线」架构,每个任务各司其职,互不干扰。

关键设计原则:每个队列只服务于一对生产者和消费者关系。不要试图用一个队列传递多种类型的数据。我见过有人把运动指令和状态反馈塞进同一个队列,结果解析逻辑乱成一团麻。

4.4 实战:多轴指令队列

好了,理论说够了,咱们直接上代码。这是一个四轴运动控制系统的指令队列实现:

// 运动指令结构体
typedef struct {
    uint8_t  ucAxisID;      // 轴ID: 0-3
    uint8_t  ucCmdType;     // 指令类型: 0=绝对定位, 1=相对定位, 2=急停
    int32_t  lTargetPos;    // 目标位置(脉冲数)
    uint32_t ulVelocity;    // 速度(脉冲/秒)
    uint32_t ulAccel;       // 加速度(脉冲/秒²)
} MotionCmd_t;

// 每个轴独立一个队列
#define AXIS_COUNT  4
QueueHandle_t xAxisQueue[AXIS_COUNT];

// 初始化所有轴的队列
void vInitMotionQueues(void) {
    for (int i = 0; i < AXIS_COUNT; i++) {
        xAxisQueue[i] = xQueueCreate(20, sizeof(MotionCmd_t));
        configASSERT(xAxisQueue[i] != NULL);
    }
}

// 发送指令到指定轴
BaseType_t xSendMotionCmd(uint8_t ucAxis, MotionCmd_t *pxCmd) {
    if (ucAxis >= AXIS_COUNT) return pdFALSE;
    
    // 队列满时等待50ms
    return xQueueSend(xAxisQueue[ucAxis], pxCmd, pdMS_TO_TICKS(50));
}

// 轴控制任务:从队列取指令并执行
void vAxisControlTask(void *pvParameters) {
    uint8_t ucAxis = (uint8_t)(uint32_t)pvParameters;
    MotionCmd_t xCmd;
    
    while (1) {
        // 阻塞等待指令,无限等待
        if (xQueueReceive(xAxisQueue[ucAxis], &xCmd, portMAX_DELAY) == pdPASS) {
            switch (xCmd.ucCmdType) {
                case 0: // 绝对定位
                    vMoveToAbsolute(ucAxis, xCmd.lTargetPos, 
                                    xCmd.ulVelocity, xCmd.ulAccel);
                    break;
                case 1: // 相对定位
                    vMoveRelative(ucAxis, xCmd.lTargetPos,
                                  xCmd.ulVelocity, xCmd.ulAccel);
                    break;
                case 2: // 急停
                    vEmergencyStop(ucAxis);
                    break;
                default:
                    break;
            }
        }
    }
}

避坑指南:我曾经在队列满时用了 portMAX_DELAY 做无限等待,结果某个轴卡住了,其他轴还在发指令,整个系统就僵死了。后来我改成有限等待 + 重试机制,问题解决。记住:别让一个轴的故障拖垮整个系统

这个实战案例中,每个轴都有自己的指令队列。这样做的好处是:

  • 隔离性:一个轴的指令不会影响其他轴
  • 可扩展性:增加轴数只需创建新队列和任务
  • 调试方便:可以单独监控每个队列的状态

你还可以用 uxQueueMessagesWaiting() 来查询队列中待处理的指令数,实现流量控制。比如当某个轴的队列积压超过15条时,暂停发送新指令,防止溢出。

总结一下:队列是RTOS任务间通信的基石。在运动控制中,它让指令的「生产」和「消费」解耦,让系统更健壮、更可预测。记住三个要点:队列深度要合理、中断中要用FromISR版本、别让一个队列成为瓶颈。


公众号:蓝海资料掘金营,微信deep3321