ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

嵌入式DMA原理与实战:从硬件本质到Linux驱动调优

嵌入式DMA原理与实战:从硬件本质到Linux驱动调优 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_fcflow control字段的真实含义——它们决定了硬件握手信号的使能方式直接影响传输可靠性。2.3 为什么“串口 DMA”比“ADC DMA”更难调——触发机制与数据粒度的错配热搜词里高频出现“串口 dma”“spi dma”但实际调试难度天差地别。根源在于触发源的事件粒度与 DMA 传输单元的匹配度。ADC DMA典型配置是Transfer Complete或Half Transfer中断 Circular Mode。ADC 每次转换完成产生一个 EOCEnd of Conversion信号触发 DMA 搬运 1 个字通常是 16bit。由于 ADC 采样是周期性、确定性的由 TIM 触发DMA 可以严格按固定步长搬运buffer 管理简单溢出风险低。串口 DMAUART 的 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 的TCIFTransfer Complete标志在搬运完 256 字节后才置位此时 buffer[0] 已被新数据覆盖原始帧头丢失。解决方案不是加大 buffer而是改用双 buffer 半满中断配置 DMA 为Double Buffer Mode当 buffer A 填满一半128 字节触发Half Transfer中断此时 CPU 快速复制 buffer A 前 128 字节到处理队列当 buffer A 满DMA 自动切到 buffer B同时置位TCIFCPU 处理 buffer A 后半段。这样即使突发大数据也能保证至少 128 字节的完整帧不被覆盖。3. 实操核心从寄存器配置到 Linux 驱动的全链路拆解3.1 裸机开发以 STM32F407 ADC DMA 为例手把手还原“零丢包”配置逻辑我们以最经典的“ADC 多通道连续采样 DMA 传输”为例不依赖 HAL 库直接操作寄存器。目标4 通道PA0~PA312-bit 分辨率TIM2 触发采样率 100kHzDMA 循环搬运到 1024 字节 bufferCPU 每 10ms 读取一次最新 256 个样本。第一步时钟与引脚初始化略重点在 DMA 关联配置ADCCLK APB2CLK / 4 84MHz / 4 21MHz满足 ADC 最大 36MHzTIM2 重装载值 (84MHz / 100kHz) - 1 839 → 100kHz 触发频率第二步ADC 配置关键点ADC_CR2EXTSEL[2:0] 010选择 TIM2 TRGO 作为外部触发ADC_SMPR2SMP0~SMP3 011112 个 ADCCLK 周期采样时间确保 12-bit 精度ADC_SQR1L 00114 个转换序列ADC_SQR3SQ1~SQ4 00000~00011通道 0~3ADC_CR2SWSTART 0禁用软件启动只响应外部触发ADC_CR1SCAN 1扫描模式、CONT 1连续转换第三步DMA 配置——这才是成败关键DMA_SxCRDMA2_Stream0CHSEL[2:0] 000选择 ADC1DIR 00外设到内存MINC 1内存地址递增PINC 0外设地址固定ADC_DR 寄存器地址不变PSIZE 01外设数据宽度 16bitADC 结果左对齐MSIZE 01内存数据宽度 16bitPL 11最高优先级避免被其他 DMA 抢占TEIE 1传输错误中断使能HTIE 1半传输中断使能TCIE 1传输完成中断使能EN 0先禁用配置完再开DMA_SxNDTRNDT 512搬运 512 个 16bit 数据 1024 字节DMA_SxPARPAR 0x4001204CADC1-DR 寄存器地址DMA_SxM0ARMAR (uint32_t)adc_bufferbuffer 起始地址第四步中断服务函数与 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的PLPriority Level字段。在多外设系统中若 SPI DMA 和 ADC DMA 共享同一 DMA2而 SPI 优先级设为11ADC 设为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; flash0 { 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 channelstruct 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); } // 配置 slaveFlexSPI 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 FIFO100MHz 时钟下最大持续速率 ≈ 100MB/s中断延迟与上下文切换每次 TC 中断触发CPU 需保存寄存器、跳转 ISR、恢复耗时 1~3μs1000 次 TC 中断额外开销 ≈ 2ms正确测速方法裸机用 DWTData Watchpoint and Trace模块的 CYCCNT 计数器精度 1 个 CPU cycle启动 DMA 前读DWT-CYCCNT启动 DMADMA_SxCR_EN 1等待TCIF置位轮询避免中断开销再读DWT-CYCCNT差值即为纯 DMA 传输耗时计算有效带宽 (buffer_size_bytes * 1000000) / (cycle_diff / CPU_Freq_MHz)实测对比1MB bufferMemory-to-Memorymemcpy()耗时 32.5ms → 带宽 ≈ 30.8MB/sDMASingle Mode耗时 12.8ms → 带宽 ≈ 78.1MB/sDMADouble 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位已置 1TCIF也置位了但adc_buffer全是 0。排查路径检查DMA_SxPAR是否指向正确的外设寄存器地址如 ADC1-DR 是0x4001204C不是0x40012000检查DMA_SxCR的DIR字段00是外设到内存10是内存到外设写反则数据流向错误最关键的遗漏ADC 是否已开启ADC_CR2的ADON 1必须先开 ADC再开 DMAADC_CR1的EOCIE 0若开了 EOC 中断可能干扰 DMA 触发ADC_CR2的EXTEN[1:0] 11外部触发使能且上升沿有效实测某次调试ADON位忘记置 1ADC 根本没工作DMA 一直在读0x4001204C的默认值 0自然 buffer 全 0。示波器抓 ADC_CLK 和 TRGO 信号发现 ADC_CLK 停振立刻定位。4.2 “DMA 传输偶尔丢 1 个字节”——时钟域交叉的隐性杀手现象99% 时间正常但每隔几分钟SPI 接收 buffer 中某个位置数据错乱且错乱位置不固定。根因分析SPI 外设时钟PCLK与 DMA 时钟HCLK属于不同总线域。当 PCLK 频率较高如 100MHz而 HCLK 较低如 200MHzDMA 在采样 PCLK 上升沿时存在亚稳态metastability风险。硬件手册中称为 “Clock Domain Crossing (CDC) synchronization delay”。解决方案在DMA_SxCR中启用DBMDouble 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 长时间 busy4.4 “DMA 测速工具显示 200MB/s但实际应用只有 50MB/s”——缓存一致性陷阱现象用dd if/dev/zero of/dev/mem bs1M count100测得 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 共享内存缓存一致性不是可选项是必选项。
返回列表