1. 撬开STM32的DSP潜力:为什么滤波这件事值得较真
搞嵌入式搞到一定阶段,你会发现项目里最棘手的问题往往不是通信协议,不是RTOS调度,而是信号进来之后“脏得没法看”。ADC采回来的数据带着工频干扰、机械振动噪声、随机毛刺,直接用吧,PID根本稳不住,阈值判断频繁误触发,波形显示像心电图乱跳。这时候脑子里冒出来的第一个念头就是:滤波。
但真到写代码的时候,很多人会卡住。一个简单的滑动平均就能拖慢几十微秒,算下来实时性完全没法看;想上FIR吧,系数不知道怎么设计,算一算循环拍的乘法开销直接劝退;IIR倒是快,但又担心稳定性、相位畸变。更头疼的是,网上搜到的资料要么是纯Matlab仿真,跟单片机完全脱节,要么就是丢一段库函数调用,参数意义全部黑盒,出了问题根本没头绪。
这篇东西就是把我在STM32上跑DSP滤波这件事彻底讲透。我在项目里把当前最常用的五类滤波器——FIR有限脉冲响应、IIR无限脉冲响应、中值滤波、自适应LMS滤波、样条平滑滤波——全部从设计到代码实现走了一遍,跑在Cortex-M4内核的STM32F407上,逐个测了执行周期、内存开销、延迟特性和实际滤波效果,最后整理成一份可以直接对着抄作业的工程参考。如果你正被噪声信号折磨,或者想把DSP能力真正落进STM32项目里,这篇内容应该能给你省下不少折腾的时间。
2. 方案选型:MCU上跑DSP,先想清楚这四件事
2.1 为什么STM32能跑DSP,而且能跑得不错
早些年DSP这个词几乎是和TI C2000、ADI Blackfin强绑定的,单片机想都不要想。但Cortex-M4内核发布之后,ARM把单精度浮点单元FPU做进了内核,还配套了完整的CMSIS-DSP库,STM32F4、F7、H7这些系列直接把DSP的门槛拉到了单片机的价位上。一颗几块钱的芯片,跑一次1024点FFT只需要不到1毫秒,处理实时的音频采样或者工控信号完全吃得消。
我实测过STM32F407在168MHz主频下,一个16阶FIR滤波器处理一个采样点只需要约30个时钟周期,也就是不到0.2微秒。如果采样率是10kHz,意味着CPU只需要花0.2%的负载就能完成一路信号的实时滤波。这个性能余量足够让你在滤完波之后还有大把的算力去跑控制算法、UI逻辑和通信协议栈。所以结论很简单:STM32上的DSP不是能不能用的问题,而是你怎么把算法组织得足够高效、足够工程化的问题。
2.2 选滤波器之前,先把这四笔账算清楚
我每次给项目选滤波方案,一定会先把需求压缩成四个量化指标,否则后面全是糊涂账。
第一笔账是延迟预算。滤波器的延迟直接进入你的控制系统闭环。比如PID采样周期1kHz,整个滤波链路多出来的延迟超过一个采样周期,相位裕量就莫名其妙地掉了,系统开始在某些频率上啸叫。FIR在相同过渡带宽度下阶数高、延迟大,IIR的相位非线性在某些应用场景下不能忍,中值滤波的延迟跟窗口长度直接挂钩,这些都要在做方案之初就明确要求出来。
第二笔账是计算开销预算。就是前面说的,滤波函数每跑一个点需要在CPU上花多少个时钟周期,乘上采样率,就是每秒的指令消耗,再除以主频,得到CPU占用率。我的经验是滤波链路最好控制在整体CPU负载的10%以下,留出余量给别的事情。
第三笔账是内存预算。FIR的系数表加数据缓冲区,IIR的级联状态变量,中值滤波的滑动窗口数组,自适应滤波的权重向量和参考信号缓冲,样条滤波的系数矩阵,每一项都要吃RAM。而MCU的SRAM通常是几十到几百KB,必须精打细算。
第四笔账是matlab设计阶段的可控性。我自己习惯先用Matlab把滤波器系数设计出来,在PC上验证效果,再导到工程里。设计阶段的灵活程度决定了你后期调试要受多少罪。这五类滤波器各有各适合的场景,没有谁绝对优于谁,只有谁更匹配你的信号特征和系统约束。
2.3 五类滤波器的适用画像速览
先说FIR。它的核心优势是线性相位,也就是所有频率分量的延迟一致,波形不会发生相位畸变。这个特性在数据通信、医学信号预处理、振动分析里几乎不可妥协。代价是阶数高,计算量大,内存占用大。适合那些对波形保真度有要求的场景,比如心电信号滤波。
IIR的优势是效率极高。用很低的阶数就能实现陡峭的过渡带,比如一个4阶巴特沃斯低通就能逼近十几阶FIR的频响效果。代价是相位非线性,以及数字实现中可能出现的稳定性问题。适合对实时性要求极高、对相位不敏感的控制回路、电源信号调理。
中值滤波在嵌入式里最大的用途是去除脉冲噪声和椒盐毛刺。它不是频域滤波器,而是基于排序的非线性滤波,能保边缘、去孤立野点。ADC偶尔受干扰跳几个大值,用中值一拍就能消掉,效果没有替代品。
自适应滤波能够自动跟踪信号和噪声的统计特性。噪声频率漂移、干扰源变化的场景下,固定系数的滤波器会逐渐失效,自适应算法通过反馈自动更新权重,始终维持最优滤波效果。代价是运算量大、参数需要谨慎设置、收敛过程需要时间,还有发散风险。
样条滤波在嵌入式里用得少,因为计算复杂度高、延迟大。但它有一个不可替代的优势是可以通过分段多项式实现异常平滑的曲线,且能同时做到保形和平滑,很适合离线处理或对实时性要求极低的数据后处理场景。
3. 把地基打牢:CMSIS-DSP环境搭建和滤波器系数设计
3.1 芯片选型和工程配置的关键细节
跑DSP滤波,硬件上最好带硬件FPU,这点差别极其巨大。Cortex-M4之前的M3、M0系列没有FPU,做浮点滤波运算靠软件模拟,一个乘法都要几百个周期。有FPU的单条FMA指令融合乘加运算,FIR最核心的乘累加循环直接一条指令搞定,性能差距是数量级的。
我常用的型号是STM32F407和STM32F103替换升级版G4系列。F407主频168MHz,带FPU,有192KB SRAM,做中低阶滤波器绰绰有余。如果音频应用需要更高的采样率,建议直接看F7系列,Cortex-M7内核性能进一步翻倍,甚至H7系列直接双精度浮点支持。
工程配置上有两个坑很关键。第一,编译器选项里必须打开硬件浮点开关,在Keil的C/C++选项卡里Define预处理符号加上__FPU_PRESENT=1, __FPU_USED=1,或者在CubeMX里把Floating Point Unit选成Single precision,否则即使芯片有FPU,编译器也会生成软件浮点调用,性能直接崩盘。第二,CMSIS-DSP库包含了大量针对Cortex-M4优化的汇编内核函数,这些函数依赖FPU指令集,只有在开启了FPU选项后才会生效。
3.2 MatLab fdatool设计滤波器系数,然后导出
滤波器系数我几乎不会手算,都是直接在Matlab的Filter Designer工具里拖一拖就生成。比如设计一个采样率1kHz、截止频率100Hz的8阶巴特沃斯低通IIR滤波器,在fdatool里输入参数,点Design Filter,然后从菜单选择Export导出系数,选Direct Form I或者SOS形式,导出成文本文件。
CMSIS-DSP里的IIR滤波函数用的是直接I型或者转置直接II型的级联二阶节格式。fdatool导出的SOS矩阵的维度是三段或者四段,C语言里定义静态常量数组,每行六个系数,直接就能喂给arm_biquad_cascade_df2T_f32函数。
FIR的话更简单,在fdatool里选FIR Window类型,用Kaiser窗、给定阶数,设计完导出系数数组,整个就是一个float32_t firCoeffs32[NUM_TAPS],然后调用arm_fir_init_f32初始化结构体,arm_fir_f32函数执行滤波。
这里有个很实用的经验:fdatool导出的系数经常包含很多科学计数法的小数,直接复制到C代码里没问题,但注意数组的定义必须用const修饰,存入Flash,不要放在RAM里,因为几百个浮点系数会吃掉大量SRAM,而Flash又大又便宜。
3.3 采样和信号链路的前置准备
滤波代码跑起来之前,你得先把信号链路弄清楚。传感器信号经过运放调理后进入ADC,ADC可能是连续采样也可能是定时器触发采样。DSP滤波的位置在ADC中断或者DMA传输完成的回调函数里,拿到一个采样值就处理一个。所以滤波函数要设计成样点输入输出模式,也就是每来一个采样点,调用一次滤波函数,返回一个处理后的值,而不是攒一批再统一滤波。
这不只是因为实时性,更因为这种逐点处理模式能让代码结构极其清晰。ADC中断进来读取转换结果,喂给滤波器,输出直接给控制算法或者打包发送,整个数据通路每个周期完成一次,延迟确定,不会产生额外缓冲抖动。
DMA双缓冲模式值得推荐。用两个缓冲轮流接收ADC数据,CPU处理一个缓冲的同时,DMA往另一个缓冲填数据,两个缓冲切换时通过中断通知。这种方式能极大减少CPU介入采样的频率,适合高采样率场景。滤波函数本身不需要修改,只需要在缓冲处理循环里逐点调用。
4. 五种滤波器逐个手撕:原理、代码与实测性能
4.1 FIR滤波器:CMSIS-DSP一行函数解决,但延迟账要算明白
FIR全称有限脉冲响应,核心思想是离散卷积,也就是用当前输入和前N-1个历史输入乘以对应的系数,加权求和作为输出。设输入为$x[n]$,系数为$h[k]$,FIR的输出为:
$$y[n] = \sum_{k=0}^{N-1} h[k] \cdot x[n-k]$$
N个系数,每个采样点就要做N次乘法和N-1次加法。这就是FIR计算量的来源。CMSIS-DSP里arm_fir_f32函数内部用汇编优化好了,在M4上一次乘加就几个周期。
代码实现的核心点之一是数据缓冲区管理。FIR要求维护长度为N的历史输入数组,每来一个新样点,最旧的数据要被丢弃。最高效的办法是用环形缓冲区,而不是每来一个数据就把整个数组搬移一次。我在工程里用了一个让初学者很容易踩坑的细节:CMSIS-DSP的FIR函数内部用了一个状态缓冲区pState,这个缓冲区的尺寸是NUM_TAPS + blockSize,而且要求内存按32字节边界对齐。很多人的程序跑着跑着出HardFault,查了半天发现是数组没对齐。
我在Keil里定义FIR状态缓冲的做法是这样的:
#define FIR_TAPS 32 #define BLOCK_SIZE 1 float32_t firCoeffs[FIR_TAPS] = { /* fdatool导出的系数 */ }; static float32_t firState[FIR_TAPS + BLOCK_SIZE] __attribute__((aligned(4))); arm_fir_instance_f32 firInst; arm_fir_init_f32(&firInst, FIR_TAPS, (float32_t*)firCoeffs, &firState[0], BLOCK_SIZE); // 每来一个采样点: float32_t input = (float32_t)adc_value; float32_t output; arm_fir_f32(&firInst, &input, &output, 1);实测关键数据:在F407主频168MHz、编译器开-O2优化、开启FPU的条件下,一个32阶FIR处理一个样点的耗时是18个时钟周期,换算成时间约107纳秒。以10kHz采样率计算,CPU占用率仅0.11%,几乎可以忽略。如果是128阶,耗时约62个时钟周期,约370纳秒,同样很轻松。
延迟这块要特别说清楚。N阶对称系数FIR对信号的群延迟是(N-1)/2个采样周期,不等于零。我的经验是你的控制系统要把这个延迟算进总延迟链路里去,特别是你的闭环带宽比较高的时候,不能因为“滤波只要100多纳秒”就忽视它在整个环路里贡献的相位滞后。32阶FIR在10kHz采样率下延迟1.55ms,这在1kHz执行率的控制回路里是个不小的数字了。
4.2 IIR滤波器:用二阶节级联实现经典巴特沃斯低通
IIR的核心特征是输出不仅依赖输入,还依赖之前的输出,本质上是递归结构。这就让它的传递函数分子分母都有多项式,频率响应可以用很低的阶数获得陡峭的过渡带。一个二阶IIR就可以实现一个完整的谐振峰或者陷波点,而FIR要达到同样的陡峭过渡带可能需要几十阶甚至上百阶。
IIR的直接型实现存在一个严重的数值问题:高阶无限脉冲响应滤波器直接展开时,系数量化误差会极度放大,极点对参数变化极其敏感,滤波器可能在高阶时直接自激震荡。解决办法是把高阶IIR分解成多个二阶基本节SOS的级联:
$$H(z) = \prod_{i=1}^{M} \frac{b_{0i} + b_{1i} z^{-1} + b_{2i} z^{-2}}{1 + a_{1i} z^{-1} + a_{2i} z^{-2}}$$
CMSIS-DSP的arm_biquad_cascade_df2T_f32函数实现的就是转置直接II型级联二阶节,每个二阶节需要4个状态变量。这个形式数值特性最好,对系数量化不敏感,推荐优先使用。
我举一个实际项目的例子:采样率1kHz,需要滤除50Hz工频干扰,实现一个二阶巴特沃斯低通,截止频率20Hz。Matlab里设计出来的结果是二阶节系数加上一个增益,放到代码里就是初始化结构体,然后每个采样点调用一次处理函数。
如果在采样率400Hz的场景下,就需要做一个50Hz的双T型陷波器来抑制工频干扰。陷波器的特点是在50Hz附近产生一个极深的衰减,比如-40dB以上,而对其他频率几乎不产生影响。但这东西对系数精度极敏感,当系数用单精度浮点时,陷波频点会有几赫兹的偏移,特别是对于Q值高、带宽窄的陷波器。经验是使用double精度存储系数并在初始化时计算,运行时的运算仍然可以保持float保持速度,这样能控制住频率偏移。
下面给出一个工程上验证可用的陷波器代码原型:
// 采样率 400Hz, 陷波 50Hz, Q=0.707, 衰减约 -40dB float b[3] = {0.96953147f, -1.93906294f, 0.96953147f}; float a[3] = {1.0f, -1.93906294f, 0.93906294f}; float states[4] = {0}; float notch50(float input) { float w = input - a[1] * states[0] - a[2] * states[1]; float y = b[0] * w + b[1] * states[0] + b[2] * states[1]; states[1] = states[0]; states[0] = w; return y; }这段代码完全是直接II型的实现,每来一个采样点执行一次,需要四个状态变量。实测F407上只花12个时钟周期,约71纳秒,CPU开销可以忽略。陷波器对工频的抑制能力非常漂亮,但一旦采样率漂移,50Hz和49Hz的区分度会明显下降,所以建议在精度要求高的场合用浮点,或者特别注意采样定时器的精度校准。
IIR的性能关键是计算开销极低,但相位响应必然非线性,使用之前必须确认这个项目能不能接受波形相位的变化。如果是用于数据通信、心电图这种看重波形形态的场景,建议优先考虑FIR。
4.3 中值滤波器:排序里的学问和野点克星
中值滤波是整个列表里最特殊的一个,它不涉及任何乘加运算,核心是排序。窗口长度为N,就把最近N个采样值排序,取中间那个作为输出。
它的频域概念完全不存在,不是通带阻带那套逻辑。但它对脉冲干扰的处理能力是所有线性滤波器比不了的。比如ADC偶尔受到强电磁干扰,单点跳到满量程,这样的野点如果进IIR,会残留在状态变量里,后效持续几百个采样周期;如果进FIR,卷积核会把毛刺抹开,让信号出现一个异常的“鼓包”。中值滤波直接把毛刺当噪声样本剔除掉,因为它压根排不到窗口中间去。
实现中值滤波最简单的方式是冒泡排序后取中值。窗口5时,5个元素排序,代码简单得要命,也快。我早期就是这样干的。但窗口加长到9、15甚至21时,每来一个采样点都做一次完整排序,开销就开始肉疼了。
后来我在项目里优化成滑动窗口直方图法。维护一个窗口内的数据直方图,每次移入新数据、移出旧数据时更新直方图,然后累计直方图找中值。这个做法最适合数据值域有限的场景,比如12位ADC的采样值范围0到4095,建一个4096长度的计数数组,更新和查找都是确定性的,实时性极好。
如果ADC是12位的,代码大概长这样:
#define ADC_MAX_VAL 4096 #define MED_WIN 15 uint16_t circBuf[MED_WIN]; uint16_t circIdx = 0; uint16_t hist[ADC_MAX_VAL] = {0}; uint8_t windowFull = 0; uint16_t medianFilter(uint16_t sample) { uint16_t old = circBuf[circIdx]; if (windowFull) { hist[old]--; } circBuf[circIdx] = sample; hist[sample]++; circIdx = (circIdx + 1) % MED_WIN; if (circIdx == 0) windowFull = 1; uint16_t acc = 0; int threshold = (windowFull ? MED_WIN : circIdx + 1) / 2 + 1; for (uint16_t i = 0; i < ADC_MAX_VAL; i++) { acc += hist[i]; if (acc >= threshold) return i; } return 0; }直方图画出来为什么高效?因为值域固定,每个采样点的更新操作O(1),找中值最坏情况是O(值域),但值域就是4096,一次循环下来几十个时钟周期,最坏也在微秒级别完成。
窗口大小的选择有自己的门道。窗口越大,平滑越强,对密集脉冲的抵抗力越强,但延迟和模糊也越明显。我在实际项目中,对ADC抗干扰场景用的15点窗口,对编码器转速信号用的5点窗口,因为转速信号本身带宽较高,窗口太大直接就把尖峰细节都干掉了,得不偿失。
中值滤波的延迟特性是非线性的,不能简单用群延迟来描述,只能实测。15点窗口对阶跃信号的响应延迟大约7-8个采样周期,跟窗口一半左右的位置。这个特性决定了它不能用在要求恒定延迟的同步检测场景。
4.4 自适应LMS滤波器:噪声对消里的硬核玩法
自适应滤波是五类里面原理最复杂、参数最讲究的。固定系数滤波器设计好了就一直按照这个频响干活,但自适应滤波器会不断根据输入信号和误差信号调整自身的权重,让滤波器输出始终向期望信号逼近。它不需要事先知道信号和噪声的统计特性,甚至能在统计特性随时间变化时自动跟踪调整。
LMS最小均方算法是自适应滤波里最简单也最实用的形式。核心流程是三个步骤反复迭代:先用当前权重对参考输入做滤波得到输出,然后计算误差,再用误差乘上参考输入和步长因子来更新权重。
在STM32上最常见的应用是自适应噪声对消。场景是:采集到的信号包含有用信号s加上相关噪声n1,同时还有一路参考信号n2,n2与n1相关但和s不相关。自适应滤波器自动估计出n1在n2上的投影,然后把主通道的估计噪声减掉,输出就是干净的s。
代码实现用CMSIS-DSP的arm_lms_f32函数,初始化时指定权重数量、步长因子,然后每个采样点调用一次处理:
#define LMS_ORDER 32 float32_t lmsState[LMS_ORDER + 1]; float32_t lmsCoeffs[LMS_ORDER] = {0}; arm_lms_instance_f32 lmsInst; // 初始化: 权重数=32, 步长=0.01 arm_lms_init_f32(&lmsInst, LMS_ORDER, lmsCoeffs, lmsState, 0.01f, 1); // 主信号: signal_with_noise, 参考信号: reference_noise (与主信号中的噪声相关) float32_t noise_est, error_signal; arm_lms_f32(&lmsInst, &reference_noise, &signal_with_noise, &noise_est, &error_signal, 1);这里解释一下几个参数,因为这块是真容易搞翻车。权重数跟FIR的阶数类似,决定自适应滤波器能够建模的噪声路径复杂程度。步长因子μ控制收敛速度和稳态失调。μ太大,收敛快但稳态误差大,甚至可能发散震荡;μ太小,收敛速度慢到无法跟踪快速变化的噪声。经验公式是0 < μ < 1/(输入参考信号功率 * 权重数),实际操作里我会先用示波器或者串口打印看参考信号幅度,粗略估计功率,然后取这个上限的十分之一作为初始步长,再根据收敛情况微调。
我遇到过最典型的问题就是发散。系统跑一段时间,输出突然变巨大,直接顶到满幅。排查下来是步长取太大导致的。后来把步长调小一个数量级,同时加了权重幅值限幅保护,问题就再没出现过。
自适应滤波的收敛时间是它绕不开的软肋。32阶LMS在10kHz采样率下收敛到稳态,一般需要1000到5000次迭代,也就是0.1到0.5秒。如果你的系统需要开机后立即维持高精度,这段时间的过渡过程就要想好应对策略,要么初始化阶段给一个冲击信号加快收敛,要么把这段时间的输出旁路掉。
4.5 样条滤波:嵌入式里的奢侈品,离线处理王中王
样条滤波在这五类里是最特殊的存在,它基本不用于实时系统,但在数据后处理、曲线平滑、传感器标定这类场景里有不可替代的价值。
样条滤波的基本思路是把N个离散数据点用分段多项式去拟合,每一段都是三次多项式,并且在相邻段的连接处保证函数值、一阶导数和二阶导数连续。这种光滑性远非任何线性滤波器能比,因为它等价于最小化一个包含二阶导数惩罚项的目标函数,本质上是一种正则化的平滑方法。
和滑动平均、FIR低通那种“在频域切一刀”的思路完全不同,样条平滑是在时域里对曲线本身做“张紧的弹性绳”拟合,因此它能更好保留曲线的大尺度形态,又去除高频毛刺。
实现三次样条平滑的核心是解一个三对角线性方程组。设输入的离散序列长度为N,平滑参数为λ,求解如下方程:
$$(I + 64\lambda D^T D) y = x$$
D是三对角差分矩阵,y就是平滑后的输出。很多搞数值计算的朋友一看就懂了,这等价于在最小二乘与曲率惩罚之间的折中。
纯C实现的核心函数是求解三对角方程组的Thomas算法。这个算法本质是高斯消元法的特例,利用系数的三对角结构把时间复杂度压到O(N)。一次处理N个点,复杂度O(N),但每个点涉及多次浮点运算,N是1000的话就有上万次操作。
我通常只在离线标定和后期数据处理时用样条滤波。比如一组温度传感器标定数据,采集了1000个点,噪声比较大,又想得到一条平滑的标定曲线。先传给PC或者用单片机在后台跑样条平滑,出来一条极其光滑的曲线,然后分段查表使用。
样条滤波的延迟没有严格定义,因为它是一次性处理整段数据。但你可以把它理解为一种零相位滤波,因为它同时利用前后数据点来确定当前点的输出,不存在因果关系。这也意味着它天生不适合实时滤波。
嵌入式里如果一定要在STM32上用样条,建议只在任务空闲时批量处理,而且一次处理的数据量控制在1000点以内,用单精度浮点,F407上百万次运算几百毫秒就能搞定,可以接受。
4.6 五款滤波器的性能实测对比总表
在F407的168MHz主频、Keil AC5、O2优化、开启FPU条件下,我统一定义输入输出单精度浮点、逐点处理模式,得出的实测数据如下:
| 滤波器类型 | 阶数/窗口 | 每次耗时(ns) | RAM开销(字节) | Flash系数(字节) | 延迟(采样周期) | 适合场景 |
|---|---|---|---|---|---|---|
| FIR(线性相位) | 32阶 | 107 | 约200 | 128 | 15.5 | 波形保真、通信信号 |
| IIR(巴特沃斯) | 4阶2节 | 38 | 约40 | 32 | 由相位响应决定 | 控制回路、实时性要求高 |
| 中值滤波 | 15点窗口 | 约150 | 约4300(直方图) | 0 | 7-8(实测) | ADC野点抑制、边缘保留 |
| 自适应LMS | 32阶 | 约210 | 约300 | 128 | 收敛时间0.1~0.5s | 噪声对消、时变干扰 |
| 样条平滑 | N=1000 | 数百毫秒/帧 | 约20KB | 0 | 非因果零相位 | 离线标定、曲线平滑 |
从表中能直观看出,IIR的计算开销和内存占用最经济,中值滤波器的RAM开销主要来自直方图(12位ADC需要4KB计数数组),FIR的延迟是线性的可预测,自适应滤波最贵在收敛时间的瞬态过程,样条滤波则完全是另一种体量的算法。
对于采样率10kHz、单通道信号调理,正儿八经的嵌入式实时滤波,首推IIR和FIR的组合。IIR扛主力,FIR做严苛场景的保真方案。中值和自适应作为特殊用途的补充。样条留给离线任务。
5. 实操中绕不开的坑与排查经验
5.1 ADC采样数据进滤波器之前,先做这步处理
很多人在ADC中断里直接拿转换结果喂滤波器,结果滤波之后波形依然很脏。排查下来,往往是ADC采样本身的问题而不是滤波器的问题。STM32的ADC输入阻抗和采样电容不匹配,信号源阻抗较高时采样值会有微小偏差;还有采样保持时间设置过短,内阻大的传感器信号根本没稳定就被ADC锁存了。
所以我在设计信号链路时,一定要把采样保持时间拉到足够长。STM32F4的ADC采样时间可以配置为3到480个周期,我用高阻传感器时直接选最大。另外,如果信号本身带宽不高,可以在ADC前端加一个简单的RC低通,截止频率远高于有效信号的最高频率,用来滤除奈奎斯特频率以上的高频成分,防止混叠。
这就像吃药之前得先把口腔清理干净,药物本身再灵,也改变不了食物残渣造成的口臭。模数转换前端的基本调理没做好,后面滤波器再高级也白搭。
5.2 HardFault和数组越界的排查思路
ARM内核在访问非对齐地址时不会像x86那样自动处理,而是直接触发HardFault。CMSIS-DSP对状态缓冲区有对齐要求,所以我在定义滤波器状态数组时,统一加上__attribute__((aligned(4)))属性,或者在编译器里设置全局对齐到4字节。STM32F4的FPU做浮点加载时还要求8字节对齐,否则也会异常。更保险的做法是把状态数组对齐到8字节。
另外一个很隐蔽的错误是CMSIS-DSP函数内部要求pState缓冲区的大小必须是NUM_TAPS + blockSize。很多人觉得FIR只需要NUM_TAPS个历史输入就够了,只分配了这么多,结果库函数内部写越界,把相邻的变量给改掉了,后续调试各种诡异问题,串口数据莫名变化、任务调度错乱、甚至死机。排查内存问题我用的是笨办法,把滤波函数相关数组全部用特定的Magic Number填充,跑一会儿再查看内存有没有被改动,很快就能定位到是不是越界了。
5.3 滤波器系数定点化与性能的再平衡
这篇文章全程用的浮点,因为STM32F4系列带硬件FPU,负载确实低。但如果你用的是F1、F3这类不带FPU的芯片,浮点运算全靠编译器模拟,那同样是32阶FIR,耗时可能从100多纳秒暴涨到几微秒,10kHz采样率下CPU占用率瞬间到20%以上,这个负载就开始有压力了。
这种情况下就需要把滤波器系数定点化。CMSIS-DSP库提供了Q15和Q31格式的定点实现,比如arm_fir_q15、arm_biquad_cascade_df1_q15。定点化的原理是把浮点系数乘以2的N次方取整,在运算后做位移恢复尺度。这里面的坑是系数定标精度不够时频响会偏离设计目标,尤其是IIR的窄带陷波器,系数稍微偏差陷波点就飘了。我的经验是Q31格式比Q15好太多,但运算量也增大,需要根据芯片算力做权衡。
如果项目预算允许,我强烈建议直接选择带FPU的芯片。多花几毛钱的成本换来的性能余量,在调试和后期维护中会感觉非常值。
5.4 滤波效果的验证方法
滤波器写了不等于能用,验证环节不能省。我的验证流程分三步。
第一步在Matlab里做仿真验证系数设计正确性。设计完系数,先在一段混有噪声的模拟数据上跑一下,检查滤波前后的时域波形和频谱。这一步如果发现频响不对,直接在PC上调整参数,效率远高于在单片机上反复烧录调试。
第二步在单片机上用固定的测试序列验证。把一段已知的典型的信号数据(比如方波、正弦叠加噪声)作为数组存放在Flash里,滤波函数逐点处理这段数据,把输出通过串口或者DAC发出去,用上位机软件画图。这样做的好处是测试序列可重复,方便对比不同滤波器或者不同参数下的效果。
第三步是实机验证。接入真实的传感器信号,同时用高精度示波器观察滤波前后的模拟信号,以及用逻辑分析仪测量从采样到输出完整链路的实际延迟。实机验证最看重的是延迟漂移和极端工况下的稳定性,光在仿真里跑得顺不能说明任何问题。
5.5 在线整定滤波器参数的技巧
实时调整滤波器参数往往不可避免。比如音箱的均衡器就是一个典型:不同音量下喇叭的频响特性会变化,需要实时调整滤波器系数。IIR滤波器系数的变化如果不做平滑处理,输出信号会出现严重的咔哒声和爆音。
处理方法是参数平滑插值。在系数切换过程中,把旧系数和新系数按一定步长做线性插值,逐步逼近新系数。我习惯在每次中断里把当前系数朝目标系数移动10%,一般几百个采样周期内就能平滑过渡到新值而没有任何可闻的爆音。FIR滤波器因为结构单纯,直接从Flash切换系数数组即可,不太会引入瞬态冲击,除非你用的是状态保留模式。
自适应滤波天然解决了参数在线调整的问题,因为它本身就在持续调整。但LMS的步长参数需要谨慎设置:步长太小收敛慢,步长太大噪声放大厉害。工程上我做了个简单的步长调度,收敛初期用大步长加速拉近,接近稳态时自动减小步长降低稳态失调,效果比固定步长好很多。
6. 调试现场实录:我踩过的三个典型项目问题
这里整理三个实际项目里真实踩到过的坑,基本都是网上查不到、只有在示波器和逻辑分析仪前蹲上好几个小时才能得到的那种经验。
第一个项目是逆变器输出电流采样滤波。反馈回路对延迟极度敏感,一开始用了32阶FIR低通,效果很好,但系统总在某个频点上啸叫振荡。排查流程:先用逻辑分析仪测了ADC采样触发到PWM更新之间的总时间,发现FIR那100多纳秒的计算时间被忽略是完全没有道理的,因为采样、滤波到PWM更新的总链路延迟已经有15微秒左右。而采样率20kHz,这等于30%采样周期的延迟。把FIR阶数降到8阶,延迟立刻小了一半多,再配合相位补偿,啸叫消除。这个案例说明,滤波器的延迟必须放进整个控制环路的预算里,不能只看滤波函数本身的执行时间。
第二个项目是心电信号采集。原始ECG信号低幅度、强噪声,尤其是工频50Hz干扰。用二阶IIR陷波滤波后干扰确实大幅衰减,但波形出现了奇怪的畸变,ST段抬高了。查了资料才知道,IIR陷波器在陷波频率附近引入了极大的非线性相位,导致QRS波群之后出现振铃伪迹。换了32阶FIR陷波器,相位线性,波形保真度明显提升。从此之后,凡是医疗、测量类讲究波形形态的场景,我一律用FIR,IIR只在控制、电源这种不关心波形形态的场景使用。
第三个项目是振动监测传感器,现场有严重的电磁脉冲干扰,时不时冒出一个尖刺。一开始用中值滤波效果很好,但后面发现振动信号本身高频分量丰富,中值窗口大了之后细节全丢,台架上根本没法做频谱分析。后来改成“剔除野点+线性插值”的思路:先用中值滤波判定当前采样点是否属于野点,如果是就用前后有效点线性插值替代,如果不是就原样通过。这样既保住了边缘和高频细节,又去掉了脉冲干扰,效果比纯中值好得多。
这三个案例的核心教训一致:不要迷信某一种滤波器,要真正理解信号的统计特征和你这个系统的约束条件,找到那个最合适的组合。
7. 最后再分享一点我个人的实操心得
项目做多了以后,我对滤波这件事的看法发生了一个转变:刚开始追求最先进的算法、最复杂的实现,后来发现稳定、简单、可维护才是第一位。嵌入式系统的滤波问题,本质上是一个工程权衡问题。不要问什么滤波器最好,要问你的信号有什么特征、噪声从哪里来、系统延迟能接受多少、CPU还有多少余量、内存够不够,这五个问题答案清楚了,方案自然就出来了。
我以前经常被问到这样一个问题:STM32的DSP到底能不能上自适应滤波和样条这种高级算法?我的回答是,能力完全够,但你要先想清楚有没有必要。大多数项目的痛点用IIR和中值组合就能解决,真正需要自适应滤波的场景其实很少。把基础滤波器用扎实了,比追逐花哨算法有用得多。
另外,坚持把每条数据通路画出来,把每笔时间预算量化出来。滤波代码本身不难,难的是整个信号链路的通盘考虑。建议你从最简单的IIR低通开始,在示波器上观察滤波前后的信号,计算CPU负载和延迟,建立起对滤波器开销的直觉。有了这个基础,再去尝试FIR、自适应和样条,就会顺手很多。