3. Verilog基础回顾:模块化设计、组合逻辑与时序逻辑、阻塞与非阻塞赋值、状态机设计

各位同学,欢迎来到第三章。说实话,很多讲Verilog的课一上来就讲语法,我总觉得少了点灵魂。咱们做低延迟交易的,代码写出来是要在FPGA上跑纳秒级竞争的,所以这一章我打算换个讲法——从实际设计的角度,把那些最基础、也最容易翻车的地方再过一遍。

你可能会问:“这些基础我早就会了,还用听吗?”嗯,我在面试新人时发现,很多人能把语法背得滚瓜烂熟,但一写代码就踩坑。尤其是阻塞赋值和非阻塞赋值的区别,我见过太多老手在这上面栽跟头。所以,咱们还是老老实实把地基打牢。

Verilog基础回顾 模块化设计 接口清晰 · 可复用 · 易维护 组合逻辑 vs 时序逻辑 always @(*) vs always @(posedge clk) 阻塞与非阻塞赋值 = 与 <= 的区别 · 仿真陷阱 状态机设计 三段式 · 安全编码 · 低延迟优化 低延迟交易FPGA设计 · 第3章知识体系

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

模块化设计,说白了就是“分而治之”。一个复杂的交易引擎,你不可能在一个module里写完所有逻辑。我个人的习惯是:每个模块只干一件事,接口尽量简单,像搭积木一样拼起来。

举个例子,一个简单的UDP接收模块,我会拆成这样:

// 顶层模块
module udp_receiver_top (
    input  wire        clk,
    input  wire        rst_n,
    input  wire [7:0]  rx_data,
    input  wire        rx_valid,
    output reg  [63:0] payload_data,
    output reg         payload_valid
);

    // 例化子模块
    wire [31:0] header_info;
    wire [63:0] data_out;
    wire        data_valid;
    
    mac_decoder u_mac (
        .clk(clk),
        .rst_n(rst_n),
        .rx_data(rx_data),
        .rx_valid(rx_valid),
        .header_info(header_info),
        .data_out(data_out),
        .data_valid(data_valid)
    );
    
    payload_extractor u_payload (
        .clk(clk),
        .rst_n(rst_n),
        .header_info(header_info),
        .data_in(data_out),
        .data_valid(data_valid),
        .payload_data(payload_data),
        .payload_valid(payload_valid)
    );
    
endmodule
我的经验:模块的端口数量最好控制在10个以内。超过这个数,八成是设计有问题。我在一个项目里见过一个模块有40多个端口,调试起来简直噩梦。

3.2 组合逻辑与时序逻辑——两个世界

很多新手分不清什么时候用组合逻辑,什么时候用时序逻辑。其实很简单:

  • 组合逻辑:输出只取决于当前输入,没有记忆功能。用 always @(*)assign 实现。
  • 时序逻辑:输出不仅取决于当前输入,还取决于之前的状态。用 always @(posedge clk) 实现。

在低延迟交易中,我倾向于尽量多用组合逻辑——少一级寄存器就少一个时钟周期的延迟。但代价是组合逻辑容易产生毛刺,而且时序收敛更难。

// 组合逻辑示例:地址译码器
always @(*) begin
    case (addr[3:2])
        2'b00: sel = 4'b0001;
        2'b01: sel = 4'b0010;
        2'b10: sel = 4'b0100;
        2'b11: sel = 4'b1000;
        default: sel = 4'b0000;
    endcase
end

// 时序逻辑示例:寄存器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        count <= 8'd0;
    else if (en)
        count <= count + 8'd1;
end
注意:组合逻辑的敏感列表一定要写全。我曾经因为漏写了一个敏感信号,导致仿真和实际行为不一致,查了整整两天。现在我用 always @(*) 代替手动列敏感列表,省心多了。

3.3 阻塞与非阻塞赋值——最容易翻车的坑

这个知识点,我敢说90%的Verilog初学者都搞混过。咱们直接上对比:

特性 阻塞赋值 = 非阻塞赋值 <=
执行顺序 顺序执行,阻塞后续语句 并行执行,不阻塞后续语句
使用场景 组合逻辑(always @(*) 时序逻辑(always @(posedge clk)
仿真行为 立即更新 在时间步结束时更新
综合结果 组合逻辑或锁存器 触发器

记住一个黄金法则:写时序逻辑用非阻塞赋值,写组合逻辑用阻塞赋值。混用的话,仿真和综合结果可能不一致。

我曾经在一个项目中,同事在时序逻辑里用了阻塞赋值,结果仿真通过,但综合出来的电路多了一级不必要的延迟。在低延迟交易中,这一级延迟可能就是几百万的损失。

// 错误示范:时序逻辑中用阻塞赋值
always @(posedge clk) begin
    a = b;  // 阻塞赋值
    c = a;  // 这里a已经更新,c得到的是新值
end
// 综合后:a和c在同一时钟沿更新,但仿真行为可能让人误解

// 正确做法:时序逻辑用非阻塞赋值
always @(posedge clk) begin
    a <= b;  // 非阻塞赋值
    c <= a;  // 这里a还是旧值,c得到的是旧a
end
// 综合后:a和c都是寄存器,行为清晰
避坑指南:同一个always块里不要混用阻塞和非阻塞赋值。我曾经见过有人这么写,结果综合工具报了一堆warning,最后查出来是代码风格问题。

3.4 状态机设计——交易逻辑的核心

状态机是FPGA设计的灵魂。在低延迟交易中,我们常用的是三段式状态机

  1. 第一段:时序逻辑,描述状态转移
  2. 第二段:组合逻辑,描述下一状态逻辑
  3. 第三段:时序逻辑或组合逻辑,描述输出

我个人强烈推荐三段式,因为它把状态转移和输出逻辑分开了,代码清晰,也容易做时序优化。

// 三段式状态机示例:简单的UDP包处理
localparam IDLE      = 2'b00;
localparam HEADER    = 2'b01;
localparam PAYLOAD   = 2'b10;
localparam CHECKSUM  = 2'b11;

reg [1:0] state, next_state;

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

// 第二段:下一状态逻辑
always @(*) begin
    next_state = state;
    case (state)
        IDLE: begin
            if (rx_valid)
                next_state = HEADER;
        end
        HEADER: begin
            if (header_done)
                next_state = PAYLOAD;
        end
        PAYLOAD: begin
            if (payload_done)
                next_state = CHECKSUM;
        end
        CHECKSUM: begin
            if (checksum_ok)
                next_state = IDLE;
            else
                next_state = IDLE;  // 错误处理
        end
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        data_valid <= 1'b0;
        error_flag <= 1'b0;
    end else begin
        case (state)
            PAYLOAD: data_valid <= 1'b1;
            CHECKSUM: begin
                if (!checksum_ok)
                    error_flag <= 1'b1;
            end
            default: begin
                data_valid <= 1'b0;
                error_flag <= 1'b0;
            end
        endcase
    end
end
低延迟优化技巧:在状态机中,尽量用独热码(one-hot)代替二进制编码。独热码的状态译码只需要一个比较器,延迟更小。代价是多用几个触发器,但在FPGA里触发器很便宜。

嗯,状态机的设计还有一个要点:一定要有default分支。FPGA上电后状态是不确定的,如果没有default,状态机可能跑到非法状态里出不来。我见过一个案例,就是因为漏了default,导致系统偶尔死机,重启才能恢复。

好了,这一章的内容就到这里。基础的东西虽然简单,但越是基础越容易出错。下一章我们会进入更实战的内容——如何用Verilog实现一个低延迟的FIFO。到时候见。


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