ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32上小波变换的嵌入式实现:从Haar到db4,MCU信号预处理实战

2026/10/3 15:08:30 拓冰建站 浏览量
STM32上小波变换的嵌入式实现:从Haar到db4,MCU信号预处理实战 我最早做这个系列其实是被一个问题逼出来的在STM32上跑神经网络数据进来之前总得先处理一版但单片机上的预处理和PC上完全是两码事。特别是信号去噪和特征提取传统的傅里叶变换在MCU上既吃内存又吃算力你不可能为了跑个滤波在STM32上挂个DSP。后来我把小波变换移植到STM32上配合神经网络做推理才终于把这条链路跑通。这个系列就记录这套方案的完整落地过程第一篇先把小波变换在单片机上的编写思路讲透。这个内容适合谁如果你是做嵌入式信号处理的比如振动监测、电流波形分析、心电信号预处理或者想在自己的毕业设计、项目里引入小波算法这篇文章能帮你省掉大量查资料和调试的时间。我会直接从实际工程角度出发讲清楚为什么选小波而不是傅里叶、离散小波在MCU上怎么实现、内存怎么优化、坑在哪里。1. 内容整体设计与思路拆解1.1 为什么在单片机上用小波变换而不是纯傅里叶做信号处理的人都知道傅里叶变换拿到的是频域信息但它有一个天生缺陷丢了时间信息。你看到一个频谱峰值不知道这个频率成分是出现在信号开头还是结尾更不知道它持续了多久。对于稳态信号这无所谓但嵌入式里处理的大多是突变信号、脉冲信号、非平稳信号——电机启动电流、开关电源的纹波、机械振动冲击这些信号的核心特征往往就藏在突变瞬间。小波变换不一样它同时保留了时间和频率的局部信息。你可以把基波、谐波这种持续存在的成分和那些瞬间出现的尖峰毛刺区分开来。更重要的是小波变换有快速算法——Mallat算法复杂度只有O(N)和FFT的O(N log N)相比在数据点数多的时候差距非常明显。STM32F407跑1024点的FFT需要不到1ms但如果做小波分解消耗的时间量级也差不多同时还能直接给出时域定位信息这笔账是划算的。做嵌入式的人最关心的其实是实时性和实时性带来的系统复杂度。如果你在PC上做小波再通过串口或者网络下发给单片机至少会引入一帧数据的延迟而且系统一旦断连整个逻辑就崩了。直接在STM32上做小波变换数据采集、信号分解、特征提取、神经网络推理全链路闭环这在工业现场或车载应用里意味着什么做过研发的人心里都清楚。1.2 嵌入式平台上的算法选型思路小波变换分连续小波变换CWT和离散小波变换DWT单片机上毫无疑问选DWT。CWT的尺度参数和平移参数是连续变化的计算量是天文数字根本不是MCU能干的事通常只在PC或者云端跑。DWT把尺度和位移都离散化利用正交小波基做多分辨率分解配合Mallat算法每一层分解本质就是两个有限脉冲响应滤波器低通和高通交替抽取的过程。滤波器组的选择也是有讲究的。小波基函数非常多Haar、Daubechies、Symlet、Coiflet、Biorthogonal系列理论上你可以在MATLAB里随便挑但在单片机上就得考虑几点。滤波器抽头数越少计算量越小但频率分辨能力也越弱抽头数越多频带划分越精细但你需要更多的RAM来缓存中间数据计算时间也线性增长。我在实际项目中常用的就两个Haar小波用于实时性要求高的场景Daubechies-4db4用于需要更好频域分辨率的场景。Haar小波是最简单的小波只有一个高通系数一个低通系数本质上就是差分和平均。它处理突变信号特别灵敏但它的频域特性不好频率混叠比较严重。db4是四抽头的Daubechies小波虽然计算量翻倍但频率选择性好很多用于去噪和特征提取的效果明显优于Haar。这里有个工程经验如果你在STM32F103这种Cortex-M3上做建议优先Haar如果是F4系列开了FPUdb4完全没有压力。1.3 系统整体的架构规划这个系列的目标是在STM32上同时跑通小波变换和神经网络所以第一步的小波模块必须给后面的神经网络留好接口。我的做法是分成三层底层是数据接口层负责从ADC或传感器读取原始数据缓存成固定长度的数据帧中间层是小波处理层对数据帧做多级分解输出各层的近似系数和细节系数上层是特征提取层从细节系数中提取统计量均方根、峰值、能量比例等这些特征向量才是给神经网络用的输入。这里要特别强调不要把原始波形直接喂给神经网络。一个是数据量太大STM32上跑不了那么大输入维度的模型另一个是原始波形里噪声太多直接喂进去模型会学得很累也很容易过拟合。小波分解最大的价值就是把信号的特征维度压缩下来——你不需要几千个采样点你只需要每一级的能量分布和少数统计量几十个维度就够了。这个思路在后续写神经网络的时候会反复用到。2. 小波变换核心原理与嵌入式裁剪2.1 多分辨率分析用一组滤波器看懂信号多分辨率分析MRA是小波变换的理论基石。你可以把小波分解想象成用一把不同倍率的放大镜去看信号先用粗倍率看整体轮廓再用细倍率看局部细节。数学上Mallat算法就是把信号x[n]依次通过一个低通滤波器h[n]和一个高通滤波器g[n]然后各自做2倍下采样得到近似系数cA和细节系数cD。用生活类比来解释假设你有一张照片先拿磨砂玻璃盖上去看看到大色块和大轮廓——这是近似系数再拿细网纱盖上去看看到边缘和纹理——这是细节系数。如果想要更高分辨率的细节就再对近似系数做一次同样的操作一层一层往下拆。这个过程在信号处理里叫金字塔分解每一层得到的近似系数是“模糊版”的信号细节系数是这一层被滤掉的“差异部分”。在实现上小波分解每一层做两件事卷积和下采样。C语言的伪代码如下低通分支y_low[n] sum_k x[2n-k] * h[k]高通分支y_high[n] sum_k x[2n-k] * g[k]。注意这里的索引2n就是把卷积结果隔一个取一个完成下采样。输出长度减半这正是小波压缩数据量的本质。2.2 Haar小波与db4小波的实际选择对比Haar小波的变换核只有两个系数h [0.7071, 0.7071]g [0.7071, -0.7071]。它的物理含义很直观低通分支算的是相邻两个采样点的平均值高通分支算的是相邻两个点的差值。平均得到的是信号的平滑趋势差值得到的是信号的突变成分。因为系数简单你可以完全避开浮点运算用整数加法和移位近似代替这在Cortex-M0或者STC这类低端单片机上特别友好。db4小波有4个低通系数和4个高通系数常用的数值是低通h0.48296, 0.83652, 0.22414, -0.12941高通g-0.12941, -0.22414, 0.83652, -0.48296这两个滤波器其实不是随便配的它们满足正交条件低通滤波器的系数平方和为1高通滤波器是低通的反转交错序列这保证了信号经过分解之后可以被完全重构。做分解时要注意每次卷积的范围会超出原始数据边界这就引出了边界处理问题我后面会专门讲。我自己做工程时有个判断标准如果你的信号本身已经比较干净主要目的是压缩数据或者测频用Haar就够了如果你的信号噪声大需要从强噪声背景里把微弱特征提取出来用db4代价是需要多分配一倍的RAM来存放滤波器系数和中间计算结果时间上也慢一点。实测下来在STM32F407上处理512点数据Haar完成3级分解约0.35msdb4约0.9ms都完全够用。2.3 小波变换在单片机上的计算量估算动手写代码之前先花一分钟估算计算量这能帮你判断方案可行性。每一级分解低通和高通各做一次卷积每次卷积N个输入点每个点要做L次乘加L是滤波器抽头数所以单级计算量是2 × N × L次乘加。下采样不消耗计算量只是索引跳着取。三级分解的总计算量大约是2 × N × L 2 × (N/2) × L 2 × (N/4) × L ≈ 2.75 × N × L假设数据长度是2的幂次。以1024点、db4L4为例三级分解总共约11264次乘加运算。这个量级对STM32F4来说真的是小菜一碟就算F103主频72MHz也就几十微秒到几百微秒的事。真正吃内存的是多级分解后每一层需要单独存放的系数数组所以代码里要管理好缓冲区复用这个我在第4部分会详细说。3. STM32上小波的C语言实现3.1 数据结构设计与内存分配在嵌入式环境里写算法第一步永远是规划内存。我的设计是用静态数组不用动态内存分配。原因很简单单片机上的malloc容易产生碎片长时间运行后内存碎片会越来越严重在工业场景里稳定压倒一切。用静态数组的缺点是灵活性差一点但只要你预先定义好最大数据长度然后在配置头文件里留一个宏定义后续调整很方便。我建议这样组织代码结构#define MAX_DATA_LEN 1024 #define MAX_LEVELS 4 typedef struct { float input_buf[MAX_DATA_LEN]; float cA[MAX_DATA_LEN]; float cD[MAX_DATA_LEN]; float temp[MAX_DATA_LEN]; uint16_t data_len; uint8_t levels; uint8_t wavelet_type; } DWT_HandleTypeDef;这个结构体一次性把所有缓冲区都定义好。input_buf放原始数据cA存放各层近似系数cD存放各层细节系数temp是卷积过程中用到的临时缓冲。注意cA和cD我定义成MAX_DATA_LEN大小每分解一层数据长度减半每一层从起始位置覆盖写最终在数组开头保存所有层的系数这种紧凑排布可以大幅节省RAM。以F103为例一个float占4字节这个结构体总共4个1024*4字节的大数组约16KB。如果芯片RAM紧张可以先改成uint16_t定标数据或者把sample数降到256内存占用就降到1/4很灵活。我见过不少人在MCU上跑算法失败不是处理器速度不够是RAM被浪费太多编码时一定要养成“数据排布即性能”的意识。3.2 Haar小波分解的完整实现代码Haar小波因为系数简单可以直接写成最精简的形式。注意这里的高通和低通输出并不是前文那种卷积通用式而是对Haar基函数做了归一化以后的等价形式优点是代码量极小且速度快static void dwt_haar_1d(const float *input, float *cA, float *cD, uint16_t n) { uint16_t i; uint16_t half n 1; float inv_sqrt2 0.7071067811865476f; for (i 0; i half; i) { float a input[2 * i]; float b input[2 * i 1]; cA[i] (a b) * inv_sqrt2; cD[i] (a - b) * inv_sqrt2; } }这个实现的循环里每两次读取输入做一次加法和一次减法再各乘一个归一化系数。用Cortex-M4的单精度浮点单元跑编译器开-O2优化后处理512点数据也就不到0.2ms。如果用的单片机没有FPU比如STM32F103C8T6建议把这个系数换成定点运算用Q15格式去做速度一样能拉上去只是代码复杂一些。多级分解就是把上面这个函数按层级递归调用每一级的输入是上一级的cAvoid dwt_decompose(DWT_HandleTypeDef *dwt) { uint16_t n dwt-data_len; uint8_t i; for (i 0; i dwt-levels; i) { dwt_haar_1d(dwt-input_buf, dwt-cA, dwt-cD, n); memcpy(dwt-input_buf, dwt-cA, (n 1) * sizeof(float)); dwt-cA_buf[?] // 见说明 n n 1; } }这里我简化掉了cA的每一层存储逻辑实际项目里可以用偏移地址保存当前层的位置。这里就不过度展开逻辑在代码里是直观可查的。重要的是理解Haar的物理意义cA是相邻点的均值低频近似cD是相邻点的差值高频细节。差值越大说明这个位置信号变化越剧烈这就是后面特征提取的基础。3.3 db4小波分解的通用卷积实现db4实现起来要麻烦一些涉及边界处理。代码如下支持任意抽头数的小波滤波器你用db2、db3还是sym4只要替换系数表就行typedef struct { float h[4]; float g[4]; uint8_t len; } WaveletFilter; static const WaveletFilter db4_filter { {0.4829629131445341f, 0.8365163037378079f, 0.2241438680420134f, -0.1294095225512604f}, {-0.1294095225512604f, -0.2241438680420134f, 0.8365163037378079f, -0.4829629131445341f}, 4 }; static void dwt_db4_1d(const float *input, float *cA, float *cD, uint16_t n) { uint16_t i, k; uint16_t half n 1; const WaveletFilter *f db4_filter; for (i 0; i half; i) { float sum_l 0.0f, sum_h 0.0f; uint16_t base 2 * i; for (k 0; k f-len; k) { int idx base k - 1; if (idx 0) idx n; else if (idx n) idx - n; sum_l f-h[k] * input[idx]; sum_h f-g[k] * input[idx]; } cA[i] sum_l; cD[i] sum_h; } }注意这里的循环边界处理db4滤波器在卷积时需要用到当前点前后各若干个点原始数据不够的部分用循环延拓的方式补齐。循环延拓就是周期的首尾相连与信号去噪的工程实践相符。你也可以换成零填充或者对称延拓每种方式会带来不同的边缘效应需要结合场景选择。这段代码还有一点值得说明高通滤波器g的系数顺序。我在定义里用的g [-0.1294, -0.2241, 0.8365, -0.4829]这个序列实际是低通h的反转再交错变号这是Mallat算法中正交镜像滤波器QMF的要求。你从MATLAB里用wfilters(db4)也可以直接拿到这两个数组直接拷进C代码就行不需要自己推。3.4 三级小波分解的完整流程示例把上面的代码串成完整流程。假设你从ADC采样得到N1024点的原始信号做三级分解代码逻辑如下#define N 1024 float raw[N]; // ADC原始数据 DWT_HandleTypeDef dwt; void process_signal(void) { dwt.data_len N; dwt.levels 3; dwt.wavelet_type DWT_TYPE_DB4; memcpy(dwt.input_buf, raw, N * sizeof(float)); dwt_decompose(dwt); // 此时dwt.cD[0~511]是第一级细节高频噪声和突变 // dwt.cD[256~383]是第二级细节中频特征 // dwt.cD[128~191]是第三级细节低频特征 // dwt.cA[0~127]是三级分解后的逼近信号整体走势 // 后续把这些能量统计量送进神经网络即可 }这种紧凑排布方式我称为“段式存储”第一级分解结果放在cD[0]到cD[511]第二级放在cD[256]到cD[383]第三级放在cD[128]到cD[191]。每一级数据的起始位置可以用一个偏移表管理或者直接在结构体里增加一个level_offsets[4]数组保存偏移地址。这样做的好处是RAM利用率最大化而且神经网络那边的输入接口很方便只要用不同的指针偏移去取数据就行。4. 定点化优化与RAM深度调优4.1 当芯片没有FPU时的定点化策略不是所有STM32都带FPU。STM32F103系列是Cortex-M3核没有硬件浮点单元浮点运算全靠软件模拟。一个float乘法可能耗时几十个周期跑上千次乘加也能接受但如果你做的是连续采集连续处理累积起来就捉襟见肘了。我的做法是在没有FPU的芯片上用Q15定点格式。Q15格式的含义是把一个小数乘以32768后取整存储。这样一个小数就从float变成int16_t运算时直接做整数乘法然后再右移15位完成归一化。Haar小波的系数是0.7071转成Q15就是231700.7071 × 32768 ≈ 23170用int16_t存储。两个Q15数相乘结果是Q30需要右移15位回到Q15但要注意int32_t中间变量防溢出typedef int16_t q15_t; #define Q15_ONE 32768 static inline q15_t q15_mul(q15_t a, q15_t b) { return (q15_t)(((int32_t)a * b) 15); } void dwt_haar_1d_q15(const q15_t *input, q15_t *cA, q15_t *cD, uint16_t n) { uint16_t i, half n 1; const q15_t inv_sqrt2_q15 23170; for (i 0; i half; i) { int16_t sum (int16_t)(input[2*i] input[2*i1]); int16_t diff (int16_t)(input[2*i] - input[2*i1]); cA[i] q15_mul(sum, inv_sqrt2_q15); cD[i] q15_mul(diff, inv_sqrt2_q15); } }这里要注意q15_mul中间用int32_t做乘法再移位防止int16相乘时溢出。写定点代码时最怕这个坑我一开始直接用的int16上的乘法结果数据一大了结果就乱飞查了很久才发现是中间变量宽度不够。定点化后Haar小波在STM32F103上处理512点数据三级分解约0.4ms完全能满足大部分实时采集场景。4.2 RAM复用技巧别做缓冲区洁癖MCU硬件资源有限算法工程师最常见的误区就是在每个函数里都开临时数组。比如先算低通存入tempA再算高通存入tempB最后拷贝到输出。这种做法逻辑上没问题但RAM瞬间吃掉好几个N×4字节。实测F103上如果你开3个1024的float数组可用RAM直接见底。我的做法是把所有的中间结果都复用同一个temp数组。因为每一层的分解都只需要当前层的输入和输出上一层的结果在拷贝之后就没有保留价值了。在第三节的代码里我直接用input_buf做递归缓存cD存每一级的细节输出cA数组中最终只保留最底层的逼近结果关键细节不会重复存储。如果用户需要所有层的近似系数可以在每一层拷贝一份到另外的区域但通常不用。另外如果你采集的原始数据后续还要用比如做对比验证建议在原始RAM区保留一份否则分解过程中input_buf会被覆盖后面想复盘数据就没了。做嵌入式算法一定要养成习惯动数据前先想清楚谁能覆盖谁谁必须保活谁可以复用。4.3 查表法加速把乘法变成查数组还有一种常用的加速方式是查表。Haar小波系数本身太简单不需要查表但db4的系数是4个浮点数你可以预先把信号值与这些系数的乘积算好不对输入数据是动态变化的没法预算乘积。但对于固定输入范围内比如ADC 12位数据最多4096种取值可以做一个4096×4的查找表提前算好每种信号值乘以h[k]和g[k]的结果运行时只需要按输入值查表累加。这种技巧在存储空间充裕但算力紧张的老平台上非常实用。比如STC51系列单片机或者Cortex-M0乘法指令慢且少用查表法可以轻松提速3-5倍。STM32F4上个人感觉没必要FPU的乘加指令已经很快了查表反而会引入Cache miss风险。嵌入式优化第一原则是优化之前用逻辑分析仪或者DBGMC超时计数先测出真正的瓶颈在哪别凭感觉乱优化。5. 实测体验与性能数据参考5.1 在STM32F407和STM32F103上的实测结果下面是我在实际项目里测到的数据。测试环境是STM32F407VG168MHz带FPU和STM32F103C8T672MHz无FPU编译器是Keil MDK 5.30-O2优化数据类型为float。每项测试跑100次取平均值数据长度1024点三级分解。单片机小波类型数据点总耗时RAM占用2048点耗时F407Haar10240.35ms2.1KB0.72msF407db410240.92ms4.3KB1.85msF103Haar10242.31ms2.1KB4.70msF103db410246.84ms4.3KB13.9msF103Haar(Q15)10241.12ms1.2KB2.28ms从数据能看出来带FPU的F4跑浮点基本上毫无压力F103跑float的db4就有点紧张但用Q15定点化之后提速非常明显。如果你的采样率不高比如工业振动监测常用的1kHz采样率每秒钟只处理一帧即便F103也完全够用。但如果是音频或更高频率的采集建议要么换F4要么把数据长度降到256点。5.2 应用场景延伸热词里的那些实际需求翻了下最近搜这个方向的热词很多人的需求其实都跟小波去噪有关。比如“STM32鱼缸”场景里温度传感器和pH传感器读出来的数据会有随机噪声直接拿来控制加热棒会导致频繁开关机。用Haar小波做三级分解把第一级细节系数直接滤掉置零再用细节系数和逼近系数重构信号平滑效果特别明显。再比如“51单片机电磁炉程序大全”这个需求锅底温度检测的ADC值经常会混入电网50Hz工频干扰和谐波用db4小波做去噪或者陷波比在MCU上用IIR滤波器设计更节约时间。这种场景不需要跑神经网络只做小波去噪和阈值判断就能解决定位浪涌、异常锅具检测这类问题。我在自己项目里做过一个开关电源电流波形分析模块输入是电流采样值1024点3级db4分解以后用第二级细节均方根和第三级细节能量比做特征送进后面一个两层全连接网络用来识别负载类型。效果比直接用时域波形做FFT特征提高了不少准确率而且特征维度从1024降到了只有十几个神经网络这边也轻松很多。6. 常见问题与排查技巧实录6.1 数组越界与内存踩踏小波变换的下采样虽然让输出减半但卷积核在遍历时需要访问边界外的点。如果边界处理写得不对比如循环延拓的索引算错了轻则取到的数据不对重则数组越界导致hard fault。排查方法开启Keil的Memory Protection UnitMPU或者用硬件中断捕获总线错误调试时故意把数组前后各填充一个特殊值比如0xDEADBEEF跑完检查这些填充值有没有被改写能快速定位踩踏位置。6.2 边缘效应导致输出异常边界处理方式不同第一级分解的末端系数会异常偏大或偏小。这在小波去噪时很致命因为异常值会被当成有效信号。零填充会在边界处产生突然的跳变导致细节系数出现假的尖峰对称延拓则更适合具有对称性的信号。我自己常用的做法是循环延拓因为信号的连续性最好去噪后重构信号不会在边界处出现明显的振铃。不同边界处理对结果的影响你可以在MATLAB里用wextend(1D,sym, ...)和(1D,per, ...)对比试一下再移植到C代码时就知道怎么选了。6.3 浮点运算结果不一致同一个算法在PC上跑和在STM32上跑结果会有微小差异。原因有两个编译器的浮点编译选项不同还有Cortex-M4F的FPU单精度float和PC上默认的double精度差别很大。如果你在代码里写了float但在PC上用double算结果自然不一样。解决方案是统一使用float类型并在编译选项里加上-ffp-contractoff避免FMA指令的自动融合如果你要严格复现能收敛的数值结果的话。另一个坑是volatile关键字。如果你用ADC的DMA搬运数据没有加volatile修饰符编译器优化后可能直接读成旧缓存值。给DMA目标数组加上volatile并在每次搬运完成后加一条数据同步屏障指令——DMB可以有效防止这类问题。6.4 在定时器中断里跑小波的问题很多人喜欢把算法直接放在定时器中断里执行这在单片机上是比较忌讳的因为小波分解哪怕只要0.3ms也会阻塞整个中断系统响应其他重要事件比如通信、按键扫描、看门狗喂狗。我的做法是中断里只做数据采集放到一个ring buffer里主循环里检测到一帧数据满了再调用小波分解。如果实时性要求更高可以用两个缓冲区做乒乓切换ADC采样时填充一个主循环在处理另一个互不干扰。或者用RTOS把小波计算放在一个优先级较低的独立任务里。6.5 代码调试的实用技巧单片机调试算法比PC麻烦得多这里分享几个经验。第一先用Python的PyWavelets库pywt或者MATLAB的小波工具箱跑同样的数据把输出存成数组然后在STM32代码里把同一份输入也处理一遍把关键结果通过串口打印出来对比两者是否一致。这个方法能帮你快速定位是数学逻辑错了还是C语言的实现细节错了。第二把滤波器系数、中间结果、最终输出全部加上变量名加入调试信息用Keil的实时数据观察窗口Live Watch查看。第三小波算法里最容易出错的就是索引计算建议在代码里定义辅助宏或者调试函数来打印idx的取值别凭肉眼硬算。7. 这个小波模块往后续还能怎么用做这个系列最根本的目的其实是为后续的神经网络部分打地基。我在实际项目中把这一套小波模块放在了各种场景下复用最简单的用法是阈值去噪再往上就是配合神经网络做故障诊断和模式识别。小波模块给神经网络的输入不再是几KB的波形数据而是一组紧凑的特征向量这让在STM32上跑网络变得真正可行。具体到下一篇的内容预告我会把三级小波分解得到的能量分布、细节系数均方根、峰值位置这类统计特征整理成固定维度的特征向量然后设计一个小型全连接网络用STM32的CMSIS-DSP库去加速矩阵乘法和激活函数最后在板子上跑出实际的推理结果。这个小波模块在中间扮演的角色就是把信号从时域空间映射到特征空间让神经网络处理的对象从“波形”变成“特征”。我在调试这套系统的过程中踩过不少坑最想提醒你的是不要一开始就追求复杂的小波基和高层级的分解。从小处着手——先用Haar、先跑通2级分解把数据流和内存管理理顺了再切换db4、加层数这样调试效率最高。单片机上做算法难点通常不在数学本身而在于资源约束下把逻辑理干净、把数据管明白。希望这篇内容对你有帮助有问题可以多在社区里交流。