4. 硬件描述语言Verilog入门(下):组合逻辑建模、时序逻辑建模与测试文件编写
好,咱们接着聊。上一节我们把Verilog的基本语法和模块结构捋了一遍,今天要讲的是真正开始“干活”的部分——怎么用Verilog描述出实际的数字电路。
我个人觉得,学Verilog最忌讳的就是把它当成软件来写。你写的是C语言,编译器帮你安排寄存器和内存;你写Verilog,每一个assign、每一个always块,最终都会变成实实在在的门电路和触发器。脑子里得有这根弦。
4.1 组合逻辑建模:case与if-else
组合逻辑,说白了就是输出只取决于当前输入,跟历史没关系。没有时钟,没有记忆。最常见的写法就是两种:assign连续赋值,或者always @(*)过程赋值。
4.1.1 用if-else实现优先级编码器
if-else结构在综合时会生成优先级逻辑。什么意思呢?就是条件判断从上到下,优先级依次降低。我在项目中遇到过一个问题:一个多路中断控制器的设计,因为if-else的顺序写反了,导致高优先级的中断一直被低优先级抢断。查了两天才发现,嗯,就是顺序问题。
module priority_encoder (
input [3:0] req, // 请求信号,req[3]优先级最高
output reg [1:0] code // 编码输出
);
always @(*) begin
if (req[3]) code = 2'b11;
else if (req[2]) code = 2'b10;
else if (req[1]) code = 2'b01;
else if (req[0]) code = 2'b00;
else code = 2'b00; // 无请求时默认输出
end
endmodule
小提示:写if-else时,最后一定要加一个else分支。哪怕你觉得“所有情况都覆盖了”,也要加。为什么?因为综合工具会给你生成一个锁存器(latch)来保持未覆盖情况下的输出值。锁存器在数字设计中通常是要避免的,时序难分析,还容易出毛刺。
4.1.2 用case实现多路选择器
case语句生成的是并行选择逻辑,说白了就是一个多路选择器(MUX)。所有分支的优先级是平等的,综合出来的电路也是并行的。
module mux_4to1 (
input [1:0] sel,
input [3:0] a, b, c, d,
output reg [3:0] out
);
always @(*) begin
case (sel)
2'b00: out = a;
2'b01: out = b;
2'b10: out = c;
2'b11: out = d;
default: out = 4'b0; // 安全兜底
endcase
end
endmodule
注意:case语句一定要写default分支。我曾经见过一个同事,case里只写了三个分支,漏了一个,结果综合出来一个巨大的锁存器阵列,面积翻了一倍。血的教训。
4.1.3 if-else vs case:怎么选?
| 对比项 | if-else | case |
|---|---|---|
| 综合结果 | 优先级逻辑(串行) | 并行选择逻辑(MUX) |
| 面积 | 条件多时较大 | 相对较小且固定 |
| 时序 | 关键路径可能较长 | 路径延迟较均衡 |
| 适用场景 | 中断优先级、仲裁器 | 指令译码、状态选择 |
我个人习惯:如果条件之间有明确的优先级关系,用if-else;如果只是平等地选一个,用case。别混着用,容易把自己绕晕。
4.2 时序逻辑建模:同步复位与异步复位
时序逻辑,就是带记忆的电路。核心元件是触发器(Flip-Flop)。在Verilog里,时序逻辑用always @(posedge clk)来描述。
4.2.1 同步复位
同步复位,意思是复位信号只在时钟上升沿到来时才生效。说白了,复位信号必须和时钟同步。
module sync_reset_counter (
input clk,
input rst_n, // 低电平有效复位
input en,
output reg [7:0] cnt
);
always @(posedge clk) begin
if (!rst_n) cnt <= 8'b0;
else if (en) cnt <= cnt + 1'b1;
// 注意:没有else时,cnt保持原值
end
endmodule
同步复位的优点:抗毛刺能力强。复位信号上的小毛刺,只要不在时钟沿附近,就影响不到触发器。缺点:需要时钟才能复位。如果时钟停了,复位就失效了。
4.2.2 异步复位
异步复位,复位信号一有效,输出立刻清零,不用等时钟。敏感列表里要同时列出时钟和复位。
module async_reset_counter (
input clk,
input rst_n,
input en,
output reg [7:0] cnt
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) cnt <= 8'b0;
else if (en) cnt <= cnt + 1'b1;
end
endmodule
关键区别:敏感列表不同。同步复位只列posedge clk,异步复位要列posedge clk or negedge rst_n。写错了,综合工具会报warning,甚至综合出错误电路。
4.2.3 我踩过的坑:异步复位的释放问题
我曾经在一个项目中用了异步复位,结果芯片上电后偶尔会跑飞。查了很久才发现,复位释放的时候,不同触发器对复位信号的释放时间有微小差异,导致系统进入非法状态。这就是所谓的“复位释放竞争”。
解决方案:加一个同步释放电路。说白了,就是先把异步复位信号用两级触发器同步一下,再送给系统。
module rst_sync (
input clk,
input rst_async_n,
output rst_sync_n
);
reg rst_meta, rst_sync;
always @(posedge clk or negedge rst_async_n) begin
if (!rst_async_n) begin
rst_meta <= 1'b0;
rst_sync <= 1'b0;
end else begin
rst_meta <= 1'b1;
rst_sync <= rst_meta;
end
end
assign rst_sync_n = rst_sync;
endmodule
现在我做设计,一律用异步复位+同步释放。既保证了复位能快速响应,又避免了释放时的竞争问题。
4.3 测试文件(testbench)编写
写好了设计,怎么验证对不对?写testbench。说白了,就是模拟一个“测试环境”,给设计喂信号,看它输出对不对。
4.3.1 一个完整的testbench结构
`timescale 1ns / 1ps // 时间单位/精度
module tb_counter;
// 1. 声明信号
reg clk;
reg rst_n;
reg en;
wire [7:0] cnt;
// 2. 实例化待测模块
sync_reset_counter u_dut (
.clk (clk),
.rst_n(rst_n),
.en (en),
.cnt (cnt)
);
// 3. 生成时钟
initial begin
clk = 0;
forever #5 clk = ~clk; // 周期10ns
end
// 4. 生成激励
initial begin
// 初始化
rst_n = 0;
en = 0;
#20; // 等待20ns
// 释放复位
rst_n = 1;
#10;
// 使能计数
en = 1;
#50;
// 暂停计数
en = 0;
#20;
// 再次使能
en = 1;
#30;
// 结束仿真
$finish;
end
// 5. 监控输出(可选)
initial begin
$monitor("Time=%0t, rst_n=%b, en=%b, cnt=%d", $time, rst_n, en, cnt);
end
endmodule
4.3.2 写testbench的几个要点
- 时间尺度:用
`timescale定义仿真时间单位,别忘了一开始就写。 - 时钟生成:用
forever循环生成连续时钟,别手动一个个翻转。 - 激励顺序:用
#延迟控制时间,模拟真实场景。 - 自动检查:别光看波形,用
$display或assert自动比对结果。
我的习惯:每个testbench里至少加一个$monitor,打印关键信号的变化。这样仿真跑完,看一眼打印信息就知道有没有明显错误,不用每次都开波形查看器。
4.4 本章知识体系
下面这张图,把本章的核心知识点串起来了。你写代码的时候,可以对照着看看自己走到哪一步了。
嗯,这一章内容不少。组合逻辑是基础,时序逻辑是核心,testbench是验证手段。三者缺一不可。你写代码的时候,多想想“这个电路综合出来长什么样”,慢慢就有感觉了。