简介:一份为微控制器量身定制的MicroPython固件工程,面向嵌入式开发者与AI边缘计算爱好者,目标是在ESP32等MCU上集成TensorFlow Lite与ulab,让开发者直接用Python开展轻量级神经网络实验。工程基于USER_C_MODULES机制扩展,包含microlite、ulab及面向person_detection示例的modcamera等模块,涉及C、Python、CMake、Shell等多种源码,并附带sdkconfig分区配置与tflite模型文件。压缩包共205个文件,约6.11MB,以.c/.h源码、.py脚本、.md说明、.txt配置和.sh构建脚本为主,同时包含csv数据、wav音频、json及board配置等辅助资源;person_detection示例还提供了模型与音频前端处理代码,可对照分析。目录结构清晰,便于按模块深入研读。已有684人学习浏览,适合希望在真实硬件上体验TFLite Micro部署、理解MicroPython底层扩展机制的读者,从固件编译到示例运行均可获得完整参考。
1. 定制 MicroPython 固件不是把 TFLite 硬塞进去
在 ESP32-S3 上做一个 10 类手势识别,最难的一步往往不是训练,而是“把模型放进板子”。直接刷官方 MicroPython 固件,解释器里没有 TensorFlow Lite;从 C 语言重写整套推理管线,预处理里的灰度转换、归一化、张量 reshape 又要自己维护。标题里这条路的真正价值是:把 TensorFlow Lite for Microcontrollers(TFLM)和 ulab 当作固件的一部分,在编译期绑进去,再用 MicroPython 脚本只负责数据流。ulab 提供张量形状和数值计算,TFLM 提供算子执行,脚本层组合这两者。适合正在评估 ESP32-S3、RP2040、STM32F746 这几类支持 MicroPython 的单片机上做分类、关键词识别、异常检测的工程师。下面按我实际编译这套固件时走过的路径来讲。
2. TFLM、ulab 与 tensor 数据通路的分工
2.1 为什么是 TFLM:一套可裁剪的推理引擎
TensorFlow Lite 的完整运行时依赖 POSIX 文件系统、动态内存分配和较多 C++ 标准库能力,微控制器上跑不动。TFLM(TensorFlow Lite for Microcontrollers)是它的裸机子集,删掉了文件 IO、多线程和大部分算子,换成静态编译、无异常、无 RTTI 的实现。
常见做法是把 TFLM 源码直接编进固件,与 MicroPython 解释器同在一个镜像里。这不只是“链接一个库”,还意味着解释器能够直接调用 C++ 对象的方法,不需要 FFI 或操作系统层面的进程间通信。TFLM 的运行不依赖任何系统调用,因此它和 MicroPython 的关系是平级的:Python 脚本通过自定义 module 触发Invoke(),实质是在当前线程里跑一段同步的 C++ 计算。
2.2 算子的体积账:MicroMutableOpResolver 决定固件大小
TFLM 最容易被忽略的约束是算子注册。完整 TFLite 有上百个算子,TFLM 只提供其中的一部分,并且在 C++ 端用MicroMutableOpResolver做显式注册。
static tflite::MicroMutableOpResolver<10> resolver; resolver.AddConv2D(); resolver.AddDepthwiseConv2D(); resolver.AddFullyConnected(); resolver.AddSoftmax(); resolver.AddReshape();第二行的模板参数10是最大算子数量,不是实际数量。每多注册一个算子,固件 flash 增加几 KB 到十几 KB,具体取决于算子实现里用到的查表、内核展开和量化辅助函数。模板参数写小了,运行时会出现Failed to get registration;写大了,new算子链表的内存占用变大。一般做法是先用一个足够大的值编进去,跑起来后再根据实际模型压缩到最小。
| 算子 | 典型用途 | 对固件体积的影响 |
|---|---|---|
| Conv2D | 卷积层为主的小模型 | 最大,内核含 im2col 展开 |
| DepthwiseConv2D | MobileNet 系列、轻量分类器 | 中等,比 Conv2D 小 |
| FullyConnected | 分类头、MLP | 中等 |
| Softmax | 分类输出 | 很小 |
| Reshape/Quantize | 张量形状与类型转换 | 很小 |
2.3 ulab 的定位:张量计算和 C 速度的缓冲层
ulab 是 MicroPython 的 numpy 子集,提供ndarray、切片、广播、dot、argmax、median等核心功能。它在固件里的定位很明确:不要在 Python 层用 list 存传感器数据,也不要在 Python 层 for 循环逐像素做归一化。
实际项目里我一般这样分配任务:传感器原始字节用ulab.numpy.frombuffer转成数组,reshape 成模型输入形状,做减均值除方差,再astype成 int8 或 uint8 交给 TFLM。推理完成后,输出张量再从内存视图转回 ulab 数组,用argmax取分类结果。整个过程只有两次跨模块的数据交接:Python 数据到 TFLM 输入张量,TFLM 输出张量到 Python 结果。交接方式就是 buffer protocol。
2.4 tensor 的数据通路:buffer protocol 是唯一契约
MicroPython 的bytes、bytearray、memoryview都实现了 buffer protocol,TFLM 的张量数据本质也是“带 shape/dtype 的连续内存块”。两者之间的契约因此非常简单:拿到对象的底层指针和长度,传递给TfLiteTensor的data.raw字段,或者从data.raw构造一个 memoryview 返回给 Python。
关键点在于内存生命周期。TFLM 的三块内存各有归属:
| 数据对象 | 分配方 | 生命周期 | 能否零拷贝 |
|---|---|---|---|
| 模型二进制 | 编译进固件 .rodata | 与固件相同 | 常驻,不拷贝 |
| interpreter 的 arena | C 层静态数组 | 固件运行期间 | 不适用 |
| 输入输出张量 | arena 内部 | 每次Invoke()前后有效 | 可共享,但要小心 |
| ulab ndarray | MicroPython GC 堆 | Python 对象引用期间 | 用 view 可以,tobytes 是拷贝 |
模型二进制放 .rodata 是最稳的:它不会被 GC 回收,也不会因为 Python 变量被删除而失效。如果从 Python 层传bytes给 C wrapper,必须确保这个 bytes 对象在每次推理期间都被 Python 引用持有,否则解释器 GC 后 C 侧指针就悬空了。这是新手最容易遇到、但报错信息往往只是Hard Fault的坑。
3. 把 TFLM 和 ulab 编进 MicroPython 固件的构建流程
3.1 构建前置:交叉编译工具链与源码目录
先明确目标平台,这里以 ESP32-S3 为例。需要三份源码:micropython 主仓库、tflite-micro 源码、ulab 源码。无论官方预编译固件还是社区里那些支持 USB Host 的 MicroPython 固件,默认都不会带 TFLM,必须自己编。
工具链用gcc-arm-none-eabi还是乐鑫的xtensa-esp32s3-elf,取决于 target。ESP32-S3 属于 Xtensa 架构,用乐鑫工具链;RP2040 和 STM32 用 ARM GCC。cmake 版本至少 3.16,Python 3 用于构建脚本和后续模型转换。
git clone --depth 1 https://github.com/micropython/micropython.git git clone --depth 1 https://github.com/tensorflow/tflite-micro.git git clone --depth 1 https://github.com/v923z/micropython-ulab.git先编译 mpy-cross,它是跑在 PC 上的 MicroPython 交叉编译器,后续打包 .mpy 文件和验证 Python 语法都要用。
make -C mpy-cross3.2 挂载 ulab:在固件里启用 numpy 子集
ulab 官方支持作为 user C module 编入固件。进入端口目录后,把 ulab 的micropython.cmake路径传给USER_C_MODULES变量即可。
cd ports/esp32 make submodules make BOARD=ESP32_GENERIC_S3 USER_C_MODULES=../../micropython-ulab/micropython.cmakeBOARD指定板卡定义,不同板卡的 sdkconfig 差异很大,ESP32 和 ESP32-S3 不能混用。编完后在板子上执行import ulab.numpy as np; np.zeros((3, 3))验证是否生效。如果 import 失败,优先检查mpconfigport.h或 sdkconfig 里有没有手动禁掉MICROPY_PY_ULAB。
3.3 用 user C module 封装 TFLM 解释器
不能直接把 TFLM 暴露给 Python,需要写一个薄封装层。这个 C module 负责三件事:创建 interpreter、返回输入输出张量、触发推理。核心代码大致是这个结构:
#include "py/runtime.h" #include "tensorflow/lite/micro/micro_interpreter.h" #include "tensorflow/lite/schema/schema_generated.h" #define ARENA_SIZE (120 * 1024) static alignas(16) uint8_t arena[ARENA_SIZE]; static tflite::MicroInterpreter *s_interp = NULL; STATIC mp_obj_t tflm_create(mp_obj_t model_data, mp_obj_t arena_size_in) { mp_buffer_info_t bufinfo; mp_get_buffer_raise(model_data, &bufinfo, MP_BUFFER_READ); static tflite::MicroMutableOpResolver<16> resolver; resolver.AddConv2D(); resolver.AddDepthwiseConv2D(); resolver.AddFullyConnected(); resolver.AddSoftmax(); resolver.AddReshape(); const tflite::Model *model = tflite::GetModel(bufinfo.buf); s_interp = new tflite::MicroInterpreter(model, resolver, arena, mp_obj_get_int(arena_size_in)); return mp_obj_new_int((mp_int_t)(uintptr_t)s_interp); }mp_get_buffer_raise负责把 Python 的 bytes/bytearray/memoryview 统一转换成底层指针加长度,这一段是 MicroPython C API 的标准姿势。arena必须是静态数组,不能用 Python 层传进来的 bytearray,因为 bytearray 来自 GC 堆,GC 移动或回收后 interpreter 还在引用它就会崩。alignas(16)保证 TFLM 要求的张量指针对齐。
tflm_create里保存 Python 对象引用这一步我不演示,直接建议把模型 bytes 放进固件 .rodata,见 4.2 节。这样避免 GC 根指针管理,是最省心的方案。
3.4 编不过时先查这四个参数
| 参数或配置 | 推荐值 | 作用与排错方向 |
|---|---|---|
-Os | 必须开启 | 不开优化,flash 和 ram 占用会直接翻倍,链接时报 overflow |
-fno-exceptions -fno-rtti | 必须开启 | TFLM 编译要求,否则链接__cxa_*符号失败 |
ARENA_SIZE | 先给 120KB | 小模型可缩到 48KB;运行时报Failed to allocate再调大 |
USER_C_MODULES | 指向 micropython.cmake | 路径写错时,构建日志里看不到任何自定义模块,需要 check 编译产物 |
报undefined reference to __aeabi_*或__cxa_pure_virtual时,优先查 C++ 标准库链接是否完整。ESP32 的构建系统默认不会自动把 libstdc++ 拉进来,需要在 CMakeLists 里把-lstdc++加进链接参数。这个问题卡住过我大半天。
4. 在 MicroPython 里跑通 TFLite 推理:量化、预处理与张量解析
4.1 在 PC 上把模型量化成 int8 再下板
TFLM 对 float32 模型支持很有限,常见做法是转换成全 int8 量化模型。量化的意义不只是体积,int8 卷积在 Cortex-M 和 Xtensa 上可以利用 SIMD 指令,速度快一个量级。
import tensorflow as tf def representative_dataset(): for i in range(200): x = np.load(f"calib/{i:04d}.npy") yield [x.astype(np.float32) / 255.0] converter = tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.representative_dataset = representative_dataset converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.target_spec.supported_types = [tf.int8] converter.inference_input_type = tf.int8 converter.inference_output_type = tf.int8 tflite_model = converter.convert() open("model.tflite", "wb").write(tflite_model)representative_dataset必须是 generator,不能返回 list,否则转换报错。校准集 100 到 200 张足够,覆盖典型亮度范围即可。inference_input_type和inference_output_type指定成 int8,这样 TFLM 端不需要额外处理 float 到 int 的转换层。转换完成后,在 PC 上用tf.lite.Interpreter跑一遍测试集,确认精度损失可接受再上板。
4.2 把 .tflite 模型数据嵌进固件镜像
模型体积在 100KB 以内时,最稳的加载方式是把模型转成 C 数组编进固件 .rodata。
xxd -i model.tflite > model_data.h生成的头文件里是const unsigned char model_tflite[]和长度变量model_tflite_len。把model_data.h放到 user C module 目录里,wrapper 的tflm_create直接引用它,彻底避开 Python 对象生命周期问题。模型超过 300KB 时,不建议这样编,flash 分区和编译时间都不划算,改成从 SD 卡或 SPI Flash 读进一个固定 buffer。
4.3 用 ulab 完成预处理、推理调用与结果解析
固件里跑推理的脚本可以很短,但每个细节都影响正确性:
import mytflm import ulab.numpy as np import time with open('model_data.tflite', 'rb') as f: model = f.read() interp = mytflm.create(model, 120 * 1024) in_t = mytflm.input(interp, 0) out_t = mytflm.output(interp, 0) # 读取传感器数据并转成模型输入形状 raw = np.fromfile('/sd/sample.bin', dtype=np.uint8) x = raw.reshape((1, 28, 28, 1)).astype(np.int8) in_t[:] = x.tobytes() t0 = time.ticks_us() status = mytflm.invoke(interp) dt = time.ticks_diff(time.ticks_us(), t0) if status != 0: raise RuntimeError('invoke failed, rc=%d' % status) scores = np.frombuffer(out_t, dtype=np.int8).reshape((10,)) pred = int(np.argmax(scores)) print('pred:', pred, 'dt_us:', dt)mytflm.input(interp, 0)返回的是一个 memoryview,指向 TFLM 输入张量的内存;in_t[:] = x.tobytes()把 ulab 数组拷贝进张量内存。np.frombuffer(out_t)把输出张量直接解释成 int8 数组,不产生 Python 层数据拷贝。status != 0对应 TFLM 的kTfLiteError,常见原因是 arena 太小或输入数据形状不对。
4.4 输出张量的反量化系数从哪拿
int8 模型的输出张量不是真实概率,需要反量化。TFLite 存储的 scale 和 zero_point 在量化时已经确定,公式是real_value = (int8_value - zero_point) * scale。
我的做法是在 C wrapper 里把 TfLiteTensor 的params.scale和params.zero_point暴露成 Python 方法,这样脚本可以动态读取:
scale = mytflm.output_scale(interp, 0) zero = mytflm.output_zero_point(interp, 0) real_scores = (scores - zero) * scale不要在脚本里硬编码 scale 和 zero_point 常量。模型重新量化后这两个值大概率变化,硬编码会让精度问题排查变得很痛苦。分类任务只关心argmax时,scale 是正数,不影响大小顺序,可以直接跳过反量化;但阈值判断、回归输出、多标签概率计算必须做这一步。argmax在同一张量内部比较 int8 值和比较 float 值结果一致,这是很多人直接忽略 scale 也不会出错的数学原因。
5. 零拷贝、对齐与验证:让 tensor 通路更稳
5.1 用 np.frombuffer 直接读输入张量,把一次拷贝省掉
4.3 节的做法是in_t[:] = x.tobytes(),这里tobytes()会先在 ulab 内部生成一份 bytes,再拷贝到张量内存。传感器数据量大时,这份拷贝是实打实的内存带宽浪费。更干净的办法是把 TFLM 输入张量内存直接包装成 ulab ndarray:
in_view = np.frombuffer(in_t, dtype=np.int8).reshape((1, 28, 28, 1)) in_view[:] = raw.reshape((1, 28, 28, 1))现在in_view和 TFLM 输入张量共享同一块内存。np.frombuffer返回的是视图,不是拷贝;reshape在 ulab 里也是创建新元数据,不改底层数据。整个预处理链路的唯一一次复制发生在最后一行,把传感器数据写进共享视图。调整 dtype 时注意:uint8 和 int8 在 buffer 层面字节数相同,但数值解释不同,转换模型输入型别时写成np.int8更省事。
5.2 arena 对齐:为什么不能随便在 Python 层分配输入 buffer
TFLM 要求张量数据按 16 字节对齐,这一点 C 层静态数组可以保证,MicroPython 的 GC 堆却不一定。GC 分配器通常只保证指针自身对齐到机器字长,也就是 4 或 8 字节。把 arena 或大 buffer 放到 Python 层,轻则运行一段时间后偶发 Hard Fault,重则每次推理都崩溃。这类问题特别难排查,因为地址恰好对齐时就正常,偏移几个字节就崩。
在 wrapper 里加一个编译期检查最直接:
_Static_assert(sizeof(arena) % 16 == 0, "arena size must be multiple of 16");再配合 C 层的alignas(16),对齐问题基本能堵死。如果目标芯片有外部 PSRAM,可以把 arena 放进 PSRAM 段,但 PSRAM 的访问延迟比内部 SRAM 高,推理耗时会有可测的增加,需按帧率预算取舍。
5.3 用时间统计与 golden test 验证推理结果
上板后第一件事不是跑实时数据,而是拿 PC 上的同一个测试样本喂给固件,对比输出。我的验证脚本长这样:
golden = [0, 0, 0, 0, 0, 1, 0, 0, 0, 0] total_err = 0 for i in range(100): raw = load_test_sample(i) run_inference(raw) total_err += int(np.sum(np.abs(scores - np.array(golden, dtype=np.int8)))) print('mean_abs_err:', total_err / 100)如果mean_abs_err不是 0,优先检查输入预处理与 PC 端是否一致,特别是归一化顺序。固件里常见错误是减均值之前做了astype(np.int8),把浮点小数先截断,导致输入分布整体偏移;量化的 scale 很小,哪怕输入差 1,输出都会差好几格。
时间统计上,time.ticks_us()在 ESP32-S3 上的精度足够,但要注意把首次推理排除在外。第一次invoke()会执行算子初始化,耗时可能是后续推理的几倍;连续测 50 次取中位数,比取第一次值更能代表真实性能。algo 的计时位置放在读传感器之后、写结果之前即可,覆盖预处理和推理全链路。
本文还有配套的精品资源,点击获取