admin 管理员组

文章数量: 1184232

用全局变量搞同步总翻车?FreeRTOS队列教你秒变“任务协调大师”!

你有没有过这样的“代码崩溃瞬间”?在FreeRTOS里写多任务,想用全局变量控制串口打印,逻辑明明捋了八遍——“没人用串口我再上”,结果运行起来全乱套:前一秒还在显示“TaskA处理完成”,后一秒突然蹦出“skB开始工作”,半截文字像被吞了似的,活像打字机突然断了电?

别怀疑自己的代码能力,不是你写得烂,是少了个“任务交通指挥官”!今天咱们要聊的FreeRTOS队列,就是专门治“同步混乱”“资源争抢”的神器——不仅能让任务们“排队干活不打架”,还能轻松实现同步与互斥,看完这篇,你再也不用为“打印翻车”“资源抢崩”头疼!

一、先搞懂:为啥全局变量不靠谱?

在聊队列之前,得先说说咱们踩过的坑——全局变量同步。比如之前用flagUARTUsed控制串口:

void TaskGeneralFunction(void *param) {
  while (1) {
    if (!flagUARTUsed) {  // 觉得“没人用串口”
      flagUARTUsed = 1;   // 声称“我要用了”
      printf("%s\r\n", (char *)param);  // 打印内容
      flagUARTUsed = 0;   // 用完释放
      vTaskDelay(1);      // 怕独占,主动延时
    }
  }
}

看着没问题吧?但FreeRTOS是“多任务抢占式”的——刚执行完if (!flagUARTUsed),系统突然切到另一个任务,两个任务同时认为“串口没人用”,都去抢着打印,结果就是文字“叠罗汉”,半截子内容满天飞。

这就像两个同事抢一台打印机,一个刚放好纸,另一个就把文件塞进去,出来的纸肯定是乱的。而队列,就是给打印机装了个“取号机”——按顺序来,绝不插队。

二、队列是啥?一句话讲明白

队列本质就是个“传送带”,或者说“快递柜”:

  • 任务A(生产者)把数据“放”到传送带上,按“先放先拿”的规矩(先进先出,FIFO);
  • 任务B(消费者)从传送带“拿”数据,拿的永远是最早放上去的那一个;
  • 你还得告诉队列两个关键信息:“能放多少个快递”(队列长度)、“每个快递多大”(数据大小)——就像快递柜要标“能放10个件,每个件不超过50cm”。

举个具体例子:

  1. 任务A放数据“10”进队列,此时队列里只有“10”,头和尾都是它;
  2. 任务B再放数据“20”进队列,队列就变成“10(头)→20(尾)”;
  3. 任务C来拿数据,先拿走“10”,队列剩下“20(头)”;
  4. 任务C再拿,就拿走“20”,队列变空。

简单吧?而且队列还支持“插队”(强制写头部),但一般别用——就像食堂排队有人插塞,容易乱套。

三、队列传数据:FreeRTOS选了个“懒人友好”的方式

用队列传数据,有两种常见思路:“拷贝”和“引用”。FreeRTOS直接选了“拷贝”,理由很实在——省心!

啥是“拷贝”?就是把你要传的变量值,完整“复制粘贴”到队列里。比如你在任务A里定义个局部变量x=10,传给队列时,队列会把“10”存一份自己的副本,哪怕任务A的x被回收了(比如函数退出),队列里的“10”还在。

这就像你把手机里的照片复制到U盘——手机里的照片删了,U盘里的还能用,不用操心“原文件没了怎么办”。

反观“引用”(传变量地址),就麻烦多了:如果原变量被回收,地址就成了“无效地址”,队列再读就会“读脏数据”;而且如果系统有内存保护,还得确保两个任务都能访问这个地址,不然直接报错。

所以除非数据特别大(比如几KB的数组),否则优先用FreeRTOS的“拷贝”方式——不用自己管内存,少踩很多坑。

四、队列的“阻塞访问”:任务不用“傻等”

你可能会问:如果任务去读队列,队列是空的咋办?总不能让任务一直循环查“有数据吗?有吗?有吗?”吧?这也太浪费CPU了。

队列的“阻塞访问”就是解决这个问题的——让任务“先歇会儿,有数据再叫你”。具体规则超简单:

  1. 读队列时没数据:任务会进入“阻塞状态”(相当于去“休息室”待着),你可以设个“等多久”(比如等10个Tick)。期间如果有数据进队列,系统立刻叫醒任务(进入就绪状态);要是等超时了,也叫醒任务,别在休息室耗着。
    就像你点外卖,没到的时候不用一直盯着手机(不占CPU),外卖到了(有数据)就去取,等太久(超时)就换家店。
  2. 写队列时队列满了:同理,任务也会进入阻塞状态,等队列有空位了再写数据,不用一直试“能写吗?能吗?”。
  3. 多个任务等同一个队列:如果好几个任务都在等队列的数据,系统会优先叫醒“优先级最高”的任务;如果优先级一样,就叫醒“等最久”的那个——公平又高效。

五、实战第一步:创建队列(两种方式,按需选)

要用电,得先插插座;要用队列,得先“创建”它。FreeRTOS给了两种创建方式:动态分配和静态分配,咱们一个个说。

1. 动态创建:“点外卖式”,不用自己准备内存

动态创建就像点外卖——不用自己准备餐具(内存),系统帮你分配好,用完还能回收。核心函数是xQueueCreate,原型长这样:

QueueHandle_t xQueueCreate(
  UBaseType_t uxQueueLength,  // 队列长度:能放多少个数据
  UBaseType_t uxItemSize      // 每个数据的大小:单位是字节
);

返回值很关键:如果返回非NULL,说明队列创建成功(拿到“队列句柄”,以后操作队列全靠它);如果返回NULL,就是内存不够了(系统“没餐具了”)。

举个例子,创建一个“能放2个int类型数据”的队列:

// 先定义一个“队列句柄”,相当于队列的“身份证”
static QueueHandle_t xQueueCalcHandle;

// 创建队列:长度2,每个数据是int(4字节)
xQueueCalcHandle = xQueueCreate(2, sizeof(int));

// 检查是否创建成功
if (xQueueCalcHandle == NULL) {
  printf("队列创建失败!内存不够啦~\n");
}
2. 静态创建:“自带饭盒式”,内存自己管

静态创建就像去食堂打饭——得自己带饭盒(提前分配好内存),系统不帮你管内存,但胜在稳定(不会因为内存不足创建失败)。核心函数是xQueueCreateStatic,原型:

QueueHandle_t xQueueCreateStatic(
  UBaseType_t uxQueueLength,        // 队列长度
  UBaseType_t uxItemSize,          // 每个数据大小
  uint8_t *pucQueueStorageBuffer,  // 存储数据的缓冲区(“饭盒”)
  StaticQueue_t *pxQueueBuffer     // 存储队列结构的缓冲区(“装饭盒的袋子”)
);

参数要注意:pucQueueStorageBuffer的大小必须≥“队列长度×每个数据大小”,不然数据存不下;pxQueueBuffer必须指向一个StaticQueue_t结构体,用来存队列的“管理信息”。

举个例子,创建一个“能放10个uint32_t类型数据”的静态队列:

// 定义队列参数:长度10,每个数据是uint32_t(4字节)
#define QUEUE_LENGTH 10
#define ITEM_SIZE sizeof(uint32_t)

// 准备“饭盒”:存数据的缓冲区,大小=10×4=40字节
uint8_t ucQueueStorage[QUEUE_LENGTH * ITEM_SIZE];

// 准备“装饭盒的袋子”:存队列结构
StaticQueue_t xQueueBuffer;

// 在任务里创建静态队列
void vATask(void *pvParameters) {
  QueueHandle_t xQueue1;
  // 传入参数:长度、数据大小、缓冲区、队列结构
  xQueue1 = xQueueCreateStatic(QUEUE_LENGTH, ITEM_SIZE, ucQueueStorage, &xQueueBuffer);
  
  // 检查是否成功:只要pxQueueBuffer不为NULL,一般都能成功
  if (xQueue1 == NULL) {
    printf("队列创建失败!袋子没准备好~\n");
  }
}
小补充:复位和删除队列
  • 复位队列:如果想把队列恢复到“刚创建时的空状态”,用xQueueReset(队列句柄),比如xQueueReset(xQueueCalcHandle),肯定能成功(返回pdPASS)。
  • 删除队列:只能删“动态创建”的队列(静态队列内存自己管,系统不删),用vQueueDelete(队列句柄),比如vQueueDelete(xQueueCalcHandle),删完后句柄就无效了,别再用了。

六、实战第二步:写队列(把数据放进“传送带”)

创建好队列,就可以往里面“放数据”了。核心函数有两个:

  • xQueueSendToBack:把数据放到队列“尾部”,按FIFO规矩来(推荐用,符合直觉);
  • xQueueSendToFront:把数据放到队列“头部”(插队,特殊场景用,比如紧急数据);

注意:这两个函数不能在中断里用!中断里要用带FromISR后缀的版本(比如xQueueSendToBackFromISR),咱们后面再聊中断。

函数原型和参数(以xQueueSendToBack为例)
BaseType_t xQueueSendToBack(
  QueueHandle_t xQueue,          // 队列句柄:要写哪个队列
  const void *pvItemToQueue,     // 数据指针:要传的数据(地址)
  TickType_t xTicksToWait        // 阻塞时间:队列满了等多久(Tick数)
);

参数解释:

  • pvItemToQueue:你要传的数据的地址,队列会从这个地址“拷贝”数据进去(比如你要传sum,就传&sum);
  • xTicksToWait:如果队列满了,任务会阻塞多久。设0就是“不等,立刻返回”;设portMAX_DELAY就是“一直等,直到有位置”(别在低优先级任务用,容易卡死)。

返回值:pdPASS表示写成功;errQUEUE_FULL表示队列满了,等超时了还没位置。

例子:任务A往队列写累加值

比如任务A负责计算累加值sum,算完就往队列里写:

static volatile int sum = 0;  // 累加值
static QueueHandle_t xQueueCalcHandle;  // 队列句柄(提前创建好)

void app_task1(void* pvParameters) {
  for (;;) {
    sum++;  // 计算累加值
    // 往队列尾部写sum,没位置就一直等(portMAX_DELAY)
    xQueueSendToBack(xQueueCalcHandle, &sum, portMAX_DELAY);
    sum = 1;  // 重置sum,准备下一次计算
  }
}

七、实战第三步:读队列(从“传送带”拿数据)

写了数据,就得有任务来“读”。核心函数是xQueueReceive,专门从队列头部拿数据,拿完后数据会从队列里删掉(给下一个数据腾位置)。

函数原型和参数
BaseType_t xQueueReceive(
  QueueHandle_t xQueue,        // 队列句柄:要读哪个队列
  void *const pvBuffer,        // 缓冲区指针:把读的数据存到这里
  TickType_t xTicksToWait      // 阻塞时间:队列空了等多久(Tick数)
);

参数解释:

  • pvBuffer:你要存数据的变量地址(比如int val,就传&val),队列会把数据“拷贝”到这个变量里;
  • xTicksToWait:和写队列一样,0是“不等”,portMAX_DELAY是“一直等”。

返回值:pdPASS表示读成功;errQUEUE_EMPTY表示队列空了,等超时了还没数据。

例子:任务B从队列读数据并处理

任务B负责读队列里的累加值,没数据就歇着,有数据就处理(比如打印):

void app_task2(void* pvParameters) {
  int val;  // 存读出来的数据
  for (;;) {
    // 从队列读数据,存到val里,没数据就一直等
    xQueueReceive(xQueueCalcHandle, &val, portMAX_DELAY);
    // 处理数据(比如打印)
    printf("拿到的累加值是:%d\n", val);
  }
}

八、用队列实现“同步”:任务们“默契配合”

同步的意思是“任务A做完某件事,再通知任务B开始做”。比如任务A算完累加值,再让任务B打印——用队列就能轻松实现,就是咱们上面的例子:

  1. 队列刚创建时是空的,任务B读队列会进入阻塞状态(不占CPU);
  2. 任务A算完sum,往队列里写数据,此时系统会立刻叫醒任务B(因为任务B在等队列数据);
  3. 任务B从阻塞变就绪,然后抢占CPU,读数据并打印;
  4. 打印完,任务B再去读队列,此时队列又空了,再次阻塞——完美配合,不浪费资源。

这就像你煮完饭(任务A),喊家人来吃饭(写队列),家人听到再过来(任务B读队列),不用你一直喊“饭好了吗”,也不用家人一直等。

九、用队列实现“互斥”:资源“一人用,其他人等”

互斥的意思是“某个资源(比如串口、打印机),同一时间只能有一个任务用”。用队列也能实现,核心思路是“给资源配一把‘钥匙’,拿钥匙才能用,用完还钥匙”。

步骤:用队列实现串口互斥
  1. 初始化“钥匙”:创建一个长度为1的队列(相当于“钥匙柜”,只能放1把钥匙),并往队列里写一个任意数据(相当于“放入钥匙”);

    QueueHandle_t xQueueUARTHandle;  // 串口队列句柄
    
    int InitUARTLock(void) {
      // 创建长度1、数据大小为int的队列(钥匙柜)
      xQueueUARTHandle = xQueueCreate(1, sizeof(int));
      if (xQueueUARTHandle == NULL) {
        return -1;  // 创建失败
      }
      int val = 0;  // 任意数据(钥匙)
      // 把钥匙放进队列
      xQueueSend(xQueueUARTHandle, &val, portMAX_DELAY);
      return 0;  // 初始化成功
    }
    
  2. 拿钥匙(用串口前):任务要用水口时,先从队列里读数据(拿钥匙)。因为队列只有1个数据,第一个任务拿走后,队列就空了,其他任务再读会阻塞(拿不到钥匙,只能等);

    void GetUARTLock(void) {
      int val;
      // 读队列拿钥匙,拿不到就一直等
      xQueueReceive(xQueueUARTHandle, &val, portMAX_DELAY);
    }
    
  3. 还钥匙(用完串口后):任务用完串口,往队列里写一个数据(还钥匙)。此时队列有数据了,阻塞的任务就能拿到钥匙,开始用串口;

    void PutUARTLock(void) {
      int val = 0;
      // 写队列还钥匙
      xQueueSend(xQueueUARTHandle, &val, portMAX_DELAY);
    }
    
  4. 任务用串口:每个要用串口的任务,都要先“拿钥匙”,再用串口,最后“还钥匙”;

    void TaskGeneralFunction(void *param) {
      while (1) {
        GetUARTLock();  // 拿钥匙,没钥匙就等
        printf("%s\r\n", (char *)param);  // 用串口打印
        PutUARTLock();  // 还钥匙,让其他任务用
        vTaskDelay(1);  // 主动延时,给其他任务机会
      }
    }
    

这样一来,串口就像有了“门禁”,同一时间只有一个任务能拿到“钥匙”,再也不会出现“打印翻车”的情况——比全局变量靠谱100倍!

十、总结:队列是FreeRTOS的“万能通信工具”

今天咱们把队列的“底细”摸透了:从解决全局变量的坑,到队列的基本概念、阻塞访问,再到创建、写、读队列,最后用队列实现同步与互斥——其实核心就是“按顺序传数据,让任务们有秩序地干活”。

下次再遇到多任务同步、资源争抢的问题,别再死磕全局变量了,掏出队列这个“神器”,保准让你的代码从“乱糟糟”变“井井有条”。

下一篇咱们再聊“队列集”——多个队列怎么协同工作,让任务通信更灵活。要是这篇里有哪个点没搞懂,回头多敲几遍代码,实践出真知,FreeRTOS没那么难!

本文标签: 翻车 教你 队列 全局变量 大师