1. 为什么先把UDP跑通再谈其他
我一直觉得,FPGA开发这件事,卡住大多数人的不是那些玄乎其玄的算法,也不是什么高深的时序收敛,而是数据到底怎么从板子出去,又怎么从外面进来。前面几篇我们聊了按键、LED、串口、FMC通信这些基础模块,到了这一篇,终于要碰一个稍微有点“网络味”的东西了——UDP模块的代码设计。
在做FPGA图像处理、高速数据采集这类项目时,UDP几乎是绕不开的选项。原因很简单:TCP虽然可靠,但状态机复杂、内存开销大、回包确认机制在FPGA里写起来非常痛苦;而UDP协议栈足够简单,处理延迟低,带宽利用率高,特别适合做实时性要求高的数据传输。如果你把FPGA当成一个高速数据采集前端,需要把ADC采到的数据、或者摄像头过来的图像数据丢给上位机处理,UDP往往就是那个“性价比最高的搬运工”。
这篇博客面向的读者,是那种“近似0基础”但已经跟着系列文章走过来的朋友。你不需要懂Linux协议栈,不需要会写C#上位机,甚至不需要太深的Verilog功底,只要会写简单的状态机,能看懂时序图,就能跟着我把一个能用的UDP模块搭起来。我会尽量把每一步为什么这么做讲清楚,而不是直接甩一段代码让你去抄。
2. 动手之前,把UDP帧格式彻底吃透
2.1 帧结构拆解:你要发出去的东西到底是什么样
很多新手上来就找代码,找到一段UDP发送的Verilog就粘进去,结果上板之后上位机收不到数据,也不知道从哪里排查。我建议你先花半小时把UDP帧格式搞清楚,后面写代码会顺畅很多。
UDP数据在以太网上传输时,实际是分层的。你要发一个UDP数据报,它会被塞进IP数据包里,IP数据包又会被塞进以太网帧里。如果只从FPGA侧看,你得在发送端把这三层东西手动组出来,也就是一个完整的以太网帧长这样:
| 字段 | 长度(字节) | 内容说明 |
|---|---|---|
| 前导码 | 7 | 同步用,一般由PHY芯片自动处理 |
| 帧起始定界符 | 1 | 0xD5,PHY自动处理 |
| 目的MAC地址 | 6 | 对端设备MAC |
| 源MAC地址 | 6 | 本板MAC |
| 以太网类型 | 2 | 0x0800表示IPV4 |
| IP头 | 20 | 版本号、总长度、协议号、源IP、目的IP等 |
| UDP头 | 8 | 源端口、目的端口、UDP长度、校验和 |
| UDP数据 | N | 你要传的有效数据 |
| FCS | 4 | 帧校验序列(CRC32),一般由MAC侧处理 |
看到这里你会发现,FPGA发送UDP时,MAC层地址和FCS其实可以交给MAC核或者PHY芯片去处理,但IP头和UDP头必须自己组。也就是说,你真正要关心的,是从“目的MAC地址”到“UDP数据”这一整段,这段也叫“MAC帧”。
2.2 为什么UDP校验和可以填0,但CRC必须算对
这里有个特别容易让新手混淆的点:UDP头里有一个校验和字段,IP头里也有一个校验和字段,很多教程说“UDP校验和为0表示不校验”,于是有人就顺手把所有校验和都填0,结果发现包发出去了,上位机也能收到,就以为万事大吉了。
实际上,UDP和IP的校验和是否填0,要看你的使用场景。在以太网这种本身就带CRC32链路校验的环境里,UDP校验和填0完全合法,Windows和Linux的socket默认也能接收。但如果你将来要跨网段路由,或者某些路由器做了严格校验,校验和为0的包可能被丢掉。所以我的建议是:IP头校验和老老实实算,UDP校验和可以先填0跑通功能,后面有空再补上。
FCS(帧校验序列)则由MAC核的发送引擎自动追加,不需要你在用户逻辑里自己算。但前提是你用的是Xilinx或Altera官方的MAC IP核,并且配置了自动生成FCS。如果你完全用纯逻辑做RGMII接口而没有经过MAC核,那CRC32就得自己算,这个我后面会单独说。
3. UDP发送模块设计与实现
3.1 顶层模块划分与时钟复位方案
我第一次写UDP发送模块时,觉得只要写一个发送状态机把数据搬出去就行,结果把模块写得又臭又长。后来重构了几版,总结出一个比较清晰的模块划分方式,先给你看一下:
udp_tx_top:UDP发送顶层模块,负责对外接口与内部子模块例化udp_tx_ctrl:UDP发送控制状态机,负责组帧与状态跳转udp_tx_data:用户数据缓冲RAM,负责缓存上位机写入的待发送数据udp_crc32:CRC32计算模块(如果使用MAC核自动FCS则不需要)
时钟方案上,我用的是125MHz的PHY接口时钟。如果PHY工作在1000Mbps,RGMII接口的时钟是125MHz,DDR双边沿采样。如果你的板子PHY没有提供125MHz时钟,也可以用自己的PLL从50MHz倍频上去。
复位方案我强烈建议用异步复位、同步释放,避免单纯异步复位带来的亚稳态问题。这个在时序收敛上不一定看得到明显区别,但在长时间运行的稳定性上有实打实的影响。
3.2 发送状态机到底怎么拆才不乱
UDP发送状态机是这整个模块的灵魂。我见过很多新手一上来就把整个UDP帧的发送放在一个状态机里,状态数量多到二十几个,信号满天飞,最后查错查到头秃。
我自己现在用的是“三段式状态机+数据计数”的方式,核心思路很简单:状态机只负责控制帧头各字段的依次发送,数据部分用单独的计数器控制。帧头发送完了,数据阶段按地址从RAM里读数据,读多少个字节由你这次发送的长度决定。
举个具体的例子,发送状态机状态划分如下:
localparam IDLE = 4'd0; // 空闲,等待发送请求 localparam PRE_MAC = 4'd1; // 发送目的MAC(6B) + 源MAC(6B) + 类型(2B) localparam PRE_IP = 4'd2; // 发送IP头(20B) localparam PRE_UDP = 4'd3; // 发送UDP头(8B) localparam SEND_DATA = 4'd4; // 发送有效数据 localparam WAIT_END = 4'd5; // 等待FCS处理完成你可能会问:为什么IP头不拆成好几个状态?因为IP头的20个字节在发送时是连续输出的,拆得越细,状态机越繁琐,出错概率越大。这里的关键是:状态机只管整段,不管逐字节,逐字节操作全部用计数器实现。
那么23个字节怎么一次发出去?方法是状态机进入PRE_IP后,启动一个计数器,从0数到19,每个时钟周期根据计数器当前值去查一张IP头部的常量表(用case语句实现),把对应字节放到数据总线上。这样一个状态就能搞定20个字节的输出,代码量少,逻辑还清晰。
3.3 用户数据缓冲RAM怎么设计
数据缓冲这块,有一个新手很容易踩的坑:直接用FIFO缓存,然后在发送数据阶段一边从FIFO读一边往MAC发,结果FIFO读空的时候数据总线出现了空拍,帧就断了。
UDP帧是一个连续的数据流,中途不能出现空闲周期,一旦出现空隙,MAC核会认为帧结束,后面对端解析必然出错。所以我更推荐用双口RAM做缓冲,而不是FIFO。
具体做法是:上位机(或者你FPGA内部的逻辑)先把待发送数据写入RAM的某个地址区间,写完以后给udp_tx_ctrl一个发送请求信号,状态机进入发送流程后,从RAM里按地址顺序把数据读出来发出去。RAM的读地址在每个数据有效的时钟周期递增,直到达到你设置的发送长度减1。
这样做的好处是,RAM的数据在发送过程中是稳定存在的,不会出现FIFO读空的问题。坏处是你要自己在逻辑里管理“写入完成”和“读取完成”这两个时序点。我一般用一个tx_req信号触发发送,tx_done信号表示发送完成,写侧的逻辑在检测到tx_done后再写入下一包数据。
还有一个细节:RAM的读延迟。如果你的RAM IP核配置了输出寄存器(Output Register),读地址给出去之后,数据要过一拍甚至两拍才出来。这时候你发数据的时序就要把读延迟算进去,否则地址变了数据还没跟上。如果不确定,先把RAM的输出寄存器关掉,走最简单的组合逻辑输出模式,等通了再优化性能。
3.4 发送模块的关键代码逻辑
为了让你有一个直观的参考,我贴一段发送状态机中“UDP头发送”部分的简化代码。注意这里为了排版做了删减,实际工程里还要加上字节计数、输出数据选择等逻辑。
// UDP头发送: 源端口(2B) + 目的端口(2B) + 长度(2B) + 校验和(2B) // 其中UDP长度 = 8 + 数据长度 always @(*) begin case (udp_cnt) 4'd0: data_out <= src_port[15:8]; 4'd1: data_out <= src_port[7:0]; 4'd2: data_out <= dst_port[15:8]; 4'd3: data_out <= dst_port[7:0]; 4'd4: data_out <= udp_length[15:8]; 4'd5: data_out <= udp_length[7:0]; 4'd6: data_out <= 8'd0; // 校验和高字节,先填0 4'd7: data_out <= 8'd0; // 校验和低字节 default: data_out <= 8'd0; endcase end这段代码的逻辑很简单:udp_cnt在状态机进入PRE_UDP时从0开始递增,每周期加1,数据总线根据计数器选择对应字节输出。这里udp_length不是固定的,要根据外部传入的数据长度实时计算:udp_length = 8 + tx_data_len。
3.5 一个关键信号:发送请求与发送完成的握手
UDP发送模块和外部逻辑之间,最核心的接口就是“请求-完成”握手。我在实际项目中通常这样定义:
tx_start:外部逻辑拉高一个周期,请求发送一帧UDP数据tx_len[15:0]:本次发送的有效数据字节数,最大不超过1472(以太网MTU 1500减去IP头20字节再减去UDP头8字节)tx_done:发送完成,拉高一个周期
握手时序上,我碰到过一个问题:上位机连续高速发数据时,外部逻辑在上一帧还没发完时又拉高了tx_start,导致状态机IDLE阶段被重复触发,帧数据错乱。解决办法是加一个tx_busy信号,外部逻辑只有在tx_busy == 0时才能发起新的发送。这个tx_busy在状态机进入第一个非IDLE状态时拉高,回到IDLE时拉低。
4. UDP接收模块设计与实现
4.1 为什么要接收,接收模块要做什么
有些项目只需要FPGA往电脑发数据,不需要接收,但大多数实际场景要求双向通信:上位机发一个配置参数过来,FPGA收到后改变工作模式;或者上位机发一个“开始采集”的命令,FPGA才开始回传数据。所以一个完整的UDP模块,接收侧是必须的。
接收模块做的事情可以拆成三步:第一,从MAC核的接收通道拿到有效的以太网帧数据;第二,解析以太网帧头,识别出IPV4类型;第三,解析IP头,识别出UDP协议;第四,剥掉以太网头、IP头、UDP头,把有效载荷交给用户逻辑。
用代码实现时,这个“识别-剥头”的过程是一个接收状态机。需要注意的关键点在于:数据什么时候有效,以及怎么根据头部的长度字段判断有效数据从哪里开始。
4.2 接收状态机的状态划分
接收状态机相比发送状态机要简单一些,因为接收方向的数据是外部进来的,时序由MAC核给,你只需要跟着数据有效的节奏走:
localparam RX_IDLE = 3'd0; // 等待帧开始 localparam RX_MAC_HDR = 3'd1; // 接收MAC头(14B) localparam RX_IP_HDR = 3'd2; // 接收IP头(20B) localparam RX_UDP_HDR = 3'd3; // 接收UDP头(8B) localparam RX_DATA = 3'd4; // 接收有效数据 localparam RX_CHECK = 3'd5; // 检查结束和发送状态机一样,每个状态内部用一个计数器控制接收的字节数,计数器到达预定值就跳转到下一个状态。
这里有个经验:在RX_MAC_HDR状态中,要判断接收到的以太网类型字段是否为0x0800。如果不是(比如是ARP帧,类型是0x0806),那你应该直接跳到RX_CHECK,把整个帧丢弃,而不是傻乎乎地把ARP的数据当成IP数据处理。同样,在RX_IP_HDR状态中,判断IP头里的协议号是否为17(UDP协议号),不是就丢弃。
4.3 接收侧的数据缓存策略
接收侧的数据缓存,很多人的第一反应是“每收到一个字节就往FIFO里写一个字节”。这个做法本身没问题,但要注意一个问题:你无法提前知道这包数据有多长。
UDP头里有一个UDP Length字段,这个字段的值包含了UDP头(8字节)加数据长度。也就是说,你在接收UDP头的时候就能算出来后续还有多少数据要收。但问题是,当你把数据往FIFO写的时候,如果同时有别的逻辑在读这个FIFO,那么数据到达和读走的时机就会耦合在一起,容易出错。
我常用的方案是:接收侧数据先写入一个双口RAM,等一整帧收完了,再触发用户逻辑去读这帧数据。这样做的好处是:接收和后续处理之间有一道天然的缓冲隔离,帧与帧之间不会相互覆盖。双口RAM的写地址在接收数据时递增,写完一帧后把当前写地址记录下来作为“数据帧结束标志”,用户逻辑检测到新帧后按地址区间读取。
4.4 要不要做CRC校验
如果使用的是MAC核,接收侧FCS默认是自动剥离并校验的,如果CRC错误,MAC核会报错或者干脆不把数据发给用户逻辑。所以你在用户逻辑里通常不需要自己算接收方向的CRC32。
但我遇到过一种情况:FPGA直接连PHY芯片,没有使用MAC核,用纯逻辑实现了RGMII接口。这种情况下,接收方向拿到的原始数据里包含FCS 4字节,需要自己校验。不要急着算CRC,建议先用Wireshark抓包确认帧的完整性和对齐方式,再看CRC算法端口的实现是否正确。很多时候“CRC校验失败”其实是字节序没对齐,数据整体错位了,不是CRC算法本身写错了。
5. ARP应答模块:让上位机找得到你
5.1 为什么没有ARP,你的UDP包发不出去
很多新手写好了UDP发送模块,兴冲冲地上板测试,结果上位机软件那边一直显示“接收不到数据”。排查半天,发现是ARP没处理。
简单解释一下ARP是干什么的:以太网通信靠的是MAC地址寻址,但你的上位机软件只知道FPGA的IP地址(比如192.168.1.10),不知道这个IP对应的MAC地址是什么。于是上位机在发送UDP数据包之前,会先广播一个ARP请求:“谁的IP是192.168.1.10?把你的MAC地址告诉我。”如果你的FPGA不回应这个ARP请求,那上位机就永远不知道把UDP包发到哪个MAC地址去。
所以,要想让UDP通信能通,你必须先把ARP应答做了。这件事在纯软件环境下是操作系统自动处理的,但在FPGA里,没人帮你处理,必须自己写逻辑。
5.2 怎么用最少的代码实现ARP应答
ARP应答模块的核心逻辑是:收到ARP请求后,判断请求里的目标IP地址是不是自己的IP,如果是,就回一个ARP应答报文,把自己的MAC地址告诉对方。
ARP请求和应答的报文结构是固定的,28字节,算上以太网帧头14字节,一共42字节。FPGA这边只需要把请求里对方的MAC地址、IP地址取出来,交换位置,填上自己的MAC和IP,回发出去即可。
我实现ARP时,没有单独做很复杂的状态机,因为ARP报文短且固定,我用一个小状态机加上一个“以太网头解析”的状态就够了。核心判断有两点:帧类型是否为0x0806(ARP),以及ARP请求里的目标IP是否等于本机IP。两个条件都满足,立刻组装ARP应答帧发出去。
有一个坑:很多上位机软件(比如我自己常用的网络调试助手)在打开的时候只会发一次ARP请求,如果FPGA当时正在忙,没来得及回复,那么后面可能就一直不通。所以ARP应答模块必须在系统里拥有“最高优先级”,不论当前UDP发送状态机在干什么,只要检测到有效的ARP请求,就要立刻打断当前发送,转去回ARP应答。
具体做法是:ARP应答逻辑单独写成一个小模块,它的发送请求信号优先级高于UDP发送请求。也就是说,发送仲裁逻辑在处理“到底发什么帧”的时候,ARP请求来了,直接先发ARP应答,发完再恢复之前的UDP发送。
6. 上板调试与验证:从零到能传数据
6.1 用什么工具验证UDP通信
代码写完了,最终要上板验证。我自己的调试工具链一般是三层:板级调试(ILA)→ 软件调试(Wireshark)→ 回环测试(网络调试助手)。
先说ILA(集成逻辑分析仪)。在UDP发送模块的关键信号上加上ILA探针,包括tx_state、tx_cnt、data_out、tx_done等,用ILA抓到真实的波形,确认状态机跳转和字节计数是否符合预期。这一步可以帮你把“FPGA侧逻辑错误”和“网络通信错误”隔离开来。
然后是Wireshark抓包。把FPGA的网口接到电脑(或者通过交换机),电脑上打开Wireshark,选择对应的网卡开始抓包。如果你能从抓包里看到FPGA发过来的UDP包,并且包结构解析正常,那恭喜你,FPGA侧基本没问题了。如果抓不到包,优先查PHY芯片的链路状态、RGMII时序、MAC核配置这三样。链路状态可以通过PHY芯片的状态寄存器读取,也可以通过查看网口指示灯判断(但注意,链路指示灯亮不代表数据收发正常,只代表物理层协商成功了)。
最后是网络调试助手。Wireshark能看到包结构,但看不到业务数据的正确性。用网络调试助手(或者你自己写的Python脚本收包),连续接收FPGA发来的数据,比对数据内容是否为预期序列。比如FPGA发送0x00到0xFF循环递增的数据,上位机接收后检查是否有跳变、重复、错乱。
6.2 常见问题排查速查表
我把做UDP模块时碰到过的高频问题整理成一个表,方便你对照排查:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| Wireshark完全抓不到包 | PHY芯片未完成初始化 | 读取PHY寄存器确认link up,检查复位时序和配置引脚 |
| 抓到的包长度不对 | 发送状态机提前结束 | ILA抓取发送状态,确认字节计数是否准确 |
| 上位机收不到但Wireshark能抓到 | 目的MAC地址填错导致交换机不转发 | 检查目的MAC地址是否对端网卡的MAC |
| 收到数据但内容错乱 | RAM读地址与数据延迟不匹配 | 检查双口RAM读延迟配置,调整读地址时序 |
| 发一个包要等很久 | ARP表未建立 | 确认ARP应答模块是否正确回复,用Wireshark看ARP交互 |
| 千兆下频繁丢包 | 跨时钟域处理不当 | 检查MAC核接口时钟、FIFO的异步处理是否正确 |
6.3 连续高速发送时怎么保证不丢数据
UDP模块做到能收发,只是第一步。真正项目中,你往往需要FPGA以比较高的速率连续往电脑传数据。比如我做过的一个图像采集项目,FPGA以100MB/s的速率向电脑传输图像数据,持续几十分钟不能丢包。这个要求听起来简单,实际做起来有几个坑:
第一个坑是CPU处理不过来。如果你的电脑上运行的是弱鸡上位机软件,单线程接收上千兆数据,很容易因为socket接收缓冲区溢出导致丢包。解决思路有三条:上位机开大接收缓冲区(在Windows下可以用setsockopt设置SO_RCVBUF);上位机开多线程处理,一个线程收包一个线程处理数据;降低发送速率,加一个简单的发送节流机制,比如每发128个包等几微秒。
第二个坑是FPGA内部的回压机制缺失。如果你的FPGA内部数据产生速度大于发送速度,而你又没有做流控,那么RAM迟早被写满,新数据就会覆盖旧数据。这个问题在FPGA侧必须解决,不能指望上位机。我自己比较喜欢的做法是在发送模块里加一个“FIFO水位线”指示器,当缓冲区的数据量超过某个阈值时,拉高背压信号,上游模块据此减少数据产生速率。
第三个坑是MAC核的内部FIFO溢出。Xilinx的Tri-Mode Ethernet MAC核内部有发送FIFO,如果你的用户逻辑写数据的速度超过MAC核发送的速率,FIFO溢出会导致帧被丢弃。解决办法是在FIFO快要满的时候暂停写数据,或者在MAC核配置里把FIFO深度调大。
6.4 一个完整的验证流程示例
我习惯按照下面这个流程来验证UDP模块,整个流程走一遍大概两三个小时,能覆盖大部分功能点和边界情况:
第一阶段:单项功能验证。先在FPGA内部写一个简单的数据发生器,比如发送0x00到0xFF循环的256字节数据。上位机用一个简单的UDP接收脚本(Python+socket即可)接收并校验数据是否正确。
第二阶段:连续压力测试。把PC和FPGA用网线直连,FPGA持续发送数据,上位机用Python脚本连续接收,统计接收到的包数量和数据正确率。这个阶段主要检测模块在高负载下会不会出现状态机卡死、RAM溢出等问题。
第三阶段:双向通信验证。上位机向FPGA发送不同的命令字,FPGA收到命令后改变发送数据的长度、内容、速率,以此来验证接收模块和发送模块的联调是否正常。
第四阶段:跨交换机测试。把FPGA通过交换机接到电脑上,打开Wireshark观察ARP交互和UDP数据包,确认跨交换机环境下模块也能正常工作。这一步还会暴露出MAC地址、IP地址配置是否合理的问题。
7. 再说几句关于“学习路线”的大实话
UDP模块写完以后,你会发现FPGA开发中很多模块的思路是相通的:状态机负责控制流程、RAM/FIFO负责数据缓存、握手信号负责模块间协调、CRC负责数据完整性校验。这套方法论几乎可以套用到PCIe、MIPI、LVDS这些高速接口的开发中。
我个人在实际调试UDP模块时最大的体会是:网络通信类模块的调试,最大的障碍不是代码逻辑本身,而是你没法“看见”数据在线上是怎么走的。相比串口那种一个字节一个字节地看,以太网的数据速率太快,人眼根本跟不上,所以必须借助工具。ILA看波形、Wireshark看协议结构,两个工具配合使用,能把问题定位的时间从几天缩短到几个小时。
另外,如果你在学习过程中发现某个波形怎么调都不对,我建议你先停下来,去把以太网协议那几页文档重新翻一遍。很多时候你觉得代码写得没问题,其实是对协议的理解出了问题——比如字段顺序、字节序、长度的计算方式,这些细节错一个,整个包就是废的。
最后分享一个小技巧:调试UDP时,我习惯在板卡上保留一组LED作为“状态指示灯”——比如发送一帧包就翻转一次LED电平,收到一帧包也翻转一次。虽然看起来土,但在没有调试环境的情况下,它能帮你快速判断模块在不在工作,比看波形快多了。这个习惯我一直保留到现在,推荐你也可以试试。