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划分为几个逻辑区域:
- 持久内存区(Persistent Memory):存放模型元数据、算子注册表、张量结构体等。这些数据从初始化到推理结束一直存在。
- 临时缓冲区区(Scratch Buffer Pool):每个算子可以申请一块临时空间,用于中间计算。算子执行完,这块空间可以被下一个算子复用。
- 张量数据区(Tensor Data):存放输入、输出、中间张量的实际数据。这部分是动态变化的,但分配策略仍然是线性分配。
我画了一张图,帮你理解这些区域的关系:
我曾经踩过的坑:把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?
- 持久内存:模型结构体、张量列表、算子列表 ≈ 10KB
- 最大临时缓冲区:卷积层中间结果 ≈ 56x56x256 ≈ 800KB(太大了!)
- 张量数据:输入 + 输出 + 中间层 ≈ 150KB
等等,800KB的临时缓冲区?STM32F4总共才192KB SRAM。怎么办?
这就是为什么我们要做内存复用。实际上,TFLite Micro的算子执行是串行的,同一时刻只有一个算子在运行。所以临时缓冲区只需要覆盖最大那个算子的需求。MobileNetV1的最大中间层是56x56x256,但我们可以通过深度可分离卷积的特性,把临时缓冲区降到56x56x1 ≈ 3KB。
最终估算:持久内存10KB + 临时缓冲区3KB + 张量数据150KB ≈ 163KB。嗯,刚好塞进192KB的SRAM里,还能留点空间给系统栈。
我个人习惯在项目初期就做一个这样的估算表,贴在工位上。每次改模型结构,先算一遍内存,省得后期抓狂。
这一章的内容就到这里。内存布局是TFLite Micro移植的基石,搞懂了它,后面的算子优化、性能调优才能有的放矢。记住:在MCU上,内存不是无限的,每一字节都要精打细算。