4、内存管理与数据搬运:DMA双缓冲机制、SRAM与DDR分配策略、数据对齐与Cache一致性

内存管理,说白了就是让数据在正确的时间出现在正确的地方。在NPU+Cortex-M的异构架构里,数据搬运的效率和正确性直接决定了推理性能。我见过太多项目,模型精度没问题,但一跑起来就卡顿、花屏,甚至死机——十有八九是内存管理没做好。

这一章,我们聚焦四个核心问题:DMA怎么用才能不丢帧?SRAM和DDR怎么分?数据对齐为什么重要?Cache一致性怎么保证?

4.1 DMA双缓冲机制:让搬运和计算并行

先问一个问题:CPU去搬数据,NPU等着,这合理吗?当然不合理。DMA就是专门干搬运的硬件,CPU和NPU应该专心做计算。

单缓冲模式下,DMA搬完一帧,CPU处理,处理完再通知DMA搬下一帧。这中间有空闲等待。双缓冲(Ping-Pong Buffer)就是准备两个缓冲区:DMA往Buffer A搬数据时,CPU/NPU处理Buffer B里的数据;下一轮交换。

核心收益:搬运时间被“隐藏”在计算时间背后,系统吞吐量接近翻倍。

我在项目中遇到过一个问题:双缓冲切换时,如果DMA传输完成中断处理得太慢,CPU还没读完Buffer B,DMA已经把新数据写进来了——这叫“缓冲区踩踏”。

避坑指南:我曾经在STM32H7上调试双缓冲,发现偶尔图像撕裂。查了三天,原因是DMA中断优先级太低,被其他外设中断打断了。解决方案:把DMA完成中断设为最高优先级,并在中断服务函数里只做“交换指针”操作,不做数据处理。

双缓冲的典型配置流程:

  1. 申请两块连续内存,地址对齐到Cache Line大小(通常是32字节)。
  2. 配置DMA源地址、目的地址、传输长度,开启循环模式。
  3. 设置DMA完成中断,中断里交换当前活动缓冲区指针。
  4. 启动DMA,开始流水线作业。
// 伪代码示例:双缓冲初始化
#define BUF_SIZE 1024
uint8_t ping_buf[BUF_SIZE] __attribute__((aligned(32)));
uint8_t pong_buf[BUF_SIZE] __attribute__((aligned(32)));

volatile uint8_t *active_buf = ping_buf;
volatile uint8_t *process_buf = pong_buf;

void DMA_IRQHandler(void) {
    // 交换指针,不做数据拷贝
    volatile uint8_t *tmp = active_buf;
    active_buf = process_buf;
    process_buf = tmp;
    // 清除中断标志
    DMA_ClearFlag();
}

小技巧:如果DMA支持链表模式(如STM32的MDMA),可以预配置好两个描述符,中断里直接跳转,省去指针交换的开销。

4.2 SRAM与DDR分配策略:把热数据放在离NPU最近的地方

SRAM快但容量小,DDR慢但容量大。怎么分?我的经验是:按访问频率分。

数据类型 推荐存放位置 原因
模型权重(常量) DDR(或Flash) 只读,访问频率低,可批量加载
输入/输出特征图 SRAM(优先) 每层计算都要读写,延迟敏感
中间激活值 SRAM(如果放得下) 频繁读写,SRAM不够时用DDR+Cache
DMA描述符/链表 SRAM DMA每次传输都要访问,不能慢
日志/调试信息 DDR 不频繁,容量大

你想想看,如果把特征图放在DDR里,NPU每算一层都要通过总线去DDR读数据,延迟几十个时钟周期,性能直接腰斩。而SRAM的访问延迟只有几个周期。

一个实用的分配原则:把整个模型推理过程中“写一次、读多次”的数据放在SRAM;“写一次、读一次”或“几乎不读”的数据放在DDR。

我在一个视觉项目中,模型有5MB权重、2MB特征图。芯片只有512KB SRAM。怎么办?我的做法是:

  • SRAM里只放当前层和下一层的输入/输出特征图(约300KB)。
  • 权重放在DDR,通过DMA按层预取到SRAM的临时缓冲区。
  • 中间激活值用完即弃,不保留。

这样SRAM刚好够用,性能损失控制在15%以内。

4.3 数据对齐:不对齐的代价比你想象的大

为什么数据要对齐?因为NPU和DMA的硬件设计决定了它们一次能处理的数据宽度。比如NPU的SIMD单元一次读128位(16字节),如果数据地址不是16的倍数,硬件就要分两次读,再拼接——性能直接打对折。

更严重的是,有些NPU硬性要求输入数据地址必须对齐到某个边界,不对齐直接报错或产生异常。

我曾经踩过的坑:在某个国产NPU上,输入特征图的宽度是奇数,导致每行数据都不对齐。NPU跑起来没报错,但推理结果全是错的。查了两天,最后发现是数据对齐问题。解决方案:在分配缓冲区时,手动填充到对齐边界。

对齐规则总结:

  • DMA传输:源地址、目的地址、传输长度都要对齐到DMA总线宽度(通常是4字节或8字节)。
  • NPU输入/输出:对齐到NPU的向量宽度(常见16字节、32字节)。
  • Cache Line:对齐到32字节或64字节(取决于具体CPU)。
// 对齐分配示例
#include <stdlib.h>

// 分配对齐到32字节的内存
void* aligned_malloc(size_t size) {
    void *ptr = NULL;
    posix_memalign(&ptr, 32, size);
    return ptr;
}

// 或者手动对齐
uint8_t buf[1024 + 32];
uint8_t *aligned_buf = (uint8_t*)(((uint32_t)buf + 31) & ~31);

小技巧:在Cortex-M上,可以用 __attribute__((aligned(32))) 让编译器帮你对齐全局变量。动态分配建议用 memalign 或 pvPortMallocAligned(FreeRTOS)。

4.4 Cache一致性:看不见的数据陷阱

这是最容易被忽视的问题。CPU有Cache,NPU有本地SRAM,DMA直接访问物理内存。当CPU写了一个数据到内存,这个数据可能还在Cache里,没写回物理内存。DMA去搬“内存”里的数据,搬到的其实是旧数据——这就是Cache一致性问题。

反过来也一样:DMA把新数据写到了物理内存,但CPU的Cache里还存着旧数据,CPU读到的也是错的。

核心原则:在DMA传输之前,必须保证CPU Cache里的数据已经写回内存(Clean);在DMA传输之后,必须让CPU Cache失效(Invalidate),强制从内存重新加载。

Cortex-M系列(如M7/M55/M85)有SCB(系统控制块)寄存器,可以操作Cache:

// 以Cortex-M7为例
// DMA传输前:Clean Cache(将Cache数据写回内存)
SCB_CleanDCache_by_Addr((uint32_t*)buf, size);

// 启动DMA传输...

// DMA传输完成后:Invalidate Cache(丢弃Cache中的旧数据)
SCB_InvalidateDCache_by_Addr((uint32_t*)buf, size);

我见过一个团队,在M7上跑NPU推理,结果每次结果都不一样。排查到最后,发现是DMA搬完数据后没有Invalidate Cache,CPU读到的还是Cache里的旧特征图。加上Invalidate之后,问题立刻消失。

避坑指南:不要对整个Cache做Clean/Invalidate,那会浪费大量时间。一定要用 _by_Addr 系列函数,只操作DMA涉及的地址范围。另外,操作地址必须对齐到Cache Line大小(M7是32字节),否则操作无效。

还有一个容易被忽略的点:NPU自己的本地内存。有些NPU有独立的SRAM或TCM,CPU不能直接访问。这种情况下,数据搬运必须通过DMA或NPU自己的搬运引擎。搬运完成后,NPU内部会自动处理一致性,但CPU侧仍然需要Invalidate。

4.5 知识结构总览

下面这张图总结了本章的核心逻辑:从数据源头到NPU计算,中间经过DMA搬运、内存分配、对齐和一致性处理,每一步都有坑。

内存管理与数据搬运知识结构图 内存管理与数据搬运核心流程 数据源(DDR / Flash) DMA双缓冲搬运 Ping Buffer / Pong Buffer 中断交换指针,流水线作业 SRAM / DDR 分配策略 热数据→SRAM,冷数据→DDR 按访问频率分层分配 数据对齐 对齐到DMA/NPU/Cache Line宽度 Cache一致性 DMA前Clean / DMA后Invalidate 关键原则 让搬运和计算并行 热数据靠近NPU 地址对齐避免性能损失 Cache操作要精准 常见问题 缓冲区踩踏 SRAM溢出 未对齐导致异常 Cache数据不一致

嗯,到这里,内存管理的四个核心问题就讲完了。DMA双缓冲让搬运不阻塞,SRAM/DDR分配让数据放对位置,数据对齐让硬件跑得顺畅,Cache一致性让数据不出错。这四个点,任何一个出问题,NPU推理性能都会大打折扣。

我个人习惯在项目初期就把内存分配表画出来,标清楚每个缓冲区的地址、大小、对齐方式、Cache操作策略。这样后期调试能省很多时间。你也不妨试试。