
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位时钟周期。这套机制在消费音频上没有任何问题立体声就是它的设计目标。但一旦通道数超过2I2S就尴尬了要么多片Codec并联每一片都需要自己的I2S引脚和DMA通道占用的MCU资源成倍增加要么用额外的“片选”机制去扩展协议上完全绕开了BCLK与WS的定义后期同步会非常痛苦。我当时面临的现实是16路模拟MIC经过前端放大接入多片ADC。如果按I2S方案需要8颗立体声ADC并联每颗都占用一组SCK/WS/SD还要与MCU的I2S外设分别同步PCB布线像蜘蛛网一样调试时光是查哪个通道的时序对不上就够喝一壶。所以这个场景下TDM几乎是唯一合理的解法。1.2 TDM的时隙复用原理TDMTime 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的重要推动力。SAISerial 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为例目标SCK48kHz×25612.288MHz。如果你的MCLK直接等于SCK那么所有分频都围绕12.288MHz来算如果Codec需要MCLK256×fs12.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设成了slaveSCK/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帧长度256bitslot0在FS有效后的第一个SCK上升沿开始传输数据MSB在前。那么SAI的配置就要用帧长度FRL255注意FRL的值是以bit为单位减1后的值所以256bit要写255帧同步有效长度FSALL0帧同步极性FSPOL低电平有效第一位偏移FBO0slot0~slot15全部EnableSLOTR寄存器是另一个容易出错的地方。你可以通过SLOTEN配置每个slot是否使能不使能的slot在数据线上是0这会导致如果外部设备输出在某个slot上有效、而你的使能掩码漏掉了这个slotDMA里对应通道就全是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;这里的FrameLength256和ActiveFrameLength1是核心实际让外部器件看到的就是一个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。设置帧长256slot数16slot大小16bitslot掩码0xFFFF。设置DMA请求使能并把DMA通道接到对应SAI的接收DMA请求。启动DMA接收后再启动SAI顺序不能反。调试时用示波器确认SCK频率和FS脉冲间隔。这个顺序里最容易被忽略的是第6步。如果先启动SAI但DMA还没有进入接收状态SAI会产生FREQFIFO 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.052400帧每帧32字节缓冲区总大小就是2400×3276800字节。切分成两块每块38400字节。这个尺寸对于H743内部RAM来说可以承受但要注意用普通RAM还是DTCM/紧耦合RAMDMA能不能直接访问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此时pingReady1主循环或处理进程读取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≈524272int32_t完全装得下安全冗余足够。当然还有另一种“专业”做法是给每路信号先做动态范围压缩compress将大动态的语音信号压到合适的电平范围再混音这样即使多路叠加也不容易爆音。但这种做法会改变信号本身的动态特性如果后续还要做语音识别或声学分析压缩会直接影响算法精度所以我在阵列采集项目里没有采用只有在纯播放场景才会考虑。