☰
Verilog实现TCP代理:状态机驱动与FPGA硬件加速数据面
2026/10/20 9:12:59 网站建设 项目流程

简介:基于Verilog语言实现的TCP代理程序,面向网络工程师、FPGA开发者和硬件加速爱好者,解决了纯软件代理在并发环境下CPU资源受限、延迟高的问题。设计将TCP代理的核心逻辑下沉至FPGA,利用硬件并行性完成连接建立、数据转发、连接释放、超时重传与多连接调度,展示了从SYN握手到四次挥手全流程的硬件实现方法。压缩包大小约57.72MB,已有567人学习下载。内容围绕TCP首部解析、序列号与确认号处理、窗口大小管理、状态表维护等关键环节展开,并涉及多级队列的并发控制与性能优化策略,为读者提供了可借鉴的工程思路和实验范式。这份方案特别适合学习硬件网络加速的人,能帮助理解如何用Verilog描述有状态协议,以及如何在FPGA上实现可靠传输与资源复用。

1. 用Verilog写TCP代理:先把它当成状态机而不是协议栈

FPGA上的网络处理,绝大多数人默认只碰UDP:无状态、转发路径短、一拍哈希一拍转发。TCP要维护会话、跟踪序号、处理握手和重传,硬件做TCP代理在很多人眼里是硬啃硬骨头。但换个角度,TCP代理的硬件化恰恰是网络功能加速里最值钱的一块,原因很简单:软件代理每包要经历系统调用、内存拷贝和锁竞争,而数据面用Verilog实现五元组解析、会话查找和状态迁移后,CPU只剩策略下发和异常上报。这份基于Verilog的TCP代理资源,就是围绕这个目标展开的完整工程,适合做FPGA网络加速、数据面开发、负载均衡和协议转换的工程师用来验证一条核心思路:有状态协议也能在逻辑里可综合地跑起来。它能帮你理清代理解析、状态机设计和时序收敛三者怎么平衡,最终得到一条能动手复现的硬件代理通路。

2. 从软件代理到硬件状态机:五元组、会话表与TCP状态迁移核心

2.1 为什么TCP代理能在FPGA上落地:给CPU卸载什么

软件代理的典型路径是:网卡驱动收包,进内核协议栈,再由用户态代理进程处理,然后重新走协议栈出去。每一包都要经历加锁、拷贝和多次调度,并发一高,延迟抖动和CPU占用立刻成为瓶颈,这是做网络功能加速的人最熟悉的一幕。硬件代理的出发点是把这条路径缩短:包从MAC进来后,在数据面上完成解析、会话查找和头部改写,全程不经过CPU。这里的重点不是把整个TCP协议栈搬进FPGA,而是只保留“代理解析与转发”这条关键路径。TCP的拥塞控制、重传定时、乱序重组这类复杂机制仍然留给两端的主机协议栈,FPGA只负责识别连接、维护会话状态、按策略转发或主动终止连接。

哪些TCP代理适合搬进硬件?个人经验是有一条很清晰的分界线:如果代理需要缓存整个应用层报文再做深度内容改写,硬件代价极高,BRAM会被吃光,不适合;如果代理只做会话管理、地址和端口改写、按五元组分流,那本质上就是纯数据面工作,非常适合。这份Verilog TCP代理资源正属于后者,它的价值在于给出一条完整的数据面代理通路,而不是一个完整的TCP协议栈。拿到工程后不需要去找拥塞控制和重传定时器,那些不在硬件里,也不应该放进来,否则时序和资源都会失控。理解这个边界,是后续所有模块设计的前提。

2.2 五元组与会话表:哈希、冲突与老化

TCP代理要工作,第一件事是从每个进入的包中提取五元组:源IP、目的IP、源端口、目的端口、协议。在Verilog里,这组字段从以太网帧的IP头和TCP头里按固定偏移取出,拼成一个128位宽的key。然后对key做哈希,映射到会话表索引。会话表的每一个表项至少需要保存:完整五元组、当前TCP状态、发送序号snd_nxt、接收确认号rcv_nxt、空闲计时器。注意,表项里的五元组必须完整保存,不能只存哈希值,否则冲突时无法确认命中的是不是同一个会话。

哈希实现常见做法是用CRC32或异或折叠。CRC32碰撞率低,但组合逻辑路径长;异或折叠简单快速,适合宽key到窄索引。实际工程里我一般用CRC32截断到8~10位,配合桶深来吸收冲突。会话表通常用RAM实现,每个bucket放4到8个表项,新会话先在同bucket内查找空闲项,找不到就按策略替换。由于硬件没法像软件那样遍历整张表做精确老化,常见做法是加一个周期性的扫描指针,每毫秒检查几个表项的空闲计时器,超时就置为无效。这样整个会话管理就是确定性的,verilog代码的可综合性和时序都可控。

下面是五元组哈希模块,HASH_WIDTH决定表索引宽度,BUCKET_DEPTH决定冲突链长度。

module five_tuple_hash #( parameter HASH_WIDTH = 8, parameter BUCKET_DEPTH = 4 )( input wire [127:0] tuple_key, // 五元组拼成的128bit key output wire [HASH_WIDTH-1:0] hash_idx ); reg [31:0] crc; integer i; integer j; always @(*) begin crc = 32'hFFFF_FFFF; // 对128bit做逐位CRC32,多项式0xEDB88320 for (i = 0; i < 128; i = i + 1) begin crc = crc ^ tuple_key[i]; for (j = 0; j < 8; j = j + 1) begin if (crc[0]) crc = (crc >> 1) ^ 32'hEDB8_8320; else crc = crc >> 1; end end end assign hash_idx = ~crc[31:32-HASH_WIDTH]; endmodule

这个模块是全组合逻辑,128位CRC展开后路径较长,后面章节会讲怎么切流水。参数HASH_WIDTH建议取8,表项256条;如果并发连接超过200,取10更稳妥。BUCKET_DEPTH不在哈希模块里,而是在会话查找模块中控制同索引下的连续比较次数,一般取4到8。会话表的核心参数冲突后的替换策略直接决定长连接稳定性,这部分的坑在第5章避坑会细说。

2.3 从软件TCP状态机到硬件映射:只留六个转移

软件协议栈里的TCP状态机有11个状态,硬件实现不需要全部保留。基于代理解析的实际场景,通常只保留IDLE、SYN_RCVD、ESTABLISHED、FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT六个状态,LISTEN和SYN_SENT都可以由外部策略逻辑控制,不需要在会话表里单独建表项。迁移条件也比RFC 793简化:收到SYN且无ACK,则进入SYN_RCVD并代答SYN-ACK;收到ACK且确认号等于snd_nxt,则进入ESTABLISHED;收到FIN则进入FIN_WAIT_1路径。TIME_WAIT也不必等2MSL,硬件表项资源宝贵,常见做法是直接释放并通知CPU做记录。

下面这个状态机片段是资源里的核心骨架,状态编码用独热码,便于综合和调试。

localparam IDLE = 6'b000001; localparam SYN_RCVD = 6'b000010; localparam ESTABLISHED = 6'b000100; localparam FIN_WAIT_1 = 6'b001000; localparam FIN_WAIT_2 = 6'b010000; localparam TIME_WAIT = 6'b100000; reg [5:0] state; reg [31:0] snd_nxt; reg [31:0] rcv_nxt; always @(posedge clk or posedge rst) begin if (rst) begin state <= IDLE; snd_nxt <= 32'd0; rcv_nxt <= 32'd0; end else begin case (state) IDLE: if (tcp_hdr.syn && !tcp_hdr.ack) begin // 收到对端握手请求,代答SYN-ACK snd_nxt <= tcp_hdr.seq_num + 32'd1; state <= SYN_RCVD; end SYN_RCVD: if (tcp_hdr.ack && (tcp_hdr.ack_num == snd_nxt)) begin // 对端确认了SYN-ACK,进入连接态 rcv_nxt <= tcp_hdr.seq_num + 32'd1; state <= ESTABLISHED; end ESTABLISHED: if (tcp_hdr.fin) begin state <= FIN_WAIT_1; end // FIN_WAIT_1 / FIN_WAIT_2 / TIME_WAIT 类似处理 endcase end end

这里有个细节值得注意:ESTABLISHED的迁移条件必须同时检查ack标志位和ack_num数值,只查标志位会踩坑,后面避坑章节会展开。状态机的输出并不直接控制回包,而是给数据通路一个状态标记;真正的SYN-ACK回包,由另一个独立的头部改写模块根据当前状态生成。这样划分后,状态机逻辑量很小,时序收敛压力也小。

3. 可综合的代理数据通路:AXI-Stream接口、包缓存与时序收敛

3.1 模块划分:从MAC到代理再到出口

整个代理数据通路的顶层连接关系按流水线划分:入口MAC核输出AXI-Stream,进入包解析模块;解析模块把IP头、TCP头的关键字段提取出来,连同原始包一起送入会话查找模块;会话查找模块根据五元组key去查会话表,命中则附加一个session_id和控制标记;接下来状态机根据session状态和当前包类型决定转发、代答还是丢弃;最后,头部改写模块修改seq、ack、IP地址和校验和,把包写到出口FIFO,由出口MAC发出。每一级模块之间都用标准的valid/ready握手。

模块划分时最容易犯的错误是把解析和查找混在一起,导致组合逻辑过长。常见做法是让包解析模块只负责输出提取字段,不参与任何查表判断;会话查找只负责哈希和比较,不修改包数据;头部改写只在数据通路末端动包内容。这样每一级都有清晰的职责,仿真和定位问题都方便。AXI-Stream的tkeep信号要一直携带,因为TCP头可能在任意字节偏移,尤其在处理VLAN标签或IPv4选项时,偏移会变化,解析模块需要输出header_offset让后续模块知道TCP头起点。

3.2 包缓存与乒乓RAM:背压和反压怎么设计

包缓存的目的是吸收入口MAC速率与处理通路速率不匹配。入口线速到来时,解析和查表可能花掉多个周期,如果直接流过,来不及处理的包就会被截断。所以常见做法是在解析之后加一个包缓存FIFO,深度按最大包长乘缓存数量估算。单个缓存深度512×64bit比较充裕,可以缓存约4个64字节最小以太网包或2个中等包。工程里一般做成双端口RAM加读写指针,写端口接输入,读端口接头部改写模块。

乒乓RAM的另一种用法是双FIFO交替读写:一个FIFO在读的同时,另一个FIFO在写,下一次交换角色,这样避免单口RAM读写互斥造成的停顿。参数上要注意almost_full阈值的设置,不能等FIFO满了才拉低ready,因为AXI-Stream握手本身还有一拍反应时间。我一般把几乎满阈值设为深度减64,留出两级延迟余量。下面是包缓存接口的示意代码。

module packet_buffer #( parameter DEPTH = 512, parameter DATA_WIDTH = 64, parameter ALMOST_FULL_MARGIN = 64 )( input wire clk, input wire rst, // 写侧 input wire [DATA_WIDTH-1:0] din, input wire din_valid, output wire din_ready, // 读侧 output wire [DATA_WIDTH-1:0] dout, input wire rd_en, output wire dout_valid ); reg [$clog2(DEPTH):0] wr_ptr; reg [$clog2(DEPTH):0] rd_ptr; wire [DEPTH-1:0] almost_full_flag; assign almost_full_flag = (wr_ptr - rd_ptr) > (DEPTH - ALMOST_FULL_MARGIN); assign din_ready = !almost_full_flag; // 双端口BRAM读写,wr_ptr/rd_ptr在各自使能下递增 endmodule

这个模块的关键参数是ALMOST_FULL_MARGIN,它表示提前多少深度就停止接收。调大它,反压更灵敏但缓存利用率下降;调小它,利用率高但突发下容易溢出。实际调试中,溢出表现为抓包可见的丢包集中在突发开始后的几十微秒内,这时候先看almost_full阈值,再看读侧是否反压了上游。另外,wr_ptr和rd_ptr的位宽必须比DEPTH多1位,否则深度满的时候指针相等无法和空区分。

3.3 时序收敛的常见手段:把长路径切成两拍

上一节的CRC32哈希,直接把128位key在一个周期里算出8位索引,综合后路径往往超过8ns,在200MHz目标时钟下必然时序违例。解决手段是流水线化。第一拍做输入寄存和前半段CRC迭代,第二拍继续迭代并输出索引。这样关键路径被切成两半,代价是hash_idx晚一个周期出来,后续的会话查找比较器也要跟着打一拍对齐。

状态机迁移也可以推迟一拍:当包头解析出的syn标志位有效时,不立即改状态,而是先把条件寄存一拍,下一拍再跳转。这看起来多个一拍延迟,但对TCP握手这种毫秒级过程,一个时钟周期的延迟完全无感。流水线切分的通用原则是:把组合逻辑最深的点位找到,在综合报告里看max delay path落在哪个模块,然后在该模块中间插一级或多级寄存器,再把同一拍上的相邻模块的valid信号做同样延迟。握手协议里的ready信号不能随意打拍,否则会造成反压误判,必须按AXI-Stream规范让ready组合产生或提前一拍产生。

4. 仿真与上板验证:从Testbench到主机联调的完整闭环

4.1 仿真环境搭建:用Verilog.Testbench把三次握手跑通

拿到这份资源后,建议先跑仿真而不是直接上板。仿真环境不需要复杂工具,用Icarus Verilog或Vivado Simulator都行。Testbench的核心是构造一个可重用的TCP握手激励源:用一个task发送SYN,检查代理回SYN-ACK,再发送ACK,最后断言状态机进入ESTABLISHED。关键点是每个task里要显式指定seq_num和ack_num,不能用随机值,否则断言会不稳定。

task tcp_handshake; input [31:0] init_seq; begin send_syn(init_seq); // 发送SYN,seq=init_seq #200; if (syn_ack_valid) begin // 代理回应SYN-ACK,确认号应为init_seq+1 check_syn_ack(init_seq + 32'd1); end // 发送ACK,seq=init_seq+1,ack=代理的snd_nxt send_ack(init_seq + 32'd1, syn_ack_seq + 32'd1); #100; if (state != ESTABLISHED) $error("handshake failed: state=0x%05b", state); end endtask

这个task里,send_syn和send_ack都要把包灌入包解析模块的入口,而不是直接改状态机内部信号。这样验证的是完整数据通路,而不是只验状态机单元。check_syn_ack里的syn_ack_seq是代理回包中的实际序号,从输出端口采样得到。仿真通过后,再测试异常路径:如重复SYN、乱序ACK、无SYN直接 DATA,确认状态机不会误判。

4.2 回环验证:把发包器接到代理解析入口

仿真环境里用回环FIFO把生成包接入代理入口,再回到发包模块,可以验证三段交互:代理解析、查表、头部回写。回环测试的目的是掩盖真实MAC时序差异,先验证模块内部协议正确。做法是例化一个发包器,支持按Testbench命令生成不同TCP标志位的帧,输出接入packet_buffer写侧;代理出口接入发包器的接收侧,形成回环。重点看三点:握手后状态是否为ESTABLISHED;数据段经过后seq是否按长改变;校验和是否在头部改写后被正确更新。

回环测试最常暴露的问题是头部改写模块偏移算错。由于以太网帧可能有VLAN标签,IP头可能有选项,TCP头偏移不是固定的;如果直接用固定偏移去改写,仿真里链路无VLAN时恰好正确,一加VLAN标签就全错。所以在回环测试里要覆盖带VLAN和不带VLAN两种帧,把这个场景写进Testbench。资源里如果只提供了普通帧的用例,拿到后建议自行补充这条,验证时代价最小。

4.3 上板验证:与主机联调,抓包确认代理是否真正代答

仿真通过后,连接主机网卡和FPGA开发板,用标准工具做联调。先在主机侧开一个监听端口,从另一个终端发起TCP连接,同时在FPGA出口侧抓包。正常情况下抓包会看到:外部主机发SYN,FPGA直接回SYN-ACK,并且FPGA同时向内层主机发起一个新的SYN;两条TCP连接在FPGA处对接,这就是代理的核心行为。

# 终端A:监听TCP端口 nc -l 5000 # 终端B:发起连接,观察三次握手由谁代答 tcpdump -nn -i eth0 'tcp port 5000'

抓包结果里如果只看到一条连接的三次握手,说明代理没有真正建立第二条TCP连接,常见原因是内层主机的握手请求没被正确生成,或者MAC地址改写错误。如果看到RST紧随SYN-ACK,说明校验和或序号不对,对端协议栈主动拒绝。联调时建议先关掉主机侧的TCP校验和卸载功能,用软件计算校验和,这样能把问题边界缩到FPGA内部,排除网卡卸载的干扰。

5. 避坑实录:Verilog写TCP代理最常翻车的五个位置

5.1 坑一:状态机停在SYN_RCVD,三次握手永远完不成

现象:仿真和上板都出现同一个问题,外部主机发SYN后,代理回了SYN-ACK,但状态机卡在SYN_RCVD不进入ESTABLISHED,连接反复重试就是建不起来。

原因:状态机的ESTABLISHED迁移条件只检查了tcp_hdr.ack标志位,没有校验ack_num是否等于snd_nxt。对端发来的ACK如果带数据或带TCP选项,seq和ack的数值会与预期不一致;更隐蔽的是,对端可能发来重复SYN-ACK而不是ACK,状态机把这些包一律当成ack,结果条件永远不满足。

解决:迁移条件收紧为ack标志有效且ack_num等于snd_nxt。每次收到重复ACK时保持状态,只触发SYN-ACK重发;收到带数据段则先把数据放入接收缓存,再按数据后的下一个预期序号更新rcv_nxt。这需要在状态机里增加一个比较器,比较器的宽度是32位,放在ESTABLISHED判定路径上,注意时序切分。

5.2 坑二:哈希冲突顶掉会话,并发一高RST暴增

现象:并发连接数从几十升到几百之后,抓包看到大量RST,丢包率上升,且所有被重置的连接都是短连接而不是长连接。

原因:会话表bucket深度只有2,并发超过一定量后,新会话哈希到同一个bucket,覆盖了旧表项。旧连接的下一个数据包到达时查不到表项,默认策略是返回RST终止连接。

解决:把bucket深度从2提到8,代价是每个哈希索引要多比较6个表项,会话表RAM面积增加。加上主动老化扫描,每1ms检查一轮表项的空闲计时器,空闲超过150秒的标记为无效。替换表项前,先查一下被替换项是否处于ESTABLISHED状态,如果是则主动发RST告知对端,避免对端一直等重传。参数上HASH_WIDTH取10、BUCKET_DEPTH取4,多数应用场景可以稳定跑到1000并发。

5.3 坑三:时序收敛失败,A200MHz的目标时钟就是差一口气

现象:综合报告显示WNS为负,关键路径落在five_tuple_hash到会话表比较器的链路上。目标时钟200MHz,实际最高只能跑到150MHz左右,怎么优化都上不去。

原因:128位CRC32的异或链和桶内多表项比较堆在了同一个周期;同时状态机的ESTABLISHED迁移条件里也做了32位数值比较,三层组合逻辑叠加导致路径超过8ns。

解决:把哈希模块中间切一级寄存器,第一拍完成前64位的CRC迭代,第二拍完成后64位合并;会话表的比较结果打到第三拍。状态机的条件比较也拆到两级。流水线化之后整体延迟多了两个周期,但TCP握手语义不受影响。还有一个容易忽略的点:头部改写模块里的校验和累加链路同样要打拍,不要让它成为新瓶颈。

5.4 坑四:FIFO溢出,突发流量开始后几十微秒丢包

现象:小流量测试完全正常,一上突发流量就丢包,丢的全是大包,小包反而没事。把FIFO深度加大一档之后丢包减轻,但没有根除。

原因:入口MAC以线速写包缓存时,读侧还在等待状态机判断结果,反压信号差不多满了才拉低,而AXI-Stream的ready拉低到对端停止发送之间还有一拍延迟。大包占缓存多,溢出窗口更早出现。

解决:设置almost_full阈值,在FIFO剩余空间低于深度减64时就拉低ready,用提前量抵消握手一拍。同时在读侧增加预取逻辑,包头解析完成即可开始读,不必等整包写完。参数ALMOST_FULL_MARGIN从64调到128,突发丢包基本消失,代价是有效缓存深度少了约四分之一,对于当前应用可以接受。

5.5 坑五:仿真全通过,上板一个包都不转发

现象:Testbench覆盖了握手、数据转发、FIN断开,全部断言通过,但把比特流下载到FPGA后,接上主机网卡,完全不通,抓包一个包都没有。

原因:两个独立问题叠加。第一,复位信号用了异步复位,FPGA上电后时钟抖动可能导致状态机复位不干净。第二,开发板的网络时钟来自时钟芯片,PLL锁定需要时间,逻辑复位释放时时钟还没稳定,状态机随机上电。

解决:改成两级触发器同步复位释放,同时将PLL的locked信号纳入复位逻辑,locked为低时强制保持复位;locked拉高后延迟1024个时钟周期再释放复位。这个复位模块是所有仿真发现不了的问题的高发区,从那以后我每次上板前都先查一遍复位时序,再查时钟锁定,形成固定习惯。

6. 进阶:把校验和增量更新合并进代理解析通路

代理每转发一个数据包都要改TCP头的序号、确认号以及IP头的源目地址,这些字段一变,TCP校验和就必须更新。如果每次都在头部改写模块里把整个TCP段重算一遍校验和,组合逻辑和功耗都不划算;更常见的做法是做增量校验和更新。TCP校验和的算法是16位反码求和,把原始值old替换成新值new时,新的校验和可以按RFC 1624的增量公式算:checksum_new = checksum_old + (~old) + new。这样就不需要读整个报文,只要知道被修改的字段和原值即可。

在Verilog数据通路里,这个增量更新可以放在头部改写模块的出口级,和状态机并行工作。当状态机判定当前包转发时,头部改写模块同时拿到三个输入:原checksum字段、被改写字段的原值、被改写字段的新值。用三段组合逻辑完成反码加法并截断回16位。

module checksum_increment #(parameter FIELD_WIDTH = 32) ( input wire [15:0] checksum_old, // 原始头部中的checksum字段 input wire [FIELD_WIDTH-1:0] old_field, // 被改写的字段原始值 input wire [FIELD_WIDTH-1:0] new_field, // 被改写的字段新值 output reg [15:0] checksum_new ); reg [31:0] sum_tmp; // 中间累加 integer i; always @(*) begin sum_tmp = checksum_old; // 把old_field按16位拆开,取反后累加 for (i = 0; i < FIELD_WIDTH / 16; i = i + 1) begin sum_tmp = sum_tmp + (~old_field[i*16 +: 16]); sum_tmp = sum_tmp + new_field[i*16 +: 16]; end // 高16位回卷到低16位 sum_tmp = (sum_tmp & 16'hFFFF) + (sum_tmp >> 16); checksum_new = ~sum_tmp; end endmodule

这个模块的入参old_field和new_field,在代理场景下分别对应原ack_num和改写后的ack_num。需要注意,当被改写的字段跨两个16位字时,拆字求和要按网络字节序的边界来拆;拆错一个字,校验和就会差一个固定值,对端协议栈会直接丢弃。验证方法很简单,上板联调时抓包后查看checksum字段,如果校验和卸载开启,抓包会显示checksum offload,此时要把主机网卡的校验和卸载关掉,确认FPGA计算出的校验和正确。从那以后,我写任何代理状态机都强制走一遍握手加增量校验的联合仿真流程,这个习惯帮我挡掉了至少三次上板才发现的玄学问题,希望帮到你。

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

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

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

立即咨询