
简介随着SoC中主设备数量激增传统总线和交叉开关在带宽、时序收敛方面面临瓶颈。片上网络NoC采用Mesh拓扑和逐跳转发每个节点仅需五个端口带宽随节点数近似线性扩展。数据以固定格式包传输通过XY路由算法先对齐X坐标再对齐Y坐标实现无死锁路由。Verilog实现时将路由器拆分为路由计算、仲裁、交叉开关和输出寄存器四部分可清晰描述数据通路。仲裁器解决多端口竞争crossbar负责选通数据结合参数化generate语句可快速搭建4x4网格。该模型可通过testbench注入数据包并观测每跳vout/dout波形验证传输正确性。该设计可作为FPGA原型和NoC教学的基础框架适用于多核处理器互联、数据采集阵列等场景。1. 为什么用Mesh拓扑替总线替交叉开关片上网络数据传输模型起点这个标题里的「路由器网络」不是互联网里的那个路由器而是片上网络NoC中的路由器节点。SoC 里的 CPU、DMA、AI 加速器、DDR 控制器越来越多传统 AMBA 总线或 AXI 交叉开关在 8 个以上主设备时会出现明显的带宽争用、时序收敛困难和布线拥塞。Mesh 拓扑把每个主从设备都变成一个小路由器数据以包为单位按照坐标逐跳转发到目标节点。用 Verilog 直接描述这套转发模型可以在仿真波形里清楚看到数据包沿 X、Y 方向一站一站移动既能当 FPGA 原型验证的基础框架也能作为 NoC、多核互联、数据采集阵列项目的起点。这篇教程从数据包格式、XY 路由算法、端口仲裁、crossbar 选通到顶层互联完整搭建一个 4x4 的二维 Mesh 路由器网络传输模型并给出可运行的 testbench 教程。2. 路由器网络的Mesh拓扑与数据包模型为何逐跳转发比集中式交换更耐扩展2.1 从总线到Mesh带宽瓶颈与连线长度让逐跳转发成为必然总线型互联共享一份数据通路主设备数量一增加仲裁延迟、线负载和功耗都会同步上升。AXI 交叉开关在端口少时表现很好但交叉开关的交叉点数量随端口数平方增长8 端口还能接受16 端口时布线面积、逻辑延迟和功耗都会显著恶化。二维 Mesh 的每个路由器节点只有东、西、南、北、本地 5 个端口端口数量不随全网节点数增长链路永远只发生在相邻两个路由器之间时序模型固定在一个 hop 的延迟上。Mesh 的数据传输模型采用包交换和逐跳转发每个节点收到包后根据包头的目标坐标决定送往哪一个方向下一跳节点继续重复这个判决。这样的流水线结构天然能打拍FPGA 上每个路由器的处理深度可以控制在两到三个时钟周期。代价是包延迟会随跳数增加但吞吐量不会像总线那样随节点数上升而下降这正是路由器网络在数据密集型场景下胜出的关键。总线型 所有主设备共享一份带宽节点越多每设备实际带宽越低。 交叉开关 交叉点 O(N^2)端口规模受布线资源限制。 二维Mesh 端口固定为5链路只在邻居间展开带宽随节点数线性扩展。从实现角度看Mesh 路由器的数据通路非常规整Verilog 描述时最容易抽象成「路由计算 仲裁 交叉开关 输出寄存器」四段式结构。这个结构与实际 NoC 芯片里的路由器微架构基本一致用 FPGA 做过一次原型后移植到 ASIC 流程时思路也能直接复用。2.2 数据包格式设计坐标头加载荷的代价与收益Mesh 路由器网络的数据传输模型需要一种固定长度的包头才能让路由判决组合逻辑在单周期内完成。常见做法是把目标坐标直接拼在数据载荷前形成一个扁平的数据包矢量字段位宽含义dest_yADDR_W 位目标节点行坐标dest_xADDR_W 位目标节点列坐标payloadDATA_W 位实际数据载荷这个模型里包头就是坐标头不包含源地址也不包含包序号。如果需要构建完整的数据传输模型可以再加 1 位包有效标志、若干位包类型字段但最小可跑通的 Mesh 路由器只需要坐标头和载荷两部分。坐标放在高位还是低位会影响解码逻辑建议统一约定 dest_y 在高位、dest_x 在中位、payload 在低位例化参数一改所有地方都用同一套位段定义。包矢量排布以 ADDR_W4、DATA_W8 为例共 16 位 bit[15:12] dest_y bit[11:8] dest_x bit[7:0] payload固定长度包头的好处是每个路由器节点在做路由判决时只需要从输入总线的固定位段取出两个坐标再和当前节点坐标做比较不需要解析变长字段也不需要缓冲整个包来做状态机。坏处是灵活性受限跨 Mesh 传输长数据时要把数据切成多个固定长度包切包与重组逻辑要由上位模块处理路由器节点本身只负责投递。2.3 Verilog模块划分路由判决、仲裁与交叉开关分离Mesh 路由器节点的 Verilog 实现我习惯拆成四个功能块这样仿真定位和参数调节都会清晰很多输入寄存器或 FIFO暂存从各方向进入的包避免上游数据突然到达时发生组合逻辑竞争正规设计中每个方向一个独立 FIFO。路由计算块RC比较当前节点坐标与包头目标坐标输出该包应当转发的方向。仲裁块Arbiter多个输入端口同时请求同一个输出端口时用固定优先级或轮询策略产生 grant 信号。交叉开关与输出寄存器Switch根据 grant 信号从对应输入端口选出数据锁存到输出寄存器把 vout 置为有效。关键点是两个一是输入端口命名必须明确「从哪个方向来」不能写成「向哪个方向输入」二是路由计算只决定包从哪个口出去真正的数据搬移发生在仲裁之后的交叉开关里。很多 Mesh 模型跑不通都是因为把这两步混在一个 always 块里导致时序一复杂就出现数据选错方向的问题。这样拆分之后顶层 mesh_top 可以用 generate 循环把多个 router 节点例化出来再用一组连线把相邻节点的输出口接到对方对应的输入口就完成了二维 Mesh 的物理拓扑搭建。后面的 Verilog 教程也按这个模块划分展开。3. 用Verilog实现Mesh路由器核心逻辑XY路由、端口仲裁与crossbar选通3.1 路由器端口定义与参数化五方向输入输出怎么命名不踩坑Mesh 路由器节点的端口设计先从参数和输入输出方向开始。以下代码直接对应 2D Mesh 中任意一个路由器的端口声明// mesh_router.v // 五端口路由器节点东(E)、西(W)、南(S)、北(N)、本地(L) module mesh_router #( parameter ADDR_W 4, // 单个坐标轴位宽支持 0~15 行列 parameter DATA_W 8, // 数据载荷位宽 parameter PKT_W DATA_W 2*ADDR_W, // 数据包总位宽 parameter ROW_COORD 0, // 当前节点行坐标 parameter COL_COORD 0 // 当前节点列坐标 )( input clk, input rst_n, // 输入方向命名din_e 表示从东边进来的数据vin_e 是有效信号 input [PKT_W-1:0] din_e, din_w, din_s, din_n, din_l, input vin_e, vin_w, vin_s, vin_n, vin_l, // 输出方向命名dout_e 表示向东边发出去的数据 output reg [PKT_W-1:0] dout_e, dout_w, dout_s, dout_n, dout_l, output reg vout_e, vout_w, vout_s, vout_n, vout_l );这段代码背后有一个容易踩的命名坑din_e是我从东边邻居那里收到、准备做路由判决的数据而dout_e是我判决之后要送给东边邻居的数据。如果命名写成data_in_east很容易把「收到东侧数据」理解为「发送到东侧数据」后面对接线时全乱。建议一律用「从东边来」和「向东边去」这两个角度来理解输入输出方向。方向输入信号输出信号互连对象东 Edin_e / vin_edout_e / vout_e东侧邻居的西口西 Wdin_w / vin_wdout_w / vout_w西侧邻居的东口南 Sdin_s / vin_sdout_s / vout_s南侧邻居的北口北 Ndin_n / vin_ndout_n / vout_n北侧邻居的南口本地 Ldin_l / vin_ldout_l / vout_l上层功能模块参数化的ROW_COORD和COL_COORD在 top 例化时通过generate循环自动生成不需要手写每个节点的坐标。这样调整 Mesh 尺寸只改ROWS和COLS两个顶层参数。3.2 XY路由判决先对齐X再对齐Y的确定性无死锁路由XY 路由是最适合 Mesh 的消息传递模型起步算法数据先从当前节点沿 X 方向走到目标列再沿 Y 方向走到目标行。整个过程只在一个维度上变化不会形成循环依赖因此天然无死锁。以下代码实现了包从本地口注入时的路由方向计算// 从本地输入包中取出目的地址位段 wire [ADDR_W-1:0] dest_y din_l[2*ADDR_WDATA_W-1 : ADDR_WDATA_W]; wire [ADDR_W-1:0] dest_x din_l[DATA_WADDR_W-1 : DATA_W]; // 当前节点的坐标由例化参数给出 wire [ADDR_W-1:0] cur_x COL_COORD[ADDR_W-1:0]; wire [ADDR_W-1:0] cur_y ROW_COORD[ADDR_W-1:0]; // 输出方向编码含义为从当前节点向哪个方向转发 localparam TO_LOCAL 3d0, TO_EAST 3d1, TO_WEST 3d2, TO_NORTH 3d3, TO_SOUTH 3d4; reg [2:0] route_dir; always * begin if (cur_x ! dest_x) begin // 目标列在右侧向东转发在左侧向西转发 if (dest_x cur_x) route_dir TO_EAST; else route_dir TO_WEST; end else if (cur_y ! dest_y) begin // X 对齐后再处理 Y 方向 if (dest_y cur_y) route_dir TO_SOUTH; else route_dir TO_NORTH; end else begin // 行列都相等已经到达目标节点送往本地口 route_dir TO_LOCAL; end end坐标比较是纯组合逻辑不依赖状态机因此路由判决可以在一个时钟周期内完成。注意cur_x和cur_y的取值来自COL_COORD和ROW_COORD不要写反否则 4x4 Mesh 里奇数行和偶数行的路由方向会整体错乱仿真时包会在局部区域反复绕圈。实际使用中路由计算结果应该对每个输入方向都做一份我这里只画了本地口注入的路径其他四个方向用同一段逻辑复制即可。XY 路由确定性的代价是路径固定流量热点集中的场景会出现局部链路拥塞。后续要改成自适应路由或转向模型时只需替换route_dir的求值逻辑仲裁和交叉开关部分不需要改动这正体现了模块分离的优势。3.3 东口仲裁与crossbar选通独热码grant怎么决定数据去路路由判决只解决「这个包要去哪个方向」不解决「同方向冲突时谁先走」。Mesh 里很可能出现北、南、本地三个口的包都要往东走的情况这时需要仲裁器产生一个独热码 grant交叉开关按照 grant 选中其中一个输入端口的数据。下面是东输出口的仲裁和选通实现// 每个输入方向的包是否请求东口这里省略各方向route_dir的计算代码 wire req_e_l vin_l (route_l TO_EAST); wire req_e_n vin_n (route_n TO_EAST); wire req_e_s vin_s (route_s TO_EAST); wire req_e_w vin_w (route_w TO_EAST); // 固定优先级本地 北 南 西即本地数据优先占用东口 wire [4:0] grant_e; assign grant_e req_e_l ? 5b10000 : req_e_n ? 5b01000 : req_e_s ? 5b00100 : req_e_w ? 5b00010 : 5b00000; // 交叉开关选通按 grant 把对应输入端口的数据锁存到东输出 always (posedge clk or negedge rst_n) begin if (!rst_n) begin dout_e {PKT_W{1b0}}; vout_e 1b0; end else if (grant_e ! 5b00000) begin case (grant_e) 5b10000: dout_e din_l; 5b01000: dout_e din_n; 5b00100: dout_e din_s; 5b00010: dout_e din_w; default: dout_e din_e; endcase vout_e 1b1; end else begin vout_e 1b0; // 没有 grant 时保持原值避免产生新的无效跳变 end endgrant 的低位到高位对应不同输入方向独热码的好处是 case 分支可以直接用 5 个常量匹配综合工具也能把它推断成并行 mux。固定优先级实现简单但存在饿死风险本地口如果频繁注入数据北口方向的流量可能长时间拿不到东口。工程上更常见的做法是轮询仲裁每次 grant 之后把优先级起点指向下一个输入方向代码只需在仲裁赋值前动态调整优先级顺序综合面积增加很少建议模型跑通固定优先级后尽快补上。3.4 顶层mesh_top互联用generate把路由器节点接成二维网络单节点逻辑完成后顶层要做两件事例化 ROWS × COLS 个路由器节点并把相邻节点的输出连接到对应的输入端口。这里最常犯的错是连线索引写反建议在顶层代码里先明确写出互联规则再用 generate 实现// mesh_top.v 顶层互联核心 // 互联规则 // 东口输出 e_out[r][c] - 右侧邻居的西口输入 w_in[r][c1] // 西口输出 w_out[r][c] - 左侧邻居的东口输入 e_in[r][c-1] // 北口输出 n_out[r][c] - 上方邻居的南口输入 s_in[r-1][c] // 南口输出 s_out[r][c] - 下方邻居的北口输入 n_in[r1][c] genvar r, c; generate for (r 0; r ROWS; r r 1) begin : row_gen for (c 0; c COLS; c c 1) begin : col_gen mesh_router #( .ROW_COORD(r), .COL_COORD(c) ) u_router ( .clk(clk), .rst_n(rst_n), .din_e(e_in[r][c]), .vin_e(vin_e[r][c]), .dout_e(e_out[r][c]), .vout_e(vout_e[r][c]), .din_w(w_in[r][c]), .vin_w(vin_w[r][c]), .dout_w(w_out[r][c]), .vout_w(vout_w[r][c]), .din_n(n_in[r][c]), .vin_n(vin_n[r][c]), .dout_n(n_out[r][c]), .vout_n(vout_n[r][c]), .din_s(s_in[r][c]), .vin_s(vin_s[r][c]), .dout_s(s_out[r][c]), .vout_s(vout_s[r][c]), .din_l(local_din[r][c]), .vin_l(local_vin[r][c]), .dout_l(local_dout[r][c]), .vout_l(local_vout[r][c]) ); // 东西方向互连 if (c COLS - 1) begin assign w_in[r][c1] e_out[r][c]; assign e_in[r][c] w_out[r][c1]; end else begin assign e_in[r][c] {PKT_W{1b0}}; // 东边界悬空 end // 南北方向互连索引规则与上面对称 if (r ROWS - 1) begin assign s_in[r1][c] n_out[r][c]; assign n_in[r][c] s_out[r1][c]; end end end endgenerate这段代码里e_in[r][c]的赋值出现了两次一次在c COLS-1的分支里从w_out[r][c1]取数一次在边界分支里置为 0。实际使用时这两种情况下必须用else if结构严格区分防止 Verilog 对多驱动产生警告。上面的写法在c为最大值时只有边界分支生效c小于最大值时只有互联分支生效逻辑是正确的但工程规范建议把每个信号的赋值统一收敛在同一个 if-else 结构中便于后续维护。生成这个 4x4 网格后数据包从任意节点的本地口注入经过若干跳后从目标节点的本地口输出。验证这个模型是否工作关键是构造 testbench 并观察每一跳的vout和dout这就是下一章要解决的问题。4. 用Vivado/ModelSim跑通路仿真testbench构造、参数设定与X态排查4.1 testbench骨架包注入函数与目的节点接收检查Mesh 传输模型的调试最有效的手段是写一个可参数化的 testbench把「从哪注入、目标是谁、载荷是什么」封装成 task。仿真开始时先注入一个包然后在目标节点位置等待接收信号拉高。下面的 testbench 用 4x4 网格演示从(0,0)发送数据到(3,2)// tb_mesh.v module tb_mesh; reg clk 1b0; always #5 clk ~clk; // 100MHz 时钟 reg rst_n; parameter ROWS 4, COLS 4; parameter ADDR_W 4, DATA_W 8; parameter PKT_W DATA_W 2*ADDR_W; reg [PKT_W-1:0] local_din [0:ROWS-1][0:COLS-1]; reg local_vin [0:ROWS-1][0:COLS-1]; wire [PKT_W-1:0] local_dout [0:ROWS-1][0:COLS-1]; wire local_vout [0:ROWS-1][0:COLS-1]; // 组装数据包函数{目标行Y, 目标列X, 载荷} function [PKT_W-1:0] make_pkt(input [ADDR_W-1:0] dy, dx, input [DATA_W-1:0] data); make_pkt {dy, dx, data}; endfunction mesh_top #(.ROWS(ROWS), .COLS(COLS)) dut ( .clk(clk), .rst_n(rst_n), .local_din(local_din), .local_vin(local_vin), .local_dout(local_dout), .local_vout(local_vout) ); task send_pkt(input [ADDR_W-1:0] sy, sx, dy, dx, input [DATA_W-1:0] data); begin (posedge clk); local_din[sy][sx] make_pkt(dy, dx, data); local_vin[sy][sx] 1b1; (posedge clk); local_vin[sy][sx] 1b0; end endtask initial begin rst_n 1b0; repeat(3) (posedge clk); rst_n 1b1; // 从 (0,0) 节点发送一个载荷 0xAB 到 (3,2) 节点 send_pkt(0, 0, 3, 2, 8hAB); // 等待目标节点的本地出口出现接收信号 wait(local_vout[3][2] 1b1); $display(TB: t%0t 目标节点(3,2)收到载荷%02x, $time, local_dout[3][2][DATA_W-1:0]); $finish; end endmodule这个 testbench 的关键点在于send_pkt只在本地口拉高一个时钟周期的local_vin模拟一个单包注入wait(local_vout[3][2] 1b1)会阻塞等待目标节点输出不会在包还没到达时就$finish。把目标坐标从(3,2)改成任意其他坐标就能验证 Mesh 里任意两节点之间的传输路径。4.2 仿真命令与波形窗口设置如果是 Vivado 环境可以在 Tcl Console 里用 xsim 直接跑# Vivado xsim 独立仿真流程 xvlog mesh_router.v mesh_top.v tb_mesh.v xelab tb_mesh -timescale 1ns/1ps -debug typical xsim tb_mesh -runallModelSim 环境则是一组等价的命令# ModelSim 仿真流程 vlib work vlog mesh_router.v mesh_top.v tb_mesh.v vsim work.tb_mesh run -all仿真跑通后打开波形窗口把tb_mesh/dut下所有层级的vout和dout信号加入波形列表。观察点在两个一是(0,0)节点的vout_e是否在注入后的下一拍拉高二是东侧邻居(0,1)节点的vin_e是否同步收到数据。如果这两点满足说明包已经进入第一跳接下来逐跳检查即可定位断点。提示波形窗口里显示信号名时可以把din_l、dout_l按进制切换成十六进制显示坐标字段一眼就能读出包当前在哪一跳、目标坐标是否正确。4.3 参数怎么调坐标位宽、载荷位宽与输入缓冲深度的取舍Mesh 模型的系统参数集中在mesh_router和mesh_top的 parameter 里调节时直接决定资源、带宽和时序表现参数含义建议起点调节代价ADDR_W单个坐标位宽4坐标超过 16 时需要 5 位包位宽同步增加DATA_W载荷位宽8位宽翻倍后 crossbar mux 面积近似翻倍FIFO_DEPTH输入 FIFO 深度4深度过小上游反压频繁深度过大 LUT 和 BRAM 占用上升ROWS / COLSMesh 行列数4行列数决定总节点数也决定最大跳数教程里先把 FIFO 省略用寄存器直接完成一拍转发这样模型最快跑通。但注意现实场景中邻居节点输出寄存器如果被占用且没有 FIFO 缓冲包就会丢失。补齐 FIFO 的设计时每个方向输入口各挂一个深度为 4 或 8 的标准 FIFO写端口连接上游dout/vout读端口连接路由仲裁逻辑反压信号通过full反馈给上游。这个改造不影响路由和仲裁模块本身。4.4 死锁、活锁、X态三类常见问题排查仿真遇到问题不要只盯代码本身按现象分类检查往往更快故障现象根因处理办法包永远到不了目标节点边界互联线断接索引方向写反在每跳用$display打印当前坐标和路由方向波形中出现大量 X 态复位时间不足或注入过早复位至少拉低 3 个时钟周期再等两个上升沿注入数据在局部区域反复绕圈XY 路由坐标取反检查dest_x对应的cur_x是否用的是COL_COORD高优先级端口持续占用其他端口饿死固定优先级仲裁把仲裁改成轮询每次 grant 后优先级起点后移还有一个常见问题仿真明明收到了数据包但local_vout[3][2]一直没有拉高。遇到这种情况先检查这个坐标的断言是否写反local_vout是目标节点把包送入本地的信号不是本地注入的local_vin两者方向完全不同。把注入和接收信号分开看就能避免把单向信号方向的回路误判成 bug。5. 传输模型的进阶扩展3D Mesh、多播标志与ILA在线观测二维 Mesh 模型跑通后向三维 Mesh 扩展是改动成本最低的升级路径。在每个路由节点上增加上方和下方两个端口坐标从(row, col)变成(row, col, layer)PKT_W变为DATA_W 3*ADDR_W顶层用三层循环例化MESH_LAYERS个二维平面即可。塞进 FPGA 验证时资源消耗会随层数线性上升建议先从2x2x2开始确认每一层跳转方向正确后再扩大规模。多播传输可以在包头中增加 1 位 kind 字段约定kind 多播时节点到达目标坐标后不终止投递而是继续沿 X 方向转发让同一行的多个节点都收到数据。例如从(0,0)广播到第一行所有节点注入包的目标坐标设为(0,3)X 方向上每个节点的目标坐标匹配判断改为「大于等于当前坐标」这样每个节点在转发的同时也向本地口复制一份。多播标志只是改变了路由判决的结束条件仲裁和 crossbar 结构完全不需要改。FPGA 原型验证阶段不能只依赖仿真还要在真实时钟频率下观测内部信号。给需要观测的信号加上mark_debug属性综合后就能用 debug core 抓取// 把 (3,2) 节点的东输出数据标记为调试信号 (* mark_debug true *) wire [PKT_W-1:0] dbg_dout_e; assign dbg_dout_e dut.row_gen[3].col_gen[2].u_router.dout_e;在 Vivado 中综合布线后创建 ILA 核把clk连到系统时钟把dbg_dout_e加入探针触发器设为dbg_dout_e[15:4] 12h321这样一旦出现目标坐标为(3,2)的数据包ILA 会把前后若干拍的数据抓下来。把dbg_dout_e分别换成dout_w、dout_n、dout_s或local_dout就能在在线调试中完整还原任何一个包在 Mesh 路由器网络里的逐跳传输序列。本文还有配套的精品资源点击获取