6.2 队列、信号量与互斥量 上一节的任务各跑各的还很和谐,可一旦中断收到的数据要交给任务、两个任务要用同一个串口、一个任务的结果是另一个任务的原料——交接问题就来了。FreeRTOS 把交接拆成三种原语:队列传数据、信号量传事件、互斥量保资源。分清"传的是数据还是事件、护的是资源还是顺序",并发 bug 就少了八成。 队列:任务之间的传送带 队列是定长、定深度的先进先出缓冲,自带两件法宝:拷贝语义(进去的数据被整块复制,收发双方不共享指针,天然没有"谁先改"的纠纷)与阻塞语义(队列空时接收方睡觉,不占 CPU;队列满时发送方睡觉或返回失败)。这是它能替代裸机时代所有"标志位 + 全局数组"方案的根本原因。 经典应用:串口中断收字节、任务解析报文。
上一节的任务各跑各的还很和谐,可一旦中断收到的数据要交给任务、两个任务要用同一个串口、一个任务的结果是另一个任务的原料——交接问题就来了。FreeRTOS 把交接拆成三种原语:队列传数据、信号量传事件、互斥量保资源。分清"传的是数据还是事件、护的是资源还是顺序",并发 bug 就少了八成。
队列是定长、定深度的先进先出缓冲,自带两件法宝:拷贝语义(进去的数据被整块复制,收发双方不共享指针,天然没有"谁先改"的纠纷)与阻塞语义(队列空时接收方睡觉,不占 CPU;队列满时发送方睡觉或返回失败)。这是它能替代裸机时代所有"标志位 + 全局数组"方案的根本原因。
经典应用:串口中断收字节、任务解析报文。中断里一行代码完成交接:
#include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" static QueueHandle_t uart_rx_queue; typedef struct { uint8_t data[32]; size_t len; } uart_msg_t; void uart_rx_isr(void *arg) /* ESP32 的中断处理注册形式 */ { uart_msg_t msg; msg.len = uart_read_bytes_now(&msg.data, sizeof(msg.data)); /* 快速取走数据 */ BaseType_t woken = pdFALSE; /* ISR 里必须用 FromISR 后缀版本:普通版可能阻塞,在中断里阻塞是灾难 */ xQueueSendFromISR(uart_rx_queue, &msg, &woken); portYIELD_FROM_ISR(woken); /* 若唤醒了高优先级任务,退出后立刻切换 */ } void parser_task(void *arg) { uart_msg_t msg; for (;;) { /* 队列空则在此阻塞睡去,一个时钟节拍都不浪费 */ if (xQueueReceive(uart_rx_queue, &msg, portMAX_DELAY) == pdTRUE) { parse_frame(msg.data, msg.len); /* 解析的慢活放到任务里做 */ } } } void app_main(void) { uart_rx_queue = xQueueCreate(10, sizeof(uart_msg_t)); /* 深度 10:扛突发 */ /* 安装串口中断、创建 parser_task …… */ }
三个工程细节决定这套结构的成败。ISR 专用 API 不是建议是强制:普通 API 的阻塞逻辑出现在中断上下文会直接挂掉系统,所有 FreeRTOS 接口都有 FromISR 对应版。队列深度按突发量估:115200 波特的字节流如果逐字节入队,深度 10 撑不过 0.9 毫秒,正确做法是中断攒一帧再入队(本例的 msg 结构就是为此);深度给足还能吸收解析任务的偶发迟到。拷贝语义决定了别传大块头:几 KB 的图像帧进队列会拖慢拷贝,这时传指针(配合静态分配的缓冲池)才是正解。

信号量内部只有一个计数,专门表达"某事发生了"。二值信号量最常见于"中断通知任务":中断给信号量,任务等到信号量去干活——与队列的区别在于它不带数据(数据自己走全局缓冲或队列)。计数信号量则用计数表达资源余量(比如缓冲池里还剩几块)。
static SemaphoreHandle_t adc_done_sem; static volatile uint16_t adc_result; /* 数据走缓冲,事件走信号量 */ void adc_isr(void) { adc_result = ADC_READ(); xSemaphoreGiveFromISR(adc_done_sem, &woken); /* 通知:转换完成了 */ } void filter_task(void *arg) { for (;;) { xSemaphoreTake(adc_done_sem, portMAX_DELAY); /* 等事件,到了再干 */ int32_t f = iir_filter(adc_result); /* 慢活在任务里 */ printf("filtered=%ld\n", (long)f); } }
两个任务抢同一个外设(都往串口打印、都刷同一块屏),输出会交错成乱码。互斥量(mutex)就是那把锁:谁拿到谁独占,用完释放,另一个才进去。它比"先来后到的轮询标志"多一层制度保障,也最容易与信号量混淆——信号量用于"通知发生了",互斥量用于"保护使用权",二者不可互换:
| 原语 | 语义 | 典型场景 | 能否 ISR 使用 |
|---|---|---|---|
| 队列 | 传数据 | 中断交字节给任务 | FromISR 版 |
| 二值信号量 | 传事件 | 中断通知任务 | FromISR 版 |
| 计数信号量 | 资源余量 | 缓冲池配额 | FromISR 版 |
| 互斥量 | 资源独占 | 多任务共用串口/屏幕 | 禁止 |
static SemaphoreHandle_t uart_mutex; void log_task(void *arg) /* 多个任务都想打印时,锁住整条消息再发 */ { for (;;) { char line[96]; wait_for_log(line); if (xSemaphoreTake(uart_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { uart_puts(line); xSemaphoreGive(uart_mutex); } } }
互斥量存在的理由不只是加锁,更是它比普通信号量多出的两件武器。其一,优先级继承:低优先级任务持锁时,若高优先级任务来等锁,前者临时被抬到后者的优先级——这直接拆解了著名的"优先级反转"死局(高优先级被低优先级间接阻塞,而中优先级任务趁虚狂奔;火星探路者号当年就栽在这上面)。其二,递归持有与持有者校验:谁锁谁解、重复锁不死锁在自家门口。代价是互斥量禁止在中断里使用——中断没有"任务身份",继承机制无从谈起。
最后补一个选型口诀,遇到交接需求时按序自问:要传数据吗? 传 → 队列;只通知事件吗? 是 → 信号量;要独占资源吗? 是 → 互斥量。三连问下来选错工具的概率趋近于零。剩下的工程变量(队列深度、等待超时值)都有经验起点:队列深度按突发速率乘最大处理延迟放大两倍,超时值宁可选"有限超时 + 出错日志"而不是无限等待——无限等待在嵌入式里几乎总是错的,它让故障从"可观测的超时"退化成"无声的挂死"。
锁是把双刃剑,三条纪律保平安。临界区尽量短:锁内只做读写共享资源的必要动作,printf、计算、再取锁统统挪出去——持锁时间就是其他任务的等待时间。多把锁按固定顺序取:任务甲先锁 A 再锁 B、任务乙先锁 B 再锁 A,就构成经典的死锁双环;全项目约定"永远按地址排序取锁"即可免疫。能不用锁就不用:单字节数据用原子访问,生产者消费者用队列,事件同步用信号量——互斥量是并发工具链的最后一道防线,不是第一反应。
到这里,FreeRTOS 的三件套齐了:任务与调度立骨架,队列信号量互斥量通血脉。最后一章回到产品视角:程序怎么从上电跑起来(启动流程)、怎么装进芯片(烧录调试)、以及量产后怎么在用户手里完成升级(OTA)——从裸机点灯到可靠产品,还剩这最后一段路。