ESP32-S3 IDF编程第七课:FreeRTOS队列与信号量 —— 任务间的“悄悄话”

上节课我们用硬件PWM让LED呼吸、舵机听话,全程硬件自动运行,CPU闲得发慌。但你有没有想过一个问题:如果多个任务需要互相通信怎么办?比如按键中断想告诉LED任务“有人按我了,你闪一下”,或者定时器想唤醒数据处理任务“时间到了,该干活了”。这节课的主角——队列与信号量——就是专门解决这个问题的。

回顾一下第四课的按键中断,我们在ISR里用了这样一行代码:

1
xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);

当时你可能一脸懵:“这个xQueueSendFromISR是啥?队列又是啥?”今天就彻底讲透。

这节课你会学到

  1. 为什么需要任务间通信——对比volatile轮询的三大缺陷。
  2. 队列(Queue)——传递数据的高速通道,带阻塞机制。
  3. 二值信号量(Binary Semaphore)——任务间的“起跑发令枪”。
  4. 计数信号量(Counting Semaphore)——管理多个资源的管理员。
  5. 互斥锁(Mutex)——防止资源冲突的“厕所门锁”(快速过一遍)。
  6. 实战:按键中断→队列→LED任务 + 定时器中断→信号量→数据处理任务
  7. 避坑指南: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占满
}
}

这种方案有三大致命缺陷:

缺陷 说明 后果
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;

// 按键中断ISR
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,
};

// 从ISR发送到队列,第三个参数通常设为NULL(不阻塞)
xQueueSendFromISR(button_queue, &evt, NULL);
}

// LED任务:初始化LED并处理队列消息
static void led_task(void *arg)
{
// 配置LED为输出
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) {
// 阻塞等待队列消息,没消息就休眠,不占CPU
if (xQueueReceive(button_queue, &evt, portMAX_DELAY)) {
printf("按键按下!GPIO:%lu,第%lu次\n", evt.gpio, evt.count);

// LED快闪一下作为反馈
gpio_set_level(LED_GPIO, 1);
vTaskDelay(pdMS_TO_TICKS(50));
gpio_set_level(LED_GPIO, 0);
}
}
}

void app_main(void)
{
// 1. 创建队列:长度10,每个元素大小为 button_event_t
button_queue = xQueueCreate(10, sizeof(button_event_t));
if (button_queue == NULL) {
printf("队列创建失败!\n");
return;
}

// 2. 配置按键GPIO(下降沿中断 + 内部下拉)
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);

// 3. 安装GPIO中断服务
gpio_install_isr_service(0);
gpio_isr_handler_add(BUTTON_GPIO, button_isr_handler, (void *)BUTTON_GPIO);

// 4. 创建LED任务
xTaskCreate(led_task, "led_task", 2048, NULL, 5, NULL);

printf("队列版按键已启动,按GPIO2试试\n");

// 主任务可以干别的,或者直接删除自己
vTaskDelete(NULL);
}

代码要点拆解

  1. 队列创建
    xQueueCreate(10, sizeof(button_event_t)) 创建了一个能存10个button_event_t的队列。如果队列满,xQueueSendFromISR会立即返回错误(因为我们传了NULL不阻塞)。

  2. ISR中发送数据
    必须用xQueueSendFromISR,不能用普通版xQueueSend!因为ISR不能阻塞。第三个参数pxHigherPriorityTaskWoken通常设为NULL,除非你需要手动触发任务调度。

  3. 任务中接收数据
    xQueueReceive的第三个参数portMAX_DELAY表示无限等待,直到有数据为止。此时任务进入阻塞态,CPU完全不搭理它,直到队列有数据才唤醒。

  4. 结构体传递
    队列传递的是数据的拷贝,不是指针!所以你在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;

// 定时器中断ISR:给信号量
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;
// 从ISR给出信号量
xSemaphoreGiveFromISR(timer_sem, &high_task_wakeup);
// 如果给出了信号量导致高优先级任务就绪,需要手动调度
return (high_task_wakeup == pdTRUE);
}

// LED任务:阻塞等待信号量
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)
{
// 1. 创建二值信号量
timer_sem = xSemaphoreCreateBinary();
if (timer_sem == NULL) {
printf("信号量创建失败!\n");
return;
}

// 2. 初始化LED
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);

// 3. 配置硬件定时器(1秒周期)
gptimer_config_t timer_config = {
.clk_src = GPTIMER_CLK_SRC_DEFAULT,
.direction = GPTIMER_COUNT_UP,
.resolution_hz = 1 * 1000 * 1000, // 1MHz,1us计数
};
ESP_ERROR_CHECK(gptimer_new_timer(&timer_config, &g_timer));

gptimer_alarm_config_t alarm_config = {
.alarm_count = 1 * 1000 * 1000, // 1秒
.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));

// 4. 创建LED任务(优先级比主任务高一点,保证及时响应)
xTaskCreate(led_task, "led_task", 2048, NULL, 5, NULL);

// 5. 启动定时器
ESP_ERROR_CHECK(gptimer_enable(g_timer));
ESP_ERROR_CHECK(gptimer_start(g_timer));

printf("信号量版定时器已启动,LED每1秒翻转一次\n");

// 主任务可以干别的,这里直接删除自己
vTaskDelete(NULL);
}

信号量关键点

  1. 创建二值信号量
    xSemaphoreCreateBinary() 创建后默认是无效状态(0),必须先用xSemaphoreGive或等待中断Give才能Take到。

  2. ISR中Give信号量
    xSemaphoreGiveFromISR 的第二个参数pxHigherPriorityTaskWoken非常重要!如果信号量Give导致了一个更高优先级的任务就绪,这个参数会被设为pdTRUE,然后ISR返回true,FreeRTOS会在退出ISR后立即切换任务。

  3. 任务中Take信号量
    xSemaphoreTake(sem, portMAX_DELAY) 会阻塞任务直到信号量有效。Take成功后信号量自动变回无效(如果是二值信号量),相当于“消费”了这次通知。

  4. 对比轮询方案

    • 轮询: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; // 也可以直接用队列传数据

// UART中断ISR(伪代码)
static void uart_isr_handler(void) {
uint8_t data = READ_UART_FIFO();
// 把数据放进队列
xQueueSendFromISR(uart_data_queue, &data, NULL);
// 同时给信号量(计数+1)
xSemaphoreGiveFromISR(uart_sem, NULL);
}

// 数据处理任务
static void data_task(void *arg) {
uint8_t data;
while (1) {
// 等待信号量,表示有数据来了
if (xSemaphoreTake(uart_sem, portMAX_DELAY)) {
// 从队列取数据(这里用0超时,因为信号量保证了至少有一个数据)
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) {
// 拿锁,最多等100ms
if (xSemaphoreTake(i2c_mutex, pdMS_TO_TICKS(100))) {
// 临界区:操作I2C
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(低)。

  1. L拿了锁,然后被H抢占,H尝试拿锁失败进入阻塞。
  2. M就绪,抢占L,执行很久。
  3. 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。
  • 队列传的是数据拷贝,安全但注意大小。
  • 互斥锁解决优先级反转,保护共享资源。