news 2026/10/29 10:58:03

STM32 FreeRTOS 实战学习笔记:从任务调度到任务间通信(CMSIS-RTOS V2)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 FreeRTOS 实战学习笔记:从任务调度到任务间通信(CMSIS-RTOS V2)

一、前言

在 STM32 裸机开发中,程序通常由主循环和中断服务函数组成。随着项目功能增加,传感器采集、数据处理、OLED 显示、串口通信等功能逐渐耦合在一起,容易出现代码结构混乱、任务响应不及时、共享资源冲突等问题。

FreeRTOS 是一种面向嵌入式系统的实时操作系统内核。它允许开发者将系统功能拆分成多个任务,由调度器管理任务的运行,并通过消息队列、信号量、互斥量等机制实现任务间通信与同步。

本文结合 STM32F103VET6、STM32CubeMX、Keil 和 CMSIS-RTOS V2 的学习过程,重点整理任务调度、任务创建、上下文切换、任务间通信以及中断与任务协作的基础知识。

与单纯介绍概念不同,本文重点回答以下问题:

  1. FreeRTOS 的基本运行机制是什么?

  2. 实际开发中应该如何创建和管理任务?

  3. 消息队列、信号量、互斥量和线程标志分别适合什么场景?

  4. 如何通过代码组织任务间的协作?

  5. 如何使用 Keil Watch、断点和调试变量验证运行结果?

  6. 编写 FreeRTOS 程序时,哪些错误容易导致任务阻塞、事件丢失或系统异常?

本文使用 CMSIS-RTOS V2 接口进行示例说明。不同 STM32CubeMX 版本生成的工程结构可能有所不同,实际使用时应以工程中的cmsis_os2.h、FreeRTOS 配置和适配层实现为准。


二、FreeRTOS 的基本运行机制

2.1 裸机程序与 RTOS 程序的区别

裸机程序通常采用前后台结构:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); while (1) { LED_Process(); Sensor_Process(); UART_Process(); } }

这种结构比较直观,但当某项业务需要长时间等待时,如果采用忙等待,CPU 可能一直停留在该业务中,其他业务的响应也会受到影响。

引入 FreeRTOS 后,可以把不同功能拆分成独立任务:

void StartLEDTask(void *argument) { for (;;) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_5); osDelay(500); } } void StartSensorTask(void *argument) { for (;;) { // 读取传感器 osDelay(10); } }

两个任务拥有各自的执行上下文。任务调用osDelay()后进入阻塞态,调度器便可以安排其他就绪任务运行。

需要注意,osDelay()的参数是内核 Tick 数,而不是天然以毫秒为单位。只有在 Tick 频率为 1000 Hz 时,500 Tick 才约等于 500 ms。

2.2 FreeRTOS、CMSIS-RTOS V2 与 HAL 的关系

在 STM32CubeMX 生成的工程中,可以把软件层次理解为:

应用层 ├── 传感器采集任务 ├── OLED 显示任务 └── 串口通信任务 | v CMSIS-RTOS V2 API ├── osThreadNew() ├── osMessageQueuePut() ├── osSemaphoreAcquire() └── osMutexAcquire() | v FreeRTOS 内核及 CMSIS 适配层 | v Cortex-M3 处理器

HAL 库负责访问 STM32 外设,例如 GPIO、I2C、USART;FreeRTOS 负责任务调度和同步;CMSIS-RTOS V2 提供统一的 RTOS API。

两套接口需要区分:

CMSIS-RTOS V2FreeRTOS 原生 API
osThreadNew()xTaskCreate()
osDelay()vTaskDelay()
osMessageQueuePut()xQueueSend()
osSemaphoreAcquire()xSemaphoreTake()
osMutexAcquire()xSemaphoreTake(),配合互斥量句柄

两套接口解决的问题相似,但参数、返回值和具体使用规则并不完全相同。一个工程应优先保持接口风格统一。

2.3 FreeRTOS 工程的启动流程

使用 STM32CubeMX 生成工程时,典型流程如下:

  1. 初始化 HAL 和系统时钟。

  2. 初始化 GPIO、I2C、USART 等硬件外设。

  3. 初始化 RTOS 内核。

  4. 创建任务以及消息队列、信号量、互斥量等对象。

  5. 调用osKernelStart()启动调度器。

  6. 调度器开始管理各个任务的运行。

CubeMX 生成的main.c和freertos.c通常已经包含相应初始化代码。实际工程中应先阅读生成代码,再将业务代码放入对应的USER CODE区域,避免重新生成代码时覆盖自己的实现。


三、任务、任务栈与任务控制块

3.1 一个任务由什么组成?

任务并不只是一个 C 函数。

一个任务通常涉及:

  • 任务入口函数:定义需要执行的业务。

  • 独立的任务栈:保存函数调用、局部变量和上下文等信息。

  • 任务控制块 TCB:保存调度器管理任务所需的信息。

  • 优先级、任务状态和任务标识等管理信息。

每个任务都有自己的任务栈和 TCB。TCB 中包含指向任务栈当前保存位置的相关信息,但 TCB 并不是任务栈本身。

可以把任务栈理解为任务的工作现场,把 TCB 理解为调度器管理该任务的记录。

3.2 任务函数的基本结构

CMSIS-RTOS V2 的任务入口函数通常采用以下形式:

void StartCountTask(void *argument) { for (;;) { // 执行当前任务的业务逻辑 } }

argument是创建任务时传入的参数。如果不需要传递参数,可以传入NULL。

长期运行的任务一般采用无限循环。对于周期性任务,应在循环中合理使用阻塞延时;对于事件驱动任务,则更适合阻塞等待消息、信号量或线程标志。

例如:

void StartLEDTask(void *argument) { for (;;) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_5); osDelay(500); } }

这个任务每次翻转 LED 后等待一段时间。延时期间,当前任务不再参与正常的 CPU 竞争。

3.3 任务的四种常见状态

状态英文含义
运行态Running当前正在 CPU 上执行
就绪态Ready具备运行条件,等待获得 CPU
阻塞态Blocked等待时间或某个事件
挂起态Suspended被显式挂起,需要显式恢复

最容易混淆的是就绪态和运行态。

就绪态表示任务已经具备运行条件,但尚未获得 CPU;运行态表示任务当前正在执行。

例如:

任务 A 正在运行 | v 调用 osDelay(100) | v A 进入阻塞态 | v 调度器选择其他就绪任务 | v 延时时间到 | v A 变为就绪态 | v 等待调度器安排运行

阻塞态变为就绪态,并不意味着任务一定立即运行。如果此时有更高优先级的就绪任务,调度器会优先安排更高优先级任务。

挂起态与阻塞态也不完全相同。阻塞态通常等待时间或事件到来,而挂起态需要通过相应的恢复操作才能重新参与调度。


四、任务调度与优先级

4.1 抢占式调度的基本规则

在启用抢占式调度的典型单核 FreeRTOS 系统中,调度器优先选择当前最高优先级的就绪任务运行。

分析任务运行情况时,可以按以下顺序思考:

  1. 哪些任务处于就绪态?

  2. 这些任务中,谁的优先级最高?

  3. 如果有多个任务优先级相同,系统如何调度?

  4. 当前运行任务是否因为延时、等待事件或挂起而失去运行资格?

例如:

任务 A:低优先级,Running 任务 B:高优先级,Blocked

当 B 等待的事件到来后:

B:Blocked -> Ready | v B 的优先级高于 A | v 调度器安排 B 运行

实际切换发生的具体时机还取决于调度器配置和当前执行上下文。

4.2 同优先级时间片轮转

如果两个任务优先级相同,并且都处于就绪状态,在启用抢占和时间片轮转的配置下,系统 Tick 可以促使它们轮流获得 CPU。

但时间片长度并不是固定为 10 ms。

例如:

  • Tick 频率为 100 Hz 时,一个 Tick 对应 10 ms。

  • Tick 频率为 1000 Hz 时,一个 Tick 对应 1 ms。

同优先级任务的轮转行为还与configUSE_PREEMPTION、configUSE_TIME_SLICING等配置有关。因此,不能脱离实际工程配置直接认定时间片是多少。

另外,如果一个高优先级任务一直处于就绪状态,低优先级任务可能长期得不到运行机会,这就是任务饥饿。

4.3 为什么不能让任务无限空转?

例如:

void StartCountTask(void *argument) { for (;;) { count_t++; } }

这段代码会持续占用 CPU。

如果没有更高优先级任务抢占它,而且任务始终保持就绪状态,那么低优先级任务可能得不到足够的运行机会。

对于真实项目,应根据需求选择合适的运行模式:

  • 周期性工作:合理使用osDelay()或osDelayUntil()。

  • 等待事件:使用消息队列、信号量或线程标志。

  • 保护共享资源:使用互斥量等同步机制。

不要为了让任务“持续运行”,就让它毫无意义地反复检查状态。

4.4osDelay()与周期性执行

osDelay()的参数单位是内核 Tick。

如果内核 Tick 频率为 1000 Hz:

osDelay(100);

表示请求延时 100 Tick,约为 100 ms。

实际恢复时间还会受到调用时刻与 Tick 边界的影响,因此不能将它当作精确的硬件定时器。

对于需要保持相对稳定周期的任务,可以使用osDelayUntil():

void StartSensorTask(void *argument) { uint32_t nextWakeTime = osKernelGetTickCount(); const uint32_t periodTicks = 10U; for (;;) { // 执行采样工作 nextWakeTime += periodTicks; osDelayUntil(nextWakeTime); } }

这种方式以目标唤醒时间为基准安排周期任务,有助于避免每次执行时间都叠加到下一周期。

使用时仍需要保证工作负载与周期匹配,并检查实际采样频率。


五、任务创建、句柄、优先级与栈大小

5.1 使用osThreadAttr_t配置任务

CMSIS-RTOS V2 通过osThreadNew()创建任务。

例如:

osThreadId_t CountTaskHandle = NULL; const osThreadAttr_t CountTask_attributes = { .name = "CountTask", .priority = osPriorityNormal, .stack_size = 1024U };

创建任务:

CountTaskHandle = osThreadNew( StartCountTask, NULL, &CountTask_attributes ); if (CountTaskHandle == NULL) { // 任务创建失败,执行错误处理 }

这里的CountTaskHandle是任务句柄,用于后续挂起、恢复、修改优先级或进行其他任务管理操作。

句柄不是任务本身,也不是任务栈。它是用于引用该任务的标识。

5.2 栈大小的单位与作用

CMSIS-RTOS V2 中,osThreadAttr_t.stack_size的单位是字节。

例如:

.stack_size = 1024U

表示请求为任务配置 1024 字节的栈空间。

栈大小决定了任务可用于函数调用、局部变量和上下文等信息的空间,并不决定任务的运行速度或优先级。

栈太小可能造成栈溢出;栈设置过大则会占用不必要的 RAM。

合理的栈大小应根据实际任务负载、函数调用深度、局部数组和运行时检查结果确定,而不是所有任务都设置成相同大小。

5.3 任务句柄为什么重要?

假设系统创建了两个名字相同的任务:

Task A ├── 独立的 TCB └── 独立的任务栈 Task B ├── 独立的 TCB └── 独立的任务栈

它们即使执行同一个入口函数,也拥有各自的任务现场。

如果任务 B 要挂起任务 A,就需要使用 A 的任务句柄,而不能只依赖任务名称。

5.4 创建任务时的常见错误

  • 忘记检查osThreadNew()是否返回NULL。

  • 将任务名称与任务入口函数混淆。

  • 任务栈设置过小,导致运行异常。

  • 使用尚未初始化的任务句柄。

  • 创建任务后没有启动内核,误以为任务会立即运行。

在 CubeMX 生成的工程中,通常先创建 RTOS 对象和任务,再启动内核。启动调度器前创建的任务一般先进入就绪状态,真正执行由调度器决定。


六、任务挂起与恢复

6.1 基本用法

CMSIS-RTOS V2 提供:

osThreadSuspend(CountTaskHandle); osThreadResume(CountTaskHandle);

假设任务 A 负责计数,任务 B 负责控制暂停和恢复:

// 暂停任务 A osThreadSuspend(CountTaskHandle); // 恢复任务 A osThreadResume(CountTaskHandle);

这两个接口适合学习显式任务控制,但并不意味着实际项目中所有暂停、恢复需求都应该使用它们。

6.2 使用时要注意什么?

第一,必须确保任务句柄有效。

第二,挂起任务后,该任务不再正常参与调度。

第三,不能把挂起与普通事件等待混为一谈。对于等待传感器数据、串口接收或按键事件的任务,通常更适合使用信号量、消息队列或线程标志。

尤其要注意:显式挂起一个正在等待事件的任务,可能改变其原有等待状态。恢复任务也不等于恢复原来的事件等待过程。

因此,挂起与恢复更适合明确的任务控制需求,而不应该替代正常的任务间同步机制。


七、上下文切换:Tick、PendSV、PSP 与 MSP

7.1 为什么任务切换需要保存上下文?

假设任务 A 正在执行一个函数,中途被更高优先级任务 B 抢占。

当 A 再次获得 CPU 时,程序必须能够从之前被打断的位置继续执行,而不是重新从任务入口函数开始。

因此,操作系统必须保存任务的执行现场,并在任务恢复时重新加载。

这就是上下文切换的核心目的。

7.2 Tick 与 PendSV 的作用

在常见的 Cortex-M3 FreeRTOS 移植中:

  • SysTick 通常用于产生 RTOS 系统 Tick。

  • Tick 用于维护内核时间,并参与延时任务的唤醒和调度。

  • PendSV 是用于执行上下文切换的重要异常。

需要注意,并不是每一次 Tick 都必然发生任务切换。

如果当前运行任务仍然是应该运行的任务,就不一定需要切换。只有调度器判断需要更换运行任务时,才会请求执行相应的上下文切换流程。

7.3 PSP 与 MSP 的区别

Cortex-M 处理器提供两个重要的栈指针:

  • MSP:Main Stack Pointer。

  • PSP:Process Stack Pointer。

在典型的 FreeRTOS Cortex-M3 任务运行环境中,任务线程模式使用 PSP,而异常和中断处理程序通常使用 MSP。

可以这样理解:

任务 A 正在运行 | v PSP 指向任务 A 的栈 | v 发生异常或中断 | v 硬件保存异常返回所需的基本上下文 | v 异常处理通常使用 MSP | v 需要切换任务时执行 PendSV | v 保存 A 的任务上下文 | v 恢复 B 的任务上下文

7.4 硬件与软件分别保存什么?

在典型 Cortex-M3 异常进入过程中,硬件会自动保存基本异常栈帧,包括:

  • R0~R3

  • R12

  • LR

  • PC

  • xPSR

在 FreeRTOS 的典型上下文切换过程中,移植层还需要保存和恢复其他需要保留的寄存器,例如 R4~R11。

任务的 PSP 以及相关上下文保存位置会与 TCB 关联。切换回该任务时,内核恢复对应的栈指针和寄存器状态,使任务能够继续执行。

需要区分两个概念:

发生中断不等于发生任务切换。

如果中断返回后仍然执行原来的任务,硬件会恢复异常返回所需的上下文;只有需要切换任务时,才进一步执行相应的任务上下文切换过程。

这些知识帮助我们理解 RTOS 的工作原理。实际业务开发通常使用 CMSIS-RTOS V2 API,不需要每次调用 API 时手动操作 PSP、MSP 或 PendSV。


八、任务间通信机制的选择

任务间通信不是 API 越多越好。真正重要的是根据需求选择合适的机制。

机制主要用途是否保存具体数据是否累计事件
线程标志通知指定任务某些条件已满足不保存任意业务数据同一标志位不会累计次数
二值信号量简单事件同步不保存业务数据最多一个可用令牌
计数信号量累计事件次数或管理同类资源数量不保存业务数据可以累计至最大计数
消息队列任务间传递数据可以消息按队列规则排队
互斥量保护共享资源不保存业务数据不用于累计事件

例如,在 MPU6500 + OLED 项目中:

  • 数据就绪时只需通知采集任务,可以考虑线程标志。

  • 多次按键事件必须分别处理,可以考虑计数信号量。

  • 采集任务要将六轴数据交给显示任务,可以使用消息队列。

  • 多个任务需要访问同一个共享外设时,可以根据实际资源关系使用互斥量。

8.1 二值信号量与计数信号量

二值信号量的计数范围是 0 或 1。

创建一个初始没有令牌的二值信号量:

osSemaphoreId_t eventSemHandle = NULL; eventSemHandle = osSemaphoreNew( 1U, // 最大计数 0U, // 初始计数 NULL );

如果任务 B 成功释放一次信号量,计数从 0 变成 1。任务 A 成功获取后,计数从 1 变成 0。

如果生产者连续释放多次,而消费者没有及时获取,二值信号量无法累计任意多次事件。达到最大计数后,额外的释放会失败。

当每次事件都必须单独处理时,可以考虑计数信号量。

例如,要求最多累计 5 次待处理事件:

eventSemHandle = osSemaphoreNew( 5U, // 最大计数 0U, // 初始计数 NULL );

生产者释放事件:

osStatus_t status; status = osSemaphoreRelease(eventSemHandle); if (status == osOK) { // 事件成功记录 } else { // 释放失败,需要分析原因 }

消费者等待事件:

void StartCountTask(void *argument) { for (;;) { if (osSemaphoreAcquire( eventSemHandle, osWaitForever) == osOK) { count_t++; } } }

每次成功获取信号量,才执行一次自增。

需要注意,计数信号量不是无限容量的事件记录器。生产者必须检查释放结果,最大计数也要满足实际需求。

8.2 消息队列:为什么适合传递结构体?

消息队列适用于任务之间传递数值、结构体或其他固定大小的消息。

假设采集任务需要把 MPU6500 六轴数据传递给显示任务:

typedef struct { int16_t ax; int16_t ay; int16_t az; int16_t gx; int16_t gy; int16_t gz; } MPU6500_Message_t;

创建队列:

osMessageQueueId_t mpuQueueHandle = NULL; mpuQueueHandle = osMessageQueueNew( 4U, sizeof(MPU6500_Message_t), NULL );

参数含义:

  • 4U:最多保存 4 条消息。

  • sizeof(MPU6500_Message_t):每条消息占用的字节数。

  • NULL:使用默认队列属性。

队列的消息存储容量约为 4 条消息乘以每条消息的大小,实际内存开销还包括队列管理信息和可能的对齐开销。

发送消息:

MPU6500_Message_t data = { 100, 200, 16000, -20, 35, -10 }; osStatus_t putStatus; putStatus = osMessageQueuePut( mpuQueueHandle, &data, 0U, 0U ); if (putStatus == osOK) { // 发送成功 } else { // 队列满或发生其他错误 }

这里的&data表示数据源地址。队列会按照创建时规定的消息大小,将消息内容复制到内部存储空间。

因此,发送完成后,局部变量data的生命周期结束,并不会导致队列中已复制的结构体自动失效。

接收消息:

MPU6500_Message_t receiveData; osStatus_t getStatus; getStatus = osMessageQueueGet( mpuQueueHandle, &receiveData, NULL, osWaitForever ); if (getStatus == osOK) { // 使用 receiveData 中的数据 }

receiveData是接收缓冲区,队列会将消息内容复制到该变量中。

osWaitForever表示如果队列暂时为空,接收任务可以一直等待。任务进入阻塞态后,不需要不断轮询队列。

如果消息优先级都设置为 0,消息按先进先出的方式排队;CMSIS-RTOS V2 还支持消息优先级,因此不能在所有情况下都把队列理解为严格忽略优先级的 FIFO。

8.3 为什么发送和接收计数会持续增加?

假设发送任务每次循环都执行:

queueSendSuccessCount++;

只要发送持续成功,这个计数就会持续增加。

接收任务也是如此。

即使发送的结构体每次都是:

MPU6500_Message_t data = { 100, 200, 16000, -20, 35, -10 };

它仍然可以被重复发送很多次。结构体中的数值没有变化,不代表队列只保存或传递一次消息。

因此,调试消息队列时,应分别观察:

  • 发送成功次数。

  • 接收成功次数。

  • 当前队列中的消息数量。

  • 每条消息的实际数据。

  • 队列满时是否发生发送失败。

发送次数与接收次数不一定在任意时刻相等,因为可能存在尚未被消费的消息,或者发送失败、接收失败等情况。


九、互斥量与共享资源保护

9.1 为什么需要互斥量?

当多个任务需要访问同一共享资源,而且并发访问可能导致冲突时,就需要考虑资源保护。

例如,两个任务同时操作同一条 I2C 总线,可能导致总线事务交叉、外设状态混乱,或者共享驱动内部状态发生冲突。

互斥量的作用是保证同一时刻只有持有互斥量的任务可以进入受保护的资源访问区域。

9.2 基本使用方法

创建互斥量:

osMutexId_t busMutexHandle = NULL; busMutexHandle = osMutexNew(NULL); if (busMutexHandle == NULL) { // 创建失败,执行错误处理 }

使用互斥量:

osStatus_t acquireStatus; osStatus_t releaseStatus; acquireStatus = osMutexAcquire( busMutexHandle, osWaitForever ); if (acquireStatus == osOK) { // 执行需要保护的外设操作 releaseStatus = osMutexRelease(busMutexHandle); if (releaseStatus != osOK) { // 释放失败,需要检查错误 } }

这里最重要的原则是:只有成功获取互斥量之后,才可以进入受保护区域并执行对应的释放操作。

不能在获取失败后仍然无条件调用osMutexRelease()。

实际项目中,如果临界区内存在多个错误退出路径,也必须保证资源能够正确释放。

9.3 临界区应该尽可能短

获得互斥量后,应尽快完成需要保护的操作并释放它。

例如:

if (osMutexAcquire(busMutexHandle, osWaitForever) == osOK) { HAL_StatusTypeDef status; status = MPU6500_ReadRawData(&rawData); osMutexRelease(busMutexHandle); if (status == HAL_OK) { // 在锁外进行数据换算和处理 } }

上面只是结构示例,实际函数名和数据类型需要与驱动保持一致。

数据换算、浮点计算和 OLED 字符格式化等工作,如果不需要访问共享总线,就不应无故放在持锁区域内。

另外,互斥量具有所有权语义:通常只能由成功获取该互斥量的任务释放,不能把它当成可以随意释放的普通信号量。

9.4 优先级反转与优先级继承

假设系统有三个任务:

任务优先级作用
A低持有互斥量
B中执行普通计算
C高等待 A 持有的互斥量

当 C 因为无法获得互斥量而阻塞时,如果 B 一直抢占 A,A 就无法及时释放互斥量,C 的等待时间也会延长。

这就是典型的优先级反转。

优先级继承机制可以暂时提高持锁任务 A 的有效优先级,使 A 有机会尽快完成资源访问并释放互斥量。

但优先级继承不是把锁直接交给 C,也不能替代合理的任务设计。

关于 CMSIS-RTOS V2 的配置,需要特别注意:

  • osMutexPrioInherit是 CMSIS-RTOS V2 定义的互斥量属性。

  • CMSIS-RTOS V2 标准中的默认属性与具体适配层的实现行为需要区分。

  • 在 FreeRTOS 适配层中,标准 FreeRTOS 互斥量通常具备优先级继承机制,但仍应检查实际使用的 CMSIS 适配代码和configUSE_MUTEXES配置。

  • 不应仅凭osMutexNew(NULL)这一行代码,就推断所有 CMSIS-RTOS V2 实现都采用相同的优先级继承配置。

9.5 互斥量与二值信号量的区别

二者都具有二值令牌特征,但用途不同:

  • 二值信号量通常用于任务同步或中断到任务的事件通知。

  • 互斥量用于保护共享资源,并具有所有权语义。

  • 标准 FreeRTOS 互斥量支持优先级继承,而二值信号量不具备这一机制。

  • 互斥量管理函数不能在 ISR 中调用。

因此,不能仅仅因为二值信号量和互斥量都只有一个令牌,就认为它们可以完全互换。


十、线程标志:通知指定任务某些条件已满足

10.1 线程标志的基本思想

CMSIS-RTOS V2 提供 Thread Flags,用于向指定任务发送事件标志。

例如:

#define FLAG_SENSOR_READY (1U << 0) #define FLAG_USER_START (1U << 1)

它们分别对应:

  • FLAG_SENSOR_READY:传感器已就绪。

  • FLAG_USER_START:收到启动指令。

一个任务可以等待其中一个或多个标志。

线程标志不保存任意业务数据,而是表示特定事件或条件是否已经发生。

10.2 等待任意一个标志

uint32_t flags; flags = osThreadFlagsWait( FLAG_SENSOR_READY | FLAG_USER_START, osFlagsWaitAny, osWaitForever );

osFlagsWaitAny表示等待任意一个指定标志。

只要其中一个标志满足条件,等待就可以结束。

10.3 等待全部标志

uint32_t flags; flags = osThreadFlagsWait( FLAG_SENSOR_READY | FLAG_USER_START, osFlagsWaitAll, osWaitForever );

osFlagsWaitAll表示等待所有指定标志都满足条件。

两个事件不必在同一时刻发生。例如先收到传感器就绪标志,再收到启动指令,只要等待期间两个标志都已设置,就可以满足条件。

默认情况下,成功等待后,相关标志会自动清除。如果设置了osFlagsNoClear,则需要根据设计决定何时手动清除标志。

10.4 为什么线程标志不能替代计数信号量?

假设任务 B 连续设置三次相同的标志:

osThreadFlagsSet(TaskAHandle, 0x01U); osThreadFlagsSet(TaskAHandle, 0x01U); osThreadFlagsSet(TaskAHandle, 0x01U);

如果任务 A 在这三次设置期间一直没有运行,也没有清除标志,那么相同的位仍然只是被设置为 1,不会累计成 3。

因此:

  • 只需要通知“事件发生了”,可以考虑线程标志。

  • 必须记录多个待处理事件的次数,应考虑计数信号量或其他能够保存事件数量的机制。

  • 需要传递每次事件的实际数据,应考虑消息队列或缓冲区。

线程标志是针对特定任务的标志。CMSIS-RTOS V2 还提供 Event Flags,用于更广泛的事件同步需求,两者不要混淆。


十一、中断与 FreeRTOS 任务协作

11.1 为什么不应该在中断中处理所有业务?

假设 MPU6500 产生数据就绪中断。

如果中断服务函数中直接完成 I2C 读取、浮点计算、OLED 刷新和串口输出,那么中断执行时间就会变长,影响其他中断和系统实时性。

更合理的方式是:

  1. 中断服务函数快速响应硬件事件。

  2. 中断通过合适的机制通知任务。

  3. 任务被唤醒后完成数据读取或后续处理。

  4. 任务根据需求将数据交给其他任务。

这体现了中断与任务的职责分离。

11.2 ISR 中可以调用哪些 CMSIS-RTOS V2 API?

是否可以在 ISR 中调用某个接口,必须依据 CMSIS-RTOS V2 对该接口的规定,并同时满足底层 FreeRTOS 的中断优先级约束。

常见情况如下:

APIISR 中的使用原则
osThreadFlagsSet()允许在 ISR 中调用
osSemaphoreRelease()允许在 ISR 中调用
osMessageQueuePut()允许在 ISR 中调用,但timeout必须为 0
osThreadFlagsWait()不能在 ISR 中调用
osDelay()不能在 ISR 中调用
osMutexAcquire()/osMutexRelease()不能在 ISR 中调用

osMessageQueueGet()等其他接口也应按照对应版本的 CMSIS 文档核对 ISR 使用条件。不能因为某个函数名称中没有FromISR,就认为它一定可以或一定不可以在中断中调用。

11.3 通过线程标志通知采集任务

假设传感器产生数据就绪中断,可以在中断处理路径中设置线程标志:

osThreadFlagsSet(SensorTaskHandle, FLAG_SENSOR_READY);

采集任务中等待:

void StartSensorTask(void *argument) { uint32_t flags; for (;;) { flags = osThreadFlagsWait( FLAG_SENSOR_READY, osFlagsWaitAny, osWaitForever ); if ((flags & 0x80000000U) == 0U) { // 处理本次传感器数据就绪事件 } } }

这里的示例省略了硬件中断入口及传感器驱动细节。实际工程中还需要确认任务句柄有效、错误码处理正确,以及中断优先级满足 FreeRTOS 的约束。

中断设置线程标志后,等待条件满足的任务会从阻塞态转为就绪态。任务是否立即执行,仍然取决于调度器。

11.4 如果中断需要传递实际数据怎么办?

假设 USART 接收中断收到了字符'A'。

线程标志只能通知任务“有事件发生”,不能自动保存这个字符。

这时可以采用:

USART 接收中断 | v 保存接收数据 | v 环形缓冲区 / 消息队列 | v 通知接收任务 | v 任务取出数据并解析

如果通过消息队列在 ISR 中发送数据,必须使用timeout = 0,并处理队列已满时的情况。

对于连续串口接收,环形缓冲区与任务通知配合也是常见的设计方式。实际选择应根据接收速率、数据量、丢包要求和 DMA 使用方式决定。


十二、如何使用 Keil Watch 验证 FreeRTOS 程序?

学习 RTOS 时,不能只根据程序是否编译通过判断实验是否成功。应该建立可观察、可验证的调试流程。

12.1 为什么 Watch 中有些变量看不到?

如果变量定义在任务函数内部:

void StartCountTask(void *argument) { uint32_t localCount = 0; for (;;) { localCount++; } }

它是局部变量,作用域只在对应函数内部。编译器优化也可能影响调试器能否准确显示变量。

为了在简单实验中方便观察,可以定义全局调试变量:

volatile uint32_t count_t = 0; volatile uint32_t queueSendSuccessCount = 0; volatile uint32_t queueReceiveCount = 0;

volatile可以减少编译器对这些变量读写的优化,使调试器更容易观察其变化。

但是,volatile不是互斥机制,也不能保证多任务之间的复合操作具有原子性。真实共享数据仍然需要根据场景选择适当的同步方式。

12.2 调试计数信号量

建议准备以下观察量:

volatile uint32_t releaseSuccessCount = 0; volatile uint32_t acquireSuccessCount = 0;

生产者每次成功释放后增加releaseSuccessCount,消费者每次成功获取后增加acquireSuccessCount。

如果生产者释放三次,且消费者成功获取三次,最终两个计数都应该达到 3。

但需要注意,生产者与消费者可能并发运行。若想观察信号量在特定时刻的计数,需要结合断点或明确的同步步骤,不能假定某个观察变量一定会保持固定值。

12.3 调试消息队列

建议观察:

  • 发送成功次数。

  • 接收成功次数。

  • 当前队列消息数量。

  • 发送和接收的数据内容。

  • 队列满时的错误状态。

如果发送任务持续循环发送消息,那么发送计数不断增加是正常现象。若只希望发送一次,应明确一次性任务的设计,而不是把发送代码无意中放进无限循环。

12.4 调试互斥量

调试时需要确认:

  1. 互斥量创建成功。

  2. 获取成功后才进入受保护区域。

  3. 任务完成操作后正确释放互斥量。

  4. 另一个任务能够在资源可用后继续执行。

  5. 获取失败或释放失败时,有明确的错误处理。

不能仅根据某个调试变量曾经变成 1,就认定整个互斥量设计没有问题。


十三、DMA:直接存储器访问与异步数据传输

13.1 DMA 的基本概念

DMA(Direct Memory Access,直接存储器访问)是一种能够在内存与外设之间自动搬运数据的硬件机制。

在没有使用 DMA 时,CPU 通常需要参与数据的逐次传输;使用 DMA 后,CPU 负责配置传输参数并启动 DMA,随后由 DMA 控制器按照配置完成数据搬运。

DMA 的主要作用是减少 CPU 参与数据搬运的开销,让 CPU 能够执行其他任务。

需要区分三个组成部分:

  • CPU:负责准备数据、配置传输、启动 DMA,以及处理传输完成事件。

  • DMA 控制器:负责按照配置在内存与外设之间搬运数据。

  • 外设:负责完成自身的功能。例如,USART 负责将数据转换为串行信号并通过通信引脚发送。

DMA 不会取代外设本身,也不会自动完成数据计算或协议处理。

13.2 DMA 传输的基本参数

一次 DMA 传输通常需要明确以下信息。

1. 源地址

源地址表示 DMA 从哪里读取数据。例如,发送串口字符串时,源地址指向存放字符串的内存缓冲区。

2. 目标地址

目标地址表示 DMA 将数据写入哪里。例如,USART 发送时,目标地址指向 USART 的数据寄存器。

3. 传输长度

传输长度表示本次需要搬运的数据量。具体单位取决于 DMA 配置的数据宽度,常见配置为字节、半字或字。

4. 数据传输方向

常见方向包括:

  • Memory to Peripheral:内存到外设。

  • Peripheral to Memory:外设到内存。

  • Memory to Memory:内存到内存,是否支持以及具体限制取决于 DMA 控制器。

以串口发送为例,DMA 从内存读取待发送的数据,并将数据写入 USART 的数据寄存器。

13.3 内存地址递增与外设地址递增

DMA 可以根据配置决定每次传输后是否递增源地址和目标地址。

对于常见的 USART 字符串发送:

  • Memory Increment:Enable。

  • Peripheral Increment:Disable。

原因是内存中连续存放着多个字符,DMA 需要依次读取不同的内存地址;而 USART 的数据寄存器地址固定,因此目标地址通常保持不变。

如果启用了不适当的地址递增方式,DMA 就可能访问错误的位置,导致数据传输异常。

13.4 DMA 的 Normal 模式与 Circular 模式

DMA 常见的工作模式包括 Normal 和 Circular。

Normal(普通模式)

DMA 完成指定长度的数据传输后,结束本次传输,等待软件再次启动。

适合一次性发送一帧数据、完成一段内存复制等场景。

Circular(循环模式)

DMA 完成指定长度的数据传输后,会按照配置自动回到缓冲区起始位置,继续执行后续传输。

常见于连续采样、循环缓冲区接收等场景。

需要注意,Circular 模式只是重复使用缓冲区,并不会自动产生新的数据。如果缓冲区内容没有更新,DMA 可能反复传输旧数据。

模式的选择应根据数据流的特点和缓冲区管理方式决定。

13.5 DMA 与 UART 异步发送

使用 HAL 库发送 UART 数据时,可以采用阻塞发送或 DMA 发送。

阻塞发送的典型接口:

HAL_UART_Transmit(&huart1, data, length, timeout);

DMA 发送的典型接口:

HAL_UART_Transmit_DMA(&huart1, data, length);

DMA 发送接口的参数含义如下:

  • &huart1:指定 UART 句柄。

  • data:指向待发送数据的缓冲区。

  • length:本次发送的数据长度,单位为字节。

DMA 发送通常是异步的。调用函数后,HAL 成功启动传输就可以返回,CPU 不需要一直等待整段数据发送结束。

常见返回值:

返回值含义
HAL_OK本次发送成功启动
HAL_BUSY当前 UART 发送状态不允许启动新的传输
HAL_ERROR发生错误,无法正常启动传输

HAL_OK只表示启动成功,并不表示所有数据已经发送完成。

13.6 DMA 发送缓冲区的生命周期

DMA 发送期间,DMA 控制器仍然需要访问原始缓冲区。因此,缓冲区必须在整个异步传输期间保持有效,内容也不能被随意修改。

例如:

static uint8_t txBuffer[] = "HELLO\r\n"; HAL_StatusTypeDef status; status = HAL_UART_Transmit_DMA( &huart1, txBuffer, sizeof(txBuffer) - 1U );

这里使用sizeof(txBuffer) - 1U,是因为字符串末尾的'\0'通常不需要发送。

在 DMA 发送完成之前,不应覆盖txBuffer中的数据。如果在传输过程中修改缓冲区,接收端可能得到不完整或混杂的数据。

常见的缓冲区管理方式包括:

  • 当前发送完成后再复用同一缓冲区。

  • 使用两个独立缓冲区,让数据准备与传输过程相互协调。

  • 使用环形缓冲区管理连续数据流。

具体采用哪种方式,需要考虑内存容量、数据产生速度和发送完成时机。

13.7 DMA 中断与发送完成回调

DMA 可以通过中断报告传输进度和完成状态。使用 HAL UART DMA 发送时,通常会涉及 DMA 中断、UART 发送完成处理以及应用层回调。

典型回调形式如下:

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { /* 记录发送完成,或通知后续处理 */ } }

在常见的 STM32 HAL UART DMA 实现中,DMA 完成数据搬运后,HAL 还会处理 UART 最终发送完成状态,随后调用HAL_UART_TxCpltCallback()。

因此,需要区分:

  • DMA 传输完成:DMA 已经完成规定的数据搬运。

  • UART 发送完成:UART 已经完成本次串行发送。

对于 UART DMA 发送,通常应以 HAL 提供的 UART 发送完成回调作为应用层确认本次发送结束的依据。

回调运行于中断处理上下文,应保持简短,不应在其中执行阻塞延时、阻塞式信号量获取或复杂的数据处理。

如果需要通知 FreeRTOS 任务继续工作,应使用符合 CMSIS-RTOS V2 中断调用规则的通知机制,并满足底层 FreeRTOS 对中断优先级的要求。

13.8 DMA 与 FreeRTOS 任务协作

DMA 负责数据搬运,FreeRTOS 负责任务调度与同步,两者可以配合完成异步通信。

一种常见的工作流程是:

  1. 任务准备待发送数据。

  2. 任务启动 DMA 传输。

  3. DMA 与外设执行数据传输,任务可以阻塞等待完成事件,或让出 CPU 执行其他工作。

  4. 传输完成后,中断回调发出通知。

  5. 等待通知的任务被唤醒,检查传输状态并处理后续数据。

在这种设计中,任务负责业务处理,中断负责快速响应和通知,DMA 负责数据搬运。

需要注意,DMA 传输期间不应随意修改正在使用的缓冲区。即使任务调度正常,如果缓冲区管理不当,仍然可能产生数据错误。

13.9 DMA 为什么不能彻底解决队列溢出?

DMA 能够减少 CPU 参与数据搬运的开销,但不能突破外设本身的物理传输速度限制。

以常见的 UART 8N1 格式为例,每个字节通常需要传输 10 个比特,包括起始位、8 个数据位和停止位。

当波特率为 (B),待发送数据长度为 (N) 字节时,理想传输时间约为:

[
t \approx \frac{10N}{B}
]

例如,发送 100 字节的数据,在 115200 bit/s 的波特率下,理想传输时间约为:

[
t \approx \frac{100\times10}{115200}
\approx 8.68\text{ ms}
]

实际时间还可能受到软件处理、传输间隔和其他因素的影响。

如果数据生产速度长期超过串口的实际发送速度,待发送数据就会不断积压,最终可能导致消息队列或发送缓冲区溢出。

因此,使用 DMA 后仍然需要合理设计:

  • 数据产生频率。

  • 串口波特率和单帧数据长度。

  • 消息队列或缓冲区容量。

  • 发送任务的消费速度。

  • 队列满和发送失败时的处理策略。

DMA 的主要优势是降低 CPU 开销,而不是无限提高数据吞吐量。

1.10 STM32F1 的 DMA 通道映射

在 STM32F1 系列中,DMA 请求与通道之间存在固定的硬件映射关系,不能像带有 DMAMUX 的部分新系列芯片那样自由选择请求映射。

例如,在 STM32F103 的常见映射中:

  • USART1_TX 对应 DMA1 Channel 4。

  • USART1_RX 对应 DMA1 Channel 5。

实际使用时,应以具体芯片的数据手册、参考手册和对应 HAL 库版本为准。

配置 DMA 时,还需要关注以下参数:

  • 传输方向。

  • Normal 或 Circular 模式。

  • 外设地址是否递增。

  • 内存地址是否递增。

  • 外设与内存的数据宽度。

  • DMA 优先级。

  • DMA 中断是否启用。

这些参数共同决定 DMA 如何搬运数据,以及软件如何获知传输进度和完成状态。


十四、常见错误与排查方法

14.1 任务一直占用 CPU

现象:高优先级任务一直运行,其他任务几乎没有机会执行。

常见原因:

  • 高优先级任务无限循环,但没有阻塞或等待事件。

  • 任务优先级设置不合理。

  • 任务等待条件设计错误。

排查方法:

  • 检查任务循环中的延时或等待接口。

  • 检查任务优先级。

  • 检查哪些任务始终处于就绪态。

14.2 信号量释放成功,但计数没有达到预期

常见原因:

  • 消费者已经获取并消耗了令牌。

  • 最大计数设置不足。

  • 部分释放操作失败。

  • 生产者和消费者的执行顺序与预期不同。

排查方法:

  • 检查osSemaphoreRelease()返回值。

  • 统计释放成功次数和获取成功次数。

  • 在合适的调试位置读取信号量计数。

14.3 消息队列传递的数据不正确

常见原因:

  • 创建队列时的消息大小与实际结构体不匹配。

  • 发送和接收使用了不同的数据类型。

  • 接收缓冲区定义不正确。

  • 将结构体指针误认为队列会自动保存结构体所指向的所有数据。

排查方法:

  • 统一结构体定义。

  • 使用sizeof(结构体类型)创建队列。

  • 检查osMessageQueuePut()和osMessageQueueGet()返回值。

  • 检查实际接收的数据。

14.4 互斥量无法释放

常见原因:

  • 获取失败后仍然调用释放函数。

  • 任务没有正确处理错误分支。

  • 不同任务使用了不同的互斥量句柄。

  • 任务持锁后发生了长时间等待。

排查方法:

  • 检查创建互斥量的代码。

  • 检查两个任务是否使用同一把锁。

  • 确保释放操作只发生在成功获取之后。

  • 缩短临界区。

14.5 线程标志重复设置后没有累计次数

原因:同一个标志位只能表达设置或未设置状态,不会因为重复设置而自动累加。

解决方法:如果业务要求每次事件都被记录,应考虑计数信号量、消息队列或其他能够保存事件次数的机制。

14.6 任务创建成功,但没有按预期运行

常见原因:

  • 调度器尚未启动。

  • 任务处于阻塞态或挂起态。

  • 有更高优先级任务持续运行。

  • 任务句柄使用错误。

  • 任务栈空间不足。

排查方法:

  • 检查osThreadNew()返回值。

  • 检查osKernelStart()是否成功执行。

  • 检查任务状态、优先级和阻塞条件。

  • 根据实际工程配置启用适当的栈溢出检测,并观察栈使用情况。


版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 10:58:01

音频频谱怎么判断音质的好与坏?三个能判断与三个判断不了的

论坛看到有人说&#xff0c;把文件拖进频谱图&#xff0c;一眼就能看出这音频被压过、被砍过高频、甚至是假无损。你也照做了&#xff1a;图放大到像素级&#xff0c;密密麻麻的亮线&#xff0c;看不出所以然。有人说 16kHz 以上一片黑就是假无损&#xff0c;你的图上确实是黑的…

作者头像 李华
网站建设 2026/10/29 10:57:50

云端 GPU 基线如何服务边缘部署:功能、延迟与算子兼容性分层验收

模型在云端 GPU 上完成推理测试&#xff0c;输出也符合预期。接下来准备部署到边缘设备&#xff0c;是否可以直接沿用云端测试报告&#xff1f; 不能。 从工程验收的角度看&#xff0c;云端功能测试与目标设备部署测试属于不同的证据范围。 云端测试可以建立功能基线&#x…

作者头像 李华
网站建设 2026/10/29 10:55:22

AI 来了之后,我的心流状态去哪了

&#x1f4a1; 很多 Java 同学都有一个疑惑&#xff1a;背了几百道八股文&#xff0c;刷了一堆面试题&#xff0c;可面试依旧频频挂掉&#xff1f; 不是你知识点记不住&#xff0c;而是面试官早就变了考核思路&#xff01; 以前面试官可能会问&#xff1a;什么是 JVM 内存模型&…

作者头像 李华
网站建设 2026/10/29 10:54:54

找开源 OA+人力项目?这套 SpringBoot + Vue3 系统值得看看

找开源 OA&#xff0b;人力项目&#xff1f;这套 SpringBoot Vue3 系统值得看看&#x1f310; 文档地址&#xff1a;https://ruoyioffice.com &#x1f447; 文章底部获取源码和演示地址 &#x1f447; &#x1f4ac; &#xff1a;17156169080&#xff08;获取产品咨询&#x…

作者头像 李华
网站建设 2026/10/29 10:53:01

Wand-Enhancer:3 分钟跑通手机远程面板

Wand-Enhancer&#xff1a;3 分钟跑通手机远程面板 【免费下载链接】Wand-Enhancer Advanced UX and interoperability extension for Wand (WeMod) app 项目地址: https://gitcode.com/GitHub_Trending/we/Wand-Enhancer Wand-Enhancer 是面向 Wand&#xff08;前身 We…

作者头像 李华