第三章 硬件描述语言基础:从代码到电路

各位同学,今天我们来聊聊硬件描述语言。说实话,很多刚接触FPGA的朋友都会问:这玩意儿跟C语言有啥区别?我当年刚入行时也这么想,结果写出来的第一个模块,综合出来一堆莫名其妙的Latch。嗯,那都是血泪教训。

3.1 模块化设计:把大问题拆成小积木

模块化设计,说白了就是“分而治之”。你想想看,一个复杂的网络协议栈,动辄几万行代码,如果全写在一个文件里,调试起来简直要命。我个人习惯是把功能拆成独立的模块,每个模块只干一件事。

举个例子,一个简单的以太网帧处理模块,我会拆成这样:

// 顶层模块:以太网帧处理器
module eth_frame_processor (
    input  wire        clk,
    input  wire        rst_n,
    input  wire [7:0]  data_in,
    input  wire        data_valid,
    output reg  [7:0]  data_out,
    output reg         data_ready
);

    // 子模块实例化
    wire [47:0] mac_dst, mac_src;
    wire [15:0] eth_type;
    wire        header_valid;

    // 解析MAC头部
    mac_header_parser u_parser (
        .clk        (clk),
        .rst_n      (rst_n),
        .data_in    (data_in),
        .data_valid (data_valid),
        .mac_dst    (mac_dst),
        .mac_src    (mac_src),
        .eth_type   (eth_type),
        .header_valid (header_valid)
    );

    // 根据以太网类型分发数据
    payload_dispatcher u_dispatcher (
        .clk        (clk),
        .rst_n      (rst_n),
        .data_in    (data_in),
        .data_valid (data_valid & header_valid),
        .eth_type   (eth_type),
        .data_out   (data_out),
        .data_ready (data_ready)
    );

endmodule

看到没?顶层模块只负责连线,具体逻辑交给子模块。这样做的好处是:

  • 可读性强:一眼就能看出数据流走向
  • 可复用性高:MAC解析器可以在多个项目里重复使用
  • 调试方便:哪个模块出问题,直接定位到那个文件
我的小技巧:模块接口尽量用标准握手信号(valid/ready),这样后续做流水线设计会轻松很多。我在做100G网络加速器时,所有模块都统一用这套接口,联调时几乎没出过问题。

3.2 组合逻辑与时序逻辑:分清“现在”和“将来”

这是新手最容易搞混的地方。组合逻辑,输出只取决于当前输入;时序逻辑,输出还取决于之前的状态。说白了,组合逻辑是“即时反应”,时序逻辑是“有记忆的”。

来看个对比:

// 组合逻辑:纯加法器
assign sum = a + b;

// 时序逻辑:带寄存器的加法器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        sum_reg <= 0;
    else
        sum_reg <= a + b;
end

注意看区别:组合逻辑用assign,时序逻辑用always @(posedge clk)。我曾经见过一个同事,把所有逻辑都写在always块里,结果综合出来的电路面积大了三倍。为什么?因为综合器会把所有信号都当成寄存器来处理。

避坑指南:我曾经在调试一个TCP卸载引擎时,发现数据总是丢包。查了两天才发现,有个组合逻辑的赋值漏了default分支,导致综合出了Latch。从那以后,我写组合逻辑必加default,写时序逻辑必检查复位。

这里有个实用的判断方法:

特性 组合逻辑 时序逻辑
输出延迟 几乎无延迟(ns级) 延迟一个时钟周期
代码风格 assign 或 always @(*) always @(posedge clk)
综合结果 门电路 触发器 + 门电路
典型应用 地址译码、数据选择 计数器、状态机、流水线

3.3 状态机设计:控制逻辑的灵魂

网络协议处理,本质上就是状态机。TCP的三次握手、IP分片重组、MAC层的帧接收……全是状态机。我个人最喜欢用三段式状态机,清晰又好维护。

来看一个ARP协议处理的状态机:

// 三段式状态机示例:ARP请求处理
localparam IDLE      = 3'd0;
localparam PARSE_HDR = 3'd1;
localparam CHECK_IP  = 3'd2;
localparam GEN_REPLY = 3'd3;
localparam SEND_REPLY = 3'd4;

reg [2:0] current_state, next_state;

// 第一段:状态转移
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        current_state <= IDLE;
    else
        current_state <= next_state;
end

// 第二段:次态逻辑(组合逻辑)
always @(*) begin
    case (current_state)
        IDLE: begin
            if (frame_start)
                next_state = PARSE_HDR;
            else
                next_state = IDLE;
        end
        PARSE_HDR: begin
            if (hdr_done)
                next_state = CHECK_IP;
            else
                next_state = PARSE_HDR;
        end
        CHECK_IP: begin
            if (ip_match)
                next_state = GEN_REPLY;
            else
                next_state = IDLE;
        end
        GEN_REPLY: begin
            next_state = SEND_REPLY;
        end
        SEND_REPLY: begin
            if (send_done)
                next_state = IDLE;
            else
                next_state = SEND_REPLY;
        end
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑(时序逻辑)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        arp_reply_valid <= 0;
        // ... 其他输出复位
    end else begin
        case (current_state)
            GEN_REPLY: arp_reply_valid <= 1;
            SEND_REPLY: arp_reply_valid <= 0;
            default: arp_reply_valid <= 0;
        endcase
    end
end
为什么用三段式? 第一段管状态跳转,第二段管跳转条件,第三段管输出。这样改状态机时,你只需要改第二段,不会影响到输出逻辑。我在做40G MAC控制器时,用这种方式改过十几次状态机,从来没出过时序问题。

3.4 仿真与测试:不仿真就流片?想都别想

仿真,是硬件开发中最重要的一环。我见过太多人,写完代码直接上板,结果板子冒烟了还不知道为什么。正确的流程应该是:写代码 → 写testbench → 仿真验证 → 上板测试。

一个简单的testbench长这样:

module tb_arp_processor;

    reg         clk;
    reg         rst_n;
    reg  [7:0]  data_in;
    reg         data_valid;
    wire [7:0]  data_out;
    wire        data_ready;

    // 实例化待测模块
    arp_processor u_dut (
        .clk        (clk),
        .rst_n      (rst_n),
        .data_in    (data_in),
        .data_valid (data_valid),
        .data_out   (data_out),
        .data_ready (data_ready)
    );

    // 生成时钟
    always #5 clk = ~clk;

    // 测试序列
    initial begin
        clk = 0;
        rst_n = 0;
        data_in = 0;
        data_valid = 0;

        #20 rst_n = 1;

        // 发送ARP请求
        @(posedge clk);
        data_in = 8'hFF;  // 目的MAC
        data_valid = 1;
        // ... 发送完整帧

        #100;
        $finish;
    end

    // 波形输出
    initial begin
        $dumpfile("arp_test.vcd");
        $dumpvars(0, tb_arp_processor);
    end

endmodule

仿真时要注意几个关键点:

  • 覆盖率:不仅要测正常情况,还要测边界条件。比如ARP请求的目标IP是自己的IP,或者MAC地址全0的情况。
  • 时序检查:用$setup/$hold检查时序约束是否满足。
  • 自动比对:写个脚本自动比对仿真输出和预期结果,别用眼睛盯着波形看。
血的教训:我曾经在仿真时偷懒,只测了正常流程。结果上板后,发现当两个ARP请求同时到达时,状态机卡死了。从那以后,我每个状态机都至少写10个测试用例,覆盖所有可能的跳转路径。

3.5 知识体系总览

下面这张图,是我自己总结的硬件描述语言学习路径。你把它打印出来贴在工位上,写代码时对照着看,能少走很多弯路。

硬件描述语言知识体系 模块化设计 组合逻辑 时序逻辑 接口定义 实例化 assign语句 always @(*) 寄存器 计数器 状态机设计 一段式 二段式 三段式(推荐) 仿真与测试

这张图把本章的核心内容串起来了。你从模块化设计入手,先学会拆功能;然后掌握组合逻辑和时序逻辑的区别;接着用状态机把控制逻辑串起来;最后用仿真验证一切。每一步都踩实了,写出来的代码才能可靠。

最后说一句:硬件开发和软件不一样,代码写错了不是改个bug那么简单,可能要重新流片,几百万就没了。所以,仿真测试再怎么强调都不为过。我每次流片前,都会把仿真覆盖率跑到95%以上,才敢签版。

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