1. 为什么从I2S切到TDM:立体声到16通道的跨越
如果你的项目还停留在I2S接一片立体声Codec的阶段,看到“16通道音频混音”可能会觉得有点夸张。但真实项目里这种需求一点都不罕见:智能音箱的麦克风阵列、多路语音识别前端、工业声学检测、会议音频处理器,这些设备经常要同时采集多路音频信号。几年前我做一套16路麦克风阵列的采集板时,就遇到了同样的主题——I2S这种“天生一帧只有左右两个声道”的协议,在通道数面前成了绕不开的瓶颈。
1.1 I2S协议的2通道上限在哪
I2S全称Inter-IC Sound,本质是给立体声音频准备的串行总线。标准I2S有三个关键信号:位时钟SCK(也叫BCLK)、帧同步WS(也叫LRCK)、串行数据SD。WS电平和SCK边沿共同决定当前SD线上传输的是左声道还是右声道,所以一帧最多两个时隙。最常见的情况是16bit或24bit字长,左右声道各占一个slot,整个帧长也就是32bit或64bit位时钟周期。
这套机制在消费音频上没有任何问题,立体声就是它的设计目标。但一旦通道数超过2,I2S就尴尬了:要么多片Codec并联,每一片都需要自己的I2S引脚和DMA通道,占用的MCU资源成倍增加;要么用额外的“片选”机制去扩展,协议上完全绕开了BCLK与WS的定义,后期同步会非常痛苦。
我当时面临的现实是:16路模拟MIC经过前端放大,接入多片ADC。如果按I2S方案,需要8颗立体声ADC并联,每颗都占用一组SCK/WS/SD,还要与MCU的I2S外设分别同步,PCB布线像蜘蛛网一样,调试时光是查哪个通道的时序对不上就够喝一壶。所以这个场景下,TDM几乎是唯一合理的解法。
1.2 TDM的时隙复用原理
TDM(Time Division Multiplexing,时分复用)在音频领域指的是一种固定帧长、多时隙传输的串行总线协议。它和I2S最大的区别在于:帧同步信号FS不再区分左右声道,而是用一个脉冲或特定polarity标识一帧的起点,随后在同一个数据线上按slot 0、slot 1、slot 2……的顺序连续传输多个通道的采样数据。数据线上永远是“一帧接一帧”,帧帧都是完整的周期。
通道数可以灵活配置:TDM4、TDM8、TDM16甚至TDM32都很常见。每帧包含多少个bit、每个slot占多少bit,都可以由主控和音频器件之间的约定决定。这个特性对多通道采集非常友好:一个字面意义上的“并车道”——所有通道走同一条数据线,按时隙切分。MCU侧只需要一个串行外设,配合DMA就能把整帧数据搬进内存,通道数由“多少组I2S线”变成了“一个帧里多少个slot”。
举个实际例子,我用的是TDM16 + 16bit slot的配置。每个SCK周期传1bit,每个slot占16个SCK周期,一帧16个slot,总帧长就是256个SCK周期。48kHz采样率下,SCK频率为12.288MHz,这个速率对STM32的外设和DMA完全没有压力。
1.3 SAI外设在这件事里的角色
STM32的SAI外设是我转TDM的重要推动力。SAI(Serial Audio Interface)在本质上是一个比传统I2S外设更灵活的串行音频接口,它支持标准的I2S/JTAG/LEFT_J等协议,但同时也能配置成TDM模式,甚至允许我们直接控制帧长、slot使能、slot大小、帧同步极性这些底层参数。
标准I2S外设通常把时序细节定死,比如某款MCU的I2S外设WS宽度固定是多少bit、数据位宽可选范围有限,想做非标帧格式基本没门。SAI则放开许多限制,以我验证过的STM32H743为例,它的SAI帧长寄存器FRL可以支持到256bit级别的帧长,这正好能装下16个16bit slot。这一下子把“在单条数据线上跑16路音频”变成了可行方案。
不过外设灵活的前提是你得真正理解怎么配置它,后面第2章我会详细展开时钟树、FS极性、SLOTR时隙寄存器这些细节。这里先强调一个容易忽略的点:SAI每个实例由A、B两个block组成,两个block既能独立工作,也能同步配合。一个block接收TDM16,另一个block输出立体声混音结果,各自独立时钟、独立DMA,这是做16通道混音的最佳结构。
2. SAI驱动配置:时钟树、帧同步与时隙映射
SAI看起来只是一个config结构体+一堆寄存器,实际跑起来后,你会发现配置顺序和参数细节几乎决定成败。很多人照着Reference Manual写一遍初始化,然后插上Codec发现没声音或者噪声满满,往往就是某个bit位没设对。这章我会把当时调试中反复验证过的配置逻辑按顺序梳理一遍。
2.1 音频时钟树与SCK/MCLK的计算
音频外设的时钟精度直接影响采样率是否准确。如果你只是用内部RC去凑一个SCK,出来的音频可能音调偏高或偏低,虽然还能听,但只要是做“阵列信号处理”这类对时基敏感的项目,就绝不能马虎。
以48kHz采样率 + TDM16 + 16bit slot为例,目标SCK=48kHz×256=12.288MHz。如果你的MCLK直接等于SCK,那么所有分频都围绕12.288MHz来算;如果Codec需要MCLK=256×fs=12.288MHz,那正好一致。我当时的连接方式是SAI作为master输出SCK和FS,给外部ADC提供12.288MHz位时钟,MCU内部不再额外输出MCLK。
在STM32CubeMX里,你需要在Clock Configuration中找到SAI时钟源,一般是某个PLL的输出。我的做法是让PLL产生24.576MHz中间频率,SAI内部分频除以2得到12.288MHz。这里有两个坑:
第一,SAI的时序控制器有自己的DIV分频位,不是所有分频倍数都能精确得到,所以最好先在CubeMX里直接输入目标SCK频率看分频结果是否接近整数,哪怕误差超过0.1%也要警惕,宁可换PLL中间频率。
第二,MCU和外部ADC时钟必须是同一根源头。TDM模式下,外部器件的FS识别和SCK采样若存在微小频差,短时间还看不出问题,长时间采集就会出现周期性丢码。我当时为了调试方便把Codec设成了slave,SCK/FS全靠SAI给,这样两侧天然同步,省掉一大堆麻烦。
分频计算可以用下面这个公式快速验证:
SCK = 音频时钟源频率 / (SAI_DIV + 1)假设时钟源为24.576MHz,想要12.288MHz,则:
12.288MHz = 24.576MHz / (SAI_DIV + 1) => SAI_DIV + 1 = 2 => SAI_DIV = 1所以只要把SAI的分频系数设为1就得到精确的12.288MHz,不需要做近似取整,这是最理想的情况。
2.2 帧同步信号与SLOTR时隙掩码
TDM协议中FS信号的定义和I2S完全不同。I2S的WS是电平信号,高电平对应左声道、低电平对应右声道;而TDM模式下FS通常是一个“帧起始标记”,一帧开始前产生一个短脉冲,后续所有slot按顺序排布。SAI的FRCR寄存器里有FRL(帧长度)、FSALL(帧同步有效长度)、FSPOL(极性)、FBO(第一位偏移)等字段,这几个字段需要和外部ADC的时序图严格对齐。
我自己画过一个参考表,把SAI配置和Codec期望值做了对比。假设外部ADC期望的TDM时序是:FS低电平有效,帧同步长度1bit,帧长度256bit,slot0在FS有效后的第一个SCK上升沿开始传输,数据MSB在前。那么SAI的配置就要用:
- 帧长度FRL=255(注意FRL的值是以bit为单位减1后的值,所以256bit要写255)
- 帧同步有效长度FSALL=0
- 帧同步极性FSPOL=低电平有效
- 第一位偏移FBO=0
- slot0~slot15全部Enable
SLOTR寄存器是另一个容易出错的地方。你可以通过SLOTEN配置每个slot是否使能,不使能的slot在数据线上是0,这会导致如果外部设备输出在某个slot上有效、而你的使能掩码漏掉了这个slot,DMA里对应通道就全是0。我以前就犯过“只使能了前8个slot,后8个通道全是静音”的低级错误,排查了很久才从寄存器读回值里发现问题。
假如你要把16路数据全部接收,应在代码里配置为,以HAL库为例:
saiHandle.Init.Protocol = SAI_FREE_PROTOCOL; saiHandle.Init.AudioMode = SAI_AUDIO_MODE_MASTER; saiHandle.Init.DataSize = SAI_DATASIZE_16BIT; saiHandle.Init.FrameLength = 256; saiHandle.Init.ActiveFrameLength = 1; saiHandle.Init.FrameSyncPolarity = SAI_FSYNC_ACTIVE_LOW; saiHandle.Init.FrameSyncOffset = SAI_FIRST_BIT; saiHandle.Init.SlotNumber = 16; saiHandle.Init.SlotSize = SAI_SLOTSIZE_16BIT; saiHandle.Init.SlotActive = 0xFFFF;这里的FrameLength=256和ActiveFrameLength=1是核心,实际让外部器件看到的就是一个1bit低脉冲开头、随后紧跟16个16bit slot的帧格式。
2.3 从I2S配置改成TDM的HAL初始化流程
我最初是拿现成的I2S驱动改的,一开始以为只要把Protocol改成SAI_FREE_PROTOCOL、SlotNumber改成16就行,结果当然不行。SAI的TDM模式和I2S模式的初始化差别还挺隐蔽。
先列一个我整理过的完整初始化流程:
- 打开SAI和DMA的时钟,包括GPIO复用时钟,GPIOAF要选对(比如H743的SAI1_A可以映射到PE2/PE4/PE5/PE6这些引脚)。
- 配置SAI为master,数据方向根据需求选发送或接收;把
Sync设置为SAI_SYNCHRONOUS或SAI_SYNCHRONOUS_EXT,如果只是一路接收,设为无同步也行。 - 设置协议为
SAI_FREE_PROTOCOL,数据位宽SAI_DATASIZE_16BIT。 - 设置帧长256,slot数16,slot大小16bit,slot掩码0xFFFF。
- 设置DMA请求使能,并把DMA通道接到对应SAI的接收DMA请求。
- 启动DMA接收后,再启动SAI,顺序不能反。
- 调试时用示波器确认SCK频率和FS脉冲间隔。
这个顺序里最容易被忽略的是第6步。如果先启动SAI但DMA还没有进入接收状态,SAI会产生FREQ(FIFO request)事件,用户代码没处理时可能被置为OVR错误或者直接阻塞后续DMA传输,表现出来就是“接收使能了但永远没有数据”。
还有一个小经验:在没有外部设备的情况下,把SAI的SD引脚悬空会导致读到的数据全为1或者全为0,但接收部分本身能正常工作。我习惯先把SAI配置成自回环模式做初步验证——把发送数据脚和接收数据脚短接,先证明DMA和中断链路没问题,再接外部设备调时序,这样能让问题范围快速分离。
3. 16通道混音的数据通路:DMA环形缓冲与归一化算法
时序和寄存器搞定之后,16路数据已经可以源源不断通过DMA进入内存。但接收数据只是第一步,真正的混音逻辑才是这个项目的核心。16通道的音频数据在DMA缓冲区里是“交织”的,一帧内紧密排列着slot0到slot15的采样值,如果直接用这种数据做处理,算法会相当别扭。所以首要任务是设计好数据通路,用内存布局把通道数据“解交织”成连续数组,再开始混音。
3.1 DMA双缓冲与音频帧结构设计
TDM16模式下,每帧共256bit,也就是32字节。如果按48kHz采样率计算,每秒会产生1.536MB数据,这个速率对DMA搬运没有任何问题,但CPU不能频繁地逐帧处理,那样中断开销会非常大。我采用的方案是DMA半传输中断+双缓冲:把接收缓冲区设为两块,DMA先填充第一块,半传输中断触发时CPU处理第一块;DMA继续填充第二块,传输完成中断触发时CPU处理第二块。这样处理延迟固定在半个缓冲区的时间,不需要从头等到尾。
缓冲区大小的选择很有讲究。假设我想每50ms处理一次数据,50ms内48kHz采样率会产生48k×0.05=2400帧,每帧32字节,缓冲区总大小就是2400×32=76800字节。切分成两块,每块38400字节。这个尺寸对于H743内部RAM来说可以承受,但要注意用普通RAM还是DTCM/紧耦合RAM,DMA能不能直接访问DTCM取决于芯片型号和总线配置,我当时的解决方法是把DMA缓冲区放在普通SRAM,处理过程中频繁读写的临时数组放DTCM,这样两边都不抢总线。
如果直接用CubeMX生成代码,环形缓冲的索引管理需要自己写。我习惯定义一个结构体:
#define CHANNEL_NUM 16 #define BLOCK_SAMPLES 1200 // 每个半块内的采样点数,即每通道采样数 typedef struct { int16_t ping[BLOCK_SAMPLES][CHANNEL_NUM]; int16_t pong[BLOCK_SAMPLES][CHANNEL_NUM]; volatile uint8_t pingReady; volatile uint8_t pongReady; } AudioBuffer;DMA搬运的目标是ping的起始地址;DMA半传输中断时,DMA已经填满了前一半的ping,此时pingReady=1;主循环或处理进程读取ping数据,混音后再清标志。需要注意,DMA地址长度我按16bit字长配置,这样每个DMA传输恰好对应一个slot采样值,省去在中断里做字节拼装的麻烦。
3.2 16通道混音的整数运算与Clipping处理
混音的本质是“多路信号叠加”。如果我直接写:
int32_t sum = 0; for (int ch = 0; ch < 16; ch++) { sum += ping[i][ch]; } output[i] = (int16_t)sum;初看没问题,实际必然爆。16路满幅信号叠加后,int16_t的范围绝对装不下;即使各路信号不是同时满幅,只要超过4条通道都接近满幅,就会产生整数溢出。整数溢出在音频里表现为可怕的爆音和削波噪声。
处理办法有很多,最简单的就是除以通道数做平均:
int32_t sum = 0; for (int ch = 0; ch < 16; ch++) { sum += (int32_t)ping[i][ch]; } int32_t mixed = sum / 16; if (mixed > 32767) mixed = 32767; if (mixed < -32768) mixed = -32768; output[i] = (int16_t)mixed;这种“平均混音”能保证不溢出,但带来的问题是整体音量偏低,因为各路信号幅度相近时,平均后的峰值可能只有单路信号的1/4甚至更低。所以更实际的做法是:先给每路信号配置一个权重(音量系数),乘累加后再做归一化。
我们项目的需求是16路中,有些麦克风距离远、有些距离近,不能简单平均。我采用了如下结构:
#define FIXED_SHIFT 8 static const int32_t chGain[16] = {256, 200, 180, 220, ...}; // Q8格式 for (int i = 0; i < BLOCK_SAMPLES; i++) { int32_t acc = 0; for (int ch = 0; ch < 16; ch++) { acc += ((int32_t)ping[i][ch] * chGain[ch]) >> FIXED_SHIFT; } if (acc > 32767) acc = 32767; else if (acc < -32768) acc = -32768; pcm16Out[i] = (int16_t)acc; }增益系数用Q8定点格式,每个通道最大增益对应256(即1.0),通过移位完成除法,避免浮点运算。这里需要一点经验:所有通道增益之和不要远大于256,比如16路全部设为1.0,那么理想情况下输出幅度和输入相当,噪声也能得到一定平均;如果故意放大某些通道,增益和后很容易触发限幅器。
3.3 音量控制与归一化策略
混音结果的音量控制我建议放在限幅之前。一个干净的做法是先算各通道加权和,再做一次“目标峰值归一化”。简单说就是统计这一块音频的绝对峰值,按比例缩放,让输出峰值尽量贴近满幅但又不削波。这种做法能自动适应多路信号大小差异,比手动调音量系数更稳。
不过峰值归一化要小心:如果某一块里有一路麦克风出现突发杂音,归一化会把整块音量拉低,听感上会有“抽风”感。我最后采用的折中是:归一化只在一个窄范围内生效(比如0.5~1.0倍),超出范围就交给软限幅。软限幅不是硬裁剪,而是用一个非线性映射让信号接近满幅时平滑压缩,听感上温和很多。
在嵌入式中实现软限幅,一种非常实用的近似算法是:
int16_t softClip(int32_t x) { if (x > 20000) { return 20000 + (x - 20000) / 4; } if (x < -20000) { return -20000 + (x + 20000) / 4; } return (int16_t)x; }这段逻辑避免硬截断在20000处,超过后再按1/4斜率压缩,信号最终不会超过约26244,从而保证输出不削波。虽然这不算严格的软限幅曲线,但在实测中听不出明显失真,比硬裁剪自然得多。
4. 实测踩坑记录:从无声、错位到削波的完整排查链路
标题既然叫“踩坑记录”,这章才是大家最想看的部分。前面这些配置和算法,我并不是一次就调通的。这块板子从第一次上电到16路信号全部稳定混音,前前后后经历了十几个问题。下面挑几个最有代表性的,把排查链路完整写出来,希望能帮后来人少走弯路。
4.1 现象一:切换后整条链路无声
第一次把I2S配置改成TDM16之后,我直接跑了一个简单的“全通测试”:所有16路输入接同一个正弦信号,混音输出接耳机放大器。结果耳机里完全无声。
我没有立刻怀疑硬件电路,而是先用逻辑分析仪量SAI的SCK和FS。这里的经验是:排查问题永远从协议端到端查起,先确认时钟和帧同步有没有出来,再看数据。
实测结果:SCK有12.288MHz波形,FS也在以48kHz频率跳变。这就说明SAI主模式基本工作正常。那么问题很可能出在数据通路或者混合输出上。我继续量SD引脚,发现SD线上确实有数据波形,但波形幅度和预期不太对——看起来像是左右声道恒定交错变化,而非16个slot的正常数据排列。
这个现象提醒了我:外部ADC的TDM模式是不是根本没开启?我仔细查了ADC的寄存器配置,发现使用的是某款支持TDM的音频ADC,但之前驱动代码里没有写它的TDM enable位,它默认还在I2S stereo模式。于是SAI这边拼命按TDM16解数据,ADC那边却只按左右声道输出,两边完全对不上,结果就是解码出来全是噪声或静音。设置ADC寄存器打开TDM16模式并在对应slot上输出数据后,SD线波形肉眼可见变得规则多了,耳机里也终于出现了正弦声音。
所以如果你的TDM无声音,第一步不是怀疑MCU,而是确认对端设备的TDM模式是否真的打开。许多音频器件虽然硬件支持TDM,但默认上电状态是I2S,必须显式配置切换。
4.2 现象二:通道错位与串音
16路全通了,但耳机里听到的明显不是“1路正弦”的简单叠加,而是像通道位置交换了:我单独给slot0输入正弦波,却发现输出里混着好几路信号的能量。用示波器触发了单通道数据,再做FFT,确实发现频谱泄漏和串扰。
我先怀疑是PCB线间电容耦合,但频率那么低的音频信号,串扰不至于这么强。后来用逻辑分析仪对照外部ADC输出时序才找到根因:外部ADC的FS高电平有效,而我配置成低电平有效,导致SAI把FS的下降沿当成了帧起点,实际上是从帧的中间位置开始采数据。这样一来,外部设备输出的slot0被我的接收端当成了slot3或slot4来采样,通道之间自然就错位串扰了。
修正方法很简单:把SAI的FS极性配置改成与外部设备一致,或者直接改外部设备的极性寄存器。我还在代码里加了一个调试辅助,把slot0固定接一个1kHz正弦,然后默认按16通道解交织,检查输出里1kHz能量落在哪个通道索引。这样每次换板子、换ADC型号都能快速确认TDM时隙映射是否正确。
4.3 现象三:DMA半传输中断丢帧
通道位置正确之后,下一步就是跑连续流混音。这时候出现了一个非常典型的疑难杂症:长时间运行后,输出每隔一段时间就会出现“咔哒”一声,像是丢了一帧数据。用计数器统计DMA半传输中断和传输完成中断的次数,发现两个中断次数存在不匹配,说明确实有中断没有按预期执行。
我先怀疑中断优先级不够高,但对比过NVIC设置、确认DMA中断优先级已经最高,仍然偶发丢帧。后来把目光转向“半传输中断的处理时长”。我在半传输中断里不仅搬运了数据,还顺便做了混音计算,而混音计算涉及16通道×1200个采样点的乘法累加,耗时大约几百微秒。在这段时间里,如果DMA已经传输完毕并触发了新一轮半传输中断,由于优先级相同且CPU正在处理前一次中断,这次中断会被挂起或丢失标记。
解决方法是让中断只负责置标志位和切换缓冲区,把混音计算全部放到主循环中轮询执行。DMA继续在后台接收,主循环检测到pingReady或pongReady后,才从对应缓冲区取数据做混音。一次中断里只做几个内存写、几个标志置位,时长降到纳秒级,DMA中断再也不会被自己挡住。处理延迟并没有变差,因为主循环轮询周期远小于音频块长度,实时性完全够用。
4.4 现象四:多路混音削波与爆音
通道数大于8之后,混音削波问题变得非常明显。我给其中8路麦克风输入同幅度的语音信号,权重统一设为1.0,结果输出波形顶部明显被压平,听感是“嘶嘶”的失真。
原因在前面3.2节已经分析过:多路信号叠加后峰值超出int16_t。我当时想依靠硬限幅来兜底,但硬限幅在信号越界时直接crop波峰,会产生大量高次谐波,主观听感非常差。后来把硬限幅换成了softClip函数,再用峰值归一化控制整体幅度,失真基本消失。
这里分享一个经验:混音输出最后一级之前,尽量用32bit或更高的中间精度做累加,只在最终输出时转成16bit并限幅。很多程序员把中间变量声明成int16_t,结果累加过程就已经在丢精度,后续任何处理都没意义。我用的是int32_t做累加,实际16路满幅信号叠加后最大值不会超过16×32767≈524272,int32_t完全装得下,安全冗余足够。
当然还有另一种“专业”做法是给每路信号先做动态范围压缩(compress),将大动态的语音信号压到合适的电平范围再混音,这样即使多路叠加也不容易爆音。但这种做法会改变信号本身的动态特性,如果后续还要做语音识别或声学分析,压缩会直接影响算法精度,所以我在阵列采集项目里没有采用,只有在纯播放场景才会考虑。