Day 1:Queue 基础
1.1 实验目标
两个任务通过 Queue 传递数据,实现零拷贝通信:
- Task_Producer:每 1000ms 向 Queue 发送一个
uint32_t 计数 - Task_Consumer:阻塞等待 Queue,收到后串口打印
Count: X - Task_LED:保留作为心跳指示
1.2 为什么用 Queue,而不用全局变量?
| 特性 | Queue | 全局变量 |
|---|
| 阻塞与解耦 | 支持发送/接收阻塞等待,生产者消费者速率不匹配时自动缓冲 | 需要轮询或手动同步,效率低 |
| 线程安全 | 内部有临界区保护,多任务同时访问不会数据竞争 | 需要手动关中断或加 Mutex |
| 数据拷贝 | 存的是数据拷贝,生产者局部变量销毁后不影响消费者 | 传递的是同一块内存,生命周期管理复杂 |
| 背压(Back Pressure) | Queue 满时 Put 可阻塞或返回错误,防止无限堆积 | 没有长度限制,可能溢出 |
回答:
“全局变量需要手动加 Mutex 保护,因为多个任务同时读写时会发生数据竞争。Queue 内部已经用临界区保护了对队列结构的操作,支持阻塞等待和背压控制,生产者速度快时自动缓冲,消费者慢时阻塞休眠不浪费 CPU。”
1.3 核心代码
freertos.c Variables 区域:
1
2
3
4
5
6
7
8
9
| static void _task_producer_handler(void *argument);
static void _task_consumer_handler(void *argument);
static void _task_led_handler(void *argument);
static osThreadId_t _task_producer_id = NULL;
static osThreadId_t _task_consumer_id = NULL;
static osThreadId_t _task_led_id = NULL;
static osMessageQueueId_t _queue_cnt_id = NULL;
|
freertos.c Init 区域:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| const osThreadAttr_t _attr_producer = {
.name = "producer",
.priority = osPriorityNormal,
.stack_size = 512 * 4
};
const osThreadAttr_t _attr_consumer = {
.name = "consumer",
.priority = osPriorityNormal,
.stack_size = 512 * 4
};
const osThreadAttr_t _attr_led = {
.name = "led",
.priority = osPriorityNormal,
.stack_size = 512 * 4
};
/* 先创建 Queue,再创建任务 */
_queue_cnt_id = osMessageQueueNew(10, sizeof(uint32_t), NULL);
_task_producer_id = osThreadNew(_task_producer_handler, NULL, &_attr_producer);
_task_consumer_id = osThreadNew(_task_consumer_handler, NULL, &_attr_consumer);
_task_led_id = osThreadNew(_task_led_handler, NULL, &_attr_led);
|
freertos.c Application 区域:
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
| static void _task_producer_handler(void *argument)
{
(void)argument;
uint32_t cnt = 0;
for (;;)
{
osMessageQueuePut(_queue_cnt_id, &cnt, 0, 100);
cnt++;
osDelay(1000);
}
}
static void _task_consumer_handler(void *argument)
{
(void)argument;
uint32_t cnt = 0;
char buf[32];
for (;;)
{
if (osMessageQueueGet(_queue_cnt_id, &cnt, NULL, osWaitForever) == osOK)
{
int len = snprintf(buf, sizeof(buf), "Count: %lu\r\n", cnt);
HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 200);
}
}
}
static void _task_led_handler(void *argument)
{
(void)argument;
for (;;)
{
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
osDelay(1000);
}
}
|
1.4 错误记录
错误 1:osMessageQueuePut 第二个参数传了值而不是地址
错误写法:
1
| osMessageQueuePut(_queue_cnt_id, cnt++, 0, 100); /* cnt 是值,不是地址! */
|
后果:编译可能通过(隐式转换),但运行时把 cnt 的数值当成指针解引用,HardFault。
正确写法:
1
| osMessageQueuePut(_queue_cnt_id, &cnt, 0, 100); /* 传地址 */
|
错误 2:osMessageQueueGet 接收缓冲区类型不匹配
Queue 创建时 sizeof(uint32_t),但 Consumer 里定义了 uint16_t cnt,导致数据截断或越界。
正确做法:Producer 和 Consumer 使用完全一致的数据类型。
1.5 验证现象
- 串口每 1000ms 输出
Count: 0、Count: 1、Count: 2… - LED 每秒闪烁(系统没卡死)
- 计数严格递增,不跳变
Day 2:Queue 传结构体
2.1 实验目标
将 Queue 元素从 uint32_t 升级为结构体,模拟真实传感器数据包传递。
2.2 结构体定义
1
2
3
4
| typedef struct {
uint32_t timestamp; /* 系统运行时间,单位 ms */
uint8_t value; /* 模拟传感器值 */
} SensorData_t;
|
注意 ARM 结构体对齐:
uint32_t 占 4 字节uint8_t 占 1 字节- 编译器默认 4 字节对齐,会在
value 后补 3 字节 sizeof(SensorData_t) = 8 字节(不是 5 字节)
Queue 创建时必须用 sizeof(SensorData_t),不要手动写死数字。
2.3 核心代码修改点
Queue 创建:
1
| _queue_cnt_id = osMessageQueueNew(10, sizeof(SensorData_t), NULL);
|
Producer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| static void _task_producer_handler(void *argument)
{
(void)argument;
SensorData_t sensor_data = {0};
for (;;)
{
sensor_data.timestamp = HAL_GetTick();
sensor_data.value++;
osMessageQueuePut(_queue_cnt_id, &sensor_data, 0, 100);
osDelay(1000);
}
}
|
Consumer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| static void _task_consumer_handler(void *argument)
{
(void)argument;
char buf[64]; /* 结构体输出更长,buf 要够大 */
uint8_t len = 0;
SensorData_t sensor_data = {0};
for (;;)
{
osMessageQueueGet(_queue_cnt_id, &sensor_data, NULL, osWaitForever);
len = snprintf(buf, sizeof(buf),
"[timestamp: %lu] Value: %d\r\n",
sensor_data.timestamp, sensor_data.value);
HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 100);
}
}
|
2.4 关键理解:局部变量传 Queue 是安全的
1
2
| SensorData_t sensor_data; /* 局部变量,栈上 */
osMessageQueuePut(_queue_id, &sensor_data, 0, 100); /* 传指针 */
|
sensor_data 是局部变量,函数返回后栈销毁,但 Queue 内部已经拷贝了一份到队列缓冲区。Consumer 拿到的是 Queue 里的拷贝,不是指向 sensor_data 的悬空指针。
这是 Queue 比"裸指针传递"安全的地方。
2.5 验证现象
1
2
3
4
| [timestamp: 500] Value: 0
[timestamp: 1000] Value: 1
[timestamp: 1500] Value: 2
...
|
- Timestamp 严格递增 1000ms
- Value 严格递增 1
Day 3:二值信号量(中断同步)
3.1 实验目标
将 Task_CMD 从轮询模式改为中断 + 信号量模式:
- USART1 接收中断:每收到 1 字节,ISR 里释放二值信号量
- Task_CMD:阻塞等待信号量,收到后处理命令(’s’/‘r’/‘g’)
3.2 为什么不能用轮询?
轮询模式(Day 1-2 的做法):
1
2
3
4
| for(;;) {
if (HAL_UART_Receive(&huart1, &rx_byte, 1, 100) == HAL_OK) { ... }
osDelay(10); /* 每 10ms 醒一次检查,没数据也浪费 CPU */
}
|
中断模式:
- 没数据时 Task_CMD 阻塞在
osSemaphoreAcquire,CPU 给其他任务 - 有数据时中断立刻唤醒 Task_CMD,零延迟、零空转
3.3 核心代码
freertos.c Variables 区域:
1
2
| static osSemaphoreId_t _sem_uart_rx = NULL;
uint8_t rx_byte = 0;
|
freertos.c Init 区域:
1
| _sem_uart_rx = osSemaphoreNew(1, 0, NULL); /* max=1, initial=0 */
|
usart.c 中断回调(USER CODE BEGIN 1):
1
2
3
4
5
6
7
8
9
10
11
12
13
| #include "cmsis_os.h"
extern osSemaphoreId_t _sem_uart_rx;
extern uint8_t rx_byte;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
osSemaphoreRelease(_sem_uart_rx); /* 释放信号量,唤醒任务 */
HAL_UART_Receive_IT(&huart1, &rx_byte, 1); /* 必须重新启动中断接收 */
}
}
|
freertos.c Task_CMD:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| static void _task_cmd_handler(void *argument)
{
(void)argument;
for (;;)
{
if (osSemaphoreAcquire(_sem_uart_rx, osWaitForever) == osOK)
{
if (rx_byte == 's')
{
osThreadSuspend(_task_consumer_id);
}
else if (rx_byte == 'r')
{
osThreadResume(_task_consumer_id);
}
else if (rx_byte == 'g')
{
osEventFlagsSet(_event_rdy_flags, EVENT_CMD_READY);
}
}
}
}
|
freertos.c 启动第一次中断接收(在 MX_FREERTOS_Init 末尾):
1
2
3
| /* USER CODE BEGIN RTOS_EVENTS */
HAL_UART_Receive_IT(&huart1, &rx_byte, 1); /* 启动第一次中断接收 */
/* USER CODE END RTOS_EVENTS */
|
3.4 错误记录
错误 1:usart.c 没包含 cmsis_os.h,也没 extern 变量
编译报错:
1
2
3
| implicit declaration of function 'osSemaphoreRelease'
'_sem_uart_rx' undeclared
'_rx_byte' undeclared
|
修复:在 usart.c 的 USER CODE BEGIN 0 区域加:
1
2
3
| #include "cmsis_os.h"
extern osSemaphoreId_t _sem_uart_rx;
extern uint8_t rx_byte;
|
错误 2:第一次中断接收没启动
HAL_UART_RxCpltCallback 只在接收完成后重新启动下一次。上电后第一次接收是谁启动的?没有人启动。
修复:在 MX_FREERTOS_Init 最后调用 HAL_UART_Receive_IT(&huart1, &rx_byte, 1)。
错误 3:中断里不能调用阻塞 API
绝对禁止在中断里调用 osSemaphoreAcquire、osDelay、osMessageQueueGet 等阻塞函数。中断必须快速返回,只能调用 非阻塞 的 osSemaphoreRelease(或 FromISR 后缀的 API)。
3.5 面试核心考点
“为什么中断里不能用 osSemaphoreAcquire?”
因为 osSemaphoreAcquire 是阻塞等待,如果中断里阻塞,中断无法返回,系统卡死。中断里只能用非阻塞的 osSemaphoreRelease(或 FromISR 后缀的 API)。
“二值信号量和 Mutex 有什么区别?”
- 二值信号量用于同步(一个任务等、一个任务/中断发),没有优先级继承;Mutex 用于互斥(保护共享资源),有优先级继承。
- Mutex 只能由获取它的任务释放;二值信号量可以由任何任务/中断释放。
- Mutex 不能在中断中使用;二值信号量可以。
Day 4:Mutex(互斥保护 UART)
4.1 实验目标
多个任务同时调用 HAL_UART_Transmit 时,输出会穿插混乱。用 Mutex 保护串口打印,确保每次输出完整。
4.2 为什么全局变量要用 Mutex 而不是直接关中断?
| 保护方式 | 适用场景 | 原理 | 对中断影响 |
|---|
| 临界区(关中断) | 极短操作(几微秒)、中断也参与的共享资源 | 屏蔽调度,原子执行 | 中断被屏蔽,高优先级中断响应延迟 |
| Mutex | 任务间共享资源、操作时间较长(毫秒级) | 调度器层面阻塞等待 | 不影响中断,只阻塞其他任务 |
具体例子:
- 用临界区保护 1KB 的
memcpy:中断被屏蔽 100us,可能错过 PID 时序 - 用 Mutex 保护 1KB 的
memcpy:任务 B 阻塞等待,中断完全不受影响
4.3 核心代码
freertos.c Variables 区域:
1
| static osMutexId_t _mutex_uart_id = NULL;
|
freertos.c Init 区域:
1
| _mutex_uart_id = osMutexNew(NULL);
|
封装安全打印函数:
1
2
3
4
5
6
7
8
9
10
11
12
| static void _safe_printf(const char *fmt, ...)
{
char buf[128];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
osMutexAcquire(_mutex_uart_id, osWaitForever);
HAL_UART_Transmit(&huart1, (uint8_t *)buf, strlen(buf), 200);
osMutexRelease(_mutex_uart_id);
}
|
Producer 和 Consumer 改用 Mutex:
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
| static void _task_producer_handler(void *argument)
{
(void)argument;
SensorData_t sensor_data = {0};
for (;;)
{
sensor_data.timestamp = HAL_GetTick();
sensor_data.value++;
osMutexAcquire(_mutex_uart_id, osWaitForever);
const char msg[] = "[PRODUCER] Sending data to queue...\r\n";
HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 200);
osMutexRelease(_mutex_uart_id);
osMessageQueuePut(_queue_cnt_id, &sensor_data, 0, 100);
osDelay(1000);
}
}
static void _task_consumer_handler(void *argument)
{
(void)argument;
char buf[64];
uint8_t len = 0;
SensorData_t sensor_data = {0};
for (;;)
{
osMessageQueueGet(_queue_cnt_id, &sensor_data, NULL, osWaitForever);
osMutexAcquire(_mutex_uart_id, osWaitForever);
len = snprintf(buf, sizeof(buf),
"[CONSUMER] [timestamp: %lu] Value: %d\r\n",
sensor_data.timestamp, sensor_data.value);
HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 100);
osMutexRelease(_mutex_uart_id);
}
}
|
4.4 踩坑记录
坑 1:试图制造 UART 输出冲突但失败
最初 Producer 和 Consumer 打印频率太低(1 秒一次),HAL_UART_Transmit 传输时间太短(40 字节 @ 115200 约 4ms),被抢占的概率极低,肉眼看不到穿插。
制造冲突的方法:
- 两个任务都打印长字符串(100+ 字节)
- 打印间隔缩短到 100ms 或更短
- 同优先级 + 时间片轮转,强制每 1ms 切换
坑 2:Mutex 不能在中断里使用
1
2
3
4
| void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
osMutexAcquire(_mutex_uart_id, ...); /* 死机! */
}
|
中断里调用阻塞 API = 系统卡死。中断里只能用非阻塞的 osSemaphoreRelease。
坑 3:忘记 Release
1
2
3
| osMutexAcquire(_mutex_uart_id, osWaitForever);
HAL_UART_Transmit(...);
/* 函数返回,忘记 Release */
|
其他任务永远拿不到锁,系统"假死"。
4.5 面试核心考点
“Mutex 和二值信号量有什么区别?”
- Mutex 有优先级继承,防止优先级翻转;二值信号量没有。
- Mutex 只能由获取它的任务释放;二值信号量可以由任何任务/中断释放。
- Mutex 不能在中断中使用;二值信号量可以(用
FromISR)。 - Mutex 用于互斥保护资源;二值信号量用于任务同步。
Day 5:Event Group(多事件同步)
5.1 实验目标
一个任务同时等待多个事件满足才执行。场景:
- 条件 A:传感器数据就绪(Producer 每 1 秒设置
EVENT_DATA_READY) - 条件 B:用户按下按键(串口发 ‘g’ 设置
EVENT_CMD_READY) - Task_Waiter:必须等两个条件都满足才执行
5.2 Event Group vs Queue:本质区别
| 维度 | Event Group | Queue |
|---|
| 专业分类 | Synchronization Primitive(同步原语) | IPC Mechanism(进程间通信机制) |
| 传递内容 | 32 个标志位(0/1),无数据 | 任意数据块,有数据 |
| 缓冲能力 | 无缓冲,标志位是状态,不会累积 | 有缓冲,可以积压多个消息 |
| 核心用途 | 多任务/多事件同步(“等条件满足”) | 任务间数据传递(“把数据给你”) |
| 典型场景 | “传感器就绪 且 网络连接 且 用户确认” | 传感器把温度值传给显示任务 |
| API 语义 | Set(置位)、Wait(等待)、Clear(清除) | Put(发送)、Get(接收) |
一句话:Event Group 是信号灯(告诉你"可以走了"),Queue 是货车(把货物运过去)。
5.3 核心代码
定义事件标志:
1
2
| #define EVENT_CMD_READY (1 << 0) /* 0x01 */
#define EVENT_DATA_READY (1 << 1) /* 0x02 */
|
freertos.c Variables 区域:
1
| static osEventFlagsId_t _event_rdy_flags = NULL;
|
freertos.c Init 区域:
1
| _event_rdy_flags = osEventFlagsNew(NULL);
|
Producer 设置数据就绪标志:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| static void _task_producer_handler(void *argument)
{
(void)argument;
SensorData_t sensor_data = {0};
for (;;)
{
sensor_data.timestamp = HAL_GetTick();
sensor_data.value++;
osMutexAcquire(_mutex_uart_id, osWaitForever);
const char msg[] = "[PRODUCER] Sending data to queue...\r\n";
HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 200);
osMutexRelease(_mutex_uart_id);
osEventFlagsSet(_event_rdy_flags, EVENT_DATA_READY); /* 设置事件标志 */
osMessageQueuePut(_queue_cnt_id, &sensor_data, 0, osWaitForever);
osDelay(1000);
}
}
|
Task_CMD 设置命令就绪标志:
1
2
3
4
5
6
| else if (rx_byte == 'g')
{
char msg[] = "CMD Event Run!\n";
HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 100);
osEventFlagsSet(_event_rdy_flags, EVENT_CMD_READY); /* 设置事件标志 */
}
|
Task_Waiter 等待两个条件:
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
| static void _task_waiter_handler(void *argument)
{
(void)argument;
for (;;)
{
/* 等待 CMD_READY 和 DATA_READY 同时置位,超时 5000ms */
uint32_t flags = osEventFlagsWait(_event_rdy_flags,
EVENT_CMD_READY | EVENT_DATA_READY,
osFlagsWaitAll | osFlagsNoClear,
5000);
if ((flags & (EVENT_CMD_READY | EVENT_DATA_READY))
== (EVENT_CMD_READY | EVENT_DATA_READY))
{
const char msg[] = "[WAITER] Both conditions met! Processing...\r\n";
HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 200);
/* 手动清除标志 */
osEventFlagsClear(_event_rdy_flags,
EVENT_CMD_READY | EVENT_DATA_READY);
}
else if (flags == osFlagsErrorTimeout)
{
const char msg[] = "[WAITER] Timeout waiting for conditions\r\n";
HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 200);
}
}
}
|
5.4 踩坑记录
错 1:不清除标志导致重复触发
如果 osFlagsNoClear + 不清除:
1
2
| osEventFlagsWait(..., osFlagsWaitAll | osFlagsNoClear, ...);
/* 返回后不清除 */
|
后果:下次 Wait 立刻返回,Task_Waiter 反复执行。
修复:返回后手动 osEventFlagsClear,或者去掉 osFlagsNoClear(让 Wait 自动清除)。
5.5 验证现象
| 操作 | 预期现象 |
|---|
| 上电后什么都不做 | 每 1 秒 Producer 设置 DATA_READY,Waiter 阻塞,5 秒后打印 Timeout |
| 串口发 ‘g’ | 设置 CMD_READY。如果 DATA_READY 已被设置 → Waiter 立即打印 Both conditions met! 并 Clear |
发 ‘g’ 后 5 秒内 Producer 再次设置 DATA_READY | Waiter 再次触发(因为 CMD_READY 被清除了,需要重新等) |
| 连续快速发 ‘g’ | 第一次触发 Waiter,后续发 ‘g’ 只设置 CMD_READY,Waiter 继续阻塞直到下一个 DATA_READY |
Week 2 总结
| 天数 | 机制 | 分类 | 核心用途 | 面试必问 |
|---|
| Day 1 | Queue | IPC | 任务间数据传递 | Queue vs 全局变量? |
| Day 2 | Queue(结构体) | IPC | 传递复杂数据包 | 结构体对齐、数据拷贝安全性 |
| Day 3 | Binary Semaphore | 同步原语 | 中断与任务同步 | 中断里为什么不能用阻塞 API? |
| Day 4 | Mutex | 互斥机制 | 保护共享资源 | Mutex vs 二值信号量? |
| Day 5 | Event Group | 同步原语 | 多条件组合等待 | Event Group vs Queue? |