ESP32-S3 IDF编程第七课:FreeRTOS队列与信号量 —— 任务间的“悄悄话”
上节课我们用硬件PWM让LED呼吸、舵机听话,全程硬件自动运行,CPU闲得发慌。但你有没有想过一个问题:如果多个任务需要互相通信怎么办?比如按键中断想告诉LED任务“有人按我了,你闪一下”,或者定时器想唤醒数据处理任务“时间到了,该干活了”。这节课的主角——队列与信号量——就是专门解决这个问题的。
回顾一下第四课的按键中断,我们在ISR里用了这样一行代码:
1
| xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
|
当时你可能一脸懵:“这个xQueueSendFromISR是啥?队列又是啥?”今天就彻底讲透。
这节课你会学到
- 为什么需要任务间通信——对比
volatile轮询的三大缺陷。
- 队列(Queue)——传递数据的高速通道,带阻塞机制。
- 二值信号量(Binary Semaphore)——任务间的“起跑发令枪”。
- 计数信号量(Counting Semaphore)——管理多个资源的管理员。
- 互斥锁(Mutex)——防止资源冲突的“厕所门锁”(快速过一遍)。
- 实战:按键中断→队列→LED任务 + 定时器中断→信号量→数据处理任务。
- 避坑指南:ISR中的
FromISR、优先级反转、队列满/空处理。
硬件接线
沿用之前的硬件,不需要改动:
- LED:GPIO4(串220Ω电阻到GND)。
- 按键:一脚接3.3V,另一脚接GPIO2(启用内部下拉)。
为什么需要队列和信号量?
先看一个反面教材——用全局变量+轮询实现任务间通信:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| volatile bool button_pressed = false;
void button_isr_handler(void) { button_pressed = true; }
void led_task(void) { while (1) { if (button_pressed) { button_pressed = false; toggle_led(); } vTaskDelay(pdMS_TO_TICKS(10)); } }
|
这种方案有三大致命缺陷:
| 缺陷 |
说明 |
后果 |
| CPU空转 |
即使没按键,任务也在疯狂轮询 |
浪费功耗、拖慢其他任务 |
| 事件可能丢失 |
如果在toggle_led()执行期间又按了一次键,button_pressed被覆盖成true然后又被清false |
第二次按键被吃掉 |
| 无法传递数据 |
只能通知“发生了某事”,不能告诉对方“发生了什么” |
扩展性极差 |
队列和信号量就是为解决这些问题而生的:
- 队列:可以传递数据(比如按键的GPIO号、按键次数、传感器数值),任务没数据时阻塞休眠,完全不占CPU。
- 信号量:更轻量,只负责“通知”,不传数据,同样支持阻塞等待。
核心概念速览
1. 队列(Queue)—— 传递数据的FIFO管道
队列就像一个管道,一头往里塞数据,另一头往外取数据。先进先出(FIFO)。
1 2
| 任务A ──▶ [ data3 | data2 | data1 ] ──▶ 任务B (队列缓冲区,长度可配置)
|
关键特性:
- 队列创建时指定长度和每个数据的大小。
- 发送和接收都可以设置超时时间,超时返回错误。
- 队列满时发送会阻塞(或立即返回错误),队列空时接收会阻塞。
2. 二值信号量(Binary Semaphore)—— 起跑发令枪
二值信号量只有两种状态:有效(1) 和 无效(0)。它就像一个发令枪:任务B在起跑线等着(阻塞),任务A扣动扳机(Give),任务B立刻起跑。
典型场景:定时器中断1秒给一次信号量,LED任务阻塞等待信号量,收到后翻转LED。
3. 计数信号量(Counting Semaphore)—— 资源管理员
计数信号量有一个计数值,每次Give加1,每次Take减1(如果大于0)。它用来管理多个相同资源。
典型场景:串口接收缓冲区有10个字节,每收到一个字节中断Give一次,数据处理任务Take后处理,计数值表示还有多少数据待处理。
4. 互斥锁(Mutex)—— 厕所门锁
互斥锁是一种特殊的二值信号量,带有优先级继承机制,专门用来保护共享资源(比如I2C总线、SPI总线),防止多个任务同时访问造成冲突。
本节课重点讲队列和二值信号量,互斥锁会简单提一下,后续I2C/SPI课程再深入。
实战一:队列版按键(中断→队列→LED任务)
还记得第四课的按键消抖吗?当时我们用状态机+队列解决了抖动,但队列只是“传了个GPIO号”。这次我们让队列传递一个自定义结构体,包含按键GPIO号和按键次数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
| #include <stdio.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" #include "driver/gpio.h"
#define BUTTON_GPIO 2 #define LED_GPIO 4
typedef struct { uint32_t gpio; uint32_t count; } button_event_t;
static QueueHandle_t button_queue = NULL; static uint32_t press_count = 0;
static void IRAM_ATTR button_isr_handler(void *arg) { uint32_t gpio_num = (uint32_t)arg; press_count++; button_event_t evt = { .gpio = gpio_num, .count = press_count, }; xQueueSendFromISR(button_queue, &evt, NULL); }
static void led_task(void *arg) { gpio_config_t io_conf = { .mode = GPIO_MODE_OUTPUT, .pin_bit_mask = (1ULL << LED_GPIO), }; gpio_config(&io_conf); gpio_set_level(LED_GPIO, 0); button_event_t evt; while (1) { if (xQueueReceive(button_queue, &evt, portMAX_DELAY)) { printf("按键按下!GPIO:%lu,第%lu次\n", evt.gpio, evt.count); gpio_set_level(LED_GPIO, 1); vTaskDelay(pdMS_TO_TICKS(50)); gpio_set_level(LED_GPIO, 0); } } }
void app_main(void) { button_queue = xQueueCreate(10, sizeof(button_event_t)); if (button_queue == NULL) { printf("队列创建失败!\n"); return; } gpio_config_t io_conf = { .intr_type = GPIO_INTR_NEGEDGE, .mode = GPIO_MODE_INPUT, .pin_bit_mask = (1ULL << BUTTON_GPIO), .pull_down_en = 1, .pull_up_en = 0, }; gpio_config(&io_conf); gpio_install_isr_service(0); gpio_isr_handler_add(BUTTON_GPIO, button_isr_handler, (void *)BUTTON_GPIO); xTaskCreate(led_task, "led_task", 2048, NULL, 5, NULL); printf("队列版按键已启动,按GPIO2试试\n"); vTaskDelete(NULL); }
|
代码要点拆解
队列创建
xQueueCreate(10, sizeof(button_event_t)) 创建了一个能存10个button_event_t的队列。如果队列满,xQueueSendFromISR会立即返回错误(因为我们传了NULL不阻塞)。
ISR中发送数据
必须用xQueueSendFromISR,不能用普通版xQueueSend!因为ISR不能阻塞。第三个参数pxHigherPriorityTaskWoken通常设为NULL,除非你需要手动触发任务调度。
任务中接收数据
xQueueReceive的第三个参数portMAX_DELAY表示无限等待,直到有数据为止。此时任务进入阻塞态,CPU完全不搭理它,直到队列有数据才唤醒。
结构体传递
队列传递的是数据的拷贝,不是指针!所以你在ISR里定义的局部变量evt被拷贝进队列,退出ISR后栈变量销毁也没关系。
实战二:信号量版定时器(精确1秒闪烁,不占用CPU)
上节课我们用volatile标志位+轮询实现了定时器闪烁LED,现在用二值信号量重写,让任务优雅地阻塞等待。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
| #include <stdio.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" #include "driver/gptimer.h" #include "driver/gpio.h"
#define LED_GPIO 4
static gptimer_handle_t g_timer = NULL; static SemaphoreHandle_t timer_sem = NULL;
static bool IRAM_ATTR timer_isr_callback(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *user_data) { BaseType_t high_task_wakeup = pdFALSE; xSemaphoreGiveFromISR(timer_sem, &high_task_wakeup); return (high_task_wakeup == pdTRUE); }
static void led_task(void *arg) { gpio_config_t io_conf = { .mode = GPIO_MODE_OUTPUT, .pin_bit_mask = (1ULL << LED_GPIO), }; gpio_config(&io_conf); static bool level = false; while (1) { if (xSemaphoreTake(timer_sem, portMAX_DELAY) == pdTRUE) { level = !level; gpio_set_level(LED_GPIO, level); printf("定时器触发,LED -> %d\n", level); } } }
void app_main(void) { timer_sem = xSemaphoreCreateBinary(); if (timer_sem == NULL) { printf("信号量创建失败!\n"); return; } gpio_config_t io_conf = { .mode = GPIO_MODE_OUTPUT, .pin_bit_mask = (1ULL << LED_GPIO), }; gpio_config(&io_conf); gpio_set_level(LED_GPIO, 0); gptimer_config_t timer_config = { .clk_src = GPTIMER_CLK_SRC_DEFAULT, .direction = GPTIMER_COUNT_UP, .resolution_hz = 1 * 1000 * 1000, }; ESP_ERROR_CHECK(gptimer_new_timer(&timer_config, &g_timer)); gptimer_alarm_config_t alarm_config = { .alarm_count = 1 * 1000 * 1000, .flags.auto_reload_on_alarm = true, }; ESP_ERROR_CHECK(gptimer_set_alarm_action(g_timer, &alarm_config)); gptimer_event_callbacks_t cbs = { .on_alarm = timer_isr_callback, }; ESP_ERROR_CHECK(gptimer_register_event_callbacks(g_timer, &cbs, NULL)); xTaskCreate(led_task, "led_task", 2048, NULL, 5, NULL); ESP_ERROR_CHECK(gptimer_enable(g_timer)); ESP_ERROR_CHECK(gptimer_start(g_timer)); printf("信号量版定时器已启动,LED每1秒翻转一次\n"); vTaskDelete(NULL); }
|
信号量关键点
创建二值信号量
xSemaphoreCreateBinary() 创建后默认是无效状态(0),必须先用xSemaphoreGive或等待中断Give才能Take到。
ISR中Give信号量
xSemaphoreGiveFromISR 的第二个参数pxHigherPriorityTaskWoken非常重要!如果信号量Give导致了一个更高优先级的任务就绪,这个参数会被设为pdTRUE,然后ISR返回true,FreeRTOS会在退出ISR后立即切换任务。
任务中Take信号量
xSemaphoreTake(sem, portMAX_DELAY) 会阻塞任务直到信号量有效。Take成功后信号量自动变回无效(如果是二值信号量),相当于“消费”了这次通知。
对比轮询方案
- 轮询:CPU每10ms醒来检查一次标志位。
- 信号量:任务完全阻塞,定时器中断来了才唤醒,省电又高效。
进阶:计数信号量模拟串口接收
假设你用UART接收数据,每收到一个字节中断一次,用计数信号量通知任务处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| static SemaphoreHandle_t uart_sem = NULL; static QueueHandle_t uart_data_queue = NULL;
static void uart_isr_handler(void) { uint8_t data = READ_UART_FIFO(); xQueueSendFromISR(uart_data_queue, &data, NULL); xSemaphoreGiveFromISR(uart_sem, NULL); }
static void data_task(void *arg) { uint8_t data; while (1) { if (xSemaphoreTake(uart_sem, portMAX_DELAY)) { if (xQueueReceive(uart_data_queue, &data, 0)) { process_byte(data); } } } }
|
计数信号量的计数值表示“还有多少数据待处理”,即使中断来得比任务处理快,计数值会累积,任务可以连续Take多次直到计数值归零。
互斥锁(Mutex)快速入门
当你多个任务需要访问同一个硬件资源(比如I2C总线),必须用互斥锁保护,否则数据会乱套。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| static SemaphoreHandle_t i2c_mutex = NULL;
void i2c_write_safe(uint8_t addr, uint8_t *data, size_t len) { if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(100))) { i2c_master_write_to_device(..., addr, data, len, ...); xSemaphoreGive(i2c_mutex); } else { printf("I2C超时!\n"); } }
void app_main() { i2c_mutex = xSemaphoreCreateMutex(); }
|
互斥锁 vs 二值信号量:
- 互斥锁有优先级继承,能防止优先级反转(高优先级任务被低优先级任务间接阻塞)。
- 互斥锁必须由同一个任务Take和Give(拿锁和放锁是同一人),信号量没有这个限制。
常见问题与避坑指南
Q1:在ISR里用了xQueueSend而不是xQueueSendFromISR,程序卡死?
原因:普通版API会尝试阻塞,而ISR不能阻塞,导致断言失败或死机。ISR里必须用带FromISR后缀的API。
Q2:信号量Take不到,明明已经Give了?
检查:
- 二值信号量创建后是无效的,必须先Give一次才能Take(或者在创建后立即
xSemaphoreGive)。
- 是否被其他任务抢先Take了?信号量是全局的,谁都可以Take。
Q3:队列满了,xQueueSend一直阻塞,怎么处理?
你可以:
- 设置超时时间:
xQueueSend(queue, &data, pdMS_TO_TICKS(100)),超时返回errQUEUE_FULL。
- 在ISR中用
xQueueSendFromISR并判断返回值,满了就丢弃或覆盖(xQueueOverwriteFromISR)。
Q4:优先级反转是什么?Mutex怎么解决的?
假设三个任务优先级:H(高)、M(中)、L(低)。
- L拿了锁,然后被H抢占,H尝试拿锁失败进入阻塞。
- M就绪,抢占L,执行很久。
- H虽然优先级最高,却被M间接阻塞(因为L拿不到CPU释放锁)。
Mutex的优先级继承:当H阻塞在Mutex上时,系统会临时把L的优先级提升到H的级别,防止M抢占L,让L尽快执行完释放锁。
Q5:队列传指针要注意什么?
如果你传的是指针(比如xQueueSend(&ptr)),必须保证指针指向的内存在接收方处理完之前一直有效。局部变量在函数返回后就销毁了,传指针会导致野指针。推荐做法:
- 传结构体本身(值拷贝),安全但占内存。
- 传指针但用动态内存分配(
malloc),接收方处理完再free。
下课小结
这节课我们正式进入FreeRTOS的核心领域,掌握了三种任务间通信武器:
| 武器 |
用途 |
特点 |
典型场景 |
| 队列 |
传递数据 |
有缓冲区,FIFO,可阻塞 |
按键事件、传感器数据、命令队列 |
| 二值信号量 |
事件通知 |
无数据,轻量,只有0/1 |
定时器触发、中断通知任务 |
| 计数信号量 |
资源管理 |
计数值表示可用资源数量 |
串口数据缓冲、多个相同外设管理 |
| 互斥锁 |
资源保护 |
有优先级继承,必须同任务Give/Take |
I2C/SPI总线、共享变量 |
核心要点回顾:
- ISR中必须用
FromISR版本API。
- 阻塞等待是FreeRTOS的精髓,任务不浪费CPU。
- 队列传的是数据拷贝,安全但注意大小。
- 互斥锁解决优先级反转,保护共享资源。