1. 为什么要在STM32上跑AI模型,而不是把数据传到云端
很多人第一次听到"在STM32上部署AI模型"这个说法,第一反应是:STM32那点主频和内存,跑得动吗?我一开始也是这个反应。但实际做过几个项目之后,我发现这件事不但可行,而且在某些场景下比"把数据传到云端推理"要靠谱得多。
先说清楚STM32的定位。它是一颗MCU,不是MPU,没有MMU,没有操作系统(裸机或者跑个RTOS),主频通常在几十MHz到几百MHz之间,SRAM从几KB到几百KB,Flash从几十KB到2MB左右。拿它去跑ResNet这种大模型当然不现实,但跑一个几层的小型神经网络——比如关键词识别、简单的手势分类、振动异常检测、电机故障判别——是完全够用的。
那为什么要在端侧做推理?我总结下来有三个实打实的理由。
第一是延迟。数据采集和推理在同一个芯片上完成,中间不需要经过串口打包、WiFi发送、服务器排队、结果回传这一整条链路。工业场景里,一个振动异常检测如果走云端,端到端延迟轻松上到几百毫秒甚至秒级,而本地推理可以做到毫秒级响应。对于需要快速切断电机的保护类应用,这个差距是致命的。
第二是隐私和成本。数据不出设备,就不存在上传过程中的泄露风险,也不需要为每一次推理付云端的算力费用。尤其是批量部署的设备,比如几百台分布在现场的监测节点,每台都往云端传数据,流量费和服务器费用加起来是笔不小的开销。
第三是离线可用。现场没有网络、网络不稳定、或者不允许联网的场景太多了。本地推理不依赖任何外部条件,上电就能工作。
当然,STM32跑AI也有它的边界。模型不能太大,参数量通常控制在几万到几十万级别;输入特征不能太复杂,一般是你自己提取好的特征向量,而不是原始的高分辨率图像;推理框架要足够轻量,不能依赖Python运行时。理解这些边界,后面的选型和部署才不会走弯路。
这篇文章我会把整个流程拆开讲:从模型训练、导出ONNX、用STM32Cube.AI转换成C代码、集成到工程里、再到实际调优。中间会重点讲那些文档里不会写、但实际做的时候一定会踩的坑。
2. 工具链选型:CUBE-AI、ONNX和STM32Cube IDE到底怎么配合
2.1 三个工具各自的角色
先把这三个东西的关系理清楚,不然很容易搞混。
ONNX是模型的中间表示格式。你在PyTorch或者TensorFlow里训练完模型,不能直接把训练框架的模型文件丢给STM32,得先转成一个通用的、与框架无关的格式,ONNX就是干这个的。它相当于一个"翻译中转站",把不同训练框架的模型统一成一种描述方式。
STM32Cube.AI(现在叫X-CUBE-AI)是ST官方提供的转换工具。它读取ONNX文件,分析网络结构,然后生成可以在STM32上运行的C代码。这个工具会做几件事:把浮点权重转成定点或者量化格式、生成推理所需的算子实现、计算内存占用、给出每一层的性能估算。
STM32Cube IDE是你最终写业务代码、编译、下载、调试的地方。Cube.AI生成的代码是以库的形式集成进来的,你在IDE里调用它提供的API完成推理。
三者的关系可以这样理解:ONNX是"原材料",Cube.AI是"加工厂",Cube IDE是"装配车间"。
2.2 版本匹配这件事比想象中重要
我踩过的第一个大坑就是版本不匹配。Cube.AI对ONNX的算子支持是跟着版本走的,你用比较新的PyTorch导出的ONNX,里面可能包含一些Cube.AI当前版本还不支持的算子,转换的时候直接报错。
我的建议是:先确定Cube.AI的版本,再倒推去选PyTorch和ONNX的版本。比如你装的是Cube.AI 8.x,那PyTorch用1.13到2.0之间的版本、ONNX用1.13到1.14之间,兼容性会好很多。不要一上来就装最新的PyTorch,很容易在转换环节卡住。
另外,Cube.AI既可以作为Cube IDE的插件使用,也可以作为独立的命令行工具使用。我个人的习惯是先用命令行工具做一次转换验证,确认模型能过,再集成到IDE里。命令行工具报错信息更详细,排查起来方便。
2.3 模型格式的选择:浮点还是量化
Cube.AI支持几种模型精度:浮点32位、浮点16位、定点8位。选择哪种,取决于你的芯片有没有FPU(浮点运算单元)以及你对精度的要求。
如果芯片带FPU(比如STM32F4、F7、H7系列),浮点模型跑起来问题不大,但内存占用和Flash占用会比较大。如果芯片没有FPU(比如F1、F0系列),浮点运算会非常慢,这时候就必须用量化模型。
定点8位量化是最省资源的方案,模型体积能压到浮点的四分之一,推理速度也快很多。但量化会带来精度损失,需要在训练阶段就做好量化感知训练(QAT),或者在转换后用一批测试数据验证精度是否可接受。
我的经验是:先用浮点模型跑通整个流程,确认端到端没问题,再尝试量化。一上来就搞量化,出了问题你分不清是流程问题还是量化问题。
3. 从训练到ONNX:模型导出环节的实操细节
3.1 训练阶段就要为部署做准备
很多人训练模型的时候只关心准确率,等到要部署了才发现模型结构太复杂、用了不支持的算子、输入输出格式不对。这些问题如果在训练阶段就注意,后面能省大量时间。
具体来说,训练时要注意这几点:
- 控制模型规模。全连接层不要堆太多,卷积核数量不要太大。一个实用的参考是:总参数量控制在10万以内,STM32F4级别基本能跑;控制在5万以内,F1级别也有希望。
- 避免使用冷门算子。像自定义的激活函数、特殊的归一化层,Cube.AI很可能不支持。尽量用ReLU、Sigmoid、Tanh这些标准激活函数。
- 固定输入尺寸。动态shape在嵌入式端是灾难,输入张量的维度必须完全固定。
- 输入最好是特征向量而不是原始信号。比如做振动检测,你在PC端先做好FFT提取频域特征,把特征向量作为模型输入,而不是把原始时域波形丢进去让模型自己学。这样模型可以做得非常小。
3.2 PyTorch导出ONNX的完整代码
假设你已经训练好了一个PyTorch模型,导出的核心代码如下:
import torch import torch.onnx # 加载训练好的模型 model = MyNet() model.load_state_dict(torch.load("model.pth")) model.eval() # 构造一个符合输入维度的假数据 dummy_input = torch.randn(1, 1, 128) # batch=1, channel=1, length=128 # 导出ONNX torch.onnx.export( model, dummy_input, "model.onnx", export_params=True, opset_version=13, # 建议用11到13,兼容性好 do_constant_folding=True, input_names=["input"], output_names=["output"], dynamic_axes=None # 不要开动态轴 )这里有几个细节值得说。
opset_version不要选太高。我试过opset 17,导出的模型里有些算子Cube.AI不认。11到13是比较稳的区间。
dynamic_axes一定要设成None或者不传。有些教程会让你设置动态batch,但在嵌入式端batch永远是1,动态轴只会给转换添麻烦。
导出之后,强烈建议用onnxruntime在PC上跑一遍,确认输出和PyTorch一致。这一步能帮你排除掉导出环节的问题,避免后面在Cube.AI里排查半天发现是导出就错了。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("model.onnx") test_input = np.random.randn(1, 1, 128).astype(np.float32) result = sess.run(None, {"input": test_input}) print(result[0].shape)3.3 用onnxsim做一次图简化
导出的ONNX图里经常有一些冗余节点,比如多余的Transpose、Identity、Constant节点。这些节点在PC上无所谓,但在STM32上会白白消耗内存和算力。
用onnx-simplifier做一次简化,能去掉不少冗余:
pip install onnx-simplifier python -m onnxsim model.onnx model_sim.onnx简化后再用onnxruntime验证一遍精度,确认没有变化。我遇到过简化后输出有微小差异的情况,一般是浮点误差,但如果差异很大就要检查是不是简化过程改动了计算逻辑。
4. Cube.AI转换:从ONNX到C代码的关键步骤
4.1 在Cube IDE里启用X-CUBE-AI
如果你用的是STM32Cube IDE,X-CUBE-AI是以插件形式存在的。安装方式是在IDE的Help菜单里找到Manage Embedded Software Packages,然后勾选X-CUBE-AI。安装完成后,新建或者打开一个工程,在工程上右键就能看到"STM32CubeAI"相关的选项。
我建议先在CubeMX里把芯片型号、时钟、外设配置好,生成基础工程,再往里加AI部分。不要在一个空工程里直接搞AI,外设没配好后面调试很麻烦。
4.2 添加模型并分析
在Cube.AI的界面里添加你导出的ONNX文件,工具会自动分析模型结构,给出几个关键指标:
| 指标 | 含义 | 关注点 |
|---|---|---|
| MACC | 乘加运算次数 | 反映计算量,越小越好 |
| Weights | 权重占用的Flash | 决定模型能不能装下 |
| Activations | 中间激活占用的RAM | 决定运行时内存够不够 |
| Flash总占用 | 权重+代码 | 对比芯片Flash容量 |
| RAM总占用 | 激活+栈 | 对比芯片SRAM容量 |
这几个数字出来之后,第一件事是对比你的芯片资源。如果Flash或者RAM超了,就得回去改模型结构,或者换更大资源的芯片。
我遇到过一次,模型权重只有80KB,但激活占了200KB,而芯片只有128KB SRAM,直接跑不起来。后来把中间层的通道数减半,激活降到90KB才通过。所以激活内存往往比权重更容易成为瓶颈,尤其是全连接层比较多的模型。
4.3 生成代码时的选项
Cube.AI生成代码时会有几个选项:
- Report:生成一份详细的分析报告,包含每一层的资源占用和耗时估算。这个一定要生成,调优的时候全靠它。
- Application template:可以生成一个带示例调用的模板,方便你快速上手。但正式项目里我一般不用模板,自己写调用逻辑更清晰。
- Compression:可以对权重做压缩,减少Flash占用,但会增加一点解压时间。Flash紧张的时候可以开。
生成之后,工程里会多出一个X-CUBE-AI目录,里面是推理引擎的源码和你的模型数据。
4.4 调用推理的代码结构
Cube.AI生成的API大致是这样的:
#include "app_x-cube-ai.h" #include "ai_platform.h" // 创建模型实例 ai_handle network = AI_HANDLE_NULL; ai_error err = ai_my_model_create(&network, AI_MY_MODEL_DATA_CONFIG); if (err.type != AI_ERROR_NONE) { // 创建失败处理 } // 初始化 const ai_handle acts[] = { ai_my_model_activations }; ai_my_model_init(network, acts, 1); // 准备输入输出buffer ai_buffer* ai_input = ai_my_model_inputs_get(network, NULL); ai_buffer* ai_output = ai_my_model_outputs_get(network, NULL); float input_data[128]; float output_data[4]; ai_input[0].data = AI_HANDLE_PTR(input_data); ai_output[0].data = AI_HANDLE_PTR(output_data); // 执行推理 ai_i32 n_batch = ai_my_model_run(network, ai_input, ai_output); if (n_batch != 1) { // 推理失败处理 }这段代码看起来简单,但实际用的时候有几个地方容易出问题。
第一,ai_input[0].data指向的buffer,其数据类型要和模型输入类型匹配。如果模型是浮点的,就用float数组;如果是量化的,就要用int8数组,并且要注意量化参数的换算。
第二,输入数据的预处理要和训练时完全一致。训练时如果做了归一化,推理时也要做同样的归一化。我见过有人训练时把数据归一化到0到1,推理时忘了做,结果输出完全不对。
第三,推理是阻塞的,ai_my_model_run会一直占用CPU直到算完。如果对实时性有要求,要么把推理放在低优先级任务里,要么用Cube.AI提供的异步接口。
5. 实测性能与内存调优:那些报告里不会告诉你的事
5.1 实测耗时和理论估算的差距
Cube.AI的报告里会给一个每层的耗时估算,但那个估算是基于理想情况的。实际跑起来,耗时会受几个因素影响:
- Flash等待周期。如果CPU主频跑得比Flash访问速度快,就需要插入等待周期,这会拖慢取指和取数。开ART加速器或者把关键代码放到RAM里执行能缓解。
- Cache命中率。H7系列有Cache,如果模型数据频繁换入换出,Cache miss会明显拖慢速度。
- DMA和CPU争抢总线。如果推理的同时还有DMA在搬数据,总线仲裁会带来额外延迟。
我的实测经验是:报告里的估算值乘以1.5到2,是比较接近实际的数字。比如报告说1ms,实际可能在1.5到2ms之间。
5.2 内存不够时的几个优化方向
如果激活内存超了,可以按下面的顺序尝试:
- 减小中间层通道数。这是最直接有效的办法,通道数减半,激活内存大致减半。
- 用全局池化替代全连接。全连接层的激活占用往往很大,用全局平均池化能显著降低。
- 缩短输入序列长度。如果输入是时序信号,把窗口长度从256降到128,激活内存也会跟着降。
- 开启Cube.AI的内存复用。Cube.AI默认会做激活内存的复用分析,但有时候需要手动调整。在配置里可以指定是否允许复用。
- 换更大RAM的芯片。如果上面都试过了还是不够,那就只能换芯片了。F4系列里不同型号的SRAM差别很大,选型的时候要留足余量。
5.3 量化模型的精度验证
如果你用了int8量化,一定要做精度验证。方法是在PC上用一批测试数据跑浮点模型,记录输出;再用同样的数据跑量化模型(Cube.AI提供了PC端的验证工具),对比两者的输出差异。
# 用Cube.AI的Python验证工具 from ai_runner import Runner runner = Runner("model_int8.onnx") runner.init() for data, label in test_dataset: output = runner.run(data) # 对比output和浮点模型的输出如果精度掉得太多,可以考虑:在训练时加入量化感知训练、调整量化时的校准数据集、或者对敏感层保持浮点。
6. 踩坑实录:从转换失败到推理结果不对的完整排查链路
6.1 转换阶段报"unsupported operator"
这是最常见的问题。Cube.AI报某个算子不支持,你需要:
- 在报告里找到是哪个算子。
- 回到PyTorch,看这个算子对应的是哪一层。
- 用支持的算子替换它。比如某些自定义的激活函数,换成ReLU或者HardSigmoid。
- 重新导出ONNX,重新转换。
如果实在找不到替代方案,可以考虑把这个算子拆成几个基础算子的组合。比如Swish可以拆成Sigmoid乘以x。
6.2 转换成功但推理结果全是0或者NaN
这种情况通常是输入数据的问题。排查步骤:
- 检查输入buffer有没有正确赋值。有时候指针赋了,但数据没填进去。
- 检查输入数据的范围。如果模型期望的是归一化后的数据,你喂了原始数据,输出可能就饱和了。
- 检查量化参数。如果是量化模型,输入需要按照量化公式换算成int8,输出也要反量化回浮点。这一步很容易搞错。
6.3 推理结果在PC上对,在STM32上不对
如果PC上验证过ONNX模型是对的,但STM32上跑出来不对,问题通常出在:
- 数据预处理不一致。PC上验证时用的预处理和STM32上的预处理要完全一样。
- 浮点精度差异。PC上是双精度,STM32上是单精度,某些数值敏感的模型会有差异。可以在PC上用float32验证一下。
- 内存越界。激活buffer或者输入输出buffer越界,会破坏其他数据。检查一下Cube.AI报告里的内存占用,确认栈空间够不够。
6.4 推理速度比预期慢很多
除了前面说的Flash等待周期和Cache问题,还有一个容易被忽略的点:编译优化等级。Cube IDE默认可能是-O0或者-Og,改成-O2或者-O3能明显提升推理速度。但要注意,高优化等级可能会让调试变困难,建议在最终版本再用。
另外,如果芯片支持,开启指令Cache和数据Cache,对推理速度提升也很明显。
7. 这套方案适合什么场景,不适合什么场景
做了几个项目之后,我对STM32跑AI的适用边界有了比较清晰的认识。
适合的场景:输入是低维特征向量(几十到几百维)、模型是浅层网络(几层到十几层)、对延迟敏感、需要离线运行、批量部署对成本敏感。典型应用包括电机故障检测、简单的手势识别、关键词唤醒、传感器数据分类。
不适合的场景:输入是高分辨率图像、模型是深层网络(几十层以上)、需要频繁更新模型、对精度要求极高。这些场景还是得上MPU或者带NPU的专用芯片。
STM32跑AI不是要替代云端,而是在特定场景下提供一个更合适的方案。理解它的能力边界,比盲目追求"把大模型塞进MCU"要有意义得多。
最后分享一个我在实际项目里养成的习惯:每次改完模型结构,先在PC上用onnxruntime跑一遍完整测试集,确认精度没问题,再走Cube.AI转换。这样能把问题定位在训练侧还是部署侧,省掉大量来回折腾的时间。