3、硬件描述语言基础(Verilog):模块与端口、数据类型、赋值方式

各位同学,咱们今天聊聊 Verilog 最核心的几个基础概念。说实话,很多新手觉得 Verilog 就是写代码,跟 C 语言差不多。但我要告诉你——千万别这么想。Verilog 描述的是硬件,是实实在在的电路。你写的每一行代码,最终都会变成门、触发器和连线。

我刚开始学的时候也犯过这个错。把 Verilog 当软件写,结果综合出来的电路完全不是那么回事。后来被 mentor 骂了几次,才真正理解硬件描述语言的本质。今天咱们就把这些基础打牢。

3.1 模块与端口:Verilog 的基本单元

模块(module)是 Verilog 中最基本的构建块。你可以把它想象成一个芯片——有输入引脚、输出引脚,内部有逻辑电路。一个完整的 FPGA 设计,就是由无数个模块拼装起来的。

模块的基本结构:

module 模块名 (
    input  端口1,   // 输入端口
    output 端口2,   // 输出端口
    inout  端口3    // 双向端口
);
    // 内部逻辑写在这里
endmodule

端口类型有三种:input、output、inout。我个人习惯把端口声明写在括号里,这样看起来更紧凑。当然你也可以写在模块内部,看团队规范。

举个例子,一个最简单的 D 触发器模块:

module d_flip_flop (
    input  clk,    // 时钟
    input  d,      // 数据输入
    output reg q   // 数据输出
);
    always @(posedge clk) begin
        q <= d;
    end
endmodule

这里有个细节——output reg q。为什么输出要声明成 reg?因为我们在 always 块里对它赋值。这个后面会细讲。

我的经验:模块命名要规范。我一般用下划线分隔单词,比如 uart_rx、spi_master。端口顺序也有讲究——时钟和复位放最前面,数据信号放后面。这样别人看你的代码,一眼就能找到关键信号。

3.2 数据类型:wire 与 reg

这是新手最容易搞混的地方。说白了:

  • wire:相当于一根导线。它不存储值,只是把驱动端的值传递出去。
  • reg:相当于一个寄存器。它能存储值,在 always 块或 initial 块中被赋值。

但这里有个坑——reg 不一定会被综合成寄存器!它只是语法上的“变量类型”。如果你在组合逻辑的 always 块里用 reg,综合出来可能只是一堆门电路,没有存储功能。

特性 wire reg
默认值 高阻态 (z) 不定态 (x)
赋值方式 连续赋值 (assign) 过程赋值 (always/initial)
综合结果 连线 寄存器或组合逻辑
能否用于 assign 可以(作为左值) 不可以
能否用于 always 不可以(作为左值) 可以(作为左值)

我曾经踩过的坑:刚做项目时,我把一个组合逻辑的输出声明成 wire,然后在 always 块里赋值。编译直接报错。后来才明白——wire 只能用 assign 赋值,reg 才能在 always 里用。这个规则一定要记住。

再补充一点:wire 和 reg 都可以是多位的。比如 wire [7:0] data_bus 就是一个 8 位宽的总线。

3.3 连续赋值与过程赋值

这两种赋值方式,对应着两种不同的电路描述思路。

3.3.1 连续赋值(assign)

连续赋值用 assign 关键字。它描述的是组合逻辑——输出随输入变化,没有时钟参与。说白了,就是一根带逻辑的导线。

wire a, b, c;
assign c = a & b;  // c 是 a 和 b 的与运算结果

这里 c 必须声明为 wire。只要 a 或 b 变化,c 立即更新。这就是“连续”的含义——赋值语句一直在执行,没有时间概念。

3.3.2 过程赋值(always)

过程赋值写在 always 块里。它描述的是时序逻辑或组合逻辑,但需要敏感列表来触发。

// 时序逻辑:D 触发器
always @(posedge clk) begin
    q <= d;
end

// 组合逻辑:多路选择器
always @(*) begin
    if (sel)
        y = a;
    else
        y = b;
end

注意看——第一个例子用 posedge clk,综合出来是触发器。第二个例子用 @(*),综合出来是组合逻辑。敏感列表决定了电路的行为。

我的建议:写组合逻辑时,尽量用 assign 而不是 always @(*)。为什么?因为 assign 更直观,不容易漏掉敏感信号。我见过太多人写 always @(a or b) 结果漏了 c,导致仿真和综合不一致。用 @(*) 虽然能自动推导,但代码可读性不如 assign。

3.4 阻塞赋值与非阻塞赋值

这是 Verilog 里最核心、也最容易出错的地方。很多面试题都爱考这个。咱们把它彻底讲明白。

3.4.1 阻塞赋值(=)

阻塞赋值用 =。它的特点是:顺序执行,立即生效。也就是说,上一条赋值语句执行完,下一条才能开始。这跟 C 语言很像。

always @(posedge clk) begin
    a = b;
    c = a;  // c 得到的是 b 的值(因为 a 已经被更新了)
end

这段代码综合出来是什么?两个寄存器串联——a 存 b 的值,c 存 a 的值(也就是 b 的值)。但注意,它们用的是同一个时钟沿。

3.4.2 非阻塞赋值(<=)

非阻塞赋值用 <=。它的特点是:并行执行,延迟生效。所有赋值语句在同一个时间点计算右值,然后同时更新左值。

always @(posedge clk) begin
    a <= b;
    c <= a;  // c 得到的是 a 的旧值(更新前的值)
end

这段代码综合出来是什么?两个寄存器——a 存 b 的值,c 存 a 的旧值。这跟阻塞赋值的结果完全不同!

特性 阻塞赋值 (=) 非阻塞赋值 (<=)
执行顺序 顺序执行 并行执行
更新时机 立即更新 块结束时更新
适用场景 组合逻辑 时序逻辑
综合结果 取决于上下文 触发器

铁律:时序逻辑用非阻塞赋值(<=),组合逻辑用阻塞赋值(=)。千万不要混用!我曾经在一个项目里看到有人把阻塞赋值用在时序逻辑里,结果仿真通过,综合出来的电路功能完全不对。查了三天才找到原因。

3.4.3 为什么会有这种区别?

你想想看——硬件电路里,所有触发器都是在同一个时钟沿同时采样的。非阻塞赋值正好模拟了这种并行行为。而阻塞赋值是顺序执行的,更适合描述组合逻辑的传播延迟。

举个例子,一个移位寄存器:

// 正确写法:非阻塞赋值
always @(posedge clk) begin
    q1 <= d;
    q2 <= q1;
    q3 <= q2;
end

// 错误写法:阻塞赋值
always @(posedge clk) begin
    q1 = d;
    q2 = q1;  // 这里 q1 已经被更新了
    q3 = q2;  // 这里 q2 也被更新了
end

用阻塞赋值,三个寄存器在同一个时钟沿全部更新为 d 的值——这根本不是移位寄存器!而是一个三位的并行寄存器。

3.5 知识体系总览

下面这张图把本章的核心知识点串起来了。你可以把它当作一个快速参考。

Verilog 基础概念关系图 Verilog 基础概念关系图 Verilog 硬件描述 模块与端口 数据类型 赋值方式 input output inout wire reg 阻塞 = 非阻塞 <= 核心原则:时序逻辑用非阻塞赋值,组合逻辑用阻塞赋值 wire 用于连续赋值,reg 用于过程赋值

3.6 实战建议

学完这些基础,我建议你动手写几个小模块练练手:

  1. 写一个 8 位计数器——用非阻塞赋值,注意复位逻辑。
  2. 写一个 4 选 1 多路选择器——用连续赋值 assign 实现。
  3. 写一个移位寄存器——对比阻塞和非阻塞赋值的区别。

仿真的时候,仔细观察波形。你会发现非阻塞赋值的结果跟你的直觉可能不太一样。嗯,这就是硬件思维——所有事情都在时钟沿同时发生。

最后说一句:Verilog 不是 C 语言。写代码的时候,脑子里要想着电路——这个信号是连线还是寄存器?这个赋值是组合逻辑还是时序逻辑?想清楚了再下笔。我见过太多人把 Verilog 写成 C,结果综合出来的电路一塌糊涂。