STM32F411E-DISCOVERY实时音频滤波实战:CMSIS-DSP与FIR/IIR全流程

发布时间:2026/8/29 12:31:08
STM32F411E-DISCOVERY实时音频滤波实战:CMSIS-DSP与FIR/IIR全流程 最近把STM32F411E-DISCOVERY这块板子拿来做audio filtering的小项目本来只是想验证一下Cortex-M4F在实时音频处理上的真实能力结果越做越深从FIR、IIR一路做到了PDM麦克风采集、DMA乒乓缓冲、CS43L22输出整个过程踩了不少坑。这篇就把完整过程写下来包括滤波器怎么选、系数怎么生成、代码怎么落、性能怎么估以及那些排查到凌晨的音频问题。如果你也手头有一块F411E-DISCOVERY或者想入门MCU DSP这篇文章应该能帮你省下几天时间。1. 这块板子天生就是为音频链路设计的1.1 板载音频资源麦克风、编解码器、调试链一条龙STM32F411E-DISCOVERY的核心是STM32F411VET6主频100MHz带单精度FPU512KB Flash128KB SRAM。但真正让它适合做音频滤波项目的不是MCU规格本身而是板载外设——板上直接集成了两颗MP45DT02数字麦克风还有一颗CS43L22低功耗立体声音频编解码器。这意味着你不需要外接任何音频模块就能完成一整套“采集-处理-播放”的闭环。很多刚接触这块板子的人容易忽略一个事实它自带了完整的音频通路。数字麦克风输出的是PDM脉冲密度调制信号经过软件抽取滤波变成PCM数据CS43L22通过I2S接收PCM数据再驱动耳机或者喇叭输出。你只需要在中间插入一个滤波算法就能变成一个完整的实时音频处理器。我以前在F103上做过类似的事光接外部音频Codec就折腾了一周在F411E-DISCOVERY上这步直接省了。1.2 Cortex-M4F的DSP家底FPU加DSP指令为什么标题里特别强调F411E-DISCOVERY这个平台因为STM32F411使用的是Cortex-M4F内核而不是M0/M3这一点对audio filtering来说是质的区别。M3虽然也能做音频滤波但浮点全靠软件模拟跑一个128阶的浮点FIR主频72MHz也够呛。而M4F自带单精度FPU还支持DSP扩展指令比如单周期的FMA融合乘加配合ARM官方CMSIS-DSP库128阶浮点FIR在100MHz下实时运行毫无压力。我实际测下来F411在48kHz采样率下跑128阶FIRCPU占用率大概在20%到25%之间这意味着你还有大量余量去做其他事比如按键扫描、LED显示甚至同时跑两组滤波器。这就是硬件浮点的价值。如果你用的是没有FPU的MCU哪怕主频更高做浮点音频滤波也可能被乘加运算拖死。1.3 它能做哪些滤波和PC端仿真有什么本质差别因为输入输出都齐全这块板子理论上可以实现大部分常见的音频滤波算法低通、高通、带通、带阻、参数均衡、噪声门、动态压缩、回声消除甚至简单的自适应滤波。它本质上是把一个DSP芯片该干的事交给一个通用MCU来做。和PC端用MATLAB、Python做算法仿真不同MCU端的音频滤波有一个很关键的约束——实时性。PC上处理一秒钟音频你花两秒钟算完也没人管你但MCU上处理的是实时音频流每个采样间隔内必须把该算的算完否则DMA缓冲区会被覆盖声音直接爆掉或者出现周期性撕裂。这决定了你的算法实现必须采用块处理模式并且要合理设计缓冲区这也是后面所有调试工作的核心矛盾。2. 滤波器选型与系数生成先想清楚再动手2.1 FIR和IIR的取舍别被“线性相位”绑架音频滤波器的第一选择就是定类型FIR还是IIR。FIR的优点是无条件稳定、可以做到严格的线性相位也就是不同频率分量的时间延迟一致波形不会畸变缺点是达到同样滤波效果需要的阶数更高计算量和延迟都更大。IIR的优点是用很少的阶数就能获得很陡的过渡带计算量小延迟低缺点是非线性相位对“相位敏感”的应用场景可能有影响。这个项目里我做了一个低通去噪的示例选了128阶FIR。为什么因为FIR实现简单、稳定性有保障而且对于48kHz采样率、截止频率5kHz的低通来说128阶FIR的通带波纹和阻带衰减已经足够听感上很干净。另外一个重要原因是CMSIS-DSP里的arm_fir_f32在M4F上做了深度优化计算量并没有想象中那么大这也让我有勇气选择FIR。如果你的最终产品对成本功耗极度敏感或者需要极低延迟处理IIR的biquad级联会是更优解。我实际也做了4段Biquad的IIR低通计算量只有FIR的十分之一左右CPU占用几乎可以忽略。所以建议是做原型验证用FIR做量产资源受限场景用IIR两者在CMSIS-DSP里都能无缝切换。2.2 用Python先算好系数再做C移植滤波器系数是整个算法的心脏。我习惯用Python的scipy库来生成系数因为整个过程可以复现、可以调参还能随手画幅频响应图确认设计对不对。以下是一个128阶汉明窗低通FIR的设计代码采样率48kHz截止频率5kHzimport numpy as np from scipy.signal import firwin, freqz import matplotlib.pyplot as plt fs 48000 cutoff 5000 numtaps 128 # 设计FIR低通窗函数选汉明窗 coeffs firwin(numtaps, cutoff, fsfs, windowhamming) # 查看频响 w, h freqz(coeffs, worN2048, fsfs) plt.plot(w, 20 * np.log10(abs(h))) plt.ylim(-80, 5) plt.show() # 输出成C数组格式 for i, c in enumerate(np.round(coeffs, 8)): print(f{c: .8f}f,, end) if (i 1) % 4 0: print()生成的系数数组直接复制到C代码里就能交给CMSIS-DSP使用。同样的方法也可以用scipy.signal.butter设计IIR系数不过IIR系数的格式需要转换成biquad的b0、b1、b2、a1、a2形式CMSIS-DSP才能直接用。这里要注意IIR系数转换中的精度问题——直接在Python里用浮点转换后贴进C代码滤波器可能会因为量化误差出现轻微偏差一般建议保持float32精度并且用双精度设计完再转单精度。2.3 为什么一定绕不开CMSIS-DSP库CMSIS-DSP是ARM官方提供的数字信号处理库目标就是针对Cortex-M内核做极致优化。F411的M4F内核上CMSIS-DSP的浮点FIR会用足FPU的乘加指令还有循环展开、数据对齐优化性能远超手写循环。我承认第一次用CMSIS-DSP的时候我也有过“要不要手绘”的念头毕竟手写也能跑。但后来对比发现同样的128阶FIR手写C循环版和CMSIS-DSP版本差距可以有3倍以上而且CMSIS-DSP的API设计非常清晰函数内部稳定性经过大量验证你不需要担心边界条件或者对齐问题。更重要的是CMSIS-DSP还提供了FFT、矩阵运算、统计学等函数后面如果想做频域分析或者自适应滤波可以直接复用不用换技术栈。3. 让音频流跑起来PDM采集、I2S输出与乒乓缓冲3.1 PDM数字麦克风的采集路径F411E-DISCOVERY的两个MP45DT02麦克风输出的是PDM信号也就是1bit的密度调制流时钟频率通常在2MHz量级必须经过抽取滤波才能变成48kHz的PCM样本。这块板子的PDM数据接收走的是板上的I2S/SPI通道配合ST提供的PDM2PCM软件库完成CIC抽取滤波。初始化的时候我用CubeMX把对应的I2S/SPI配置成接收模式开启DMA然后调用PDM库的滤波函数把原始PDM比特流转成16位或32位的PCM样本。这里的一个关键点是PDM解码本身也是计算密集型的它内部有CIC滤波器加补偿FIR在F411这种100MHz的芯片上其实占有相当一部分CPU。所以当你评估整体性能时别忘了把PDM解码的开销算进去否则后面滤波器的性能预算会偏乐观。另外一个容易被忽略的坑是PDM数据的位宽和字节序。DMA搬进内存的原始PDM数据是打包的bit流如果你直接把这块内存当PCM解析出来的声音会完全是乱的。必须先跑PDM库的转换函数而且转换函数需要每次喂入固定大小的输入块这个块长度和DMA中断周期要匹配否则会出现“每过一段时间声音卡一下”的怪象。3.2 CS43L22编解码器初始化CS43L22是Cirrus Logic的低功耗音频编解码器数据通路走I2S控制通路走I2C。初始化其实不复杂但顺序很重要第一个是复位先把codec挂到硬件复位或软件复位状态。第二个是配置电源寄存器让codec进入正常电源模式。第三个是配置MCLK和采样率相关寄存器确保codec内部锁相环能正确生成所需的音频时钟。第四个是配置I2S数据格式标准I2S、16bit/24bit、48kHz和MCU侧I2S发送配置保持一致。第五是配置音量CS43L22的音量寄存器是0x20那一组需要按自己的输出设备调一个合适的值。最后是解除静音codec默认上电是静音状态忘了这一步输出就是全无声很容易误判为硬件故障。我在这块板子上遇到过一个奇怪的现象程序一启动输出“滋滋”响后来发现是CS43L22的MCLK没配好——I2S的位时钟和MCLK的频率比例不对codec内部时钟错乱导致解出的音频数据产生周期性噪声。所以建议初始化完codec后先用固定的正弦波数据测试输出确认音调准确了再接麦克风数据。3.3 DMA乒乓缓冲实时处理的关键实时音频处理最核心的机制是DMA乒乓缓冲也叫双缓冲。原理很简单内存里准备两个缓冲区DMA在采集或播放时轮流使用它们——当一个缓冲区被DMA填充或读取时CPU在处理另一个。这样采集和计算能并行不会出现“算完才能采下一块”的串行等待。我的实现里缓冲块大小设为32个采样点也可以设64或者128。DMA配置成循环模式每填满半个缓冲区触发一次半传输中断填满整个缓冲区触发一次完成中断这两个中断分别对应处理A缓冲和B缓冲。处理完的数据直接写入I2S发送的DMA缓冲区发送侧同样使用双缓冲。伪代码大概是这样的void HAL_SAI_RxHalfCpltCallback(SAI_HandleTypeDef *hsai) { // A缓冲已满处理A process_audio_block(pdm_out_buffer_A); } void HAL_SAI_RxCpltCallback(SAI_HandleTypeDef *hsai) { // B缓冲已满处理B process_audio_block(pdm_out_buffer_B); } void process_audio_block(float32_t *input) { arm_fir_f32(fir, input, output_buffer, BLOCK_SIZE); // 将output_buffer拷贝到I2S DMA发送缓冲区 copy_to_i2s_tx(output_buffer, BLOCK_SIZE); }这里要注意中断回调里不要做太耗时的事情尤其不要做printf或复杂日志调试输出会严重拉长中断时间直接导致音频卡顿。如果一定要看数据我建议用DMA把关键变量搬出来或者只在调试模式开串口正式运行时关掉。3.4 采样率怎么定48kHz背后有一连串因果关系这个项目里采样率我定为48kHz。为什么选它不是因为听起来比44.1kHz好而是因为容易从板载时钟得到。F411E-DISCOVERY板上的音频时钟一般用12.288MHz作为MCLK而256乘以48000正好等于12.288MHz所以48kHz是CD采样率的数字组合中最容易精确生成的那个。如果你硬要设成44.1kHzMCLK就不是整数倍关系了要么换晶振要么用PLL搞复杂分频非常麻烦。采样率的准确性直接影响音调。如果I2S实际输出是44.1kHz但你滤波算法按48kHz设计声音会整体变低或变高而且滤波器的截止频率也会偏离目标。调试时我习惯用示波器量I2S的WS字选择信号频率确认它就是48kHz再往下查别的。这个习惯帮我避开了不少“看似算法问题实为时钟问题”的坑。4. 滤波代码实战FIR、IIR与性能实测4.1 128阶FIR低通滤波的落地CMSIS-DSP的FIR接口非常干净主要是一个实例结构体加三个函数初始化、滤波、以及可选重置状态。#include arm_math.h #define BLOCK_SIZE 32 #define NUM_TAPS 128 // 系数数组由Python生成后填入 float32_t fir_coeffs[NUM_TAPS] { // ... 从scipy导出的128个系数 }; // 状态缓冲长度必须至少为 BLOCK_SIZE NUM_TAPS - 1 // 建议写成 BLOCK_SIZE NUM_TAPS留点余量并做4字节对齐 static float32_t fir_state[BLOCK_SIZE NUM_TAPS] __attribute__((aligned(4))); arm_fir_instance_f32 fir; void audio_filter_init(void) { // 先清零状态缓冲否则第一次滤波会带进随机数据 memset(fir_state, 0, sizeof(fir_state)); arm_fir_init_f32(fir, NUM_TAPS, fir_coeffs, fir_state, BLOCK_SIZE); } void audio_filter_process(float32_t *input, float32_t *output) { arm_fir_f32(fir, input, output, BLOCK_SIZE); }这里有一个非常容易踩的细节fir_state数组的长度必须大于等于BLOCK_SIZE NUM_TAPS - 1而不是直接等于BLOCK_SIZE。CMSIS-DSP的FIR实现会把历史采样存在这个数组里长度不够轻则内存越界写坏相邻变量重则直接hardfault。我在第一次调的时候就因为状态数组长度设成了BLOCK_SIZE导致系统跑几十秒就随机死机排查了很久才发现是这里越界。另外初始化时一定要memset清零状态缓冲。虽然变量如果定义为全局静态数组默认是零初始化但如果你在同一个循环里反复调用初始化函数不手动清零会把上一次运行残留的尾数据带进新滤波器前几十毫秒的输出会有一阵噪声。4.2 IIR Biquad代替FIR低延迟的实惠方案如果追求极低算力消耗IIR是一个更好的选择。CMSIS-DSP提供级联biquad滤波器接口#define NUM_STAGES 4 // biquad系数排列方式每5个一组依次是 b0, b1, b2, a1, a2 // 注意a1, a2 在库内部是以取负后的形式使用的参考CMSIS文档 float32_t iir_coeffs[5 * NUM_STAGES] { // 来自Python scipy.signal.butter 转换 }; arm_biquad_casd_df1_inst_f32 iir; static float32_t iir_state[4 * NUM_STAGES]; void iir_filter_init(void) { memset(iir_state, 0, sizeof(iir_state)); arm_biquad_cascade_df1_init_f32(iir, NUM_STAGES, iir_coeffs, iir_state); } void iir_filter_process(float32_t *input, float32_t *output) { arm_biquad_cascade_df1_f32(iir, input, output, BLOCK_SIZE); }4段biquad的IIR低通阻带衰减比128阶FIR还陡峭计算量大概只有它的1/10。但IIR的缺点也很明显非线性相位。如果你把滤波前后的波形放在一起对比会看到波形形状有一定程度畸变但这个畸变对听感的影响其实很小尤其是做去噪、去直流这种粗粒度处理耳朵基本听不出来。实际使用中我建议FIR做分频、信号分析这些相位敏感场景IIR做简单的频段塑形和去噪。两者的CMSIS-DSP接口都非常稳定从FIR切到IIR只需要改一个函数调用非常方便。4.3 性能实测100MHz主频下能跑多少阶写代码之前最好心里有个数所以我做了个简单的性能测试。通过翻转一个GPIO用示波器测量滤波函数执行时间的占空比得到大概的数据滤波器采样率块大小单块耗时估算CPU占用率估算64阶FIR48kHz32约 160us约 9%128阶FIR48kHz32约 320us约 18%256阶FIR48kHz32约 640us约 36%4段IIR Biquad48kHz32约 26us约 3%这个数据是粗略值实际会受编译器优化等级、中断频率和代码对齐影响。但能看出一个结论F411在48kHz实时音频下跑128阶FIR毫无压力256阶也还在安全范围之内。如果做到512阶FIR使用率会逼近70%这时候如果再叠加PDM解码整机负载就有点高了容易出问题。这里还要提醒一句PDM解码本身是要吃CPU的。ST的PDM库做CIC抽取滤波时在F411上会额外占用大约20%到30%的CPU具体取决于抽取率和内部滤波器阶数。所以如果你用了PDM麦克风作为音源实际能分配给后续滤波算法的CPU余量并没有纯算了那么多。做性能评估时一定要把整条链路算进去否则后期会发现自己把CPU用满了。4.4 滤波后的增益补偿与防削波FIR滤波器通过增益归一化设计后通带增益一般是0dB但由于窗函数和舍入精度的影响实际通带可能略低一点再加上多级滤波叠加音量会轻微下降。听感上可能只是“好像声音变小了”但分贝数一测才发现掉了3dB甚至更多。处理办法很简单在系数数组末尾乘以一个增益系数比如1.2。但这里有个防削波的问题——如果输入信号已经接近满幅滤波器通带内增益只要超过1输出就会削波产生刺耳的失真。解决思路是在滤波前统计输入信号峰值动态调整增益或者干脆在滤波器后面接一个软限幅函数牺牲一点点动态范围换取无破音体验。我实际做的是滤波器前面做自动增益控制AGC的简化版计算每32个样本的峰值若峰值接近满幅把输入先衰减3dB再过滤波器输出后按比例补偿。这个逻辑不复杂但对最终听感提升很大。5. 踩坑实录从高频噪声到声音变调的排查链路5.1 现象一滤波处理完还是满屏“沙沙”声第一次把FPIR烧进去后耳机里传来的只有“沙沙”声完全听不出滤波有没有效果。我第一时间怀疑滤波器设计错了结果绕过了滤波器直接把PDM解码后的PCM数据送CS43L22播放发现“沙沙”声依旧存在。这让我意识到问题出在滤波之前而不是滤波本身。最终定位到PDM数据接收的位深配置上。DMA从I2S/SPI接口搬进来的原始PDM比特流与PDM库内部期待的输入宽度不对齐导致每几个样本就错位一次解码出来的PCM就变成剧烈的噪声。这个教训很重要当你发现“滤波明明加了却没效果”的时候先确认滤波前链路是干净的。最好的办法是做一个直通模式也就是滤波开关不处理数据直接播放先保证原始音频是正常的再关掉直通模式去测算法。5.2 现象二声音变调所有频率都不对输出声音像“慢速播放”或“音调变高”这通常是采样率不匹配。我遇到的情况是MCU侧I2S配置的采样率和CS43L22内部锁相环推导出的采样率不一致导致codec播放时时序错乱。排查链路是这样的先用示波器量I2S的WS波形频率看是不是48kHz。结果发现WS刚好是48kHz说明MCU那边没问题。接着看CS43L22的MCLK输入发现MCLK频率是16.9344MHz而不是期望的12.288MHz。因为MCLK不等于256乘以48kHzcodec内部无法正确产生音频主时钟最终输出的音频速度就错了。这个问题的根因在CubeMX时钟树配置上我把I2S时钟源选错了导致分频出来的MCLK不是目标值。修正时钟配置后声音恢复正常。这里强烈建议用示波器或逻辑分析仪在调试阶段量一下MCLK和WS这两个信号的频率是音频系统的物理基准比任何软件日志都可靠。5.3 现象三周期性“咔哒”爆音当系统跑起来后每隔一两秒就会出现一声“咔嗒”实际是缓冲覆盖的典型症状。这说明DMA在写入新数据时CPU还没处理完上一块数据或者I2S发送DMA把缓冲区的旧数据发完后又把还没填好的新数据发出去了。排查顺序我先看了中断优先级。因为工程里还开了SysTick、串口中断如果DMA半传输/完成中断的优先级太低就会被其他中断打断太久导致处理超时。把DMA中断调到最高优先级后爆音频率明显降低但还是有偶发。接着我检查了块大小和DMA周期。当块大小从32改成64之后DMA中断触发频率减半CPU处理压力反而减轻了爆音消失。这说明32样本块虽然延迟更低但在F411上留给CPU的处理时间窗口太短一旦有其他长中断干扰就会来不及处理。对于这个项目64样本块是延迟和稳定性之间的一个甜点值。5.4 现象四滤波后声音偏低且带直流偏移当我切换了不同滤波器去测试时发现滤波后的信号比原始信号轻了不少甚至波形整体有一个直流偏移。原因有两个一是滤波器的通带增益不是严格的1尤其IIR滤波器系数经过浮点舍入后通带增益会略低于预期二是低通滤波会把一部分直流分量包含进来如果输入前端本来就有一个小的直流偏差滤波后会保留下来。我的做法是在滤波前加一阶高通DC blocker截止频率几赫兹专门滤掉直流分量在滤波后再做一个标量增益补偿。DC blocker的代码实现很便宜// DC blocker: y[n] x[n] - x[n-1] R * y[n-1] float32_t dc_blocker(float32_t input, float32_t *prev_x, float32_t *prev_y) { float32_t output input - *prev_x 0.995f * (*prev_y); *prev_x input; *prev_y output; return output; }这个函数对每个样本逐一调用即可基本不占CPU。用了它之后滤波后信号的中线稳定回到零附近后面再做增益补偿也更有意义。6. 把项目往前推一步实时均衡器与噪声门6.1 三分频均衡器并联滤波架构当你已经跑通了一个FIR或IIR再往实用场景走一步就是做一个简单的三分频均衡器。思路是把音频路径分成三路低通、带通、高通每路滤波器的输出乘一个可调增益最后叠加输出。在CMSIS-DSP里这就是三个滤波器实例并行处理同一份输入输出各自乘系数后相加。void eq3_process(float32_t *input, float32_t *output, uint16_t size) { arm_fir_f32(fir_low, input, tmp_low, size); arm_fir_f32(fir_band, input, tmp_band, size); arm_fir_f32(fir_high, input, tmp_high, size); for (uint16_t i 0; i size; i) { output[i] low_gain * tmp_low[i] band_gain * tmp_band[i] high_gain * tmp_high[i]; } }F411跑这个三分频架构性能余量很大我实测三个64阶FIR同时工作都没有压力。这个基础上可以加按键循环切换预设或者用ADC电压控制增益做一个低成本调音台原型。6.2 噪声门与动态处理不只是线性滤波音频处理里还有一类非线性的“滤波器”最典型的是噪声门。它不改变频率成分而是根据信号能量决定是否衰减。实现的时候先计算当前块的能量RMS如果低于阈值就输出很小的增益高于阈值就保持正常。关键在于attack和release时间——如果门开得太快会产生咔哒声如果关得太快音乐尾音会突然被“砍掉”。attack_time 5ms release_time 50ms hold_time 20ms我在实现时先维护一个缓慢变化的增益包络attack和release分别用不同的系数同时加一个“hold”计时器避免信号跨过阈值瞬间反复开关门。这种控制在F411上用浮点做很顺手可以说给了块DSP芯片级别的能力。6.3 从固定滤波到参数可调更新系数的正确姿势进一步扩展就可以让用户动态调整滤波器的截止频率。这时有一个很关键的问题滤波系数不能在DMA中断处理的中途修改否则正在处理的样本会使用“新旧混合系数”直接产生毛刺噪声。正确做法是设一个标志位在主循环里等当前块处理完成后再更新系数更新完把实例里的系数指针指向新数组同时重置状态缓冲。如果不小心在中断回调里直接改了系数数组你可能会听到“咔嗒”一声那是因为卷积窗内混入了不连续的跳变。这是实时音频系统的一个经典问题和DMA缓冲覆盖一样都属于“数据一致性”的范畴。这块F411E-DISCOVERY我断断续续折腾了几个星期最大的感悟是实时音频滤波本身并不玄难的是把数据通路和时序管理好。PDM解码、DMA缓冲、采样率、codec寄存器这些环节任何一个出了问题算法写再好都白搭。所以我强烈建议你复现这个项目时也遵守一条原则先把“直通模式”调通再接滤波算法让问题在测试链路上就暴露出位置。CMSIS-DSP能省一半的功夫但那些看不见的DMA覆盖和数据竞争仍然要自己一步步踩出来。希望这篇记录能让你少走点弯路。