1. 流量洪峰倒逼出来的架构问题:为什么必须认真对待数据搬运
1.1 一个具体的采样场景和之前的"笨办法"
前22篇我们把时序约束、FIFO、状态机、串口、SPI这些地基模块一个个搭了起来,到了Part.23,终于要面对本系列第一个真正意义上的"系统级"问题:数据量变大以后,怎么让数据自己从DDR里面跑出来,再自己跑回去,而不是让逻辑或者CPU去逐字节当搬运工。
我经常用这个例子说明问题:假设你有一个ADC,以250 MSPS的采样率持续输出16 bit采样值,换算下来每秒要处理500 MB数据。如果按老思路写一个状态机,每次向DDR控制器发一笔写请求,等响应,再发下一笔,这个状态机从头到尾会被地址计算、bank切换、响应等待占满。CPU直接参与搬运更糟,中断一来就要进上下文切换,数据稍微快点就追不上。结果就是:带宽数字看起来挺大,实际一测吞吐腰斩,波形一多就丢采样点。
所谓"搬运",其实有三个递进层次:最底层是CPU一条条读外设寄存器,再一条条写DDR;中间层是逻辑自己维护地址计数器、自己发AXI突发;最上层才是用DMA控制器,把"源地址、目的地址、传输长度"扔给硬件,剩下的事情全部自动化。做FPGA越久越会感觉到,大部分性能问题不是算力不够,而是数据在来的路上堵死了。这也是本篇要解决的问题。
1.2 数据搬运的三个层次:从人肉搬运到自动化分拣
先把这个分层讲透,后面看架构图才不会晕。
第一层:CPU直搬。逻辑把采样数据放进一个状态寄存器,CPU读回来再写进DDR。优点是实现简单,调试方便;缺点是吞吐完全取决于CPU读写速度,而且高频率中断会挤占其他任务的执行时间,实测带宽通常只有几十MB/s的量级,适合低速控制和调试用。
第二层:FIFO加状态机直写DDR。自己设计一个写控制器,维护写地址、写长度,FIFO攒够一小段就发一笔AXI突发。这个方案比CPU快得多,在小带宽场景下很实用,但每笔突发之间地址递增、响应等待都是串行的,突发长度和地址连续性一旦安排不好,效率照样上不去。
第三层:DMA控制器。DMA的本质是"可编程地址发生器加长度计数器",它把搬运动作拆成读和写两条通道,读通道负责把数据从源端拿回来,写通道负责把数据推到目的端。搬运的过程中,主逻辑只需要在开始和结束的时候介入一下。把这个过程类比成物流很合适:CPU是人肉跑仓库,状态机是手推车,DMA是一条能自己认路的自动分拣线。
本篇要搭的核心链路,就是这套自动分拣线的骨架:AXI/AXIS是道路规则,DMA是运输车,DDR缓存是仓库。三者各司其职,数据从源头进来,经DMA进仓库,再从仓库经DMA出去,全程不需要人工介入。
1.3 本篇的阅读路线图
如果你手里已经有一块带DDR颗粒的开发板,而且前面的FIFO和AXI-Lite读写实验都跑通过,这篇的内容可以直接落到工程里。我会先在第二部分把AXI和AXIS的最小必要知识讲清楚,只讲和搬运强相关的握手、突发、对齐,不铺开讲协议全集;第三部分讲DMA控制器内部到底在做什么,读通道和写通道各自的工作流程;第四部分是重头戏,讲DDR缓存架构如何分层、缓存深度怎么算、水位线怎么设;第五部分给出搭建链路的具体步骤,并分享三个我实测踩过的坑。
这篇文章不会涉及某个厂商特有的IP细节,但思路和调试方法在所有主流FPGA平台上都通用。
2. AXI 与 AXIS 协议入门:0基础需要掌握的最小集
2.1 valid/ready 握手:数据搬运的"红绿灯"
AXI-Stream(常简写为AXIS)是整个搬运链路的"传送带"。它不需要地址,也没有独立的读写区分,数据从一个主设备流到一个从设备,一切规则都围绕一对握手信号展开。
先记住这个最核心的关系:发送方拉高valid,表示当前总线上的数据是有效的;接收方拉高ready,表示现在有能力接收;只有当valid和ready同时为高的一拍,数据才算真正传输完成。这个规则和现实中的红绿灯非常像:valid是绿灯,告诉后面"我这辆车要过路口了",ready是路口另一侧的管理员说"我这边能接",两灯同时亮,车才真正通过。
这里有一个初学者很容易犯的错误:发送方先看ready再决定要不要拉valid,甚至用ready做valid的组合逻辑,结果出现组合环或者死锁。正确做法是,数据准备好了就无条件拉高valid,如果对端一直拉高ready,那就每个周期持续送数;如果对端暂时不能接收,就拉低ready,数据在源端多等几拍。这个"valid不等ready"的原则能避免很多调试噩梦。
规范的握手逻辑大概是这样的:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin axis_tvalid <= 1'b0; axis_tdata <= 'd0; axis_tlast <= 1'b0; cnt <= 'd0; end else if (axis_tready) begin axis_tvalid <= 1'b1; axis_tdata <= data_ram[cnt]; axis_tlast <= (cnt == total_len - 1); cnt <= (cnt == total_len - 1) ? 'd0 : cnt + 1'b1; end end注意上面代码里有一个隐含细节:axis_tvalid在整段传输中始终保持为高,表示发送方没有停顿;接收方的ready可以随时变化,这是AXIS最灵活的背压方式。另外,tlast用来标识最后一个数据拍,接收方靠它判断一个数据包的边界,在有不定长包传输时尤其重要。
除了valid/ready/data/last这四个基本信号,AXIS还常用tkeep或tstrb来表示总线中哪些字节有效。比如64位总线上一次能带8个字节,如果这次的包尾只有3个字节有效,就需要拉低tkeep的对应位。很多人在调试时只关注data和last,忽略了字节有效信号,导致接收方把无效字节当有效数据处理,这个细节我会在踩坑部分再提。
2.2 AXI 与 AXIS 的区别:流式 vs 地址式
AXIS只是整个AMBA总线协议家族里的一员,完整版的AXI总线是另一套东西,理解它们的区别,才知道什么时候该接谁。
AXI全协议带地址,典型特征是读写通道分离。以一次写传输为例:主机先在AW通道上发送写地址,在W通道上发送写数据,从机处理完之后通过B通道返回写响应。读传输则分为AR通道(读地址)和R通道(读数据)。正因为地址通道和数据通道是分开的,主机可以在等待上一次写响应时,提前发起下一次地址请求,形成流水线。
另外还有AXI-Lite,它是AXI的精简版,不支持突发传输,一次只能读写单个数据,非常适合用来配置寄存器。我们在工程里常见的一种接法就是:CPU或调试逻辑通过AXI-Lite配置DMA,而DMA和DDR控制器之间用完整的AXI走大数据,各端口之间的数据流则用AXIS传递。
做个简单对照:
| 接口 | 特点 | 典型用途 |
|---|---|---|
| AXIS | 无地址、单向流、有握手无响应 | 模块间数据搬运、音频/图像流 |
| AXI-Lite | 有地址、无突发、每次单读单写 | 寄存器配置、状态查询 |
| AXI全协议 | 有地址、支持突发、读写通道分离 | DDR控制器、DMA主接口 |
在架构选型时,我的原则很简单:凡是搬运大块数据的路径,一律用AXI全协议;凡是配置类的路径,用AXI-Lite;凡是模块间持续的数据流,用AXIS。三者各司其职,工程结构会清爽很多。
2.3 突发长度、字节通道和地址对齐:DDR高效读写的底层原因
AXI全协议和大吞吐最相关的特性就是突发传输(burst)。一次地址请求可以携带长度信息,例如arlen为15时表示后续还有15拍数据,总共16拍,每拍按总线位宽传输。对64位总线来说,16拍可以一次传128字节。DDR控制器最喜欢这种连续突发,因为它可以把SDRAM中同一行(row)的数据连续读出来,不需要反复切换行地址。
突发长度不是越大越好,它受限于DDR控制器IP的设置。比如控制器可能规定最大突发64拍,如果用户一次请求128拍,控制器内部会自己拆成两笔小突发,虽然逻辑上没有问题,但中间会插入额外的总线占用。所以在配置DMA时,最好把单次传输长度控制在DDR控制器支持的最大突发之内。
地址对齐则是另一个效率杀手。AXI总线突发有个规则:一次突发不能跨越4KB边界,如果起始地址不在自然对齐位置,控制器会把这个长突发拆成多段,每段都要重新发一次地址请求、重新计算行地址。我实测过,同样的数据量,对齐和未对齐的吞吐差距可以超过30%。行业里常见的做法是让DMA的缓存基地址按64字节对齐,DDR控制器内部如果按照最佳页面大小配置,还能进一步和缓存行对齐,把效率拉到最高。
3. 中间人DMA:把"搬运工"做成可编程的硬件模块
3.1 DMA的本质:地址、长度、方向
聊到DMA,很多从软件转过来的人会下意识把它理解成"快速memcpy",这个类比其实不准确。CPU里的memcpy是软件指令逐字节拷贝,而FPGA里的DMA是硬件状态机,它做的事情只有三件:记住源地址、记住目的地址、记住传输长度。启动之后,它自己完成地址递增、突发组装、长度递减、结束中断。
一个完整的DMA控制器通常拆成两条独立通道。一条叫MM2S,意思是"内存映射到流",负责从DDR读取数据,然后以AXIS流的形式发送出去;另一条叫S2MM,意思是"流到内存映射",负责接收外部AXIS数据,写成DDR。这个叫法在很多厂商的IP核文档里都能看到,为了方便,下面直接用这两个缩写。
为什么要把读写分成两条独立通道?因为DDR一侧是双端口性质,可以同时处理读写请求;而数据流走到内外系统两侧往往不是对称的。比如一个回放场景,读通道可能一直在持续出数,而写通道偶尔才有一段数据进来,如果共用一个控制状态机,回放节奏很容易被写操作干扰。拆开后,读通道和写通道各自维护自己的地址计数器和长度计数器,互不干扰。
3.2 寄存器模式还是描述符模式:如何选择
DMA的控制模式大致分两种。第一种是寄存器模式:主机往DMA的寄存器里写入源地址、目的地址、长度,然后置位启动位,DMA就跑完一笔,结束后拉中断。这种模式实现简单、调试直观,适合搬运固定地址的连续数据块,比如把一段正弦波表从DDR搬给DAC。
第二种是描述符环模式:在DDR里维护一个描述符表,每个描述符里存放地址、长度、控制位和下一个描述符的指针,DMA自动去DDR抓取描述符,按描述符逐个搬运,全部搬完再产生中断。这种方式的好处是CPU只需要在起始时告诉DMA"描述符环在哪个地址",后续所有搬运都不需要CPU介入,特别适合多段不连续数据的连续采集。
我的建议是:如果只是做单通道、数据量不大、地址固定的搬运动作,寄存器模式完全够用,复杂机制反而引入风险;如果要做连续采样、数据要按帧写入多个缓冲区、或者多个数据流向需要仲裁,再上描述符环。别为了"用上高级功能"而牺牲可调试性。
3.3 在AXI与AXIS之间搭桥:两大通道的工作流程
展开讲一下MM2S通道的工作流程,S2MM理解后就是它的镜像。启动前,主机先通过AXI-Lite接口把源地址和字节长度配置进去。DMA收到启动命令后,内部的主机接口开始往AXI总线上发送读请求:先是AR通道上出现地址和突发长度,DDR控制器接受到请求后,后续R通道会不断返回数据。每返回一笔数据,DMA内部就把它推进一个缓冲FIFO,当FIFO里攒够一定字节数后,才从AXIS主接口输出,同时把tvalid拉高。
为什么要经过这个内部FIFO,而不是让AXI过来的数据直接透传到AXIS?原因有两个:第一,AXI的读数据是带地址通道触发、可能乱序返回的,而AXIS是严格有序的流,必须通过FIFO排队重新整形;第二,总线位宽和数据通道位宽可能不一致,比如AXI侧是256位,AXIS侧配置成64位,必须靠FIFO做位宽转换。
S2MM方向则反过来:外部AXIS数据先进入DMA的写数据FIFO,DMA侧的主机接口凑齐一个整数突发后,向DDR发起一个AW写地址请求,然后逐渐把FIFO中的数据在W通道上送出。接收DDR返回的B响应后,这一次写突发才算完成。如果进来的数据速率忽高忽低,写FIFO还能起到"流量整形"的作用,把小片段攒成大突发,以便提高DDR效率。
读和写之间还有一个共同点:都要维护"长度计数器"和"完成标志"。DMA内部每完成一笔数据搬运,长度计数器就减一次。当减到零时,会产生一个完成中断,同时将状态寄存器里的对应位拉高。主机收到中断后,要么再次配置新任务,要么直接读取状态判断搬运是否结束。
4. DDR缓存架构:从单组FIFO到完整的缓存分层
4.1 DDR控制器的带宽真相与背压含义
DDR的标称带宽非常好算:数据速率乘以位宽再除以8。比如DDR4-2400,数据速率是2400 MT/s,64位总线,理论带宽就是2400×64/8=19.2 GB/s。但这个数字只是理论上限,实际能拿到的有效带宽通常只有60%到80%,剩下的都被以下环节吃掉了:刷新操作,DDR颗粒需要定期刷新,刷新期间该bank的读写都要暂停;读写切换,总线上从读切到写,或者从写切到读,都需要插入空闲周期;bank切换,不同行的数据需要预充电和激活,耗时比连续读同一行高出不少。
理解背压是理解缓存架构的前提。所谓背压,就是"下游忙不过来时向上游传递压力"的过程。在AXI总线上,这个压力表现为DDR控制器在数据通道上拉低ready;上游拿不到ready就无法发送下一拍,数据只能堆积在中间的FIFO里。如果这个FIFO也满了,压力就会继续向上游传播,直到源头被暂停。
架构设计的关键问题不是"DDR有多快",而是"DDR在什么情况下会暂停,暂停多久,暂停时前端的数据应该放到哪里去"。这三个问题搞清楚了,缓存深度的计算就水到渠成。
4.2 缓存架构设计:前端AXIS、中间缓存、后端DDR
我这里给出一套非常经典、也最适合0基础起步的分层结构。沿着数据流向依次是:前端AXIS数据源、写缓存FIFO、DMA写通道、DDR控制器、DMA读通道、读缓存FIFO、后端AXIS数据目的。这里写缓存和读缓存是两个独立的FIFO,不要用一个双口RAM硬扛两个方向,否则地址管理会非常痛苦。
为什么要在前端和DDR控制器之间硬塞一层FIFO,而不是把AXIS直接接到DMA上?第一是时钟域转换。很多ADC或以太网接口的时钟域和DDR用户时钟域不同,FIFO天然承担异步时钟转换的功能。第二是流量整形。AXIS数据可能是一个一个字节到的,如果来一个字节就发一次AXI写请求,效率会非常低下;FIFO把数据攒起来,攒够一个标准突发长度后再触发DMA搬运,DDR效率能提升一个数量级。
读方向也类似。当后端设备需要数据时,它会通过AXIS的valid/ready机制拉数据。如果直接从DDR读,每次只发一小段请求,DDR控制器会被频繁打断,带宽和响应时间都受影响。通过读缓存FIFO,可以预先从DDR里多取一些数据,后端消费一帧,DMA就自动补一帧,形成流水。
4.3 缓存深度估算与水位线设定:一个计算实例
很多教程告诉你"FIFO当然是越大越好",这句话在资源充足的板卡上没错,但在资源紧张或需要控制延迟的场合,盲目加大FIFO会带来资源浪费和延迟抬升。我习惯的做法是:先算"可容忍停顿时间",再反推FIFO深度。
仍然以250 MSPS、16 bit采样的ADC为例,数据率约500 MB/s。假设DDR控制器因为刷新和bank调度,每过一段时间会停顿约10µs。为了让这10µs期间前端完全不丢数据,写缓存至少要能容纳500 MB/s乘以10µs等于5 KB的数据。再加上读写切换、地址冲突等不确定因素,我会再乘上1.5到2的余量,最终设计成深度为8KB到16KB的FIFO。这个深度在实际工程中非常常见。
FIFO确定后,水位线设置同样关键。写缓存不要等全满才让DMA来搬,也不要一有数据就每笔都搬;我一般设置为"超过一半才触发DMA搬运",这样DMA每笔搬走的都是足够长的数据块。读缓存则设低水位线,比如FIFO中的数据被消费到只剩四分之一时,DMA才开始下一次预取,避免一空就请求、一请求又立刻满满当当的抖动式搬运。
| 方向 | 推荐水位策略 | 效果 |
|---|---|---|
| 写缓存 | 半满触发DMA搬走 | 减少小突发次数,提升DDR写效率 |
| 读缓存 | 低水位触发预取 | 平滑后端消费抖动,减少读延迟 |
| 双缓冲 | 两块完整帧交替使用 | 简化帧边界控制,适合图像类数据 |
5. 实测链路搭建与三个真实踩坑案例
5.1 一条完整的搬运链路的搭建步骤
讲完原理,直接给一套能落地的搭建顺序。假设你已经有了一块带DDR颗粒的开发板,并且前几篇已经把AXI-Lite读写寄存器的小实验调通了。
第一步,在FPGA工程里加入DDR控制器IP,把数据位宽配成和DMA相匹配的位宽,例如64位或128位,确保突发长度配置一致。第二步,加入DMA控制器IP,把它的主接口连到DDR控制器的从接口,把配置接口接到你的AXI-Lite总线上,AXIS接口则分别接到你自己的数据源和数据目的模块上。第三步,做仿真验证,这一步千万不要跳过,很多地址错误、长度错误在仿真里一眼就能看出,上板后再查就费劲了。
仿真通过之后,上板验证要有一个固定流程。先跑内环回环测试:在DDR里预先写入一段已知模式数据,然后启动DMA读通道,把读出来的数据和原始数据做比对。比对通过后,再接入真实数据源,从写通道进数据,读到存储区之后做CRC校验。全程记录时间戳,用搬运的总字节数除以耗时,就能得到实际吞吐。
5.2 踩坑一:地址未对齐导致突发长度被截断
这个坑是我在调一条图像采集链路时踩到的。现象很奇怪:DDR控制器的报告显示用户接口利用率很高,但实际吞吐只有理想值的一半。我一开始以为是时钟频率不够,后来把AR通道的信号抓出来看,发现本来期望的64拍长突发,被系统拆成了好几段16拍的小突发,中间还隔了空闲周期。
根因是DMA的源地址没有按DDR控制器的最佳突发边界对齐。AXI协议规定突发不能跨4KB边界,而DDR控制器内部又有自己的页大小约束,当地址落在边界的偏置处,控制器只能把长突发切成两段甚至多段。每一段都要重新仲裁、重新预充电,效率自然上不去。
解决方案分成两层。第一层是缓存地址对齐:所有分配给DMA的缓冲区基地址,统一按64字节对齐分配,如果用的是带描述符模式的DMA,还要保证每个描述符内的地址也满足对齐条件。第二层是长度对齐:单次搬运长度尽量取突发长度的整数倍,否则最后一小段只能单独发起一笔短突发,白白浪费一次总线授权。
5.3 踩坑二:FIFO半空时的死锁问题
第二个坑是系统在长时间运行后会间歇性卡死,表现为所有AXIS数据流停止,但DDR控制器的状态没有报错,DMA也没有超时。我查了很久才发现,问题出在我对读缓存FIFO的触发时机判断得太简单:当时用的是空信号作为DMA预取触发条件,只要FIFO空了,DMA就去DDR取数据,取回来填满整个FIFO。
表面上看逻辑没问题,但实际运行中,后端消费数据的速度并不恒定,当FIFO栈底还有几个数据、还没变成空信号时,DMA不会启动下一次预取;而前端数据源又在等待写缓存有空位,写缓存又在等待DMA搬走数据。于是读通道等空、写通道等满,两条通道互相等待,总线进入死锁状态。
正确做法是使用FIFO的可编程水位信号,比如prog_empty和prog_full,通过设定精确阈值,做到"还没空就开始预取"和"还没满就允许搬运"。另一个更稳妥的补充手段是给DMA加一个看门狗超时机制,一旦某条通道超过预设时间没有完成预期进度,就主动发起软复位。
5.4 踩坑三:DDR刷新与带宽冲突的怪现象
第三个坑更隐蔽。我在跑一个长时间大数据量传输测试时,发现偶发出现校验错误,而且出错位置完全没有规律,重新上电后又可能几个小时不出现。起初怀疑是信号完整性,后来用仿真反复验证都没复现,最后回到DDR控制器的文档里翻到了刷新仲裁的细节。
DDR颗粒要求定期执行刷新命令,否则存储的电荷会泄漏导致数据损坏。DDR控制器内部有自动刷新机制,但当用户接口的访问请求非常密集时,某些控制器如果按"用户请求优先"的策略调度,刷新命令就可能被一拖再拖。拖的时间太长,恰好又有温度升高,数据出错概率就会显著上升。
解决思路有三条:第一,在高带宽传输场景中,不要把用户接口的请求队列全部占满,留出一定余量给自动刷新;第二,某些DDR控制器IP支持温度补偿刷新,开启后控制器会根据芯片温度动态调整刷新频率;第三,如果用的是自己设计的读写控制器,可以在状态机里加入"刷新窗口"机制,每搬完一定字节数就主动让出一个周期给刷新命令。想验证这个问题也很简单,看链路空闲时的稳定性是否比满载时好得多,基本就能指向刷新调度。
这三类问题在网上的讨论区里经常被归为"玄学问题",其实背后都有明确的协议和时序原因。地址对齐属于静态配置错误,死锁属于触发条件设计不足,刷新冲突属于资源调度考虑不周。调试顺序建议是先查地址对齐,再查水位逻辑,最后才查刷新仲裁。
最后分享一个我自己的调试习惯:搭建这种带DDR的搬运链路时,一定要在关键节点预留可观测的调试接口。比如在AXIS通道上加一个数据数量统计计数器,在DMA完成标志上加一个GPIO翻转,在DDR控制器侧保留错误状态寄存器的读取路径。这些额外的测试逻辑看起来占用资源,但能让你在复现问题时快速缩小范围。等你把架构设计调成熟,再把这些调试逻辑去掉就好。