3. Verilog基础(一):模块结构、端口定义、数据类型、参数化设计、always块与assign语句

各位同学,欢迎来到Verilog基础的第一讲。说实话,每次带新人时,我总会先问一个问题:“你觉得Verilog是硬件描述语言,还是硬件设计语言?” 很多人会愣住。其实,它既是描述,也是设计。你写下的每一行代码,最终都会变成实实在在的电路。所以,从一开始,我们就要带着“电路思维”去写代码。

今天这一章,我们先把地基打牢。我会带你过一遍模块结构、端口、数据类型这些最基础的东西。别觉得枯燥,这些细节决定了你未来能不能写出“可综合”的、不出bug的代码。

3.1 模块结构:一切设计的起点

在FPGA的世界里,模块(module) 就是最小的设计单元。你可以把它想象成一个黑盒子,有输入、有输出,内部封装了具体的逻辑功能。

一个完整的模块结构长这样:

module 模块名 (
    端口列表
);
    // 端口声明
    // 内部信号声明
    // 逻辑功能描述
endmodule

我个人习惯,moduleendmodule 一定要对齐。代码的层次感,说白了就是可读性。你想想看,一个几千行的模块,如果缩进乱七八糟,调试起来得多痛苦。

小技巧: 模块名最好用下划线分隔,比如 uart_topspi_master。别用驼峰命名,容易和系统函数混淆。

3.2 端口定义:模块的“嘴巴”和“耳朵”

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

  • input:输入端口,只能从外部读入数据。
  • output:输出端口,只能从内部驱动出去。
  • inout:双向端口,既能输入也能输出(比如I2C的SDA线)。

这里有个坑,我刚开始做设计时踩过:inout 端口内部必须用三态门驱动,否则综合会报错。我们来看个例子:

module my_module (
    input  wire       clk,      // 时钟
    input  wire       rst_n,    // 复位,低有效
    input  wire [7:0] data_in,  // 8位输入数据
    output reg  [7:0] data_out, // 8位输出数据
    inout  wire       sda       // 双向数据线
);
    // 内部逻辑
endmodule

注意看,data_out 我声明成了 output reg。为什么?因为我要在 always 块里给它赋值。如果只是用 assign 语句,那声明成 output wire 就行。嗯,这里要记住:端口类型要和驱动方式匹配

3.3 数据类型:wire 与 reg 的“爱恨情仇”

这是新手最容易搞混的地方。我直接说结论:

数据类型 本质 驱动方式 典型用途
wire 线网型,相当于物理连线 只能被 assign 或模块端口驱动 组合逻辑、模块间连线
reg 寄存器型,代表存储单元 只能在 always 块中被赋值 时序逻辑、状态机

但是!注意这个但是——reg 不一定会综合成寄存器。如果你在 always 块里写的是组合逻辑(比如 always @(*)),那 reg 综合出来还是连线。我曾经见过有人把 reg 和“寄存器”划等号,结果综合出来的面积比预期大了一倍。

核心原则: 用 wire 还是 reg,取决于你怎么驱动它,而不是你想让它变成什么。

3.4 参数化设计:让模块“活”起来

写死代码是最容易的,但也是最蠢的。参数化设计,说白了就是给模块留个“旋钮”,让你能随时调整位宽、深度、计数阈值等。

Verilog 里用 parameter 关键字定义参数:

module counter #(
    parameter WIDTH = 8,   // 计数器位宽,默认8位
    parameter MAX   = 255  // 计数最大值
) (
    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 <= 0;
        else if (count == MAX)
            count <= 0;
        else
            count <= count + 1;
    end
endmodule

实例化时,你可以覆盖默认参数:

counter #(
    .WIDTH(16),
    .MAX  (50000)
) u_counter (
    .clk  (clk),
    .rst_n(rst_n),
    .count(cnt_16bit)
);

我在项目中遇到过,一个 SPI 控制器因为位宽写死,导致换芯片后全部重写。从那以后,我所有模块都强制加参数化设计。你想想看,改一个参数和改几百行代码,哪个划算?

警告: 参数名不要和内部信号名重复,否则综合工具会报“redeclaration”错误。我习惯用全大写命名参数,比如 DATA_WIDTHFIFO_DEPTH

3.5 always 块与 assign 语句:两种驱动方式

这是 Verilog 里最核心的两个语法结构。简单来说:

  • assign:连续赋值,用于组合逻辑。等号左边必须是 wire 类型。
  • always:过程赋值,用于组合或时序逻辑。等号左边必须是 reg 类型。

先看 assign 的例子:

assign data_out = (sel) ? data_a : data_b;

这行代码综合出来就是一个多路选择器。assign 语句是并行执行的,和代码顺序无关。

再看 always 块:

// 时序逻辑:带同步复位的D触发器
always @(posedge clk) begin
    if (rst_n)
        q <= 0;
    else
        q <= d;
end

// 组合逻辑:译码器
always @(*) begin
    case (sel)
        2'b00: y = 4'b0001;
        2'b01: y = 4'b0010;
        2'b10: y = 4'b0100;
        2'b11: y = 4'b1000;
        default: y = 4'b0000;
    endcase
end

注意两个细节:

  1. 时序逻辑用 posedge clknegedge clk 触发,组合逻辑用 @(*) 自动推导敏感列表。
  2. 时序逻辑用 <=(非阻塞赋值),组合逻辑用 =(阻塞赋值)。混用会导致仿真和综合结果不一致。
避坑指南: 我曾经在一个 always 块里同时写了阻塞和非阻塞赋值,结果仿真波形看起来没问题,但综合后功能完全不对。查了两天才发现是赋值方式混用了。所以,一个 always 块里只用一种赋值方式

3.6 本章知识体系

为了让你更直观地理解本章内容,我画了一张结构图:

Verilog 基础(一) 模块结构 module ... endmodule 端口定义 input / output / inout 数据类型 wire / reg 参数化设计 parameter / #() always 块 时序 / 组合逻辑 assign 语句 连续赋值 核心:用电路思维写代码 wire/reg 选择取决于驱动方式,而非最终形态

这张图把本章的核心知识点串起来了。你可以看到,模块结构是骨架,端口是接口,数据类型是血液,参数化是灵活性,而 always 和 assign 是驱动逻辑的两种方式。它们共同构成了一个完整的 Verilog 设计单元。

好了,这一章的内容就到这里。记住,基础不牢,地动山摇。下一章我们会深入运算符和表达式,到时候你会发现,今天学的这些概念会反复用到。好好消化,我们下次见。


专注资料整理