1. 底半和顶半操作
"顶半操作"(Top Half)和 "底半操作"(Bottom Half)是嵌入式 / 操作系统中 中断处理的经典设计模式,核心目的是解决 "中断需要快进快出" 和 "实际业务可能耗时" 的矛盾
| 对比维度 | 顶半操作(中断上下文) | 底半操作(任务上下文) |
|---|---|---|
| 运行环境 | 中断触发后自动执行 | 被顶半通过 IPC 唤醒执行 |
| 执行速度 | 必须极快(<1us),快进快出 | 可慢(ms 级),按需处理 |
| 允许操作 | 仅缓存数据、清除中断、发通知(信号量 / 队列) | 打印、解析、存储、网络发送等耗时业务 |
| API 限制 | 仅能用中断安全 API(如 xSemaphoreGiveFromISR) |
可调用所有任务级 API(printf、vTaskDelay) |
| 阻塞允许 | 绝对不允许(中断不能阻塞) | 允许(xSemaphoreTake 阻塞不占 CPU) |
| 优先级 | 极高(高于所有任务) | 由任务优先级决定(你的代码中是 10) |
| 典型操作 | 数据缓存、中断标志清除、触发底半 | 数据解析、打印、文件读写、命令执行 |
核心设计思想:
中断的核心职责是 "快速响应硬件事件"(比如串口收到 1 字节、按键被按下),不能在中断里做耗时操作(如打印、数据解析)------ 否则会阻塞其他中断 / 任务,导致系统实时性变差。
因此:
- 顶半:中断里只做 "最紧急、最快" 的事(比如缓存数据、清除中断标志);
- 底半:把 "耗时的业务逻辑"(比如打印、数据处理)交给任务执行,通过信号量、队列等 IPC 机制触发。
中断中与任务中释放信号量有何不同?
对比维度 中断中释放信号量( xSemaphoreGiveFromISR)任务中释放信号量( xSemaphoreGive)适用环境 中断服务函数 / 回调函数(如 HAL_UART_RxCpltCallback)任务函数(如 uart_rx_entry、led_toggle_entry)调用的 API 必须用 xSemaphoreGiveFromISR(中断安全版)用 xSemaphoreGive(任务级版)上下文类型 中断上下文(无操作系统调度,优先级最高) 任务上下文(有操作系统调度,按优先级执行) 核心内部逻辑 1. 原子操作 S+1;2. 标记等待任务为 "就绪";3. 不直接触发调度(通过taskWoken标记)1. 原子操作 S+1;2. 标记等待任务为 "就绪";3. 自动触发任务调度(若唤醒更高优先级任务)关键参数 必须传入 taskWoken指针(标记是否需要任务切换)无额外参数(调度由系统自动处理) 执行速度 极快(微秒级),无复杂逻辑(符合中断 "快进快出" 要求) 速度中等(比中断版多调度逻辑),但不影响使用 使用限制 1. 仅支持二值 / 计数信号量(不支持互斥锁);2. 不能阻塞;3. 必须快速执行 1. 支持所有信号量(二值 / 计数 / 互斥锁);2. 可调用其他任务级 API;3. 无 "快速退出" 强制要求(但不建议长时间占用) 任务切换触发方式 需手动调用 portYIELD_FROM_ISR(taskWoken)(按需切换)自动触发(若唤醒的任务优先级 > 当前运行任务)
2. 串口中断接收 + 任务处理
2.1 实验
2.1.1 代码逻辑
- 串口 1 配置为 115200 波特率,开启 中断接收模式(每次接收 1 字节);
- 中断回调函数中缓存接收字节,检测到回车(0x0D)或换行(0x0A)时,通过 二值信号量 唤醒后台任务;
- 后台 uart_rx_entry 任务等待信号量,收到信号后打印完整接收字符串;
- 额外创建 led_toggle_entry 任务实现 LED 1 秒翻转,task_create_entry 任务负责初始化信号量和其他任务(创建完成后自删除)。
简单说:通过中断 + 信号量实现 "串口数据异步接收→后台任务同步处理",避免中断中长时间操作(如打印),保证系统实时性。
2.1.2 全局变量与初始化
cpp
SemaphoreHandle_t uart_rx_sem; // 串口接收同步用二值信号量句柄
uint8_t uart_rx_byte = 0; // 中断接收临时字节
uint8_t uart_rx_buf[100] = {0}; // 接收数据缓冲区(最大99字节+结束符)
uint8_t uart_rx_cnt = 0; // 接收字节计数
信号量 uart_rx_sem 用于 中断与任务的同步:中断接收完一行数据后,通过信号量 "通知" 任务处理;
缓冲区 uart_rx_buf 缓存接收字节,uart_rx_cnt 记录当前缓存长度,遇到回车 / 换行时终止缓存。
2.1.3 串口发送重定向(printf 支持)
cpp
int fputc(int ch,FILE *f)
{
while((USART1->SR & 0X40) == 0); // 等待发送缓冲区空(TXE位)
USART1->DR = (uint8_t)ch; // 写入发送数据
return ch;
}
重定向 C 标准库 fputc 函数,让 printf 输出到串口 1;
采用 轮询方式发送(无中断),适合短数据打印(如接收字符串回显)。
fputc不懂?
printf 函数:C 语言标准库的 "打印函数",本质是 "把字符串输出到某个地方"。但 C 语言本身不知道 "某个地方" 是串口、屏幕还是文件 ------ 它需要一个 "底层输出接口" 来实现实际的硬件操作。
fputc 函数:这个函数是 printf 的 "底层帮手",printf 最终会把每个字符拆出来,调用 fputc 一个个输出。默认情况下,fputc 是为 "电脑文件" 设计的(比如输出到硬盘文件),在嵌入式 MCU(如 STM32)上没用,所以需要 "改造" 它。
什么叫重定向?重定向 = 把 fputc 的默认输出行为,改成 "输出到串口 1"。
原本 printf 是 "写给硬盘文件" 的,你通过重定向,让它变成 "写给串口",这样调用 printf 时,数据就会从串口发出去,电脑串口工具就能收到。
那为什么要加FILE *f呢?1. 本质原因:fputc 是 C 语言标准库函数,不是你 "自定义" 的函数
你写的 fputc 不是 "新创造" 的函数,而是重写了 C 语言标准库中已有的 fputc 函数(这是重定向的核心)。
C 语言标准库的 fputc 原型就是:
int fputc(int ch, FILE *stream);
ch:要输出的字符(你代码里的打印字符);
FILE *stream:输出 "流"(可以理解为 "输出目的地",比如文件、串口、屏幕等)。
2. FILE *f 的作用:兼容标准库的 "多输出场景"C 语言标准库设计 fputc 时,要支持多种输出场景(比如同时输出到文件、屏幕、串口),FILE *f 就是用来区分 "输出到哪个地方" 的。
但在本嵌入式场景中,我们只需要 "输出到串口 1",不需要区分多个输出,所以 FILE *f 这个参数实际上没被使用------ 但必须保留,否则会和标准库的 fputc 函数原型不匹配,编译器会报错!
USART1的SR和DR又是什么?1. USART1->SR:状态寄存器(Status Register)
- 作用:记录串口的工作状态(比如 "发送缓冲区空了吗?""接收数据了吗?""有没有错误?");
- 你代码里的
(USART1->SR & 0X40) == 0:
0X40是十六进制,对应二进制01000000,也就是 SR 寄存器的第 6 位(TXE 位,Transmit Data Register Empty);- TXE 位的规则:
0= 发送缓冲区非空(正在发送数据,不能写新数据);1= 发送缓冲区空(可以写新数据);- 所以
while((USART1->SR & 0X40) == 0)的意思是:等待串口发送缓冲区空,直到可以发送下一个字符(避免发送冲突)。2. USART1->DR:数据寄存器(Data Register)
- 作用:存储要发送 / 接收的数据;
- 你代码里的
USART1->DR = (uint8_t)ch:
- 把要打印的字符
ch(比如 'h'、'e')写入 DR 寄存器;- 串口硬件会自动读取 DR 寄存器里的数据,通过 TX 引脚发送出去;
- 接收数据时,串口硬件也会把收到的数据存入 DR 寄存器,你可以通过
USART1->DR读取。
2.1.4 串口中断接收回调函数(核心)
cpp
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
portBASE_TYPE taskWoken = 0; // 标记是否需要触发任务切换(中断安全)
if(huart->Instance == USART1)
{
HAL_UART_Receive_IT(&huart1,&uart_rx_byte,1); // 重新开启中断接收(关键!)
uart_rx_buf[uart_rx_cnt] = uart_rx_byte; // 缓存当前接收字节
// 检测结束符:回车(0x0D)或换行(0x0A)
if(uart_rx_buf[uart_rx_cnt] == 0X0D || uart_rx_buf[uart_rx_cnt] == 0X0A)
{
uart_rx_buf[uart_rx_cnt] = 0; // 字符串结尾加 '\0'(C语言字符串标准)
uart_rx_cnt = 0; // 重置接收计数,准备下一次接收
xSemaphoreGiveFromISR(uart_rx_sem,&taskWoken); // 中断中释放信号量(同步任务)
}
else
{
uart_rx_cnt ++; // 未到结束符,计数自增
}
portYIELD_FROM_ISR(taskWoken); // 若需切换任务,在中断退出时触发
}
}
HAL_UART_Receive_IT 必须重新调用:HAL 库的中断接收是 "一次性的",接收 1 字节后中断会关闭,需手动重新开启下一次接收;
信号量操作:中断中必须用 xSemaphoreGiveFromISR(中断安全版本),不能用 xSemaphoreGive(任务级 API);
taskWoken标记:若释放信号量后唤醒了更高优先级任务,portYIELD_FROM_ISR 会触发任务切换,保证高优先级任务及时执行。
xSemaphoreGiveFromISR和xSemaphoreGive什么区别?
关键原因:中断和任务的 "运行规则" 不同:
任务是 "可以被打断的",运行在 "任务上下文",有操作系统调度,允许调用稍微复杂的 API;
中断是 "最高优先级" 的,运行在 "中断上下文",必须快速执行、不能阻塞、不能调用复杂 API(比如不能用 vTaskDelay、printf 等)。
xSemaphoreGiveFromISR 是专门为中断设计的:它不会阻塞,执行速度极快(符合中断 "快进快出" 的要求);
它需要传入一个 taskWoken 参数,用来标记 "释放信号量后是否唤醒了更高优先级任务",之后通过 portYIELD_FROM_ISR(taskWoken) 触发任务切换(如果需要),这是中断中安全切换任务的唯一方式。
2.1.5 核心任务实现
(1)串口接收处理任务
cpp
void uart_rx_entry(void *p)
{
while(1)
{
xSemaphoreTake(uart_rx_sem,portMAX_DELAY); // 无限等待信号量(阻塞态,不占用CPU)
printf("%s\r\n",uart_rx_buf); // 打印接收字符串(加换行符,回显更整齐)
}
}
任务逻辑:等待信号量→收到信号后打印缓冲区内容;
优势:任务平时处于阻塞态,仅在有数据时执行,CPU 利用率高。
(2)LED 翻转任务
cpp
void led_toggle_entry(void *p)
{
while(1)
{
HAL_GPIO_TogglePin(GPIOA,GPIO_PIN_8); // 翻转 PA8 电平(LED 亮灭切换)
vTaskDelay(1000); // 延时1秒(FreeRTOS 相对延时)
}
}
简单的周期性任务,用于验证系统调度是否正常(LED 每秒闪一次,说明系统运行正常)。
(3)任务创建任务
cpp
void task_create_entry(void *p)
{
TaskHandle_t tmp_handle;
TaskHandle_t xHandle;
TaskHandle_t xHandle2; // 未使用,可删除
vTaskDelay(5000); // 延时5秒(模拟系统初始化等待)
printf("start to create sems\r\n");
uart_rx_sem = xSemaphoreCreateBinary(); // 创建二值信号量(初始值0)
printf("start to create tasks\r\n");
printf("create 1st task\r\n");
xTaskCreate(uart_rx_entry,"uart_rx_task",128,(void *)0,10,&xHandle); // 串口接收任务(优先级10)
HAL_UART_Receive_IT(&huart1,&uart_rx_byte,1); // 开启第一次串口中断接收
printf("create 2nd task\r\n");
xTaskCreate(led_toggle_entry,"led_toggle_task",128,(void *)0,10,&xHandle); // LED任务(优先级10)
// 隐患:两个任务用了同一个句柄 xHandle,导致无法单独操作其中一个任务(无功能影响,但不规范)
tmp_handle = xTaskGetHandle("task_create_task"); // 获取自身任务句柄
vTaskDelete(tmp_handle); // 自删除(任务创建完成后,自身无用,释放资源)
}
作用:集中初始化信号量和其他任务,避免 main 函数过于臃肿;
自删除逻辑:任务创建完成后调用 vTaskDelete 释放自身栈空间,是 FreeRTOS 中常见的 "初始化任务" 写法。
2.1.6 main 函数
cpp
int main(void)
{
TaskHandle_t xHandle;
HAL_Init(); // HAL库初始化
SystemClock_Config(); // 系统时钟配置(HSE+PLL,具体频率需看硬件)
MX_GPIO_Init(); // GPIO初始化(PA8为输出,控制LED)
MX_USART1_UART_Init(); // 串口1初始化(115200波特率)
xTaskCreate(task_create_entry,"task_create_task",128,(void *)0,12,&xHandle); // 创建初始化任务(优先级12)
vTaskStartScheduler(); // 启动FreeRTOS调度器
while (1) {} // 调度器启动失败才会进入这里(如内存不足)
}
初始化顺序:硬件初始化 → 创建初始化任务 → 启动调度器;
任务优先级:task_create_task 优先级 12(高于其他任务 10),确保初始化工作优先完成。
2.1.7 总结
- 中断与任务同步:通过二值信号量 uart_rx_sem 实现 "中断触发→任务处理",避免中断中长时间操作(如打印);
- FreeRTOS 中断安全 API:中断中必须使用 xSemaphoreGiveFromISR、portYIELD_FROM_ISR 等中断安全函数;
- HAL 库串口中断接收:HAL_UART_Receive_IT 是一次性的,接收完成后需重新调用才能继续接收;
- 任务管理:初始化任务创建其他任务后自删除,节省系统资源;
通过二值信号量 uart_rx_sem 实现 "中断触发→任务处理",避免中断中长时间操作(如打印),没看出来?
代码中,这个逻辑是 "中断接收数据→信号量通知任务→任务打印数据",核心是把 "长时间的打印操作" 从中断里搬到任务里,避免中断阻塞。
先看:如果不这么做,直接在中断里打印会怎么样?
cppvoid HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if(huart->Instance == USART1) { HAL_UART_Receive_IT(&huart1,&uart_rx_byte,1); uart_rx_buf[uart_rx_cnt] = uart_rx_byte; if(uart_rx_buf[uart_rx_cnt] == 0X0D || uart_rx_buf[uart_rx_cnt] == 0X0A) { uart_rx_buf[uart_rx_cnt] = 0; uart_rx_cnt = 0; printf("%s\r\n", uart_rx_buf); // 直接在中断里打印(错误!) } else uart_rx_cnt ++; } }为什么错误?------ 中断不能 "耗时"!
打印字符串是 "长时间操作":比如打印 10 个字符,115200 波特率下每个字符约 86us,10 个字符就是 860us,加上 printf 的内部处理,可能超过 1ms;
中断的 "职责" 是:快速处理紧急事件(比如接收 1 个字节),然后立刻退出,不能霸占 CPU;
后果:如果中断里打印耗时 1ms,这 1ms 内所有其他中断(比如定时器、按键)都会被屏蔽,系统实时性变差,甚至丢失其他数据(比如串口连续发送时,后面的字节会因为中断被占用而丢失)。
再看:我的代码是怎么用信号量解决的?(分 3 步)
步骤 1:中断里只做 "快速操作"(耗时 < 1us)
cppvoid HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { portBASE_TYPE taskWoken = 0; if(huart->Instance == USART1) { HAL_UART_Receive_IT(&huart1,&uart_rx_byte,1); // 1. 重新开启中断(快) uart_rx_buf[uart_rx_cnt] = uart_rx_byte; // 2. 缓存字节(快) if(/* 检测到结束符 */) { uart_rx_buf[uart_rx_cnt] = 0; // 3. 字符串加结束符(快) uart_rx_cnt = 0; xSemaphoreGiveFromISR(uart_rx_sem,&taskWoken); // 4. 释放信号量(快,中断安全) } else uart_rx_cnt ++; // 快 portYIELD_FROM_ISR(taskWoken); } }中断里只做 "缓存数据" 和 "发信号",所有操作都是微秒级,快速退出,不影响其他中断。
步骤 2:信号量充当 "通知"
二值信号量 uart_rx_sem 初始值是 0(xSemaphoreCreateBinary());
中断里调用 xSemaphoreGiveFromISR 后,信号量值变成 1(相当于 "告诉任务:数据准备好了,快来处理!")
步骤 3:任务里做 "长时间操作"(打印)
cppvoid uart_rx_entry(void *p) { while(1) { xSemaphoreTake(uart_rx_sem,portMAX_DELAY); // 等待信号量(没数据时阻塞,不占CPU) printf("%s\r\n",uart_rx_buf); // 打印(耗时操作,在任务里做) } }任务平时处于 "阻塞态"(不占用 CPU),只有收到信号量(数据准备好)才会被唤醒,执行打印;
打印是在 "任务上下文" 执行的,此时即使打印耗时 1ms,也不会屏蔽中断 ------ 其他中断(比如定时器、按键)可以正常响应,系统实时性不受影响。
核心逻辑闭环:
中断(快操作:缓存 + 通知)→ 信号量(传递通知)→ 任务(慢操作:打印)
这样就把 "耗时的打印" 从中断里 "搬" 到了任务里,避免了中断长时间占用 CPU,这就是信号量的核心作用!
结合代码跑一遍流程,就能完全串起来了:
串口发字符 → 中断接收缓存 → 信号量计数器 + 1 → 唤醒等待任务 → 任务打印 → 任务再次等待信号量。
2.2 关于FROMISR API的形参:pxHigherPriorityTaskWoken
pxHigherPriorityTaskWoken 是 FreeRTOS 中断安全 API(带 FromISR 后缀的函数,如 xSemaphoreGiveFromISR、xQueueSendFromISR)的核心形参,本质是 "中断中是否唤醒了更高优先级任务" 的标记位------ 它的存在是为了在不破坏中断 "快进快出" 原则的前提下,保证系统实时性。
先明确核心定义:一个 "被动更新的标记位"
参数类型:portBASE_TYPE *(指针类型,本质是传递一个 "布尔值" 的地址);
核心作用:由 FreeRTOS 内核在中断安全 API 内部修改,告诉用户 "这次操作是否唤醒了比当前正在运行的任务优先级更高的任务";
取值含义:
- pdFALSE(默认):未唤醒高优先级任务,或没有任务在等待该信号量 / 队列;
- pdTRUE:唤醒了高优先级任务(该任务之前因等待信号量 / 队列而阻塞,现在被唤醒进入就绪态);
关键特点:用户只需要 "传入指针",不需要手动修改它 ------ 它是 FreeRTOS 内核给用户的 "反馈"。
为什么需要这个参数?------ 中断上下文不能直接调度
这是理解该参数的核心前提,之前反复强调:
中断运行在 中断上下文,优先级高于所有任务,且必须 "快进快出"(不能执行复杂逻辑,包括任务调度);
任务调度(比如切换到高优先级任务)是 "任务上下文" 的操作,需要依赖任务控制块(TCB)、就绪队列等信息,中断中无法安全执行;
但如果中断中释放信号量 / 队列,唤醒了高优先级任务,又需要让该任务 "尽快执行"(保证实时性)------ 不能等到中断退出后,让低优先级任务继续运行半天再切换。
因此,pxHigherPriorityTaskWoken 承担了 "中间桥梁" 的作用:
- 中断中执行 xSemaphoreGiveFromISR 时,内核判断是否唤醒高优先级任务,并用该参数标记;
- 中断退出前,用户通过 portYIELD_FROM_ISR(pxHigherPriorityTaskWoken) 告诉内核:"如果标记为 pdTRUE,就触发一次任务切换";
- 任务切换在 "中断退出时" 执行(此时已回到任务上下文的边缘),既安全又能保证高优先级任务及时响应。
完整流程闭环:
串口收到结束符 → 中断触发 → 调用 xSemaphoreGiveFromISR → 内核标记 taskWoken = pdTRUE → 调用 portYIELD_FROM_ISR → 中断退出时切换到 uart_rx_entry 任务 → 打印数据。
根据以上代码
1. 定义变量并初始化
cppvoid HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { portBASE_TYPE taskWoken = 0; // 初始化:默认未唤醒高优先级任务(0=pdFALSE) // ... 其他代码 ... }
- 必须初始化为 pdFALSE(或 0):因为默认情况下,中断操作不一定会唤醒任务,先假设 "未唤醒";
- 变量类型是 portBASE_TYPE:FreeRTOS 定义的跨平台布尔类型(适配不同 MCU 架构),等价于 uint32_t 或 int,但建议用 FreeRTOS 定义的类型保证兼容性。
2. 传入中断安全 API
cppxSemaphoreGiveFromISR(uart_rx_sem, &taskWoken); // 传入变量地址为什么传 "指针"?:因为 FreeRTOS 内核需要修改这个变量的值(标记是否唤醒高优先级任务),传值无法修改外部变量,必须传地址(指针);
内核内部操作:
- 内核检查 uart_rx_sem 的等待队列:如果 uart_rx_entry 任务正在等待该信号量;
- 且 uart_rx_entry 任务的优先级(10)高于当前正在运行的任务(比如 LED 任务优先级也是 10,或更低);
- 内核会把 taskWoken 设为 pdTRUE(1),同时把 uart_rx_entry 任务标记为就绪态。
3. 中断退出时触发调度(关键!)
cppportYIELD_FROM_ISR(taskWoken); // 根据标记决定是否切换任务这是 pxHigherPriorityTaskWoken 的 "最终用途":
- 若 taskWoken = pdTRUE:中断退出时,FreeRTOS 会触发一次任务切换,让刚唤醒的高优先级任务(uart_rx_entry)立即执行;
- 若 taskWoken = pdFALSE:不触发任务切换,中断退出后继续执行之前被中断的任务(比如 LED 任务);
为什么必须加这一句?:如果不加,即使 taskWoken = pdTRUE,高优先级任务也不会立即执行,要等当前任务的时间片用完或主动放弃 CPU,导致实时性变差(比如串口数据准备好了,但 LED 任务还在运行 1 秒,数据打印延迟)。
每接收一个字节,都要向底半任务发送一个信号量
需要频繁调度底半任务,效率太低,能否改进?

3. 善用DMA
3.1 DMA
本节在之前 "串口中断 + FreeRTOS" 的基础上,新增了 DMA + 空闲中断 接收模式 ------ 这是嵌入式中 "高速、无 CPU 占用" 的串口接收方案,核心目标是:让 DMA 硬件自动接收串口数据,CPU 只在数据接收完成(触发空闲中断)后才处理,避免之前 "中断逐字节接收" 的 CPU 开销。
复习一下DMA吧
DMA
https://blog.csdn.net/2301_76153977/article/details/154903943?spm=1011.2415.3001.5331
为什么用 DMA + 空闲中断?
之前的代码是 "串口逐字节中断接收",每收 1 个字节就触发 1 次中断,CPU 需要频繁响应中断、缓存数据,效率低(尤其高速 / 大数据量时)。
这次升级用 "DMA + 空闲中断" 方案,解决了这个问题,核心思路:
- DMA 负责 "数据搬运":硬件自动把串口接收的数据搬到内存缓冲区,全程不占用 CPU;
- 空闲中断负责 "通知":串口发完一帧数据后(空闲一段时间),触发 1 次中断,告诉系统 "数据收完了";
- FreeRTOS 任务负责 "处理":用二值信号量让任务阻塞等待,收到中断通知后唤醒,处理数据(回显、打印);
- 并行执行:LED 任务和串口任务独立运行,互不干扰,体现 FreeRTOS 的多任务调度优势。
一帧数据是一字节吗
"帧" 是串口通信中 "一组完整数据的单位",本质是「包含 "起始位 + 数据位 + 校验位 + 停止位" 的完整传输单元」,通常承载 1 个字节的有效数据,但 "帧"≠"字节"(帧是传输单元,字节是数据存储单元)。

3.2 开始敲代码
3.2.1 全局变量与宏定义(核心数据存储 + 配置)
cpp
// 串口句柄(HAL库管理串口的核心结构体,包含串口配置、状态等)
UART_HandleTypeDef huart1;
// DMA句柄(管理DMA通道的核心结构体,包含DMA配置、传输状态等)
DMA_HandleTypeDef hdma_usart1_rx;
// 二值信号量句柄(用于中断与任务的同步:中断通知任务数据就绪)
SemaphoreHandle_t uart_rx_sem;
// 串口接收配置宏
#define UART_RX_LEN 1024 // 一次最大接收长度(1024字节,可根据需求调整)
uint8_t UART_RX_BUF[UART_RX_LEN]; // DMA接收缓存区(DMA自动把串口数据存到这里)
__IO uint16_t UART_RX_STA = 0; // 接收状态标记(16位整数,用不同位表示不同状态)
// UART_RX_STA 位定义:
// bit15(最高位):1=一帧数据接收完成;0=未完成
// bit0~bit14(低15位):存储实际接收的数据长度
3.2.2 printf 重定向(复用之前的逻辑)
cpp
int fputc(int ch, FILE *f)
{
while((USART1->SR & 0X40) == 0); // 等待串口发送缓冲区空(SR寄存器的TXE位,0X40=bit6)
USART1->DR = (uint8_t)ch; // 把要打印的字符写入串口数据寄存器(DR),硬件自动发送
return ch;
}
3.2.3 串口中断处理函数(核心:空闲中断响应)
cpp
void HAL_UART_IRQHandler(UART_HandleTypeDef *huart)
{
portBASE_TYPE taskWoken = pdFALSE; // FreeRTOS中断安全标记:是否唤醒了高优先级任务
if (huart->Instance == USART1) // 只处理串口1的中断
{
// 检查是否是"空闲中断"(串口接收完一帧数据后触发)
if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE) != RESET)
{
__HAL_UART_CLEAR_IDLEFLAG(huart); // 清除空闲中断标志(必须手动清,否则会一直触发中断)
HAL_UART_DMAStop(huart); // 停止DMA传输(避免数据继续写入,导致覆盖)
// 计算接收的数据长度:总缓存长度 - DMA剩余未接收的长度
UART_RX_STA = UART_RX_LEN - __HAL_DMA_GET_COUNTER(huart->hdmarx);
UART_RX_STA |= 0X8000; // 标记一帧数据接收完成(把bit15置1)
UART_RX_BUF[UART_RX_STA & 0X7FFF] = '\0'; // 添加字符串结束符(方便printf打印)
// 释放信号量,唤醒串口处理任务(中断→任务的同步)
if (uart_rx_sem != NULL)
{
xSemaphoreGiveFromISR(uart_rx_sem, &taskWoken); // 中断安全的信号量释放
}
HAL_UART_Receive_DMA(huart, UART_RX_BUF, UART_RX_LEN); // 重启DMA接收,准备下一次数据
portYIELD_FROM_ISR(taskWoken); // 若唤醒高优先级任务,触发任务切换(中断安全)
}
else
{
HAL_UART_IRQHandler(huart); // 其他串口中断(如错误中断),交给HAL库默认处理
}
}
}
核心作用:空闲中断触发后,快速完成 "停止 DMA→计算数据长度→标记完成→通知任务→重启 DMA",全程微秒级(符合中断 "快进快出" 原则);
关键:__HAL_DMA_GET_COUNTER 函数获取 DMA 剩余未传输的字节数,用总长度减去它,就是实际接收的数据长度。
UART_RX_BUF[UART_RX_STA & 0X7FFF] = '\0';什么意思
一、先明确两个关键前提
UART_RX_STA 的位定义(代码中已约定):是 16 位变量(uint16_t),用不同位存储 "状态" 和 "数据长度":
bit15(最高位,第 15 位):标记 "一帧数据是否接收完成"(1 = 完成,0 = 未完成);
bit0~bit14(低 15 位):存储 "实际接收的有效数据长度"(比如接收 9 字节,低 15 位就是 0x0009)。
示例:接收完 9 字节数据后,UART_RX_STA = 0X8009(bit15=1,低 15 位 = 0x0009→1000 0000 0000 8001)。
字符串结束符 '\0':C 语言中,字符串是以 '\0'(ASCII 码 0)为结束标记的 ------ 如果没有这个符号,printf 会一直往后读取内存,直到遇到随机的 '\0',导致打印乱码。
比如接收 "hello"(5 字节),需要在第 5 个字节后写入 '\0',printf 才知道只打印前 5 个字符。
**二、逐部分拆解代码含义
- UART_RX_STA & 0X7FFF:提取有效数据长度**
0X7FFF 是什么?:16 位二进制是 0111 1111 1111 1111(bit15=0,低 15 位全 = 1)。
位运算 &(按位与)的作用:只保留 UART_RX_STA 的 "低 15 位",强制把 bit15 清 0。
目的:过滤掉 "接收完成标记"(bit15),只拿到纯粹的 "数据长度数值"。
2. UART_RX_BUF[...) = '\0':在数据末尾加结束符UART_RX_BUF 是 DMA 接收缓存区(存储串口接收的字节数据);
用 "提取出的有效数据长度" 作为数组索引,在该位置写入 '\0'。
示例:数据长度 = 9,数组索引就是 9(UART_RX_BUF[9] = '\0'):
UART_RX_BUF[0]~UART_RX_BUF[8]:存储 "hello DMA!" 的 9 个有效字符('h'、'e'、'l'...'!');
UART_RX_BUF[9]:写入 '\0',标记字符串结束。
3.2.4 FreeRTOS 任务(两个核心任务)
(1)串口数据处理任务(uart_rx_entry)
cpp
void uart_rx_entry(void *p)
{
while(1) // 任务循环(FreeRTOS任务必须是死循环,不能返回)
{
// 阻塞等待信号量(portMAX_DELAY=无限等待,没数据时任务不占用CPU)
if (xSemaphoreTake(uart_rx_sem, portMAX_DELAY) == pdPASS)
{
// 检查数据接收完成标记(bit15是否为1)
if (UART_RX_STA & 0X8000)
{
// 1. 回显数据:把收到的数据通过串口发回去(HAL_UART_Transmit是阻塞发送,超时100ms)
HAL_UART_Transmit(&huart1, UART_RX_BUF, UART_RX_STA & 0X7FFF, 100);
// 2. 打印日志:用printf输出收到的数据(通过fputc重定向到串口)
printf("\r\n收到数据:%s\r\n", UART_RX_BUF);
// 3. 清空状态,准备下一次接收
UART_RX_STA = 0; // 清除接收完成标记和数据长度
memset(UART_RX_BUF, 0, sizeof(UART_RX_BUF)); // 清空缓存区(避免旧数据干扰)
}
}
}
}
核心逻辑:阻塞等待信号量→收到信号量后处理数据→清空状态;
优势:没数据时任务阻塞,不占用 CPU(FreeRTOS 的核心优势),不会像 "轮询" 那样浪费资源。
什么是"轮询"
CPU 反复、循环地检查某个 "状态"(比如 "串口有没有收到数据""按键有没有被按下"),直到状态满足(比如 "收到数据了"),才执行对应的处理逻辑。
cpp// 不推荐的轮询写法(会一直占用CPU) void uart_rx_entry(void *p) { while(1) { // 轮询:反复检查 UART_RX_STA 的"接收完成标记" if(UART_RX_STA & 0X8000) { HAL_UART_Transmit(&huart1, UART_RX_BUF, UART_RX_STA & 0X7FFF, 100); UART_RX_STA = 0; } // 没有数据时,CPU 也在疯狂循环检查,完全不休息! } }问题:没有串口数据时,while(1) 会一直循环执行 if 判断,CPU 占用率 100%,导致 LED 任务无法及时执行(FreeRTOS 调度被干扰),系统效率极低。
用 FreeRTOS 信号量,替代了轮询:
cppvoid uart_rx_entry(void *p) { while(1) { // 阻塞等待:没有数据时,任务挂起,不占用CPU if (xSemaphoreTake(uart_rx_sem, portMAX_DELAY) == pdPASS) { if(UART_RX_STA & 0X8000) { HAL_UART_Transmit(&huart1, UART_RX_BUF, UART_RX_STA & 0X7FFF, 100); printf("\r\n收到数据:%s\r\n", UART_RX_BUF); UART_RX_STA = 0; memset(UART_RX_BUF, 0, sizeof(UART_RX_BUF)); } } } }区别:没有数据时,xSemaphoreTake 会让任务 "阻塞挂起",CPU 会切换到 LED 任务执行,不会浪费资源 ------ 这就是 "轮询" 和 "阻塞等待" 的核心差异。
(2)LED 定时翻转任务(led_toggle_entry)
cpp
void led_toggle_entry(void *p)
{
while(1)
{
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_8); // 翻转PA8引脚电平(LED亮/灭切换)
vTaskDelay(500); // 延时500ms(FreeRTOS的延时函数,会让任务阻塞500ms,期间CPU可以处理其他任务)
}
}
作用:演示多任务并行 ------LED 每 500ms 闪一次,同时串口可以正常接收数据,互不干扰;
细节:vTaskDelay 是相对延时,500ms 后任务自动唤醒,切换引脚电平。
3.2.5 初始化任务(task_create_entry)
cpp
void task_create_entry(void *p)
{
TaskHandle_t tmp_handle;
TaskHandle_t uart_rx_handle; // 串口任务句柄
TaskHandle_t led_handle; // LED任务句柄
vTaskDelay(5000); // 延时5秒(等待硬件初始化稳定:DMA、串口、GPIO)
// 1. 创建二值信号量(用于中断与任务同步)
printf("start to create sems\r\n");
uart_rx_sem = xSemaphoreCreateBinary(); // 创建二值信号量(初始值0)
if (uart_rx_sem == NULL) // 信号量创建失败(如内存不足),死等报错
{
printf("Semaphore create failed!\r\n");
while(1);
}
// 2. 创建串口处理任务
printf("start to create tasks\r\n");
xTaskCreate(
uart_rx_entry, // 任务函数
"uart_rx_task", // 任务名称(调试用)
256, // 任务栈大小(256字,STM32中1字=4字节,共1024字节,足够处理字符串)
NULL, // 任务参数(这里不需要,传NULL)
10, // 任务优先级(10,与LED任务相同)
&uart_rx_handle // 任务句柄(用于后续控制任务,如暂停、删除)
);
printf("create 1st task: uart_rx_task\r\n");
// 3. 创建LED任务
xTaskCreate(
led_toggle_entry,
"led_toggle_task",
128, // LED任务逻辑简单,栈大小128字足够
NULL,
10, // 优先级10,与串口任务相同(FreeRTOS会按时间片调度)
&led_handle
);
printf("create 2nd task: led_toggle_task\r\n");
// 4. 自删除初始化任务(初始化完成后,这个任务就没用了,释放资源)
tmp_handle = xTaskGetHandle("task_create_task");
vTaskDelete(tmp_handle);
}
作用:统一创建信号量和业务任务,避免在 main 函数中写过多初始化代码;
关键:xSemaphoreCreateBinary() 创建的二值信号量初始值为 0,任务调用 xSemaphoreTake 会阻塞,直到中断中释放信号量(值变为 1)。
3.2.6 main 函数(系统入口,初始化 + 启动调度)
cpp
int main(void)
{
TaskHandle_t init_task_handle; // 初始化任务句柄
// 1. 底层硬件初始化(HAL库标准流程)
HAL_Init(); // HAL库初始化(时钟、中断控制器等)
SystemClock_Config(); // 系统时钟配置(如72MHz、168MHz,根据硬件调整)
MX_GPIO_Init(); // GPIO初始化(PA8用于LED,PE6备用)
MX_DMA_Init(); // DMA初始化(先初始化DMA,再绑定串口)
MX_USART1_UART_Init(); // 串口1初始化(绑定DMA,配置波特率115200等)
// 2. 启动串口DMA接收+使能空闲中断
HAL_UART_Receive_DMA(&huart1, UART_RX_BUF, UART_RX_LEN); // DMA开始自动接收串口数据
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 使能串口空闲中断(数据收完触发)
// 3. 创建初始化任务(优先级12,高于业务任务,确保初始化先完成)
xTaskCreate(task_create_entry, "task_create_task", 128, NULL, 12, &init_task_handle);
// 4. 启动FreeRTOS调度器(从此开始,任务由FreeRTOS管理,main函数不再执行)
vTaskStartScheduler();
// 若调度器启动失败(如内存不足),进入死循环
while (1)
{
}
}
核心流程:初始化硬件→启动 DMA + 空闲中断→创建初始化任务→启动调度器;
关键:vTaskStartScheduler() 是 FreeRTOS 的 "开关",调用后系统会按任务优先级和调度策略,自动切换运行各个任务。
3.2.7 硬件初始化辅助函数(MX_DMA_Init、MX_USART1_UART_Init 等)
(1)MX_DMA_Init(DMA 配置)
cpp
static void MX_DMA_Init(void)
{
__HAL_RCC_DMA1_CLK_ENABLE(); // 使能DMA1时钟(DMA1是STM32F1系列的DMA控制器)
// 配置USART1_RX对应的DMA通道(DMA1_Channel5,需查芯片手册确认)
hdma_usart1_rx.Instance = DMA1_Channel5;
hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; // 传输方向:外设(串口)→内存(UART_RX_BUF)
hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不递增(串口DR寄存器地址固定)
hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增(缓存区从0到UART_RX_LEN-1)
hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; // 外设数据宽度:字节
hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; // 内存数据宽度:字节
hdma_usart1_rx.Init.Mode = DMA_NORMAL; // DMA模式:正常模式(接收完一次后停止,需重启)
hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH; // DMA优先级:高(避免与其他DMA通道冲突)
HAL_DMA_Init(&hdma_usart1_rx); // 初始化DMA通道
__HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx); // 把DMA通道绑定到串口1的接收端
// 配置DMA中断(可选,这里冗余,主要靠串口空闲中断)
HAL_NVIC_SetPriority(DMA1_Channel5_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(DMA1_Channel5_IRQn);
__HAL_DMA_ENABLE_IT(&hdma_usart1_rx, DMA_IT_TC); // 使能DMA传输完成中断
}
配置DMA有什么讲究
代码中 DMA 配置核心函数是 MX_DMA_Init(),每个配置项都有明确讲究,不能乱设:
1. 传输方向(Direction):必须匹配数据流向
cpphdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;讲究:根据 "谁是数据源,谁是目标" 选择,选错会导致数据传输失败(比如串口接收用 MEMORY_TO_PERIPH,永远收不到数据);
常见方向:
- DMA_PERIPH_TO_MEMORY:外设→内存(如串口接收、ADC 采集,你的代码场景);
- DMA_MEMORY_TO_PERIPH:内存→外设(如串口批量发送、DAC 输出);
- DMA_MEMORY_TO_MEMORY:内存→内存(如 SRAM 中数据拷贝,少用);
代码场景:串口接收是 "外设(USART1->DR)→内存(UART_RX_BUF)",方向正确。
2. 地址递增(PeriphInc/MemInc):决定数据是否连续存储
cpphdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不递增 hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增核心讲究:
外设地址(PeriphInc):串口、ADC 等外设的 "数据寄存器" 地址是固定的(如 USART1->DR 地址不变),必须设为 DMA_PINC_DISABLE(不递增);如果设为 "递增",DMA 会访问外设的其他寄存器,导致硬件错误;
内存地址(MemInc):接收数据时,需要连续存储到数组(UART_RX_BUF[0]→UART_RX_BUF[1]→...),必须设为 DMA_MINC_ENABLE(递增);如果设为 "不递增",所有数据都会覆盖到 UART_RX_BUF[0],最终只保留最后 1 字节;
3. 数据宽度(PeriphDataAlignment/MemDataAlignment):两边必须一致
cpphdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; // 外设:字节(8位) hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; // 内存:字节(8位)讲究:外设和内存的数据宽度必须相同,否则会导致数据错位、乱码;
常见宽度:BYTE(8 位)、HALFWORD(16 位)、WORD(32 位);
代码场景:串口接收的是字符(1 字节 = 8 位),所以两边都设为 BYTE,正确;如果外设是 16 位传感器(如 12 位 ADC 左对齐为 16 位),则需设为 HALFWORD。
4. 传输模式(Mode):按 "是否重复传输" 选择
cpphdma_usart1_rx.Init.Mode = DMA_NORMAL; // 正常模式讲究:模式决定 DMA 传输完成后是否自动重启,选对直接影响数据接收稳定性:
- DMA_NORMAL(正常模式):传输完设定长度的数据后,DMA 停止工作,需手动调用 HAL_UART_Receive_DMA() 重启;适合 "一帧一帧接收"(如你的串口空闲中断场景),避免数据覆盖;
- DMA_CIRCULAR(循环模式):传输完设定长度后,DMA 自动回到起始地址,重复传输;适合 "持续接收、覆盖旧数据"(如实时数据流采集,比如每秒接收 100 字节,缓存区设 100 字节,循环覆盖);
5. 优先级(Priority):避免通道抢占冲突
cpphdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH; // 高优先级讲究:当多个 DMA 通道同时请求传输时(如串口 DMA + ADC DMA),优先级决定谁先传输;
优先级等级:DMA_PRIORITY_LOW(低)→MEDIUM(中)→HIGH(高)→VERY_HIGH(极高);
配置原则:
重要数据(如串口命令)设高优先级,非重要数据(如普通日志)设低优先级;
不要所有通道都设极高优先级,否则会导致抢占冲突,部分通道传输失败;
代码场景:串口接收是核心功能,设 HIGH 优先级,合理。
6. 中断配置:按需启用,避免冗余
cpp__HAL_DMA_ENABLE_IT(&hdma_usart1_rx, DMA_IT_TC); // 使能传输完成中断 HAL_NVIC_SetPriority(DMA1_Channel5_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA1_Channel5_IRQn);讲究:DMA 中断不是必须的,需根据场景选择,过多中断会占用 CPU:
常用中断类型:
- DMA_IT_TC(传输完成中断):传输完设定长度后触发(如你设 UART_RX_LEN=1024,接收 1024 字节后触发);
- DMA_IT_HT(半传输中断):传输到一半长度时触发(如接收 512 字节时触发,少用);
- DMA_IT_TE(传输错误中断):地址越界、外设错误时触发(建议必开,用于错误处理);
此段代码其实有点冗余:用 "空闲中断" 标记数据完成,DMA 传输完成中断(DMA_IT_TC)其实没用(只有接收满 1024 字节才触发,普通短数据不会触发),建议关闭,减少中断开销;
优化建议:保留 DMA_IT_TE(传输错误中断),关闭 DMA_IT_TC,避免无用中断。
7. 通道绑定:必须匹配外设与 DMA 通道的映射关系
cpphdma_usart1_rx.Instance = DMA1_Channel5; // USART1_RX 对应 DMA1_Channel5 __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx); // 绑定串口与 DMA核心讲究:STM32 的外设(如 USART1_RX)与 DMA 通道有固定映射关系,选错通道会导致 DMA 完全不工作!
示例(STM32F103 系列):
USART1_RX → DMA1_Channel5;
USART1_TX → DMA1_Channel4;
ADC1 → DMA1_Channel1;配置前必须查芯片手册的 "DMA 通道映射表",确认外设对应的 DMA 控制器(DMA1/DMA2)和通道号。
(2)MX_USART1_UART_Init(串口配置)
cpp
static void MX_USART1_UART_Init(void)
{
huart1.Instance = USART1; // 外设为USART1
huart1.Init.BaudRate = 115200; // 波特率115200(串口通信速率,需与串口工具一致)
huart1.Init.WordLength = UART_WORDLENGTH_8B; // 数据位:8位
huart1.Init.StopBits = UART_STOPBITS_1; // 停止位:1位
huart1.Init.Parity = UART_PARITY_NONE; // 校验位:无
huart1.Init.Mode = UART_MODE_TX_RX; // 模式:收发双向
huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 硬件流控:无(简单通信无需)
huart1.Init.OverSampling = UART_OVERSAMPLING_16; // 过采样:16倍(提高通信稳定性)
HAL_UART_Init(&huart1); // 初始化串口
}
(3)MX_GPIO_Init(GPIO 配置)
cpp
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOE_CLK_ENABLE(); // 使能GPIOE时钟(PE6引脚)
__HAL_RCC_GPIOA_CLK_ENABLE(); // 使能GPIOA时钟(PA8引脚,LED)
// 配置PA8为输出模式(LED控制)
GPIO_InitStruct.Pin = GPIO_PIN_8;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出(可驱动LED)
GPIO_InitStruct.Pull = GPIO_PULLUP; // 上拉(默认高电平,LED灭)
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速(LED无需高速)
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 配置PE6为输出模式(备用,可接另一个LED)
GPIO_InitStruct.Pin = GPIO_PIN_6;
HAL_GPIO_Init(GPIOE, &GPIO_InitStruct);
// 初始状态:PA8、PE6设为高电平(LED灭)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOE, GPIO_PIN_6, GPIO_PIN_SET);
}
3.3 完整工作流程(从串口发数据到任务处理)
用 "串口工具发送字符串" 为例,全程拆解:
①硬件准备:系统上电→main 函数初始化 DMA、串口、GPIO→启动 DMA 接收 + 空闲中断→创建初始化任务→启动 FreeRTOS 调度器;
②初始化任务执行:延时 5 秒→创建二值信号量→创建串口任务和 LED 任务→自删除;
③串口发送数据:串口工具发送字符串 "hello DMA!"→串口硬件接收数据;
④DMA 自动搬运:DMA 通道 5 自动把串口数据从 USART1->DR 搬到 UART_RX_BUF,CPU 全程不参与;
⑤触发空闲中断:串口工具发完数据→串口进入空闲状态→触发 UART_IT_IDLE 中断;
⑥中断处理:
- 清除空闲中断标志→停止 DMA;
- 计算数据长度(比如 "hello DMA!" 是 9 字节)→标记 UART_RX_STA=0X8009(bit15=1,低 15 位 = 9);
- 释放二值信号量(uart_rx_sem 从 0→1)→重启 DMA 接收;
⑦任务唤醒处理:
- 串口任务从阻塞态被唤醒→获取信号量(uart_rx_sem 从 1→0);
- 检查 UART_RX_STA 标记→调用 HAL_UART_Transmit 回显数据→printf 打印日志;
- 清空 UART_RX_STA 和 UART_RX_BUF→再次阻塞等待下一次信号量;
⑧LED 任务并行:整个过程中,LED 任务每 500ms 翻转一次,FreeRTOS 会在串口任务阻塞时切换到 LED 任务,实现并行执行。
|---------------|------------------------------------------------------------------------------------|------------------------------------------------|
| 流程节点 | 核心对应函数 / 操作 | 作用说明 |
| 底层硬件初始化 | HAL_Init()、SystemClock_Config()、MX_GPIO_Init()、MX_DMA_Init()、MX_USART1_UART_Init() | 初始化 HAL 库、系统时钟、GPIO(LED)、DMA 通道、串口(115200 波特率) |
| 启动 DMA + 空闲中断 | HAL_UART_Receive_DMA()、__HAL_UART_ENABLE_IT(UART_IT_IDLE) | 让 DMA 开始自动接收串口数据,使能空闲中断(数据收完触发) |
| 创建初始化任务 | xTaskCreate(task_create_entry) | 创建统一初始化任务,避免 main 函数冗余 |
| 初始化任务执行 | task_create_entry() | 延时等待硬件稳定、创建信号量和业务任务、自删除释放资源 |
| LED 任务循环 | led_toggle_entry():HAL_GPIO_TogglePin()、vTaskDelay(500) | 每 500ms 翻转 PA8 引脚(LED 闪烁),阻塞期间不占用 CPU |
| 串口任务阻塞等待 | uart_rx_entry():xSemaphoreTake(uart_rx_sem, portMAX_DELAY) | 无数据时阻塞,不浪费 CPU 资源 |
| DMA 自动搬运 | 无显式函数(DMA 硬件自动执行) | 无需 CPU 干预,将串口接收的数据直接存入 UART_RX_BUF 缓存区 |
| 空闲中断处理 | HAL_UART_IRQHandler() | 中断上下文快速处理:清标志、停 DMA、算长度、标记完成、释放信号量、重启 DMA |
| 信号量同步 | xSemaphoreGiveFromISR()(中断中)、xSemaphoreTake()(任务中) | 中断→任务的同步桥梁:通知任务 "数据已就绪" |
| 数据处理 | HAL_UART_Transmit()、printf()、memset() | 串口回显接收的数据、打印调试日志、清空缓存和状态,准备下一次接收 |
