
做嵌入式音频有一段时间了这次拿到STM32N657第一反应就是把它当成一个能跑AI算法的音频中枢来用。为什么这么说这颗芯片用的是Cortex-M55核心主频能到800MHz而且还带了NPU处理音频DSP和轻量级模型识别都不吃力。但换个角度看音频数据从I2S进到内存这件事如果还要靠CPU一条条搬那和杀鸡用牛刀没什么区别所以GPDMA1是绕不开的。这篇文章就是我在STM32N657上把GPDMA1和I2S真正打通的过程记录——包括GPDMA1的链表配置思路、I2S协议时序的理解、缓存一致性处理以及几个非常有代表性的坑给同样在做N6系列音频采集、或者想从老DMA迁移到GPDMA1的朋友做个参考。1. 项目背景与整体方案拆解1.1 为什么要用GPDMA1驱动I2S老用户应该比较熟悉STM32之前的产品线DMA1/DMA2、DMAMUX、BDMA相当于三套人马各管一摊触发的请求还要在DMAMUX里做路由。到了STM32N6ST把这些统一成了GPDMA1。GPDMA1不是一个简单的“多通道DMA”它的配置模型完全变了通道配置和请求配置拆开了通道描述的是传输特性请求描述的是外设事件来源支持链表式描述符可以预先串好几段传输任务DMA做完一段自动拿下一段不用CPU中途干预地址寄存器是四维的支持Gather/Scatter可以做源地址跳变、目的地址跳变。这些特性在I2S场景下特别有用。音频流是持续不断的如果每收到一个采样就产生一次中断让CPU搬到内存那系统在低中断延迟上的投入全都白费。用GPDMA1把I2S接收寄存器里的数据自动搬运到内存等缓冲区积累到一定数量再通知CPU才是正确的做法。STM32N657上运行的音频算法本身可能还要靠M55的DSP指令或者NPU来计算如果CPU大量时间浪费在搬数据上实时性根本压不住。因此把I2S和DMA绑定是这类系统设计的前提。我这次实现的是双通道I2S采集输入是I2S/TDM格式的数字麦克风数据输出靠的是独立的I2S/DMA链路CPU只在缓冲区满的时候做短暂处理。1.2 整体数据流设计我这次硬件上用的是STM32N657本身自带的I2S接口在N6系列里I2S功能通常由SPI外设复用或者由SAI接口承担两者在CubeMX里都能看到对应的GPDMA请求ID外接一颗音频codeccodec工作在从机模式MCU是I2S主机提供BCLK和WS。整体的数据流是codec内部ADC采集模拟音频通过TDM格式把多通道数据按槽位发出来MCU的I2S接收逻辑把串行数据转成一个字一个字的并行数据放到外设数据寄存器每次寄存器里有完整数据I2S产生一个GPDMA请求GPDMA1把寄存器里的值搬到SRAM中的接收缓冲区接收缓冲区分成两段交替使用GPDMA1用链表把两段缓冲区串起来一段填满自动切到下一段同时触发传输完成中断CPU在中断里处理已经填满的那段数据。这种“双缓冲链表”的做法本质上就是用硬件事务替代中断驱动的搬移。如果只配置一个普通的单缓冲DMA那每次缓冲满了都要停下来重配地址和长度中间有间隙面对持续I2S时钟时很容易丢采样。而链表可以做到无间隔衔接这一点是GPDMA1相比老DMA最大的优势。2. GPDMA1核心机制与I2S时序要点2.1 GPDMA1的通道与请求模型GPDMA1设计上的核心思想是把“传什么样的数据”和“谁来触发传输”分离开。先看通道配置里面设置的是方向、源地址和目的地址是否自增、数据位宽、突发长度、FIFO门限、传输完成中断使能等。再看请求配置里面设置的是外设请求ID、事件模式。在CubeMX里你选择I2S1_RX作为请求源后自动会拿到对应的请求ID在配置代码里这个ID要准确填进请求配置结构体否则DMA收不到任何触发。通道配置里几个关键参数我建议重点理解第一是方向I2S接收场景是PERIPH_TO_MEMORY源是外设数据寄存器地址不自增目的地址是内存缓冲区地址自增第二是位宽I2S数据寄存器的位宽通常是32位所以源数据位宽通常是Word但音频采样如果是16位内存里可能希望按16位存储这时可以让源是Word、目的是HalfWordGPDMA会自己拆分第三是突发I2S本身是一个采样一个采样来的不会连续来一堆所以突发长度不用配置得很大过大的burst反而要等FIFO凑齐容易引入额外延迟第四是FIFO门限每次要等FIFO到设定门限才启动搬运I2S是单字到达门限越小越及时。如果你的系统里内存开启了缓存还涉及bufferable和缓存一致性问题这个坑我放在第五章专门讲。2.2 I2S协议里BCLK上升沿到底能干什么搞GPDMA1之前建议先看一下I2S协议本身因为DMA最终服务的还是I2S的时序。标准I2SPhilips协议里BCLK是位时钟WS是声道选择/帧同步信号SD是数据线。大家经常问的几个问题主设备读取数据和从设备准备好数据都是在BCLK的上升沿吗答案不是。标准I2S里发送方在BCLK的下降沿更新数据线接收方在BCLK的上升沿采样数据。“准备好数据”通常发生在前一个下降沿“读取数据”发生在上升沿两者是错开的。WS信号也在BCLK下降沿附近变化但不同接收方对WS的采样沿要求不完全一样。多数实现了标准I2S外设的MCU会同时处理好内部边沿你只需要在外设配置里选择“标准I2S/Philips”即可硬件不会让你手动在两沿之间做选择。那“主设备读取和从设备准备”到底是不是上升沿更准确地说主设备读取通常是上升沿从设备准备通常是在下降沿把数据放到线路上。在硬件调试里把示波器钩到BCLK和SD线上看数据切换沿确实和采样沿不同。搞清楚这个有什么用如果你用FPGA或者外部逻辑实现了I2S从机并且发现GPDMA1收到的数据全部错位先别怀疑DMA配置先去看外部设备到底在哪个沿更新数据、哪个沿采样两边只要差了半个周期数据就会整整偏一位。这类问题在codec芯片上不太容易出现因为codec内部和外设都有对齐但在自己做板子或者接第三方数字麦克风时很常见。TDM是I2S的扩展不是简单地把左右声道变成多路数据。TDM里WS变成帧同步脉冲一帧内分固定数量的时隙每个时隙对应一路音频通道数据在指定时隙内发送。对于MCU来说除了BCLK和WS的边沿还需要知道数据有效位是哪个slot开始时隙宽度是几个BCLK。比如8通道TDM每个时隙32bit一帧就是256个BCLK。如果你配置的slot总数和codec实际输出的不一致那么DMA搬进来的数据看起来“没乱”其实通道位置全错这种问题比电气问题更难查。2.3 GPDMA1触发与数据对齐细节I2S外设每收到一个完整的字就产生一个接收事件对应GPDMA1的请求。GPDMA1在每次请求到来后按通道配置搬运数据。搬运的数据量不是无限的而是由链表节点里的传输长度控制。每个节点可以定义自己的地址、长度、下一跳。所以你可以让第一个节点搬运4096字节自动切到第二个节点再搬运4096字节然后在中断里知道两段都完成了。我一直强调节点数要等于缓冲区段数。比如你开双缓冲就得建两个节点每个节点指向一个缓冲区并且两个节点互相首尾相连形成一个环形链表。GPDMA1在完成第二段后重新执行第一段。不能偷懒只建一个节点——那样DMA不会自动循环I2S流会断。数据对齐方面比较容易被忽略的是I2S寄存器的左右对齐和采样格式。I2S可以传输16位、24位、32位宽的数据但硬件寄存器里通常左对齐或者右对齐存放需要根据codec的数据手册确认。像24位数据在32位寄存器里一般左对齐MSB first放置低8位补零。你在配置GPDMA1的源位宽时如果按32位Word读取内存里得到的数据就带了对齐位后续做音频处理时需要右移8位才能得到原始24位采样。如果用SAI还分为FIFO字对齐和slot对齐。反正所有对齐问题建议在DMA搬运阶段就保持原始宽度不要在外设侧做拆字留到DSP处理阶段统一处理逻辑会清晰很多。3. 实操配置步骤与代码实现3.1 硬件连接与引脚配置STM32N657的引脚情况要在CubeMX里先确认。以I2S为例如果采用SPI1复用为I2S则CK引脚负责BCLKWS引脚负责声道选择SD引脚负责数据。使用CubeMX时把SPI1的Mode切换到I2S然后选Master RX或Master TX。如果使用SAI则配置SCK、FS、SD两者都有对应的GPDMA请求。硬件连接上如果你做的是主机BCLK和WS都要接到从机设备的对应引脚SD方向要一致。MCU作为I2S主机输出BCLK和WS作为接收方时SD由codec输出接到MCU的SD引脚作为发送方时MCU的SD接到codec的SDIN。注意有些外设的SD和BCLK共用音频PLL配置时钟时CubeMX会按采样率自动计算MCLK和BCLK分频。如果用的是外部codec尽量让它吃MCU送过来的MCLK/BCLK避免两边时钟源不同导致的采样偏移。布线层面没太多可说的音频信号频率不高只要注意地处理干净、电源纹波别太大就成。真正要多花时间的是时钟树。STM32N657的主频很高但I2S外设需要的是一个能够精确分频得到标准音频频率48kHz、44.1kHz之类的时钟随便从APB直接拿不合适。CubeMX里把I2S时钟源选成PLL设置好目标音频频率它会把分频系数算出来。手动配置时的计算公式是BCLK频率 采样率 × 声道数 × 每个声道的位宽比如双声道、16bit、48kHzBCLK 48000 × 2 × 16 1.536MHzI2S时钟源经外设分频后要能整除出这个BCLK常见问题是44.1kHz和它的倍数88.2k、176.4k需要特定的PLL配置选了个不准的时钟源后实测频率会偏几百Hz。3.2 GPDMA1与I2S的CubeMX初始化建议直接用CubeMX生成底层代码可以省掉很多GPDMA1寄存器细节。CubeMX里外设和DMA的交互是图形化的在外设配置页里添加“GPDMA1 Request”指定方向和内存地址增长方式然后它会自动生成相关的初始化代码。生成后的GPDMA1初始化代码大概是这样的static void MX_GPDMA1_Init(void) { GPDMA_ChannelRequestConfig_TypeDef requestConfig {0}; GPDMA_ChannelConfig_TypeDef channelConfig {0}; /* 请求配置外设请求ID、事件模式 */ requestConfig.Request GPDMA1_REQUEST_I2S1_RX; requestConfig.TransferEventMode GPDMA_TC_EVENT_AT_BLOCK_LEVEL; /* 通道配置方向、地址增长、位宽、FIFO等 */ channelConfig.Direction GPDMA_PERIPH_TO_MEMORY; channelConfig.SrcInc GPDMA_DISABLE; channelConfig.DestInc GPDMA_ENABLE; channelConfig.SrcDataWidth GPDMA_DATA_WIDTH_WORD; channelConfig.DestDataWidth GPDMA_DATA_WIDTH_WORD; channelConfig.BurstLength GPDMA_BURST_LENGTH_1; channelConfig.FifoThreshold GPDMA_FIFO_THRESHOLD_1_WORD; /* 实际字段名以CubeMX生成的HAL版本为准 */ HAL_GPDMA_Init(hdma_i2s1_rx, requestConfig, channelConfig); }CubeMX帮你填好之后requestConfig里的请求ID尽量不要手动改除非你明确知道自己在做什么。它几乎决定了整个DMA通道能不能被外设触发。3.3 手动构建双缓冲链表CubeMX默认生成的是普通的单块传输。对于I2S这种连续流我更推荐改成双缓冲链表这样不用等一个缓冲区完全搬完再重新启动两段之间无缝衔接。具体做法是先用HAL的链表API创建两个节点把两个节点接到同一个传输配置上然后启动。关键点在于节点配置结构体里的地址和长度static uint32_t rx_buffer_a[4096]; static uint32_t rx_buffer_b[4096]; GPDMA_LinkedListNode_TypeDef node_a; GPDMA_LinkedListNode_TypeDef node_b; /* 节点A把I2S接收寄存器搬到缓冲区A */ node_a.TransferConfig.SrcAddress (uint32_t)I2S1_RX_DR; node_a.TransferConfig.DstAddress (uint32_t)rx_buffer_a; node_a.TransferConfig.BlockLength sizeof(rx_buffer_a); node_a.NextNode node_b; /* 节点B搬到缓冲区B */ node_b.TransferConfig.SrcAddress (uint32_t)I2S1_RX_DR; node_b.TransferConfig.DstAddress (uint32_t)rx_buffer_b; node_b.TransferConfig.BlockLength sizeof(rx_buffer_b); node_b.NextNode node_a; /* 环形 */上面这个结构体字段名在具体HAL库版本里可能有差异但思路是明确的。需要注意的是节点结构体要放在一个DMA能够访问到的内存区域而且不能是局部变量否则节点还没执行完栈上的内容可能已经被覆盖了。我习惯把链表节点也定义成全局变量。3.4 启动传输与中断回调配置好链表后启动传输只需要一句类似下面的调用HAL_GPDMA_Start_IT(hdma_i2s1_rx, (uint32_t)I2S1_RX_DR, (uint32_t)rx_buffer_a, 4096, 0);但在链表模式下实际上传输的是链表节点的内容。具体HAL库可能有专门接口比如HAL_GPDMA_LinkedList_Start_IT如果用的是普通HAL接口HAL内部会根据链表配置来搬。中断回调里我们需要判断是哪一段完成了void HAL_GPDMA_TransferCompleteCallback(GPDMA_HandleTypeDef *hdma) { if (hdma-Instance GPDMA1_Channel0) { /* 看当前DMA寄存器里的链表指针判断完成的是A还是B */ /* 标记对应的缓冲区可以处理 */ } }比较稳的做法是分别在节点A和节点B里启用各自的中断当节点A完成时进入回调A节点B完成时进入回调B。如果链表级事件和块级事件都触发了同一个回调再根据状态寄存器判断当前执行到哪个节点。总之回调里只做“标记哪个缓冲区满了”这件事音频处理放到主循环或者高优先级任务里做不要在中断里直接跑算法。3.5 收发同时使用的注意事项如果你的系统还要通过I2S输出音频比如同时播放处理后的声音那么需要再配置一条TX方向的GPDMA1通道。TX侧握手逻辑和RX不同DMA要把内存数据搬到I2S发送寄存器外设会等待数据假如DMA缓冲区没有准备好I2S会出现underrun也就是发送时刻到了但没数据可发这时候BCLK里会出现空洞或者发送静音数据。建议TX侧同样用双缓冲/环形链表并且让“填完RX一段”和“准备TX一段”的时序连起来在回调里做缓冲交换。我在项目里把RX和TX缓冲区做了环形管理每个节点完成回调只做索引步进主循环根据索引判断哪些块数据是新的、哪些块可以复用。刚开始没做索引同步直接在主循环里检查全局flag结果丢失了几次回调时机导致偶尔出现杂音。后来改成回调只置位主循环消费问题就没了。这类经验在音频DMA里非常常见建议新手一开始就养成这种风格。4. 常见问题与排查技巧实录4.1 数据错位和左右声道互换这个问题我一度以为是GPDMA1字节顺序配置错了后来发现是I2S标准选择的问题。标准I2S里WS为低时一般是左声道但有的codec和MCU约定的“高位在前”“低位在前”不同或者使用了左对齐/右对齐格式就会导致左右声道完全反过来。排查方法很简单输入一个已知的单声道正弦波信号然后左右声道分别听或者用逻辑分析仪抓SD线上的数据。如果用逻辑分析仪能看到一个声道一直是固定值另一个是正弦波那么基本可以确认是声道顺序反了。解决办法是把codec的TDM时隙映射调整一下或者在代码里对左右采样做一次交换。对于TDM多通道尤其是8通道以上的数字麦克风阵列通道映射表是必须做的一层不要指望硬件会帮你“自动对齐”。4.2 持续采集时偶发OverrunI2S接收方向最常见的问题是overrun——数据来了但上一笔还没被取走。如果你用的是GPDMA1且配置了足够的缓冲区正常情况下不会出现。但如果你发现运行一段时间后回调里收到错误事件且错误类型是overrun基本能从下面几个方向查缓冲区长度设置太小DMA还没把数据搬走外设寄存器就被新的数据覆盖。尤其当主频高、采样率高、GPDMA1突发配置又不匹配时CPU被低优先级任务抢占导致回调延迟就会overrun。中断优先级低于某个临界值。I2S的DMA传输完成中断不能设得太低否则DMA在等待CPU处理回调时并不会暂停I2S数据照样来。缓存一致性问题导致CPU看到的缓冲区和实际DMA写入的不一致算法把旧数据当成新数据也可能表现为类似overrun的行为这点要注意区分。还有一个隐藏问题如果链表节点之间衔接不上DMA在执行一个节点结束后需要重新加载下一个节点的描述符这个加载过程虽然很快但如果有别的更高优先级DMA通道占用了总线就可能拖慢加载。我建议把I2S相关的GPDMA1通道优先级设置成尽可能高但不要高于对时延极度敏感的内存到内存通道。音频数据有连续性偶尔一次延迟不致命但连续几次延迟就会造成音频断裂。4.3 缓存一致性导致的数据“一半新一半旧”Cortex-M55默认有D-Cache而GPDMA1是AXI总线上的外设它写入内存不会自动去维护Cache。如果CPU侧预先读过那块缓冲区Cache里保存了旧数据DMA传输完成后CPU再去读读到的还是Cache里的旧数据导致音频听起来是重复的片段。解决办法两选一要么把音频缓冲区所在的区域配置成非Cache的RAM段如果芯片支持要么每次DMA传输完成后调用SCB_InvalidateDCache_by_Addr刷新对应缓冲区。我是用后者的因为非Cache化会损失算法访问这段内存的性能而音频处理又非常依赖高速内存。提前invalidate要注意地址和长度的对齐一般按32字节对齐处理避免把别的数据也刷掉。4.4 常见问题速查现象可能原因排查方向全部是杂音/数据完全不对I2S标准或TDM槽位配置错误检查标准、slot映射、时隙宽度只有一个声道有声音数据宽度或WS极性不对核对codec与MCU的左右声道约定运行一段时间后中断停止链表节点地址或下一跳被意外修改检查节点内存是否被栈覆盖偶发爆音缓存一致性问题检查invalidate范围和对齐采样值整体错位若干bitBCLK采样沿不一致检查BCLK极性和外部设备规格这张表是我实际调试中总结的不一定覆盖所有情况但基本覆盖了I2SDMA从零到通的大多数弯路。5. 缓存一致性与性能优化细节5.1 缓冲区设计双缓冲/环形链表我前面提前说了双缓冲这里把细节补完。双缓冲的核心是把“DMA正在写的缓冲区”和“CPU正在读的缓冲区”分开两者不打架。环形链表让DMA可以在两段甚至多段之间自动循环。配置时要确认两点一是每段缓冲区的长度要能被传输宽度整除而且最好按Cache Line对齐。比如Cache Line是32字节那至少每段缓冲区首地址32字节对齐长度也是32字节的整数倍这样invalidate操作不会误伤邻区数据。二是节点数和缓冲区段数一致别建了两个节点却只用一个缓冲区那第二个节点会把数据覆盖到别的地方。从性能角度看缓冲区大小不是越大越好。太长音频算法为了等一个满缓冲区会累积过高延迟太短