4. Verilog进阶:任务与函数、generate语句、参数化设计、状态机编写

好,咱们继续往前走。前面几章我们把Verilog的基础语法和组合逻辑、时序逻辑都过了一遍。说实话,那些是基本功,就像练武要扎马步一样。但到了实际项目里,光会这些还不够。

这一章,我们来聊聊几个真正能让你的代码“活起来”的东西——任务与函数、generate语句、参数化设计,还有状态机。嗯,这些都是我每天写验证环境时几乎必用的技巧。

4.1 任务与函数:把重复劳动交给机器

先说说任务和函数。你想想看,写代码最烦什么?最烦的就是重复。同一个操作,比如给总线发个写命令,你每次都要写一堆assign和always,累不累?

任务(task)和函数(function)就是用来解决这个问题的。它们就像是你定义好的“小工具”,用的时候直接调用就行。

4.1.1 函数(function)

函数的特点是:不能包含时序控制,也就是不能有#delay、@(posedge clk)这些东西。它必须在同一个时钟周期内完成计算,然后返回一个值。

我个人习惯用函数来做一些简单的数据转换或计算。比如把二进制转成格雷码:

function [7:0] bin2gray;
    input [7:0] bin;
    begin
        bin2gray = bin ^ (bin >> 1);
    end
endfunction

调用的时候特别清爽:

wire [7:0] gray_out;
assign gray_out = bin2gray(bin_in);

你看,一行代码就搞定了。如果不用函数,你得写一个always块,还要定义中间变量,麻烦得很。

小提示:函数默认是“自动递归”的,但如果你在函数里用了静态变量,要小心多线程调用时的冲突。我在做多通道验证时踩过这个坑,后来统一加了automatic关键字。

4.1.2 任务(task)

任务比函数灵活得多。它可以包含时序控制,可以输出多个值,甚至可以不返回值。说白了,任务就是一段可以随时调用的“子程序”。

我在项目中经常用任务来封装总线操作。比如写一个AXI4-Lite的写操作:

task automatic axi_write;
    input  [31:0] addr;
    input  [31:0] data;
    begin
        @(posedge clk);
        awvalid <= 1'b1;
        awaddr  <= addr;
        wvalid  <= 1'b1;
        wdata   <= data;
        @(posedge clk);
        while (!awready) @(posedge clk);
        while (!wready)  @(posedge clk);
        awvalid <= 1'b0;
        wvalid  <= 1'b0;
        @(posedge clk);
        bready <= 1'b1;
        @(posedge clk);
        while (!bvalid) @(posedge clk);
        bready <= 1'b0;
    end
endtask

调用的时候,一句 axi_write(32'h1000, 32'hDEAD_BEEF); 就完成了整个握手过程。是不是很爽?

注意:任务里的变量默认是静态的。如果你在多个地方同时调用同一个任务,变量会被互相覆盖。解决办法就是加上automatic关键字。我曾经因为这个bug调了整整一个下午,后来发现是任务里的循环变量冲突了。

4.2 generate语句:批量生产,省时省力

接下来是generate语句。这东西说白了就是“代码生成器”。当你需要重复例化同一个模块很多次,或者根据参数条件决定是否生成某段代码时,generate就是你的救星。

generate有三种形式:

  • generate for:循环生成,适合重复例化
  • generate if:条件生成,适合根据参数选择
  • generate case:多分支条件生成

4.2.1 generate for 实战

我记得有一次做8x8的Crossbar交换机,需要例化64个交叉节点。如果手写64遍例化,不仅累,还容易写错行号。用generate for就优雅多了:

genvar i, j;
generate
    for (i = 0; i < 8; i = i + 1) begin : row
        for (j = 0; j < 8; j = j + 1) begin : col
            cross_node #(
                .WIDTH(DATA_WIDTH)
            ) u_node (
                .clk    (clk),
                .rst_n  (rst_n),
                .data_in (data_in[i]),
                .sel     (sel[i][j]),
                .data_out(data_out[j])
            );
        end
    end
endgenerate

这里有个细节要注意:begin : rowbegin : col 后面的名字是必须的。这是generate块的标签,用来生成唯一的层次化路径名。没有它,仿真器会报错。

4.2.2 generate if 实战

条件生成也很常用。比如你的设计支持两种模式,根据参数选择不同的实现:

parameter MODE = "FAST";

generate
    if (MODE == "FAST") begin : fast_mode
        // 高性能实现
        adder_fast u_adder (...);
    end
    else if (MODE == "LOW_POWER") begin : low_power_mode
        // 低功耗实现
        adder_low_power u_adder (...);
    end
    else begin : default_mode
        // 默认实现
        adder_std u_adder (...);
    end
endgenerate

这样做的好处是,你只需要改一个参数,就能切换整个设计的实现方式。我在做多版本IP时特别喜欢用这招。

4.3 参数化设计:一次编写,多次复用

参数化设计,说白了就是让你的模块“可配置”。通过parameter和localparam,你可以把模块的位宽、深度、阈值等做成可调参数。

举个例子,一个参数化的FIFO:

module fifo #(
    parameter DATA_WIDTH = 8,
    parameter DEPTH      = 16,
    parameter ALMOST_FULL_THRESHOLD = DEPTH - 2
) (
    input  wire                 clk,
    input  wire                 rst_n,
    input  wire                 wr_en,
    input  wire [DATA_WIDTH-1:0] wr_data,
    input  wire                 rd_en,
    output reg  [DATA_WIDTH-1:0] rd_data,
    output wire                 full,
    output wire                 empty,
    output wire                 almost_full
);
    // 内部实现...
endmodule

实例化的时候,你可以根据需要调整参数:

fifo #(
    .DATA_WIDTH(32),
    .DEPTH(64),
    .ALMOST_FULL_THRESHOLD(60)
) u_fifo (
    .clk   (clk),
    .rst_n (rst_n),
    // ... 其他端口
);
核心思想:参数化设计的目标是“一次编写,到处复用”。好的参数化设计,应该能覆盖80%以上的使用场景,同时保持代码的可读性。

4.4 状态机编写:控制逻辑的灵魂

状态机,这是数字设计的核心中的核心。我敢说,90%以上的控制逻辑都是用状态机实现的。状态机写得好不好,直接决定了你的设计稳不稳。

4.4.1 三段式状态机

我个人强烈推荐使用三段式状态机。为什么?因为它把状态跳转、状态判断、输出逻辑分得清清楚楚,调试起来特别方便。

三段式状态机的结构:

  • 第一段:时序逻辑,描述状态跳转
  • 第二段:组合逻辑,描述下一状态
  • 第三段:时序逻辑,描述输出

来看一个经典的UART接收状态机:

// 状态定义
localparam IDLE     = 3'b001;
localparam START    = 3'b010;
localparam DATA     = 3'b011;
localparam STOP     = 3'b100;

reg [2:0] state, next_state;
reg [3:0] bit_cnt;
reg [7:0] shift_reg;

// 第一段:状态跳转
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 (rxd == 1'b0)
                next_state = START;
        end
        START: begin
            next_state = DATA;
        end
        DATA: begin
            if (bit_cnt == 4'd7)
                next_state = STOP;
            else
                next_state = DATA;
        end
        STOP: begin
            next_state = IDLE;
        end
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        bit_cnt   <= 4'd0;
        shift_reg <= 8'd0;
        rx_done   <= 1'b0;
    end
    else begin
        case (state)
            IDLE: begin
                bit_cnt <= 4'd0;
                rx_done <= 1'b0;
            end
            START: begin
                // 什么都不做,等待半个位周期
            end
            DATA: begin
                shift_reg <= {rxd, shift_reg[7:1]};
                bit_cnt   <= bit_cnt + 1'b1;
            end
            STOP: begin
                rx_done <= 1'b1;
            end
        endcase
    end
end
我的经验:写状态机时,一定要用独热码(one-hot)或者格雷码(gray code)。独热码速度快,格雷码省面积。我一般用独热码,因为综合出来的电路延迟小,而且仿真时一眼就能看出当前状态。

4.4.2 状态机避坑指南

我曾经在一个项目中,因为状态机漏写了default分支,导致综合后出现了锁存器。仿真时一切正常,但上板子就死机。查了三天才发现是这个问题。

所以,写状态机时一定要记住:

  • 每个case都要有default,哪怕只是回到IDLE
  • 输出信号要赋初值,避免产生锁存器
  • 状态编码要留余量,防止非法状态死锁

另外,我建议在状态机里加一个“看门狗”计数器。如果某个状态停留时间超过预期,就强制跳回IDLE。这在处理外部干扰时特别有用。

4.5 本章小结

这一章的内容,说实话,是Verilog从入门到进阶的分水岭。任务和函数让你的代码更简洁,generate语句让你告别重复劳动,参数化设计让你的模块更灵活,而状态机则是控制逻辑的基石。

嗯,我建议你把这些技巧都练熟。下一章我们会聊到仿真验证中的高级技巧,到时候这些知识都会用上。记住,写代码不是为了炫技,而是为了解决问题。工具用得顺手,问题自然就解决得快。