一、Verilog 模块化设计:把大问题拆成小积木
做 FPGA 设计,说白了就是搭积木。
你想想看,一个交易系统里,有行情解析、订单管理、风控检查、撮合引擎……如果全写在一个文件里,那代码量轻松上千行。我刚开始做项目时就这么干过,结果调试起来简直想哭——改一个地方,不知道会影响哪里。
模块化设计就是来解决这个问题的。
1.1 模块是什么?
模块(module)是 Verilog 的基本单元。每个模块有输入、输出,内部实现具体功能。模块之间通过端口连接,就像乐高积木一样拼起来。
// 一个简单的 D 触发器模块
module d_flip_flop (
input wire clk, // 时钟
input wire rst_n, // 复位,低有效
input wire d, // 数据输入
output reg q // 数据输出
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 1'b0;
else
q <= d;
end
endmodule
嗯,这里要注意:模块名最好用下划线分隔,比如 d_flip_flop,别用驼峰。我个人习惯用全小写,这样在 Linux 下不会出大小写问题。
1.2 模块实例化:把积木拼起来
写好模块后,怎么用?实例化。
module top (
input wire clk,
input wire rst_n,
input wire data_in,
output wire data_out
);
wire q_mid; // 中间连线
// 实例化第一个 D 触发器
d_flip_flop u_dff0 (
.clk (clk),
.rst_n(rst_n),
.d (data_in),
.q (q_mid)
);
// 实例化第二个 D 触发器
d_flip_flop u_dff1 (
.clk (clk),
.rst_n(rst_n),
.d (q_mid),
.q (data_out)
);
endmodule
看到没?u_dff0 和 u_dff1 就是实例名。我建议实例名用 u_ 开头,这样一眼就能看出是模块实例,不是变量。
.端口名(连线名) 的格式,别用位置连接。位置连接一旦端口顺序变了,代码就全废了。我在项目中吃过这个亏,后来再也不敢用了。
二、组合逻辑与时序逻辑:一个管现在,一个管未来
这两个概念,是 FPGA 设计的根基。搞不懂它们,后面寸步难行。
2.1 组合逻辑
组合逻辑的输出只取决于当前输入。说白了,输入一变,输出立刻变,没有记忆功能。
// 组合逻辑:与门
assign y = a & b;
这里 assign 是连续赋值语句。只要 a 或 b 变了,y 马上跟着变。
2.2 时序逻辑
时序逻辑的输出不仅取决于当前输入,还取决于之前的状态。它需要时钟来驱动,有记忆功能。
// 时序逻辑:计数器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
cnt <= 8'd0;
else if (cnt == 8'd255)
cnt <= 8'd0;
else
cnt <= cnt + 1'b1;
end
注意看,这里用的是 <=(非阻塞赋值),不是 =。为什么?因为时序逻辑里,多个赋值要同时发生,不能有先后顺序。
<=,组合逻辑用 =,别混着来。
三、always 块与 assign 语句:两种描述方式
Verilog 里描述电路,主要就这两种方式。我刚开始学的时候,总觉得它们差不多,后来才发现各有各的用处。
3.1 assign 语句
assign 用来描述组合逻辑。它相当于一根导线,左边是输出,右边是输入表达式。
assign sum = a + b;
assign carry = a & b;
简单、直接、好理解。适合做简单的组合逻辑。
3.2 always 块
always 块更灵活,既能描述组合逻辑,也能描述时序逻辑。关键看敏感列表怎么写。
// 组合逻辑的 always 块
always @(*) begin
if (sel)
out = a;
else
out = b;
end
// 时序逻辑的 always 块
always @(posedge clk) begin
q <= d;
end
看到区别了吗?组合逻辑的敏感列表是 @(*),表示所有输入信号变化都触发。时序逻辑的敏感列表是 @(posedge clk),只在时钟上升沿触发。
- 简单组合逻辑 → 用
assign - 复杂组合逻辑(if-else、case) → 用
always @(*) - 时序逻辑 → 用
always @(posedge clk)
四、仿真与 Testbench 编写:不仿真就上板,那是找死
我见过太多人,写完代码直接烧到板子上。结果呢?灯不亮、数据不对、系统死机……然后开始怀疑人生。
仿真,是 FPGA 设计的必备环节。说白了,就是在电脑上模拟你的电路,看看它跑起来对不对。
4.1 什么是 Testbench?
Testbench 就是测试平台。它不是一个可综合的模块,而是用来给被测试模块(DUT)提供激励信号,并检查输出是否正确。
`timescale 1ns / 1ps // 时间单位/精度
module tb_d_flip_flop;
reg clk;
reg rst_n;
reg d;
wire q;
// 实例化被测试模块
d_flip_flop u_dut (
.clk (clk),
.rst_n(rst_n),
.d (d),
.q (q)
);
// 生成时钟:周期 10ns
always #5 clk = ~clk;
// 测试过程
initial begin
// 初始化
clk = 0;
rst_n = 0;
d = 0;
// 复位
#20 rst_n = 1;
// 测试数据
#10 d = 1;
#10 d = 0;
#10 d = 1;
#10 d = 1;
// 结束仿真
#50 $finish;
end
// 监控输出
initial begin
$monitor("time=%0t, clk=%b, rst_n=%b, d=%b, q=%b",
$time, clk, rst_n, d, q);
end
endmodule
4.2 仿真流程
- 编写 Testbench:用
initial块生成激励,用always块生成时钟 - 编译:把 DUT 和 Testbench 一起编译
- 运行仿真:观察波形或打印日志
- 检查结果:看输出是否符合预期
五、本章知识体系
下面这张图,帮你理清本章的核心脉络:
六、避坑指南:我踩过的那些坑
最后,分享几个我亲身经历过的教训:
- 忘记初始化寄存器:FPGA 上电后,寄存器值是随机的。如果不复位,仿真可能对,但上板就错。我有个项目因此多花了两周调试。
- 组合逻辑环路组合逻辑的输出又连回输入,形成环路。这会导致仿真卡死,综合报错。检查代码时,多留意有没有
assign a = a & b;这种写法。 - 敏感列表不全:在
always @(a or b)里漏了信号,综合时会生成锁存器。现在我都用@(*),省心。 - Testbench 写得太简单:只测正常情况,不测边界。结果上板后,遇到极端数据就崩了。现在我的 Testbench 都会覆盖正常、边界、异常三种情况。
好了,这一章的内容就到这里。记住:模块化是骨架,组合逻辑和时序逻辑是血肉,仿真验证是灵魂。把这四个点吃透了,后面的路就好走了。