尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32N6音频输入方案:SAI+GPDMA双缓冲与TrustZone指南

STM32N6音频输入方案:SAI+GPDMA双缓冲与TrustZone指南 拿到STM32N6的Nucleo板我最想验证的不是那颗800MHz的Cortex-M55核心也不是板载的Neural-ART加速器反而是音频输入这条最“普通”的链路。原因很简单跑AI、跑算法都是后话如果一个系统连麦克风数据都收不干净后面所有处理都是空中楼阁。在这块板上做音频输入最正统的方案就是SAI GPDMASAI负责把外部Codec或数字麦克风的串行数据变成CPU能读的字节流GPDMA负责把这些字节流不经过CPU搬到内存全程零拷贝实时性还稳。这篇文章我按实际开发流程来写从为什么这么选型、硬件时钟怎么搭、GPDMA数据流怎么设计到TrustZone安全区/非安全区的影响、核心代码实现、最后是调试中踩过的坑。适合正在用Nucleo-N6做音频采集、语音识别前处理、或者想在STM32N6上跑实时音频应用的开发者参考。我会把每个环节的“为什么”一起讲清楚而不是只给一个能跑的CubeMX配置。1. 为什么音频输入选SAI GPDMA而不是I2S 传统DMA1.1 SAI比传统I2S强在哪很多STM32老型号没有独立的I2S外设或者I2S只是SPI的附属模式能力和灵活性都很受限。SAISerial Audio Interface在STM32F7/H7这一代开始普及到了STM32N6上已经是一个完全独立、双BlockA和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.152MHz256 × 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缓冲区ADMA当前正在写入。缓冲区BCPU正在处理的数据。DMA写满A后跳到B同时产生A完成中断CPU在中断里处理A的数据而这个时候DMA正在写B互不干扰。处理完A下次DMA写满B再中断CPU处理B如此循环。GPDMA实现双缓冲有两种常见方式使用循环模式Circular ModeDMA自动在描述符列表里循环执行两个节点。使用链表模式手动管理两个节点的跳转。我实际测试下来循环模式代码最简单HAL库就支持HAL_SAI_RxHalfCpltCallback和HAL_SAI_RxCpltCallback两个回调分别对应半缓冲和全缓冲完成。只要把buffer size设置成整个缓冲区的一半比如总共1024字节size写512HalfCplt就是缓冲区前半段完成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选I2SData Size选16bitFrame Length填32每个声道一个slot。GPDMA在DMA Settings里为SAI1_RX添加一个DMA通道Direction选Peripheral to MemoryMode选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 32I2S标准一帧内包含左声道16bit 右声道16bit如果数据位长是24bit这里通常要配成64左右各32个bit slot。ActiveFrameLength 16FS低电平有效的位长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设16bitCodec也要配16bit不能一边16bit一边24bit有声音但速度不对SAI kernel clock频率不对手动验算MCLK/BCLK分频确认采样率实际值偶发爆音双缓冲未正确配置确认HAL_SAI_Receive_DMA的size是否等于单缓冲长度DMA中断不触发外设/中断安全属性不匹配检查TrustZone下SAI和GPDMA是否都配为Non-secure数据全是0或0xFFSD引脚接错或Codec未上电用示波器量SD引脚确认实际有数据翻转左右声道数据错位FS有效电平或帧长配置错误I2S标准下ActiveFrameLength设为16FrameLength设为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打印出来。如果数字稳定、没有跳变说明链路是通的再逐步添加功能。音频开发最忌一次引入太多变量有一个稳定基线比什么都重要。
返回列表