做FPGA开发到一定阶段,你会发现手里攒了一堆“能跑”的模块,串口、SPI、I2C、PWM、状态机,单看每个都能工作,但一旦想和PC高速交互数据,就立刻卡住。串口115200bps传个几十KB的波形数据能等到人发霉,USB转串口芯片本质上也没有突破串口瓶颈,这时候以太网就成了绕不开的方向。我在整个系列的第10篇,终于决定啃verilog-ethernet这个开源UDP协议栈工程,也把之前一直没想明白的“FPGA怎么和PC用网线通信”这件事彻底搞通了。
这篇内容不是单纯翻译一遍官方文档,而是把我从0开始摸索这个工程的过程、模块连接原理、仿真验证方法和上板实测踩坑记录都写出来。如果你已经学过Verilog基础、知道怎么写状态机、会用Vivado建工程,但还没接触过以太网协议栈,那这篇文章正好卡在你的技术断点上。看完之后你能理解UDP协议栈在FPGA里到底是怎么组织的,以及怎么改参数、搭回环实验、定位“收发不通”的问题。
1. 为什么第10步要选UDP协议栈,而不是TCP、PCIe或者DDR
先说一个比较现实的问题。FPGA的学习路径到了后面,方向特别多,有人去做图像处理,有人去做高速ADC采集,有人去做电机控制,但无论哪个方向,几乎都躲不开“把FPGA的数据送到PC看看”这个需求。图像处理要传一帧帧的视频流,TDC直方图要传密集的时间戳数据,干涉仪测向要传多通道采样结果,这些数据量用串口根本扛不住。要么挂USB芯片,要么走PCIe,要么走以太网,而以太网是三者里面最容易上手、成本最低、也最通用的方案。
我选UDP而不是TCP,理由其实很实在。TCP在FPGA里实现要考虑连接管理、序列号、重传、拥塞控制、窗口滑动,这些逻辑堆下来代码量直接翻几倍,而且协议栈越复杂,出bug的概率越高。UDP是无连接的,发送端把数据打包扔出去,接收端解包拿出来,中间没有握手、没有确认,数据丢了就丢了。听起来好像很不可靠,但用在内部系统通信、实时数据采集、视频传输这些场景下,UDP反而更合适。FPGA的优势本来就是低延迟高吞吐,如果为了可靠心跳牺牲掉实时性,就没有意义了。
verilog-ethernet这个开源工程之所以值得学,是因为它把以太网链路层、ARP、UDP层都封装成了可复用的Verilog模块。它不是那种只能整个工程例化、改不了内部结构的黑盒IP,而是把MAC、RGMII接口、UDP收发、ARP缓存这些部分拆成独立的rtl源码,你可以像搭积木一样只拿自己需要的模块。这对学习协议栈原理来说非常友好,你可以一个模块一个模块地读源码、做仿真,搞清楚每一层到底干了什么。
1.1 从串口到以太网,我踩过的“升级通信方式”的弯路
我在做FPGA和PC通信的时候最早用的是USB转串口,也就是CH340或者FT232这类芯片。刚开始觉得只要能发数据就行了,后来发现串口有几个很要命的问题。首先是速度瓶颈,普通USB转串口跑到921600波特率就到头了,每秒最多传90KB左右。其次是没有帧的概念,串口就是字节流,如果信号一抖,帧边界就丢了。我做实时数据的波形显示时,经常出现上位机解析错位、曲线乱跳的情况。
后来又试过用USB芯片,比如CY7C68013A这种,能跑到几十MB/s的速率,但又要写驱动、又要写固件和PC端SDK对接,环境搭建成本很高。PCIe更是要熟悉AXI总线、DMA、驱动开发,对一个刚开始做项目的人来说门槛太高。以太网的优势在于PC端不需要额外驱动,操作系统原生支持UDP/IP协议栈,只要用网线连上交换机,上位机写socket代码就能收发数据。而且千兆以太网的带宽是1000Mbps,即使UDP协议有开销,有效数据率跑到接近100MB/s也很轻松,比USB转串口强了几个数量级。
1.2 UDP为什么是一个“正常FPGA开发”绕不开的选项
很多初学者会问,既然有现成的TCP/IP协议栈IP核,为什么还要自己去看开源的UDP实现?这里我想强调一个点:商用IP核,比如Xilinx的三速以太网MAC、Altera的TTSE,它们解决的是MAC层和PHY层的问题,但UDP/IP协议栈那一层还是得你自己处理。而UDP层恰恰是FPGA和PC通信差异最大的地方,也是需要根据业务逻辑调整的地方。
比如你要传图像数据,一帧1080p的RGB图像就有6MB,以太网MTU通常是1500字节,你必须把图像切分成几千个UDP包来发送。每个包里要带什么序号、怎么描述数据格式、接收端如何重组,这些业务逻辑都建在UDP之上。如果你不理解UDP的帧结构、校验和、端口匹配、ARP解析这些底层细节,上层业务根本就没法写。verilog-ethernet的UDP代码写得比较规范,直接读源码比看协议文档效率要高得多。
2. 理解verilog-ethernet工程的整体结构和设计思路
刚打开verilog-ethernet的仓库时,我第一反应是“怎么这么多文件夹”。rtl目录下面按功能拆了eth_mac_1g_axis、eth_udp、eth_arp、eth_rgmii、axis_fifo这些子目录,看起来有点乱。但如果把数据流的方向画出来,就会清楚很多。
工程本质上是解决“数据从网线进来,如何变成用户能用的数据”和“用户数据如何变成网线上的信号”这两个问题。网线上跑的物理信号是模拟电平,PHY芯片负责把它们变成RGMII接口上的数字信号。RGMII再被MAC层模块接收,完成前导码校验、CRC校验、帧长度检查,输出的就是一整帧打好包的以太网数据。这一帧数据里包含目的MAC、源MAC、类型字段、IP头、UDP头和应用负载。UDP收发模块做的就是从这帧数据里剥离出应用负载,或者反过来把应用负载加上IP头、UDP头发送出去。
这里最关键的设计思想是分层。MAC层不关心你传的是什么协议,它只管把以太网帧正确地收进来、发出去。UDP层不关心物理接口是RGMII还是GMII,它只处理IP包和UDP协议字段。用户应用层更不关心底层网络细节,它看到的只是AXI-Stream接口上的一个数据流。这种分层让每一层都能独立测试和替换,也让我在学习的时候可以一次只关注一个模块。
2.1 梳理verilog-ethernet文件结构,别被仓库吓到
我当时为了找到UDP收发到底用哪些文件,把rtl目录翻了一遍。整体文件结构分为几个层次,最底层是lib目录下的通用模块,比如axis_fifo、async_fifo、sync_reset这些基础元件。再往上是MAC相关模块,eth_mac_1g_axis负责以太网MAC,eth_rgmii_rx和eth_rgmii_tx负责RGMII物理接口的接收和发送。再往上是ARP和UDP层,eth_arp处理地址解析协议,eth_udp_rx和eth_udp_tx处理UDP包的收发。
实际使用时,如果不使用ARP缓存,一个最简单的UDP收发系统可以只例化这些核心模块:eth_mac_1g_axis、eth_udp_rx、eth_udp_tx、async_fifo,以及RX/TX方向各一个axis_fifo。MAC输出的AXI-Stream数据先经过FIFO跨时钟域,再进入eth_udp_rx解析。发送方向则相反,eth_udp_tx打包好UDP帧后,经过FIFO进入MAC模块发送。
我建议初学者不要一开始就想着把所有模块都研究透,先把数据流打通。也就是先忽略ARP细节,直接把目的MAC地址固定配置好,让UDP收发链路跑起来,然后再回头研究ARP、协议细节这些进阶内容。
2.2 UDP接收路径,从网线到应用数据的“解包旅程”
接收方向的完整链路是这样的:PHY芯片把差分信号转换为RGMII格式的RXD[3:0]、RX_CLK、RX_CTL信号,eth_rgmii_rx模块用IDDR原语在每个时钟的上升沿和下降沿各采样一次4位数据,拼成8位字节流。然后eth_mac_1g_axis模块开始处理这个字节流,它会检查前导码、SFD分隔符、目的MAC、源MAC、长度/类型字段,校验FCS CRC。如果帧正确,MAC模块会按8位到64位的数据位宽转换,把整个以太网帧以AXI-Stream形式输出,附带tvalid、tlast、tkeep这些信号。
接下来数据进入eth_udp_rx模块。这个模块做的事情非常纯粹:它检查以太网类型字段,如果是0x0806,说明是ARP包,就交给ARP模块处理;如果是0x0800,说明是IPv4包,就继续解析IP头。IP头默认20字节,里面包含了源IP、目的IP、协议字段。如果协议字段是17,即UDP协议,再解析UDP头。UDP头8字节,包含源端口、目的端口、长度、校验和。eth_udp_rx会检查目的端口是否和自己配置的端口匹配,匹配的话,把UDP负载部分通过AXI-Stream输出给用户逻辑,同时输出解析出来的源IP、源端口等元数据。
这个模块最方便的一点是它把各种头部分的剥离开销都做完了,用户逻辑不用关心以太网帧格式。但你还是得理解这个过程,不然接收时序、AXI-Stream的tlast信号怎么对齐都容易搞错。
2.3 UDP发送路径,从应用数据到网线的“打包旅程”
发送方向是接收方向的逆过程。用户逻辑准备好要发送的数据,通过AXI-Stream接口写入eth_udp_tx模块。eth_udp_tx会检查当前是否处于busy状态,如果总线空闲,它就开始构造以太网帧。
第一步是发目标MAC地址、源MAC地址、以太网类型0x0800,然后是IP头。IP头里的总长度、标识、校验和字段由模块自动计算。接下来是UDP头,源端口和目的端口分别来自配置寄存器和输入数据。UDP头里还有一个校验和字段,发送模块会一边发送负载数据一边进行增量校验计算,在负载发送完之后把校验值写入帧尾。最后,整个帧交给MAC层模块加上前导码和FCS CRC,再通过RGMII发送到PHY芯片。
一个容易忽略的细节是MAC层会根据帧长度自动做填充。以太网规定最小帧长是64字节,如果UDP负载太短,整个帧长度不足64字节,MAC模块会在负载后面填充0。这个填充动作在接收端会被MAC当作填充数据剥掉,所以用户数据从协议栈收出来还是原来的长度,不会看到这些填充字节。
3. 配置和接口细节,MAC地址、IP、端口、时钟这些参数到底怎么设
学习verilog-ethernet时,参数配置是你第一个会遇到的实际问题。工程里很多模块都有类似parameter src_mac、src_ip、dst_mac、dst_ip的定义。刚开始我直接照搬示例的配置,结果在板子上跑的时候发现数据根本发不出去,后来才搞明白,问题出在目的MAC地址上。
PC的网卡只接收发给自己MAC地址的帧,如果你的FPGA发送的帧里目的MAC不是PC网卡的MAC地址,PC网卡会在硬件层面就把帧丢掉,根本不会交给操作系统协议栈。所以最简单的办法是打开PC命令行,输入ipconfig /all找到以太网适配器的物理地址,然后把这个MAC地址固化到FPGA的dst_mac参数里。如果你用了ARP模块,发送前会自动查询PC的MAC,但你还是得先把FPGA自己的源MAC地址设成和PC在同一局域网内的值,否则交换机不一定转发。
IP地址也有讲究,FPGA的src_ip要配成和PC同一子网,比如PC是192.168.1.100,FPGA就配192.168.1.50。目的IP自然是PC的192.168.1.100。子网掩码虽然不影响UDP收发模块,但PC发送UDP包时如果目的IP不在同一子网,会走网关而不是直连网卡,所以测试时最好把FPGA和PC用网线直连,避免交换机介入。
3.1 MAC、IP、端口三个宏,改值前先想清楚
我看到有些开发板例程里,以太网参数写死在顶层模块里,改一次要查找替换好几处。verilog-ethernet的工程其实可以在顶层模块里定义parameter,然后把参数传给UDP和MAC模块。修改前建议先给整个系统画一个表,列出FPGA的MAC、FPGA的IP、PC的MAC、PC的IP、FPGA要监听的UDP端口、PC要发送的UDP端口、PC要接收的UDP端口、FPGA发送的目的UDP端口。
这里容易踩坑的是端口的方向。FPGA如果要把数据发给PC的某个上位机软件,目的端口必须和上位机绑定的端口一致。PC如果要把数据发给FPGA,目的端口必须等于eth_udp_rx模块配置的监听端口。不要想当然地认为PC发送端口和FPGA接收端口肯定一样,很多UDP调试助手默认在同一个本地端口上接收和发送,但如果你用Python写socket或者使用特定上位机,收发端口可能不同。
3.2 跨时钟域,为什么以太网工程一定有FIFO
我在之前学FPGA时,对跨时钟域处理的理解停留在“加两级触发器打拍”这个层面,但到了以太网工程里,这个办法没用。因为接收方向,RGMII RX_CLK是由远端PHY提供的125MHz时钟,而用户逻辑通常运行在另一个时钟域里,比如200MHz的全局时钟,两块数据必须通过异步FIFO才能可靠交接。
verilog-ethernet在RX路径上通常放一个axis_fifo,TX路径也放一个axis_fifo,这样MAC和UDP模块之间的AXI-Stream信号就变成了异步FIFO的读写端口。我从这个工程里学到的经验是,FIFO的深度不要用默认的小值,至少要能容纳几个MTU大小的数据包。比如整帧最大1518字节,如果数据位宽是64位,FIFO深度做到2048个字是比较稳妥的。这样做的好处是即使网络有突发流量,FIFO不会立刻溢出,链路层重传或者背压的时间也足够。
3.3 最容易掉进去的坑,回环测试中PC网卡的ARP表
我第一次做回环实验的时候,总是发送不通。后来用Wireshark抓包才发现,PC发了ARP请求询问“192.168.1.50的MAC地址是多少”,但我FPGA工程里压根没接ARP模块,所以PC根本不知道FPGA的MAC地址是多少,UDP数据包因为找不到目的MAC而无法发送。
解决这个问题的办法有两种。第一种是给PC静态添加ARP条目,用管理员身份运行命令arp -s 192.168.1.50 aa:bb:cc:dd:ee:ff,强制告诉PC这个IP对应哪个MAC。第二种是在FPGA工程里把eth_arp模块加上,让FPGA能够自动响应PC的ARP请求。我推荐第二种,因为更接近真实应用。但如果你只是想快速验证UDP通路,第一种最省事。
值得注意,Windows系统的ARP缓存在一段时间后会自动过期,有时需要重新添加。另外如果FPGA重启后MAC地址没变,ARP缓存还能继续用;如果MAC变了,缓存里的旧条目就会导致数据发到错误的目标。
4. 实操,从零搭一个UDP回环工程并跑通
理论知识说再多,不如实际跑一遍。我这次实操的目标非常简单:FPGA收到PC发来的UDP数据,然后原封不动地发回给PC。用这个回环实验验证RX和TX两条通路是不是都正常工作。如果再跑通了,我再把代码改成自定义数据处理逻辑。
我先在Vivado里新建了一个工程,芯片用XC7A35T。然后从verilog-ethernet仓库的rtl目录下把这些文件添加到工程:rtl/eth_mac_1g_axis/eth_mac_1g_axis.v、rtl/eth_mac_1g_axis/eth_mac_1g_axis_fifo.v、rtl/eth_rgmii/eth_rgmii_rx.v、rtl/eth_rgmii/eth_rgmii_tx.v、rtl/eth_udp/eth_udp_rx.v、rtl/eth_udp/eth_udp_tx.v,还有lib目录下的async_fifo、axis_fifo、reset_sync等通用模块。源码里某些模块会被重复引用,Vivado会自动处理依赖,但如果你是用命令行编译,要注意包含顺序。
顶层模块我命名叫udp_loopback_top,里面例化了一个RX方向的FIFO、一个TX方向的FIFO、eth_udp_rx和eth_udp_tx,然后直接用一对assign把RX输出的m_axis_tdata和tkeep接到TX输入的s_axis_tdata和s_axis_tkeep,再把tvalid和tready做握手连接。逻辑上看起来很简单,实际接线时要严格对齐AXI-Stream信号的时序关系。比如只有tvalid和tready同时为高时才传输数据,tlast在整帧最后一个周期拉高,tkeep指示最后一周期的有效字节数。
4.1 顶层模块搭建,先用回环把数据流打通
顶层模块的关键例化代码逻辑是这样的。eth_udp_rx解析出UDP负载后,把数据送到axis_rx_fifo缓存,然后回环逻辑从fifo读出,写入eth_udp_tx。这里有个重要的背压关系:如果eth_udp_tx因为忙于发送上一帧而拉低s_axis_tready,那回环逻辑就必须停下从fifo读数据,fifo满了之后eth_udp_rx的m_axis_tready被拉低,接收通路自然被阻塞。这样整个系统的流量控制就串起来了。
我犯过的错误是把模块的复位信号直接接到了全局复位而没有做异步复位同步释放处理。在以太网工程里,复位信号至关重要,尤其是MAC模块在复位释放后需要重新同步到RX_CLK和GTX_CLK,如果复位不同步,模块状态机可能跑飞。verilog-ethernet自带sync_reset模块,顶层一定要用这个模块生成各个时钟域的复位信号。
4.2 回环逻辑与AXI-Stream握手细节,看起来简单但容易写错
回环逻辑听起来就是“收到什么发什么”,但如果你直接写一个always块把RX数据赋给TX数据,多半会有时序问题。因为RX路径上的AXI-Stream主端模块和TX路径上的AXI-Stream从端模块都有自己的一套tvalid和tready握手信号,必须一套完整的组合逻辑把读端口和写端口对接。
我的做法是用一个简单的状态机:空闲状态,等待输入FIFO非空;读使能拉高,从输入FIFO读出一个周期数据;写使能拉高,把数据和tlast、tkeep一起写入发送FIFO;如果这一帧数据还没结束就继续读,如果tlast为高,就说明一帧结束,回到空闲状态。这个状态机非常单薄,但能把tvalid/tready的时序搞得清清楚楚。写FIFO时如果发送端一直拉低tready,状态机就要在等待状态里停住,不能丢掉数据。
这里还要注意位宽对齐。eth_udp_rx输出的AXI-Stream数据位宽默认是64位,也就是每个周期8字节。PC发的UDP负载长度不一定是8的倍数,所以tkeep信号会指示最后一拍有几个字节有效。回环逻辑不能只看tdata,还必须原样传递tkeep,否则发送端会把无效字节当作有效数据打包,导致对端收到的数据长度不对。
4.3 上板验证流程,上位机配置和波形实测
工程综合、布局布线之后,把bit文件下载到FPGA开发板。开发板的以太网PHY型号一般是RTL8211E或88E1512,这些PHY在上电后需要一段时间完成自协商,等链路建立后RGMII接口的时钟才稳定。所以我顶层里做了一个上电延时计数器,延时几百毫秒后再释放以太网复位,确保PHY初始化和时钟稳定。
我的PC端使用一个简单的UDP调试工具,先把本地IP设为192.168.1.100,网关不用填。FPGA的IP在Verilog里配置为192.168.1.50,监听UDP端口5000,发送目的IP是192.168.1.100,目的端口也是5000。测试时,我在调试工具里输入“hello fpga”并发送,然后观察接收窗口。如果回环逻辑正常工作,几毫秒内就能收到一模一样的“hello fpga”。
如果收不到,第一时间要用Wireshark在PC网卡上抓包,看PC是否发出了UDP包,是否收到了ARP回复。我这次实际测试的时候,第一次就是Wireshark看到了PC发出的UDP包,但FPGA没有返回任何包。排查后发现是PHY芯片复位时间不够,RX_CLK没有稳定,MAC模块收到的全是乱码。把复位延时加长后,问题就解决了。
5. 常见问题与排查技巧,收不到包、长度不对、偶尔丢包
以太网调试最痛苦的现象是“明明代码看起来没问题,但就是收不到数据”。我整理了这张速查表,可以帮你定位问题。
| 现象 | 可能原因 | 检查方法 |
|---|---|---|
| PC发UDP,FPGA完全没反应 | FPGA的MAC地址没被PC学习,ARP解析失败 | Wireshark抓包看PC有没有发ARP请求,FPGA有没有ARP应答 |
| PC收不到FPGA发送的数据 | FPGA发送帧的目的MAC写错了 | 抓包确认FPGA发出的以太网帧目的MAC是否为PC网卡MAC |
| 收到数据长度不对 | AXI-Stream的tkeep没有正确传递 | 检查回环逻辑是否原样传递tlast和tkeep |
| 偶尔丢包 | FIFO深度不够,突发流量溢出 | 增大FIFO深度或优化背压逻辑 |
| 数据内容错乱 | RGMII时钟相位问题,或者PHY芯片工作模式不对 | 查看MAC复位时序、PHY配置寄存器 |
5.1 收不到数据的排查顺序
如果收不到数据,我的习惯是先抓包,再查MAC,再查PHY状态,最后才查FPGA内部逻辑。因为PC端的网络配置和网卡驱动问题在FPGA开发中非常常见,先排除环境因素能省很多时间。
第一步在PC上抓包,看PC是否把UDP报文发出来了。如果没发出来,说明PC的ARP表里没有FPGA的MAC条目,或者防火墙拦截了ICMP/UDP报文。如果PC发了UDP但FPGA没反应,就要检查RGMII时序。用逻辑分析仪或Vivado的ILA抓FPGA侧RGMII引脚上的RX_CLK和RXD信号,确认PHY是否输出了有效的网络波形。如果RX_CLK根本没有,那大概率是PHY复位或配置出了问题,比如MDIO接口没有正确配置PHY工作方式。
第二步检查MAC层状态。用ILA抓eth_rgmii_rx模块输出的8位数据和MAC模块输出的AXI-Stream信号,看看有没有完整的一帧数据。如果MAC收到了帧,但UDP模块没输出,说明问题在UDP层,比如目的端口不匹配、IP协议字段不是UDP、或者帧里的Type字段不是0x0800。
5.2 组织链路层数据时的常见错误
在修改UDP发送端代码时,不少人会手动构帧,但这几个错误可以说是“经典三连”。第一,忘记填充最小帧长,导致MAC模块报错或者PHY发不出去。如果不用MAC模块自动填充,自己发短帧时要在数据后补0到60字节,加上4字节FCS才是最小64字节。第二,IP头里的总长度字段没算对,它包含IP头本身20字节加上UDP头8字节加上负载长度。第三,UDP校验和不计算或计算错误。虽然很多接收端不校验UDP校验和,但PC的某些网络驱动会直接丢弃校验错误的包。
verilog-ethernet里eth_udp_tx模块会自动处理这些字段,但如果你参考它的代码做二次开发,一定要理解这些细节。
5.3 如何用Wireshark定位“假通”
我在测试时遇到过一个很有意思的场景:PC用ping命令能ping通FPGA,但UDP数据死活不通。表面上看“网络是通的”,但实际上ping走的是ICMP协议,它和UDP的帧结构完全不同。很多FPGA工程只实现了ICMP应答模块,并没有实现UDP解析。所以ping通只能证明MAC层和IP层的接收路径是好的,不能证明UDP层能处理数据。
用Wireshark抓包后你会发现,PC发出的UDP包确实到达了网卡,FPGA也收到了,但FPGA没有回应。问题大概率出在eth_udp_rx的目的端口匹配或者ip数据包的校验上。所以不要迷信“能ping通”,要以UDP回环实验结果为准。同样,如果你看到FPGA发送的UDP包在Wireshark里标着“checksum offload”或者“incorrect checksum”,那很可能是PC网卡的硬件事务卸载功能在干扰,并不代表网络真的有问题。
最后的几点感受
verilog-ethernet这个工程让我真正理解了“协议分层”在硬件上怎么落地。之前用单片机做以太网时,TCP/IP协议栈是软件库帮你搞定的,你不需要考虑MAC帧的细节。但在FPGA里,所有东西都得用逻辑描述出来,这逼着你去读协议文档、去抠每一个时序细节。也正是这个过程,让我对以太网的理解比之前任何时候都更扎实。
如果你也是刚开始学FPGA以太网,我建议第一步不要直接上TCP,先从UDP回环做起,把MAC、UDP、RGMII这条链路打通了,再慢慢加ARP、DHCP、TCP这些进阶功能。verilog-ethernet的源码写得非常规整,遇到不懂的模块,直接打开源代码跟着数据流看,比看任何教程都管用。
另外说一个后来对我帮助很大的习惯:每次做以太网相关的实验,我会先在PC端抓一次包,记下正常的收发时序和帧内容,然后再改FPGA代码。这个“黄金样本”能让你在改代码出错时快速定位是环境变化还是逻辑错误,省下不少排查时间。