用Verilog在FPGA上实现TCP代理:架构、序列号翻译与验证
简介基于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资源被大量吃掉时序还可能恶化。合理的做法是先算带宽延迟积BDPFIFO至少要能容纳“线速乘端到端往返时间”的数据量。举个例子假设端口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终结对外呈现为一个明文流量出口。这些都是把代理从单个功能升级成平台的自然路径。最后说一个我做这类项目的体会硬件代理比普通转发设备复杂的地方不在于某个模块有多难而在于你同时要照顾好状态、时序和系统三个层面的正确性。如果让我重新做一遍我会先把缓存调度和流表这两块搭好再往上叠协议解析和代理逻辑。顺序反了后面每一轮迭代都会非常痛苦。本文还有配套的精品资源点击获取