3. 硬件描述语言基础(Verilog):模块结构、数据类型、运算符、assign语句、always块、组合逻辑与时序逻辑

好,咱们进入正题。Verilog 这玩意儿,说白了就是硬件工程师的“普通话”。你写出来的每一行代码,最终都会变成实实在在的门电路、触发器和连线。这跟写软件完全是两码事——软件是跑在 CPU 上的指令,而 Verilog 描述的是硬件本身的结构和行为。

我个人习惯把 Verilog 看作“画电路图的另一种方式”。你想想看,画原理图时,你需要芯片(模块)、导线(连线)、逻辑门(运算)。Verilog 里,这些东西都有对应的语法。今天咱们就把最核心的几个概念捋清楚。

3.1 模块结构:你的第一个“芯片”

一个 Verilog 模块,就是一个硬件单元。小到一个与门,大到整个 CPU,都可以用一个模块来描述。模块的基本骨架长这样:

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

嗯,这里要注意:module 和 endmodule 是成对出现的,就像括号一样,千万别漏了。端口列表里,input、output 定义了信号的方向。还有一个 inout,双向端口,用的少,但做总线接口时会碰到。

我在项目中遇到过新手把端口方向搞反,结果仿真怎么都不对。查了半天,原来是输出信号写成了 input。这种低级错误,一查一个准。

核心要点: 模块名最好跟文件名一致,这是行业惯例。端口列表里,每个信号都要明确方向。

3.2 数据类型:线网 vs 变量

Verilog 的数据类型,主要就两类:wire 和 reg。很多人一开始搞不清它们的区别,我当年也迷糊过。

  • wire(线网):代表物理连线。它本身不存储值,只是把驱动端的值传递过来。比如 assign 语句的输出,或者模块端口的连接,都用 wire。
  • reg(寄存器):代表存储单元。它可以在 always 块里被赋值,并且会保持上一次的值,直到被新的赋值覆盖。

但是!这里有个大坑:reg 不一定真的综合成寄存器。如果你在 always 块里写的是组合逻辑,那 reg 综合出来可能只是一堆门电路,而不是触发器。说白了,reg 是语法上的“变量”,不是硬件上的“寄存器”。

我建议你一开始就这么记:

使用场景 数据类型
assign 语句左侧 wire
always 块内被赋值的信号 reg
模块端口连接 wire(默认)

另外,还有 integer、time 这些,但做逻辑设计时用得少。咱们先把 wire 和 reg 玩明白。

3.3 运算符:跟 C 语言差不多,但有区别

Verilog 的运算符,大部分跟 C 语言一样。比如 +、-、*、/、&、|、^、~。但有几个地方要特别小心:

  • 位宽问题:两个 4 位宽的数相加,结果默认是 4 位宽,高位会被截断。如果你想保留进位,必须把结果定义成 5 位宽。
  • 拼接运算符 {}:这个很实用。比如 {a, b} 可以把两个信号拼在一起。我经常用它来组合数据总线。
  • 三目运算符 ?::写组合逻辑时,可以替代简单的 if-else,代码更简洁。

举个例子:

wire [3:0] a, b;
wire [4:0] sum;
assign sum = a + b;  // 注意:a+b 是 4 位,赋值给 5 位的 sum,高位补 0

我曾经在做一个加法器时,忘了考虑进位,结果仿真结果总是差一点。后来用 {1'b0, a} + {1'b0, b} 才搞定。嗯,细节决定成败。

3.4 assign 语句:连续赋值,组合逻辑的基石

assign 语句,说白了就是“只要输入变了,输出立刻跟着变”。它描述的是组合逻辑,没有时钟,没有边沿。比如:

assign y = (a & b) | c;

这行代码综合出来就是一个与或门。只要 a、b、c 任何一个变化,y 会立即更新。注意,assign 的左边必须是 wire 类型。

小技巧: 如果你要描述简单的组合逻辑,比如加法、比较、多路选择,用 assign 最直接。代码可读性也高。

3.5 always 块:时序逻辑与组合逻辑的“万能工具”

always 块是 Verilog 里最灵活、也最容易出错的语法。它的基本格式是:

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

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

  • 组合逻辑:敏感列表里写所有输入信号,比如 @(a or b or c)。或者用 @(*) 自动推导,我强烈推荐用这个,省事又安全。
  • 时序逻辑:敏感列表里写时钟边沿和复位,比如 @(posedge clk or negedge rst_n)。

这里有个经典错误:在同一个 always 块里,既写组合逻辑又写时序逻辑。我见过有人把 always @(posedge clk) 里的赋值,跟 always @(*) 里的赋值混在一起,结果综合出来一堆奇怪的锁存器。记住:组合逻辑和时序逻辑,最好分开写。

3.6 组合逻辑 vs 时序逻辑:本质区别

这两个概念,是数字设计的核心。我简单总结一下:

特性 组合逻辑 时序逻辑
输出依赖 只依赖当前输入 依赖当前输入和过去状态
存储能力 无 有(触发器)
时钟 不需要 需要
典型写法 assign 或 always @(*) always @(posedge clk)

举个例子,一个 D 触发器:

// 时序逻辑:在时钟上升沿,把 d 的值传给 q
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 1'b0;
    else
        q <= d;
end

而组合逻辑的写法:

// 组合逻辑:q 等于 d 取反
assign q = ~d;

你想想看,如果我把 q <= d 写成 q = d,会怎样?在时序逻辑里,用阻塞赋值 = 会导致仿真结果完全错误,甚至综合出意想不到的电路。这就是为什么我反复强调:时序逻辑用非阻塞赋值 <=,组合逻辑用阻塞赋值 =。这是铁律,别问为什么,先记住。

避坑指南: 我曾经在做一个计数器时,不小心在 always @(posedge clk) 里用了阻塞赋值。仿真看起来没问题,但上板后计数器死活不工作。查了两天才发现是赋值方式的问题。从那以后,我写时序逻辑前都会默念三遍:“非阻塞、非阻塞、非阻塞”。

3.7 知识体系总览

为了让你更直观地理解这些概念之间的关系,我画了一张图。它展示了 Verilog 基础的核心脉络:从模块结构出发,到数据类型、运算符,再到两种主要的逻辑描述方式(assign 和 always),最后落到组合逻辑与时序逻辑的区别上。

Verilog 基础核心知识体系 模块结构 (module) 数据类型 & 运算符 assign 语句 always 块 组合逻辑 vs 时序逻辑 组合逻辑:assign / always @(*) 时序逻辑:always @(posedge clk)

这张图把今天讲的内容串起来了。你写任何 Verilog 代码,都逃不出这个框架。先确定模块结构,再选数据类型,然后用 assign 或 always 描述行为,最后搞清楚你写的是组合逻辑还是时序逻辑。思路清晰了,代码就不会乱。

好了,这一章的内容就到这儿。记住:Verilog 不是软件语言,它是硬件的“图纸”。每一行代码,都要想清楚它对应什么电路。多练、多仿真、多上板,慢慢就有感觉了。