最近这阵子一直在琢磨一件事:国产单片机到底能不能也跑 AI?正好手头有一块雅特力的 AT32F435,主频 288MHz、512KB Flash、192KB SRAM,配置在 MCU 里算是很能打的了。我就拿 TinyML 最经典的“HelloWorld 级正弦波模型”试了一把,从模型训练、量化导出,到把 C 数组塞进单片机工程、烧录跑通,整个过程都记录在这篇文章里。
如果你也想在自己手头的国产 MCU 上跑个小模型,搞明白训练侧和部署侧是怎么对接的,这篇文章可以直接当参考。不需要你有多少机器学习基础,只要会一点 Python、能用 Keil 开工程就行。我会把关键代码、参数、以及我实际踩过的坑全摊开来说。
1. 为什么选AT32F435和“正弦波”这个模型
1.1 AT32F435这颗芯片到底能跑什么
先看硬件底子。AT32F435 是雅特力的高性能 Cortex-M4F 内核芯片,带硬件浮点单元和 DSP 指令,主频最高 288MHz。我用的型号是 AT32F435RGT7,LQFP64 封装,512KB Flash、192KB SRAM,外设方面有双路 DAC、三路 ADC、多个 USART/SPI/I2C,还有 USB、SDIO、XMC 外部存储控制器。
这颗芯片的定位其实很明确:在传统 MCU 里面属于“大内存、高主频”一类,跑 RTOS、跑复杂协议栈都毫无压力,资源余量比老一代的 M3/M0 芯片大得多。而 TinyML 这种“要在资源受限设备上跑推理”的场景,正好需要这种底子。你可以把 Flash 理解成仓库,SRAM 理解成操作台,模型权重和运行时都要占地方,如果芯片本身只有 32KB Flash、4KB SRAM,那能腾挪的空间就很紧张,跑起来相当憋屈。
我用它跑 TinyML,最直接的感受就是:Flash 和 RAM 都很宽裕,不用为了省几十 KB 去反复裁剪代码。模型权重只有几百字节,TFLite Micro 运行时大概占 80~120KB Flash(取决于编译选项和解析器模式),tensor arena 分配 16KB 左右,剩下的空间还很充足。这跟当年在 64KB Flash 的芯片上跑 TinyML 是完全不一样的体验。
1.2 正弦波模型为什么是“Helloworld”
TinyML 的 Helloworld 示例,官方就叫 sine 模型,也就是用神经网络去拟合一个正弦函数:输入是 x(0~2π 之间的一个数),输出是对应的 sin(x) 预测值。为什么所有教程都拿这个当入门?原因很朴实:数据生成容易,网络训练快,结果可视化直观,而且流程很完整。
首先,训练数据不需要你去采集,用 numpy 一行代码就能生成几千个样本点。其次,模型结构非常简单,一个两层或三层的全连接网络就能把正弦拟合得相当好,训练时间在 CPU 上也就几十秒。更重要的是,结果可以“看见”:把模型部署到 MCU 后,连续给不同的 x,模型会输出一串预测值,把这些值画出来就是一条正弦曲线。你能用肉眼判断 AI 到底跑对了没有,这比对着串口打印一堆数字有意义多了。
别小看这个“最简单的模型”,它的流程跟跑真正的项目完全一致。训练、量化、转换、部署、推理、结果可视化,每一步都要走通。真实项目只是把数据换掉、把网络换大、把部署目标换到具体应用,骨架不会变。所以把这个 Helloworld 跑明白,后面做语音唤醒、异常检测、传感器数据分类,思路完全是通用的。
1.3 照搬官方示例还不够
官方 TensorFlow Lite Micro 仓库里也有现成的 sine 模型示例,但默认针对的是 Arduino、STM32F746 这类板子。你把它直接搬到一个国产 MCU 工程里,会发现有很多地方要自己处理:芯片头文件、启动文件、时钟配置、串口驱动、模型文件路径,全部要跟 AT32F435 对齐。
所以这篇文章没有直接说“下载例程烧录就好”,而是按从零搭建的方式讲。这样你拿到任意一款国产 MCU,只要硬件资源够,都能照着这个流程迁移过去。AT32 的 BSP 结构跟 STM32 标准库风格很像,如果你之前在 STM32 上开发过,过渡到 AT32 几乎无感。
2. 跑通TinyML需要准备的完整工具链
2.1 PC端环境:Python、TensorFlow与tflite-micro
先做训练侧准备。PC 端需要装 Python 3.8 以上版本,然后安装 TensorFlow。我这边用的是 TensorFlow 2.x,具体版本没太挑,2.10 之后到 2.16 都能跑通。安装命令就是常规操作:
pip install tensorflow注意装的时候选 CPU 版本就够了,模型极小,用不到 GPU。训练期间 CPU 占用也会很轻,不需要担心电脑扛不住。
接着要拿一份 TFLite Micro 的运行时源码。现在的官方仓库已经独立出来,在 GitHub 上的 tensorflow/tflite-micro,直接 clone 下来备用:
git clone https://github.com/tensorflow/tflite-micro.git这份源码后面要用来给 MCU 工程提供推理运行时,所以下载完不要随手删。如果你本地网络访问 GitHub 不方便,也可以从 TensorFlow 主仓库的 tensorflow/lite/micro 目录拿到同样一套东西,只是路径稍有不同。
这里额外提醒一句:如果是第一次接触 TinyML,建议先把 PC 端的环境跑通再碰单片机。你在单片机上折腾了半天发现跑不出结果,结果最后定位到是模型训练阶段就没对,那就很浪费时间了。环境问题越早暴露越好。
2.2 MCU端环境:Keil工程与AT32 BSP
单片机侧我用的是 Keil MDK。AT32F435 是 Cortex-M4 内核,MDK 对它的调试支持很成熟。装完 MDK 之后,需要去雅特力官网下载对应型号的 BSP 固件库,关键词就是“AT32F435_437 Firmware Library”,解压后里面有很多官方例程,我拿一个最基本的 GPIO 或 UART 工程作为起点,然后往里加东西。
AT32 BSP 的工程文件结构大概是这样:Firmware 目录下是标准外设库,包含 at32f435_437.h 头文件、system_at32f435_437.c 时钟初始化文件,以及各个外设驱动源文件。工程模板里已经帮我们配好了启动文件、链接脚本和 Flash 下载算法,这部分不用自己折腾,省了很多事。
另外注意一点,国产芯片在 Keil 里如果识别不到型号,通常是两个原因:一是 Pack 没装,二是芯片选项里的 Flash 下载算法没选对。AT32 的 Pack 在官网下载安装后,Keil 的 Device 列表里就能找到 AT32F435 系列。下载算法选 AT32F435 Flash 对应的算法,否则烧录会失败。
2.3 怎么搭建“PC训练+MCU部署”的目录结构
我习惯把训练脚本和 MCU 工程分开目录,但模型文件要能快速同步。我的项目目录长这样:
AT32F435_TinyML/ ├── train/ │ ├── train_sine.py │ ├── convert_to_tflite.py │ └── model_data.cc ├── mcu_project/ │ ├── /AT32F435_437/... │ ├── /User/main.c │ ├── /User/model_data.cc │ └── /ThirdParty/tflite-micro/...train 目录统一管理训练和转换脚本,model_data.cc 可以手动拷贝到 mcu_project/User 目录。tflite-micro 源码我直接放在 mcu_project/ThirdParty 里面,方便 Keil 引用。
这种拆分有个好处:训练代码和嵌入式代码互不干扰,换芯片时只需要重新处理 mcu_project 里面的东西,train 目录可以原样复用。后面如果真的要在其他 MCU 上跑同一个模型,直接复制工程比重新训练更快。
3. 模型训练、量化与导出:从正弦波到model_data.cc
3.1 训练数据怎么生成
正弦波模型的训练数据不需要真实采集,用 numpy 直接生成即可。标准做法是在 0 到 2π 区间内均匀取 1000 个点,计算对应的正弦值,然后作为输入和标签。
import numpy as np import tensorflow as tf sampling_count = 1000 x_values = np.linspace(0, 2 * np.pi, sampling_count, dtype=np.float32) y_values = np.sin(x_values).astype(np.float32) x_train = x_values.reshape(-1, 1) y_train = y_values.reshape(-1, 1)这里有一个细节:numpy 的 linspace 默认是 float64,但神经网络模型里通常用 float32 就够,而且后面量化时更平滑,所以我一上来就把数据类型转成了 float32。如果这里不转换,训练时会多一步自动类型转换,虽然也能跑,但数据来回转类型容易埋坑。
为什么采样 1000 个点?因为正弦函数本身很平滑,1000 个点足够覆盖 0 到 2π 区间里的特征了。实际你多采样一倍、两倍,最终模型精度提升不大,反而训练时间变长。对于这种玩具级任务,数据量不是瓶颈。
3.2 网络结构设计:为什么两层隐藏层就够
正弦函数是一个连续的非线性函数,理论上三层全连接网络(输入层+一个隐藏层+输出层)就能拟合得很好。我实际用的是两层隐藏层,每层 16 个神经元,激活函数选 ReLU,输出层不加激活直接输出数值。
model = tf.keras.Sequential([ tf.keras.layers.InputLayer(input_shape=(1,)), tf.keras.layers.Dense(16, activation='relu'), tf.keras.layers.Dense(16, activation='relu'), tf.keras.layers.Dense(1) ]) model.compile(optimizer='adam', loss='mse') history = model.fit(x_train, y_train, epochs=500, verbose=0)这个网络总共有多少个参数?算一下:第一层是 1×16 个权重再加 16 个偏置,一共 32 个参数;第二层是 16×16 再加 16,共 272 个参数;输出层是 16×1 再加 1,共 17 个参数。加起来 321 个参数。
这 321 个浮点参数转成 int8 量化后再浓缩一下,模型文件只有几百字节,放在 MCU 的 Flash 里几乎可以忽略不计。这也是这个例子适合入门的原因之一:你完全可以拿着计算器算出模型的资源占用,不会出现“怎么看都觉得不对劲”的玄学问题。
训练 500 个 epoch 后,loss 一般能降到 10⁻⁴ 量级。如果你发现 loss 降不下去,可以把神经元扩到 32,或者把 epoch 加到 1000。但也不用追求极致精度,这不是要参加竞赛,理解流程比堆指标重要。
3.3 int8量化与模型转换
模型训练完后,下一步是转成 TensorFlow Lite 格式,并做 int8 量化。为什么要量化?因为 MCU 上没有 GPU,也没有大内存去存 float32 权重,int8 量化能把模型体积缩小到原来的四分之一,推理速度也更快。
def representative_dataset(): for _ in range(100): data = np.random.uniform(0, 2 * np.pi, (1, 1)).astype(np.float32) yield [data] converter = tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.representative_dataset = representative_dataset converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] tflite_model = converter.convert() with open("model.tflite", "wb") as f: f.write(tflite_model)这里最关键的是 representative_dataset。量化过程需要一些“真实输入代表”来确定每个浮点数值对应的定点范围,它不需要很多,100 个随机采样点足够。如果没有这个校准数据集,量化会退化成纯动态范围量化,精度会受影响。
还有一点要注意:target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]这个配置会强制模型全部转成 int8,包括输入和输出张量。这会让 MCU 端推理更快,但代价是输入输出不再是浮点,MCU 端代码需要做定点与浮点的换算。如果你想省掉换算的麻烦,可以不写这一行,让转换器保留 float32 输入输出,只量化权重。两种方案都能跑,但部署代码不一样,要根据实际情况选。
我对这个模型是强制全 int8 的,因为想测一测国产 MCU 在纯定点推理下的表现。后面第四部分会给出对应的输入输出换算代码。
3.4 导出C数组:模型文件变成头文件
模型文件 model.tflite 现在只是二进制文件,MCU 上一般没有文件系统,所以我们需要把它变成 C 语言的字节数组,直接和固件一起编译烧录。
最简单粗暴的方式是用 xxd 工具转换:
xxd -i model.tflite > model_data.ccLinux/macOS 自带 xxd。Windows 下可以用 Python 替代:
with open("model.tflite", "rb") as f: data = f.read() with open("model_data.cc", "w") as f: f.write("const unsigned char model_data[] __attribute__((aligned(16))) = {\n") for i in range(0, len(data), 12): chunk = data[i:i+12] f.write(", ".join(f"0x{b:02x}" for b in chunk)) f.write(",\n") f.write("};\n") f.write(f"const unsigned int model_data_len = {len(data)};\n")同时生成一个 model_data.h 声明外部变量:
#ifndef MODEL_DATA_H #define MODEL_DATA_H extern const unsigned char model_data[]; extern const unsigned int model_data_len; #endif这里我特意加了一个__attribute__((aligned(16))),这个对齐属性是给后面 MCU 端加载模型时用的。TFLite Micro 在解析模型时有时会做一些对齐访问,不对齐可能触发 hardfault,提前标上能省不少排查时间。
3.5 在PC端先验证量化模型
在把模型搬到 MCU 之前,一定要先在 PC 端用 TensorFlow Lite 的 Python 接口验证一下量化后的模型输出是否还正常。这一步能帮你把“模型有问题”和“MCU端代码有问题”隔离开。
import tensorflow as tf import numpy as np interpreter = tf.lite.Interpreter(model_path="model.tflite") interpreter.allocate_tensors() input_details = interpreter.get_input_details() output_details = interpreter.get_output_details() print("input shape:", input_details[0]["shape"], "dtype:", input_details[0]["dtype"]) print("output shape:", output_details[0]["shape"], "dtype:", output_details[0]["dtype"])然后选几个点做推理,把输出反量化成 float 再画出来对比 sin 曲线。如果这一步的曲线和真实正弦曲线差太多,说明量化出了问题,需要回去调整 representative_dataset 或数量。如果这一步正常,那后面 MCU 上的问题就可以更自信地去排查。
4. 在AT32F435上部署:工程集成与推理代码
4.1 把tflite-micro运行时加进Keil工程
这一节是整个部署过程里最繁琐的部分,没经验的人很容易在这一步卡住。我的做法是把 tflite-micro 源码作为第三方库,直接塞进 Keil 工程。
打开 Keil 工程,新建一个组(Group)叫tflite_micro,然后把下列源文件添加进去:
- tensorflow/lite/micro/micro_interpreter.cpp
- tensorflow/lite/micro/micro_error_reporter.cpp
- tensorflow/lite/micro/micro_allocator.cpp
- tensorflow/lite/micro/all_ops_resolver.cpp
- tensorflow/lite/micro/micro_mutable_op_resolver.cpp
- tensorflow/lite/core/api/flatbuffer_conversions.cpp
- tensorflow/lite/core/api/op_resolver.cpp
- tensorflow/lite/core/api/tensor_utils.cpp
- tensorflow/lite/micro/kernels/fully_connected.cpp
- tensorflow/lite/micro/kernels/softmax.cpp
- tensorflow/lite/micro/kernels/add.cpp
- tensorflow/lite/micro/kernels/mul.cpp
这里到底要加多少文件,取决于你模型里用了哪些算子。我们的正弦波模型只有全连接层和可能被融合掉的 ReLU,理论上只需要 fully_connected 和配套的文件就够了。但我图省事直接用了AllOpsResolver,把所有算子都编译进去,这样后面换模型时不需要再回头调整源文件列表。
代价是代码体积会大一些,但对 AT32F435 的 512KB Flash 来说完全不是问题。
接下来设置 include 路径。在 Keil 的 C/C++ 选项卡里,把 tflite-micro 源码的根目录、tensorflow 目录、third_party 目录都加进去,否则编译器会找不到头文件。我记得大致的路径是:
tensorflow/lite/micro tensorflow/lite/micro/kernels tensorflow/lite/micro/examples/hello_world tensorflow/lite tensorflow/lite/third_party/flatbuffers/include如果编译时报找不到某个 .h,不要慌,按报错路径往上找,把对应的 include 目录加进去就行。这一步本质就是“养编译器”,报一个加一个,很快就能过。
另外一个很容易踩的坑是 Keil 默认的 C99 和 C++ 标准。tflite-micro 的源码是 C++,要确保源文件后缀是 .cpp,并且在工程里对应的组属性里加上--cpp11或更高版本编译选项。我用的是 AC6 编译器,加上--c++11后顺利通过。
4.2 推理主流程代码:从加载模型到逐点预测
模型文件进来后,写推理主流程就很直白了。核心步骤是:分配 tensor arena、创建 interpreter、加载模型、把输入数据填进去、调用 Invoke、读取输出。
下面这段代码就是我最终跑通的精简版流程:
#include <stdio.h> #include "at32f435_437.h" #include "tensorflow/lite/micro/all_ops_resolver.h" #include "tensorflow/lite/micro/micro_error_reporter.h" #include "tensorflow/lite/micro/micro_interpreter.h" #include "tensorflow/lite/schema/schema_generated.h" #include "model_data.h" static constexpr int kTensorArenaSize = 16 * 1024; static uint8_t tensor_arena[kTensorArenaSize]; static tflite::MicroErrorReporter micro_error_reporter; static tflite::AllOpsResolver resolver; static tflite::MicroInterpreter *interpreter = nullptr; static TfLiteTensor *input_tensor = nullptr; static TfLiteTensor *output_tensor = nullptr; extern "C" void tiny_ml_init(void) { const tflite::Model *model = tflite::GetModel(model_data); if (model->version() != TFLITE_SCHEMA_VERSION) { while(1); } interpreter = new tflite::MicroInterpreter( model, resolver, tensor_arena, kTensorArenaSize, µ_error_reporter); TfLiteStatus status = interpreter->AllocateTensors(); if (status != kTfLiteOk) { while(1); } input_tensor = interpreter->inputs(0); output_tensor = interpreter->outputs(0); }注意这里的interpreter用了裸指针和 new。这是因为 TFLite Micro 的 MicroInterpreter 依赖 tensor arena 里面预先分配好的内存,不能随随便便作为栈变量,而且它的生命周期要覆盖整个推理过程。写单片机程序时,很多人习惯把对象定义成static,这没问题;但要注意 MicroInterpreter 不能频繁构造和析构,否则 tensor arena 会被反复申请和释放,可能踩到内存碎片。
推理部分我单独抽了一个函数:
extern "C" float tiny_ml_predict(float x) { float input_scale = input_tensor->params.scale; int input_zero_point = input_tensor->params.zero_point; float output_scale = output_tensor->params.scale; int output_zero_point = output_tensor->params.zero_point; if (input_tensor->type == kTfLiteInt8) { int8_t q = (int8_t)(x / input_scale + input_zero_point); input_tensor->data.int8[0] = q; } else { input_tensor->data.f[0] = x; } TfLiteStatus status = interpreter->Invoke(); if (status != kTfLiteOk) { return 0.0f; } if (output_tensor->type == kTfLiteInt8) { int8_t q = output_tensor->data.int8[0]; return (q - output_zero_point) * output_scale; } return output_tensor->data.f[0]; }这段代码里最关键的是量化换算。int8 模型的输入需要把浮点 x 先除以 scale 加上 zero_point,转成 int8;输出则需要反向操作,把 int8 结果减掉 zero_point 再乘 scale,才能得到真实的浮点预测值。很多人在 MCU 上跑出“一条直线”或者“满屏乱码”,十有八九是这里偷懒了,直接把 data.int8 的值当成结果打印。
4.3 验证手段一:串口绘图
跑通推理后,最直观的验证方式是把输入 x 和预测值一起通过串口发出来,在 PC 端画图看波形。
AT32F435 的串口输出我用的是标准库的 printf 重定向。将 fputc 重定向到 USART 发送寄存器即可:
int fputc(int ch, FILE *f) { while(usart_flag_get(USART1, USART_FLAG_TDBE) == RESET); usart_data_transmit(USART1, ch); return ch; }然后在主循环里这样输出:
for (int i = 0; i < 100; i++) { float x = (2.0f * PI * i) / 100.0f; float y = tiny_ml_predict(x); printf("%.4f;%.4f\n", x, y); delay_ms(50); }PC 端我用串口助手把数据保存成 CSV,然后画图;也可以用 Arduino IDE 自带的 Serial Plotter 工具,把串口接到虚拟串口,选好波特率,直接就能看到动态曲线。我用的是 115200 波特率,实测很稳。
如果串口没数据,优先检查三处:波特率是否匹配、波特率寄存器是否因时钟配置错误而产生偏差、printf 是否真的被重定向到了串口而不是默认的 debug 通道。AT32 的时钟配置里面如果 PLL 没设置对,系统主频跑了 288MHz 但外设总线时钟没跟上,串口就会变成乱码。
4.4 验证手段二:DAC输出模拟波形
串口画图虽然直观,但毕竟还要转一道线。如果想要更“硬核”一点,可以直接用 AT32F435 内置的 DAC,把模型预测值输出成模拟电压信号,用示波器看波形。
AT32F435 的 DAC 分辨率是 12 位,输出范围通常是 0~3.3V。正弦预测值在 -1 到 1 之间,需要先映射到 0~3.3V 的电压范围:
float voltage = (y * 1.65f) + 1.65f; // -1~1 -> 0~3.3V uint16_t dac_val = (uint16_t)(voltage / 3.3f * 4095.0f); dac_data_set(DAC1, DAC_ALIGN_12B_R, dac_val);然后让主循环以一个固定的时间步长喂 x,DAC 就会输出连续的正弦波。把示波器探头接到 DAC 对应引脚(AT32F435 的 DAC 输出脚一般是 PA4),就能看到一条平滑的正弦曲线。这个输出效果比串口绘图更有“在硬件上跑 AI”的真实感。
4.5 验证手段三:LED亮度呼吸灯
如果想在没有任何外部设备的情况下演示,连示波器都不想接,那可以把预测值映射成 PWM 占空比,让 LED 亮度跟随正弦波呼吸。虽然看起来只是一个呼吸灯,但本质上和“AI 控制灯光亮度”没什么区别。
我在定时器输出比较通道上配了一个 PWM 输出,频率设成 1kHz,占空比根据预测值实时更新:
timer_channel_value_config(TMR1, TMR_SELECT_CHANNEL_1, (uint32_t)((y * 0.5f + 0.5f) * 999));这样整个系统跑起来,LED 会从一个非常暗的状态开始,慢慢变亮再慢慢变暗,循环往复。不懂的人看着就是一个呼吸灯,但内核其实是一个神经网络正在单片机上做推理。
5. 常见问题与排查技巧实录
5.1 编译报错的两种典型原因
Keil 编译 tflite-micro 最容易踩的坑,第一是没开 C++11 导致一些模板语法报错,第二是文件路径没配齐导致头文件找不到。前者修改编译选项即可,后者需要根据报错信息逐个添加 include 路径。
还有一种情况是模型数组太大,编译时直接撑爆了优化选项的内存预算。Keil 的 AC6 编译器在默认优化级别下,对超大数组有时会提示 “section overflow”。当时的解法有两种:一是把 tflite-micro 相关源文件的优化等级调到-O2,二是把 tensor arena 从全局数组改成 static 局部数组,缩小作用域。实测下来,开-O2后工程体积能明显下降,Flash 占用从 160KB 左右降到 110KB 上下。
5.2 运行时卡死:先看AllocateTensors返回值
如果程序烧录后跑不起来,第一步不是去翻 Debug,而是盯紧AllocateTensors()的返回值。我在调试时遇到过kTfLiteError,定位结果是 tensor arena 给得太小了。模型虽然很小,但 TFLite Micro 在内部还要为中间状态分配内存,对 arena 的需求比单纯模型文件体积大好几倍。我当时从 8KB 改成 16KB 就好了。
判断 arena 是否够用有个标准做法:把 kTensorArenaSize 从很小开始,比如 4KB,然后不断往上加,直到 AllocateTensors 返回 kTfLiteOk。这个值就是当前模型的最小需求。对于我们的正弦波模型,经验值是 8KB 以上,给 16KB 最保险。
5.3 输出数值不对:八成是量化换算问题
模型能跑、串口也能出数,但画出来的曲线和 sin 对不上,这种情况我见得太多了。最常见的原因就是前面提到的 int8 输入输出没有做 scale/zero_point 换算。
排查很容易,在代码里打印模型输入输出的 scale 和 zero_point:
printf("input scale %f, zero %d\n", input_tensor->params.scale, input_tensor->params.zero_point); printf("output scale %f, zero %d\n", output_tensor->params.scale, output_tensor->params.zero_point);如果 scale 是 0.0235294、zero_point 是 0 这类带小数的数,说明模型确实是 int8 模型,必须做换算。如果 scale 是 1.0、zero_point 是 0,那就说明你的模型根本没量化成功,输入输出仍是浮点类型,代码也要走另一条分支。
5.4 下载失败与芯片锁死:救砖方法
搞嵌入式的人都知道,改完时钟配置后第一次下载程序,最容易碰到 SWD 连接不上。AT32F435 如果引脚被复用成其他功能,或者进入某种异常状态,调试器可能就识别不到芯片了。
救砖方法很简单:把 BOOT0 引脚拉高,重新上电,让芯片进入系统存储器引导模式,然后用雅特力官方提供的 ISP 工具连接串口或 USB,把 Flash 全擦除,再拉低 BOOT0 重启,芯片就恢复正常了。这个操作我至少用过两次,一次是改了 SWD 引脚复用到 SPI,一次是 PLL 配置写错导致主频跑飞。建议所有玩 AT32 的朋友都记住这个操作,关键时刻非常救命。
5.5 代码体积优化与推理速度实测
最后聊一下性能。tflite-micro 运行时加上模型文件,用 AllOpsResolver 编译出来的固件在 Flash 里大概占 100KB 多,用 MicroMutableOpResolver 只注册需要算子后,能再压缩几十 KB。如果你对 Flash 占用敏感,建议改用后者:
static tflite::MicroMutableOpResolver<10> resolver; resolver.AddFullyConnected();推理耗时我用 DWT 计数器测过,在 288MHz 主频下,这个 321 参数的小模型每次推理大约几百微秒,具体数值和 Keil 编译优化等级有关。这个速度对传感器数据分类、异常检测这类实时性要求不高的应用来说,完全够用。真正耗时的场景是跑卷积神经网络或连续音频处理,那就需要另想办法了,比如用 CMSIS-NN 优化算子。
最后再分享一点小经验
这个项目看着简单,但它把“训练一个神经网络”和“在单片机上做推理”这两件事之间的桥真正搭起来了。我最大的体会是:TinyML 的门槛不在模型训练,也不在 C 语言,而在于你能否静下心把整个工具链捋顺。tflite-micro 的源码很开放,编译不过就查路径,跑不对就查量化,下载不了就 ISP 擦除,每一步都有规律。
如果你也准备入坑,我的建议是:不要想着一步到位跑语音识别或摄像头模型,先把这个正弦波 Helloworld 完完整整跑通,再换一个你实际场景里的简单模型,思路是完全一样的。国产 MCU 的算力和外设在逐渐变强,这套方法论会越来越实用。