拷数据这件事,看起来简单,真正较真起来能吵一晚上。我在做嵌入式音视频处理和网络数据转发的时候,经常遇到一个场景:一块数据要从A处搬到B处,有的人说用DMA,有的人说用NEON加速,有的人说直接memcpy不就行了吗。三种说法各有道理,但放在不同硬件、不同数据量、不同场景下,结论可能完全相反。以前我写过一篇笔记,核心观点是“不要无脑选DMA,也不要无脑memcpy,关键是搞清楚工作量落在谁头上”,后来发现问这个问题的朋友很多,涉及的知识点也确实绕,干脆系统展开聊一次。
先交代背景。我长期接触ARM架构的SoC,也做过x86平台的高性能数据通路,处理过大量“从网卡收包送到业务层”这类考虑性能的路径。在这个过程里,“拷贝”是最频繁、最不起眼,却最容易被忽视的性能杀手。加上现在很多外设都支持DMA(比如串口、SPI、以太网MAC、摄像头接口),NEON/SIMD又是ARM平台标配,普通程序员对这个概念很容易产生三种误解:DMA什么都快、NEON是万能加速器、CPU拷贝一定低效。这篇就围绕这三种误解,结合我对数据拷贝的完整排查和实测经验,讲清楚各自的原理、代价、适用边界以及最终怎么选。
1. 三种“数据搬家”的本质区别:指令、总线、还是外设
先说清楚一个基础概念:所谓“数据拷贝”,本质上是数据从源地址搬到目的地址,无论是内存到内存、外设到内存、还是内存到外设,都需要有一个“搬运工”。DMA、NEON、普通CPU拷贝之所以不同,是因为“搬运工”不同——CPU指令负责、SIMD协处理单元负责、还是独立DMA控制器负责。这个差别决定了性能、功耗、代码复杂度,也决定了你该在什么时候用哪个。
1.1 普通CPU拷贝:一个“按块计价”的快递员
最朴素的CPU拷贝就是循环里逐字节/逐字地mov一下。编译器优化后,会变成一次搬4字节、8字节甚至16字节的指令,配合预取、缓存行填充,性能其实不差。这也是为什么很多程序员直接写memcpy()就能跑出很可观的带宽。
它的本质特征是:拷贝过程的每一步都要CPU参与执行,从取指令、译码、访存、写回,CPU核心全程在干活。这就像你请了一位按小时计费的快递员,他每搬一个箱子都需要你盯着,哪怕只是从仓库左边挪到右边。CPU在拷贝期间不能干别的,也不能进入低功耗状态,对实时任务来说是很大的干扰。
一个重要而容易被忽略的点:普通CPU拷贝的效率上限,往往不是指令执行速度,而是内存带宽和缓存命中率。数据在L1/L2 cache里时,memcpy能跑到几十GB/s;数据要穿透到DDR时,就只有几GB/s到十几GB/s。很多人在性能测试里盲目对比“CPU拷贝每秒多少”,根本不说明是在哪个层次拷贝——是缓存内、缓存与内存之间、还是跨NUMA节点,差异可以到十倍以上。
1.2 NEON拷贝:一条“流水线式”的机械化小队
NEON(在ARMv8也叫ASIMD)是ARM体系结构中的SIMD扩展,本质上是CPU内部的一组宽向量寄存器(128位)和对应的向量指令。它能一次对多个数据元素执行相同操作,比如一次性把8个16位数据或4个32位数据搬进搬出寄存器。
很多人认为NEON就是“快”,这个理解粗了。NEON快的本质在于两点:一是把多条标量指令合并成一条向量指令,减少了指令发射和译码开销;二是提供了更好的内存访问策略(比如ld4/st4这类交错加载),可以一次操作多个缓存行。但NEON仍然是CPU执行单元的一部分——也就是说它的数据和指令都要进入CPU的流水线,CPU核心依然在工作。它不是“免费用”的,只是“一个能干四个活的工人”而已。
一个非常典型的现象:当你用NEON优化内存拷贝时,你会发现带宽提升并没有想象的那么大,可能只比普通memcpy提升10-30%。道理很简单——内存拷贝的瓶颈几乎总是总线带宽,而不是指令吞吐。NEON减少了指令条数,但如果总线已经饱和,那就等于一个快递员换成了一队机械臂,但仓库门口的路只有一条,照样堵车。
NEON真正的价值不在“搬运”本身,而在于“边搬边算”。比如像素格式转换(RGB转YUV)、编解码中的半像素插值、加解密中的按块混合、网络包校验和计算,这些场景需要同时处理大量数据且伴有运算动作,NEON的优势才体现得淋漓尽致。如果你只是想把一块内存不动脑筋地搬到另一块内存,NEON不是最优解——你很可能只是把memcpy换了个写法,收益极其有限。
1.3 DMA拷贝:一个“不用你管”的独立物流公司
DMA(Direct Memory Access)是一种由DMA控制器负责搬运数据的机制。CPU只需告诉DMA控制器“从哪里搬、搬到哪、搬多少”,然后就可以该干嘛干嘛去了。DMA控制器自己占用总线,完成搬运后再通过中断或轮询标志通知CPU。这就是前面说的“独立物流公司”——你把运单填好,货物怎么卸、怎么装、怎么运,是别人的事。
DMA有几个关键特征决定了它的适用场景:
- 数据不经过CPU寄存器,直接从源地址到目的地址。
- 拷贝过程占用的是总线带宽,而不是CPU执行带宽。
- 完成信号是中断或状态位,有可预测的延迟和开销。
这意味着DMA非常适合“大批量、无需即时处理、不想中断CPU主逻辑”的拷贝。比如大文件从SD卡到内存、网卡收到的一整个数据包搬运到用户缓冲区、摄像头采集到连续帧数据送入内存,这些都是DMA的主场。
但DMA不是没有代价。启动一次DMA需要配置寄存器、维护描述符、处理完成中断,这些开销都是固定的。数据量越小,固定开销占比越高,DMA反而比CPU拷贝更慢。很多人第一次用DMA传了几百字节,反而比memcpy慢好几倍,就是这个原因。
这三种方式的关键区别我做个表格,方便以后选型时对照:
| 对比维度 | 普通CPU拷贝 | NEON/SIMD拷贝 | DMA拷贝 |
|---|---|---|---|
| 执行主体 | CPU核心 | CPU核心(SIMD单元) | 独立DMA控制器 |
| CPU占用 | 全程占用 | 全程占用 | 仅启动和完成时短暂占用 |
| 是否经过寄存器 | 是 | 是(向量寄存器) | 否 |
| 最大优势 | 简单通用、零配置 | 边搬边算、指令少 | 大批量时CPU可做别的事 |
| 最大劣势 | 耗费CPU时间 | 总线瓶颈限制提升空间 | 小数据量延迟高、配置复杂 |
| 典型数据量 | 任意,适合小块 | 需要计算的块 | 大块、流式数据 |
| 完成通知 | 同步执行完毕 | 同步执行完毕 | 中断/标志位 |
选择的第一原则就是:根据你的瓶颈资源来决定方式。如果是CPU时间宝贵,DMA优先;如果数据本身需要计算,NEON优先;如果是简单搬移且数据量不大,直接CPU拷贝。
2. 关键临界点:多大数据量下DMA才开始占优势
这一节是纯经验,也是我当年踩了坑之后反复测量出来的结论。很多人问“DMA是不是比memcpy快”,答案取决于两个变量:数据量和平台本身的DMA启动延迟。
2.1 我实测的一组参考数据
以我手头一块主频1.8GHz的ARM Cortex-A72双核平台为例,DDR3-1600,32位总线,使用memcpy(编译为NEON优化版本)和DMA搬运同一缓冲区到另一缓冲区,分别测10次取平均。数据如下:
| 数据量 | CPU memcpy耗时 | DMA耗时(含中断与等待) | 结论 |
|---|---|---|---|
| 1KB | 约2微秒 | 约15微秒 | CPU远快于DMA |
| 16KB | 约15微秒 | 约25微秒 | 依然CPU占优 |
| 64KB | 约55微秒 | 约55微秒 | 基本持平 |
| 256KB | 约220微秒 | 约80微秒 | DMA明显胜出 |
| 1MB | 约800微秒 | 约150微秒 | DMA碾压 |
注意,这个数据是在比较“单次”DMA包含中断处理全流程的情况下测的。如果把多个DMA请求排成描述符链,让DMA控制器连续搬运,DMA的吞吐优势还会进一步扩大。反过来说,如果你的DMA驱动实现得很差,每次搬运都等中断再配置、再启动,那么临界点可能会提高到好几百KB。
不同平台差异会很大,Cortex-M系列上DMA配置更简单、中断更快,临界点可能在8KB-32KB之间;x86平台因为PCIe设备和IOMMU的存在,DMA映射开销大,临界点更高。所以我不建议记住“64KB”这个数字,而是要记住这个测量方法——在你的目标平台上跑一次同样的对比,用实测结果做依据。
2.2 为什么存在这样的临界点:固定成本与线性成本的较量
DMA耗时大致可以拆成两个部分:
- 固定成本:初始化通道、配置源地址/目的地址/长度、开启搬运、等待完成中断、处理中断服务函数。这个成本基本不随数据量变化。
- 线性成本:真正搬运数据时,按字节数增长的总线占用时间。
CPU拷贝也有类似的拆分:函数调用、cache miss、搬运指令循环,但CPU拷贝中“开始搬运”这件事的成本很低,起一个循环就开始了,所以固定成本几乎可以忽略。于是总耗时就是一个很陡的线性增长。
DMA因为固定成本高,所以小数据量时总耗时被“启动费”占了主导,线性增长反而不明显。两条曲线的交点,就是临界点。实际测下来,临界点往往刚好落在“memcpy能跑满缓存带宽”的那一段附近——也就是说,当你的源/目的数据都还能待在L2 cache里时,CPU拷贝极其凶猛,DMA的独立搬运反而因为走总线到内存而吃亏,根本没有可比性。
所以一个实用判断规则:如果你的源和目的缓冲区大小都小于L2缓存大小,优先使用CPU memcpy。当缓冲区超过缓存容量、需要从DDR搬运大块数据时,才值得考虑DMA。我在很多项目里用这个规则做主次判断,比照搬网上的“4KB以上用DMA”要靠谱得多。
2.3 别被“DMA带宽高”迷惑:有效带宽不等于理论带宽
DMA控制器的datasheet上通常会写“支持xxx MB/s的传输速率”,很多初学者看到这个数字就决定“全用DMA”。实际上有效带宽要扣除启动时间、仲裁等待、内存刷新周期、总线争用。真实项目的有效吞吐通常只有理论值的50%-80%。
更隐蔽的是,DMA和CPU访问内存是竞争同一个总线的。你在一个核上跑DMA搬运,同时在另一个核上跑内存密集计算,两边互相拖慢,最终DMA“节省”的CPU时间有一部分会因为访存变慢而还回去。所以我总建议做对比测试时,不要只测纯搬运时间,还要测“在典型业务负载下,引入DMA后整体系统吞吐是否真的提升”。
3. NEON参与拷贝的真正价值区间:搬运之外的“顺路计算”
前面说了NEON在单纯拷贝上提升有限,这不代表NEON没用。换个角度看:如果数据本来就要经过CPU做处理,那么NEON可以做到“边拷贝边算”,让搬运和处理合二为一,这才是NEON的不可替代性。
3.1 一个典型的“拷贝+格式转换”场景
我做过一个图像采集项目:摄像头DMA把RAW数据送到内存,然后需要做RGB888转RGB565,再搬运到显示缓冲区。如果按“先DMA搬一次,再用CPU逐像素算,再搬一次”的老思路,整个链路是:DMA搬运(总线占用)→ CPU像素转换(反复读内存写内存)→ CPU拷贝(又一遍读内存写内存)。
用NEON改写后,可以直接在读RAW数据的同时做像素转换,以128位为单位一次处理16个像素,并且转换结果直接写到目的缓冲区。这样省掉了一次完整的内存拷贝,数据从内存读出来一次,就完成了“转换+搬运”两个动作。实测下来,整条流水线的时间比原先减少了约45%,而且CPU占用率还降了20%。
这个案例是理解NEON的正确姿势:它更适合“处理即搬运”的模式,而不是纯粹的搬运。好比说你家要搬家,NEON不是帮你把箱子搬过去的司机,而是一个在搬箱子过程中顺便帮你把箱子里的东西分类整理好的管家——如果你根本不需要整理,那请司机就行,请管家纯属浪费。
3.2 NEON与普通memcpy的性能对比:收益主要来自指令开销
我还在同一个A72平台上做过一个纯拷贝对比:手写NEON四路循环展开vld1q_u8+vst1q_u8,和libc的memcpy对比。结果memcpy约9.2GB/s,NEON版本约10.1GB/s,提升约10%。而如果换成计算密集的像素转换,NEON的收益可以到200%-400%。
这说明一个规律:在数据通路上,纯NEON拷贝的优化空间受限于内存带宽;在计算通路上,NEON因为一次能算多个数,优化空间取决于并行度。所以选择NEON时,先问自己一个问题:我在搬完这批数据之后,还要不要对它们做点什么?如果答案是“不需要”,NEON的优先级就该排在DMA和memcpy之后;如果答案是“要”,而且操作是逐像素、逐字节、可并行的数学运算,NEON应当排在最前面。
3.3 注意NEON的使用边界:寄存器压力与流水线阻塞
NEON不是随便一排指令就能达到最优。实际优化时会遇到几个典型的坑:
- 寄存器不够用。AArch64下有32个128位向量寄存器,但编译器在函数调用约定中只保证低16个不用保存,如果你要处理16个通道以上的中间变量,容易发生压栈,反而更慢。
- 流水线依赖。连续的
vld1q和vst1q之间如果有依赖,会导致NEON流水线停顿。解决办法是手动展开两个以上独立的数据块,让两条独立指令流交替发射。 - 部分ARM核的NEON与FPU共用寄存器文件,频繁切换上下文时有额外开销。在中断频繁的裸机环境中使用NEON要特别注意保存/恢复向量寄存器,否则中断现场会坏掉。
我的经验是:把NEON优化放到代码热区(profiler证明CPU时间最集中的地方),不要因为你“觉得应该快”就到处用。优化前用perf top或者类似工具确认热点,再动手,这是通用准则。
4. DMA的隐藏成本:缓存一致性、延迟与描述符管理
DMA用得好是利器,用得不好是坑王。这一节专门讲DMA最容易出问题的几个地方,都是我在项目中真实遇到过的,任何人做DMA方案前最好都认真看一遍。
4.1 缓存一致性:DMA和CPU看到的内存可能“不一样”
现在的ARM SoC上,CPU通过cache访问内存,而DMA控制器通常直接访问物理内存。如果CPU把数据写在了cache里还没写回DDR,DMA去搬的时候看到的可能是旧的、脏的内存内容。反过来,DMA把新数据写进了内存,CPU读出来时可能还是缓存中的旧值。
为保证一致性,一般有两种做法:
- 使用一致性缓冲区(DMA-capable,或dma_alloc_coherent)。内核API会帮你分配一片始终映射为一致性的内存,CPU写进去的数据会立刻对DMA可见,代价是每次访问都绕过cache优化,性能偏低。
- 在DMA启动前做
dma_map_single(..., DMA_TO_DEVICE),完成后再dma_unmap_single,由驱动框架在适当时机执行cache clean和invalidate。
我在一个网络驱动项目里曾因为漏了cache clean,导致DMA发送出去的报文尾部全是垃圾数据,排查了整整一天才定位到是脏缓存行没写回。从那以后我就定了一条铁律:DMA缓冲区不要自己malloc或栈上分配,必须走标准DMA API分配和映射。
这里可以打一个生活化比方:cache是书桌上摊开的草稿纸,DDR是上锁的文件柜。CPU抄写一份数据放在草稿纸上,还没收进文件柜就让DMA来拿,DMA打开文件柜当然看不到最新版本。一致性API的作用就是强制CPU“先归档再通知别人来取”。
4.2 DMA启动与完成的延迟:不是“零等待”
很多人以为DMA是异步的所以不占CPU时间,但实际上DMA启动也需要时间,完成信号来了以后CPU还要进中断处理。整个过程CPU虽然不用持续工作,但至少有两个时间点是被占住的:发指令时和中断响应时。
在Linux用户态使用DMA(比如通过/dev/mem或dpdk这类方案)时,映射和同步的开销可能还会更大。我在一个用户态高速数据采集项目中测过,单次mmap加DMA同步的开销约8-12微秒,而直接用memcpy的4KB块只要2微秒不到。所以“DMA快”的前提是:数据量足够大,大到固定开销可以被均摊;或者CPU在等待期间有别的任务要做,这时DMA的异步优势才会真正体现。
如果CPU在DMA搬运期间只是空转等待,那DMA在延迟上反而吃亏——因为它多了配置和中断的开销,CPU也没有被解放。异步带来的收益是“并发”,不是“更快”,这个逻辑一定得想清楚。
4.3 描述符链与双缓冲:真正走进DMA的用法
现代DMA控制器基本都支持描述符链(descriptor ring),可以一次配置一串搬运任务,让DMA连续执行,每个任务完成后自动取下一个描述符。这个特性适合“周期性搬运固定大小的数据”的场景,比如音频采集、ADC连续采样、网卡接收队列。
描述符链的关键设计决定了可靠性:
- 每个描述符要提前准备好,包括源地址、目的地址、长度、控制位和下一个描述符指针。
- 要在内存中保持描述符自己的对齐要求(通常是32字节或64字节对齐,存于cache一致性区域,或用屏障保证可见性)。
- 完成中断应尽量在整条链完成后再触发,避免每个包都产生中断把CPU打断。
真实项目中我看到很多人把DMA使能成“一次任务一次中断”,导致高吞吐场景下中断风暴,CPU反而被中断处理占满。正确做法是配合“双缓冲”或“多缓冲”,让DMA在CPU处理当前缓冲区的同时搬运下一块,让总线和CPU真正并行起来。这就像洗盘子和烘干:不要等洗完一个烘干一个,而是洗好一盘放一边,烘干机连续开,两边同时走,吞吐才最大化。
5. 实际选型策略:按访问模式和上下文切换代价来做决定
现在把前面的原理和实测数据转化成一套可以直接落地的选择思路。我不会给你一个“XX KB以上选DMA”的死命令,因为平台差异太大,但以下这套判断流程在各类嵌入式平台上都适用。
5.1 先问三个问题再动手
选拷贝方式前先回答:
这份数据接下来要交给CPU做计算吗?
- 要做:优先NEON,把“拷贝+计算”合并,减少内存往返。
- 不做:进入第二步。
数据量大吗?源/目的缓冲区有多大?
- 小于L2缓存容量且是普通内存搬运:直接用memcpy,优先保证代码简单。
- 远大于L2缓存容量,且拷贝频繁发生:进入第三步。
CPU在搬运期间有事可做吗?
- 有:用DMA,让CPU去处理别的任务,典型于多任务系统。
- 没有:比较延迟,多测几次memcpy和DMA的实际耗时,直接在延迟上比。
这三个问题的判断顺序,基本覆盖了90%的应用场景。
5.2 一个我常用的策略表格
| 场景举例 | 数据特点 | 推荐方案 | 原因 |
|---|---|---|---|
| 串口/SPI总线接收几百字节 | 小,<8KB | CPU中断拷贝或按需memcpy | DMA固定开销占比过高,中断处理本身就很短 |
| SD卡读取大文件到内存 | 大,几百KB-MB | DMA | 连续大块搬运,CPU可处理文件系统逻辑 |
| 视频帧格式转换 | 大且逐个像素计算 | NEON | “边搬边算”,减少读写内存次数 |
| 网卡收发包到用户态 | 中等到大,连续到达 | DMA描述符环+预取/映射 | 多队列DMA驱动成熟,CPU专注协议栈 |
| 配置文件/结构体赋值 | 极小,百字节内 | 普通赋值/memcpy | 零配置、无中断、无一致性问题 |
| 加解密或校验算法处理大数据块 | 大且运算密集 | NEON(配合硬件加速器) | SIMD块操作天然适合按块数学运算 |
值得一提的是,现在不少SoC还带有“内存到内存DMA引擎”,但启用这种引擎需要额外的驱动支持和地址转换。我自己的经验是:低成本平台的m2m DMA往往存在缓存一致性缺陷,驱动做好了收益才明显,做不好就是雷;如果数据量在几十KB到几百KB之间、CPU还有余力,不如优先用NEON优化memcpy之后的计算部分。
5.3 功耗与实时性的维度:别只看性能数字
拷贝方式不仅影响带宽和CPU占用,还影响功耗和实时性。
- 普通CPU拷贝会让CPU核保持在高频运行状态,功耗较高,但延迟确定。
- NEON拷贝在拷数据时也会拉高CPU负载,但因为执行时间短,整体能耗可能更低。
- DMA拷贝让CPU可以进入低功耗空闲状态,在物联网设备中非常关键;但DMA本身在搬运时需要打开DMA控制器和总线时钟,也有自身功耗。
实时性方面,DMA靠中断通知CPU,中断响应延迟是额外的。如果你在做一个硬实时系统,数据量又不算大,用CPU轮询或中断直接拷贝反而更容易预测行为。反之,在跑RTOS并要做低功耗管理的设备里,DMA的大块搬运+CPU深度休眠是省电利器。这块需要在项目早期就做功耗预算,否则后面想换已经是牵一发动全身。
5.4 实操建议:一个可复用的性能验证脚本
最后给一个通用的验证思路,帮助你在自己平台上快速得到结论。不需要复杂的工具链,只需一个定时器和一段能重复执行的拷贝例程。
- 分配两块足够大的缓冲区(建议4MB以上),首地址按64字节对齐。
- 分别用
memcpy、手写NEON循环、DMA搬运三种方式,从不同数据量开始(建议1KB、4KB、16KB、64KB、256KB、1MB),每个数据量重复10次,记录平均耗时。 - 分别测两个版本:主核空闲时的耗时,以及主核同时在跑一个计算密集任务的耗时。
- 把关键耗时列成表格,找到数据和DMA效率的交叉点。
- 再测一次“DMA搬运完成中断到数据可用的完整时延”,如果项目里CPU最终要读数据,结果中要包含这个延迟。
这一套下来,基本能在半天内干掉“到底用哪个”的纠结。我在不同项目里用这个方法得到过完全不同的结论:同样是64KB,一个平台DMA快,另一个平台memcpy快。所以真的不要迷信任何人的“标准答案”,包括我这篇。
6. 回到最初的问题:我会怎样“选”
我在实际项目里的取舍习惯是:如果拷贝量大而且CPU还有正经事要做,优先DMA;如果数据量大但CPU本来就要对每个字节做处理,优先NEON或SIMD;如果数据量小、延迟敏感,无脑memcpy。这个顺序说起来简单,难的是判断“大”和“小”、判断“有正经事”和“顺手处理”。
有过一次特别惨的教训:我在一个视频预览功能中想当然地把所有帧数据都交给DMA搬运,结果DMA通道争用导致高分辨率帧在高峰期排队,反而比原来的CPU拷贝产生更严重的帧延迟。排查后改为“大小帧分流”,小帧直接用memcpy,大帧才走DMA,整个系统流畅度立刻改善。这件事让我彻底接受了“选型必须结合数据特征”这个原则。
所以我在团队里一直强调:不要问“DMA快还是memcpy快”,要问“我的数据长什么样、CPU在拷贝期间要干嘛、我能接受多大的延迟”。这三个答案摆出来,选型其实是水到渠成的事。如果你现在正在纠结三选一,建议先拿一块数据、跑一遍上面第5.4的流程,你的平台会给你最诚实的答案。