☰
FPGA控制DM9000A以太网收发的Verilog实现与状态机设计
2026/10/15 15:26:48 网站建设 项目流程

简介:面向FPGA与嵌入式开发者的DM9000A以太网收发完整工程,基于Verilog实现MAC层控制逻辑,涵盖芯片初始化、寄存器读写、数据收发状态机、DMA与中断处理等关键环节,适合学习以太网协议栈、硬件接口设计或进行毕业设计/竞赛开发参考。资源包共71个文件,约2.53MB,以Verilog源码(.v)为核心,辅以Quartus工程文件(.qpf/.qsf)、仿真波形(.vwf)、编译报告(.rpt)、初始化代码及DM9000A数据手册、接口实物图等,目录模块清晰,便于按功能对照学习。已有627人在CSDN学习下载。压缩包内不仅包含初始化、读/写时序、PHY寄存器配置等可复用模块,还提供完整的Quartus II工程配置与下载文件(.sof/.pof),可直接用于仿真验证或上板调试。借助这一资源可快速掌握FPGA控制DM9000A进行以太网帧收发的时序设计思路,理解CRC校验、DMA传输与中断响应等实现细节,有效缩短自研以太网通信模块的开发周期。

1. 直接用 FPGA 控制 DM9000A 做以太网收发:先解决一个“接口是否合拍”的问题

“FPGA 控制 DM9000A 进行以太网数据收发的 Verilog 实现”这个标题,很多人看到的第一反应是:“不就是挂个网卡芯片吗?”但真的做过就会明白,核心工作不在 DM9000A,而在 FPGA 内部那一套寄存器读写控制器和数据搬运通路。DM9000A 是一颗 10/100M 自适应以太网控制器,对外走类 ISA 并行总线,内部有 MAC、PHY 和收发 FIFO。FPGA 要做的,是给它提供正确的复位、寄存器读写时序,然后像 CPU 一样去配置它的控制寄存器、写 MAC 地址、把要发送的帧搬进它的 TX FIFO,再从 RX FIFO 里取回收到的帧。这个方案在低成本仪器、测试板卡和教学实验里很常见,尤其适合“不要 CPU,只想用纯逻辑把网络收发跑通”的场景。动手之前先搞清楚一件事:DM9000A 和 FPGA 之间的“总线”比一般外设更挑时序,后面所有代码都要围绕这套时序展开。

2. DM9000A 并行接口与寄存器:先看懂 CMD 和数据总线再动状态机

DM9000A 给人最容易漏掉的一点:它和 FPGA 之间不是 SPI/I2C,而是一个带地址锁存风格的并行总线。虽然它不像传统 SRAM 接口那样有一整根地址总线,但多了个 CMD 引脚。CMD 为低时,数据线上传的是内部寄存器索引;CMD 为高时,数据线上才是真正的寄存器数据。也就是说,FPGA 至少要维护“地址”和“数据”两个窗口,这个窗口切换就是后面状态机的主线。

2.1 一组需要严格对齐的控制引脚:CS、IOR、IOW、CMD

常见接法里,DM9000A 的 SD[15:0] 直接连到 FPGA 的通用 IO,CMD 引脚通常由 FPGA 内部某个地址位映射,也可以直接用一根 IO 控制。CS#、IOR#、IOW# 都是低有效,RST 是高有效复位。INT 引脚是中断输出,如果 FPGA 逻辑里不想做中断,可以把它接成普通输入,靠轮询状态寄存器也能跑通,但 INT 一定要处理成“不复位、不悬空”,否则容易出现上电后收发状态不同步。

接线时最容易出问题的是数据总线方向。DM9000A 的数据线是双向的,FPGA 端必须用 inout 引脚,并且在 Verilog 里同时控制输出使能和一个寄存器缓冲,不要直接 assign dm9_data = reg_wdata,否则读数据时会和芯片输出打架。一般会把 inout 拆成三段:写数据时使能输出;读数据时把使能拉高,把输入采样到 reg_rdata;总线空闲时使能拉高,让数据线处于高阻。这个习惯要养成,因为后文所有状态机代码都依赖这个双向口处理。

还有一个容易看漏的点:DM9000A 支持 8 位和 16 位数据模式。16 位模式下,一次读写一个 word,寄存器索引和数据的低字节都在 SD[7:0] 上,SD[15:8] 在某些访问里可能是 0 或高字节。如果你把数据总线接到 FPGA 的不同管脚上,就会遇到“索引写对了,但寄存器数据高低字节错位”的问题。我一般会在顶层约束文件里把 SD[15:0] 连续排布,并且先写一个 ID 读取测试,确认 16 位模式下的字节序再继续做收发,否则后面查起来很痛苦。

2.2 16 位模式下的读写时序:一个总线周期里发生了什么

一次完整的寄存器写访问可以拆成两步。第一步,把 CMD 拉低,把寄存器索引放到数据总线上,然后给一个 IOW# 低脉冲,DM9000A 在 IOW# 上升沿锁存索引。第二步,把 CMD 拉高,把要写的寄存器值放到数据总线上,再给一个 IOW# 低脉冲,这次锁存的是数据。读访问类似,第一步锁存索引,第二步把 CMD 拉高后拉低 IOR#,FPGA 在 IOR# 低电平期间采样数据总线。

下面这段用 task 描述时序意图,方便理解窗口切换:

task write_dm9_reg; input [7:0] reg_idx; input [15:0] reg_val; begin dm9_cs_n = 1'b0; dm9_cmd = 1'b0; // 切换到索引窗口 dm9_data = {8'h00, reg_idx}; // 16位模式,索引放在低字节 dm9_iow_n = 1'b0; // 产生索引写脉冲 #100; // 保持时间,非可综合写法 dm9_cmd = 1'b1; // 切换到数据窗口 dm9_data = reg_val; #100; dm9_iow_n = 1'b1; // 数据写脉冲结束,寄存器更新 dm9_cs_n = 1'b1; end endtask

这段代码不能直接综合,因为 #100 是仿真延时,但能帮你理解时序。真正可综合的逻辑要用状态机加周期计数器,把 CS/CMD/IOW/IOR 全部用时钟沿驱动。读时序也是同样思路:第一步锁存索引,第二步把 CMD 拉高后拉低 IOR#,然后在下一个时钟沿采样 dm9_data。

时序参数要按数据手册来,不同 DM9000A 批次对最小脉冲宽度要求略有差异。我的习惯是 50MHz 系统时钟下,一个总线读或写周期给足 4 个时钟周期,其中地址窗口占 1 拍,数据窗口占 2 拍,释放占 1 拍。如果系统时钟到 100MHz,可能需要增加一个周期计数器,保证 IOW/IOR 低电平宽度不低于 10ns。这个余量比寄存器配置本身更容易帮你避开“时好时坏”的随机性问题。

2.3 寄存器地图和内存端口:那些你迟早要查的地址

DM9000A 的寄存器地址不多,但收发通路上有几个地址必须记牢。表格里列出来的是最常用的一组:

偏移名称读写属性作用
0x00NCR读写网络控制寄存器,bit0 是软复位
0x01NSR只读网络状态寄存器,收发就绪标志
0x02TXCR/TXPL读写发送控制,也兼 TX 长度低字节
0x03TXSR/TXPH读写发送状态,也兼 TX 长度高字节
0x04RCR读写接收控制寄存器,bit0 是 RXEN
0x05RSR只读接收状态寄存器
0x08-0x0DPAR0-PAR5读写本机 MAC 地址
0x2AVID只读芯片厂商 ID
0x2BPID只读芯片产品 ID
0x40MWCMD写连续写模式,把数据写入 TX FIFO
0xF0MRCMDX读内存读扩展命令,判断 RX 是否就绪
0xF1MRCMD读连续读模式,从 RX FIFO 读数据

这里最容易混淆的是 0x02/0x03。它们既是 TX 控制/状态寄存器,又承担发送长度低字节和高字节的写入功能。实际驱动里的常见做法是:发送前先连续写这两个偏移地址来设置长度,随后再写 0x02 并置 TXREQ 位来触发发送。不同参考代码可能会用不同常量名,但偏移地址基本一致。

PHY 寄存器不直接暴露在地址空间里,而是通过 0x18 到 0x1B 这一组 EPCR/EPAR/EPDR 间接访问。FPGA 里如果只想把链路层打通,PHY 配置可以交给芯片自动协商,甚至不显式去读 PHY 状态。但如果你想做链路检测,就得会用这组寄存器:先写 EPAR 指定 PHY 寄存器地址,再写 EPCR 触发读或写,再从 EPDR 取数据。初始化阶段我一般不碰 PHY,除非出现“复位后插着网线也不亮灯”的情况。

3. 用状态机把寄存器读写收进时钟域:可综合的实现骨架

寄存器读写一旦落到可综合 Verilog,就不能用前面那种 task 加延时的方法了。常见方案是写一个总线状态机,把所有控制信号在时钟沿处打一拍,再通过周期计数控制脉冲宽度。这个状态机要同时服务初始化、发送配置和接收状态读取,所以它的接口应该尽量通用:一个寄存器地址、一个写数据、一个读写请求、一个忙标志和读回数据。收发模块只是这个接口的使用者。

3.1 寄存器读写状态机:从 IDLE 到 DONE 的四种转移

核心状态可以只分四五个:IDLE、SET_CMD、DO_ACCESS、DONE。IDLE 等待请求;SET_CMD 把索引写到数据总线上,同时给一次索引写脉冲;DO_ACCESS 切到数据窗口,根据请求拉低 IOW 或 IOR;DONE 释放总线和片选信号。下面是一个简化但可综合的状态机片段:

localparam IDLE = 3'd0; localparam SET_CMD = 3'd1; localparam WR_DATA = 3'd2; localparam RD_DATA = 3'd3; localparam DONE_ST = 3'd4; reg [2:0] bus_state; reg [1:0] hold_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bus_state <= IDLE; dm9_cs_n <= 1'b1; dm9_cmd <= 1'b0; dm9_iow_n <= 1'b1; dm9_ior_n <= 1'b1; reg_busy <= 1'b0; dm9_ob_en <= 1'b0; end else begin case (bus_state) IDLE: begin if (wr_req || rd_req) begin reg_busy <= 1'b1; dm9_cs_n <= 1'b0; dm9_cmd <= 1'b0; dm9_ob_en <= 1'b1; dm9_data <= {8'h00, reg_idx[7:0]}; dm9_iow_n <= 1'b0; bus_state <= SET_CMD; end end SET_CMD: begin dm9_iow_n <= 1'b1; // 索引锁存结束 dm9_cmd <= 1'b1; // 切到数据窗口 if (wr_req) begin dm9_data <= reg_wdata; dm9_iow_n <= 1'b0; // 产生数据写脉冲 end else begin dm9_ior_n <= 1'b0; // 产生读脉冲 end hold_cnt <= 2'd0; bus_state <= (wr_req) ? WR_DATA : RD_DATA; end WR_DATA: begin hold_cnt <= hold_cnt + 1'b1; if (hold_cnt == 2'd1) begin dm9_iow_n <= 1'b1; bus_state <= DONE_ST; end end RD_DATA: begin hold_cnt <= hold_cnt + 1'b1; if (hold_cnt == 2'd1) begin reg_rdata <= dm9_data; // 在 IOR 有效期间采样 dm9_ior_n <= 1'b1; bus_state <= DONE_ST; end end DONE_ST: begin dm9_cs_n <= 1'b1; dm9_ob_en <= 1'b0; reg_busy <= 1'b0; bus_state <= IDLE; end default: bus_state <= IDLE; endcase end end

这个状态机里最值得注意两点。第一,dm9_data 是三态总线,必须有 dm9_ob_en 控制输出使能,否则读时序会和芯片输出冲突。第二,读数据采样点要放在 IOR# 仍然有效的那个时钟周期,而不是等 IOR# 已经拉高之后再采样。你可以把 hold_cnt 扩展到 8bit 来调节脉冲宽度,比如 100MHz 下加一个reg [3:0] wait_cnt,让 IOW/IOR 低电平保持 3 到 4 拍,这个余量能明显减少偶发读写错误。

另外,dm9_data在写索引和写数据两个窗口共用同一根总线,所以状态机里务必先切 CMD,再赋值数据。如果顺序反了,DM9000A 会看到“数据窗口里写的是索引”的时序,寄存器内容就乱了。这个坑在仿真里不一定会暴露,但上板十次有七八次会翻车。

3.2 初始化序列:软复位、ID 校验、MAC 和 RCR

初始化序列建议按固定顺序做:先软复位,再读芯片 ID,然后写 MAC 地址,最后开接收。复位和等待时间必须给足,否则后续访问会读到随机值。下面这段代码展示了顺序:

task init_dm9000a; begin // 1. 软复位:NCR[0] 置 1,然后清零 write_dm9_reg(8'h00, 16'h0001); delay_us(20); write_dm9_reg(8'h00, 16'h0000); delay_ms(2); // 2. 读芯片 ID,确认总线时序正确 read_dm9_reg(8'h2A, chip_vid); read_dm9_reg(8'h2B, chip_pid); if (chip_vid != 16'h0A46 || chip_pid != 16'h9000) begin init_error <= 1'b1; // 说明复位或时序有问题 end // 3. 写 MAC 地址到 PAR0-PAR5 // 每写一个字,低字节对应一个 PAR 寄存器 write_dm9_reg(8'h08, {mac[47:40], mac[39:32]}); write_dm9_reg(8'h0A, {mac[31:24], mac[23:16]}); write_dm9_reg(8'h0C, {mac[15:8], mac[7:0]}); // 4. 使能接收:RCR[0]=RXEN // 如果还要接收广播,这个配置已经够用 write_dm9_reg(8'h04, 16'h0001); end endtask

这段伪代码里 write_dm9_reg/read_dm9_reg 都对应前面的总线状态机接口。注意 MAC 寄存器的写法:PAR0-PAR5 是 6 个字节寄存器,但在 16 位模式下按字访问时,偏移地址是 0x08、0x0A、0x0C,对应 PAR0/PAR1、PAR2/PAR3、PAR4/PAR5。高低字节顺序要和你希望 MAC 在以太网帧里的排列顺序一致,否则对端看到的源 MAC 会倒序。

RCR 寄存器不建议一上来就加太多过滤位。很多文档会写RCR = 0x21之类,那是因为它开了广播和组播过滤。最省事的做法是只写0x01,让芯片接收单播、广播和标准组播,先把数据通路跑通。等收发稳定后,再根据需求关掉不需要的帧。

3.3 时钟频率和总线宽度的参数选择

这个状态机适合的参数,一个是系统时钟频率,一个是总线周期保持拍数。我一般会在模块顶部全部定义成 parameter:

module dm9000_if #( parameter CLK_FREQ = 50_000_000, parameter IO_HOLD_CYCLE = 3 )( ... );

CLK_FREQ 用于初始化时的延时计算,IO_HOLD_CYCLE 用于控制 IOW/IOR 低电平宽度。50MHz 下 IO_HOLD_CYCLE 取 2 到 3 拍就够;100MHz 下如果 IO 引脚走线较长,建议 5 拍以上。对于数据总线宽度,默认按 16 位模式写,因为一次可搬运两字节,发送和接收效率更高。如果板子硬件只连了 SD[7:0],则需要把所有 16 位访问改成连续两个 8 位访问,状态机里每个写数据阶段会多一倍步骤,初始化代码也要跟着改。不要试图用一个参数同时支持两种模式,那会让代码里的字节序判断到处都是,最终把自己绕晕。

4. 从 TX FIFO 到 RX FIFO:数据收发状态机和帧长处理

寄存器控制器跑通以后,剩下的工作就是两条数据通路:一条把 FPGA 内部要发送的帧搬到 DM9000A 的 TX FIFO,另一条把 DM9000A 收到的帧搬到 FPGA 内部缓存。很多人在这一步犯的错,是把发送和接收都用“一个大 while 循环”的思路写,结果时序一复杂,总线上读一个字节要等好几天。正确做法依然是状态机,并且让数据总线和内部缓存之间只用一拍完成读写。

4.1 发送通路:先搬数据,再写长度,最后置 TXREQ

发送一个以太网帧的常见顺序是:先把整帧数据通过 MWCMD 命令连续写到 TX FIFO,然后写入帧长度到 TXPL/TXPH,最后写 TXCR 的 TXREQ 位触发发送。帧数据从哪里来,在 FPGA 里一般是一块双口 RAM 或 FIFO,发送状态机按节拍读出来。下面是一个发送状态机的关键片段:

localparam TX_IDLE = 4'd0; localparam TX_MWCMD = 4'd1; localparam TX_DATA = 4'd2; localparam TX_LENL = 4'd3; localparam TX_LENH = 4'd4; localparam TX_TRIG = 4'd5; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tx_state <= TX_IDLE; end else begin case (tx_state) TX_IDLE: begin if (tx_valid && tx_ready) begin dm9_cs_n <= 1'b0; dm9_cmd <= 1'b0; dm9_data <= 16'h0040; // MWCMD 索引 dm9_iow_n <= 1'b0; tx_cnt <= 16'd0; tx_state <= TX_MWCMD; end end TX_MWCMD: begin dm9_iow_n <= 1'b1; // 索引锁存完成 dm9_cmd <= 1'b1; dm9_data <= tx_ram_dout; // 第一拍帧数据 tx_state <= TX_DATA; end TX_DATA: begin dm9_iow_n <= 1'b0; // 写一帧数据到 TX FIFO if (tx_cnt == tx_len - 1) begin tx_state <= TX_LENL; end else begin tx_cnt <= tx_cnt + 1'b1; // 这里下一拍从 RAM 读新数据 dm9_data <= tx_ram_dout; end end TX_LENL: begin dm9_iow_n <= 1'b1; dm9_cmd <= 1'b0; dm9_data <= {8'h00, tx_len[7:0]}; dm9_iow_n <= 1'b0; tx_state <= TX_LENH; end TX_LENH: begin dm9_iow_n <= 1'b1; dm9_data <= {8'h00, tx_len[15:8]}; dm9_iow_n <= 1'b0; tx_state <= TX_TRIG; end TX_TRIG: begin dm9_iow_n <= 1'b1; dm9_cmd <= 1'b0; dm9_data <= 16'h0002; // 写 TXCR 地址 dm9_iow_n <= 1'b0; tx_state <= TX_TRIG2; end // TX_TRIG2 里置 TXREQ 并释放总线 default: tx_state <= TX_IDLE; endcase end end

发送状态机最重要的参数是 tx_len。这个长度指的是 TX FIFO 里实际写入的帧数据字节数,不包含 DM9000A 自动补的 CRC。如果你在 FPGA 里已经手动算了 CRC 并写进帧尾,那就要把 CRC 也计入长度,同时在发送控制寄存器里关掉自动 CRC 填充。绝大多数情况建议让 DM9000A 自己补 CRC,所以 tx_len = 以太网帧头 + IP/UDP 头 + 有效载荷,最短到 60 字节,不足 60 字节要补 0,否则对端网卡可能直接丢帧。

还有一个容易被忽略的点:TX FIFO 写入阶段结束后,要等 TX 状态寄存器里的发送完成标志,才能发起下一帧。有些设计图省事,在 TX_TRIG 之后立刻把 tx_ready 拉高,导致连续发送时第一帧还没发完,第二帧数据已经把 TX FIFO 覆盖了。正确的握手应该是:tx_ready 在 TX 完成标志有效后再拉高,至少留出一个帧间隙。

4.2 接收通路:检查 RXRDY、读状态、连续搬移

接收流程比发送多一个判断:要先知道 DM9000A 的 RX FIFO 里有没有完整的一帧。常见做法是读 NSR 寄存器看 RXRDY 位,然后用 MRCMDX 命令读一个特殊字来确认。确认就绪后,切到 MRCMD 连续读数据,最后根据 RX 状态寄存器判断这帧是否有效。接收状态机片段如下:

localparam RX_IDLE = 4'd0; localparam RX_CMD = 4'd1; localparam RX_STATUS = 4'd2; localparam RX_DATA = 4'd3; localparam RX_END = 4'd4; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_state <= RX_IDLE; end else begin case (rx_state) RX_IDLE: begin // 轮询 NSR,读到 RXRDY 后再进入读命令 dm9_cs_n <= 1'b0; dm9_cmd <= 1'b0; dm9_data <= 16'h0001; // NSR 地址 dm9_ior_n <= 1'b0; rx_state <= RX_CMD; end RX_CMD: begin dm9_ior_n <= 1'b1; // nsr_data 是刚读到的 NSR if (nsr_data[0]) begin dm9_cmd <= 1'b0; dm9_data <= 16'h00F0; // MRCMDX 地址 dm9_ior_n <= 1'b0; rx_state <= RX_STATUS; end else begin rx_state <= RX_IDLE; // 没有帧,继续轮询 end end RX_STATUS: begin dm9_ior_n <= 1'b1; dm9_cmd <= 1'b1; // 这里读到的第一个 16 位数据,高字节约定为 0x01 // 低字节是 RX 状态和长度的一部分 rx_state <= RX_DATA; end RX_DATA: begin dm9_ior_n <= 1'b0; // 不断读 MRCMD,直到帧长度达到预期值 if (rx_cnt == rx_len) begin rx_state <= RX_END; end else begin rx_fifo_data <= dm9_data; rx_fifo_wr <= 1'b1; rx_cnt <= rx_cnt + 1'b1; end end RX_END: begin dm9_ior_n <= 1'b1; dm9_cs_n <= 1'b1; rx_fifo_wr <= 1'b0; rx_state <= RX_IDLE; end default: rx_state <= RX_IDLE; endcase end end

接收流程里最容易出错的是 MRCMDX 和 MRCMD 的语义差异。MRCMDX 不是取走数据,而是从 RX FIFO 头部“瞄一眼”,判断是否有一帧完整的帧等着。看到标志后,需要用 MRCMD 真正消费数据。如果读完一个坏帧不把它清掉,后续所有帧都会堵在 FIFO 里。所以 RSR 的读取和错误判断要放在 RX_STATUS 之后,发现 CRC 错误或帧长度非法时,也要把剩余数据读完,或者通过关闭 RX 再开启来触发芯片内部清空。

4.3 用一块双口 RAM 做帧缓存,避免一路写一路卡

纯收发状态机可以直接接 FIFO,但做完整以太网收发时,我建议用双口 RAM 而不是简单 FIFO。因为发送前需要知道整帧长度,接收后需要让上层逻辑能随机访问协议头。一个典型参数是:位宽 16bit,深度 1024,能存一帧最大 1518 字节的以太网帧外加 4 字节帧状态。使用双口 RAM 的好处是,收发状态机读端口和上层用户逻辑写端口完全独立,不会出现写一半被读走的情况。

如果是在 FPGA 里用 FIFO,要关注 almost_full 信号。发送时,DM9000A 内部 TX FIFO 一旦满了,MWCMD 连续写可能会丢数据。所以发送状态机里最好在每写一拍后看 DM9000A 的 TX 状态,但不建议每个字节都判断,那样会拖慢总线。折中办法是:在 TX_DATA 状态里,每写 8 拍检查一次 TX 完成或 FIFO 满标志,满则暂停当前帧,等标志消失再继续。这样既不会丢帧,也不会让总线吞吐掉得太快。

接收侧的帧长度一般是 DM9000A 在状态字里给出的,不是由 FPGA 自己猜。所以 RX_DATA 状态里读到一个合法长度后,要用一个寄存器把剩余字节数锁存下来,依次递减。如果收到广播风暴或错误帧,长度可能超过你的缓存深度,常见做法是设置一个最大长度上限,超过 1518 字节直接放弃该帧,并继续读完直到 FIFO 清空。这样能防止恶意大帧把 FPGA 内部 RAM 打爆。

5. 联调避坑:DM9000A 最容易翻车的 5 个现场(含排查顺序)

寄存器控制器和数据通路都写完后,真正耗时间的是上板联调。下面这 5 个现场我基本都踩过,按排查优先级从高到低写出来。

5.1 复位即玄学:ID 读不出来

现象:初始化脚本读 0x2A/0x2B 时,读到 0xFFFF 或随机值,偶尔复位后第一次能读对,第二次就读错。

原因:DM9000A 的软复位要求 NCR 置位后保持足够时间,释放后还要等芯片从 PHY 上电状态稳定下来。很多参考代码只写了 20us 等待,实际上不同板子差异很大。另一个常见原因是 RST 引脚在上电瞬间处于不定态,导致芯片一直没退出复位。

解决:把等待时间加大到“保守线”。我一般用 FPGA 时钟计数器做延时,软复位置位保持 20us,复位释放后等 2ms 以上再访问寄存器。如果读 ID 仍不对,先检查 RST 引脚是否接了可靠下拉,再看数据总线高低字节是否接反,最后才怀疑芯片本身。

5.2 总线时序和采样点:寄存器时好时坏

现象:写 MAC 地址后,用读回的方式校验,发现偶发第一位错。连续读写寄存器时,每隔几十次就返回一个 0x00。

原因:状态机把 dm9_data 的赋值和 dm9_iow_n 拉低放在了同一个时钟沿,导致数据还没稳定 IOW 就已经有效。或者读操作在 IOR 上升沿之后才采样,数据已经从总线上消失。

解决:把数据变化和 IOW/IOR 有效沿错开一拍。写操作先赋数据,下一拍拉低 IOW;读操作在 IOR 有效期间用寄存器打一拍采样,不要在同一个 always 块里既拉低 IOR 又采样数据。如果你用的是下板调试,强烈建议把总线的 IO 时序约束加上,告诉综合工具 CS/CMD/IOW/IOR 之间是外部延迟关系,否则布局布线后各个信号到达时间可能偏差很大。

5.3 发送完成位一直不置位

现象:TX 触发以后,寄存器里发送完成标志始终不变,但用示波器看 IOW/CS 时序又没什么问题。

原因:帧长度寄存器没有写对。DM9000A 触发发送时,如果 TXPL/TXPH 是 0,芯片认为空帧,不会真正发起发送。有些设计把帧长写成了“数据长度加 4”,但 TX FIFO 里没写 CRC,导致芯片等一个永不出现的 CRC。

解决:发送前先确认 TX FIFO 里写入的字节数和 TXPL/TXPH 完全一致,不要多算 CRC。触发位 TXREQ 要写在最后一次长度写入之后,建议用独立一拍写 TXCR 地址并置 bit0,不要和长度写混在一起。完成标志以 NSR 或 TX 状态寄存器为准,不要靠猜延时。

5.4 对端提示帧长度不对或 CRC 错误

现象:PC 端抓包能看到帧,但 Wireshark 提示 “frame check sequence incorrect”,或者长度字段比实际多了 4 字节。

原因:MAC 层同时有“自动填充 CRC”和“手动送 CRC”两种模式。如果你在 FPGA 里发送的帧尾已经带 4 字节 CRC,而 DM9000A 又在后面自动补了 4 字节,就会造成 CRC 错。如果你把 tx_len 设成了包含 CRC 的长度,但芯片自动补 CRC,则长度字段多算 4。

解决:二选一。要么 FPGA 只负责发到“伪头部 + 数据”,把 tx_len 设为不含 CRC 的字节数,让 DM9000A 自动补;要么 FPGA 自己算好 CRC 并写入帧尾,此时要先把 DM9000A 的自动 CRC 填充关掉,tx_len 包含 CRC。最简单的方案是前者,初始化时不要开任何“CRC 手动”模式,发送长度按 len = 6 + 6 + 2 + payload 计算即可。

5.5 接收被错误帧卡死

现象:RX 状态寄存器一直显示有帧,但每次读出来的数据都一样,或者一轮接收后整个 FIFO 再也不出新数据。

原因:读到一个 CRC 错误或长度错误的帧后,没有消费完这一帧的全部数据,DM9000A 的 RX 状态机停留在“帧未读完”的状态。另一个常见原因是 MRCMD 模式读的数据比实际帧多或少一个周期,导致下一个帧头被错位。

解决:进入 RX_DATA 后严格按状态字给出的长度读完,不提前退出。每次读完一帧后,判断 RSR 里的错误位,如果出错要把这一帧完整吞掉再处理下一帧。更稳的做法是读到错误帧后,写 RCR 关闭 RXEN,再重新打开,让芯片内部清一次 FIFO,代价是会丢接下来几帧,但系统至少不会死锁。

6. 进阶验证:做一个最小回环测通 ARP,再谈吞吐

收发通路稳定后,不要急着去调大流量吞吐,先做一个最小回环:让 FPGA 在初始化完成后,自动发出一个 ARP 请求帧,等 PC 端用 ping 来触发 ARP 响应,FPGA 再把收到的响应帧原样发回去。这个实验能同时验证四件事:MD9 的 TX FIFO 写入、发送触发、接收就绪判断、RX 数据搬移。PC 端只要用抓包软件看到 FPGA 发出的 ARP 请求,并且能看到 FPGA 回应的重复帧,基本说明链路层已经通了。然后你再把 FPGA 接收到的一个 UDP 帧内容存进 RAM,再原封不动发回去,用网卡工具持续打流,观察有无丢包。

验证过程中记录几个关键指标:发送一帧的端到端延迟、连续发送时的帧间隙、接收时的最大连续帧数。常见误区的“100M 以太网 = FPGA 里 100Mbps”,实际上 DM9000A 的并行总线在 16 位模式和 50MHz 时钟下,理论搬运能力远超 100Mbps,瓶颈反而在 DM9000A 内部 FIFO 和 MAC/PHY 的帧处理上。实测能做到 80Mbps 以上就已经是合理水平,再往上就要检查发送状态机是否在每帧之间浪费了过长等待时间。你可以给发送通路加一个简单的手握计数器:从 tx_valid 拉高到 tx_ready 重新拉高之间记录周期数,连续发包时这个数字的稳定性比瞬时速率更能反映状态机优劣。

如果你希望这个方案能长期用在项目中,建议再补三件事:把复位后的初始化超时做成可配置参数,方便换不同批次的 DM9000A;把总线状态机的读数据采样点做成一个 localparam,便于针对不同 PCB 走线长度调整;最后在顶层文件里显式约束 IOR/IOW/CS 的对外延迟标记,否则换一块 FPGA 型号就容易从“能跑”变成“偶发跑不通”。我做这个项目时最大的教训是:DM9000A 的寄存器配置并不难,难的是每一次时序微调都要看数据手册,而不是靠猜。后来我给自己定了一个规矩:每次联调前先在纸上画清楚索引窗口和数据窗口的波形,再动代码。这个习惯帮我省掉了很多改一次综合一次的死循环。这个方案和这些参数边界,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询