这两年我一直想把神经网络往单片机上塞,不是为了赶时髦,是真的有需求:传感器采集端想直接做识别,数据又不想绕到云端兜一圈。STM32作为最常用的MCU平台,搭配PyTorch训练的模型,中间隔着的并不是一段代码,而是一整条精度、内存、算子支持的适配链路。这篇文章把我实际跑通的一条路完整拆开,从PyTorch模型训练、轻量化压缩,到最终在STM32上落地推理,给你一套可以直接抄的作业。
这个项目适合谁?你要是做嵌入式数据采集、边缘小盒子,或者正在搞毕业设计想给作品加个“智能识别”,都值得看一遍。我会把方案选型、量化思路、CubeMX集成和常见问题全写清楚,整个过程不需要树莓派级别的算力,一块几十块钱的STM32开发板就能跑起来。
1. 在STM32上跑神经网络:先想清楚方案再动手
1.1 为什么非要在MCU上部署:边缘推理的3个硬需求
日常接触的单片机项目里,很多人一提到AI就默认要上电脑、上服务器。但实际做产品你会发现,有几种场景必须把模型放在MCU本地:
第一是实时性,工业现场判断一个包裹是否损坏、设备是否异响,数据从传感器采回来到输出结果要在几十毫秒内完成,网络往返那几百毫秒根本扛不住。第二是隐私和安全,有些设备采集的数据是带个人信息或工艺参数的,不能往外传,本地推理直接避免数据外泄。第三是成本和功耗,一个带MCU的模组功耗可以做到几十毫瓦,而跑Linux的小主机起步就是几瓦,电池供电的产品没有第二种选择。
STM32在MCU里的地位不用多说,F1系列出货量巨大,F4/H7性能也能满足中等规模的卷积网络。这类芯片的典型资源是主频72MHz到480MHz,Flash从64KB到2MB,RAM从20KB到1MB不等。在这种资源约束下,几亿参数的模型想都别想,我们要做的是把模型压到几十KB到几百KB,算子精简到工具链认识的那几个。
1.2 工具链选型对照:X-CUBE-AI、TFLite Micro与手写C
从PyTorch训练好的模型到STM32能跑的C代码,目前主流的路线有三条。
第一条是ST官方工具链X-CUBE-AI。它集成在STM32CubeMX里,直接导入ONNX或Keras模型,一键生成C代码。优点是和CubeMX生态无缝衔接,支持绝大多数STM32系列,并且内置针对Cortex-M内核的优化算子库,还会给出详细的RAM/Flash/周期数评估报告,这是我最推荐新手走的路。
第二条是TFLite Micro。PyTorch模型先转ONNX,再转TFLite,然后用TFLite Micro的运行时在MCU上执行。这套方案灵活性很高,社区活跃,还能配合CMSIS-NN加速,但在STM32上需要自己准备解释器、自己做内存池分配,集成成本比X-CUBE-AI高不少。
第三条是纯手写C语言推理代码。把训练好的权重导出成数组,然后自己实现卷积、池化、全连接。这种方式适合极小模型或者教学演示,但一旦网络结构改动,代码维护成本会暴涨,别指望它能应付真实项目。
我最终选择的是X-CUBE-AI,原因有三个:一是X-CUBE-AI支持从ONNX直接转换,省掉TFLite那一步;二是它生成的网络评估报告非常有用,部署前就能预估资源占用;三是ST官方在Cortex-M内核上做了大量底层优化,同样是卷积,比自己写的循环快好几倍。
| 方案 | 转换链路 | STM32集成难度 | 算子支持 | 资源优化 |
|---|---|---|---|---|
| X-CUBE-AI | PyTorch/ONNX/Keras -> C | 低(CubeMX原生集成) | 常见算子支持完整 | 自动调用CMSIS-NN/DSP库,提供详细评估报告 |
| TFLite Micro | PyTorch -> ONNX -> TFLite -> C | 中(需要手动集成运行时) | 算子覆盖面广,但部分算子要自己适配 | 依赖CMSIS-NN,内存分配需自己管 |
| 手写C推理 | 导出权重 -> 手写计算 | 高(模型一变全重来) | 完全由自己控制 | 基本没有自动优化 |
2. 训练好一个“能塞进MCU”的模型,从PyTorch开始
2.1 面向单片机的网络结构设计:参数数量和算子类型都要控制
很多人第一步就栽了:训练时用的ResNet50,精度确实高,但转成ONNX一看400多MB,STM32的Flash装都装不下。在MCU上跑模型,必须从设计阶段就想着“轻量化”这三个字。
我的经验是,面向MCU的模型要同时满足三条:参数少、算子类型少、中间特征图小。参数直接决定Flash占用,特征图决定RAM峰值,算子类型决定工具链能不能成功转换。
用一个我在工业瑕疵识别里常用的轻量CNN为例,输入是32x32单通道灰度图,结构是三组卷积加池化:第一组4个3x3卷积核,第二组8个3x3卷积核,第三组16个3x3卷积核,最后接全局平均池化,再接一个10分类全连接层。整体参数大约是4×3×3+8×4×3×3+16×8×3×3,这里把细节展开算:
第一层卷积:输入1通道,输出4通道,3x3卷积核,参数为4×1×3×3=36,加上偏置4,共40个。第二层卷积:输入4通道,输出8通道,参数为8×4×3×3=288,加偏置8,共296个。第三层卷积:输入8通道,输出16通道,参数为16×8×3×3=1152,加偏置16,共1168个。全连接层:经过三层卷积后如果输入是32×32,第一次卷积不改变尺寸,第二次池化后变成16×16,第三次卷积后还是16×16,第二次池化后变成8×8,那么全连接输入就是16×8×8=1024个特征,输出10类,参数为10240+10=10250个。整体加起来约11754个参数,按float32保存也就47KB,按int8量化后不到12KB。
这里的关键是:输入图像不要贪大。32×32的输入在CIFAR级别任务够用,很多工业检测问题黑白图也能解决,因为MCU不擅长处理大图,一张224×224的RGB图光输入特征就是150KB,RAM已经接近低端芯片极限了。
算子方面,X-CUBE-AI对Conv2d、AveragePool、MaxPool、Gemm(即全连接)、Softmax这些基础算子支持得很好。但像Transformer里的LayerNorm、Attention,或者PyTorch里的Fold、Unfold这类特殊算子,转换时很容易报不支持。所以设计网络时尽量只用经典CNN组件,如果你非要用新结构,先在ONNX里用onnxruntime跑通,再交给X-CUBE-AI验证。
2.2 环境搭建与训练流程:Anaconda装PyTorch那点事
部署的前半段是模型训练,这一步环境搭建很多人会卡住,其实完全没必要上GPU,数据量不大CPU也能训。我习惯用Anaconda创建独立环境,方便在不同项目之间切版本,避免依赖冲突。
安装命令很固定,先建环境再装PyTorch:
conda create -n stm32 python=3.9 conda activate stm32 conda install pytorch torchvision cpuonly -c pytorch注意这里装的是CPU版,因为MCU部署场景下的模型都很小,用CPU训练几分钟就能收敛,完全没必要为了一个千分类小模型去折腾CUDA。如果你电脑装了NVIDIA显卡想快一点,再把cpuonly换成pytorch-cuda=11.8之类的版本,但这不是必须的。
训练流程没什么特别,和普通PyTorch项目一样:加载数据集、定义模型、交叉熵损失、Adam优化器、训练若干epoch。值得强调的是,训练完成后一定要记录验证集上的准确率,最好把每一类的准确率也打出来。为什么?后面量化部署后如果整体精度掉了2%,你可以对比是不是某一类被量化噪声干扰,这能帮你快速定位问题出在量化还是预处理。
训练完保存模型,推荐只保存state_dict:
torch.save(model.state_dict(), "best_model.pth")下次加载时先new一个模型再load,这样最稳妥,不会因为类路径改动导致加载失败。
3. 模型轻量化实操:ONNX导出、int8量化与验证
3.1 量化为什么是必选项:算一笔Flash与RAM的账
模型训练阶段默认是float32精度,但STM32的Flash和RAM都很宝贵。我就算这么一笔账:一个参数5万的模型,float32存储是5万×4字节=200KB,这还只是权重,不算代码和中间结果。很多入门级STM32 Flash只有64KB到128KB,直接打破预算。如果换成int8存储,5万×1字节=50KB,直接瘦身四分之三,大多数芯片都能装下。
量化带来的另一个好处是可能推理更快。MCU里的乘法运算,int8的SIMD指令效率很高,ST官方的CMSIS-NN库甚至专门为int8卷积做了加速。实际项目里,int8量化后的推理时间往往比float32快2到4倍。
但量化的代价是精度损失。这种损失来自“舍入误差”,float32能表示的很细的小数,int8只能分成256个等级,如果参数分布比较极端,精度就会掉得明显。解决思路有两个:训练后量化(PTQ)和量化感知训练(QAT)。PTQ简单,直接拿训练好的模型跑一小段校准数据,统计权重和激活值的范围,然后线性映射到int8区间。QAT则是训练阶段就模拟量化误差,让模型参数适应量化扰动,精度保持更好但训练时间更长。
我的建议是先PTQ试一试。很多轻量模型冗余度很高,PTQ后精度掉不到1%。如果掉了超过2%再考虑QAT。这句话值得屏幕前的你抄进笔记里:先简单方案,后复杂方案,不要在没发生真正精度问题时引入额外复杂度。
3.2 ONNX导出与静态量化:从PyTorch到可部署文件的完整操作
PyTorch模型转ONNX,代码量很小但有几个细节必须注意。先看完整代码:
import torch from model import LightCNN model = LightCNN(num_classes=10) model.load_state_dict(torch.load("best_model.pth", map_location="cpu")) model.eval() dummy_input = torch.randn(1, 1, 32, 32) torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], opset_version=17, dynamic_axes=None, )导出的几个关键点得说清楚。第一个是model.eval(),这一步必须做,否则模型里的dropout和batchnorm对应的训练状态会影响推理输出。第二个是dummy_input的形状要和真实输入一致,导出时ONNX会把输入输出的shape固定下来,后面部署时也按这个shape来准备数据。第三个是opset_version,不要一味求新,X-CUBE-AI对不同版本opset的兼容程度不一样,我一般固定17,太新的opset可能在工具链里出莫名奇妙的算子错误。
导出之后先验证一下ONNX是否正确:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("model.onnx") dummy = np.random.randn(1, 1, 32, 32).astype(np.float32) ort_out = sess.run(None, {"input": dummy})[0] print(ort_out.shape)这一句能验证ONNX文件和PyTorch模型在相同输入下的输出是否一致。如果不一致,问题通常出在模型结构里有不支持的操作,或者导出时train和eval状态没切换对。
接下来是量化。ONNX Runtime提供了现成的量化工具,最简单的是动态量化:
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "model.onnx", "model_int8.onnx", weight_type=QuantType.QInt8, )动态量化只量化权重,激活值还是float,代码最简单。X-CUBE-AI支持这种int8权重的ONNX输入,转换时会在MCU侧做反量化处理。如果追求更低RAM占用,可以试静态量化,需要一组校准数据来统计激活范围,代码会多一些,但效果也更彻底。第一次上手不建议静态量化,先用动态量化把链路跑通,后面再回来精细化调优。
4. STM32工程集成:CubeMX、X-CUBE-AI与IDE联调
4.1 CubeMX配置:把模型导入工程并生成C代码
模型文件准备好之后,进入部署环节。我用的是STM32CubeMX加Keil MDK的组合,这是STM32项目最常见的开发模式。
启动CubeMX后,先选择一个具体型号。我用STM32F407VET6和STM32H743VIT6都跑过这个流程。F407是Cortex-M4F,带FPU,主频168MHz,Flash 512KB,RAM 128KB,跑小型CNN绰绰有余;H743是Cortex-M7,主频480MHz,适合对推理速度有更高要求的场景。
时钟配置不用多说,一般把主频拉到满,F407就是168MHz,H743是480MHz。有一点需要特意检查:在Project Manager -> Advanced Settings里确认Generate peripheral initialization as a pair of .c/.h files per peripheral是打开状态,方便后面改代码。中间件的选择里,找到Software Packs -> Select Components,勾选X-CUBE-AI,版本我当前用的是9.1.0。CubeMX会把AI Runtime代码自动集成到工程里,这点非常省事,不需要手动复制库文件。
然后进入X-CUBE-AI的配置页,点击Add Network,选择我们生成的model_int8.onnx。工具会先解析模型,如果模型太大或算子不支持,它会直接报错。解析成功后,右侧会出现模型的层结构、参数个数、要求的内存大小,以及一个简单分析报告。此时可以设置Optimization模式,选项有latency、ram和general三种。想快就选latency,想省RAM就选ram。我的习惯是先选ram,因为嵌入开发里RAM往往比Flash更稀缺,而且很多芯片Flash可以用外部扩展,RAM不行。
配置完之后直接Generate Code,CubeMX会生成整个工程,包括AI Runtime初始化代码和模型权重数据文件。生成完不要急着改业务逻辑,先用默认代码编译一次,确认工具链链条是通的。
4.2 模型推理代码与耗时统计:用DWT做精准计时
代码生成后,推理的核心接口其实就那么几个。我以X-CUBE-AI的C API为例写一套最简调用逻辑:
#include "ai_platform.h" #include "network.h" #include "network_data.h" ai_handle network = AI_HANDLE_NULL; static ai_network_report report; AI_NETWORK_INPUT(input); AI_NETWORK_OUTPUT(output); void AI_Init(void) { ai_network_create(&network, AI_NETWORK_DATA_CONFIG); ai_network_init(network, NULL); ai_network_get_info(network, &report); } float AI_Predict(float *data, int len) { ai_network_input_set(network, ai_network_inputs_get(network), &input); for (int i = 0; i < len; i++) { ((float*)input[0].data)[i] = data[i]; } ai_network_run(network); ai_network_output_get(network, ai_network_outputs_get(network), &output); float *out_ptr = (float*)output[0].data; return out_ptr[0]; // 二分类场景返回正类概率 }这套代码里有几个容易踩坑的地方。AI_NETWORK_INPUT和AI_NETWORK_OUTPUT这两个宏展开后是一段缓冲区定义,必须在全局作用域声明,不能放在函数里,否则内存会被分配在栈上直接爆RAM。ai_network_init(network, NULL)第二个参数可以传入一个回调结构体,用来把每个layer的输出打印出来做调试,平时不需要传NULL就行。
接着看推理耗时统计。很多人的做法是调用HAL_GetTick(),但HAL_GetTick的分辨率是1ms,对于几十毫秒内完成的推理太粗糙。更准确的做法是用DWT的周期计数器,Cortex-M4和Cortex-M7内核自带这个功能,开启方法也不复杂:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; uint32_t start = DWT->CYCCNT; ai_network_run(network); uint32_t cycles = DWT->CYCCNT - start; float time_ms = (float)cycles / (SystemCoreClock / 1000.0f); printf("inference time: %.3f ms\n", time_ms);这样计时精度能到几个时钟周期,对评估模型性能足够用了。实测定下来,那个1.1万参数的三层CNN在F407上大概跑2ms左右,Flash增量在30KB上下,RAM增量在10KB上下;换到H743后推理能压到1ms以内。如果你的模型参数量再多一点,推理时间会线性增长,所以不要盲目加大模型。
4.3 Keil开发环境与USB识别那类常见坑
工程生成后,就要用Keil MDK打开编译环境。这里遇到最典型的问题是“Keil5怎么装STM32设备包,为什么我新建工程找不到STM32F407”,以及“芯片能编译但下载时报错”。我单独说明一下。
Keil MDK和Keil C51用的是同一个安装器界面,很多人先装了C51版本的Keil,再装MDK会互相干扰,最常见的是Pack Installer下载不了设备包。解决办法有三个步骤:第一步用管理员身份运行Pack Installer,第二步在Tools -> Pack Installer里勾选Keil::STM32F4xx_DFP设备包,第三步检查网络,很多公司内网会把keil的pack下载服务器屏蔽,导致设备包一直转圈。如果实在装不上,可以到Keil官网下载离线pack,手动双击安装。
另一个高频问题是STM32无法识别USB设备。这里得分情况:如果芯片本身没有USB外设,你需要在板上外接USB转串口芯片,比如CH340或CP2102,这时电脑识别不到设备是正常的,因为USB枚举的是转串口芯片而不是MCU;如果芯片带USB外设,识别不了通常是晶振配置不对或者VUSB引脚的启动模式没设置。对于模型部署调试来说,串口完全够用,我一般是UART输出日志和预测值,USB都是后话。
下载器方面,ST-Link驱动装好后,在Keil的Options -> Debug里选择ST-Link Debugger,点击Settings如果能读到IDCODE就是正常的。很多下载失败是因为SWDIO和SWCLK引脚被复用成了GPIO,初始化时不要动这两个引脚,或者用Connect under Reset模式强行连接。
5. 部署实战中的问题排查与避坑速查表
5.1 模型转换失败:算子不支持的3种排查思路
X-CUBE-AI转换报错最常见的一种是Operation not supported,或者Unsupported operator。很多刚接触TinyML的人看到这个直接懵,其实排查思路是有套路的。
第一种情况是ONNX版本太新,某些新算子还没有被X-CUBE-AI收录,解决办法是降低opset_version重新导出,比如从17降到15,有时候问题就消失了。第二种情况是模型中带了工具链不认识的动态算子,比如torch.where、torch.topk,这类操作在训练时很常用,但推理时未必需要。先检查模型结构,把不参与推理的计算全部去掉。第三种情况是Flatten或Reshape相关错误,如果报的是维度问题,可以考虑在保证结构不变的前提下,把torch.flatten替换成torch.reshape再导出,或者干脆用全局平均池化直接把特征图压成一维向量。
我见过一个很无语的坑:模型里某个卷积层用了padding_mode='reflect',PyTorch里能跑,但ONNX转换后工具链不认这个padding模式,最后把padding_mode改成默认的零填充就过了。所以经验是:部署模型里只保留最基础、最标准的算子,宁可牺牲一点精度,也要保证能转、能跑。
5.2 PC推理和MCU推理结果对不上怎么办
模型在电脑上跑得好好的,一放到STM32上预测结果就乱七八糟,这个是群里被问烂的问题。绝大多数情况不是模型部署坏了,而是输入预处理不一致。
PyTorch训练时如果做了归一化,比如图像要除以255,或者减去均值再除以标准差,那么MCU侧喂给模型的数值也必须是同样的处理。我见过一个项目,训练时输入是0到1的浮点数,部署时忘记把传感器原始数据做归一化,直接喂了0到255的原始值,结果模型输出全是一类,怎么调都白搭。另外还要检查输入数据的排列顺序,PyTorch默认是NCHW,也就是通道维度在第二位,如果你在MCU侧把数据存成了HWC,那么即使数值对,模型看到的却是乱序的通道,推理自然也错。
还有一个容易忽略的点是字节对齐。Keil MDK在编译时默认4字节对齐,但工程师可能因为某些原因改了#pragma pack,这会导致模型权重数组被错误解析。排查方法很简单:在PC端把某个固定输入向量先导出结果,然后在MCU侧用同样的数据跑一遍,比较输出的数值。如果结果完全一致,说明链路通;如果不一致,再打印输入数据的十六进制,确认MCU侧拿到的字节和PC端一致。
5.3 Flash与RAM不够用:降低资源占用的5个办法
模型转换成功后,X-CUBE-AI会给出资源占用报告。如果报错说RAM不够,不要慌,按优先级尝试下面几个方案。
降输入分辨率最直接。输入从32×32降到16×16,中间特征图的内存占用按宽度高度两个维度同时降,理论上减少到四分之一,这对RAM的缓解立竿见影。压缩网络宽度也很有效,比如把第一层卷积通道数从16降到8,特征图和权重的体积都能瘦一圈。如果量化还没做,优先做int8量化,权重一次性缩小到原四分之一,RAM也有明显改善。x-cube-ai的Optimization模式也很关键,原来选latency的改成ram模式,工具会用“时间换空间”的策略复用中间缓存。最后,如果模型实在大,比如几百KB的权重,可以把权重放到外部串行Flash里,运行时按层加载进内存,这个方案复杂度高,但是很多产品化的做法。
Flash不够的情形相对少一些,因为Flash可以外扩。要注意的是优化RAM时常以增大代码体积作为代价,如果是Flash紧张,反过来选latency模式会更好。这两种模式之间的取舍,最好结合工具的分析报告反复试验。
5.4 推理太慢:从硬件FPU到CMSIS-NN的提速路线
推理时间测出来不理想,先别看算法问题,先检查工程配置对不对。第一要确认芯片的FPU已经开启了。Cortex-M4F/M7都有硬件浮点单元,但如果你创建工程时没有勾选Use Single Precision FPU或者启动代码里没使能FPU,浮点运算会回调到软件计算,慢得离谱。CubeMX生成代码时默认会开FPU,但如果是从旧工程改造过来的,就要自己检查SystemInit或SCB->CPACR寄存器。Keil里还有编译选项,C/C++页面的FPU: Single Precision必须选上,同时Optimization: Level 3,不然代码性能差很多。
第二要开启CMSIS-NN优化。在X-CUBE-AI配置页,切换Advanced选项,勾选Use optimized network,工具链会调用业内成熟的内核级优化代码,实测卷积层的加速效果至少有2倍以上。第三才是优化模型本身。通道数减一半,推理时间近似减半,这个数学关系是线性的,所以如果就差那么几毫秒,砍通道往往是最简单粗暴的解法。
# Keil编译配置参考 C/C++ -> Language/C: C99 C/C++ -> Optimization: -O3 C/C++ -> Floating Point: Single Precision说实话,在MCU上跑神经网络最消耗时间的往往不是模型训练,而是调试部署链路。我第一次跑通从PyTorch到STM32F407的完整流程,前后折腾了整整两个周末,踩遍了算子不支持、量化精度崩掉、CubeMX配置错乱这些坑。后来把流程固化成了标准步骤,再训新模型直接照搬,半天就能完成从训练到板上推理。这套东西做熟练之后,你会发现MCU上的AI没有想象中那么神秘,本质上就是一个“资源约束下的模式匹配工具”,只要把参数量、算子类型、量化方式这三个变量管住,绝大多数想法都能在这颗小芯片上跑起来。