做嵌入式音频有一段时间了,这次拿到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里做路由。到了STM32N6,ST把这些统一成了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),外接一颗音频codec,codec工作在从机模式,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、目的是HalfWord,GPDMA会自己拆分;第三是突发,I2S本身是一个采样一个采样来的,不会连续来一堆,所以突发长度不用配置得很大,过大的burst反而要等FIFO凑齐,容易引入额外延迟;第四是FIFO门限,每次要等FIFO到设定门限才启动搬运,I2S是单字到达,门限越小越及时。如果你的系统里内存开启了缓存,还涉及bufferable和缓存一致性问题,这个坑我放在第五章专门讲。
2.2 I2S协议里BCLK上升沿到底能干什么
搞GPDMA1之前,建议先看一下I2S协议本身,因为DMA最终服务的还是I2S的时序。标准I2S(Philips协议)里,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引脚负责BCLK,WS引脚负责声道选择,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、48kHz,BCLK = 48000 × 2 × 16 = 1.536MHz
- I2S时钟源经外设分频后要能整除出这个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 持续采集时偶发Overrun
I2S接收方向最常见的问题是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范围和对齐 |
| 采样值整体错位若干bit | BCLK采样沿不一致 | 检查BCLK极性和外部设备规格 |
这张表是我实际调试中总结的,不一定覆盖所有情况,但基本覆盖了I2S+DMA从零到通的大多数弯路。
5. 缓存一致性与性能优化细节
5.1 缓冲区设计:双缓冲/环形链表
我前面提前说了双缓冲,这里把细节补完。双缓冲的核心,是把“DMA正在写的缓冲区”和“CPU正在读的缓冲区”分开,两者不打架。环形链表让DMA可以在两段甚至多段之间自动循环。配置时要确认两点:一是每段缓冲区的长度要能被传输宽度整除,而且最好按Cache Line对齐。比如Cache Line是32字节,那至少每段缓冲区首地址32字节对齐,长度也是32字节的整数倍,这样invalidate操作不会误伤邻区数据。二是节点数和缓冲区段数一致,别建了两个节点却只用一个缓冲区,那第二个节点会把数据覆盖到别的地方。
从性能角度看,缓冲区大小不是越大越好。太长,音频算法为了等一个满缓冲区会累积过高延迟;太短