2. 硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、阻塞赋值与非阻塞赋值、Testbench编写
各位同学,咱们今天聊聊Verilog。说实话,我刚开始学FPGA那会儿,觉得Verilog不就是写代码嘛,跟C语言差不多。后来踩了无数坑才明白——硬件描述语言,核心是“描述硬件”,不是“写程序”。这个观念不转过来,后面会很难受。
好,咱们直接进入正题。这一章我打算从四个核心点展开:模块化设计、组合逻辑与时序逻辑的区别、阻塞赋值与非阻塞赋值的坑、以及Testbench怎么写。这些都是你写Verilog必须刻在脑子里的东西。
2.1 模块化设计:把大问题拆成小积木
做FPGA工程,最忌讳的就是把所有逻辑写在一个大模块里。我见过一个同事,一个模块写了3000行,最后他自己都看不懂了。调试的时候,改一行代码要仿真半小时,那叫一个痛苦。
模块化设计说白了,就是“分而治之”。把复杂功能拆成多个小模块,每个模块只干一件事。比如一个视频处理系统,你可以拆成:
- 输入接口模块:负责接收摄像头数据
- 图像处理模块:做缩放、滤波、色彩转换
- 输出显示模块:驱动HDMI或LCD屏幕
- 控制模块:管理状态机、配置寄存器
每个模块独立设计、独立仿真、独立验证。最后再像搭积木一样拼起来。这样做的好处很明显:
- 代码可读性强,别人一看就懂
- 复用性高,这个模块下次还能用
- 调试方便,出问题能快速定位到具体模块
来看一个简单的模块定义示例:
module counter #(
parameter WIDTH = 8
)(
input wire clk,
input wire rst_n,
input wire en,
output reg [WIDTH-1:0] count
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
count <= 0;
else if (en)
count <= count + 1;
end
endmodule
嗯,这里要注意:模块的端口声明要清晰,输入输出分开。我个人习惯把时钟和复位放在最前面,这样一眼就能看到时序控制信号。
2.2 组合逻辑与时序逻辑:一个“立刻”一个“等一拍”
这是新手最容易混淆的地方。我直接说结论:
- 组合逻辑:输入一变,输出立刻变。没有时钟参与,没有记忆功能。
- 时序逻辑:在时钟边沿触发,输出要等一个时钟周期才变化。有记忆功能。
你想想看,如果你把该用时序逻辑的地方写成了组合逻辑,那结果就是——输出乱跳,时序不满足,仿真和实际对不上。我曾经在一个项目里,把状态机的状态更新写成了组合逻辑,结果仿真跑得好好的,上板子就乱跳。查了两天才发现是这个问题,气得我差点把键盘摔了。
来看两个对比示例:
组合逻辑(用 assign 或 always @(*))
// 组合逻辑:输入a、b变化,输出y立刻变化
assign y = a & b;
// 或者用always块
always @(*) begin
y = a & b;
end
时序逻辑(用 always @(posedge clk))
// 时序逻辑:在时钟上升沿,把a&b的结果赋值给y_reg
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
y_reg <= 1'b0;
else
y_reg <= a & b;
end
看到了吗?组合逻辑的赋值用 =,时序逻辑的赋值用 <=。这可不是随便写的,背后有深刻的硬件原理。
2.3 阻塞赋值与非阻塞赋值:一字之差,天壤之别
这个知识点,我敢说90%的Verilog初学者都在这上面栽过跟头。我自己也不例外。
简单理解:
- 阻塞赋值 (=):顺序执行,上一条赋值完,下一条才能开始。像C语言那样。
- 非阻塞赋值 (<=):同时执行,所有赋值在时钟边沿同时发生。这才是硬件的真实行为。
为什么会这样?因为硬件里,所有寄存器都是在同一个时钟边沿同时更新的。你用阻塞赋值去描述时序逻辑,就会导致“先赋值给A,再用A的新值赋值给B”,这在硬件里是不存在的。
来看一个经典的错误示例:
// 错误写法:用阻塞赋值描述时序逻辑
always @(posedge clk) begin
a = b;
c = a; // 这里c得到的是a的新值,不是旧值!
end
在硬件里,a和c应该同时更新。a拿到b的旧值,c拿到a的旧值。但上面这段代码,c拿到的是a的新值。仿真结果和实际硬件完全不一样!
正确的写法:
// 正确写法:用非阻塞赋值描述时序逻辑
always @(posedge clk) begin
a <= b;
c <= a; // c得到的是a的旧值,符合硬件行为
end
总结一下我的个人习惯:
- 描述组合逻辑的 always @(*),用阻塞赋值 =
- 描述时序逻辑的 always @(posedge clk),用非阻塞赋值 <=
- 同一个 always 块里,不要混用两种赋值方式
2.4 Testbench编写:没有仿真,你就是在盲人摸象
写FPGA不写Testbench?那跟闭着眼睛开车有什么区别。我见过太多人,写完代码直接上板子调,结果一调就是一周。其实花半小时写个Testbench,很多问题在仿真阶段就能发现。
一个标准的Testbench包含这几部分:
- 实例化待测模块(DUT)
- 生成时钟和复位信号
- 产生测试激励
- 检查输出结果(可选,但强烈建议)
来看一个完整的Testbench示例,测试我们刚才写的计数器:
`timescale 1ns / 1ps
module tb_counter;
// 参数
parameter WIDTH = 8;
// 信号声明
reg clk;
reg rst_n;
reg en;
wire [WIDTH-1:0] count;
// 实例化DUT
counter #(
.WIDTH(WIDTH)
) u_dut (
.clk (clk),
.rst_n (rst_n),
.en (en),
.count (count)
);
// 生成时钟:周期10ns
initial begin
clk = 0;
forever #5 clk = ~clk;
end
// 生成复位和激励
initial begin
// 初始化
rst_n = 0;
en = 0;
// 释放复位
#20;
rst_n = 1;
// 使能计数
#10;
en = 1;
// 计数20个周期后停止
#200;
en = 0;
// 再等一会儿,观察结果
#50;
// 结束仿真
$finish;
end
// 打印结果(可选)
initial begin
$monitor("time=%0t, count=%d", $time, count);
end
endmodule
嗯,这里有几个要点:
timescale定义了时间单位和精度,一定要写- 时钟生成用
forever循环,简单高效 - 激励用
initial块,按时间顺序写 $monitor可以实时打印信号变化,调试利器
2.5 本章知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图。这张图展示了Verilog基础的四块内容,以及它们之间的关系:
这张图把本章的四个知识点串起来了。你注意看,组合逻辑和时序逻辑是并列关系,它们分别对应不同的赋值方式。而模块化设计是顶层思想,Testbench是验证手段。四者缺一不可。
好了,这一章的内容就到这里。记住我反复强调的那句话:Verilog是描述硬件,不是写程序。把心态调整好,后面学起来会轻松很多。