FPGA驱动DM9000A实现UDP协议栈:从总线时序到校验和避坑指南
简介:这份资源面向FPGA与嵌入式网络开发的学习者,提供基于DM9000A千兆以太网控制器、在FPGA上实现UDP通信的参考工程,可用于视频流或低速数据的网络收发实验,帮助理解MAC/PHY控制逻辑、UDP协议栈封装解封装及驱动交互流程。压缩包共961个文件、约7.2MB,以Quartus工程文件(qpf、qsf、sof、pof)、SOPC系统配置(sopcinfo、ptf、sopc)、Verilog源码(v)、C与头文件(c、h)、脚本与工程配置(tcl、sh、mk、xml)以及大量编译中间产物和日志为主,完整保留了从工程搭建到综合下载的目录结构。已有246人学习下载,适合作为千兆网UDP传输的入门框架,读者可据此梳理DM9000A初始化、数据收发与错误检测的实现思路,并在此基础上按实际场景修改优化,同时注意UDP丢包与乱序问题。
1. 一块网卡芯片和一片FPGA之间,到底发生了什么
手里有一份 DM9000A 的 FPGA UDP 代码,第一次打开工程的时候我其实有点懵:以太网协议栈那么厚一层东西,怎么就被塞进一片没有操作系统的 FPGA 里了。后来把收发链路一段段拆开才明白,这套代码干的事情很具体——用 FPGA 直接驱动 DM9000A 这颗以太网控制芯片,在硬件层面完成 UDP 报文的组包、发送、接收和解析,中间不经过任何 CPU 和软件协议栈。它适合两类人:一类是想搞懂以太网 MAC 层到底怎么在时序逻辑里跑起来的数字前端开发者,另一类是要在板子上做低延迟数据回传、又不想背一个软核处理器的工程人员。千兆网这个词经常被挂在嘴边,但这套代码真正的价值不在速率数字,而在于它把「PHY 芯片寄存器配置、SRAM 读写时序、UDP 校验和计算、跨时钟域数据搬运」这几件容易翻车的事,全部摊开写成了可综合的 Verilog。
2. 架构拆解:从 PHY 到 UDP 的四级流水
2.1 整体数据通路长什么样
这套工程的核心思路是「芯片管物理层,FPGA 管协议层」。DM9000A 内部集成了 MAC 和 PHY,对外是 RJ45 网口,对内是一组 SRAM 风格的并行总线接口。FPGA 要做的,是通过这根总线把芯片内部的收发缓冲区当成一块外部存储器来读写,然后在读写的过程中把以太网帧头和 UDP 头拼上去或者剥下来。
整条发送通路大致是这样分级的:上层应用数据先进入一个发送 FIFO,组帧模块从 FIFO 取数据,依次写入前导码、目的 MAC、源 MAC、类型字段、IP 头、UDP 头,再写入载荷,最后写 CRC;组好的帧通过总线时序控制模块,按 DM9000A 要求的读写时序一个字节一个字节送进芯片的发送 SRAM;芯片自己完成线路编码和发送。接收方向反过来:芯片收到帧后放进接收 SRAM,FPGA 轮询芯片的中断或状态寄存器,发现有包就启动读时序,把字节流搬进接收 FIFO,解析模块再逐字段比对目的 MAC、协议类型、目的端口,命中就把载荷交给上层。
这里有个设计取舍值得说清楚。常见做法是用一个状态机把「组帧」和「总线写」串起来做,省资源但吞吐受限;这套代码把它们拆成两个并行模块,中间用 FIFO 解耦,好处是总线时序可以跑满,坏处是 FIFO 深度不够时会丢包。我一般会把发送 FIFO 深度设成至少两个最大帧长,也就是 2×1518 字节,取 4096 深度比较稳妥。
2.2 目录结构和模块职责
工程按功能切分,典型目录结构如下(不同工程文件名会有出入,但分层基本一致):
dm9000a_udp_fpga/ ├── rtl/ │ ├── dm9000a_top.v // 顶层,例化各子模块并连线 │ ├── dm9000a_bus_ctrl.v // 总线读写时序控制,核心中的核心 │ ├── dm9000a_init.v // 上电寄存器配置序列 │ ├── udp_tx.v // UDP 发送组帧 │ ├── udp_rx.v // UDP 接收解析 │ ├── crc32_gen.v // 以太网 FCS 校验生成 │ ├── checksum.v // IP 头与 UDP 校验和 │ └── fifo_generator/ // 收发 FIFO,IP 核或手写均可 ├── sim/ │ ├── tb_dm9000a_top.v // 顶层仿真激励 │ └── dm9000a_model.v // 芯片行为模型,仿真必备 ├── constr/ │ └── pin.xdc // 引脚与时钟约束 └── doc/ └── reg_map.md // 寄存器地址与位定义说明dm9000a_bus_ctrl.v是整份代码里最需要逐行看的地方,因为 DM9000A 的读写时序对建立保持时间有明确要求,写错一个时钟周期的偏移,表现就是「仿真全对、上板收不到包」。dm9000a_init.v则是一段上电后必须执行的寄存器配置序列,漏配任何一个寄存器,芯片可能根本不往外发数据。
2.3 总线时序控制:一个字节怎么写进芯片
DM9000A 的总线接口是地址线和数据线复用的思路,通过 CMD 引脚区分「写地址」和「写数据」两个阶段。下面这段是写一个字节的典型实现,我按自己的习惯加了注释:
// 向 DM9000A 指定地址写入一个字节数据 // addr : 芯片内部寄存器/缓冲区地址 // din : 待写入的字节 task dm9000a_write_byte; input [7:0] addr; input [7:0] din; begin cs_n = 1'b0; // 片选拉低,选中芯片 cmd = 1'b1; // CMD=1 表示当前总线周期是地址周期 addr_bus = addr; // 地址送上总线 @(posedge clk); // 保持一个时钟,满足地址建立时间 cmd = 1'b0; // CMD=0 切换到数据周期 data_bus = din; // 数据送上总线 wr_n = 1'b0; // 写使能有效 @(posedge clk); // 写脉冲宽度至少一个时钟 wr_n = 1'b1; // 撤销写使能 cs_n = 1'b1; // 释放片选 @(posedge clk); // 周期间隔,避免背靠背写时序违规 end endtask逻辑上分三步:先给地址、再给数据、最后给写脉冲。参数里addr是芯片内部地址,写发送缓冲区时通常从0x00开始递增;din是当前字节。这里最容易踩的坑是wr_n的脉宽——如果系统时钟是 50MHz,一个周期 20ns,而芯片手册要求写脉冲最小 10ns,看起来够,但加上引脚延迟和布线 skew 就悬了。稳妥做法是写脉冲保持两个时钟周期,代价是吞吐减半,但换来的是上板必过。另外cs_n释放后那个空周期不能省,背靠背连续写会让芯片内部状态机来不及复位。
2.4 初始化序列:上电后必须先做的几件事
DM9000A 上电后处于一个默认状态,必须通过寄存器配置才能正常工作。典型序列包括:软复位、设置网络控制寄存器(NCR)使能收发、写物理地址寄存器(PAR)设置 MAC 地址、写中断屏蔽寄存器、清接收状态。下面是一个精简版:
// 上电初始化状态机(节选关键步骤) localparam S_RESET = 3'd0, S_NCR = 3'd1, S_PAR = 3'd2, S_IMR = 3'd3, S_DONE = 3'd4; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_RESET; init_cnt <= 0; end else begin case (state) S_RESET: begin dm9000a_write_byte(8'hFF, 8'h01); // NCR 软复位 state <= S_NCR; end S_NCR: begin dm9000a_write_byte(8'h00, 8'h03); // 使能收发 state <= S_PAR; end S_PAR: begin // 依次写入 6 字节 MAC 地址到 0x10~0x15 dm9000a_write_byte(8'h10 + init_cnt, mac_addr[47 - init_cnt*8 -: 8]); if (init_cnt == 5) begin init_cnt <= 0; state <= S_IMR; end else begin init_cnt <= init_cnt + 1; end end S_IMR: begin dm9000a_write_byte(8'hFF, 8'h81); // 使能接收中断 state <= S_DONE; end S_DONE: begin // 初始化完成,拉高 ready 供上层使用 end endcase end endS_RESET里往0xFF地址写0x01是触发芯片软复位,这一步不能省,否则芯片可能停在未知状态。S_NCR写0x00地址的0x03是同时置位收发使能位。S_PAR循环六次写 MAC 地址,注意字节序——芯片要求低字节在前还是高字节在前,不同批次手册写法有差异,我一般先用抓包工具确认源 MAC 显示是否正确,再决定要不要翻转。S_IMR使能接收中断,这样接收侧就不用死等轮询,可以走中断触发读包,省总线带宽。
3. UDP 组帧与校验:两个最容易算错的地方
3.1 发送组帧的字段顺序
UDP 报文在以太网帧里的位置是固定的:7 字节前导码 + 1 字节 SFD + 6 字节目的 MAC + 6 字节源 MAC + 2 字节类型(0x0800 表示 IPv4)+ 20 字节 IP 头 + 8 字节 UDP 头 + 载荷 + 4 字节 FCS。组帧模块本质上就是一个按顺序输出常量和变量的状态机。下面这段是 UDP 头部的组装:
// UDP 头部组装:源端口、目的端口、长度、校验和 // 长度字段 = UDP 头 8 字节 + 载荷长度 always @(posedge clk) begin if (tx_start) begin udp_hdr <= { src_port, // 16 bit 源端口 dst_port, // 16 bit 目的端口 (payload_len + 16'd8), // 16 bit UDP 总长度 16'h0000 // 校验和先填 0,后续计算回填 }; end end字段顺序不能错,源端口和目的端口各 16 位,长度字段是「UDP 头 + 载荷」的总字节数,不是载荷长度,这个差一错误非常常见。校验和先填 0,等整个报文(包括伪首部)算完再回填,回填位置在 UDP 头偏移 6 字节处。
3.2 校验和计算:伪首部不能漏
UDP 校验和计算需要包含一个 12 字节的伪首部:源 IP、目的 IP、保留字节、协议号、UDP 长度。很多人只对 UDP 头和载荷求和,结果接收端全部丢弃,抓包看就是「校验和错误」。实现上通常用一个 16 位累加器做反码求和:
// 16 位反码求和,输入流按 16 位对齐 // 注意:奇数字节载荷需要补零到偶数字节 always @(posedge clk) begin if (sum_en) begin sum <= sum + {8'h00, data_byte}; // 简化示意,实际按 16 位拼接 end end // 最终校验和 = ~(sum 的高 16 位 + sum 的低 16 位) assign checksum_out = ~(sum[31:16] + sum[15:0]);参数说明:data_byte是逐字节输入,实际实现里要先用一个移位寄存器把两个字节拼成 16 位再累加;sum位宽要留够 32 位,因为累加过程中可能溢出,最后做一次折叠相加。奇数字节载荷末尾补零这个细节,仿真时如果不补,校验和算出来和抓包工具对不上,排查起来很费时间。
3.3 接收解析:三重过滤
接收侧不是收到什么都往上交,一般做三层过滤:目的 MAC 是否匹配本机或广播地址、以太网类型是否为 0x0800、UDP 目的端口是否在监听列表里。三层都过才把载荷写进接收 FIFO,否则直接丢弃并清空芯片接收缓冲区。这个「清空」动作不能忘,否则芯片接收 SRAM 满了之后新包全部丢失,表现就是「跑一段时间后彻底收不到数据」。
4. 避坑排查:上板收不到包时先看这五条
4.1 仿真通过但上板无数据
现象:testbench 里收发全对,综合下载后抓包工具一个包都看不到。原因多半是引脚约束或时钟约束缺失,综合工具把关键路径优化掉了,或者引脚分配和实际板子对不上。解决:先确认pin.xdc里时钟引脚、复位引脚、总线引脚全部约束到位,再用示波器量一下芯片的 CS 和 WR 引脚有没有波形,没有波形就是约束或顶层连线问题。
4.2 能收到包但校验和错误
现象:抓包工具显示 UDP 校验和错误,接收端全部丢弃。原因通常是伪首部漏算,或者奇数字节载荷没补零。解决:把伪首部的 12 字节单独打印出来核对,再检查载荷长度是否为奇数,奇数时在累加前补一个 0x00。
4.3 连续发送时丢包
现象:单包发送正常,连续发几十包后开始丢。原因是发送 FIFO 深度不足,或者总线写时序里周期间隔被优化掉了。解决:把发送 FIFO 加深到 4096,并在每次写操作后强制插入一个空周期,用@(posedge clk)显式保证。
4.4 MAC 地址字节序反了
现象:抓包看到源 MAC 是乱序的,比如期望AA:BB:CC:DD:EE:FF实际显示FF:EE:DD:CC:BB:AA。原因是写 PAR 寄存器时字节序和芯片要求相反。解决:在初始化序列里把 MAC 地址数组倒序写入,或者用抓包结果反推正确顺序后固定下来。
4.5 接收一段时间后彻底停摆
现象:刚上电正常,跑几分钟后接收中断不再触发。原因是接收缓冲区满后没有正确清除接收状态寄存器。解决:每次读完一个包,必须往接收状态寄存器写清除位,并在读完后检查芯片的接收长度寄存器是否为 0,非 0 说明还有残留数据没读完。
5. 二次开发:把固定端口改成可配置,再谈性能
这套代码拿来直接用的场景其实有限,因为端口、IP、MAC 大多是写死的常量。真正上手改,第一步是把这些常量抽成寄存器或者参数,让上层能动态配置。我一般会在顶层加一组配置寄存器,通过一个简单的寄存器总线写入,这样换一个应用场景不用重新综合。
// 可配置参数组,替代原来的 localparam 常量 reg [15:0] cfg_src_port; reg [15:0] cfg_dst_port; reg [31:0] cfg_src_ip; reg [47:0] cfg_mac_addr; // 配置写入口:addr 译码,wdata 写入 always @(posedge clk) begin if (cfg_wr) begin case (cfg_addr) 8'h00: cfg_src_port <= cfg_wdata[15:0]; 8'h01: cfg_dst_port <= cfg_wdata[15:0]; 8'h02: cfg_src_ip <= cfg_wdata; 8'h03: cfg_mac_addr <= {cfg_mac_addr[23:0], cfg_wdata[7:0]}; endcase end end改造时有个顺序建议:先改端口和 IP,验证收发正常;再改 MAC 地址,用抓包确认字节序;最后才动 FIFO 深度和时序参数。每改一步都跑一次上板验证,不要一次改完再调,否则出问题根本定位不到是哪一处。
性能方面,这套架构的瓶颈在总线写时序。如果每个字节写操作占三个时钟周期,50MHz 时钟下理论吞吐大约 16MB/s,离千兆线速差得远。想提速有两条路:一是把总线时钟提上去,但要先确认芯片手册支持的最高总线频率;二是改成突发写模式,如果芯片支持的话,一次地址周期后连续写多个数据字节,能把有效吞吐拉高不少。这两条路都需要重新核对时序余量,我一般会先用仿真跑一遍最坏情况下的建立保持时间,再上板。
还有一个容易被忽略的点:跨时钟域。如果上层数据来自另一个时钟域,FIFO 的读写时钟必须严格分开,且格雷码指针同步不能省。我见过有人图省事直接用同一个时钟,结果综合后时序报告一片红,上板偶发丢包,查了两天才发现是 CDC 问题。从那以后我每次接这类工程,第一件事就是把所有跨时钟域路径列出来,逐个确认有没有同步器,没有就补上,再谈功能。希望帮到你。
本文还有配套的精品资源,点击获取