ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MCU实时信号处理库实战:从滤波到FFT的工程实现

MCU实时信号处理库实战:从滤波到FFT的工程实现 1. 项目背景与整体方案实时信号处理库该怎么选上个月接了一台便携振动监测设备的原型开发需求写得很简单传感器信号进来实时做低通滤波、FFT频谱分析、窄带能量统计再把结果通过网口往上抛。我第一反应当然是搜“实时信号处理库”看了一圈以后发现一个尴尬的问题网上能搜到的库很多但绝大部分项目文档只告诉你“我有FFT我有FIR”几乎没有项目会讲清楚一个实时信号处理库到底应该怎么组织、怎么定数据边界、怎么评估延迟。后来我只能自己把一套方案完整落地这次就把整条链路写出来从选型思路到核心代码再到实测和踩坑一次性说透。这套东西适合谁看正在MCU或者嵌入式Linux上做音频、振动、生物电、声呐等连续信号采集的工程师以及打算把MATLAB/Python里的滤波或频谱算法往板子上移植的同学。如果你只是要跑离线仿真这篇文章帮助不大但只要你面对的是“数据一直在来处理不过来就丢”的场景里面的内容基本都能复用。1.1 先想明白“实时”两个字到底要求什么在选择或者自研实时信号处理库之前必须先想清楚需求里的“实时”属于哪一类。很多项目只是要求“处理得快”但我做这块的习惯是把实时性拆成三个独立指标第一个是吞吐即单位时间内能处理的数据量必须不低于单位时间内产生的数据量否则缓冲区会持续增长最终内存耗尽或者丢帧第二个是延迟从数据被采样到处理结果可用中间隔了多少时间第三个是抖动也就是每一次处理耗时的稳定性这一点在控制类场景里尤其敏感因为某个循环偶尔多花5毫秒对检测系统可能只是多一次报警对闭环控制系统就可能直接激发振荡。我这次的项目属于典型的软实时场景数据以固定采样率持续流入判断标准是在任意一个帧周期内必须等上一帧处理完下一帧才会到达。板上主循环里偶尔做一些打印、网络发送操作是被允许的只要整帧处理的耗时上界明显小于帧周期就不会丢数据。把这个标准定下来之后“实时信号处理库”的选型才有讨论的余地否则很容易陷入“谁的FFT更快”这种局部最优的误区。1.2 为什么我不建议从FFT和FIR裸函数开始自己攒很多人看到实时信号处理库第一反应是“FFT算法自己写也就一百行”这句话在玩票项目里成立放到产品里基本是给自己埋雷。FFT的蝶形计算、位反转、旋转因子表、定点溢出处理每一个细节都不难但组合起来以后要在不同编译器优化等级、不同对齐条件下稳定运行需要投入的测试时间远超你的直觉。CMSIS-DSP这类库为什么值得信任不是因为函数本身有多神奇而是它在ARM Cortex-M平台上被跑过无数轮代码里的循环展开、流水线停顿优化、内存对齐策略都是调过的。相比之下业务代码真正需要关注的是数据流划分和模块边界。比如传感器数据从DMA进来后是先拷贝还是直接原地处理滤波器的状态在帧与帧之间怎么保存FFT窗口需要的历史数据和当前帧怎么拼接这些才是决定一个库能不能稳定运行的关键。所以我最终的选择很明确底层数学算子用CMSIS-DSP上层业务调度自己写并封装成一个只暴露少量接口的实时信号处理库。这个组合比完全自研省掉大量底层验证时间又比直接拿某个开源DSP项目硬套灵活得多。1.3 项目里采用的硬件平台和总体指标开发板选用的是STM32F446RECortex-M4F内核主频180MHz带硬件FPU。板上的模拟输入经过前级信号调理后接到ADC由定时器触发以48kHz的采样率连续采集。DMA配置为循环模式缓冲区切分成两个半区当半区填满时触发中断主循环检测到标志后从半区取出2048个样本作为一个处理帧。这样每帧实际覆盖的时长为2048除以48000约42.7毫秒留给处理的预算还算宽裕。整帧处理内容包含四步去直流、101阶FIR低通滤波、2048点加窗FFT、频带能量统计和峰值检测。按照这个配置系统每42.7毫秒输出一次频谱更新音频或者振动事件的时间分辨率已经足够。如果后续要求更快更新可以把帧长降为1024点或者改成半帧重叠的滑动窗方案但那会影响频率分辨率属于另一个层面的权衡后文会展开讲。2. 模块化拆解实时信号处理库的核心结构怎么定刚开始设计这个库的时候很容易犯的一个错误是“每个函数看起来很干净串起来却很难维护”。FFT函数归FFT滤波函数归滤波貌似解耦了实际调用方要自己记住滤波器的初始化参数、状态缓存、输出缓冲放在哪稍有不慎就会把滤波器状态数组传错结果数据错得离谱还排查不出来。所以我这次把项目按数据角色拆成三层采集层、算法层、结果层。算法层单独封装成一个小型信号处理库对外提供统一的初始化和处理接口业务代码不关心每个算子内部的状态。采集层负责把ADC的连续数据流切成一帧一帧送给算法层。结果层则是把算法算出的能量、谱峰、滤波波形打包成结构化数据供上层组帧发送或报警。三个层的代码尽量不互相调用边界非常清晰。2.1 算法库内部模块怎么划分这个信号处理库内部我分成四个子模块分别是dc_blocker、fir_filter、spectrum、detection。dc_blocker负责去除信号中的直流分量fir_filter负责用户设定的低通或者带通滤波spectrum负责加窗和FFTdetection负责从频谱中提取特征比如某几个频段的能量和峰值位置。模块之间通过结构体传递配置不直接共享全局变量。每个模块单独写一个.c和一个.h保持可替换性。举个例子如果某天想把FIR滤波器换成IIR的Biquad级联滤波器我只需要保证fir_filter模块提供的对外函数名不变内部实现换成arm_biquad_cascade_df1_f32就可以上层调用方几乎不需要改动。这种设计一开始看起来繁琐但一旦算法需要迭代收益非常明显。/* signal_lib.h */ #ifndef SIGNAL_LIB_H #define SIGNAL_LIB_H #include stdint.h #include arm_math.h #define SIGNAL_FRAME_SIZE 2048 #define SIGNAL_FFT_SIZE 2048 #define SIGNAL_FIR_TAPS 101 typedef struct { uint32_t sample_rate; float32_t lowpass_fc_hz; float32_t input_gain; } signal_lib_cfg_t; typedef struct { arm_fir_instance_f32 fir_inst; float32_t fir_state[SIGNAL_FIR_TAPS SIGNAL_FRAME_SIZE - 1]; float32_t fir_coeffs[SIGNAL_FIR_TAPS]; arm_cfft_instance_f32 cfft_inst; float32_t window[SIGNAL_FFT_SIZE]; float32_t fft_buffer[SIGNAL_FFT_SIZE * 2]; float32_t magnitude[SIGNAL_FFT_SIZE]; float32_t dc_prev_input; float32_t dc_prev_output; } signal_lib_t; typedef enum { SIGNAL_OK 0, SIGNAL_ERR_INIT, SIGNAL_ERR_PARAM } signal_ret_t; signal_ret_t signal_lib_init(signal_lib_t *lib, const signal_lib_cfg_t *cfg); signal_ret_t signal_lib_process_frame(signal_lib_t *lib, const float32_t *in, float32_t *out_filtered, float32_t *spectrum_db, uint32_t fft_len); #endif看到这个结构体有经验的工程师马上会注意到一个问题fir_state数组以及fft_buffer这类大数组全部定义在结构体里面一个结构体的大小很容易超过几十KB。如果直接在main函数里定义该结构体而工程又把栈空间设置得比较小那么一启动就会栈溢出。我在项目中把signal_lib_t实例定义为全局变量并明确不建议把它放在栈上。另一个需要注意的点是CMSIS-DSP的FFT初始化函数arm_cfft_init_f32会在内部生成旋转因子表这个表的位置需要一定内存初始化时通常耗时几十毫秒所以绝对不能放在中断服务函数里执行只允许在系统启动阶段调用一次。2.2 对外接口只暴露两件事我认为一个库好用的标准不是功能多而是接口少。这个信号处理库最终对外只暴露两个核心函数signal_lib_init负责初始化signal_lib_process_frame负责处理一整帧数据。调用方不需要知道内部是否调用了arm_fir_f32也不需要知道FFT怎么加窗怎么校正幅值只要保证传入的in指针指向一段长度正确、已经去除掉采集阶段异常值的浮点数组即可。process_frame内部的处理顺序是固定的先做一阶高通去直流再做FIR低通输出一份给上层作为滤波后的时域波形同时把滤波结果加窗并做FFT输出幅度谱。频谱结果并不是直接输出原始复数模值而是换算成dB值后存到spectrum_db数组里。这样上层无论是做可视化还是做阈值判断拿到的都已经是物理含义明确的数值而不是需要二次计算的原始频谱。与其把所有功能都放到一个函数里为什么不提供更多灵活的接口因为对于一个实时处理库来说自由度是隐患。调用者如果自行组合滤波和FFT很容易忘记去直流、忘记加窗、忘记状态复位最后得到错误结果时很难判断是哪一步出了问题。把所有固定流程锁在库内部换来的是更高的可复现性这也是实时系统里非常重要的一种设计取向。2.3 数据从ADC到算法层的边界处理这个项目里ADC通过DMA循环采样DMA缓冲区分成两个半区每个半区大小是2048个样本。当DMA半传输中断触发时说明当前半区的数据已经填满主循环里将对应的半区标记为ready。这里有一个很容易踩的坑主循环在处理数据的时候DMA可能已经在往另一个半区写数据了如果处理耗时超过了半区填满时间新数据就会覆盖掉还没处理的半区。因此我把处理流程拆成两个独立缓冲区DMA只负责往raw_buffer里写process_frame只认processing_buffer里的数据两者之间用memcpy一次性搬移数据。这种拷贝看起来浪费了一些CPU周期但换来的是主循环和DMA之间彻底解耦。memcpy一块2048个float的数据在180MHz主频下大概只需要几十微秒相比整帧处理时间可以忽略。有人在追求极致延迟的音频项目里会直接原地处理DMA缓冲区但这要求你对DMA时序有非常精确的控制而且还要处理缓存一致性不适合作为第一版方案。稳是第一位的。3. 滤波器和FFT实现细节从系数生成到频谱输出的完整链路前面讲了库的整体结构这一部分进入主体实操。FIR滤波为什么用101阶、FFT为什么要加汉宁窗、幅度谱怎么样才能换算成正确的物理幅值这些如果不在设计阶段一次性想明白后期调试会浪费大量时间。3.1 直流分量的处理方式传感器信号经过ADC后经常叠加一个直流偏置这个偏置如果不处理FFT结果里0Hz附近的谱线会非常高而且它产生的频谱泄漏会污染低频段让真正关心的振动信号淹没在毛刺里。解决直流问题有两种常用思路一种是在一帧数据内部求均值然后逐点减去另一种是设计一阶高通滤波器。帧内减去均值的方式实现最简单但有个隐患如果被处理的信号本身存在慢变趋势一帧内的均值并不能准确代表直流分量减完之后反而会引入低频误差。我采用的是单极点高通滤波结构上是一个一阶IIR公式为y[n] alpha * (y[n-1] x[n] - x[n-1])当alpha取0.995左右时-3dB截止频率大约在采样率的千分之一附近也就是48kHz采样率下大概48Hz。这个配置既可以去直流又不会过度衰减低频振动信号。高通滤波状态必须在帧与帧之间保存因为滤波器是有记忆的。我代码里dc_prev_input和dc_prev_output两个变量就承担这个职责每次处理完一帧后不要清零。如果清零每帧开头会出现明显的瞬态响应表现在时域上是帧首部的低频振荡表现在频域上就是连续帧的FFT结果不稳定。3.2 FIR低通滤波器系数生成与状态管理FIR在MCU上做低通是个很稳的选择主要是它天生稳定、线性相位群延迟对所有频率都一致这一点对后续时域波形分析和频谱分析的一致性很有价值。IIR虽然计算量更小但存在相位非线性在某些需要保留波形形态的场景下会产生可见失真。101阶FIR的系数我是用Python生成后直接导出成C数组的scipy.signal.firwin一行就能算出来import numpy as np from scipy.signal import firwin fs 48000 fc 5000 taps 101 h firwin(taps, fc, fsfs, windowhamming) h h.astype(np.float32) with open(fir_coeffs.h, w) as f: f.write(#ifndef FIR_COEFFS_H\n#define FIR_COEFFS_H\n\n) f.write(static const float32_t fir_coeffs[%d] {\n % taps) for i in range(0, taps, 4): line , .join(f{v:.9f}f for v in h[i:i4]) f.write( line ,\n) f.write(};\n\n#endif\n)这里为什么要用汉宁窗而不是矩形窗矩形窗在频率响应上虽然主瓣更窄但第一旁瓣只衰减13dB左右很容易让带外信号泄漏到带内。汉宁窗的旁瓣衰减达到31dB以上换来的是主瓣稍宽但实际传感器信号里高频噪声往往比较强如果旁瓣衰减不够滤波之后仍然会残留明显的高频成分。对于低通滤波器设计阻带衰减往往比过渡带宽度更重要。CMSIS-DSP的arm_fir_init_f32初始化时给了三个重要参数滤波器系数指针、状态数组指针和状态数组大小。状态数组大小的正确值是numTaps blockSize - 1而不是简单的blockSize。我在项目里定义的fir_state数组长度是101加2048减1也就是2148个float。为什么需要这么多因为FIR对当前帧滤波时要用到前100个历史输入样本这些历史样本必须保存在状态数组开头处理完一帧后CMSIS-DSP会自动把最新的blockSize个样本滑动写入状态数组你不需要手动搬移。如果状态数组分配得不够数据会写到相邻内存造成非常隐蔽的内存越界问题。这个问题我在下面的排查实录里还会细讲。3.3 FFT加窗、幅值校正和标定技巧FFT长度选了2048点采样率48kHz所以频谱分辨率是48000除以2048约23.44Hz。如果分析对象是转速为1500RPM的设备振动基频25Hz23.44Hz的分辨率刚好可以分辨基频和2倍频但想要更精细地区分24Hz和25Hz就不行了。频率分辨率和窗口长度是绑定关系想提高分辨率就必须增大窗口但窗口增大会让时间分辨率下降这个取舍没有免费午餐。处理FFT之前先加汉宁窗主要目的是抑制频谱泄漏。如果直接把原始信号截取2048点做FFT当一个频率成分不是FFT频率间隔的整数倍时它的能量会泄漏到所有频点产生类似一大片“裙边”的假频谱。加窗后
返回列表