第二讲:数字逻辑基础回顾与第一个加法器

各位同学,欢迎来到第二讲。今天我们要做两件事:一是把数字逻辑里最核心的两个概念——组合逻辑和时序逻辑——彻底讲明白;二是动手写你的第一个Verilog模块:一个加法器。

别小看加法器。我做了十几年芯片,见过太多新手在加法器上栽跟头。不是功能不对,而是风格从一开始就歪了。今天我们就把它掰正。

2.1 组合逻辑 vs 时序逻辑:本质区别在哪?

先问个问题:你按下开关,灯就亮。这叫组合逻辑。你按下开关,灯过一秒才亮。这叫时序逻辑。

说白了,组合逻辑的输出只取决于当前输入。没有记忆,没有延迟。而时序逻辑的输出不仅看当前输入,还看之前的状态——它有记忆。

核心区别一句话:组合逻辑没有状态,时序逻辑有状态。

我在项目中遇到过一件事:一个同事写了一个计数器,结果发现每次上电复位后,计数器的初始值都不对。查了半天,原来他把时序逻辑写成了组合逻辑——寄存器被综合成了纯导线。嗯,这就是没搞清楚两者区别的典型后果。

组合逻辑的特点

  • 输出只由当前输入决定
  • 没有时钟,没有存储元件
  • 典型电路:与门、或门、加法器、多路选择器
  • Verilog中常用 assignalways @(*) 描述

时序逻辑的特点

  • 输出由当前输入和之前状态共同决定
  • 需要时钟沿触发,需要寄存器(flip-flop)
  • 典型电路:计数器、状态机、移位寄存器
  • Verilog中常用 always @(posedge clk) 描述
对比项 组合逻辑 时序逻辑
记忆能力
时钟依赖 不依赖 依赖时钟沿
输出延迟 仅门延迟 一个或多个时钟周期
典型描述方式 assign / always @(*) always @(posedge clk)

我的习惯:写代码前先问自己一句——这个信号需要记住之前的值吗?如果需要,它就是时序逻辑。不需要,就是组合逻辑。这个判断做对了,代码结构就清晰了一半。

2.2 Verilog基础语法:module、wire、reg、always块

好,理论讲完了,我们上手写代码。先认识四个最常用的语法元素。

module:一切从模块开始

Verilog里每个设计都是一个模块。模块就是你的电路的黑盒子——有输入、有输出、有内部实现。

module adder (
    input  [3:0] a,    // 4位输入a
    input  [3:0] b,    // 4位输入b
    output [4:0] sum   // 5位输出sum(考虑进位)
);
    // 内部实现
endmodule

注意端口声明。我个人习惯把输入输出分开写,每个信号一行,加上注释。这样代码可读性高,也方便后续修改。

wire vs reg:别被名字骗了

很多初学者以为wire就是导线,reg就是寄存器。其实不完全对。

  • wire:用于组合逻辑的连接。可以理解为物理导线。
  • reg:用于在always块中赋值的变量。它不一定被综合成寄存器!

举个例子:

wire [3:0] c;
assign c = a & b;   // 组合逻辑,用wire

reg [3:0] d;
always @(*) begin
    d = a | b;      // 组合逻辑,但用reg赋值
end

看到了吗?d虽然是reg类型,但综合出来还是组合逻辑。为什么?因为always @(*)里没有时钟沿触发。

我曾经踩过的坑:刚学Verilog时,我以为reg就一定是寄存器。结果写了一个always @(*)里的组合逻辑,综合后看到报告里说"reg被优化掉了",我还以为工具出bug了。后来才明白——reg只是变量类型,不是硬件类型。硬件是什么,取决于你怎么用它。

always块:描述逻辑的核心

always块有两种常见写法:

// 组合逻辑:敏感列表用 @(*)
always @(*) begin
    // 组合逻辑赋值
end

// 时序逻辑:敏感列表用 @(posedge clk)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        // 复位
    else
        // 时序逻辑赋值
end

这里有个细节:组合逻辑的敏感列表用 @(*) 表示"所有输入信号变化都触发"。我建议你永远用 @(*),不要手动列信号。为什么?因为手动列容易漏。漏了就会产生锁存器(latch),而锁存器在大多数设计中是不想要的。

2.3 编写第一个加法器模块

好了,现在我们来写一个完整的4位加法器。这个加法器要做两件事:

  1. 计算两个4位数的和
  2. 输出5位结果(包含进位)
module adder_4bit (
    input  [3:0] a,      // 加数a
    input  [3:0] b,      // 加数b
    input        cin,    // 进位输入
    output [3:0] sum,    // 和(低4位)
    output       cout    // 进位输出
);

    // 组合逻辑实现加法
    assign {cout, sum} = a + b + cin;

endmodule

就这么简单?对,就这么简单。Verilog里 + 运算符直接对应硬件加法器。但注意:

  • {cout, sum} 是拼接操作,把进位和结果拼成一个5位数
  • a和b是4位,cin是1位,相加结果最大是5位(4+4+1=9,但4位加4位最大是15+15+1=31,需要5位)
  • 这个模块是纯组合逻辑,没有时钟

我的建议:写加法器时,永远把进位输出和结果一起处理。不要分开写 sum = a + b + cin 再单独算进位。那样容易出错,而且综合工具不一定能优化好。用拼接操作,一步到位。

如果你想验证这个加法器,可以写一个testbench:

module tb_adder_4bit;
    reg  [3:0] a, b;
    reg        cin;
    wire [3:0] sum;
    wire       cout;

    adder_4bit u_adder (
        .a(a), .b(b), .cin(cin),
        .sum(sum), .cout(cout)
    );

    initial begin
        a = 4'd5;  b = 4'd3;  cin = 1'b0;  // 5 + 3 = 8
        #10;
        a = 4'd9;  b = 4'd7;  cin = 1'b1;  // 9 + 7 + 1 = 17
        #10;
        a = 4'd15; b = 4'd15; cin = 1'b1;  // 15 + 15 + 1 = 31
        #10;
        $finish;
    end

    initial begin
        $monitor("a=%d b=%d cin=%d => sum=%d cout=%d", a, b, cin, sum, cout);
    end
endmodule

运行这个testbench,你会看到:

a=5  b=3  cin=0 => sum=8  cout=0
a=9  b=7  cin=1 => sum=1  cout=1   // 17的二进制是10001,低4位是1,进位是1
a=15 b=15 cin=1 => sum=15 cout=1   // 31的二进制是11111,低4位是15,进位是1

看到第二个结果了吗?9+7+1=17,但sum只显示了1。为什么?因为sum只有4位,只能表示0-15。17的二进制是10001,低4位是0001,所以sum=1,进位cout=1。这就是溢出——但在这个设计里,我们通过cout把溢出信息保留下来了。

关键理解:加法器的输出位宽决定了它能表示的最大值。4位加法器最大能表示15,超过15就要靠进位输出。所以设计时一定要考虑好位宽,否则数据会悄悄丢失。

2.4 小结与避坑指南

今天的内容就到这里。总结几个要点:

  • 组合逻辑没有记忆,时序逻辑有记忆——这是最根本的区别
  • wire用于组合逻辑连接,reg用于always块赋值——但reg不一定是寄存器
  • always @(*) 描述组合逻辑,always @(posedge clk) 描述时序逻辑
  • 加法器用拼接操作 {cout, sum} 一步到位,避免分开计算

我曾经犯过的错:有一次写一个16位加法器,我偷懒用了 sum = a + b,然后单独用 cout = (a + b) > 16'hFFFF 算进位。结果综合后发现面积大了30%。为什么?因为工具把加法计算了两遍!正确的做法是用拼接操作,让工具只综合一个加法器。

下一讲我们会深入时序逻辑,写一个真正的寄存器。到时候你会发现,有了今天的基础,那些东西其实水到渠成。

好,今天就到这里。动手写写代码,跑跑仿真。有什么问题,我们下节课见。