简介:基于Verilog语言实现的TCP代理程序,完整呈现了在FPGA上实现该功能的设计与实现思路,面向具备数字电路基础、希望借助硬件加速提升网络处理能力的FPGA工程师、嵌入式开发者及网络方向研究者。项目内容覆盖TCP协议从握手建立、数据转发到连接释放的完整流程,详细拆解了三次握手交互、序列号与确认号维护、窗口字段解析等关键细节,并针对高并发场景给出了多级队列与调度机制的优化方案。压缩包为zip格式,整体体积约57.72MB,便于下载后直接对照学习。目前已有567人浏览学习。对于正在探索网络功能加速或FPGA网络应用的开发者,既能辅助理解TCP协议在硬件层的实现机制,也能为实际项目的逻辑设计与性能调优提供有价值的参考。
1. 用硬件跑网络代理:CPU顶不住的转发面
做网络设备的工程师对这一幕应该不陌生:硬件平台明明是双千兆口,软核上用软件实现TCP代理后,单方向吞吐刚到200Mbps,CPU占用率就顶满了100%,再往上加压就是疯狂丢包。这份工程把TCP代理的转发面整个下沉到了FPGA逻辑里:以太网收帧、IP/TCP头解析、连接状态跟踪、地址改写、校验和重算,全部走Verilog硬件流水线,CPU只保留规则下发和异常处理两件事。A同学在一款双千兆板卡上做对比测试时,同样的物理平台从软件代理迁到这份硬件代理,单口吞吐直接从200Mbps干到900Mbps以上,CPU占用掉到个位数。这就是网络功能加速在真实网络设备里的价值。适合正在做FPGA网络处理、硬件加速网卡、或被软核协议栈性能卡住的开发者。
2. 整体架构:数据面与控制面分离的硬件代理设计
2.1 四个核心模块与单向数据流水线
这份工程虽然叫“TCP代理程序”,但它和你平时写的socket代理有本质区别:没有进程、没有socket、没有操作系统协议栈。数据从PHY进来开始,遇到的每一个模块都是“组合逻辑加寄存器”的硬流水线,顶层设计把代理拆成了四个模块,数据流向严格单向:
| 模块 | 职责 | 数据方向 |
|---|---|---|
| mac_rx_adapter | GMII/RGMII接口适配、CRC剥离、字节对齐 | 外部PHY → 内部FIFO |
| frame_parser | 解析以太网/IPv4/TCP三级头,抽取五元组 | FIFO → head_bus → 下游 |
| tcp_session_table | 哈希表维护TCP连接状态、超时扫描、正向反向表项 | head_bus → 查表结果/状态更新 |
| forwarding_engine | 根据查表动作改写字段、重算校验和、输出帧 | 查表结果 → TX FIFO → PHY |
我一般在这种硬件代理工程里坚持一个原则:把“解析”和“转发”彻底分开,中间只靠一组打拍寄存器和总线结构通信。frame_parser解析完帧头后,把eth_type、ip_proto、src_ip、dst_ip、src_port、dst_port、tcp_seq这些字段打包成一条152bit宽的head_bus,推给下游,forwarding_engine只消费head_bus和原始帧缓存,不会再回去翻以太网帧头,这样两侧时序完全解耦。
head_bus为什么设计成一条扁平总线而不是寄存器堆?纯粹是省周期。寄存器堆每个读写口都会引入额外的hazard和资源开销,而一条152bit总线在一个时钟周期内就能完成全部头信息的传递,转发侧在一个周期内决定动作,不会形成多周期数据依赖。代价是综合后布线略长,所以总线出口处加了两级打拍寄存器,牺牲两拍延迟换时序收敛。这套设计在200MHz时钟下能稳定跑满,瓶颈最后落在MAC侧的125MHz上。
2.2 控制面只做三件事:规则、异常、模式
代理这个场景天然要求数据面快、控制面灵活。如果让CPU每个包都过一遍,性能必然崩;如果让硬件把所有策略全做死,又没法应对代理场景里经常变化的转发规则。这份工程的解决思路和SDN里“转发与控制分离”是同一个道理,只是落到了FPGA内部。
控制面软核只干三件事:第一,下发五元组匹配规则,告诉硬件哪条连接应该被代理、目标地址改写成什么;第二,监听硬件中断,处理连接异常和超时清除;第三,配置代理模式——端口代理还是透明代理、是否开启硬件代答握手。剩下的收包、查表、改写、重算校验和全部在数据面硬件里完成。
举透明代理模式下的具体流程:客户端SYN到达后,frame_parser解析出dst_ip和dst_port,tcp_session_table查表发现没有这条连接,新建表项并把状态置为SYN_RCVD,同时拉高一个中断通知CPU。CPU查自己的策略表,认为这个目的端口应该被代理到后端某台服务器,于是把后端地址和seq_delta写进表项配置寄存器。硬件在下一个时钟周期代答SYN-ACK给客户端,同时向后端发起真实SYN。整个握手期间任何数据包都不需要经过CPU,代答延时的实测值在8个时钟周期以内。这个“硬件代答”的设计是吞吐能跑满的关键,但风险也明确:如果CPU还没来得及配置规则而业务流量已经到达,硬件必须默认丢弃并上报。宁可丢包也不能放行无规则流量,这是硬件代理最底线的语义。
2.3 FIFO缓冲与跨时钟域反压
数据路径上所有缓冲都用了标准同步FIFO,跨时钟域处用两级异步FIFO。有一个细节值得抄作业:TX侧FIFO深度定成4096×64bit,刚好能完整缓冲一个半标准以太网帧(1518B)。目的是避免背压直接打到PHY,PHY对背压的处理能力通常很差,一旦FIFO满信号直接反压给RGMII接口,PHY会直接丢整帧。FIFO深一档,让上游MAC能停下等待,状态机也有时间完成当前帧的解析和改写,这是用容量换时序的典型做法。
时钟域上,mac_rx_adapter工作在PHY提供的125MHz,tcp_session_table的哈希查找逻辑跑在200MHz内部时钟。两个域之间由一个异步FIFO隔开,哈希查找在200MHz域内两拍出结果,而帧数据还排在上游FIFO里等待,查表延迟被完全隐藏。背压链路是逐级锁存的:TX FIFO的almost_full上升沿后,forwarding_engine暂停一拍读帧,同时向tcp_session_table拉高stall,解析器也相应停采,最终mac_rx_adapter把GMII的rx_enable拉低,PHY侧收到反压信号后自行延迟发送。每一级只延迟一拍,不会出现多米诺式的全链路停摆。这个“打时间差”和“逐级背压”的组合设计,是整套代理吞吐突破900Mbps的根本原因。
3. 核心代码拆解:一个包从进入到转发的完整流水
3.1 帧头解析:一个case硬吃下三层协议头
frame_parser是数据面的第一站。它每拍收一个字节,用byte_cnt计数帧内偏移,在关键偏移处把字段采样到寄存器里。前提是假设IP头不带option、TCP头不带option,即IPv4头固定20字节、TCP头固定20字节,因此所有字段偏移都是定值。带option的包要么被MAC侧的长度字段过滤,要么走一条旁路异常通道——这是硬件里常见的工程近似。
// frame_parser.v —— 以太网/IPv4/TCP 三级头解析 module frame_parser ( input wire clk, input wire rst_n, input wire [7:0] rx_data, // MAC RX 字节流 input wire rx_valid, // 字节有效 input wire rx_last, // 帧结束 output reg parse_done, // 帧头解析完毕脉冲 output reg [151:0] head_bus // 解析出的头信息总线 ); reg [6:0] byte_cnt; // 帧内字节偏移 0~127 reg [15:0] eth_type; reg [7:0] ip_proto; reg [31:0] src_ip, dst_ip; reg [15:0] src_port, dst_port; reg [31:0] tcp_seq; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin byte_cnt <= 7'd0; parse_done <= 1'b0; end else begin parse_done <= 1'b0; if (rx_valid) begin case (byte_cnt) 7'd12: eth_type[15:8] <= rx_data; // EtherType 高字节 7'd13: eth_type[7:0] <= rx_data; // EtherType 低字节 7'd23: ip_proto <= rx_data; // IPv4 Protocol 字段 7'd26: src_ip[31:24] <= rx_data; // 源IP 高字节 7'd27: src_ip[23:16] <= rx_data; 7'd28: src_ip[15:8] <= rx_data; 7'd29: src_ip[7:0] <= rx_data; 7'd30: dst_ip[31:24] <= rx_data; // 目的IP 高字节 7'd31: dst_ip[23:16] <= rx_data; 7'd32: dst_ip[15:8] <= rx_data; 7'd33: dst_ip[7:0] <= rx_data; 7'd34: src_port[15:8] <= rx_data; // TCP 源端口 7'd35: src_port[7:0] <= rx_data; 7'd36: dst_port[15:8] <= rx_data; // TCP 目的端口 7'd37: dst_port[7:0] <= rx_data; 7'd38: tcp_seq[31:24] <= rx_data; // TCP 序列号 7'd39: tcp_seq[23:16] <= rx_data; 7'd40: tcp_seq[15:8] <= rx_data; 7'd41: begin // 低字节当前拍到达,同时打包 head_bus head_bus[151:136] <= eth_type; head_bus[135:128] <= ip_proto; head_bus[127:96] <= src_ip; head_bus[95:64] <= dst_ip; head_bus[63:48] <= src_port; head_bus[47:32] <= dst_port; head_bus[31:0] <= {tcp_seq[31:8], rx_data}; parse_done <= 1'b1; end default: ; endcase byte_cnt <= byte_cnt + 1; end end end endmodule逻辑说明:这个流水是“一边计数一边采样”的结构。byte_cnt从0开始,每个rx_valid有效拍自增,到偏移41时,TCP序列号的低字节正好在这一拍到达,因此直接用rx_data上位打包,不需要再多等一拍。parse_done脉冲只在偏移41的那一帧内产生,下游看到它就知道head_bus内容已刷新。
参数说明:byte_cnt只有7位,因为以太网最短帧是64字节,我们只关心到偏移41,用7位足够;eth_type用来后续判断是否是IPv4,ip_proto判断是否是TCP,这两个字段没进判断条件,是在下游查表前做valid前缀检查用的。如果解析到非IPv4或非TCP包,整个代理直接走直通路径,不建表不改写,相当于一个普通交换机转发。
3.2 五元组哈希查表:两拍出结果的组相联查找
tcp_session_table的核心是一张哈希表。工程把五元组拼成104bit的key,折叠异或后得到一个8bit索引,每组4个槽位做组相联。查表逻辑两拍出结果,第一拍算索引并读RAM,第二拍比较四个槽位的key和有效位。
// tcp_session_table.v —— 五元组哈希 + 深度4的组相联槽位 module tcp_session_table ( input wire clk, input wire rst_n, input wire lookup_en, input wire [103:0] lookup_key, // src_ip+dst_ip+src_port+dst_port+ip_proto output reg hit, output reg [2:0] session_state, output reg [1:0] slot_idx ); // 折叠异或哈希:13个字节逐个异或到一个字节 function [7:0] hash5; input [103:0] key; integer i; reg [7:0] acc; begin acc = 8'h00; for (i = 0; i < 13; i = i + 1) acc = acc ^ key[i*8 +: 8]; hash5 = acc; end endfunction reg [7:0] bucket_index; reg [103:0] slot_key [0:3]; reg slot_valid [0:3]; reg [2:0] slot_state [0:3]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin hit <= 1'b0; end else if (lookup_en) begin bucket_index <= hash5(lookup_key); end end // 第二拍:4槽位并行比较,命中则读出状态 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin hit <= 1'b0; end else if (lookup_en) begin hit <= 1'b0; for (int i = 0; i < 4; i = i + 1) begin if (slot_valid[i] && slot_key[i] == lookup_key) begin hit <= 1'b1; session_state <= slot_state[i]; slot_idx <= i[1:0]; end end end end endmodule逻辑说明:这里把“算索引”和“比槽位”拆在两个always块里,故意错开一拍。第一拍hash5的结果先在bucket_index里打住,第二拍再用lookup_key直接和四个槽位比较,而不是先经RAM索引再读。这样四个槽位的比较是纯组合逻辑,不读RAM,时序上更容易收敛。命中时同时输出状态和槽位编号,下游用slot_idx可以直接反写状态机。
参数说明:lookup_key固定为104bit,顺序是src_ip(32)、dst_ip(32)、src_port(16)、dst_port(16)、ip_proto(8)。这个顺序和3.1里head_bus的字段排列完全一致,省掉了在查表端再做一次位重排。哈希函数对13个字节做折叠异或,冲突概率在高随机性流量下约1/256;4槽位组相联能把冲突导致的建链失败概率压到极低,工程实测600条并发连接下只有一个槽位发生过一次替换。
3.3 TCP握手状态机的硬件建模
每条连接在表里有一份状态寄存器,状态机的转移条件和软件TCP栈的握手逻辑一致,但实现上完全是硬件风格:每个时钟沿检查一组控制信号,没有循环等待,没有阻塞调用。
// tcp_session_table.v 内部的连接状态机 localparam S_CLOSED = 3'd0; localparam S_SYN_RCVD = 3'd1; // 已收到客户端SYN,等待CPU下发目标 localparam S_SYN_SENT = 3'd2; // 已向后端发起真实SYN localparam S_ESTABLISHED = 3'd3; localparam S_FIN_WAIT = 3'd4; localparam S_TIME_WAIT = 3'd5; reg [2:0] state; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_CLOSED; end else begin case (state) S_CLOSED: if (rx_syn) state <= S_SYN_RCVD; S_SYN_RCVD: if (cfg_valid) state <= S_SYN_SENT; S_SYN_SENT: if (tx_syn_ack_ok && rx_ack) state <= S_ESTABLISHED; S_ESTABLISHED: if (rx_fin) state <= S_FIN_WAIT; S_FIN_WAIT: if (rx_ack_fin) state <= S_TIME_WAIT; S_TIME_WAIT: if (timeout_expired) state <= S_CLOSED; endcase end end逻辑说明:S_SYN_RCVD到S_SYN_SENT的转换条件是cfg_valid——CPU把目标服务器地址写进配置寄存器后,硬件才允许代答并向后端发起真实SYN。这里有个刻意的设计:cfg_valid是一个电平信号而不是脉冲,因为CPU总线写入可能在任意周期完成,电平方式避免漏采。代答SYN-ACK与发送真实SYN在时序上是同一个周期触发的两个动作,保证握手窗口最小。
参数说明:timeout_expired由外部超时扫描器每N个时钟周期拉高一次,N用parameter定义,工程默认100万周期,对应200MHz下5ms。5ms对于正常TCP建连来说足够,但能快速回收异常半开连接。状态机的硬编码用本地参数而非全局parameter,换连接数时不会牵连其他模块。
3.4 转发改写:字节替换与校验和重算
forwarding_engine把原始帧从RX FIFO里逐字节读出来,根据查表动作决定某几个字节偏移位置是否需要替换。代理场景下需要改写的字段有:目的MAC、源MAC、目的IP、目的端口、序列号偏移。实现方式是在帧内字节计数上做条件替换。
// forwarding_engine.v —— 查表命中后按动作改写并重算校验和 always @(posedge clk) begin if (fwd_en && rewrite_en) begin case (byte_cnt) 7'd0: tx_data <= new_dst_mac[47:40]; // 目的MAC第1字节 7'd1: tx_data <= new_dst_mac[39:32]; 7'd2: tx_data <= new_dst_mac[31:24]; 7'd3: tx_data <= new_dst_mac[23:16]; 7'd4: tx_data <= new_dst_mac[15:8]; 7'd5: tx_data <= new_dst_mac[7:0]; 7'd30: tx_data <= nat_dst_ip[31:24]; // 目的IP 7'd31: tx_data <= nat_dst_ip[23:16]; 7'd32: tx_data <= nat_dst_ip[15:8]; 7'd33: tx_data <= nat_dst_ip[7:0]; 7'd36: tx_data <= nat_dst_port[15:8]; // 目的端口 7'd37: tx_data <= nat_dst_port[7:0]; default: tx_data <= rx_data; // 其余字节直通 endcase end else begin tx_data <= rx_data; end end逻辑说明:rewrite_en由查表命中的动作决定,未命中或动作是DROP时走else分支。改写位置和3.1解析时的偏移完全对应,只是方向相反——解析时在偏移30到33提取目的IP,现在在同一偏移覆盖新值。这样设计的好处是解析器和转发引擎的偏移常量表只有一份,改字段定义时两处同步改,不容易错位。
TCP校验和的处理,这份工程没有做增量更新,而是选择了重算。原因是改写点多达4个字段,增量更新要做4次RFC 1624的补码回卷操作,边界条件太多,重算整个TCP头只需要一组16位反码加法器,逻辑更干净,代价是几十个LUT的额外面积。重算核心是一个带回卷的16位反码加法函数:
// ones_complement_add.v —— 16位反码加法,带end-around carry function [15:0] ones_complement_add; input [15:0] a; input [15:0] b; reg [16:0] tmp; begin tmp = {1'b0, a} + {1'b0, b}; if (tmp[16]) tmp = tmp + 1'b1; // 最高位溢出回卷 ones_complement_add = tmp[15:0]; end endfunction逻辑说明:这是整个校验和重算的基石。16位加法结果若产生第17位进位,就需要把这个进位再加回最低位,这正是TCP/IP校验和区别于普通二进制加法的关键。所有参与校验和的16位字都通过这个函数累加,最后取反输出,得到正确的反码校验和。
参数说明:校验和覆盖范围是伪首部(源IP、目的IP、协议号、TCP长度)加完整TCP头,数据段不参与重算,因为代理不改写负载。重算使能信号csum_en从TCP头的第一个字节开始拉高,到帧结束拉低,覆盖范围严格对齐。
4. 仿真与验证:从IV编译到波形定位的实操记录
4.1 工程目录与编译环境
把源码下载下来后,工程顶层目录结构如下:
tcp_proxy_fpga/ ├── rtl/ │ ├── mac_rx_adapter.v │ ├── frame_parser.v │ ├── tcp_session_table.v │ ├── forwarding_engine.v │ └── ones_complement_add.v ├── tb/ │ ├── tb_top.v │ ├── tb_nat_case.v │ └── gen_syn_pkt.v └── sim/ └── Makefile仿真环境用Icarus Verilog,编译命令很直接:
iverilog -o sim/sim.vvp -s tb_top \ -I rtl \ tb/tb_top.v \ rtl/mac_rx_adapter.v \ rtl/frame_parser.v \ rtl/tcp_session_table.v \ rtl/forwarding_engine.v \ rtl/ones_complement_add.v vvp sim/sim.vvp编译参数说明:-s指定顶层模块名,-I指定include路径,tb_top放在最前面是因为它实例化了被测模块。如果新增文件,直接在命令后面追加即可。Makefile里我习惯加两个target:sim跑全量用例,wave在sim后自动打开GTKWave。
4.2 构造激励:从SYN到数据传输的完整时间线
testbench不能只喂一个孤立包,我是按完整连接生命周期来构造的:先发一个TCP SYN包,等硬件代答SYN-ACK,再回一个ACK完成握手,然后发一个带负载的数据包验证转发改写。为了可控,帧内容直接用字节数组手工拼。
// tb/tb_top.v —— 最小TCP SYN包激励片段 reg [7:0] syn_pkt [0:53]; initial begin // 组装最小TCP SYN包(14B以太网头 + 20B IPv4头 + 20B TCP头) // 目的MAC: AA:BB:CC:DD:EE:01 // 源MAC: AA:BB:CC:DD:EE:02 // 源IP: 10.0.0.1, 目的IP: 10.0.0.2 // 源端口: 10001, 目的端口: 80, SEQ: 1000 rst_n = 1'b0; #20 rst_n = 1'b1; // 先复位各模块,再逐字节发送SYN包 @(posedge clk); for (int i = 0; i < 54; i = i + 1) begin @(posedge clk); rx_data <= syn_pkt[i]; rx_valid <= 1'b1; end @(posedge clk); rx_valid <= 1'b0; // 等待硬件代答SYN-ACK,再回ACK wait (tx_verdict == FWD_REWRITE); // 此处省略回ACK包的激励,与SYN发送逻辑一致 end逻辑说明:这个激励的关键点是把帧字节在时间上展开,每个clk上升沿喂一个字节。54字节的包意味着从开始发送到帧结束一共54拍,frame_parser会在第42拍打出parse_done脉冲。注意rx_valid在发送完成后保持低电平,模拟MAC侧帧间隙的IDLE状态,防止解析器把下一帧的字节当成当前帧偏移继续数。
参数说明:syn_pkt数组的内容在代码里用注释做了逐字段标注,实际使用中如果嫌手写麻烦,可以改用$readmemh从hex文件加载,工程里gen_syn_pkt.v就是按标准TCP头布局生成hex文件的。wait语句的tx_verdict来自forwarding_engine的测试端口,用于同步握手时序,避免激励提前发送而丢失。
4.3 波形验证技巧
跑完仿真后,用GTKWave打开生成的VCD波形:
gtkwave sim/wave.vcd重点盯四个信号:frame_parser.byte_cnt、frame_parser.parse_done、tcp_session_table.hit、tcp_session_table.session_state。正常时序下,parse_done应该在SYN包第42拍出现,两拍后hit拉高,session_state从S_CLOSED跳到S_SYN_RCVD,再等CPU写入cfg_valid后跳到S_SYN_SENT。如果parse_done周期不对,多半是byte_cnt复位时机出了问题;如果hit一直为低,先查lookup_key的位拼接顺序是否和head_bus一致。
我一般会在testbench里加一个简单的自动断言,而不全靠肉眼盯波形:
always @(posedge clk) begin if (parse_done && !parse_ok) $display("ERROR: parse_done at byte_cnt=%0d", byte_cnt); if (hit && (session_state != S_SYN_RCVD)) $display("ERROR: session state mismatch"); end逻辑说明:断言把“解析完成”和“状态跳转”两个关键点做成自动检查,一旦时序错位立即打印错误并定位到周期数。这个方法在迭代改代码时价值很大,比每次打开波形一帧帧看要快得多,也适合作为回归测试的门禁。
5. 避坑指南:硬件TCP代理调试中容易翻车的五个细节
这几个坑都是仿真和上板过程中真正遇到过的,不是纸面推演,每条都按现象到原因再到处理的顺序写,照着排查能省不少时间。
5.1 TX FIFO空读造成帧内容错位
现象:仿真波形里解析出来的dst_port和激励里组装的对不上,整体后移了一个字节,且只发生在大包连续发送时。原因:读FIFO的rden拉高的同时empty还在高电平,读出来的是上一拍的旧值。异步FIFO的empty信号本身有快照时延,等价于多了一拍,而代码里直接用empty的下降沿触发读使能,导致空读。解决:读使能改成empty下降沿打两拍后再允许rden,或者直接换成交付型FIFO(FWFT模式),让数据提前出现在dout上。工程里改的是读使能时序,加了两级同步寄存器后问题消失。
5.2 回程方向查不到表项,ACK全被丢
现象:上行流量转发正常,后端服务器返回的包几乎全部被丢弃,抓包看到的ACK数量极不对称。原因:设计里只在收到客户端SYN时建了一条连接表项,回程流量的五元组和正向方向相反,查表时反向key和正向key完全对不上。解决:在硬件代答握手并下发规则时,正反两个方向的表项一起创建,各自保存各自的seq_delta。正向delta是请求方向的序列号改写值,反向delta是响应方向的改写值。这个经验后来在改造多规则代理时也一直沿用——凡是代理或NAT,建表时必须成对建。
5.3 增量校验和改了低16位忘改高16位
现象:抓包工具校验和检查报错,TCP重传量暴增,但帧内容看起来都正确。原因:如果采用增量更新,公式HC'=~(~HC+~m+m')是按16位字为单位的。当时改32位seq偏移量时只对低16位字做了增量更新,高16位没处理,导致校验和计算错误。解决:把seq拆成高、低两个16位字分别做增量更新,并且每次16位加法都要带回卷进位。后来这份工程干脆改成3.4里的全量重算方案,逻辑上彻底规避了这个坑,代价只是多占用十几个LUT。
5.4 哈希冲突把老连接顶下线
现象:压测并发连接时,发到第257条全双工连接后,前面某几条连接会同时掉线。原因:哈希索引只有8bit,每组只有1个槽位。新连接的哈希索引碰撞到老连接所在槽位时,直接把老连接表项覆盖了。解决:改成深度4的组相联结构,每组4个槽位,冲突时优先替换LRU槽位。同时把key里加入ip_proto参与异或,降低同一端口不同协议互相踩踏的概率。回归脚本里专门加了一个“连满300条连接”的激励用例,从此再没出现连接数一多就掉线的情况。
5.5 上电后表项被随机旧值初始化,连接秒断
现象:板卡上电跑一段时间后所有连接同时断开,重启后复现,且时间点不稳定。原因:Block RAM上电时没有确定性初始值。超时扫描线程把随机“有效”且“长时间老化”的垃圾表项当成正常连接清除,等于把所有连接全误杀了一遍。解决:加了一个上电初始化状态机,复位释放后先把整个表项的valid统一清零再开放查表。256个槽位用时钟分频每个周期清一个,几百个周期内完成初始化,之后查表逻辑才使能。从那以后我再也不信任Block RAM的默认初值。
6. 二次开发:把透明代理延伸成协议卸载引擎
6.1 改造方向与动作表设计
落地到自有板卡时,最常见的需求是“不只代理一个端口”,把透明代理做成一个多规则NAT加负载均衡卸载引擎。改造点集中在三处:哈希键从四元组扩到五元组加输入接口号,104bit扩到112bit;查表命中后不再直接转发,而是取出规则表的动作,动作可以是DROP、PASS、DNAT、SNAT或负载均衡;转发引擎除了改写目的地址,还能按规则池选后端。
6.2 关键代码与验证方法
规则表放在一个独立的小模块里,和连接表分离,优先级按index从0到15递减:
// nat_rule_table.v —— 16条NAT规则,优先级从0到15递减 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rule_hit <= 1'b0; end else if (rule_lookup_en) begin rule_hit <= 1'b0; for (int i = 0; i < 16; i = i + 1) begin if (rule_valid[i] && rule_match[i]) begin rule_hit <= 1'b1; rule_action <= rule_action[i]; rule_dst <= {rule_dst_ip[i], rule_dst_port[i]}; end end end end逻辑说明:这个循环是硬件综合工具能展开成16路并行比较的,不是软件里的顺序循环。每条规则的匹配条件和动作独立存储在寄存器堆里,CPU通过配置总线直接读写。命中后输出的rule_action和rule_dst直接接入6.1的连接表新建流程。
参数说明:rule_match需要三部分同时成立——源IP前缀匹配、目的IP精确匹配、目的端口匹配。前缀长度用独立的mask寄存器配置,这样一条规则能覆盖一个网段。
验证方法我固定用三套用例。case1:单连接透明代理,验证双向转发和校验和正确;case2:多规则NAT,两个不同后端各自的DNAT规则各自命中;case3:负载均衡,同一条四元组连续建两条连接,验证后端地址在池内轮换。每套用例盯三个信号:parse_done、hash_hit、校验和重算值,全部通过再跑综合。
从那以后,我每次改连接表结构都会先把这三套用例跑一遍再动时序。哈希宽度、规则表深度这些参数全部留成parameter,换FPGA型号时改一个值就行,不用动逻辑主体。这个习惯帮我躲过了好几次“代码看着对、一跑就崩”的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取