3. 硬件描述语言基础(Verilog):模块结构、端口定义、assign语句、always块、阻塞与非阻塞赋值

好,咱们今天聊点硬核的。Verilog 这门语言,说白了就是用来描述数字电路的。你把它当成画电路图的另一种方式就行。我刚开始学的时候,总觉得它像 C 语言,后来吃了不少亏才明白——这玩意儿是并行的,不是顺序执行的。

这一节,我会把 Verilog 最核心的几个概念掰开揉碎了讲。你只要把这几个点吃透,后面写 CPLD 代码基本不会卡壳。

3.1 模块结构:电路的最小单元

Verilog 里最基本的单位叫 模块(module)。一个模块就是一个电路块,可以是一个与门,也可以是一个复杂的计数器。

模块长这样:

module 模块名 (端口列表);
    // 内部逻辑
endmodule

我个人习惯,模块名用大写字母开头,比如 Counter8LedDriver。这样一眼就能看出是模块,不是变量。

举个例子,一个最简单的与门模块:

module AndGate (a, b, y);
    input  a;
    input  b;
    output y;

    assign y = a & b;
endmodule

你看,moduleendmodule 就像一对括号,把整个电路包在里面。所有逻辑都写在这对关键字之间。

小技巧: 我建议每个文件只放一个模块,文件名和模块名保持一致。这样找代码的时候不会抓狂。

3.2 端口定义:电路的输入输出

端口就是模块跟外界打交道的通道。有三种类型:

端口方向 关键字 说明
输入 input 信号从外部流入模块
输出 output 信号从模块流出到外部
双向 inout 既可以输入也可以输出(慎用)

端口定义有两种写法。老式写法:

module Mux2 (sel, a, b, y);
    input  sel;
    input  [3:0] a, b;
    output [3:0] y;
    ...
endmodule

新式写法(ANSI 风格):

module Mux2 (
    input  wire       sel,
    input  wire [3:0] a,
    input  wire [3:0] b,
    output reg  [3:0] y
);
    ...
endmodule

我个人更推荐新式写法。为什么?因为端口类型和位宽写在一起,一目了然。我在项目中见过太多因为端口定义混乱导致的低级错误了。

注意: inout 端口在 CPLD 里要特别小心。我曾经在一个项目里用了 inout,结果因为三态控制没处理好,两个输出同时使能,直接把引脚烧了。嗯,从那以后我对 inout 就格外谨慎。

3.3 assign 语句:组合逻辑的直通车

assign 语句用来描述组合逻辑。说白了,就是输入一变,输出立刻跟着变,没有延迟(理想情况下)。

语法很简单:

assign 信号名 = 表达式;

举个例子,一个 4 位加法器:

module Add4 (a, b, sum);
    input  [3:0] a, b;
    output [3:0] sum;

    assign sum = a + b;
endmodule

你想想看,这就像把 a 和 b 直接连到一个加法器的输入端,sum 就是加法器的输出。硬件上就是这么干的。

assign 语句有几个特点:

  • 左边必须是 wire 类型(线网类型)
  • 多个 assign 语句是 并行执行
  • 不能用在 always 块里面

核心要点: assign 描述的是纯组合逻辑。如果你想要寄存器(触发器),就得用 always 块。

3.4 always 块:时序逻辑的舞台

always 块是 Verilog 里最强大的结构。它既可以描述组合逻辑,也可以描述时序逻辑。关键看你怎么用。

基本语法:

always @ (敏感列表) begin
    // 逻辑代码
end

敏感列表决定了 always 块什么时候执行。最常见的两种:

  • 组合逻辑: always @ (*) —— 所有输入信号变化都触发
  • 时序逻辑: always @ (posedge clk) —— 时钟上升沿触发

举个例子,一个带使能的 D 触发器:

module DffEn (clk, rst_n, en, d, q);
    input  clk, rst_n, en;
    input  d;
    output reg q;

    always @ (posedge clk or negedge rst_n) begin
        if (!rst_n)
            q <= 1'b0;
        else if (en)
            q <= d;
    end
endmodule

这里要注意几点:

  • 输出 q 声明为 reg 类型(在 always 块里赋值的信号必须是 reg)
  • 异步复位用了 negedge rst_n(低电平有效)
  • 敏感列表里同时有时钟和复位
我的经验: 写 always 块时,敏感列表一定要写全。漏掉一个信号,综合出来的电路可能跟你想象的不一样。我曾经漏了一个使能信号,结果仿真和实际跑起来完全两码事,查了整整两天。

3.5 阻塞与非阻塞赋值:新手最容易踩的坑

这个知识点,我敢说 90% 的新手都在这上面翻过车。我自己也不例外。

Verilog 有两种赋值方式:

赋值方式 符号 特点
阻塞赋值 = 立即执行,顺序执行
非阻塞赋值 <= 并行执行,延迟赋值

黄金法则:

  • 描述 组合逻辑 用阻塞赋值 =
  • 描述 时序逻辑 用非阻塞赋值 <=

为什么?我举个例子你就明白了。

假设我们要实现一个移位寄存器,把 a 的值赋给 b,再把 b 的值赋给 c。

错误写法(用阻塞赋值):

always @ (posedge clk) begin
    b = a;  // b 立刻变成 a
    c = b;  // c 变成 b(此时 b 已经是 a 了)
end

结果:b 和 c 同时变成了 a。这不是移位,这是复制。

正确写法(用非阻塞赋值):

always @ (posedge clk) begin
    b <= a;  // 记录要赋值,但不立即执行
    c <= b;  // 记录要赋值,但不立即执行
end

结果:时钟上升沿到来时,所有赋值同时发生。b 拿到 a 的值,c 拿到 b 原来的值。这才是真正的移位寄存器。

避坑指南: 我曾经在一个项目里,把 always 块里的非阻塞赋值写成了阻塞赋值。结果仿真通过,上板子就跑飞了。为什么?因为综合工具把阻塞赋值解释成了组合逻辑,生成了 latch(锁存器)。latch 这东西,能不用就不用,时序难控,还容易出毛刺。

总结一下:

  • always @ (*) 里用 =(组合逻辑)
  • always @ (posedge clk) 里用 <=(时序逻辑)
  • 同一个 always 块里,不要混用两种赋值方式

3.6 实战小例子:边沿检测器

最后,我分享一个我常用的电路——边沿检测器。它能检测信号的上升沿或下降沿。

module EdgeDetect (
    input  clk,
    input  rst_n,
    input  sig_in,
    output pos_edge,
    output neg_edge
);
    reg sig_dly;

    always @ (posedge clk or negedge rst_n) begin
        if (!rst_n)
            sig_dly <= 1'b0;
        else
            sig_dly <= sig_in;
    end

    assign pos_edge = sig_in & ~sig_dly;  // 上升沿
    assign neg_edge = ~sig_in & sig_dly;  // 下降沿
endmodule

原理很简单:用寄存器打一拍,把当前值和前一拍的值做比较。如果当前是 1,前一拍是 0,就是上升沿。

这个电路我在按键消抖、脉冲捕获等场景里用了无数次,非常实用。

本节要点回顾:

  • 模块是 Verilog 的基本单元,用 moduleendmodule 包裹
  • 端口分 input、output、inout 三种
  • assign 描述组合逻辑,always 描述时序逻辑
  • 组合逻辑用阻塞赋值 =,时序逻辑用非阻塞赋值 <=
  • 敏感列表要写全,否则综合结果可能不对

好了,这一节的内容就到这。下一节我们会讲 Verilog 的运算符和常用结构,到时候再聊。