很多人第一次接触边缘 AI 时,脑子里都盘旋着一个问题:我在电脑上用 Python 写好了一个模型,它跑得好好的,怎么才能塞进一块 STM32 里?中间到底要经历什么?
这个问题看起来简单,实际上横跨了模型训练、模型压缩、嵌入式工程、底层硬件加速好几层。我最初入坑时也天真地以为,训练完直接转个 C 文件烧进去就完事了,结果被现实狠狠教育了一顿。这篇文章就用我实际做过的项目来拆解,一个 PyTorch 训练的图像分类模型,到底怎么一步步变成 STM32 里能跑的推理代码。整个过程既有工具链的转换,也有硬件资源的妥协,更有无数个“为什么这里要这么干”的道理。
1. 整体流程拆解:从 PyTorch 到单片机的五步跨越
先给没接触过这块的朋友画个全景图。整个过程大致可以分成五个阶段:
- 模型训练与导出:在 PC 上用 Python 训练出模型,保存为通用格式(ONNX 或 TFLite)。
- 模型优化与量化:把 FP32 的浮点权重压缩成 INT8 整数权重,体积缩到四分之一,推理速度大幅提升。
- 工具链转换:用厂商提供的工具(STM32Cube.AI 或 TFLite Micro)把模型转换成 C 代码或二进制库。
- 嵌入式工程集成:把生成的代码塞进 STM32 工程,编写采集数据的代码、调用推理接口、解析输出结果。
- 板端验证与调优:实测推理时间、RAM/Flash 占用,解决 “板子上跑不起来” 或 “结果不对” 的各种问题。
这五个步骤环环相扣。很多人栽跟头,是因为把第三步想得太简单,又把第五步想得太难。实际上第三步反而是最轻松的——厂商工具基本一键生成,难的是第二步的量化取舍和第五步的调试优化。
2. 模型侧的准备:训练不是终点,导出才是起点
2.1 为什么要导成标准化格式
在训练阶段,我们用 PyTorch 或 TensorFlow,它们各有自己的模型存储格式。PyTorch 的.pth文件本质是一个 Python 序列化对象,里面除了网络结构和权重,还混着 Python 类的定义信息。这东西离开 Python 环境就不可能直接用,更别提让 C 语言去解析了。
所以你得导出一个语言无关的中间格式。目前嵌入式 AI 领域有两大标准:ONNX 和 TFLite。
- ONNX是微软牵头搞的开放格式,PyTorch 官方就支持一行代码导出。
- TFLite是谷歌的格式,主要服务于 TensorFlow 生态,在 MCU 上配合 TFLite Micro 使用。
如果你用的是 STM32,我强烈建议优先考虑 ONNX,因为 STM32Cube.AI 对 ONNX 的支持非常全面,而且转换流程最顺。TFLite 也可以,但算子映射的坑会多一点。
我自己的习惯是:PyTorch 训练完,转 ONNX,再喂给 STM32Cube.AI。这条路线走的人最多,社区资料最丰富,遇到问题容易搜到答案。
2.2 导出 ONNX 时的实际步骤
假设我用 PyTorch 训练了一个 ResNet-18 分类模型(对着 CIFAR-10 数据集训练的十类图像分类),导出代码大概是这样的:
import torch import torch.onnx model = ResNet18(num_classes=10) model.load_state_dict(torch.load("resnet18_cifar10.pth")) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "resnet18_cifar10.onnx", opset_version=11, input_names=["input"], output_names=["output"] )注意几个细节:
model.eval()必须调用。不调用的话 BatchNorm 层会进入训练模式,用 batch 统计量而不是全局统计量,导出的模型推理结果会乱掉。- dummy_input 的形状要匹配真实输入。这个形状就是之后在单片机上输入张量的形状,224x224 的图跟 96x96 的图,后续内存占用天差地别。
- opset_version 选 11 或稍低。太高的 opset 版本包含的新算子,Cube.AI 不一定支持。选太低了又可能找不到某些算子实现。实测 CUBA.AI 对 opset 11 的支持比较均衡。
导出之后,用 Netron 打开看一眼模型结构,确认输入输出节点名字,后面在 Cube.AI 里要用到这两个名字。
3. 量化,嵌入式 AI 最关键的搬运工
这是整条链路里最需要理解的一环,也是绝大多数初学者没搞明白的地方。
3.1 为什么非量化不可
训练时的模型权重是 FP32(单精度浮点),每个数字占 4 字节。一个 ResNet-18 有 1100 万个参数,光权重就要 44MB。而一块 STM32F407 只有 192KB RAM 和 1MB Flash,你连模型体积的 1/30 都塞不进去。
就算换个大点的芯片,比如 STM32H743(2MB Flash),FP32 模型直接放进去也有压力,而且 FP32 的浮点运算在 MCU 上极慢——除非带 FPU(浮点运算单元),否则一个浮点乘加可能要吃几十个时钟周期。
量化到 INT8 后,每个权重的存储从 4 字节压到 1 字节,模型体积缩到原来的四分之一,ResNet-18 可以塞到 11MB。运算也变成整数乘加,很多 ARM Cortex-M 芯片有对应的 DSP 指令加速,速度能翻好几倍。
3.2 量化的数学原理
量化的本质,是把一个浮点区间 [min, max] 映射到整数区间 [-128, 127]。映射关系是:
real_value = scale * (int8_value - zero_point)scale是缩放系数,一个浮点数zero_point是零点偏移,一个整数
这个过程相当于给每个 tensor 做了一次线性的 “压缩”。关键在于如何确定每个 tensor 的 [min, max] 范围。有两种思路:
- 训练后量化(Post-Training Quantization, PTQ):训练完的模型,直接喂一些有代表性的校准数据,统计每一层的激活值范围,然后完成映射。实现简单,是 MCU 部署最常用的方案。
- 量化感知训练(Quantization-Aware Training, QAT):在训练过程中就模拟量化误差,让网络主动适应取整的扰动。精度往往比 PTQ 高一点点,但需要重新训练,耗时大。
我做 CIFAR-10 分类这个项目时,PTQ 后精度从 92.3% 掉到 90.8%,损失 1.5 个百分点,对很多应用来说完全可以接受。如果掉得太多再考虑 QAT。
3.3 校准数据集的作用
PTQ 最重要的输入是校准数据集。Cube.AI 的量化工具会拿着这批数据通过训练好的模型,记录每层激活值的大致范围。校准集一般取训练集的子集,几百张图就够了,但要保证覆盖各类别、各种光照和形态。
有人偷懒不提供校准集,让工具用默认范围。结果就是激活值的 min/max 没找对,量化误差被放大,最终精度可能崩到 30%。这块千万别偷懒,几百张图做一次校准,也就花两分钟的事。
4. STM32Cube.AI 工具链实战
4.1 工具选型:Cube.AI 还是 TFLite Micro
STM32 生态里跑 AI 主流有两条路:
| 维度 | STM32Cube.AI | TFLite Micro |
|---|---|---|
| 目标芯片 | 全系 STM32 | 理论上全系,实际依赖算子实现 |
| 操作方式 | 一键生成静态代码 | 解释器模式,运行时解析模型 |
| RAM 占用 | 较低(静态分配) | 较高(解释器额外开销) |
| 算子支持 | 广泛,且有 CMSIS-NN 加速 | 受 TFLite 算子集限制 |
| 使用难度 | 低 | 中 |
我建议优先选 STM32Cube.AI。理由:它对 STM32 平台的底层优化最彻底,能自动利用芯片内的 DSP 指令和硬件加速器,生成的代码也是静态分析的,内存布局更高效。
4.2 Cube.AI 的操作流程
打开 STM32CubeMX,在中间件列表里就能看到AI这一项。
第一步:Network选项卡里点Add,把刚才导出的resnet18_cifar10.onnx加载进去。
第二步:配置输入输出。这里填写的就是导出 ONNX 时定义的input和output。工具会自动解析出输入形状 [1, 3, 224, 224],你可以在Input里确认。
第三步:Compression选项卡选择量化策略。选8-bit量化,并指定校准数据集的路径。Cube.AI 只支持.npz或二进制文件作为校准输入,需要写个小脚本把图像集打包成 npz。
第四步:点击Analyze,工具会生成一份报告,显示模型经过优化后的 Flash/RAM 预估占用,以及每层算子的耗时预估。这份报告很有参考价值,能让你在烧录之前就发现潜在的超内存问题。
第五步:点击Generate Code,CubeMX 就会在工程里生成一堆network.c、network_data.c、ai_platform.c等文件。这就是在你的 MCU 上运行的全部核心。
4.3 生成的代码是什么
生成的代码核心有两个文件:
- network.c:包含
ai_network_create_and_init()、ai_network_run()等接口函数。这就是推理引擎,底层是对算子的直接 C 实现,按照网络拓扑一行行排好的静态代码。 - network_data.c:里面是一个巨大的
uint8_t数组,存储量化后的权重和激活缩放系数。数组可能占几十 KB 甚至几 MB,烧录时放在 Flash 里。
应用代码只需要调用接口即可,不需要理解内部算子细节,这跟你在 PC 上调用 PyTorch 的model.forward()有异曲同工之妙,但多了大量手动内存管理。
5. 嵌入式侧集成的技术细节
5.1 内存规划:模型的 RAM 去哪了
Cube.AI 生成的报告里有一项叫Ram size,它包含三部分:
- Activations buffer:网络运行时的中间激活值。一个 224x224x3 的输入图片,经过第一层卷积后变成 112x112x64 的特征图,光这一层就要 800KB 的 RAM(用 FP32 情况下)。量化到 INT8 后是 200KB。如果你的板子 RAM 不够,第一个念头就应该是把输入分辨率降下来。
- Weights buffer:量化后的权重,一般放在 Flash 里,不去占 RAM。
- Extra memory:算子的临时缓冲区。有些算子需要额外的 workspace,比如全连接层前的 flatten 操作。
对于 CIFAR-10 那个 ResNet-18 例子,224x224 输入时总共需要约 1.1MB RAM,标准的 F4 系列直接爆掉。我把输入从 224 降到 96x96 后,RAM 需求变成了 210KB,勉强能塞进 F407 的 192KB……还是差点,最终换成 80x80 输入,RAM 降到 130KB,这才跑起来。
这告诉我们要么靠算法(减小输入分辨率、剪枝、深度可分离卷积)压内存,要么换大 RAM 的芯片(H7、L4+ 等)。
5.2 数据采集与预处理
模型在 PC 上接受的是经过归一化的张量,到了单片机上你得自己写代码完成这个过程。
以图像识别为例,流程是:
- 摄像头采到的是 RGB565 或 JPEG,先解压成 RGB888。
- 用 NEON 或纯 C 把图像缩放到模型输入尺寸(96x96)。
- 执行
(pixel / 255.0 - mean) / std归一化,然后转成 INT8 定点格式写入激活缓冲区。
这块有个坑:Cube.AI 量化后的输入张量默认也是 INT8,但它的输入 scale 和 zero_point 是工具自动计算出来的。你要做的是把 0~255 的像素值转成模型输入的量化表示。Cube.AI 提供了一个辅助函数ai_network_inputs_get(),可以拿到输入 tensor 的 scale 和 zero_point,然后手动做量化,也可以直接调用它提供的转换函数。
我遇到过不少人在这里偷懒,直接把原始图字节往输入缓冲里填,结果推理结果完全随机。这个转换步骤极其关键,必须按工具给定的量化参数来。
5.3 推理调用流程
在应用代码里,推理的调用形式大致像这样:
#include "ai_network.h" #include "network.h" AI_NETWORK_DATA_PARAMS_INIT; ai_network_t network; ai_network_inputs in; ai_network_outputs out; float input_data[1 * 3 * 80 * 80]; uint8_t output_data[1 * 10]; ai_network_create_and_init(&network); in[0].data = input_data; in[0].fmt = AI_NETWORK_DATA_FORMAT_FLOAT; out[0].data = output_data; out[0].fmt = AI_NETWORK_DATA_FORMAT_UINT8; // 采集图像,写入 input_data capture_and_preprocess(&input_data); // 执行推理 ai_network_run(&network, &in, &out); // 此时 output_data 里就是各分类的得分这段代码里的ai_network_run()是同步阻塞的,执行期间 MCU 不能干别的。如果推理一次要 300ms,你的系统调度就得围绕这个时间轴来设计。
5.4 Flash 内存放不下的策略
CIFAR-10 的 ResNet-18 量化后约 11MB,而 STM32F407 的 Flash 只有 1MB,根本放不下。
三个解决思路,我按优先级推荐:
- 换小模型:从 ResNet-18 换成 MobileNetV2,参数量直接从 1100 万降到 350 万,量化后约 3.5MB,再配上 96x96 的输入,很多芯片都能塞进去。这是性价比最高的方案。
- 深度可分离卷积:MobileNet 的架构本身就为端侧设计,深度可分离卷积把标准卷积拆成两个步骤,参数量和计算量都大幅下降。
- 外部 Flash:如果你的应用确实需要大模型,可以用 QSPI 外挂 Flash,把权重放在外部,运行时按需加载到内存。STM32Cube.AI 支持这种方式,但会牺牲一点推理速度。
我自己做过一次从 ResNet 换到 MobileNet 的迭代,精度掉了不到 2 个点,但模型体积缩小了五倍,推理速度快了四倍——这笔买卖非常划算。
6. 实际运行与调试:那些说不清的坑
6.1 精度验证的第一原则
拿到板子后第一件事,不要接摄像头,先用固定数据验证。
我的做法是:在 PC 上取一张测试图片,预处理后把它的数值保存成一个 C 数组,放进工程里,跑一遍推理,跟 PC 上的结果做比对。这能确保从 Python 到 C 的每个环节都没问题,然后再去接摄像头数据流。
如果结果跟 PC 不一致,排查思路:
- 确认输入量化参数与预处理计算一致;
- 确认模型权重没有因为 Flash 读取异常而损坏;
- 确认没有 stack overflow——推理过程使用了大量局部变量,线程栈设置太保守直接跑飞。
6.2 推理耗时的测量标准
关于“推理耗时”这件事,不能只看 Cube.AI 报告里的分析数字。那个数字是在特定时钟频率和 Flash 等待状态下的理论推算。实际测量至少要考虑三块:
- 数据采集时间(摄像头通过 DCMI 接口把图送到内存)
- 预处理时间(缩放 + 归一化 + 量化)
- 推理时间
我用 DWT(Data Watchpoint and Trace)计数器来做高精度时间测量,比普通定时器精度高且不占额外资源。
一个 80x80 输入的 MobileNetV2 量化模型,在 STM32F746(216MHz)上推理实测约 80ms。如果这个速度不满足需求,再往下优化要考虑用 STM32N6 这类带 NPU 的新品,推理速度能再快一到两个数量级。
6.3 常见问题速查表
我把项目里遇到或听说过的典型问题整理成了下面的表,开发过程中可以直接对照。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| Cube.AI 报错"Unsupported operator" | 模型里用了新版算子,工具版本太老不认 | 升级 CubeMX/Cube.AI;换用旧算子重建模型;算子融合 |
| 烧录后程序死循环 | RAM 不足,激活缓冲区挪用冲突 | 查看 Map 文件,确认内存占用;减小输入尺寸 |
| 输出全是同一值 | 输入数据忘记量化,或量化参数不对 | 按ai_network_inputs_get返回的 scale/zero_point 进行预处理 |
| 结果在 PC 上对,板子上不对 | 摄像头采集像素格式不匹配 | 确认 RGB565/YCbCr 转 RGB888 的通道顺序 |
| 推理耗时与报告差很多 | Flash 等待周期太高,CPU 频率没拉满 | 开启 ART 加速器,调整 Flash 延迟,确认 PLL 配置 |
| 跑着跑着随机复位 | 栈溢出 | 加大启动文件里的 Stack_Size,或者尽量把大数组定义为全局变量 |
6.4 调试利器 ST-LINK 和串口
嵌入式开发免不了打日志。建议在工程里加一个调试串口,用下面的简单函数在关键节点打印耗时和结果:
printf("%d ms inference done, top class: %d\r\n", time_ms, top_class);注意如果你用的是 MDK,默认微库模式下printf重定向到串口需要重写fputc。IAR 则要设置__write函数。每个环境略有差异,但核心思路一样。
另外,配合 ST-LINK 的 SWD 接口做在线调试,可以在ai_network_run()附近打断点,观察输入输出张量是不是期望值。这会比裸烧程序然后盲猜高效得多。
7. 一条从零到一的完整实战路线参考
我这里提供一条我走过、确定可行的完整路线,适合第一次上手的人参考:
- 板子选择:STM32L496-DISCOVERY 或 STM32F746G-DISCOVERY,这两块板子 RAM 丰富,能装下 96x96 输入的 MobileNetV2 量化模型。
- 模型选择:先用现成的 MobileNetV2(ImageNet 预训练权重)做部署,不要一上来就自己训练,先打通部署流程再说。在 Cube.AI 里加载之后,把输出层改成你需要的分类个数,简单重训一下。
- 数据集:PyTorch 里用 CIFAR-10 或者你自己拍的百来张照片,准备一个 100 张左右的校准集。
- 量化与生成:CubeMX 里 8-bit PTQ,生成代码,加入串口打印。
- 验证:固定数组测试 → 摄像头实时测试 → 测耗时、调精度。
我第一次跑通全流程,是在一个周五晚上加班做的。从 PC 上模型到我看到板子准确识别出“猫”这个类别,花了大概六个小时。中间有一大半时间浪费在调试输入张量没对齐上。现在回头想,如果当时知道前面写的这些排查表格,四十分钟就能搞定。
8. 几个直接能落地的经验
最后写一点笼统上的建议,可能不算长,但干货密度高。
关于算子支持:Cube.AI 报表里列了算子支持清单,集成前先查一遍。Transformer 的 Attention 层在大部分 MCU 工具链上支持不佳,如果你目标平台是 STM32,尽量优先设计 CNN 架构。
关于输入分辨率:不要盲目追求大数据输入。80x80 可能就足够了,你要在精度、内存、速度之间取平衡点。
关于版本管理:CubeMX 和 Cube.AI 的版本必须匹配,AI 插件版本不同,生成的代码 API 可能有细微变化。确定一套版本后固定下来,免得升级后老工程编译不过。
关于扩展方向:这套流程不只能跑图像分类。换成关键词唤醒模型、跌倒检测等传感器模型,只要遵守“导出 → 量化 → 转换 → 集成”这个流程,原理完全一样。更进一步,STM32N6 系列自带 NPU,同样的流程可以换来几十倍的加速,值得跟进。
说到底,AI 模型部署到 STM32 从来没变得简单过,但它的套路确实是清晰可循的。你只要走通一次从 Python 到 C、从浮点到整数的迁移路径,后面再遇到任何模型、任何芯片,本质上都是同一套方法论在不同尺度上的复用了。