第二章:Verilog基础回顾——模块结构、数据类型、连续赋值与过程赋值、时序逻辑与组合逻辑

各位同学,欢迎来到第二章。说实话,Verilog 基础这部分,很多老工程师都觉得「这有什么好讲的」。但我带过不少新人,发现他们写出来的代码,要么仿真跑不动,要么综合出一堆莫名其妙的 latch。所以这一章,咱们把地基打牢。

2.1 模块结构——芯片设计的「积木块」

Verilog 里最基本的单元就是 module。你可以把它想象成一块乐高积木。每个模块都有输入、输出,内部实现具体功能。

一个标准的模块长这样:

module counter (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       en,
    output reg  [3:0] count
);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 4'b0;
        else if (en)
            count <= count + 1'b1;
    end

endmodule

我个人习惯把端口声明和内部逻辑分开写。这样代码结构清晰,别人一看就知道哪些是接口,哪些是内部信号。

小技巧: 端口方向建议写在端口列表里,而不是用 ANSI 风格全部挤在一起。虽然 ANSI 风格更简洁,但调试时不够直观。

2.2 数据类型——wire 和 reg 到底怎么选?

这个问题,我几乎每次面试新人都会问。很多人背了答案,但一写代码就乱。

说白了,记住一句话:wire 是被驱动的,reg 是被赋值的

  • wire:用于组合逻辑,比如 assign 语句的输出。它不能存储值,只能反映输入的变化。
  • reg:用于过程赋值,比如 always 块里的变量。它可以在时钟沿到来时保持状态。

举个例子:

wire a, b, c;
assign c = a & b;   // c 是 wire,组合逻辑

reg [7:0] data_reg;
always @(posedge clk) begin
    data_reg <= data_in;  // data_reg 是 reg,时序逻辑
end
注意: 千万别以为 reg 就一定会综合成寄存器!在 always @(*) 块里,reg 变量综合出来是组合逻辑。我曾经见过一个同事,把所有信号都定义成 reg,结果综合出一堆不必要的 latch,功耗直接翻倍。

2.3 连续赋值与过程赋值——assign 和 always 的区别

嗯,这里要注意。很多新手分不清什么时候用 assign,什么时候用 always。

连续赋值(assign)

  • 只能用于 wire 类型
  • 赋值是持续的,输入一变,输出立刻变
  • 适合简单的组合逻辑,比如加法、选择器

过程赋值(always)

  • 可以用于 reg 类型
  • 赋值在特定事件触发时执行(比如时钟上升沿)
  • 适合复杂的逻辑,尤其是时序逻辑

来看个对比:

// 连续赋值:组合逻辑
assign sum = a + b;

// 过程赋值:时序逻辑
always @(posedge clk) begin
    sum_reg <= a + b;
end

你想想看,如果我想实现一个带使能的加法器,用 assign 就不好使了。因为 assign 没有「条件控制」的能力。这时候必须用 always 块。

核心区别: assign 是「线」的驱动,always 是「块」的驱动。前者没有记忆,后者可以有时序。

2.4 阻塞赋值与非阻塞赋值——仿真中的「大坑」

这个话题,我敢说每个芯片工程师都踩过坑。我自己刚入行时,就因为搞混了 = 和 <=,仿真结果和预期差了十万八千里。

阻塞赋值(=)

  • 顺序执行,前面的赋值会阻塞后面的
  • 用于组合逻辑的 always 块

非阻塞赋值(<=)

  • 并行执行,所有赋值在时钟沿同时更新
  • 用于时序逻辑的 always 块

看个经典例子:

// 错误示范:阻塞赋值用于时序逻辑
always @(posedge clk) begin
    a = b;
    c = a;  // 这里 c 拿到的是 a 更新后的值,不是上一拍的值
end

// 正确示范:非阻塞赋值用于时序逻辑
always @(posedge clk) begin
    a <= b;
    c <= a;  // 这里 c 拿到的是 a 上一拍的值,符合预期
end
避坑指南: 我曾经在一个项目中,把非阻塞赋值写成了阻塞赋值,结果仿真时数据路径全乱了。排查了整整两天才发现是赋值方式的问题。记住:时序逻辑用 <=,组合逻辑用 =,这是铁律。

2.5 时序逻辑与组合逻辑——芯片的「记忆」与「反应」

说白了,组合逻辑就是「看到什么输出什么」,没有记忆。时序逻辑就是「记住过去的状态」,有记忆。

组合逻辑的特点:

  • 输出只取决于当前输入
  • 没有时钟沿触发
  • 用 assign 或 always @(*) 实现

时序逻辑的特点:

  • 输出取决于当前输入和过去的状态
  • 需要时钟沿触发
  • 用 always @(posedge clk) 实现

来看个实际例子:

// 组合逻辑:译码器
always @(*) begin
    case (sel)
        2'b00: out = 4'b0001;
        2'b01: out = 4'b0010;
        2'b10: out = 4'b0100;
        2'b11: out = 4'b1000;
        default: out = 4'b0000;
    endcase
end

// 时序逻辑:计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        cnt <= 4'b0;
    else
        cnt <= cnt + 1'b1;
end
经验之谈: 写代码前,先想清楚这个模块是组合逻辑还是时序逻辑。如果混在一起,很容易产生 latch。我建议新手先画个简单的时序图,再动手写代码。

2.6 实战建议——如何写出「可仿真、可综合」的代码

最后,分享几个我个人的习惯:

  1. 模块划分要清晰:一个模块只做一件事。比如计数器模块只计数,不要在里面加译码逻辑。
  2. 信号命名要规范:时钟信号用 clk,复位用 rst_n(低有效加 _n),使能用 en。这样别人一看就懂。
  3. 避免 latch:组合逻辑的 always 块里,所有条件分支都要覆盖完整。if 要有 else,case 要有 default。
  4. 仿真和综合要一致:仿真通过的代码,综合不一定能过。建议写代码时就考虑可综合性。

我记得有一次,一个新人写的代码仿真完全正确,但综合时报了一堆 warning。原因是他用了 initial 块来初始化寄存器。initial 块不可综合,仿真和综合的结果对不上。嗯,这个坑大家要记住。

最后的小建议: 多读优秀的开源 Verilog 代码,比如 OpenCores 上的项目。看多了,自然就知道怎么写才规范。

好了,第二章就到这里。下一章我们讲仿真环境的搭建,包括 Testbench 怎么写、波形怎么看。这些都是实战中天天用的技能。