简介:这份资源面向网络协议硬件实现的学习者与FPGA开发者,提供一套用Verilog编写的TCP代理程序,用于解决软件代理在高并发场景下受CPU资源限制、延迟与吞吐难以兼顾的问题。压缩包为zip格式,约57.72MB,文件总数暂未提供,包内以Verilog源码为主体,配合工程与说明类文件,便于在FPGA平台上综合与验证。已有568人学习下载,说明其在硬件网络加速方向具有一定参考价值。程序覆盖TCP三次握手与四次挥手的连接建立、管理与释放,数据包首部解析与转发,超时重传、连接重置等错误检测恢复机制,以及多连接并发下的队列与调度设计,并给出减少计算与内存访问的性能优化思路。读者可据此理解如何把TCP协议解析与处理下沉到硬件,对照源码梳理状态机与流水线结构,为网络功能加速类项目提供可复用的实现框架与排错参考。
1. 用硬件做 TCP 代理:一个把网络协议栈塞进 FPGA 的思路
第一次看到“基于 Verilog 语言的 TCP 代理程序”这个标题,我的反应是:TCP 代理不是应该跑在 Linux 用户态或者内核里吗,怎么跑到硬件描述语言里去了?后来把源码翻了一遍才明白,这个项目的定位不是替代 Nginx 那种七层代理,而是把 TCP 连接的建立、转发、状态维护做成一个可以综合到 FPGA 上的硬件模块,让代理逻辑贴着网口跑,省掉操作系统协议栈那一层开销。它适合两类人:一类是做 FPGA 网络加速、想找一个能上板的 TCP 处理参考实现的硬件工程师;另一类是想搞清楚 TCP 三次握手、序列号管理在硬件里到底怎么落地的软件开发者。关键词里的“网络功能加速”正是它的核心卖点——用硬件并行处理多路连接,而不是靠 CPU 轮询。
2. 项目定位与技术选型:为什么是 Verilog 而不是软件代理
2.1 这个代理到底代理了什么
先把概念掰清楚。软件世界的 TCP 代理,典型形态是客户端连到代理端口,代理再向真实服务端发起一条连接,中间做数据搬运,必要时还能改包、做负载均衡。这个项目做的事情在语义上是一样的,但实现层次完全不同:它工作在 MAC 与 IP 之间,自己维护一张连接表,每条表项记录源端口、目的端口、双方序列号、窗口大小和连接状态机。当一帧以太网数据进来,硬件解析出 IP 头和 TCP 头,查表决定这条连接是新建、转发还是丢弃,然后重新组帧发出去。
这种做法的价值在于确定性。软件代理的转发延迟受调度、中断、内存拷贝影响,抖动大;硬件流水线一旦跑起来,每个时钟周期处理固定字段,延迟基本是常数。代价也很明显:连接数受片上 RAM 限制,复杂策略(比如内容改写)很难做。所以它更适合做“高速通道”而不是“智能网关”,这一点在二次开发时要心里有数。
2.2 技术栈拆解:Verilog 之外还需要什么
项目主体是纯 Verilog-2001 风格的 RTL 代码,没有用 SystemVerilog 的类和接口,好处是综合工具兼容性好,老一点的 Quartus 和 Vivado 都能吃。除了 RTL,工程里通常还会带这几样东西:
| 组成 | 作用 | 常见形式 |
|---|---|---|
| RTL 源码 | TCP 状态机、校验和、连接表、收发 FIFO | .v 文件 |
| 约束文件 | 引脚分配、时钟约束 | .xdc / .sdc / .qsf |
| 仿真平台 | 激励生成、抓包比对 | testbench .v |
| 上位机脚本 | 发包测试、结果统计 | Python + scapy |
我一般会把仿真和上板分开验证:先在 ModelSim 或 Verilator 里跑通握手和单连接转发,再上板测多连接并发。直接上板调时序问题,排查成本太高,这是血泪经验。
2.3 模块划分:一个报文进来经过哪些单元
按数据流拆,核心模块大致是这几块,命名可能因工程而异,但职责是固定的:
- MAC 接收/发送接口:对接 PHY 的 GMII/RGMII,做 8bit 到 32bit 的位宽转换。
- 以太网/IP/TCP 解析器:逐层剥头,输出五元组和 payload 起始位置。
- 连接表(Connection Table):双端口 RAM 或寄存器堆,存连接状态和序列号。
- TCP 状态机:处理 SYN、SYN-ACK、ACK、FIN,维护 ESTABLISHED 等状态。
- 校验和计算单元:增量式更新 IP 头和 TCP 头校验和,这是硬件代理最容易翻车的地方。
- 发送仲裁与组帧:把转发数据和本机产生的 ACK 合流,避免冲突。
下面这段是连接表查表的简化写法,展示“用五元组哈希索引 + 有效位判断”的典型结构:
// 连接表查表:输入五元组,输出表项索引与命中标志 // 表深 256,每条表项 128bit,实际工程按资源调整 module conn_lookup ( input wire clk, input wire rst_n, input wire [31:0] src_ip, input wire [31:0] dst_ip, input wire [15:0] src_port, input wire [15:0] dst_port, output reg [7:0] entry_idx, output reg hit ); // 简单异或哈希,够用且省资源;冲突靠表项内五元组比对兜底 wire [7:0] hash = src_ip[7:0] ^ dst_ip[7:0] ^ src_port[7:0] ^ dst_port[7:0]; reg [127:0] conn_table [0:255]; reg valid [0:255]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin hit <= 1'b0; entry_idx <= 8'd0; end else begin entry_idx <= hash; // 命中条件:有效位为 1 且五元组完全一致 hit <= valid[hash] && (conn_table[hash][127:96] == src_ip) && (conn_table[hash][95:64] == dst_ip) && (conn_table[hash][63:48] == src_port) && (conn_table[hash][47:32] == dst_port); end end endmodule逻辑说明:哈希只负责把五元组压到一个 8 位索引,真正判断命中还要比对完整五元组,否则哈希冲突会导致串连接,这是硬件查表的标准做法。参数上,conn_table的位宽决定能存多少状态字段,128bit 里我留了 32bit 给双方序列号、16bit 给窗口、其余给标志位;表深 256 意味着最多 256 条并发连接,想扩就换 BRAM 并加宽索引。注意hit是打一拍输出的,上层状态机要相应延迟一个周期对齐,这个细节漏了会出现“第一帧查不到表”的玄学问题。
3. 跑起来:从仿真到上板的完整步骤
3.1 环境准备与目录结构
拿到源码包,先别急着打开综合工具。我习惯先看目录,确认哪些是 RTL、哪些是仿真、哪些是约束。典型结构长这样:
tcp_proxy_verilog/ ├── rtl/ # 综合用源码 │ ├── tcp_proxy_top.v │ ├── conn_table.v │ ├── tcp_fsm.v │ └── checksum.v ├── sim/ # 仿真平台 │ ├── tb_top.v │ └── pcap/ # 抓包激励 ├── constraints/ # 引脚与时序约束 └── scripts/ # 上位机测试脚本工具方面,仿真推荐 Verilator(开源、跑得快)或 ModelSim;综合看手头板子,Xilinx 系用 Vivado,Altera 系用 Quartus。Python 侧装 scapy 用来构造测试报文。这里不需要装什么冷门依赖,Verilog 工程的好处就是工具链相对干净。
3.2 仿真验证:先让握手跑通
仿真阶段的目标很明确:一条连接从 SYN 进、SYN-ACK 出、ACK 进、数据双向转发,全程状态机不卡死。testbench 里我一般用任务封装发包:
// 发送一个最小以太网帧:14B 以太头 + 20B IP + 20B TCP + payload task send_frame; input [47:0] dst_mac; input [31:0] src_ip, dst_ip; input [15:0] src_port, dst_port; input [7:0] flags; // bit0=FIN bit1=SYN bit2=RST bit3=PSH bit4=ACK input [15:0] payload_len; begin // 逐字节驱动 GMII 发送接口,帧间隙按 12 字节处理 drive_eth_header(dst_mac); drive_ip_header(src_ip, dst_ip, payload_len + 20); drive_tcp_header(src_port, dst_port, flags); drive_payload(payload_len); wait_ifg(12); end endtask逻辑说明:这个 task 把组帧拆成四段,方便单独改某一层做异常测试,比如故意把校验和写错验证丢弃逻辑。参数里flags用位掩码而不是枚举,是因为硬件里 TCP 标志位本来就是 6 个独立 bit,这样写和 RTL 里的判断一一对应,读代码时不用来回翻译。跑仿真时重点看三个波形:状态机是否从 SYN_RCVD 正确跳到 ESTABLISHED、序列号是否按 payload 长度递增、校验和单元输出是否和软件算的一致。前两个错了是逻辑问题,第三个错了多半是字节序搞反。
3.3 上板与联调:把 PC 当流量源
仿真过了就上板。约束文件里先把 GMII 的收发时钟、数据线、PHY 复位引脚对上,时钟约束按 PHY 提供的 125MHz 写。上板后最直接的验证方式是把板子串在 PC 和一台测试服务器之间,PC 上跑一个简单的 TCP 客户端连服务器,用 Wireshark 抓两侧的包对比。
# 上位机侧:用 scapy 构造一条 SYN,验证代理是否回 SYN-ACK python3 - <<'EOF' from scapy.all import * pkt = Ether(dst="ff:ff:ff:ff:ff:ff")/IP(src="192.168.1.10", dst="192.168.1.20")/TCP(sport=40000, dport=8080, flags="S", seq=1000) sendp(pkt, iface="eth0") EOF这段脚本的作用是绕过操作系统协议栈直接发裸包,避免内核自动回 RST 干扰观察。参数上seq给一个固定初值,方便在抓包里核对代理返回的 ACK 号是不是seq+1。如果板子没反应,先查 PHY 链路是否 up,再查接收 FIFO 是否溢出——这两个是上板初期最高频的故障点。
4. 核心模块拆解:状态机、校验和与连接表怎么配合
4.1 TCP 状态机:硬件里怎么表达“连接”
软件里一个switch(state)就完事,硬件里状态机要同时处理“收到包”和“超时”两个事件源,还要保证同一时刻只有一条连接在被处理。项目里的做法是给每条连接一个状态寄存器,主状态机按轮询或事件驱动方式推进。简化后的状态转移如下:
// 单条连接的 TCP 状态推进,事件由解析器给出 localparam CLOSED = 4'd0, SYN_SENT = 4'd1, SYN_RCVD = 4'd2, ESTABLISHED = 4'd3, FIN_WAIT = 4'd4; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= CLOSED; end else if (pkt_valid) begin case (state) CLOSED: if (tcp_flags[1]) // 收到 SYN state <= SYN_RCVD; SYN_RCVD: if (tcp_flags[4]) // 收到 ACK,握手完成 state <= ESTABLISHED; ESTABLISHED: if (tcp_flags[0]) // 收到 FIN state <= FIN_WAIT; FIN_WAIT: if (tcp_flags[4]) state <= CLOSED; default: state <= CLOSED; endcase end end逻辑说明:这里只列了代理侧被动打开的主干路径,实际工程还要处理同时打开、RST 强制关闭、重传超时。参数上tcp_flags的位序要和解析器输出严格一致,我见过因为 FIN 和 ACK 位定义反了导致连接永远关不掉的案例。另外pkt_valid必须是“本连接相关包”的有效信号,不能是全局有效,否则多连接场景下状态会互相污染,这是硬件代理和软件代理在写法上最大的差别。
4.2 校验和:增量更新比全量重算省一个数量级
转发时 IP 头的 TTL 要减一,TCP 头的序列号和 ACK 号会变,校验和必须跟着改。全量重算要把整个 payload 过一遍,硬件代价大;标准做法是增量更新,只根据变化的 16 位字修正校验和。核心公式是 RFC 1624 里的那种反码运算:
// 增量更新 16 位反码校验和:old_sum 为原校验和,old_field/new_field 为变化字段 function [15:0] csum_update; input [15:0] old_sum; input [15:0] old_field; input [15:0] new_field; reg [31:0] tmp; begin tmp = ~old_sum + ~old_field + new_field; // 折叠进位 csum_update = tmp[15:0] + tmp[31:16]; end endfunction逻辑说明:这个函数只处理一个 16 位字段的变化,TTL 和序列号各调一次即可。参数上old_sum是包头里原来的校验和,注意要先取反再参与运算,这是反码和的性质决定的,直接加会算错。实际工程里我会在仿真中拿软件算的校验和做逐包比对,跑一万帧没错再上板,因为校验和错误在抓包里表现为“对端静默丢包”,现象极其隐蔽,属于典型的黑匣子问题。
4.3 连接表与资源权衡
连接表是整个设计里最吃资源的部分。每条表项要存五元组、双方序列号、窗口、状态、时间戳,粗算 128bit 起步。用分布式 RAM 还是块 RAM,取决于并发连接数和时序要求:小表用寄存器堆查得快但占 LUT,大表用 BRAM 省资源但读延迟一拍。我一般按“目标连接数 × 1.5”留余量,因为哈希冲突会浪费槽位。
还有一个容易忽略的点:连接老化。硬件里没有操作系统帮你回收,必须自己加一个定时器字段,超时未活动的表项清零有效位。这个逻辑不写,跑久了表就满了,新连接全部被拒,现象是“刚上电好好的,跑几小时后连不上”,排查时很容易怀疑到别处去。
5. 避坑与排查:上板后最容易翻车的几个点
5.1 现象:仿真全过,上板第一帧就丢
原因多半是复位时序和 PHY 时钟不同步。仿真里复位是理想信号,上板后 PHY 的接收时钟要等链路训练完成才稳定,复位释放早了这个窗口里的帧全是垃圾。解决办法是把复位释放挂到 PHY 的 link_up 信号上,或者加一个 100ms 的延时计数器,等时钟稳了再放。
5.2 现象:单连接正常,多连接时数据串包
这是连接表哈希冲突没做五元组比对,或者比对用了打拍前的信号。前面代码里hit是延迟一拍输出的,如果上层状态机在同一拍就用hit去取 payload,取到的可能是上一条连接的数据。解决方法是把 payload 也同步打一拍,或者让状态机等hit有效后再动作。这类问题在波形上表现为数据错位一个周期,盯着看很久才能发现。
5.3 现象:大包转发正常,小包偶发校验和错误
常见原因是增量校验和没处理奇数长度 payload 的补零。TCP 校验和计算时,如果 payload 是奇数字节,末尾要补一个零字节凑成 16 位。硬件里如果按 32bit 位宽处理,最后一个字的高 16 位可能是无效数据,直接参与运算就错了。解决方法是解析器输出一个last_be字节使能,校验和单元按使能屏蔽无效字节。
5.4 现象:跑一段时间后新连接建立不了
就是前面说的连接老化没做。表项只增不减,有效位永远不清。加一个 32bit 的 tick 计数器,每条表项存最后活动时间,后台扫描超过阈值就清有效位。阈值别设太短,TCP 有 keepalive 间隔,设短了会把正常空闲连接踢掉,反而制造重连风暴。
5.5 现象:综合后时序不收敛
连接表用寄存器堆实现时,256 项的全并行比对会形成巨大的组合逻辑,跑到 125MHz 很吃力。解决办法是改流水线:第一拍算哈希,第二拍读表,第三拍比对,用三拍换频率。代价是延迟增加,但代理场景对几拍的延迟不敏感,这个交换是划算的。
6. 二次开发:把单连接代理改成多连接调度器
如果只是拿这个项目跑通,到上一章就够了。真正有价值的是在它基础上改。我做过一次改造,把原来“来一帧处理一帧”的被动结构,改成带优先级调度的多连接代理,思路可以复用。
第一步是给连接表加优先级字段。每条连接建表时按源 IP 或端口打一个 2bit 优先级,发送仲裁单元在多个连接同时有数据待发时,按优先级选路。改动集中在仲裁模块,状态机和校验和不用动,这是模块划分清晰带来的好处。
第二步是把发送路径做成两级流水。第一级从各连接的发送 FIFO 里取包头做仲裁,第二级组帧发出去。这样仲裁的决策延迟被流水线吸收,不会拖慢整体吞吐。验证方法是构造 8 条连接同时打流的激励,看高优先级连接的包间隔是否稳定。
第三步是加一个统计计数器组,按连接记录收发包数和丢包数,通过一个简单的寄存器读接口暴露给上位机。这一步对调试帮助极大,很多偶发问题靠计数器就能定位到是哪条连接、哪个方向出的错,比抓包快得多。
| 改造点 | 涉及模块 | 验证方式 |
|---|---|---|
| 优先级调度 | 发送仲裁 | 多连接并发激励,看包间隔 |
| 两级流水 | 发送路径 | 时序报告 + 吞吐测试 |
| 统计计数器 | 新增 stats 模块 | 上位机读寄存器比对抓包 |
改造时有个习惯我一直保持:每加一个功能,先在仿真里把对应的异常路径也测了,比如优先级字段全零、统计计数器溢出。硬件不像软件能打日志,出了问题只能靠波形和计数器反推,前期多测一条异常路径,上板就少熬一个通宵。从那以后我每次动状态机或者校验和相关的代码,都强制把仿真回归跑满一万帧再上板,这个习惯帮我挡掉了大部分低级错误。希望帮到你。
本文还有配套的精品资源,点击获取