资讯详情

DMA驱动开发实战:从寄存器配置到Cache一致性

📅 2026/10/8 17:15:32 | 华诺云谱 👁 阅读
DMA驱动开发实战:从寄存器配置到Cache一致性
1. 一个老驱动工程师眼里的 DMA先忘掉那几个字母做嵌入式驱动这些年我越来越觉得DMA是个特别有意思的东西。它不像中断、定时器那样“学了就会用”DMA是个典型的看起来简单、做起来踩坑的外设。很多刚转驱动开发的兄弟问我“DMA不就是搬运数据吗配几个寄存器就行了”——对也不对。配寄存器确实不难但DMA真正考验人的地方在于它跟你整个系统的内存模型、总线拓扑、外设工作模式、中断优先级全搅在一起。你把它当“搬运工”用它就能搬出各种让你抓狂的bug。这期内容我打算把做DMA驱动这些年攒下来的经验梳理一遍从DMA控制器的底层原理到实际驱动开发中的寄存器配置、描述符链表、Cache一致性处理再到串口、SPI、ADC这些常见场景的实操要点和问题排查。不管你是刚入门嵌入式还是已经在写Linux驱动了我都尽量讲得能直接用上。先聊一个最实际的问题为什么驱动开发一定要懂DMA我见过太多人做串口项目上来就是中断逐字节接收波特率115200还好说上了460800、921600之后中断频繁到系统其他任务全部受影响还时不时丢数据。这时候如果你把DMA用上CPU只负责在数据攒够一批之后处理一次效率是完全两个量级。换句话说DMA解决的从来不只是“数据搬运”而是整个系统的“CPU解放”和“吞吐能力”问题。2. DMA控制器的本质它不是一个外设是一组“搬运规则”聊DMA之前先把字面意思拆开Direct Memory Access直接内存访问。所谓“直接”指的是数据可以不经过CPU的寄存器转发在外设和内存之间、或者内存和内存之间直接搬运。这句话听起来轻描淡写但背后的总线机制才是关键。2.1 三种搬运模式先想清楚谁跟谁传DMA搬运方向往大了说就三类内存到外设CPU把要发送的数据放到内存缓冲区DMA自动搬到外设的发送寄存器比如UART-DR、SPI-TX。外设到内存外设收到一包数据DMA自动搬进内存缓冲区搬够了触发中断通知CPU。内存到内存比如把一个大数组从一个地址拷贝到另一个地址适合做图像处理、协议栈报文搬移。驱动开发的第一步永远是搞清楚你的数据流是什么方向。我见过有人把外设到内存配成了内存到外设结果外设一直没数据CPU那边还在傻等——方向的本质是数据流的“源”和“目的”源是外设就得配外设读模式源是内存就得配内存读模式。补充一个细节内存到内存的DMA搬运在很多芯片上要额外的条件支持。比如STM32的DMA2支持内存到内存DMA1就不一定好用又比如某些DMA控制器要求“内存到内存”模式下必须指定固定的位宽、不能开FIFO。这些不是芯片手册上花大篇幅讲的东西但实际用起来全是坑。建议每个平台拿到手先把参考手册里DMA那一章从头到尾读一遍特别是“不支持”的注意事项。2.2 请求映射到底是谁在指挥DMA干活DMA控制器不是“孤军奋战”的。一个典型的搬运过程是这样的外设UART/SPI/ADC产生一个“请求”告诉DMA控制器“我这里有数据了或者我需要数据”DMA控制器接收到请求后根据配置从源地址读取数据、写入目的地址每搬运一次内部计数器减一计数器减到0触发传输完成中断。这里最容易搞混的是请求映射。比如STM32F4系列DMA1的Channel 4可以映射到UART4_TX、SPI1_RX、TIM1_CH4这些请求但同一时刻只能选一个。很多新手驱动失效就是因为多个外设共用了一个DMA通道或者配了通道但没配对应的请求映射。我用一个更生活化的类比DMA通道就像一条物流专线外设是发件方内存是收件方。请求映射就是“这条专线的货单来源”——你告诉物流公司去哪个仓库取货仓库发出发货信号物流才发车。货单来源没配对车永远空跑。2.3 描述符链表DMA的“连续剧剧本”单次DMA传输好理解源地址、目的地址、数据长度搬完就停了。但实际驱动里很少有人只用一次搬运。串口收不定长数据、SPI接收多帧图像、网卡收包……这些场景要求DMA能够“连续不断地干活”于是就有了描述符链表。描述符Descriptor本质上一块固定的内存区域里面记录了源地址、目的地址、数据长度、控制标志是否最后一帧、是否中断、指向下一个描述符的指针。DMA控制器搬完一个描述符就自动从指针指向取下一个描述符继续搬。驱动要做的事情就是事先在内存里准备好一串描述符让硬件像一个自动播放器一样一集一集往下放。这里有一个嵌入式驱动的经典配置——乒乓缓冲Ping-Pong Buffer。思路极其简单准备两块缓冲区Buffer A和Buffer BDMA先搬运到A搬完了触发中断同时硬件自动切换到B继续搬运驱动在中断里处理A的数据处理完等B满再处理B。这样做的效果是数据采集和处理可以并行CPU处理A的时候DMA还在不停地往B里写数据不会出现“DMA等CPU处理完再继续”这种掉速问题。做音频、ADC连续采样、串口高速收发的同学对这个应该深有体会。描述符链表的驱动设计核心就三件事描述符池的分配与回收、链表指针的正确衔接、中断后状态机的切换。我见过最多的bug就是把描述符A处理完后丢了回链指针结果DMA把链表跑断了触发一次bus error直接把系统干挂。3. 寄存器配置的“为什么”每个位都是权衡进入驱动实战前先把寄存器层面抠细。DMA控制器的寄存器大同小异以经典的STM32 DMA_SxCR为例几个关键位我给你过一遍重点讲为什么这么配。3.1 方向、位宽与增量模式三个最常见的配置点DIR方向外设到内存、内存到外设、内存到内存三选一。这个前面说过了不重复。PSIZE/MSIZE外设/内存数据宽度8位、16位、32位可选。注意一个坑如果外设寄存器是8位的比如UART-DR低位你却配成了32位的DMA搬移搬运过来的数据会错位。PINC/MINC外设/内存地址增量这是决定“搬到哪”的关键位。外设寄存器多半不是连续内存所以外设地址通常不增量但内存缓冲是一块连续区域必须增量。我见过有人把MINC关掉DMA把一整包数据全写到了同一个内存地址最后接收缓冲区只有一个字节。位宽配置还有个进阶问题源和目的位宽不一致怎么办比如外设是8位内存缓冲区你按32位来管理。这个在硬件上往往需要FIFO进行“位宽拼接”DMA控制器会把8个8位数据攒成一个32位写入。但如果你的DMA控制器性能不够频繁发生FIFO上溢那就得靠“突发传输模式”来配合。3.2 突发传输与FIFO高性能的代价是复杂度突发传输Burst简单理解就是DMA连续搬运多个数据中间不被其他总线请求打断。这种模式在摄像头数据、高速ADC采样、SD卡读写里特别重要。但突发的代价是总线占用时间变长其他DMA通道、CPU访问总线的延迟都会增加。所以别无脑开Burst要根据外设的数据量和总线负载来权衡。我的经验是串口这种低速外设普通传输完全够用开了Burst反而可能增加FIFO管理的复杂度而像摄像头这类高频数据源该开就开不然DMA中断太频繁CPU根本扛不住。3.3 FIFO阈值一个驱动里容易被忽略的调优点很多DMA控制器带内部FIFO用作数据缓冲和位宽转换。FIFO的阈值Threshold决定攒多少数据触发一次搬运阈值设低了DMA响应快、搬得勤但总线占用率高阈值设高了DMA效率高但延迟增大极端情况下小批量数据会滞留在FIFO里造成传输完成事件迟迟不触发。我做ADC连续采样时就遇到过FIFO阈值设太高导致采样数据一直压在FIFO里不急写到内存结果采样率上去之后最后一次搬运的延迟把我整个时间戳都洗白了。后来把阈值调到一半问题消失。这类的调优经验芯片手册基本不会写全靠“跑测→看波形/看时间戳→改阈值”循环。4. 驱动开发实战串口DMA接收驱动的完整实现理论铺垫到位下面来一个完整的实操案例。这个例子我建议所有嵌入式驱动工程师都亲手写一遍它是DMA驱动里最典型、信息密度最高的场景之一串口DMA不定长接收。4.1 场景与需求分析先摆需求MCU串口波特率至少460800外部设备会不定时发送不定长度的帧比如一帧长度1~256字节不等CPU不能逐字节中断接收否则高波特率下肯定丢数据又要保证“不定长”帧能被系统及时感知和处理。如果只是定长接收DMA到固定长度触发中断就够了。不定长就得想办法。常用方案是IDLE中断空闲中断配合DMA串口收到一帧数据的最后一个字节后总线空闲超过一个字节时间硬件触发IDLE中断。驱动在IDLE中断里查看DMA当前搬了多少数据由此推断本次一帧数据的长度然后处理缓冲区。4.2 初始化与配置代码以下代码以STM32 HAL库为例实际上我更推荐直接操作寄存器但HAL便于理解流程核心步骤就五步void uart_dma_rx_init(UART_HandleTypeDef *huart, uint8_t *buf, uint16_t len) { // 1. 配置DMA接收通道 huart-RxDMABuffer buf; huart-hdmarx-Init.Direction DMA_PERIPH_TO_MEMORY; huart-hdmarx-Init.PeriphInc DMA_PINC_DISABLE; huart-hdmarx-Init.MemInc DMA_MINC_ENABLE; huart-hdmarx-Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; huart-hdmarx-Init.MemDataAlignment DMA_MDATAALIGN_BYTE; // 2. 启动DMA接收目标是“永远不停”地往缓冲区搬 HAL_UART_Receive_DMA(huart, buf, len); // 之后要使能串口IDLE中断由IDLE中断来判断“一帧结束” }4.3 IDLE中断与帧长度判断IDLE中断的核心逻辑是“DMA一共要搬len个字节现在搬了count个字节那么本次接收了count个字节”。注意这里的count是总共的累计值是从初始化之后的计数不是本次帧长度。你需要记录上一帧结束时的累计值二者之差才是这一帧的字节数。void UART_IDLE_Callback(UART_HandleTypeDef *huart) { uint16_t idle_cnt, recv_len, last_cnt; // 当前DMA搬了多少字节累计值 idle_cnt (uint16_t)(__HAL_DMA_GET_COUNTER(huart-hdmarx)); idle_cnt RX_BUF_SIZE - idle_cnt; // 换算成已接收总数 // 跟上次IDLE中断时的计数做差 if (idle_cnt last_rx_cnt) { recv_len idle_cnt - last_rx_cnt; } else { // 环形缓冲回绕情形 recv_len RX_BUF_SIZE - last_rx_cnt idle_cnt; } // 更新last_rx_cnt last_rx_cnt idle_cnt; // 交给协议解析 parse_frame(huart-RxDMABuffer, recv_len); }这里有两个极容易踩的坑我必须单独拿出来说坑1环形缓冲回绕。如果你用固定长度缓冲比如1024字节DMA会一遍遍地在里面循环写。当累计计数回绕到0的时候简单地用“当前计数 - 上次计数”得到负数一定要做回绕判断。我见过线上设备偶发性丢帧找了半天最后发现是回绕算错了长度。坑2DMA搬运完成中断与IDLE中断的配合。如果缓冲满了DMA会触发传输完成中断。此时硬件会“暂时失去接收能力”直到你重新配置DMA基地址和长度。很多新手只处理了IDLE中断没人管DMA满中断结果缓冲满后一切静默偶发定长帧还正常不定长帧全丢。所以满中断里必须重新挂载缓冲区并且调整last_rx_cnt。4.4 为什么这个方案优于“逐字节中断”用DMAIDLE的方案一帧数据哪怕400字节也就触发一次IDLE中断加一次DMA中断满时CPU负担极低。对比逐字节中断400字节就是400次中断每次中断还要保存现场、压栈、出栈、进解析函数高波特率下系统其他任务基本没法跑。我实测过460800波特率一秒钟约46000字节如果用中断逐字节接收每秒钟大概4.6万次中断几乎每个字节都打断CPU。而DMAIDLE方案下假设一帧10字节一秒钟约4600次中断已经少了一个数量级如果一帧上百字节中断次数还会更少。这个效率差距在串口屏通信、多路传感器上报、日志采集场景里表现极其明显。4.5 扩展环形缓冲与协议解析的衔接IDLE中断里拿到“本次接收长度”后通常还要把数据拷贝到协议解析队列。如果每一帧都memcpy一次大批量小帧场景下内存拷贝本身也是开销。我后来在驱动里直接做了个“帧指针链表”DMA缓冲固定IDLE中断里只记录“这一帧在缓冲区的起始地址和长度”协议解析任务从链表取地址直接访问DMA缓冲区里的原始数据解析完再将这段环形缓冲标记为可覆盖。这个设计省掉了中间拷贝但对缓冲区生命周期管理要求高协调不好容易产生数据覆盖。总的原则是DMA缓冲区永远属于DMA和驱动协议层只允许“借看”不允许“长留”。5. Linux驱动里的DMA从裸机思维切换到内存管理思维如果你是做嵌入式Linux驱动开发的上面讲的裸机思路只是基础真正的复杂点完全变了。裸机时代你考虑的是寄存器、中断、FIFOLinux驱动的DMA要考虑的是内存分配、Cache一致性、总线地址与内核虚拟地址的映射。5.1 DMA地址、物理地址与虚拟地址的三方关系我们常说“CPU访问内存通过MMUDMA访问内存不走MMU”这句话道出了Linux DMA的核心矛盾CPU跑到一半指令和数据有Cache缓存Cache里的内容不一定同步到内存DMA控制器直接访问物理内存它看到的内容和CPU Cache里的内容可能不一致。因此驱动里凡是给DMA用的缓冲区就必须处理一致性coherent或同步sync问题。在Linux里最常见的手段是dma_addr_t dma_handle; void *cpu_addr; cpu_addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL);dma_alloc_coherent分配的内存会保证CPU和DMA看到的内容是一致的内部通常做了Cache属性配置所以它适合不需要频繁读取、写入的场景。而dma_map_single/dma_unmap_single则适合“临时映射”的场景驱动把一块普通内核缓冲区映射给DMA在DMA搬运前做一次cache invalidate或clean搬运完成后做一次invalidate。这个“invalidate/clean”是新手最容易漏掉的。你写完数据不cleanDMA可能搬的是Cache里面的旧数据DMA写完数据你不invalidateCPU读的可能是Cache里的旧数据。这两条方向一次都不能搞错。5.2 连续内存与SGScatter-Gather裸机上给DMA分配一块连续内存很简单。但Linux内核运行久了物理内存碎片化严重动辄分配几MB连续物理内存很可能失败。于是有了SG散列表机制——让DMA把一块物理上不连续、但逻辑上连续的数据搬完。SG在驱动开发里的表现形式就是struct scatterlist数组。每个SG节点描述一个物理内存块的地址和长度。DMA控制器支持Scatter-Gather时只需把SG列表交给DMA控制器自己挨个搬运。拿网卡驱动来举例一个待发送的SKB可能分散在多个页里驱动会把每个片段放入SG listDMA逐个搬运到FIFO网卡那边看到的就是一个完整帧。之前做一款USB网卡驱动时我踩过一个价值两个通宵的坑SG列表只做了映射没做unmap导致长时间收发后IOMMU/DMA映射表被填满驱动直接死机。映射与解除映射必须一一对应不仅在错误路径上要unmap在正常完成路径上也要unmap。5.3 怎么测DMA性能一个“DMA测速”的实际脚本思路热词里反复出现“dma测速”“dma测速工具”说明大家普遍关心DMA到底快了多少。Linux下测DMA性能我一般分两层硬件层利用/proc/interrupts观察DMA中断次数结合DMA控制器寄存器里的字节计数算实际吞吐量。驱动层在自己的驱动里对“每次DMA完成的数据量/耗时”做统计比如每搬完1MB数据记一次时间戳连续统计几十次看波动和均值。很多芯片SDK里其实自带了DMA benchmark的例程比如用DMA做内嵌RAM到外部SDRAM的拷贝测试。这类测速有几个变量要控制源地址与目的地址的Cache一致性策略或者关Cache、DMA的突发长度、总线竞争是否有其他设备在大量访问内存。我自己常用的一个判断是DMA测速结果如果显著低于总线带宽的一半先怀疑是不是Cache一致性问题导致每次都做了慢速的sync其次怀疑FIFO阈值和突发长度没有对上外设节奏最后才怀疑DMA时钟或总线优先级配置。这几个点排查顺序在多数平台都能覆盖80%的“DMA慢”问题。6. 常见DMA驱动bug排查一个速查表最后把实战中最常遇到、也是我出过血的问题汇总成一张速查表。每一个背后都是真实案例希望能帮你少踩一次坑。现象可能原因排查思路与解法外设收到数据但内存缓冲区没有请求映射配错DMA通道没被外设触发查看芯片参考手册的DMA请求映射表核对通道与外设对应关系缓冲区数据错位/乱序外设与内存位宽不一致FIFO未启用配置PSIZE和MSIZE与真实寄存器位宽一致必要时开启FIFO做位宽拼接偶发性丢帧尤其不定长帧IDLE中断与DMA满中断竞争last_cnt未处理好在满中断中重新挂载缓冲区并修正累计计数用环形缓冲回绕公式处理开机跑一会后死机DMA描述符链表断裂或内存越界检查描述符的next指针是否被覆盖给描述符池加保护头/尾哨兵Linux驱动里数据始终是旧的缺Cache sync操作或用了错误的DMA API梳理数据流方向CPU→DMA要cleanDMA→CPU要invalidateDMA传输完成中断不来FIFO阈值设太高小数据量滞留降低FIFO阈值确认DMA完成中断是否需要在最后一个描述符上单独使能多通道同时工作时性能骤降所有通道都开了Burst总线冲突严重非高频外设关掉Burst给高优先级DMA通道配置更高总线优先级一个细节值得多说一句DMA中断里尽量别做重活。我曾见过同事把协议解析整个放进DMA完成中断里做每次传输完成中断要执行几百微秒直接把系统实时性干穿了。DMA中断的正确姿势是快速把数据从DMA缓冲区挪走或登记指针用标志位或信号量通知任务上下文去处理。中断里只做“转移”不做“处理”这条规则放哪个平台都适用。还有一个很隐蔽的坑调试器单步调试DMA驱动时DMA传输状态会异常。因为调试器暂停CPU时总线时钟可能被冻结DMA搬运也会卡住导致你看到的现象和实际运行完全不符。遇到“看起来寄存器值不对”的时候先别怀疑芯片直接全速运行打印日志试试。写到这里基本把做DMA驱动这六年里最能直接帮到你的经验都倒出来了。DMA说难也难说不难也不难——它难在跨模块的系统思维不难在只要你把数据流方向、缓冲区生命周期、Cache一致性这三个问题想透大多数DMA问题都能水到渠成地解决。希望这篇能给你接下来的DMA驱动开发省下几个加班的夜晚。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑