第三章 MAC层设计(一):以太网帧结构解析、CRC32校验原理与实现、MAC层发送状态机设计

各位同学,今天我们正式进入MAC层的设计。说实话,MAC层是整个网络协议栈里最“实在”的一层——它不跟你玩虚的,直接跟物理层的比特流打交道。我当年刚接触FPGA网络开发时,觉得MAC层不就是拼个帧嘛,结果第一次上板调试,抓出来的数据全是乱的……嗯,从那以后我老老实实把帧结构背得滚瓜烂熟。

3.1 以太网帧结构:别小看这14个字节

先看标准以太网帧长什么样。我习惯把帧结构分成三块:头部、数据、尾部。头部固定14字节,尾部是4字节的FCS(帧校验序列)。

字段长度(字节)说明
目的MAC地址6接收方MAC
源MAC地址6发送方MAC
类型/长度20x0800表示IPv4,0x0806表示ARP
数据载荷46~1500上层协议数据,不足46字节需填充
FCS(CRC32)4对整个帧(不含前导码)做CRC校验

这里有个坑,我必须要提醒你:前导码(Preamble)和帧起始定界符(SFD)不属于MAC帧。前导码是物理层加的,共8字节(7字节0x55 + 1字节0xD5)。你在FPGA里做MAC设计时,收到的第一个有效字节应该是目的MAC的第一个字节。

重点记忆:最小帧长64字节(从目的MAC到FCS)。如果数据载荷不足46字节,必须填充到46字节。为什么?因为要保证冲突检测的有效性。这个在CSMA/CD里会细讲。

3.2 CRC32校验原理:多项式除法在硬件里的玩法

CRC32说白了就是多项式除法。以太网用的是CRC-32标准,生成多项式是:

G(x) = x^32 + x^26 + x^23 + x^22 + x^16 + x^12 + x^11 + x^10 + x^8 + x^7 + x^5 + x^4 + x^2 + x + 1

对应的二进制是 0x04C11DB7。注意,这个多项式是“反转”的——什么意思?你查资料时会发现有的写0xEDB88320,其实是一个东西,只是位序不同。我个人习惯用0x04C11DB7,因为跟IEEE标准一致。

计算过程分三步:

  1. 在数据后面补32个0
  2. 用生成多项式做模2除法(异或运算)
  3. 余数取反,得到32位CRC值

你想想看,如果每个字节都做一次除法,那速度太慢了。FPGA里我们通常用并行CRC算法,一次处理一个字节或四个字节。我当年在10G以太网项目里,用的是64位并行CRC,一个时钟周期处理8个字节。

实战技巧:别自己推导并行CRC公式,直接用工具生成。推荐用“CRC Generator”或者“PyCRC”库。我在项目中吃过亏,手算的并行CRC公式少了一个异或项,结果仿真全对,上板就错。后来老老实实用工具生成。

3.3 CRC32的Verilog实现:一个可综合的模块

下面给出一个经典的字节并行CRC32模块。这个代码我用了很多年,从百兆到万兆都能用,只是时钟频率不同。

module crc32_d8 (
    input  wire        clk,
    input  wire        rst_n,
    input  wire [7:0]  data_in,
    input  wire        data_valid,
    output reg  [31:0] crc_out,
    output reg         crc_valid
);

// 内部寄存器
reg [31:0] crc_reg;

// CRC32多项式(反转后)
parameter CRC_POLY = 32'hEDB88320;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        crc_reg <= 32'hFFFFFFFF;
        crc_out  <= 32'h0;
        crc_valid <= 1'b0;
    end else if (data_valid) begin
        // 逐字节计算CRC
        crc_reg <= next_crc(crc_reg, data_in);
        crc_valid <= 1'b0;
    end else begin
        // 数据结束后输出结果(取反)
        crc_out  <= ~crc_reg;
        crc_valid <= 1'b1;
    end
end

// 组合逻辑:计算下一个CRC值
function [31:0] next_crc;
    input [31:0] crc;
    input [7:0]  data;
    reg [31:0] new_crc;
    integer i;
    begin
        new_crc = crc ^ {24'h0, data};
        for (i = 0; i < 8; i = i + 1) begin
            if (new_crc[0])
                new_crc = (new_crc >> 1) ^ CRC_POLY;
            else
                new_crc = (new_crc >> 1);
        end
        next_crc = new_crc;
    end
endfunction

endmodule
避坑指南:我曾经在项目中犯过一个低级错误——CRC计算完成后忘记取反。结果抓包软件一直报FCS错误,排查了整整两天。记住:以太网标准要求CRC结果取反后存入FCS字段。

3.4 MAC发送状态机:从数据到比特流的转换

MAC发送状态机是整个发送通路的核心。我习惯把它设计成5个状态:

IDLE PREAMBLE DATA FCS IFG 帧到达 前导码结束 数据结束 FCS发送完 IFG结束 IFG计数 MAC发送状态机 IFG: 帧间隔(96 bit time) FCS: 帧校验序列(4字节CRC)

状态机的工作流程:

  • IDLE:等待发送请求。有帧要发时,跳转到PREAMBLE。
  • PREAMBLE:发送8字节前导码(7字节0x55 + 1字节0xD5)。发送完成后进入DATA。
  • DATA:发送MAC帧头(14字节)+ 数据载荷。注意这里要处理填充逻辑——如果数据不足46字节,自动填充0。
  • FCS:发送4字节CRC32校验值。这个值在DATA阶段就要并行计算好。
  • IFG:帧间隔,至少96个bit time(12字节)。这是以太网的规矩,给接收端留出处理时间。
设计要点:我建议把CRC计算和状态机分开。状态机只管发送时序,CRC计算用独立的模块。这样调试时能快速定位问题——到底是时序错了还是CRC算错了。

3.5 发送状态机的Verilog实现框架

module mac_tx (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        tx_start,      // 发送启动信号
    input  wire [7:0]  tx_data,       // 待发送数据
    input  wire        tx_data_valid, // 数据有效
    output reg  [7:0]  gmii_txd,      // GMII接口数据
    output reg         gmii_tx_en,    // GMII接口使能
    output reg         tx_done        // 发送完成
);

// 状态定义
localparam IDLE      = 3'd0;
localparam PREAMBLE  = 3'd1;
localparam DATA      = 3'd2;
localparam FCS       = 3'd3;
localparam IFG       = 3'd4;

reg [2:0] state, next_state;
reg [3:0] preamble_cnt;  // 前导码计数器
reg [15:0] byte_cnt;     // 字节计数器
reg [31:0] crc_value;    // CRC计算结果

// 状态转移逻辑(略,参考状态图)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        state <= IDLE;
    else
        state <= next_state;
end

// 输出逻辑
always @(*) begin
    gmii_txd   = 8'h00;
    gmii_tx_en = 1'b0;
    tx_done    = 1'b0;
    
    case (state)
        IDLE: begin
            // 等待tx_start
        end
        PREAMBLE: begin
            gmii_tx_en = 1'b1;
            if (preamble_cnt < 7)
                gmii_txd = 8'h55;  // 前导码
            else
                gmii_txd = 8'hD5;  // SFD
        end
        DATA: begin
            gmii_tx_en = 1'b1;
            gmii_txd   = tx_data;  // 发送MAC帧数据
        end
        FCS: begin
            gmii_tx_en = 1'b1;
            // 按字节发送CRC值(大端序)
            case (byte_cnt[1:0])
                2'b00: gmii_txd = crc_value[31:24];
                2'b01: gmii_txd = crc_value[23:16];
                2'b10: gmii_txd = crc_value[15:8];
                2'b11: gmii_txd = crc_value[7:0];
            endcase
        end
        IFG: begin
            gmii_tx_en = 1'b0;  // 发送空闲
            if (byte_cnt == 12) // IFG结束
                tx_done = 1'b1;
        end
    endcase
end

endmodule
调试建议:仿真时一定要检查FCS字段是否正确。我常用的方法是:用Wireshark抓一个标准以太网帧,把它的FCS值记下来,然后在仿真波形里对比。如果CRC模块算出来的值和Wireshark一致,那基本就对了。

3.6 关于字节序的提醒

以太网采用大端序(Big-Endian),即高位字节先发送。比如MAC地址 00:11:22:33:44:55,先发0x00,再发0x11,以此类推。CRC32也是大端序,先发高位字节。

这一点在调试时特别容易出错。我记得有一次,同事把CRC的字节序搞反了,结果接收端一直报错。查了三天才发现是字节序问题。所以,写代码前先确认字节序,这个习惯能帮你省下大量调试时间。

好了,MAC层发送部分就讲到这里。下一节我们会深入MAC接收状态机,以及如何处理接收到的错误帧。记住:发送是“主动”的,接收是“被动”的,接收端的设计往往更复杂。


公众号:蓝海资料掘金营,微信deep3321