如何让ARM Cortex-M的串口“自己干活”?DMA配置实战全解析
你有没有遇到过这种情况:系统跑着跑着,突然收不到UART数据了?查了半天发现是高速通信时CPU被中断淹没,根本来不及处理——这就是传统轮询或中断方式在高波特率下的典型瓶颈。
尤其是在做固件升级、音频流传输或者工业Modbus通信时,动辄几百KB的数据量,如果每个字节都要触发一次中断,那CPU别说干正事了,光“接电话”就得累趴下。
怎么办?答案是:让硬件替你干活。
而最有效的手段之一,就是——串口DMA。
今天我们就来彻底讲清楚,在ARM Cortex-M平台上,如何真正把串口DMA用起来,不只是调个HAL库函数那么简单,而是从原理到寄存器级配置,再到实际工程避坑,一文打尽。
为什么非得用DMA?
先说个真实案例:某客户做了一个基于STM32F4的传感器网关,原本设计为每秒通过UART接收64KB原始数据。一开始用中断方式,结果发现超过115200波特率就开始丢包;换成DMA后,轻松跑到了2Mbps,且CPU占用几乎为零。
这背后的核心差异在哪?
| 维度 | 中断方式 | DMA方式 |
|---|---|---|
| 每字节是否打断CPU | ✅ 是 | ❌ 否(仅结束时通知) |
| 数据搬运谁来做 | CPU亲自搬 | 硬件自动搬 |
| 实际吞吐上限 | 受限于中断响应延迟 | 接近物理层极限 |
| 适合场景 | 调试打印、低频命令交互 | 高速数据采集、OTA升级 |
所以,当你面对的是连续、大批量、实时性强的数据流时,不用DMA,等于主动放弃性能天花板。
DMA到底是个啥?它怎么和UART搭上线的?
别被名字吓住,“Direct Memory Access”听起来高大上,其实本质很简单:
DMA就是一个专职搬运工,专门负责在外设和内存之间搬数据,不占CPU工时。
在Cortex-M芯片里(比如STM32系列),DMA控制器通常是独立模块,挂在AHB总线上,能直接访问SRAM和所有支持DMA请求的外设,包括USART、SPI、ADC等。
它是怎么跟UART配合工作的?
我们以最常见的DMA接收模式为例,拆解整个流程:
- 你告诉DMA:“我要从USART1的DR寄存器往rx_buffer搬256个字节。”
- 你再告诉USART1:“我启用了DMA,有数据来了别叫我,直接发信号给DMA就行。”
- 外部设备开始发送数据 → USART1收到一个字节 → 自动产生DMA请求;
- DMA收到请求 → 把这个字节从
USART1->DR读出来 → 写进rx_buffer[0]; - 地址递增,计数减一,继续下一步……直到搬完256个;
- 搬完了,DMA说:“老板,活干完了!” 触发中断,让你去处理数据。
全程CPU只参与开头设置和结尾收尾,中间完全可以去执行算法、调度任务、甚至睡觉。
关键寄存器怎么配?别再只靠HAL了!
虽然现在很多人用HAL库一键启动DMA:
HAL_UART_Receive_DMA(&huart1, buffer, size);但如果你不知道背后发生了什么,出了问题就只能“重启试试”、“换波特率看看”,没法真正掌控系统。
下面我们深入到底层,看看关键寄存器是怎么设置的——以STM32F4为例。
1. 找对DMA通道与Stream
首先得知道:哪个DMA Stream对应哪个外设?
比如STM32F4的USART1_RX通常绑定到:
- DMA2_Stream2
- Channel = 4
这是写死的,必须查参考手册RM0090里的《DMA request mapping》表格确认。
2. 核心配置参数一览
| 寄存器字段 | 配置值 | 说明 |
|---|---|---|
DIR(Direction) | PeriphToMemory | 数据从外设流向内存 |
PAR(Peripheral Address) | &USART1->DR | 固定地址,每次读同一位置 |
MAR(Memory Address) | rx_buffer | 缓冲区首地址 |
NDTR(Number of Data Register) | 256 | 传输256次 |
PINC(Peripheral Inc) | Disable | 外设地址不变 |
MINC(Memory Inc) | Enable | 内存地址自动+1 |
PSIZE/MSIZE | Byte / Byte | 数据宽度8位对齐 |
CIRCULAR | Optional | 循环模式开关 |
PRIORTY | High | 建议高于普通任务 |
这些最终都会映射到具体的DMA_SxCR、SxPAR、SxMAR、SxNDTR等寄存器中。
3. 寄存器操作示例(LL库风格)
不想用HAL?可以用LL库直接操作:
// 使能时钟 __HAL_RCC_DMA2_CLK_ENABLE(); // 配置DMA Stream 2 LL_DMA_SetDataTransferDirection(DMA2, LL_DMA_STREAM_2, LL_DMA_DIRECTION_PERIPH_TO_MEMORY); LL_DMA_SetChannelSelection(DMA2, LL_DMA_STREAM_2, LL_DMA_CHANNEL_4); LL_DMA_SetPeriphRequest(DMA2, LL_DMA_STREAM_2, LL_DMA_REQUEST_4); // USART1_RX LL_DMA_SetMemoryIncMode(DMA2, LL_DMA_STREAM_2, LL_DMA_MEMORY_INCREMENT); LL_DMA_SetPeriphSize(DMA2, LL_DMA_STREAM_2, LL_DMA_PDATAALIGN_BYTE); LL_DMA_SetMemorySize(DMA2, LL_DMA_STREAM_2, LL_DMA_MDATAALIGN_BYTE); LL_DMA_SetMode(DMA2, LL_DMA_STREAM_2, LL_DMA_MODE_NORMAL); // 或 CIRCULAR LL_DMA_SetPriority(DMA2, LL_DMA_STREAM_2, LL_DMA_PRIORITY_HIGH); // 设置地址 LL_DMA_SetPeriphAddress(DMA2, LL_DMA_STREAM_2, (uint32_t)&USART1->DR); LL_DMA_SetMemory0Address(DMA2, LL_DMA_STREAM_2, (uint32_t)rx_buffer); LL_DMA_SetDataLength(DMA2, LL_DMA_STREAM_2, 256); // 开启中断(可选) LL_DMA_EnableIT_TC(DMA2, LL_DMA_STREAM_2); // 传输完成中断 NVIC_EnableIRQ(DMA2_Stream2_IRQn); // 最后一步:启动DMA LL_DMA_EnableStream(DMA2, LL_DMA_STREAM_2); // 别忘了开启UART的DMA请求! LL_USART_EnableDMAReq_RX(USART1);看到没?这才是真正的“掌控感”。每一行代码都清楚知道自己在干什么。
发送也一样高效:DMA帮你“悄悄发完”
接收可以用DMA,发送当然也可以。
想象一下你要发一个128KB的固件包,如果用中断逐字节发,不仅效率低,还可能因为调度延迟导致帧间间隔过大,对方接收失败。
而用DMA发送,步骤也很清晰:
- 准备好待发送数据缓冲区
tx_buffer[] - 配置DMA方向为
MemoryToPeripheral - 源地址 =
tx_buffer,目标地址 =USART1->DR - 启动DMA,它会自动把数据一个个塞进TDR,UART自动串行发出
- 发完了给你个中断,你可以接着发下一包
关键点在于:一旦启动,你就不用管了,CPU自由了。
高阶玩法:双缓冲 + 空闲线检测 = 真·无缝接收
前面说的都是“一次性搬256字节”,搬完中断。但如果数据是持续不断的呢?比如音频流、实时监控日志?
这时候你需要两个利器:
1. 循环模式(Circular Mode)
启用后,DMA搬完一圈自动回到起点重新填,形成一个无限循环缓冲区。
⚠️ 注意:这种模式下不会频繁中断,你得靠其他机制判断“哪里是有效数据”。
2. 双缓冲模式(Double Buffer)
更高级!允许你设置两个独立缓冲区 A 和 B。
- 当前使用A → 搬完切换到B → 同时通知CPU处理A中的数据;
- 处理完A → 切回A作为下一个备用区……
这样就能实现零等待切换,特别适合音频采集这类不能断流的应用。
3. 空闲线检测(IDLE Line Detection)+ DMA暂停
这才是处理不定长帧(如Modbus RTU)的王道组合!
原理如下:
- 启动DMA接收,缓冲区设大一点(如256字节)
- 同时开启UART的IDLE中断(线路空闲即触发)
- 数据发完后,总线静默一段时间 → 触发IDLE中断
- 在中断里立刻暂停DMA → 此时已接收的数据就是完整一帧
- 记录实际长度,交给协议栈处理
- 清空状态,重启DMA,等待下一帧
这样一来,既避免了定时器超时判断的延迟,又能精准捕获帧边界。
代码示意:
void USART1_IRQHandler(void) { if (LL_USART_IsActiveFlag_IDLE(USART1)) { // 清除标志 LL_USART_ClearFlag_IDLE(USART1); // 暂停DMA LL_DMA_DisableStream(DMA2, LL_DMA_STREAM_2); // 获取已接收字节数 uint16_t received_len = 256 - LL_DMA_GetDataLength(DMA2, LL_DMA_STREAM_2); // 提交数据处理 process_modbus_frame(rx_buffer, received_len); // 重置并重启 LL_DMA_SetDataLength(DMA2, LL_DMA_STREAM_2, 256); LL_DMA_EnableStream(DMA2, LL_DMA_STREAM_2); } }这套组合拳下来,哪怕是921600波特率下的Modbus通信,也能稳如老狗。
工程实践中那些“踩过的坑”
理论再完美,落地才是考验。以下是我在多个项目中总结出的关键注意事项:
✅ 坑点1:缓存一致性问题(Cortex-M7/M55必看)
如果你的MCU带DCache(如STM32H7、LPC55S69),注意!
DMA写入的是SRAM物理地址,但CPU可能从Cache读取旧数据。结果就是:明明收到了数据,程序却“看不见”。
解决方案:
- 方法一:将DMA缓冲区放在非缓存区域(Uncached SRAM),通过链接脚本分配.dma_buf段
- 方法二:在处理数据前执行SCB_InvalidateDCache_by_Addr()强制刷新
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buffer, 256);否则你会陷入“数据确实来了但我拿不到”的诡异调试地狱。
✅ 坑点2:内存对齐要求别忽视
某些DMA控制器要求地址按数据宽度对齐。例如:
- 使用半字(16位)传输 → 地址需2字节对齐
- 使用字(32位)传输 → 地址需4字节对齐
否则可能导致HardFault或传输错误。
解决方法:显式对齐声明
__ALIGNED(4) uint8_t rx_buffer[256]; // 强制4字节对齐或者用DMA-friendly的内存池管理。
✅ 坑点3:低功耗模式下DMA还能工作吗?
答案是:取决于你的低功耗模式。
- Sleep模式:CPU停,但外设时钟仍在 → DMA可正常运行 ✅
- Stop模式:大部分时钟关闭 → DMA停止 ❌
- Standby:全系统断电 → 想都别想
所以如果你想在低功耗下继续接收心跳包,记得:
- 使用Sleep而非Stop
- 保持DMA和UART时钟开启
- 可结合RTC唤醒周期性检查
✅ 坑点4:错误处理不能少
DMA不是万能的,也会出错。常见异常包括:
- 传输错误(TEIF)
- FIFO溢出(FE/ORE)
- 地址不对齐
- 总线冲突
建议在初始化时开启相关中断,并编写健壮的恢复逻辑:
if (LL_DMA_IsActiveFlag_TE(DMA2, LL_DMA_STREAM_2)) { LL_DMA_ClearFlag_TE(DMA2, LL_DMA_STREAM_2); // 重启DMA restart_uart_dma(); }宁可多花几行代码,也不要让系统卡死。
实际应用场景推荐
| 应用场景 | 是否推荐DMA | 推荐理由 |
|---|---|---|
| 调试信息输出(printf) | ⭕ 可选 | 数据量小,中断足够 |
| Modbus RTU通信 | ✅ 强烈推荐 | 高波特率防丢包 |
| OTA固件升级 | ✅ 必须用 | 减少下载时间,提升成功率 |
| 音频数据采集/播放 | ✅ 核心依赖 | 实现无中断音频流 |
| 多传感器聚合上报 | ✅ 推荐 | 提升并发能力 |
| 低功耗蓝牙透传 | ✅ 推荐 | 收包时不唤醒CPU |
一句话总结:只要数据量上来,就必须上DMA。
写到最后:掌握DMA,才算真正入门嵌入式
很多初学者觉得“能点亮LED、串口打印Hello World”就算学会了单片机。但实际上,只有当你开始思考“如何减少CPU干预”、“怎样提高系统效率”时,才真正踏入了嵌入式开发的大门。
而串口DMA,正是这条路上的第一个里程碑。
它教会你:
- 如何理解硬件协同机制
- 如何平衡资源与性能
- 如何写出稳定可靠的底层驱动
下次当你面对一个高速通信需求时,不要再问“能不能扛得住”,而是直接动手:
“让我给它配上DMA。”
这才是工程师该有的底气。
如果你正在做一个需要高性能串行通信的项目,不妨试试今天的方案。有任何问题,欢迎留言讨论,我们一起把每一个细节抠明白。