4. Verilog基础语法(下):参数化设计、generate语句、任务与函数、状态机基础

好,咱们接着聊。上一章我们把Verilog的基本语法过了一遍,那些是写代码的“砖块”。这一章,我们来聊聊怎么把这些砖块搭得更灵活、更聪明。说白了,就是让你的代码能“举一反三”,而不是写一堆重复的体力活。

4.1 参数化设计:让模块学会“变通”

先问个问题:你写过一个8位计数器,下次要16位的,怎么办?复制粘贴改数字?嗯,我以前也这么干过。直到有一次,一个项目里需要3个不同位宽的计数器,我复制了三次,结果改漏了一个地方,仿真跑出来全是错的……

从那以后,我就爱上了参数化设计。核心就是两个关键字:parameterlocalparam

4.1.1 parameter vs localparam

关键字 作用 能否被外部修改
parameter 模块的“可调旋钮” 可以(实例化时用 #() 传参)
localparam 模块内部的“固定常数” 不可以

举个例子,一个参数化的计数器模块:

module counter #(
    parameter WIDTH = 8   // 默认8位
)(
    input  clk,
    input  rst_n,
    output reg [WIDTH-1:0] count
);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 0;
        else
            count <= count + 1;
    end

endmodule

实例化的时候,你可以这样用:

counter #(.WIDTH(16)) u_counter_16bit (...);  // 变成16位
counter #(.WIDTH(32)) u_counter_32bit (...);  // 变成32位

你看,一个模块,搞定所有位宽。这就是参数化的魅力。

我的小习惯: 我一般把 parameter 放在模块端口声明之前,用 #() 括起来。这样别人一看就知道这个模块有哪些“旋钮”可以调。

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

参数化解决的是“一个模块变多个版本”的问题。那如果我想在模块内部批量生成一堆相同的电路呢?比如,一个8位的加法器,难道要手写8个全加器?

这时候 generate 就派上用场了。它有三种用法:generate forgenerate ifgenerate case

4.2.1 generate for:循环生成结构

我最常用的是 generate for。比如,我要生成一个参数化的加法树:

module adder_tree #(
    parameter NUM_INPUTS = 4,
    parameter WIDTH      = 8
)(
    input  [WIDTH-1:0] data_in [0:NUM_INPUTS-1],
    output [WIDTH-1:0] sum
);

    genvar i;
    wire [WIDTH-1:0] temp [0:NUM_INPUTS/2-1];

    generate
        for (i = 0; i < NUM_INPUTS/2; i = i + 1) begin : gen_add
            assign temp[i] = data_in[2*i] + data_in[2*i+1];
        end
    endgenerate

    // 这里可以继续用 generate 做下一级加法...
    // 实际项目中会用递归或流水线,这里只是演示

endmodule

注意那个 begin : gen_add,我给这个生成块起了个名字。为什么?因为仿真时方便看波形,也方便调试。我曾经有一次没起名字,结果仿真器里报错说找不到信号,找了半天才发现是generate块没命名。

4.2.2 generate if/case:条件生成

有时候,同一个模块要根据参数不同,生成不同的电路。比如:

generate
    if (WIDTH <= 8) begin : impl_small
        // 用简单的加法器实现
    end else begin : impl_large
        // 用流水线加法器实现
    end
endgenerate

这就像C语言里的 #ifdef,但它是硬件描述,综合器会根据条件“裁剪”电路。

注意: generate 块里的循环变量必须用 genvar 声明,不能用 integer。这是Verilog的硬性规定,别问我为什么,问就是语法规定。

4.3 任务与函数:把重复代码“封装”起来

写代码最怕什么?重复。同样的逻辑写好几遍,改一个地方得改所有地方。在Verilog里,taskfunction 就是用来干这个的。

4.3.1 function:纯组合逻辑的“计算器”

function 只能用于组合逻辑,不能包含时序控制(比如 @(posedge clk))。它返回一个值。比如,一个计算奇偶校验的函数:

function parity;
    input [7:0] data;
    integer i;
    begin
        parity = 0;
        for (i = 0; i < 8; i = i + 1) begin
            parity = parity ^ data[i];
        end
    end
endfunction

调用的时候:

assign parity_bit = parity(data_in);

4.3.2 task:可以包含时序的“小模块”

taskfunction 灵活,它可以包含延时、等待等时序控制。但它不返回值,而是通过输出参数传递结果。比如,一个产生特定脉冲的task:

task pulse_gen;
    input  [7:0] pulse_width;
    output reg   pulse_out;
    begin
        pulse_out = 1;
        #pulse_width;
        pulse_out = 0;
    end
endtask

调用:

pulse_gen(10, my_pulse);  // 产生一个宽度为10个时间单位的脉冲
我的经验: 在可综合的代码里,我很少用 task,因为它容易写出不可综合的代码。但仿真验证时,task 是神器,尤其是写testbench的时候。比如,模拟一个I2C的读写操作,用task封装起来,调用起来特别爽。

4.4 状态机基础:数字系统的“大脑”

终于到了状态机。说实话,我觉得状态机是FPGA设计的灵魂。你想想看,一个复杂的控制逻辑,比如UART的收发、SPI的时序、甚至一个CPU的指令执行,本质上都是状态机。

4.4.1 三段式状态机:最推荐的写法

状态机的写法有很多种,但我个人强烈推荐“三段式”。为什么?因为它把“状态跳转”、“状态输出”、“次态逻辑”分得清清楚楚,代码可读性高,也容易维护。

看一个经典的例子:一个简单的交通灯控制器。

module traffic_light (
    input  clk,
    input  rst_n,
    output reg [2:0] light  // {red, yellow, green}
);

    // 状态编码
    localparam IDLE    = 2'b00;
    localparam GREEN   = 2'b01;
    localparam YELLOW  = 2'b10;
    localparam RED     = 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
        case (state)
            IDLE:    next_state = GREEN;
            GREEN:   next_state = YELLOW;
            YELLOW:  next_state = RED;
            RED:     next_state = GREEN;
            default: next_state = IDLE;
        endcase
    end

    // 第三段:输出逻辑(组合逻辑或时序逻辑)
    always @(*) begin
        case (state)
            GREEN:  light = 3'b001;  // 绿灯
            YELLOW: light = 3'b010;  // 黄灯
            RED:    light = 3'b100;  // 红灯
            default: light = 3'b000;
        endcase
    end

endmodule

你看,三段各司其职:

  • 第一段:只负责把 next_state 锁存到 state,纯时序。
  • 第二段:根据当前状态和输入,决定下一个状态,纯组合。
  • 第三段:根据当前状态,产生输出。

4.4.2 状态编码的选择

状态编码不是随便选的。常用的有三种:

编码方式 特点 适用场景
二进制编码 状态数少,节省寄存器 状态数很多(比如几十个)
格雷码 相邻状态只变1位,抗毛刺 跨时钟域传递状态
独热码 每个状态一个寄存器,译码简单 状态数少(一般<10),追求速度

我个人习惯:如果状态少于8个,用独热码;多于8个,用二进制编码。格雷码嘛,只有在跨时钟域时才用。

避坑指南: 我曾经在一个项目里用了二进制编码,状态有16个。结果综合后时序不满足,因为状态译码的组合逻辑路径太长了。后来改成独热码,虽然寄存器多了,但组合逻辑变浅了,时序就过了。所以,编码方式的选择,本质是“面积换速度”的权衡。

4.5 本章知识体系

说了这么多,咱们用一张图来总结一下这一章的核心内容:

Verilog基础语法(下)知识体系 参数化设计 • parameter • localparam • 实例化传参 #() • 默认值设置 • 模块复用 generate语句 • generate for • generate if • generate case • genvar声明 • 命名块 任务与函数 • function • task • 组合 vs 时序 • 可综合性 • 仿真封装 状态机基础 • 三段式写法 • 状态编码 • 次态逻辑 • 输出逻辑 • 独热码/格雷码 核心思想:提高代码复用性,降低维护成本 参数化 → 灵活配置 | generate → 批量生成 | 任务函数 → 封装复用 | 状态机 → 控制逻辑

嗯,这一章的内容确实不少。参数化设计让你的模块更灵活,generate语句帮你批量“造电路”,任务和函数让你告别重复代码,而状态机则是你设计控制逻辑的利器。这些东西,我在实际项目中几乎天天用。你想想看,一个复杂的FPGA设计,如果不用参数化,不用generate,那代码量得翻多少倍?

好了,这一章就到这里。记住,写Verilog不是写C语言,你脑子里要时刻想着“电路”。参数化生成的是电路,generate生成的是电路,状态机更是电路。把这一点想通了,后面的路就好走了。