1、背景
在现代计算机系统中,CPU cache的引入极大地提升了内存访问的性能,但是同样也带来了非常让人头疼的问题——缓存一致性问题。
在仅涉及CPU访问内存的场景下,大部分开发者其实是感受不到cache的存在的,然而当系统中引入DMA设备后,情况就变了,很多时候你会发现在CPU视角下“完全正确”的代码,可能会在实际运行中出现一些预料之外的问题,最近在工作中就碰到了这种问题,而且它的出现会是偶发的,如果没有很好的工具定位,大部分时候只能通过经验去解决。
本文将会从缓存一致性的角度出发,介绍Linux内核中与DMA相关的一些接口以及使用场景。本文重点将放在接口的基本功能、使用以及概念理解上,而不会深入展开函数的底层实现。
2、DMA机制与缓存一致性
在以往,一个硬件设备要去读写内存,往往需要CPU参与搬运数据,以读内存为例,CPU会先将数据从内存中读取过来之后,再通过总线或寄存器送给设备。这种方式显而易见,不仅占用了大量CPU资源的同时,还会大大降低设备的吞吐量,后来为了缓解这个问题,DMA机制出现了。
DMA的核心思想是:在数据的传输阶段,设备可以在DMA控制器的协助下直接访问内存,而不再需要CPU去帮忙读数据或者写数据了,CPU要做的就是在传输开始前进行一些配置即可。通过这种方式,大大降低了CPU占用率的同时,还提升了自身设备的吞吐量。
从表面上来看,cache的引入本身是为了提升CPU读写的性能,DMA的引入也是为了提升设备性能,但是当两者一起使用的时候,可能会事与愿违。想象一下一个场景,现在CPU更新了一组数据,由于cache的存在,CPU会暂时把数据更新在cache中,此时DMA设备屁颠屁颠的去内存中读取数据,发现读到的数据怎么是老的数据,这就出现了缓存不一致的问题。
那么怎么解决这个问题呢,目前最常见的两种方式,缓存刷新与缓存失效。当CPU刚刚修改完数据并即将由DMA设备读取时,通过缓存刷新能将cache中的脏数据写回内存中,DMA设备就能读到最新的数据。而当DMA设备向内存写了的数据,CPU随后需要去读,则需要通过缓存失效使CPU cache中的旧数据失效,从而让CPU从内存中读取设备刚刚写入的新内容。
了解了缓存一致性问题和DMA机制后,在实际的 Linux 驱动开发中,开发者该如何正确使用cache和DMA设备并避免这类陷阱?Linux 内核早已意识到这一问题,并提供了一套完整的 DMA 映射(DMA mapping)API,专门用于在启用缓存的系统上安全地进行 DMA 操作。
3、Linux DMA
在Linux中,提供了两种对DMA的操作方式,一种是coherent DMA,另一种是Streaming DMA。它们最大的区别有两点:(1)CPU和DMA设备在访问同一块内存的时候,缓存一致性是系统帮你管理还是需要开发者自己来管理,(2)这块DMA缓冲区生命周期的长短。
3.1 coherent DMA
先说coherent DMA,在这种操作方式下,你可以不用操心什么时候需要进行缓存刷新,缓存失效,从你获得内存的那一刻起,你就不用管了,系统会接管所有缓存一致性的问题。
Linux里用来分配coherent DMA内存的接口是:- void *dma_alloc_coherent(struct device *dev, size_t size, dma_addr_t *dma_handle, int flag);
- 功能:分配一块CPU和DMA设备能安全、一致访问的内存。
- 返回值:分配的内存的虚拟地址
- 参数:
- dev:指向使用该DMA内存的设备
- size:要分配的字节数
- dma_handle:输出参数,返回连续的总线起始地址
- flag:内存分配标志,通常使用GFP_KERNEL、GFP_ATOMIC
复制代码 使用该函数需要注意以下三点:
(1)分配给设备使用的总线地址是连续的,大部分设备只看得懂连续的总线地址。
(2)分配的内存能够保证缓存一致性。
(3)要正确使用内存分配标志,当内存不够时,GFP_KERNEL 允许睡眠等待系统回收内存,适用于进程上下文,但绝对不允许用于中断等原子上下文;GFP_ATOMIC 与GFP_KERNEL 恰好相反,它不会进入睡眠,内存不足时直接返回 NULL,因此一般该标志用来分配比较小的内存。
有内存分配函数就会有对应的内存释放函数,对应的内存释放函数接口是:- void dma_free_coherent(struct device *dev, size_t size, void *cpu_addr, dma_addr_t dma_handle);
- 功能:释放由dma_alloc_coherent分配的一致性DMA内存,同时解除其对设备总线地址的映射
- 参数:
- dev:指向使用该DMA内存的设备
- size:要释放的字节数(必须要与分配时候的size相同)
- cpu_addr:dma_alloc_coherent返回的内核虚拟地址
- dma_handle:dma_alloc_coherent返回的设备总线地址
复制代码 这个函数就是把 dma_alloc_coherent() 分配的内存还给系统,同时解除设备那边的 DMA 映射关系。
这里要特别强调一点:
在内核里,内存分配和释放一定要成对出现。
在用户态写程序的时候,就算你忘了 free(),最坏的情况也就是进程退出时由内核帮你兜底回收。但在内核里不是这样,一块内存一旦泄漏,就是系统级的资源损失,时间一长,轻则内存越来越紧张,重则直接把整个系统拖垮。
coherent接口最大的优点就是省心,但是同样任何事情有好的一面就有坏的一面,这种方式分配的成本更高并且在某些平台上为了保证缓存一致性其性能会比较差。因此在Linux系统中,还提供了另一套比较灵活的使用方式:Streaming DMA。
3.2 Streaming DMA
在实际驱动开发中,并非所有 DMA 场景都需要通过 dma_alloc_coherent() 直接分配一致性内存。更多时候,驱动已经持有一块通过 kmalloc()、vmalloc() 或用户态传入的普通内存缓冲区,希望将其临时用于一次 DMA 传输。然而,普通内存既不具备设备可直接识别的总线地址,也无法保证在 DMA 传输前后与 CPU cache 自动保持一致。
此外,DMA 数据传输通常具有明确的开始与结束阶段,在不同阶段,CPU 与设备对同一块内存的访问权并不相同。基于这样的使用特点,Linux 提供了Streaming DMA的使用方式,该方式提供了dma_map_single() 与 dma_unmap_single() 两个接口,用于界定一次 DMA 事务的生命周期。
dma_map_single函数可以理解为DMA事务的起点:- dma_addr_t dma_map_single(struct device *dev, void *buffer, size_t size, enum dma_data_direction direction);
- 功能:标志一次DMA事务的开始,将已经分配的内存映射到DMA缓冲区,并完成一次必要的cache同步。
- 返回值:映射后的DMA总线初始地址
- dev:指向使用该DMA内存的设备
- buffer:需要映射的内存起始地址
- size:需要映射的字节数
- direction:数据移动的方向
复制代码 这个函数主要做了两件事:
(1)把一块已经分配好的普通内存,映射成设备可以访问的DMA总线地址。
(2)根据数据传输方向,完成一次必要的cache同步。
在设备完成DMA传输之后,需要调用对应的解映射接口:- void dma_unmap_single(struct device *dev, dma_addr_t dma_addr, size_t size, enum dma_data_direction direction);
- 功能:标志一次DMA事务的结束,撤销设备对DMA缓冲区的映射,并在必要时完成一次cache同步。
- dev:指向使用该DMA内存的设备
- dma_addr:dma_map_single返回的总线地址。
- size:需要解映射的字节数,需与dma_map_single一致。
- direction:数据移动的方向,需与dma_map_single一致
复制代码 dma_unmap_single可以理解为DMA事务的终点,它的作用是撤销之前建立的DMA映射,并在必要的时候完成一次缓存一致性处理,从而标志着这次DMA事务结束。
需要注意的是,在这段DMA生命周期,只有映射开始和映射结束的时候才会执行一次缓存同步,假设在过程中,CPU对这块内存进行了读写,那么缓存一致性就无法保证了,我认为这是Streaming DMA中最容易出现的问题。
那么,假设现在的情况是,CPU和设备需要对一块缓冲区进行频繁的交替访问该怎么办呢,当然有的人会想着说,那我频繁进行映射和解映射不就行了,这种方式理论上来说是可行的,但是实际上不仅会带来比较大的性能开销,也不符合dma_map/unmap_single这组接口的语义。为了应对这种情况,Linux 提供了两个更细粒度的关键接口:dma_sync_single_for_cpu() 和 dma_sync_single_for_device()。它们能够在DMA映射的生命周期内灵活的切换 CPU和设备对于DMA 缓冲区的访问所有权,并对缓存进行刷新和失效。- void dma_sync_single_for_cpu(struct device *dev,
- dma_addr_t dma_addr,
- size_t size,
- enum dma_data_direction direction);
- 功能:将DMA缓冲区的访问权切换给CPU,确保CPU能够看到设备刚刚写入内存的最新数据。
- dev:指向执行DMA操作的设备结构体
- dma_addr:dma_map_single函数返回的DMA总线地址
- size:需要进行同步的字节数,与映射时使用的大小保持一致
- direction:DMA数据移动的方向
复制代码 这个接口用于 把 DMA 缓冲区的访问权切换回 CPU。它会使 CPU cache 中可能存在的旧数据失效,确保 CPU 随后读到的是设备刚刚通过 DMA 写入内存的最新内容。- void dma_sync_single_for_device(struct device *dev,
- dma_addr_t dma_addr,
- size_t size,
- enum dma_data_direction direction);
- 功能:将DMA缓冲区的访问权切换给设备,确保设备能够看到CPU刚刚写入缓存的最新数据。
- dev:指向执行DMA操作的设备结构体
- dma_addr:dma_map_single函数返回的DMA总线地址
- size:需要进行同步的字节数,与映射时使用的大小保持一致
- direction:DMA数据移动的方向
复制代码 与之相对,dma_sync_single_for_device() 用于 把 DMA 缓冲区的访问权重新交给设备。它会将 CPU cache 中的脏数据写回内存,确保设备在下一次 DMA 操作时看到的是 CPU 最新写入的数据。
4、总结
本文从缓存一致性问题出发,简单梳理了 DMA 机制在 Linux 中的几种常见使用方式。CPU cache 和 DMA 设备本身都是为了提升系统性能而存在的,但当两者同时参与内存访问时,如果处理不当,就会引入非常棘手、而且难以调试的缓存一致性的问题。
在Linux中,DMA 的使用方式大致可以分为 coherent DMA 和 Streaming DMA。coherent DMA 的优点是省心,CPU 和设备可以长期共享同一块内存,缓存一致性由内核负责处理;但它的分配成本相对较高,在某些平台上性能也并不理想。Streaming DMA 则更加灵活,适合临时使用普通内存进行 DMA,但需要开发者在合适的时机显式地处理缓存同步。
对于 Streaming DMA,dma_map_single() 和 dma_unmap_single() 用来界定一次 DMA 事务的开始和结束,而 dma_sync_single_for_cpu() 与 dma_sync_single_for_device() 则用于在 DMA 映射仍然存在的情况下,切换 CPU 与设备对同一块缓冲区的访问阶段。理解这些接口各自负责的“层级”,是避免 DMA 缓冲区被误用的关键。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |