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: 0Count: 1Count: 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.cUSER 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

绝对禁止在中断里调用 osSemaphoreAcquireosDelayosMessageQueueGet 等阻塞函数。中断必须快速返回,只能调用 非阻塞osSemaphoreRelease(或 FromISR 后缀的 API)。

3.5 面试核心考点

“为什么中断里不能用 osSemaphoreAcquire?”

因为 osSemaphoreAcquire 是阻塞等待,如果中断里阻塞,中断无法返回,系统卡死。中断里只能用非阻塞的 osSemaphoreRelease(或 FromISR 后缀的 API)。

“二值信号量和 Mutex 有什么区别?”

  1. 二值信号量用于同步(一个任务等、一个任务/中断发),没有优先级继承;Mutex 用于互斥(保护共享资源),有优先级继承。
  2. Mutex 只能由获取它的任务释放;二值信号量可以由任何任务/中断释放。
  3. 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 和二值信号量有什么区别?”

  1. Mutex 有优先级继承,防止优先级翻转;二值信号量没有。
  2. Mutex 只能由获取它的任务释放;二值信号量可以由任何任务/中断释放。
  3. Mutex 不能在中断中使用;二值信号量可以(用 FromISR)。
  4. 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 GroupQueue
专业分类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_READYWaiter 再次触发(因为 CMD_READY 被清除了,需要重新等)
连续快速发 ‘g’第一次触发 Waiter,后续发 ‘g’ 只设置 CMD_READY,Waiter 继续阻塞直到下一个 DATA_READY

Week 2 总结

天数机制分类核心用途面试必问
Day 1QueueIPC任务间数据传递Queue vs 全局变量?
Day 2Queue(结构体)IPC传递复杂数据包结构体对齐、数据拷贝安全性
Day 3Binary Semaphore同步原语中断与任务同步中断里为什么不能用阻塞 API?
Day 4Mutex互斥机制保护共享资源Mutex vs 二值信号量?
Day 5Event Group同步原语多条件组合等待Event Group vs Queue?