
我得先交代一个背景在这个系列写到part.8的时候我已经在FPGA开发板上折腾过了流水灯、按键消抖、UART收发、SPI接口、简单的图像缓存自认为对Verilog的语法和状态机已经不算陌生。但真到了要写UDP模块我才发现自己之前那点会写状态机的底子连门都没摸着。因为UDP模块不是一个单纯的功能模块它是通信链路的一整段从PHY芯片的引脚时序到MAC帧封装再到IP层和UDP层的数据结构每一层都有坑每一层都互相牵连。这篇文章把我从对着空电脑不知道先敲哪行代码到能跑通UDP收发的全过程写下来给同样从近似零基础起步的读者一条可以参考的路径也包括我踩过的、你大概率也会踩的坑。我当时最焦虑的问题就是网上一搜FPGA UDP出来的全是高大上的千兆收发、三速以太网MAC IP核、DDR3缓存、上位机Wireshark抓包看着都懂自己写就完蛋。后来我才意识到问题不在于代码量在于我需要先弄清楚在FPGA里做UDP到底做到哪一层才算完。这篇part.8就围绕UDP模块的代码设计这个目标从协议裁剪、帧格式、模块划分、状态机设计、时序约束到仿真上板一条线讲到底。1. 先定义清楚FPGA里的UDP到底是啥、做到哪一层才算完很多零基础教程一上来就甩给你一堆代码一个顶层模块例化了一堆子模块然后告诉你照抄就行了。但真到自己写的时候最关键的其实是先想明白边界你的UDP模块需要承担多少工作量1.1 自写UDP模块的边界这是裁剪版协议栈不是完整TCP/IPFPGA里的UDP模块和你在PC上用Socket写UDP是完全两个概念。PC上有操作系统Socket往下是完整的内核协议栈协议栈下面又是网卡驱动网卡芯片又帮你处理了MAC层和物理层。这些层次对你透明你只需要提供目标IP、目标端口、数据剩下的全是黑盒。FPGA里没有这个黑盒。你需要自己把UDP数据报拆成MAC帧头、IP头、UDP头然后以字节为单位按顺序从引脚发出去。但同时你也不需要把完整协议栈那一套全实现了。我们做FPGA的UDP绝大多数场景是图像采集卡往PC传图像数据高速ADC采样数据回传上位机上位机下发控制参数给FPGA两个FPGA板卡之间点对点通信这些场景通常只需要支持IPv4、UDP最多加一个ARP用于获取对端MAC地址直连电脑时甚至可以不处理ARP把MAC地址写成静态的。TCP那种三次握手、重传、拥塞控制在FPGA里极少有人自己抡代码去实现代价太高。ICMP回显Ping也完全可以不做。换句话说我们要实现的是一个能用够用就行的裁剪版协议栈不是能通过协议一致性测试的完整实现。所以在动手前建议你先建立一个意识不要被UDP模块四个字吓住它的实质就是把固定格式的字节按顺序送出去以及从一堆字节流里把特定位置的数据抠出来。这个思路一旦建立后面的工作就变成拆解格式 写状态机的机械劳动。1.2 从MAC层到UDP层数据包在FPGA内部要经过的站点假设你的FPGA想发一包数据出去数据从你写的逻辑到网线至少经过五站用户逻辑你有一块数据比如一帧图像打算通过UDP发给PC。发送FIFO数据先放进异步FIFO做一个跨时钟域缓冲。MAC发送模块负责把FIFO里的数据加上MAC帧头目的MAC、源MAC、类型字段并且在帧尾生成CRC32输出MAC帧。IP/UDP组包模块在MAC帧的用户数据部分填入IP头、UDP头把用户payload放在最后。RGMII接口把并行数据按RGMII的时序要求变成4bit DDR信号送给PHY芯片PHY再把它变成差分串行信号送上网线。如果你的板子用的是带MAC的PHY芯片有些PHY带MAC桥接那MAC层可能由PHY做了但初学者常用的RTL8211、YT8512这类纯PHY芯片MAC层必须由FPGA自己做可以是自己写的逻辑也可以例化厂商的MAC IP核。我的整个设计里最笨但最可控的方案就是自写软MAC层 自写IP/UDP组包解析不例化厂商MAC核。这样做的好处是每一行代码都知道在干什么出了问题能一步步排查。2. 帧结构逐字节拆解知道数据在线缆上长什么样代码才不会写歪写代码之前先做数据格式勘察。很多入门者卡在这状态机写得再漂亮字节序搞反了、长度字段算错了包发出去就是错包。所以这一节非常枯燥却非常值得反复看。2.1 一帧UDP报文从FPGA发出去时实际在线上是这么一串字节先看一个完整例子。假设FPGA的MAC地址是00:11:22:33:44:55IP地址是192.168.1.10要发给PCMACAA:BB:CC:DD:EE:FFIP192.168.1.100源端口5000目的端口6000payload是三个字节0x01 0x02 0x03。那么从FPGA发出去、线上实际传输的字节序列如下表所示偏移字节数内容含义0755 55 55 55 55 55 55前导码Preamble71D5帧起始定界符SFD86AA BB CC DD EE FF目的MAC地址14600 11 22 33 44 55源MAC地址20208 00类型字段IPv4222045 00 ...IP头42813 88 17 70 ...UDP头50301 02 03UDP payload53需要填充00 00 ...填充位Pad之后4CRC32帧校验序列FCS这里有个入门必踩的点一帧以太网数据包从目的MAC开始到FCS结束最短是64字节。上面例子里MAC头14字节 IP头20字节 UDP头8字节 payload 3字节 45字节还差19字节必须补零凑够64字节。很多人第一次写发出去51字节的包抓包工具上直接显示帧过短。IP头长这样偏移字节说明045版本IPv4 头长度5个32位字100DSCP/ECN一般填02-3总长度IP头20字节 UDP头8字节 payload长度不含MAC头4-500 01标识可随便填6-700 00标志片偏移不分片就填0880TTL常用64或128911协议号UDP是17也就是0x1110-11头部校验和IP头校验和必须算12-15源IP4字节16-19目的IP4字节UDP头长这样偏移字节说明0-1源端口2-3目的端口4-5UDP长度UDP头8字节 payload长度6-7UDP校验和可以填0IPv4下允许推荐计算MAC头的类型字段用0x0800表示后面跟的是IPv4包用0x0806表示ARP包。如果你后面要实现ARP类型字段就得区分开。2.2 哪些字段必须认真算哪些字段可以偷懒填初学阶段对字段的认真程度可以分级必须算IP总长度、UDP长度、IP头校验和、CRC32如果MAC层自己处理。可以不那么认真IP标识字段ID、UDP校验和IPv4下可以置零但Zynq/PC收包一般没问题注意有些苛刻的抓包工具会标warning。完全不用管DSCP、ECN、片偏移填0即可。关于校验和我再多说一点。IP头校验和算法是16位逐位相加溢出回卷最后取反看起来复杂写出来也就几行状态机的事。但很多初学者栽在处理边界上IP头的字节数是20正好是奇数个16位字不是20字节正好10个16位字刚好。UDP校验和的伪头部计算涉及源IP、目的IP、协议号、UDP长度第一次写很容易漏掉把校验和字段本身填0再参与计算这一步。如果不想在UDP校验和上花时间初版可以填0这个取舍是合法的。我自己的实现里IP校验和是算了UDP校验和先摆了个0。后来用iperf3打流、用网络调试工具收包都没问题。等整体链路通了再回头补。3. 发送通路设计把状态机拆好组包就是按图填寄存器帧结构搞清楚之后就可以动手写代码了。发送通路的总体结构比一个great big状态机要复杂一些但也远没有那么高不可攀。3.1 顶层结构数据不直接进MAC先过FIFO我最终采用的发送通路顶层结构是这样的用户逻辑 → 发送FIFO(异步) → MAC发送状态机 → 组包模块(可选合并) → RGMII发送接口 → PHY这里有两个设计选择值得解释第一为什么用户数据不直接进MAC而是先过FIFO。因为用户逻辑的时钟域往往是100MHz或150MHz的系统时钟而RGMII在千兆模式下是125MHz而且MAC发送状态机是突发式工作——它不能保证你数据一来就立刻能发它要先发前导码、MAC头、IP头然后才能轮到payload。如果用户数据直接接进来就会遇到对方数据准备好了MAC还在忙的握手问题。异步FIFO恰好解决了跨时钟域和速率匹配两个问题。第二MAC发送状态机和IP/UDP组包模块可以合并也可以分开。我建议分开虽然多了一层接口但调试时层次分明。实际发送状态机的主循环很简单等FIFO非空 → 读出一个数据包长度 → 依次发送前导码、MAC头、IP头、UDP头、payload、填充、CRC。这里要特别说一下发送一个数据包和发送一个字节的关系。很多人的状态机写成了一个时钟周期一个状态结果遇到多字节字段比如MAC地址6字节就不知道该怎么办。我后来采用的做法是状态机是字段级的内部再加一个字节计数器每个状态内部循环发送完该字段的所有字节后再跳转到下一个状态。比如SEND_MAC_HEADER状态里计数器从0数到11目的MAC6字节 源MAC6字节发完跳去SEND_ETHER_TYPE。3.2 发送状态机骨架与长度字段的两次计算发送状态机的关键代码骨架大致长这样。我先声明这不是完整可综合代码但逻辑骨架和真实工程一致。localparam SEND_IDLE 5d0; localparam SEND_PREAMBLE 5d1; localparam SEND_MAC_HEADER 5d2; localparam SEND_ETHER_TYPE 5d3; localparam SEND_IP_HEADER 5d4; localparam SEND_UDP_HEADER 5d5; localparam SEND_PAYLOAD 5d6; localparam SEND_PAD 5d7; localparam SEND_CRC 5d8; // 这里的tx_data是8bit经过RGMII接口后变成4bit DDR输出 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state SEND_IDLE; end else begin case (state) SEND_IDLE: begin if (fifo_empty 1b0) begin // 读FIFO侧先取出来一包数据的长度 pkt_len fifo_dout[15:0]; // 假设FIFO宽度16高16位存长度 state SEND_PREAMBLE; end end SEND_PREAMBLE: begin if (byte_cnt 7) begin byte_cnt 0; state SEND_MAC_HEADER; end end // ... 类似地依次处理各字段 endcase end end这里有几个实际经验长度字段要提前确定。IP总长度 20IP头 8UDP头 payload_lenUDP长度 8 payload_len。所以发送模块必须在进入业务payload之前就知道这一包要发多少字节。这也是为什么我建议FIFO深度设计成16位长度 N位数据的格式发送前先在IDLE状态把长度读出来存到一个寄存器里后面所有长度计算都基于这个寄存器。MAC头里如果只发不接收目的MAC写成PC的MAC即可。当时我为了看起来灵活做了一个寄存器数组存MAC地址实际上在一个固定点对点的系统里写死 留两个可配置寄存器就够了。填充位的计算最容易错。填充字节数 60 - (14 20 8 payload_len)如果结果小于0就不用填。这里的60是最小帧长64减去CRC4字节。很多写错的人都是拿64去减结果永远多算了4个字节。4. RGMII接口的时序陷阱与跨时钟域处理即使你的状态机写对了数据出来引脚上不对PHY照样不认识。RGMII是FPGA与PHY芯片之间最常见的接口但恰恰是这里新手容易卡得欲仙欲死。4.1 RGMII是DDR采样上升沿和下降沿各发半字节RGMII千兆模式下的引脚很简洁引脚方向说明GTX_CLKFPGA→PHY125MHz发送时钟TXD[3:0]FPGA→PHY发送数据DDRTX_CTLFPGA→PHY发送控制DDRRX_CLKPHY→FPGA125MHz接收时钟RXD[3:0]PHY→FPGA接收数据DDRRX_CTLPHY→FPGA接收控制DDR关键点在于TXD[3:0]在时钟上升沿发送字节的低4位在下降沿发送字节的高4位。不要搞反。TX_CTL同理上升沿是TX_EN下降沿是TX_EN 异或 TX_ER通常TX_ER为0所以下降沿看到的是TX_EN本身这也是很多人的调试盲区抓逻辑分析仪发现TX_CTL一直为高以为是bug实际上是两个沿都在拉高因为TX_EN1时异或0还是1。在Xilinx 7系列上DDR输出可以用ODDR原语实现。我的发送接口是这样处理的// 把8bit数据拆成两个半字节交替输出 wire [3:0] txd_low tx_data_byte[3:0]; // 上升沿 wire [3:0] txd_high tx_data_byte[7:4]; // 下降沿 ODDR #(.DDR_CLK_EDGE(SAME_EDGE)) oddr_txd0 ( .Q(txd[0]), .C(clk_125m), .CE(1b1), .D1(txd_low[0]), .D2(txd_high[0]), .R(1b0), .S(1b0) ); // 其余3个bit同样例化记得GTX_CLK和数据之间的相位关系。RGMII规范里发送时钟是中心对齐center-aligned的即数据在时钟上升沿两侧稳定时钟沿在数据窗口中间翻转。但在FPGA上用ODDR发送时如果不做处理数据和时钟的边沿可能完全相同导致PHY采样出错。经验做法是数据经过ODDR输出后对GTX_CLK做延迟或在PHY配置中调整时钟相位。RTL8211等芯片的寄存器里一般有RGMII时序调整位。我调试时最明显的迹象是偶尔通、偶尔不通、长时间跑就错包这大概率就是时钟相位问题不是逻辑问题。4.2 时钟约束和跨时钟域为什么必须在MAC入口放FIFO很多人的UDP代码仿真没问题上板就偶发丢包十有八九是跨时钟域没处理好。你的用户逻辑跑在100MHzPLL出来的MAC接口跑在125MHz数据不能直接连。我的做法是在MAC发送状态机前面放一个异步FIFO用写时钟域写读时钟域125MHz读。初学阶段Xilinx的FIFO IP核足够可靠如果你不想用IP也可以手写一个异步FIFO——但说实话这个阶段完全没必要重复造轮子直接用IP把注意点放在FIFO的读时序上。异步FIFO的另一个好处是它天然把用户逻辑和MAC逻辑完全隔离开。MAC侧可以专心处理125MHz时序用户侧不用关心PHY在干什么。调试时甚至可以把FIFO替换成寄存器直通单独验证MAC逻辑。时序约束方面至少要保证两件事GTX_CLK的约束125MHz时钟引脚需要声明为primary clock。RGMII接口的set_input_delay / set_output_delay这一条很容易被初学者忽略。RGMII标准里RX_CLK和数据之间的相对延迟是有要求的约1.5ns~2ns看具体PHY的数据手册。如果不做约束Vivado可能把它约束得很随意结果就是前10个包对到第11个包错位了。我刚开始完全没管约束仿真都过了上板用网络调试工具收包时发现包的数量对内容经常错1个字节或者周期性地错。最后往工程里加了output delay约束才彻底稳定。这段经历我想说的是不要觉得FPGA开发就是写Verilog时序约束也是代码设计的一部分尤其当你开始碰RGMII、DDR这类源同步接口时逃不掉的。5. 接收解析与CRC校验从一坨电信号里把UDP payload抠出来发送搞通只是故事的一半。PC发给FPGA的包FPGA要能识别、能拆解、能把payload交给你自己的逻辑这才算完整的UDP模块。5.1 接收状态机先认帧头再逐层剥壳接收方向的RGMII接口和发送是对称的RXD[3:0]和RX_CTL都是DDR。125MHz的RX_CLK由PHY提供FPGA端要用IDDR原语把上升沿、下降沿的数据拼成一个8bit字节。我在接收状态机里采用的状态序列是IDLE → DETECT_SFD → MAC_HEADER → CHECK_TYPE → IP_HEADER → UDP_HEADER → PAYLOAD → CRC每个状态内部同样是字段级 字节计数器的工作方式。不同的是接收状态机要时刻准备着出错就回IDLE。比如MAC头收完发现EtherType不是0x0800也不是0x0806那就没必要继续了直接回IDLE等下一帧。IP头收完发现protocol字段不是0x11UDP这不是发给UDP模块的包也该回IDLE。这种早退机制非常重要它保证你接收到任何广播包、其他协议的包时不会污染你的payload FIFO。目的MAC匹配的判断我处理了两个caseFF:FF:FF:FF:FF:FF广播帧和本机MAC地址。这个判断要在MAC头6字节收完后立刻做匹配才继续不匹配直接从IDLE重新开始。如果你不管目的MAC就一股脑收下去PC发一个广播包你的模块也会把payload丢给用户逻辑用户逻辑还以为是发给自己的这会导致莫名奇妙的脏数据。UDP解析的关键是把UDP头和IP头的关键字段存到寄存器里源IP地址以后你要回复它就得知道它地址源端口回包时的目的端口目的端口判断这个包是不是发给你的模块的UDP长度去掉8字节头就是payload长度5.2 CRC校验为什么放最后缓存一帧再判断以太网帧末4字节是CRC32。接收侧有两种做法边收边算数据进来时就同步往CRC计算器里送收完最后1字节后把计算出来的CRC和收到的CRC比较。先缓存后校验把整个帧或至少payload缓存到FIFO/RAM里收完一帧后再算CRC算对了再把数据真正提交给用户逻辑。工程上更常用的是先缓存后校验。原因很简单如果CRC错了你可能已经给用户逻辑写了半包垃圾数据。而且对初学者来说CRC对了再提交数据这个流程在调试时非常直观——你可以把FIFO里的数据先不要交付先用一个寄存器记录CRC检查通过标志再控制FIFO读出。我自己做的接收数据通路是RGMII解析出来的字节先进入一个接收缓冲区RAM按帧写入一帧收完检测到RX_DV拉低抽空算一遍CRC通过后把RAM里的payload部分按用户逻辑的时钟域搬运到接收FIFO里。这个设计多用了RAM资源但它换来的是极清晰的调试逻辑RAM里数据对不对和CRC对不对可以分开检查。CRC32的Verilog实现网上到处都是查表法和LFSR法的代码多项式是0x04C11DB7初值全1结果异或全1。这里我踩过一个坑CRC的计算字节顺序和PHY送到FPGA的字节顺序完全一致但很多网上代码会默认调换bit顺序复制过来直接算错。建议在testbench里用已知报文做一次完整测试用一个抓包工具抓一帧正常UDP包的CRC按字节拼回去算一遍能对上就说明你的CRC模块没问题。6. 仿真和实测一起上没有逻辑分析仪也能把UDP调通很多新手把仿真和上板当成了两件事其实它们是一个闭环。仿真验证的是代码逻辑符不符合协议上板验证的是物理世界认不认这套逻辑。UDP这种通信类设计尤其依赖双轨验证。6.1 testbench里模拟一个假PHY发回包、测接收我的testbench里没有直接例化PHY模型而是写了一个以太网行为模型它做的事情很简单一端连接FPGA的RGMII引脚摆出RGMII时序在testbench里维护一个虚拟数据包存储区当检测到FPGA发来的数据时解析出里面的UDP payload$display打印出来同时可以主动往FPGA发一个UDP包看看接收状态机能不能正确提取。这么做的核心好处是它让仿真的输入和输出都可观测。我当时仿真时最爱干的事就是在testbench里打印两句话if (rx_payload_valid) begin $display(RX captured: total_len%0d, dst_port%0d, payload%h, ...); end打印出来的值再拿Wireshark抓过包以后做对比两边一致说明代码对协议的理解没问题。还有一个小技巧仿真时把CRC校验故意置反比如把CRC模块的输入翻转一位确认接收状态机会不会把坏包提交给用户逻辑。这能非常方便地验证你的错误处理机制是否真的在工作免得以后上板收到错包时一脸懵。6.2 上板三板斧link灯、抓包工具、注意校验和上板调试我总结了三板斧第一板斧先看PHY的link状态再看RX_DV。如果网线插上PHY的link灯不亮后面全白搭。灯亮了再用逻辑分析仪ILA抓RX_DV引脚确认PHY是不是真的给你送数据了。很多时候PC发的包格式不对比如没发UDP、发了个ARP请求RGMII上根本没有数据活动你还以为是代码问题就白查半天。第二板斧用抓包工具Wireshark验证发出去的数据。FPGA发UDP包到PCPC用Wireshark抓一下直接看帧结构。这一步非常重要Wireshark会非常诚实地告诉你有没有填充位、CRC对不对、IP校验和是不是错的它会在Expert Info里标出来。我调试时发现IP校验和错误就是在上板后查出来的仿真没暴露这个问题因为在仿真里我自己打印的校验和值是对的但实际组包时字节序反了。第三板斧使用iperf3或网络调试工具做连续收发压力测试。单发一个包能通不代表大量数据不出错。我建议用iperf3的UDP模式打流或者让PC端网络调试工具循环发送观察FPGA端的接收统计计数一个收到多少包、多少包CRC错误的寄存器。如果CRC错误计数不断增长回头检查RGMII时序约束、时钟相位和跨时钟域FIFO的水位。关于如何借助AI扫描代码可能存在的bug和设计是否合理我自己是这么干的把关键模块尤其是状态机部分的Verilog贴给AI让它逐状态检查是否有不可达状态、是否有时钟域问题、有无锁存器风险。实测下来AI对保护性else缺失状态机没有default回IDLE跨时钟域信号打拍这类问题非常敏锐。但它对协议格式对错RGMII时序约束是否合理这类问题帮助不大毕竟那是需要结合外部芯片手册的知识。合理用法是让AI做静态Review、查代码规范性协议正确性一定要靠仿真和抓包来兜底。另外调通UDP以后不要急着扩展功能。先把一套固定的点对点通信链路FPGA发PC收PC发FPGA收彻底跑稳再考虑ARP动态获取MAC、再考虑多端口、再考虑整合DDR缓存做大报文。我自己就是在这个先固定配置跑稳的阶段把RGMII时序、CRC、长度计算这些最底层的问题彻底吃透的后面加功能时省了太多事。