1. 这不是“搬运数据”的搬运工,而是嵌入式系统里最懂节奏的指挥家
DMA——直接内存访问(Direct Memory Access),在嵌入式驱动开发中常被初学者误读为“省事的 memcpy”,被中级工程师当作“配置完就不管的黑盒”,而真正跑过百万行产线代码的老手心里都清楚:DMA 不是功能模块,它是整个数据通路的节拍器、带宽分配器、时序仲裁者,更是系统稳定性的第一道守门人。
我做过 7 款量产级工业控制器的底层驱动重构,从 GD32F407 到 STM32H750,再到 NXP i.MX RT1176,所有涉及高速 ADC 采样、SPI Flash 流式写入、LCD 显存刷屏、以太网 DMA 收发的场景,最终瓶颈几乎都卡在 DMA 配置的毫秒级偏差上。比如某款激光测距仪项目,ADC 采样率标称 1MSPS,实测有效数据率始终卡在 820kSPS,排查三天才发现是 DMA 的 Circular Mode 下未正确同步 TIM 触发源与外设寄存器更新时机,导致每 128 个样本丢弃 1 个——不是硬件问题,是 DMA 控制器在“等一个它以为该来的信号”,而那个信号,其实早被 CPU 写寄存器的延迟吃掉了。
本期聚焦 DMA,不讲教科书定义,不列寄存器地址表,只拆解真实项目里你一定会踩的坑、必须懂的逻辑、绕不开的取舍。关键词“嵌入式”“驱动开发”“DMA”不是标签,是坐标:它锚定你在资源受限、实时性敏感、硬件耦合深的环境里,如何让数据流像地铁准点运行一样可靠。适合三类人:刚写完第一个字符设备驱动、正被串口丢包折磨的新人;能调通中断但一加 DMA 就崩溃的进阶者;以及正在做 Linux platform driver 或裸机 BSP 移植、需要厘清 DMA Engine 与硬件通道映射关系的架构侧同学。下面所有内容,都来自产线贴片机、医疗监护仪、智能电表这些真实设备的调试日志、示波器截图和烧录失败的芯片堆。
2. 为什么不能把 DMA 当成“自动 memcpy”?——从硬件本质到软件抽象的断层
2.1 DMA 的物理真相:它根本不是“CPU 的替身”,而是独立于 CPU 的微型协处理器
很多教程说“DMA 让 CPU 不用搬数据”,这说法本身没错,但极具误导性。真实情况是:DMA 控制器(DMAC)是一套拥有自己总线仲裁器、地址生成器、传输计数器、触发状态机的独立硬件模块,它甚至有自己的时钟域和电源域。以 STM32F407 的 DMA2 为例,它有 8 个通道,每个通道可配置 4 种请求源(SPI1_RX、TIM1_UP、ADC1、EXTI9_5 等),但关键在于:这些请求源并非“随时可发”,而是受制于外设自身的 Ready/Busy 信号、寄存器锁存周期、以及 DMAC 自身的优先级仲裁逻辑。
举个反直觉的例子:当 SPI1 设置为 10MHz 波特率收数据,理论上每 100ns 收 1 字节。但如果你配置 DMA 为“外设到内存”、Memory Increment Enable、Circular Mode,看似完美——实际运行时你会发现,DMA 每次搬运 16 字节后,第 17 字节开始出现错位。原因?SPI1 的 RXNE(接收缓冲区非空)标志位,从硬件采样到置位,存在 2~3 个 APB2 时钟周期的延迟;而 DMAC 在检测到 RXNE 后,需再经 1 个 AHB 总线周期才能启动传输;若此时 CPU 正在执行一条多周期指令(如 LDRD),总线被占用,DMA 请求会被挂起——这 3~5 个周期的不确定性,在高速传输下直接导致 FIFO 溢出或数据覆盖。
提示:这不是“配置错了”,而是硬件设计的固有特性。所有 Cortex-M 系列 MCU 的 Reference Manual 第 12 章 DMA 部分,都会强调 “The DMA controller operates independently of the CPU core and has its own bus interface” —— 独立二字,是理解一切问题的起点。
2.2 软件抽象的代价:Linux kernel 的 dmaengine API 与裸机寄存器操作的本质差异
在嵌入式 Linux 驱动开发中,我们习惯调用dma_map_single()、dmaengine_submit()、dma_async_issue_pending()这套 API。表面看很优雅,但背后隐藏着三层抽象:
第一层:DMA Engine 子系统
它将不同 SoC 的 DMA 控制器(如 i.MX 的 SDMA、STM32 的 DMAMUX、Allwinner 的 DMA)统一抽象为struct dma_device,通过device_tx_status()查询状态,用device_issue_pending()提交任务。但这个“提交”不等于硬件启动——它只是把描述符(descriptor)链表挂到 channel 的 pending queue,由内核线程dma_worker负责实际下发。第二层:DMA Buffer 管理
dma_map_single()不仅做地址转换(ARM 的 IOMMU 或 SWIOTLB),还会根据DMA_TO_DEVICE/DMA_FROM_DEVICE标志,决定是否 flush/invalidate cache。这里有个经典陷阱:若你用kmalloc()分配 buffer,其物理地址可能不连续,dma_map_single()会 internally bounce buffer(拷贝到连续物理页),导致实际传输的是 bounce buffer 地址,而非你传入的虚拟地址——而你的外设寄存器却写死了你传入的地址,结果数据永远到不了你预期的位置。第三层:Channel 复用与抢占
Linux 默认启用CONFIG_DMADEVICES,多个驱动(如 SPI、I2C、MMC)共享同一组 DMA channel。当你调用dma_request_chan()申请spi0_tx,内核会从dmaengine的 channel pool 中分配一个空闲 channel,但若此时 MMC 正在进行大块写入,它可能已占用该 channel 的硬件资源,dmaengine_submit()返回 -EBUSY。而裸机开发中,你直接操作寄存器,channel 是静态绑定的,不存在“抢不到”的概念。
注意:这就是为什么很多移植 Linux 的项目,在裸机下 DMA 100% 稳定,一上 kernel 就偶发丢包。问题不在 DMA 本身,而在抽象层引入的调度不确定性。解决方案不是“换驱动”,而是理解
dma_slave_config中src_addr_width、dst_addr_width、device_fc(flow control)字段的真实含义——它们决定了硬件握手信号的使能方式,直接影响传输可靠性。
2.3 为什么“串口 DMA”比“ADC DMA”更难调?——触发机制与数据粒度的错配
热搜词里高频出现“串口 dma”“spi dma”,但实际调试难度天差地别。根源在于触发源的事件粒度与 DMA 传输单元的匹配度。
ADC DMA:典型配置是
Transfer Complete或Half Transfer中断 + Circular Mode。ADC 每次转换完成,产生一个 EOC(End of Conversion)信号,触发 DMA 搬运 1 个字(通常是 16bit)。由于 ADC 采样是周期性、确定性的(由 TIM 触发),DMA 可以严格按固定步长搬运,buffer 管理简单,溢出风险低。串口 DMA:UART 的 RXNE(接收非空中断)是“数据到达即触发”,但 UART FIFO 深度有限(通常 1~16 字节),且数据到达时间完全不可预测。若配置 DMA 为
Peripheral to Memory、Memory Increment、Circular,看似能持续接收——但问题来了:当 FIFO 满时,新数据会覆盖旧数据(硬件行为),而 DMA 只负责搬运 FIFO 中的数据,无法感知“覆盖发生”。结果就是:你看到 buffer 里数据是连续的,但中间某段被静默覆盖了,表现为协议解析失败。
实测案例:某 Modbus RTU 设备,波特率 115200,要求 20ms 内响应。裸机用中断+环形 buffer 可稳定运行,改用 DMA 后,第 37 次通信必超时。示波器抓 UART_RX 线,发现每次超时前,RX 线都有一次 >15ms 的高电平(空闲),说明主站发送间隔异常——但设备端 log 显示“收到 0x01 0x03...”,解析却失败。最终定位:DMA buffer size 设为 256 字节,Circular Mode 下,当主站突发发送 300 字节数据,DMA 搬运前 256 字节后,第 257 字节覆盖 buffer[0],而 DMA 的TCIF(Transfer Complete)标志在搬运完 256 字节后才置位,此时 buffer[0] 已被新数据覆盖,原始帧头丢失。
解决方案不是加大 buffer,而是改用双 buffer + 半满中断:配置 DMA 为Double Buffer Mode,当 buffer A 填满一半(128 字节),触发Half Transfer中断,此时 CPU 快速复制 buffer A 前 128 字节到处理队列;当 buffer A 满,DMA 自动切到 buffer B,同时置位TCIF,CPU 处理 buffer A 后半段。这样即使突发大数据,也能保证至少 128 字节的完整帧不被覆盖。
3. 实操核心:从寄存器配置到 Linux 驱动的全链路拆解
3.1 裸机开发:以 STM32F407 + ADC + DMA 为例,手把手还原“零丢包”配置逻辑
我们以最经典的“ADC 多通道连续采样 + DMA 传输”为例,不依赖 HAL 库,直接操作寄存器。目标:4 通道(PA0~PA3),12-bit 分辨率,TIM2 触发,采样率 100kHz,DMA 循环搬运到 1024 字节 buffer,CPU 每 10ms 读取一次最新 256 个样本。
第一步:时钟与引脚初始化(略,重点在 DMA 关联配置)
ADCCLK = APB2CLK / 4 = 84MHz / 4 = 21MHz(满足 ADC 最大 36MHz)
TIM2 重装载值 = (84MHz / 100kHz) - 1 = 839 → 100kHz 触发频率
第二步:ADC 配置关键点
ADC_CR2:EXTSEL[2:0] = 010(选择 TIM2 TRGO 作为外部触发)ADC_SMPR2:SMP0~SMP3 = 011(112 个 ADCCLK 周期采样时间,确保 12-bit 精度)ADC_SQR1:L = 0011(4 个转换序列)ADC_SQR3:SQ1~SQ4 = 00000~00011(通道 0~3)ADC_CR2:SWSTART = 0(禁用软件启动,只响应外部触发)ADC_CR1:SCAN = 1(扫描模式)、CONT = 1(连续转换)
第三步:DMA 配置——这才是成败关键
DMA_SxCR(DMA2_Stream0):CHSEL[2:0] = 000(选择 ADC1)DIR = 00(外设到内存)MINC = 1(内存地址递增)PINC = 0(外设地址固定,ADC_DR 寄存器地址不变)PSIZE = 01(外设数据宽度 16bit,ADC 结果左对齐)MSIZE = 01(内存数据宽度 16bit)PL = 11(最高优先级,避免被其他 DMA 抢占)TEIE = 1(传输错误中断使能)HTIE = 1(半传输中断使能)TCIE = 1(传输完成中断使能)EN = 0(先禁用,配置完再开)
DMA_SxNDTR:NDT = 512(搬运 512 个 16bit 数据 = 1024 字节)DMA_SxPAR:PAR = 0x4001204C(ADC1->DR 寄存器地址)DMA_SxM0AR:MAR = (uint32_t)adc_buffer(buffer 起始地址)
第四步:中断服务函数与 buffer 管理
volatile uint16_t adc_buffer[512]; volatile uint16_t *current_ptr = adc_buffer; void DMA2_Stream0_IRQHandler(void) { uint32_t flags = DMA2->LISR; if (flags & DMA_LISR_HTIF0) { // 半传输中断 // 此时 buffer[0~255] 已填满,可安全读取 process_adc_data(current_ptr, 256); current_ptr += 256; if (current_ptr >= adc_buffer + 512) current_ptr = adc_buffer; DMA2->LIFCR |= DMA_LIFCR_CHTIF0; // 清中断标志 } if (flags & DMA_LISR_TCIF0) { // 传输完成中断 // buffer[256~511] 已填满,读取后半段 process_adc_data(current_ptr, 256); DMA2->LIFCR |= DMA_LIFCR_CTCIF0; } if (flags & DMA_LISR_TEIF0) { // 传输错误 // 硬件错误:总线错误、访问违例等,需复位 DMA DMA2_Stream0->CR &= ~DMA_SxCR_EN; while (DMA2_Stream0->CR & DMA_SxCR_EN); DMA2_Stream0->CR |= DMA_SxCR_EN; DMA2->LIFCR |= DMA_LIFCR_CTEIF0; } }实操心得:很多人忽略
DMA_SxCR的PL(Priority Level)字段。在多外设系统中,若 SPI DMA 和 ADC DMA 共享同一 DMA2,而 SPI 优先级设为11,ADC 设为00,则 SPI 传输时会抢占 ADC DMA,导致 ADC 采样间隔抖动。必须根据实时性要求分级:ADC/TIM 类硬实时外设设最高优先级,SPI/UART 类设中优先级,SDIO/USB 设最低。
3.2 Linux 驱动开发:以 i.MX RT1064 + SPI Flash DMA 写入为例,解析 platform driver 中的 dmaengine 集成
i.MX RT1064 使用 FlexSPI 控制器连接 QSPI Flash,写入速度要求 ≥2MB/s。裸机下用 Polling 可达 3MB/s,但占用 100% CPU;用中断 + buffer 可降负载,但仍有 15% CPU 开销;最佳方案是 DMA + Scatter-Gather。
关键步骤:
Device Tree 配置
在arch/arm/boot/dts/imxrt1064-evk.dts中添加:&flexspi1 { status = "okay"; #address-cells = <1>; #size-cells = <1>; flash@0 { compatible = "jedec,spi-nor"; reg = <0x0>; spi-max-frequency = <100000000>; // 100MHz /* 关键:声明 DMA 请求 */ dmas = <&edma0 0 0 0>, <&edma0 0 1 0>; // tx_ch, rx_ch dma-names = "tx", "rx"; }; };Driver 中申请 DMA channel
struct spi_nor_flash { struct device *dev; struct dma_chan *tx_chan; struct dma_async_tx_descriptor *tx_desc; dma_addr_t dma_addr; void *dma_buf; // 用于 bounce buffer }; static int spi_nor_dma_init(struct spi_nor_flash *flash) { struct dma_slave_config config = {0}; flash->tx_chan = dma_request_chan(flash->dev, "tx"); if (IS_ERR(flash->tx_chan)) { dev_err(flash->dev, "Failed to request TX DMA\n"); return PTR_ERR(flash->tx_chan); } // 配置 slave:FlexSPI TX FIFO 深度为 16,数据宽度 32bit config.direction = DMA_MEM_TO_DEV; config.device_fc = true; // 启用硬件流控,FlexSPI 会发请求信号 config.src_addr_width = DMA_SLAVE_BUSWIDTH_4_BYTES; config.dst_addr_width = DMA_SLAVE_BUSWIDTH_4_BYTES; config.src_maxburst = 16; // 一次最多搬 16 个 4-byte config.dst_maxburst = 16; config.dst_addr = FLEXSPI1_BASE_ADDR + 0x100; // TX FIFO 地址 dmaengine_slave_config(flash->tx_chan, &config); return 0; }DMA 传输实现
static int spi_nor_dma_write(struct spi_nor_flash *flash, const void *buf, size_t len) { struct dma_async_tx_descriptor *tx; dma_addr_t dma_addr; int ret; // 分配 DMA-safe buffer(必须物理连续) flash->dma_buf = dma_alloc_coherent(flash->dev, len, &dma_addr, GFP_KERNEL); if (!flash->dma_buf) return -ENOMEM; memcpy(flash->dma_buf, buf, len); flash->dma_addr = dma_addr; tx = dmaengine_prep_slave_single(flash->tx_chan, dma_addr, len, DMA_MEM_TO_DEV, DMA_CTRL_ACK); if (!tx) { dma_free_coherent(flash->dev, len, flash->dma_buf, dma_addr); return -ENOMEM; } tx->callback = spi_nor_dma_complete; tx->callback_param = flash; dmaengine_submit(tx); dma_async_issue_pending(flash->tx_chan); // 等待完成(实际项目中应使用 completion 或 workqueue) wait_event_timeout(flash->wait, flash->dma_done, msecs_to_jiffies(100)); return flash->dma_result; }
注意事项:
dma_alloc_coherent()分配的内存,其物理地址必须对齐到dma_get_cache_alignment()返回的值(通常是 64 字节)。若len不是 64 的倍数,dma_alloc_coherent()会向上对齐,导致实际分配内存大于len,但dmaengine_prep_slave_single()只搬运len字节,剩余空间无影响。但若你手动用kmalloc()+dma_map_single(),则必须确保kmalloc()分配的内存物理地址连续,否则dma_map_single()会 fallback 到 bounce buffer,性能下降 30% 以上。
3.3 性能调优实战:DMA 测速的正确姿势与常见误区
“DMA 测速”是热搜高频词,但多数人测的其实是“理论带宽”,而非“有效吞吐量”。真实项目中,有效吞吐量 = (实际传输字节数)/(从启动到完成的 wall-clock 时间),它受三大因素制约:
| 因素 | 影响机制 | 实测数据(STM32H750) |
|---|---|---|
| 总线争用 | AHB/APB 总线被 CPU、其他 DMA、Cache 共享,带宽非独占 | 128-bit AHB 总线理论 200MB/s,实测单 DMA 通道 ≤140MB/s |
| 外设 FIFO 深度 | DMA 搬运速度受限于外设 FIFO 填充/清空速率 | SPI 16-byte FIFO,100MHz 时钟下,最大持续速率 ≈ 100MB/s |
| 中断延迟与上下文切换 | 每次 TC 中断触发,CPU 需保存寄存器、跳转 ISR、恢复,耗时 1~3μs | 1000 次 TC 中断,额外开销 ≈ 2ms |
正确测速方法(裸机):
- 用 DWT(Data Watchpoint and Trace)模块的 CYCCNT 计数器,精度 1 个 CPU cycle
- 启动 DMA 前读
DWT->CYCCNT - 启动 DMA(
DMA_SxCR_EN = 1) - 等待
TCIF置位(轮询,避免中断开销) - 再读
DWT->CYCCNT,差值即为纯 DMA 传输耗时 - 计算:
有效带宽 = (buffer_size_bytes * 1000000) / (cycle_diff / CPU_Freq_MHz)
实测对比(1MB buffer,Memory-to-Memory):
memcpy():耗时 32.5ms → 带宽 ≈ 30.8MB/s- DMA(Single Mode):耗时 12.8ms → 带宽 ≈ 78.1MB/s
- DMA(Double Buffer + 中断处理):耗时 13.2ms(含中断开销)→ 带宽 ≈ 75.8MB/s
关键结论:DMA 的优势不在“绝对速度”,而在“释放 CPU”。
memcpy()占用 CPU 100%,DMA 占用 CPU <1%,这对实时系统意义远大于 2MB/s 的带宽差。测速时务必区分“峰值带宽”和“可持续带宽”,后者需在 10s 内连续传输 100MB 数据,观察是否出现丢包或 jitter。
4. 常见问题与排查技巧实录:那些烧掉 3 块开发板才换来的经验
4.1 “DMA 传输完成后,buffer 里全是 0x0000”——最经典的初始化遗漏
现象:DMA 配置看起来完全正确,EN位已置 1,TCIF也置位了,但adc_buffer全是 0。
排查路径:
- 检查
DMA_SxPAR是否指向正确的外设寄存器地址(如 ADC1->DR 是0x4001204C,不是0x40012000) - 检查
DMA_SxCR的DIR字段:00是外设到内存,10是内存到外设,写反则数据流向错误 - 最关键的遗漏:ADC 是否已开启?
ADC_CR2的ADON = 1(必须先开 ADC,再开 DMA)ADC_CR1的EOCIE = 0(若开了 EOC 中断,可能干扰 DMA 触发)ADC_CR2的EXTEN[1:0] = 11(外部触发使能,且上升沿有效)
实测:某次调试,ADON位忘记置 1,ADC 根本没工作,DMA 一直在读0x4001204C的默认值 0,自然 buffer 全 0。示波器抓 ADC_CLK 和 TRGO 信号,发现 ADC_CLK 停振,立刻定位。
4.2 “DMA 传输偶尔丢 1 个字节”——时钟域交叉的隐性杀手
现象:99% 时间正常,但每隔几分钟,SPI 接收 buffer 中某个位置数据错乱,且错乱位置不固定。
根因分析:
SPI 外设时钟(PCLK)与 DMA 时钟(HCLK)属于不同总线域。当 PCLK 频率较高(如 100MHz),而 HCLK 较低(如 200MHz),DMA 在采样 PCLK 上升沿时,存在亚稳态(metastability)风险。硬件手册中称为 “Clock Domain Crossing (CDC) synchronization delay”。
解决方案:
- 在
DMA_SxCR中启用DBM(Double Buffer Mode),让 DMA 内部自动处理跨时钟域同步 - 若芯片不支持 DBM(如 STM32F1),则必须在外设配置中降低 PCLK 频率,或增加
DMA_SxCR的MBURST字段(设置 burst length,减少跨域采样次数) - 最彻底方案:在 Device Tree 中为 SPI 添加
clocks和clock-names,确保 PCLK 与 HCLK 同源或相位锁定
4.3 “Linux 下 dmaengine_submit() 返回 -EBUSY”——channel 资源死锁的破局法
现象:SPI 驱动调用dmaengine_submit()失败,log 显示-EBUSY,但cat /sys/class/dma/显示所有 channel 状态为idle。
深层原因:
Linux DMA Engine 子系统中,dmaengine_submit()并非立即下发,而是将 descriptor 加入 channel 的pending_list。若前一个 descriptor 的callback函数中,又调用了dmaengine_submit()(形成递归提交),而pending_list未及时清理,则submit()会返回 -EBUSY。
调试命令:
# 查看 DMA channel 状态 cat /sys/class/dma/dma0chan00/device/name # 显示 channel 名称 cat /sys/class/dma/dma0chan00/device/chan_id # 显示 ID # 查看 pending descriptors 数量(需内核开启 CONFIG_DEBUG_FS) cat /sys/kernel/debug/dmaengine/chan00/pending_count修复方案:
- 确保
callback函数中不调用dmaengine_submit(),改用schedule_work()或queue_delayed_work()异步提交 - 在
probe()函数中,为每个 channel 预分配 2~3 个 descriptor,避免 runtime 分配失败 - 修改
dma_slave_config,将device_fc设为true,启用硬件流控,让外设主动控制 DMA 启停,避免 channel 长时间 busy
4.4 “DMA 测速工具显示 200MB/s,但实际应用只有 50MB/s”——缓存一致性陷阱
现象:用dd if=/dev/zero of=/dev/mem bs=1M count=100测得 DMA 内存拷贝 200MB/s,但实际跑图像处理算法时,DMA 从 DDR 读取 1080p 图像,速度仅 50MB/s。
真相:/dev/mem直接映射物理内存,绕过 MMU 和 Cache;而实际应用中,图像 buffer 是kmalloc()分配的虚拟地址,CPU 访问时经过 Cache。DMA 写入物理内存后,CPU 缓存中的对应 line 仍是 dirty 或 invalid,若未执行dma_sync_single_for_cpu(),CPU 读到的就是 stale data,导致算法误判,不得不反复重传,有效带宽暴跌。
正确流程:
// CPU 准备数据 cpu_buffer = kmalloc(size, GFP_KERNEL); // ... fill data ... // DMA 传输前:确保 CPU cache clean dma_sync_single_for_device(dev, dma_handle, size, DMA_TO_DEVICE); // 启动 DMA // DMA 完成后:确保 CPU cache invalidate dma_sync_single_for_cpu(dev, dma_handle, size, DMA_FROM_DEVICE); // 此时 cpu_buffer 才是最新数据实操心得:在 ARM 架构下,
dma_sync_*实际调用__cpuc_flush_dcache_area()或__cpuc_coherent_kern_range(),耗时约 0.5~2μs/MB。很多人为了“省时间”跳过这步,结果花 3 天 debug 数据错乱,得不偿失。记住:DMA 与 CPU 共享内存,缓存一致性不是可选项,是必选项。