3. Verilog基础语法回顾:模块化设计、always块、assign语句、阻塞与非阻塞赋值、testbench编写
各位同学,欢迎来到第三讲。在正式开始图像滤波算法的FPGA实现之前,我们得先把吃饭的家伙——Verilog语法——再捋一遍。别嫌我啰嗦,我见过太多同学算法思路清晰,结果一写代码就出bug,最后查半天发现是最基本的赋值语句用错了。这就像盖大楼,地基没打好,后面全白搭。
今天咱们就聚焦几个核心点:模块化设计、always块、assign语句、阻塞与非阻塞赋值,还有testbench怎么写。这些内容你肯定都学过,但咱们今天换个角度,从“实战避坑”的角度再过一遍。
3.1 模块化设计:把大问题拆成小积木
说白了,模块化设计就是“分而治之”。一个复杂的图像滤波算法,你不可能在一个module里写完所有逻辑。那样做,代码会变得又臭又长,仿真慢,调试更是噩梦。
我个人习惯,拿到一个算法,先画数据流图。比如一个3x3的均值滤波,我会把它拆成:
- 行缓存模块:负责生成3行数据。
- 窗口生成模块:从3行数据里抠出3x3的像素矩阵。
- 计算模块:对9个像素求和、求平均。
每个模块独立设计、独立仿真。最后再顶层例化,像搭积木一样把它们拼起来。
核心原则:高内聚、低耦合。每个模块只干一件事,接口尽量简单。
这里我画了一张图,帮你理解模块化设计的层次关系:
3.2 always块:组合逻辑 vs 时序逻辑
always块是Verilog的灵魂。但很多新手分不清什么时候用组合逻辑,什么时候用时序逻辑。
我的经验法则:
- 要生成寄存器(触发器),就用
always @(posedge clk or negedge rst_n) - 要生成纯组合逻辑,就用
always @(*)
举个例子,一个简单的D触发器:
// 时序逻辑:带异步复位的D触发器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 1'b0;
else
q <= d;
end
再看一个组合逻辑的例子:
// 组合逻辑:4选1多路选择器
always @(*) begin
case(sel)
2'b00: y = a;
2'b01: y = b;
2'b10: y = c;
2'b11: y = d;
default: y = a;
endcase
end
注意:组合逻辑的always块里,如果条件没覆盖全,很容易产生锁存器(latch)。我当年第一次做项目,就因为case语句少写了一个default,综合出来一堆latch,时序直接崩了。从那以后,我写组合逻辑always块,必加default。
3.3 assign语句:连续赋值的妙用
assign语句,说白了就是“线连好了,信号一变,输出立马跟着变”。它描述的是组合逻辑,而且比always块更简洁。
我个人习惯,简单的组合逻辑用assign,复杂的才用always块。比如:
// 用assign实现一个加法器
assign sum = a + b;
// 用assign实现一个三态门
assign data_bus = (en) ? data_out : 1'bz;
你想想看,如果上面这个加法器用always块写,是不是得多写好几行?assign就是干这个的——让代码更干净。
3.4 阻塞与非阻塞赋值:FPGA工程师的“送命题”
这个话题,我每次讲都要多花点时间。因为这是面试必问,也是项目里最容易出bug的地方。
先记住两条铁律:
- 时序逻辑(always @(posedge clk))里,用非阻塞赋值 <=。
- 组合逻辑(always @(*) 或 assign)里,用阻塞赋值 =。
为什么会这样?我解释一下:
- 非阻塞赋值 <=:所有赋值操作是“同时”发生的。它模拟了寄存器的行为——在时钟沿到来时,所有寄存器同时更新。
- 阻塞赋值 =:赋值操作是“顺序”执行的。它模拟了组合逻辑的行为——信号像水流一样,一步一步传递。
来看一个经典的反例:
// 错误示范:时序逻辑里用了阻塞赋值
always @(posedge clk) begin
a = b;
c = a; // 这里c得到的是a更新后的值,而不是b原来的值
end
这段代码综合出来是什么?是两个串联的寄存器,而不是两个并行的寄存器。这在大部分情况下都不是你想要的结果。
避坑指南:我曾经在一个图像缩放项目里,就因为一个always块里混用了阻塞和非阻塞赋值,导致仿真结果和实际硬件行为不一致。查了整整两天,最后发现是赋值语句用错了。从那以后,我写代码前都会先问自己一句:“这个always块是组合逻辑还是时序逻辑?”
3.5 testbench编写:验证你的设计
代码写完了,不仿真验证一下,你敢直接上板子?反正我不敢。testbench就是用来干这个的。
一个基本的testbench结构如下:
module tb_image_filter();
// 1. 声明信号
reg clk;
reg rst_n;
reg [7:0] pixel_in;
wire [7:0] pixel_out;
// 2. 例化待测模块
image_filter u_dut (
.clk (clk),
.rst_n (rst_n),
.pixel_in (pixel_in),
.pixel_out(pixel_out)
);
// 3. 生成时钟
initial begin
clk = 0;
forever #5 clk = ~clk; // 10ns周期,100MHz
end
// 4. 生成复位和测试激励
initial begin
rst_n = 0;
#20;
rst_n = 1;
// 输入测试数据
#10 pixel_in = 8'h10;
#10 pixel_in = 8'h20;
#10 pixel_in = 8'h30;
// ... 更多测试数据
#100;
$finish;
end
// 5. 监控输出
initial begin
$monitor("time=%0t, pixel_out=%h", $time, pixel_out);
end
endmodule
写testbench时,我习惯用表格来规划测试用例:
| 测试用例 | 输入数据 | 预期输出 | 说明 |
|---|---|---|---|
| 全零输入 | 0x00 | 0x00 | 验证复位和边界 |
| 全最大值 | 0xFF | 0xFF | 验证饱和处理 |
| 渐变数据 | 0x10, 0x20, 0x30... | 根据算法计算 | 验证功能正确性 |
我的建议:testbench不是随便写写就行的。好的testbench能覆盖边界情况、异常情况。我每次写完testbench,都会问自己:“如果这个模块有bug,我的testbench能抓到吗?”如果答案是否定的,那就继续加测试用例。
嗯,今天的内容就到这里。这些语法基础,是后续实现图像滤波算法的基石。别觉得简单就跳过,我见过太多人在这些细节上栽跟头。记住:写Verilog,不是写C语言,脑子里时刻要有硬件电路的概念。