第三讲:Verilog基础(一)——模块与端口、组合逻辑、常用数据类型

各位同学,欢迎来到第三讲。今天咱们聊聊Verilog最基础的东西——模块、端口、组合逻辑,还有那两个绕不开的数据类型:wire和reg。

说实话,我刚开始学Verilog的时候,觉得这些东西太简单了,不就是画个框、连根线嘛。结果后来在项目中吃过亏,才明白基础不牢,地动山摇。今天我就把当年踩过的坑,还有后来总结的经验,一并分享给你们。

3.1 模块与端口——芯片设计的“积木块”

Verilog里,模块(module)就是最基本的设计单元。你可以把它想象成一块乐高积木——每个模块有自己的功能,有自己的输入输出,然后多个模块拼在一起,就成了一个完整的系统。

我个人习惯,写模块之前先在纸上画个框图。把输入信号、输出信号、双向信号都标清楚。别小看这一步,我见过太多人上来就写代码,写到一半发现端口少了,或者方向搞反了,回头改起来特别痛苦。

看一个最简单的例子——一个与门模块:

module and_gate (
    input  wire a,      // 输入端口a
    input  wire b,      // 输入端口b
    output wire y       // 输出端口y
);

    assign y = a & b;   // 组合逻辑:y等于a与b

endmodule

这里有几个要点:

  • moduleendmodule 成对出现,就像C语言里的花括号
  • 端口声明有三种方向:inputoutputinout(双向,用的少,但很重要)
  • 每个端口都要指定数据类型,默认是wire,但我建议显式写出来,代码更清晰
⚠️ 我曾经犯过的错: 端口列表里写了10个信号,结果代码里只用了9个。综合工具报了个warning,我没在意。后来调试时发现有个信号一直悬空,查了两天才找到原因。所以,永远不要忽略综合工具的warning,尤其是端口相关的。

3.2 组合逻辑——两种写法,一个目的

组合逻辑,说白了就是输出只取决于当前输入,没有记忆功能。你给它什么输入,它立马给你什么输出,不拖泥带水。

Verilog里实现组合逻辑,主要有两种方式:assignalways@(*)。这两种我都用,但场景不同。

3.2.1 assign——连续赋值,简单粗暴

assign 适合描述简单的逻辑关系,比如与、或、非、加法器、多路选择器。它的特点是:等号左边必须是wire类型

// 一个2选1多路选择器
module mux2to1 (
    input  wire [3:0] data0,
    input  wire [3:0] data1,
    input  wire       sel,
    output wire [3:0] out
);

    assign out = sel ? data1 : data0;

endmodule

你看,一行assign就搞定了。简洁、直观、不容易出错。我个人习惯,能用assign的地方绝不用always,因为assign的仿真行为更确定,不容易出现“仿真与综合不一致”的坑。

3.2.2 always@(*)——敏感列表,自动推导

当逻辑变得复杂,比如需要if-else、case语句时,就得用 always@(*) 了。这里的 * 表示“所有输入信号”,综合工具会自动推导敏感列表。

嗯,这里要注意:always@(*) 里赋值的变量,必须是reg类型。但别误会,这里的reg并不代表寄存器,它只是一个语法要求。在组合逻辑中,reg综合出来还是组合电路。

// 用always实现同样的2选1多路选择器
module mux2to1_always (
    input  wire [3:0] data0,
    input  wire [3:0] data1,
    input  wire       sel,
    output reg  [3:0] out
);

    always@(*) begin
        if (sel)
            out = data1;
        else
            out = data0;
    end

endmodule
💡 我的经验: 写always@(*)时,一定要确保所有分支都被覆盖。比如if-else要写全,case要加default。否则综合工具会给你生成一个锁存器(latch),而锁存器在FPGA里通常是不想要的。我曾经因为漏写了一个else,导致整个模块的时序全乱了,查了整整一个下午。

3.3 常用数据类型——wire和reg,别再傻傻分不清

很多初学者被wire和reg搞晕了。我当年也一样,觉得“reg就是寄存器,wire就是连线”。其实这个理解不完全对

特性 wire reg
默认值 高阻态(z) 不定态(x)
赋值方式 assign 连续赋值 always 或 initial 过程赋值
物理含义 连线、三态门 组合逻辑或时序逻辑
能否存储值 不能 能(在always块内保持)

说白了:

  • wire 就是一根导线,它本身不存值,值由驱动源决定。如果没驱动,就是高阻z。
  • reg 是一个变量,可以在always块里被反复赋值。在组合逻辑中,它综合出来还是连线;在时序逻辑中,它综合出来就是触发器。

你想想看,为什么会有这种设计?其实是为了仿真。在仿真时,reg可以保持上一次赋的值,而wire不行。所以testbench里经常用reg来驱动输入,用wire来观察输出

🔑 记住这个黄金法则:

  • assign 左边 → wire
  • always 左边 → reg
  • 模块端口 → 默认wire,但output可以声明为reg

3.4 实战小例子——一个4位加法器

光说不练假把式。咱们写一个完整的4位加法器,把今天学的都用上。

module adder_4bit (
    input  wire [3:0] a,      // 加数a
    input  wire [3:0] b,      // 加数b
    input  wire       cin,    // 进位输入
    output wire [3:0] sum,    // 和
    output wire       cout    // 进位输出
);

    // 用assign实现,简单直接
    assign {cout, sum} = a + b + cin;

endmodule

这里用了一个拼接操作符 {},把cout和sum拼在一起,正好是5位结果。你看,一行代码就搞定了加法器。这就是Verilog的魅力——硬件描述语言,描述的是行为,不是结构

如果换成always写法:

module adder_4bit_always (
    input  wire [3:0] a,
    input  wire [3:0] b,
    input  wire       cin,
    output reg  [3:0] sum,
    output reg        cout
);

    always@(*) begin
        {cout, sum} = a + b + cin;
    end

endmodule

两种写法综合出来的电路是一样的。我个人更倾向于第一种,因为代码更少,阅读起来更轻松。

3.5 避坑指南——我踩过的那些雷

最后,分享几个我实际项目中遇到的坑,你们以后遇到了,至少知道往哪个方向查。

  1. 端口位宽不匹配:比如模块定义的是[7:0],你实例化时连了个[3:0]的信号。综合工具会截断或补零,但行为可能不是你想要的。我的习惯是,所有端口位宽都用parameter定义,这样改起来方便,也不容易出错。
  2. always@(*) 里漏了敏感信号:虽然用 * 可以自动推导,但如果你在always块里用了函数调用或者多维数组,有些老版本工具可能推导不全。保险起见,检查一下综合报告里的“inferred latch”,如果有,赶紧改。
  3. reg类型在仿真中初始为x:如果你在testbench里没给reg赋初值,仿真一开始它就是x。然后所有跟它相关的信号都会变成x,整个仿真就废了。所以,写testbench时,记得在initial块里给reg赋初值
⚠️ 我曾经踩过的一个大坑: 有一次做项目,我用always@(*)写了一个复杂的组合逻辑,里面用了case语句。结果有一个case分支没写default,综合工具给我生成了一个锁存器。当时仿真没问题,但上板测试时,某些输入组合下输出一直保持上一次的值,怎么都不对。后来用ChipScope抓波形才发现是锁存器在作怪。从那以后,我写case必加default,写if-else必写全。

好了,今天的内容就到这里。模块与端口是Verilog的骨架,组合逻辑是血肉,wire和reg是细胞。把这些基础打牢,后面学时序逻辑、状态机、FIFO什么的,就会轻松很多。

下一讲,咱们聊聊时序逻辑——always@(posedge clk) 和 非阻塞赋值。到时候我会分享一个关于“仿真与综合不一致”的经典案例,保证让你们印象深刻。

下课!