做嵌入式的信号处理有个问题绕不开怎么在资源有限的MCU上做去噪、特征提取如果你接触过FFT会发现它在频域定位上有先天短板——只能告诉你某个频率存在却说不清这个频率发生在什么时刻。这就让小波变换有了用武之地。最近我在STM32上做了一个小波变换加神经网络的综合项目这一篇先把小波算法如何在单片机上落地讲清楚。文章适合正在做振动监测、设备故障诊断、心电信号处理的工程师也适合拿STM32做毕业设计或竞赛项目的同学。读完你不仅能看懂小波变换的C语言实现还能绕过我在调试时踩过的几个大坑。很多人一听到小波变换第一反应是数学公式太多不敢碰。实际上在单片机上跑小波真正需要掌握的算法并不多主线就是离散小波变换DWT说的再直白一点就是一组滤波器组加降采样。这一篇我会直接用C语言把DWT写出来再给出一套可以复现到Keil工程里的代码骨架。1. 为什么要在STM32上跑小波变换1.1 小波变换到底解决什么问题先说说工程背景。传感器采集回来的信号比如电机振动信号、齿轮箱冲击信号、心电信号绝大多数不是平稳信号。所谓平稳简单理解就是统计特征不随时间变化但现实中的故障冲击、瞬态波动往往只在某个很短的时间片段里出现。FFT做全局频谱分析时相当于把整段信号平均起来看频率成分存在但具体在哪个时间点产生的完全丢失了。窗口傅里叶变换虽然加了时间窗可窗宽固定之后高频和低频的分辨率就矛盾了窗宽变大频率分辨率好但时间分辨率差窗宽变小时间定位准但频率看不清楚。小波变换的核心优势就是“多分辨率”。它用一组伸缩和平移的小波基函数去匹配信号低频段用宽窗子看趋势高频段用窄窗子看突变时间和频率的局部化信息都能保留下来。落到工程上就是既能从一段振动数据里找出故障频率又能定位到故障冲击发生在第几个采样点。这种能力在机械故障诊断、电力暂态分析、医学信号处理里非常实用。在MCU上做这类计算最大的驱动力是“边缘处理”。现场数据量往往很大如果全部无线传到上位机再分析延迟高还费电。直接在STM32上算完去噪和特征提取只把结果上报是更合理的产品形态。1.2 FFT的盲区与小波的优势我经常把FFT和小波变换放在一起比较因为它们都是频域分析工具但适用场景差异很大。对比项FFT小波变换全局频率非常强适合稳态信号也能分析但不如FFT直观瞬态定位弱无法知道频率何时出现强能同时看到时间位置计算复杂度O(N log N)O(N)线性复杂度内存需求需要重排和旋转因子表占用较大多级分解可原地处理内存可控参数调整窗函数、点数固定小波基、分解层数可调适合平台PC、DSP、高端MCU中低端MCU也能用FFT适合分析平稳周期信号比如逆变器输出波形的谐波分析、电源噪声评估。小波变换适合信号里有突变和暂态的场景比如轴承早期故障、电缆局部放电、肌电信号提取。在STM32这种主频不过一两百兆的平台上小波的O(N)复杂度很有吸引力尤其当数据长度是2的幂时层级分解的边缘处理也简单。1.3 什么场景才值得在MCU上用小波不是所有嵌入式项目都得上小波。我判断的标准很简单第一信号里存在非平稳成分第二需要实时或准实时输出结果第三不能把全部原始数据传到上位机。三条都满足才值得在MCU上做。有一个典型例子是旋转机械的冲击监测。设备正常运行时振动信号近似平稳一旦某个轴承出现剥落会产生周期性的冲击脉冲这时信号在时域上出现明显的局部变化。用FFT看频谱冲击的能量会散布在一整个频段不如小波系数里高频细节的突变明显。另一个例子是电力系统的暂态录波故障发生前后几十个毫秒信号剧烈变化小波分解出的细节系数能直接用来做故障类型识别。如果你只是做简单的温度采集、慢速的传感器平滑滤波那完全没有必要引入小波一个滑动平均或一阶低通滤波就够了。嵌入式开发的第一原则是合适不是算法越高级越好。2. 小波变换的数学基础与算法选型2.1 从连续小波到离散小波Mallat算法连续小波变换的定义里面带积分还要对尺度和平移参数连续取值这在单片机上没法直接算。工程上使用的是离散小波变换DWT而DWT最经典的实现就是Mallat算法。Mallat算法把小波分解变成了两件事一是用低通滤波器h和高通滤波器g分别对信号做卷积二是隔点采样实现降尺度。每分解一层就得到一组低频近似系数cA和高频细节系数cD下一层继续对cA做同样处理。看到的滤波器组结构本质上是一个二叉树。对单片机来说这一步的计算量主要是乘加运算。假设信号长度N每个滤波器长度为L一次卷积的计算量大约是N乘L再加上N次加法。如果直接用卷积实现N点信号、L4的db2小波一次分解也就几千次乘加对STM32来说压力并不大。不过直接用滤波器卷积有一个麻烦每层都要保留完整的卷积中间结果还要分配两个输出数组。对于内存只有几十K的芯片这是一个比较紧的限制。所以我在单片机上优先建议用提升方案而不是教科书里的卷积Mallat形式。2.2 为什么我推荐提升方案提升方案也叫Lifting Scheme是传统小波的一种高效实现。它把滤波器组分解成多个“预测-更新”步骤可以在原地完成变换不需要额外的大缓冲区。这点在嵌入式环境里是压倒性的优势。举一个直观的例子Haar小波用卷积做要先算平均和差值再用两个数组保存低频和高频。用提升方案只需要先把偶数位和奇数位分开做一次差值预测再更新偶数位结果就能放回原数组的前半段和后半段。整个过程不需要复制整块数据内存占用从两倍N降到N加上一小块临时空间。提升方案另一个好处是计算量更小。传统卷积需要对每个输出点做L次乘加提升方案把乘加拆分后很多系数可以归一化成简单的加减和移位操作。比如CDF(2,2)小波也就是常用的5/3小波提升系数只有0.5和0.25在浮点上就是两次乘法和几次加法要是转定点还能用右移替代速度更快。2.3 小波基怎么选Haar、db2还是db4市面上小波基几十种但真正适合单片机的没那么多。我的选择原则是先满足需求再看计算代价。Haar小波是最简单的小波只有两个系数核心运算就是相邻两点求平均和求差。它的优点是计算量极小内存占用少实时性极高缺点是频域分辨率差高频细节比较粗去噪效果往往一般。适合快速验证流程或者对频率定位要求不高的场景。db2小波是Daubechies系列里最短的滤波器长度4比Haar平滑能较好保持信号连续性去噪效果明显好于Haar。在STM32上跑db2一轮分解的计算量比Haar高两倍左右但通常仍可接受我很多项目默认选db2。db4小波滤波器长度8频域特性更好但计算量翻倍。如果信号比较平滑需要提取微弱特征db4更合适。在F407这类带FPU的芯片上1024点数据做4层db4分解也就在几十毫秒级别。更长的滤波器比如db8之类我就不建议在MCU上硬跑了除非你有DSP扩展指令或者主频非常高。2.4 用C语言的思维理解提升步骤学习提升方案时不要直接去看数学推导先把“分裂、预测、更新”三个动作刻在脑子里。分裂是把输入序列分成偶数位和奇数位两组。预测是用相邻的偶数位去预测奇数位两者相减得到高频细节。更新是用已经算好的高频细节去修正偶数位得到低频近似。每一组动作做完低频和高频就分别有了下一层继续对低频部分操作。用C语言表达时最核心的是数组索引。分解后习惯把低频放在数组前一半高频放在后一半。这样下一层只需对前一半继续处理不需要额外数组来区分层数。这种布局对MCU编程非常友好内存复用率高调试也直观。3. 从零编写STM32可用的DWT实现3.1 数据结构和内存规划实际在STM32工程里写DWT我习惯先定义几个全局常量避免用动态内存#define DWT_MAX_LEN 1024 #define DWT_MAX_LEVEL 5信号缓冲区用静态数组长度最大1024个浮点数。如果用float总共4KB大部分STM32都放得下。如果要做多通道比如三个通道同时采集那就需要3个缓冲区内存占用需要认真核算。建议先用单通道调通算法再扩展多通道。缓冲区还有一个细节尽量16字节对齐。STM32的FPU对超标量加载有对齐要求DMA搬运数据时也偏好对齐地址。在C文件里定义数组时加上对齐属性float signal[DWT_MAX_LEN] __attribute__((aligned(16)));如果你用的是C51风格的单片机迁移过来的代码可能不熟悉这个写法但在GCC和ARMCC下都支持。对齐之后调试和后续优化都会顺畅很多。3.2 一维DWT核心函数Haar实现先给一个最简单的Haar小波实现帮助大家理解整体结构。这个函数是原地变换输入信号在data数组里分解后低频系数放在前一半高频系数放在后一半。void dwt_haar_1d(float *data, uint16_t n, uint16_t levels) { float tmp[DWT_MAX_LEN / 2]; for (uint16_t lv 0; lv levels; lv) { uint16_t len n lv; if (len 2) break; uint16_t half len 1; for (uint16_t i 0; i half; i) { float a data[2 * i]; float b data[2 * i 1]; tmp[i] (a b) * 0.5f; tmp[i half] (a - b) * 0.5f; } for (uint16_t i 0; i len; i) { data[i] tmp[i]; } } }这个函数每一层都用一个中间数组tmp虽然比完整卷积省内存但还不算最彻底。对于Haar来说其实可以完全不用临时数组直接在原数组里操作但需要小心覆盖顺序。对初学者先通过这段代码理解分解过程比较重要。实际工程中我会把tmp改成函数内部静态数组避免每次调用花时间去栈上分配static float tmp[DWT_MAX_LEN / 2];这样做的代价是可重入性变差但在单任务裸机环境下这点影响可以忽略。3.3 更常用的CDF(2,2)提升实现实际去噪和特征提取我更推荐CDF(2,2)小波也就是5/3小波。它比Haar平滑在图像和信号处理里都很常见。下面这段是提升方案的DWT核心代码。static void dwt_split(float *x, uint16_t n, float *even, float *odd) { uint16_t half n 1; for (uint16_t i 0; i half; i) { even[i] x[2 * i]; odd[i] x[2 * i 1]; } } static void dwt_cdf22(float *x, uint16_t n) { uint16_t half n 1; float even[DWT_MAX_LEN / 2]; float odd[DWT_MAX_LEN / 2]; dwt_split(x, n, even, odd); // predict for (uint16_t i 0; i half; i) { float left even[(i - 1 half) % half]; float right even[(i 1) % half]; odd[i] - (left right) * 0.5f; } // update for (uint16_t i 0; i half; i) { float left odd[(i - 1 half) % half]; float right odd[(i 1) % half]; even[i] (left right) * 0.25f; } // pack: low in first half, high in second half for (uint16_t i 0; i half; i) { x[i] even[i]; x[i half] odd[i]; } }注意这里边界处理用的是循环取模相当于把信号首尾相接。对于大多数连续采集场景这是最简单也最稳定的做法。如果对边界效应有更高要求可以改成对称延拓后面会详细说。多级分解只需要反复对前半段调用void dwt_cdf22_decompose(float *data, uint16_t n, uint16_t levels) { for (uint16_t lv 0; lv levels; lv) { uint16_t len n lv; if (len 2) break; dwt_cdf22(data, len); } }这段代码在实际项目里已经可以工作但有两个前提第一数组长度最好是2的n次幂不然最后一层会出错第二levels需要小于log2(n)。3.4 内存优化原地变换与乒乓缓冲上面cdf22实现里even和odd数组都占n/2个float如果n1024两个数组加起来4KB其实也不小。要做到真正的原地变换可以在同一个临时缓冲区里先存拆分后的偶序列和奇序列再逐步用计算结果覆盖。但这个写起来比较容易绕我建议新手先用临时数组等流程跑通后再优化。最优的优化思路是把整个变换设计成乒乓缓冲。定义两个全局数组一个存当前层输入一个存当前层输出下一层时把输入输出角色对调。这样无论多少层数组总量始终是两倍N。这种结构也方便用DMA搬运数据比如ADC采样一轮填满bufferADSP开始处理bufferA同时DMA继续往bufferB里填交替使用不让采集停顿。实现乒乓缓冲时最关键的是不要在某层计算过程中读写同一个数组。哪怕只是一个小bug数据被覆盖了结果就完全不对。我在调试时就吃过亏结果怎么看怎么不对最后发现是更新层里参与计算的odd分量已经被修改过。所以代码里每一层的predict和update要严格分离先全部算完odd再全部算完even。3.5 利用STM32的FPU和DSP库提速STM32F4、F7、H7系列自带FPU开启后浮点运算大多是单周期指令。虽然小波提升以加法和减法为主但db2和db4这类小波仍然有大量乘法FPU的帮助非常大。Keil和STM32CubeIDE里需要在工程选项里开启FPU硬浮点在ARMCC下选“FPU: Single Precision”在GCC下加-mfpufpv4-sp-d16 -mfloat-abihard。这个不打开浮点运算会走软库速度慢好几倍。如果你的信号长度比较大比如一次分析4096点可以考虑用CMSIS-DSP里的arm_fir_f32实现卷积式Mallat分解。虽然CMSIS-DSP没有一个专门的“小波变换”API但低通和高通滤波器本质上就是两次FIR滤波。比如db2小波低通系数和高通系数都是4个直接配置两个arm_fir_instance_f32结构体就能用DSP库的优化循环跑卷积。不过别忘了卷积之后要隔点抽取这一步用一个步长为2的循环即可。下面是一个用arm_fir_f32做单层低通滤波的示意#include arm_math.h float32_t firState[64]; arm_fir_instance_f32 lowpass; const float32_t lpCoeffs[4] {0.48296f, 0.83652f, 0.22414f, -0.12941f}; // db2 demo void fir_filter_init(void) { arm_fir_init_f32(lowpass, 4, (float32_t *)lpCoeffs, firState, DWT_MAX_LEN); } void lowpass_and_decimate(float32_t *src, float32_t *dst, uint16_t n) { arm_fir_f32(lowpass, src, dst, n); uint16_t half n 1; for (uint16_t i 0; i half; i) { dst[i] dst[2 * i]; } }这里省略了高通部分实际使用时要两个滤波器同时跑。需要特别注意CMSIS-DSP的arm_fir_f32输出长度和输入一样抽取后要把有效数据搬到数组前部避免后一层用错位置。4. 真实项目中的应用信号去噪与特征提取4.1 小波阈值去噪实践小波去噪是MCU上很常见的用途。传感器信号往往叠加了白噪声直接做阈值判断容易误报。把信号做几层小波分解后真实成分通常对应幅值较大的小波系数噪声则分散在幅值较小的系数上。对细节系数做一个阈值处理再把信号重构回去就能保留主要特征同时压低噪声。在单片机上我建议用最简单的软阈值方法。先估计噪声标准差常用公式float mad 0.0f; // 取第一层细节系数的绝对值中位数乘以1.4826近似噪声标准差 float sigma (median_abs) * 1.4826f; float lambda sigma * sqrtf(2.0f * logf((float)n));求中位数需要排序在MCU上比较耗时。一个替代方案是用第一层细节系数的平均绝对值乘一个经验系数比如0.6745效果也够用。阈值处理函数如下float soft_threshold(float x, float thr) { if (fabsf(x) thr) return 0.0f; if (x thr) return x - thr; return x thr; }处理完细节系数后小波重构就是分解的逆过程。用提升方案做重构只需要把预测和更新的符号反一下信号合并方式反过来。我这里建议第一次做的时候在PC上用Python的PyWavelets库先验证结果再平移成C代码。用小波库验证阈值参数比在MCU上反复烧录调试快得多。4.2 特征提取如何为神经网络喂数据小波变换在项目里的第二个关键角色是给神经网络准备特征。以轴承故障诊断为例原始振动信号是时间序列直接丢给网络模型很难学到稳定的模式而且计算量太大。用小波分解之后信号变成不同尺度上的系数低频近似系数反映整体趋势高频细节系数反映冲击和噪声。对这些系数计算统计量比如每个尺度的能量、方差、均值、峰值、峭度就组成一个固定维度的特征向量。特征向量可以做成类似这样的结构#define FEAT_NUM 8 typedef struct { float energy[4]; // 每层细节能量 float rms[4]; // 每层细节有效值 float peak[4]; // 每层细节峰值 float kurtosis[4]; // 每层细节峭度 } WaveFeature;把WaveFeature输入给一个简单的全连接网络或者轻量级一维CNN就能实现故障分类。这里只是把结果算出来神经网络的训练和推理会在后面一篇里详细展开。需要提醒的是特征维度不能太多否则MCU端神经网络库的参数会很大一般4层分解选取16到32个特征就足够了。4.3 在STM32F407上的实测性能我手头常用来验证的方案是STM32F407主频168MHz开启FPU编译器用AC6开-O2。以1024点数据为例分解3层两种小波的耗时大致如下小波基3层分解耗时是否包含阈值重构缓冲区占用Haar约2.8ms否约7KBCDF(2,2)约9.6ms否约8KBdb4DSP库约18ms否约12KBCDF(2,2) 阈值重构约19ms是约8KB实测数据会因编译器版本、优化选项、数组对齐有波动但数量级是稳定的。如果信号采样率是20kHz一帧1024点代表51.2ms的数据处理时间不到10ms就可以完成实时性完全没问题。如果采样率更高比如100kHz一帧1024点只有10.24ms这时就要考虑用双缓冲把采集和处理流水起来否则会丢数据。5. 常见问题与避坑指南5.1 边界处理补零、镜像还是周期延拓小波变换本质上是对有限长信号做卷积边界处的滤波器窗口会出界。最常见的三种做法是补零、对称延拓和周期延拓。补零代码最简单比如数据最后补4个0然后卷积但会在边界产生较大的幅值跳变去噪时容易出现假边缘。对称延拓把信号边界镜像折叠边界效应较小是信号处理里推荐的做法。周期延拓适合采集信号首尾连续的情况比如电机旋转一圈的振动信号首尾天然衔接用循环取模实现很自然。我在C代码里默认用周期延拓因为实现起来就是一个取模索引计算量最小。但也有一个坑如果采集长度不是整周期周期延拓会引入突变。这时候改用镜像延拓代码稍微复杂点但稳定得多。镜像索引可以这样计算static int16_t mirror_index(int16_t i, int16_t len) { if (i 0) return -i; if (i len) return 2 * len - i - 2; return i; }在提升方案的predict和update里把取模改成这个函数就能平滑处理边界。5.2 定点数实现时的溢出问题如果单片机上没有FPU比如STM32F1系列或者为了降低功耗不想用浮点那就得把算法定点化。提升系数0.5和0.25可以用右移和乘法实现但中间累加值很容易溢出。比如原始信号是16位ADC采集范围是0到65535转成Q15格式后两个数相加就可能超过1.0再乘0.5虽然结果可能落回去但加法瞬间的溢出会把符号位吃掉。解决办法有两个。一个是在累加前先缩小输入比如把ADC原始数据右移2位再转Q15。另一个更稳妥的办法是使用32位累加器中间结果用int32_t保存最后再缩放回Q15。工程量虽然大一点但可以避免很多隐性bug。我的建议是先用浮点把整个流程跑通再定点化不要一开始就写定点否则定位问题会非常痛苦。5.3 实时性优化中断里别做重计算很多人在ADC采样中断里顺便做滤波或者小波变换这是个很容易踩的坑。小波变换即使再快1024点CDF(2,2)也要接近10ms如果在采样中断里执行中断被长时间占用后续采样事件全部丢失系统实时性彻底崩溃。正确的做法是ADC采样用DMA数据填满一帧后置一个标志位主循环检测到标志位后再调用小波处理函数。这样采样是硬实时的处理是准实时的两者用双缓冲解耦。我见过一个项目为了省内存没做双缓冲直接在单缓冲上边采样边处理结果信号稍微一抖动处理函数还没跑完DMA就把下一批数据覆盖进去最后波形乱成一团。后来改成两块缓冲交替使用问题立刻消失。内存多花4KB换来的是稳定性非常值得。5.4 不要被DWT这个缩写坑了STM32的调试组件里有一个功能叫Data Watchpoint and Trace缩写也是DWT。在Cortex-M内核手册里经常出现“DWT寄存器”“DWT-CYCCNT”这样的描述用来做代码运行时间统计。如果你搜索小波变换相关资料看到“DWT”两个字第一反应可能混淆。实测中我就在查STM32周期计数时误入了小波变换的坑还在想为什么DWT寄存器和小波系数有关系。在搜索引擎里搜索时建议带全称Discrete Wavelet Transform或者在DWT后面加wavelet。代码里命名也尽量用dwt_wavelet前缀区分调试用的DWT。这个命名细节看似小事但在多人协作的项目里能避免很多误解。我个人在实际操作中的体会是小波变换在STM32上并没有想象中那么难关键是先选对算法形态。Haar用来跑通流程CDF(2,2)用来做实际去噪db4留给精度要求更高的场景。先把浮点版本调通再根据硬件资源决定要不要定点化。这一篇只是整个系列的第一部分小波算法是后续神经网络模型的“前端处理器”。如果你正在做一个类似的嵌入式信号分析项目建议先把这一套分解和重构代码在PC上验证好再搬到MCU上优化性能。下一步我会继续分享STM32上神经网络推理的部署细节包括模型压缩、量化以及如何把小波特征直接喂给轻量级网络。