做过多任务开发的朋友应该都有体会:任务创建好了、调度跑起来了,看似一切正常,可一旦要把数据从采集任务交给处理任务,或者在中断里通知某个任务去干活,第一反应往往是写一个全局变量加一个标志位。我最早就是这么干的,结果项目一复杂,全局变量被到处改,调试器里根本看不出是谁动了它。后来把 FreeRTOS API 里的队列、信号量、互斥量、事件组这套组合拳用起来,任务之间的协作才算真正顺了。这篇文章是系列第二篇,继续聊 FreeRTOS API 的使用,重点放在任务间通信与同步,适合已经会创建任务、会使用延时与调度器的开发者。
简单说,这一篇不追求把 API 列表背完,而是把“什么时候该用哪个、参数怎么填、填错了会出现什么现象”讲透。你会发现,队列和信号量其实比想象中简单,但真正容易出问题的地方都在细节里:超时参数填 0 还是填最大值、中断里该不该调用普通版本、互斥量为什么不能用于中断……这些坑我都踩过,下面逐个拆开讲。
1. 从任务创建到任务通信:第二篇到底该学什么
1.1 先看清第一篇的边界
大多数 FreeRTOS 入门资料都会先讲任务创建、删除、挂起、恢复,还有 vTaskDelay 这类延时函数。这些属于“单任务视角”:你让一个任务诞生,告诉它什么时候跑、跑完怎么延时,然后启动调度器。这时候系统能跑,但任务之间是相互隔离的,各做各的事。
进入实际项目后,几乎没有一个系统是单任务自娱自乐的。比如一个环境监测设备,传感器采集任务负责读数据,显示任务负责把数据刷到屏幕上,通信任务负责把数据打包发出。采集任务拿到数据以后,怎么交给显示任务?显示任务怎么知道“有新数据了”?通信任务和显示任务都要用同一个缓冲区,怎么避免互相覆盖?这些才是第二篇真正要解决的问题。
所以,第二篇的边界非常清晰:不再研究“单个任务怎么跑”,而是研究“多个任务之间怎么协作”。协作的基础就是 FreeRTOS 提供的一组 API——队列、信号量、互斥量、事件组。把它们掌握好,等于给任务之间拉好了“电话线”和“交通规则”。
1.2 通信与同步的三种典型诉求
我习惯把任务协作的需求拆成三类,后面所有 API 基本都是围绕这三类展开的。
第一类是数据搬运。一个任务产生数据,另一个任务消费数据,典型场景是采集任务读到传感器数值后交给处理任务。这类需求首选队列,因为队列天然适合“生产者-消费者”模型,并且自带缓冲和阻塞机制,不会丢数据(除非队列满了)。
第二类是事件通知。外设中断里发生了某件事,比如按键按下、串口收到一个字节、定时器到期,你希望某个任务赶紧去处理。这类需求用二值信号量,或者用事件组做更复杂的多条件判断。信号量在这里扮演的角色是“门铃”,它不搬数据,只管叫人。
第三类是资源互斥。两个任务都要操作同一个串口、同一个 LCD、同一片 Flash,如果同时操作,数据就乱套了。这类需求用互斥量,它保证“同一时间只有一个任务在使用这份资源”。
理清这三类诉求后再去翻 API,你会发现 FreeRTOS 的设计逻辑非常清晰。很多新手拿到题目就开始敲代码,结果把队列、信号量混着用,项目不崩才怪。正确做法是先回答一个问题:我现在是搬数据、叫任务,还是抢资源?
2. 队列API:任务之间传数据,先搞懂快递柜
2.1 队列的创建、删除与内存来源
队列的本质是内核维护的一块缓冲区,发送方把数据寄进去,接收方从里面取出来。FreeRTOS 的队列在创建时就要指定两件事:能装多少个数据项(长度),每个数据项多大(字节数)。于是队列内部相当于一个环形数组,再加上等待队列来管理阻塞的任务。
创建 API 是 xQueueCreate,原型简单:
QueueHandle_t xQueueCreate(UBaseType_t uxQueueLength, UBaseType_t uxItemSize);返回一个句柄,创建失败返回 NULL。两个参数看起来简单,但有一个细节很容易忽略:uxItemSize 是按“字节拷贝”来存储数据的,也就是说发送方传的是一个指针,队列内部会把指针指向的内容复制一整份进队列缓冲区,而不是存指针本身。如果存一个 uint16_t 采样值,uxItemSize 就填 sizeof(uint16_t);如果要传一个结构体,就填 sizeof(结构体),入队时传结构体指针即可。
队列使用的内存来自 FreeRTOS 的堆。如果创建时返回 NULL,优先查 configTOTAL_HEAP_SIZE 是否够用,或者查一下创建时参数是否合法。不用队列时可以调用 vQueueDelete 删除并释放内存,不过实际项目里队列一旦创建基本会伴随整个生命周期,很少会动态删。
QueueHandle_t xSensorQueue = xQueueCreate(10, sizeof(uint16_t)); if (xSensorQueue == NULL) { // 队列创建失败,处理错误 // 常见原因:堆内存不足、参数非法 }2.2 发送与接收:超时参数决定任务“等多久”
队列创建完成后,核心操作就四个:发送到队尾、发送到队首、接收、窥探。常用的发送 API 有三个,但参数几乎一样:
- xQueueSend:等价于 xQueueSendToBack,数据从队尾进入
- xQueueSendToFront:数据从队首插入,一般用于紧急数据
- xQueueReceive:从队首取数据,取完数据就从队列里移除
它们的第一个参数都是队列句柄,第二个参数是待发送/接收数据的指针,第三个参数是超时时间。超时时间这个参数非常重要,很多人在这里翻车。
第三个参数的单位是“tick 数”,不是毫秒。FreeRTOS 提供了 pdMS_TO_TICKS 宏,可以把毫秒转换成 tick,比如 pdMS_TO_TICKS(100) 表示 100 毫秒。当你调用 xQueueSend 时,如果队列是满的,任务就会阻塞,阻塞最长时间由第三个参数决定;0 表示不等待,队列满就立即返回;portMAX_DELAY 表示一直等下去,直到发出去为止。
// 发送:队列满时最多等 100ms if (xQueueSend(xSensorQueue, &sample, pdMS_TO_TICKS(100)) != pdPASS) { // 发送失败,说明 100ms 内队列一直是满的 // 需要调整队列长度,或者检查接收任务是否被卡死 }接收同理。xQueueReceive 在队列空时会阻塞等待,超时参数同样决定等待上限。最经典的组合是:发送方用有限超时,接收方用 portMAX_DELAY 一直等待。这样接收任务一旦没数据就休眠,不占 CPU;发送方如果发不出去,还能感知到队列满,从而做策略调整(比如丢弃数据、记录错误)。
2.3 中断中使用队列:xQueueSendFromISR 与 pxHigherPriorityTaskWoken
中断里发送队列是嵌入式开发的必修课。为什么要单独讲?因为 xQueueSend 等普通 API 内部可能让任务进入阻塞态,而中断上下文里不允许阻塞。FreeRTOS 专门提供了一套 FromISR 版本,最常用的是:
BaseType_t xQueueSendFromISR(QueueHandle_t xQueue, const void *pvItemToQueue, BaseType_t *pxHigherPriorityTaskWoken);第三个参数是个输出型参数,这一点非常关键。中断里发送队列后,如果发现有更高优先级的任务因为接收这个数据而被唤醒,系统需要立刻执行一次任务切换,否则高优先级任务可能得不到及时运行。正确做法是:调用前把变量初始化为 pdFALSE,调用后判断它是否为 pdTRUE,如果为真,就调用 vPortYieldFromISR 触发上下文切换。
void UART_ISR(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; char c = get_char_from_uart(); xQueueSendFromISR(xUartQueue, &c, &xHigherPriorityTaskWoken); if (xHigherPriorityTaskWoken == pdTRUE) { vPortYieldFromISR(); // 让被唤醒的高优先级任务立即运行 } }这个三参数的模式在后面信号量、事件组里都会反复出现。我建议直接把这段代码当成固定模板,千万别漏掉最后那个判断,否则某些场景下中断响应的实时性会大打折扣,而且这种问题很难排查,因为它不是必现的,只有任务刚好处于可被唤醒状态时才会暴露。
2.4 队列实战:采集与处理任务解耦
下面是一个完整的小例子,展示两个任务如何通过队列协作。采集任务模拟读传感器,处理任务负责取数据并计算滑动平均值。
QueueHandle_t xSensorQueue; #define QUEUE_LENGTH 10 #define ITEM_SIZE sizeof(uint16_t) void vSensorTask(void *pvParameters) { uint16_t sample = 0; while (1) { sample = read_sensor(); if (xQueueSend(xSensorQueue, &sample, pdMS_TO_TICKS(100)) != pdPASS) { // 100ms 内没发出去,说明消费速度跟不上 // 这里可以计数,也可以直接丢弃本次数据 } vTaskDelay(pdMS_TO_TICKS(50)); } } void vProcessTask(void *pvParameters) { uint16_t sample = 0; while (1) { if (xQueueReceive(xSensorQueue, &sample, portMAX_DELAY) == pdPASS) { process_sample(sample); // 拿到了数据,开始处理 } } }这个模型的好处是:采集任务只管产生数据,处理任务只管消费数据,哪怕某一瞬间采集特别快,数据也会在队列里排队,不会直接丢失(前提是队列没满)。队列长度 10 是怎么来的?我一般估算“最大的生产者积压量”,比如采集任务每 50ms 发一个数据,处理任务最坏情况下要 500ms 才能处理一个,那积压上限就是 10 左右。填个 100 也不是不行,但会白白浪费内存,所以定长度前先算清楚积压峰值。
另一个常见问题是:队列里到底存值还是存指针?如果数据结构体很大,比如几百字节,每次拷贝进队列会占用大量 RAM。这时候往往存“指向数据块的指针”,但要注意指针指向的内存必须一直有效,不能是发送任务栈上的局部变量,否则发送任务退出后那块内存就没了。我见过不少项目在这里踩坑,处理办法是用静态缓冲区或专门的内存池,而不是直接把局部结构体指针扔进队列。
3. 信号量:从“通知”到“资源计数”
3.1 二值信号量:最快的事件通知机制
二值信号量很像一个带“空/满”两种状态的门铃。初始状态是“空”,调用 xSemaphoreGive 会把它变成“满”,调用 xSemaphoreTake 会把它重新变成“空”。如果一直没人 Take,无论 Give 多少次,它最多也只是“满”状态,并不会累积。
创建接口是:
SemaphoreHandle_t xSemaphoreCreateBinary(void);典型场景是按键中断通知任务处理事件:
SemaphoreHandle_t xKeySem; void KEY_ISR(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; xSemaphoreGiveFromISR(xKeySem, &xHigherPriorityTaskWoken); if (xHigherPriorityTaskWoken == pdTRUE) { vPortYieldFromISR(); } } void vKeyTask(void *pvParameters) { while (1) { if (xSemaphoreTake(xKeySem, portMAX_DELAY) == pdPASS) { handle_key_event(); // 按键事件处理 } } }注意一个细节:二值信号量里,Give 和 Take 不要求是同一个任务,任何任务都能 Give,任何任务都能 Take。这正是它适合做事件通知的原因——中断 Give,任务 Take。但也正因为它没有所有权概念,如果两个任务同时 Take 同一个二值信号量,内核只会让其中一个拿到,另一个继续阻塞,这种用法在语义上非常容易混乱,建议只用作“一对多”或“多对一”的事件通知,不要用它来保护共享资源。
3.2 计数信号量:统计事件的“积压数量”
计数信号量可以理解为一个“计数器”,创建时要指定最大计数值和初始计数值:
SemaphoreHandle_t xSemaphoreCreateCounting(UBaseType_t uxMaxCount, UBaseType_t uxInitialCount);每次 Give,计数加一;每次 Take,计数减一。如果当前计数已经等于 uxMaxCount,再 Give 就不会继续增加了,相当于计数器“溢出保护”。这个特性非常适合处理“事件产生速度大于消费速度”的场景。
举个例子,一个通信任务从网络接口收数据,中断很快,但任务处理每个数据包需要比较长的时间。中断每收一包就 Give 一次,任务每处理一包就 Take 一次,这样计数信号量里保存的就是“待处理数据包的积压数量”。如果中断期间来不及取走,计数会持续增长,任务忙完手上的活之后可以连续 Take 多次,把积压的数据全部处理掉。相比之下,如果这里用二值信号量,中断 Give 两次而任务只 Take 一次,第二次通知就丢了,这会造成严重的数据丢失。
所以选择依据很简单:如果事件只关心“有没有发生”,用二值信号量;如果关心“发生了多少次、还剩多少没处理”,用计数信号量。很多时候换一个 API,bug 就少一半。
3.3 信号量使用的最常见误区
我见过的信号量低级错误太多了,集中列几个,大家可以对号入座。
第一个误区是创建后不检查返回值。信号量创建失败时返回 NULL,如果直接拿去 Give/Take,轻则断言失败,重则内存非法访问。堆内存不足是最常见原因,尤其是用了大队列之后又创建大量信号量。
第二个误区是中断里用了不带 FromISR 后缀的 API。有些初学者在中断里写 xSemaphoreGive,恰好编译不报错,运行起来偶尔正常,于是以为没事。实际上这个调用可能引起阻塞或者破坏临界区,系统会在某次中断里莫名崩溃。规则就是:中断上下文里,凡是涉及信号量、队列、事件组操作的,一律找 FromISR 版本。
第三个误区是 Take 和 Give 的顺序或次数不匹配。二值信号量一般 Take 一次就要 Give 一次,如果拿锁后提前 return 忘记 Give,后面的任务就会永远阻塞。我建议在任务里用统一的“申请-处理-释放”结构,别在多个 if 分支里放 Give,一旦分支多了非常容易漏。
第四个误区是在任务代码里 Give 一个二值信号量,然后自己再 Take 同一个信号量。虽然从语法上能跑通,但语义完全错了,说明你真正需要的可能是一个互斥量而不是信号量。这也是下面要展开的话题。
4. 互斥量:多个任务抢同一个资源时,别翻车
4.1 互斥量和二值信号量的本质差异
互斥量经常被误认为“带初始满状态的二值信号量”,这话只说对了一半。二值信号量本质是“事件通知”,不记录是谁 Give、谁 Take;互斥量则强调“所有权”:谁成功 Take 了互斥量,谁就必须由谁来 Give,别的人 Give 不掉。这个所有权概念,让互斥量天然适合保护共享资源。
另外一个差异是互斥量支持“优先级继承”,这个特性后面专门讲。它是 FreeRTOS 里少有的、具备递归获取能力的同步对象,也就是说同一个任务可以多次 Take 同一个互斥量而不死锁,但对应的 Give 次数也要匹配。不过我不建议日常频繁用递归特性,临界区设计的正确性远比重用代码重要。
还有一个硬性限制:互斥量不能在中断服务程序里使用,因为它的 Take/Give 可能触发优先级继承逻辑,中断上下文里没有任务调度概念,调用结果不可预测。所以要保护中断和任务共享的资源,不能简单用一个互斥量,通常的做法是关中断加短临界区,或者用队列/信号量配合。
4.2 互斥量的创建、获取与释放
创建互斥量的接口:
SemaphoreHandle_t xSemaphoreCreateMutex(void);创建后,初始状态是“满”的,也就是“没人持有锁”。任务要使用共享资源时,先 Take 拿锁,用完立刻 Give 还锁。
SemaphoreHandle_t xUartMutex; void vLogTask(void *pvParameters) { while (1) { if (xSemaphoreTake(xUartMutex, pdMS_TO_TICKS(1000)) == pdPASS) { uart_send_string("log data"); xSemaphoreGive(xUartMutex); // 用完立刻释放 } vTaskDelay(pdMS_TO_TICKS(100)); } }这段代码里我加了超时时间 1000ms,就是为了防止拿不到锁时任务无限期等待。很多初学者喜欢用 portMAX_DELAY 去拿互斥量,一旦持有者没释放,整个系统就卡死在那里。我自己的习惯是:能不能拿锁、多久拿不到锁,都要有明确策略。如果 1 秒拿不到互斥量,多半说明哪里锁没放,这时候记录日志比死等有用得多。
4.3 优先级反转与优先级继承
优先级反转是实时系统里非常经典的问题。假设任务 A 是低优先级,任务 C 是高优先级,任务 B 是中优先级。A 先拿到互斥量正在使用串口,这时候 C 也要用串口,于是 C 阻塞等待 A 释放锁。本来 A 很快能跑完,但偏偏 B 在这个节骨眼上变成了就绪态。由于 B 的优先级高于 A 而低于 C,调度器会先让 B 运行,A 被抢占,锁迟迟不释放,C 就一直等。结果高优先级的 C 被低优先级的 A 和中优先级的 B 联手“拖死”,实时性完全无法保证。
FreeRTOS 的互斥量内置了优先级继承机制:当 C 因为拿不到互斥量而阻塞时,内核会临时把持有互斥量的 A 的优先级提升到 C 的优先级,这样 A 就能立刻获得运行机会,尽快把锁释放。B 此时因为优先级低于“临时提升后的 A”,无法抢占 A,等 A 释放互斥量后,A 的优先级恢复原样,C 再继续运行。这个机制不会瞬间解决系统拥塞,但能有效避免优先级反转导致的长时间卡顿。
这也解释了为什么受实时性要求高的共享资源保护场景应该用互斥量,而不是二值信号量。顺便说一句,不要因为有了优先级继承,就放心地让任务长时间持有互斥量。临界区越短越好,最好把需要保护的操作压缩到几行代码内,锁里的操作越少,系统实时性越好。
5. 事件组:多个条件都满足,任务才继续跑
5.1 事件组模型:一组标志位加一个等待队列
队列和信号量解决的是“单一条件”的同步,但实际项目里经常出现“必须等好几个条件都满足才继续”或“只要满足其中一个就继续”的场景。比如一个数据处理任务,需要等温度传感器就绪、湿度传感器就绪、按钮按下,三个条件全满足才启动。用三个二值信号量去等,写起来非常别扭。事件组就是为这种场景准备的。
事件组本质上是一个整数,每一位代表一个事件是否发生。创建接口:
EventGroupHandle_t xEventGroupCreate(void);在常见的 Cortex-M 内核配置下,事件组通常提供 24 个可用事件位。每个位都是一个布尔量,置 1 表示“事件发生”,置 0 表示“还没发生”。等待任务可以一次性等待多个位,并且可以精确配置“全部满足”还是“任一满足”。
我用一个生活类比来帮助理解:这就像你出门前的检查清单,你定义了“带钥匙”“带手机”“带雨伞”三个位,出门任务必须等“带钥匙”和“带手机”两个位都置 1 才允许执行;如果你只是想“有任何一样忘带就回去拿”,那等的是“任一条件不满足”。事件组把这种多条件判断从“多个信号量组合”简化成了“一个整数上的位运算”。
5.2 设置与等待事件:xEventGroupSetBits 与 xEventGroupWaitBits
设置事件用 xEventGroupSetBits:
EventBits_t xEventGroupSetBits(EventGroupHandle_t xEventGroup, EventBits_t uxBitsToSet);等待事件用 xEventGroupWaitBits,这个函数的参数比较多,值得逐个理解:
EventBits_t xEventGroupWaitBits(EventGroupHandle_t xEventGroup, EventBits_t uxBitsToWaitFor, BaseType_t xClearOnExit, BaseType_t xWaitForAllBits, TickType_t xTicksToWait);- uxBitsToWaitFor:要等待哪些位,比如 (EVT_TEMP | EVT_HUMI)。
- xClearOnExit:等待成功退出时,是否把这些位自动清零。填 pdTRUE 表示“消费掉这次事件”,下次需要重新等待;填 pdFALSE 表示只等不消费。
- xWaitForAllBits:填 pdTRUE 表示所有位都置 1 才返回;填 pdFALSE 表示只要其中一个位满足就返回。
- xTicksToWait:超时时间。
下面是一个经典的“双条件都满足才执行”的示例:
#define EVT_TEMP (1 << 0) #define EVT_HUMI (1 << 1) EventGroupHandle_t xEventGrp; void vSensorReadyTask(void *pvParameters) { EventBits_t bits; while (1) { bits = xEventGroupWaitBits(xEventGrp, EVT_TEMP | EVT_HUMI, pdTRUE, // 成功返回后自动清零 pdTRUE, // 两个位都置1才返回 portMAX_DELAY); // 温度和湿度都就绪,开始联合处理 do_combined_processing(); } }注意 xEventGroupWaitBits 正常返回时的 bits 值,可能包含你等待的位,也可能包含其他你没等待的位。判断逻辑时别写死“bits == (EVT_TEMP | EVT_HUMI)”,因为其他位可能也恰好被置 1 了。正确做法是用位与运算去提取,比如 (bits & EVT_TEMP) != 0。
5.3 中断里设置事件:xEventGroupSetBitsFromISR
和队列、信号量一样,中断里设置事件位要用 FromISR 版本:
BaseType_t xEventGroupSetBitsFromISR(EventGroupHandle_t xEventGroup, EventBits_t uxBitsToSet, BaseType_t *pxHigherPriorityTaskWoken);它和 xQueueSendFromISR 的用法完全一致:传入输出参数 pxHigherPriorityTaskWoken,如果设置为 pdTRUE,就调用 vPortYieldFromISR。这个函数适合把多个中断源汇聚到一个任务里做统一处理。
我实际用事件组的一个场景是设备控制:有三个传感器中断,任何一个数据更新了都要重新计算一次控制量,但必须等三个传感器的数据都收齐了才能算一遍。于是三个中断各自设置对应的事件位,控制任务用 xEventGroupWaitBits 等“全部位都置 1”,数据收齐后自动清零,然后开始计算。这样代码非常清晰,不用在中断里维护复杂的全局标志位,也不会有漏判条件的问题。
事件组唯一的“坑”是事件位数量有限,如果项目里同时要等几十个条件,24 个位可能不够用。这种情况要么拆成多个事件组,要么重新审视设计,绝大多数情况下 24 位绰绰有余。
6. 新手最容易踩的坑:问题排查与经验总结
6.1 任务卡死:先查超时参数和锁的释放
任务“假死”是 FreeRTOS 项目里最常见的现象。表现是某个任务再也不执行了,系统其他任务看起来正常。第一个怀疑对象就是阻塞超时参数。如果一个任务在 xQueueReceive 或 xSemaphoreTake 里用了 portMAX_DELAY,而对应的发送方或 Give 方因为某种原因再也不来了,任务就会永久阻塞。排查时先用调试器暂停系统,查看任务列表里这个任务的状态,通常显示为 Blocked,且阻塞对象就是目标队列或信号量。
解决思路分两层:第一,检查对应的“唤醒源”是否真的存在。是不是中断没触发?是不是发送任务被更高优先级的任务饿死了?第二,给关键等待加上“超时保护”。不是所有等待都必须无限阻塞,比如等待通信应答,最好设置一个上限,超时后走错误处理分支,而不是一直吊死。
另一个卡死原因是互斥量 Take 了没有 Give。代码里一旦出现提前 return 或异常跳转,锁就还不了。我习惯把临界区代码单独抽成函数,函数内所有 return 前都确保 Give,或者干脆用“先收个 bool 结果,函数末尾统一释放锁”的方式。这比靠肉眼检查分支靠谱多了。
6.2 中断里敢调用普通 API:系统直接重启
中断里调用普通 API 的后果可以很严重。FreeRTOS 的普通 API 内部通常会对调度器状态做检查,也会操作临界区保护的数据结构。中断上下文没有任务概念,一旦在其中调用了可能阻塞的 API,轻则系统挂起,重则触发断言或硬件错误,直接重启。
这里有个经验规则:在中断里要用某个功能时,先找这个 API 是否提供了 FromISR 版本。提供了就用 FromISR 版本,并且一定要把 pxHigherPriorityTaskWoken 参数配合好。下表是常用 API 的对照:
| 普通版本 | 中断版本 | 说明 |
|---|---|---|
| xQueueSend | xQueueSendFromISR | 队列发送 |
| xQueueReceive | xQueueReceiveFromISR | 队列接收(很少用) |
| xSemaphoreGive | xSemaphoreGiveFromISR | 信号量/互斥量释放 |
| xSemaphoreTake | 无 | 中断不推荐 Take |
| xEventGroupSetBits | xEventGroupSetBitsFromISR | 事件组置位 |
| xEventGroupWaitBits | 无 | 中断不能等待事件 |
还要注意一点:FromISR 版本也不是随便调用的。如果你在中断里操作一个已经损坏的队列句柄,照样会出事;如果中断优先级设置过高,超过 FreeRTOS 可管理的最高优先级,调度器相关操作可能失效。所以中断优先级配置也要检查,不能只盯着 API。
6.3 句柄无效和内存不足:栈上分配结构体问题
创建队列或信号量时如果返回 NULL,很多人第一反应是“我明明写对了呀”,然后开始怀疑编译器。实际上,九成以上是堆内存不足。FreeRTOS 的动态内存分配默认从 configTOTAL_HEAP_SIZE 指定的堆里取,如果堆太小,创建大队列或者大量信号量时就会失败。
排查方式很简单,调用 xPortGetFreeHeapSize 查看剩余堆空间,再对比每次创建对象消耗的内存大小。我遇到过的情况是:队列项大小设计成 256 字节,队列长度设计成 20,一次就吃掉 5KB 堆,剩下全系统只剩几百字节,再创建别的对象自然失败。解决办法要么扩大堆,要么缩小队列,要么改用静态创建 API(xQueueCreateStatic、xSemaphoreCreateBinaryStatic),把内存放在用户自己定义的静态数组里,这样大小可控、也不会产生堆碎片。
还有一个和内存相关的典型 bug:发送队列时传了一个指向局部变量的指针。队列会拷贝指针指向的内容,这个动作本身是安全的,但如果队列项里存的是“指向动态内存的指针”,而发送任务在发送后马上释放了这块内存,接收任务再通过队列里的指针访问数据,就会踩到已释放内存。这种问题特别隐蔽,建议统一规则:队列内保存数据值,不保存裸指针,除非你能保证指针指向内存的生命周期足够长。
6.4 经验速查表:一份浓缩的 API 对照清单
最后把整篇内容浓缩成一张表,方便平时查阅。我在项目里经常用这张表来快速判断“这个场景该用哪个”。
| 场景 | 推荐机制 | 创建 API | 发送/通知 | 接收/等待 | 中断安全版本 |
|---|---|---|---|---|---|
| 数据搬运,生产者-消费者 | 队列 | xQueueCreate | xQueueSend / xQueueSendFromISR | xQueueReceive | xQueueSendFromISR |
| 事件通知,只关心有没有发生 | 二值信号量 | xSemaphoreCreateBinary | xSemaphoreGiveFromISR | xSemaphoreTake | xSemaphoreGiveFromISR |
| 事件计数,关心积压量 | 计数信号量 | xSemaphoreCreateCounting | xSemaphoreGiveFromISR | xSemaphoreTake | xSemaphoreGiveFromISR |
| 保护共享资源,互斥访问 | 互斥量 | xSemaphoreCreateMutex | xSemaphoreGive | xSemaphoreTake | 不支持,不要用 |
| 多条件等待,且/或逻辑 | 事件组 | xEventGroupCreate | xEventGroupSetBitsFromISR | xEventGroupWaitBits | xEventGroupSetBitsFromISR |
写到这里,我想起自己第一次用队列时踩过的一个坑:把结构体直接放进队列,结果因为结构体里有指针成员,指针指向的缓冲区却被发送任务复用了,接收任务拿到数据时内容已经被覆盖。后来我学乖了,先明确“这块数据归谁管”,再决定队列里存值还是存指针。如果你也遇到类似问题,不妨按这个顺序排查:先确认创建成功并查看堆剩余量,再确认发送和接收的超时参数是否符合逻辑,最后检查中断里是否误用了普通版本 API。把这几类问题摸透之后,再回头调多任务逻辑会顺手很多。