做FPGA开发的人,十有八九都会走到以太网这一步。串口速度再快也就那点带宽,PCIe门槛又高,UDP成了最务实的中间选择——协议简单、吞吐可观、调试方便,PC端还有现成的网络调试工具。这篇文章是系列第8篇,前面我们已经把FPGA开发流程、时序约束、仿真技巧过了个遍,这次就专心把UDP模块的代码设计啃下来。我默认你已经会用Verilog写状态机,能跑Vivado或Quartus仿真,对AXI接口也有个基本概念。如果这些还不太熟,建议先翻翻前面的文章。
这篇文章不会给你贴一大坨完整工程代码,而是拆开讲设计思路和代码骨架。等你看明白数据从用户逻辑到网线的完整路径,再回头写代码,逻辑会清晰得多。我会从协议裁剪、模块架构、发送通路、接收通路、仿真验证、上板实测六个方面展开,最后把最常见的坑挨个说一遍。
1. 为什么要自己写UDP:协议栈裁剪思路
1.1 先把UDP帧格式画清楚
很多新手上来就找完整的UDP/IP协议栈代码,动辄几千行,结果看两天直接劝退。实际上在FPGA里做UDP通信,根本不需要实现完整的协议栈,我们只需要把数据链路层(MAC帧)、网络层(IP)、传输层(UDP)这三层里最核心的字段填对,就能和PC端通信。
一张以太网帧在FPGA内部,从用户数据到有线信号,会经历三次"包装":
第一层是MAC帧:前导码(7字节0x55)加帧起始符(1字节0xD5),然后是目的MAC地址(6字节)、源MAC地址(6字节)、类型字段(2字节,IP包填0x0800),后面跟数据,最后是CRC32校验(4字节)。
第二层是IP头:标准IPv4头部20字节,包括版本号IHL(0x45)、服务类型(0x00,即为0)、总长度(2字节)、标识(2字节)、标志与偏移(2字节)、TTL(1字节,填0x40或0x80都行)、协议号(1字节,UDP填17)、头部校验和(2字节)、源IP(4字节)、目的IP(4字节)。
第三层是UDP头:源端口(2字节)、目的端口(2字节)、UDP长度(2字节,8字节头加数据长度)、校验和(2字节)。
注意,MAC帧的数据字段最小是46字节,如果IP包加UDP包整体不足46字节,后面要补0填充。以太网帧总长度最小64字节(不含前导码),不管实际数据多短,这个下限必须满足。
1.2 自实现与现成IP核的取舍
既然是做FPGA开发,就得先回答一个问题:直接用Xilinx/Intel自带的MAC IP核,还是自己写?我的建议是分阶段:第一阶段自己写一个精简的GMII/RGMII接口MAC加UDP逻辑,先把通路跑通;第二阶段再切换到官方IP核做性能优化和稳定性加固。
为什么这么建议?官方MAC核功能全,支持流控、巨型帧、VLAN,但配置项多,初次上手光搞懂那些寄存器就要花不少时间。而且MAC核的AXI接口封装程度高,出了问题很难定位是接口时序还是内部逻辑。自己写一个精简MAC,收发状态机加一起不到500行,每个字节怎么拼接都清清楚楚,后面排查问题效率反而高。
当然,自己写也有代价:CRC32计算逻辑要自己搞定,前导码和帧间距时序要严格对齐,RGMII的信号延迟需要约束好。这些内容后面会有专门的小节来讲。
2. 顶层架构:模块划分与关键端口
2.1 与MAC核对接的RGMII信号
先看整体模块划分。我把整个UDP通信模块拆成五个子模块:udp_tx(发送状态机)、udp_rx(接收状态机)、crc32(CRC计算)、tx_fifo(发送缓冲)、rx_fifo(接收缓冲)。顶层模块叫udp_stack,对外暴露RGMII接口和用户接口。
RGMII接口是现在网口芯片最常见的接口标准。千兆模式下,GTX_CLK时钟125MHz,数据在时钟上升沿和下降沿都采样——TXD[3:0]和RXD[3:0]各4位,上升沿传低4位,下降沿传高4位。这个双沿采样机制是新手最容易写错的地方,后面会细说。
控制信号方面,tx_ctl在上升沿表示TX_EN,下降沿表示TX_ER(发送错误);rx_ctl在上升沿表示RX_DV(数据有效),下降沿表示RX_ER(接收错误)。发送侧还要注意,复位后TX_CLK必须稳定输出,否则对端PHY芯片可能一直检测不到链路。
顶层模块的端口定义大致如下:
module udp_stack #( parameter SRC_MAC = 48'h00_11_22_33_44_55, parameter DST_MAC = 48'hff_ff_ff_ff_ff_ff, parameter SRC_IP = {8'd192, 8'd168, 8'd1, 8'd10}, parameter DST_IP = {8'd192, 8'd168, 8'd1, 8'd100}, parameter SRC_PORT = 16'd8080, parameter DST_PORT = 16'd8080 )( input wire rst_n, input wire clk_125m, // RGMII接口 output wire [3:0] rgmii_txd, output wire rgmii_tx_ctl, output wire rgmii_tx_clk, input wire [3:0] rgmii_rxd, input wire rgmii_rx_ctl, input wire rgmii_rx_clk, // 用户发送接口 input wire [31:0] user_tdata, input wire user_tvalid, output wire user_tready, input wire user_tlast, // 用户接收接口 output wire [31:0] user_rdata, output wire user_rvalid, input wire user_rready, output wire user_rlast );用parameter定义MAC地址、IP和端口,是为了方便在不同板卡上适配。板子上的PHY芯片地址和MAC地址直接在例化时改参数就行,不用改内部逻辑。
2.2 跨时钟域与FIFO缓冲
这里有个细节要提前说清楚:UDP发送和接收的逻辑时钟跟用户逻辑时钟通常不是同一个。RGMII接口工作在125MHz,但用户逻辑可能跑在100MHz甚至更低。所以必须在中间加异步FIFO做跨时钟域缓冲。
发送侧的结构是:用户逻辑(100MHz)→ 异步FIFO → UDP发送状态机(125MHz)→ RGMII → PHY。接收侧反过来:PHY → UDP接收状态机(125MHz)→ 异步FIFO → 用户逻辑。
异步FIFO建议直接用Xilinx的FIFO Generator IP核,选"Independent Clocks"模式,数据宽度32位,深度选512或1024都够用——单包最大1500字节,大约是375个32位字,512深度留了余量也不浪费资源。
有人会问:为什么不直接用官方MAC核的AXI接口,省掉FIFO?因为官方MAC核的发送接口虽然是AXI-Stream,但时钟域已经绑定在MAC核内部时钟上,用户逻辑如果不做跨时钟域处理,时序问题早晚会冒出来。自己加一道FIFO,边界清晰,排查问题也方便。
3. 发送通路代码设计:从用户数据到网线电信号
3.1 发送状态机设计
发送通路的状态机是整个模块的核心,我把它分成7个状态:IDLE、PRE、MAC_HEAD、IP_HEAD、UDP_HEAD、DATA、PAD_CRC。状态跳转逻辑如下:
IDLE:等待FIFO非空信号,一旦检测到数据就跳转到PRE,同时拉高rgmii_tx_ctl表示数据有效。PRE:发送8字节前导码(7字节0x55加1字节0xD5),发送完毕后进入MAC_HEAD。MAC_HEAD:发送目的MAC、源MAC和类型字段,共14字节,然后进入IP_HEAD。IP_HEAD:发送20字节IP头,进入UDP_HEAD。UDP_HEAD:发送8字节UDP头,进入DATA。DATA:从FIFO中读出用户数据,按字节发送。用户接口是32位宽,但RGMII接口是4位宽(千兆双沿等效8位),所以要做宽度转换。当用户数据发送完毕(收到tlast或者FIFO读空且长度计数器满),进入PAD_CRC。PAD_CRC:如果总数据长度不足46字节,补充0字节;然后拼接4字节CRC32,最后拉低tx_ctl,回到IDLE。
这个状态机写起来不算复杂,但有几个细节特别容易错。
3.2 CRC32与UDP校验和的工程实现
CRC32在以太网里用的多项式是0x04C11DB7,按MSB-first方式计算。这里先给大家一个可以直接使用的CRC32模块,我在多个项目中都用过,靠得住:
module crc32_d8 ( input wire clk, input wire rst_n, input wire [7:0] data_in, input wire crc_en, output reg [31:0] crc_out ); reg [31:0] lfsr_q; wire [31:0] lfsr_c; wire [31:0] lfsr_next; assign lfsr_c[0] = lfsr_q[24] ^ lfsr_q[30] ^ data_in[0] ^ data_in[6]; assign lfsr_c[1] = lfsr_q[25] ^ lfsr_q[31] ^ data_in[0] ^ data_in[1] ^ data_in[7] ^ data_in[6]; // 中间省略,标准的CRC32比特级展开公式 assign lfsr_c[31] = lfsr_q[23] ^ lfsr_q[29] ^ data_in[7] ^ data_in[0] ^ data_in[5] ^ data_in[6]; assign lfsr_next = crc_en ? lfsr_c : lfsr_q; always @(posedge clk or negedge rst_n) begin if (!rst_n) lfsr_q <= 32'hffff_ffff; else lfsr_q <= lfsr_next; end assign crc_out = lfsr_q; endmodule关于CRC32有几个必须知道的坑。第一,发送时CRC初值是0xFFFFFFFF,计算完成后要把结果按位取反再发送,这就是以太网标准里的"补码CRC"。直接发送原始lfsr_q会导致对端校验失败。第二,CRC32在以太网中是按字节处理的,不是按32位字。如果你的用户数据是32位宽,就要注意字节顺序转换。第三,CRC计算范围是从目的MAC地址的第一个字节到IP包、UDP包数据结束为止,不包括前导码和CRC本身。发送侧在MAC_HEAD状态之前就应该启动CRC使能,否则会把前导码也算进去。
再来看UDP校验和。UDP校验和的计算比较特殊,它引入了一个"伪头部"的概念:计算时在UDP头前面加上源IP、目的IP、协议号(0x11)和UDP长度,组成一个12字节的伪头部,然后对整个伪头部加上UDP头加上数据一起做反码求和,最后取反填入校验和字段。如果计算结果为0,则填0xFFFF(注意,发送端计算完如果反码求和结果为0x0000,填入的是0xFFFF,这是协议规定的特殊情况)。
工程上更常用的做法是:先一次性计算伪头部加UDP头的校验和,数据部分边发送边累加计算。这样不用先把整包数据缓存下来再算,延迟更低。具体来说:
reg [31:0] checksum_acc; reg [15:0] checksum_result; always @(*) begin checksum_acc = 32'h0; // 伪头部:源IP高16位 + 源IP低16位 checksum_acc = checksum_acc + {SRC_IP[31:16], SRC_IP[15:0]}; // 目的IP checksum_acc = checksum_acc + {DST_IP[31:16], DST_IP[15:0]}; // 协议号(17) + 0 checksum_acc = checksum_acc + {8'h0, 8'd17}; // UDP长度 checksum_acc = checksum_acc + udp_len; // UDP源端口、目的端口 checksum_acc = checksum_acc + {SRC_PORT, DST_PORT}; // UDP长度再次相加 checksum_acc = checksum_acc + udp_len; end // 在DATA状态下累加用户数据 always @(posedge clk) begin if (state == DATA && tx_valid) checksum_acc <= checksum_acc + {data_byte[7:0], 8'h00}; end // 最终结果:累加高位进位再取反 wire [15:0] checksum_final; assign checksum_final = ~(checksum_acc[31:16] + checksum_acc[15:0]);3.3 最小帧长填充技巧
前文提到以太网帧最短64字节,换算一下:MAC头14字节,IP头20字节,UDP头8字节,数据至少得22字节。如果用户数据少于22字节,MAC层会对后面填充0到22字节。很多人想当然觉得填充0不就行了?其实填充会导致接收端解析出错——接收端怎么知道哪些数据是真实数据,哪些是填充字节?
答案是IP头总长度字段。IP头的第4到5字节记录了IP包总长度(IP头加UDP头加真实数据),接收端会按照这个长度来截取有效数据,后面的填充字节自动丢弃。所以发送端在填充字节之后,CRC32的计算要包含填充字节,但IP头的总长度字段不能把填充字节算进去。这个细节必须严格对应,不然对端要么解析出乱码,要么Wireshark里报[Malformed Packet]。
我自己第一次做的时候就在这里栽过跟头:填了填充字节,但忘了修正IP总长度,抓包软件里一直提示长度不对,排查了好几个小时。后来总结出一个顺序:先根据用户数据长度算出IP总长度和UDP长度,然后照这个长度发送数据,数据不够就填充,最后算CRC32。
另外补充一个实际使用中的小技巧:如果用户数据经常很短(比如只有几十字节),可以在上层就约定一个固定包长(比如512字节),短包统一填充,这样接收端解析更简单,发送状态机也不用频繁切换长度判断逻辑,时序上会更稳定。
4. 接收通路代码设计:解析每一个字节
4.1 接收状态机与字节对齐
接收状态机比发送稍复杂,难点在于字节对齐。RGMII接口的rx_ctl信号在上升沿表示数据有效,下降沿表示错误标志;数据在上升沿采到低4位,下降沿采到高4位。这意味着你需要在每个时钟周期内采两次数据,拼成一个完整字节——但第一次采到rx_ctl上升沿时,当前的半字节是哪一位,不同的PHY芯片可能有差异。
这里给出一个通用的字节拼接逻辑:
reg [7:0] rx_byte; reg rx_byte_valid; reg [3:0] rx_nibble_low; reg [3:0] rx_nibble_high; always @(posedge rgmii_rx_clk or negedge rst_n) begin if (!rst_n) begin rx_byte_valid <= 1'b0; end else begin // 上升沿采样低4位 rx_nibble_low <= rgmii_rxd; // 下降沿采样高4位 // RGMII接口实现中常用IDDR原语 rx_byte <= {rx_nibble_high, rx_nibble_low}; rx_byte_valid <= rgmii_rx_ctl; end end实际工程中,RGMII的接收侧数据捕获一般用IDDR原语完成,Vivado和Quartus都有对应的原语。如果是Intel平台,ALTDDIO_IN类似。这里我建议直接用原语而不是always @(posedge clk or negedge clk),因为综合工具对后者支持不太好,而且时序约束也不好做。IDDR的代码在官方模板库里能直接找,这里不展开。
接收状态机本身和发送类似,只是方向反过来:先检测IDLE状态下rx_ctl拉高,然后依次解析MAC头、IP头、UDP头,最后进入数据状态。需要注意IP头里的协议字段如果不是17(UDP),直接丢弃整包,回到IDLE;如果IP头总长度和UDP头长度字段不一致,也做丢弃处理。
4.2 校验逻辑与输出接口
接收侧的校验主要做两件事:CRC32校验和UDP校验和校验。CRC32校验比较简单,整个包收完,看CRC余数是否为0xC704DD7B——这是以太网CRC32校验的"魔数",计算完成后直接比对即可。如果你嫌麻烦,也可以把接收到的CRC字节存下来,和本地计算的CRC比较,两者效果一样,只是前者少存储4字节。
UDP校验和的计算在接收侧稍微麻烦一点,因为计算范围是伪头部加UDP头加数据。这里我采用"边收边算"的方式:从解析到IP头里的源IP和目的IP开始累加,然后累加协议号、UDP头前4字节,接着边收数据边累加。收完再把UDP头里的校验和字段加进来,做最终的反码求和判断。如果最终结果为0xFFFF,校验通过;否则丢弃。
输出接口是简单的AXI-Stream风格:user_rvalid拉高表示数据有效,user_rlast表示最后一个字节。配合user_rready做流控——如果用户逻辑没准备好接收(rready拉低),接收状态机就要暂停读FIFO,防止数据丢失。
还有一个容易忽略的点:接收FIFO的ALMOST_FULL标志应该接到状态机上,当FIFO快满时,接收状态机要暂停读取RGMII数据。但问题在于,RGMII接口是连续采样的,暂停读取会导致后续数据覆盖前面的数据——所以根本解法还是FIFO深度要够,或者把接收侧改成暂停读取时拉低对端PHY的流控信号。不过在实际调试中,PC端发UDP包的速率通常远低于千兆线速,只要不是打流压测,FIFO深度512就够用了。
5. 仿真验证:上板之前先让它飞起来
5.1 testbench的搭建思路
UDP模块的仿真要分两段来写:发送通路仿真和接收通路仿真,这两段逻辑差别很大,混在一起写容易被互相干扰。
发送通路的testbench核心是:模拟用户逻辑向user_tdata写入一个已知内容的数据包,然后在rgmii_txd上捕获输出,按协议解析成以太网帧,再和预期值对比。我的建议是把解析逻辑直接写成task,这样一次写好后,后面加测试用例只需换个数据源就行。
一个关键技巧是:仿真时不能只对着rgmii_txd看波形,要把发送的每一字节都收集到一个数组里,等整包收完后统一解析。这样检查CRC、IP头、UDP头是否正确一目了然,不用在波形图里一个字节一个字节地数。写testbench的时候可以用Verilog的$fwrite把字节流导出成文件,再用Python或者Wireshark的text2pcap工具转成pcap,直接打开看协议解析结果——这个工作流我在实际项目中用了很久,效率非常高。
接收通路的testbench就要反过来:构造一个以太网帧的字节流,通过rgmii_rxd和rgmii_rx_ctl输入到DUT,然后在用户接口端验证解析结果。这里要注意构造以太网帧时CRC32必须算对,否则DUT会一直丢弃,仿真波形一片空白,还排查不出原因。建议也写一个task负责自动计算CRC32,不要手算。
5.2 常见仿真Bug与排查
仿真阶段常见的Bug,我列几个出现频率最高的:
第一,RGMII接口双沿采样。如果testbench里用#4延迟控制数据变化,但时钟周期是8ns,那么上升沿和下降沿采样的数据会有竞争问题。正确做法是用时钟的上升沿和下降沿分别驱动数据,或者用虚拟时钟的@(posedge clk)和@(negedge clk)分两次赋值。
第二,CRC32计算把前导码也算进去了。这个问题仿真时看不出来,因为CRC比较的是输出值,输出值不匹配而rx_crc_error信号根本没拉高——需要对比以太网帧里自带CRC和本地计算CRC,如果两者不一致,多半是使能信号的时序提前了一个周期。建议CRC使能用状态寄存器打一拍,从PRE状态开始使能,到了MAC_HEAD正式计算。
第三,状态机死锁。最常见的是发送数据还没发完,FIFO就已经读空,状态机一直在DATA状态空转。这个可以在testbench里故意构造短包——用户数据长度等于1字节,FIFO写入后立即读,模拟边界情况。还有一种是我自己遇到过的:tx_ctl信号在IDLE状态下没有拉低,导致对端PHY一直认为数据有效,接收方解析错位。
仿真过了不代表上板能通,但仿真没过肯定上板不通。我见过不少同学跳过仿真直接上板调试,最后被各种时序问题折腾到怀疑人生。仿真阶段的付出,在后面积累到PC端网络协议栈这种复杂模块时,能帮你省下无数排错时间。
6. 上板实测与调试经验
6.1 用Wireshark验证你的UDP报文
仿真通过以后,上板调试的第一步不是直接和上位机通信,而是先做一次"自发自收"测试:让FPGA自己向自己发送UDP包,同时用Wireshark在PC端抓包。为什么这么做?因为如果PC端工具箱和数据通路都有问题,你没法判断是FPGA发的问题还是PC收的问题。先保证本地链路正确,再去联调外部设备。
在Wireshark里看到FPGA发过来的UDP包,优先看这几个地方:
- MAC地址是否正确,源MAC和目的MAC不能反。很多PHY芯片会过滤掉非本机MAC的包,一旦MAC写错,包根本到不了上位机。
- IP首部校验和,Wireshark会在IP层自动校验,如果报
Header checksum incorrect,说明IP头里的校验和计算错了。这里有个细节:IP头校验和和UDP校验和不同,IP头校验和只算IP头那20字节,不算UDP和数据。 - UDP长度字段,如果报
Length异常,多半是IP总长度或者UDP长度在填充字节的处理上没对齐。 - 校验和错误,Wireshark会在UDP层标红。如果接口的checksum offloading开着,可能需要先关掉才能看到错误标志。
6.2 回环测试与性能评估
链路通了以后,建议做一次完整的回环测试:PC端通过网络调试工具(比如SocketTool、野人调试助手都行)向FPGA发数据,FPGA收到后原封不动地发回来。这样能同时验证接收和发送通路。
回环测试通过以后,再跑一下性能测试。如果你的FPGA逻辑时钟是125MHz,RGMII接口理论上可以跑千兆线速——但用户逻辑常常处理不过来。这里有个实际经验:不要一上来就追求线速,先用小包(64字节)测吞吐,再用大包(1472字节,这个长度对应MTU 1500减去UDP和IP头)测吞吐,看看差距在哪里。
我用iperf3做过实测,一个简化的UDP模块,256字节小包大概只能跑到600Mbps左右,1472字节大包能到940Mbps。瓶颈往往在用户接口的数据宽度——如果用户接口是32位宽,125MHz下理论带宽就是500MB/s,换算成UDP吞吐大约是940Mbps,基本符合预期。如果用户逻辑时钟只有100MHz,那上限就卡在100MHz×4字节=400MB/s,再优化MAC层也没用。
另外,UDP发送的间隔控制也很重要。有些FPGA工程里用户逻辑一股脑把数据塞进FIFO,发送状态机拼命发,PC端网卡可能因为缓冲区溢出丢包。这时候需要在发送端做包间隔控制,最简单的做法是在PAD_CRC状态结束后插入几个空闲周期,或者在更上层做一个APB/AXI寄存器接口,让上位机可以动态配置发包间隔。
调试过程中我建议把udp_tx模块里的计数器和状态值引到ILA(集成逻辑分析仪)里,触发条件选state == DATA,这样能看到每个状态停留的周期数,快速定位状态机卡住的位置。这个排查方法比对着rgmii_txd的信号看要高效得多。
6.3 回头看看那些年踩过的坑
写这个模块前前后后调试了两周,把踩过的坑总结一下,基本都是网上查不到、只能自己踩的:
第一个坑是复位问题。PHY芯片的复位需要上电后拉低至少10ms,但FPGA内部的复位逻辑如果也依赖PHY复位完成信号,可能会出现FPGA逻辑已经跑起来、PHY还在复位的情况。解决方法是FPGA和PHY分开控制复位,PHY复位用专门的复位模块,内部逻辑复位只依赖FPGA自身的复位信号。
第二个坑是时序约束。RGMII接口如果不加约束,综合工具默认按理想时钟处理,上板后时序大概率不过。Vivado里面要新建一个XDC文件,对rgmii_tx_clk做create_generated_clock处理,并且对rgmii_txd和rgmii_tx_ctl设置set_output_delay。这个约束值不同PHY芯片略有差异,参考PHY数据手册里的tSU/tH参数计算。具体来说,RGMII标准规定TXD在TX_CLK上升沿前约2ns建立,所以set_output_delay -clock [get_clocks {rgmii_tx_clk}] -max 2.0 [get_ports {rgmii_txd[*]}]这类约束是常见做法。
第三个坑是字节序。我从PC端发出的数据,到了FPGA收到的字节顺序总是反的——原因是以太网是Big-Endian传输,而PC端本地存储是Little-Endian。FPGA内部如果按32位字处理数据,要把{byte0, byte1, byte2, byte3}重新排成{byte3, byte2, byte1, byte0}才能和PC端对应上。这个坑极其隐蔽,光靠时序分析根本查不出来,只有对比收发数据内容才能发现。
每次我回头看这个UDP模块的代码,都觉得如果一开始就有人提醒这些坑,能少走不少弯路。这也是为什么我在同事带新人时,第一课就让他们从最原始的MAC层开始手写UDP,而不是直接塞一个现成协议栈——有些东西只有亲手踩过坑,才能真正长成自己的肌肉记忆。