ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式数据搬运选型:DMA、NEON与CPU拷贝底层逻辑与实战

嵌入式数据搬运选型:DMA、NEON与CPU拷贝底层逻辑与实战 写这份分享时我刚在板子上调完一版 DMA 环形缓冲区顺手把总线上的数据搬运负载从 CPU 手里彻底剥了下来。说实话在嵌入式或者驱动开发里“拷贝数据”这件事看起来基础但对性能的影响往往是一票否决级的。你到底是该无脑 memcpy还是上 DMA或者干脆用 NEON 向量指令暴力搬运我见过太多人在这个问题上凭感觉选结果要么 CPU 占用率被打满要么数据一致性踩坑。所以今天这篇就专门来盘一盘 DMA、NEON、CPU 这三种拷贝方式的底层逻辑、适用场景和实战选型思路。这篇文章适合正在做驱动开发、嵌入式音视频处理、网络收发逻辑优化以及任何被“数据搬运太慢”困扰的朋友。1. 三种拷贝方式底层到底在干什么选型之前先把三者的本质差异搞清楚。很多人以为 DMA 是“更快的拷贝”NEON 是“更快的拷贝”CPU 普通拷贝是“更慢的拷贝”这个认知大方向没错但底层机制完全不同直接决定了它们在不同场景下的表现。1.1 CPU 普通拷贝的本质CPU 拷贝最常见的就是 memcpy本质上就是让处理器内核直接参与数据搬运。具体流程是CPU 从源地址加载数据到寄存器再从寄存器写回目标地址。这个过程完全由 CPU 的流水线驱动走的也是 CPU 的 Load/Store 单元。这里有个关键点容易被忽略CPU 拷贝的速度受限于主频、内存带宽和缓存命中率。如果数据在 Cache 里热着memcpy 可以非常快如果数据在 DDR 里冷着CPU 就得一路等内存总线这时性能会严重下滑。而且CPU 在搬运过程中没法干别的活执行流水线被占住这就是“阻塞”的本质。还有一个不太好察觉的细节现代 CPU 的 memcpy 实际上会利用 SIMD 指令集。编译器在处理大数据块时会自动向量化一次拷贝 16 字节甚至 32 字节而不是逐字节搬。所以单纯从“指令效率”来看CPU 的 memcpy 已经做了不少优化。但在嵌入式领域尤其是 Cortex-A 系列这样的应用处理器上CPU 拷贝的核心瓶颈不在指令效率而在于它占用了 CPU 的执行资源。你在 memcpy 大数据块的时候中断响应延迟会明显变差实时性受影响。1.2 NEON 拷贝的本质差异NEON 是 ARM 平台的 SIMD 指令集它的思路和 CPU 普通拷贝有很大区别它的定位是“用更宽的寄存器、并行处理更多数据”本质上还是 CPU 在搬但它用的是 NEON 单元而非普通 Load/Store 单元。举个例子Cortex-A7 的 NEON 寄存器是 128 位宽的Q0-Q15一次 vld1q 指令能从内存一次性加载 16 字节vst1q 再一次性写回。这比普通寄存器 32 位宽一次 4 字节要高效得多。NEON 拷贝的典型写法是循环展开 预取把内存访问延迟压到最低。NEON 在拷贝上的优势有两个维度一是数据并行度二是 cache 友好性。你可以用 pld 指令把下一块数据提前预取到 cache配合 vld1q/vst1q 流水线批量搬运实测在大部分 ARM 平台上NEON 拷贝带宽是普通 memcpy 的 1.5 到 2 倍。但注意NEON 的优势仅限于“数据在内存和寄存器之间搬运”这个场景它并没有绕开 CPU 核心所以它依然会占 CPU 资源。另一个值得提的点是 NEON 对数据对齐的要求。vld1q/vst1q 要求 16 字节对齐时效率最高未对齐时虽然也能工作但会触发额外的内存访问。这也是为什么很多高性能代码里你会看到“先按字节把头尾修的干干净净再用 NEON 刷中间大块”这种典型写法。后面讲实操时我会给出具体的对齐处理思路。1.3 DMA 的本质把数据搬运变成外设逻辑DMA 和 CPU 拷贝、NEON 拷贝有本质区别它压根不用处理器核心参与。它的全称是 Direct Memory Access直接内存访问通过一个独立的总线控制器在源地址和目标地址之间传输数据传输完成后通过中断或事件通知 CPU。整个过程中CPU 只需要做两件事启动时配置好 DMA 的描述符包括源地址、目标地址、传输长度、突发大小传输完成时处理中断。中间的大块时间CPU 完全解放出来。这就是 DMA 最大的价值。但要注意DMA 的出现不是为了“更快”。它的性能并不一定高于 NEON 或 CPU 拷贝尤其是对于小数据块DMA 的配置开销可能比本身传输时间还长。DMA 的优势是“不占用 CPU”而不是“传输速度快”。这也直接影响了选型策略大批量、持续性的数据搬运优先 DMA小批量、偶尔一次性的数据搬运CPU 或 NEON 反而更划算。2. 不同场景下怎么选这才是真正的核心问题理论上理解了三种方式后选型逻辑才真正落地。我的实际经验是选型不只看数据量还得看处理频率、数据特点、实时性要求甚至看你手里的硬件平台支持什么特性。这里我把实战中最常遇到的场景逐一拆开讲。2.1 大数据块搬运优选 DMA但要处理对齐和描述符先说最经典的大批量场景从网卡 NIC 收包到内存缓冲区从 ADC 采集 FIFO 搬到内存或者从摄像头传感器把一帧 YUV 数据搬到内存。这类数据传输的特点是单次数据量大可能是几 KB 甚至几 MB而且通常是周期性发生的。在这个场景下DMA 是绝对的主力。原因也很直白用 CPU 或 NEON 搬一个大块的同时整个 CPU 都沉浸在搬运中而 DMA 可以把 CPU 释放出来去做更紧急的事比如协议解析、状态机处理、用户态调度。你说 NEON 更快确实更快但在驱动层跑 NEON 拷贝很容易导致中断处理时间过长触发高层 watchdog。不过用 DMA 也有几个细节要注意。首先是内存类型。DMA 使用的缓冲区必须是 cache 一致性的或者你要手动做 cache 无效化/clean 操作。否则会出现 DMA 已经把数据写进内存了CPU 却从 cache 里读到旧数据的经典 bug。在多核 CPU 上这个问题更容易踩雷因为你甚至无法预判 cache line 被哪个核加载。其次是 DMA 描述符的管理。有些平台的 DMA 驱动是简单的寄存器模式一次配置一个块传输有些则支持描述符链表比如 Zynq 的 SG-DMA可以把多个不连续的内存块串成一个链表逐个搬运。后者在处理网络报文分片时特别好用。但带来的复杂度也直线上升你要小心描述符的回写状态、半传输中断、传输完成中断的时序。2.2 小批量高频拷贝NEON 是甜点区DMA 反而累赘很多人上来就想用 DMA 优化一切但小数据量场景下 DMA 的成本是完全不划算的。举个例子一个 64 字节的控制结构体从内核态拷贝到用户态DMA 做什么你要分配 DMA buffer、配置描述符、开启通道、等待中断——这一套动作的延迟可能就得几微秒而 64 字节用 NEON 四个 vld1q four vst1q 指令纳秒级完成。这种情况下 DMA 就是杀鸡用牛刀而且刀还没磨好。我建议的分界线大致是 256 字节以下走 NEON 或普通 CPU 拷贝256 到 1KB 之间看场景灵活切换1KB 以上才认真考虑 DMA。这个阈值不是严格的黄金比例但它是基于“DMA 固定开销 中断延迟”和“CPU 逐字节搬运成本”交叉计算出来的经验值。NEON 在做小批量拷贝时还有个底层优势它的预取指令可以让“下一个块”的数据提前进入 cache。如果你在一个循环里反复拷贝多个小块数据NEON 的小块拷贝速度会特别稳定。但要注意NEON 拷贝虽然不调操作系统 API但你得自己保证内存对齐。我的做法是在每个拷贝入口做一个指针对齐检查未对齐的先用普通拷贝把首部修正再把中间的大块交给 NEON。2.3 缓存一致性问题DMA 和 Cache 的爱恨纠葛这一节必须单独拿出来讲因为这是区分“能跑”和“能稳定上线”的关键。简单解释一下什么叫 cache 一致性问题。假设你有一个 DMA 缓冲区它是一块普通的内存CPU 和 DMA 控制器都可以访问它。CPU 读数据时优先查 cache如果数据在 cache 里命中就直接用 cache 里的副本不会去访问内存。现在 DMA 控制器从外设把数据写进了这块内存新的数据已经在内存里了但 cache 里还是旧数据。CPU 再读的时候命中 cache读到的是旧数据——bug 出现了。解决这个问题的思路有三个其一将 DMA 缓冲区配置为 non-cacheable也就是不走 cache每次 CPU 访问都直接打到内存。这种方法最简单但性能会下降CPU 在上面频繁读写的开销翻倍。其二在 CPU 读 DMA 数据前显式地执行 cache invalidate 操作告诉 cache “老数据作废”在 CPU 写 DMA 数据前执行 cache clean 操作把数据刷到内存。这种方法是性能与正确性的平衡点也是驱动开发中最常见的做法。其三使用硬件自动维护 cache 一致性的机制比如 ARM 的 DMA 设备带 inner/outer shareable 属性或者用 IOMMU/SMMU 做地址重映射。这类方案在复杂 SoC 上越来越普及但在很多嵌入式平台上并没有完整实现所以不能拍脑袋依赖它。我在一轮实际调试中的体会是cache 问题在单核简单 DMA 场景下还比较可控真正常常翻车的是多核 CPU 上的 DMA 缓冲。因为我没办法预判某个 cache line 是哪个核加载的只有严格按“DMA 写 → invalidate → CPU 读”、“CPU 写 → clean → DMA 读”的规范来才能真正避免那种“偶发性数据错误”——这种 bug 是最难查的因为它不是必现而是偶尔出现一次复现周期可能是一小时甚至一天。3. 实操串口 DMA 接收不定长数据的完整实现这一节我挑一个大家都熟悉的场景来完整走一遍串口 DMA 接收不定长数据。这可能是 DMA 教程里点击率最高的一类需求因为串口通讯在嵌入式开发中太常用了而 DMA 处理不定长数据的核心技巧就是“空闲中断 DMA 环形缓冲”。3.1 环境与基础原理以 STM32F103 为例或者扩大到任何带 UART DMA 的 MCU。核心思路是把串口的 RX DMA 配置为循环模式让 DMA 在内存里一圈一圈地搬运数据不关心什么时候收到一帧风的数据。那怎么判断一‘帧’数据结束呢就需要用到串口的空闲中断IDLE当总线上没有新数据进来时串口硬件会触发一个空闲中断这时候 CPU 去读 DMA 当前传输计数就能算出本轮收到了多少字节。这是串口 DMA 接收不定长数据的经典套路。它的好处是接收过程全程 DMA 搬运CPU 几乎不参与只有在一帧数据接收完成后CPU 才去处理一次。这就把一个高频的“每个字节中断一次”变成了“每一帧中断一次”CPU 负载大幅下降。3.2 配置步骤和关键代码以 HAL 库为例初始化时开 UART 的 DMA 接收为循环模式再使能 IDLE 中断。下面给出一个典型的初始化片段// 假设 UART_Handle 已经初始化好 // 配置 DMA RX 缓冲区和长度 #define RX_BUF_SIZE 1024 uint8_t rx_buf[RX_BUF_SIZE]; // 开启 UART DMA 接收循环模式 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); // 使能 UART 的 IDLE 中断注意要直接操作寄存器 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);然后写一个中断回调处理函数这里有个技巧HAL 库的 UART 中断处理函数会把 IDLE 中断吞掉所以你得先调用它的处理函数再自己判断标志位void UART_IDLE_Callback(UART_HandleTypeDef *huart) { if (huart huart1) { // 读取当前 DMA 剩余计数 uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 当前 DMA 总共要搬 RX_BUF_SIZE 字节所以已接收长度 uint16_t recv_len RX_BUF_SIZE - remain; // 这里 recv_len 就是本轮接收到的完整数据长度 // 接下来就可以把 rx_buf 中的数据提交给协议栈或处理函数 process_recv_data(rx_buf, recv_len); // 关键步骤重启下一次 DMA 接收 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); } }当然你需要在串口中断服务函数里手动调用这个回调因为 HAL 库不会自动调它。具体做法是在UART_IRQHandler里判断 IDLE 标志并清除然后调用上面的处理函数。3.3 环形缓冲区的进阶处理上面的例子是最简单的单缓冲区版本实际项目里我更推荐把 RX 缓冲区做成环形。也就是说 DMA 始终在一个固定大小的数组里循环搬运每次收到一帧数据时帧的起止位置不一定在数组头部而是在数组中间旋转。处理思路是通过 DMA 当前计数计算读写偏移量然后按“先拷贝尾部再拷贝头部”的方式把完整帧拼接出来。环形缓冲区的核心代码逻辑大致如下// 环形缓冲区读写指针 volatile uint16_t rx_head 0; // DMA 当前写入到的位置 uint16_t rx_tail 0; // 应用层已经读到的位置 void UART_IDLE_Callback(UART_HandleTypeDef *huart) { uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t current_pos RX_BUF_SIZE - remain; if (current_pos rx_tail) { // 数据没有跨越缓冲区尾部直接分段拷贝 handle_frame(rx_buf[rx_tail], current_pos - rx_tail); } else { // 数据跨越缓冲区尾部需要分两次拷贝 uint16_t first_part RX_BUF_SIZE - rx_tail; handle_frame(rx_buf[rx_tail], first_part); handle_frame(rx_buf[0], current_pos); } rx_tail current_pos; }这个环形方案的优点是不用频繁停止和重启 DMA数据始终在流入丢数据的概率更小。缺点是代码逻辑要处理边缘情况比如 DMA 写指针绕回时和读指针相等要判断缓冲区是空还是满这个标签位判断一定要小心否则会复现那种“看起来偶发丢数据”的诡异 bug。4. DMA 性能实测与参数选择解析聊完原理和选型很多人会问DMA 到底能快多少NEON 相比 memcpy 有没有质的提升这里我用自己的板子实测的数据来给一个直观的参照。同时也会讲到 DMA 驱动里两个很关键、却经常被忽略的参数burst size 和 alignment。4.1 实测数据CPU vs NEON vs DMA我基于一块常见的 ARM 开发板主频 1.2GHzDDR3 内存分别测试了 4 种拷贝方式在不同数据量下的吞吐量。测试方法很简单循环拷贝 1000 次记总时间除以总字节数得到带宽。数据源是 16 字节对齐的内存块排除首末处理干扰。拷贝方式32B256B4KB1MBCPU memcpy0.9 GB/s1.8 GB/s2.1 GB/s2.2 GB/sNEON 优化拷贝1.5 GB/s3.2 GB/s3.8 GB/s3.7 GB/sDMA含中断开销0.05 GB/s0.3 GB/s1.5 GB/s2.8 GB/s这个数据很能说明问题。在 32 字节的小数据块场景下DMA 的速度慢到令人发指因为配置描述符、启动通道、等待中断这套流程的固定开销远大于数据本身搬运的时间。而 NEON 因为向量并行小数据块也能跑出不错的速度。在 4KB 这个档位DMA 开始接近 CPU 的带宽但仍未超越。到 1MB 级别DMA 才真正展现出吞吐优势加上它能做到 CPU 零占用综合价值就远高于另外两种了。所以别再迷信“DMA 快”这个说法。DMA 的价值是“解放 CPU”它的吞吐优势通常要在较大的数据块下才能体现。4.2 Burst Size 的选择与对齐策略DMA 配置里有个参数叫 Burst Size即每次总线事务突发访问的次数。假设系统总线宽度是 64 位burst size 为 4 意味着 DMA 会一次性连续读取 4 个 64 位数据也就是 32 字节连续访问。这个参数极大地影响 DMA 的效率。理论上 burst 越大总线利用效率越高因为它减少了地址阶段的切换开销。但 burst 太大有一个副作用当 DMA 与 CPU 同时访问内存总线时DMA 会对总线进行较长时间的“占用”导致 CPU 访问内存的延迟飙升影响整体性能。所以 burst size 并不是越大越好。我的建议是从小到大测试在典型负载下逐一测试 burst size 1、2、4、8选吞吐量和企业性能的平衡点。如果你的板子在 DMA 搬运时 CPU 表现明显卡顿优先减小 burst size。对齐策略则是另一个关键点。DMA 控制器通常要求源地址和目标地址满足一定对齐要求常见的有 4、8、16、32 字节对齐。如果你的 DMA 源数据是串口收来的字节流天然没有对齐概念那就要在 DMA 描述符中配置允许非对齐访问或者显式地把数据搬到一个对齐缓冲区再进行 DMA。否则硬件会报错或产生未知行为。NEON 拷贝也是一样的逻辑只不过它自身对齐的要求是 16 字节宽只要源和目的地址都是 16 字节对齐NEON 就能以最高效率运行。如果源地址是奇数偏移你只能用 vld1q_u8 之类的非对齐加载指令性能有所下降但不至于出错。这也是为什么我在做图像处理时会先把图像数据的行地址统一 align 到 16 字节的原因。5. 实战中常见的坑与排查技巧现在进入“坑位预警”环节。这三种拷贝方式带来的问题通常都不是“性能不够”而是“偶尔出错”“偶发卡死”“数据不对”。下面这些案例都是我自己或周围同事实际踩过的把症状、原因、解决方案列出来大家可以对照排查。5.1 DMA 数据旧值问题cache 一致性彻底翻车有次在调试一个网络驱动客户端收包的时候偶尔会出现相同的一包数据被重复上报。排查了很久发现不是协议问题而是 DMA 的 cache 一致性问题DMA 把新数据写进了内存但 CPU 读取时从 cache 读到的是上一次的旧数据。这种情况特别容易在“DMA 接收缓存刚好命中 CPU 之前读过的 cache line”时出现因为 CPU 对这块内存是有缓存的它觉得自己读的是最新数据实际却是旧的。解决方法是在任何“DMA 写完数据之后CPU 读取该缓冲区之前”显式调用 cache invalidate API。Cortex-A 平台通常有dma_map_single或dma_sync_single_for_device这样的接口驱动层直接调用即可。对裸机开发可以查 ARM 架构手册中的DC IVAC指令手动 invalidate。关键点在于这个动作不能在 DMA 启动前一次性做完后就万事大吉必须在每一次 DMA 传输完成后、CPU 读数据前执行否则就会出现“偶尔”的数据错乱。5.2 小数据块 DMA 反而把系统拖垮有人把 DMA 用在频率极高但数据量很小的场景比如每 100 微秒传输 32 字节的状态信息。表面看 DMA 在搬运这 32 字节但底层它在每 100 微秒就要触发一次中断CPU 被中断子程序包围。虽然中断处理很短但高频中断对流水线预取、cache 命中率的破坏是巨大的。看 CPU 占用率可能不高但系统实时性严重恶化。这种情况我的经验是直接改用 NEON 或普通 memcpy。32 字节的搬运是百纳秒级别DMA 的中断开销却是微秒级别怎么算都不划算。还有一个更隐蔽的坑DMA 中断服务函数里的处理逻辑如果太复杂会导致下一次 DMA 中断被延迟处理如果 DMA 通道没有缓冲能力就可能丢数据。所以 DMA 中断服务函数里要遵循“快进快出”原则只做必要的数据指针更新和计数器维护真正的协议解析放到主循环或高优先级任务里。5.3 NEON 拷贝在裸机上的数据 ABORTNEON 指令本身不难难在它的使用环境。如果你在一个不带 NEON 单元的老旧处理器上执行 NEON 指令或者操作系统的 NEON 上下文保存与恢复机制没有正确注册就会触发 Undefined Instruction 异常。在 Linux 内核模块中使用 NEON 指令时要特别小心因为内核不保证 NEON 寄存器在上下文切换时被保存你需要先调用kernel_neon_begin()再执行 NEON 代码结束后调用kernel_neon_end()。裸机环境下相对简单你只需要确认编译选项里打开了-mfpuneon链接时也要有对应的浮点库支持。否则即使代码写对了编译出来的二进制在真正执行时也会异常。另一个容易踩的坑是指令集兼容性Cortex-A53 支持 NEON但某些入门级的 Cortex-M0/M0 根本不支持。所以在项目早期要明确目标芯片的指令集范围别把 NEON 拷贝代码写进一个会被编译到 M0 内核的驱动文件里那是灾难性的自找麻烦。6. 我自己的一套决策心法讲了这么多原理和坑最后把这个选型思路凝练成一套简单的判断流程方便大家在实际项目中快速决策。先问三个问题数据块多大传输频率多高CPU 是否有更紧急的任务如果数据块小于 256 字节或者传输频率极高但每份数据只有几个字节直接放弃 DMA选择 NEON 或 memcpy如果数据块大于 1KB并且传输频率合理DMA 是第一选择如果数据量在中间地带就需要进一步评估当前 CPU 负载是否吃紧如果吃紧即使单次传输不太大也倾向于 DMA毕竟任何 CPU 拷贝的带宽消耗都会占用核心执行资源如果 CPU 还很闲那 NEON 的简单高效就足够了。考虑数据特点。如果源数据和目标数据在内存里天然是分散的比如多段不连续的内存SG-DMA 的链表模式非常适合如果数据是连续的CPU 和 NEON 都可以简单处理。考虑实时性。如果系统对中断有极严格的要求比如中断必须在某个硬期限内响应那 DMA 是唯一选择因为它在搬运大块数据时不会阻塞 CPU。反之如果中断的抖动不太敏感NEON 反而简单稳定。这套心法的本质就是一句话DMA 的价值不在“更快”而在“不占 CPU”NEON 的价值在“快而便宜”CPU memcpy 的价值在“简单可靠”。三者的分工非常清晰没有谁完全覆盖谁。你只需要识别出自己的瓶颈到底在吞吐还是 CPU 占用答案往往就自己浮现了。写到这里想起踩过的不少坑其实大多不是原理不懂而是没把“为什么选它”想明白。抱着“DMA 一定比 CPU 快”的想法去设计几个迭代之后就会撞上 cache 一致性、中断延迟、描述符管理这些墙。反过来如果你带着“让合适的工具处理合适规模的数据”的思路去选型大部分情况下都能少走很多弯路。最后再分享一个小技巧把所有拷贝路径做成一个统一的抽象接口内部根据长度自动选择 memcpy、NEON 或 DMA这样在项目初期不管你的选择是否精准后续调整都只需要改一处代码不用把外层逻辑全部推翻。我就是靠着这个抽象层在一周之内把整个通信驱动的效率提了一倍也没有引入新的难查 bug。希望这篇文章能帮你少掉几根头发。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进