做嵌入式的信号处理,有个问题绕不开:怎么在资源有限的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点信号、L=4的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,如果n=1024,两个数组加起来4KB,其实也不小。要做到真正的原地变换,可以在同一个临时缓冲区里先存拆分后的偶序列和奇序列,再逐步用计算结果覆盖。但这个写起来比较容易绕,我建议新手先用临时数组,等流程跑通后再优化。
最优的优化思路是把整个变换设计成乒乓缓冲。定义两个全局数组,一个存当前层输入,一个存当前层输出;下一层时,把输入输出角色对调。这样无论多少层,数组总量始终是两倍N。这种结构也方便用DMA搬运数据,比如ADC采样一轮填满bufferA,DSP开始处理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下加-mfpu=fpv4-sp-d16 -mfloat-abi=hard。这个不打开,浮点运算会走软库,速度慢好几倍。
如果你的信号长度比较大,比如一次分析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 | 否 | 约7KB |
| CDF(2,2) | 约9.6ms | 否 | 约8KB |
| db4(DSP库) | 约18ms | 否 | 约12KB |
| CDF(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上神经网络推理的部署细节,包括模型压缩、量化以及如何把小波特征直接喂给轻量级网络。