4、硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、阻塞赋值与非阻塞赋值、Testbench编写
各位同学好,我是老李。今天咱们聊聊Verilog里最核心的几个概念。说实话,很多新手一上来就被阻塞赋值和非阻塞赋值搞晕了。别急,咱们一个一个来拆解。
4.1 模块化设计——把大问题拆成小积木
模块化设计,说白了就是“分而治之”。你想想看,一个复杂的芯片动辄上亿门电路,要是全写在一个文件里,别说调试了,光看代码就能把人看吐。
我个人习惯,每个模块只干一件事。比如一个计数器模块,就只管计数;一个FIFO模块,就只管数据缓存。这样每个模块的代码量控制在200行以内,调试起来特别方便。
模块化设计三原则:
- 高内聚:一个模块内部的功能要紧密相关
- 低耦合:模块之间的接口要尽量简单
- 接口清晰:用input/output把端口定义清楚
来看一个简单的例子。这是一个4位加法器,我把它拆成了两个半加器模块:
// 半加器模块
module half_adder (
input a, b,
output sum, carry
);
assign sum = a ^ b;
assign carry = a & b;
endmodule
// 顶层模块:4位加法器
module adder_4bit (
input [3:0] a, b,
input cin,
output [3:0] sum,
output cout
);
wire c1, c2, c3;
half_adder u0 (.a(a[0]), .b(b[0]), .sum(sum[0]), .carry(c1));
half_adder u1 (.a(a[1]), .b(b[1]), .sum(sum[1]), .carry(c2));
half_adder u2 (.a(a[2]), .b(b[2]), .sum(sum[2]), .carry(c3));
half_adder u3 (.a(a[3]), .b(b[3]), .sum(sum[3]), .carry(cout));
endmodule
看到没?顶层模块只是把四个半加器“拼”在一起。每个半加器只有几行代码,逻辑一目了然。我在项目中遇到过有人把整个CPU写在一个module里,结果仿真跑一次要半小时……后来拆成取指、译码、执行、访存、写回五个模块,调试效率直接翻倍。
4.2 组合逻辑与时序逻辑——数字电路的两大支柱
组合逻辑,输出只取决于当前输入。时序逻辑,输出还取决于之前的状态。嗯,这里要注意,很多新手容易把两者混在一起写。
我画了一张图,帮你理清思路:
写组合逻辑时,我用assign或者always @(*)。写时序逻辑时,我用always @(posedge clk)。这个习惯帮我避免了很多bug。
小技巧:如果你不确定一个信号是组合还是时序,问自己一个问题——这个信号需要“记住”过去的值吗?如果需要,那就是时序逻辑。
4.3 阻塞赋值与非阻塞赋值——最容易踩的坑
这个知识点,我当年学的时候也迷糊过。阻塞赋值用=,非阻塞赋值用<=。区别在哪?
- 阻塞赋值(=):顺序执行,写完上一句才能写下一句。适合组合逻辑。
- 非阻塞赋值(<=):并行执行,所有赋值同时发生。适合时序逻辑。
我曾经在项目中犯过一个低级错误——在时序逻辑里用了阻塞赋值。结果仿真波形看起来是对的,但综合出来的电路完全不是我想的那样。查了两天才发现是赋值方式用错了。
铁律:
- 写组合逻辑(always @(*))时,用阻塞赋值 =
- 写时序逻辑(always @(posedge clk))时,用非阻塞赋值 <=
- 千万别混用!
来看一个对比例子:
// 错误写法:时序逻辑用了阻塞赋值
always @(posedge clk) begin
a = b; // 阻塞赋值
c = a; // 此时a已经更新,c拿到的是新值
end
// 综合出来可能只有一个寄存器,而不是两个
// 正确写法:时序逻辑用非阻塞赋值
always @(posedge clk) begin
a <= b; // 非阻塞赋值
c <= a; // 此时a还是旧值,c拿到的是a的旧值
end
// 综合出来是两个寄存器,符合预期
为什么会这样?因为非阻塞赋值在时钟上升沿“同时”采样和更新。你想想看,如果两个寄存器要串联传递数据,用非阻塞赋值才能保证每个时钟周期只传递一次。
4.4 Testbench编写——验证你的设计对不对
写Testbench,说白了就是给你的设计“喂”输入,然后看输出对不对。我一般按这个套路来:
- 例化待测模块(DUT)
- 生成时钟和复位信号
- 构造测试激励
- 检查输出结果
来看一个完整的Testbench例子,测试我们刚才写的4位加法器:
module tb_adder_4bit;
reg [3:0] a, b;
reg cin;
wire [3:0] sum;
wire cout;
// 例化待测模块
adder_4bit u_dut (
.a (a),
.b (b),
.cin (cin),
.sum (sum),
.cout(cout)
);
// 生成时钟(如果需要)
reg clk;
always #5 clk = ~clk;
// 测试激励
initial begin
// 初始化
a = 0; b = 0; cin = 0;
#10;
// 测试1:1 + 2 = 3
a = 4'd1; b = 4'd2; cin = 0;
#10;
$display("1 + 2 = %d, carry = %b", sum, cout);
// 测试2:15 + 1 = 0(溢出)
a = 4'd15; b = 4'd1; cin = 0;
#10;
$display("15 + 1 = %d, carry = %b", sum, cout);
// 测试3:带进位输入
a = 4'd5; b = 4'd5; cin = 1;
#10;
$display("5 + 5 + 1 = %d, carry = %b", sum, cout);
#20;
$finish;
end
// 自动检查结果
always @(a or b or cin) begin
#1; // 等待组合逻辑稳定
if ({cout, sum} !== a + b + cin) begin
$error("Mismatch: %d + %d + %d = %d, expected %d",
a, b, cin, {cout, sum}, a + b + cin);
end
end
endmodule
我的习惯:Testbench里一定要加自动检查。手动看波形太累了,而且容易漏掉边界情况。用$error或者assertion,仿真跑完就知道有没有问题。
嗯,到这里Verilog基础就讲完了。记住:模块化设计让你代码更清晰,组合逻辑和时序逻辑要分清楚,阻塞和非阻塞赋值别搞混,Testbench要写自动检查。这些基本功打扎实了,后面上板联调才能少踩坑。
本章核心要点:
- 模块化设计:高内聚、低耦合、接口清晰
- 组合逻辑:输出只依赖当前输入,用assign或always @(*)
- 时序逻辑:输出依赖历史状态,用always @(posedge clk)
- 阻塞赋值(=)用于组合逻辑,非阻塞赋值(<=)用于时序逻辑
- Testbench要包含时钟生成、激励构造和自动结果检查
可可精品资料库