verilog-ethernet UDP协议栈:FPGA以太网收发与校验和解析 前面九篇从点亮第一个 LED 一路写到状态机、FIFO、跨时钟域和管脚约束到这一步差不多该碰点真正能拿得出手的东西了。这次盯上的是 verilog-ethernet 这个开源工程重点在它里面那套 UDP 协议栈。为什么是它因为 FPGA 做数据采集、图像传输、板间通信的时候TCP 那套重传、拥塞控制在纯硬件里写起来成本和风险都高得离谱而 UDP 无连接、头部短、几乎不做流量管理天生就适合硬件流水线去实现。verilog-ethernet 把以太网 MAC、IP、UDP、ARP 这些东西全部用 Verilog 写成了可综合的 RTL接口统一走 AXI-Stream参数化做得也还算克制正适合拿来当学习样本。这篇面向的是刚过完 FPGA 入门、能看懂 always 块和握手时序、但又没系统碰过网络协议栈的人。我会从模块分层一路拆到字节级的字段解析把收包和发包两条链路都走一遍顺带把校验和算法、时钟域处理、上板抓包定位这些真正会卡住人的地方讲透。读完之后你至少能做到看懂 udp_complete 的输入输出、知道一帧 UDP 从 PHY 进来是怎么变成用户数据的、以及自己动手发一帧能被 Wireshark 正常解出来的 UDP 包。1. 先搞清楚这个工程为什么值得啃1.1 verilog-ethernet 到底封装了什么很多人第一次打开这个仓库会有点懵目录里一堆 eth_mac_1g、ip_eth_rx、udp_ip_tx、udp_complete 之类的东西不知道从哪儿下手。我的建议是先抓住它的分层哲学每一层只干一件事层与层之间用 AXI-Stream 加 tuser 元数据通信。物理层往上是 MAC 层处理前导码、SFD、CRC、帧间隙和填充再往上是 IP 层处理版本、首部长度、总长度、协议号和首部校验和最上面是 UDP 层处理源端口、目的端口、长度和校验和。ARP 单独放在旁边负责把目的 IP 换成目的 MAC。这个分层最大的好处是你可以按需取用。只想做个裸以太网收发那就拿 eth_mac_1g 加 eth_axis_rx/tx 就够需要 IP 但不用 UDP用 ip_complete等到真的要 socket 那种感觉的收发才上 udp_complete。udp_complete 其实是 udp ip eth arp 的总封装它对外暴露的是一组 ready/valid 的 AXI-Stream 端口加上一堆 tuser 元数据信号。元数据里带着源 IP、目的 IP、源端口、目的端口、以太网类型这些字段收包的时候由下层填好往上抛发包的时候由上层填好往下传。理解了这个数据流走 AXI-Stream、控制信息走 tuser的约定整个工程的结构就顺了。另一个值得说的是它内部用的是 XGMII 这种 64 位数据加 8 位控制的总线形态再通过 eth_axis_rx、eth_axis_tx 两个适配器转成 AXI-Stream。这么绕一层是有讲究的XGMII 是 10G 以太网的通用接口形态把 MAC 输出统一成 XGMII那么后面挂 GMII、RGMII、SGMII 甚至 10G 的 PCS 都只是换个适配器的事。对 1G 的板子来说eth_mac_1g_rgmii 这种顶层模块已经把 RGMII 的 DDR 时序、时钟 90 度相移这些都封装好了你只要把 GTX_CLK、TXD、RXD 这些管脚连对就行。1.2 UDP 在 FPGA 场景里的真实定位不少刚接触的人会问既然 TCP 这么通用为什么硬件圈偏爱 UDP。关键在两个字确定性。UDP 不握手、不重传、不做窗口管理一帧出去就是出去硬件流水线可以一直满速跑延迟基本就是链路延迟加打包延迟抖动很小。这对于高速采样、雷达回波、图像帧、工业控制总线这类数据过期就废的场景特别合适。代价是丢包自己扛所以通常在上层应用再补一点点机制比如加序号、加时间戳接收端自己判断丢了哪些、能不能容忍。verilog-ethernet 里的 UDP 实现完全遵循 RFC 768 的报文格式头就 8 个字节源端口 2 字节、目的端口 2 字节、长度 2 字节、校验和 2 字节。长度字段是头加数据的总长最小 8最大 65535。校验和这块是新手最容易踩坑的地方因为它带一个伪首部这个伪首部只参与计算、不上链路包含源 IP、目的 IP、一个字节的保留 0、一个字节的协议号 0x11、再加 UDP 长度。这一点我在第 4 节会专门展开讲先把结构记住。还有一点常被忽略UDP 校验和的值为 0 表示发送端不计算校验和接收端遇到 0 可以直接跳过校验但如果计算结果本身就是 0发送时必须填 0xFFFF。这个和 IPv4 首部校验和的处理方式正好相反后者算出 0 就填 0。工程里 udp_ip_rx 就是这么判断的看代码的时候留意一下这两处不对称能省你不少调试时间。1.3 啃之前建议补齐的三块基础第一块是 AXI-Stream 的握手。这个东西看着简单tvalid 和 tready 同时拉高的那个时钟沿数据才有效但真正写起来反压路径没理清楚就会出现丢包或者数据错位。我建议先把 tvalid、tready、tlast、tkeep 这四个信号的作用背下来tkeep 是字节有效位最后一拍不满一个数据位宽的时候用它标记哪几个字节是有效的tlast 标记这一帧的最后一拍。第二块是跨时钟域。MAC 侧的时钟和用户逻辑的时钟往往不是一个工程里靠异步 FIFO 来过渡收包方向是 MAC 域写、用户域读发包方向反过来。理解这个之后你才知道为什么 udp_complete 会有一个 rx_clk 和一个独立的用户时钟输入。第三块是校验和的算法本身。一补数求和、回卷进位、最后取反这套东西在 IP 首部校验和和 UDP 校验和里都在用写一遍 Verilog 实现就能彻底记住。这三块补完再看工程里的代码就基本没有盲区了。2. 模块分层拆解数据到底怎么一层层走2.1 接收链路从 PHY 到 udp_demux接收这条链路的顺序是外部 PHY 的 RXD 引脚进来先过 eth_mac_1g_rgmii 里的 RGMII 接收逻辑把 DDR 的 4 位数据在上下沿采样拼成 8 位 GMII再转成内部 64 位 XGMII。这一步你不用操心只要管脚约束和 IDELAY 参数设对就行。接下来 eth_axis_rx 接手它干三件事找前导码7 个 0x55和 SFD0xD5、把数据段转成 AXI-Stream、顺带算 CRC32 和长度检查。校验失败的帧它会打一个 tuser 的错误标志或者按配置直接丢掉。再往上是 ip_eth_rx它解析 IP 首部。它检查版本号是不是 4、首部长度对不对、总长度字段和实际收到的字节数是否一致、首部校验和是否正确以及协议号是不是 UDP0x11。任何一项不过它就把错误标志抛到 tuser 上同时把源 IP、目的 IP 填进元数据。然后是 udp_ip_rx解析 8 字节 UDP 头把源端口、目的端口从元数据里带出来校验 UDP 校验和遇 0 跳过。最后是 udp_demux它按目的端口把这一帧路由到多个输出端口中的一个还能顺手把 UDP 头剥掉只把 payload 交出去。这条链路里每一级都是 ready/valid 握手反压会一级一级往回传。所以 udp_demux 某一端口没准备好压力会一路顶到 eth_axis_rx再顶到 MAC 层帧就在那儿等着。理解了这一点你就知道为什么某些情况下抓包会看到大量重传或者丢包——不是协议栈坏了是下游没跟上。2.2 发送链路从用户数据到线上波形发送是反过来走。用户逻辑准备好数据带上目的 IP、目的端口这些元数据交给 udp_deparser。这个模块负责把 UDP 头、IP 头、以太网头一层层拼出来——先算 UDP 校验和再补 IP 首部校验和最后套上以太网头。有版本会把它拆成 udp_ip_tx 和 ip_eth_tx 两级逻辑是一样的都是逐层封装。目的 MAC 地址如果用户没直接给就走 ARP 缓存去查查不到就发起 ARP 请求等回应这个机制让发包这块能像 socket 一样给个 IP 就发。封装完之后进 eth_axis_tx它负责加前导码和 SFD、算 CRC32 附在帧尾、以及做最小帧长的填充。以太网要求帧长不含 FCS最少 60 字节UDP 数据太短的时候就得补 0这一点工程里做了但你要知道它补在哪——补在 payload 后面、CRC 前面。然后是 eth_mac_1g把 64 位 XGMII 转回 8 位 GMII 再转成 RGMII 的 DDR最后从 TXD 引脚出去。这里有个帧间隙的规定发完一帧要等至少 12 个字节时间才能发下一帧MAC 层自动处理你只管连续喂数据就行。2.3 时钟域、FIFO 与反压的位置整个链路里最需要你想清楚的就是时钟域。典型布局是MAC 侧用 PHY 给的 rx_clk 和 tx_clk1G 下都是 125MHz用户侧用自己板子上的系统时钟。两个域之间的过渡靠异步 FIFO收包方向 FIFO 的写侧在 MAC 域、读侧在用户域发包方向反过来。工程里 udp_complete 的顶层参数允许你给一个独立的用户时钟就是这个道理。反压路径也值得单独捋。当用户侧读得慢FIFO 满了tready 拉低这个低电平会沿着接收链路一级级传到 eth_axis_rx最终让 MAC 层停止接收。因为以太网没有流控时对方是不管你收不收的所以 FIFO 深度要留够至少能扛住一帧最大长度1518 字节加上几帧的余量。我一般会把它设成 8KB 到 16KB 之间具体看你的突发特性。发包方向同理但一般发比收好控制因为节奏在你这。有一点要提醒跨时钟域 FIFO 一定要用带 gray 码指针的异步 FIFO不能用简单的双口 RAM 加计数。工程里的 FIFO 实现是经过验证的别自己手搓直接用。3. 接收方向实操把一帧 UDP 拆到字节级3.1 MAC 层做了什么不该你操心什么第一次看 eth_axis_rx 的代码会觉得有点复杂其实它核心就是一个状态机在 XGMII 上找 SFD。XGMII 是 64 位数据加 8 位控制位控制位标记当前字节是数据还是控制字符比如前导、SFD、空闲、错误。状态机从前导码状态开始逐拍扫找到 SFD 那拍之后切到数据状态把前 8 个控制字节里的数据部分提取出来从这一拍起后续所有字节都是帧内容。这里的关键是字节对齐。因为 XGMII 一拍 8 个字节SFD 可能落在任意一个字节位置所以数据拼接的时候要把 SFD 之后的字节往前挪到你期望的位置。工程里用了一个移位加字节计数的逻辑来处理你得仔细看那个 first_desc 和偏移计算否则仿真出来数据会错位。这一步搞明白了后面 AXI-Stream 的 tkeep 边界就是水到渠成的事。CRC32 校验是这个模块的另一半。以太网用的是多项式 0x04C11DB7 的反射实现帧尾 4 字节是前 4 字节的补码形式。工程里 crc32 模块是标准实现输入数据流、输出校验结果。这里有个细节以太网 CRC 是从目的 MAC 地址第一个字节开始算的不包括前导码和 SFD也不包括帧尾的 FCS 本身。有些人手算对不上就是把这几个字节算进去了。注意如果你用的是 SGMII 或者 100M 的 PHYeth_mac_1g 要换成对应版本XGMII 以上的逻辑不变别自己改协议栈层。3.2 IP 头解析与长度字段的坑到了 ip_eth_rx第一个要过的关是 IP 首部校验和。算法是把首部按 16 位一组求和一补数回卷进位取反结果应该是 0x0000接收端校验或者就是你算出来的那个校验值发送端。接收端收到全部为 0 才算过。工程里的实现用了一个累加器加回卷逻辑逐 16 位累加最后判是否为 0。长度字段有两个IP 首部的总长度和 UDP 头里的UDP 长度。总长度包含 IP 首部加数据UDP 长度只包含 UDP 首部加数据。这两个数字之间差一个 IP 首部长度通常是 20 字节带选项会更大。判断帧是否完整的时候ip_eth_rx 会把实际收到的字节数和总长度比不一致就报错。踩过的坑是这样的上游 MAC 层做了最小帧填充帧尾补了 0如果填充的字节被算进 IP 总长度里校验就过不了。正确的做法是 ip_eth_rx 用总长度字段来截断数据把多余的部分丢掉而不是依赖收到的字节数。还有一个容易忽略的点IP 首部可能有选项字段首部长度字段IHL会大于 5。工程里的实现会跳过选项只处理固定部分同时把数据段的起点按 IHL 对齐。如果你的应用不会用到 IP 选项可以不去管但要确认实现里处理了否则遇到带选项的包会解析错位。3.3 UDP 头、元数据与 demux 端口映射udp_ip_rx 处理的就是那 8 个字节。它把源端口、目的端口、长度、校验和都提取出来放进元数据然后校验。校验和为零直接放过这是 RFC 允许的。校验的具体做法是把伪首部、UDP 头和 payload 一起按 16 位累加回卷取反结果应为 0。工程里 udp_checksum_gen 这个模块在收发两个方向都用到了收方向用它做校验发方向用它生成。然后是 udp_demux。这个模块的输入是一路 UDP 流输出是多路按目的端口路由。它的实现方式通常是查一个查找表把端口号映射到输出索引然后根据索引把帧转发到对应的输出端口。问题是同一时刻只能有一个端口用它所以如果多个端口同时有数据需要仲裁。工程里的做法是简单的轮询或者优先级具体看配置。剥头这个功能可以开可以关开了之后输出就只有 payload元数据里保留端口和 IP 信息方便你在应用层用。这里给一段典型的接收侧例化参数按 1G、RGMII、四个 UDP 端口来设你可以直接抄udp_complete #( .UDP_PORT_COUNT(4), .UDP_DATA_WIDTH(8), .UDP_KEEP_WIDTH(1), .UDP_HAS_TX_CSUM(1), .UDP_HAS_RX_CSUM(1) ) udp_inst ( .clk(usr_clk), .rst(usr_rst), // 接收侧 .udp_rx_clk(mac_rx_clk), .m_udp_rx_udp_hdr_valid(udp_rx_hdr_valid), .m_udp_rx_udp_hdr_ready(udp_rx_hdr_ready), .m_udp_rx_udp_dest_port(udp_rx_dst_port), .m_udp_rx_udp_source_port(udp_rx_src_port), .m_udp_rx_eth_dest_mac(udp_rx_dst_mac), .m_udp_rx_eth_source_mac(udp_rx_src_mac), .m_udp_rx_eth_type(udp_rx_eth_type), .m_udp_rx_ip_version(udp_rx_ip_ver), .m_udp_rx_ip_ihl(udp_rx_ip_ihl), .m_udp_rx_ip_dscp(udp_rx_ip_dscp), .m_udp_rx_ip_ecn(udp_rx_ip_ecn), .m_udp_rx_ip_length(udp_rx_ip_len), .m_udp_rx_ip_identification(udp_rx_ip_id), .m_udp_rx_ip_ttl(udp_rx_ip_ttl), .m_udp_rx_ip_protocol(udp_rx_ip_proto), .m_udp_rx_ip_header_csum(udp_rx_ip_hdr_csum), .m_udp_rx_ip_source_ip(udp_rx_src_ip), .m_udp_rx_ip_dest_ip(udp_rx_dst_ip), .m_udp_rx_udp_length(udp_rx_udp_len), .m_udp_rx_udp_checksum(udp_rx_udp_csum), .m_udp_rx_payload_axis_tdata(udp_rx_tdata), .m_udp_rx_payload_axis_tkeep(udp_rx_tkeep), .m_udp_rx_payload_axis_tvalid(udp_rx_tvalid), .m_udp_rx_payload_axis_tready(udp_rx_tready), .m_udp_rx_payload_axis_tlast(udp_rx_tlast), .m_udp_rx_payload_axis_tuser(udp_rx_tuser) );这段的重点是接收侧的元数据信号都是跟着 hdr_valid/hdr_ready 这对握手走的payload 才是跟着 AXI-Stream 走的。这个头一拍、数据后面跟的约定你要记牢因为很多人在应用层忘记看 hdr_valid结果元数据没准备好就去读 payload读到的端口号是上一帧的。4. 发送方向实操手搓一帧能过的 UDP4.1 UDP 伪首部校验和的算法与实现发送方向最容易翻车的就是校验和。我们把算法拆开一步步来。第一步构造伪首部它是一个 12 字节的临时结构内容按顺序是源 IP 4 字节、目的 IP 4 字节、全 0 的 1 字节、协议号 0x11 的 1 字节、UDP 长度 2 字节。注意这个伪首部只参与计算不会出现在实际报文里。第二步把伪首部、UDP 头校验和字段先填 0和 payload 拼在一起按 16 位大端对齐做一补数求和。一补数求和的细节是这样每两个字节组成一个 16 位字累加进一个 32 位或者更宽的累加器如果累加过程产生了超过 16 位的进位把这个进位再回加到底 16 位上。全部加完之后取反低 16 位就是校验和。如果结果是 0x0000按 RFC 规定发送时填 0xFFFF。Verilog 里的实现通常是逐字节读大数据流用一个位宽足够的累加器加一个回卷组合逻辑。回卷那张逻辑是性能瓶颈因为每拍都要做加法回卷高频设计里容易成为关键路径。工程里 udp_checksum_gen 用了优化的结构把回卷延迟摊到多拍你要是自己写先保证功能正确再谈时序。这里有个独家技巧仿真验算校验和最快的方式不是自己盯着波形算而是拿同样的数据在 Python 里算一遍对比。下面这段几行就能算配合你的 testbench 输出一眼就能看出差在哪儿def udp_csum(src_ip, dst_ip, payload): pseudo src_ip dst_ip b\x00\x11 len(payload).to_bytes(2, big) data pseudo payload if len(data) % 2: data b\x00 s 0 for i in range(0, len(data), 2): s (data[i] 8) data[i 1] s (s 0xffff) (s 16) s ~s 0xffff return s if s ! 0 else 0xffff注意 payload 这里我传的是包含 UDP 头的完整内容校验和字段先填 0。你按这个对一下仿真里生成的校验和如果差一个固定的偏移多半是字节序或者伪首部长度搞错了。4.2 从 depaser 到 GMII 的时序配合udp_deparser 拼包的顺序是从外往里还是从里往外取决于实现。常见的做法是先发以太网头14 字节目的 MAC 6、源 MAC 6、类型 2再发 IP 头20 字节再发 UDP 头8 字节最后是 payload。每一段都是通过内部的状态机选择数据源往输出 AXI-Stream 上送。这里的时序配合要点是头部的 AXI-Stream 握手和 payload 的握手要能平滑衔接中间不能出现 tvalid 断开导致的帧间隙——虽然以太网上允许帧内的空闲前导码之前可以有帧内不行但你如果在帧中间断开CRC 计算会把这些间隙当成数据最后校验就错了。eth_axis_tx 这边的时序要注意两点。一是前导码和 SFD7 个 0x55 加一个 0xD5这是固定模式工程里直接生成。二是最小帧填充帧内容不含 FCS不足 60 字节时补 0 到 60补的字节计入 CRC 计算。RGMII 输出侧eth_mac_1g_rgmii 把 8 位 GMII 转成 4 位 DDR在 125MHz 的上下沿各传 4 位正好凑成 1Gbps。TX_CTL 也是 DDR 的和 TXD 同步。注意RGMII 的输出时钟 GTX_CLK 和数据的相位关系很关键通常需要 IDELAY 或者时钟树上的相移。板子不同策略不同先用示波器或者 ILA 抓一下眼图别一上来就怀疑协议栈。4.3 多端口发送与仲裁发多个 UDP 流的时候你需要一个仲裁器把多个上游的发送请求合到一路。工程里通常不直接提供这个东西因为怎么仲裁取决于你的业务——是固定优先级、轮询、还是按数据量加权。我一般会在 udp_deparser 前面加一层自己的仲裁用简单的轮询就行实现起来就是一个计数器加一个选择器。关键的坑在于帧不能被中途打断。仲裁器只在上一帧发完tlast 落下之后才能切换上游否则两帧的数据会粘在一起接收端解出来就是一个超长的、校验和错误的帧。所以在仲裁器里要维护一个帧边界锁存一旦某路上游的帧开始就锁住选择直到 tlast。这个逻辑用状态机写最清楚IDLE 状态下按轮询选一个 ready 的上游进入 BUSY 状态后不再切换直到看到 tlast 回到 IDLE。多端口还有个元数据来源的问题。每一路的源 IP、目的 IP、源端口、目的端口可能是固定的也可能随包变。如果固定直接在仲裁器里按路号查表填如果变就得让上游把这些信息一路带过来用 tuser 或者旁路信号。我一般推荐固定映射因为 FPGA 里动态改这些字段会引入额外的逻辑和时序风险业务上用多个固定端口区分不同数据流更简单。5. 联调实录抓包、打流、定位问题5.1 上板前的仿真与自环上板之前一定先把仿真跑通。verilog-ethernet 自带 cocotb 写的 testbench能直接跑 UDP 收发验证这是最省事的。如果你环境搭不起来退一步用 Icarus Verilog 跑个简单的自环把 eth_axis_tx 的输出直接接到 eth_axis_rx 的输入绕开 PHY验证协议栈层的收发逻辑。这样能先把协议栈和握手的问题排掉剩下再上板调 PHY。自环测试的时候我会写一个简单的激励发一帧固定内容的 UDP然后在接收侧检查头字段和 payload 是否一致。这里要注意的是自环会同时测试发送和接收的校验和路径如果收发都错可能是公共的 udp_checksum_gen 有问题如果只有一个方向错那问题在那一侧的封装或解析。这个二分法很有效。上板之后如果 PHY 侧有问题通常会表现为 link 不通看 PHY 的 link LED或者收到全 0 或者全 1 的数据。先确认 MDIO 配置对、PHY 复位时序对、RGMII 的 IDELAY 参数对。这三样有一样错协议栈写得再对也白搭。5.2 抓包工具与现象对照PC 侧抓包用 Wireshark 就够配合 tcpdump 或者直接图形界面。打流工具可以用 iperf3 走 UDP 模式但注意 iperf3 的 UDP 是单向压测适合测吞吐和丢包不适合测功能。测功能我更推荐自己写个 Python 脚本用 socket 发几帧带特殊内容的包然后在 FPGA 侧把收到的数据通过 ILA 或者串口打出来比对。几个典型现象和对应的方向Wireshark 看到 CRC 错误多半是发送侧 CRC 算错或者 RGMII 时序问题看到帧长不对比如 60 字节全是 0 或者一个奇怪的短帧多半是最小帧填充逻辑有问题或者帧间隙没处理好看到 IP 首部校验和错误多半是 udp_deparser 里 IP 头构造错了看到 UDP 校验和错误就回到 4.1 的算法和伪首部再查一遍。ILA 是上板调试的神器建议在 tvalid、tready、tlast 和 payload 的前几个字节上抓波形。触发条件设在帧开始那一拍抓几百个样点就能看清一帧从开始到结束的握手全过程。这里提醒一下ILA 会占用 BRAM 资源采样深度别设太大够用就行。5.3 常见问题速查表与避坑清单把踩过的坑整理成一张表遇到问题先按表查现象最可能的原因排查方向link 不亮PHY 配置或复位时序MDIO 读 PHY ID检查复位脉宽收到全 0/全 1RGMII 采样时序调 IDELAY查时钟相位CRC 错误发送 CRC 或时序自环验证抓 TX 波形IP 校验和错IP 头构造错对比 Wireshark 的字段UDP 校验和错伪首部或长度错用 Python 脚本复算比对数据错位XGMII 字节对齐查 SFD 后偏移计算丢包严重FIFO 反压或深度不足加大 FIFO查 tready 波形帧粘连仲裁器中途切路锁帧边界tlast 后再切帧长异常最小帧填充逻辑检查补 0 位置和 CRC 范围端口不匹配demux 映射表核对端口到索引的映射避坑清单我补充几条文档里不会写的。第一仿真时别忘了复位要拉够时间异步复位释放要同步化不然第一次仿真经常出现首帧丢失。第二udp_complete 的收发时钟如果都接同一个时钟异步 FIFO 的跨时钟逻辑虽然还能工作但你会失去验证跨时钟域的机会建议仿真时故意用两个不同频率的时钟把这部分也测到。第三tkeep 在最后一拍的处理是全链路最容易出错的地方任何一层漏算 tkeep最后一帧就会多出几个无效字节抓包看不出来但业务数据会错。第四如果用了 ARP第一次发包会因为 ARP 未解析而延迟这个延迟在时序敏感的应用里要提前触发解析。6. 在这个工程上做二次开发的几条路子6.1 提高吞吐从单帧到连续流默认配置下的 UDP 收发是单帧流水一帧发完等下一帧中间有握手的空档吞吐上不去。要跑满 1G 线速得让发送侧能流水化也就是在发送当前帧的同时准备下一帧。做法是在 udp_deparser 前面加一个双缓冲或者小 FIFO把多帧数据预先排好发送状态机连续取用中间不断流。接收侧同理用 FIFO 把接收和用户处理解耦。这里的关键是校验和的流水化。逐帧算校验和会成为瓶颈因为 payload 长的时候一拍一拍加串行延迟很长。优化的方向是用多个累加器并行比如把数据按 4 个字节一组用两个加法器树同时算最后再合并。这样吞吐能翻几倍。工程默认的实现偏保守想跑满线速这块基本都要自己改。还有一个容易忽略的点是帧间隙。发完一帧必须等 12 字节时间这是以太网硬性要求MAC 层会自动插入空闲。但如果你连续喂帧太快MAC 层插入间隙的时候会产生反压你的上游 FIFO 得能吸收这个反压否则数据会丢。这个在跑满速的时候尤其明显。6.2 加一层自己的应用协议裸 UDP 只保证到端口不保证顺序、不保证不重复、不保证到达。真正做产品一般会在 UDP payload 里再叠一层自己的小协议。我在项目里常用的做法是payload 前 8 个字节放帧序号4 字节和时间戳4 字节后面才是业务数据。接收端按序号判断丢包按时间戳判断数据新鲜度。这一层协议完全在用户逻辑里做和 verilog-ethernet 解耦改起来不影响协议栈。序号这块我踩过一个坑计数器位宽选小了高频发的时候几十秒就回卷接收端判断丢包的逻辑就乱了。按 1G 线速满帧算每秒大概 80 多万帧32 位序号大约 5000 秒回卷够用但不算宽裕。如果业务连续跑很久建议用 64 位或者在回卷的时候做个标志让接收端知道要重新对齐。时间戳的来源可以是本地计数器也可以是外部授时比如 PPS 或者 IEEE 1588但后者完全另一套东西不在这个工程范围内。本地计数器实现简单精度受时钟本身精度限制够大多数场景用。要做高精度时间同步那又是另一个要啃的大坑了等把 UDP 这套吃透再说。整体上verilog-ethernet 这个 UDP 协议栈工程最值得学的不是某一行代码而是它分层、握手、元数据传递这套设计思想。把这套思想搬到别的协议上比如你要做自定义的板间通信、或者封装别的传感器数据思路是一样的。我个人的体会是用它做过一两个完整项目之后再回头看 AXI-Stream 和协议分层会有完全不一样的理解。