3. 硬件描述语言基础(Verilog):模块结构、数据类型、连续赋值与过程赋值、组合逻辑与时序逻辑

好,咱们开始聊Verilog。说实话,很多初学者把Verilog当成C语言来写,这是个大坑。我见过不少项目,代码写得像C,综合出来一堆莫名其妙的Latch。嗯,咱们得从一开始就摆正心态——Verilog是描述硬件的,不是写软件的。

3.1 模块结构:你的第一个数字电路

Verilog的基本单元叫模块(module)。你可以把它想象成一个黑盒子,有输入、有输出,里面装着你要实现的逻辑。

一个典型的模块长这样:

module counter (
    input  wire       clk,      // 时钟
    input  wire       rst_n,    // 复位,低有效
    input  wire       en,       // 使能
    output reg  [3:0] count     // 4位计数器输出
);

    // 时序逻辑:计数器
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 4'd0;
        else if (en)
            count <= count + 1'b1;
    end

endmodule

我个人习惯把端口声明和内部信号分开写,这样结构清晰。你看,inputoutput后面跟了wirereg,这涉及到数据类型,咱们马上讲。

小技巧: 模块名建议用下划线命名法,比如uart_txspi_master。别用驼峰,也别用中文拼音,不然同事会找你麻烦。

3.2 数据类型:wire 和 reg 到底怎么选?

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

  • wire:连线。它不存储值,只是把两个端口连起来。用在assign语句、模块端口连接。
  • reg:寄存器。它存储值,用在always块里被赋值。

但注意!reg不一定综合成寄存器。在组合逻辑的always块里,reg只是行为描述,综合出来还是连线。我刚开始做设计时也犯过这个错,以为always块里用reg就一定是触发器,结果综合报告里一堆组合逻辑。

看个例子:

// 组合逻辑:用reg描述,但综合出来是wire
reg [3:0] sum;
always @(*) begin
    sum = a + b;
end

// 时序逻辑:用reg描述,综合出来是触发器
reg [3:0] count;
always @(posedge clk) begin
    count <= count + 1;
end

还有几个常用的数据类型:

  • integer:32位有符号整数,常用于循环变量。别用它描述硬件,只用在testbench或generate循环里。
  • parameter:常量,用来定义位宽、状态等。比如parameter WIDTH = 8;
避坑指南: 我曾经在项目里用integer定义了一个计数器,结果综合出来一堆多余的逻辑。记住,描述硬件时用reg [N-1:0],别用integer。

3.3 连续赋值 vs 过程赋值:两种思维方式

连续赋值用assign,过程赋值用always块。它们代表了两种不同的硬件描述方式。

3.4.1 连续赋值(assign)

说白了,assign就是一根导线,左边输出等于右边输入的组合逻辑。它总是激活的,只要右边变了,左边立刻更新。

// 连续赋值:描述组合逻辑
assign sum = a + b;
assign full = (count == 4'd9);

你想想看,assign不能用在always块里,也不能给reg类型赋值。它只能驱动wire。

3.4.2 过程赋值(always块)

always块里可以写两种赋值:

  • 阻塞赋值(=):顺序执行,像C语言。用于组合逻辑。
  • 非阻塞赋值(<=):并行执行,所有赋值同时更新。用于时序逻辑。

这是Verilog里最容易出bug的地方。我见过一个同事,在时序逻辑里用了阻塞赋值,结果仿真和综合结果完全不一样。为什么呢?

看这个例子:

// 错误写法:时序逻辑用阻塞赋值
always @(posedge clk) begin
    a = b;
    c = a;  // 这里c得到的是b的新值,不是a的旧值
end

// 正确写法:时序逻辑用非阻塞赋值
always @(posedge clk) begin
    a <= b;
    c <= a;  // 这里c得到的是a的旧值,符合寄存器行为
end

为什么会这样?因为非阻塞赋值模拟了寄存器的行为——所有寄存器在时钟沿同时采样输入,然后同时更新输出。阻塞赋值则是顺序执行,破坏了这种并行性。

黄金法则:
  • 组合逻辑用 always @(*) + 阻塞赋值 =
  • 时序逻辑用 always @(posedge clk) + 非阻塞赋值 <=
  • 千万别混用!

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

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

特性 组合逻辑 时序逻辑
输出依赖 仅当前输入 当前输入 + 历史状态
存储能力 有(寄存器)
时钟 不需要 需要时钟沿触发
Verilog描述 assign 或 always @(*) always @(posedge clk)
典型电路 加法器、多路选择器、译码器 计数器、移位寄存器、状态机

组合逻辑说白了就是一堆门电路,输入一变,输出立刻变。时序逻辑则像有个记忆,它记得之前的状态。

举个例子:

// 组合逻辑:加法器
assign result = a + b;

// 时序逻辑:累加器
always @(posedge clk) begin
    if (rst_n)
        accumulator <= 0;
    else
        accumulator <= accumulator + a;
end

你看,加法器没有记忆,每次输入a和b,输出就是它们的和。累加器则记住了之前的值,每次时钟沿来,它把当前值加上a,再存回去。

我的经验: 写代码前,先在纸上画出电路结构。组合逻辑画成门电路,时序逻辑画成触发器。画清楚了再写代码,基本不会出错。我早期项目里,凡是没画图直接写的,后期debug都花了两倍时间。

3.5 实战:一个简单的数据通路

咱们把今天学的串起来,写一个简单的数据通路:输入两个数,相加后寄存,然后输出。

module data_path (
    input  wire       clk,
    input  wire       rst_n,
    input  wire [7:0] data_a,
    input  wire [7:0] data_b,
    output reg  [8:0] result
);

    // 组合逻辑:加法
    wire [8:0] sum;
    assign sum = data_a + data_b;

    // 时序逻辑:寄存结果
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            result <= 9'd0;
        else
            result <= sum;
    end

endmodule

这个例子很简单,但包含了今天讲的所有要素:模块结构、wire和reg、连续赋值、过程赋值、组合逻辑和时序逻辑。你想想看,assign sum是组合逻辑,always块是时序逻辑。两者通过wire连接,完美配合。

注意: 加法结果需要9位,因为两个8位数相加最大是510(9位)。位宽不够会溢出,这是新手常犯的错误。我曾经在项目中因为位宽少了一位,导致数据截断,查了两天才发现。

好了,这一章的内容就这些。记住,Verilog不是软件,是硬件。写代码时多想想它综合出来是什么电路,而不是它运行起来是什么结果。下一章咱们聊更深入的话题——如何写出可综合的、高效的Verilog代码。