☰
纯Verilog手写UDP/TCP协议栈:FPGA工程师的底层网络实现指南
2026/10/21 8:12:58 网站建设 项目流程

各位做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 <= 32'd0; end else begin // 每来一个16bit数据,进行反码和不完全累加 sum <= sum + {16'h0000, 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 = 5'd0; localparam SYN_SENT = 5'd1; localparam SYN_RCVD = 5'd2; localparam ESTABLISHED = 5'd3; localparam FIN_WAIT_1 = 5'd4; localparam FIN_WAIT_2 = 5'd5; localparam TIME_WAIT = 5'd6; localparam CLOSE_WAIT = 5'd7; localparam LAST_ACK = 5'd8; localparam LISTEN = 5'd9;

很多人会问我,用FPGA实现TCP,最重要的设计理念是什么?我的答案是:把复杂的状态变化拆成简单的条件触发。比如在LISTEN状态下收到SYN,就回复SYN+ACK并跳转到SYN_RCVD;在SYN_RCVD状态下收到ACK,就跳转到ESTABLISHED。这个逻辑虽然简单,但你必须精确处理每一个状态里收到的非法报文,否则协议栈就可能挂死。

我实际用下来最花精力的是序号和确认号管理。TCP里的SEQ和ACK计算不能只看发送的字节数,还要考虑握手阶段SYN和FIN各占一个序号。很多初次接触的人在算ACK时会漏掉这个细节:建立连接时发的SYN包序号记为x,收到对端SYN后回ACK时确认号必须是x+1。数据阶段每个字节占一个序号,发送了长度为n的数据包,下一个报文的SEQ就应该增长n。我曾经因为算错ACK号,导致PC端一直回复重复ACK,Wireshark里看到一连串的"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、SYN+ACK、ACK三个报文交错处理,任何一步出问题都会导致连接失败。

  • PC发起连接,FPGA无响应:优先看逻辑分析仪里SYN包是否到达了TCP模块。这个排查点的重要性在于,前面有太多环节可能导致SYN被过滤掉,比如MAC地址不匹配、IP校验失败等。尤其要确认ARP是否已经应答了PC的ARP请求,否则即使SYN到了MAC层,也会因为目的MAC不是本机而被丢弃。

  • 三次握手响应了SYN但无法进入ESTABLISHED:多数是ACK确认号计算不对。对端发来的SYN序号为a,FPGA回复SYN+ACK时,ACK号应为a+1,同时自己的SEQ为b;当PC再发送ACK时,确认号则应该是b+1。我犯过最蠢的错误就是只检查了标志位,没有校验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部分只要抓住状态机这个牛鼻子,把时序和序号管理搞清,就成功了大半。仿真阶段多花点时间构建异常场景,上板之后就不用对着逻辑分析仪反复猜疑。整个过程也许会让你掉不少头发,但调试通过那一刻的成就感,以及后续调试其他模块时养成的那份底层清晰感,绝对值得。

我自己以后再做网络相关设计时,已经习惯了先画时序图、再写状态机、最后才动手敲代码,这个习惯就是连续折腾了两三个协议栈项目才沉淀下来的。希望这篇文章能让你少走一些弯路,把这些已经踩平的坑,直接变成你前进的垫脚石。

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

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

立即咨询