DMA固件开发指南:从原理到串口实作
简介:DMA固件开发指南[源码]定位于嵌入式系统开发者,针对直接内存访问(DMA)控制器在固件层面的设计、配置与调试,帮助读者掌握块传输、请求传输及级联传输等典型模式,并理解如何通过DMA降低CPU负担、提升系统吞吐。压缩包共4个文件,包含一个C源码示例(dma_demo.c)、工程配置(dma_demo)、版本管理忽略文件(.gitignore)及在线运行环境描述(.inscode),整体仅8KB,轻量且便于对照学习。内容覆盖从需求分析、硬件接口配置到软件设计的关键步骤,并给出DMA控制器初始化、启动传输及中断处理的示例代码;同时总结了数据不完整、性能瓶颈等常见问题的排查思路与调试技巧。已有397人学习下载,适合具备一定嵌入式基础、希望快速上手DMA固件开发的工程师。 做嵌入式固件这行十多年,DMA 是我用过就回不去的硬件外设之一。早年在 STM32F103 上做串口数据采集,CPU 动不动就被中断打满,后来把串口、ADC、SPI 全部切换到 DMA 搬运,CPU 占用率从 60% 直接降到 5% 以内。这次整理这份 DMA 固件开发指南,不打算把参考手册给你翻译一遍,而是把我实际做 DMA 固件时踩过的坑、验证过的思路和可以直接抄的源码结构整理出来。内容涵盖 DMA 的核心原理、传输模式选型、句柄关联、串口 DMA 收发完整实现,以及从 MCU 到 Zynq/DSP 的差异。适合正在做 STM32、GD32、Zynq、DSP 这类平台固件的开发者,尤其是刚把 DMA 纳入自己项目、对中断和内存管理还不太熟的朋友。看完这篇,你应该能少走很多弯路。
1. 为什么固件开发离不开 DMA
1.1 DMA 到底解决了什么问题
CPU 本质上是个执行指令的机器,而外设收发数据时,CPU 要一条一条地把数据从寄存器搬到内存、再从内存搬到外设。这个搬运过程本身不产生任何业务价值,却占用了 CPU 最宝贵的执行周期。DMA 做的事情很简单:把"搬运"这个动作接过去,让 CPU 去跑协议栈、跑业务逻辑、跑算法。
我用一个生活化的类比。你点了一份外卖,骑手送到楼下,如果每一份外卖都要你亲自下楼跑一趟去拿,你一天什么事都别干了。DMA 就相当于物业的快递柜,外卖到了直接放进柜子,你攒到合适的时候一次性去取。CPU 该写代码写代码,该算数据算数据,只有快递柜满了(缓冲区满/传输完成)才需要你去处理一下。
1.2 什么样的固件场景需要 DMA
不是所有场景都需要 DMA。判断标准很简单:数据量大不大、频率高不高、CPU 还忙不忙。以下是我实际项目里用 DMA 最多的几类场景:
- 串口数据流:比如 4G 模块、GPS、LoRa 这类持续往外吐数据的设备。一个 115200 波特率的串口,每秒大概产生 11.5KB 数据,用中断接收的话每字节触发一次中断,CPU 几乎被钉死。换成 DMA 接收 + 空闲中断,CPU 只在收到一帧完整数据时才参与处理。
- ADC 连续采样:电力监测、音频采集这类需要以几十 kHz 以上频率连续采样的场景,定时器触发 ADC,ADC 用 DMA 把结果持续搬运到内存环形缓冲区,CPU 只需要定期处理已经采集到的数据块。
- 存储类应用:SSD 固件开发里,主机数据通过 PCIe/NVMe 到达控制器后,绝大部分数据搬运都是 DMA 完成的。固件只需要维护描述符和状态位,真正搬数据的是 DMA 引擎。
- 显示与波形生成:STM32F103 用 PWM+DMA 驱动 WS2812 灯带,或者用 DMA 从查找表往 DAC 搬运波形数据。热词里提到的"dma句柄与dac句柄关联"就是这个方向,后面我会专门讲。
在这些场景里,DMA 不只是"省 CPU",它直接决定了系统能不能支撑更高的吞吐率。判断一个功能要不要上 DMA,我一般会问三个问题:数据是不是持续产生?单次数据量是不是超过几十字节?CPU 是不是同时还在跑协议栈或 UI?如果答案大部分是"是",那就应该用 DMA。
2. DMA 固件设计的核心细节
2.1 传输模式选型:单次、循环还是双缓冲
DMA 的传输模式看起来就几个选项,但选错的人非常多。最常见的三种:
单次传输(Normal):传输次数到了就停,需要重新使能才能再触发。适合一次性的数据块搬运,比如把一帧协议数据从内存搬到 SPI 发送寄存器。
循环传输(Circular):搬完自动重新开始,配合环形缓冲区特别好用。ADC 连续采样、串口接收不定长数据,都用循环模式。
双缓冲(Double Buffer):两个缓冲区轮流用,一个在搬数据,另一个在处理数据。这个模式在高速场景几乎标配,比如音频流、高速 ADC、以太网 DMA 描述符。
选型的核心原则是:看你的数据是"有界的任务"还是"无界的流"。有界任务用单次,无界流用循环,既要无界又要处理低延迟就用双缓冲。我在做串口 DMA 接收时默认选循环模式,理由是串口数据永远不知道什么时候结束,循环模式可以保证 DMA 永远在等数据,不会因为传输次数耗尽而停止接收。
2.2 中断与回调:什么时候该开中断
DMA 传输完成可以产生中断,但开中断不是越多越好。我见过不少项目,DMA 确实用了,但中断里做的事情太多,导致实时性反而变差了。
我的做法是分三层处理:传输完成中断里只置标志位,或者用信号量通知任务层去处理,绝不在中断里做耗时的数据处理。传输一半中断(Half Transfer)很多人忽略,但在音频或 ADC 双缓冲场景里非常好用——DMA 写完前半段缓冲区时触发,CPU 可以处理前半段,DMA 正在写后半段,相当于软双缓冲。错误中断一定要开,DMA 传输错误(溢出、总线错误)如果不处理,系统会安静地丢数据,排查起来极其痛苦。
一个实际经验:在裸机环境里,我习惯把 DMA 完成中断和空闲中断合并到一个 IRQHandler 里处理,先读状态寄存器判断是哪种事件,再分别处理。这样能避免两个中断源抢优先级导致事件丢失。
2.3 DMA 句柄与设备句柄的关联
热词里有一条"dma句柄与dac句柄关联",这其实是个非常关键的固件设计概念。DMA 不是一个孤立的模块,它是给其他外设服务的。在 HAL/LL 库(比如 STM32Cube)里,每个使用 DMA 的外设都有独立的句柄,同时又有 DMA 通道的句柄。
正确的关联方式是这样:外设句柄(如 UART_HandleTypeDef、DAC_HandleTypeDef)负责描述外设本身的工作参数,比如波特率、分辨率、数据格式。DMA 句柄(DMA_HandleTypeDef)负责描述搬运参数,比如源地址、目的地址、数据宽度、传输方向。两者通过__HAL_LINKDMA这样的宏绑定在一起。
绑定的本质是把 DMA 通道信息挂到外设句柄的成员指针上,这样外设启动时(比如HAL_UART_Transmit_DMA),内部会调用HAL_DMA_Start_IT去配置并启动 DMA。
这里踩过一个坑:句柄关联写在某个条件初始化分支里,启动函数在另一个分支里调用,初始化顺序一乱,huart->hdmatx还是空指针,调用HAL_UART_Transmit_DMA直接进 HardFault。所以我养成了一个习惯:在调试器里把外设句柄展开看一遍,确认hdmatx和hdmarx都已经指向有效地址,再跑传输。
3. 实操:STM32 串口 DMA 收发的完整实现
3.1 工程配置与内存规划
示例以 STM32G031 为例(手上正好有这个片子,用 LL 库做串口 DMA 特别清爽),思路完全适用于 F1/F4/H7、GD32 等同系列。
先说内存规划。DMA 搬运要保证源地址和目的地址都对齐到数据宽度。串口数据宽度一般是 8bit,对齐要求不高,但如果你要搬 32bit 数据,缓冲区起始地址最好 4 字节对齐。我一般这样声明接收缓冲区:
#define UART_RX_BUF_SIZE 512 static uint8_t uart_rx_buf[UART_RX_BUF_SIZE] __attribute__((aligned(4))); static volatile uint16_t uart_rx_index = 0; /* DMA 当前写到的位置 */ static volatile uint8_t uart_frame_ready = 0;volatile关键字不能省,因为 DMA 会异步写这块缓冲区,编译器优化时如果不加volatile,CPU 侧很可能读到缓存里的旧值。
LL 库初始化串口 DMA 比 HAL 库直观得多。核心步骤:配置串口参数、使能 UART 的 DMA 请求、配置 DMA 通道、使能 DMA。
void uart_dma_init(void) { LL_USART_InitTypeDef usart_init = {0}; usart_init.BaudRate = 115200; usart_init.DataWidth = LL_USART_DATAWIDTH_8B; usart_init.StopBits = LL_USART_STOPBITS_1; usart_init.Parity = LL_USART_PARITY_NONE; usart_init.TransferDirection = LL_USART_DIRECTION_TX_RX; usart_init.HardwareFlowControl = LL_USART_HWCONTROL_NONE; LL_USART_Init(USART1, &usart_init); LL_USART_EnableDMAReq_RX(USART1); LL_USART_EnableDMAReq_TX(USART1); LL_DMA_SetPeriphAddr(DMA1, LL_DMA_CHANNEL_1, LL_USART_DMA_GetRegAddr(USART1, LL_USART_DMA_REG_DATA_RECEIVE)); LL_DMA_SetMemoryAddr(DMA1, LL_DMA_CHANNEL_1, (uint32_t)uart_rx_buf); LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_1, UART_RX_BUF_SIZE); LL_DMA_SetPeriphSize(DMA1, LL_DMA_CHANNEL_1, LL_DMA_PDATAALIGN_BYTE); LL_DMA_SetMemorySize(DMA1, LL_DMA_CHANNEL_1, LL_DMA_MDATAALIGN_BYTE); LL_DMA_SetMode(DMA1, LL_DMA_CHANNEL_1, LL_DMA_MODE_CIRCULAR); LL_DMA_SetTransferDirection(DMA1, LL_DMA_CHANNEL_1, LL_DMA_DIRECTION_PERIPH_TO_MEMORY); LL_DMA_EnableIT_TC(DMA1, LL_DMA_CHANNEL_1); LL_DMA_EnableIT_HT(DMA1, LL_DMA_CHANNEL_1); LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_1); }几个细节值得展开:LL_USART_DMA_GetRegAddr获取的是 USART 数据寄存器的地址,DMA 的源地址填
本文还有配套的精品资源,点击获取
