简介:面向FPGA开发者的AXI DMA接口工程设计资源,聚焦Xilinx Vivado 2017环境下AX7015平台的Scatter-Gather(SG)模式与FIFO缓存设计。工程共包含1174个文件,压缩包约48.73MB,其中Vivado工程文件(xpr/bd)用于整体项目重建,HDL源码(v/vhd/vhdl)提供模块级实现,C驱动(c/h)便于软件控制,XDC约束文件定义引脚与时序,Tcl脚本可自动化建工程,Do文件用于仿真验证,DCP/bit等为综合与编译产物,类型覆盖开发全流程。目前已有1262人学习下载。资源的关键价值在于给出了SG模式如何组织非连续内存描述符并启动批量传输,以及FIFO如何在不同时钟域之间平滑缓冲数据的完整范例。开发者可据此理解AXI DMA的控制通路与数据通路,学习中断处理、地址描述符链表和缓存一致性设计,并直接加载工程进行仿真、综合与上板调试。通过该工程,可缩短开发周期,降低调试门槛。
1. DMA_SG_FIFO 项目全景拆解:一场关于数据搬运的效率革命
干嵌入式这行越久,越觉得数据搬运是门大学问。我拿到这个DMA_SG_FIFO.zip工程文件时,第一反应就是:这三个缩写凑在一起,基本宣告了你要处理的是大批量、不规则、高性能的数据传输场景。DMA(直接内存访问)、SG(分散/聚集)、FIFO(先入先出队列),单个拎出来都不陌生,但组合在一起,解决的就不是“能不能跑”的问题,而是“怎么跑得又快又稳还不丢数据”的问题。
先说清楚这套组合到底解决什么痛点。传统的DMA传输,你得给它一个源地址、一个目的地址、一个传输长度,然后它就吭哧吭哧地搬。如果数据在内存里是东一块西一块的,对不起,你得先把数据整理到一块连续内存里才能启动DMA。SG模式就是为了干掉这个“整理”动作——它允许你维护一张描述符表,每个描述符指向一块不连续的内存区域,DMA控制器自己会顺着这张表把数据搬完。而FIFO则是给数据流加了个缓冲层,把生产和消费解耦开,异步FIFO还能跨时钟域。这三样东西叠在一起,典型应用就是UFS存储控制器、以太网MAC、高速USB、串口不定长收发这类场景。
这篇文章适合谁看?如果你正在做单片机上的大数据量通信,或者被串口DMA接收不定长数据折磨过,又或者想搞明白ADC多通道采集为什么用DMA还是卡顿,那这篇就是写给你的。我不会堆一堆晦涩的协议术语,而是从一个顺手能跑的工程出发,把DMA_SG_FIFO这套方案怎么设计、怎么调通、踩过哪些坑,全部摊开讲清楚。
2. 整体设计思路:为什么非要用 SG 和 FIFO 组合
2.1 DMA 单次搬运的局限
先看最基础的DMA。任何一个支持DMA的MCU,比如STM32,它的DMA控制器都有几个关键寄存器:源地址、目的地址、传输数据量。你配置好这三个,然后启动,DMA就一次性把数据搬完,搬完触发中断。听起来挺完美,但实际用起来处处受限。
最难受的一点是内存连续性要求。我举个例子,你用ADC四通道规则采样,每个通道采样100次,采样结果放在一个二维数组里。如果你用DMA直接把ADC的数据寄存器映射过去,DMA只能按顺序往一块连续内存里填。假如你要把通道1和通道3的数据分别放到不同缓冲区,单靠DMA一个通道是做不到的,要么开多个DMA通道配合定时器触发,要么搬完再软件处理。第二种情况是网络或者USART这种数据包频繁到达的场景,协议栈上层需要把数据放进不同的预留buffer里,如果每次来一包数据都要申请一块大连续内存,内存碎片很快就会把系统拖垮。
GPDMA或者带SG功能的DMA控制器就是冲着这个去的。它维护一个链表结构,每个链表节点叫描述符,描述符里写着这一次搬运的源地址、目的地址、传输长度,以及下一个描述符的指针。DMA控制器搬完当前描述符,自动跳转到下一个描述符继续搬,全程不用CPU参与。这就有个非常实用的价值:你可以提前把所有需要的缓冲区挂到SG链表上,DMA按顺序挨个填充,所有缓冲区都填满之后才触发一次中断。
2.2 FIFO 为什么不可或缺
但SG只解决了“搬到哪里”的问题,没解决“数据节奏”的问题。外设产生数据的节奏是忽快忽慢的,比如串口,一帧数据7个字节,中间可能跟着几十毫秒的空闲;又比如ADC,采样率固定,但DMA可能在忙别的描述符跳转。如果数据直接进内存,内存端压力波动一大,就可能丢数据。
这就是FIFO存在的意义。FIFO其实就是一个环形缓冲区,一头写、一头读,各管各的指针。在异步场景下,写时钟和读时钟可以完全无关,写入方只管往FIFO里塞,读取方只管从FIFO里取,只要FIFO不满不空,数据就是安全的。同步FIFO只能解决缓冲问题,异步FIFO才能解决跨时钟域的问题。
回到这个项目,DMA和FIFO是怎么配合的呢?典型的链路是这样的:外设数据 → DMA搬运 → FIFO缓冲 → 上层协议处理。外设侧产生数据的时钟域和CPU总线时钟域经常不一样,DMA负责把数据从外设搬到FIFO,而DMA的SG链表可以把不同的外设数据流映射到同一个FIFO的不同区块,或者不同FIFO。这样,上层软件启动一次SG传输,就能从多个外设句柄拿到完整的数据,而不是一个个寄存器去读。
2.3 方案选型的取舍:全软件 vs 硬件SG
有人在评论区问,能不能用普通DMA+软件遍历数组来模拟SG?可以,但性能差距巨大。软件实现意味着每一次缓冲区块切换都需要CPU干预。假设你有4个缓冲区,每个缓冲区512字节,软件方式需要CPU参与4次调度,每次切换都有上下文开销。SG硬件链表则是一次配置、全部搬运,唯一的开销是构建描述符表。在有OS的环境里,频繁打断还涉及调度延迟,实时性无法保证。所以,只要硬件支持SG,我强烈建议用硬件链表,把CPU解放出来干更值钱的事。
也有朋友问,某些低端MCU不支持SG怎么办?那就退而求其次,用Ping-Pong缓冲(双缓冲)配合中断。Ping-Pong的思路是准备两个Buffer,A满了传B,B满了传A,交替使用,也算一种简化的FIFO思想。但要注意,Ping-Pong本质是半双工的,同一时刻你只能安全访问一个缓冲区,而FIFO可以通过读指针和写指针实现真正的同时读写。对低吞吐量的串口,Ping-Pong够用了;对动不动几十MB/s吞吐的UFS或以太网,老老实实上硬件FIFO。
3. 核心细节解析:场景化实现的关键要素
3.1 描述符表与环形链表的构建
SG的核心是描述符表。以一个典型的UFS DMA控制器为例,描述符一般包含四个关键字段:
- 源地址:DMA从哪读数据
- 目的地址:DMA把数据写到哪
- 传输长度:这一块搬多少字节
- 下一个描述符指针:搬完了去哪
描述符表可以做成静态数组,也可以动态申请。我自己的习惯是在启动DMA之前静态批量构建,因为描述符本身也是内存,如果动态申请反而增加了内存管理的复杂度。比如接收一个大数据包,数据可能会被拆进四个物理上不连续但逻辑上连续的块里,那我就在初始化时构建一个长度为4的环形SG链表,最后一个描述符的next指针指回第一个,形成循环。
构建时有个细节很容易踩坑:地址对齐。很多DMA控制器要求源地址和目的地址至少按32位对齐,有的还要按缓存行对齐。你如果传一个不对齐的地址,轻则性能骤降,重则直接触发总线错误。所以我在代码里会给每个buffer强制对齐到8字节或者16字节边界,用一个宏实现,比如#define ALIGN_TO(x, a) (((x) + (a) - 1) & ~((a) - 1))。
3.2 异步FIFO的边界处理
异步FIFO说实话是一个写起来很容易翻车的东西。如果你只是做MCU内部缓冲,可以用同步FIFO;一旦涉及跨时钟域,就必须上异步FIFO。异步FIFO的经典实现是分布式RAM + 格雷码指针同步,为什么用格雷码?因为格雷码相邻两个值只有1位差异,跨时钟域同步时不会被采集到中间态。我调试过几个FIFO,发现绝大多数bug不是出现在正常读写,而是出现在空满标志的生成逻辑上。
满标志的产生条件是写指针追上读指针,空标志的产生条件是读指针追上写指针。看起来简单,实际要考虑读写指针的不同步延迟。我常用的做法是:写侧判断满时,用同步到写时钟域后的读指针;读侧判断空时,用同步到读时钟域后的写指针。这个同步过程需要打两拍,所以空满标志天然有2-3个时钟周期的反应延迟,设计时要把这个延迟算进FIFO深度里。有些项目的FIFO深度定得刚好,结果在瞬时突发流量下出现了FIFO溢出丢数据,排查下来就是没算同步延迟的账。
FIFO深度怎么定?有个粗糙的经验公式:突发长度除以读写带宽比。比如写侧突发64字节,读侧平均每8个周期读1个字节,那FIFO深度至少得64字节加上同步延迟的缓冲。我一般会再留1.5倍的余量,宁可FIFO多占点RAM,也不能让数据丢在边界上。
3.3 带包边界保护的数据流
热词里看到“带包边界保护的AXI-Stream FIFO模块”,这个点非常实用。串口接收的时候,协议包是怎么切分的?传统做法是每收一个字节就触发中断,判断是不是帧头、帧尾,效率极低。用DMA接收,一个包一个包地收,一个包的特征是什么?是帧头帧尾或者长度字段。如果你只是用DMA裸搬,DMA不会替你判断“这是一包的结束”,它只会傻乎乎地搬完你指定的长度。
所以要做包边界保护。串口这种字节流本身没有长度信息,通常配合空闲中断使用:检测到总线空闲一段时间(比如一个字节时间的1.5倍)就算一包数据结束。在STM32 HAL库里,你可以配置串口的空闲中断IDLE,在空闲中断触发时,统计DMA已经接收了多少字节,然后把这个包交给上层。搜热词里也出现了“串口DMA接收不定长数据”,思路一模一样。
对于AXI-Stream这类带TKEEP/TLAST信号的接口,FIFO模块需要在每包数据末尾打上TLAST标记,这样下游就知道这是一笔事务的结束。实现层面,FIFO的存储宽度除了数据本身,还要扩展1位用来存储TLAST标志。读侧根据这个标志就能恢复出数据的包边界,上层协议栈就不会出现“多读了几个字节”或“少读了几个字节”的错位。
3.4 ADC多通道DMA采样的实战细节
ADC四通道用DMA采样,很多人一上来就头疼。ADC的规则组通道是顺序转换的,转换顺序由寄存器配置决定,转换结果会依次产生在数据寄存器里。如果你开DMA,DMA会把每次转换结果按顺序搬到内存数组里。问题来了:四通道循环采样100次,内存数组里是[ch1_1, ch2_1, ch3_1, ch4_1, ch1_2, ch2_2, ...]这种交织排布,如果你要单独分析每个通道,就得自己拆数据。
这里有个巧妙的办法:把DMA目的地址分别指向不同通道的独立缓冲区,利用SG的分散能力。具体来说,构建一个SG链表,第一个描述符目的地址指向通道1的buffer,长度4个字节;第二个描述符指向通道2的buffer,长度4个字节;第三个、第四个同理,四个描述符一轮循环。这样DMA每次转换完成,数据就自动分拣到各自的通道缓冲区里了。配置完成后,CPU只需要在DMA传输完成中断里把一轮采样计数清零,其他时间彻底歇着。
不过要注意,SG链表一轮循环的触发频率和ADC采样频率必须匹配。ADC转换一次,DMA搬运一次,如果ADC太快,SG链表可能还没跳完,数据就覆盖了。所以ADC触发DMA的方式建议用定时器触发转换,确保节奏稳定。
4. 实操过程:从一个串口DMA接收示范工程说起
4.1 工程文件结构
解压DMA_SG_FIFO.zip后,典型的工程结构应该是这样:
DMA_SG_FIFO/ ├─ src/ │ ├─ main.c │ ├─ dma_sg.c │ ├─ fifo.c │ └─ uart_driver.c ├─ inc/ │ ├─ dma_sg.h │ ├─ fifo.h │ └─ uart_driver.h ├─ tests/ │ └─ test_fifo_sg.c └─ README.mddma_sg.c负责描述符表的构建和DMA控制器的初始化,fifo.c负责FIFO读写逻辑,uart_driver.c把串口数据通过DMA搬进FIFO,并在空闲中断后通知协议层。这个分层是我个人比较习惯的——DMA、FIFO、外设驱动各自独立,哪一环出了问题可以直接断点单测。
4.2 核心代码流程
先看FIFO初始化与读写。这里给一个同步FIFO的简化版:
// fifo.h typedef struct { uint8_t *buffer; uint32_t capacity; volatile uint32_t write_index; volatile uint32_t read_index; } fifo_t; static inline uint32_t fifo_count(const fifo_t *fifo) { return fifo->write_index - fifo->read_index; } static inline bool fifo_is_empty(const fifo_t *fifo) { return fifo->read_index == fifo->write_index; } static inline bool fifo_is_full(const fifo_t *fifo) { return (fifo->write_index - fifo->read_index) >= fifo->capacity; }写索引和读索引用无符号整数递增,差值就是当前有效的字节数。这种做法的好处是不用做取模运算,等索引撞到UINT32_MAX再环绕。容量必须是2的幂,这样差值天然在32位范围内。实际操作时,write_index - read_index即使发生了环绕,结果依然是正确的——这是无符号数减法的猫腻所在,也是FIFO实现里最优雅的一个点。
再看DMA空闲中断接收不定长串口数据:
void UART_DMA_IdleHandler(UART_HandleTypeDef *huart) { if (huart == &huart1) { uint32_t remaining = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint32_t received_len = RX_BUFFER_SIZE - remaining; fifo_write(&rx_fifo, rx_buffer, received_len); HAL_UART_DMAStop(&huart1); HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); } }这段代码的思路是:启动DMA后,DMA会一直往接收缓冲区搬数据,当串口线上超过一个大字节周期没有新数据进来时,硬件触发空闲中断。在空闲中断里,读DMA剩余计数,就能算出已经收到多少字节,然后把这些字节一股脑写进FIFO,再重新启动DMA接收。这里有一个非常关键的细节:必须在空闲中断里先停止DMA,再重新启动DMA,否则在同一个中断上下文里直接重启DMA会有竞争风险。实测下来,如果省略HAL_UART_DMAStop,偶尔会出现漏字节的情况。
SG链表构建的核心代码大致长这样:
dma_sg_desc_t desc_table[4]; volatile bool desc_table_ready; void dma_sg_prepare(uint32_t *buf0, uint32_t len0, uint32_t *buf1, uint32_t len1, uint32_t *buf2, uint32_t len2, uint32_t *buf3, uint32_t len3) { desc_table[0].src_addr = (uint32_t)(uintptr_t)buf0; desc_table[0].dst_addr = (uint32_t)(uintptr_t)shared_dst; desc_table[0].len = len0; desc_table[0].next = &desc_table[1]; desc_table[1].src_addr = (uint32_t)(uintptr_t)buf1; desc_table[1].dst_addr = (uint32_t)(uintptr_t)(shared_dst + len0); desc_table[1].len = len1; desc_table[1].next = &desc_table[2]; // ... }这里要注意,每个描述符的目的地址要自己手动加上偏移,SG链表的自动跳转只是帮你换源地址,不会自动帮你连续排布目的地址。
4.3 参数计算:缓冲区与描述符数量怎么定
缓冲区数量和描述符数量怎么选?我总结了三个判断标准:
- 外设数据到达频率:频率高,描述符要短小精悍,快速转移;频率低,描述符可以大块预留。
- 协议包平均长度:缓冲区大小最好能装下2-3个完整协议包,避免包被截断在缓冲区中间。
- 可用RAM预算:描述符每增加一个,RAM多消耗12-16字节,一百个描述符也就1.6KB,但缓冲区是按KB级算,所以先聊缓冲区再聊描述符。
拿一个实际项目举例:跑Modbus协议,115200波特率,一帧最长256字节。接收缓冲区按512字节设置,FIFO深度设为2K字节,SG链表设置了4个描述符,每个描述符对应512字节接收缓冲区。这样即使协议栈处理慢一点,DMA也能连续接收好几帧不丢数据。如果FIFO太小,Modbus请求密集时就会出现丢帧,这是典型的用户反馈“偶尔通讯中断”的根因。
4.4 DMA测速这件事
DMA测速不是跑个分图个乐,它直接决定了你能不能在要求的截止时间内搬完数据。测速的常用方法有两种,一种是硬件方式,在DMA传输完成时翻转一个GPIO用示波器测脉宽;另一种是软件方式,用DMAC的统计寄存器读搬运的字节数和耗时。软件方式受中断延迟影响较大,我一般两个都用:波形看宏观趋势,寄存器数据看精确字节数。
测出来的速度要跟理论带宽对照一下。比如某MCU的DMA是APB2总线上的,APB2频率84MHz,每次搬运32位,那理论最大速度是84MB/s吗?不是。还要算上总线仲裁、等待周期、描述符跳转的开销,实测能达到峰值的60%就算不错了。如果你实测值明显偏低,优先检查时钟树配置,看DMA时钟是不是被分频了。还有一个隐蔽的坑,就是字节序。如果你的外设是小端,内存也是小端,那没事;但外设FIFO是32位宽,数据往里写的时候如果没做字节交换,读出来数据顺序是乱的,我见过有人拿着这个bug排查了一整天,最后发现是DMA的PSIZE和MSIZE没对齐。
5. 常见问题与排查技巧实录
5.1 串口DMA发送需要等待上一轮数据发完吗
这个问题被问得最多。直接给结论:需要,除非你能接受数据交错。DMA发送的特点是控制器只负责搬运,数据是否真的从UART移位寄存器发出去,取决于UART的TXE标志。如果你启动了DMA传输后,不等上一次传输完成就立刻发起下一次,那你只是把新数据覆盖到发送缓冲区上,UART发出的可能是新旧掺杂的数据。
我自己的做法是在DMA发送完成中断里设置一个tx_busy标志位,发送前检查这个标志。如果是用HAL库,回调函数HAL_UART_TxCpltCallback里清标志,再配合一个超时保护。有些朋友会问,串口还需要超时保护?当然需要,比如DMA因为异常没触发完成中断,软件就永远卡死在等标志位上面。所以在tx_busy置位的循环里加一个毫秒级的超时计数,超过100ms直接复位串口外设并清标志,宁可发丢也不能卡死。
5.2 异步FIFO读空写满的误判
这个问题我调过很久,最终定位到问题出在指针同步延迟上。读侧判断空标志时,写指针还需要打两拍才能进入读时钟域,如果写指针在同步过程中前进了一次,读侧看到的写指针仍然是旧值,就可能误报空。反过来,写侧判断满也一样。
解决办法只有一个,给FIFO留出保护余量:实际可用的深度要比物理深度小2-3个条目,专门用来吸收同步延迟。如果FIFO深度是16,设计上最多允许写14个条目。这是从硬件设计角度解决。软件层面,在读写时加入状态机判断,连续读到空标志2次才认为真的空,也能降低误判风险,但毕竟治标不治本,根子还得在FIFO空满逻辑上处理。
5.3 DMA连续请求模式 CS 和普通模式区别
很多DMA控制器有Continuous Request(连续请求)模式,区别于外设单次请求模式。普通模式下,DMA每个描述符传完都要等待下一个外设请求,才能继续下一个描述符。连续请求模式下,DMA会自动连续搬运所有描述符,不用等外设重新触发。
这个模式适合什么场景?适合数据源是内存、目的端也是内存的搬运。比如你把一个大的数据块从Flash搬到SRAM,普通模式会要求Flash端配合产生请求信号,但Flash端是主动的,不会自己产生连续的请求信号,这时候连续请求模式就派上用场了。如果在外设到内存的链路上误开启Continuous Request,可能导致DMA一直在搬,把外设根本还没准备好的数据也搬进去。所以一个判断准则:只要链路两端有一端是外设,就不要开Continuous Request;两端都是内存,才放心开启。
5.4 框架外的疑难杂症:总线锁死与缓存一致性
DMA调试到后期,最痛的是遇到总线锁死。现象是程序跑着跑着,DMA中断不触发,其他中断也像死了一样。原因多半是DMA读写访问了一块非法地址,总线事务悬挂,导致其他总线主设备拿不到总线控制权。解决办法是把DMA的控制寄存器DUAL、FIFO阈值、总线优先级调节到一个硬件能容忍的范围,同时用示波器抓总线占用。如果系统里有Cache,还得注意DMA和设备之间的一致性。Cortex-M7这类带Cache的核,DMA看到的地址和CPU看到的地址如果不一致,CPU写完数据后要执行SCB_CleanDCache,DMA写完内存CPU要读时得SCB_InvalidateDCache。Cache操作放对了位置,能救你于水火。
6. 实操心得:这套方案还能怎么扩展
写到这里,我想分享一个实际的扩展思路。很多人觉得DMA_SG_FIFO这套组合只有在STM32MP1、i.MX RT这类带复杂DMA控制器的平台上才能玩,实际上现在很多Cortex-M0+芯片都能挂上带SG的DMA外设。你可以把SG链表和FIFO当成一套通用数据管道,外设侧不管输入是串口、SPI、I2C还是ADC,只要把它接进这条管道,上层协议栈拿到的就是整齐划一的、带边界的、无粘包的数据流。
我最近在做一个采集板,用到16路ADC加上一个备用UART日志口,就是把这套思路移植过去的。CPU负载从原来的85%降到了20%左右,剩下的算力都拿去跑滤波算法了。对我个人而言,DMA_SG_FIFO带来的不只是性能,它把繁琐的数据搬运逻辑从CPU里剥离出来,让代码的可维护性有了质的提升。每个外设一个描述符表,一个FIFO,职责单一,出问题也好定位。
如果你要在这个工程上继续往下扩展,我建议下一步加一层解析状态机,直接对接Modbus或者私有协议栈。这层状态机的输入就是FIFO输出的完整数据包,输出是解析好的结构体。到这里,你会体会到整套架构彻底闭环的感觉——数据从物理层进来,到应用层消费,全程零拷贝、零中断风暴、零软件缓冲漏数据。
提示:所有代码和配置,记得先跑通最小系统,再加入协议解析。一次性全上,出了问题很难定位。DMA的调试尤其如此,先把裸搬运跑稳,再加业务逻辑,这是我能给的最实在的一条建议。
本文还有配套的精品资源,点击获取