4、TensorFlow Lite Micro实战:框架介绍、模型转换流程、在MCU上部署一个简单的分类模型

好,咱们进入实战环节了。前面几章讲了那么多理论,说实话,不亲手跑一遍代码,总觉得心里没底。这一章我就带大家走一遍完整的流程——从TensorFlow Lite Micro框架是什么,到怎么把模型转成MCU能吃的格式,最后在开发板上跑一个分类模型。

我个人习惯是,先搞清楚工具链再动手。所以咱们先聊聊TFLM这个框架。

4.1 TensorFlow Lite Micro 框架介绍

TensorFlow Lite Micro,简称TFLM,是Google专门为微控制器设计的推理引擎。说白了,它就是TFLite的“瘦身版”。

为什么需要单独搞一个Micro版本?你想想看,普通的TFLite依赖标准C++库和操作系统,动不动就要几百KB的RAM。MCU上呢?可能总共就几十KB。所以TFLM做了几件事:

  • 去掉了动态内存分配——所有内存都在编译时确定
  • 精简了算子集——只保留最常用的操作,比如卷积、全连接、池化
  • 没有文件系统依赖——模型数据直接以C数组形式嵌入代码
  • 纯C++实现——不依赖操作系统,裸机也能跑

我在项目中遇到过一个问题:一开始用了完整的TFLite库,结果编译出来200多KB,MCU根本放不下。换成TFLM之后,直接降到30KB左右。嗯,差距就是这么大。

核心要点:TFLM的运行时环境只有几千行代码,非常适合资源受限的嵌入式设备。它支持ARM Cortex-M系列、ESP32、RISC-V等主流架构。

4.2 模型转换流程

咱们在PC上训练好的模型通常是Keras的.h5文件或者PyTorch的.pth文件。MCU不认识这些格式。所以需要经过一个转换流程。

整个流程分三步走:

  1. 训练模型——用Keras或PyTorch训练一个分类模型
  2. 转成TFLite格式——用TensorFlow的转换器生成.tflite文件
  3. 量化并生成C数组——把浮点模型转成int8,然后导出为C语言头文件

我给大家看一个具体的转换代码。假设我们有一个简单的二分类模型,输入是32x32的灰度图:

import tensorflow as tf

# 1. 加载训练好的Keras模型
model = tf.keras.models.load_model('my_model.h5')

# 2. 转换为TFLite格式
converter = tf.lite.TFLiteConverter.from_keras_model(model)

# 3. 设置量化——这一步很关键
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset  # 校准数据集
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8

tflite_model = converter.convert()

# 4. 保存为.tflite文件
with open('model_quantized.tflite', 'wb') as f:
    f.write(tflite_model)

# 5. 生成C数组
import binascii
with open('model_quantized.tflite', 'rb') as f:
    hex_data = binascii.hexlify(f.read()).decode('utf-8')
    c_array = 'unsigned char model_tflite[] = {\n'
    for i in range(0, len(hex_data), 2):
        c_array += '0x' + hex_data[i:i+2] + ', '
        if (i // 2) % 16 == 15:
            c_array += '\n'
    c_array += '};\n'
    c_array += 'unsigned int model_tflite_len = ' + str(len(hex_data)//2) + ';'

with open('model_data.h', 'w') as f:
    f.write(c_array)

避坑指南:我曾经在量化这一步吃过亏。如果不提供representative_dataset,转换器会使用默认的校准数据,导致量化后的模型精度暴跌。一定要用真实的验证集数据做校准,哪怕只有几十张图片也行。

量化为什么重要?说白了,MCU没有FPU(浮点运算单元)或者FPU性能很弱。用int8计算比float32快4倍以上,内存占用也减少75%。

4.3 在MCU上部署分类模型

好,模型文件已经转成C数组了。接下来就是把它烧到MCU里跑起来。

我以STM32F407为例,给大家演示部署流程。这个芯片有192KB RAM,跑一个简单的分类模型绰绰有余。

4.3.1 工程结构

一个典型的TFLM工程包含这些文件:

project/
├── src/
│   ├── main.cpp          # 主程序
│   ├── model_data.h      # 模型C数组
│   └── tensorflow/       # TFLM核心库
├── include/
│   └── tensorflow/       # 头文件
└── Makefile

4.3.2 推理代码实现

这是核心部分。我直接贴代码,大家跟着看:

#include "tensorflow/lite/micro/all_ops_resolver.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/system_setup.h"
#include "model_data.h"  // 包含模型数据

// 定义全局变量
constexpr int kTensorArenaSize = 50 * 1024;  // 50KB的Tensor Arena
uint8_t tensor_arena[kTensorArenaSize];

void setup() {
    // 初始化TFLM
    tflite::InitializeTarget();
    
    // 创建算子解析器
    static tflite::AllOpsResolver resolver;
    
    // 创建解释器
    static tflite::MicroInterpreter interpreter(
        tflite::GetModel(model_tflite),  // 模型数据
        resolver,
        tensor_arena,
        kTensorArenaSize
    );
    
    // 分配张量内存
    TfLiteStatus allocate_status = interpreter.AllocateTensors();
    if (allocate_status != kTfLiteOk) {
        // 内存分配失败处理
        while(1);
    }
    
    // 获取输入输出张量指针
    TfLiteTensor* input = interpreter.input(0);
    TfLiteTensor* output = interpreter.output(0);
}

void loop() {
    // 1. 准备输入数据(假设从ADC读取)
    int8_t* input_data = interpreter.input(0)->data.int8;
    for (int i = 0; i < 1024; i++) {  // 32x32=1024
        input_data[i] = read_adc_value();  // 读取传感器数据
    }
    
    // 2. 执行推理
    TfLiteStatus invoke_status = interpreter.Invoke();
    if (invoke_status != kTfLiteOk) {
        // 推理失败处理
        return;
    }
    
    // 3. 获取分类结果
    int8_t* output_data = interpreter.output(0)->data.int8;
    int predicted_class = 0;
    int8_t max_value = output_data[0];
    for (int i = 1; i < 2; i++) {  // 二分类
        if (output_data[i] > max_value) {
            max_value = output_data[i];
            predicted_class = i;
        }
    }
    
    // 4. 根据结果执行动作
    if (predicted_class == 0) {
        // 类别0:开灯
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
    } else {
        // 类别1:关灯
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
    }
    
    delay(100);  // 100ms推理一次
}

注意:Tensor Arena的大小要根据模型来定。我建议先设大一点,比如50KB,然后看实际用了多少。可以在代码里加一句printf("arena used: %d\n", interpreter.arena_used_bytes());来查看。用多少设多少,别浪费宝贵的RAM。

4.3.3 性能优化技巧

在实际项目中,我总结了几条经验:

  • 算子融合:把ReLU和卷积融合成一个算子,减少内存访问次数
  • 内存复用:TFLM内部已经做了张量复用,但输入输出缓冲区可以自己管理
  • 降低推理频率:不是每毫秒都需要推理,100ms一次对大多数场景足够了
  • 使用DMA传输数据:如果输入来自ADC或摄像头,用DMA可以减少CPU占用

我记得有一次做手势识别项目,推理一次要80ms,感觉太慢了。后来发现是输入数据拷贝花了大量时间。改成DMA传输后,推理时间降到了30ms。嗯,有时候瓶颈不在计算,而在数据搬运。

4.4 实战总结

这一章咱们走完了从模型转换到MCU部署的全流程。我再说几个关键点:

步骤 关键动作 常见坑
模型转换 量化到int8 忘记提供校准数据集
生成C数组 用binascii转hex 数组太大导致编译失败
部署推理 分配Tensor Arena Arena太小导致分配失败
结果处理 解析输出张量 量化后的输出需要反量化

最后说一句,TFLM虽然好用,但不是万能的。如果你的模型有自定义算子,或者网络结构特别复杂,可能得自己实现算子。不过对于大多数分类任务,TFLM内置的算子已经够用了。

下一章咱们会讲更高级的话题——如何在低功耗场景下做持续推理,以及如何用RTOS管理推理任务。到时候见。