简介:基于Verilog的TCP代理程序是一份面向FPGA开发与网络功能加速方向的高阶硬件设计资源,适合有Verilog基础并希望深入TCP协议栈硬件化的工程师、研究生或竞赛选手,用于解决软件TCP代理在CPU上的性能瓶颈,实现网络功能硬件加速。资源压缩包约57.72MB,由于上游未提供文件类型明细,具体文件数量与格式暂无法列出,但内容围绕TCP代理的硬件实现展开,覆盖连接建立与管理、透明数据转发、连接释放、错误检测与恢复、并发调度等核心模块。实现细节上,重点涉及TCP握手与四次挥手、序号/确认号及窗口字段解析、连接状态表维护、超时重传机制,以及利用FPGA并行特性进行多连接高吞吐处理与低延迟优化,为在硬件层面搭建可靠、高效的TCP代理提供完整技术路线。目前已有564人学习,适合希望用FPGA替代CPU实现高性能网络中间件的读者作为参考,也是学习和实践硬件网络功能加速的典型案例。 在FPGA里用Verilog写一个“TCP代理”?不少同事听到这个需求的第一反应是:“这东西不是Linux上几行BPF就能干的吗,为什么要和RTL过不去?”说句实话,几年前我也这么想。直到我遇到一个场景:需要在一条高速链路上做透明的TCP流量审计,要求不改变两端的连接语义,同时时延控制在个位数微秒级,整机吞吐还不能因为代理逻辑而掉速。软件方案即使上了DPDK,在那个吞吐和时延约束下也压得很吃力,于是我把目光放回了FPGA。
这篇文章就聊聊“基于Verilog语言的TCP代理程序”这个看似矛盾的项目,到底该怎么落地。内容适合对网络协议栈和RTL设计都有点底子、但还没真正在FPGA上碰过TCP状态机的朋友。我会从架构拆解、序列号翻译、缓存调度到仿真验证一条线讲完,顺便把容易踩的坑都翻出来。
1. 为什么要用Verilog写TCP代理——硬件数据平面的现实需求
1.1 软件代理跑不满线速,硬件代理补的正是这个空档
先摆一个态度:我不是要证明“RTL能替代Linux内核”,这没有意义。软件代理的灵活性、生态、业务逻辑开发速度都是硬件没法比的,但它有明确的天花板。一个普通的内核网络路径,报文从网卡到应用要经历中断、软中断、协议栈、socket、上下文切换、内存拷贝、锁竞争,再回到网卡。就算用上DPDK和用户态协议栈,把核绑死、大页内存、无锁队列全部拉满,单核吞吐能做到几百万pps已经很不错了,但在64字节小包场景下,1G线速就是一秒一千四百多万个包,还要RTT控制在微秒量级,CPU方案往往撑不住。
FPGA的优势恰恰在这里。从报文解析、查表、改写再到转发,整条流水线是固定周期、固定延迟的,不随业务负载波动。只要流水线不反压,线速就是可承诺的。所以“Verilog写TCP代理”一般不是要去替换软件,而是出现在两类真实场景里:一类是必须在物理链路上做透明审计、报文改写、流量编排的设备,另一类是作为更高层加速设备的前置,比如在TLS卸载设备之前先把TCP流理顺、把流表建好,决定哪些报文送硬件解密、哪些上抛CPU。
1.2 写在RTL里的TCP代理,本质是什么
很多人一听“TCP代理”就觉得是一坨沉重的业务逻辑。从RTL视角看,它其实只是数据通路上一个“带连接状态感知的转发节点”。它不关心上层跑的是HTTP还是数据库协议,只做四件事:识别并维护两端TCP连接的状态;对经过的每一个SEQ/ACK做翻译;根据代理策略决定转发、拦截还是改写;保证在不破坏TCP语义的前提下把数据从一侧搬到另一侧。想清楚这一层,硬件结构就自然出来了。
一个典型的硬件TCP代理,内部至少要拆成六块:收包解析、流查找、TCP状态跟踪、序列号翻译、缓存调度、发送封装。Verilog做这件事的优势在于,可以把六块做成独立的流水级,每级只干固定的事,没有缓存一致性协议要处理,没有内核调度器反复抢占。至于用原生Verilog还是Chisel这类高层生成器,我的看法是:如果团队没有成熟的生成库,直接用SystemVerilog手写网络报文处理这种字节级格式反而更直观。生成RTL和手写RTL的区别在于“给人看”还是“给工具看”,网络协议这类固定结构化格式,两者差距没有想象中那么大。
2. 架构拆解:数据平面和控制平面如何在FPGA里分工
2.1 整体数据流:从收包到转发的六段流水线
我习惯把这套系统画成一条清晰的流水线。
第一级是MAC收包,做CRC校验、FCS剥离,只把有效的以太网帧交给后续逻辑。第二级解析,抽出以太网类型、IP头、TCP头,确认是IPv4/TCP报文,同时把五元组哈希出来。第三级查流表,用哈希结果匹配当前活跃的TCP连接条目,命中后拿到这条流在代理两侧的序列号映射。第四级是TCP状态机更新和策略判定,这一级是整个设计的核心,也最容易被低估。第五级做改写,包括SEQ、ACK、窗口字段,以及对应的校验和增量更新。第六级把报文写入缓存队列,由调度器按策略发到对端MAC。
难点在于每一级都必须按线速设计。举个例子,1Gbps端口、64字节最小以太网帧,每个包的到来间隔大约是6.7纳秒。也就是说,解析、查表、状态更新这一串动作,必须在大约三到五个时钟周期内完成,否则就得把流水线设计成多个周期并行处理多个包。这也是硬件代理和软件代理最本质的思维差异:软件在乎能不能正确处理,硬件在乎每个周期眼前这个包能不能处理完。做RTL之前,我习惯先把每一级的周期预算写死在设计文档里,再开始编码,不然写着写着就会失控。
2.2 连接建立与ARP:完全交给状态机,还是留一个软核
接下来要回答的问题是谁来管TCP握手,这个选择直接决定系统复杂度。
最简单的做法是让代理只转发已建立的连接,握手期间直接透传。但这种方式对审计和加速场景基本没用,因为你没有两端各自的序列号空间,后面做不了任何翻译。正规的透明代理必须和客户端建立一条连接,再向后端发起另一条连接,两条连接各自维护状态,才对两端透明。
那这两条连接的建立过程谁来处理?有两条路线。第一条是用一个软核CPU做控制平面,RTL检测到新连接的SYN包后,上抛给CPU,由CPU负责回SYN-ACK、维护状态表,再把表项下发给硬件数据平面。好处是实现快,坏处是CPU决定了建链速率上限,SYN Flood时容易被拖死。第二条是纯硬件状态机,RTL里用FSM直接处理SYN、SYN-ACK、三次握手完成的状态迁移,序列号偏移量在握手过程中自然记录到RAM表项里。这种方式没有CPU参与,建链速率只取决于哈希冲突率和RAM带宽,抗冲击能力强得多。
我在对稳定性要求高的场景里倾向于纯硬件状态机,但必须提醒:纯状态机对异常报文路径的处理非常繁琐,SYN重传、同时打开、半关闭状态、RST处理,每一条分支都要在testbench里提前覆盖。这块偷懒,后面整机联调时一定会加倍还债。
2.3 计数器与定时器:硬件TCP状态机不可缺少的“心跳”
纯硬件维护TCP状态,逃不开定时器。重传定时器、保活定时器、时间戳选项里的TSval更新,都需要定时机制。Verilog里写计数器本身很简单,难的是跨时钟域和溢出处理。
我的经验是把所有TCP状态定时器统一到一个基础时基上,比如1毫秒一个tick,由全局计数器分频产生。每个流表项不单独维护一个递增计数器,而是记录“到期时间戳”,周期性扫描时用当前时间戳去比较。这么做的好处是资源占用小,也方便在查表时顺带判断超时并回收表项。计数器跨时钟域时务必做同步处理,异步复位释放要安排在同一个时钟沿,别为了省事把异步信号直接当使能用。仿真阶段可能看不出问题,上板之后就是偶发的僵尸状态,排查起来非常痛苦。
3. 核心难点之一:序列号改写与校验和增量更新
3.1 代理为什么必须翻译SEQ/ACK
先理解一个关键事实:透明TCP代理在逻辑上是“两个背靠背的TCP端点”。代理和后端是一条连接,代理和客户端是另一条连接,两条连接的序列号空间各自独立。客户端发给代理的数据,代理要转给后端时,TCP头里的SEQ必须是代理到后端这条连接里的序列号,不能把客户端那侧的SEQ原样塞过去。如果不做翻译,后端会把合法数据当成乱序包丢弃,或者触发重复ACK风暴,最终表现就是连接看似建立了,数据却动不了。
这个点看似基础,但在硬件上很容易被忽略。很多人第一次写硬件代理,注意力全放在转发和校验和上,忽略了序列号翻译,结果仿真里用真实协议栈一联调就崩。代理在完成握手时会记录两个方向上ISN的差值,转发数据报文时对SEQ加上或减去这个偏移量,ACK字段同样处理。需要特别注意SYN和FIN报文各自会消耗一个序列号,计算偏移量时要把这两个标志位考虑进去,否则长连接传输几十GB数据后,累计误差会越来越大。
3.2 两端连接映射:流表怎么存,查表怎么快
每条被代理的连接,在RTL里就是一张表项:五元组、方向、当前发送序号、当前期望接收序号、映射后的序号偏移、连接状态、超时时间,以及指向缓存队列的指针。表项一般用哈希表和桶链实现。哈希算法不需要太复杂,CRC32或简单的异或折叠都够用,关键是要控制冲突率。我的经验是表项容量开成实际并发连接数的两倍以上,负载因子控制在0.5以下,否则随机五元组进来时冲突链会迅速变长,查表周期就不确定了。
流表存储可以用BRAM,也可以用寄存器堆。并发连接数几千条以内,BRAM完全够用;到几万条以上,就要考虑外置QDR或DDR来存储表项,但查表延迟会相应增加。另一个容易被忽略的问题是老化:流表必须周期性扫描超时表项并回收,否则设备运行时间一长,表项被死连接耗尽,新连接就建不起来了。我建议把表项回收和定时器扫描设计成同一个模块,放在同一个状态机里处理,逻辑上更紧凑,也方便在系统级仿真里一次性覆盖。
3.3 校验和增量更新:如何不重算整包校验和
TCP校验和覆盖伪首部和整个TCP报文段,如果每次转发都把整包数据重新读一遍来计算校验和,缓存和带宽压力都很大。好在有增量更新算法,原理是校验和是16位累加和的反码,当只修改其中某几个16位字时,新旧校验和之间存在固定数学关系,可以用旧校验和、旧字段、新字段直接算出新校验和,不需要把整包再算一遍。
对TCP代理来说,需要改的字段是SEQ和ACK两个32位字段,拆成四个16位字,逐个做增量更新,再加上TTL变化引起的IP校验和更新。这个计算在RTL里用一个16位加法器的流水线就能实现,几个周期出结果,比全包校验节省至少一个数量级的BRAM读带宽。这是硬件代理能做高吞吐的关键优化之一。还要记得,增量更新计算出的结果需要做“和为0”的最终处理,很多RTL实现最后忘了这一步,导致某些字段组合下结果差1,线上就会出现偶发的校验和错误。
4. 核心难点之二:缓存、背压与DDR3调度,不丢包的硬件前提
4.1 FIFO深度不能拍脑袋:从带宽延迟积出发
硬件代理的数据通路里,FIFO深度是绕不开的参数。开太小,突发流量一来就丢包;开太大,BRAM资源被大量吃掉,时序还可能恶化。合理的做法是先算带宽延迟积(BDP):FIFO至少要能容纳“线速乘端到端往返时间”的数据量。举个例子,假设端口1Gbps、端到端RTT是50微秒,BDP大约是1000Mbit/s乘以50微秒,折算下来约6.25KB。这是理论下限,实际还要考虑出口拥塞持续时间和调度器粒度。我从工程经验出发,典型机框内互联场景下单方向256KB的FIFO,可以覆盖绝大多数瞬时拥塞。
另一个容易踩的坑是FIFO读写位宽和队头阻塞。包长不是固定的,如果一个长包堵在出口,后面所有短包都出不去,整个链路延迟就会被拉高。更合理的做法是分级队列:每个连接或每个优先级队列一个FIFO,调度器按策略选择下一个发包队列,而不是一个FIFO里先入先出硬塞。多个连接共享一条物理链路时,公平性全靠调度器保证,队头阻塞和优先级反转必须设计初期就想清楚。
4.2 BRAM不够时上DDR3,但调度是关键
当缓存需求超过几十MB时,片上BRAM就不够了,这时候会引入DDR3或DDR4。DDR的突发特性决定了它适合大块连续读写,不适合一个包一个包地随机访问。所以硬件代理的缓存管理一般分两层:上层是包描述符队列,记录每个包在DDR里的起始地址、长度、所属连接和时间戳;下层是DDR读写调度器,把离散的包数据整理成较长的突发访问,提升带宽利用率。
DDR3读写调度我建议做成简单的优先级仲裁:读请求优先级高于写请求,写请求在缓冲区内攒到一定长度后一次性突发写入。仲裁器用轮询或加权轮询,把不同连接队列的请求按权重分发,没必要上太复杂的算法,重点是给写侧留足缓冲深度,避免读请求频繁打断写突发,导致总线效率受限于DDR读写切换的额外延迟。实测下来,一个带优先级轮询的仲裁器配合DDR3 1333MHz,能稳定跑到接近理论带宽的八成左右,对代理场景完全够用。
4.3 背压策略:反压、丢弃还是暂停
最后一个问题是“缓存满了怎么办”。硬件代理和普通防火墙最大的区别在于不能随便丢包。代理在逻辑上对两端透明,一旦丢了一个数据包,TCP会依赖重传恢复,而重传又会让序列号翻译逻辑处理同一段数据的重复转发,处理不当就会造成数据重复或序列号错乱。所以硬件代理的默认选择应该是背压,也就是通过MAC层流控信号暂停对端发送。
如果链路本身没有流控能力,或者对端网卡不响应PAUSE帧,那就只能尾部丢弃。但丢弃时务必在流表里打标记,至少在发送方向触发对端的接收窗口收缩或主动发送RST,让对端感知到拥塞,而不是沉默丢包。精细的WRED在这里往往不如一个稳定的“水线加全丢”简单可靠,代理场景的流量模型太复杂,每个流对丢包的敏感度差异极大,简单策略反而好排查问题。总之,背压与丢弃的设计结论要写进架构文档,别等到联调时再临时改。
5. 仿真验证:Modelsim里的单模块到整机回环
5.1 testbench:先造一个能“说话”的MAC模型
网络设备仿真最大的难点不是RTL本身,而是测试环境。在Verilog里写了一个代理,仿真阶段总不能在实验室里真的连物理网卡去抓包。最常用的做法是在testbench里写一个行为级的MAC/PHY模型,按照以太网时序把AXI-Stream接口上的报文发出去,同时能接住RTL发出的包,再把包送到另一侧的模型里,模拟对端主机。这样就能在仿真中构造一个双端口环境:一个端口扮演客户端,一个端口扮演服务端,代理在中间透明转发。
行为模型最关键的是能自动生成TCP报文,并且能根据对端ACK调整SEQ,这就相当于在testbench里实现了一个简化版TCP协议栈。我写testbench时会做几个可配置的task,比如发送SYN、发送ACK、发送带数据的包、发送FIN,再配合脚本化的数据源随机生成各种长度的包。不一定把TCP全部实现,但至少要有窗口控制和重传的概念,否则回归测试达不到效果。
5.2 回环测试:让代理代理自己
单模块测试通过后,最有效的验证手段是回环测试。把代理的两个端口在逻辑上接回同一个激励模型,让激励模型既当客户端又当服务端,发起一条经过代理的连接,然后大量传输数据。这个测试能一次性验证握手、流表建立、序列号翻译、校验和更新、缓存调度全链路。如果序列号偏移算错,回环测试里立刻会出现对端ACK和发送端期望对不上的现象,仿真波形里一查就能定位到到底哪个包被算错了。
回环测试还可以加压力:数据长度随机、突发间隔随机、两个方向同时传输,甚至在特定包上人为注入CRC错误,验证RTL的异常处理路径。我在这类测试里遇到过不少只在“方向交错”时才出现的bug,比如正向和反向查表用了同一个RAM端口导致访问冲突,这种问题单向仿真时永远不会暴露,回环一跑就现形。
5.3 仿真中容易翻车的几个点
第一是跨时钟域。代理两个端口如果跑在不同时钟域,同步FIFO的读写指针、跨时钟域信号打拍都必须严格处理。仿真里如果手动打拍方式不当,可能掩盖亚稳态风险,建议用断言或属性检查约束异步握手信号。第二是复位释放。常见做法是异步复位、同步释放,但释放时刻要避开时钟上升沿,否则寄存器复位状态和功能状态会出现竞争。第三是FIFO读写冲突。带show-ahead的FIFO在同时读写时,读数据可能出现一个周期的不稳定,Modelsim仿真里能看到,但容易误判为功能bug,需要先用干净的读写模型把FIFO本身验证一遍,再进入系统级仿真。
6. 实测结果与后续扩展空间
6.1 资源和时序实测经验
在中端FPGA上做一个双端口千兆以太网的TCP代理,资源占用大概是:解析和状态机部分消耗一万多个LUT,流表和缓存调度消耗几百KB BRAM,再加上DDR3控制器和MAC软核,整体资源占用在中端器件上大约在两到三成。时序方面,纯数据通路的组合逻辑尽量控制在十级以内,主频跑到200MHz问题不大。如果要把吞吐推到10G端口,建议数据总线位宽做到256比特以上,否则内部频率要求会高到不现实。
6.2 小包性能:代理项目的隐形瓶颈
小包是网络设备的老大难,硬件代理一样躲不开。64字节包在1G端口上线速约1.488Mpps,在200MHz、64比特接口下,每个周期能处理约128比特,一个包需要大概八个周期处理完。如果流水线每包处理时间超过这个预算,就要考虑把解析和查表拆成多级、多bank并行。实测下来,小包场景瓶颈往往不在逻辑本身,而在FIFO空满信号的延迟和调度器的决策周期,这两处每多一拍,都会直接吃掉一部分包间隔。所以写调度器时,能提前一拍预判空满,就不要等到下一拍再去读FIFO状态。
6.3 后续扩展:从代理到流量编排
“基于Verilog的TCP代理”跑通之后,能扩展的地方其实不少。最常见的是把流表和代理逻辑结合,做成带状态的流量编排节点:先根据五元组做粗分类,再决定哪些流送硬件代理、哪些直接透明转发、哪些上抛CPU做深度检测。另一个方向是和加密卸载结合,TCP代理顺利跑通后,TLS记录层解析也能顺势做进来,在代理节点上完成TLS终结,对外呈现为一个明文流量出口。这些都是把代理从单个功能升级成平台的自然路径。
最后说一个我做这类项目的体会:硬件代理比普通转发设备复杂的地方,不在于某个模块有多难,而在于你同时要照顾好状态、时序和系统三个层面的正确性。如果让我重新做一遍,我会先把缓存调度和流表这两块搭好,再往上叠协议解析和代理逻辑。顺序反了,后面每一轮迭代都会非常痛苦。
本文还有配套的精品资源,点击获取