2. TFLite Micro 模型基础:文件格式、解析流程与内存分配

好,咱们正式开始啃 TFLite Micro 这块硬骨头。这一章我打算把模型文件本身拆开来看——它到底长什么样?怎么被读进内存的?又是怎么在 STM32 那点可怜的 RAM 里跑起来的?

说白了,你拿到一个 .tflite 文件,不能直接往芯片里一丢就完事。你得知道它内部的结构,才能理解为什么有些模型能跑,有些模型一跑就崩。我刚开始接触 TFLite Micro 时,就踩过这个坑——模型转换成功了,部署到板子上直接 HardFault。后来才发现,是 Tensor Arena 大小没算对。

2.1 FlatBuffer:模型文件的“骨架”

TFLite 模型文件用的是 Google 的 FlatBuffer 格式。它不是 JSON,也不是 Protobuf,而是一种零反序列化开销的二进制格式。什么意思?就是你拿到文件后,不需要解析就能直接访问里面的数据。

核心优势: 内存映射(mmap)友好,解析速度极快,非常适合资源受限的嵌入式环境。

我个人的习惯是,拿到一个陌生的 .tflite 文件,先用 flatc 工具把它转成 JSON 看一眼结构。这样心里就有底了。

# 安装 flatc(FlatBuffer 编译器)
# 然后查看模型结构
flatc --json --strict-json --defaults-json schema.fbs -- model.tflite

一个典型的 TFLite 模型文件,内部结构大致如下:

层级 内容 说明
Model 版本号、描述、子图列表 最外层容器
SubGraph 张量列表、算子列表、输入/输出索引 实际的计算图
Tensors 形状、类型、量化参数、缓冲区索引 所有中间结果和参数
Operators 算子类型、输入/输出张量索引、内置选项 每个计算节点
Buffers 原始权重数据(二进制) 模型参数的实际存储

你看,结构其实很清晰。模型就是一个容器,里面装着子图,子图里挂着张量和算子。权重数据单独放在 Buffers 里,通过索引引用。

小技巧: 在调试模型时,我经常用 Netron 工具(netron.app)可视化 .tflite 文件。它能直接展示出张量形状、算子类型和连接关系,比看 JSON 直观得多。

2.2 模型解析流程:从文件到可执行图

模型文件在 PC 上是一堆二进制字节,到了 STM32 上,怎么变成能跑的计算图?这个过程我拆成三步来讲。

  1. 加载模型数据:把 .tflite 文件读到内存中,通常是一个 const unsigned char* 数组。
  2. 验证模型结构:调用 tflite::GetModel() 检查 FlatBuffer 的合法性,确保文件没有被截断或损坏。
  3. 构建解释器:通过 tflite::MicroInterpreter 解析模型,分配 Tensor Arena,注册算子。

嗯,这里要注意一点:GetModel() 只是做指针转换和基本校验,不会复制数据。真正的内存分配发生在创建 MicroInterpreter 的时候。

// 典型的模型加载流程
const unsigned char* model_data = ...; // 从外部 Flash 读取
const tflite::Model* model = tflite::GetModel(model_data);
TFLITE_CHECK_EQ(model->version(), TFLITE_SCHEMA_VERSION);

// 创建解释器
static tflite::MicroMutableOpResolver<10> resolver;
static tflite::MicroInterpreter interpreter(
    model, resolver, tensor_arena, kTensorArenaSize);
TFLITE_CHECK_EQ(interpreter.AllocateTensors(), kTfLiteOk);

我曾经在一个项目里,因为模型文件是从外部 SPI Flash 读取的,读取时字节序没处理好,导致 GetModel() 返回了空指针。排查了半天才发现是 SPI 读取的字节顺序和 FlatBuffer 期望的不一致。所以,数据完整性校验一定要做。

2.3 Tensor Arena:内存的“战场”

Tensor Arena 是 TFLite Micro 最核心的内存区域。所有张量的数据、中间计算结果、甚至部分算子的临时缓冲区,都从这里分配。

说白了,它就是一块你提前声明好的大数组。解释器在里面做内存管理,不依赖 malloc/free。

重要: Tensor Arena 的大小直接影响模型能否成功运行。太小了,AllocateTensors() 会失败;太大了,浪费宝贵的 RAM。

我一般这样估算 Arena 大小:

  • 固定开销:解释器本身、算子解析器、张量元数据等,约 2-4 KB。
  • 张量数据:所有张量的总大小(输入 + 输出 + 中间张量),按 float32 或 int8 计算。
  • 临时缓冲区:某些算子(如 CONV、DEPTHWISE_CONV)需要额外的 scratch buffer,通常是输入张量大小的 1-2 倍。
  • 对齐填充:ARM Cortex-M 要求 8 字节对齐,预留 100-200 字节的余量。

举个例子,一个 MobileNetV1 量化模型(输入 96x96x3),我算出来的 Arena 需求大约是:

项目 大小(KB)
固定开销 3
张量数据 120
临时缓冲区 40
对齐余量 1
总计 164

实际项目中,我建议先设一个较大的值(比如 200 KB),跑通后再逐步缩小,找到临界点。

2.4 内存分配器:谁管着这块“战场”

TFLite Micro 内部使用一个简单的线性分配器(Linear Allocator)。它不像标准 C 库的 malloc 那样支持任意释放和碎片整理,而是按顺序分配,用完就丢。

为什么这么设计?

  • 确定性:分配时间固定,没有碎片问题。
  • 低开销:只需要一个指针记录当前位置。
  • 适合推理场景:模型推理是一次性的前向传播,不需要动态创建和销毁张量。

分配器的核心逻辑其实很简单:

// 简化版线性分配器
class LinearAllocator {
  uint8_t* buffer;
  size_t capacity;
  size_t head;  // 当前分配位置

  void* Allocate(size_t size, size_t alignment) {
    // 对齐
    size_t aligned_head = (head + alignment - 1) & ~(alignment - 1);
    if (aligned_head + size > capacity) return nullptr; // 溢出
    void* ptr = buffer + aligned_head;
    head = aligned_head + size;
    return ptr;
  }

  void Reset() { head = 0; } // 重置,用于下一次推理
};

你看,就这么几行代码。但正是这个简单的分配器,支撑起了整个 TFLite Micro 的内存管理。

避坑指南: 我曾经在 STM32F746 上移植时,因为 Tensor Arena 没有做 8 字节对齐,导致 AllocateTensors() 返回了 kTfLiteError。后来在声明数组时加了 __attribute__((aligned(8))) 就解决了。

2.5 整体流程可视化

下面这张图,我把从模型文件到推理执行的整体流程画了出来。你可以对照着理解每个环节的内存变化。

TFLite Micro 模型加载与内存分配流程 .tflite 模型文件 tflite::GetModel() MicroInterpreter 创建 Tensor Arena Op Resolver AllocateTensors() Invoke() 推理 线性分配器管理内存 不支持 free,只支持 Reset

从这张图你可以看到,整个流程是线性的。模型文件经过 GetModel() 校验后,交给 MicroInterpreter,后者结合 Tensor Arena 和 Op Resolver 完成内存分配,最后才能执行推理。

嗯,这里有个关键点:Tensor Arena 必须在调用 AllocateTensors() 之前就准备好。而且一旦分配完成,就不能再修改 Arena 大小了。如果你想换一个更大的模型,得重新编译固件。

实战建议: 在项目初期,我通常会在代码里加一个 interpreter.arena_used_bytes() 的打印,看看实际用了多少内存。然后根据这个值,把 Arena 大小调整到“够用 + 10% 余量”的水平。