3. Verilog基础语法回顾:模块化设计、always块、assign语句、阻塞与非阻塞赋值、testbench编写

各位同学,欢迎来到第三讲。在正式开始图像滤波算法的FPGA实现之前,我们得先把吃饭的家伙——Verilog语法——再捋一遍。别嫌我啰嗦,我见过太多同学算法思路清晰,结果一写代码就出bug,最后查半天发现是最基本的赋值语句用错了。这就像盖大楼,地基没打好,后面全白搭。

今天咱们就聚焦几个核心点:模块化设计、always块、assign语句、阻塞与非阻塞赋值,还有testbench怎么写。这些内容你肯定都学过,但咱们今天换个角度,从“实战避坑”的角度再过一遍。

3.1 模块化设计:把大问题拆成小积木

说白了,模块化设计就是“分而治之”。一个复杂的图像滤波算法,你不可能在一个module里写完所有逻辑。那样做,代码会变得又臭又长,仿真慢,调试更是噩梦。

我个人习惯,拿到一个算法,先画数据流图。比如一个3x3的均值滤波,我会把它拆成:

  • 行缓存模块:负责生成3行数据。
  • 窗口生成模块:从3行数据里抠出3x3的像素矩阵。
  • 计算模块:对9个像素求和、求平均。

每个模块独立设计、独立仿真。最后再顶层例化,像搭积木一样把它们拼起来。

核心原则:高内聚、低耦合。每个模块只干一件事,接口尽量简单。

这里我画了一张图,帮你理解模块化设计的层次关系:

顶层模块 (Top) 行缓存模块 窗口生成模块 计算模块 数据流方向:行缓存 → 窗口生成 → 计算

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的地方。

先记住两条铁律:

  1. 时序逻辑(always @(posedge clk))里,用非阻塞赋值 <=。
  2. 组合逻辑(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语言,脑子里时刻要有硬件电路的概念。

可可精品资料库