拿到STM32N6的Nucleo板,我最想验证的不是那颗800MHz的Cortex-M55核心,也不是板载的Neural-ART加速器,反而是音频输入这条最“普通”的链路。原因很简单:跑AI、跑算法都是后话,如果一个系统连麦克风数据都收不干净,后面所有处理都是空中楼阁。在这块板上做音频输入,最正统的方案就是SAI + GPDMA:SAI负责把外部Codec或数字麦克风的串行数据变成CPU能读的字节流,GPDMA负责把这些字节流不经过CPU搬到内存,全程零拷贝,实时性还稳。
这篇文章我按实际开发流程来写,从为什么这么选型、硬件时钟怎么搭、GPDMA数据流怎么设计,到TrustZone安全区/非安全区的影响、核心代码实现、最后是调试中踩过的坑。适合正在用Nucleo-N6做音频采集、语音识别前处理、或者想在STM32N6上跑实时音频应用的开发者参考。我会把每个环节的“为什么”一起讲清楚,而不是只给一个能跑的CubeMX配置。
1. 为什么音频输入选SAI + GPDMA,而不是I2S + 传统DMA
1.1 SAI比传统I2S强在哪
很多STM32老型号没有独立的I2S外设,或者I2S只是SPI的附属模式,能力和灵活性都很受限。SAI(Serial Audio Interface)在STM32F7/H7这一代开始普及,到了STM32N6上已经是一个完全独立、双Block(A和B)、支持TDM多通道、支持MCLK输出的完整音频接口。
我见过不少工程师在STM32N6上还是习惯性找“I2S”选项,结果发现CubeMX里只有SAI,就开始犯嘀咕。实际上你把SAI配成I2S标准模式,和传统I2S外设的时序完全一致:SCK是位时钟,FS是帧同步,SD是数据线。区别在于SAI的配置维度更多:
- 支持I2S、LSB/MSB Justified、TDM等帧格式,意味着你对外部Codec的兼容面更宽。
- 一个Block做接收,另一个Block做发送,双向音频可以同时跑,非常适合对讲或双向语音处理。
- 帧长度、数据位长、FS有效电平都可以独立配置,调试灵活性大。
所以选SAI不是因为没有I2S,而是它本身就是I2S的“增强版”,同一个工程里兼容性更好。你把它当I2S用,完全不亏。
1.2 GPDMA比老DMA强在哪
STM32N6上的GPDMA和之前STM32的DMA1/DMA2完全不是一个东西。老DMA最让人头疼的是通道映射固定,比如你想用SPI3的RX,只能绑特定的Channel,一旦被别的外设占了就得改硬件或者改方案。GPDMA把所有请求信号放到一个大的交叉矩阵里,任何通道几乎可以选择任何外设请求,自由度很高。
更关键的是GPDMA支持链表(Linked-List)和触发传输。链表的意义在于:你可以提前准备好多个传输节点,每个节点描述“源地址、目的地址、数据长度、下一个节点地址”,GPDMA执行完一个节点自动跳去执行下一个,全程不需要CPU介入。这对音频多缓冲、分片传输、不连续内存搬运来说是完整的解决方案。
STM32N6的DMA还具备系统级安全属性控制,和芯片的TrustZone机制强绑定。这是老DMA没有的维度,也是很多人在新平台上第一次接触会翻车的地方,后面我会单独展开。
简单说,选SAI + GPDMA不是跟风,是这个平台做音频输入“正确”的答案。SAI负责格式转换和时序生产,GPDMA负责稳定可靠的数据搬运,两者配合把CPU解放出来做更有价值的事。
2. 硬件连接与时钟树的那些坑
2.1 引脚与外部Codec接线
Nucleo-N6板子的具体引脚布局,建议直接打开STM32CubeMX的Pinout视图,选中SAI1的Block A后它会高亮推荐引脚。以典型的I2S外部Codec为例,你需要接四根信号线:
- SAI1_SCK_A:位时钟,由SAI作为主机时输出。
- SAI1_FS_A:帧同步(左右声道时钟),由SAI或外部Codec提供。
- SAI1_SD_A:串行数据输入,注意方向上是从Codec进MCU。
- SAI1_MCLK_A:主时钟输出,很多Codec的PLL需要它作为参考时钟,不接可能导致Codec无法正常工作。
Nucleo板的Arduino接口通常会把部分SAI信号引出来,但具体位置不同板子不一样,建议以板子用户手册的引脚分配表为准。我自己习惯把SCK、FS、SD三根线先接好,MCLK单独飞线,因为MCLK特别容易受干扰,走得越短越好。
如果使用PDM数字麦克风,要注意确认当前STM32N6的具体型号SAI是否支持硬件PDM抽取滤波。有些STM32系列SAI自带PDM抽取器,可以直接接PDM麦克风并把位流转成PCM数据;如果硬件不支持,就得在外部加转换芯片,或者用IO口模拟采样。这个一定要在选型阶段确认清楚,不要默认所有型号都支持。
2.2 bit clock与MCLK的计算
SAI音频最核心的计算就是位时钟和主时钟分频。以最常见的48kHz采样率、双声道、16bit数据为例:
- 位时钟BCLK = 采样率 × 帧长度 × 通道数。
- I2S标准下每声道32个bit slot,双声道就是64 bit slot一帧。
- BCLK = 48000 × 64 = 3.072MHz。
MCLK一般取BCLK的整数倍,常见是256倍于采样率,也就是48000 × 256 = 12.288MHz。外部Codec拿到12.288MHz的MCLK后,自己内部的PLL就能合成出所有需要的时钟,这是一个非常标准的配置。
SAI的时钟源通常挂在RCC的PLL2或PLL3上,你需要先在CubeMX里把PLL2的VCO配出来,再通过RCC分频得到SAI kernel clock,最后SAI内部再分频得到MCLK和BCLK。这个链条上任何一级配错,最终的音频就可能是变速或者无声。
我的建议是不要在CubeMX里让系统自动生成一个看起来合理的时钟树,而是手动验算一遍:从PLL VCO到SAI kernel clock到MCLK到BCLK,每一步是不是目标频率的整数倍关系。音频设备对时钟稳定性的要求极高,抖动大、频率不对就会有杂音。
2.3 时钟树配置里的隐性问题
一个比较容易忽略的点是SAI的kernel clock和外部Codec的MCLK输入不是一回事。前者是MCU内部SAI模块工作时用的时钟,后者是SAI输出给Codec的参考时钟。如果SAI的kernel clock和MCLK配置成同一个源头,且频率算得不干净(比如非整数倍关系),会导致位时钟和MCLK不同步,Codec内部PLL锁定困难。
实际操作中建议:
- kernel clock选一个足够高的整数倍频率,比如给SAI的kernel clock提供49.152MHz(256 × 192kHz,或者256 × 48kHz的2倍)。
- MCLK输出由SAI内部再从kernel clock分频得到12.288MHz。
- BCLK再从MCLK分频得到3.072MHz。
这个“从高频到低频逐级分频”的思路,比“给SAI一个刚好3.072MHz的kernel clock”要稳健得多。因为分频链每一级都是整数分频,误差为0,而外部PLL锁频也有更充裕的调整空间。
3. GPDMA数据流设计与双缓冲方案
3.1 音频数据的搬运需求分析
48kHz采样率、双声道、16bit,每秒产生的数据量是:
48000 × 2 × 2 = 192KB/s
这个数据量对MCU来说不算大,但难在实时性。如果让CPU在SAI中断里逐样本读取,800MHz的M55虽然算力强,但中断频繁会导致两个问题:一是CPU被拖住,AI推理、算法处理等主要任务没法稳定执行;二是中断响应的不确定性会导致音频样本偶尔丢失,表现为“沙沙”爆音。
所以正确方案是:SAI接收完成一帧数据后,由GPDMA负责搬运到内存缓冲区,每积累到一定长度才产生一次DMA传输完成中断,CPU在中断里直接拿到一块连续的PCM数据块,做后续处理。这个模式下CPU每处理一帧数据只需进入中断一次,而且数据已经在内存里对齐好,算法可以直接访问。
3.2 双缓冲和循环模式的取舍
如果只用一个缓冲区,DMA写满后触发中断,CPU开始处理,但此时DMA必须停下来等待CPU处理完才能继续接收,这中间会造成数据丢失。解决方式就是双缓冲,也叫Ping-Pong Buffer:
- 缓冲区A:DMA当前正在写入。
- 缓冲区B:CPU正在处理的数据。
DMA写满A后跳到B,同时产生A完成中断;CPU在中断里处理A的数据,而这个时候DMA正在写B,互不干扰。处理完A,下次DMA写满B再中断,CPU处理B,如此循环。
GPDMA实现双缓冲有两种常见方式:
- 使用循环模式(Circular Mode),DMA自动在描述符列表里循环执行两个节点。
- 使用链表模式,手动管理两个节点的跳转。
我实际测试下来,循环模式代码最简单,HAL库就支持HAL_SAI_RxHalfCpltCallback和HAL_SAI_RxCpltCallback两个回调,分别对应半缓冲和全缓冲完成。只要把buffer size设置成整个缓冲区的一半,比如总共1024字节,size写512,HalfCplt就是缓冲区前半段完成,RxCplt就是后半段完成,天然就是双缓冲。
如果你想更精细地控制每次搬运的起始地址和数据长度,比如做变速变调、动态调整缓冲区大小,那就用链表模式。链表模式在GPDMA上更灵活,因为每个节点都可以完全自定义,但调试复杂度也高一些,新手建议先从循环模式跑通。
3.3 linked-list描述符的实际用法
GPDMA的链表描述符存放在普通SRAM里,DMA硬件会读取描述符来了解下一个节点在什么地方、搬运什么数据。描述符本身也是数据,如果描述符所在内存被错误配置为安全属性,非安全DMA通道就无法访问,这是TrustZone场景下常见的坑。
描述符节点的关键字段包括:
- 源地址:外设数据寄存器地址,比如
SAI1_DR_ADDR。 - 目的地址:内存缓冲区地址。
- 传输长度:一次搬运多少字节。
- 触发配置:在什么条件下开始执行。
- 下一节点地址:执行完当前节点后跳到哪个描述符。
我建议在链表模式里至少配置两个描述符节点,分别指向BufferA和BufferB,然后在传输管理中动态更新两个节点的目的地址。这样双缓冲的“切换”完全由DMA硬件完成,CPU只需要在处理完一帧数据后更新对应描述符的地址字段,不需要重新启动DMA。
有一点值得注意:描述符节点在内存中必须对齐,一般要求地址对齐到32字节,这个在定义全局数组时用__attribute__((aligned(32)))或__ALIGNED(32)声明,避免踩到对齐异常。
4. TrustZone安全区/非安全区对SAI和GPDMA的影响
4.1 外设和DMA的安全属性分配
STM32N6的Cortex-M55核支持TrustZone,这也是和很多老STM32平台差异最大的一点。默认情况下,系统上电后所有资源和内存都在安全区,如果你把整个工程编译成Non-secure,但外设的安全属性没有正确分配,就会出现“非安全代码访问安全外设”的Fault,表现往往很诡异,比如DMA死也不触发、SAI寄存器写不进值。
在使用SAI和GPDMA做音频输入时,核心原则是:处理器核在哪个安全状态运行,外设和DMA通道的访问权限就要匹配。最省事的做法是在CubeMX里把SAI和GPDMA都配置为Non-secure外设,并把它们对应的NVIC中断也分配为非安全,这样非安全侧代码可以直接使用,不需要每次调用都做安全状态切换。
具体来说,在CubeMX的Security配置页面里,你会看到外设列表旁边有Secure/Non-secure选项。把SAI1、GPDMA0或GPDMA1(具体编号取决于你的工程分配)切成Non-secure,然后把相关中断在NVIC配置里的Secure属性也改成Non-secure。这个两步缺一不可,只看外设不看中断、或者只看中断不看外设,都会导致运行时问题。
4.2 内存保护与缓冲区放置
TrustZone的另一个影响维度是内存。音频DMA缓冲区、GPDMA链表描述符、甚至是中断回调里访问的全局变量,它们在内存中都必须满足DMA通道的安全属性要求。如果GPDMA配置为非安全通道,那缓冲区就必须放在非安全内存里,否则DMA访问会直接被SAU/GTZC拦截。
我建议的分配方式:
- 音频PCM数据缓冲区:放在非安全SRAM区域,因为它数据量大、被非安全代码频繁访问。
- GPDMA链表描述符:也放非安全SRAM,因为DMA硬件在非安全通道下需要访问它。
- 安全侧与算法相关的关键参数(比如增益系数、滤波器系数):放在安全区,只开放给安全代码访问。
不是说所有东西都要放非安全侧,而是让“能被非安全世界访问的数据”和“音频处理链路上的数据”保持安全属性一致。如果你把一些敏感系数放在安全区,非安全算法代码又不能直接读,那就得通过Secure Callable接口跨区访问,复杂度会上去。
4.3 推荐工程配置
从实际开发效率出发,如果音频应用不是安全关键系统,推荐这种分层:
- 安全侧:做系统上电初始化、时钟配置、GTZC区域设置后,把控制权交给非安全侧。
- 非安全侧:所有应用代码、中间件、音频驱动都跑在这里,SAI和GPDMA也配置为非安全。
- 安全侧只保留安全启动、密钥管理、或者你用ST的Secure Manager想保护的核心算法。
这样做的好处是:你写的音频代码和以前跑Cortex-M4/M7的老工程没有大的心智负担,不涉及跨安全区调用,调试也更方便。安全侧代码越少,出问题排查范围就越小。
5. 核心代码实现与关键环节拆解
5.1 基于CubeMX的初始化骨架
先说明一下,下面的代码基于STM32CubeMX生成的工程框架,版本建议用6.13及以上,对STM32N6有完整支持。创建工程时选择NUCLEO-N6开发板,在Pinout视图中完成以下配置:
- SAI1 Block A:勾选Receive模式,Frame Standard选I2S,Data Size选16bit,Frame Length填32(每个声道一个slot)。
- GPDMA:在DMA Settings里为SAI1_RX添加一个DMA通道,Direction选Peripheral to Memory,Mode选Circular,优先级默认即可。
- Global Interrupt:开启SAI1全局中断。
- RCC:配置PLL2使SAI kernel clock得到49.152MHz。
- Project Manager里勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”,方便分文件管理。
生成代码后,主要需要修改的部分在main.c和stm32n6xx_it.c(或者你命名的中断回调文件)里。
5.2 SAI配置代码逐行说明
CubeMX生成的MX_SAI1_Init()是这个样子的大致逻辑,关键参数说明放在代码下面:
static void MX_SAI1_Init(void) { hsai1.Instance = SAI1_BLOCK_A; hsai1.Init.AudioMode = SAI_MODEMASTER_RX; hsai1.Init.Synchro = SAI_SYNCHRONOUS; hsai1.Init.OutputDrive = SAI_OUTPUTDRIVE_ENABLE; hsai1.Init.NoDivider = SAI_MASTERDIVIDER_ENABLE; hsai1.Init.FIFOThreshold = SAI_FIFOTHRESHOLD_HF; hsai1.Init.AudioFrequency = 48000; hsai1.Init.MonoStereoMode = SAI_STEREOMODE; hsai1.Init.CompandingMode = SAI_NOCOMPANDING; hsai1.Init.TriState = SAI_TRISTATEMANAGEMENT; hsai1.Init.Protocol = SAI_FREE_PROTOCOL; hsai1.Init.DataSize = SAI_DATASIZE_16BIT; hsai1.Init.FrameLength = 32; hsai1.Init.ActiveFrameLength = 16; hsai1.Init.Mckdiv = 8; hsai1.Init.Mckhsdiv = 1; hsai1.Init.Mckovr = 16; hsai1.Init.SynchroExt = SAI_SYNCEXT_DISABLE; hsai1.Init.SlotActive = SAI_SLOTACTIVE_0 | SAI_SLOTACTIVE_1; hsai1.Init.MonoStereoMode = SAI_STEREOMODE; if (HAL_SAI_Init(&hsai1) != HAL_OK) { Error_Handler(); } }几个关键点:
AudioFrequency = 48000:这是SAI计算分频的参考采样率,必须和实际麦克风Codec的采样率一致。FrameLength = 32:I2S标准一帧内包含左声道16bit + 右声道16bit,如果数据位长是24bit,这里通常要配成64(左右各32个bit slot)。ActiveFrameLength = 16:FS低电平有效的位长,I2S标准下一般等于一个声道的有效位长。Mckdiv、Mckhsdiv、Mckovr:这几个分频值的组合要配合你配置的SAI kernel clock,最终算出MCLK和BCLK。建议对照数据手册里的分频公式验算一遍,不要只靠CubeMX的自动计算。
5.3 GPDMA配置与中断处理
GPDMA的初始化在CubeMX生成的MX_DMA_Init()中,核心是调用HAL的函数把DMA通道和SAI关联起来。启动接收的代码通常长这样:
/* SAI1_RX DMA缓冲,双缓冲:buffer 0 和 buffer 1 交替使用 */ #define AUDIO_BUFFER_SIZE 512 static __ALIGNED(32) int16_t audio_buffer_rx[2][AUDIO_BUFFER_SIZE]; /* 在初始化完成后调用 */ void AudioReceiveStart(void) { HAL_SAI_Receive_DMA(&hsai1, (uint8_t *)audio_buffer_rx, AUDIO_BUFFER_SIZE); }这里有一个HAL库的参数细节:HAL_SAI_Receive_DMA传入的buffer大小是单个缓冲区的样本数,而不是字节数。HAL内部会把整个缓冲区看成“半缓冲 + 全缓冲”,当size传入AUDIO_BUFFER_SIZE时,内部实际分配的数据区是2 * AUDIO_BUFFER_SIZE * 2字节,前一半由半传输完成中断标志,后一半由全传输完成中断标志。
回调函数写法:
void HAL_SAI_RxHalfCpltCallback(SAI_HandleTypeDef *hsai) { if (hsai->Instance == SAI1_BLOCK_A) { /* DMA已经填满 audio_buffer_rx[0],可以处理这一半数据 */ ProcessAudioFrame(0, AUDIO_BUFFER_SIZE); } } void HAL_SAI_RxCpltCallback(SAI_HandleTypeDef *hsai) { if (hsai->Instance == SAI1_BLOCK_A) { /* DMA已经填满 audio_buffer_rx[1],可以处理这一半数据 */ ProcessAudioFrame(1, AUDIO_BUFFER_SIZE); } }回调函数在中断上下文执行,原则是越快越好,不要在回调里做耗时的浮点计算、日志打印或者发送网络数据。我一般是在回调里设置一个标志位,或者把buffer索引推入一个无锁环形队列,回到主循环后再做处理,这样既保证音频不丢数据,又不阻塞中断。
GPDMA如果使用链表模式,初始化代码会比这个复杂,核心是配置多个描述符节点并启停DMA。如果你用的是CubeMX生成的LL驱动,建议把描述符链的配置单独封装成一个函数,并在工程初始化早期就调用,不要等启动接收时才去配。
5.4 音频数据消费端怎么接
音频数据收到后,你的算法或者输出逻辑怎么接,直接决定了系统架构合不合理。常见做法是:
- 环形队列:DMA中断里把buffer索引压入队列,主循环从队列取出索引。队列本身是FIFO,天然解耦中断和主循环的时序。
- 实时处理:如果算法耗时很短,比如延时估计、音量检测之类,可以直接在回调里调用算法函数,但要注意中断优先级和可重入性。
- 转发输出:如果你需要同时把音频送到SAI的发送Block做回放,需要在发送完成中断里填充下一个缓冲区的数据,同样用双缓冲,不过方向变成Memory to Peripheral。
我强烈建议在任何算法接入之前,先做一个裸数据的loopback测试:SAI RX收到的数据直接写到发送Block,或者通过UART/USB发到PC,用Audacity等软件查看波形。如果波形干净、没有毛刺,再接入你的处理算法,这样能确定问题出在硬件链路还是算法层。
6. 常见问题与排查技巧总结
6.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 完全无声 | SAI未工作在接收模式 | 检查AudioMode是否为SAI_MODEMASTER_RX,确认FS极性 |
| 声音失真严重 | 数据位长和Codec配置不一致 | SAIDataSize设16bit,Codec也要配16bit,不能一边16bit一边24bit |
| 有声音但速度不对 | SAI kernel clock频率不对 | 手动验算MCLK/BCLK分频,确认采样率实际值 |
| 偶发爆音 | 双缓冲未正确配置 | 确认HAL_SAI_Receive_DMA的size是否等于单缓冲长度 |
| DMA中断不触发 | 外设/中断安全属性不匹配 | 检查TrustZone下SAI和GPDMA是否都配为Non-secure |
| 数据全是0或0xFF | SD引脚接错或Codec未上电 | 用示波器量SD引脚,确认实际有数据翻转 |
| 左右声道数据错位 | FS有效电平或帧长配置错误 | I2S标准下ActiveFrameLength设为16,FrameLength设为32 |
| 描述符循环死机 | 链表描述符未对齐或内存安全属性错误 | 检查__ALIGNED(32)声明,确认描述符在Non-secure区 |
6.2 三个我踩得最狠的坑
第一个坑是TrustZone下DMA缓冲区安全属性。第一次调试时SAI能正常配置,GPDMA看起来也启动了,但DMA传输永远不完成,中断一次也不进。排查了很久才发现是我把GPDMA通道配置成了Non-secure,但音频缓冲区默认放在了安全区,DMA访问被GTZC拦了。把buffer显式放入非安全区后立即正常。这个坑在STM32N6上太典型了。
第二个坑是MCLK分频值依靠CubeMX自动计算导致时钟不准。CubeMX在勾选了48000采样率后自动填的Mckdiv有时不是最优解,需要手动调整才能让BCLK和MCLK都落在Codec数据手册推荐的范围。后来我习惯每次生成代码后在MX_SAI1_Init里把分频值重新核对一遍,不盲目信任自动生成的参数。
第三个坑是HalfCplt和Cplt回调搞反。HAL SAI的HalfCplt回调是前半段buffer完成,Cplt回调是整个buffer完成,不是“第一个buffer”和“第二个buffer”的区分。第一次用的时候我按直觉写了两个buffer的处理,结果数据顺序颠倒了,听感上就是明显的回声和乱序。理清楚之后,在测试代码里给每个frame打了时间戳,才真正确认回调顺序。
最后分享一个调试技巧:刚搭好音频链路时,不要急着接复杂算法,先在DMA中断里对收到的样本做一个简单的计数和峰值统计,通过UART打印出来。如果数字稳定、没有跳变,说明链路是通的,再逐步添加功能。音频开发最忌一次引入太多变量,有一个稳定基线比什么都重要。