3. 硬件描述语言基础(Verilog):模块结构、端口定义、assign语句、always块、阻塞与非阻塞赋值
好,咱们今天聊点硬核的。Verilog 这门语言,说白了就是用来描述数字电路的。你把它当成画电路图的另一种方式就行。我刚开始学的时候,总觉得它像 C 语言,后来吃了不少亏才明白——这玩意儿是并行的,不是顺序执行的。
这一节,我会把 Verilog 最核心的几个概念掰开揉碎了讲。你只要把这几个点吃透,后面写 CPLD 代码基本不会卡壳。
3.1 模块结构:电路的最小单元
Verilog 里最基本的单位叫 模块(module)。一个模块就是一个电路块,可以是一个与门,也可以是一个复杂的计数器。
模块长这样:
module 模块名 (端口列表);
// 内部逻辑
endmodule
我个人习惯,模块名用大写字母开头,比如 Counter8、LedDriver。这样一眼就能看出是模块,不是变量。
举个例子,一个最简单的与门模块:
module AndGate (a, b, y);
input a;
input b;
output y;
assign y = a & b;
endmodule
你看,module 和 endmodule 就像一对括号,把整个电路包在里面。所有逻辑都写在这对关键字之间。
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(低电平有效) - 敏感列表里同时有时钟和复位
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 @ (*) 里用
=(组合逻辑) - 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 的基本单元,用
module和endmodule包裹 - 端口分 input、output、inout 三种
- assign 描述组合逻辑,always 描述时序逻辑
- 组合逻辑用阻塞赋值
=,时序逻辑用非阻塞赋值<= - 敏感列表要写全,否则综合结果可能不对
好了,这一节的内容就到这。下一节我们会讲 Verilog 的运算符和常用结构,到时候再聊。