6000 点加速度数据 → 100 维特征 → 5KB int8 模型,完整部署到 MCU
目标硬件是一块 Cortex-M 级别的 MCU,只有 64KB Flash 和 64KB RAM。要在上面跑一个程序:输入加速度传感器采集的 6000 个连续采样点,输出"这是震动A、震动B、还是震动C"。
这篇文章走一遍完整过程:怎么把原始震动数据变成 MCU 能理解的数字,怎么训练一个足够小的模型,怎么把它压缩到 int8 塞进 MCU,以及怎么用 C++ 写出和 Python 训练时精度一致的推理代码。
先看一眼整条链路在做什么:
六个步骤,①②③ 在 Python 端完成(训练 + 导出),④⑤ 在 C++ 端完成(MCU 推理)。下面逐个展开。
特征提取:6000 点 → 100 维
MCU 拿到的是一串 6000 个 float:[0.003, -0.012, 0.008, ...]。直接把这 6000 个数扔给神经网络行不通 — 输入太大了,MCU 算不动。
解决方案是做特征提取:把 6000 个原始采样点压缩成 100 个有意义的数字。这些数字要能描述震动信号的核心特性 — 低频有多强、波动大不大、有没有突然的尖峰。可以把它理解成一种有损压缩:扔掉不重要的细节,保留能区分三类震动的关键信息。
FFT 频域特征(64 维)
FFT 做的事情:把一段随时间变化的波形,拆成"哪些频率成分组成了这段波"。不同震动的频率分布不一样,这是 FFT 能区分它们的原因。
做法是对整条 6000 点的信号跑一次 FFT,取 64 个频率 bin 的幅度,用 log1p(x) = ln(1 + x) 对幅度做对数压缩。对数压缩的目的很简单:低频分量的幅度可能是高频的几百倍,直接送给模型,大数会把小数淹掉。取个对数,差距从几百倍缩到几倍。
fft = np.abs(np.fft.rfft(sig)) # FFT → 取幅度
feats[:64] = np.log1p(fft[1:65]) # 跳过 DC(0Hz),取 64 个 bin + 对数压缩时域分段特征(36 维)
频域告诉模型"哪个频率在震",时域告诉模型"震动有多剧烈、怎么变的"。
把 6000 个点切成 4 段,每段 1500 点。对每一段算 9 个统计量:
| 特征 | 含义 | 只用加减乘除? |
|---|---|---|
| 均值 | 这段信号的平均值 | ✓ |
| 标准差 | 波动幅度 | ✓ |
| 最小值 / 最大值 | 振幅范围 | ✓ |
| RMS | 有效值,衡量能量大小 | ✓ |
| 峰值 | 绝对值的最大值 | ✓ |
| 过零率 | 穿越零点的次数 ÷ 段长 | ✓ |
| 能量 | 平方和 ÷ 段长 | ✓ |
| 非零比 | 非零采样点占比 | ✓ |
9 个特征 × 4 段 = 36 维。 加上前面 64 维 FFT,总共 100 维特征向量。
选这些特征的硬性条件只有一个:MCU 上只用加减乘除就能算。 没有 percentile()、没有 sort()、没有 log()。
for seg in range(4):
s = sig[seg*1500:(seg+1)*1500]
b = 64 + seg * 9
feats[b+0] = np.mean(s) # 均值
feats[b+1] = np.std(s) # 标准差
feats[b+2] = np.min(s) # 最小值
feats[b+3] = np.max(s) # 最大值
feats[b+4] = np.sqrt(np.mean(s**2)) # RMS
feats[b+5] = np.max(np.abs(s)) # 峰值
feats[b+6] = np.sum(np.abs(np.diff(np.signbit(s)))) / len(s) # 过零率
feats[b+7] = np.sum(s**2) / len(s) # 能量
feats[b+8] = np.count_nonzero(s) / len(s) # 非零比特征标准化
100 维特征算出来了,但有一个问题:不同特征的数值范围差别很大。
比如"均值"可能一直在 ±0.01 之间晃,但"能量"可能是几千的量级。直接把这些数喂给模型,数值大的特征会主导计算结果,数值小的等于没参与。
解决方法是做一次标准化(StandardScaler):对每个特征维度,减掉均值、除以标准差。处理完后,每个维度都变成零均值、单位方差的分布。相当于把厘米、千克、秒全部统一成同一个尺度。
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
# 保存 mean 和 scale — C++ 端要用完全一样的值
s_mean = scaler.mean_
s_scale = scaler.scale_
s_scale[s_scale < 1e-8] = 1.0 # 防止除零保存 s_mean 和 s_scale 是关键动作。训练时用的 normalization 参数,推理时也必须用同一组。
模型结构
100 维输入 → Dense(16, ReLU) → Dense(8, ReLU) → Dense(3, Softmax)三层逐级压缩:100 → 16 → 8 → 3,每层只保留最关键的信息。16 和 8 是"刚好够用且省空间"的平衡点 — 翻倍则 Flash 放不下,减半则表达力不够。
| 指标 | 数值 |
|---|---|
| float32 权重 | ~7.0 KB |
| int8 权重 | ~1.8 KB |
| TFLite 模型 | 5,024 字节 |
model = tf.keras.Sequential([
tf.keras.layers.InputLayer(shape=(100,)),
tf.keras.layers.Dense(16, activation='relu'),
tf.keras.layers.Dense(8, activation='relu'),
tf.keras.layers.Dense(3, activation='softmax'),
])
model.compile(optimizer=tf.keras.optimizers.Adam(0.001),
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
model.fit(X_train_s, y_train, batch_size=16, epochs=60,
validation_split=0.2,
callbacks=[tf.keras.callbacks.EarlyStopping(
monitor='val_loss', patience=12, restore_best_weights=True)])量化:把 float32 压成 int8
训练用的是 float32 — 每个权重占 4 字节。要在 MCU 上跑推理,需要把 float32 换成 int8,减少存储空间和计算开销。
量化和 map() 是一个道理——把数值从一个范围线性映射到另一个范围:
map(value, fromLow, fromHigh, toLow, toHigh)量化就是把 float32 的 [r_min, r_max] 映射到 int8 的 [-128, 127]:
q = round((r - r_min) / (r_max - r_min) * 255) - 128TFLite 的写法等价,用 scale(S)和 zero point(Z)来表达同一件事:
q = round(r / S) + ZS = (r_max - r_min) / 255— 一个 int8 刻度代表多少 float32Z— float32 的 0 对应到 int8 的哪个值
举个例子:某层激活值在 [-1.0, 1.0] 之间,则 S = 2.0/255 ≈ 0.0078,Z = 0。r = 0.5 映射后 q = 64。
TFLite 转换器会自动算每层最优的 S 和 Z — 提供几十到几百个真实样本作为校准数据即可:
def representative_dataset():
for i in range(0, min(len(X_train_s), 300)):
yield [X_train_s[i:i+1]]
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]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
tflite_model = converter.convert()量化前后的准确率对比:
| 阶段 | 准确率 |
|---|---|
| float32 训练模型 | 80.9% |
| int8 TFLite(同测试集) | 79.3% |
| 量化损失 | −1.6% |
损失不到 2%,但模型的存储空间从 7KB 降到了 1.8KB,计算从 float32 乘法变成了 int8 乘法(MCU 上快很多)。
用 C 实现特征提取
python 端的工作完成后,产出三个文件:
vibration_model_v2.tflite— 量化后的模型(5024 字节)vibration_model_v2_data.h— 模型转为 C 数组,直接#includescaler_params_v2.h— Scaler 的均值、标准差和量化参数
C 端的特征提取流水线:
CSV 一行 → 特征提取(DFT+时域) → StandardScaler → 量化到 int8 → 送入推理 runtime → 取最大类别手写 DFT 的精度细节
python 端用的是 numpy 的高性能 FFT,MCU 上只能手写朴素 DFT。注意一个容易被跳过的细节 — 角度必须用 double 算:
double omega = -2.0 * 3.141592653589793 * k / len;
for (int i = 0; i < len; i++) {
double angle = omega * i; // ← 关键:double,不是 float
real += sig[i] * cos(angle);
imag += sig[i] * sin(angle);
}
feats[k - 1] = (float)log1p(sqrt(real * real + imag * imag));为什么?6000 点的 DFT,omega * i 循环到最后一轮时 i=5999。如果用 float32 计算角度,累加误差在 10⁻³ 量级,cos/sin 之后误差被放大,最终的幅度谱和 python 端对不上。用 double 算角度,结果再截回 float,两边就对齐了。
时域特征没这个问题 — 1500 点的段内累加,float32 精度绰绰有余。
标准化 + 量化 + 推理
这步在 C++ 端复现 python 训练时的 Scaler + 量化逻辑:
for (int i = 0; i < 100; i++) {
float s = (feats[i] - kScalerMeanV2[i]) / kScalerScaleV2[i]; // Scaler
float q = s / kInputScale + kInputZeroPoint; // 量化
if (q > 127.0f) q = 127.0f; // clip 到 int8 范围
if (q < -128.0f) q = -128.0f;
input->data.int8[i] = (int8_t)lroundf(q);
}
interpreter.Invoke();TFLM 的初始化也很轻量 — 模型只有全连接和 Softmax,两个 Op 即可:
tflite::MicroMutableOpResolver<2> resolver;
resolver.AddFullyConnected();
resolver.AddSoftmax();
static uint8_t arena[16 * 1024]; // Tensor Arena
tflite::MicroInterpreter interpreter(model, resolver, arena, 16 * 1024);
interpreter.AllocateTensors();Python 与 C 端结果对比
同一批测试文件、同一组 Scaler 参数、同一个 TFLite 模型:
| 类别 | python int8 | C++ int8 | 差异 |
|---|---|---|---|
| 震动A | 94.3% | 92.4% | ±1.9% |
| 震动B | 92.0% | 93.9% | ±1.9% |
| 震动C | 53.4% | 55.7% | ±2.3% |
1-3% 的差异来自 numpy 的优化 FFT 实现 vs 朴素 DFT,以及 float64 和 double 中间精度的微小区别。两端结果高度一致,整条链路验证通过。
震动C 准确率偏低(~54%),原因是该类样本和震动B 的时域特征高度重叠,仅靠 100 维线性特征难以完全分开。这个方向上的改进空间在特征工程,不在模型结构。
资源占用
| 资源 | 用量 | 占 64KB 总量 |
|---|---|---|
| Flash(模型权重) | 5,024 B | 7.7% |
| Flash(含代码) | ~10 KB | 16% |
| RAM(Tensor Arena) | ~1,800 B | 2.7% |
| RAM(特征+工作缓冲) | ~3,000 B | 4.6% |
Flash 用了 16%,RAM 用了不到 10%。在 64KB/64KB 的 MCU 上还有充裕空间跑传感器驱动和应用层代码。
适用范围:
- 时序信号的三分类,输入几千点以内
- 特征维度 100-200,模型参数 2000-5000
- 对实时性要求不极端 — 朴素 DFT 在 MCU 上的耗时与采样点数和 bin 数量成正比,64 bin × 6000 点的计算量在秒级分类场景中足够用
如果需要更低延迟,可以对输入做降采样(如 1024 点)来减少 DFT 计算量。移植到实际 Cortex-M 开发板后,用 CMSIS-NN 的 int8 矩阵乘法内核替换 TFLM 的 Reference Fallback,可以进一步提升推理速度。
注意事项
Scaler 参数必须一字不差。 python 训练时的 mean 和 scale 要原样导出到 C 头文件。一个特征维度对不上,量化后的 int8 值直接漂到 −128 或 127 饱和,整条推理报废。
DFT 角度用 double 算。 长序列下 float32 的角度累加误差会通过 cos/sin 放大,导致幅度谱和 python 端偏差不可忽略。用 double 算角度,结果截回 float。
校准数据覆盖所有类别。 量化校准时 representative_dataset 覆盖三类震动,否则某些层的激活范围估计不准,clip 损失大。
训练前洗数据。 传感器偶发野值(读数突然飙到天文数字)会污染 Scaler 的统计。过滤掉绝对值异常的样本(比如超过 1000 的读数),用 float64 做特征提取中间计算。
防除零。 Scaler 拟合后,接近 0 的 scale_ 替换为 1.0,避免 C 头文件出现正负无穷。
在 STM32L496 上用 X-CUBE-AI 部署
前面的 C++ 推理代码先在云端虚拟机上跑通了一遍,确认整条链路(特征提取 → Scaler → 量化 → 推理)和 Python 端结果一致。实际落到 STM32L496 工程时,没有手动移植 TFLM,而是使用 STM32CubeMX 集成的 X-CUBE-AI,把同一个 vibration_model_v2.tflite 转成 ST 官方的 MCU runtime 工程代码。
CubeMX 里选择 TFLite 模型,runtime 选择 STM32Cube.AI MCU runtime。模型输入仍然是 int8[100],输出是 int8[3],核心网络结构没有变化。

X-CUBE-AI 生成后的计算图和训练时一致:
100 维输入 → Dense(16) → Dense(8) → Dense(3) → Softmax
这条部署路径的职责划分比较清楚:
- 特征工程仍然由固件侧 C 代码完成:
6000 点原始采样 → 64 维 DFT + 36 维时域特征 → StandardScaler → int8。 - 模型推理交给 X-CUBE-AI runtime:全连接层、ReLU、Softmax 和量化参数由生成代码处理。
- 验证时同时保留一个手写
tiny_infer路径,只用于对照每层 int8 计算,不作为最终 runtime。
真正容易出错的位置在模型输入之前。最初 C 端特征提取把时域特征数组偏移加了两次,导致 feats[64..99] 没有写入正确数据,栈上还发生了越界写。X-CUBE-AI 拿到错误的 100 维输入,预测结果自然和 Python 参考对不上。
修正后,把三类样本分别烧到 STM32L496 上运行,串口里看到的预测类别和预期类别一致:

最终验证结果:
| 测试样本 | 预期类别 | MCU 预测 |
|---|---|---|
| vib_A | vib_A | vib_A |
| vib_B | vib_B | vib_B |
| vib_C | vib_C | vib_C |
这说明模型部署到 STM32 后,实际分类结果和桌面端验证结果一致。真正的部署检查重点是:固件端送进模型的 100 个 int8 特征必须和训练/验证端保持同一套特征工程和量化流程。
如果想看手动移植 TFLite Micro 的做法,可以看这篇更小的正弦模型示例:CH585M 上手动移植 TFLite Micro 跑 sin demo。这篇振动分类工程选择 X-CUBE-AI,是为了直接使用 ST 官方生成的 MCU runtime;手动 TFLM 和 X-CUBE-AI 两条路线的目标相同,都是把同一个量化模型稳定地放到 MCU 上运行。
如果手上有 STM32L496 开发板,也可以直接下载这份编译产物烧录验证:HEX 固件,或者用 BIN 固件 从 0x08000000 写入。烧录后打开串口窗口看日志即可,参数是 115200 8N1。
总结与改进方向
这条路线的优点是可解释、可复现,训练、特征工程、量化和 MCU 端输入都能逐项对齐;缺点也很明显,6000 点特征提取在 MCU 上跑得比较慢,最终分类效果也只能说够用。
如果想要更好的效果,可以先用 ST 的 NanoEdge AI 跑一轮基准测试。它会给出一个黑盒模型,也会把模型路线流水线暴露出来:中间用了哪些特征提取、有没有 FFT、有没有裁剪、最后用了什么模型。先让 NanoEdge AI 帮你把特征流水线试错一遍,再把这条流水线的核心思路移植成自己的代码,这样就能从黑盒工具里提炼出一个更可控的边缘 AI 模型。