3、硬件描述语言基础(Verilog):模块与端口、组合逻辑与时序逻辑、always块与assign语句、仿真与测试平台基础
好,咱们直接进入正题。Verilog 这门语言,说白了就是用来「画」数字电路的。只不过你不用鼠标拖门电路,而是用代码来描述。我当年刚学的时候,总觉得它跟 C 语言差不多,结果写出来的东西综合出来一堆莫名其妙的 Latch……嗯,今天咱们就把这些坑一个个填上。
3.1 模块与端口:芯片的「黑盒子」
Verilog 的基本单元叫 module。你可以把它想象成一个黑盒子——外面只露出几个引脚(端口),里面藏着具体的逻辑电路。
我个人习惯,写模块时先把端口声明写好,就像画芯片先画封装一样。来看个最简单的例子:
module adder (
input wire [3:0] a, // 4位输入
input wire [3:0] b, // 4位输入
output wire [4:0] sum // 5位输出,防止溢出
);
assign sum = a + b;
endmodule
这里有几个关键点:
- input / output:定义数据流向。记住,端口方向是设计约束,不是建议。
- wire / reg:wire 是线网类型,用于组合逻辑;reg 是寄存器类型,用于时序逻辑。但别被名字骗了——reg 不一定是寄存器,它只是表示「过程赋值」。
- 位宽声明:比如 [3:0] 表示 4 位宽。位宽不匹配是我在项目中遇到最多的低级错误,仿真能过,上板就挂。
3.2 组合逻辑与时序逻辑:两个世界的分界线
数字电路就两种逻辑:组合逻辑和时序逻辑。你想想看,组合逻辑就是「输入一变,输出立刻变」,没有记忆功能。时序逻辑则依赖时钟,只在时钟沿更新状态。
我画个图帮你理清思路:
为什么要把它们分得这么清楚?因为综合工具处理它们的方式完全不同。组合逻辑综合成门电路,时序逻辑综合成触发器和锁存器。混在一起写,工具会给你一堆警告,甚至综合出你不想要的东西。
3.3 always 块与 assign 语句:两种描述方式
Verilog 里描述逻辑就两种主要方式:assign 和 always。我刚开始时总搞混什么时候用哪个,后来总结了一个简单规则:
- assign:用于组合逻辑,连续赋值。左边必须是 wire 类型。
- always:用于组合逻辑或时序逻辑,过程赋值。左边必须是 reg 类型。
来看对比:
| 特性 | assign | always |
|---|---|---|
| 适用场景 | 简单的组合逻辑 | 复杂的组合逻辑 / 时序逻辑 |
| 赋值对象 | wire | reg |
| 触发方式 | 输入变化立即触发 | 敏感列表变化触发 |
| 典型写法 | assign y = a & b; | always @(posedge clk) q <= d; |
举个例子,用两种方式实现同一个 2 选 1 多路选择器:
// 方式一:assign 语句
module mux2_assign (
input wire a, b, sel,
output wire y
);
assign y = sel ? b : a;
endmodule
// 方式二:always 块
module mux2_always (
input wire a, b, sel,
output reg y
);
always @(*) begin
if (sel) y = b;
else y = a;
end
endmodule
3.4 仿真与测试平台基础:不仿真的代码都是耍流氓
写 Verilog 不仿真,就像盖楼不打地基。我见过太多人写完代码直接上板,结果 debug 到崩溃。其实写个 testbench 花不了几分钟,但能帮你省下几小时的调试时间。
测试平台(testbench)说白了就是给被测试模块(DUT)提供激励,然后观察输出对不对。它不需要端口,也不需要综合。来看个完整的例子:
`timescale 1ns / 1ps
module tb_adder;
// 声明信号
reg [3:0] a, b;
wire [4:0] sum;
// 实例化被测试模块
adder u_adder (
.a (a),
.b (b),
.sum (sum)
);
// 产生激励
initial begin
a = 4'd5; b = 4'd3;
#10;
a = 4'd10; b = 4'd7;
#10;
a = 4'd15; b = 4'd1;
#10;
$finish;
end
// 监视输出
initial begin
$monitor("Time=%0t, a=%d, b=%d, sum=%d", $time, a, b, sum);
end
endmodule
这里有几个要点:
- `timescale:定义时间单位和精度。1ns/1ps 表示单位 1ns,精度 1ps。
- initial 块:只执行一次,用于初始化或产生激励。
- #10:延迟 10 个时间单位。这是仿真特有的语法,不可综合。
- $monitor:系统任务,信号变化时自动打印。
3.5 避坑指南:新手最容易犯的 3 个错误
- 组合逻辑中产生 Latch:在 always @(*) 中,如果某个分支没有给所有变量赋值,综合工具会推断出锁存器。比如 if 没有 else,case 没有 default。解决办法:要么补全所有分支,要么在 always 块开头给所有变量赋默认值。
- 阻塞赋值与非阻塞赋值混用:组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)。混用会导致仿真和综合结果不一致。我见过一个项目,就因为这里写错了,仿真没问题,上板后数据总是不对。
- 敏感列表不完整:always @(a or b) 只对 a 和 b 敏感,如果内部用了 c,c 变化时不会触发。现在都建议用 always @(*),让工具自动推导敏感列表。
核心总结:
- 模块 = 端口 + 内部逻辑
- 组合逻辑用 assign 或 always @(*),时序逻辑用 always @(posedge clk)
- 仿真不是可选项,是必选项
- 写代码前先想清楚:这是组合还是时序?
嗯,这一章的内容就到这里。记住,Verilog 只是工具,真正的功夫在于理解硬件电路。下一章咱们聊聊更深入的东西——不过在那之前,先把这些基础练扎实了。