做了快十年嵌入式,看过太多工程师把 AI 和 MCU 分成两个世界:AI 是云端 GPU 的专属玩具,MCU 最多做做数据采集。但这个认知在 TinyML 出现之后就该被推翻了。我在 AT32F435 这颗国产 Cortex-M4F 芯片上,把 TensorFlow Lite for Microcontrollers 的 Helloworld 正弦波模型完整跑通了一次,从环境搭建、模型移植到最终的 LED 呼吸灯效果都做了实测验证。整个过程走下来,我的感受是:真正的 AI 落地不能只看服务器端的浮点算力,把推理拉到几十毫瓦功耗的 MCU 上跑,才是边缘智能最有价值的地方。这篇文章把我从零到一的过程、踩过的坑和最终的实测数据全部复盘一遍,给那些想在国产 MCU 上试水 TinyML 的工程师们一个可直接参考的路径。
1. 选型逻辑:为什么我拿AT32F435这颗国产MCU来跑TinyML
1.1 先拆解TinyML对MCU硬件的底线要求
很多同学一听『MCU跑AI』,第一反应是得用什么高端芯片。实际上 TinyML 这个方向之所以能火,恰恰是因为它对硬件的要求低到了一个非常亲民的位置。以 TensorFlow Lite for Microcontrollers(简称 TFLM)官方给出的 Helloworld 为例,模型本身是一个 1-16-1 的三层全连接网络,输入一个值、中间 16 个神经元、输出一个值。量化成 int8 之后整个模型体积也就 3KB 左右,TensorArena 内存缓冲区给到 2KB 以上就足够跑推理。
这背后的需求逻辑其实非常清晰:模型足够小,所以 Flash 的占用不是瓶颈;推理过程没有卷积,主要是矩阵乘法和 ReLU 激活函数,所以内核需要有 FPU 或者至少能高效处理定点的乘加操作;中间层的临时数据很少,所以 SRAM 只需要几 KB 级别。任何一颗带硬件浮点单元的 Cortex-M4/M33/M7 内核芯片,理论上都能轻松胜任。
1.2 AT32F435的硬参数到底够不够打
AT32F435 是雅特力推出的一款基于 ARM Cortex-M4F 内核的高性能 MCU。我手上这颗是 AT32F435ZMT7,主频可以直接拉到 288MHz,Flash 512KB,SRAM 最大 256KB(我没用到这么大,但跑 TinyML 绰绰有余)。Cortex-M4F 这个内核本身就带 FPU 浮点单元和 DSP 指令集,对矩阵乘法和信号处理这类负载有天然的加速优势。
单从跑 TinyML 的角度看,这颗芯片的算力冗余非常大。我用 Helloworld 这个例子做评测,推理一次的主循环耗时大概在几百微秒级别,288MHz 的主频加上 FPU 的加持,全连接层的一轮矩阵乘法根本来不及形成性能压力。
1.3 和其他常见选型的对比
我也不是没想过直接用 STM32F407 这类经典板子,或者用 ESP32 带 WiFi 的方案。但最终选择了 AT32F435,有几个很实在的原因。我用下面这个表格直接对比一下:
| 芯片型号 | 内核 | 主频 | Flash/SRAM | TinyML适配度 | 额外考虑 |
|---|---|---|---|---|---|
| STM32F407 | Cortex-M4F | 168MHz | 1MB/192KB | 适合,资料多 | 价格偏高,传统方案 |
| ESP32 | Xtensa LX6 | 240MHz | 4MB/520KB | 适合,有TFLM官方支持 | 无线功能在这个场景用不上 |
| AT32F435 | Cortex-M4F | 288MHz | 512KB/256KB | 非常适合 | 国产化场景、性价比高 |
| RP2040 | Cortex-M0+ | 133MHz | 无Flash/264KB | 勉强,无FPU | 只适合极简模型 |
选 AT32F435 的逻辑其实不复杂:主频在同等定位芯片里属于第一梯队,Flash/SRAM 大得完全不担心模型放不下,Cortex-M4F 的内核对 TFLM 的算子底层实现有硬件加速加持,再加上国产化替代的大趋势下,这颗芯片的供货和价格都非常友好。做产品落地的工程师应该都懂我这个考虑。
2. 正弦波模型拆解:TinyML的Hello World到底在做什么
2.1 模型任务定义:用神经网络去拟合一个数学函数
TFLM 官方把正弦波预测这个例子定义为嵌入式 AI 的 Helloworld,思路非常直接:给模型输入一个 0 到 2π 之间的相位值 x,模型输出 y = sin(x) 的近似值。看起来有点大材小用——一个数学函数而已,直接调用数学库的 sin() 不就行了?
但这里是为了验证 AI 在 MCU 上的完整工作链路:数据输入、模型加载、算子执行、结果输出,以及如何把一个神经网络真正部署到资源受限的嵌入式环境。用正弦波做例子,是因为它的输出是一个连续的非线性曲线,神经网络需要学习这种非线性映射关系,任务简单到可以肉眼验证,同时又完整覆盖了训练、量化、部署的全流程。
2.2 从Keras训练到int8量化
官方模型的训练过程,其实是在 Linux 环境下用 Keras 完成的。训练数据结构很简单:在 -π 到 π 或 0 到 2π 的区间内采样若干个 x 值,对应的标签 y = sin(x),其中部分样本加了噪声防止模型过拟合。网络结构就是经典的三层全连接:第一层 1 个输入节点,隐藏层 16 个神经元用 ReLU 激活,输出层 1 个线性节点。
训练完成后,关键步骤是量化。TFLM 在 MCU 上默认跑的是 int8 量化模型,因为浮点模型在部分低端 MCU 上是跑不动的,即便有 FPU,单纯从功耗和内存角度考虑,int8 也是最优解。量化的过程用 TensorFlow Lite Converter 做 post-training quantization,把权重从 float32 压缩到 int8,同时记录每个张量的 scale 和 zero_point 两个参数,后续推理时要用这两个参数反量化得到最终的浮点结果。
量化之后的模型文件大概只有 3KB,一个 C 数组就能装下,这也是 MCU 能轻松承载的根本原因。
2.3 模型文件最终长什么样
把 Keras 模型转成 TensorFlow Lite 格式后,再用 xxd 或者 TFLM 的工具把它转成一个 C 源文件,里面就是一个 int8 的字节数组。Helloworld 例程中这个数组被命名为 g_model 或者 model_data,我在做移植的时候直接在官方仓库里提取了这份模型数组,放到自己的 AT32 工程里。
我建议第一次做移植的同学不要自己去从头练一个模型,直接用官方已经量化好的模型文件跑通链路,然后再去折腾自训练。用官方模型可以排除掉训练环节的变量,让排错范围集中在部署和推理部分。
3. 工程搭建与TFLM源码移植:一半时间花在环境上
3.1 TFLM源码的正确获取方式
TFLM 现在的代码托管在 tensorflow/tflite-micro 仓库下,早期是 TensorFlow 主仓库的 tensorflow/lite/micro 子目录。我建议直接用 GitHub 上的独立仓库,版本迭代很快,而且官方配套了很好的示例。
拿到源码之后不要急着往工程里拖,先确认一下目录结构。TFLM 的源码是平铺式的,tensorflow/lite/micro 下包含所有核心推理引擎代码,examples/hello_world 下则是官方示例,包含 main.cc、hello_world_model_data.cc 这些核心文件。移植的时候主要把 micro 目录下的核心代码、模型数组文件、以及你用的板子对应的 debug_log 实现抠出来就行。
3.2 在AT32工程里搭建TFLM运行骨架
我用的是 Keil MDK 工程来跑 AT32F435 的 BSP 基础工程。先把 AT32F43x 标准外设库工程建好,然后在工程里新建一个 TFLM 分组,把 TFLM 源码中需要编译的 C/C++ 文件全部加进去。
这一步最大的坑是文件依赖。TFLM 源码内部各文件之间互相 include 很频繁,如果你只挑一部分文件编译,很快会遇到一堆找不到头文件的报错。我个人的做法是:直接把整个 tensorflow/lite 目录加进 include path,编译时让编译器自动处理依赖关系,配合 Keil 的 One ELF Section per Function 选项,链接器会自动把没用到的函数剔除掉,这样既能保证编译通过,又能控制最终固件大小。
3.3 打开FPU和优化级别:否则推理速度会慢得怀疑人生
TFLM 的矩阵乘和量化算子对编译器优化极度敏感。我在做 Helloworld 实测时做过对比:立创的 ARM GCC 默认 O0 优化下,一次推理耗时接近 2ms,但把优化级别开到 O2 之后,瞬间降到几百微秒。原因很简单,矩阵乘的核心循环在没有优化的情况下会频繁产生内存访问和冗余计算,O2 以上的优化会让编译器自动利用 Cortex-M4F 的 FPU 寄存器进行向量化计算。
另外,一定记得在工程里打开硬件浮点选项。GCC 的编译选项中要加-mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard,Keil 里则在 Target 选项里把 Floating Point Hardware 选成 Single Precision。这个选项没弄对,浮点运行库会回退到软件模拟,性能直接下降一个数量级。
4. 实际点亮呼吸灯:推理结果如何变成LED亮度
4.1 核心推理代码的调用逻辑
TFLM 在 MCU 上的推理接口非常简洁,核心就是四个步骤:加载模型、创建解释器、设置输入、执行推理。官方 Helloworld 的代码结构大致如下:
#include "tensorflow/lite/micro/micro_interpreter.h" #include "tensorflow/lite/micro/micro_mutable_op_resolver.h" // 模型数据 const unsigned char* model_data = g_model; const tflite::Model* model = tflite::GetModel(model_data); // 算子解析器:注册模型用到的算子 static tflite::MicroMutableOpResolver<10> resolver; resolver.AddFullyConnected(); resolver.AddQuantize(); resolver.AddDequantize(); // 张量内存缓冲 static uint8_t tensor_arena[2 * 1024]; // 创建解释器 tflite::MicroInterpreter interpreter(model, resolver, tensor_arena, sizeof(tensor_arena)); // 获取输入输出张量 TfLiteTensor* input = interpreter.input(0); TfLiteTensor* output = interpreter.output(0); // 推理循环 input->data.float32[0] = x; interpreter.Invoke(); float y = output->data.float32[0];需要注意的一点是,当前的模型文件如果是从官方仓库直接拿的量化模型,输入输出张量类型是 int8,需要通过 input->params.scale 和 zero_point 做量化再喂进去,我一开始忽略了这一步,导致输出全是乱码。
4.2 把正弦波预测变成呼吸灯的实际映射
跑模型只是第一步,把预测结果变成一个肉眼可见、可交互验证的输出,才算真正跑通了 Helloworld。我的做法是:主循环里不断递增 x 的值(每次增加 0.1 弧度),把它喂给模型,得到一个 y 值(范围在 -1 到 1 之间),再把 y 映射为 PWM 占空比,控制一个 LED 的亮度。
由于 x 是线性递增的,模型预测的 y 就呈现出正弦波形态,LED 的亮度也同步呈现呼吸灯的起伏效果。相位步进的大小直接影响呼吸灯的频率,我用 0.1 弧度每步,完整周期大概 63 步,主频 288MHz 跑一遍主循环完全在几十毫秒以内,看起来非常流畅。
4.3 串口输出验证:模型预测值和理论值能对得上吗
光看呼吸灯亮暗只能说明『有反应』,不能证明『预测准』。我同时通过串口把模型输入 x、预测输出 y、理论 sin(x) 三组数据打印出来,方便做定量对比。实测下来,在 -1 到 1 的输出区间内,模型预测值和理论值的误差基本控制在 0.05 以内,波形轮廓和真实正弦曲线高度一致。这个精度对于 Helloworld 级别的 16 神经元隐藏层网络来说,完全符合预期。等到后面换成更大规模模型,或者换用 LSTM/Transformer 这类复杂结构,才需要重新评估精度表现。
5. 实测数据与踩坑排查:预测误差、耗时和那些反直觉的bug
5.1 实测数据记录
下面是我在 AT32F435 上跑 Helloworld 模型记录的几组数据,输入 x 从 0 到 π 之间取几个点做对照:
| 输入x (弧度) | 理论sin(x) | 模型预测值 | 绝对误差 |
|---|---|---|---|
| 0 | 0 | 0.01 | 0.01 |
| 0.5 | 0.479 | 0.47 | 0.009 |
| 1.0 | 0.841 | 0.82 | 0.021 |
| 1.57 | 1.000 | 0.95 | 0.05 |
| 2.0 | 0.909 | 0.87 | 0.039 |
| 2.5 | 0.598 | 0.61 | 0.012 |
| 3.14 | 0.001 | 0.05 | 0.05 |
误差最大出现在峰值附近,这是全连接网络逼近非线性曲线的正常表现,如果要更高的精度,可以加宽隐藏层或者增加训练轮次。就 Helloworld 这个示例任务而言,这个精度已经足够验证整条链路。
推理耗时的实测:我通过 GPIO 翻转配合示波器测量,单次推理在 288MHz 下约为 0.35ms,比在 STM32F407 上的实测值(约 0.8ms)快了一倍左右,这主要归功于主频优势。
5.2 坑一:模型输出恒为0或乱码,问题出在量化的反向操作
第一次跑起来的时候,串口打印的预测值全是 0 或者明显乱码。排查了很久才发现,我用的模型数组是官方仓库里的 int8 量化模型,但我在代码里却按照 float 的方式来读取输入输出。int8 模型输入输出的 TfLiteTensor->data.data 是一个 int8 指针,而且存储的值是量化后的整数,必须通过 params.scale 和 params.zero_point 做转换才能还原成真实的浮点正弦值。
float real_val = (output->data.int8[0] - output->params.zero_point) * output->params.scale;这个坑几乎是每个初用 TFLM 的人都会踩一次。解决办法就是先打印一下 input 和 output 张量的 type 和 params 值,确认数据类型后再决定用哪种方式读写。
5.3 坑二:编译通过但上电直接卡死,TensorArena内存对齐
第二个比较隐蔽的问题是 TensorArena 的内存对齐。TFLM 的内部算子对张量地址的对齐要求很高,通常要求 4 字节甚至 16 字节对齐。如果你在 Keil 工程里直接定义一个普通全局数组作为 tensor_arena,它的对齐方式取决于编译器的默认设置,一旦对齐不对,Invoke 的时候会触发 HardFault,表现就是上电后程序直接卡死。
解决办法有两种:一是用 C11 的alignas(16)指定字节对齐属性;二是在 Keil 的启动文件或链接脚本里使用__attribute__((aligned(16)))。我最终用的是:
__attribute__((aligned(16))) static uint8_t tensor_arena[2 * 1024];改完之后,HardFault 的问题立即消失。
5.4 坑三:Flash不够用的裁减思路
虽然 Helloworld 模型本身只有 3KB,但 TFLM 运行时库编译出来之后接近 40KB,如果再加上 AT32 的外设库和协议栈,Flash 压力就开始出现了。这也是很多人在 MCU 上跑 TinyML 会遇到的共性问题。
裁减思路主要围绕算子注册表来想:MicroMutableOpResolver 默认可以注册的算子上限是根据模板参数决定的,如果你只用了 FullyConnected, 那尽可能把句柄数调到实际用到的数量,给编译器的优化去掉多余的算子。再配合 Keil 的--split_sections选项、使用优化等级 O2 以上的方式,基本能把 TFLM 运行时裁剪到 20KB 左右。对于 512KB Flash 的 AT32F435 来说,这完全不是瓶颈了。
5.5 那些反直觉的性能认知
我实测之后对 MCU 跑 AI 这件事有了几个新的认识。第一,量化模型在带 FPU 的 MCU 上跑,速度反而可能比纯浮点模型慢,因为 int8 的乘加需要额外的 scale 计算。所以如果你的 MCU 内存充裕、又有硬件 FPU,直接用 float 模型反而可以简化代码、减少踩坑。第二,TinyML 的性能瓶颈往往不在算子本身,而在于内存访问效率。TensorArena 的内存布局、D-Cache 的命中率对耗时的影响,比微调模型结构更明显。第三,MCU 的真正价值在超低功耗,AT32F435 正常跑推理时的功耗远低于任何云端的单位成本,这才是 TinyML 的终极竞争力。
收尾再聊几句心里话
实际上把 TFLM 的 Helloworld 跑通只是 TinyML 知识体系里最小的一块拼图。我在 AT32F435 上走这一圈下来,最大的收获倒不是测出了多好看的推理耗时,而是对一个事实有了更实感的确认:国产 MCU 在 AI 这条路线上一点没落下,无论是主频、内存、Flash 资源的硬件底子,还是从标准库到 IDE 的支持,都已经到了可以支撑量产级 TinyML 应用的程度。后面如果继续往下走,我觉得可以先从两个方向深入:一是换成能处理二维数据的 CNN 模型做图像识别,体验一下卷积算子的部署难度;二是研究一下 TFLM 自带的内存规划工具,看看怎么把 TensorArena 压榨到最小。真心建议手头有国产 Cortex-M 开发板的同学,把这个 Helloworld 例程翻出来跑一遍。实际动手之后你会发现,芯片原厂和开源社区已经把最难的路都铺好了,你只需要把第一块砖放上去。