ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DMA固件开发指南:从原理到串口实作

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. 为什么固件开发离不开 DMA1.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 以上频率连续采样的场景定时器触发 ADCADC 用 DMA 把结果持续搬运到内存环形缓冲区CPU 只需要定期处理已经采集到的数据块。存储类应用SSD 固件开发里主机数据通过 PCIe/NVMe 到达控制器后绝大部分数据搬运都是 DMA 完成的。固件只需要维护描述符和状态位真正搬数据的是 DMA 引擎。显示与波形生成STM32F103 用 PWMDMA 驱动 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 会异步写这块缓冲区编译器优化时如果不加volatileCPU 侧很可能读到缓存里的旧值。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 的源地址填本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进