
各位做FPGA嵌入式开发的朋友今天想跟你们好好聊聊纯Verilog手写UDP/TCP协议栈这个话题。做这行的人都清楚现在用现成IP核实现以太网通信太容易了花点时间调用厂商方案、配一下接口封装收发数据就能跑起来。但当项目要求特殊、需要深度定制或者板子资源受限、不能吃下完整IP核的时候你会发现自己其实对底层协议一头雾水出了问题都不知道从哪儿下手排查。正因如此我才会在好几个项目中坚持用纯逻辑、纯手写代码的方式去实现UDP协议和TCP协议虽然过程很痛苦但收获却是实打实的。这篇文章不会跟你讲空泛理论我会把自己踩过的坑、反复试错的细节、以及最终稳定运行的架构方案全部掏出来尽量还原一个真实项目的完整思考过程。这篇内容的定位很清晰给那些不想只停留在调用IP核层面、想真正吃透FPGA以太网底层原理的工程师一份自查参考。无论你是刚入门想搞懂数据链路传输机制还是已经卡在TCP交互状态机调试上这篇梳理都会对你非常有用。1. 整体设计思路与架构拆解动手写任何协议栈之前我首先逼自己回答一个问题既然现成方案一抓一大把为什么还要自己从头写答案不只是为了学习更多是实际项目逼出来的。比如之前做一块小型化采集板板子规模很小逻辑资源预算非常吃紧厂商标准TCP/IP核动不动占掉几万LUT根本塞不下同时我们还需要在上层跑自定义的应答机制用别人的核反而不自由。这时候用纯Verilog手写精简协议栈就成了唯一合理的选择。1.1 为什么选择纯逻辑手写方案很多人低估了纯逻辑手写协议栈的价值觉得这就是个重复造轮子的活儿。但我的体会完全不同。用厂商IP核确实能帮你快速上线TCP/UDP通道但你必须忍受一个黑盒子你并不知道它内部状态机是怎么处理的不知道ACK超时机制具体是多少也不知道它对异常ARP请求如何处理。一旦对端网络环境复杂一点比如出现丢包重传、乱序、半开连接你连从哪儿下手诊断都不知道。纯手写方案最核心的优势是可控性和可裁剪性。我做的模块里TCP状态机、UDP校验、ARP缓存表都是自己管理的所有状态一目了然逻辑资源占用也能控制得很低。比如我实现的UDP模块整体占用才几百个LUT完整TCP简化版也只要几千个LUT的量级这对资源紧张的项目帮助非常大。另一个隐性好处是调试学习和问题定位效率因为是自己写的代码仿真波形里每个状态变化都有明确的对应逻辑定位问题往往只需要十几分钟。当然我也不会昧着良心只说优点。纯手写的代价也很明显开发周期长、考虑的情况可能不全、标准符合度需要自己测试验证。如果项目周期特别短要求的TCP并发连接数特别高那我还是建议用现成方案。但如果你做的应用就是点对点、单连接或少量连接、对吞吐量有一定要求同时你希望数据通路完全自己做主那这条路一定值得走。1.2 整体模块划分与数据通路我最终确定的架构严格遵循分层思想但又为了FPGA的实现做了简化裁剪。整体分成五大块最底层是RGMII接口和MAC控制核心负责物理帧收发、CRC校验、帧间隙控制再往上是ARP模块用来解决IP到MAC地址的映射问题再往上是IP层收发模块处理IP包头解析与封装最上层分成两个独立引擎一个是UDP引擎、一个是TCP引擎同时还有一套用户接口寄存器组模块对外提供简洁的总线式接口方便用户逻辑读写。这里可能有人问为什么还要单独拆ARP出来很简单TCP和UDP的通信都依赖底层的MAC地址寻址ARP是局域网内必然要处理的协议。如果忽略ARP你的TCP握手包根本发不出去因为对端根本不知道你的MAC地址。而且ARP的设计很讲究要处理请求、应答、缓存老化还要防止缓存被频繁更新导致性能抖动。数据通路大概是这样的用户逻辑需要发送数据时把待发送的数据和目的IP、端口号写到用户接口UDP/TCP引擎加上包头然后交给IP层封装IP头再交给MAC层封装以太网帧头发出去。接收方向上则正好反过来MAC层收到帧去掉帧头判断协议类型如果是ARP就直接交给ARP模块如果是IP包就交给IP层解析目标IP是否匹配匹配后再根据协议号分给UDP或TCP引擎。整个过程每一层都只做很小的一件事但这种流水线式的结构非常容易被仿真和排查。1.3 数据缓冲与时序设计的几个关键决策缓冲区设计是整个项目的胜负手。我首选了异步FIFO来做跨时钟域的数据缓冲一方面解决MAC时钟和用户逻辑时钟不同频的问题另一方面也能起到暂存报文、平滑突发的作用。为了尽量节省Block RAM资源我严格控制FIFO深度接收方向只要有能存下若干个最大长度帧的能力就够了发送方向则要能缓冲一定量的用户突发数据。还有一个关键决策是CRC校验到底放在哪一侧。我最终选择在MAC发送侧计算并附加CRC32接收侧校验CRC。可能有人问能不能把CRC计算放到IP层理论上可以但那样会把错误帧也送到上层处理白白浪费逻辑资源。放在MAC层是最朴素也是最高效的位置。另外就是MAC地址过滤这个问题。我们在接收方向加了一个简单的地址过滤逻辑目标MAC为广播地址或与自身MAC地址一致的帧才会被放行。如果不加这个过滤网卡实际上会收到局域网里的所有广播帧对侧看看似乎无所谓的但实际在繁忙的网络里会让你的FIFO很容易溢出同时浪费了大量处理时钟周期。2. 核心协议模块的Verilog实现细节如果说整体架构是骨架那UDP和TCP模块就是血肉。这两个模块的难度差别巨大UDP是小台阶TCP则直接上了一个大台阶。我分别说一下它们的设计要点和几个容易被忽略的地方。2.1 UDP模块设计没有想象中的那么简单UDP在教科书上的描述就六个字无连接、不可靠。很多文章告诉你实现UDP很容易加个端口号和校验和就完事了。从逻辑结构上看确实是这样UDP头只有四部分源端口、目的端口、长度、校验和。实现时发送端把用户数据长度加8字节头校验和用IP层的伪头部加UDP头加数据计算出来接收端做对应的校验然后根据目的端口分发数据。但实际动手后你会发现校验和这个环节就是第一个坑。UDP校验和计算的是16位反码和——注意是反码和不是补码和。我第一次写的时候直接按补码逐个累加结果和Wireshark抓包比对时发现校验和字段总是不一致。原因在于反码和的溢出回卷是在最高位进位加回到最低位而不是舍弃进位。这里我给出了当时验证过的一段简化代码片段// 伪代码计算UDP校验和时的反码累加 always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum 32d0; end else begin // 每来一个16bit数据进行反码和不完全累加 sum sum {16h0000, data_in}; // 注意data_in是16bit // 最终需要把sum高16位和低16位反复相加直到没有进位 end end看到这里你可能会问那为什么把高位回卷加到低位就正确其实这就是二进制反码的实现方式。做完累加之后还要取反才是校验和。我当时调试时让数据通过UDP发送出去然后同时用Wireshark在PC侧抓包对比校验和字段前几次都是错的后来把反码和逻辑改对之后Wireshark里显示校验和状态从incorrect变成correct那一刻真的松了口气。另一个容易被忽视的点是UDP端口匹配在接收方向的处理。收到一个UDP包时不能只看目的端口就分发数据你还得校验UDP长度和IP头里的总长度字段是否一致否则可能是被截断的坏包。合理做法是先校验IP头总长度和UDP长度字段的关系再判断目的端口是否匹配用户配置的端口列表如果都匹配再往用户FIFO写数据否则直接丢弃。这样做能让上层逻辑省去一大堆防御性判断。UDP模块虽然简单但它的多端口支持也值得提一下。我管理的模块里允许配置四组过滤端口每组包含一个使能位和16位的端口号接收方向根据配置决定把数据送入哪个用户通道。这个设计非常实用我项目里就是把图像数据和控制指令放在两个不同的UDP端口上底层用一套逻辑就同时服务了两条数据通路。2.2 TCP模块设计状态机是灵魂TCP模块的复杂度比UDP高出一个数量级因为它是有连接、可靠传输的协议需要维护连接状态、序号、确认号、重传机制、拥塞控制等等。但FPGA不适合实现完整的TCP协议栈资源开销和信息维护成本都太高。一个很实际的问题多连接并发怎么办答案是看你项目需求。如果是点对点系统比如与PC通信我很干脆地只实现单连接但为了程序灵活模块内部依然保留少量连接表项可以支持一两个后台连接同时极力精简状态处理。一个参考实现的状态机机械拆解localparam IDLE 5d0; localparam SYN_SENT 5d1; localparam SYN_RCVD 5d2; localparam ESTABLISHED 5d3; localparam FIN_WAIT_1 5d4; localparam FIN_WAIT_2 5d5; localparam TIME_WAIT 5d6; localparam CLOSE_WAIT 5d7; localparam LAST_ACK 5d8; localparam LISTEN 5d9;很多人会问我用FPGA实现TCP最重要的设计理念是什么我的答案是把复杂的状态变化拆成简单的条件触发。比如在LISTEN状态下收到SYN就回复SYNACK并跳转到SYN_RCVD在SYN_RCVD状态下收到ACK就跳转到ESTABLISHED。这个逻辑虽然简单但你必须精确处理每一个状态里收到的非法报文否则协议栈就可能挂死。我实际用下来最花精力的是序号和确认号管理。TCP里的SEQ和ACK计算不能只看发送的字节数还要考虑握手阶段SYN和FIN各占一个序号。很多初次接触的人在算ACK时会漏掉这个细节建立连接时发的SYN包序号记为x收到对端SYN后回ACK时确认号必须是x1。数据阶段每个字节占一个序号发送了长度为n的数据包下一个报文的SEQ就应该增长n。我曾经因为算错ACK号导致PC端一直回复重复ACKWireshark里看到一连串的TCP Dup ACK和TCP Retransmission折腾了很久才发现是发送侧序号增加逻辑漏了数据长度。窗口管理和重传机制也要做取舍。标准TCP有一个可变大小的滑动窗口但FPGA里如果完全实现它资源消耗巨大。我选用了简化方案发送窗口固定为一个较大的常数接收窗口也固定为内部FIFO可容纳的某个深度并在握手应答时通告给对端。同时实现了一个基础的超时重传用一个计数器在发出数据后开始计时超时未收到ACK就重传当前包重传次数达到上限就断开连接。这套简化的可靠传输机制虽然不能与标准TCP的拥塞控制相提并论但在可控局域网环境、丢包率较低的情况下稳定性已经足够。2.3 纯逻辑实现TCP的几个取舍心得缓存策略发送方向我建议只缓存待发送的数据而不缓存整个TCP连接上下文。连接上下文用一组寄存器表维护即可。接收方向使用一个较大的FIFO作为重排缓冲但注意力求简单——能不做乱序重排就不做依赖底层网络本身的有序性这样逻辑复杂度会大幅度下降。定时器的实现TCP必须有多组定时器比如重传定时器、连接建立超时定时器、TIME_WAIT定时器等。我可以只用一个基础时基计数器然后通过比较器同时派生多路定时信号这样可以省下大量计数器资源。握手与挥手细节主动断开连接时从ESTABLISHED状态发出FIN包跳到FIN_WAIT_1收到对端ACK进入FIN_WAIT_2再收到FIN则回复ACK并进入TIME_WAIT。被动断开时收到FIN后进入CLOSE_WAIT回复ACK等用户逻辑处理好之后发送FIN。实际上这一步经常被项目的业务逻辑卡住如果用户逻辑一直没有给出可以关闭的信号TCP就停在CLOSE_WAIT状态。排查时需要看PC端是否一直在重传FIN而FPGA这边却没有任何日志大概率就是CLOSE_WAIT状态之后没有正确跳转。3. 实操过程与核心环节实现理论说得再多不如直接看实操过程。我尽量把跑通过程中真正值得固化的步骤一个个列出来从写代码到仿真到上板实测整个过程都是我的真实操作记录。3.1 代码结构与工程组织我最开始写的时候把所有逻辑堆在一个文件里很快发现仿真乱了引脚约束也对不上。后来老老实实按模块拆分每个模块一个文件夹文件命名统一是模块名_功能描述.v比如mac_rx.v、mac_tx.v、arp_cache.v、ip_rx.v、ip_tx.v、udp_engine.v、tcp_engine.v顶层top_eth.v只做例化和时钟/复位管理。顶层接口设计上我奉行最简原则对外只暴露一个用户写接口和用户读接口。发送路径是用户逻辑通过一个握手机制把数据压入发送FIFO然后协议栈自动完成后续封装发送接收路径则是协议栈把收到的有效载荷写入接收FIFO用户逻辑按信号读取。每个接口都有valid和ready信号做标准的AXI-Style握手这样上层逻辑无论是状态机还是DMA读写都很好适配。时钟和复位是工程的基础。由于RGMII接口的时钟与用户逻辑时钟不同频我通过MMCM/PLL产生了多个时钟域并且在顶层做了异步FIFO隔离。复位采用了异步复位同步释放的写法保证复位释放时各模块时钟域不同步导致的亚稳态问题降低。这一步看似基础但在长期运行测试时非常考验稳定性。3.2 仿真环境搭建与激励构造仿真阶段我做了很多工作可以说项目能否顺利上板有一大半取决于仿真环境是否充分。UDP路径的testbench逻辑比较简单模拟MAC层发送一个字节流MAC接收模块解析帧头后交给IP层再到UDP模块最终看到用户接口上有正确的数据输出。我专门构造了各种边界条件错误CRC帧、错误的IP校验和、目的MAC不匹配帧、目的端口不匹配帧每一个异常情况都要求模块能够干脆地丢弃或者做出正确反应。TCP的仿真就困难多了。你的testbench既要模拟主动连接建立又要模拟被动接收连接。我的做法是使用一个虚拟对端在一个testbench里例化两份协议栈但它们共享同一个物理数据接口用一组model去模拟真实物理链路。一份作为主动端一份作为被动端直接通过逻辑连接然后观察状态机跳转是否正确。这个办法非常有效能直接看到时序波形里握手是否正常。另外仿真时必须注意的一个细节不要忽略计时器的验证。你写一个计数100万个周期的重传定时器如果不仿真验证上板后断网重连可能就直接卡死。我在仿真里专门做了故意不回复ACK的场景验证重传逻辑是否能在超时后再次发送同一包并且重传计数到上限后连接能自动从ESTABLISHED回落到CLOSED状态。这种在教科书里不起眼的细节往往是实际项目中导致设备找不到复活能力的元凶。3.3 上板调试与实测记录上板调试阶段我的首选调试工具是片上逻辑分析仪。在这套设计中我预留了几个调试观测点MAC接收模块的状态、IP层包头解析结果、UDP/TCP状态机跳转信号、发送FIFO的空满状态和各个CRC/校验和的错误标志。通过触发条件抓取异常发生时的波形几乎能定位所有模块级问题。实际调试过程中最经典的一次经历是UDP可以收到PC发送的数据但PC一直收不到FPGA发出来的数据。这个问题的排查过程很好地体现了分层排查的重要性。我先用逻辑分析仪看MAC发送模块是否有帧启动信号发现TX模块根本没有收到MAC层的发送使能。进一步追踪发现UDP模块发送的valid信号确实置位了但IP层有一个判断条件不满足导致帧被丢弃。最后查下来竟是IP头里的总长度字段与UDP数据长度不一致——数据长度统计出了偏差。这种问题不通过内部观测点是很难定位的因为Wireshark根本不可能抓到没发出去的帧。上板后我还会用一个本地回环测试方法把FPGA的接收口数据直接接到发送口在PC上ping一下FPGA的IP如果ping通了说明MAC和IP层的基础功能都正常。然后再测试UDP通信最后再测试TCP连接。每上一层测试通过后再进入下一层这种递增式的验证思路可以大幅减少工作量。4. 常见问题与排查技巧实录这一节我整理了手写协议栈过程中最容易踩的几个坑和对应的排查方法。这些问题很多都是项目组里反复出现的值得形成速查表。4.1 UDP路径的高频问题与解法现象直接原因排查与解决PC收不到FPGA发出的UDP包IP校验和或UDP校验和错误Wireshark抓包看校验和状态检查反码累加逻辑特别是进位回卷环节FPGA收不到PC的UDP包ARP缓存表未正确建立先ping测试连通性再查看ARP缓存表看是否已解析出PC的MAC地址能收到包但数据内容错位IP头长度解析偏移错误检查IP层包头解析确认版本号、IHL字段不要漏算很多模块固定IHL为5但别忽略选项字段存在的情况端口过滤不生效端口号比较逻辑用了错误的字节序确认端口号在包头中的字节序通常是大端与用户配置值需要做一次字节交换4.2 TCP握手的疑难杂症TCP握手问题出现的频率最高因为SYN、SYNACK、ACK三个报文交错处理任何一步出问题都会导致连接失败。PC发起连接FPGA无响应优先看逻辑分析仪里SYN包是否到达了TCP模块。这个排查点的重要性在于前面有太多环节可能导致SYN被过滤掉比如MAC地址不匹配、IP校验失败等。尤其要确认ARP是否已经应答了PC的ARP请求否则即使SYN到了MAC层也会因为目的MAC不是本机而被丢弃。三次握手响应了SYN但无法进入ESTABLISHED多数是ACK确认号计算不对。对端发来的SYN序号为aFPGA回复SYNACK时ACK号应为a1同时自己的SEQ为b当PC再发送ACK时确认号则应该是b1。我犯过最蠢的错误就是只检查了标志位没有校验ACK号导致模块提前进入了ESTABLISHED结果后续传输的数据包全部被对端因为ACK号错误而丢弃。连接建立了但一次数据都传不过去这种问题往往跟序号管理无关而是接收FIFO或发送FIFO的满状态处理不当。比如FPGA内部接收FIFO已经满了但TCP模块没有及时向对端通告接收窗口为0对端还在继续发送导致数据被硬件直接丢弃。如果你在逻辑分析仪里看到接收FIFO的prog_full信号长期置位对端却在疯狂重传那大概率就是这个原因。4.3 从仿真到上板的其他细节竖直方向的帧填充和帧间隔也值得单独说一句。以太网最小帧长度是64字节如果你发送的UDP包很小比如只有几个字节的控制指令MAC发送模块必须自动填充到最小帧长。这个填充逻辑不在IP层做而是在MAC层做否则接收端的IP层解析时会被填充字节混淆。我第一次做的时候把填充逻辑写在IP层结果校验和、长度全都对不上。后面把填充挪到MAC发送侧并把填充计算纳入CRC计算范围才真正干净利落地解决。还有RGMII接口时序问题。RGMII标准要求在时钟的上升沿和下降沿分别采样控制信号和数据信号。如果接口时序约束没做对上板后链路时而通时而不通。我的建议是尽早检查FPGA的I/O约束文件特别是DDR模式的信号约束看是否和数据手册一致。很多调试时间浪费在对端PC的网卡属性设置上其实问题就出在约束文件里某个信号的建立/保持时间不达标。结尾的个人实操体会最后作为真实带着这些方案跑过多个项目的人我最想说的是纯Verilog手写协议栈这件事技术上完全可行它最大的价值不在于替代厂商IP核而在于让你真正获得对网络数据通路的掌控力。依赖于黑盒方案你永远无法从容地应对痛点而自己掌握协议栈的状态机之后你甚至能根据产品需求做各种定制化改动比如增加私有报文类型、自定义重传策略这些能力在实际工程里比想象中更重要。如果你也准备尝试这条路我的建议是先从UDP做起把校验和算对把MAC层调稳然后再挑战TCP精简实现。TCP部分只要抓住状态机这个牛鼻子把时序和序号管理搞清就成功了大半。仿真阶段多花点时间构建异常场景上板之后就不用对着逻辑分析仪反复猜疑。整个过程也许会让你掉不少头发但调试通过那一刻的成就感以及后续调试其他模块时养成的那份底层清晰感绝对值得。我自己以后再做网络相关设计时已经习惯了先画时序图、再写状态机、最后才动手敲代码这个习惯就是连续折腾了两三个协议栈项目才沉淀下来的。希望这篇文章能让你少走一些弯路把这些已经踩平的坑直接变成你前进的垫脚石。