第二讲:数字逻辑基础回顾与第一个加法器
各位同学,欢迎来到第二讲。今天我们要做两件事:一是把数字逻辑里最核心的两个概念——组合逻辑和时序逻辑——彻底讲明白;二是动手写你的第一个Verilog模块:一个加法器。
别小看加法器。我做了十几年芯片,见过太多新手在加法器上栽跟头。不是功能不对,而是风格从一开始就歪了。今天我们就把它掰正。
2.1 组合逻辑 vs 时序逻辑:本质区别在哪?
先问个问题:你按下开关,灯就亮。这叫组合逻辑。你按下开关,灯过一秒才亮。这叫时序逻辑。
说白了,组合逻辑的输出只取决于当前输入。没有记忆,没有延迟。而时序逻辑的输出不仅看当前输入,还看之前的状态——它有记忆。
核心区别一句话:组合逻辑没有状态,时序逻辑有状态。
我在项目中遇到过一件事:一个同事写了一个计数器,结果发现每次上电复位后,计数器的初始值都不对。查了半天,原来他把时序逻辑写成了组合逻辑——寄存器被综合成了纯导线。嗯,这就是没搞清楚两者区别的典型后果。
组合逻辑的特点
- 输出只由当前输入决定
- 没有时钟,没有存储元件
- 典型电路:与门、或门、加法器、多路选择器
- Verilog中常用
assign或always @(*)描述
时序逻辑的特点
- 输出由当前输入和之前状态共同决定
- 需要时钟沿触发,需要寄存器(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位加法器。这个加法器要做两件事:
- 计算两个4位数的和
- 输出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%。为什么?因为工具把加法计算了两遍!正确的做法是用拼接操作,让工具只综合一个加法器。
下一讲我们会深入时序逻辑,写一个真正的寄存器。到时候你会发现,有了今天的基础,那些东西其实水到渠成。
好,今天就到这里。动手写写代码,跑跑仿真。有什么问题,我们下节课见。