TinyML 初探:在 MCU 上部署异常检测模型,实现本地振动故障诊断 摘要工业设备的振动监测是边缘 AI 最典型的应用场景之一——传统方案把振动数据上传云端分析延迟高、流量贵、隐私差而本地推理需要在只有几百 KB RAM 的 MCU 上跑模型。本文用一个真实的电机故障诊断项目从零演示在 MCU 上部署 TinyML 模型的完整流程——数据采集、特征工程、模型训练、量化、转换、部署到 STM32。包含 TensorFlow Lite for Microcontrollers 的实际使用、CMSIS-NN 加速、内存优化和性能实测。这是付费专栏第一季的收尾篇也是第二季《嵌入式边缘 AI 实战》的开篇导引。建议收藏。一、引子一个“看起来很美好”的云端方案去年做一款工业电机监测设备需求是实时监测电机振动提前预警轴承故障。第一版方案很“标准”STM32F407 采集振动传感器加速度计数据1kHz 采样通过 4G 模块上传到云端云端跑 AI 模型分析发现异常后推送告警方案很快遇到了三个问题问题一流量成本。三轴加速度计1kHz 采样每轴 16 位一天的数据量3 轴 × 1000 Hz × 2 字节 × 86400 秒 518 MB/天每台设备每天 500MB 流量1000 台设备就是 500GB/天。4G 流量费一年 5 万多元。问题二延迟。数据上传 云端推理 告警下发端到端延迟 2-5 秒。对于高速旋转的电机轴承损坏可能在几秒内就从“早期”发展到“严重”。问题三隐私。客户是军工企业振动数据属于敏感信息不允许出厂区。结论必须本地推理。但本地推理要解决一个核心问题MCU 只有 192KB RAM、1MB Flash怎么跑 AI 模型这就是 TinyML 要解决的问题。二、TinyML 的核心思路2.1 为什么 MCU 能跑 AI很多人对“MCU 跑 AI”有误解认为这是不可能的事。其实只要模型足够小MCU 完全能跑。关键认知不是所有 AI 都需要 GPT 那种大模型——很多工业场景只需要简单分类器模型可以压缩量化float32 → int8能减 4 倍特征工程比模型复杂更重要好的特征能让简单模型达到好效果硬件加速可用Cortex-M4/M7 的 DSP 指令、CMSIS-NN 库一个典型的 MCU 异常检测模型这种规模的模型STM32F4 完全能跑。2.2 TinyML 的三大技术本项目的核心是量化这也是最成熟、最容易落地的技术。2.3 整体流程1. 数据采集振动信号 ↓ 2. 特征工程FFT、时域统计 ↓ 3. PC 端训练模型Keras ↓ 4. 模型量化TFLite Converter ↓ 5. 转换为 C 数组xxd ↓ 6. 集成到 MCU 工程TFLite Micro ↓ 7. 部署 优化CMSIS-NN ↓ 8. 实测验证三、数据采集与特征工程3.1 硬件选型3.2 数据采集采集正常和故障两种状态下的振动数据。// vibration_capture.c #include stm32f4xx_hal.h #define SAMPLE_RATE 1000 #define SAMPLE_DURATION 1 // 采集 1 秒 1000 个点 typedef struct { int16_t x; int16_t y; int16_t z; } accel_sample_t; accel_sample_t g_buffer[SAMPLE_RATE * SAMPLE_DURATION]; void CaptureVibration(void) { uint32_t start HAL_GetTick(); uint32_t idx 0; while (idx SAMPLE_RATE * SAMPLE_DURATION) { uint32_t now HAL_GetTick(); if (now - start idx) { // 每 1ms 采一次 ADXL345_Read(g_buffer[idx].x, g_buffer[idx].y, g_buffer[idx].z); idx; } } }采集数据量每个状态 100 组每组 1 秒。这些数据通过 UART 传输到 PC保存为 CSV。3.3 特征工程关键点不要把原始振动信号直接喂给模型而是提取特征。原因原始信号 1000×33000 个点模型输入太大会占用大量 RAM特征能大幅压缩输入比如 FFT 的前 20 个峰值 20 个特征特征有物理意义可解释性强本项目提取的特征# feature_extraction.py import numpy as np from scipy.fft import fft def extract_features(samples: np.ndarray) - np.ndarray: 从振动信号提取特征 samples: shape (N, 3)N 个采样点3 轴 返回: shape (F,)F 个特征 features [] for axis in range(3): signal samples[:, axis].astype(np.float32) # 时域特征 features.append(np.mean(signal)) features.append(np.std(signal)) features.append(np.max(np.abs(signal))) features.append(np.sqrt(np.mean(signal ** 2))) # RMS # 峰峰值 features.append(np.max(signal) - np.min(signal)) # 峭度Kurtosis对冲击敏感 features.append(np.mean(((signal - np.mean(signal)) / (np.std(signal) 1e-8)) ** 4)) # 频域特征FFT spectrum np.abs(fft(signal))[:len(signal) // 2] # 前 5 个峰值频率对应的幅值 top5_idx np.argsort(spectrum)[-5:] features.extend(spectrum[top5_idx]) # 频谱质心 features.append(np.sum(np.arange(len(spectrum)) * spectrum) / (np.sum(spectrum) 1e-8)) return np.array(features, dtypenp.float32)每轴提取 12 个特征3 轴共 36 个特征。为什么这些特征有用轴承故障的典型特征在轴承故障特征频率BPFO、BPFI处出现峰值。峭度和 FFT 峰值是最重要的特征。3.4 数据集准备# prepare_dataset.py import numpy as np import pandas as pd from glob import glob def load_dataset(): X [] y [] # 加载正常样本 for f in glob(data/normal/*.csv): samples pd.read_csv(f).values features extract_features(samples) X.append(features) y.append(0) # 0 正常 # 加载轻微异常样本 for f in glob(data/minor/*.csv): samples pd.read_csv(f).values features extract_features(samples) X.append(features) y.append(1) # 1 轻微异常 # 加载严重故障样本 for f in glob(data/severe/*.csv): samples pd.read_csv(f).values features extract_features(samples) X.append(features) y.append(2) # 2 严重故障 X np.array(X, dtypenp.float32) y np.array(y, dtypenp.int32) # 打乱顺序 idx np.random.permutation(len(X)) X, y X[idx], y[idx] # 划分训练集/测试集8:2 split int(len(X) * 0.8) return X[:split], y[:split], X[split:], y[split:] if __name__ __main__: X_train, y_train, X_test, y_test load_dataset() np.save(X_train.npy, X_train) np.save(y_train.npy, y_train) np.save(X_test.npy, X_test) np.save(y_test.npy, y_test) print(f训练集: {X_train.shape}, 测试集: {X_test.shape}) print(f特征维度: {X_train.shape[1]})四、PC 端训练模型4.1 模型设计关键原则关键原则模型要小 100KB量化后输入要小本项目 36 个特征输出要简单3 分类层数要少2-3 层全连接就够# train_model.py import numpy as np import tensorflow as tf from tensorflow import keras def build_model(input_dim: int, num_classes: int) - keras.Model: 构建小型全连接网络 model keras.Sequential([ keras.layers.Input(shape(input_dim,)), # 第一层36 - 32 keras.layers.Dense(32, activationrelu), keras.layers.Dropout(0.2), # 第二层32 - 16 keras.layers.Dense(16, activationrelu), # 输出层16 - 3 keras.layers.Dense(num_classes, activationsoftmax), ]) return model def main(): # 加载数据 X_train np.load(X_train.npy) y_train np.load(y_train.npy) X_test np.load(X_test.npy) y_test np.load(y_test.npy) # 特征归一化重要 mean X_train.mean(axis0) std X_train.std(axis0) 1e-8 X_train (X_train - mean) / std X_test (X_test - mean) / std # 保存归一化参数部署时要一起嵌入 np.save(norm_mean.npy, mean) np.save(norm_std.npy, std) # 构建模型 model build_model(X_train.shape[1], 3) model.summary() # 编译 model.compile( optimizerkeras.optimizers.Adam(learning_rate0.001), losssparse_categorical_crossentropy, metrics[accuracy], ) # 训练 history model.fit( X_train, y_train, validation_data(X_test, y_test), epochs100, batch_size16, verbose1, ) # 评估 loss, acc model.evaluate(X_test, y_test, verbose0) print(f测试集准确率: {acc:.4f}) # 保存模型 model.save(vibration_model.h5) if __name__ __main__: main()典型训练结果Epoch 100/100 8/8 [] - 0s 5ms/step - loss: 0.0321 - accuracy: 0.9917 测试集准确率: 0.9833未量化的模型参数量36×32 32 32×16 16 16×3 3 1727 个参数模型大小1727 × 4 字节 6.9 KB已经很小了但我们可以继续压缩。4.2 模型量化量化的核心把 float32 权重和激活值转成 int8。# quantize_model.py import numpy as np import tensorflow as tf def representative_dataset(): 提供代表性样本用于量化校准 X_train np.load(X_train.npy) mean np.load(norm_mean.npy) std np.load(norm_std.npy) X_train (X_train - mean) / std for i in range(100): yield [X_train[i:i1].astype(np.float32)] def main(): # 加载模型 model tf.keras.models.load_model(vibration_model.h5) # 转换为 TFLite converter tf.lite.TFLiteConverter.from_keras_model(model) # 启用量化 converter.optimizations [tf.lite.Optimize.DEFAULT] converter.representative_dataset representative_dataset # 强制输入输出也是 int8 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() # 保存 with open(vibration_model_int8.tflite, wb) as f: f.write(tflite_model) print(f量化后模型大小: {len(tflite_model)} 字节) if __name__ __main__: main()量化效果精度只掉 0.5%体积减少 74%绝对值得。4.3 转成 C 数组# 把 .tflite 文件转成 C 数组 xxd -i vibration_model_int8.tflite vibration_model.cc生成的 C 文件unsigned char vibration_model_int8_tflite[] { 0x1c, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4c, 0x33, 0x00, 0x00, 0x12, 0x00, // ... 省略 }; unsigned int vibration_model_int8_tflite_len 1840;这个 C 数组就是要嵌入到固件里的模型。五、MCU 端部署5.1 集成 TensorFlow Lite MicroTFLite Micro 是 Google 为 MCU 设计的轻量级推理引擎。获取源码git clone https://github.com/tensorflow/tflite-micro.git需要加入工程的源文件tflite-micro/tensorflow/lite/ ├── micro/ │ ├── micro_interpreter.cc │ ├── micro_error_reporter.cc │ ├── micro_allocator.cc │ ├── micro_string.cc │ ├── memory_planner/ │ │ └── greedy_memory_planner.cc │ └── kernels/ │ ├── fully_connected.cc │ ├── softmax.cc │ └── ... └── ...通过 Makefile 集成# Makefile TFLM_DIR tflite-micro/tensorflow/lite C_SOURCES \ $(TFLM_DIR)/micro/micro_interpreter.cc \ $(TFLM_DIR)/micro/micro_error_reporter.cc \ $(TFLM_DIR)/micro/micro_allocator.cc \ $(TFLM_DIR)/micro/micro_string.cc \ $(TFLM_DIR)/micro/memory_planner/greedy_memory_planner.cc \ $(TFLM_DIR)/micro/kernels/fully_connected.cc \ $(TFLM_DIR)/micro/kernels/softmax.cc \ $(TFLM_DIR)/micro/kernels/quantize.cc \ $(TFLM_DIR)/micro/kernels/dequantize.cc C_INCLUDES \ -I$(TFLM_DIR)/.. \ -I$(TFLM_DIR)/micro \ -I$(TFLM_DIR)/micro/kernels CFLAGS -DTF_LITE_STATIC_MEMORY5.2 MCU 端推理代码// vibration_inference.cc #include tensorflow/lite/micro/micro_interpreter.h #include tensorflow/lite/micro/micro_error_reporter.h #include tensorflow/lite/micro/micro_mutable_op_resolver.h #include tensorflow/lite/schema/schema_generated.h #include vibration_model.h // 包含模型数组 // 模型和解释器 static const tflite::Model* s_model nullptr; static tflite::MicroInterpreter* s_interpreter nullptr; static TfLiteTensor* s_input nullptr; static TfLiteTensor* s_output nullptr; // 张量竞技场Tensor Arena模型运行时的工作内存 // 大小需要根据模型确定从 8KB 开始试 static constexpr int kTensorArenaSize 8 * 1024; static uint8_t s_tensor_arena[kTensorArenaSize]; // 输出分类名称 static const char* kClassNames[] {正常, 轻微异常, 严重故障}; int Vibration_Init(void) { // 1. 加载模型 s_model tflite::GetModel(vibration_model_int8_tflite); if (s_model-version() ! TFLITE_SCHEMA_VERSION) { return -1; } // 2. 注册算子只注册模型用到的节省 Flash static tflite::MicroMutableOpResolver4 resolver; resolver.AddFullyConnected(); resolver.AddSoftmax(); resolver.AddQuantize(); resolver.AddDequantize(); // 3. 创建解释器 static tflite::MicroErrorReporter error_reporter; static tflite::MicroInterpreter static_interpreter( s_model, resolver, s_tensor_arena, kTensorArenaSize, error_reporter); s_interpreter static_interpreter; // 4. 分配张量 if (s_interpreter-AllocateTensors() ! kTfLiteOk) { return -2; } // 5. 获取输入输出张量 s_input s_interpreter-input(0); s_output s_interpreter-output(0); return 0; } // 执行推理 int Vibration_Inference(const float *features, int feature_count, float *probabilities, int *predicted_class) { if (feature_count ! s_input-dims-data[1]) { return -1; } // 输入量化参数 float input_scale s_input-params.scale; int32_t input_zero_point s_input-params.zero_point; // 1. 把 float 特征量化成 int8 for (int i 0; i feature_count; i) { int32_t quantized (int32_t)(features[i] / input_scale) input_zero_point; // 限制到 int8 范围 if (quantized 127) quantized 127; if (quantized -128) quantized -128; s_input-data.int8[i] (int8_t)quantized; } // 2. 执行推理 if (s_interpreter-Invoke() ! kTfLiteOk) { return -2; } // 3. 输出去量化 float output_scale s_output-params.scale; int32_t output_zero_point s_output-params.zero_point; int best_class 0; float best_score -1.0f; for (int i 0; i s_output-dims-data[1]; i) { int8_t raw s_output-data.int8[i]; float score (raw - output_zero_point) * output_scale; probabilities[i] score; if (score best_score) { best_score score; best_class i; } } *predicted_class best_class; return 0; }5.3 集成到 FreeRTOS 任务// vibration_task.c #include FreeRTOS.h #include task.h extern int Vibration_Init(void); extern int Vibration_Inference(const float *features, int feature_count, float *probabilities, int *predicted_class); void vVibrationTask(void *pvParameters) { // 初始化 if (Vibration_Init() ! 0) { // 错误处理 vTaskDelete(NULL); } float features[36]; float probabilities[3]; int predicted_class; while (1) { // 1. 采集振动数据 accel_sample_t samples[1000]; CaptureVibration(samples); // 2. 提取特征 ExtractFeatures(samples, features); // 3. 归一化用 PC 端保存的参数 for (int i 0; i 36; i) { features[i] (features[i] - g_norm_mean[i]) / g_norm_std[i]; } // 4. 推理 if (Vibration_Inference(features, 36, probabilities, predicted_class) 0) { // 5. 处理结果 if (predicted_class 2) { // 严重故障触发告警 TriggerAlarm(严重故障); } else if (predicted_class 1) { // 轻微异常记录 LogWarning(轻微异常); } } // 每秒采一次 vTaskDelay(pdMS_TO_TICKS(1000)); } }注意特征提取函数ExtractFeatures在 MCU 上实现需要用 ARM 的 DSP 库或自己实现 FFT。这是 MCU 端的核心难点。六、MCU 端特征提取6.1 时域特征直接计算不需要额外库。// feature_extract.c #include math.h #include string.h typedef struct { int16_t x; int16_t y; int16_t z; } accel_sample_t; static void extract_time_domain(const accel_sample_t *samples, int count, int axis, float *features) { // 提取某一轴的时域特征 int32_t sum 0; int16_t max_v -32768, min_v 32767; int32_t sum_sq 0; for (int i 0; i count; i) { int16_t v (axis 0) ? samples[i].x : (axis 1) ? samples[i].y : samples[i].z; sum v; sum_sq (int32_t)v * v; if (v max_v) max_v v; if (v min_v) min_v v; } float mean (float)sum / count; float rms sqrtf((float)sum_sq / count); float peak_to_peak (float)(max_v - min_v); // 标准差 float var 0; for (int i 0; i count; i) { int16_t v (axis 0) ? samples[i].x : (axis 1) ? samples[i].y : samples[i].z; float d v - mean; var d * d; } float std sqrtf(var / count); // 峭度 float kurt_sum 0; for (int i 0; i count; i) { int16_t v (axis 0) ? samples[i].x : (axis 1) ? samples[i].y : samples[i].z; float d (v - mean) / (std 1e-8f); kurt_sum d * d * d * d; } float kurtosis kurt_sum / count; features[0] mean; features[1] std; features[2] (float)(max_v -min_v ? max_v : -min_v); // 峰值 features[3] rms; features[4] peak_to_peak; features[5] kurtosis; }6.2 频域特征用 CMSIS-DSP 库做 FFT。#include arm_math.h #define FFT_SIZE 1024 // FFT 缓冲区 static float32_t s_fft_input[FFT_SIZE]; static float32_t s_fft_output[FFT_SIZE]; static arm_rfft_fast_instance_f32 s_fft_instance; void FFT_Init(void) { arm_rfft_fast_init_f32(s_fft_instance, FFT_SIZE); } static void extract_frequency_domain(const accel_sample_t *samples, int count, int axis, float *features) { // 1. 填充 FFT 输入加窗 for (int i 0; i FFT_SIZE i count; i) { int16_t v (axis 0) ? samples[i].x : (axis 1) ? samples[i].y : samples[i].z; // 汉宁窗 float window 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1))); s_fft_input[i] v * window; } // 补零 for (int i count; i FFT_SIZE; i) { s_fft_input[i] 0.0f; } // 2. 执行 FFT arm_rfft_fast_f32(s_fft_instance, s_fft_input, s_fft_output, 0); // 3. 计算幅值 float32_t magnitudes[FFT_SIZE / 2]; arm_cmplx_mag_f32(s_fft_output, magnitudes, FFT_SIZE / 2); // 4. 找前 5 个峰值 // 简化实现遍历找到最大的 5 个 float peaks[5] {0}; for (int i 1; i FFT_SIZE / 2; i) { float m magnitudes[i]; for (int j 0; j 5; j) { if (m peaks[j]) { for (int k 4; k j; k--) { peaks[k] peaks[k-1]; } peaks[j] m; break; } } } // 5. 计算频谱质心 float sum_mag 0, sum_weighted 0; for (int i 0; i FFT_SIZE / 2; i) { sum_mag magnitudes[i]; sum_weighted i * magnitudes[i]; } float spectral_centroid sum_weighted / (sum_mag 1e-8f); // 6. 输出特征 for (int i 0; i 5; i) features[i] peaks[i]; features[5] spectral_centroid; }6.3 完整特征提取void ExtractFeatures(const accel_sample_t *samples, int count, float *features) { for (int axis 0; axis 3; axis) { extract_time_domain(samples, count, axis, features axis * 12); extract_frequency_domain(samples, count, axis, features axis * 12 6); } }实测耗时STM32F407 168MHz加上推理时间约 2ms总共约 8.5ms。对每秒采一次的任务完全够用。七、CMSIS-NN 加速CMSIS-NN 是 ARM 为 Cortex-M 优化的神经网络推理库比 TFLite Micro 自带的算子快 2-5 倍。7.1 使用方式TFLite Micro 支持 CMSIS-NN 后端只需在 Makefile 里加上C_SOURCES \ tflite-micro/tensorflow/lite/micro/kernels/cmsis_nn/fully_connected.cc \ tflite-micro/tensorflow/lite/micro/kernels/cmsis_nn/softmax.cc \ tflite-micro/tensorflow/lite/micro/kernels/cmsis_nn/... CFLAGS -DCMSIS_NN C_INCLUDES -Ipath/to/CMSIS-NN/Include同时在解析器里注册 CMSIS-NN 版本#include tensorflow/lite/micro/kernels/cmsis_nn/fully_connected.h resolver.AddFullyConnected(tflite::Register_FULLY_CONNECTED_INT8());7.2 性能对比在 STM32F407 168MHz 上整体推理时间从 2ms 降到 0.8ms。八、内存优化8.1 Tensor Arena 大小Tensor Arena 是 TFLite Micro 的工作内存需要根据模型确定。如果太小AllocateTensors()返回失败。如果太大浪费 RAM。确定方法从 4KB 开始尝试失败就增加一倍成功后再减 1KB找到临界值最终值加 20% 余量本项目实测8.2 模型裁剪如果 RAM 不够可以进一步裁剪模型减少神经元数量32 → 16减少层数3 层 → 2 层减少特征数36 → 20只保留最重要的特征但要注意精度损失。8.3 Flash 占用完整部署的 Flash 占用加上其他功能UART、传感器、FreeRTOS总计约 120KB Flash。STM32F407 的 1MB Flash 绰绰有余。九、实测效果在 100 组测试数据上评估关键结论在 STM32F407 上跑振动异常检测完全可行端到端延迟从云端的 2-5 秒降到本地的 10 毫秒流量成本降为零数据不出设备隐私安全整体方案价值每台设备节省 4G 流量费约 50 元/年1000 台设备节省 5 万元/年故障预警时间提前 2-5 秒避免重大事故十、避坑清单十一、小结与第二季预告本文从一个真实的电机振动监测项目出发完整演示了在 MCU 上部署 TinyML 模型的流程——数据采集、特征工程、模型训练、量化、转换、部署到 STM32。最终实现了 97.8% 的准确率、8.5ms 的推理延迟、55KB 的 Flash 占用。这个方案比云端推理延迟低 200 倍、流量成本为零、数据不出设备。三个核心要点MCU 上的 AI 不是缩小版 GPT而是特征工程 小模型 量化特征工程比模型设计更重要——好的特征能让简单模型达到好效果量化是必经之路——float32 模型在 MCU 上又大又慢这是付费专栏第一季《嵌入式系统调优高手课》的收尾篇。20 篇文章涵盖了 RTOS 内核、内存池、链接脚本、优先级反转、编译器优化、中断风暴、Cache 一致性、低功耗、日志系统、栈保护、固件瘦身、OOP、状态机、安全启动、防逆向、敏感数据存储、RISC-V 移植、TinyML。从底层汇编到系统架构从性能优化到安全加固构成一套完整的嵌入式系统调优方法论。第二季《嵌入式边缘 AI 实战从 STM32 到 TinyML 的模型部署与优化》即将推出将深入以下主题TFLite Micro 架构剖析interpreter、arena、op resolver模型量化进阶per-channel、混合量化、量化感知训练CMSIS-NN 与 XNNPACK 深度对比关键词唤醒KWS实战——音频流 MFCC CNN手势识别——ToF 传感器 轻量级分类模型剪枝与知识蒸馏边缘 AI 的数据闭环与 OTA 更新NPU 初探——Ethos-U55 与 CMSIS-NN 性能对比多模型协同——在单 MCU 上同时跑检测 分类 控制从 TinyML 到 Edge Impulse——低代码平台的利与弊