基于FPGA自研UDP/TCP协议栈:从RGMII到状态机的实战解析
写这篇之前先说个背景。前阵子做一套数据采集板卡上位机要实时读取FPGA端的数据带宽需求比串口高了一个量级第一反应就是上以太网。翻了一圈现成的方案厂商自带的MAC硬核加协议栈IP核当然省事但落地时遇到了两个很实际的问题一是资源占用在大器件上无所谓在我这套板子的中端器件上就有点心疼二是IP核是个黑盒关了中断、调整时序、加自定义过滤逻辑都不好下手。折腾了一阵之后我决定干脆自己用纯Verilog把UDP和TCP的最小可用协议栈写出来从MAC帧到ARP、IP、UDP、TCP全部自研。这套实现后来在板子上跑了小半年踩过的坑不少但对以太网的理解也确实是质的提升。这篇文章把整个设计过程、关键模块的取舍、调试办法和实测结果完整梳理一遍。适合两类人看一类是刚开始想自己撸协议栈、正在纠结要不要动手的另一类是已经用IP核跑通、但一遇到奇怪的丢包或时序问题就无从下手的。你不需要先读任何协议文档我会把一个能用的最小实现拆开讲清楚顺带把文档里不会写的坑都标出来。1. 先别急着写代码自研协议栈的边界与选型判断很多教程上来就贴代码实际项目里最要命的恰恰是选型阶段。你到底是该自己写还是老老实实用厂商IP核这个判断比写多少行Verilog都重要。说几个我实际对比过的点。授权和资源方面。厂商的MAC硬核加协议栈IP核大厂确实做得稳定但代价是资源。一个带ARP/UDP/TCP栈的IP核典型会消耗几块BRAM和数千个逻辑单元因为它内部有缓存管理、校验和计算、以及完善的中断与DMA接口。如果你的FPGA本身资源冗余不大想再放一组并行数据通路就会明显感到紧张。而纯自研的最小栈在只保留收发路径、砍掉DMA和管理接口的情况下资源能压缩到IP核的几分之一这也是我最终选择自研的最直接原因。延迟和可控性。这点容易被忽视。IP核的收发路径非常长从MAC到协议栈再到用户DMA中途还有多级FIFO和中断调度延迟动辄几十微秒。自研栈最大的优势在于从网线进来到用户逻辑拿到数据中间每一条路径都在自己控制之下可以把固定延迟压到微秒级甚至几百纳秒。对实时控制类应用这个优势很关键。黑盒调试的痛点。用IP核跑出问题你手上只有状态寄存器和中断标志很难看清是哪一层出的错。自研栈的每个状态机都可以用片内逻辑分析仪直接拉出来看收发路径上每个信号都是可见的排查问题的效率完全不同。但这不代表自研就是万能的。下面这几种场景我反而劝你直接用IP核一是多连接、高并发的TCP服务器二是需要完整拥塞控制、快速重传、SACK这些进阶特性的复杂TCP传输三是纯业务项目、给到Deadline只有两周。自研TCP在这个体量下会变成无底洞。我最后定的边界很简单UDP全自研TCP只做一个简化版本目标场景是单连接、可靠地传完一段数据比如上位机下发配置或固件。这个定位决定了后面所有设计取舍。TCP每次都要握手、维护序号、做重传实现复杂度比UDP高一个量级如果你明确知道自己只需要UDP完全可以跳过第四章直接看UDP部分。2. 底层链路RGMII时序、以太网帧与CRC32的细节不管是UDP还是TCP所有协议最终都在同一个MII/RGMII链路上跑。底层这关过不去上层的协议写得再漂亮也白搭。我用的PHY接口是RGMII千兆速率这个接口里坑最多的地方有三个。2.1 RGMII的DDR采样与发送时钟延迟RGMII的电气定义看着简单TXD[3:0]、RXD[3:0]、TX_CTL、RX_CTL、RX_CLK、TX_CLK一共也就几个信号。但它在千兆模式下是双沿DDR采样——时钟上升沿传低4位数据下降沿传高4位数据TX_CTL同理上升沿对应TX_EN下降沿对应TX_ER。等效下来4根数据线加1根控制线双沿打完正好是8bit数据加1bit控制信息。在FPGA侧发送路径上最容易错的是忘记用ODDR原语。直接写always (posedge clk)输出是单沿数据PHY根本采不到完整字节。我在发送端用一个ODDR把数据拼成DDR形式代码大概是这个逻辑ODDR #( .DDR_CLK_EDGE(SAME_EDGE) ) u_txd_oddr ( .Q(txd[0]), .C(tx_clk), .CE(1b1), .D1(tx_data_byte[0]), // 上升沿输出的低4bit之一 .D2(tx_data_byte[4]) // 下降沿输出的高4bit之一 );这里有个细节RGMII规范要求源同步时钟相对数据有约2纳秒的延迟因为接收端要用这个时钟去采数据没有延迟的话时钟边沿会正好骑在数据翻转点上采样极易出错。但实际开发板上很多PHY的输入时钟已经做了内部延迟兼容±0延时工作。我的经验是先按无延时调通抓包有大量CRC错误再加约束到2纳秒。两种状态之间切起来很快但能帮你快速定位是PHY兼容性问题还是自己的逻辑问题。接收方向RGMII的RX_CLK是PHY恢复出来的源同步时钟进FPGA后需要对数据做DDR输入采样。这里一般用IDDR或者直接交给时序工具做输入延迟调整。踩过的教训是不要试图用一个外部高频时钟去采RX_CLK必须老老实实把它当独立时钟域用IDDR的边沿去锁数据。2.2 以太网帧结构与最小帧长以太网帧从线上看是这样的字段长度说明前导码7字节固定0x55用于时钟同步SFD1字节0xD5表示帧开始目的MAC6字节单播/组播/广播地址源MAC6字节本地MAC地址EtherType2字节0x0800为IPv40x0806为ARP载荷46~1500字节IP/UDP/TCP等上层数据FCS4字节CRC32校验值有个新手必踩的坑是最小帧长以太网帧不含前导码和SFD最短是64字节。也就是说目的MAC源MACEtherType一共14字节加上FCS 4字节载荷部分最少要有46字节。如果你的UDP包实际数据只有十几个字节发送端必须补零填充到46字节否则对端PHY或交换机可能直接丢弃这个帧。填充逻辑本身不复杂但要注意填充数据和CRC的计算范围是同一个“帧体”也就是从目的MAC开始到填充后的最后一个字节都要进CRC。先填零、算完CRC、连CRC一起发出去这个顺序别搞反。2.3 CRC32一篇讲透FCS字段是CRC32这是底层最容易出问题的地方因为以太网的CRC32和大部分教程里的CRC算法在字节序上有区别。多项式是0x04C11DB7初值0xFFFFFFFF计算完成后结果需要取反并且整个CRC是按位的LSB-first处理的。简单说以太网CRC32和软件里常见的CRC32/MPEG-2算法不是一回事。你用ZIP里的CRC32模块直接拿去算以太网帧算出来的字节序和位序都不对。并行CRC的生成标准做法是用生成矩阵展开每个时钟周期的线性方程或者用查表法。我只写一个思路对输入字节按位倒序因为以太网按LSB先传每来一个字节按查表更新CRC寄存器。如果不想自己推导表项可以直接用IP核生成器或者常见的CRC软件工具导出Verilog表方法没毛病但一定要核对初值和反序标志。实测定下来的经验是先把CRC发送和接收都实现出来然后在回环模式PHY的loopback下自收自发只要回环能收到0错误包CRC就过了。如果你一上来就接对端设备两边CRC逻辑互相干扰反而不容易定位。3. UDP实现链路ARP缓存、封装解析与FIFO缓冲UDP实现简单但有一个前置条件绕不开你得知道对端的MAC地址。PC的MAC地址不会提前写死在FPGA里所以必须用ARP协议去问。这也是很多自研栈一开始感觉“UDP好简单但包发不出去”的原因。3.1 ARP模块一次请求一张小缓存表ARP请求帧就是一层裸以太网EtherType是0x0806广播目的MACFF:FF:FF:FF:FF:FF。FPGA发送ARP请求内容是自己的MAC、自己的IP、目标IP操作码为1请求。对端看到请求后会用单播的ARP应答操作码2回来里面就带上了对端的MAC和IP。我的实现里ARP状态机简化成四个状态IDLE、SEND_REQ、WAIT_REPLY、UPDATE。一开始清零收到上层发送需求时如果缓存表里没有对端MAC就先进入SEND_REQ发请求同时启动重发定时器和超时计数。正常情况下应答到达后跳到UPDATE把收到的MAC写入缓存表然后通知UDP/TCP模块可以发数据了。这里有个设计取舍缓存表我一开始写了16项后来发现单连接场景根本用不上就砍到1项只存“目标IP - 目标MAC”这一条映射。省了不少BRAM。如果你要对多个点通信再开放多表项不迟但要注意老化时间——我用的是30秒超过后把valid位清零强制下次重新ARP。不设老化的话PC换MAC或换IP后你会连着丢包找不出原因。3.2 UDP发送组包顺序与校验和UDP发送路径的完整组包顺序是这样的MAC头目的MAC、源MAC、0x0800→ IP头20字节→ UDP头8字节→ 用户数据 → 0填充到46字节 → CRC32。整个帧长度根据用户数据动态计算写入IP头总长度字段和UDP长度字段。IP头和UDP头大部分字段是固定的变化的主要是长度、校验和以及目的地址。我通过一个简单的发送状态机配合计数器来逐字节产生这些字段。UDP校验和计算里最容易被忽略的是伪头部它不算真正的UDP数据但参与校验伪头部 源IP(4字节) 目的IP(4字节) 0x00(1字节) 协议号(0x11) UDP长度(2字节) 校验字段 伪头部 UDP头 UDP数据按16位反码求和这里反码求和要和IP头校验和区分开。IP头校验和不带伪头直接对20字节IP头做16位反码求和。两者算法都是补码回卷但参与数据不同。我早期在同一个模块里复用了求和逻辑结果IP头校验和对UDP校验和一直报bad checksum排查了半天才发现是伪头没加进去。3.3 UDP接收解析与异步FIFO缓冲接收方向从MAC接收FIFO读出一整帧先判断EtherType是否为0x0800不是就丢。然后看IP头的协议字段0x11才继续解析UDP。在解析UDP头的同时检查目的IP和目的端口不匹配直接丢帧。数据从PHY时钟域进入MAC再到用户逻辑这中间至少涉及两个时钟域。我的处理是在MAC接收FIFO之后接一个异步FIFO做缓冲用户逻辑在自身时钟域读走。异步FIFO深度我选512字节对普通UDP包绰绰有余。如果上位机突发大量包、FIFO写满我采取的策略是直接丢弃后续帧而不是阻塞MAC因为UDP本来就不保证可靠交付重传由上位机应用层做反而简单。4. TCP实现难点连接状态机、序号管理与重传机制TCP比UDP难是公认的。它在链路上有连接状态、要维护序号窗口、要做超时重传。纯Verilog实现TCP本质上是把一个简化版的TCP协议栈用有限状态机表达出来。我不建议在没彻底理解UDP链路之前直接跳到TCP因为TCP的组帧底层完全复用MAC/UDP那套通路只是上层逻辑复杂得多。4.1 你只需要一个“够用的”状态机完整TCP状态机有十几个状态但对FPGA这种资源受限的场景完全可以砍到最小集合。我实现的会话模型只有六个状态LISTEN、SYN_RCVD、ESTABLISHED、FIN_WAIT、LAST_ACK、CLOSED。对应到实际行为是LISTEN等待对端SYN收到后回复SYNACK进SYN_RCVD。SYN_RCVD等待对端ACK收到后进ESTABLISHED连接建立。ESTABLISHED正常收发数据处理对端的FIN请求。FIN_WAIT本地主动发FIN后等对端ACK和FIN。LAST_ACK对端发FIN且本地已回复等对端最后的ACK。CLOSED连接彻底关闭回到LISTEN。握手时最重要的细节是SYN和FIN各占一个序号。收到对端SYNseqx时我的应答ACK要填x1同时我自己的SYN消耗一个序号y握手完成后我的数据序号从y1开始。刚开始写的时候这里差一个序号导致整个连接建立后第一次数据发送直接失败抓包看到的总是对端回RST。4.2 序号管理与回绕TCP序号是32位无符号数在长会话里可能回绕。FPGA里做序号比较不能用简单的或得用回绕感知的方式(seq - last_ack) 65535这种判断。为保证行为正确我用了一个小函数处理回绕比较代码不多但省了后面大量判断逻辑。另外确认号的处理要跟数据帧长度挂钩。收到一个带数据的包seq从A开始长度L确认号应该是AL。如果只做ack seq 1握手特例数据传输阶段就无法推进窗口。我在这里吃过亏调试时看到数据重传一次就停了因为对端期望的ack一直不变。4.3 超时重传的实现方式FPGA里做TCP最朴素但可靠的方式是发送数据时启动一个毫秒级定时器如果收到对应ACK就清零否则超时就把发送缓冲区的数据重新发一遍。RTO重传超时初始值500毫秒每重传一次加倍最大到4秒。超过3次重传就放弃连接回到CLOSED。发送缓冲区我用了一个深度4096字节的BRAM也就是窗口大小限制在4KB。这样对端通告的窗口再大我最多也只允许4KB在途数据。这个折中直接决定了资源占用上限也是我资源表里BRAM占得最多的点。如果你的TCP只用来传小配置包可以更激进一点缓冲区256字节就够。千万要注意的是重传包不能简单地从缓冲区再发一遍而是要更新IP头里的标识字段IPv4 Identification吗其实可以不变但TCP对端根据序号去重所以重传重复包是合法的。真正要更新的只有TCP头里的序号因为期间可能已经发过新数据。这里尤其容易写出“重传了旧seq导致对端丢弃”的bug。4.4 握手与挥手的具体处理完整交互流程我用文字列一遍对端发SYN seqaFPGA回SYNACK seqb, acka1。对端回ACK ackb1状态进ESTABLISHED。数据传输阶段收到对端ACK ackN时把发送缓冲区里seq小于N的数据判定为已确认可释放。对端发FIN seqMFPGA回ACK ackM1继续自己的发送。本地发完剩余数据后回FIN seqN, ackM1等对端ACK ackN1进LAST_ACK收到后关闭。步骤3里的“确认释放”逻辑容易被写成“收到一个大于当前发送seq的ack就认为全发完了”。实际上TCP的ACK是累计确认只表示对端期望的下一个字节序号不代表所有更早的都丢了但也不代表更早的都确认了——语义是“seq ack 的数据都已确认”。理解了这一点窗口推进的逻辑就不容易晕。4.5 自研TCP能做什么不能做什么我的TCP实现和Linux、Windows自带的TCP完全可以正常互通实测可以传几百KB的文件逐包校验无误。但它做不到这些快速重传、SACK、拥塞控制、多连接并发、路径MTU发现。这些一起写出来工作量太大也没必要。所以我的建议是自研TCP要清楚定位它适合做可靠的控制通道、配置下发、小文件传输。如果要对端把数GB数据打过来那还是老老实实上协议栈IP核或者干脆在FPGA前面放个小CPU跑轻量TCP。5. 联调实录抓包现象对照与故障排查链路代码写完了最终都要拿到板子上见真章。我最难受的一段调试经历就是自己觉得逻辑没问题但对端就是不通也没有任何错误提示。下面这些现象都是我实际遇到过的每个都给对应的排查路径。5.1 测试环境搭建我的实验环境很简单开发板网口直连PC不经过交换机。PC侧设固定IP192.168.1.100FPGA侧固定192.168.1.10。抓包用Wireshark。直连的好处是省去交换机的未知转发行为抓包看到的就是线缆上真实的内容。设置固定IP是关键别开着DHCP去测。自研栈不会主动做DHCPPC如果变成了自动获取IP很可能拿到一个奇怪的网段ARP就找不到FPGA了。5.2 故障现象与排查对照表现象可能原因排查方法ARP请求反复发但始终无应答PHY初始化没完成、RGMII时钟延迟不对、CRC错误先查PHY寄存器link状态再用回环模式自收发ARP通了但UDP包发不出去IP头里的目的IP写错、IP头校验和错、长度字段超MTUWireshark只看IP层确认目标IP和总长度抓包显示bad checksumUDP伪头没算、IP头求和字节序错、CRC与IP校验混用拆开包用工具单独算一遍校验和TCP握手第二次包没发出SYNACK组包长度不对、序号/确认号错看片内逻辑分析仪确认SYN_RCVD状态机有没有走到三次握手成功但数据传不动确认号语义理解错、窗口为0、重传定时器没启动抓包看PC是否一直回重复ACK检查发送FIFO是否有反压其中ARP通了但UDP不通的情况最坑。因为ARP通了说明MAC层是对的问题一定出在IP/UDP层。我当时是IP头里把总长度字段填成了“UDP头数据”忘了加IP头本身的20字节导致对端直接丢弃。5.3 内观信号逻辑分析仪与Wireshark对照自研栈最大的调试优势就是内部信号全部可见。我每个协议模块的状态机信号都用片内逻辑分析仪拉出来触发条件设为“发出SYNACK”或“收到UDP帧”这种关键事件然后和Wireshark抓包时间戳对照。一个非常实用的排查方法把抓包时间轴上的每一个外部事件和逻辑分析仪里的状态跳变对上这样能快速区分问题是在MAC层外面看到的包不对还是在协议层外面看到的包对但内部状态不对。比如Wireshark里看到了重复的SYN但逻辑分析仪显示状态机停在SYN_RCVD说明收到ACK后没有正确跳转原因大概率是确认号比较逻辑写错了。6. 资源占用与实测性能这套实现值不值最后放一组实测数据给想参考的人在选型时有个直观判断。我在某系列入门级器件上跑过两个版本只含UDP的功能模块和UDPTCP的完整版。综合后的资源大概如下不同器件会略有浮动但比例可参考版本LUTFFBRAM备注UDP-only约1200约9002块ARP表收发FIFOUDPTCP约3400约26006块增加TCP状态机发送缓冲区对比厂商协议栈IP核动辄上万LUT和十几块BRAM的大小自研栈的资源优势很明显。但代价是功能边界也非常明显。如果你的项目只需要UDP自研UDP是绝对划算的需要TCP时先想清楚是否只是简简单单传个文件。性能方面UDP的收发在千兆下可以跑满线速——也就是理论上PHY支持多少就能发多少因为UDP没有确认重传负担。但受限于上位机抓包工具和PC网卡中断实际持续吞吐大概能到950Mbps左右。TCP单连接受4KB窗口和RTT限制实测大概在30到60Mbps之间取决于链路往返时间。如果对端在同一台PC上环回延迟很低能跑高点如果穿过交换机或长距离线缆吞吐就会明显掉下来。这个量级对配置下发、状态采集完全足够但对高速流传输是不够的这一点在动手之前就要有预期。延迟方面用PC ping FPGA的IP实测平均大概是70微秒左右这包括PC端协议栈的处理时间。FPGA内部处理一个UDP帧的固定延迟从RX引脚到用户逻辑FIFO全程约1到2微秒。对实时控制系统来说这个延迟是完全可以接受的。写在最后一点经验之谈真正把协议栈从头写一遍之后再看那些网络调优的讨论和交换芯片的数据手册感觉完全不一样。以前觉得以太网是一个黑盒设备现在心里很清楚每一帧从产生到经过PHY、再到对端协议栈中间发生了什么。这个理解比省下来的资源值钱得多。我给准备动手的人一个建议先只实现UDP把底层链路彻底跑稳定再去碰TCP。UDP能通说明MAC、RGMII、CRC、ARP这些地基是好的TCP的调试复杂度是UDP的数倍地基不稳的时候你根本分不清是协议层问题还是底层问题。最后再分享一个小技巧——调试时抓包软件里如果看到大量的重复ACK或乱序包别急着怀疑协议栈逻辑先看一眼是不是PC网卡的TCP卸载引擎在捣鬼很多时候网卡硬件自己做了优化和你FPGA行为就会产生不一致。关掉网卡的TCP Offload之后自研栈的很多诡异现象会瞬间消失。