简介:基于Verilog语言实现的TCP代理程序,面向FPGA开发工程师、硬件网络加速方向的研究者及高年级学生。这套实现将软件TCP代理的数据转发、连接管理等功能下沉到FPGA硬件执行,借助Verilog完成SYN握手、序列号与确认号解析、连接状态维护、超时重传等关键逻辑,能够有效利用硬件并行性降低高并发场景下的处理延迟并提升吞吐量。资源包为ZIP压缩格式,大小约57.72MB,文件清单暂未单独统计,已有567人学习/下载。内容围绕TCP代理的完整数据通路展开,涵盖连接建立与释放、透明转发、错误检测与恢复、多连接并行调度与流水线优化等模块,可帮助读者理解状态机设计、时序控制与TCP协议字段处理的配合关系,为在FPGA上自研网络加速组件提供可借鉴的代码框架与优化思路。
1. 一个 Verilog 写的 TCP 代理:把数据面下沉到 FPGA 能省下什么
在一台软核 CPU 上跑 TCP 代理,连接数一涨,中断和内存拷贝就把性能吃光了;而把同样的逻辑用 Verilog 写进 FPGA,转发路径变成纯硬件流水,每个包的处理延迟是固定的几拍时钟,不随连接数抖动。这就是这个“基于 Verilog 语言的 TCP 代理程序”做的事:它把 TCP 会话识别、双向转发、地址端口改写和校验和重算全部下沉到硬件数据面,是典型的网络功能加速预研项目,也适合想亲手在 FPGA 上实现 L4 代理的人当骨架。读完你会拿到完整的模块划分、TCP 状态机写法、仿真激励和上板验证流程,以及我实际调通过程里踩过的坑。如果你正准备做硬件 TCP 卸载或者网关口,这份工程的 RTL 和脚本可以直接当起点。
2. 整体架构与目录:从 GMII 收包到转发出去的五个环节
2.1 为什么用 Verilog 写代理:控制面和数据面分开
软件代理慢不在协议逻辑,而在每个包都要经历“中断上报 → 协议栈解析 → 用户态拷贝 → socket 发送”的长路径。连接数一多,调度延迟和缓存缺失会把平均时延拉出好几个数量级的毛刺。FPGA 方案的核心思路是把 per-packet 操作全部硬件化:收包、查会话、改写、校验,全部在固定节拍的流水线上完成。“网络功能加速”这个词落在这个项目里,指的就是把数据面从 CPU 手里拿走。
控制面仍然留给软件。代理的转发规则、ACL 白名单、会话老化参数,由软件通过寄存器或 AXI-Lite 接口下发到 FPGA,数据面只做查表和转发。这样做的好处是:改规则不需要重新综合硬件;加新功能时,硬件只动数据通路,软件只动规则下发。这个项目按 1G 以太网设计,GMII 接口 8 位数据位宽,工作时钟 125MHz;想要上 10G,把 MAC 换成 64 位或 512 位位宽、时钟提高到 156.25MHz 或更高即可,核心状态机不用动。
2.2 工程目录与顶层结构:一张表看懂每个文件干什么
拿到手的源码包是典型的 RTL + 仿真 + 约束三段式布局:
tcp_proxy_prj/ ├── rtl/ │ ├── tcp_proxy_top.v # 顶层:例化两个 GMII 口和代理核心 │ ├── gmii_rx.v # GMII 收包,带异步 FIFO 跨时钟 │ ├── gmii_tx.v # GMII 发包,带反压握手 │ ├── pkt_parse.v # 以太网 / IP / TCP 头解析,输出五元组 │ ├── tcp_endpoint_fsm.v # 单条半连接的 TCP 端点状态机 │ ├── session_table.v # 4 路组相联会话表 │ ├── proxy_core.v # 代理决策:查会话、决定转发方向 │ ├── checksum_unit.v # 校验和增量更新与出口全量重算 │ ├── acl_filter.v # 可选规则匹配(二次开发用) │ └── clk_rst.v # 时钟与复位同步器 ├── sim/ │ ├── tb_tcp_proxy.v # testbench │ ├── pkt_gen.py # 生成带正确校验和的激励报文 │ └── monitor.v # 链路层观测:抓 TX 侧发出的帧 ├── syn/ │ └── tcp_proxy_top.xdc # 时钟与引脚约束 └── docs/ └── session_table.md # 会话字段与哈希策略说明顶层模块把两个 GMII 口夹住一条代理数据通路,例化关系非常直白:
// tcp_proxy_top.v 例化要点 gmii_rx u_rx0 (.clk(clk), .rxd(gmii0_rxd), .rx_dv(gmii0_rx_dv), .axi(...)); pkt_parse u_parse (.axi_rx(...), .tuple(tuple0), .syn(syn0), .fin(fin0)); session_table u_table (.lookup_key(norm_key), .hit(hit), .entry(entry)); proxy_core u_proxy (.hit(hit), .entry(entry), .tuple(tuple0), .rewrite(...)); checksum_unit u_cksum (.old_sum(...), .old_field(...), .new_field(...)); gmii_tx u_tx1 (.txd(gmii1_txd), .tx_en(gmii1_tx_en), .axi(...), .tready(...));为什么把解析、查表、改写拆成独立模块?因为这三者的时钟频率和流水深度不一样。解析是组合逻辑加少量寄存,查表需要多拍 RAM 访问,改写要做两次校验和运算,拆开之后每一级都可以单独加流水寄存器,综合时序好收敛,调试时也能直接拉某一段信号看波形。
2.3 数据通路与反压约定:AXI-Stream 握手
整个数据通路内部统一用 AXI-Stream 风格握手,四个信号走天下:tvalid 表示数据有效,tready 表示对端能收,tdata 是数据总线,tlast 是一帧的结尾。模块之间不用 FIFO 深度互猜,而是靠握手反压。这里有一个新手很容易翻车的点:GMII 收包速率和内部处理速率不一致,如果收侧 FIFO 满了还继续往 tvalid 上灌数据,丢包就是一瞬间的事。
一个 TCP 数据包从 client 侧进来,完整走一遍是七步:
- gmii_rx 把 8 位数据拼成 AXI-Stream 帧,同时跨时钟到内部 clk;
- pkt_parse 在帧头位置解析出五元组:src_ip、dst_ip、src_port、dst_port、协议号;
- 五元组做方向归一化,生成查找 key,送 session_table;
- 查表命中后,proxy_core 根据会话里的映射信息决定出口方向;
- 改写单元替换 MAC/IP/端口,并更新序号;
- checksum_unit 增量更新 IP 校验和,出口处全量重算 TCP 校验和;
- gmii_tx 按握手把帧发到 server 侧网口。
时钟域是另一个需要提前定死的事。两个 GMII 口的 RX 时钟来自外部 PHY,和内部工作时钟不同源,所以 gmii_rx 一律先用异步 FIFO 把数据搬到内部时钟域,任何跨时钟路径都不允许直接连线。这套约定在后面上板时会救你一命。
3. 核心模块拆解:TCP 状态机、会话表与校验和的硬件实现
3.1 半连接状态机:从 IDLE 到 ESTABLISHED 的四个状态
代理和普通转发设备最大的区别在于:它维护的是两条独立的 TCP 连接。client 和代理之间是一条,代理和 server 之间是另一条。所以核心状态机按“半连接”来设计——每一条连接只有一个角色:主动打开(代理主动向 server 发 SYN)或被动打开(代理收到 client 的 SYN 后回 SYN-ACK)。两条半连接各自维护自己的序号空间,互不干扰。
// tcp_endpoint_fsm.v —— 单条半连接的 TCP 端点状态机 module tcp_endpoint_fsm #( parameter ROLE_ACTIVE = 1'b0, // 主动打开:本端先发 SYN parameter ROLE_PASSIVE = 1'b1 // 被动打开:收到 SYN 回 SYN-ACK )( input wire clk, input wire rst_n, input wire role, // 本半连接的角色 input wire start_en, // 仅主动方用:允许发起连接 input wire pkt_valid, // 收到一个 TCP 包 input wire pkt_syn, input wire pkt_ack, input wire pkt_fin, input wire pkt_rst, input wire seq_ok, // 序号落在窗口内 output reg [1:0] state, output reg tx_syn, // 发 SYN output reg tx_synack, // 发 SYN+ACK output reg tx_ack, // 发纯 ACK output reg tx_fin, // 发 FIN output reg fwd_payload // 数据包放行进对端发送队列 ); localparam S_IDLE = 2'd0; // 连接未建立 localparam S_WAIT_ACK = 2'd1; // 已发 SYN,等对端 ACK localparam S_ESTAB = 2'd2; // 已建立,能搬数据 localparam S_CLOSE = 2'd3; // 已收 FIN,等 RST 或超时 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; tx_syn <= 1'b0; tx_synack <= 1'b0; tx_ack <= 1'b0; tx_fin <= 1'b0; fwd_payload <= 1'b0; end else begin // 输出默认拉低,让它们是单拍脉冲 tx_syn <= 1'b0; tx_synack <= 1'b0; tx_ack <= 1'b0; tx_fin <= 1'b0; fwd_payload <= 1'b0; case (state) S_IDLE: begin if (role == ROLE_ACTIVE && start_en) begin tx_syn <= 1'b1; // 代理向 server 发起 SYN state <= S_WAIT_ACK; end else if (role == ROLE_PASSIVE && pkt_valid && pkt_syn && !pkt_ack) begin tx_synack <= 1'b1; // 对 client 回 SYN+ACK state <= S_WAIT_ACK; end end S_WAIT_ACK: begin if (pkt_valid && pkt_ack && !pkt_syn) begin state <= S_ESTAB; tx_ack <= 1'b1; // 进入 established 前补 ACK end end S_ESTAB: begin if (pkt_valid && pkt_rst) begin state <= S_IDLE; // 对端硬拆,直接回收 end else if (pkt_valid && pkt_fin && seq_ok) begin tx_fin <= 1'b1; // 回 FIN,半关闭 state <= S_CLOSE; end else if (pkt_valid && seq_ok && !pkt_syn && !pkt_fin) begin fwd_payload <= 1'b1; // 数据包放行进对端发送队列 tx_ack <= 1'b1; // 并 ACK 当前序号 end end S_CLOSE: begin // 等对端 RST,或交给老化模块回收 if (pkt_valid && pkt_rst) state <= S_IDLE; end endcase end end endmodule参数 ROLE_ACTIVE 和 ROLE_PASSIVE 是编译期常量,不是运行时切换的寄存器——两条半连接在例化时就把角色定死了。start_en 是主动侧的信号,由 proxy_core 在“被动侧完成握手”时拉起来,这样 client 侧的 SYN 到达后,代理才会向 server 发起对应的新连接。所有 tx_* 输出都是单拍脉冲,TX 引擎用这些脉冲去组装对应标志位的报文,比如看到 tx_synack 脉冲就发出 SYN+ACK 帧,序号取本半连接的发送计数器。
这里有一个刻意的简化:seq_ok 只做窗口起点检查,收到乱序段直接丢弃,靠对端超时重传,没有做乱序重排。真正常量窗口加 reorder buffer 的完整实现需要 BRAM 排队和按序索引,工程复杂度会上一个台阶,作为代理骨架这个版本够用,也更容易跑通。
3.2 会话表:组相联哈希 + 方向归一化
会话表是代理的“记忆”。每个活动会话的表项记录两边的地址映射、当前序号和老化时间戳。表项字段如下:
| 字段 | 位宽 | 说明 |
|---|---|---|
| VALID | 1 | 表项有效位 |
| STATE_C2S / STATE_S2C | 各 2 | 两条半连接各自的状态 |
| CLIENT_IP | 32 | 原始 client 地址 |
| CLIENT_PORT | 16 | 原始 client 端口 |
| MAP_PORT | 16 | 代理对外映射端口 |
| SERVER_IP | 32 | 真实 server 地址 |
| SERVER_PORT | 16 | 真实 server 端口 |
| SEQ / ACK | 各 64 | 两条半连接的收发序号 |
| TIMESTAMP | 48 | 老化时间戳 |
查找 key 的方向归一化是这个模块最容易出错的地方。一个 TCP 连接的两个方向,五元组是镜像的:client 到 server 是“源=client,目的=server”,反向则是“源=server,目的=client”。如果直接用原始五元组哈希,同一连接会查成两个不同表项,代理会把它当成两条独立连接,握手能过,数据一发就断。解决办法是在生成 key 之前先排序:
// 归一化 key:保证两个方向命中同一个表项 function [95:0] make_key; input [31:0] sip, dip; input [15:0] sport, dport; reg [31:0] a, b; reg [15:0] pa, pb; begin if ((sip < dip) || ((sip == dip) && (sport <= dport))) begin a = sip; pa = sport; b = dip; pb = dport; end else begin a = dip; pa = dport; b = sip; pb = sport; end make_key = {a, pa, b, pb}; end endfunction排序规则是 IP 小者在前,IP 相同再比端口。这样 client→server 和 server→client 两个方向的包,查到的都是同一个表项,代理才能把两个半连接关联成一条完整会话。
查表结构没有用 TCAM 原语,而是做了 4 路组相联的哈希表,思路和 CPU 的 cache 一样:用折叠异或得到的 10 位哈希做索引,一个索引对应 4 个表项槽位,并行比较。每个槽位存的不是整个 key,而是 key 的哈希摘要,先比摘要再比完整 key,节省比较器资源:
// session_table.v —— 组相联查找 // fold: 把 96 位 key 按 16 位一段折叠异或 wire [15:0] fold = norm_key[15:0] ^ norm_key[31:16] ^ norm_key[47:32] ^ norm_key[63:48] ^ norm_key[79:64] ^ norm_key[95:80]; wire [9:0] idx = fold[9:0] ^ {2'b0, fold[15:10]}; // 10 位索引 always @(posedge clk) begin if (lookup_en) begin hit <= 1'b0; for (i = 0; i < 4; i = i + 1) begin if (slot[i][idx].valid && slot[i][idx].hash == fold && slot[i][idx].key == norm_key) begin hit <= 1'b1; entry_data <= slot[i][idx].data; end end end end注意这段代码里的 slot 数组是 SystemVerilog 的 struct 风格,纯 Verilog 工程里会拆成三块独立的 reg 数组,分别存 valid、hash、data,用同一个 idx 寻址,比较逻辑不变。哈希摘要 fold 已经参与索引生成,等于一次比较同时完成了两轮筛选,所以 4 路并行比较足够把冲突率压到很低。替换策略是:新会话优先写入 valid=0 的空槽位;全满时替换 TIMESTAMP 最老的一条;TIME_WAIT 状态的表项永远优先被淘汰。
3.3 地址改写与校验和:增量更新和出口重算两条路
代理必须改写的字段有三个:源 MAC、源 IP、源端口。MAC 改写是纯查表赋值,IP 改写要动 IP 校验和,TCP 校验和因为伪头部包含了 IP 和端口,也必须同步修正。这里用两条不同的计算路径:IP 头用增量更新,TCP 段用出口全量重算。
增量更新基于 RFC 1624 的公式 HC' = ~(~HC + ~m + m'),m 是被改写的 16 位原字段,m' 是新字段。好处是只需要读一次原字段,不用把整个 IP 头重新累加一遍:
// checksum_unit.v —— 增量更新一个 16 位字段 reg [16:0] t; always @(posedge clk) begin if (calc_en) begin t = {1'b0, ~old_sum} + {1'b0, ~old_field} + 17'd1 + {1'b0, new_field}; new_sum <= t[15:0] + t[16]; // 端回卷:进位加回低位 end end这里的关键细节是端回卷。简单地把 17 位结果砍掉高位拿低 16 位,会让校验和在概率上偶发差 1;必须把第 17 位进位再加回低 16 位。IP 头 src/dst 各 16 位,连续调用两次增量更新即可。
TCP 校验和则不走增量,因为出口侧重写了序号,TCP 头变化的不止一个字段,干脆全量重算。全量重算的累加顺序是“伪头部 + TCP 段”,伪头部 12 字节按 RFC 793 拼:源 IP 4 字节、目的 IP 4 字节、协议号 8 位补零 8 位、TCP 段长度 16 位。工程上常见的做法是把伪头部做成一段固定 ROM,先累加完再累加 TCP 段本体:
// 出口侧:伪头部逐项累加,再累加 TCP 段 always @(posedge clk) begin if (acc_en) begin acc <= acc + {1'b0, din}; // 17 位累加器 if (acc[16]) acc <= {1'b0, acc[15:0]} + 1'b1; // 端回卷 end end // 帧收尾时:tcp_checksum = ~acc累加器宽度用 17 位而不是 16 位,就是为了在硬件里“看见”进位并在同一拍回卷。等整个帧的 tlast 到来,再取反就是校验和。伪头部累加需要提前知道“这条会话的源和目的是谁”,这些信息在查会话表时已经拿到了,所以出口引擎会先把改写后的五元组暂存一拍,拼出伪头部,再启动累加流水。这个顺序一旦搞反,校验和就会用旧的 IP 算出新值,对端必然静默丢包。
4. 仿真与上板:先让 testbench 替你跑一遍代理
4.1 用脚本生成带正确校验和的激励报文
仿真的第一步是准备报文。手写二进制太容易错,而且错的往往不是报文本身而是校验和——用 Python 脚本生成十六进制激励文件,再让 testbench 用$readmemh读进来,是最省事的做法。脚本核心只干三件事:拼以太网头、拼 IP 头、算校验和。
# sim/pkt_gen.py —— 生成一个 TCP SYN 报文,输出 sim/syn_pkt.hex import socket, struct def csum16(data: bytes) -> int: if len(data) % 2: data += b"\x00" s = 0 for i in range(0, len(data), 2): s += (data[i] << 8) + data[i + 1] s = (s & 0xffff) + (s >> 16) return (~s) & 0xffff def build_syn(src: str, dst: str, sport: int, dport: int): eth = bytes.fromhex("00112233445566778899aabb0800") src_b, dst_b = socket.inet_aton(src), socket.inet_aton(dst) # IP 头先填 20 字节,checksum 留 0 ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 40, 0x1234, 0, 64, 6, 0, src_b, dst_b) ip = ip[:10] + struct.pack("!H", csum16(ip)) + ip[12:] # TCP 头:sport/dport/seq/ack/offset/flags/window/checksum/urg tcp = struct.pack("!HHIIBBHHH", sport, dport, 0x1000, 0, 5 << 4, 0x02, 1024, 0, 0) pseudo = src_b + dst_b + b"\x00\x06" + struct.pack("!H", 20) tcp_cksum = csum16(pseudo + tcp) tcp = tcp[:16] + struct.pack("!H", tcp_cksum) + tcp[18:] return eth + ip + tcp frame = build_syn("192.168.1.10", "10.0.0.5", 12345, 8080) with open("sim/syn_pkt.hex", "w") as f: for b in frame: f.write("%02x\n" % b)参数说明:0x45 是 IPv4 头长 5×4=20 字节;0x06 是 TCP 协议号;TCP 头里 5<<4 表示数据偏移 20 字节,0x02 是 SYN 标志;pseudo 头里的 20 是 TCP 段长度。生成后可以在 testbench 里逐字节喂给 GMII 接口,也可以直接用这套脚本生成 SYN-ACK、纯 ACK、带数据的帧,覆盖整个握手流程。
4.2 testbench 与断言:握手时序怎么查
testbench 按 GMII 时序把 hex 文件里的字节一字节推给 rxd0,同时拉高 rx_dv,模拟 PHY 收包。代理核心则应该在另一侧网口发出响应帧,用$display在 TX 侧抓特征字节:
// tb_tcp_proxy.v —— 精简版 module tb_tcp_proxy; reg clk = 0; reg rst_n = 0; reg [7:0] rxd0 = 0; reg dv0 = 0; integer i; reg [7:0] pkt [0:2047]; always #4 clk = ~clk; // 125 MHz initial begin #20 rst_n = 1; $readmemh("sim/syn_pkt.hex", pkt); @(posedge clk); for (i = 0; i < 54; i = i + 1) begin // 54 = eth14 + ip20 + tcp20 @(posedge clk); rxd0 <= pkt[i]; dv0 <= 1; end dv0 <= 0; #300 $finish; end // 检查代理是否在 server 侧网口发出了 SYN always @(posedge clk) begin if (gmii1_tx_en && gmii1_txd == 8'h02) $display("[%0t] server 侧发出 SYN 帧, flag=0x02", $time); end endmodule跑仿真的命令很简单,开源 Verilog 仿真器两行搞定。想要看波形,在 testbench 里加$dumpfile和$dumpvars,仿真结束会产出 vcd 文件,用波形工具打开观察状态机跳转。
# 1) 生成激励 python3 sim/pkt_gen.py # 2) 编译并运行仿真 iverilog -g2012 -o tcp_proxy_tb rtl/*.v sim/tb_tcp_proxy.v vvp tcp_proxy_tb仿真通过的标准不只是“看到了 SYN 帧”,而是状态跳转和时序都对:client 侧 FSM 收到 SYN 后进 S_WAIT_ACK、拉出 tx_synack,server 侧 FSM 在 start_en 之后拉出 tx_syn,两侧都在收到 ACK 后进 S_ESTAB。哪个状态没跳过去,波形里一眼就能看出来。
4.3 综合约束与双网口冒烟
仿真过了再上板。综合时的约束文件重点就两类:时钟约束和 GMII 输入延迟约束。125MHz 输入时钟直接声明周期 8ns;GMII 数据相对 RX 时钟有一个确定延迟,不约束的话综合工具会默认很紧,接口时序报告一片红。
# syn/tcp_proxy_top.xdc 要点 create_clock -name clk_125m -period 8.000 [get_ports clk] set_input_delay -clock clk_125m -max 3.5 [get_ports {gmii0_rxd[*]}] set_input_delay -clock clk_125m -min 1.0 [get_ports {gmii0_rxd[*]}] set_max_delay -datapath_only -from [get_cells -hier *fifo_*] 4.0上板验证用双网口串接:客户端 PC 网口 1 接开发板 GMII0,GMII1 接服务器,代理规则由软件下发。冒烟测试就用两个终端的网络工具,客户端连代理端口,服务器侧监听真实端口:
# 服务器侧监听 nc -l -p 8080 # 客户端连接代理的映射端口 nc 192.168.1.10 8000能建连、能互发字符串,再传一个大文件比对校验值,冒烟就算过了。这一步最容易暴露仿真和上板的差距——也就是下一章要说的这些坑。
5. 避坑记录:代理在仿真与上板之间反复横跳的五个原因
5.1 对端静默不回包:校验和的两个阴间细节
现象:Wireshark 里看着报文结构完全正常,握手方向也对了,但对端就是不回包,重传几次之后连接被放弃。我把包一根根抓下来人工核对,最后用抓包工具的校验和功能一算是错的——TCP 校验和只算了 TCP 段,没算伪头部。
原因:TCP 校验和的覆盖范围是“伪头部 12 字节 + TCP 段”,代理改了源 IP 和源端口之后,伪头部的四个字段全变了。只重算 TCP 段等于把新端口套在旧校验和上,对端一验就丢。
解决:出口重算路径必须先累加伪头部,再累加 TCP 段。这个坑我调了一整个晚上,从那以后写校验和模块都强制先列伪头部字段清单。
现象二:文件传输时偶尔有一个包被重传,仿真里还能复现,丢包位置不固定。
原因:这是增量更新的端回卷进位丢了。16 位加法把进位砍掉,对绝大多数字段是对的,但反码校验和运算要求把进位回卷加回低位。
解决:累加器一律用 17 位,每凑满一个 16 位字就把进位加回低 16 位,最后取反。回卷逻辑一加,偶发差 1 的玄学彻底消失。
5.2 能握手不能传数据:会话表的方向性问题
现象:SYN/SYN-ACK/ACK 全部顺利,连接建立,但客户端一发数据对端就断开,或者服务器端收到的包源地址不对。
原因:这是我踩过最隐蔽的坑。最初会话表用原始五元组哈希,client→server 和 server→client 两个方向查出来两个不同表项,代理把同一条 TCP 连接当成两条独立连接,对第二条半连接而言,数据包的序号根本对不上,状态机直接按乱序丢弃,连接自然活不过第一个数据包。
解决:查表 key 必须做方向归一化,IP 和端口按大小排序后再进哈希。同步还要确认:会话建立时写入的表项方向要和 key 排序方向一致,不能一边排序一边不排序。
现象二:连接数一多,某些连接突然中断,MAC 层没有任何错误计数。
原因:哈希表桶内槽位被占满,新会话把活会话覆盖了。组相联的替换策略没区分“空闲槽位”和“冲突淘汰”,一股脑覆盖。
解决:替换策略改成优先写 valid=0 的空槽,其次淘汰 TIMESTAMP 最老的项,TIME_WAIT 状态永远让位。如果业务上连接数接近表容量,直接把表深度调大比优化替换策略更划算。
5.3 上板才出现的问题:跨时钟与反压
现象:仿真全绿,上板后 link 都起不来,或者 RX 侧收到的全是错帧。
原因:GMII 的 RX 时钟来自外部 PHY,和内部工作时钟不同源。收包模块如果直接用内部 clk 采 gmii_rxd,dv 信号和数据的相位关系不稳定,采样点落在数据翻转沿上就是错帧。这是仿真空洞里看不到的——testbench 里 clk 和 rx 是同源的,只有真实 PHY 才会给你一个频偏的时钟。
解决:每个 GMII 口的 RX 数据先用异步 FIFO 跨时钟。dv 信号进 FIFO 之前打两拍同步,保证 wr_en 不出毛刺:
// gmii_rx.v —— 跨时钟采样:dv 先打两拍再进 FIFO reg [1:0] dv_sync; always @(posedge rx_clk) dv_sync <= {dv_sync[0], gmii_rx_dv}; wire dv_ok = dv_sync[1];反压是另一个上板才暴露的问题:仿真里测试仪不会真把 FIFO 灌满,上板后收包速率超过内部处理速率,FIFO 满时还继续收,就是掉帧。解决方式是数据通路全程握手,FIFO 满时把 tready 拉低,上游模块停一拍。这个在设计初期加,后期几乎零成本;等上板再补,要动的模块就多了。
6. 二次开发:把裸转发代理改成带 ACL 的 L4 网关
6.1 加规则表:在查会话之前先做匹配
裸代理只能做“无条件转发”,二次开发最先加的通常是访问控制。在 pkt_parse 和 session_table 之间插一个 acl_filter 模块,五元组解析出来后先过规则表,命中 drop 就直接把包丢弃,命中 allow 才继续查会话。规则表用 reg 数组实现,每条规则包含目的端口、源地址掩码和动作,编号小的规则优先级高:
// acl_filter.v —— 规则匹配 always @(posedge clk) begin if (match_en) begin act <= ACT_ALLOW; // 默认放行 for (i = 0; i < RULE_NUM; i = i + 1) begin if (rules[i].valid && rules[i].dport == pkt_dst_port && (pkt_src_ip & rules[i].mask) == rules[i].addr) begin act <= rules[i].action; break; // 编号小的规则优先 end end end endRULE_NUM 一般给 64 到 256,纯 reg 实现占用的是 LUT 资源,想扩大到几千条就换厂商的 TCAM 原语。规则内容由软件通过寄存器接口动态更新,不用重新综合。再加一个每秒新建连接计数器和令牌桶,就是一个简单的防连接洪水的雏形。
6.2 会话老化与连接数上限
会话表是有限资源,不做老化就会被死连接占满。老化逻辑不放在查表路径上,而是用一个独立的扫描器,每 1024 个周期扫一个桶,避免一个周期把整张表扫完吃掉时序:
// session_table.v —— 分时老化扫描 if (scan_cnt == 1024) begin scan_cnt <= 0; for (i = 0; i < WAYS; i = i + 1) begin if (slot[i][scan_idx].valid && (now_ts - slot[i][scan_idx].ts) > AGING_TMO_MS) slot[i][scan_idx].valid <= 1'b0; end scan_idx <= scan_idx + 1'b1; end连接数上限等于 DEPTH×WAYS,满了以后对新 SYN 直接回 RST,这个逻辑放在 proxy_core 的查表 miss 分支里,三行代码的事,但对稳定性很关键。
6.3 每次改完怎么验:三个固定用例加时序报告
改完任何逻辑,我都强制先跑三个固定用例再上板:单向建连握手、双向 10MB 数据对拍、FIN 优雅关闭。三个用例各配一组生成好的 hex 激励,testbench 里用宏切换,跑完看状态机跳转和校验结果。之后再看综合工具给出的时序报告,重点盯 session_table 的比较路径和异步 FIFO 的跨时钟路径。仿真能暴露逻辑错误,时序报告能暴露实现错误,两者都过了才轮到上板抓包。
源码包里我留了 rtl、sim、syn 三个目录,脚本和约束都写了注释,直接拿这套骨架改就行。每次动代理逻辑都先跑一遍那三个固定用例再上板,已经成了习惯——在仿真里能暴露的问题,就不要留到板子上;在板子上能抓到的现象,就不要靠猜。希望帮到你。
本文还有配套的精品资源,点击获取