基于STM32F407的串口通信实战:用DMA释放CPU,真正实现“发完就忘”
你有没有遇到过这种情况——系统明明跑得挺稳,但只要一打开串口打印大量日志,主任务就开始卡顿?或者在做固件升级时,
HAL_UART_Transmit()
一调用就是几百毫秒的阻塞,整个实时性直接崩了?
说实话,我第一次调试一个图像上传功能的时候就被坑惨了。每帧数据大概1.5KB,波特率设的是115200,结果发现MCU几乎啥也干不了——光是等串口发完这一帧,就得花上百毫秒,期间还不断进中断,调度全乱套了。
后来才明白,传统轮询或中断方式处理串口,在大数据量面前根本扛不住。每个字节都要CPU参与?那不是让飞行员去拧螺丝嘛!
直到我用了 DMA + USART 的组合拳,才算真正把串口用明白了。
为什么你的串口总是拖累系统性能?
我们先来算笔账。
假设你用的是常见的 115200bps 波特率,传输8位数据+1停止位(即每帧10bit),那么理论最大吞吐量是:
115200 / 10 = 11,520 字节/秒
也就是说,每秒要收发超过一万一千个字节。如果采用中断方式接收,意味着 每秒要触发上万次中断 !
这还不包括发送端的情况。一旦你在主循环里频繁调用
printf
或者批量发送数据,CPU就会陷入“填缓冲 → 等完成 → 再填”的死循环中。
更别说现在很多应用已经不只是传几个参数了:
- 要上传传感器原始波形?
- 要做远程固件更新(OTA)?
- 要把摄像头拍到的BMP图片通过串口吐出去?
这时候你还指望靠
while(HAL_UART_Transmit())
撑住系统?别闹了 😅
好在,STM32F407这种级别的MCU早就给你准备好了“外挂”—— DMA 。
DMA到底是什么?它凭什么能让CPU躺平?
简单说,DMA 就是一个 独立的数据搬运工 。
它能在不打扰CPU的情况下,直接把内存中的数据搬到外设寄存器(比如USART的TDR),或者反过来,把外设接收到的数据自动存进内存缓冲区。
想象一下:以前是你自己一趟趟扛箱子从A地送到B地;现在你雇了个快递小哥,只告诉他起点、终点和数量,剩下的他全包了。你就可以安心写代码、处理业务逻辑去了。
STM32F407上的DMA架构长什么样?
STM32F407有两个DMA控制器:
-
DMA1
:7个stream
-
DMA2
:8个stream
每个stream可以绑定不同的channel(通道),对应不同外设的DMA请求信号。
以我们最常用的
USART1
为例:
- 发送(TX)→ 映射到
DMA2_Stream6,Channel 4
- 接收(RX)→ 映射到
DMA2_Stream5,Channel 4
📌 提示:APB2总线最高支持84MHz时钟,所以USART1理论上可达10.5Mbps波特率,配合DMA完全能跑满物理极限。
而且DMA支持多种工作模式:
-
Normal Mode
:传一次就停
-
Circular Mode
:循环缓冲,适合持续采样
- 还有FIFO、优先级控制、突发传输优化等高级特性
换句话说,只要你配置得当,DMA甚至能帮你实现一个“永不丢包”的接收环形缓冲区。
实战第一步:让DMA替你发数据
咱们先来看最典型的场景——异步发送一大段数据,比如JSON格式的状态上报、固件镜像传输等等。
目标很明确: 调用发送函数后立即返回,后台静默完成传输,完成后通知我一声就行。
HAL库怎么配DMA发送?
// 全局DMA句柄
DMA_HandleTypeDef hdma_usart1_tx;
static void MX_DMA_Init(void)
{
__HAL_RCC_DMA2_CLK_ENABLE();
// 配置DMA2_Stream6用于USART1_TX
hdma_usart1_tx.Instance = DMA2_Stream6;
hdma_usart1_tx.Init.Channel = DMA_CHANNEL_4; // 对应USART1
hdma_usart1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; // 内存→外设
hdma_usart1_tx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址固定(都是往TDR写)
hdma_usart1_tx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_usart1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; // 字节对齐
hdma_usart1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_usart1_tx.Init.Mode = DMA_NORMAL; // 单次传输
hdma_usart1_tx.Init.Priority = DMA_PRIORITY_HIGH; // 高优先级
hdma_usart1_tx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
if (HAL_DMA_Init(&hdma_usart1_tx) != HAL_OK) {
Error_Handler();
}
// 关键一步:把DMA和UART关联起来!
__HAL_LINKDMA(&huart1, hdmatx, hdma_usart1_tx);
}
📌 注意这个宏:
__HAL_LINKDMA(&huart1, hdmatx, hdma_usart1_tx);
它的作用是告诉HAL库:“以后
huart1
的发送DMA就用我刚配好的这个句柄。”
否则你调
HAL_UART_Transmit_DMA()
的时候会找不到DMA,直接卡死。
然后初始化UART的时候别忘了开启DMA时钟并使能TX DMA请求:
huart1.Instance = USART1;
huart1.Init.BaudRate = 115200;
huart1.Init.WordLength = UART_WORDLENGTH_8B;
huart1.Init.StopBits = UART_STOPBITS_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;
if (HAL_UART_Init(&huart1) != HAL_OK) {
Error_Handler();
}
// 启用DMA发送请求
__HAL_UART_ENABLE_DMA(&huart1, UART_DMAReq_Tx);
怎么发?一句话搞定!
uint8_t tx_buffer[] = "{\"temp\":25.3,\"hum\":60}\r\n";
HAL_UART_Transmit_DMA(&huart1, tx_buffer, sizeof(tx_buffer) - 1);
✅ 调用完立刻返回!
✅ CPU继续执行其他任务!
✅ 数据由DMA自动搬进USART_TDR!
✅ 传完触发中断,回调告诉你“搞定了”!
别忘了写回调函数
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
// 可以在这里置标志位、切换状态机、启动下一轮发送...
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); // 指示发送完成
}
}
💡 小技巧:如果你想连续发送多个包,可以在回调里再次调用
HAL_UART_Transmit_DMA()
,形成链式传输。
更狠的操作:DMA接收 + 空闲线检测,彻底告别字节级中断
发送还好办,顶多是阻塞问题。真正头疼的是接收——你怎么知道对方发完了?
很多人还在用这种方式:
while (HAL_UART_Receive(&huart1, &ch, 1, 10) == HAL_OK) {
buf[i++] = ch;
}
拜托……这种写法不仅效率低,还会导致超时判断不准,尤其在不定长协议(如AT指令、Modbus)中极易出错。
正确的做法是: DMA + IDLE Line Detection(空闲线检测)
它是怎么工作的?
STM32的USART有一个非常实用的功能叫 IDLE 中断 :当RX线上连续出现一个完整字符时间以上的静默期时,就会触发一次中断。
这意味着什么?
👉 如果对方一次性发来一串数据(比如”AT+READ?\r\n”),中间没有停顿,最后突然断开,那这个“断开瞬间”就会产生一个IDLE事件!
于是我们可以这样设计接收机制:
1. 启动DMA,让它一直把收到的数据往某个缓冲区搬;
2. 不管来了多少字节,都先存着;
3. 一旦检测到总线空闲(IDLE中断),说明一帧数据很可能已经收完了;
4. 此时再唤醒CPU去处理这整块数据。
这样一来,哪怕对方发了512字节,你也只被打扰一次!
配置DMA接收流
DMA_HandleTypeDef hdma_usart1_rx;
static void MX_DMA_Init(void)
{
// ...前面发送部分省略...
// 接收流:DMA2_Stream5
hdma_usart1_rx.Instance = DMA2_Stream5;
hdma_usart1_rx.Init.Channel = DMA_CHANNEL_4;
hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE;
hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; // 关键!循环模式
hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH;
hdma_usart1_rx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
HAL_DMA_Init(&hdma_usart1_rx);
__HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx);
}
注意这里用了 DMA_CIRCULAR 模式 ,意味着缓冲区满了不会停,而是从头开始覆盖。这对长期运行的系统特别有用。
接着开启UART的DMA接收请求和IDLE中断:
// 启动DMA接收
uint8_t rx_buffer[256];
HAL_UART_Receive_DMA(&huart1, rx_buffer, 256);
// 开启IDLE中断
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
记得在NVIC里也使能USART1的中断:
HAL_NVIC_SetPriority(USART1_IRQn, 0, 1);
HAL_NVIC_EnableIRQ(USART1_IRQn);
在中断服务函数中捕获IDLE事件
void USART1_IRQHandler(void)
{
uint32_t tmp_flag = __HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE);
uint32_t tmp_it_source = __HAL_UART_GET_IT_SOURCE(&huart1, UART_IT_IDLE);
if (tmp_flag && tmp_it_source) {
// 清除标志位(必须先读SR,再读DR)
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
// 停止当前DMA传输以便读取有效长度
HAL_UART_DMAStop(&huart1);
// 计算实际接收到的字节数
uint16_t rx_len = 256 - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
// 把数据拷贝到安全区域处理(避免被后续DMA覆盖)
memcpy(app_rx_buffer, rx_buffer, rx_len);
app_rx_length = rx_len;
// 标志位置位,通知主循环处理
rx_complete_flag = 1;
// 重启DMA接收
__HAL_DMA_DISABLE(&hdma_usart1_rx);
hdma_usart1_rx.Instance->M0AR = (uint32_t)rx_buffer; // 重置地址
hdma_usart1_rx.Instance->NDTR = 256; // 重置计数
__HAL_DMA_ENABLE(&hdma_usart1_rx);
}
// 其他中断处理...
HAL_UART_IRQHandler(&huart1);
}
🎯 核心思路就是:
- 收到IDLE → 说明帧结束 → 算出DMA已搬了多少字节 → 拷走数据 → 重置DMA继续监听
这样既保证了高效接收,又避免了频繁中断打扰CPU。
实际工程中的那些坑,我都替你踩过了 💣
你以为配完就能高枕无忧?Too young.
我在实际项目中遇到过不少诡异问题,分享几个典型雷区,帮你少走弯路。
❌ 问题1:DMA接收缓冲区被Cache“保护”了?
如果你开启了DCache(比如某些带FPU的复杂系统),而你的接收缓冲区恰好落在可缓存区域,可能会出现一种奇怪现象:
“明明看到DMA说收到了100个字节,但我一读内存,全是0!”
原因很简单:DMA写进了物理内存,但CPU读的是Cache里的旧数据。
✅ 解决方案有两种:
1. 把接收缓冲区定义在
非缓存区(Uncached Region)
2. 或者在读取前手动刷新Cache:
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buffer, 256);
推荐做法是在链接脚本里划一块NO_CACHE区域专门给DMA用。
❌ 问题2:DMA传输中途被抢占,数据错乱?
DMA虽然强大,但也怕冲突。比如你正在用DMA发数据,另一个高优先级任务突然也要用同一个Stream(比如ADC也在用DMA2_Stream6),会发生什么?
轻则传输失败,重则地址错乱、HardFault。
✅ 解决办法:
- 使用不同的DMA控制器或Stream;
- 必须共用时,加互斥锁(比如用信号量保护DMA资源);
- 或者干脆提高关键DMA的优先级到“极高”。
❌ 问题3:循环模式下如何防止数据被覆盖?
你开了Circular模式,DMA一直在写rx_buffer。但如果主程序没及时处理,新来的数据就把老数据盖掉了。
怎么办?
✅ 建议引入双缓冲机制(Double Buffer)或使用 DMA Half-Transfer Interrupt :
// 启用半传输中断
hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_usart1_rx.Init.MemBurst = DMA_MBURST_SINGLE;
hdma_usart1_rx.XferHalfCpltCallback = MyHalfCpltCallback; // 注册回调
当DMA写满前一半(128字节)时就提醒你处理,另一半还能接着收。相当于流水线作业,大大降低丢包风险。
这些场景,闭眼用DMA就对了 ✅
别再纠结要不要上DMA了,下面这些情况直接冲:
| 应用场景 | 是否推荐DMA | 理由 |
|---|---|---|
| 日常调试打印 | ❌ 否 |
数据量小,用
ITM
或普通中断足矣
|
| 固件OTA升级 | ✅ 强烈推荐 | 几百KB数据,必须异步后台传输 |
| 传感器原始数据聚合 | ✅ 推荐 | 高频采样+批量上报,减少中断抖动 |
| Modbus RTU主站轮询 | ✅ 推荐 | 提升响应速度,降低协议层延迟 |
| 图像/音频流传输 | ✅ 必须用 | 否则CPU直接瘫痪 |
| 日志持久化存储 | ✅ 推荐 | 避免影响主控逻辑 |
特别是做工业网关、边缘计算盒子这类设备,串口往往是连接PLC、仪表的核心通路,DMA几乎是标配。
给初学者的几点建议 🛠️
-
不要一开始就追求完美架构
先从简单的单次DMA发送开始,确保基本流程通了再说。然后再加接收、加IDLE、加双缓冲。 -
善用调试工具
- 用逻辑分析仪看TX波形,确认是否真的连续发出;
- 在回调函数里翻转GPIO,用示波器测DMA传输耗时;
- 查看__HAL_DMA_GET_COUNTER()值验证剩余字节数。 -
学会看参考手册的关键章节
- RM0090 第9章:DMA controller
- RM0090 第19章:USART
- 数据手册中的DMA请求映射表 -
关注DMA状态机
Stream不是随时都能写的,要检查是否处于“Ready”状态。可以用HAL_DMA_GetState()判断。 -
命名规范很重要
比如:
c DMA_HandleTypeDef hdma_usart1_tx; DMA_HandleTypeDef hdma_usart1_rx;
清晰明了,后期维护不抓瞎。
写到最后:嵌入式开发的本质是“聪明地偷懒”
DMA不是一个炫技的功能,它是嵌入式工程师用来 提升系统效率、增强实时性、降低功耗 的核心武器之一。
当你学会让硬件替你干活,你才有资格谈“高性能系统设计”。
下次当你又要写一堆
while(!tx_complete)
的时候,停下来问问自己:
“我真的需要亲自盯着每一个字节发出去吗?”
也许答案早已写在STM32的DMA控制器里了 😉
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
1004

被折叠的 条评论
为什么被折叠?



