STM32 串口与 DMA 进阶:如何打造高实时性交互体验
从最基础的点亮一颗 LED,到让单片机能够通过串口流畅地与用户的电脑控制台实现类似于“请求-执行-反馈”的交互(比如你输入数字 1 点亮灯,输入 4 开启 ADC 连续采样),这中间跨越的是外设协同编程思维的鸿沟。
本文从串口收发的陷阱讲起,并逐层递进,推演出一套最适用于具备严苛时序要求的高实时性交互框架:IDLE 断帧 + DMA。
一、printf 移植:底层 fputc 那些事儿
要在项目中使用 printf 输出日志菜单,就必须打通 C 库底层的发送通道。C 语言默认把字符串往显存终端发,而在单片机这儿我们只能重定向挂接去某个 USART 外设。
// 重定向 fputc
int fputc(int ch, FILE *f) {
USART_SendData(USART1, (uint8_t)ch);
// 这里非常重要:你一定要确保它传过去了!
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
return ch;
}
警惕 USART_FLAG_TC vs USART_FLAG_TXE:
TXE(Transmit Data Register Empty): 数据已从暂存器移到了发送移位寄存器里去组装并排着队送走。检查它最快。但数据可能还在排队,并未全发去硬件引脚上。TC(Transmission Complete): 这里表示真的没活儿干了。如果你打算在发完之后立马关掉设备的供电睡眠,等TC是最安全的保障。
致命的初始化依赖 Bug
有的新手刚配好头文件就在 main 开头打印 printf("hello world"),随后才在下面去调用自己写的 UART_Init()。这不仅什么也看不见,更有可能使程序陷入死等的 TXE 的底层状态而再也无法往下运行!
二、接收数据战争:轮询 VS 逐字中断
对于接收菜单这种“单字节命令”,我们总会去选择更省力的形式。
1. 简陋的轮询(Polling)
这是让你的 CPU 在无尽的 while(1) 里面跑断腿:
while (1) {
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) != RESET) {
char data = UART1->DR;
// 执行对应任务...
}
}
场景比喻:每隔 5 秒去楼下跑一趟包裹柜。
致命缺陷:如果你的主干循环里还有耗时操作(HAL_Delay),你必然会跑丢数据。更灾难的是:如果你还需要 CPU 时刻计算和发送脉冲控制像 WS2812 这样有严苛时序要求的灯带,这种抽帧轮询做法直接让系统瘫痪!
2. 传统串口逐字节中断(RXNE IT)
为了对抗 CPU 的空余浪费,利用外设中断管线让 CPU 接收通知。
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
if (rxBuffer == '4') {
Start_ADC_DMA();
}
// 重新开启下一次中断接收待命
HAL_UART_Receive_IT(&huart1, &rxBuffer, 1);
}
场景比喻:快递员来了专门给你打电话,你暂停手头去办。 这套流程完全能够搞定单字符的菜单。但当你想发送一个不确定长度的长字符串命令时,让整个 CPU 处于几毫秒内反复触发并停止响应的“被打断”状态也会极度消耗主频的带宽。
三、终极答案:IDLE + DMA 的自动流水线
这是业界真正处理高并发字符串通讯最青睐的解决方案!
什么是 IDLE + DMA?
它相当于一个拥有智能封包打包器的高级仓库通道:
- 纯净背景搬砖:你只需提前找一块大数组
RxBuffer。告诉 DMA 大将:“串口收到什么字节你别管,你直接按地址一个个怼进这块内存里面去,别来烦我(CPU)。” - 硬件断帧感知:当外面的电脑用 115200 发送完长长的
Hello STM32!封包时。串口必然会随之处于没有任何波形的“空闲停顿”。这就瞬间激活了串口的IDLE(总线空闲)状态。 - 只惊动一次 CPU:当这句整包传输完成后。外围触发一次唯一的高昂的中断
USART_IT_IDLE。你这才醒过来去处理 DMA 在后台拼好的成果。
必须掌握的核心流程
中断回调里的逻辑至关重要,你还要重新重装 DMA 的倒数计数器来开启下一轮游戏:
// 1. 读 SR 再读 DR 以此来暴力清除 IDLE 通知
(void)USART3->SR;
(void)USART3->DR;
// 2. 关停 DMA 传送器(为了安全修改内部地址等计数器)
DMA_Cmd(DMA1_Channel3, DISABLE);
// 3. 把总剩余容量进行相减,得到具体接收了啥
USART3_RxCount = USART3_RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel3);
// 4. 重装计数准备下次
DMA_SetCurrDataCounter(DMA1_Channel3, USART3_RX_BUFFER_SIZE);
DMA_Cmd(DMA1_Channel3, ENABLE);
如此处理,哪怕是你随意发送多长、多大的交互请求。这套基于硬件级别接力的设计永远不会令你在接收命令的瞬间,看到你的 LED 发生一丝意外的卡顿!