ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32 SAI TDM模式实现16通道音频混音:从I2S到TDM的实战指南

STM32 SAI TDM模式实现16通道音频混音:从I2S到TDM的实战指南 最近在做一个多通道音频处理项目需要接收十六路PCM音频流并实时混音。板子用的是STM32最初方案拿I2S搭规划的时候就已经头疼了——I2S一帧固定只有左右两个声道十六路输入意味着至少要接八个I2S外设数据同步、引脚分配、时钟树全部挤在一起。后来把目光转到SAI接口上用TDM模式一帧塞下16个时隙一颗MCU的外设就搞定接收另一块SAI Block负责混音后输出硬件瞬间清爽。这篇把从I2S切到TDM最终跑通16通道混音的完整过程、关键配置和踩过的坑整理一下。如果你正准备用STM32 SAI做多通道音频、麦克风阵列采集或者要和外部音频DSP跑TDM总线这篇应该能帮你省下好几个晚上。1. 需求与方案从I2S到TDM到底是为了解决什么1.1 项目场景和原始需求项目是一个室内多路音频处理装置输入源是8组立体声模拟信号每组对应一路音源最终需要把16个通道按照预设权重混合输出到一对左右声道功放。采样率定在48kHz数据位深16bit算是很常规的音频指标。难点不在算法而在怎么把16路数据稳定、低延时不丢数地送进MCU。最直观的方案是用多路I2S并行接收。标准I2S一帧只有两个时隙也就是左右声道各一个想收16通道就得准备8路I2S接口。STM32上能同时挂8路独立I2S的型号少之又少而且就算外设够用8路独立的位时钟和帧同步也要完全同源否则通道间会引入采样偏移后续混音相位会乱。这个方案从引脚到时钟都很难落地我试到一半就放弃了。1.2 I2S做多通道的硬伤I2S本质是两声道协议。WS信号高电平表示右声道低电平表示左声道Philips标准一帧就两个slot。想扩展多通道可以用软件模拟把多帧拼起来但这样实时性差还得自己处理帧边界也可以用多路I2S芯片分别对接但硬件成本和引脚占用直接翻倍16通道就得8路I2S同时工作MCU引脚根本不够分。更关键的问题是时钟同步。多路I2S如果各自跑独立的BCLK和WS采样时刻会有微小差异长期运行会产生累计漂移混音时会出现类似相位抵消的梳状滤波效应。如果强行把8路I2S的BCLK和WS并到一起那就相当于做了一根共享总线——这已经是TDM的雏形了只是协议不规范调试更痛苦。1.3 SAI接口和TDM为什么能解开这个局SAI是STM32上的串行音频接口每个SAI有两个独立的子块Block A和Block B这两个子块可以分别配置成主机或从机、发送或接收。SAI本身支持I2S、左对齐、右对齐、TDM芯片手册里叫Free protocol自由协议等模式。TDM模式下一个帧同步周期内可以配置1到16个时隙每个时隙承载一个通道的数据。这意味着16通道音频用一个SAI Block加一根SD数据线就能全部接收位时钟和帧同步天然共用不存在通道间漂移问题。数据进到内存后混音运算在DMA搬运的同时完成结构非常干净。最妙的是SAI的A、B两个块可以各自独立工作A块接收16通道TDM输入B块用I2S输出混音后的立体声给DAC整颗MCU就占用了3条音频总线硬件设计大大简化。2. SAI接口和TDM协议关键点到底在哪2.1 TDM和I2S的本质差异很多人第一次接触TDM容易把它理解成加了更多通道的I2S。这个理解方向对但细节上差距很大。I2S的WS是50%占空比的方波高电平对应右声道低电平对应左声道而且数据位比WS跳变沿滞后一个位时钟。TDM的FS帧同步信号则是一个周期的窄脉冲一个高电平或低电平取决于极性配置代表一帧开始然后连续传输多个slot每个slot可以是8、16、24或32位。I2S一帧的数据长度是固定的总是两个时隙TDM的帧长度完全由你配置理论上一个FS周期内可以塞任意数量的slot。STM32 SAI的TDM模式最多支持16个slot正好能覆盖16通道音频的典型需求。而且TDM的FS极性、有效电平、前后延迟都可以配置用来适配不同厂商的ADC、DAC和DSP这个灵活性是I2S不具备的。2.2 时钟和帧同步的参数关系TDM模式下三个关键信号必须理解透位时钟BCLKSAI_CK、帧同步FSSAI_FS、数据线SDSAI_SD。位时钟频率由采样率、slot数量和slot位宽共同决定公式是BCLK 采样率 × 通道数 × 每通道位数以16通道16bit、48kHz为例BCLK 48000 × 16 × 16 12.288 MHz如果你用的外部ADC或DAC要求MCLK主时钟那MCLK一般是BCLK的整数倍或采样率的256倍、512倍。比如MCLK24.576MHz时BCLK12.288MHz比例正好是2:1。这个倍数关系在配置SAI的MCK分频时要用到。帧长FrameLength是另一个容易出错的参数。对于TDM1616bit一个完整帧是256个位时钟周期。FS的有效电平可以只持续1个bit也可以持续多个bit甚至占满整个frame具体要对齐对端芯片的时序图。STM32 SAI支持通过FSDefinition、FSPolarity、FSOffset三个参数组合出各式各样的帧格式这也是调试阶段最花时间的部分。2.3 时隙配置一帧切成16份每一份对应一路通道TDM模式下的slot配置核心是SlotNumber、SlotSize和FirstBitOffset。SlotNumber决定一帧分多少份16通道就填16。SlotSize决定每个时隙的位宽可以直接等于DataSize也可以大于DataSize。很多外部芯片要求slot位宽固定32bit但有效数据只占高16位或低16位这时候SlotSize设32bit、DataSize设16bit配合FirstBitOffset做移位就能正确解析。FirstBitOffset表示第一个有效数据位距离slot起始点的偏移量。如果对端芯片的数据是MSB先出、slot对齐偏移就是0如果数据在slot中间才开始就要填对应的偏移值。我实际调过一颗DSP它输出的数据在slot里偏了2个bit当时SAI收到的数据整体左移了两位声音都是撕裂的查了半天才定位到是这个偏移没配。3. 硬件设计与引脚规划16通道进来2通道出去3.1 STM32选型和SAI资源分配做这个项目选型时要确认芯片带几个SAI外设每个SAI又分几个Block。ST的命名不太统一F4系列的SAI通常在F427以上型号出现F7和H7基本全系标配。我用的型号是STM32H743它有两组SAI每组两个Block也就是最多能支持四个独立的音频收发通道组。16通道TDM输入占用一组Block AI2S立体声输出占用同组的Block B这样还有另一组SAI富余可以做后续扩展或备用调试。引脚规划时注意SAI的SD、CK、FS信号尽量走同一个GPIO端口附近的引脚方便PCB布线。数据量大的时候DMA优先级要高于普通外设我直接把SAI的DMA请求挂在高优先级DMA流上避免长时间占用总线导致SAI FIFO欠载或溢出。电源上如果外部ADC和MCU共用一个MCLK源模拟地和数字地要单点连接不然噪声会通过地环路串进音频信号。3.2 外部ADC的TDM组网16路模拟输入我用了两片8通道ADC型号带TDM输出的都可以比如Cirrus Logic的CS42448这类。两片ADC共享同一个BCLK和FS数据线单独接到MCU的SAI_SD引脚上。实际接法有两片ADC的SD线并联到一起或者分时挂在总线上前提是两片芯片的空闲slot要能配置成高阻态。如果芯片不支持高阻输出也可以用两路SD引脚分别接两个Block或者用GPIO加简单逻辑门做时分选通。MCU作为主机时SAI_CK和SAI_FS由STM32输出两片ADC都作为从机接收时钟这时候帧同步天然对齐。MCU作为从机时外部DSP提供时钟STM32作为TDM slave接收数据这时要注意SAI的同步输入引脚配置否则FS可能一直识别不到。我的做法是MCU做主机把SAI的MCLK输出给ADC做内部时钟源保证所有通道严格同步采样。3.3 DMA和中断的设计思路16通道、48kHz、16bit的数据率是16×48000×2字节1.536MB/s这个带宽对STM32来说不算高但DMA配置不当照样会丢数据。SAI每个Block都有自己的DMA请求接收方向用一个DMA流发送方向用另一个。DMA buffer我用双缓冲一块在填数据时另一块在做混音运算DMA传输完成中断里只交换指针不在中断里做耗时运算。中断频率要算清楚一个buffer如果装1ms的数据双缓冲下每1ms切换一次中断频率1kHzCPU占用很低。buffer太大延迟高太小中断太频繁我试下来1ms到2ms的缓冲长度比较平衡。对实时混音来说端到端延迟控制在5ms以内体感基本无延迟。4. 初始化配置与代码实现直接能抄的SAI TDM配置4.1 用HAL库把SAI Block A配置成TDM从机接收HAL库把SAI的配置拆成了三组Init音频模式、同步方式、FIFO阈值等、FrameInit帧长、FS极性、FS偏移等、SlotInit时隙数、时隙位宽、有效时隙等。下面这段是我实际跑通的配置16通道接收方向数据16bitslot也是16bitstatic void HAL_SAI_MspInit(SAI_HandleTypeDef *hsai) { if (hsai-Instance SAI1_Block_A) { __HAL_RCC_SAI1_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); __HAL_RCC_GPIOE_CLK_ENABLE(); GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode GPIO_MODE_AF_PP; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_VERY_HIGH; gpio.Alternate GPIO_AF6_SAI1; HAL_GPIO_Init(GPIOE, gpio); hdma_sai1a_rx.Init.Request DMA_REQUEST_SAI1_A; hdma_sai1a_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_sai1a_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_sai1a_rx.Init.MemInc DMA_MINC_ENABLE; hdma_sai1a_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_sai1a_rx.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_sai1a_rx.Init.Mode DMA_CIRCULAR; hdma_sai1a_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_sai1a_rx); __HAL_LINKDMA(hsai, hdmarx, hdma_sai1a_rx); } } static void SAI_TDM16_Init(void) { hsai1a.Instance SAI1_Block_A; hsai1a.Init.AudioMode SAI_MODESLAVE_RX; hsai1a.Init.Synchro SAI_SYNCHRONOUS; hsai1a.Init.OutputDrive SAI_OUTPUTDRIVE_DISABLE; hsai1a.Init.NoDivider SAI_MASTERDIVIDER_DISABLE; hsai1a.Init.FIFOThreshold SAI_FIFOTHRESHOLD_HF; hsai1a.Init.AudioFrequency 48000; hsai1a.Init.MonoStereoMode SAI_STEREOMODE; hsai1a.Init.CompandingMode SAI_NOCOMPANDING; hsai1a.Init.Protocol SAI_FREE_PROTOCOL; hsai1a.FrameInit.FrameLength 256; hsai1a.FrameInit.ActiveFrameLength 1; hsai1a.FrameInit.FSDefinition SAI_FS_CHANNEL_IDENTIFICATION; hsai1a.FrameInit.FSPolarity SAI_FS_ACTIVE_LOW; hsai1a.FrameInit.FSOffset SAI_FS_FIRSTBIT; hsai1a.SlotInit.FirstBitOffset 0; hsai1a.SlotInit.SlotSize SAI_SLOTSIZE_16BITS; hsai1a.SlotInit.SlotNumber 16; hsai1a.SlotInit.SlotActive SAI_SLOTACTIVE_ALL; HAL_SAI_Init(hsai1a); HAL_SAI_Receive_DMA(hsai1a, (uint8_t *)rx_buf, RX_BUF_SIZE); }注意一个细节FIFOThreshold我配成了半满HF。如果外部设备长时间不发数或者时钟不稳FIFO半满阈值配合DMA循环模式能尽早触发搬移。阈值太高容易溢出丢数据太低会增加中断频率一般取半空或四分之一位置比较稳。4.2 Block B初始化成I2S主机发送输出方向我用的是I2S因为后级就是一颗立体声DAC不需要TDM那么多通道。SAI的Block B和Block A可以完全独立配置只要在初始化时把Instance换成SAI1_Block_B即可。Block B配置成主机发送帧长固定64bit左右声道各32bit其中有效16bit数据FIFO阈值选四分之一DMA也挂上循环模式。hsai1b.Instance SAI1_Block_B; hsai1b.Init.AudioMode SAI_MODEMASTER_TX; hsai1b.Init.Synchro SAI_SYNCHRONOUS; hsai1b.Init.OutputDrive SAI_OUTPUTDRIVE_ENABLE; hsai1b.Init.NoDivider SAI_MASTERDIVIDER_ENABLE; hsai1b.Init.FIFOThreshold SAI_FIFOTHRESHOLD_QF; hsai1b.Init.AudioFrequency 48000; /* 帧长64bit左右各32bit */ hsai1b.FrameInit.FrameLength 64; hsai1b.FrameInit.ActiveFrameLength 32; hsai1b.SlotInit.SlotNumber 2;Block A和Block B之间的Synchro我配成了SAI_SYNCHRONOUS意思是B块的时钟和帧同步由A块派生两者天然同源不会出现采样率微差。如果两个Block各自独立跑哪怕都用同一个晶振实际分频结果也可能有微小偏差长期工作会产生相位漂移甚至周期性爆音。4.3 混音核心算法和DMA缓冲管理混音的核心是把DMA收到的16通道数据按通道拆开乘以各自衰减系数后叠加输出左右两个声道。整个过程在DMA空闲回调里完成用双缓冲避免读写冲突。伪代码如下void mix_16_to_2(const int16_t *in, int16_t *out, uint32_t frames) { for (uint32_t i 0; i frames; i) { const int16_t *ch in[(uint32_t)i * 16]; int32_t sum_l 0, sum_r 0; /* 8组立体声分别对应左右 */ for (int g 0; g 8; g) { sum_l (int32_t)ch[g * 2] * CH_GAIN[g]; sum_r (int32_t)ch[g * 2 1] * CH_GAIN[g]; } /* 饱和限制防止int16溢出 */ sum_l (sum_l 32767) ? 32767 : (sum_l -32768 ? -32768 : sum_l); sum_r (sum_r 32767) ? 32767 : (sum_r -32768 ? -32768 : sum_r); out[i * 2] (int16_t)sum_l; out[i * 2 1] (int16_t)sum_r; } }每个通道的增益CH_GAIN建议用定点Q15格式比如0.5对应16384这样乘加运算全部用整数完成不会引入浮点开销。如果16路全开且增益都大于0.5叠加结果很容易超过32767饱和处理必须做否则int16溢出后会跳变到负数听起来就是刺啦刺啦的爆音。缓冲切换用DMA的传输完成中断。我在回调里只把当前可读buffer的指针交给混音函数混音函数处理完后把结果写进发送buffer然后立即开启下一次DMA接收。整个流程是两个DMA循环搬运CPU只做混音运算不参与逐字节的音频搬运实测下来CPU占用很低。5. 踩坑实录我在这条路上撞过的墙5.1 第一坑时隙错位所有通道数据全拧了第一次上电DMA收到的buffer肉眼可见是一堆规律但完全错位的数据。用调试器看第一个通道的数据出现在第5个slot的位置后面所有通道跟着整体平移了4个slot。当时以为是ADC配置问题折腾了半天最后拿逻辑分析仪抓SAI_SD和SAI_FS的时序发现SD线上第一个有效数据位比FS下降沿晚了好几个BCLK周期。问题出在FSOffset上。我的外部ADC手册要求FS边沿之后的第一个位时钟就输出第一位数据但STM32 SAI默认的FSOffset是SAI_FS_FIRSTBIT这个配置在TDM下比较容易踩坑。把它改成SAI_FS_BEFOREFIRSTBIT数据位就对齐了。这个经验是FS是宽脉冲还是窄脉冲、数据是FS之前出还是之后出必须先查对端芯片时序图再回过来配STM32顺序反了就是瞎调。5.2 第二坑混音爆音int16溢出把声音打没了第一次跑通混音输入单路正弦波没问题两路一起输入就开始出现明显的破音四路以上基本没法听。排查思维导图绕了一圈最后定位到是int16数据直接相加后溢出翻转。两个32767相加得到65534截断成int16后变成了-2波形直接从正向峰值跳变到负向产生严重的非线性失真。解决方法是上面代码里的饱和处理加一个clip函数把结果限制在-32768到32767之间。另外混音增益不能所有通道都是1.016路全开时增益要衰减到0.25左右也就是6个bit右移这既防止溢出也预留了headroom。后来我还在输出端加了一个一阶低通滤波把混音后可能产生的高频毛刺滤掉一点听感会干净很多。5.3 第三坑DMA和FIFO宽度不匹配数据乱序最初DMA的外设数据宽度我配成了字节PDATAALIGN_BYTESAI的数据是16bit结果DMA每次从SAI FIFO读半个字却按字节搬到内存所有16bit数据的字节序和通道边界全乱了。这个坑比较隐蔽因为你看到的数据不是全错只是每两个字节位置互相颠倒通道0变成了通道1的低字节拼接高字节播放出来就是完全无法辨认的噪声。正确的做法是DMA外设和内存数据宽度都设为半字HALFWORD和SAI的DataSize对齐。如果DataSize是24bit或32bitDMA宽度就要配成字WORD。记住一个原则DMA搬运宽度必须等于SAI的数据字长不能为了省内存把宽度压小否则FIFO会把数据自动拆分重组结果一定错。5.4 第四坑H7的Cache一致性问题数据看着像过期的项目用的H743默认开启D-CacheDMA把TDM数据从外设搬到内存后CPU读到的可能还是Cache里旧的buffer内容表现就是混音输出一会儿正常、一会儿完全不对中断里清一次Cache又恢复。这个坑在F4系列不会有但H7的Cortex-M7核一定要处理。我的处理是在MPU配置里把DMA的接收buffer区域设置为non-cacheable具体是把整块SRAM划分成两部分音频buffer放在non-cacheable区域。如果不想动MPU也可以在DMA传输完成中断里调用SCB_InvalidateDCache_by_Addr把对应buffer的Cache行置为无效强制CPU从内存重新读取。实际对比下来MPU配置成non-cacheable更省心不用每个中断都用SCB函数刷Cache延迟也更低。5.5 附加坑调试时SAI没有时钟输出SAI作为主机时如果忘了使能OutputDrive或者MCLK分频没配置外部ADC收不到BCLK和FS整条链路就是死的。这个问题不上示波器很难发现因为在MCU内部看DMA和SAI的寄存器状态都正常但外部芯片完全没有工作。排查时先用示波器看SAI_CK和SAI_FS引脚有没有波形没有波形就查OutputDrive、分频器和GPIO复用配置这是最快定位方向。6. 验证方法与问题速查怎么确认16通道真的对齐了6.1 验证手段单通道正弦、逻辑分析仪、听感三步走调试时先不急着上16路混音。第一步只让外部ADC的第一路输入正弦波其他通道接地或静音然后看DMA buffer里16个slot的数据正常应该只有slot0有波形其余全接近0。这一步能确认通道映射。第二步把正弦波依次切换到第2路、第3路每切换一次确认对应slot有数据其他slot无数据这就把16个通道的位置全部验证了一遍。第三步才开混音听声音、看输出波形。如果手头有逻辑分析仪抓BCLK、FS、SD三条线的时序图对照外部芯片手册里的时序参数检查边沿关系比盲调代码高效得多。我用的逻辑分析仪采样率要高于BCLK的四倍以上也就是至少50MS/s否则12.288MHz的BCLK波形会被混叠看出来的时序完全是错的。6.2 16通道TDM常见问题速查表现象大概率原因解决方向数据整体错位几个slotFSOffset或FirstBitOffset不对抓时序对照对端芯片手册配FS边沿位置只有偶数通道有数据SlotMask只使能了部分slot把SlotActive配成SAI_SLOTACTIVE_ALL采样率偏高或偏低FrameLength或音频时钟分频算错用BCLK采样率×通道数×位宽复核声音断断续续有溢出中断DMA优先级太低或FIFO阈值不合适提高DMA优先级FIFO阈值调成HF或QF混音后破音、爆音int16溢出或增益过大加饱和限制总增益衰减到0.25左右H7上数据时好时坏D-Cache一致性问题音频buffer区域设为non-cacheable外部ADC无响应主机时钟没输出或极性不对示波器查SAI_CK/FS有无波形检查OutputDriveI2S输出正常但无TDM接收SAI初始化成了I2S协议Protocol设为SAI_FREE_PROTOCOL6.3 性能余量与后续扩展跑通16通道混音之后我又测了H743在48kHz下的CPU占用纯混音加两路简单滤波不到10%说明这颗芯片在这个负载下余量很大。后续想扩展功能可以在DMA中断里再加实时FFT频谱、自动增益控制、房间均衡这些算法对H7来说都跑得动。如果想继续增加通道数TDM的16slot已经是SAI硬件上限可以再启用第二组SAI的Block A做17到32通道两组数据在内存里做交叉混音总线带宽也完全够。从I2S到TDM表面看是换了种帧格式实际是整个项目从硬件架构到调试思路的转变。I2S简单直观但也限制了通道扩展TDM一上来配置项多、时序要求高但一旦跑通后面的扩展空间非常大。如果你也在做多通道音频建议一开始就按TDM规划硬件别在I2S上走我走过的弯路。
返回列表