☰
基于Verilog的二维Mesh片上网络路由器设计与实现
2026/10/12 1:58:19 网站建设 项目流程

简介:随着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_W=4、DATA_W=8 为例,共 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_W+DATA_W-1 : ADDR_W+DATA_W]; wire [ADDR_W-1:0] dest_x = din_l[DATA_W+ADDR_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 = 3'd0, TO_EAST = 3'd1, TO_WEST = 3'd2, TO_NORTH = 3'd3, TO_SOUTH = 3'd4; 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 ? 5'b10000 : req_e_n ? 5'b01000 : req_e_s ? 5'b00100 : req_e_w ? 5'b00010 : 5'b00000; // 交叉开关选通:按 grant 把对应输入端口的数据锁存到东输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin dout_e <= {PKT_W{1'b0}}; vout_e <= 1'b0; end else if (grant_e != 5'b00000) begin case (grant_e) 5'b10000: dout_e <= din_l; 5'b01000: dout_e <= din_n; 5'b00100: dout_e <= din_s; 5'b00010: dout_e <= din_w; default: dout_e <= din_e; endcase vout_e <= 1'b1; end else begin vout_e <= 1'b0; // 没有 grant 时保持原值,避免产生新的无效跳变 end end

grant 的低位到高位对应不同输入方向,独热码的好处是 case 分支可以直接用 5 个常量匹配,综合工具也能把它推断成并行 mux。固定优先级实现简单,但存在饿死风险:本地口如果频繁注入数据,北口方向的流量可能长时间拿不到东口。工程上更常见的做法是轮询仲裁,每次 grant 之后把优先级起点指向下一个输入方向,代码只需在仲裁赋值前动态调整优先级顺序,综合面积增加很少,建议模型跑通固定优先级后尽快补上。

3.4 顶层mesh_top互联:用generate把路由器节点接成二维网络

单节点逻辑完成后,顶层要做两件事:例化 ROWS × COLS 个路由器节点,并把相邻节点的输出连接到对应的输入端口。这里最常犯的错是连线索引写反,建议在顶层代码里先明确写出互联规则,再用 generate 实现:

// mesh_top.v 顶层互联核心 // 互联规则: // 东口输出 e_out[r][c] -> 右侧邻居的西口输入 w_in[r][c+1] // 西口输出 w_out[r][c] -> 左侧邻居的东口输入 e_in[r][c-1] // 北口输出 n_out[r][c] -> 上方邻居的南口输入 s_in[r-1][c] // 南口输出 s_out[r][c] -> 下方邻居的北口输入 n_in[r+1][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][c+1] = e_out[r][c]; assign e_in[r][c] = w_out[r][c+1]; end else begin assign e_in[r][c] = {PKT_W{1'b0}}; // 东边界悬空 end // 南北方向互连,索引规则与上面对称 if (r < ROWS - 1) begin assign s_in[r+1][c] = n_out[r][c]; assign n_in[r][c] = s_out[r+1][c]; end end end endgenerate

这段代码里,e_in[r][c]的赋值出现了两次:一次在c < COLS-1的分支里从w_out[r][c+1]取数,一次在边界分支里置为 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 = 1'b0; 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] = 1'b1; @(posedge clk); local_vin[sy][sx] = 1'b0; end endtask initial begin rst_n = 1'b0; repeat(3) @(posedge clk); rst_n = 1'b1; // 从 (0,0) 节点发送一个载荷 0xAB 到 (3,2) 节点 send_pkt(0, 0, 3, 2, 8'hAB); // 等待目标节点的本地出口出现接收信号 wait(local_vout[3][2] === 1'b1); $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] === 1'b1)会阻塞等待目标节点输出,不会在包还没到达时就$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 -runall

ModelSim 环境则是一组等价的命令:

# 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] == 12'h321,这样一旦出现目标坐标为(3,2)的数据包,ILA 会把前后若干拍的数据抓下来。把dbg_dout_e分别换成dout_w、dout_n、dout_s或local_dout,就能在在线调试中完整还原任何一个包在 Mesh 路由器网络里的逐跳传输序列。

本文还有配套的精品资源,点击获取

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

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

立即咨询