从近似0基础开始啃 FPGA 开发,走到第 10 篇,终于摸到了网络通信这块硬骨头。前面几篇我们把 Verilog 语法、时序逻辑、状态机、FIFO、跨时钟域这些基础攒得差不多了,但真要在板子上跑通一条 UDP 链路,光靠手写是远远不够的——你要处理 PHY 接口时序、MAC 帧格式、IP 头校验和、ARP 地址解析、UDP 伪首部校验,任何一个环节错了,抓包工具里就是一片沉默。这时候把 verilog-ethernet 这个开源工程拿过来读,性价比是最高的。它是一个纯 Verilog 实现的以太网协议栈,支持 1G/2.5G/10G 速率,覆盖 GMII、RGMII、SGMII、XGMII 这些常见 PHY 接口,内部用 AXI-Stream 做统一的数据通路,把 ARP、ICMP、IPv4、UDP 拆成独立模块,哪一层出问题就单独盯哪一层。我写这篇的目的很直接:给同样处在入门阶段、手里有一块带网口的 FPGA 板子、想把 UDP 收发跑起来的人,一份能照着抄的工程学习笔记。不管你是刚学完 Verilog 语法,还是已经能点灯但没碰过网络,这篇里的选型思路、模块拆解、仿真流程和踩坑记录,应该都能省你几天时间。
1. 为什么最后选了 verilog-ethernet 这个工程
1.1 从零手写 UDP 协议栈会撞上哪几堵墙
刚开始我也想过自己从 MAC 层往上撸一遍,觉得不就是拼几个字节嘛。真正动手才发现,第一堵墙是 PHY 接口时序:RGMII 是 DDR 双边沿采样,4 位数据在时钟上下沿各传一次,你还得在 FPGA 侧做输出延迟和输入延迟对齐,不同厂商的 PHY 芯片要求还不一样,差个几百皮秒就开始丢包。第二堵墙是帧结构,以太网帧头 14 字节、前导码 7 字节、SFD 1 字节、最小帧 64 字节、CRC32 校验,你少填一个字段对面直接当垃圾帧扔掉。第三堵墙是校验和,IP 头的 checksum 是 16 位反码求和再取反,UDP 还要额外算伪首部,涉及到字节序和进位回卷,手写特别容易出错。第四堵墙是 ARP,你得先发广播问“谁是 192.168.1.100”,等对方回了 MAC 地址才能发数据,中间还有超时重试和缓存老化。这四堵墙叠在一起,没有一两个月根本理不顺,而且你调试的时候根本不知道是自己逻辑错了还是对面没收,全凭猜。
1.2 verilog-ethernet 的定位和分层设计
verilog-ethernet 这个工程最舒服的地方在于它的分层特别干净。最底下是 PHY 接口层,负责把 RGMII/GMII 的物理信号转换成 8 位或 32 位的内部数据流;往上是 MAC 层,处理前导码、帧间隔、CRC 生成和校验、最小帧填充;再往上是以太网帧解析层,把帧头剥掉,把 payload 交给上层;最上面才是 ARP、ICMP、IPv4、UDP 这几个协议模块。每一层之间统一用 AXI-Stream 连接,也就是valid、ready、data、last这四根线的握手机制。这个设计的好处是你可以只关心自己那一层:想验证 UDP 收发逻辑,就绕开 PHY,直接在仿真里往 UDP 模块灌 AXI-Stream 数据;想验证 PHY 时序,就发固定帧看波形。我第一次跑通 ping 的时候才意识到,这种“每层可独立测试”的结构,对一个初学者有多重要——它把排错空间从整个链路压缩到了单个模块。
1.3 为什么不直接用 lwIP 或软核方案
有人会问,直接上一个软核跑 lwIP 不香吗,何必在 Verilog 里遭这个罪。这里得说清楚场景差异。lwIP 是跑在 CPU 上的软件协议栈,适合那些需要 TCP、HTTP、DHCP 这类复杂协议的场合,代价是它要占处理器资源和内存,吞吐还受 CPU 主频限制。而纯 Verilog 协议栈是硬件并行跑,一个时钟周期处理一批数据,1Gbps 线速下几乎不占 CPU,延迟稳定在微秒级。如果你的板子上本来就没有软核,或者你的应用就是高速采集、实时控制这类对延迟敏感的场景,那硬件协议栈才是对的选择。verilog-ethernet 还有个优势是纯 RTL、无厂商 IP 依赖,Xilinx 和 Altera 都能综合,这也让它成了很多教学和开源项目的默认底座。
1.4 上手前要先具备哪些前置知识
虽然标题写的是“近似 0 基础”,但读这个工程还是需要一点铺垫的。我个人建议至少掌握这几样再进来:一是能看懂always @(posedge clk)和阻塞、非阻塞赋值的行为差异,二是理解同步复位和异步复位的区别,三是知道 FIFO 是干什么用的、读写指针为什么不能直接拿来当握手信号,四是最好在仿真里跑过一个带跨时钟域的小设计。缺了这些直接冲进来看 RGMII 的 DDR 原语,会很痛苦,因为那部分代码里混着厂商原语例化和时序约束。基础不牢的时候,读开源工程最容易犯的错是“看不懂就跳过”,结果跳过的全是最关键的握手逻辑,最后上板不通又回头补,时间反而花得更多。
2. 工程目录与核心模块逐个拆解
2.1 顶层封装怎么选:从 FIFO 版本切入最省事
打开工程源码目录,你会看到rtl下面按功能排了一长串文件,第一次看容易懵。我的建议是从顶层封装文件开始看,也就是那些名字里带_fifo或者_64bit后缀的封装。以 1G 速率、RGMII 接口为例,eth_mac_1g_rgmii_fifo这个顶层把 PHY 接口、MAC、FIFO 全包进去了,对外只暴露一个标准的 AXI-Stream 收发接口。这样你几乎不用管 RGMII 的时序细节,只要把tx_axis和rx_axis接上自己的逻辑就行。FIFO 版本里有一堆参数值得注意:TARGET用来指定厂商原语,Xilinx 填"XILINX",Altera 填"ALTERA";DATA_WIDTH决定内部数据位宽,8 位是标配但可以升到 32 位来降时钟压力;ENABLE_PADDING控制小于 60 字节的帧是否自动填充到最小帧长;TX_FIFO_DEPTH和RX_FIFO_DEPTH决定缓冲深度,跑满 1G 线速的时候这两个值别设太小,我一般起始给 4096,跑通了再往下调。
2.2 PHY 与 MAC 层:GMII 和 RGMII 到底差在哪
GMII 和 RGMII 是两块最常打交道的接口。GMII 简单粗暴,8 位数据配一路 125MHz 时钟,上升沿就是上升沿,采样逻辑一目了然,缺点是引脚多,8 根数据加控制信号占一片 IO。RGMII 为了省引脚,把数据砍到 4 位,同时在时钟的上升沿和下降沿各传一次数据,还额外引入了 TXC 和 RXC 两根时钟线。代价是 FPGA 侧必须用 DDR 原语来收发,Xilinx 上对应ODDR和IDDR,而且要在建立保持时间上做延迟补偿,通常通过约束或 PHY 内部延迟来调。工程里对这两种接口都有现成实现,我的经验是:初学阶段如果你的板子两种都支持,优先用 GMII 调试,出问题时波形好读;等逻辑跑通了再切 RGMII 去省引脚,切过去之后只需要重点盯 DDR 原语和延迟约束这两块。理解这一层的关键是搞清楚数据从 PHY 进来时是什么节拍,进来之后被拼成了几个字节、什么时候开始有效,这个信息直接决定你后面协议层模块的数据流怎么接。
2.3 AXI-Stream 握手:整个工程的通用语言
AXI-Stream 这个接口协议是这个工程的骨架,所有模块之间都靠它说话。它其实就四根关键线:valid表示发送方数据有效,ready表示接收方可以收,data是数据总线,last表示这是一帧的最后一个数据。握手规则是:只有当valid和ready在同一个时钟上升沿同时为高时,数据才算真正传输成功。这个规则看起来简单,但初学最容易犯的错是把它当普通使能信号用,比如只看valid就开始处理数据,或者last和valid不同步拉高。正确的心智模型是“一次握手一笔交易”:ready没来之前valid和data都要保持不动,不能因为时钟走了一拍就把数据换掉。我刚开始写接收逻辑时,last位置判断错了一位,结果整帧数据永远差一个字节,抓包看到长度不对又找不到原因,最后对着波形一拍一拍数才定位到。读懂 AXI-Stream,这个工程就通了一半。
2.4 ARP、IP、UDP、ICMP 四块积木怎么拼
协议层这块,工程拆得特别清楚,最大的一个封装叫udp_complete,名字就直接告诉你它把 UDP 相关的东西全装好了。里面主要包含四块:arp负责地址解析,收到广播请求就回自己的 MAC,同时把别人的 IP-MAC 对应关系缓存下来,ARP_CACHE_SIZE默认给 8 条,够小网络用;ip负责 IPv4 头解析和生成,包括版本号、头长度、TTL、协议号这些字段,ip_tx负责把用户数据加上 IP 头再算校验和;udp是核心业务层,udp_rx剥掉 UDP 头把 payload 往外送,udp_tx加上源端口、目的端口、长度和校验和;icmp单独拿出来,专门处理 ping 请求,回了 ping 包才算网络层通了。这四块拼装的顺序是:以太网帧进ip,ip看协议号分流到udp或icmp。我第一次看的时候不知道从哪下手,后来发现只要记住“数据从哪进、往哪出、不匹配的分流到哪”这三件事,整个数据流就理顺了。
2.5 参数化设计里的几个关键配置
这个工程全程参数化,很多行为靠参数开关。我挑几个实际会改的说说。UDP_CHECKSUM_ENABLE控制 UDP 校验和是否生成,如果你的上位机不校验,关掉能省点逻辑,但网络里混有校验的设备时最好开着,不然对面可能拒收。IPV4_ADDR和IPV4_MAC_ADDR是本机地址,必须和上位机配成同一网段,我踩过把板子 IP 设成 192.168.1.x 而电脑是 192.168.0.x 的坑,ping 了半天才发现网段不通。ARP_REQUEST_RETRY_INTERVAL决定多久重发一次 ARP 请求,网络稍微慢点就把它调大。还有MII_*一堆参数跟 PHY 管理接口有关,用于配置 PHY 的速率和双工模式,如果你的板子用 MDIO 配置 PHY,这组参数得对着手册填。
3. 实操过程:从仿真到上板的完整链路
3.1 仿真环境搭建:先让数据流在波形里跑起来
我强烈建议第一步不要上板,先在仿真里把协议层跑通。工程自带的 testbench 通常都放在tb或者sim目录下,里面会例化 UDP 顶层,然后造一帧数据从tx灌进去,再从rx读出来对比。可惜大部分开源工程的 testbench 不完整,需要你自己补一点。我的做法是写一个最简单的激励:给udp_tx的 AXI-Stream 灌一帧固定内容,比如0x11 0x22 0x33 ...,同时在udp_rx侧监听输出,用$display或者写文件的方式把收到的 payload 打印出来。仿真跑通之后再往上层加 ARP 和 IP。这一步的核心目的是验证你的握手时序理解对不对,因为仿真是唯一能看到每一拍信号的地方,上板抓波形成本太高。这里有个小技巧:用$dumpfile和$dumpvars存 VCD,再用 GTKWave 打开,把valid、ready、last加进波形组,这样握手有没有问题是肉眼可见的。
3.2 引脚、时钟、复位三件套怎么接
上板前的准备工作里,引脚约束、时钟、复位是最容易出问题的三样。先说时钟,RGMII 方案通常需要一个 125MHz 的参考时钟给 PHY,有的板子由 PHY 芯片回传 RXC,有的需要 FPGA 自己产生。如果工程配置成 FPGA 输出时钟,那你要在顶层例化一个 MMCM 或 PLL,把晶振频率倍频到 125MHz 并且注意相位。引脚约束要严格对着板子原理图来,我就因为把 RGMII 的 TXD 和 TXC 顺序写反,折腾了一晚上。复位这块,工程用的是同步复位,所以你不能把外部按键直接接到rst上,中间要加两级同步器消抖和打拍,否则按键抖动会造成状态机误复位。另外注意上电顺序:PHY 芯片需要时间完成内部初始化,主逻辑复位释放得太早,第一批数据会丢,我一般让复位信号保持几百毫秒再释放,或者干脆等 PHY 链路上的 RXC 稳定后再放。
3.3 与 PC 联调:先把 ping 跑通再谈 UDP
上板后第一件事不是发 UDP 数据,而是 ping。为什么先 ping?因为 ping 走的是 ICMP,它不需要 ARP 之外的其他解析,路径最短,能通就说明物理层、MAC 层、IP 层、ARP 全部没问题,后面 UDP 出问题就可以只在 UDP 模块里找。联调步骤是这样:先把板子和电脑用网线直连,电脑端把 IP 设成和板子同网段,比如板子是 192.168.1.10,电脑设 192.168.1.100,掩码 255.255.255.0。然后在电脑上ping 192.168.1.10,同时用抓包工具看有没有 ARP 请求发出去。如果 ARP 有请求但板子没回,检查IPV4_MAC_ADDR参数是不是填错了;如果 ARP 回了但 ping 不通,检查 ICMP 模块的校验和计算。我第一次 ping 通的那一刻真的挺激动,因为从那之后所有的问题都变成了局部问题。
3.4 用 UDP 打流验证数据通路
ping 通之后开始测 UDP。最土但最有效的办法是写一个简单的上位机脚本,用 Python 的 socket 往板子发数据,同时板子把收到的数据原样回发,看内容对不对。这里要注意端口号,板子的udp_rx侧只认自己配置的目的端口,上位机发的目标端口要对上。发送频率也从慢到快,先一秒钟一包,跑通了再加到线速。如果一开始就满速发,缓冲来不及处理会丢包,你反而以为是逻辑错了。我实测下来,1G 链路上用 32 位数据位宽、FIFO 深度给够,丢包率可以做到 0。另外建议在板子侧加一个计数器,统计正确收到的包数和校验失败的包数,用 LED 或者串口打出来,这样你在不看抓包的情况下也能判断链路健康度。
3.5 关键的时序约束和跨时钟域处理
上板跑通之后如果想跑稳,时序约束是绕不过去的。RGMII 的收发路径上,PHY 时钟和内部逻辑时钟是不同源的,属于典型的跨时钟域场景。工程里通常用异步 FIFO 来做隔离,你要做的是给这两个时钟域分别建时钟约束,并且给跨域的路径加set_false_path或者set_clock_groups把它们分开,否则工具会去优化那些本来不该优化的路径,反而引入问题。我第一次没加约束,综合报了上百个时序违例,后来加了时钟组就好了。另外,如果时钟是自己用 MMCM 生成的,别忘了给生成的时钟建约束,还要检查 MMCM 的锁定信号是否参与了复位释放逻辑。
4. 常见问题与排查技巧实录
4.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 完全 ping 不通 | IP 不同网段、MAC 参数错、ARP 没工作 | 抓包看是否有 ARP 请求和回复 |
| ARP 通但 ping 不通 | ICMP 校验和错、TTL 为 0 | 看 ICMP 模块生成的回包字段 |
| ping 通但 UDP 收不到 | 目的端口不匹配、UDP 校验和错 | 抓包看 UDP 头是否符合预期 |
| 偶尔丢包 | 缓冲太浅、时钟约束缺失 | 加大 FIFO 深度、补时序约束 |
| 收到的数据偏移一位 | last判断位置错、握手采错拍 | 波形逐拍核对valid/ready/last |
| 上电后无反应 | 复位释放时机早、PHY 未初始化 | 检查复位时长和 MDIO 配置 |
这张表是我自己踩坑攒下来的,遇到问题先对照着缩小范围,比盲目改代码快得多。特别是“收到的数据偏移一位”这类问题,八成是握手逻辑写错了,先在波形里确认last拉高的那一拍对应的是不是最后一个字节,再回头改代码。
4.2 波形定位三步法
怎么用波形快速定位问题,我总结了三步。第一步看顶层的valid和ready,如果有一方一直不拉高,说明卡在握手上了,往下找是哪个模块没准备好。第二步看last,一帧数据里last只应该出现一次,如果出现多次或者根本没出现,说明长度信息有问题。第三步看数据内容的前几个字节,正常以太网帧开头应该是目的 MAC 地址,如果开头全是 0 或者乱的,说明数据流接错了源头。这三步走下来,八成的问题都能定位到具体模块。我个人的习惯是把关键信号提前在仿真里打标记,上板时再用集成逻辑分析工具抓同样的信号,两边对比着看,效率会高很多。
4.3 几个让我印象深刻的坑
第一个坑是字节序。Verilog 里数据总线通常按大端排列,但我在拼 IP 头的时候按小端思维把地址填反了,结果包发出去了但对面不认。后来改成严格按照字段定义从高字节往低字节摆,问题就没了。第二个坑是 UDP 长度字段,它指的是“UDP 头 + 数据”的总长度,不包括以太网头和 IP 头,我一开始把整个帧长填进去了,被抓包工具标红。第三个坑是 ARP 缓存的老化时间,默认几分钟就过期,长时间不发数据之后突然要发,会先卡在 ARP 请求上,表现出来就是“隔一会儿第一包丢”。解决方式是让应用层在发送前检查 ARP 状态,或者把缓存超时调长。这些坑网上文档基本不讲,都是自己撞出来的,写出来给后面的人省点时间。
4.4 关于仿真授权和工具链的小提醒
另外提一句工具链的问题。有些人跑仿真时会遇到授权报错,比如拿不到仿真 license,这种情况一般是环境变量或者工具配置问题,不是代码本身的问题。我的建议是仿真优先用开源工具链,配合iverilog或者 Verilator 做功能验证,综合和上板再用厂商工具,这样能避开一堆授权和版本兼容的坑。verilog-ethernet 本身是纯 RTL,跨工具综合能力不错,但你要注意它里面用到的厂商原语在仿真时可能需要对应的库支持,遇到找不到模块的报错,先确认是不是原语库没加到仿真路径里。
5. 跑通之后还能往哪扩展
把基础 UDP 收发跑通之后,这个工程其实是个很好的起点。往性能方向走,可以试着把DATA_WIDTH从 8 位提到 32 位甚至 64 位,看看吞吐能不能翻倍,同时观察 FIFO 深度和时钟频率的配合;往协议方向走,工程里已经有不少上层接口的预留,你可以在 UDP 之上自己定义一个简单的应用层协议,加个序号和校验字段,做成可靠传输;往应用方向走,把数据采集逻辑挂到udp_tx前面,就变成了一个网络化的采集节点,比如把 ADC 数据或者图像数据打 UDP 包发出去,实时性比软核方案好很多。我自己下一步打算做的是把 UDP 接收和一个小型状态机结合,根据收到的命令字去控制板子上的外设,相当于一个最简的网络控制节点。整个过程里最值钱的其实不是代码本身,而是排错那套方法:先分层、再仿真、再抓波形、最后对照速查表,这套流程走到哪都通用。