资讯动态

FPGA中自己写UDP协议栈:从能跑到敢用的关键设计与调试

发布时间:2026/9/7 4:40:38 来源:尧图企业网站定制
把UDP模块从“能跑”写到“敢用”是FPGA网络开发里一道绕不过去的坎。这篇part.8接着前面的系列往下走默认你已经能把一个MAC层跑通、能用ILA抓出正确的以太网帧接下来要解决的关键问题是怎么在FPGA里把应用数据打包成UDP报文发出去再把收到的UDP报文拆出来交给用户逻辑。适合正在写UDP协议栈、做高速数据采集回传、或者想搞懂RGMII上到底在传什么的读者。1. 写UDP模块前先把这几件事想清楚1.1 为什么在FPGA里自己写UDP协议栈很多初学者会问既然有W5500、LAN8720LWIP这种现成方案为什么还要在FPGA里用Verilog硬撸一个UDP我的看法是如果只是做低速控制、一个月发几百个包那W5500确实省事但你要是做高速ADC采样回传、图像数据实时上传或者需要精确控制每一帧的发送时序那硬核方案就顶不住了。FPGA里自己写UDP本质上是把协议栈最核心的收发通路用硬件流水线实现不依赖CPU单帧处理延迟可以做到微秒级甚至更低。UDP协议本身在TCP/IP协议族里属于“轻量级”头格式固定、无握手、无状态这恰好是FPGA最喜欢的用状态机计数器就能实现不需要像TCP那样维护一大堆连接状态。所以FPGA工程里最常见的网络方案就是“MAC层 ARP IP/UDP”三层各管各的代码结构清晰调试也很直接。这篇主要聚焦在IP/UDP这一层MAC层和RGMII接口会在涉及的地方提但默认你已经有基础。1.2 从整体架构看UDP模块在工程里的位置一个典型的FPGA千兆以太网数据通路可以分成五段用户应用逻辑 - UDP发送模块 - IP层打包 - MAC层封装 - RGMII接口 - PHY芯片 - 网线。接收方向反过来。UDP模块实际负责的工作是在发送方向接收用户数据填上UDP头、IP头计算校验和然后把完整的IP包交给MAC层接收方向做逆操作把UDP头、IP头剥掉校验无误后把payload交给用户。这里有个工程上的关键点不要把UDP模块和MAC层揉在一起。我见过不少新手把整个以太网收发写成一个巨型模块状态机叠状态机最后仿真都难以下手。正确做法是明确接口比如UDP模块向MAC层发送时只输出“源MAC、目的MAC、数据、长度、发送使能”这几个信号MAC层负责加前导码、CRC、控制发送时序。这样每一层都可以单独仿真、单独替换出问题也好定位。1.3 用到的核心知识点自查在动手写代码前我建议你先自查一下这几项基础熟悉以太网帧格式和IP头、UDP头每个字段的含义理解RGMII接口的DDR时序至少知道TXC和TXD的相位关系会用Wireshark或网络调试助手抓包、发包有基本的Verilog状态机编写能力。都不用精通但至少心里有数。接下来我们开始拆解UDP协议的字节布局这是整个代码设计的地基。我不建议跳过去直接看代码因为很多隐蔽的bug比如校验和算错、字节序反了根源都是对协议格式理解不透。2. UDP协议拆解代码设计的地基2.1 一个UDP报文在网线上长什么样先记一个总体的层次关系。网线上实际传的以太网帧分为三段以太网头14字节、IP数据报、帧校验FCS 4字节。IP数据报里又包含IP头20字节和UDP数据报UDP数据报再分成UDP头8字节和应用数据。把这张图刻在脑子里后面写代码就是“按字节填空”。以太网头14字节的内容是目的MAC 6字节、源MAC 6字节、类型0x0800两字节。类型字段表示上层是IPv4协议如果是0x0806则是ARP报文。IP头20字节里有几个关键字段需要手动填版本号首部长度(0x45)、总长度、标识、TTL、协议(0x11代表UDP)、首部校验和、源IP、目的IP。UDP头8字节则是源端口、目的端口、UDP长度、UDP校验和。实际设计时“标识”字段一般固定自增或填0“标志片偏移”直接填0因为UDP不做分片重组。总长度字段必须准确IP总长度IP头20UDP头8payload长度UDP长度UDP头8payload长度。这两个长度很容易填错尤其当payload长度不是固定值时建议用计数器在组帧时同步计算。2.2 校验和UDP唯一需要动脑子的地方UDP校验和是整个模块里最容易写错的地方。它的计算范围不只是UDP头和数据还要加上一个12字节的伪头部伪头的组成是源IP 4字节、目的IP 4字节、0x00 1字节、协议号0x11 1字节、UDP长度2字节。伪头部不真正发送只参与校验和计算。算法是所有16位数据按反码求和再把进位累加回来最后取反。具体做法是把伪头、UDP头、payload每16位作为一个数相加如果某次加法产生进位(第16位溢出)则结果加1回卷全部加完后取反得到校验和。在Verilog里实现时通常边存数据边算等最后一个字节写入FIFO之后再补算校验和填充到UDP头对应位置。这里要注意字节序IP/ UDP头里所有多字节字段都是大端模式即高字节在前低字节在后字节序搞反是新手高频Bug。2.3 字节序与位宽FPGA里最容易错的两件事FPGA内部处理数据常用32位或64位位宽但以太网是逐字节传输的。位宽转换时涉及字节排序比如32位数据din[31:0]对应网线上的字节顺序是din[31:24]、din[23:16]、din[15:8]、din[7:0]。如果你从AXI接口直接拿数据来封装必须先确认总线的字节序和UDP头的字段顺序一致不一致就做swap。我踩过一次很惨的坑上位机软件发的UDP数据协议规定低字节在前而FPGA内部32位总线高字节在前结果收到的数据全部错位排查了两天才定位到是字节序没做转换。端口号、长度、IP地址也一样。比如目的端口是0x1234网线上先传0x12再传0x34所以Verilog里拼位宽时经常这样写udp_header[15:0] {src_port_high, src_port_low}。经验是每填一个字段都问自己一句这个字段在高位还是低位网线上先出去的是什么。3. 模块划分与状态机设计3.1 顶层连接关系从MAC到UDP再到用户逻辑我建议把设计拆成三个模块udp_tx、udp_rx、arp_module。udp_tx负责把用户数据打包成UDP/IP报文并按MAC层时序发送udp_rx负责接收并解析剥离头后输出payloadarp_module负责查询或应答MAC地址解决“目的MAC填什么”的问题。顶层互联大致是这样tx方向用户逻辑写FIFOFIFO满/空信号反馈给用户udp_tx从FIFO读数据拼装帧按MAC接口时序输出rx方向udp_rx从MAC层接收完整IP包判断协议类型是否为UDP是则剥离头输出payload和有效信号arp发送方向要求查目的MAC如果缓存里没有则先发ARP请求收到ARP应答后更新MAC表再发送接收方向收到ARP请求则自动应答。如果你的使用场景是PC直连FPGA并且FPGA侧目的MAC固定可以暂时不做ARP缓存表直接在参数里配成目的MAC。但如果以后要接交换机、要动态识别上位机ARP还是老老实实做上比较好。3.2 UDP发送通路状态机怎么跳发送状态机我习惯设置6个状态空闲、发送前导码、发送以太网头、发送IP头、发送UDP头、发送数据。MAC层如果已经帮你处理了前导码和CRC那以太网头会包含6字节目的MAC、6字节源MAC、2字节类型如果MAC层连这些都要由上层填充只需要把状态对应调整。为了便于复用我的代码里udp_tx模块会输出一个总长度信号给MAC层由MAC层决定什么时候结束一帧并追加FCS。状态机跳转逻辑不复杂核心是每个状态维护一个字节计数器到数量后跳下一个状态。发数据时从FIFO读出的数据宽度若是32位而MAC接口是8位注意发送顺序必须从高字节开始。发完最后一个payload数据后回到IDLE同时把发送完成标志拉高一个周期用户逻辑可以据此清空或更新发送缓冲。3.3 UDP接收通路状态机与数据解析接收方向的状态机同样基于字节计数。我的做法是先让MAC层恢复出整帧的以太网数据再在udp_rx内部同步解析也可以边收边解析收到目的MAC和类型后就开始判断。相比发送接收有一个额外的约束必须做合法性和长度的校验。初版可以只做两个关键判断以太网类型是否为0x0800IP头中的协议字段是否为0x11。两者都满足说明这是一帧IPv4 UDP报文然后跳过固定长度的IP头和UDP头把后面的数据作为payload输出。输出方式可以是AXI-Stream或者简单的validdatalast。另外如果IP头的总长度和实际收到的字节数不一致最好丢掉这一帧防止错误数据污染上层。4. 关键代码设计与实现细节4.1 RGMII接口的时序处理RGMII在千兆模式下是源同步接口发送时钟TXC由FPGA产生数据TXD[3:0]在TXC上升沿发送低4位下降沿发送高4位接收时钟RXC由PHY提供同样DDR采样。很多FPGA的IO资源内部自带IDDR/ODDR原语可以直接用如果是纯逻辑模拟DDR需要保证约束正确。我做发送部分时的体会是不要在RGMII层直接拼凑UDP帧而是内部先按“字节流”处理由MAC层把字节流转成RGMII需要的4位DDR形式。这样上层逻辑完全不用关心DDR细节。时序上TXC相对TXD要有一个较小的延迟通常是2ns左右很多PHY数据手册会写时序约束文件里用set_output_delay来控制。如果你初版跑起来能抓到包但偶发CRC错误大概率是这里约束不对。4.2 UDP发送模块核心代码下面给一个简化版UDP发送模块的Verilog代码骨架重点看状态机和帧构造方式。数据宽度按32位示例MAC接口侧按8位示例。module udp_tx #( parameter SRC_IP 32hC0A8000A, // 192.168.0.10 parameter DST_IP 32hC0A80001, // 192.168.0.1 parameter SRC_MAC 48h001122334455, parameter DST_MAC 48hFFFFFFFFFFFF, parameter SRC_PORT 16d8080, parameter DST_PORT 16d8080 )( input wire clk, input wire rst_n, // 用户接口 input wire [31:0] user_data, input wire user_valid, output wire user_ready, input wire user_last, // MAC接口 output reg [7:0] mac_txd, output reg mac_tx_valid, input wire mac_tx_ready ); localparam IDLE 3d0; localparam PREAMBLE 3d1; localparam ETH_HDR 3d2; localparam IP_HDR 3d3; localparam UDP_HDR 3d4; localparam PAYLOAD 3d5; localparam FINISH 3d6; reg [2:0] state; reg [9:0] byte_cnt; reg [15:0] total_len; // IP总长度 20 8 payload reg [15:0] udp_len; // UDP长度 8 payload reg [15:0] checksum; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; // ... 其余寄存器复位 end else begin case (state) IDLE: begin if (user_valid user_ready) begin // 这里预计算长度实际工程需先FIFO缓存数据或提前知道长度 state PREAMBLE; end end PREAMBLE: begin // 输出前导码0x55和帧起始界定符0xD5由MAC层或这里实现 // byte_cnt计数满后跳转 end ETH_HDR: begin // 依次输出DST_MAC, SRC_MAC, 0x0800 // 每周期输出一个字节 end IP_HDR: begin // 输出IP头20字节 // 其中校验和位置可以先填0在计算完成后回填 end UDP_HDR: begin // 输出UDP头8字节 end PAYLOAD: begin // 从用户FIFO读数据按字节送给MAC end FINISH: begin state IDLE; end endcase end end endmodule代码是框架性的实际填充细节时需要注意两点。第一user_ready信号表示模块当前能否接收用户数据数据进入PAYLOAD状态之前FIFO应保持读出状态。第二发送过程中尽量不要让mac_tx_valid拉低否则部分MAC或PHY会把帧截断所以我一般用握手信号配合FIFO保证数据连续。4.3 校验和计算与存储校验和采取边发送边计算的方式。发送IP头/UDP头时校验和字段先发0同时用一个寄存器累加16位和。比如发送IP头时把10个32位数据(等价20字节)依次加进累加器发完前19字节后第20字节所在的位置马上回填计算值。UDP头同理在发完UDP头的第8字节前要把伪头UDP头的累加结果计算出来并回填。这么做需要提前一周期算好所以代码上通常是在发某个头的前一个周期用组合逻辑或提前一拍算出来存好。累加器逻辑实质是补码和代码可以用如下方式reg [15:0] sum; reg [31:0] add_tmp; always (*) begin add_tmp {16d0, sum} next_data; // 反码回卷 if (add_tmp[16]) add_tmp add_tmp 1; end always (posedge clk) begin sum add_tmp[15:0]; end注意这里的next_data必须是16位数据发送方向的数据本来就是8位所以相邻两个字节拼成一个16位数参与累加。校验和的字节序同样要按大端处理。做完校验和之后建议在仿真里用Wireshark对比一下。4.4 FIFO缓冲与跨时钟域如果用户逻辑和UDP模块工作在同一个时钟域可以不用FIFO直接握手但多数情况下数据源是ADC采样时钟或图像时钟和以太网125MHz不是一个域FIFO几乎是必需的。用Xilinx或Intel的FIFO IP即可一般配置为标准模式写侧数据宽度32位、读侧数据宽度32位。跨时钟域还有一个容易忽略的点如果数据源是连续流比如ADC一直有数据UDP发送模块需要主动“取帧”你的用户逻辑侧就得有一个“攒够一帧就发送”的机制。常见做法是用一个FIFO做缓冲fifo_count达到设定阈值或收到last标志后启动UDP发送发送期间暂停写入。需要注意FIFO深度要大于一帧最大数据量防止发送期间溢出。UDP单帧最大payload是1472字节(1500-20-8)加上头尾留余量FIFO深度建议至少2048x32位。5. 调试实录从“完全不通”到“稳定收发”5.1 抓包工具与测试环境搭建先把环境搭好FPGA开发板通过网线直连电脑电脑上装好Wireshark和网络调试助手。给电脑的网卡配一个静态IP比如192.168.0.1掩码255.255.255.0FPGA侧源码参数里配192.168.0.10。直连时不需要交换机帧从FPGA出来到电脑网卡中间不经过其他设备方便抓包。我推荐的调试顺序是自底向上先用MAC层自发自收或者发ARP请求电脑上能看到ARP广播说明PHY和MAC基本通了。再调UDP发送在FPGA里写一个固定payload的测试模块循环发UDP包用Wireshark过滤udp端口8080确认能收到。最后调接收电脑发UDP包给FPGAFPGA把收到的数据原样回发电脑端校验回包内容。回环测试通过这个模块就可以放心接用户逻辑了。5.2 常见问题的排查思路速查下面列几个我自己踩过、也被学员反复问过的问题。Wireshark里看不到FPGA发的UDP包。先检查PHY的link状态灯是否正常再用Wireshark看有没有其他报文比如ARP。如果ARP都看不到问题在MAC/PHY层如果ARP能看到但没有UDP问题在UDP发送模块重点看发送完成后MAC层有没有收到长度信号。能抓到包但长度不对。IP总长度和UDP长度都小于实际发送字节往往是因为长度字段写成固定值而payload长度变化。对策是用计数器动态算长度或者确保每次发送长度固定。能收到UDP包但checksum报错。Wireshark会在校验和错误时直接标红。通常原因是伪头部计算范围漏了字段或者16位加法没做进位回卷。建议先用网络调试工具发包Wireshark里查看正常帧的校验和字段和FPGA里的计算结果对比。接收方向偶尔丢包。主要怀疑FIFO深度不够或跨时钟域处理不当。发送一帧期间写侧和读侧同时工作FIFO深度要按最坏情况估算并加上余量。另外检查读侧有没有在fifoempty时仍继续读避免读到垃圾数据。5.3 性能与稳定性验证模块调通之后建议用一个简单的方式压一下电脑用iperf3或网络调试助手连续打UDP流给FPGAFPGA侧通过回环把数据发回电脑观察丢包率和延迟。如果只是PC直连千兆UDP在批量小帧情况下会出现比较明显的CPU中断开销丢包不一定是FPGA的锅需要用专用打流工具或者降低发包速率来判断。我实测过一组数据同样是FPGA回环单包payload 1472字节速率压到600Mbps左右时CPU占用已经很高但把payload改成1000字节、提高帧率表现又好一些。这个现象说明UDP小包场景下瓶颈往往在PC侧不在FPGA。所以做性能评估时建议同时看FPGA内部ILA波形和PC端统计两边对齐才可信。稳定性方面最值得做的是长时间运行测试。让FPGA连续发24小时UDP包观察Wireshark里有没有乱序、重复、长度错误的帧。这个过程中经常能发现一些偶发问题比如某个寄存器在极端计数条件下没有清零、FIFO的almost_full信号时序没处理好等。6. 一些实际操作中的体会自己动手写UDP模块最大的收获不是“会填IP头了”而是理解了每一层协议在硬件上到底是怎么流转的。MAC层管帧边界IP层管寻址UDP层管端口三层职责分明代码里也按照这个边界去划分模块后面要加TCP、加ICMP、加VLAN标签都只需要在对应位置插入逻辑不需要推翻重写。如果非要给一个优先级排序我觉得初版先别追求功能完整先做到“固定目的MAC、固定源IP、固定端口”的最简通路能把一包数据从FPGA发到PC、再从PC发回FPGA就已经完成了一大半。ARP、动态端口、CRC卸载这些优化完全可以等通路稳定之后再加。很多人在最开始就想着把所有功能一次性做进去结果调了半个月还在跟状态机搏斗。后面我会在这个基础上继续写ARP和MAC层的设计细节包括RGMII时序约束怎么加、CRC32怎么算、以及怎么用ILA观察PHY接口的关键波形。如果这篇文章能让你少走一点弯路顺手点个收藏就行。有问题也欢迎在评论区留具体的现象和日志我看到会回复。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价