数字麦克风PDM信号采集与STM32 I2S接口应用(二)
上篇把PDM麦克风的基础原理、I2S管脚对应关系和硬件接线的坑讲了一遍,后台催更的留言比我想象中多。很多人卡在同一个位置:原理懂了、线也接上了,但CubeMX里那几项配置和代码里的解码逻辑才是真正劝退的地方。这篇就顺着这条主线往下走,从工程配置讲到软件解码,再讲到DMA双缓冲和实测调音,把数字麦克风PDM信号采集到STM32 I2S接口这条链路完整跑通。上一篇侧重“怎么把线接对”,这一篇重点解决“怎么让数据持续稳定地流进来,并且变成能用的音频数据”。如果你正打算在STM32上做语音识别前端、环境声音检测或者简单的录音笔功能,这篇可以直接抄作业。
1. 把PDM麦克风接上I2S口:不只是连三根线那么简单
1.1 先回看一下I2S和PDM的两个时钟边界
PDM麦克风输出的是脉冲密度调制信号,本质上是一连串0和1的比特流。它需要外部提供一个时钟,通常叫PDM_CLK,频率在1MHz到3.3MHz之间,常见的是1.024MHz、2.048MHz、2.4MHz、3.072MHz这几档。麦克风在这个时钟的边沿把数据送到DATA线上,所以MCU这边要做的事情很简单:产生时钟,然后持续读取数据线上的电平。
STM32的I2S接口天然适合干这件事。它的BCLK引脚可以对外输出时钟,SD引脚可以作为数据输入,DMA可以自动把一串采样值搬进内存。很多人以为I2S只能接I2S格式的麦克风,比如INMP441这种,但其实I2S接收模式就是一个带帧同步的移位寄存器,PDM比特流只要满足I2S的时钟时序,就能被完整接收下来。
需要注意一个概念:I2S的帧长度是固定的,一般配置成16bit或32bit有效数据。如果你把BCLK配置成3.072MHz、采样率配置成48kHz,那么每个帧就是3.072MHz除以48kHz,等于64个BCLK周期。其中前32个BCLK属于左声道,后32个属于右声道。PDM麦克风只有一根DATA线,没有左右声道之分,所以我们必须想办法让I2S把这根线上的数据持续无缝地读进来。
1.2 三根线的连接方案与L/R引脚的作用
PDM麦克风通常有四个引脚:VDD、GND、CLK、DATA,另外还有一个L/R选择引脚。L/R引脚的电平决定麦克风在什么时候把数据输出到DATA线上。如果L/R接低电平,麦克风在CLK的低电平期间输出数据;如果接高电平,则在CLK高电平期间输出。这个特性对单个麦克风来说无关紧要,但对两个麦克风共用一根DATA线时非常关键。
我的推荐接法:
| 麦克风引脚 | 连接到STM32 | 说明 |
|---|---|---|
| CLK | I2S_CK(如PB13) | 由I2S的BCLK提供,3.072MHz |
| DATA | I2S_SD(如PB15) | 数据输入到I2S接收端 |
| L/R | GND或VDD | 固定电平,选择输出相位 |
| VDD | 3.3V | 必须加100nF去耦电容,尽量靠近VDD引脚 |
| GND | GND | 与MCU共地 |
这里有一个很多新手会踩的坑:L/R引脚悬空。悬空时电平平漂不定,麦克风会在不该输出的时间往DATA线上灌数据,导致采集到的比特流时序乱七八糟。最好直接用跳线接死到VDD或GND,而不是靠浮空引脚。
1.3 为什么推荐用I2S2而不是I2S1或I2S3
STM32F407上有三个I2S外设,I2S1、I2S2、I2S3各有专用的引脚。从实际项目经验来看,I2S2是最顺手的,因为它的引脚PB12、PB13、PB15在100脚封装上都有,且不与调试串口、USB等常用外设冲突。I2S1的SD引脚PA7经常被用作ADC,I2S3的引脚又和FSMC部分引脚复用,规划不好就打架。
当然具体用哪一个还得看你的整体引脚分配,我这里只是给出一个默认推荐。关键是确定好外设编号后,在CubeMX里把对应引脚初始化为I2S功能即可。
2. CubeMX里的I2S参数:算清楚这四组数字再生成代码
2.1 Audio Frequency、MCK、BCLK、采样率到底是什么关系
打开CubeMX,选择I2S外设后,需要填一堆参数。很多人直接默认值生成代码,结果采集的数据要么全是0要么全是乱码,问题多半出在这里。
I2S配置界面里几个关键参数:
- 标准(Standard):选I2S Philips或者Left Justified都可以,PDM场景下两者差别不大,真正影响采样边沿的是时钟极性。
- 帧长(Data and Frame Size):建议选32bit帧长、16bit有效数据。
- 主时钟(MCK Output):如果选Enabled,I2S会额外输出一个MCK时钟。PDM麦克风不需要这个时钟,我建议Disable掉,省一个引脚还能减少时钟树的复杂度。
- Audio Frequency:这里填目标采样率,PDM解码后得到的PCM采样率就是它,一般填48000或者16000。
- Clock Polarity:这个要格外注意,后面调音质时会专门讲。
先理清一个换算关系:BCLK频率等于Audio Frequency乘以帧长。比如Audio Frequency填48000、帧长32bit,那BCLK就是48000乘以32,等于1.536MHz。但如果我们要给PDM麦克风提供3.072MHz的时钟,就必须把Audio Frequency填成96000,帧长32bit,这样BCLK就是96000乘以32,等于3.072MHz。
也就是说,CubeMX里的Audio Frequency不一定是最终PCM采样率。PDM场景下,它决定的是BCLK频率,而BCLK直接就是PDM麦克风的输入时钟。软件解码时再做64倍抽取,PCM采样率就等于BCLK除以64,也就是96000除以64,等于1500?等等,这样不对。
2.2 用我实际验证过的参数组合
上面那个换算关系如果绕进去了,很容易出错。我直接给出一个验证过的配置组合,照填就行。
我的目标是PDM_CLK等于3.072MHz,PCM输出采样率48kHz,抽取倍数为64。CubeMX参数这样设置:
| 参数 | 值 | 计算逻辑 |
|---|---|---|
| Audio Frequency | 96000 | BCLK = 96000 × 32 = 3.072MHz |
| Data and Frame Size | 32bit frame / 16bit data | 帧长32bit,BCLK按32倍Audio Frequency算 |
| Standard | I2S Philips | 数据从BCLK下降沿变化,上升沿稳定 |
| MCK Output | Disable | 不用MCK |
| DMA模式 | Circular + HalfWord | 循环模式保证连续采集 |
这样BCLK引脚输出的就是3.072MHz时钟,PDM麦克风在时钟驱动下持续输出比特流。I2S接收端按16bit半字粒度把数据交给DMA,每两个16bit半字组合成一个32bit帧,对应左右声道各16bit。如果WS引脚接到了GND,采样数据会被固定认为是左声道,那这连续的16bit半字就是连续的PDM比特流。
2.3 时钟树里的PLLI2S配置,别让它自动生成一个奇怪的频率
用CubeMX时,如果开了I2S,时钟树会自动算PLLI2S。但自动算出来的BCLK不一定是3.072MHz整倍关系。我遇到过一次,它给我算了个3.018MHz,以为是四舍五入误差,实际用示波器一量,PDM_CLK频率偏了约1.7%,导致解码后的音频整体变调。
解决办法是在时钟树里手动指定PLLI2SR的值。以F407、外部晶振8MHz为例,要让I2S时钟源得到96MHz,PLLI2SN、PLLI2SR需要满足:VCO输入频率乘以N等于VCO输出,VCO输出除以R等于I2S时钟。我常用的组合是M=8,N=192,R=2,得到I2S时钟96MHz。然后I2S分频器再按BCLK目标频率做整数分频,正好能得到3.072MHz。
CubeMX不会帮你在程序里同步这些参数,所以生成代码之前务必回到Clock Configuration页面确认PLLI2SR和你期望的一致。这一步省了,后面调试时会花十倍时间去抓头发。
2.4 DMA配置:循环模式是底线
I2S接收数据必须配DMA,否则CPU无法在3.072MHz比特率下实时响应。DMA设置里最关键的是模式选Circular循环模式,数据宽度选HalfWord(16bit)。循环模式的好处是:DMA搬完一部分数据后自动回到缓冲区开头,继续接收新数据,中间不需要CPU介入,软件只需要通过中断回调来处理已经填好的缓冲区即可。
在CubeMX的DMA配置页面,把I2S2_RX分配到DMA1的Stream3或Stream7上,优先级设为High,Data Width选Half Word,Memory Increment开启,Peripheral地址固定不变。生成代码后记得检查HAL_I2S_Receive_DMA()传进去的缓冲区是uint16_t数组,长度为偶数,否则DMA半传输中断的边界会错位。
3. PDM解码不是库函数调一下:CIC滤波器为什么绕不开
3.1 连续的1和0怎么变成有正负的振幅
PDM比特流里,1的密度高代表当前信号幅值接近正满幅,0的密度高代表接近负满幅,密度一半一半时代表信号为0。所以解码PDM的核心操作就是统计一段时间窗口内1的数量,减去窗口长度的一半,得到一个有正负的PCM样本。
最简单粗暴的做法是每64个PDM bit数一次1。因为3.072MHz除以64等于48kHz,正好是一个PCM采样周期。如果把这64个bit里1的个数记为n,那么PCM样本就是n减去32,范围在负32到正32之间,再乘以一个增益就能得到16bit的PCM数据。这个方法简单,但高频噪声抑制能力很差,听起来会有点毛刺感。
专业的做法是用CIC滤波器,也就是级联积分梳状滤波器。它把积分器和梳状器组合在一起,对PDM比特流做抽取和低通滤波。CIC的好处是结构规则、不需要乘法器、非常适合MCU定点运算。
3.2 一阶CIC、四阶CIC的取舍,以及那个绕不开的增益问题
先看一段最直观的四阶CIC核心代码,方便理解积分和梳状的顺序关系:
#define CIC_ORDER 4 #define CIC_RATE 64 typedef struct { int32_t integrator[CIC_ORDER]; int32_t comb[CIC_ORDER]; uint32_t count; } cic_decimator_t; void cic_init(cic_decimator_t *f) { memset(f, 0, sizeof(cic_decimator_t)); } int32_t cic_run(cic_decimator_t *f, int32_t bit) { int32_t sum = f->integrator[0] + bit; f->integrator[0] = sum; for (int i = 1; i < CIC_ORDER; i++) { sum = f->integrator[i] + sum; f->integrator[i] = sum; } if (++f->count < CIC_RATE) { return 0; // 未到抽取点,不产生输出 } f->count = 0; int32_t y = f->integrator[CIC_ORDER - 1]; for (int i = CIC_ORDER - 1; i >= 0; i--) { int32_t diff = y - f->comb[i]; f->comb[i] = y; y = diff; } return y; // 抽取点输出PCM样本 }这段代码输出的是抽取后的整数样本,但要注意增益。CIC的增益等于抽取率的阶数次方,也就是64的4次方,约1600万。所以内部累加器必须用32位变量,归一化时再向右移位或除法。如果直接用16位变量,早就溢出变成噪声了。
四阶CIC的带外衰减大约在每倍频程80dB左右,对语音应用完全够用。想要更平坦的通带,后面可以再加一个FIR补偿滤波器,但那是吹毛求疵的阶段。对STM32来说,在48kHz采样率下跑一个四阶CIC,CPU占用其实非常低,主要开销来自每bit的积分运算。
3.3 查表法:把逐bit运算换成查表累加,实测能省一半时间
上面的代码逻辑清晰,但逐bit处理在极端场景下还是有点浪费。更高效的做法是查表法:预先建一个256项的表,记录每个8bit字节里1的个数。DMA每收到一个16bit半字,就拆成高8位和低8位,分别查表得到0到8之间的数,相加后这个半字里1的总数范围是0到16。
查表法代码:
static const uint8_t bitcount_table[256] = { 0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4, // 后面按位1的个数依次填充 }; int16_t decode_pdm_64(uint16_t *pdm_buf, uint32_t block_words) { uint32_t acc = 0; uint32_t out_index = 0; int16_t pcm_out[block_words / 4]; for (uint32_t i = 0; i < block_words; i++) { acc += bitcount_table[pdm_buf[i] & 0xFF]; acc += bitcount_table[pdm_buf[i] >> 8]; if ((i & 3) == 3) { // 4个半字 = 64个PDM bit,输出一个PCM样本 pcm_out[out_index++] = (int16_t)((acc - 32) * 128); acc = 0; } } return 0; }这个查表法的输出质量等价于一阶CIC,阻带衰减比较差。如果要求更高,可以在查表累加的基础上再做一次简单的IIR低通,或者用多个抽头做一个均值滤波器。实测下来,查表法加上后级均值滤波,在很多语音识别场景下已经够用,而且代码量小、容易维护。
4. DMA双缓冲与中断框架:让3.072MHz的数据流不丢
4.1 为什么单缓冲必丢数据
如果DMA只有一个缓冲区,那么DMA填满缓冲区后触发一次传输完成中断,CPU在这个中断里把数据全拷贝走,然后DMA重新从头开始填充。看似逻辑闭环,但问题是DMA在中断处理期间并没有停止工作,它继续从外设搬数据。如果中断处理时间超过了缓冲区被填满的时间,新数据就会覆盖还没被CPU拷贝走的旧数据。
举个具体数字:缓冲区开256个半字,也就是512字节,在3.072MHz比特率下,DMA搬完256个半字大约需要256乘以16除以3.072MHz,约1.33毫秒。如果CPU在中断里花了超过1.33毫秒才处理完,下一轮数据就开始覆盖缓冲区了。真正跑起来你会发现,串口打印调试信息、浮点运算、甚至一次耗时的查找表填充都可能让处理时间超过这个阈值。
4.2 HAL库半传输中断回调的正确用法
双缓冲的标准做法是把一块缓冲区逻辑上分成前半区和后半区。DMA填充满前半区时触发半传输完成中断,CPU处理前半区;与此同时DMA继续填满后半区。等后半区填满时触发传输完成中断,CPU处理后半区。整个过程DMA始终在写数据,CPU和DMA通过时间片交错访问缓冲区,互不干扰。
HAL库下的实现框架:
#define PDM_BUF_WORDS 1024 uint16_t pdm_dma_buf[PDM_BUF_WORDS]; int16_t pcm_buf[PDM_BUF_WORDS / 4]; void HAL_I2S_RxHalfCpltCallback(I2S_HandleTypeDef *hi2s) { if (hi2s->Instance == SPI2) { decode_pdm_block(&pdm_dma_buf[0], PDM_BUF_WORDS / 2, &pcm_buf[0]); } } void HAL_I2S_RxCpltCallback(I2S_HandleTypeDef *hi2s) { if (hi2s->Instance == SPI2) { decode_pdm_block(&pdm_dma_buf[PDM_BUF_WORDS / 2], PDM_BUF_WORDS / 2, &pcm_buf[PDM_BUF_WORDS / 4]); } }这里有个细节:pdm_dma_buf是uint16_t数组,因为I2S的DMA数据宽度是HalfWord。解码函数的输入参数直接传数组起始地址,注意前半区结束位置和后半区起始位置不要越界。实测下来,只要解码函数里没有浮点运算和长阻塞,1024半字的缓冲区在48kHz PCM输出下足够稳定。
4.3 不要在中断里干的三件事
双缓冲机制虽然好用,但很多人跑起来之后还是出现随机爆音,原因往往不是机制本身,而是中断里干了不该干的事。
第一,不要在中断回调里调用printf、HAL_UART_Transmit这类阻塞函数。串口发送一个字符在115200波特率下大约需要87微秒,如果一次打印几十个字符,轻松超过DMA半缓冲时间。第二,不要做浮点运算。虽然STM32F407有FPU,但浮点除法仍然比整数运算慢得多,没必要为解码算法引入浮点。第三,不要在回调里动态分配内存,比如malloc。嵌入式环境下的内存分配具有不确定性,一次内存申请可能触发系统调用甚至内存碎片整理,直接破坏实时性。
我习惯的做法是:中断回调里只做两件事,一是解码PDM到PCM缓冲区,二是置一个标志位,主循环检测到标志位后把PCM数据送往下一级处理,比如USB音频、UART或者存储介质。
5. 实测调音:单一噪声、音量不对、偶发爆音的完整排查链路
5.1 采回来的数据全是0或者全是1,从哪里查起
这个问题占了PDM调试问题的六成以上。排查顺序非常固定,不要跳步。
先用示波器量PDM_CLK引脚,确认有没有3.072MHz的方波输出。没有波形,说明I2S没有进入主模式,或者CubeMX生成的时钟树配置有问题,重点查PLLI2S是否按照预期输出。有波形,接着量麦克风DATA引脚,看有没有随声音变化的脉冲。如果DATA引脚一直是低电平,通常是麦克风VDD没供上或者虚焊;一直是高电平,可能是L/R引脚电平导致数据输出了很长时间但没握手成功,或者麦克风本身损坏。
如果示波器看着波形正常,但DMA缓冲区里全是0,那问题出在I2S数据采样边沿和麦克风输出边沿不匹配上。这时把CubeMX里的Clock Polarity从Falling Edge改成Rising Edge,或者反过来,重新生成代码再试。PDM麦克风和I2S数据线都是靠时钟边沿对齐的,两边相位差一个周期会导致每次采样都落在数据跳变沿上,结果就变成固定值。
5.2 能解出声音但音量特别小,问题大概率在直流偏置和增益
PDM解码后得到的PCM数据是有直流分量的。当麦克风前没有声音时,PDM比特流中1和0的数量应该大致相等,解码出来的样本应该围绕0波动。但实际由于硬件偏置,解出来的值可能围绕一个很大的正数或负数波动。如果不减去这个直流分量,后面的增益调整会把整体信号推偏,听着就像音量小还带着闷闷的底噪。
解决办法是对输出样本做一阶高通滤波,截止频率设在100Hz左右:
static int32_t dc_block(int32_t x) { static int32_t prev_x = 0; static int32_t prev_y = 0; int32_t y = x - prev_x + prev_y * 0.98; prev_x = x; prev_y = y; return y; }当然MCU里不要直接写浮点系数,用定点近似即可。实测下来,去直流后再做256倍增益缩放,音量输出就比较正常了。PDM麦克风的灵敏度普遍不高,如果最终PCM还要送入AEC回声消除等算法,建议增益留足余量,避免削顶。
5.3 偶发爆音和卡顿:先怀疑缓冲越界,再怀疑中断抢占
偶发爆音是双缓冲机制最常见的副作用。最典型的场景是解码函数写PCM缓冲区时越界,把DMA缓冲区或别的变量覆盖了。解码函数在写输出样本时一定要严格按输入半字数除以4来限制输出数量,不要想当然地认为缓冲区很大就安全。
另一个常见原因是更高优先级中断密集中断I2S的DMA请求。如果系统里同时还有定时器中断、串口中断,且它们的优先级都比I2S DMA高,那么在极短时间窗口内I2S DMA可能被延迟,导致采样时钟边沿和实际数据统计出现偏差。排查方法很简单:把I2S DMA中断优先级调到最高,或者降低其他无关中断的频率。
我遇到过一次非常隐蔽的爆音:ADC的中断服务程序里用了HAL_ADC_Start_DMA重新启动转换,导致一次进度较大的内存拷贝操作卡住了总线,I2S DMA的一个半字没来得及搬走,就出现了周期性爆音。后来把ADC的DMA改成始终运行,只通过软件切换通道,问题就消失了。所以说,DMA通道分配和中断优先级规划在整个系统层面是联动的,不能只看I2S这一个外设。
5.4 问题排查顺序速查表
| 现象 | 优先检查项 | 再检查项 | 最后检查项 |
|---|---|---|---|
| 数据全0或全1 | PDM_CLK波形 | DATA引脚连接 | I2S采样边沿极性 |
| 白噪声/沙沙声 | 直流偏置 | CIC抽取率匹配 | 电源纹波 |
| 音量小 | 灵敏度增益 | 直流偏置未除 | I2S有效数据位选择 |
| 偶发爆音 | DMA缓冲区越界 | 中断优先级 | 其他外设DMA干扰 |
| 变调 | PLLI2S频率偏了 | BCLK不等于标称 | 帧长设置 |
6. 这套方案跑通之后,还能往哪些方向扩展
6.1 两个PDM麦克风共用一根数据线,实现双路采集
PDM麦克风最吸引人的地方之一就是支持时分复用。两个麦克风的DATA引脚可以直接短接在一起,一个L/R接GND,另一个接VDD。这样在CLK的不同相位上,两根麦克风交替驱动DATA线,I2S接收到的数据流自然包含了两个麦克风的交替信息。软件解码时,把连续的比特流按偶数比特和奇数比特拆成两路,分别送到两个CIC滤波器里,就能得到两个独立的PCM通道。
这个扩展很适合做简单的波束成形或者双麦降噪。不过有个硬件细节需要提醒:两个麦克风的DATA线直接短接,对制造工艺和布局要求略高,尽量缩短走线,避免信号边沿变形。软件上也要保证看到的是完整交替序列,一旦同步丢失,左右声道就互换了,而且这种错误很难从声音上第一时间听出来。
6.2 从前端采集到后级处理,可以衔接的下一站
PDM解码后得到的是48kHz采样率、16bit量化的PCM数据,这可以非常方便地接进各种处理链路。
如果做关键词唤醒,可以在主循环里对PCM数据窗口做简单的能量检测和过零率检测,先筛掉静音帧,再送入模型推理。如果做录音,可以把PCM数据通过SDIO写入TF卡,用WAV格式打包即可。如果做实时传输,可以把PCM数据打包成USB Audio Class格式,让STM32变成一个USB麦克风,在PC上直接使用。
我在实际项目里把这套PDM采集链路接到过简单的音频能量灯上,用FFT算频谱后驱动RGB灯条,效果非常稳定,整个系统的CPU占用率还不到25%。这说明对于一个以语音交互为主要功能的产品来说,PDM麦克风加STM32软件解码这条路完全走得通,不需要为了省事去额外挂一颗昂贵的音频编解码芯片。
6.3 几条说烂了但仍然值得重复的硬件建议
最后再提几个硬件层面的经验,都是实测里反复验证过的。第一,PDM麦克风的VDD去耦电容一定要对着麦克风引脚就近放,最好100pF和100nF并联,给高频脉冲电流一个低阻抗回路。第二,DATA线和CLK线不要平行走太远,更要避免在PCB上形成大环路面积,否则辐射噪声会直接混进采样数据,表现为无论怎么调软件都去不掉的底噪。第三,如果用了FPC排线连接麦克风小板,两端要加串联电阻,一般33欧姆到100欧姆,用来抑制反射和振铃。
这些建议不一定每条都能立刻看到效果,但当你遇到莫名其妙的杂音和稳定性问题时,返回头检查一下硬件往往比在代码里折腾更有效。软件解码再完善,也不可能修复一个在信号源处就已经被污染的数据链路。