引言
把 AI 推理放到 MCU 上,听起来像「用指甲刀砍树」,但现实需求非常硬:语音唤醒要在本地完成(不然一直上传音频费流量又侵犯隐私)、振动异常检测要在本地判定(云端往返延迟太高)、电池设备不可能常连网络。这些场景的共同约束是「几十 KB 内存、几十 MHz 主频、毫瓦级功耗」,而模型往往来自一个用 GPU 训练的几 MB 网络。TinyML 要解决的就是这个落差。
落地的技术路线其实很清晰:把模型量化到 8 位整数(体积和算力都降一个数量级),用针对 MCU 优化的推理内核(CMSIS-NN 之类)跑,用一套运行时(TFLite Micro)管理算子与内存。难点不在概念,而在细节:量化到底掉多少精度、张量内存怎么算够、算子裁剪后会不会跑不起来、NPU 能不能用上。这些细节错了,表现为「模型在 PC 上好好的,烧到板子上要么编译不过、要么结果全错、要么内存溢出」。
本文聚焦 MCU 与低端 SoC 上的推理,与 边缘计算与边缘网关 里讲的 x86/ARM 网关推理(ONNX Runtime、OpenVINO)是不同层次的方案:那篇面向 4 到 64 核、跑 Linux 的设备,本文面向几百 KB 内存、跑裸机或 RTOS 的节点。硬件约束与低功耗模式见 ESP32 与 STM32 嵌入式开发 。
目录
- MCU 上做推理的约束
- 模型表示:TFLite FlatBuffer
- TFLite Micro 运行时与算子
- CMSIS-NN 与内核优化
- 量化:PTQ 与 QAT
- 剪枝与结构压缩
- 内存规划与张量复用
- NPU 与加速器接入
- 端云协同与模型分发
- 性能剖析与实测
- 典型管线:关键词唤醒与视觉检测
- 权衡取舍
- 常见坑清单
- 小结
1. MCU 上做推理的约束
MCU 推理受四个硬约束:内存、算力、精度、功耗。四者互相牵制,先看量级。
| 平台 | 主频 | SRAM | Flash | 典型可跑模型 |
|---|---|---|---|---|
| Cortex-M0+ | 48MHz | 32KB | 256KB | 极小 MLP、简单分类 |
| Cortex-M4F | 80 到 168MHz | 128 到 320KB | 1MB | DS-CNN 关键词唤醒 |
| Cortex-M7 | 480MHz | 512KB 到 1MB | 2MB | 小型 CNN、姿态估计 |
| ESP32-S3 | 240MHz | 512KB + PSRAM | 8MB | 人脸检测、图像分类 |
| Cortex-A + NPU | 1GHz+ | GB 级 | eMMC | 多路视觉、目标检测 |
内存是最紧的:模型权重放 Flash(只读),激活值(张量)放 SRAM。一个 100KB 的 int8 模型,激活峰值可能就要 50 到 150KB SRAM,很容易撞上 320KB 的上限。算力上,Cortex-M4F 在 168MHz 下大约能提供 100 到 200 MIPS(int8 有 DSP 指令加成),跑一个 30 万 MAC 的 DS-CNN 大约 20 到 60ms,这个延迟对语音唤醒够用,对实时视频不够。
功耗是电池设备的命门。一次推理 20ms、峰值电流 20mA,一天跑 1000 次就是约 0.1mAh/天,配合深度睡眠(uA 级)总体可控。但如果推理频率高、模型大,平均电流会迅速吃掉电池预算——这也是为什么边缘 AI 的模型要「够用就好」,而不是越大越好。
2. 模型表示:TFLite FlatBuffer
TFLite 用 FlatBuffer 序列化模型,文件后缀 .tflite。理解它的结构是排查「模型跑不起来」的第一步。
.tflite 文件结构:
Model
├── version
├── operator_codes[] 算子类型列表(CONV_2D、FULLY_CONNECTED、SOFTMAX...)
├── subgraphs[] 子图,通常只有一个
│ ├── tensors[] 张量定义(形状、类型、量化参数、buffer 索引)
│ ├── inputs[] 输入张量索引
│ ├── outputs[] 输出张量索引
│ └── operators[] 算子实例(输入张量、输出张量、算子码索引)
├── buffers[] 权重与偏置的原始字节
└── description 元数据(作者、版本)
关键点:权重不是存在张量定义里,而是存在 buffers 数组里,张量通过 buffer 索引引用它。一个张量的 quantization 字段带 scale 和 zero_point,是 int8 推理正确性的核心——推理时 real = (int8 - zero_point) * scale,这个换算必须在训练与推理两侧一致。
flatc --json --raw-buffer schema.fbs -- model.tflite # 反序列化成可读 JSON
python3 -c "import tensorflow as tf; i=tf.lite.Interpreter('model.tflite'); i.allocate_tensors(); print(i.get_input_details(), i.get_output_details())" # 看输入输出
排查模型问题时的顺序:先用 flatc 看结构与量化参数,再用 Python 的 TFLite Interpreter 在 PC 上验证输入输出是否符合预期。如果 PC 上结果就对不上,问题在模型转换;如果 PC 上对、板子上错,问题在运行时的算子实现或内存。
3. TFLite Micro 运行时与算子
TFLite Micro(TFLM)是专为 MCU 设计的推理运行时,没有动态内存分配、没有操作系统依赖、没有异常。它的核心对象是解释器与张量竞技场(tensor arena)。
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/micro_mutable_op_resolver.h"
#include "model_data.h" // 由 xxd 生成的模型字节数组
// 1. 静态分配张量竞技场(大小必须手工估算,不能动态扩展)
constexpr int kArenaSize = 60 * 1024;
static uint8_t tensor_arena[kArenaSize];
// 2. 只注册用到的算子,能省 Flash
static tflite::MicroMutableOpResolver<6> resolver;
void RegisterOps() {
resolver.AddConv2D();
resolver.AddDepthwiseConv2D();
resolver.AddFullyConnected();
resolver.AddSoftmax();
resolver.AddReshape();
resolver.AddQuantize();
}
static tflite::MicroInterpreter* interpreter = nullptr;
void Setup() {
static const tflite::Model* model = tflite::GetModel(g_model);
RegisterOps();
static tflite::MicroInterpreter static_interpreter(
model, resolver, tensor_arena, kArenaSize);
interpreter = &static_interpreter;
TfLiteStatus status = interpreter->AllocateTensors();
if (status != kTfLiteOk) {
// 竞技场太小,表现为 AllocateTensors 失败
return;
}
}
void RunInference(const int8_t* input) {
int8_t* in = interpreter->input(0)->data.int8;
memcpy(in, input, interpreter->input(0)->bytes);
interpreter->Invoke();
int8_t* out = interpreter->output(0)->data.int8;
// out 是量化后的值,需用输出张量的 scale/zero_point 还原
}
两个设计要点。第一是算子解析器:MicroMutableOpResolver<N> 只注册用到的算子,N 是数量上限,用 AllOpsResolver 会把所有算子都链进来,Flash 占用翻好几倍。第二是张量竞技场:一块静态分配的缓冲区,所有中间张量在里面复用,大小要手工估算——太小则 AllocateTensors 失败,太大则浪费 RAM。
4. CMSIS-NN 与内核优化
CMSIS-NN 是 ARM 为 Cortex-M 优化的神经网络内核库,用 int8 和 DSP 指令把卷积、深度卷积、全连接等算子加速。TFLM 在 ARM 上会自动链接 CMSIS-NN 版本的算子,前提是正确配置构建。
CMSIS-NN 的加速来源:
- int8 数据 + SIMD(如 SMLAD 一次算两个 16 位乘加)
- 深度卷积的专用实现(MobileNet 类模型的主力算子)
- 利用 Cortex-M4/M7 的 DSP 扩展与 M 系列的流水线
- 避免浮点(M0/M3 无 FPU,M4F/M7 的 FPU 在 int8 场景也用不上)
典型加速比(相对纯 C 参考实现):
- 深度卷积:5 到 10 倍
- 全连接:2 到 4 倍
- 整体模型:3 到 8 倍
启用 CMSIS-NN 的方式:在 TFLM 的构建里定义 CMSIS_NN 宏,或使用厂商的预集成版本(如 STM32Cube.AI、Espressif 的 ESP-DL 封装)。注意 CMSIS-NN 的算子有对齐与布局要求(如输入通道按 4 字节对齐),模型转换时如果布局不符,可能退化到参考实现,表现为「开了 CMSIS-NN 但没变快」。
其他优化手段:把热路径函数放进 RAM 执行(Flash 取指有等待周期)、用 DMA 搬运输入数据、把不常变的权重留在 Flash。对 M7 这类带缓存的核,数据布局要尽量连续以提高缓存命中。
一个容易被忽视的优化点是算子融合:卷积加批归一化加激活,在推理时可以融合成一个算子,减少中间张量的读写。TFLite 转换器会自动做一部分融合,但自定义算子或非标准结构可能漏掉。融合后不仅省内存(中间张量消失),也省时间(少一次遍历)。检查方法是看反序列化后的算子列表,如果看到紧邻的 CONV_2D 与 ADD 而没有被融合,说明转换时没触发优化。
5. 量化:PTQ 与 QAT
量化是把 fp32 权重与激活转成 int8,是 MCU 推理的关键一步:体积降 4 倍,算力需求降 4 倍以上,精度通常掉 1% 以内(取决于模型与任务)。
| 方式 | 做法 | 精度 | 成本 |
|---|---|---|---|
| 动态范围量化 | 只量化权重,激活运行时量化 | 一般 | 最低 |
| 全整型 PTQ | 权重与激活都量化,需代表性数据集 | 好 | 低 |
| QAT | 训练时模拟量化误差 | 最好 | 高(要重训) |
PTQ(Post-Training Quantization)只需要一个代表性数据集(几百到几千个样本)来统计激活范围,无需重训,是首选。转换命令:
import tensorflow as tf
def representative_dataset():
for sample in calib_samples: # 100 到 500 个代表性样本
yield [sample.astype("float32")]
converter = tf.lite.TFLiteConverter.from_saved_model("saved_model")
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 # 输入输出也用 int8
converter.inference_output_type = tf.int8
tflite_quant_model = converter.convert()
open("model_int8.tflite", "wb").write(tflite_quant_model)
几个关键细节:一是代表性数据集要覆盖真实分布,只用训练集首尾样本会让激活范围统计偏差,导致量化后精度骤降;二是per-channel 量化(每个输出通道独立的 scale)比 per-tensor 精度高,转换器默认对卷积权重做 per-channel;三是输入输出也量化成 int8 能省掉端上的量化/反量化开销,但需要应用侧配合换算。
QAT 在训练图里插入伪量化节点(fake quant),让模型在训练时就适应量化误差,精度最好但需要重训与调参。当 PTQ 掉点超过可接受范围(如分类任务掉 3% 以上)时才上 QAT。
6. 剪枝与结构压缩
量化之后如果模型还是太大,下一步是剪枝(去掉不重要的权重)与结构压缩。
| 方法 | 原理 | 收益 | 代价 |
|---|---|---|---|
| 幅度剪枝 | 去掉绝对值小的权重 | 稀疏化,可压缩存储 | 需稀疏推理支持才提速 |
| 结构化剪枝 | 去掉整个通道或滤波器 | 直接减小模型 | 需重训恢复精度 |
| 权重聚类 | 权重聚到少数几个值 | 压缩存储 | 收益有限 |
| 知识蒸馏 | 小模型学大模型输出 | 小模型精度提升 | 训练复杂 |
对 MCU 而言,结构化剪枝最有价值:去掉整个通道后,模型的实际计算量真的下降,而幅度剪枝产生的稀疏矩阵在 MCU 上没有高效的稀疏推理内核,压缩了存储但不提速。结构化剪枝的流程是「训练 → 剪枝 → 微调」,剪枝后必须微调,否则精度掉得厉害。
另一条路是直接设计小模型:MobileNet 系列的深度可分离卷积、DS-CNN(语音唤醒)、以及针对 MCU 设计的 MCUNet 系列。很多时候重新设计一个小而高效的网络,比压缩一个大网络效果更好——因为压缩后的网络结构往往不是最优的。
7. 内存规划与张量复用
张量竞技场的大小估算是 MCU 推理最容易翻车的地方。TFLM 的内存规划器(memory planner)会在 AllocateTensors 时给每个中间张量分配偏移,并复用生命周期不重叠的张量。
竞技场大小估算方法:
1. 先用一个「足够大」的竞技场(如 1MB)跑通,读 arena_used_bytes()
2. 把竞技场设为略大于该值(如 1.2 倍)验证
3. 逐步缩小直到 AllocateTensors 失败,取失败前的值加余量
经验法则:激活峰值 ≈ 最大中间张量 × 2 到 3(复用不完美时)
代码里读取:
size_t used = interpreter->arena_used_bytes();
优化竞技场的三种手段:一是改变算子顺序,让大张量的生命周期尽量不重叠(转换时可用工具重排);二是分段推理,把大模型切成几段分别推理,中间结果落 Flash(慢但省 RAM);三是降低输入分辨率,这是最有效的杠杆——输入从 224x224 降到 96x96,激活内存降 5 倍以上。
要区分「权重内存」与「激活内存」:权重在 Flash 里,不影响 SRAM;激活在 SRAM 里,是竞技场的主要占用。很多人以为「模型 200KB 就要 200KB RAM」,其实权重 200KB 的模型激活可能只有 50KB,也可能有 300KB,取决于网络结构。所以竞技场必须实测,不能拍脑袋。
8. NPU 与加速器接入
当 MCU 算力不够时,可以外挂或选用带 NPU 的芯片。
| 加速器 | 平台 | 算力 | 接入方式 |
|---|---|---|---|
| Ethos-U55/U65 | Cortex-M55/M85 | 0.5 到 4 TOPS | Vela 编译器 + TFLM delegate |
| ESP-DL / ESP-NN | ESP32-S3 | 向量指令加速 | 厂商封装 |
| MAX78000 CNN | MAX78000 | 硬件 CNN 引擎 | 厂商工具链 |
| Coral Edge TPU | 带 USB/PCIe 的主机 | 4 TOPS | TFLite delegate |
Ethos-U 是最主流的 MCU NPU 方案:它由 ARM 提供,配合 Cortex-M55 使用,模型通过 Vela 编译器编译成 NPU 指令,运行时由 TFLM 的 delegate 把兼容算子卸载到 NPU。Vela 编译时会报告「哪些算子能在 NPU 上跑、哪些回退到 CPU」,回退的算子会成为性能瓶颈——所以设计模型时要尽量用 NPU 支持的算子集。
接入 NPU 的三个注意点:一是算子兼容性,不是所有算子都能卸载,设计网络时先查支持的算子列表;二是数据布局,NPU 常要求特定的张量布局(如 NHWC),转换时注意;三是回退路径,NPU 不可用时要有 CPU 兜底,避免整个推理失败。ESP32-S3 的方案更简单:用 ESP-DL 提供的加速算子,无需额外编译器,但只能用厂商支持的模型类型。
9. 端云协同与模型分发
边缘模型不是一次烧录就完事,它需要随数据分布变化而更新。模型分发本质是一次特殊形式的 OTA。
端云协同闭环:
1. 端侧推理,把「低置信度样本」或「特征摘要」上传(不是原始数据)
2. 云端聚合、标注、重新训练
3. 新模型量化、验证、生成版本
4. 通过 OTA 通道分发到设备(可灰度)
5. 设备加载新模型,上报推理指标
6. 云端对比新旧模型的表现,决定是否全量
模型分发的工程要求与固件 OTA 类似:版本管理、签名校验、灰度、回滚。区别在于模型可以「热加载」——存在独立的模型分区,运行时切换到新模型,不必重启整机。模型文件要带哈希与签名,加载前校验,防止被替换成恶意模型。
模型版本要与固件版本解耦:一个固件可能支持多个模型版本,模型更新不必等固件更新。但要处理「固件不支持新模型算子」的情况——新模型引入了固件里没有的算子,加载会失败。所以模型分发前要检查目标设备的固件版本与算子支持,机制与 OTA 固件升级与差分更新 里讲的兼容性判断一致。
端侧还可以做「数据选择」:不是所有数据都值得上传,优先上传「模型不确定的样本」(置信度接近决策边界的),这些样本对再训练最有价值。这样能用最少的流量获得最大的模型提升。
10. 性能剖析与实测
推理延迟与内存必须实测,不能估算。TFLM 自带微剖析器(Micro Profiler),能输出每个算子的耗时。
#include "tensorflow/lite/micro/micro_profiler.h"
static tflite::MicroProfiler profiler;
interpreter->SetProfiler(&profiler);
interpreter->Invoke();
profiler.Log(); // 打印每个算子的耗时(微秒)
实测的关键指标:
| 指标 | 含义 | 目标 |
|---|---|---|
| 单次推理延迟 | 从输入到输出的时间 | 按业务定(唤醒 <100ms) |
| 峰值内存 | arena_used_bytes | 留 20% 余量 |
| 单次推理能耗 | 电流积分 | 按电池预算定 |
| 端到端延迟 | 含采集、预处理 | 通常比推理大得多 |
常见认知误区:只关注推理延迟,忽略预处理(MFCC 特征提取、图像缩放)的耗时——在很多管线里预处理比推理还慢。例如语音唤醒的 MFCC 计算可能占端到端延迟的一半,图像缩放的耗时也可能超过推理本身。剖析要覆盖整条管线,而不只是 Invoke()。
能耗测量要用库仑计或高精度电流表,测一个完整推理周期(唤醒、采集、预处理、推理、上报、睡眠)的平均电流,而不是单看推理峰值。
11. 典型管线:关键词唤醒与视觉检测
两个最典型的 MCU 推理场景,管线结构值得参考。
关键词唤醒(KWS):麦克风以 16kHz 采样,每 20 到 30ms 一帧,提取 MFCC(13 维、40ms 窗、20ms 步长),喂给 DS-CNN 或 TC-ResNet 模型,输出「是否包含唤醒词」的概率。模型通常 20 到 100KB,推理 10 到 40ms,配合 VAD(语音活动检测)只在有声音时推理,平均功耗能压到毫安级以下。难点在误唤醒率:太灵敏会频繁误触发,太迟钝会漏唤醒,需要在真实噪声环境下调阈值。
视觉检测:ESP32-S3 加摄像头,QVGA(320x240)图像缩放后喂给轻量检测模型(如 MobileNet-SSD 的裁剪版)。瓶颈常在图像搬运与缩放,而非推理本身。为了省内存,可以用灰度图、降低分辨率、或只在 PIR 触发时才开启摄像头。带 NPU 的芯片(如 K230、RV1106)能跑更复杂的检测,但仍受内存带宽限制。
两条管线的共同经验:预处理与后处理往往比推理更耗资源,模型压缩省下的算力可能被图像缩放吃掉;触发机制(VAD、PIR)比优化推理更能省电,因为大部分时间设备在睡觉。
12. 权衡取舍
| 决策点 | 方案 A | 方案 B | 判据 |
|---|---|---|---|
| 量化方式 | PTQ | QAT | 掉点在可接受范围用 PTQ,否则 QAT |
| 模型压缩 | 剪枝 | 重新设计小模型 | 结构合适时剪枝,否则重新设计 |
| 推理位置 | 端侧 | 云端 | 延迟与隐私要求高的端侧,算力需求高的云端 |
| 算子注册 | MicroMutableOpResolver | AllOpsResolver | 生产用前者省 Flash,原型可临时用后者 |
| 加速 | CMSIS-NN | NPU | 无 NPU 用 CMSIS-NN,有 NPU 且算子兼容则卸载 |
| 模型更新 | 随固件 | 独立模型分区 | 模型迭代频繁用独立分区热加载 |
一条原则:先让模型「跑得起来且够准」,再优化「跑得快」。过早优化内存和延迟会陷入反复调参,而模型结构一旦定了,优化空间其实有限。
13. 常见坑清单
- 现象:
AllocateTensors返回失败。原因:张量竞技场太小。规避:先给大竞技场跑通,读arena_used_bytes再收缩并留余量。 - 现象:板子上推理结果全错。原因:输入量化换算错误,或 scale/zero_point 用错。规避:按
real=(int8-zero_point)*scale换算,与训练侧核对。 - 现象:模型转换后精度骤降。原因:代表性数据集不具代表性。规避:用覆盖真实分布的几百个样本,检查量化前后在验证集上的差异。
- 现象:Flash 占用翻倍。原因:误用
AllOpsResolver链入全部算子。规避:用MicroMutableOpResolver只注册用到的算子。 - 现象:开了 CMSIS-NN 却没提速。原因:张量布局不满足对齐要求,退化为参考实现。规避:检查通道对齐,确认构建宏正确启用。
- 现象:端到端延迟远超推理延迟。原因:预处理(MFCC、图像缩放)耗时被忽略。规避:剖析整条管线,优化预处理与内存搬运。
- 现象:NPU 只跑了一部分算子。原因:部分算子不支持,回退到 CPU。规避:查 Vela 报告的算子覆盖,调整模型结构。
- 现象:模型更新后设备加载失败。原因:新模型算子固件不支持。规避:分发前校验固件版本与算子兼容性。
- 现象:误唤醒率高。原因:阈值不当或训练数据噪声不足。规避:在真实噪声环境采集数据并调阈值。
- 现象:电池续航远低于预期。原因:推理频率高、常开麦克风或摄像头。规避:用 VAD/PIR 触发,降低推理频率。
14. 小结
MCU 级边缘 AI 的工程路径可以概括为「量化优先、内存为王、够用就好」。量化把模型从 fp32 压到 int8,是能在 MCU 上跑起来的前提;张量竞技场的大小决定了模型能不能加载,必须实测;模型的复杂度要匹配业务需求,而不是追求 PC 上的最高精度。
落地的优先级:先把量化流程与精度验证做扎实(这决定了可行性),再把内存规划与算子裁剪做对(这决定了能否烧进芯片),然后优化预处理与触发机制(这决定了功耗),最后才是 NPU 接入与端云协同(这决定了长期迭代效率)。
下一步建议:x86 与 ARM 网关上的推理方案(ONNX Runtime、OpenVINO)属于边缘计算与边缘网关一文的范围;MCU 的内存、功耗与外设约束在 ESP32 与 STM32 嵌入式开发一文中有系统展开;模型作为制品分发的通道设计见 OTA 固件升级一文。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。