第四章 Verilog基础(一):模块结构、端口定义、数据类型、参数化设计、赋值语句

各位同学,今天咱们来聊聊Verilog最基础的东西。说实话,很多新手一上来就急着写代码,结果连模块长什么样都没搞清楚。我当年刚入行时也犯过这毛病——照着别人的代码抄,抄完发现综合出来一堆错误。嗯,咱们还是先把地基打牢。

4.1 模块结构:Verilog的基本单元

Verilog里所有的设计,说白了就是一个一个的模块(module)。你可以把模块想象成一个黑盒子:它有几个引脚(端口),里面封装了具体的逻辑功能。

一个完整的模块长这样:

module counter (
    input  wire       clk,      // 时钟
    input  wire       rst_n,    // 复位,低有效
    output reg  [3:0] count     // 4位计数器输出
);

    // 内部逻辑写在这里

endmodule

注意看,moduleendmodule 是成对出现的。我见过有人写了 module 忘了写 endmodule,结果编译报错找了半天——这种低级错误其实挺常见的。

模块内部可以包含:

  • 端口定义(input/output/inout)
  • 信号声明(wire/reg)
  • 参数定义(parameter/localparam)
  • 逻辑功能(assign/always/实例化等)

4.2 端口定义:输入、输出、双向

端口就是模块和外界通信的通道。Verilog 支持三种端口方向:

端口类型 关键字 说明
输入端口 input 只能从外部读入,模块内部不能赋值
输出端口 output 只能从模块内部驱动,外部不能赋值
双向端口 inout 既可输入也可输出,常用于总线

这里有个坑:inout 端口必须声明为 wire 类型,不能是 reg。为什么?因为双向端口需要三态驱动,而 reg 类型在综合时会被映射成寄存器,没法直接挂到总线上。我曾经在一个 I2C 控制器项目里踩过这个坑,仿真怎么都对,综合出来全是 X 态——后来才发现是 inout 端口用了 reg。

端口声明的两种写法:

// 写法一:ANSI风格(推荐)
module adder (
    input  wire [7:0] a, b,
    output wire [8:0] sum
);

// 写法二:非ANSI风格(老式)
module adder (a, b, sum);
    input  [7:0] a, b;
    output [8:0] sum;
    wire   [7:0] a, b;
    wire   [8:0] sum;

我个人习惯用 ANSI 风格,代码更紧凑,一眼就能看到端口方向和位宽。非 ANSI 风格在旧代码里常见,但新项目建议别用了。

4.3 数据类型:wire 与 reg

这是新手最容易搞混的地方。我简单说:

  • wire:连线型,用于组合逻辑。它本身不存储值,只是把两个点连起来。
  • reg:寄存器型,用于时序逻辑。它可以在 always 块里被赋值,并且保持上次的值。

但注意!reg 不一定会被综合成寄存器。如果你在 always 块里写的是组合逻辑(比如 always @(*)),reg 综合出来还是组合电路。这个误解我见过太多人犯了——以为 reg 就是触发器,其实不是。

举个例子:

// 这个 reg 综合出来是组合逻辑
reg [3:0] temp;
always @(*) begin
    temp = a + b;
end

// 这个 reg 综合出来是触发器
reg [3:0] count;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        count <= 4'd0;
    else
        count <= count + 1'b1;
end

看到了吗?同样是 reg,写法不同,综合结果天差地别。所以别被关键字骗了,要看你怎么用。

4.4 参数化设计:parameter 与 localparam

写代码最怕什么?最怕写死。比如一个计数器位宽是 8 位,下次项目要 16 位,你难道把所有代码都改一遍?

参数化设计就是解决这个问题的。用 parameter 定义可配置的常量:

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

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= {WIDTH{1'b0}};
        else
            count <= count + 1'b1;
    end

endmodule

实例化时可以覆盖参数:

counter #(.WIDTH(16)) u_counter (
    .clk   (clk),
    .rst_n (rst_n),
    .count (count_16bit)
);

localparamparameter 的区别:

  • parameter:可以在实例化时被修改,适合做可配置参数
  • localparam:只能在模块内部使用,外部不能修改,适合做内部常量

我一般把模块的位宽、频率分频系数等用 parameter,把状态机的状态编码、查找表内容用 localparam。这样接口清晰,内部实现又不会暴露给外部。

4.4 赋值语句:assign 与 always

Verilog 里赋值就两种方式:连续赋值和过程赋值。

4.4.1 assign:连续赋值

assign 用于组合逻辑,等号左边必须是 wire 类型。它描述的是「只要输入变了,输出立刻跟着变」的关系。

assign sum = a + b;
assign flag = (a > b) ? 1'b1 : 1'b0;

注意:assign 不能写在 always 块里面。它和 always 是并列的。

4.4.2 always:过程赋值

always 块可以描述组合逻辑,也可以描述时序逻辑。区别在于敏感列表怎么写:

  • 组合逻辑always @(*)always @(a or b)
  • 时序逻辑always @(posedge clk or negedge rst_n)

在 always 块里赋值,左边必须是 reg 类型。赋值符号有两种:

  • =(阻塞赋值):顺序执行,用于组合逻辑
  • <=(非阻塞赋值):并行执行,用于时序逻辑

这里有个铁律:时序逻辑用非阻塞赋值,组合逻辑用阻塞赋值。别混着用,否则仿真和综合结果可能不一致。我曾经接手过一个项目,前任把两种赋值混在一起写,结果仿真通过,上板子就乱跳——查了三天才发现是赋值方式的问题。

看个对比:

// 组合逻辑:用阻塞赋值
always @(*) begin
    temp = a & b;
    result = temp | c;
end

// 时序逻辑:用非阻塞赋值
always @(posedge clk) begin
    q1 <= d;
    q2 <= q1;
end

在时序逻辑里,q2 <= q1 拿到的是 q1 在时钟沿之前的值,而不是 q1 <= d 更新后的值。这就是非阻塞赋值的精髓——所有赋值在时钟沿同时发生。

核心总结:

  • 模块是 Verilog 的基本单元,用 module/endmodule 包裹
  • 端口分 input/output/inout,inout 必须用 wire
  • wire 用于组合逻辑,reg 用于 always 块内赋值
  • parameter 可外部修改,localparam 只能内部用
  • assign 用于组合逻辑,always 可组合可时序
  • 时序逻辑用 <=,组合逻辑用 =

个人小技巧:写模块时先把端口和参数定义好,再写内部逻辑。就像画电路板先确定接插件位置一样,结构清晰了,后面不容易乱。

避坑指南:我曾经在 always 块里同时用阻塞和非阻塞赋值,结果综合工具报了一堆 warning,仿真波形怎么看怎么不对。后来养成习惯:一个 always 块只用一种赋值方式。如果组合和时序逻辑混在一起,拆成两个 always 块。

Verilog 基础知识体系 module 模块 端口定义 input output inout 数据类型 wire reg 参数化设计 parameter localparam 赋值语句 assign(连续赋值) always(过程赋值)

这张图把本章的知识点串起来了。你看,模块是核心,往外分出端口、数据类型、参数化、赋值语句四个方向。每个方向又有自己的子知识点。学 Verilog 就是把这些东西理清楚,然后组合起来用。

好了,这一章的内容就这些。记住:写代码之前先想清楚结构,端口怎么定义,用 wire 还是 reg,参数要不要可配置,赋值用 assign 还是 always。想清楚了再动手,比边写边改要快得多。


专注资料整理