做一个多路音频聚合的项目,我一开始天真地以为I2S就够了,直到面对16通道混音需求时,才发现单根I2S数据线只能承载两路音频,想继续拓展就得往TDM方向走。最终我用STM32的SAI接口,把多路I2S数据按时隙拆开、再打包成一根TDM流,完成了整个16通道音频混合处理的链路。这篇文章就把我踩过的槽位偏移、FIFO溢出、时钟配置这些坑,以及SAI接口的初始化细节和混音处理流程,完整记录下来,给同样在做多通道音频项目的朋友做个参考。
先说结论:如果你也在做多通道音频采集或输出,且手头主控是STM32F4/F7/H7这一档的芯片,SAI接口几乎是绕不开的。普通I2S外设在绝大多数MCU上只能处理立体声,要做到16通道,要么使用多颗I2S芯片并联,要么借助TDM协议把多个时隙塞进一根数据线。STM32的SAI接口天然支持I2S、TDM、PCM等模式,配置得当就能像切时间片一样,把多路音频数据有序地送进同一根线上,既省引脚又省PCB面积。这篇文章适合嵌入式音频开发、音视频矩阵设备相关从业者,也适合正在准备基于STM32毕业设计的学生,照着配置思路走一遍就能跑通基础的TDM收发。
1. 项目背景与方案选型思路
1.1 为什么从I2S转向TDM
传统I2S协议的结构非常经典:一条位时钟(BCLK)、一条帧同步(WS/LRCK)、一条数据线(SD),帧同步频率等于采样率,每个声道占半个帧周期,因此在标准I2S模式下,一根数据线只能承载左右两个通道。立体声场景下完全够用,但一旦需要做16通道音频混音,就面临一个物理层面的瓶颈——数据线上没有多余的时隙去承载更多通道。
TDM(Time Division Multiplexing)从本质上解决了这个问题。它的帧同步信号每个采样周期只产生一个脉冲,而在这一帧之内,数据线上按照固定时钟周期划分出多个时隙,每个时隙对应一个通道。只要位时钟跑得够快、时隙分得够细,一根线就能塞下8路、16路甚至32路音频。对于音频矩阵、多通道录音前端、数字音频处理器一类的设备,TDM几乎是标准答案。
我最初也犹豫过,是不是直接用几颗I2S编解码器并行接入MCU,通过软件去轮询读取。但仔细算了一下引脚占用和中断负担,还是要用TDM。4路I2S解码器需要至少4根数据线,还要为每颗芯片独立配置MCLK,BOM成本高不说,MCU引脚也吃紧。换成TDM后,一根SD线就搞定所有通道数据,只额外多花点时间在配置和时序调试上,从工程角度看划算得多。
1.2 为什么选STM32 SAI而不是普通I2S外设
ST在STM32F405/407这一代开始引入SAI(Serial Audio Interface),到了F7、H7系列上,SAI外设的数量和功能都有增强。SAI接口的特别之处在于它不是一个简单的I2S控制器,而是一个支持多协议的可配置串行音频接口。它既能工作在I2S模式,也能工作在TDM模式,甚至能模拟SPDIF的部分行为。
普通I2S外设的问题在于其帧结构是写死的。比如一个常规I2S模块,帧同步信号的高低电平状态,直接对应左右声道,数据位长固定,你很难把它扩展成16个时隙。SAI则不同,它提供了高度的可编程性,包括时隙数量、时隙位长、帧同步的有效电平、数据线起始偏移等都可以独立设置。这意味着你可以把一根数据线组织成16个32bit的时隙,对应16路单声道音频。
同时,SAI配套的FIFO深度和DMA接口也比I2S外设宽裕不少。在高通道数、高采样率场景下,能够稳定搬运数据是第一位的要求。SAI的FIFO在溢出和下溢时会给出明确状态标志,配合DMA循环模式,可以把CPU干预降到很低。我最后选择STM32F746作为主控,就是因为它的SAI外设能力足够支撑16通道TDM流,同时有足够的DSP算力去处理混音算法。
1.3 SAI、I2S与TDM的概念对照
把几个概念放在一张表里比较,能直观看出它们的定位差别:
| 协议/接口 | 帧内通道数 | 典型应用 | 数据线数量 | 时隙可编程性 |
|---|---|---|---|---|
| 标准I2S | 2 | 立体声音频解码器 | 1 | 基本不可编程 |
| 左对齐/右对齐PCM | 2 | 部分ADC/DAC | 1 | 有限 |
| TDM | 4/8/16/32 | 多通道音频总线 | 1 | 可按时隙配置 |
| STM32 SAI | 2/4/8/16/32 | 上述全部+自定义 | 1或2 | 高度可编程 |
这里要注意的是,SAI不是一种协议,而是一种硬件接口。同一个SAI块可以配置成I2S收发,也可以切换成TDM模式。实际项目中,我正是利用两个SAI块协同工作:一个SAI块配置成TDM接收,从外部ADC板卡读入多路采样数据;另一个SAI块配置成TDM发送,把混音后的数据送往音频后端。整条链路从协议视角来看,就是“多路I2S气质的信号、TDM的物理帧格式、SAI的硬件载体”。
2. SAI接口的底层机制:从时隙到FIFO
2.1 SAI外设的内部架构简化理解
SAI的架构可以拆成几个关键部分:帧同步生成器、位时钟发生器、数据线控制器、FIFO缓冲以及中断/DMA事件逻辑。帧同步信号决定一帧的起始位置,位时钟决定数据移位的节奏,数据线按帧同步的节拍把每个时隙的数据逐个输出或采样进来,FIFO则负责在CPU/DMA与数据线之间做速率缓冲。
实际调试中,我建议把SAI想象成一个经过高度定制的移位寄存器:位时钟每来一个上升沿,数据线就往FIFO方向移动一个bit,而帧同步信号只是告诉这个移位寄存器“现在开始算一帧”。这种方式理解TDM模式会清晰很多——你不需要为每个通道设计独立的状态机,只要把时隙顺序和位长设置正确,数据流自然会按顺序落位。
SAI还分A块和B块(SAI_A/SAI_B),每个块都有独立的收发路径。两个块可以分别配置成不同协议,也可以配对使用。对于全双工多通道项目,可以利用SAI_A做接收、SAI_B做发送,两个块之间在内部一侧通过DMA分别接驳,这样就能实现真正的同时收发。
2.2 时隙配置的核心寄存器思路
TDM模式下最需要吃透的是时隙相关的寄存器,主要包括SLOTR(Slot Register)和FRCR(Frame Configuration Register)。FRCR控制帧长、帧同步长度和有效电平;SLOTR控制时隙数量、每个时隙的位数以及哪些时隙被使能。
举例来说,如果我要组织16个时隙、每个时隙32bit的一帧数据,帧长就是512bit。帧同步信号长度可以设置为一个位时钟周期,也可以设置为整个帧的宽度,这取决于后级芯片的时序要求。数据线偏移量也是一个容易忽略的配置项,它表示数据从帧同步有效沿之后延迟多少个位时钟开始有效,偏移设置错误常常导致整个数据序列整体平移几个时隙。
实操上,我推荐先用逻辑分析仪抓取MCLK、FS、SD这三根线的时序,对照参考手册确认帧同步脉冲位置和有效数据区域。比如设置FRL=511、FSL=1、FSO=1、FSB=0后,示波器上应该能看到每个采样周期开始时FS出现一个位时钟宽度的低脉冲,随后SD线上依次排布16个32bit的数据块。只要这组波形对上,后面通道错位的问题就能少一大半。
2.3 MCLK与采样率的数学关系
TDM模式下的主时钟计算,说白了就是一个除法关系:主时钟频率由采样率、时隙个数和时隙位长共同决定。16通道音频、单声道对应一个时隙、每个时隙32bit,如果采样率是48kHz,那么主时钟MCLK = 48000 × 16 × 32 = 24.576MHz。
选择MCLK时还要看MCU时钟树能否生成对应的频率。STM32F746的SAI时钟源可以来自PLLI2S,也可以直接走系统PLL输出。实践中我先把PLLI2S配置成80MHz左右,再通过SAI分频器逐级分频到目标频率。如果分频系数不是整数,最终MCLK就会偏离目标值,音调轻微变化甚至表现为采样率漂移。此时改PLLI2S的N/P/Q参数比硬调SAI分频器更稳妥,因为后者只能做整数分频。
2.4 DMA与FIFO的配合方式
16通道、24bit位深、48kHz采样率下,每秒的数据量为16 × 24 × 48000 / 8 = 2.304M字节,再加上双缓冲需要的RAM,数据吞吐压力不小,单纯靠中断搬运非常吃力。SAI与DMA配合是必然选择:DMA把接收FIFO的数据按节拍搬到内存缓冲区,发送端则反向搬运。
FIFO在水位达到阈值时触发DMA请求。例如接收侧配置为“FIFO半满时请求”,DMA就会以Burst方式读取一批数据。这里有一个坑:如果突发长度设置得比FIFO深度还大,或者DMA的源地址/目的地址增量方向配反,数据就会在搬运过程中错位或丢包。我最开始就因为DMA外设地址配置成递增,导致数据在内存里反复重叠,最终输出的通道序列完全乱掉。
SAI的FIFO深度因芯片型号而异,F7系列单次FIFO深度一般是8个word(32bit)。结合DMA突发4个word的配置,基本可以保证每个采样周期内FIFO不会溢出,也不会因为读太快而连续下溢。配合双缓冲区(Ping-Pong Buffer),I2S/TDM数据流可以做到无缝切换,过程中不会产生卡顿或重复帧。
3. 初始化配置与数据混音流程
3.1 通过CubeMX布置基础工程
CubeMX对SAI的可视化配置做得很直观。在Pinout视图中选定SAI后,主要设置几个项目:协议选择TDM、Frame length设为512 bit、Slot number设为16、Slot size设为32 bit、Data alignment设为Left。时钟树页面里,把SAI时钟源选为PLLI2S,并在下方直接看到生成的MCLK频率是否符合24.576MHz。
有一点想提醒:CubeMX生成的初始化代码通常是基于HAL库的,HAL对SAI的分频处理比较保守,有时候不会自动帮你把DMA请求打开。我习惯在CubeMX里先把DMA请求勾选上,再将生成的main.c中的MX_SAIx_Init()函数里补充DMA初始化。否则直接使用HAL_SAI_Receive_DMA会报错。
CubeMX配置完成后,记得检查SAI的Slave/Master角色。我的项目里STM32作为I2S总线主设备,给外部ADC提供MCLK和BCLK,因此SAI要配置成Master模式;如果接的是自带时钟输出的DSP,则要反过来配成Slave,否则两边都在输出时钟,总线时序只会一塌糊涂。
3.2 初始化代码的关键片段
使用HAL库时的SAI初始化结构体大致长这样,但要注意,HAL_SAI_InitTypeDef中的结构体对应的是SAI通用配置,而TDM参数需要额外填充Init.SAI_Protocol、Init.DataSize等字段,同时利用HAL_SAI_ConfigSlot函数配置时隙。
SAI_HandleTypeDef hsai_BlockA; SAI_SlotConfigTypeDef SlotConfig; hsai_BlockA.Instance = SAI1_Block_A; hsai_BlockA.Init.AudioMode = SAI_MODEMASTER_TX; hsai_BlockA.Init.Synchro = SAI_SYNCHRONOUS; hsai_BlockA.Init.OutputDrive = SAI_OUTPUTDRIVE_DISABLE; hsai_BlockA.Init.NoDivider = SAI_MASTERDIVIDER_ENABLE; hsai_BlockA.Init.FIFOThreshold = SAI_FIFOTHRESHOLD_1QF; hsai_BlockA.Init.AudioFrequency = 48000; hsai_BlockA.Init.MonoStereoMode = SAI_STEREOMODE; hsai_BlockA.Init.CompandingMode = SAI_NOCOMPANDING; hsai_BlockA.Init.Protocol = SAI_FREE_PROTOCOL; hsai_BlockA.Init.DataSize = SAI_DATASIZE_32BIT; hsai_BlockA.Init.FirstBit = SAI_FIRSTBIT_MSB; hsai_BlockA.Init.ClockStrobing = SAI_CLOCKSTROBING_FALLINGEDGE; HAL_SAI_Init(&hsai_BlockA); SlotConfig.FirstBitOffset = 0; SlotConfig.SlotSize = SAI_SLOTSIZE_32BIT; SlotConfig.SlotNumber = 16; SlotConfig.ActiveSlot = SAI_SLOTACTIVE_0 | SAI_SLOTACTIVE_1 | ... ; // 按需填满16个时隙 SlotConfig.SlotStride = 32; SlotConfig.FrameLength = 512; SlotConfig.FrameActive = SAI_FRAMEACTIVE_LOW; SlotConfig.FrameOffset = 0; HAL_SAI_ConfigSlot(&hsai_BlockA, &SlotConfig);这段代码里有个值得强调的地方:FirstBitOffset和SlotStride必须精确匹配时序要求。如果后级DSP规定数据起始位置从FS有效后延迟1个BCLK开始,那么FirstBitOffset就得设为1,否则你的第0通道数据就会出现在隔壁时隙上。这种偏移类的问题用示波器很难立刻发现,但用逻辑分析仪对比协议手册就一目了然。
还有一点,如果使用HAL的DMA接口,发送端的HAL_SAI_Transmit_DMA会要求传入的缓冲区大小能够整除每个周期的数据量。16通道、32bit时隙、48kHz采样率,一个采样周期就是16×4=64字节,双缓冲时每个缓冲区至少要一个周期的数据。初始化时别把buffer size参数填错,否则DMA半满中断可能永远不触发。
3.3 混音算法与数据处理流水线
16通道数据进入MCU后,要先经过预处理,才能进入真正的混音阶段。处理流水线大致是:DMA把TDM帧搬到内存 -> 从每个时隙中解析出24bit或32bit采样值 -> 各路样本乘以通道增益系数 -> 加权叠加成输出样本 -> 限幅或动态范围压缩 -> 打包进TDM发送帧。
我最初以为混音只是在软件里做个加法,结果第一版试听就出现爆音。原因是多路信号直接相加时,峰值很容易超过满幅范围。16路信号如果每路都是0dBFS,叠加后必然溢出。需要在混音总线前加增益衰减,或者用动态范围控制算法做峰值限制。最简单的做法是每路先乘以0.5的增益,把叠加后的理论峰值压到50%,给后续算法留出余量。
数据对齐也是个常见坑。外部ADC输出的是左对齐24bit数据,而TDM总线配置成32bit时隙时,有效数据往往位于32bit的高24位或低24位,具体取决于协议。我采用的做法是在解析阶段统一提取有效位,换算成带符号的32bit整形,在软件内部以统一格式处理,最后送出发送端时再按照目标格式重新打包。这样避免了不同芯片数据格式差异导致的通道音调异常。
3.4 混音输出的饱和处理
混音加法之后,数据大概率会超过int16_t或int32_t的表示范围。此时不能简单地做截断处理,否则会产生刺耳的削波失真。可以采用软限幅曲线,或者更简单地在输出前做固定比例的衰减,并用钳位函数确保最终值落在合法范围内。
我踩过最难受的坑是:通道数大于8路后,即使所有输入信号都处于正常音量,混音输出还是会有间歇性爆音。最后定位到原因是中间计算用了int16_t累加,一旦多路同相峰值叠加就直接翻滚成正负反转。后来全部改成int32_t累加,并在混音总线上预留6dB的headroom,问题才彻底解决。对于音频处理,中间的位宽宁可多留,不要抠门,等最后输出到DAC或I2S时再截断到目标位深。
4. 踩坑记录与排查技巧实录
4.1 时隙错位和数据歪斜
TDM最折磨人的一类问题就是时隙错位。表现是通道A的数据跑到通道B上面,或者整体延迟一个时隙。排查时除了对照逻辑分析仪时序图,还可以做一个单通道测试:只在第0个时隙灌入幅度固定的正弦波,其余时隙全部写0,收端观察是哪个时隙收到信号。这个方法比盯着几十路实时数据猜测高效得多。
时隙错位最常见的原因是FS帧同步脉冲位置设置不对。TDM协议标准里FS可以在帧起始前一个BCLK产生,也可以在帧起始沿与数据同步有效。STM32的FrameOffset和FirstBitOffset就是为了校正这类差异。遇到错位时,优先调整FrameOffset参数,再观察时隙是否回到预期位置,不要上来就大改SLOTR配置。
另一个容易忽略的因素是SLOTR寄存器中ActiveSlot的设置。如果某路通道对应的Slot没有在ActiveSlot字段中使能,驱动库会在该时隙插入静音数据或者跳过采样,表现就是缺失通道声音或通道顺位对不上。务必检查ActiveSlot的bitmask是否正确覆盖全部16个时隙。
4.2 FIFO溢出与下溢
FIFO溢出发生在接收侧数据进入速度大于DMA搬走速度时;下溢则发生在发送侧DMA“供料”跟不上数据线消耗速度时。两类问题都可能导致音频中出现明显的滴答声或重复帧。排查FIFO问题第一步,是读取SAI的状态寄存器,看看有没有OVR/UDR标志置位。
接收侧FIFO溢出,很多时候是DMA突发配置过短,造成数据在FIFO里积压。可以调大DMA的Burst长度,或者把FIFO的触发阈值从半满改成满时再触发。发送侧下溢更常见,尤其在中断优先级设置不当或者DMA带宽被其他外设抢占时。我倾向把SAI的DMA中断优先级设为中等偏上,同时把系统里对时延敏感的批量任务挪到空闲时间处理。
如果用了双缓冲,还要重点检查DMA传输完成中断里的缓冲切换逻辑是否足够快。我在一个版本里用了printf做过场调试输出,结果每条打印指令都要等串口,音频数据流直接卡出破音。换成无阻塞日志或者关闭日志后,FIFO下溢问题立刻消失了。
4.3 时钟配置不当导致的声音异常
时钟问题通常有两个方向:一是频率不对,二是相位抖动。频率不对时声音会整体跑调,比如采样率设置成44.1kHz但实际PLL输出是48kHz;相位抖动则表现为底噪增加,严重时甚至出现爆音。我建议调试初期先用示波器测量MCLK和BCLK的实际频率,和理论值比对,误差控制在万分之几级别才算正常。
PLLI2S的配置需要仔细计算。比如系统主频为216MHz,PLLI2S的VCO输入通常在1MHz到2MHz之间,N值决定倍频系数,P/Q/R为分频输出。算的时候可以把所有分频链路倒推一遍,从最终需要的24.576MHz反推出中间分频系数。一个比较稳妥的方法是借助CubeMX的时钟窗口自动计算,它会给出可用的组合,手动调参容易在和具体板子匹配时浪费时间。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 通道顺序错乱 | FS偏移/FirstBitOffset设置错误 | 单通道灌测试信号,观察时隙落点 |
| 有数据但声音发闷 | 数据位对齐错误,有效位不在预定位序 | 用逻辑分析仪对比数据和帧同步 |
| 周期性爆音 | FIFO溢出/下溢 | 查OVR/UDR标志,调DMA突发或优先级 |
| 整体音调偏高/偏低 | MCLK或BCLK频率不准 | 示波器测频率,核对PLL分频系数 |
| 只有左声道有声 | 时隙配置为立体声模式 | 检查MonoStereoMode和SlotNumber设置 |
| 混音输出削波严重 | 多路信号叠加超过位宽 | 增加headroom,前置增益衰减 |
| DMA不工作 | 缓冲长度不符合要求或DMA方向错误 | 查DMA配置寄存器,核对源/目的地址方向 |
| 噪声明显 | 时钟抖动或地线干扰 | 改善PCB布局,检查电源去耦 |
4.5 调试工具和方法
音频类项目调试,逻辑分析仪远比示波器好用。示波器适合看电压波形和时序关系,逻辑分析仪适合看协议电平,采样率做到100MHz以上就能轻松解出TDM帧结构。抓取时注意同时采集MCLK、FS、SD三根线,设置合适的触发条件,例如FS上升沿或下降沿触发,解码后直接看每个时隙的数据是否符合预期。
软件侧可以做一个统计调试模块:每收到一帧TDM数据,记录当前时隙的绝对计数值,周期性通过串口上报。这样即使不接逻辑分析仪,也能大致判断通道映射是否正确。我在项目后期就靠这个功能快速定位了一个外部ADC上电顺序问题——某一路芯片晚启动了几毫秒,导致头几帧数据里该通道为静音,而后续数据正常,表现为间歇性断声。
5. 实测效果与性能优化经验
5.1 回环测试与链路验证
在我的F746开发板上,整条链路最后的回环测试结果基本达到预期。SAI_A以TDM主模式接收外部模拟前端板卡发送的16通道、24bit、48kHz采样数据,DMA双缓冲搬运到内存,经过增益调整与混音算法后,由SAI_B以TDM主模式输出到后级音频处理板。用同一颗MCU内部把接收数据直接转发的直通模式,通道错位和爆音问题在调整完槽位配置后消失,连续运行数小时没有出现FIFO异常标志。
16通道混音的CPU占用率在STM32F746上实测大约为30%到40%,主要开销在逐时隙数据解析、混音累加和饱和钳位处理。如果只做直通转发,不做任何算法处理,CPU占用率低于5%,这也可以说明DMA和SAI硬件分担了绝大部分数据传输压力。对于追求更高通道数或者更复杂算法的场景,可以考虑把混音算法放到Cortex-M7核心跑,或者干脆把原始TDM流给外部DSP去处理。
5.2 芯片选型与成本优化思路
STM32F4系列里带SAI的型号也能完成TDM收发,但SAI的FIFO深度和DMA能力略弱于F7/H7系列。如果通道数在8路以内,F4是比较经济的选择;16路以上且要做混音算法,建议直接上F746或H743,主频更高、SRAM容量更大,算法施展空间更充裕。
如果想降低成本,还可以把“外部ADC板卡”和“后级DSP板卡”合并到一块PCB板上,仅保留一组SAI链路作为扩展音频数据的入口。这样MCU和音频编解码器之间的距离大大缩短,时钟抗干扰能力也更强。这种合并方式还能减少I2S/TDM总线上的连接器成本和信号质量风险,尤其适合批量生产的多通道音频采集设备。
5.3 通道数不足时的折中方案
如果目标通道数是12路而不是16路,依然可以使用16时隙TDM帧,只是禁用多余的ActiveSlot或者让多余时隙填充静音数据。这样下游设备解析时只需要跳过空时隙,无需修改帧结构。反过来,如果某一时刻商用的TDM编解码器只支持8时隙,可以配置成两个4通道SAI块并行工作,但这样就失去了单线TDM的引脚优势,具体选择还是要结合实际BOM和算法需求。
我自己在做16通道方案时,也保留了切换到8通道两线的软件开关,方便适配不同批次的外部硬件。代码层面把时隙映射关系做成一张配置表,修改通道数时只改SlotNumber、ActiveSlot和数据缓冲区大小,逻辑主体不需要动。这个设计在项目验收时帮了大忙,因为客户要求兼容8通道与16通道两种模拟前端板卡,改动工作量被压缩到了半小时以内。
5.4 长期的工程建议
经过这个项目,我对SAI接口和TDM协议有了更实际的感知。做多通道音频项目时,硬件上务必先确认时钟树能否覆盖目标采样率,时序上用逻辑分析仪验证再写应用代码,软件上坚持用DMA和双缓冲处理数据,混音算法留出headroom,并为不同通道数预留配置余地。这些听着像常识,但在工期紧张、问题叠加的时候,常常容易被忽略。
如果你也打算基于STM32做多通道音频处理,建议第一步不要直接碰16通道,先用2通道I2S或者4通道TDM把SAI的收发链路跑通,再用测试信号验证时隙映射,最后再切换成16通道。这样每一层不确定性都被单独隔离,出问题时排查范围会小很多。多通道音频项目难度不在“把堆叠信号传出去”,而在“让每一路的时隙、位长、时钟都精确落位”,把这个基本功练好,后续不管接DSP还是接编解码器,都会顺很多。