内核DMA实战手册:原理、映射、缓存一致性与安全防护
聊内核DMA之前我先说个多数工程师都经历过的场景串口波特率一提高CPU占用率就肉眼可见地往上涨中断一个接着一个数据稍多一点就开始丢包。很多人第一反应是优化中断处理其实问题的根源往往不在中断函数本身而在于没有用好DMA。这篇文章从内核视角把DMA的工作原理、Linux和嵌入式平台上的典型用法、以及缓存一致性和安全防护这些容易被忽略的角落一次讲清楚。无论你是写Linux驱动的还是调STM32外设的都应该能从里面找到自己踩过或将要踩的坑。1. 先厘清概念DMA究竟是谁在搬运数据1.1 没有DMA时CPU在忙什么先算一笔账。以常见的115200波特率串口为例8N1格式下每个字节实际要发10位理论上每秒最多约11520个字节。传统无DMA的收法是每个字节进来都触发一次接收中断CPU要在中断里读数据寄存器、判断错误标志、把数据塞进缓冲区。也就是说哪怕系统什么都不干每秒也要响应11kHz的中断平均每87微秒就要打断一次。如果把波特率提到1M甚至更高这个数字会直接逼近100kHz级CPU的时间基本都耗在进中断、出中断和保存现场上了主任务反而没有执行机会。网卡和存储设备更夸张。万兆网卡在满速小包场景下每秒可能产生上百万个包如果每个包都要CPU逐字节拷贝再强的处理器也扛不住。这类场景下DMA不是优化手段而是唯一可行方案让设备自己把数据写进内存CPU只处理元数据和上层逻辑。这也是为什么DMA相关话题在内核开发和嵌入式开发中出现的频率越来越高。不管你是看Linux内核源码还是用STM32CubeMX配置外设最终都会碰到DMA。1.2 DMA控制器是个专职快递员把CPU想成项目经理DMA控制器就是公司里那个专职快递员。项目经理只需要在电子工单上写清楚从哪取货、送到哪、送多少件快递员就能自己跑完整个流程送完之后回来报告一下。在计算机系统里这份电子工单就是DMA的源地址、目的地址和传输长度快递员就是DMA控制器这个总线主设备bus master。它不经过CPU逐条指令搬运而是直接接管总线访问内存搬完以后产生一个传输完成事件。各家的DMA IP核实现细节不一样。有的简单到一条通道只能服务一个外设请求有的复杂到支持多级描述符链表、二维传输、FIFO和突发传输但抽象出来后都是同一套心智模型配置通道、启动传输、等待完成事件、处理下一batch。理解了这条主线看任何平台的DMA代码心里都不慌。1.3 内核为什么必须介入DMADMA虽然绕过了CPU但绕不过操作系统。原因很直接CPU这边看到的是虚拟地址而DMA控制器看到的是物理地址或总线地址。一个用户程序malloc出来的缓冲区在物理内存里可能是不连续的甚至内存页可能被换出到磁盘直接把这个缓冲区的虚拟地址丢给硬件硬件根本不知道去哪读写。更危险的是越界问题。一次DMA写操作如果长度算错硬件会不受控地把数据写到相邻内存区域轻则破坏别的进程数据重则直接崩溃。内核的DMA子系统实际上承担了三件事一是做地址翻译和映射把CPU虚拟地址转换成硬件能用的总线地址二是管理映射生命周期确保在DMA没有完成之前对应的内存不会被释放或复用三是做边界和安全隔离通过IOMMU/SMMU限制设备只能访问分给它的那部分内存。所以内核DMA这个词本质上是说DMA不是单纯的外设功能而是内存管理、设备驱动和硬件之间的一整套协同机制。接下来进入Linux内核的实际API层面看这套机制怎么落地。2. Linux内核DMA API映射、方向与生命周期2.1 dma_mask决定了设备能看到多少内存驱动probe的时候第一步通常是告诉内核这个设备有多大的寻址能力。设备可能只能访问32位地址空间也可能在64位总线上很灵活。这个能力用dma_mask表达一般通过dma_set_mask_and_coherent设置static int my_probe(struct platform_device *pdev) { struct device *dev pdev-dev; if (dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))) { dev_err(dev, no usable DMA mask\n); return -EIO; } /* 后续分配、映射 */ return 0; }DMA_BIT_MASK(32)表示设备寻址能力上限约4GBDMA_BIT_MASK(64)则意味着支持64位地址。为什么内核一定要知道这个上限因为如果系统内存放在高物理地址而设备只能寻址32位内存页面就不能直接给这个设备用内核需要选择低位内存或者走bounce buffer。很多驱动在probe阶段没有检查dma_set_mask的返回值这在64位平台上可能不报错但一旦设置的mask不满足硬件实际能力后续会出现随机性的DMA失败。2.2 coherent与streaming两种DMA内存的取舍Linux的DMA API把内存使用方式分成两类一致性DMAcoherent DMA和流式DMAstreaming DMA。很多新手在这里混淆我从使用场景讲。一致性DMA用dma_alloc_coherent分配返回CPU虚拟地址和对应的DMA总线地址典型用途是环形描述符、DMA控制块这类需要驱动和硬件长期共享的结构。它保证CPU和设备看到的内容始终一致不需要程序员手动做缓存同步代价是这块内存很可能被映射为uncached或者write-through读写性能偏慢不适合用来搬运大块数据。流式DMA用dma_map_single或dma_map_sg将一块已有内存映射给设备适合网卡的数据包、块设备IO等一次性传输场景。它性能好但存在一个严格约束从map到unmap期间CPU不能随意访问这块内存如果确实需要中途访问必须用dma_sync_*系列函数手动同步。对比项coherent DMAstreaming DMA分配/映射方式dma_alloc_coherent / dma_free_coherentdma_map_single / dma_unmap_single典型用途描述符、控制块数据包、传输缓冲区缓存一致性由内核保证无需syncmap/unmap时自动处理长期驻留需手动sync性能特征控制结构友好大块数据偏慢大块数据传输更高效但生命周期要严格管理2.3 一个驱动骨架里的dma_map/unmap先说描述符分配priv-desc_cpu dma_alloc_coherent(dev, PAGE_SIZE, priv-desc_dma, GFP_KERNEL); if (!priv-desc_cpu) return -ENOMEM;streaming数据的典型发送路径是这样dma_addr_t dma_addr; int len skb-len; dma_addr dma_map_single(dev, skb-data, len, DMA_TO_DEVICE); if (dma_mapping_error(dev, dma_addr)) goto drop; /* 把 dma_addr 写入描述符然后启动硬件 */ /* 硬件传输完成中断里做 unmap */ dma_unmap_single(dev, dma_addr, len, DMA_TO_DEVICE);这段代码看起来简单但有几个点必须养成习惯一是dma_mapping_error检查不可省尤其是IOMMU开启时映射可能因为IOVA耗尽而失败二是unmap必须在DMA完全结束后再注册的路径里执行不能提前unmap后硬件还在写三是发送完成回调里做unmap时len和direction必须与map时完全一致。内核有个CONFIG_DMA_API_DEBUG选项开以后会在map/unmap不匹配时打印调用栈强烈建议调试驱动时打开。2.4 物理连续内存不够时SWIOTLB、CMA与IOMMU现实里内存够大但不够连续的情况非常常见。Linux提供了三层兜底机制。第一层是swiotlb即软件IOMMU。当设备寻址能力不足无法直接访问目标物理内存时内核会从预留的swiotlb bounce buffer里分配一块设备能访问的内存搬运DMA数据。比如一台x86机器内存超过4GB但PCIe设备只支持32位DMA开启swiotlb后内核会自动帮你做中转代价是额外一次内存拷贝吞吐量下降。第二层是CMA连续内存分配器。对于显示、GPU、视频编解码这类需要大量物理连续内存的场景可以在内核启动参数里加cma64M预留连续内存区。dma_alloc_coherent等API在普通分配失败时会尝试从CMA区域分配这就是很多人搜的dma continuous requests背后的实现机制。注意CMA内存从系统中扣掉后不能给页面缓存使用线上服务器如果不需要大块连续内存不必盲目调大CMA。第三层是IOMMU/SMMU。有了IOMMU设备看到的是IOVAIO virtual address内核可以把若干段不连续物理页映射成设备视角的一段连续地址既绕过了物理连续性的限制又天然做了访问隔离。现代平台的DMA映射路径大多会经过IOMMU这也是后续聊安全防护时的重要一环。3. 嵌入式DMA实战串口、ADC和SPI的常见姿势3.1 串口不定长接收DMA加空闲中断嵌入式平台以STM32为例最典型的实战是串口不定长接收。固定长度的数据直接用HAL_UART_Receive_DMA就够了但实际通信中往往不知道一帧到底有多少字节。解决方案是用DMA的circular模式配合串口IDLE空闲中断接收过程中一旦总线空闲超过一个字节周期IDLE中断触发此时从DMA缓冲区里读取出本次帧的数据长度。CubeMX里配置很简单串口打开DMA接收模式设成Circular数据宽度、内存地址自增都按字节配好。代码上新版HAL库可以直接用HAL_UARTEx_ReceiveToIdle_DMAHAL_UARTEx_ReceiveToIdle_DMA(huart2, rx_buf, RX_BUF_SIZE);然后在回调里处理数据void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART2) { process_frame(rx_buf, Size); HAL_UARTEx_ReceiveToIdle_DMA(huart2, rx_buf, RX_BUF_SIZE); } }这里有两个高频坑。第一个是回调里如果不重新启动DMA接收下一帧数据就不会再进缓冲区。第二个是当数据持续高速到达、始终没有空闲字节时circular缓冲区会被写满并绕回这时候需要配合半满/全满中断或超时机制来切帧。很多老工程师更习惯老标准库时代DMA中断加IDLE的写法本质不变只是HAL库把这些事件封装进了RxEventCallback。3.2 ADC多通道采集定时触发加循环搬运ADC多通道采集用DMA核心诉求是不管ADC什么时候转换完成DMA都在后台把结果搬走。CubeMX里把ADC配置成Scan模式并用定时器触发再打开DMA circular搬运转换结果会按配置的通道顺序依次写入数组。假设我采集3个通道代码上通常这样启动HAL_ADC_Start_DMA(hadc, (uint32_t *)adc_buf, 3);启动后adc_buf[0]对应第一个配置的通道adc_buf[1]对应第二个adc_buf[2]对应第三个DMA循环搬运。想连续采集多轮的话可以把这个3扩大成通道数×轮数比如开一圈放30个元素每10个元素一组。这里要提醒的是如果你在DMA搬运过程中直接从adc_buf读取数据必须自己确认读取时对应的数组位置是新的还是上一轮的否则会读到新旧交错的数据。稳妥做法是在HAL_ADC_ConvCpltCallback里把整块数据memcpy出来再用双缓冲交替处理。这个模式和后面讲的双缓冲思想完全一致。3.3 SPI DMA的坑NSS与传输完成回调SPI接DMA同样是高频需求尤其当SPI速率跑到几Mbit/s甚至十几Mbit/s时逐字节中断发送基本不可持续。HAL下的启动代码一般是HAL_SPI_TransmitReceive_DMA(hspi, tx_buf, rx_buf, len);完成后的处理放在HAL_SPI_TxRxCpltCallback里。看似简单但SPI的NSS片选经常制造莫名其妙的问题。如果用硬件NSS片选信号会跟着SPI数据传输自动拉低和拉高半双工或全双工场景下的时序不一定符合外设要求。更可控的做法是把NSS设为软件控制用一个普通GPIO来拉低拉高在调用DMA传输前先拉低等传输完成后回调里再拉高。另外SPI DMA同时使用发送和接收通道时两个DMA通道的方向不能配反。很多人把发送通道的外设到内存和接收通道的内存到外设搞混结果要么数据全是0xFF要么一直收不到数据。调试时先固定一边比如打印tx_buf的原始值再看DMA寄存器里的实际写入数据很快能定位。3.4 NDTR限制与多段搬运STM32多数DMA控制器的NDTR寄存器只有16位也就是说单次DMA传输的数据单元数量上限是65535。如果内存数据宽度配成字节一次最多搬65535字节配成半字最多搬131070字节。数据长度一旦超过这个上限并且没有拆段DMA就会在中途停止只搬了低16位的部分剩下的数据永远到不了目的地。解决思路无非三条一是把大块传输拆成多个不超过65535单元的分段每完成一段重新配置再启动二是用circular模式加半满/全满中断让CPU和DMA在时间上交错处理三是用支持链接列表linked-list的DMA控制器比如部分高端型号把多个描述符链起来DMA自动跳到下一个描述符继续搬真正实现大块连续搬运。后者效率最高但描述符管理和cache同步也会更麻烦需要对手头的硬件手册有足够理解。4. 缓存一致性DMA最容易翻车的区域4.1 为什么CPU看到的数据是旧的DMA数据明明已经写到内存了CPU读出来的却是旧值这个现象在带D-Cache的处理器上几乎人人都会遇到。原因是CPU读数据时会命中cache line而DMA是直接写DRAM的它不会主动去改CPU cache里的副本。于是DMA写完以后CPU再次访问同一地址命中的仍然是cache里那行旧数据。反过来也一样。CPU先写好一大块发送数据这些数据还躺在cache里没有回写DRAMDMA就去读取这块内存结果读到的是DRAM里的旧内容。这种问题不是DMA控制器坏了而是软件没有按一致性规则办事。4.2 方向表达的逻辑TO/FROM/BIDIRLinux的DMA方向用enum dma_data_direction表达三个常量分别代表CPU和硬件之间的数据流向方向语义map/unmap时内核做的事DMA_TO_DEVICECPU写硬件读map时flush cache确保dirty数据落DRAMDMA_FROM_DEVICE硬件写CPU读unmap时invalid cache丢弃stale行DMA_BIDIRECTIONAL双向读写两类操作都可能做开销最大很多人把方向当成传输方向其实它表达的是缓存维护的正确时机。发送数据用DMA_TO_DEVICE接收数据用DMA_FROM_DEVICE这个错了会引发非常隐蔽的脏数据问题。比如接收缓冲区标成TO_DEVICEDMA写完后CPU读的时候没有invalid cache看起来数据是对的但下次DMA再写同一块缓冲区时如果cache里还有旧引用就可能读到上一轮残留数据。4.3 Linux下的dma_sync与STM32 D-Cache维护当一块streaming DMA内存需要在映射有效期内被CPU多次访问时必须用dma_sync_*手动维护一致性。典型场景是block设备的连续读写每次传输完成后CPU先做for_cpu同步再读取数据下次硬件复用缓冲区之前再做for_device同步dma_sync_single_for_cpu(dev, dma_addr, len, DMA_FROM_DEVICE); /* CPU读取/处理数据 */ dma_sync_single_for_device(dev, dma_addr, len, DMA_FROM_DEVICE); /* 把缓冲区重新交给硬件 */嵌入式平台上的做法类似。Cortex-M7这类带D-Cache的内核收发DMA数据前必须手动clean或invalid cacheSCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); /* 发送前脏数据回写DRAM */ SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len); /* 接收后丢弃旧cache行 */一个容易忽略的细节是这两个函数要求地址和长度通常要按cache line对齐一般是32字节否则操作范围不准。所以DMA缓冲区最好声明时__ALIGNED(32)回避对齐问题。4.4 双缓冲用空间换调度时间DMA和CPU天然适合流水线协作。一个经典模型是双缓冲DMA往缓冲区的后半段搬数据时CPU处理前半段DMA跳到前半段搬时CPU处理后半段。底层靠的是DMA的half-transfer和transfer-complete两路中断。STM32的HAL库提供了HAL_UARTEx_RxHalfCpltCallback和HAL_UARTEx_RxCpltCallback分别对应缓冲区半满和全满事件。在这两个回调里分别处理两个半区再配合circular模式就能做到几乎不间断的收发流水线。要注意的是两个回调都必须快速处理完不要在回调里做耗时操作数据量大时先把数据拷贝出来再交给业务线程。4.5 描述符与内存屏障驱动写描述符到一致性内存时描述符里的各个字段写入顺序以及启动DMA这个动作的先后不能编译器和CPU随便乱调。典型的写法desc-addr dma_addr; desc-len len; dma_wmb(); /* 确保上述写入先被硬件看到 */ desc-start 1; /* 启动位最后写 */dma_wmb在ARM等架构上会插入必要的内存屏障防止store缓冲重排。很多驱动不写屏障也能跑通是因为碰巧时序足够宽松但在高负载或者乱序执行的处理器上迟早暴雷。这个细节属于文档里会写但很多人不重视的典型。5. 缓冲区尺寸、对齐和总线宽度规格书里的隐藏细节5.1 Linux DMA pool与对齐要求普通内核驱动如果用kmalloc分配一堆大小不同的DMA描述符很容易出现对齐不达标或分配/释放开销过大的问题。内核提供了dma_pool专门用于频繁分配固定大小、有对齐要求的小对象struct dma_pool *pool; pool dma_pool_create(tx_desc, dev, sizeof(struct tx_desc), 64, 0); struct tx_desc *desc dma_pool_alloc(pool, GFP_KERNEL, desc_dma);第三个参数64就是对齐字节数。不同DMA控制器对描述符地址对齐的要求不一样有的要求4字节有的要求32字节务必按手册配置。用dma_pool还有一个隐性好处对象是从DMA能力良好的内存区分配的不会落到硬件够不着的高地址内存。5.2 16位计数器的九九八十一难前面讲过NDTR最多65535个数据单元这里再从工程角度补充一下杀伤力。假设一个USB摄像头的DMA缓冲区配置成4MB用STM32F1这种老平台传输时DMA会在第65535字节处停下来抓包数据只有64KB出头。很多人为了绕开这个问题去开更大的DMA缓冲区结果毫无作用因为瓶颈在计数寄存器位数而不在缓冲区大小。正确姿势是拆段一段传输完成后立刻重新装填NDTR下一段中间用中断衔接。如果中断衔接时间太长还会出现总线空隙某些对时序敏感的外设根本不能接受。所以更推荐的做法是circular模式或者链表描述符模式让DMA在硬件层面连续工作。5.3 总线宽度与数据拼接配置DMA时外设数据宽度PSIZE和内存数据宽度MSIZE如果不一致硬件会做一些看似合理但不符合直觉的拼接动作。举个实际例子外设是8位UART数据内存如果配成16位数组DMA每次会把一个字节写进半字的MySQL单元。在外设看来没问题但你的内存数据排列会变成每个有效字节之间夹着空字节解析数据时稍一疏忽就错位。图形和音频DMA里这种宽度不匹配更是重灾区。我自己的习惯是除非明确理解控制器的拼接规则否则外设和内存的数据宽度保持一致避免所有额外麻烦。如果一定需要转换优先用DMA的FIFO/burst功能而不是靠数据位宽硬凑。5.4 scatter-gather如何救场Linux网络驱动里一个发送缓冲区往往对应多个不连续的物理页DMA要完成一次逻辑上的大传输又不想每个物理段都单独搬一次于是有了scatter-gatherSG。dma_map_sg会把一个scatterlist里的多个内存片段映射成硬件能访问的地址集合返回实际映射的entry数sg_init_table(sg, num_ents); for (i 0; i num_ents; i) sg_set_page(sg[i], page_i, len_i, offset_i); nents dma_map_sg(dev, sg, num_ents, DMA_TO_DEVICE); for (i 0; i nents; i) dma_addr[i] sg_dma_address(sg[i]);如果系统开了IOMMUdma_map_sg甚至可能把多个不连续物理页映射成连续IOVA设备视角下就是一整段地址传输性能大幅提升。这也是网络和块设备驱动在现代内核里普遍使用SG的原因。6. 安全与蓝屏内核DMA保护机制离我们不远6.1 Windows的Kernel DMA Protection与0xE6很多Windows用户搜内核DMA保护蓝屏看到的是系统设置里的内核DMA保护Kernel DMA Protection功能。它利用IOMMU限制外部设备访问系统内存主要防的是雷电口一类可插拔设备发起的DMA攻击。开启后老的驱动如果没有按照DMA映射规则工作要么加载失败要么运行中触发蓝屏。驱动开发者更常用的调试工具是Driver Verifier。把verifier打开并勾选DMA相关校验项后驱动如果对非法地址做DMA操作系统会直接抛0xE6: DRIVER_VERIFIER_DMA_VIOLATION。这个bugcheck几乎可以断定是驱动生命周期管理有漏洞常见原因包括缓冲区已经被释放或unmap但DMA还在进行、DMA请求的长度越过缓冲区边界、方向参数错误导致cache维护错乱。6.2 Linux上的IOMMU/SMMU防护Linux同样有IOMMU防护机制。x86平台常见参数intel_iommuon、amd_iommuonARM SoC上则是SMMUdevice tree里通过iommu-maps或默认domain把设备纳入地址翻译范围。开启IOMMU后每个设备只能访问内核为它映射的IOVA区域即便设备被攻击或被固件bug引导也无法直接读写任意物理内存。这就是所谓untrusted device隔离。但IOMMU不是银弹。映射和unmap的基准是驱动API调用驱动如果map之后忘记unmapIOMMU页表里就残留着无效映射时间长了I/O地址空间被耗尽其他DMA请求就会失败。这也是为什么CONFIG_DMA_API_DEBUG这类调试手段在内核社区里被反复推荐。6.3 DMA驱动的生命周期纪律综合前面所有内容我把DMA驱动的生命周期纪律总结成几条硬规矩缓冲区分配和DMA映射必须放在一条明确的时间线上map之前绝不能启动硬件传输。每次map后都要检查dma_mapping_error失败路径里得把已分配的资源全部回滚。同一块缓冲区反复复用时不能省略dma_sync_*并且每次都要确认方向。unmap必须且只能执行一次错误处理路径和正常完成路径要统一收口。不要往内核栈、vmalloc区域直接做DMA这类内存不是为DMA准备的map大概率失败或数据错乱。如果用了DMA_ATTR_SKIP_CPU_SYNC这类标志一定要自己负责后续的cache维护否则等于裸奔。6.4 从现象到根因排查DMA问题的一般思路DMA问题表面看千奇百怪实际成因就那么几类。我的排查顺序是先看现象出现在地址翻译层面还是数据内容层面。如果是随机崩溃或蓝屏优先怀疑映射生命周期打开DMA API debug或Driver Verifier抓调用栈如果是数据内容差一点点优先怀疑缓存一致性和对齐如果是搬运到一半停住优先怀疑计数限制和触发源丢失如果是首包数据全0优先怀疑缓冲区还没有真正被映射到设备地址空间。嵌入式侧没有那么多调试工具就看两样东西DMA状态寄存器和外设事件标志。比如HAL_UART_ErrorCallback里查OVR、DMA传输错误标志位一看就能区分是DMA根本没启动还是DMA中途出错。不要一上来就怀疑芯片坏了绝大多数DMA问题最后都归结为软件把边界划错了。我自己这几年调DMA有个很深的体会越是大数据量、高并发的场景越要老老实实按映射、同步、拆段的固定套路走。偶尔偷懒少写一次sync眼前可能一切正常等换到缓存策略更激进的CPU上老问题就原样返工。这套代码层面的自律比起找一万个为什么数据不对的答案来得可靠得多。