4. 内存映射与SRAM布局分析:STM32内存架构与TFLite Micro内存调优

做嵌入式AI模型部署,说白了就是跟内存打交道。模型再小,跑在MCU上也得精打细算。我见过不少新手,模型量化好了,算子也选对了,结果一跑就崩——内存不够。所以这一章,咱们把STM32的内存家底翻个底朝天,再看看TFLite Micro是怎么在这片有限的空间里“螺蛳壳里做道场”的。

4.1 STM32内存架构:Flash / SRAM / CCM

STM32的内存不是铁板一块。以我常用的STM32F4系列为例,它的内存空间大致分三块:

区域 起始地址 大小(典型) 特点 用途
Flash 0x0800 0000 512KB ~ 2MB 非易失,读快写慢 存放代码、常量、模型权重
SRAM 0x2000 0000 128KB ~ 256KB 易失,读写快 运行时数据、堆栈、Tensor Arena
CCM 0x1000 0000 64KB 仅CPU可访问,极低延迟 关键变量、中断栈、紧急缓冲区

关键认知:Flash存模型权重,SRAM跑推理,CCM留给紧急数据。这三者的分配比例,直接决定了你能跑多大的模型。

我在项目里遇到过一个问题:模型权重放在Flash里,但推理时频繁读取Flash导致性能下降。后来我把部分中间结果缓存到CCM里,推理速度提升了将近20%。嗯,这就是内存布局优化的价值。

4.2 TFLite Micro默认内存分配器:MicroAllocator

TFLite Micro不依赖malloc/free,它自己搞了一套分配器——MicroAllocator。你想想看,在裸机或RTOS环境下,堆管理是不可靠的,碎片化问题能把人逼疯。MicroAllocator的做法很简单:

  • 预分配一大块内存,然后在这块内存里做线性分配。
  • 不释放(至少不主动释放),直到整个推理结束。
  • 分两种类型:持久内存(Persistent Memory)和临时缓冲区(Scratch Buffer)。

它的核心接口长这样:

// 创建分配器,传入内存起始地址和大小
MicroAllocator* allocator = MicroAllocator::Create(
    tensor_arena,          // 内存起始
    tensor_arena_size      // 内存大小
);

// 分配持久内存(生命周期贯穿整个推理)
TfLiteStatus status = allocator->AllocatePersistentBuffer(
    size,                  // 需要的大小
    &buffer_ptr            // 返回的指针
);

// 请求临时缓冲区(可复用)
TfLiteStatus status = allocator->RequestScratchBuffer(
    size,                  // 需要的大小
    &buffer_index          // 返回的索引
);

// 获取临时缓冲区指针
void* scratch = allocator->GetScratchBuffer(buffer_index);

我的习惯:在初始化阶段就把所有持久内存分配好,临时缓冲区只在算子执行期间使用。这样能最大程度避免内存碎片。

4.3 张量内存区域(Tensor Arena)划分

Tensor Arena是整个推理引擎的“主战场”。它被MicroAllocator划分为几个逻辑区域:

  1. 持久内存区(Persistent Memory):存放模型元数据、算子注册表、张量结构体等。这些数据从初始化到推理结束一直存在。
  2. 临时缓冲区区(Scratch Buffer Pool):每个算子可以申请一块临时空间,用于中间计算。算子执行完,这块空间可以被下一个算子复用。
  3. 张量数据区(Tensor Data):存放输入、输出、中间张量的实际数据。这部分是动态变化的,但分配策略仍然是线性分配。

我画了一张图,帮你理解这些区域的关系:

Tensor Arena 内存布局 持久内存区(Persistent Memory) 模型元数据 | 算子注册表 | 张量结构体 | 常量缓冲区 临时缓冲区区(Scratch Buffer Pool) 算子A临时空间 | 算子B临时空间 | 可复用区域 张量数据区(Tensor Data) 输入张量 | 中间张量 | 输出张量 注意:三个区域是连续分配的,总大小不能超过 Tensor Arena 的预设值

我曾经踩过的坑:把Tensor Arena设得太小,结果模型加载时直接报错“Arena allocation failed”。后来我算了一下,模型需要至少120KB的Tensor Arena,而我只给了80KB。记住:Tensor Arena的大小 = 持久内存 + 最大临时缓冲区 + 张量数据峰值。

4.4 Scratch Buffer与Persistent Memory

这两个概念是MicroAllocator的精髓。我刚开始接触时也搞混过,后来用一句话记住了:

Persistent Memory —— 从生到死都在,像你的身份证。
Scratch Buffer —— 用完就扔,像草稿纸。

具体区别看下表:

特性 Persistent Memory Scratch Buffer
生命周期 整个推理过程 单个算子执行期间
分配时机 初始化阶段 算子Prepare阶段
释放方式 不释放,直到重置 自动复用,无需手动释放
典型用途 模型权重指针、张量描述符 卷积中间结果、池化缓冲区
对齐要求 16字节对齐 16字节对齐

在实际项目中,我建议你这样分配:

// 假设总SRAM为256KB,分配给Tensor Arena 180KB
constexpr int kTensorArenaSize = 180 * 1024;
alignas(16) uint8_t tensor_arena[kTensorArenaSize];

// 初始化分配器
MicroAllocator* allocator = MicroAllocator::Create(
    tensor_arena, kTensorArenaSize
);

// 分配持久内存(用于模型解释器)
void* interpreter_buffer = nullptr;
allocator->AllocatePersistentBuffer(
    sizeof(MicroInterpreter), &interpreter_buffer
);

// 在算子中请求临时缓冲区(以卷积为例)
// 在 Conv::Prepare() 中调用
const int scratch_size = input_height * input_width * output_channels;
allocator->RequestScratchBuffer(scratch_size, &scratch_index);

优化技巧:如果模型中有多个算子,尽量让它们共享Scratch Buffer。比如卷积和池化不会同时执行,它们的临时空间可以重叠。MicroAllocator会自动处理复用,你只需要在Prepare阶段申请就行。

4.5 实战:计算你的Tensor Arena大小

好了,理论讲完了,咱们来点实际的。假设你要部署一个MobileNetV1(输入224x224x3,量化后int8),怎么算Tensor Arena?

  1. 持久内存:模型结构体、张量列表、算子列表 ≈ 10KB
  2. 最大临时缓冲区:卷积层中间结果 ≈ 56x56x256 ≈ 800KB(太大了!)
  3. 张量数据:输入 + 输出 + 中间层 ≈ 150KB

等等,800KB的临时缓冲区?STM32F4总共才192KB SRAM。怎么办?

这就是为什么我们要做内存复用。实际上,TFLite Micro的算子执行是串行的,同一时刻只有一个算子在运行。所以临时缓冲区只需要覆盖最大那个算子的需求。MobileNetV1的最大中间层是56x56x256,但我们可以通过深度可分离卷积的特性,把临时缓冲区降到56x56x1 ≈ 3KB。

最终估算:持久内存10KB + 临时缓冲区3KB + 张量数据150KB ≈ 163KB。嗯,刚好塞进192KB的SRAM里,还能留点空间给系统栈。

我个人习惯在项目初期就做一个这样的估算表,贴在工位上。每次改模型结构,先算一遍内存,省得后期抓狂。

这一章的内容就到这里。内存布局是TFLite Micro移植的基石,搞懂了它,后面的算子优化、性能调优才能有的放矢。记住:在MCU上,内存不是无限的,每一字节都要精打细算。