做FPGA开发的人应该都有同感:寄存器、状态机、UART、SPI这些基础模块玩顺之后,下一步往往不是“更复杂的状态机”,而是被同一类问题卡住——数据量太大,根本搬不动。举个最常见的场景,接一个1080p@60的摄像头,RGB888输出,一秒就有接近373MB的原始数据,如果你还想着“每来一个像素就写一次内存”,那写出来的逻辑基本没法用。这一期Part.23,就是把“大流量数据搬运”这层窗户纸捅破:AXI/AXIS总线、DMA搬运、DDR缓存架构设计。它们单看协议文档都很劝退,但合在一起解决的事情其实很朴素:让数据从A点高效走到B点,中间穿过DDR时既不掉速、也不丢数、更不乱序。这一期适合已经能写基础逻辑、但还没碰过高速总线和存储系统的朋友,也适合被DMA调到头秃的兄弟。
关于总线协议、DMA和DDR缓存,很多中文资料讲得太碎,要么只给你看波形,要么只给你看IP核配置界面。我打算反过来,从“为什么要这么设计”开始讲,再给一套可以直接抄作业的模块划分和验证方法。
1. 为什么到了Part.23,绕不开AXI/AXIS、DMA与DDR
1.1 从“能跑”到“能用”:数据搬运是分水岭
很多初学者对FPGA的印象是“写逻辑控制电平”,但真实项目里,FPGA一半以上的价值在做数据的搬运和暂存。传感器采集的数据要进DDR,DDR里的数据要送给算法模块,算法模块算完又要写回DDR,最终还要以特定时序发出去。
这一连串动作里,真正决定系统能不能跑的,不是单个逻辑模块的写法,而是模块之间的“运输系统”。如果用点灯的思路去写数据搬运,每来一个数据就产生一次总线事务,结果就是总线效率极低、时序跑不高、DDR带宽被白白浪费。
从“能跑”到“能用”,差的就是这一整套设计思维:知道数据以什么格式流动、用什么规则握手、以多长的突发方式访问DDR、如何避免跨时钟域和数据乱序。
1.2 这一章解决的三件事
这一篇主要回答三个问题:
- AXI/AXIS到底是什么,和普通的寄存器读写有什么本质区别。
- 为什么数据量大时必须用DMA,DMA和DDR缓存搭配时的带宽账怎么算。
- 一个实际可用的数据搬运架构长什么样,模块怎么划、接口怎么约、验证怎么做。
这三个问题分别对应总线协议基础、系统架构思维、可复现的工程细节。说实话,协议层面的东西查手册能查到,但“为什么选这个方案”“实际会遇到什么坑”,才是这一篇最想讲清楚的。
2. 先拆协议:AXI/AXIS的核心机制与最容易理解错的细节
2.1 AXI的五个通道和AXIS的单向数据流
AXI协议最常见的形态是AXI4,它把一次读写拆成多个通道。写数据时有写地址通道、写数据通道、写响应通道;读数据时有读地址通道、读数据通道。每个通道都是独立的握手机制,因此可以实现乱序、乱序返回、outstanding传输等高级特性。
AXIS则简单很多,它本质上是一个“单向数据管道”,只有一组数据信号和握手信号,方向固定。视频流、网络包、DMA搬运的数据流都用AXIS表示,因为它不需要关心地址,只需要关心数据什么时候有效、能不能被接收。
一个普遍的误解是“AXI比AXIS复杂,所以优先用AXI”。其实,如果你要传输一段连续的数据流,用AXIS更合适;只有当你需要随机访问某个地址空间,或者需要CPU/控制器去配置寄存器时,才用AXI4或AXI-Lite。在自家模块之间,尽量用AXIS,省掉大量地址和响应逻辑。
| 总线风格 | 典型用途 | 数据方向 | 地址/响应通道 | 适合场景 |
|---|---|---|---|---|
| AXI4全功能 | 高性能存储访问 | 读写双向 | 有 | DMA、DDR、多主多从互联 |
| AXI4-Lite | 寄存器和低速控制 | 读写双向 | 有 | 配置寄存器、状态查询 |
| AXIS | 高速连续数据流 | 单向 | 无 | 视频、网络、DMA数据通路 |
2.2 握手与背压:不会握手的AXIS不是合格的AXIS
AXIS的握手看似只有两个信号:TVALID和TREADY。数据在TVALID和TREADY同时为高的那个时钟沿被传输。看起来很简单,但细节规则很容易写错。
首先是TVALID一旦拉高,在握手成功之前不允许变低,也就是说你不能在等TREADY的过程中把数据换掉。这决定了源端必须先把数据锁存好,再拉VALID。
其次是背压问题。当接收端暂时处理不过来时,TREADY拉低,数据就在源端堆积。这一机制叫背压。很多初学者犯的错误是:接收端只在能接收数据的那一个周期拉高TREADY,而源端的数据又靠FIFO裸露出来,结果导致空一个周期、采一个周期,效率很低。
正确的做法是尽量让接收端保持“只要内部FIFO没满,TREADY就一直为高”。这样源端来的数据可以被连续吸收,整个链路的吞吐不会因为握手的空档掉下去。我通常会在模块入口加一个小FIFO,用FIFO的几乎满信号反向控制TREADY,效果立竿见影。
2.3 Burst、地址对齐与大小端:三个必须知道的隐藏规则
AXI的突发传输在协议里定义了三个关键参数:AXLEN、AXSIZE和AXBURST。AXLEN表示一次突发传输包含多少个数据节拍,AXSIZE表示每个节拍的字节数,AXBURST表示地址递增方式。
很多人把这一切看成“IP核会自动处理”,但当你自己写DMA或者AXI从机时,这几个参数必须完全理解。比如一组突发总共传输的字节数等于AXLEN × AXSIZE。如果要传输512字节数据,突发长度设为16、数据位宽64位(8字节),那么AXLEN应该是64?不对,协议里的突发长度上限通常被限制为256个节拍,所以可以设为64。把数据总量和突发长度对应清楚,是地址生成器的基本功。
地址对齐是另一个最容易踩的坑。AXI协议要求,一次突发的起始地址必须与整个突发传输的总字节数对齐。通俗点说,如果一次突发传输64字节,那么起始地址的低6位必须是0;如果传输4字节,那么地址低2位必须是0。这个规则在DDR访问时尤其重要,因为很多DDR控制器对未对齐的突发要么拒绝、要么内部拆散,最终都会表现为带宽暴跌。
大小端主要影响字节通道BW/STRB信号。如果你用AXIS接入一个以64位为粒度输出像素的模块,而后续处理模块希望按字节顺序访问,那么需要明确TSTRB/TKEEP以及字节通道的排列规则。乱序出错的案例我见过很多,但根因几乎都是“有人忘了字节选通”。
2.4 什么时候用AXI,什么时候用AXIS
我给一个非常直白的建议:自己模块内部的数据通路,一律用AXIS;只有到了必须访问外部地址空间(比如DDR)的边界,才用AXI4。原因是AXI4的复杂度主要来自多通道、乱序和响应机制,这些对系统级互联很重要,但对内部模块来说只会增加出错概率。
比如你要把摄像头数据搬进DDR:摄像头采集模块输出AXIS,DMA控制器接收AXIS并转换成AXI写事务写入DDR;从DDR读回时,DMA把AXI读数据转换成AXIS输出。这个结构里,内部逻辑根本不需要关心AXI地址通道的仲裁逻辑,因为这些都由DMA控制器承担了。
再比如DDR控制器本身是AXI从机,CPU和DMA都可能要访问它。这时需要一个互联结构来仲裁多个主设备。这个互联结构可以来自厂家的IP,也可以自己写一个简单的轮询仲裁器。最怕的是把AXI事务逻辑写满整个工程,什么都想自己控制,结果验证工作量翻几倍。
3. DMA与DDR:不是“加个IP”那么简单
3.1 为什么必须DMA:逐字节搬运的代价
如果不用DMA,数据搬运有哪些办法?一个极端是每次数据到达都让CPU或软核参与,读到寄存器再写出去。这种方法在数据率低的时候没问题,但到了几百MB/s的并发流,CPU根本来不及响应,而且每一次搬运都有中断和上下文切换的损耗。
另一个办法是在FPGA里用状态机直接操作总线。但这会产生大量短小的事务,比如一次写4字节,写一万次。总线仲裁和DDR控制器每次都要重新激活、预充电,效率极低。
DMA的本质是“能离线完成的搬运就不麻烦处理器”。它把“从哪里搬、搬多少、搬到哪”这些信息提前放进描述符或者寄存器,然后开始自动执行。搬运期间,处理器可以做别的事情,DMA完成后再通过中断或状态标志通知。
在FPGA里,这个角色通常是一个自定义的AXI DMA模块,或者某些IP核。我建议初学者至少自己动手写一次“AXIS to AXI”的写入控制器,而不是只会调用现成IP。写过一次之后,你才能真正理解地址生成、突发长度、响应处理之间的关系。
3.2 先算带宽账:1080p视频的实战估算
很多人在选型之后才发现带宽不够,问题就出在没提前算账。我们拿一个具体场景来算:
假设一个1080p@60的摄像头,像素格式为RGB888,每像素3字节。
- 每帧大小:1920 × 1080 × 3 ≈ 6.22 MB
- 每秒帧数:60
- 访问DDR的原始需求:6.22 × 60 ≈ 373 MB/s
如果你还要做两帧乒乓缓存,写一帧同时读一帧,那么DDR访问总量就变成约746MB/s。如果中间还叠加缩放、旋转、OSD等操作,数据可能会被多次读写,轻松超过1.5GB/s。
再看DDR能提供多少。以一块DDR3-1600、64位接口为例,理论带宽是8.5GB/s左右。但因为刷新、行激活、预充电以及读写切换,实际利用率通常只有理论值的50%~70%,也就是说稳定可用带宽大概在4~6GB/s。看数字还够用,但如果你用短突发、随机地址访问,实际带宽可能只有理论带宽的20%,那就不够了。
所以构建设计的第一件事,不是先写代码,而是把“源数据率、DDR访问次数、可用带宽”列成一张表。这张表会直接影响你要不要做缓存压缩、要不要用多块DDR、要不要把DMA的突发长度设成最大。
3.3 缓存架构的三种典型选择:Ping-Pong、环形与多缓冲
DDR带宽只是基础,缓存架构决定数据怎么利用这些带宽。最常见的选择有三种。
第一种是Ping-Pong缓冲。A区和B区交替使用,采集端往A区写一帧时,处理端读B区;下一帧反着来。优点是可以做到帧级并行,视频处理最常用。缺点是至少需要两帧大小的DDR空间,DDR容量不大时会有压力。
第二种是环形缓冲。把一整块DDR地址空间切成N个段,写指针绕圈走,读指针在后面追,适合连续流数据,比如ADC采样数据、网络包捕获。难点在于要处理“写指针追上读指针”以及“读指针追上写指针”的情况,否则要么覆盖新数据,要么读到脏数据。
第三种是多缓冲加流式处理,本质上可以看成很多个小的Ping-Pong区域拼接。适合做流水线处理,每个处理级都有自己专用的缓存区,各级之间用FIFO衔接。
从FPGA项目落地角度,我通常的做法是:处理对象是“帧”就选Ping-Pong,处理对象是“连续流”就选环形,处理对象是“多级流水线中间结果”就选多缓冲。不要一上来就整一个特别复杂的动态缓存管理,除非你确实需要非常高的DDR利用率。
3.4 DDR控制器与后端协作:不是所有IP都一样
DDR控制器通常由EDA工具自带的IP生成,它会自动完成刷新、时序训练、命令调度,对外提供一个AXI接口。你真正要关心的,是“如何让DMA配合这个AXI接口干活”。
几个关键点:
- 突发长度需要与DDR控制器的配置匹配。比如控制器内部偏好64B或128B的访问粒度,那么DMA的突发总字节数最好也按这个粒度设计。
- 读写切换要尽量少。DDR控制器最怕读写交替频繁,因为总线要转相,会有额外周期损耗。如果可能,把写数据集中到一段时间,把读数据集中到另一段时间。
- 地址分配要留意rank和bank。虽然控制器会自动调度,但如果你把两个并发流分配在同一个bank的相邻地址,冲突概率就会上升。
这些优化说难不难,但需要你在系统设计阶段就给DMA控制器的地址生成器留好参数接口,否则等代码写完了再想改,成本就很大。
4. 一个可以复用的数据搬运架构:从模块划分到代码骨架
4.1 总体结构:采集端、DMA、DDR、输出端怎么连
我建议所有涉及DDR的数据流都采用统一的五段结构:源端模块、跨时钟域FIFO、DMA写控制器、DDR控制器、DMA读控制器和目的端模块。以视频采集写入DDR为例:
源端摄像头输出AXIS像素流,经过一个跨时钟域FIFO进入DMA写控制器。DMA写控制器会按预设的起始地址、帧长和突发长度,把连续数据流切分成多个AXI写事务。DDR控制器负责真正把数据写进DDR颗粒。读路径反过来,DMA读控制器按地址从DDR控制器取数,以AXIS形式送给显示模块。
这里的核心思想是,DMA控制器是整个架构的“翻译官”:一边是简单的流式AXIS接口,另一边是复杂的AXI内存访问接口。其他模块都尽量跟AXIS打交道,只有DMA和DDR控制器之间使用AXI。
4.2 接口约定:AXIS字节流与DDR地址空间的映射
很多人在做架构时忽略了一个问题:AXIS数据流的字节顺序和DDR地址空间的字节顺序怎么对应。
举个实际例子,如果你用64位AXIS数据总线传输摄像头像素,一行数据有1920×3=5760字节,而DDR按64字节对齐的块访问,那么每行数据必然出现“最后一块不够64字节”的情况。这怎么处理?两种做法:一种是不对齐,允许DMA在最后一个突发里传输少量数据;另一种是硬性把每行补齐到64字节的整数倍,浪费一点空间但简化地址生成。
我更推荐后者,尤其在自己的视频处理流水线里。把行有效数据后面补几个字节的填充,让每行固定占满若干个64字节块,地址计算和DMA描述符都会简单很多。这个填充逻辑放在源端模块里做,可能只是加几个计数器的事,却能让整个系统的调试时间缩短一大截。
4.3 简化版AXIS FIFO代码与解释
直接嵌一个成品FIFO的代码会很长,我写一个精简的AXIS接口示例,展示握手逻辑的核心,而不是完整FIFO实现。下面的代码是一个把普通同步FIFO包装成AXIS输出端的简化逻辑:
module axis_fifo_wrapper #( parameter DATA_WIDTH = 64 )( input logic clk, input logic rst_n, // 接收侧 AXIS input logic [DATA_WIDTH-1:0] s_axis_tdata, input logic s_axis_tvalid, output logic s_axis_tready, // 发送侧 AXIS output logic [DATA_WIDTH-1:0] m_axis_tdata, output logic m_axis_tvalid, input logic m_axis_tready, // 内部FIFO满/空 input logic fifo_full, input logic fifo_empty, output logic fifo_wr_en, output logic fifo_rd_en, output logic [DATA_WIDTH-1:0] fifo_rd_data ); assign s_axis_tready = ~fifo_full; assign fifo_wr_en = s_axis_tvalid && s_axis_tready; assign m_axis_tvalid = ~fifo_empty; assign fifo_rd_en = m_axis_tvalid && m_axis_tready; assign m_axis_tdata = fifo_rd_data; endmodule这里的关键点是接收侧和发送侧的握手都通过FIFO的满空信号进行背压回传。图里没有画出FIFO内部容量,但核心思想是“内部FIFO不满,源端就能继续写入;内部FIFO不空,目的端就能持续读出”。我建议真实项目中把FIFO的almost_full提前一两个周期接入s_axis_tready,这样可以抵消跨时钟域或组合逻辑的延迟。
4.4 突发与描述符:如何让DMA知道“搬多少、搬去哪”
DMA控制器需要一个任务描述。最简单的描述符包含:源地址/目标地址、总字节数、突发长度、是否产生中断。在FPGA里,如果你没有处理器,通常把这些参数放在一组寄存器里,由控制状态机配置;如果有处理器,则常常把描述符放进DDR的一段专用区域,DMA控制器通过AXI从DDR里把描述符读出来。
描述符的结构可以类似这样:
| 字段 | 位宽 | 说明 |
|---|---|---|
| src_addr / dst_addr | 32或64 | 内存地址 |
| total_bytes | 32 | 本次搬运总字节数 |
| burst_len | 16 | 每个AXI burst的节拍数 |
| flags | 8 | 中断使能、循环搬运等 |
当DMA搬完一个描述符,就跳到下一个描述符,直到“链尾”标志出现。这种方式非常适合长时间连续搬运,因为CPU可以在描述符链中预留很多个任务,DMA只需要沿着链表一直执行。
我实际开发中的一个体会是:不要一开始就做完整的多通道DMA。先让它能处理“单通道、固定地址、固定长度”最简单的情况,跑通了再加描述符链、再加循环模式、再加多通道。每加一个特性,验证难度会成倍上升,提前做好心理准备。
5. 实际仿真和上板验证中,我踩过的几个深坑
5.1 坑1:burst长度跨界,写DDR时直接无响应
第一次自己写AXI写控制器时,我遇到一个诡异的现象:小数据量传输一切正常,一旦传输超过某个长度,DDR控制器就会超时,甚至总线直接挂死。调了很久,最后发现问题出在“突发长度跨越了页边界”。
具体来说,DDR控制器内部按行(row)组织存储,一个行通常数千字节。如果你的突发起始于一个行尾附近,而这个突发又超过了当前行的剩余范围,控制器就需要自行拆分处理,但部分设计不支持跨行自动拆分,导致事务在地址阶段就卡住了。
解决方法是让地址生成器按“当前地址到行边界的剩余空间”动态调整突发长度。在系统层面更省心的做法,是把每个DMA任务的起始地址都按页边界对齐,并且使每个突发长度都小于页大小。这样就能完全避免触发控制器的异常路径。
5.2 坑2:跨时钟域的FIFO不能“一把梭”
AXIS的数据流经常从一个时钟域进入另一个时钟域,很多人的第一反应是“加一个异步FIFO就好”。但异步FIFO并不是万能的。
第一个问题,FIFO的满信号和空信号在跨时钟域时存在不确定性,会导致你的握手逻辑偶尔提前阻塞或者偶尔漏掉数据。如果对实时性要求高,需要使用格雷码指针和两级同步链,并且在设计上允许一定的空/满判断延迟。
第二个问题,异步FIFO处理的是相同位宽的数据流,如果你同时要改变位宽,比如从8位转成64位,简单堆一个FIFO是做不到的,还需要并串转换和拍频逻辑。我建议把“跨时钟域”和“位宽转换”分开处理,先在源时钟域把位宽转好,再进入异步FIFO,不要指望一个IP同时解决两个问题。
5.3 坑3:last信号的时间问题与空包数据
AXIS在传输整包数据时,使用TLAST标识包的最后一个节拍。很多模块在处理TLAST时,只关心“有没有”,不关心它和数据的对齐关系,结果出现一个字节的残包或者空包。
一个典型案例是,如果源端在数据全部传完后,又额外产生了一个只有TLAST、没有有效数据的时钟周期,接收端就会把一个包识别成两个,导致DDR里出现错误的分段。归根结底,是因为源端没管住“当没有数据时不要拉高TVALID”,或者接收端在TLAST时多采了一拍。
我的处理方式通常是:在DMA写控制器中,只在真正有数据需要写入时才发起AXI事务;TLAST来时如果数据位宽大于实际剩余字节,就把TKEEP对应位清零并补齐最后一拍。这个逻辑不复杂,但容易遗漏,必须专门写断言来检查“TLAST有效时必然有数据且TKEEP不能为0”。
5.4 坑4:DDR读写混合时的bank冲突和效率骤降
你明明算了带宽足够,实际跑起来却只有理论值的1/3,这时候大概率是读写混合调度出了问题。DDR控制器虽然内部有调度器,但它只能看到队列里的请求,无法预知你整个系统的读写规律。
比如,你的系统每一帧中会交替执行“写一帧新数据”和“读一帧老数据”,如果粒度太细,DDR总线上会频繁出现读转写、写转读,每个切换周期都要付出额外的损失,带宽自然就下来了。
一个可行的改进是在架构上把读写阶段尽量分开。比如在帧缓存设计里,把“一帧写入”和“一帧读取”错开成两个半帧阶段,或者使用更深的DDR请求队列让控制器能更好地集中调度。这个方法并不能消除所有bank冲突,但往往能让有效带宽恢复接近理论值。
6. 仿真与原型验证:怎么确认数据没丢、顺序没乱
6.1 自己写一个简单的AXIS激励器
项目中验证AXIS模块,不需要一上来就引入复杂的通用验证方法学。最简单的办法是写一个可配置的AXIS激励器,它能按你预设的节拍数产生带计数值的数据流,并且每隔一段时间拉低TREADY,模拟背压场景。
激励器内部维护一个计数器,每输出一个数据,就把计数值的低8位放在TData的低字节上。接收端把收到的数据组合成一个大数组或者直接在校验模块里做累加,就能判断有没有丢数、有没有乱序。
关键点是TREADY的拉低时机要覆盖“刚开始就拉低”“中间任意拉低”“快结束才拉低”三种情况。很多时序问题只有在背压和释放的边界才会暴露,只测连续传输不测背压,基本等于没测。
6.2 特征值与校验:比波形更可靠的验证思路
刚开始做FPGA验证时,我习惯把信号拉到波形里看,但数据量一大,波形看得眼花缭乱,效率很低。后来改成“特征值校验”的思路,省心很多。
具体做法是:写数据时,除了发送递增计数值,还会发送一个随地址变化的伪随机特征值,比如把地址、计数器、固定种子做一个16位CRC。读回时,接收端按相同算法计算CRC并比对。如果CRC一致,说明数据没丢、顺序没乱、字节位置也对。
这个方法尤其适合DMA和DDR链路的验证。因为DDR里缓存的数据不一定按原顺序读回,单纯用计数值可能无法发现字节错位,但CRC能覆盖地址和数据的关联,所以能暴露更隐蔽的错误。
6.3 上板验证之前,先做这几件事
上板调DDR可以说是最折磨人的阶段,因为一旦DDR训练失败,你会分不清是时钟问题、时序约束问题,还是逻辑问题。我的建议是:
- 在纯仿真环境里先跑通“DMA写DDR、DMA读DDR”的完整过程,不要跳过DDR控制器的仿真模型。
- 仿真通过后再上板,先跑一个最简单的“固定地址写固定数据再读回”测试,确认DDR控制器本身没有问题。
- 再用递增数据进行小批量DMA验证,逐步增大数据量,直到覆盖跨行边界和帧边界。
- 最后才把摄像头模组等真实源端接入,观察长时间连续运行是否稳定。
这四步看起来慢,但实际能省的时间远超预期。尤其是第一步,很多问题在波形里一眼就能看出来,等上了板再抓,一个下午可能就没了。
关于这部分,我个人一直有个习惯:上板前一定先做两件事。第一,把源端的数据发生器改成固定模式,让每个Burst的第一个字节等于地址的低8位;第二,读回之后不急着看波形,直接比对CRC或者特征值。这两步基本能过滤掉八成以上的接口错位问题。等你把这套链路调通,再回头看AXI/AXIS、DMA、DDR这三件套,会发现它们只是数据搬运的骨架,真正让你成长的是如何设计数据流的整个往返过程。这也是我建议每个想深入FPGA的开发者,都亲手写一次DMA的原因。