☰
FPGA高速互联SRIO实战:Vivado下事务级通信平台设计与调试
2026/10/21 16:26:31 网站建设 项目流程

很多做FPGA高速互联的工程师,第一次接触SRIO都是被那堆ireq、iresp、treq、tresp信号劝退的。相比PCIe有成熟的DMA和AXI桥接方案,Serial RapidIO在Xilinx Vivado里落地的难度几乎全在用户侧——IP核只保证链路层和逻辑层的协议处理,至于怎么把寄存器命令组织成一包合法的请求、怎么处理握手与反压、怎么识别和维护事务的响应,全部需要自己写Verilog。这种时候,一个包含维护包、读写和门铃功能的事务级源码工程就显得很珍贵了。它不只是一个示例,更像一个可以当底座用的SRIO通信平台,把协议里最常用的三类事务用可读、可扩展的代码实现出来,能帮人省掉大量从零啃手册的时间。

这篇内容就从这个视角展开:先说清楚SRIO事务模型的底层逻辑,再讲Vivado中IP核的配置关键,然后拆解工程里用户逻辑的组织方式、三大功能模块的实现细节,最后分享我在实测中走过的排查链路。不管你是准备把SRIO引入自己的系统,还是想在这个工程基础上做二次开发,希望能少踩几个坑。

1. 先弄明白SRIO在传什么:事务模型决定代码怎么写

1.1 一个请求包从产生到落地的完整路径

SRIO的体系结构分成逻辑层、传输层和物理层三层。大多数人在调试时关心的其实是逻辑层行为,但传输层的destID、物理层的链路协商往往才是问题所在。

一个典型的SRIO读请求,从本端发起到远端返回数据,整个过程是:用户逻辑构造一个NREAD请求包,包含FTYPE、TTYPE、目标地址、事务ID、目的设备ID;IP核把这个包交给传输层,加上源设备ID和目的设备ID;再往下走,物理层会在包前面加物理字和CRC校验,通过SerDes发送出去。远端收到后,IP核做物理层校验、传输层路由,逻辑层再把请求从treq接口递交给用户逻辑。如果目标端决定响应,它构造一个带数据的RESPONSE包,沿相同路径返回。

理解了这条链路就会发现一个关键点:本地用户逻辑看到的事务(ireq/treq接口上的包),和线上真正传输的包是两回事。IP核在中间作了大量协议封装和转换。这也是为什么很多人调试时盯着线上波形,却迟迟定位不了逻辑层错误——你要找的信号其实在IP核的用户接口上,而不是在GT的收发数据上。

1.2 维护、读写、门铃在协议语义上的差别

SRIO的事务种类按FTYPE划分,每种FTYPE下再按TTYPE区分具体操作。工程里涉及的三大类事务,对应关系如下。

事务FTYPETTYPE是否需要响应典型用途
NREAD0x02(请求)0x04是,返回读数据远端存储器读取
NWRITE0x02(请求)0x05否远端存储器写入
NWRITE_R0x02(请求)0x06是,返回完成响应需要确认的写操作
SWRITE0x02(请求)0x07否大数据流写入
DOORBELL0x01(门铃)0x00否事件通知、中断触发
维护读0x02(请求)0x00是读取远端配置寄存器
维护写0x02(请求)0x01是配置交换路由、控制寄存器

这张表值得贴在电脑边上看。工程里“读写”通常指的是NREAD/NWRITE/NWRITE_R/SWRITE这一类数据事务;“门铃”是FTYPE=1的事件通知机制,它不携带地址和数据,纯粹靠一个包传递信息;而“维护”虽然也是读写的语义,但它访问的是端点内部的配置寄存器和设备ID寄存器,走的是另一套路由寻址方式。

这三类事务在源码实现上的差别会在后面的代码拆解里看到,但先记住结论:读写事务处理的是“地址+数据”,门铃事务处理的是“信息”,维护事务处理的是“配置空间”,三者的构造、响应、异常处理逻辑完全不同。

2. Vivado里的IP核配置:链接参数和逻辑接口的分工

2.1 链路参数配置中最容易忽略的地方

在Vivado中例化Serial RapidIO Gen2 IP核,配置页面第一层是链路参数:线速率、通道数、参考时钟。这些参数直接决定了GT的位置和约束,上板后想改,只能重构bitstream。

我自己踩过的一个典型问题是参考时钟选择。SRIO IP核的GT参考时钟通常用125MHz或156.25MHz,配置页面选错时钟频率,Link Initialization会一直卡在Port OK之前的状态。另一个容易被忽略的是线速率和参考时钟的倍数关系,比如3.125Gbps的线速率在某些器件下要求156.25MHz的参考时钟,这是由GT的PLL分频结构决定的。配置代码生成后,务必检查example design里的时钟约束文件,确认IBUFDS_GTE4的引脚和频率注释与配置页面一致。

链路宽度方面,如果只是做点对点调试,1x链路完全够用;要跑大数据吞吐,至少用2x或4x。要注意的是,链路宽度设定为4x,不代表逻辑层一次性给你4x64位的数据总线——用户接口的数据宽度默认是64位,按ready/valid握手传输。链路宽度影响的是物理层的并行通道数,和逻辑侧接口位宽并没有直接等号关系。

2.2 ireq/iresp和treq/tresp四个接口的分工

SRIO IP核逻辑层给用户提供了四个方向的接口,很多初学者会把它们搞混。弄清楚这四个接口,就基本读懂了这个工程的代码框架。

ireq接口是本地发起请求的出口。本地用户逻辑作为发起端,生成NREAD/NWRITE/门铃/维护包,从ireq发出去。与ireq对应的iresp接口,是本地发起的请求在远端被执行后,远端回给本地的响应包入口。也就是说,本地请求远端读数据,读回来的数据从iresp进来。

treq接口是本地作为目标端时接收到的请求入口。远端发起访问,如果目标是本地的存储空间或寄存器,请求包会从treq交给用户逻辑处理。而tresp接口是本地作为目标端时回给远端的响应出口。本地必须在收到treq后,根据请求类型判断是否需要构造RESPONSE包,从tresp返回。

一句话总结:ireq/iresp服务于“本端往外发起的请求”,treq/tresp服务于“本端响应远端发来的请求”。源码工程里,四个接口分别对应四个独立的处理模块,这样调试的时候可以单独拉某一组信号来看,不会互相干扰。

2.3 握手信号的真实时序:rdy、vld、ack谁说了算

SRIO逻辑层的用户接口握手方式比较特殊,不是简单的valid/ready,而是three-way握手。以ireq为例,发起端置ireq_vld,IP核置ireq_rdy,当两者同时为高,形成握手,发起端在下一拍还要观察ireq_ack,ack为高才表示IP核真正接受了这个包。

这里的坑在于:vld和rdy同时为高并不代表当前拍的包头一定被接受,必须以ack为准。IP核内部有自己的缓冲队列,当队列满时rdy会拉低,但有时候rdy为高而ack恰好为低,说明IP核正在处理某些内部冲突,这一拍的数据不能被当作有效传输。

实测中我发现,严格按照“vld拉高后等待rdy,rdy拉高后等ack再推进状态”的时序写状态机,是最稳妥的方式。千万不要只用vld和rdy两信号握手就推进请求包,碰上IP核内部重传和重排序,非法事务就会从这里产生。

3. 用户逻辑的组织方式:请求生成、响应配对与异常恢复

3.1 请求构造状态机的设计思路

工程里对每个事务定义了一组寄存器命令,用户通过AXI-Lite写入命令寄存器,触发事务执行。核心状态机一般分成IDLE、WAIT_RDY、LATCH、WAIT_ACK、SEND_DATA几个状态。

以一次NWRITE_R写事务为例:状态机先进入WAIT_RDY,检测到ireq_rdy为高后,拉高ireq_vld,并输出包头信息——控制信号ireq_type置为IO请求,ireq_ftype置为0x02,ireq_ttype置为0x06,同时给出目的设备ID、事务ID、地址。下一拍如果ireq_ack为高,则进入SEND_DATA状态,按突发长度逐拍发送数据。每拍数据要同时给出ireq_wr_en和ireq_byte_en,这里最容易出错的是字节使能,如果按64位数据总线算,一个地址对应8字节,字节使能必须精确到每次写操作实际覆盖的字节范围。

对于读请求(NREAD),构造要简单一些:包头之后不需要发送数据,但如果使能了load/store扩展,还需要处理ireq_wr_be信号,表示读操作的字节使能。很多实现里把这个信号固定为全F,实际上对于非对齐访问,这个信号不能随便填,否则读返回的数据顺序会错乱。

3.2 响应配对是怎么完成的:事务ID管理

响应包和请求包通过事务ID(tID)配对。本地发起多个事务时,每个未完成请求占用一个唯一的tID。实际工程里用的是一个简单的tag表——一个tID位宽大小的队列,发起请求时从队列头部分配一个空闲tID,收到对应的响应包后,再将该tID回收。

我见过不少实现省掉了这个机制:每次只允许一个事务在飞行中,发完一个请求必须等响应回来才发下一个。这确实简化了逻辑,但吞吐会非常低。在SRIO场景里,往返延迟可能达到几百纳秒,如果一直单线程跑事务,链路的利用率连10%都到不了。源码工程里的做法是维护一个深度为16的tID队列,配合一个以tID为索引的完成表,每个响应进来后,直接用iresp_tid索引完成表,将数据写入对应条目,同时置完成标志。

这个设计有一个隐蔽问题:tID复用。响应包在路上延迟比较长时,请求端可能已经超重发了一个相同tID的新请求,此时晚到的旧响应就会和新请求的响应混淆。解决方案是给每个tID增加一个序列号位,或者确保在tID回收后等待足够长的空闲时间再分配。工程里用的是后者——tID从完成队列回收后,必须经过一个计数器延迟才能再次分配,这个延迟通常设置为最大响应时间的一半。

3.3 维护事务响应超时的判断逻辑

维护事务最容易出现的情况是发出去的维护读请求石沉大海。比如远端交换芯片的路由表还没配置,或者hop_count不对,维护请求根本到不了目标设备,自然没有响应。

工程里的处理方式很典型:每个维护事务启动时,同时启动一个超时计数器,计数器的超时门限由寄存器配置,默认值设在几十微秒到几百微秒之间。如果超时计数器溢出时iresp还没有匹配的响应进来,状态机会主动向IP核发送一个清除事务的操作,并把维护事务挂在错误队列里,向上位机报错。

这里的关键是,SRIO协议本身没有定义“事务超时”机制,IP核不会替你判断请求是否超时,这个功能完全要靠用户逻辑实现。所以在源码工程中,维护事务的超时判断是一个独立的模块,不跟读写事务共用一套状态机,原因是维护事务经常是用于系统初始化的,此时链路上根本没有其他流量,单独做判断逻辑会更清晰。

4. 模块级拆解:门铃、读写和维护的Verilog实现要点

4.1 门铃事务:最简单但最容易被忽略的细节

门铃事务在源码工程里实现起来非常简单:一个包长度通常只有4字节,没有地址,FTYPE为0x01,TTYPE为0x00,信息字段放在包头的bit15到bit0中。发起门铃时,ireq接口只需要发送一拍控制信号、不需要发送任何数据,也不需要等待响应。

不过有两个细节值得注意。第一,门铃事务的优先级。SRIO包头的优先级字段影响交换芯片的处理顺序。如果同时有大批量写事务和门铃事务在飞行中,门铃优先级低的话,可能被堵在交换芯片的几个时钟周期之外,导致延迟抖动变大。工程里把门铃优先级设为最高,对应的ireq_prio置为3。

第二,接收侧的处理。远端通过treq接口收到FTYPE=1的包后,用户逻辑需要解析出信息字段,触发一个中断或者置位某个寄存器。因为门铃不携带地址,接收侧不必做地址译码,只需在treq_vld和treq_rdy同时有效的那一拍锁存数据。

功能上,门铃最常用于中断通知。我在一个模拟项目X里用它来同步远端DMA完成状态,一个门铃包发出,远端立刻拉高中断,比轮询读状态寄存器的延迟低了几个数量级。

4.2 NREAD/NWRITE/SWRITE的地址对齐与字节使能处理

读写事务是源码工程里代码量最大的一部分,容易出错的地方集中在地址对齐和字节使能推导上。

SRIO要求所有读写地址按32位边界对齐。但用户在寄存器里填的地址不一定是4字节对齐的,所以工程里在事务发射前做了地址修正:如果地址低两位非零,则自动向下取整,对应的字节使能保留实际需要访问的字节位。比如一个从地址0x02开始的8字节写操作,前4字节只有高2字节有效,后4字节全部有效,那么第一次发送数据时byte_en为0x30,后续数据拍byte_en全为0xFF。这个逻辑写起来不复杂,却是最容易引入数据错位的地方。

再进一步说,64位总线下地址的步进是8字节。NREAD读请求里ireq_addr填的其实是8字节块的起始地址,而实际要读的数据可能只占其中的一部分。返回的读数据是完整64位总线,用户逻辑必须根据字节使能从返回数据中提取有效字节。工程的读响应模块里专门有一段bit-shift逻辑,功能是“根据ireq_wr_be将返回的64位数据对齐到输出总线的低有效位”,这样上层拿到的是一个严格对齐后的数据,方便直接写入FIFO。

SWRITE(流写)的实现又不一样。SWRITE固定地址连续、固定8字节对齐,且比分片的NWRITE效率更高,因为它不需要每包都携带地址。在FPGA里做大块DMA搬移,SWRITE是首选。工程里把SWRITE单独拆成一个模块,用状态机连续发送数据包,包与包之间几乎没有间隔,配合IP核的acc_features,可以把链路利用率推得很高。

4.3 维护读写的hop-by-hop与寄存器偏移

维护事务访问的是远端设备的配置空间,与普通存储器访问最大的差异是维护包的hop_count字段。

SRIO网络里,交换芯片通过维护包配置路由表。当本地要向远端设备发维护读时,这个请求在交换芯片之间每跳一次,hop_count减一;减到零的那一跳才被当前设备接受,用于访问它的配置寄存器。这个机制决定了:请求设备必须知道目标设备位于第几跳,才能正确设置hop_count。

工程里维护事务模块的做法是把hop_count做成可配置寄存器,初始化流程里通过逐步增加hop_count的方式扫描整个网络,探测哪个设备ID是可达的。扫描到一个端点后,就可以用维护读读取设备ID寄存器和扩展特性指针,做路由表配置。

维护读的寄存器地址由两部分组成:维护事务的地址字段用于指定要访问的config空间偏移。例如读一个端点的设备ID寄存器,地址填0x60,hop_count填1,事务发出后,响应包的tresp_data会返回该端点的设备ID。维护写用于配置路由表,地址指向交换芯片的路由表寄存器,数据为被配置设备ID,实际上是让交换芯片知道,去往某个设备ID的包应该从哪个端口转发。

因为维护事务直接面向系统级配置,它的实现必须非常谨慎。工程里在维护写之前会先做一次维护读验证,确认目标设备存在且状态寄存器正常,才发写请求。这个“先读后写”的机制,避免了一大堆“看起来写成功了实际上目标不存在”的诡异问题。

5. 实测调试验证:从Link Up到数据正确的三个关键阶段

5.1 链路已连接但事务卡死的排查链路

拿到板子,先把example design跑起来,用ILA抓link_status和port_initialized信号。这两个信号正常后,说明物理层和传输层已经初始化完成,但此时发出的维护读请求却可能一直没有响应。

我遇到过的典型场景是:维护请求发给一个尚未配置路由表的交换芯片,请求包被发到错误的端口然后丢弃;此时illegal transaction错误被记录在端点状态寄存器里,后续的维护事务全部被IP核阻塞。排查方法是用维护读去读本端自己的错误状态寄存器,看一眼bit14到bit15的type字段,就能定位是“packet was discarded”还是“request not accepted”。绝大多数初始化卡死都从这里找到原因。

工程里还内置了一个“链路诊断状态机”,能按顺序输出本端设备ID、Link Timeout、错误状态字、成功接收计数等关键寄存器。初始化失败时直接把这些寄存器抓上来比对,比盲查IP核内部转换消息快得多。维护事务的响应速度本来就不是固定的,有的交换芯片响应能拖到几百纳秒,状态机里需要留足余量,否则很容易把自己误判成超时。

5.2 数据错位与端序问题

链路通了、事务也有响应,但远端读回来的数据和写入的不一致,这是最常见的问题。一般情况下,先把传出数据设为0x55AA55AA这种特征值,再做环回测试。环回正常后接真实远端,一旦数据错位,优先检查的是地址转字节偏移时端序是否匹配。

SRIO本身规定的是大端序,而FPGA处理器核普遍按小端序处理数据,因此必须在接口处做一次变换。工程的解决方式是:对所有写入远端的数据,先按字节交换再送入ireq数据总线;所有读回的数据,先做字节交换再写回AVMM总线或FIFO。具体交换规则是:在64位数据总线上,最低字节和最高字节互换,中间两个字节互换。这个逻辑占的资源不多,却几乎决定了通信正确性。

如果在调试中懒得做成逻辑,也可以在寄存器配置里加一个“端序模式”开关,配置为0走直通,配置为1走交换,调试时通过翻转配置比对数据的差异。多了一个寄存器位,整个调试工作量能减少不少。

5.3 效率不如预期时的优化方向

事务层调通后,最关心的就是吞吐了。如果SRIO线速率5Gbps但实际读写吞吐只有两三Gbps,先别急着怀疑IP核——问题大概率出在未完成事务数不够。

SRIO全双工吞吐要充分发挥,必须让多个事务同时在线上飞行,也就是保持足够多的未完成读请求。工程里tID队列深度是16,如果每次只发一个NREAD,往返延迟下吞吐一定上不去。把队列深度提升到16之后,同样的链路条件下,读吞吐大约能提升到单事务模式的6倍左右。

另一个容易被忽略的是包长度。SRIO单包最大有效负载是256字节,但IP核默认配置可能把最大负载限制在64字节。如果你一直用默认配置,链路上每个包都只有64字节,包间隔和包头开销会占掉一大截有效带宽。修改IP核的maximum payload size为256字节,配合SWRITE突发传输,实测下来吞吐提升非常明显。

再有就是中断合并策略。别让每个门铃事务都直接触发一次主机中断,中断响应、保存现场、恢复现场的开销会浪费大量时间。工程里让门铃先写进FIFO,由中断聚合模块在FIFO达到一定水位或者超时后再统一上报,CPU侧处理SRIO事件的效率能提升一个档次。

就我个人实际操作中的体会,SRIO源码工程调通的标志不光是数据读写得对,而是在异常情况下能从维护事务的错误寄存器里快速定位问题,能利用未完成事务深度把带宽压榨出来。这三类功能模块合在一起,才算是一个真正能落地使用的SRIO通信平台。接下来你可以试着往这个框架里加消息事务或数据流DMA逻辑,地址映射和状态机结构都不用推翻重来,这也是我建议在这个基础上二次开发而不是另起炉灶的原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询