FPGA与DSP高速互连:SRIO接口Verilog开发与调试实战 一块板卡上同时挂着DSP、FPGA和数据采集前端FPGA要把几百兆字节的采集数据送到DSP去算DSP算完还要把结果回传。这种场景里用得最多的互连协议就是SRIO。我刚毕业那会儿第一次接触Serial RapidIO对着Xilinx的IP核手册硬啃了一个多月才把链路跑通踩了不少坑后来把Verilog源码重写了三版才稳定。这次把整个SRIO接口的开发框架、源码模块设计和调试经验整理出来希望对正在做多芯片高速互连的兄弟有帮助。文章里的代码块都是工程里提炼出来的可复用片段适配Xilinx 7系列和UltraScale系列FPGA适用场景覆盖FPGA与DSP/CPU/交换芯片之间的高吞吐数据交换。1. 为什么选SRIO协议背景与互连场景拆解1.1 SRIO与PCIe、以太网的差异很多刚接触高速互连的开发者会问板级芯片互联为什么放着PCIe和以太网不用偏偏选SRIO先说清楚这个问题后面看源码才有方向。PCIe本质是为CPU设计的拓扑是一个root complex带一堆endpoint适合CPU挂外设的场景。但嵌入式信号处理链路里DSP、FPGA、交换芯片之间是对等的经常需要互相发起读写事务PCIe的树形结构用起来反而别扭。以太网比较好上手但协议栈偏重数据要过MAC、IP、UDP这些层次几十纳秒的事务延迟被拉长到微秒级而且TCP/IP的拥塞控制在这种硬实时场景里完全是负资产。SRIO的定位刚好卡在中间它没有PCIe的官僚层级也不像以太网那样带一屁股协议开销。SRIO的读写模型极其简单——发起设备直接对目标设备的内存地址做NREAD/NWRITE或者发DOORBELL这种轻量级中断通知。就这三板斧足够覆盖绝大多数嵌入式异构多芯片协同场景。特性SRIOPCIe以太网典型拓扑点对点交换网络Root Complex树形任意网络读写事务模型直接内存读/写/消息DMA/MMIO报文收发事务延迟数百ns级别微秒级微秒到毫秒级协议栈复杂度低中高适用场景嵌入式多芯片互连CPU外设扩展系统间通信另一个关键点是SRIO天然支持多主多从。SRIO交换网络里任意两个端点都可以互相发起DMA不需要经过某个中心节点。这对多FPGA级联、FPGA池和DSP池之间自由通信的架构来说基本就是量身定做的。1.2 SRIO协议栈构成SRIO协议分三层逻辑层、传输层、物理层。逻辑层定义事务类型包括读请求NREAD、写请求NWRITE、流写SWRITE、带响应写的NWRITE_R、门铃DOORBELL、消息MESSAGE和各类响应。传输层负责端到端路由靠源设备IDsrcID和目的设备IDdstID寻址类似以太网里的源MAC和目的MAC。物理层管链路训练、8B/10B编码、通道绑定、错误检测和恢复。这三层在Xilinx IP核里大部分被固化了。用户能看到的基本就是逻辑层事务的收发通道和传输层的设备ID配置。物理层的链路训练自动完成跑不跑得起来看硬件和配置是否匹配。所以从事Verilog开发的角度看重点精力放在逻辑层事务的构造和解析上就够了。1.3 典型应用场景SRIO最常见的组合是FPGA做数据采集预处理DSP负责算法运算。FPGA通过SRIO把批量原始数据DMA到DSP的DDR里算完再通过SRIO把结果读回来。这种模式下SRIO的高带宽和低延迟优势非常明显。还有就是FPGA和FPGA之间的级联。比如前端多通道采样数据量太大单颗FPGA处理不过来就用SRIO并行分发到后端多颗FPGA上。SRIO交换网络可以灵活组网比PCIe的树形结构更适合这种数据流水线的拓扑。另外很多通信基站的基带处理单元内部主控CPU和信号处理DSP/FPGA之间也是用SRIO直连。应用场景决定了开发重点——你必须把事务生成、优先级控制、流量控制和错误恢复这几个基本功做到位。2. Xilinx SRIO IP核架构与配置要点2.1 IP核版本与生成流程Xilinx的RapidIO IP核Vivado里叫Serial RapidIO Gen27系列和UltraScale系列都有对应版本。不同版本间用户接口略有差异主要是tuser的位域定义和header缓冲区的可读方式。在Vivado的IP Catalog里搜RapidIO就能找到配置向导生成后会得到一个包含物理层、传输层、逻辑层完整链路的加密网表核外加一个可读的示例顶层。生成IP后重点看两个东西一个是IP自带的手册里面详述了用户接口信号的时序和每个tuser位域另一个是示例工程里的VHDL或Verilog封装代码里面基本把IP核所有端口和用法都列了一遍。我的习惯是先把示例工程跑一遍仿真确认信号时序再动手写自己的业务逻辑。2.2 物理层与链路层内部机制物理层主要做三件事参考时钟接收、SerDes收发、8B/10B编解码。SRIO Gen2的线速率典型配置是1.25G、2.5G、3.125G、5G、6.25G等通道数支持1x、2x、4x。链路层在上电或复位后自动执行链路训练训练过程包括通道协商、链路初始化、错误恢复等阶段训练完成后会拉高port_initialized信号。链路训练不通过后面写再多事务逻辑都白搭。所以调试SRIO的第一件事永远是确认链路状态而不是着急看读写数据。Xilinx IP核在AXI4-Lite管理接口里暴露了链路状态寄存器和错误计数这些是排查链路问题的第一手信息。2.3 用户接口信号全景Xilinx SRIO IP核的用户侧接口主要有四对AXI4-Stream通道外加一组AXI4-Lite管理接口s_axis_ireq / m_axis_ireq发起设备的请求通道当前模块作为Initiator发送NREAD、NWRITE等事务时使用。m_axis_iresp / s_axis_iresp发起设备的响应通道收到的响应包比如NREAD读回来的数据从这进来。m_axis_treq / s_axis_treq目标设备的请求通道当前模块作为Target收到别人发来的读写请求时使用。s_axis_tresp / m_axis_tresp目标设备的响应通道对别人的请求做应答时使用。s_axi_ctrlAXI4-Lite从接口用于读写IP核维护寄存器、配置设备ID、查询链路状态和错误信息。每个通道的握手都是标准AXI4-Stream协议tvalid/tready握手成功后数据才有效tlast表示包的最后一次传输tuser携带事务的路由信息、类型信息、事务ID、优先级等元数据。若把IP配置成HELLO模式tdata的第一个beat里会直接给出简化64位头用户侧不用自己拼完整RapidIO包头稍微省点事。2.4 配置选项逐项说明配置IP核时下面这几项直接影响后续Verilog代码的逻辑线速率和通道数必须和对端设备一致否则链路训练铁定失败。如果板上有SRIO交换芯片还要保证时钟参考源符合交换芯片的时钟要求。传输层设备ID宽度有三档8位、16位、32位。这个宽度决定包头里srcID和dstID占多少bit。ID位宽越宽支持的网络节点数越多但包头额外开销也随之增加。开发时选16位比较通用既能满足大多数板级设计也兼容DSP端的默认配置。最大有效载荷长度max payload size的取值范围一般是256字节、512字节、1KB等。这是逻辑层单包能携带的最大数据量直接影响吞吐率。工程里通常建议直接拉满到1KB甚至更高但必须确认对端设备的同样参数否则对端会认为包超长而丢弃。还有一类配置是逻辑层和传输层的使能选项比如使能NWRITE_R、SWRITE、DOORBELL、MESSAGE等事务类型。这些开关决定IP核在数据处理路径上支持哪些操作码。开发时按需开启就好全开容易让内部逻辑变复杂对时序不太友好。提示IP核配置里还有个关键选择是用户接口模式Classic模式和HELLO模式的tdata头布局完全不同。选哪种取决于你习惯解析完整包头还是简化头。我个人倾向用HELLO模式代码更简洁出错概率也低。3. Verilog源码框架从零搭一个可收发的主设备模块3.1 模块划分思路一个实用的SRIO接口工程不会把业务逻辑直接怼在IP核端口上。因为IP核的信号里包含大量的握手协议细节和tuser位域映射直接写业务逻辑会有一堆重复代码。我的做法是中间加一层wrapper把IP核的复杂信号映射成几个语义清晰的事务级接口。整个源码框架可以按功能拆成这几个模块模块名职责srio_ip_wrapper封装Xilinx IP核统一时钟复位映射tuser位域tx_engine发送引擎构造NREAD/NWRITE/DOORBELL等请求rx_engine接收引擎解析响应包并按事务类型分发tid_manager事务ID分配与回收跟踪outstanding请求reg_if用户寄存器接口供上层配置和状态查询这样的好处是IP核版本升级导致tuser位域变化时只需要改srio_ip_wrapper一层业务模块动都不用动。这个隔离思路碰到项目后期换FPGA型号或者升级IP版本时会救你一命。3.2 发送通路状态机NWRITE的完整流程NWRITE是SRIO里最简单的写事务发起方发送一个携带目标地址和数据的请求包目标收到后写入内存不返回响应。从发端视角看就是一个AXI4-Stream包上带着类型信息、目标设备ID、目标地址和payload数据。核心状态机分四个状态IDLE、TX_DATA、DONE。IDLE里收到发送请求时组装header发出TX_DATA里按拍推进payload最后一拍拉tlast。代码框架大概长这样localparam IDLE 2d0; localparam TX_DATA 2d1; localparam DONE 2d2; wire ireq_hsk s_axis_ireq_tvalid s_axis_ireq_tready; always (posedge clk) begin if (!rst_n) begin state IDLE; s_axis_ireq_tvalid 1b0; s_axis_ireq_tlast 1b0; payload_cnt 16d0; tx_done 1b0; end else begin case (state) IDLE: begin tx_done 1b0; if (tx_req_vld !tx_busy) begin tx_busy 1b1; payload_cnt tx_len; // 剩余payload拍数 s_axis_ireq_tvalid 1b1; s_axis_ireq_tdata make_header(tx_addr, DST_ID, tx_tid); s_axis_ireq_tlast (tx_len 16d0); // 只有header时直接结束 state TX_DATA; end end TX_DATA: begin if (ireq_hsk) begin if (payload_cnt 16d0) begin // header已发出且无payload s_axis_ireq_tvalid 1b0; tx_busy 1b0; tx_done 1b1; state DONE; end else begin s_axis_ireq_tdata tx_dout; s_axis_ireq_tlast (payload_cnt 16d1); if (payload_cnt 16d1) begin s_axis_ireq_tvalid 1b0; tx_busy 1b0; tx_done 1b1; state DONE; end payload_cnt payload_cnt - 16d1; end end end DONE: begin tx_done 1b0; state IDLE; end endcase end endmake_header和make_tuser的位域拼接我单独放在srio_ip_wrapper里实现因为这跟IP版本的tuser定义强相关。上面这段状态机是纯粹的事务业务逻辑不管IP核接口怎么变这段代码基本不用动。这个状态机的核心设计思想是把header作为一个独立beatpayload作为后续若干beat用同一个计数器统一管理再通过tlast指示包结尾。这样能够天然适配AXI4-Stream的任意背压节奏无论IP核的tready抖动得再厉害状态机都不会丢beat。3.3 接收通路状态机NREAD响应读取与数据分发NREAD是读请求发出去后目标端会返回一个包含数据的响应包。接收侧的状态机比发送侧多一个等待响应的环节。整体思路发完NREAD请求后发起总线转向响应通道等待m_axis_iresp通道的tvalid拉高然后逐拍取回数据。localparam WAIT_RSP 2d2; always (posedge clk) begin if (!rst_n) begin state IDLE; end else begin case (state) IDLE: begin if (rd_req_vld !rd_busy) begin // 发送NREAD请求带上目标地址和读取长度 s_axis_ireq_tvalid 1b1; s_axis_ireq_tdata make_nread_header(rd_addr, DST_ID, rd_tid); s_axis_ireq_tlast 1b1; // NREAD请求只有header rd_busy 1b1; state WAIT_RSP; end end WAIT_RSP: begin if (m_axis_iresp_tvalid) begin // 响应到来先检查tid是否匹配再接收数据 if (resp_tid_ok) begin rd_data m_axis_iresp_tdata; rd_data_vld 1b1; m_axis_iresp_tready 1b1; if (m_axis_iresp_tlast) begin m_axis_iresp_tready 1b0; rd_busy 1b0; state DONE; end end end end DONE: begin rd_busy 1b0; rd_done 1b1; state IDLE; end endcase end end这里有个细节响应的data在tvalid拉高期间tready可能因为内部FIFO忙而拉低所以m_axis_iresp_tready不能始终保持拉高必须在每拍握手后再评估。接收数据分发时根据tuser里的响应类型字段来判断是NREAD的正常数据响应还是NWRITE_R的完成响应抑或是错误响应。错误响应通常带status字段表示目标侧地址越界、权限不足或者payload超长等异常。收到错误响应比较合理的做法是记录错误类型和对应tid挂到一个错误状态寄存器里供上层读取定位问题。3.4 字节序问题协议大端与接口小端的纠葛RapidIO协议本身规定使用大端字节序也就是高位字节在前。但Xilinx的IP核在用户接口内部已经是转换好的更麻烦的是不同DSP/CPU平台对内存的字节序定义各不相同。联调时如果不先统一字节序数据错位让人排查到怀疑人生。我在项目里总结的排查顺序很简单先在FPGA侧发一个64位的固定pattern比如AABBCCDD_11223344对端读到后记录自己看到的十六进制。如果看到的是44_33_22_11_DD_CC_BB_AA那就是纯字节交换的问题在发送前做一次byte swap即可。字节序的转换位置最好放在业务数据进入发送FIFO之前或者在接收FIFO读出之后不要在拼接header的代码里到处散落byte swap逻辑。集中处理可以保证整个工程的数据流视角统一。4. 核心功能模块源码解读4.1 请求包生成模块请求包生成模块的核心任务是把用户层的我要写一块数据到某地址这种抽象请求转化为符合IP核接口要求的事务级信号。在IP核的HELLO模式下header只需要关注ftype、ttype、tid、dstID、优先级、地址和长度这几个元素。当IP核工作在Classic模式时header的字段更复杂需要用户自己拼完整RapidIO包头的各个字段。但不管哪种模式用户代码始终要明确一件事每个bit最终落在tdata的哪个位置、tuser的哪个bit必须以自己使用的IP版本手册为准。下面这段代码展示的是业务层通过一个简单的用户端口发起写请求的思路// 用户接口 wire tx_req_vld; // 写请求脉冲 wire [31:0] tx_req_addr; // 目标地址 wire [15:0] tx_req_len; // 数据拍数-1 wire [63:0] tx_req_din; // 待发送数据 wire tx_req_rdy; // 忙标志 // 将用户请求映射为事务元素 always (posedge clk) begin if (tx_req_vld !tx_req_rdy) begin wr_addr_q tx_req_addr; wr_len_q tx_req_len; wr_en_q 1b1; end end // 发送header的简洁示例 assign header_tdata[63:0] { 16h0001, // dstID实际项目里填入目标设备ID 8h05, // ftype5 NWRITE 8h00, // ttype辅助信息预留 8hA5, // tid由tid_manager分配 24h00, // 保留字段 8h01 // 其他字段按需组合 };这种代码的优点是业务模块只关心ftype、ttype、tid和地址无需关心底层具体的位映射。但绝对不要在生产代码里真的照抄这段拼位逻辑一定以SRIO IP核的实际接口手册为准。4.2 事务ID管理与超时重发事务ID是SRIO匹配请求和响应的关键。每个在途的outstanding请求必须有一个唯一tid响应回来时要靠tid找到是哪个请求的。最简单的tid管理器是环形分配维护一个计数器每发一个新请求计数器加一达到最大值后回绕。工程上推荐用一个空闲FIFO来做tid池初始化时把所有tid写入FIFO发请求前从FIFO里取一个收到响应后把tid放回去。这样能天然规避tid溢出和重复分配的问题。// 初始化时把一个FIFO填满可用tid integer i; initial begin for (i 0; i 256; i i 1) tid_free_fifo[i] i[7:0]; end // 发送请求时 assign tx_tid tid_free_fifo[rd_ptr]; // 收到响应时 always (posedge clk) begin if (resp_valid resp_tid_match) begin tid_free_fifo[wr_ptr] resp_tid; wr_ptr wr_ptr 1b1; end end比tid管理更考验工程能力的是超时重发。SRIO链路上如果目标端忙可能长时间不响应如果链路本身出错请求包可能直接丢失。此时发起端要能主动超时并在超时后决定重发还是报错。我的做法是维护一个超时定时器超时阈值设为几十微秒。超时后先读链路错误状态寄存器确认没有链路错误再重发同tid的请求。如果连续重发三次还是无响应就把事务标记为失败并上报不再无脑重发。这里要小心对于NWRITE这种写事务重发可能造成数据重复写。所以严格说超时重发只适用于幂等事务比如配置类的维护读写。数据搬运类的操作更稳妥的做法是上报错误由上层应用决策是否重新搬运。4.3 响应解析模块响应解析模块从m_axis_iresp通道接收数据。首先是判断响应类型ftype为13表示这是响应包接着看ttype区分是完成响应DONE还是错误响应ERROR再看status字段判断目标操作是否成功。下面是一个简化的响应解析代码always (posedge clk) begin if (m_axis_iresp_tvalid m_axis_iresp_tready) begin case (resp_type) 4hD: begin // DONE resp_status_q 1b0; resp_cnt resp_cnt 1b1; end 4hE: begin // ERROR resp_status_q 1b1; error_code_q m_axis_iresp_tdata[31:0]; end default: begin resp_status_q 1b1; end endcase end end响应解析之后的数据要按tid找到对应的请求上下文把数据写入正确的位置。工程上会建一个context RAM以tid作为地址存储请求的目标地址、剩余长度、回调指针等。响应到来时用tid查context RAM就知道这批数据该填到哪。这个机制用在大批量DMA场景尤其重要。4.4 DOORBELL和维护事务的Verilog实现DOORBELL是SRIO里非常实用的轻量级通知机制本质是一个携带16位信息的短事务不携带数据用来做中断通知。它的开销远小于发送一个完整的写请求很适合做跨芯片的握手信号。DOORBELL的发送代码非常简单本质上就是发一个单拍header// DOORBELL请求构造 s_axis_ireq_tdata[63:0] { 16h0001, // dstID 8h08, // ftype8 DOORBELL 8h00, // ttype保留 8hA5, // tid 16hC0DE, // info字段可放任意16位自定义信息 16h0000 }; s_axis_ireq_tlast 1b1; s_axis_ireq_tvalid 1b1;DOORBELL的响应DONE是一包很短的报文逻辑上只要确认它的到来即可。工程上我习惯在接收端把DOORBELL信息直接映射成中断寄存器信息字段就是中断号或事件类型这样上层软件处理起来非常方便。维护事务用于读写SRIO端点内部的维护寄存器常见用途是查询对端设备的能力寄存器、配置交换芯片路由表、获取链路状态。维护事务本质上也是一种读写请求区别在于目标地址是维护空间而不再是内存地址空间。很多开发者一开始不知道SRIO还有这层能力结果一遇到调试对端状态只能干瞪眼。维护事务在IP核的AXI4-Lite管理接口上有专门映射也可以通过用户逻辑构造维护请求包。5. 板级调试与避坑实录5.1 链路训练失败的排查链路SRIO调试中超过一半的问题出在链路训练阶段。链路训练失败的表现是port_initialized信号一直不拉高或者拉高了又掉下来。常见原因按概率排列第一是参考时钟不对第二是线速率和通道数不匹配第三是SerDes驱动端端接电阻问题。参考时钟这块SRIO IP核通常需要一对差分参考时钟频率和线速率有对应关系。用示波器测一下差分波形如果幅度太小或者频率偏了链路一定起不来。注意SRIO对参考时钟的抖动有严格要求不能拿普通的低频晶振直接怼上去最好用低抖动差分晶振或时钟芯片。通道数不匹配的问题经常出现在两片FPGA对接的场景一端配置成4x另一端配置成1x链路训练就永远无法协商完成。Xilinx IP核本身支持1x/2x/4x自动协商但前提是IP配置里把支持的最大通道数开足。有些旧版本IP不支持协商那就必须两侧配置完全一致。还有一种情况是链路状态在跑数据时突然掉链子。这种问题一般和信号完整性有关需要查眼图和板级布线单纯改逻辑解决不了。用Vivado自带的IBERT工具扫一遍SerDes眼图是判断高速链路健康状况最直接的手段。5.2 数据校验出错的排查方法链路通了读写事务也正常发起但数据内容不对这是第二阶段最常见的坑。数据不对通常是下面几个原因第一个就是前面说的字节序问题。RapidIO大端、Xilinx IP接口小端、DSP侧又是另一个字节序三层一叠加错位的方式五花八门。不要试图靠肉眼比对数据直接把发送的pattern和接收的pattern打印出来转换成连续内存对比一眼就能看出是字节交换还是字交换。第二个是地址对齐问题。SRIO对事务的地址和长度有对齐要求。如果payload宽度是64位那么NWRITE的起始地址必须按8字节对齐否则目标端会返回ERROR响应。很多DSP侧驱动对地址对齐非常严格FPGA侧发了个奇数地址过去直接报错。代码里加入地址低位的断言或校验能省下大量联调时间。第三个是max payload size不匹配。发送端认为能发1KB的包对端配置的max payload只有256字节对端会把超出部分的包当作格式错误丢弃或者回复错误响应。联调前第一步就是互换设备能力寄存器信息确认双方max payload一致。5.3 性能优化要点链路和数据的正确性搞定后才会进入性能优化阶段。SRIO的吞吐率瓶颈通常不在线速率而在事务层。一个NWRITE包的header要占一拍payload越长有效带宽越高。所以第一个优化点是尽量发大包不要用一串256字节的小包去搬运大数据。第二个优化点是合理使用SWRITE。SWRITE是流写事务不带事务ID也不需要响应包头开销比NWRITE更小适合纯数据流搬运场景。但SWRITE不保证送达所以在数据可靠性要求极高的场景要谨慎使用必要时候配合消息事务做端到端确认。第三个优化点是outstanding事务深度。IP核配置和用户逻辑共同决定了同时在途的请求数量。outstanding越多目标端的读写流水线越不容易因为等待而停顿但代价是需要更多的context RAM和更复杂的tid管理。工程上一般先在IP核里配置一个较大的outstanding数量再看用户逻辑能不能支撑对应数量的上下文跟踪最后找到一个平衡点。性能实测时数据要量一下实际吞吐率而不是直接拿线速率算理论带宽。SRIO 5G线速率、4x通道的物理链路带宽约2.5GB/s刨去8B/10B编码开销和包间隙实测能跑到1.8GB/s以上就是比较健康的状态。如果实测只有几百MB/s优先看payload长度和outstanding深度。5.4 常见问题速查表把项目里遇到的高频问题和排查手段整理成下面这张表调试时对照着查能少走很多弯路故障现象可能原因排查方法port_initialized一直为低参考时钟频率/幅度异常示波器测差分时钟比对配置速率链路能建立但跑一段时间断开线缆/连接器接触不良、SerDes链路裕量不足用IBERT扫眼图检查通道损耗NREAD返回ERROR响应地址越界、地址未对齐、未授权的访问空间读错误状态寄存器检查地址映射响应永远等不到事务ID匹配异常、超时设置太短检查tid分配抓iresp通道信号数据全是错位字节序问题发固定pattern对拍调整byte swappayload一大就丢包max payload size两侧不匹配核对双方能力寄存器重发后数据重复NWRITE超时后无脑重发重发只用于幂等事务数据搬运交给上层提示所有寄存器调试都尽量通过AXI4-Lite管理接口访问不要试图在用户逻辑里解析链路层信号。IP核已经把错误状态、链路状态这类信息都映射到寄存器空间了直接用即可比自己到处抓信号靠谱得多。写SRIO接口这几年下来我自己最大的体会是这个协议看着复杂其实只要把链路层交给IP核用户逻辑层只需要做好三件事——构造正确的事务包、管理好事务ID、把数据路径上的字节序搞清楚。调试顺序永远是从链路到事务再到性能一步都不要跳。尤其是字节序这个问题每一次联调它都阴魂不散索性在工程里做一次集中处理后面就再不用管了。如果你刚开始接触这个接口建议先在Xilinx的仿真环境里跑通loopback再上板对接真实设备整个节奏会稳很多。SRIO这套事务模型后续还可以继续扩展出多端点交换网络的收发调度、消息事务和基于门铃的中断协议都是一通百通的活。