1. 跑在ARM Cortex-M上的神经网络到底图什么
很多人第一次听说“STM32部署神经网络”时的反应几乎一致:单片机那么点资源,跑神经网络是不是在开玩笑?我刚开始接触这个方向的时候也是这么想的。直到真正把一个小模型从PyTorch搬上一块不到十块钱的Cortex-M芯片,才发现这个组合非但不荒谬,反而在很多场景里是性价比极高的方案。
先说结论:STM32这种MCU不适合跑大模型,但它非常适合跑那些经过轻量化设计的“小网络”。比如唤醒词识别、异常声音检测、简单图像分类、传感器数据模式判断、预测性维护里的振动特征识别,这些任务模型都不大,输入很小,推理频率也不高,完全可以在端侧本地完成。好处很直接:数据不出设备,没有网络延迟,不依赖云端连接,功耗控制更自由,长期运行没有服务器成本。
我自己踩过一条弯路,一开始总想着把PC上跑得好好的模型直接塞进单片机,结果被Flash、RAM和算力三座大山压得动弹不得。后来才意识到,MCU上做AI,选对任务边界比选对模型结构更重要。
1.1 STM32的资源账本:Flash、RAM、算力三本账
以最常见的STM32F103为例,主流型号内部资源大致是64KB~512KB Flash、20KB~64KB SRAM,主频72MHz。稍微高级一点的STM32F401/F407系列,主频到84MHz/168MHz,RAM和Flash也会翻倍,但本质依然是单片机层级。对比GPU动辄几十GB显存、几百万线程并行,MCU的算力差距是数量级的。
所以部署前必须先算账。一个32×32×3的输入,第一层卷积特征图32×32×16,光这一个激活张量就是49KB的浮点数,F103的20KB RAM直接放不下。这就是为什么MCU上做视觉任务,输入分辨率必须瘦身,通道数必须克制,全连接层能不用就不用。我在后面第二三章会给出一个比较完整的账本算法,先把Flash和RAM两条线缕清晰:
- Flash:模型权重、推理库代码、中断服务程序、HAL库全都住在这。模型量化到int8之后,一万个参数只占10KB左右,这块压力通常比想象中小。
- RAM:输入缓存、中间激活值、输出缓存、网络层工作区全在这。RAM的规划比Flash更严格,这也是为什么激活函数和池化的位置、内存复用策略会直接决定模型能不能跑起来。
1.2 什么模型适合搬到MCU,什么不适合
适合的模型有几个共同特征:输入分辨率小,网络层数浅,结构以卷积、全连接、ReLU、MaxPool这类经典算子为主,模型参数在几十万以内,最好十万都不到。MNIST缩放到8×8的手写数字识别、基于加速度计的姿态分类、单通道心电信号分类,都属于这个范围。
不适合的则包括:注意力机制满天飞的Transformer类模型、YOLO这种大目标检测头和复杂特征金字塔结构、需要处理长序列的RNN/LSTM。不是说完全没可能,而是对于通用MCU来说,这类模型的存储和计算开销不划算。想跑YOLO级别的检测任务,建议直接选带NPU的芯片或者SoC,别为难Cortex-M内核。
知道什么能跑、什么不能跑之后,接下来才是正经事:怎么训练一个适合部署的模型。
2. 模型准备:用PyTorch训练一个真正称得上轻量的CNN
这一章我以手写数字识别MNIST为例来讲完整流程。之所以选MNIST,是因为它足够经典、数据公开、模型结构简单到可以完整展开每一步。实际工程里换成自己的传感器数据,流程完全一致。
环境部分我先说几句。PyTorch官方对CPU和GPU都支持,如果你的机器有NVIDIA显卡,装CUDA版本会快一点;没有显卡也不要紧,MNIST这么小的数据集,CPU训练也就几分钟。我习惯用conda建独立环境,避免不同项目之间包冲突:
conda create -n stm32ai python=3.9 conda activate stm32ai pip install torch torchvision onnx onnxruntime安装完成后可以检查一下版本是否正常,python -c "import torch; print(torch.__version__)",能打印出版本号就说明环境没问题。
2.1 轻量化设计的核心:不要用大模型硬剪
很多从算法岗转过来的人,第一反应是把ResNet或者MobileNet拿来“剪几刀”。这个思路在MCU上容易走偏,MobileNet这类模型本身是为了手机端的Mobile GPU设计的,虽然比大模型轻,但对单片机来说依然超重。真正高效的路径是从头的“设计轻模型”,而不是事后的“压缩重模型”。
设计轻量化CNN有一个我认为最重要的原则:优先缩减空间尺寸,其次缩减通道数,最后考虑深度。因为一般MCU部署的瓶颈往往在RAM,也就是中间特征图的大小。尺寸下降一半,特征图面积变成四分之一,RAM占用立刻大幅下降。通道数下降一半,特征图面积不变,RAM只是减半。深度增加则主要影响Flash和Flash读取带宽,和RAM关系相对小一点。
对应到MNIST这个任务,我设计了一个很小的网络:输入8×8×1的灰度图像,第一层卷积8个3×3卷积核,经ReLU和2×2最大池化后变成4×4×8;第二层卷积16个3×3卷积核,ReLU和2×2最大池化后变成2×2×16;然后直接把2×2×16展平成64维向量,接一个输出10类的全连接层。整个网络参数量大概1900个左右,浮点权重7.6KB,int8量化后不到2KB。
你可能想问:8×8的图像还能认出数字吗?实测下来,MNIST原图28×28缩放成8×8后,人类肉眼要认确实费劲,但神经网络在这种情况下依然能拿到95%以上的验证准确率。这是因为数字结构信息在8×8分辨率下依然保留着。所以不要被“越小越看不清”的直觉吓住,数据证明小输入配合小模型在简单任务上是可行的。
2.2 PyTorch训练的关键抉择与导出ONNX
模型定义代码其实很朴素:
import torch import torch.nn as nn class TinyCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Linear(16 * 2 * 2, 10) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)训练里我固定用了Adam优化器,初始学习率0.001,batch size 128,循环10轮左右就够了。MNIST数据下载之后不能直接用,需要把28×28缩放到8×8,最简单的方式是torchvision.transforms.Resize((8, 8))。训练时对输入做归一化,把像素从0~255缩放到0~1,这和后面的量化部署是有关系的,千万别忽略。
一个很重要的选择:forward最后直接返回logits,不再套Softmax。分类任务里Softmax的作用是把logits变成概率分布,但训练用的CrossEntropyLoss内部已经包含了LogSoftmax逻辑,训练时不需要手动加。部署时更不需要Softmax,因为我们只关心argmax到底选中哪个类别,原始logits的大小关系已经能反映分类结果。Softmax在单片机上是纯粹的浪费,还可能增加算子兼容性问题,所以我后面的导出环节也是把没有Softmax的模型导出为ONNX。
导出ONNX时有一个最容易出错的点:输入张量的形状必须固定。因为MCU推理引擎需要静态的输入输出尺寸,动态维度在部署工具链里往往支持得很差。导出代码就几行:
model.eval() dummy_input = torch.randn(1, 1, 8, 8) torch.onnx.export( model, dummy_input, "tiny_mnist.onnx", input_names=["input"], output_names=["output"], opset_version=13, dynamic_axes=None, )dynamic_axes=None就是强制固定维度。opset_version用13是一个比较稳的版本,主流工具链基本都能解析。导出后我用onnxruntime加载跑了一遍,确保输出和PyTorch一致,这里省略了代码,但强烈建议每一步都做这种“对照验证”,部署出问题的时候能快速定位是哪一环节引入的偏差。
2.3 训练结果与对这个模型的期望值
我也顺便把训练中的一些现象写出来供你参考。这个极简模型在验证集上的准确率,我这边普通训练能到95%~96%,偶尔跑得好一些能到97%。相比原版MNIST在28×28输入下99%以上的准确率,这确实是有明显差距的,但它换来了极小的体积和MCU上可接受的推理时间。量化之后准确率会有轻微下降,通常还有95%上下,这对于很多工业场景足够用了。
如果你要部署的是自己的二分类或少数几分类传感器数据,输入可以更小,模型可以更薄,准确率和资源占用之间的平衡会更好。相反,如果你要识别几万类商品,那就不是MCU该干的事。
3. 导出与量化:从浮点ONNX到INT8的关键一战
模型训练好,ONNX也导出来了,这时候如果把浮点模型直接扔给单片机,倒不是完全不能跑,但非常不划算。MCU的算力天然有限,浮点计算在Cortex-M0/M3上要靠软件库模拟,慢到让人怀疑人生。Cortex-M4带FPU会好一点,但跑浮点卷积依然比定点慢很多。量化就是这个环节的破局点。
3.1 量化的本质:INT8到底做了什么
量化的核心思路是把神经网络的权重和激活值从FP32的连续数值范围,映射到INT8的256个离散等级上。以最常见的非对称量化为例子,数学表达式是:
q = clamp(round(r / scale) + zero_point)
其中r是原始浮点值,scale是缩放因子,zero_point是零点偏移,q是量化后的整数。反量化的时候用:
r = (q - zero_point) * scale
这意味着原来用4字节表示的浮点数,现在只用1字节整数,权重体积直接缩到四分之一。更重要的是,CPU在做卷积乘加运算时,INT8乘法速度远快于软件浮点乘法,并且可以利用Cortex-M4/M7的DSP指令进行优化。
3.2 为什么部署前后的精度会下降
量化必然带来信息损失。FP32能表示约7位有效十进制数字的精度,INT8只有大约2~3位有效数字。对于分类任务来说,只要logits之间的差距没有被量化噪声完全抹平,最终argmax的结果通常不会变。但如果模型本身处于“临界状态”,多个类别的logits非常接近,量化噪声就可能改变分类结果。这是量化精度下降的主要来源。
实际部署里最怕的还不是这种正常损失,而是一些可以避免的精度暴跌。最常见的原因有两个:
- 权重中存在明显的离群大值。比如某个卷积核权重范围在-0.05到0.08之间,但偏偏有一个权重是2.3,那么量化尺度会被这个离群值拉大,导致绝大多数权重只能落在很粗糙的整数格点上,精度自然崩溃。
- 校准数据分布不具代表性。量化时需要跑一批真实数据来统计激活值的动态范围,如果这批校准数据和实际部署时遇到的数据差异太大,激活量化尺度就会偏差。
3.3 PTQ与QAT:什么时候够用,什么时候要动真格
实践中主流的两种量化方式:训练后量化PTQ(Post-Training Quantization)和量化感知训练QAT(Quantization-Aware Training)。
PTQ最省事,把训练好的模型拿过来,喂一批校准数据,统计激活值范围,然后转换成INT8。我刚才说的那个小MNIST模型,PTQ之后准确率几乎不掉,从95.7%降到95.2%,降幅只有0.5个百分点。这个精度损失对于绝大多数应用是完全可接受的。
如果PTQ之后精度掉得厉害,比如掉了5个百分点以上,那基本说明模型存在离群权重问题,直接换QAT:在训练过程中插入伪量化节点,让网络在优化时主动适应量化误差。QAT的训练代码比普通训练稍复杂,也需要更长训练时间,但能显著降低量化损失。我的建议是先用PTQ,CPU训练也不贵,几步就能完成;只有PTQ明显不达标时才上QAT。
这里要强调一点:PyTorch自带的量化工具和后面部署用的STM32Cube.AI量化机制不完全一样。PyTorch量化的产物不一定能直接扔给MCU工具链,所以我更推荐的做法是把ONNX模型直接交给STM32Cube.AI做量化,让部署工具链自带的校准数据流程来处理。具体流程下一章展开。
4. 部署到Keil工程:用STM32Cube.AI把网络变成C代码
模型准备的功夫做足之后,真正进入单片机工程师熟悉的领域。STM32部署神经网络,现阶段官方提供的工具链已经很成熟,最主流的是ST官方的X-CUBE-AI扩展包,集成在STM32CubeMX里。它能读取Keras、TFLite、ONNX等格式的模型,自动生成C语言推理代码,并在PC端给出Flash/RAM和推理时间估算,甚至可以做板级验证。
我最初还尝试过完全手写C代码去实现卷积、池化和全连接,作为理解原理的练习确实有价值,但工程效率太低。你最好把手写推理当成学习工具,把X-CUBE-AI当成生产工具。
4.1 在CubeMX里导入模型并完成量化配置
用STM32CubeMX打开一个新工程,选择具体的MCU型号,比如STM32F401RE。配置时钟树、串口和GPIO这些常规外设之后,在Additional Software里找到X-CUBE-AI并勾选。这一步需要联网下载扩展包,如果之前已经安装过,CubeMX会直接提示。
导入ONNX模型的位置在软件面板的Model选项卡。点击Add network,选择tiny_mnist.onnx,Cube.AI会先做一次解析。如果模型包含不支持的算子,这里会直接报错。对于我这个小模型,正常情况一次通过。
这里有一个关键设置:Input/Output的形状。Cube.AI会自动读取ONNX里的输入输出维度,但你仍然需要确认数据类型。因为我们准备在MCU上量化推理,建议在Quantization相关选项里启用INT8,并指定一个包含校准图片的文件夹。校准集一般准备一两百张从训练集或验证集里抽出来的样本就够了。它内部会运行一套PTQ流程,统计每层激活的范围,生成量化参数。
量化配置完成后,Cube.AI会给出一个分析报告,包含模型的Flash占用、RAM占用、单次推理复杂度和预期的堆栈大小。看到这个报告时不用太紧张,准确性上PC端分析远好于拍脑袋估算,但实际工程仍有一些偏差,我们会在第五章讲怎么修正。
4.2 生成代码与Keil工程整合
在CubeMX里点击生成代码之后,工程目录下就会多出一堆network_xxx.c/h文件。它们就是从ONNX转换出来的C语言推理内核。其中有几个关键文件需要认识:
network.c/network.h:推理主流程和API声明。network_data.c:模型权重和量化参数,通常定义为static const数组,编译后会放在Flash区域。network_config.h:包含输入输出大小、层数、内存池大小等宏定义。network_data_params.c:量化相关的scale和zero_point参数。
在Keil MDK里,把生成的文件加入工程,然后在main.c里调用推理API。Cube.AI 8.0以后的API风格大致是这样:
#include "network.h" AI_ALIGNED(4) static ai_u8 activations[AI_NETWORK_DATA_ACTIVATIONS_SIZE]; static ai_handle network; void ai_init(void) { ai_error err; err = ai_network_create(&network, activations, AI_NETWORK_DATA_ACTIVATIONS_SIZE); if (err.type != AI_ERROR_NONE) { // 处理错误 } } void ai_run_inference(int8_t *input_data, int8_t *output_data) { ai_network_run(network, input_data, output_data); }这段代码是最简化的调用逻辑,具体函数名和参数顺序不同版本之间略有差异,但结构一致。拿到工程后建议先看生成的network.h头文件注释,比任何教程都权威。
一个很容易踩的坑是Keil的编译器选择。Cube.AI生成的代码对AC6的兼容性明显好于AC5,所以我建议在Options for Target里把编译器切到AC6,同时打开优化等级-O2。使用ARMCC AC6之后,经常会看到警告说没有加--c99之类的flag,按提示加上即可。
4.3 备用路线:TFLite Micro与CMSIS-NN
有的团队不想绑定ST的生态,也可以用TFLite Micro这条路线。流程是PyTorch导出ONNX,再用onnx2tf之类工具转成TFLite,用TensorFlow Lite Converter量化为INT8,最终导入TFLite Micro运行时,配合ARM官方的CMSIS-NN算子库跑在Cortex-M上。
这条路线的好处是完全开源,灵活度高,对非ST芯片也适用;缺点是配置繁琐,TFLite Micro的运行时本身会占一定Flash,集成到自己的工程里需要处理不少兼容问题。我在实际项目里两条路线都试过,如果你的目标芯片已经是STM32,用Cube.AI更省事;如果考虑芯片平台可迁移性,TFLite Micro值得投入时间。
5. 上板实测:内存账本、推理耗时与调优记录
部署代码写完,接下来是最让人忐忑的环节:烧录、运行、看结果。我建议任何MCU AI项目都从上板的第一步就开始记录实测数据,不要停留在Cube.AI的分析报告上。因为我发现,虽然分析报告已经很准,但实际工程的时钟配置、优化等级、HAL库版本都会对最终结果产生影响。
5.1 实测内存与推理耗时参考
我在STM32F401RE上运行这个MNIST模型的实测数据如下表:
| 指标 | Cube.AI静态分析 | 实测值 |
|---|---|---|
| 模型权重Flash占用 | 约2.1KB | 约2.1KB |
| 激活内存RAM占用 | 约23KB | 约24KB |
| 推理耗时(84MHz) | 约8ms | 约11ms |
| 推理耗时(168MHz) | 约4ms | 约6ms |
| 验证准确率(INT8) | 95.1% | 95.0% |
这里面有几个值得注意的点。Cube.AI分析出来的激活内存和实测差距不大,仍在合理范围内。推理耗时则比分析值高一些,因为实际CPU还承担中断、外设驱动、循环调度等任务,纯推理代码不可能独占总线。如果换成STM32F103C8主频72MHz,推理时间会到20ms左右,对大多数低功耗传感类应用依然够用。
同量级的模型在不同芯片上表现差异很大,核心瓶颈往往不是算力,而是Flash读取带宽。Cortex-M4的Flash加速器如果配置不当,频繁读取权重数组会成为最大的瓶颈。所以配置时钟树时务必打开Flash wait states和Prefetch,老的STM32F1系列还要注意ART加速器的状态。
5.2 输入输出对齐:最容易反复折腾的环节
板子跑通了,但识别结果全错或者一直输出固定类别,绝大多数时候不是模型问题,而是输入输出数据的组织方式不对。
训练阶段我做了像素归一化到0~1,部署到MCU时,原始图像数据往往是uint8的0~255,直接填进推理缓冲区显然不对。Cube.AI在量化配置时会生成输入scale和zero_point参数,正确的做法是:先把原始像素缩放到浮点0~1范围,再按输入量化参数变换成int8。如果Cube.AI的配置选择了“直接输入int8图像”,那就要在预处理阶段用归一化公式换算,保证输入数值范围和训练时一致。
输出侧同理。推理输出的是10个类别的logits,量化后是整数,读出来之后要执行反量化得到浮点logits,或者不反量化直接比较大小找最大值。由于量化参数对所有类别是同一个scale,直接比较整数logits大小和反量化后再比较的结果一致,所以多数时候直接argmax整型输出数组就行。
一个经典翻车场景:图像数据是从摄像头读来的RGB,而模型训练用的是灰度,直接送进去当然全错。所以输入预处理要严格对照训练时的transforms的顺序来写。
5.3 进一步压缩内存的三个方向
- 缩小输入分辨率:这是最立竿见影的方式。把8×8降到6×6虽然数据量只减少约43%,但中间特征图和第一层卷积的计算量会成片下降。代价是精度可能再掉一点。
- 内存复用:Cube.AI已经做了内存复用的优化,但如果你手写推理,可以考虑把不再使用的输入缓冲区覆盖为下一层输出。对8×8这种小模型效果有限,对稍大些的模型非常关键。
- 激活函数和池化层融合:MaxPool之后紧接ReLU的场合,可以先ReLU再MaxPool,结果一致,却能减少一次全数组元素扫描,理论上也能略微降低RAM峰值。工程上很少影响布局,但在极受限的环境里算是一个可抠出来的优化点。
功耗方面,MCU跑神经网络的功耗大头还是Flash读取和RAM访问。如果做电池供电的设备,建议跑完推理后立刻进入低功耗模式,而不是让MCU空转等下次数据。一次推理6ms,即使每秒钟唤醒做一次推理,整体占空比也才0.6%,平均功耗能压得很低。
6. 最容易翻车的六个部署现场
写到这里,我想把这一年多在STM32上反复踩过的坑集中整理出来。这些问题网上问的人特别多,但回答往往零散,我按自己的经验做了合并归类,希望你能绕开这些“必经之路”里的深坑。
第一,ONNX导出时的算子兼容问题。我在尝试导出包含torch.flatten和view的模型时,Cube.AI偶尔会解析失败。解决办法是尽量把模型结构限制在Conv、ReLU、MaxPool、Linear这些标准算子内,在模型定义阶段避免花哨的reshape逻辑。opset_version不一定要追求最新,13这个版本是我用下来兼容性最稳的。
第二,量化校准集不够。有一回我把校准集只放了20张图,验证集准确率直接从96%掉到61%。加回200张之后,准确率恢复到了94.9%。校准集太少,统计出来的激活值范围极不稳定,导致量化尺度偏离。我现在的经验是:校准集至少100张,最好300张以上,并且要从真实部署场景的数据分布里抽。
第三,Keil版本与编译器切换问题。新版Cube.AI需要MCU的软件包版本较新,老工程升级之后容易出现头文件冲突。我的建议是新建一个干净的CubeMX工程,把原有外设配置重新生成一遍,而不是在老工程里硬塞新生成的网络文件。工程能少很多奇怪问题。
第四,INT8推理结果和PC端验证不一致。这个要看差别有多大。如果只是少数样本分类不同,大概率是量化精度损失,正常;如果大面积不同,九成是输入预处理没对齐,个别时候是输出反量化参数没看全。先打印出输入缓冲区的数值,和PC端预处理出来的数据做对比,基本一查一个准。
第五,USB调试不识别MCU。很多MCU的USB外设需要软件配置上拉电阻,或者芯片根本只有USB Device没有差分数据引脚的控制器,这个时候不管驱动装多少遍都识别不了。不要在这种事情上死磕,直接用ST-Link的SWD接口调试,数据观察用UART转USB,比折腾USB虚拟串口省心得多。我自从切到串口调试之后,整个调试流程的稳定性高了一个数量级。
第六,把分析报告的数值当铁律。Cube.AI的分析报告很详细,但它默认的配置基于“整个推理期间独占CPU”的假设。你的工程里如果还有定时器中断、ADC采样、LCD刷新,都会抢占CPU时间,导致实际推理耗时比报告高一点。所以分析报告用来做方案选型、估算资源够不够,非常合适;但精确到微秒的性能承诺,还是以板上实测为准。
我在实际项目里总结出一个习惯,遇到任何部署问题,第一件事不是改代码,而是确认“训练侧行为”和“部署侧行为”在哪一个环节分叉了。把PyTorch、ONNX Runtime、Cube.AI验证工具、板上实测四个环节的输出逐层对齐,问题总能被快速定位到某一个小环节上,而不是整锅端着重蒸。
MCU神经网络部署这条路,最关键的思维转变是接受降级:模型小一点,精度低一点,输入糙一点,但换来的是极低的成本、极高的隐私性和完全本地化的运行能力。手里那个跑着几毫秒推理的小单片机,是真的能把AI塞进每一个需要智能判断的小设备里的。