第二章 硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、always块与assign语句、Testbench编写基础
各位同学,欢迎来到第二章。这一章我们聊聊Verilog,这是SoC设计的“普通话”。你想想看,没有它,你拿什么跟工具沟通?拿什么描述你的硬件想法?
我个人习惯把Verilog看作一种“画电路图的文字版”。你画电路图时,有门、有连线、有触发器。Verilog里,assign就是连线,always就是那些带时钟的触发器。说白了,它就是让你用代码把电路“写”出来。
2.1 模块化设计:搭积木的艺术
做SoC,没人会写一个几百万门的单文件。那太疯狂了。我们讲“模块化”,就是把大系统拆成小模块,每个模块干一件事。
我在项目中遇到过最头疼的事,就是接手一个同事写的“超级模块”——一个文件上万行,什么功能都揉在一起。想改个计数器,得翻半天。从那以后,我坚持每个模块只做一件事,接口要清晰。
一个标准的Verilog模块长这样:
module counter (
input wire clk,
input wire rst_n,
input wire en,
output reg [7:0] count
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
count <= 8'd0;
else if (en)
count <= count + 1'b1;
end
endmodule
注意看,端口列表里我用了wire和reg。嗯,这里要注意:wire一般用在assign赋值,或者模块的输入;reg用在always块里。但别死记硬背,后面我们慢慢就熟了。
counter,文件就叫 counter.v。这是行业惯例,工具也喜欢这样。
2.2 组合逻辑 vs 时序逻辑
这是新手最容易搞混的地方。我简单解释一下:
- 组合逻辑:输出只取决于当前输入。没有记忆,没有时钟。比如一个加法器,输入1+1,输出就是2。
- 时序逻辑:输出不仅取决于当前输入,还取决于之前的状态。说白了,它有“记忆”。比如计数器,它记得自己数到哪了。
为什么会这样?因为时序逻辑里有触发器(Flip-Flop),而组合逻辑只有门电路。
我曾经犯过一个低级错误:在组合逻辑里试图“记住”一个值。结果仿真时数据乱跳,查了半天才发现是忘了加时钟。从那以后,我写代码前会先问自己:这个信号需要记忆吗?需要,就用时序逻辑。
2.3 always块与assign语句
这两个是Verilog里最常用的描述方式。我习惯这么区分:
| 语句 | 用途 | 典型场景 |
|---|---|---|
assign |
组合逻辑 | 数据选择器、加法器、译码器 |
always @(*) |
组合逻辑 | 复杂组合逻辑、状态机中的下一状态 |
always @(posedge clk) |
时序逻辑 | 计数器、寄存器、状态机中的状态更新 |
举个例子,一个2选1数据选择器:
// 用assign实现
assign out = sel ? b : a;
// 用always实现
always @(*) begin
if (sel)
out = b;
else
out = a;
end
两种写法功能一样。我个人习惯:简单的组合逻辑用assign,复杂的用always @(*)。为什么?因为always块里可以写if-else、case,可读性更好。
always @(posedge clk) 里,赋值要用 <= (非阻塞赋值)。在组合逻辑的 always @(*) 里,要用 =(阻塞赋值)。混用了,仿真结果会跟你预期不一样。我曾经因为这个,调了整整一个下午。
2.4 Testbench编写基础
写完了模块,怎么知道它对不对?靠Testbench。说白了,Testbench就是给设计模块“喂”激励,然后看它输出对不对。
一个最简单的Testbench结构:
`timescale 1ns / 1ps
module tb_counter;
// 信号声明
reg clk;
reg rst_n;
reg en;
wire [7:0] count;
// 实例化被测试模块
counter u_counter (
.clk (clk),
.rst_n (rst_n),
.en (en),
.count (count)
);
// 生成时钟
initial begin
clk = 0;
forever #5 clk = ~clk; // 周期10ns
end
// 测试激励
initial begin
// 初始化
rst_n = 0;
en = 0;
#20;
rst_n = 1;
#10;
en = 1;
#100;
en = 0;
#50;
$finish;
end
// 监视输出
initial begin
$monitor("time=%0t, count=%d", $time, count);
end
endmodule
你看,Testbench里没有端口,因为它不是要综合的硬件。它只是一个仿真环境。我习惯把Testbench分成三部分:
- 信号声明和模块实例化:就像搭积木,把被测模块放进来。
- 激励生成:用
initial块产生时钟、复位、数据。 - 结果检查:可以用
$monitor打印,也可以用if判断输出对不对。
`timescale。没有它,工具不知道你的 #5 是5纳秒还是5皮秒。我刚开始学时就吃过这个亏,仿真结果全是乱的。
嗯,这一章的内容就这些。记住,Verilog不是编程语言,它是硬件描述语言。你写的是电路,不是软件。多写、多仿真、多犯错,慢慢就找到感觉了。
下一章,我们聊聊如何用Verilog搭建一个简单的SoC子系统。到时候,你会看到这些基础模块是怎么组合成系统的。