ARTICLE DETAIL

资讯详情

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

STM32N6 SAI音频采集与GPDMA链表传输实战指南

STM32N6 SAI音频采集与GPDMA链表传输实战指南 最近一直在折腾STM32N6的Nucleo板子想把它做成一个音频采集前端。原本以为直接调CubeMX生成代码就够了结果从SAI引脚映射到GPDMA中断一路踩坑踩到怀疑人生。这篇文章记录一下我用SAI做音频输入并配合GPDMA搬运数据的完整过程包括硬件理解、CubeMX配置、代码实现和排查经验希望能帮你少走几个弯路。1. 为什么用STM32N6做音频输入从硬件选型说起1.1 STM32N6这颗芯片到底有什么不一样STM32N6算得上是ST最近几年比较有分量的MCU产品核心是一颗800MHz的Arm Cortex-M55内部还带了一个独立的NPU加速器专门跑神经网络推理。很多人拿到这颗芯片第一反应是“哇可以做边缘AI”但真正上手之后会发现所有AI应用的前提都是先把数据喂进来。音频输入就是最典型的前端场景无论是关键词唤醒、环境声音分类还是语音指令识别都需要先把麦克风的PCM数据完完整整搬进内存AI算法才有东西可算。M55内核和之前M4/M7最大的不同是增加了Arm TrustZone和Helium向量扩展但也正因为它主频高、外设多对数据搬运的要求也更高了。如果用CPU中断方式读音频数据在高主频下确实能跑但一旦NPU开始推理CPU调度会出现明显抖动音频数据就可能丢帧。所以我在选型时就明确一点音频输入必须走DMA而且是带足够灵活性的DMA。STM32N6上的DMA已经不是传统意义上的DMA而是通用的GPDMA。它和以往的DMA1/DMA2有了一个质的区别支持链表式描述符。也就是说你不需要在每次传输完成时刷新目标地址而是可以预先配置好一整串传输描述符让DMA自己跳转。这个特性对音频这种需要周期性输入的场合特别有利。1.2 SAI接口和普通I2S有什么区别很多做音频的工程师习惯用I2S确实STM32的I2S外设用起来很顺手配置也不复杂。但STM32N6这款芯片上并没有单独列出传统I2S外设它给你的是一组SAISerial Audio Interface模块。SAI本质上是一个功能更通用的音频串行接口可以配置成I2S、Left Justified、Right Justified、DSP/PCM等模式而且分成多个独立的音频块。在N6上SAI有A、B两个子块每个子块都能独立配置为主机或从机时钟极性也可以分别控制。刚开始我也纠结既然I2S能做的事情SAI都能做那我干脆就用SAI但寄存器配置明显比传统I2S复杂。后来我明白了SAI的灵活度恰好是N6需要的。比如你要接一个数字麦克风阵列或者接一个外部音频编解码器SAI可以很轻松地调整帧长、位深、槽位标准I2S反而做不到。音频输入不是只有I2S一种场景SAI是那种“你一开始觉得繁琐但用到复杂项目时会感谢它”的外设。再说SAI和GPDMA的配合。SAI的接收接口可以产生DMA请求数据从RX FIFO搬运到内存GPDMA则在后台把这些数据按描述符写进缓冲区。两者配合之后CPU基本只管处理满缓冲区的数据中间完全不干预时钟和传输。这是我追求的目标——音频输入链路尽量做到零CPU参与把算力留给后面的NPU推理。1.3 GPDMA带来的传输红利GPDMA全称是General Purpose DMA在STM32N6上替代了老一代DMAMUX和DMA控制器。老DMA模式你至少需要配置外设地址、内存地址、传输宽度、循环模式然后启动。对于音频输入这种反复传输传统做法是开循环模式并配一个一半传输完成中断手动处理前后半缓冲区。而GPDMA引入了描述符链表你可以把每一帧或每一个半缓冲区的传输都定义成节点节点之间通过指针连接起来传输结束后自动跳到下一个节点。在音频输入场景下这个特性最大的好处是你能把“双缓冲”策略做成硬件级别。用传统DMA时即使开了循环模式也改不了缓冲区大小但GPDMA可以自由定义节点A传输1KB节点B传输2KB甚至可以某次传输结束后改变后续传输的源地址。这种灵活性给嵌入式音频留下了非常大的设计空间。不过要注意GPDMA虽然强大但配置难度也上来了。它不再是一个简单的通道号而是一组DMA请求映射、事件标志、链表描述符的组合。如果对它的理解还停留在“设个源地址设个目的地址启动”这个层面很容易在调试时卡在“为什么DMA不动”的问题上。后面我会专门讲我踩过的配置坑。2. 硬件连接与音频通路设计2.1 Nucleo板载音频硬件解析STM32N6的Nucleo板上其实并没有直接焊一个音频编解码器它留出来的是一组兼容Arduino和ST morpho接口的排针音频信号需要自己外接扩展板或者通过杜邦线连接到音频模块。如果你像我一样手头只有一个裸的Nucleo板最简单的做法是接一个I2S接口的数字麦克风比如INMP441或者ICS43434。这类数字麦克风输出直接是PDM或I2S格式不需要额外的CODEC省去很多模拟前端设计。用数字麦克风的好处是抗干扰能力强走线稍长问题也不大。坏处是它供电和时钟要求比较严格MCLK和BCLK都需要MCU输出。我用的是INMP441它的接口只需要LRCK帧同步、BCLK位时钟、ADATA数据三根线板卡上给3.3V供电即可。有些麦克风还需要MCLK主时钟这时候SAI里就必须开启MCLK输出这是一个很容易被忽略的点。接线方面我按照Nucleo板丝印和参考手册找到SAI对应的引脚。不同型号的Nucleo板引脚分布不一样你的板子上可能是PA、PB、PC等不同组合所以一定先查板子手册不要凭经验猜。接好之后先上电用逻辑分析仪或者示波器看一眼BCLK和LRCK是否有波形如果没有波形后半段程序写了也是白写。2.2 SAI引脚映射与音频时钟设计SAI模块每个子块都有独立的时钟源可以从PLL、外部时钟或者内部时钟分频得到。在Nucleo-N6上SAI外设通常挂到某个PLL输出上用CubeMX配置时你会在时钟树里看到一条清晰的链路主PLL-PLL1Q-SAI时钟。音频采样率是必须精确的比如48kHz需要BCLK48kHz1621.536MHz如果是24位深两通道就是48kHz3223.072MHz。如果时钟源分频不准采样率就会出现微小偏差长时间录音就会逐渐漂移甚至产生杂音。我建议在CubeMX里先把SAI时钟需求填进去。假设你的音频配置是48kHz采样率、每个通道16位、双通道立体声那么位时钟BCLK就是48k×16×21.536MHz。如果使用I2S标准格式帧同步LRCK频率就是采样率48kHz。SAI模块的MCLK输出可以设为BCLK的整数倍通常为BCLK×2或×4有些CODEC要求MCLK是采样率×256这些值都需要提前算清楚。SAI主机模式需要MCU输出BCLK和LRCK这种模式适合连接大多数从机麦克风或CODEC。从机模式下BCLK和LRCK由外部设备提供SAI只负责接收数据。我的INMP441可以配置为从模式所以SAI承担主机角色负责产生所有时钟。如果你用的是从机模式时钟树的配置就不那么关键但要注意外部时钟是否满足规范。2.3 双缓冲还是环形缓冲区音频数据流设计在写代码之前一定要先想好数据从SAI FIFO到内存之后怎么让应用程序高效取用。最简单的方式是用单个缓冲区满了就中断通知CPU去处理。但这样CPU等待时间很长音频连续性容易出问题。更常见的做法是双缓冲Ping-Pong Buffer一个缓冲区被DMA写另一个缓冲区供CPU读取交替使用。传统DMA的循环模式能实现双缓冲但要借助半传输中断DMA先写前半段触发半中断再写后半段触发传输完成中断。这样两个中断交替CPU拿到的缓冲区正好是“前半段/后半段”。但这种方式在GPDMA下可以有更优雅的解法利用链表描述符把两个缓冲区连接起来DMA在每次传输完成后自动指向另一个描述符并触发对应的事件标志。这个做法可以省去你在中断里手动切换缓冲区的代码。我在设计音频数据流时最终选择的是一个基于GPDMA的“链表循环四缓冲”。四个缓冲区长度一样DMA依次填充每个缓冲区填完触发一个事件标志。CPU在哪个缓冲区被填完后就去处理对应索引的数据。这种方式比双缓冲多了一些软件复杂度但容错性更好即使一次处理超时后面还有缓冲区兜底不至于丢掉所有数据。3. CubeMX工程配置一步步到生成代码3.1 时钟树配置要点使用STM32CubeMX打开STM32N6的工程时第一步不是配置SAI而是先把时钟树搞定。在Clock Configuration选项卡里选择HSE作为输入源然后把主PLL倍频到目标频率。STM32N6最高可以跑到800MHz但如果只是测试音频输入我建议把CPU频率稍微降到400MHz左右降低功耗和电磁干扰。当然如果你后面要跑NPU还是得拉满音频子系统的时钟需求不大不用担心。SAI时钟源通常选PLL1Q或PLL2P。我推荐使用PLL1Q因为PLL1Q与CPU主PLL同源便于调试。在CubeMX中选中SAI选项后可以看到“Clock Frequency”显示当前SAI时钟频率你需要在下方输入BCLK、LRCK等目标参数。CubeMX会把SOSC、PLL倍频、分频系数自动算好但你要核对最终数值是否精确比如1.536MHz不能偏差超过几十Hz。还要注意MCLK输出。如果外部麦克风需要主时钟你要在CubeMX中把SAI的Master Clock Output设置为“Enable”输出频率按CODEC规格填。INMP441不需要MCLK我把这个选项关掉了省一个引脚。如果你的外部模块需要MCLK一定要确认引脚没有被其他功能占用。3.2 SAI配置为接收模式在Pinout Configuration中找到SAI1或SAI2看Nucleo板实际把哪些引脚连接到了排针。我用的板子SAI的A块映射到了PA、PB等引脚选择对应的引脚功能SAI1_A或者SAI1_B。然后点击SAI模块打开配置界面。这时候你会看到一个很重要的选择Audio Mode必须是“Master Receive”或者“Slave Receive”。我选的是Master Receive因为我们让SAI自己产生时钟并接收数据。Audio Format选择“I2S”标准格式如果你连接的是DSP格式麦克风就选“DSP/PCM”。Frame Length设置为一帧的位长比如16位立体声一帧就是32位。Slot Size也必须设置成16位或32位和数据大小一致。如果Slot Size配成16位实际传输32位数据会错位得一塌糊涂这是我第一次调试时的深刻教训。在SAI配置里Data FIFO Enable要打开Data Size选择“16-bit”或“24-bit”。我记得STM32N6的SAI FIFO支持最多8个字的突发传输配置突发长度可以提升GPDMA效率。FIFO阈值建议设为“1/4”或“1/2”阈值太高可能导致数据堆积太低则频繁触发DMA请求。我最终用的是FIFO阈值1/2。3.3 GPDMA请求映射与配置GPDMA和传统DMA的最大区别在于你需要先确认SAI接收的事件具体对应哪个DMA通道请求。在N6参考手册里有个很长的DMA请求映射表例如SAI1_A接收事件对应DMA请求线44。在CubeMX中打开DMA选项卡添加SAI1_A的接收请求此时CubeMX会自动分配一个GPDMA通道并显示通道号和请求ID。自动生成的代码会包含对应的LL/HAL库调用非常方便。但自动生成只是表面轻松。我在实际使用中需要把GPDMA配置成非循环的链表模式这时候CubeMX自动生成的代码就不够用了得手动修改。你可以先生成初始代码然后把GPDMA的句柄从标准模式改成链表模式。有经验的工程师建议一开始就在CubeMX的外部代码段中准备自己的链表描述符结构体避免以后和生成的代码冲突。在GPDMA配置页面Transfer Data Width设为“Half Word”16位因为音频采样是16位。Source Address Increment设为“No Increment”因为数据来自同一个FIFO寄存器Destination Address Increment设为“Increment”因为内存地址要连续增加。这些设置看似简单但错了就会一直采到同样的数据让你误以为外设没工作。4. 核心代码实现初始化与中断处理4.1 SAI接收初始化代码CubeMX生成初始代码块之后SAI和GPDMA的底层时钟、引脚和句柄都已经初始化好了。你需要在用户文件也可以直接在生成的main.c里添加自己的初始化逻辑。下面是我使用的初始化代码片段去掉了无关部分#include stm32n6xx_hal.h extern SAI_HandleTypeDef hsai1_a; extern DMA_HandleTypeDef hdma_sai1_a; #define AUDIO_BUFFER_SIZE 1024 int16_t audio_rx_buffer[2][AUDIO_BUFFER_SIZE]; volatile uint8_t current_buffer_index 0; void MX_SAI1_AUDIO_Init(void) { // 配置SAI为16位、双通道、I2S主机接收模式 hsai1_a.Init.AudioMode SAI_AUDIOMODE_MASTER_RX; hsai1_a.Init.Synchro SAI_ASYNCHRONOUS; hsai1_a.Init.OutputDrive SAI_OUTPUTDRIVE_DISABLE; hsai1_a.Init.NoDivider SAI_NODIVIDER_DISABLE; hsai1_a.Init.FIFOThreshold SAI_FIFO_THRESHOLD_HF; hsai1_a.Init.ClockStrobing SAI_CLOCKSTROBING_RISINGEDGE; hsai1_a.Init.CompandingMode SAI_NOCOMPANDING; hsai1_a.Init.Protocol SAI_FREE_PROTOCOL; hsai1_a.Init.DataSize SAI_DATASIZE_16BIT; hsai1_a.Init.FirstBit SAI_FIRSTBIT_MSB; hsai1_a.Init.FrameLength 32; hsai1_a.Init.ActiveFrameLength 16; hsai1_a.Init.SlotSize SAI_SLOTSIZE_16BIT; hsai1_a.Init.FrameNumber 2; if (HAL_SAI_Init(hsai1_a) ! HAL_OK) { Error_Handler(); } }注意这里的FrameLength32ActiveFrameLength16FrameNumber2表示一帧共32位其中帧同步有效长度为16位正好对应两个槽位。这些参数必须和你的麦克风时序手册对应不然采样率正确但声音是扭曲的。4.2 GPDMA传输启动与回调SAI初始化完成后启动DMA接收是整个链路的关键。标准HAL库函数是HAL_SAI_Receive_DMA如果你用链表模式也可以直接用底层DMA的链表命令。下面是标准双缓冲启动方式void Audio_Start(void) { HAL_SAI_Receive_DMA(hsai1_a, (uint8_t *)audio_rx_buffer[0], AUDIO_BUFFER_SIZE); HAL_SAI_Receive_DMA(hsai1_a, (uint8_t *)audio_rx_buffer[1], AUDIO_BUFFER_SIZE); }不过这个顺序其实不对同一个SAI外设不能并发调用两个DMA接收正确做法是只启动一次然后在回调中切换缓冲区。用链表模式时你只需要配置两个描述符节点并启动一次传输static DMA_NodeTypeDef node0, node1; void Audio_Start_LinkedList(void) { // 配置节点0 node0.NodeType DMA_NODE1; node0.Init.Request DMA_REQUEST_SAI1_A_RX; node0.Init.SrcInc DMA_SRC_NOINC; node0.Init.DestInc DMA_DEST_INC; node0.Init.SrcWidth DMA_SRC_WIDTH_HALFWORD; node0.Init.DestWidth DMA_DEST_WIDTH_HALFWORD; node0.SrcAddress (uint32_t)hsai1_a.Instance-DR; node0.DestAddress (uint32_t)audio_rx_buffer[0]; node0.DataLength AUDIO_BUFFER_SIZE; node0.LinkedListAddress node1; // 配置节点1 node1.NodeType DMA_NODE1; node1.Init.Request DMA_REQUEST_SAI1_A_RX; node1.Init.SrcInc DMA_SRC_NOINC; node1.Init.DestInc DMA_DEST_INC; node1.Init.SrcWidth DMA_SRC_WIDTH_HALFWORD; node1.Init.DestWidth DMA_DEST_WIDTH_HALFWORD; node1.SrcAddress (uint32_t)hsai1_a.Instance-DR; node1.DestAddress (uint32_t)audio_rx_buffer[1]; node1.DataLength AUDIO_BUFFER_SIZE; node1.LinkedListAddress node0; // 形成循环 HAL_DMA_Start_IT(hdma_sai1_a, (uint32_t)hsai1_a.Instance-DR, (uint32_t)audio_rx_buffer[0], AUDIO_BUFFER_SIZE); }实际代码会比这个复杂因为需要处理GPDMA链表的初始化函数。但关键是理解链路方向一个节点传输结束后GPDMA会自动加载下一个节点的描述符并把DestAddress更新为下一块缓冲区。这比每次在中断里调用接收函数更省时。4.3 音频数据的存取与后续处理DMA跑起来之后音频数据已经源源不断地写入缓冲区了。但你不是任何时候都能访问缓冲区必须等接收完成回调。我在回调中切换索引并设置一个标志主循环轮询这个标志volatile uint8_t audio_ready 0; volatile uint8_t audio_index 0; void HAL_SAI_RxCpltCallback(SAI_HandleTypeDef *hsai) { if (hsai hsai1_a) { audio_ready 1; audio_index current_buffer_index; current_buffer_index ^ 1; } }主循环里处理音频数据时要对audio_ready加访问保护防止刚处理完又被回调修改。我推荐在关中断的环境下读取并清标志避免临界区问题。如果后续想接NPU可以直接把audio_rx_buffer[audio_index]传给神经网络的输入预处理函数比如计算MFCC特征。5. 调试过程与坑点排查5.1 无声/噪声问题定位思路我第一次接上INMP441之后主循环里读到的数据全是很小的随机数完全不像麦克风信号。用示波器一测BCLK和LRCK波形都存在但是数据线没有任何变化。排查下来发现是麦克风没有正确上电复位INMP441的L/R引脚我悬空了它默认输出右声道数据但我配置的是左声道接收。解决方法是把L/R引脚拉低或拉高让输出通道和SAI配置的槽位一致。噪声问题则更多出在信号完整性和共地上。Nucleo板用USB供电时地线阻抗比较高音频模块如果另接电源两地之间有电位差就会引入大量底噪。后来我把麦克风模块和Nucleo共用地线使用同一个3.3V供电噪声显著下降。如果你的噪声是小幅度的高频嘶嘶声多半是电源纹波建议在电源引脚附近加一个10uF电容再去耦。5.2 数据错位帧同步和通道顺序问题另一个让我头疼的问题是数据虽然有了但左右声道的数据完全颠倒了而且每两个采样对调一次。罪魁祸首是SAI的FrameLength和SlotSize配置不一致。我最初把SlotSize配成32位但FrameLength也设成32这样每一帧只有一个slot导致I2S的两根数据线握手错误。后来把SlotSize改成16位FrameLength保持32左右声道数据才恢复正确。还有一个细节I2S协议里LRCK低电平表示左声道高电平表示右声道。但有些CODEC的极性定义恰好相反。如果你确定数据错位可以在SAI配置中把Frame Polarity改成反向。这个参数藏在CubeMX的SAI高级配置里默认是Falling Edge我的麦克风需要Rising Edge调整后再采到的数据就完全正确了。5.3 DMA中断和数据丢失缓冲管理的教训使用GPDMA时最典型的问题是“为什么我只接收到前两个缓冲区后面就再也不进回调了”。这个问题绝大多数情况下是链表描述符没有正确形成循环。如果你在描述符中最后一个节点没有指回第一个节点DMA在搬完最后一个缓冲区后就会停在那里不再传输新数据。我用逻辑分析仪看过确认GPDMA确实停了然后回头检查链表指针原来是初始化节点时地址对齐没做对导致节点结构体被错误覆盖。另一个常见问题是中断优先级。GPDMA中断优先级如果设置得高于系统滴答定时器导致回调里过多延时可能引起数据覆盖或丢失。建议把GPDMA接收中断优先级设置为略低于关键时间中断但高于普通外设。还要注意在回调中不要执行耗时过长的数据处理只做标志切换把真正的音频处理放到主循环中这样能最大程度减少中断关闭时长。6. 进阶基于安全区的缓冲区隔离与AI对接展望6.1 TrustZone下音频缓冲区的安全设计STM32N6支持TrustZone技术可以把系统分成安全区和非安全区。传统MCU上做音频采集时缓冲区直接放在普通RAM里容易被其他非安全代码误访问。如果你希望音频数据只被安全代码处理或者未来要满足一些安全认证需求可以把DMA描述符和音频缓冲区放到安全RAM中并配置GPDMA的访问权限。在CubeMX中你可以把某块RAM属性设置为安全然后在非安全代码中访问时触发异常。这样即使非安全环境被攻破攻击者也拿不到原始的音频流。考虑到N6的NPU主要运行非安全代码而音频采集属于安全侧那么缓冲区隔离就非常关键。需要配置的PRIV/UNPRIV属性以及GTZC全局TrustZone控制器的整块保护。这部分配置相对复杂但值得提前设计而不是等项目跑起来再补。6.2 从原始PCM到AI推理STM32N6的加速潜力如果你把这条音频通路跑通了下一步就可以考虑接上N6内置的NPU做推理了。音频数据不是直接送入CNN一般需要先做预处理比如分帧、加窗、计算MFCC或Mel频谱这些运算可以放在M55内核上用Helium向量指令加速。预处理之后的数据再喂给NPU模型比如关键词分类或者环境声识别。我个人的体会是STM32N6的音频输入通路设计要考虑整个系统架构。SAI加GPDMA只是第一步真正释放N6算力的是安全区之外还有大量带宽给NPU使用。所以一开始不要只盯着“把数据采进来”还要提前规划好缓冲区所有权、数据传输路径以及后续模型输入的接口。音频信号到达NPU之前链路中任何一处阻塞都可能成为瓶颈。GPDMA的链表模式可以很自然地扩展出多级流水比如“采集到缓冲区A - 预处理到缓冲区A - NPU读取”这样的结构这件事值得在后续项目中好好利用。如果后面有机会我打算把采集到的音频直接接上NPU做关键词识别。那又是一片新坑但至少SAI和GPDMA这条路我已经探明白了。希望这篇记录也能给你一点参考。
返回列表