模块化设计:从零开始搭建你的数字世界

各位同学,今天我们来聊聊FPGA/CPLD设计中一个非常重要的概念——模块化设计。说实话,我刚开始学Verilog的时候,总喜欢把所有代码写在一个文件里,觉得这样省事。结果有一次项目做到一半,要修改一个功能,找了半天才找到对应的代码段,改完又担心影响其他地方。那叫一个痛苦啊!

后来我明白了,模块化设计就像搭积木。你把一个复杂的功能拆成几个小模块,每个模块只管自己的事。这样不仅代码清晰,调试方便,还能复用。今天我们就来系统学习一下Verilog中的模块化设计。

一、模块的基本结构:module/endmodule

在Verilog里,每个设计单元都是一个模块。模块以module开头,以endmodule结束。你可以把它想象成一个黑盒子,有输入、有输出,内部实现具体的功能。

module counter (
    input  wire       clk,
    input  wire       rst_n,
    output reg  [3:0] count
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 4'd0;
        else
            count <= count + 1'b1;
    end
endmodule

你看,这个计数器模块,输入是时钟和复位,输出是4位的计数值。模块内部就是一个简单的计数器逻辑。我个人习惯,模块名用有意义的英文单词,比如counteruart_txspi_master,这样一看就知道是干什么的。

小技巧:模块名不要用数字开头,也不要用Verilog的关键字。我见过有人用1st_counter,结果编译报错,找了半天才发现是名字的问题。

二、端口定义:input/output/inout

端口就是模块和外界通信的通道。Verilog支持三种端口类型:

端口类型 方向 常用场景
input 输入 时钟、复位、数据输入
output 输出 数据输出、状态指示
inout 双向 I2C数据线、三态总线

端口定义时,还要指定数据类型。最常用的是wirereg。简单来说:

  • wire:用于组合逻辑,比如assign语句驱动的信号
  • reg:用于时序逻辑,比如always块中赋值的信号

嗯,这里要注意:output reg可以直接在端口定义时声明,也可以先声明output,再在内部定义reg。我个人喜欢第一种,代码更简洁。

重点:inout端口必须声明为wire类型,不能是reg。因为双向端口需要三态控制,而三态门只能用连续赋值(assign)实现。

三、模块实例化:把积木拼起来

模块定义好了,怎么用呢?这就需要实例化。说白了,就是把你写好的模块当成一个元件,在另一个模块里调用它。

实例化有两种方式:

1. 按端口顺序连接

counter u_counter (
    clk,    // 连接到clk
    rst_n,  // 连接到rst_n
    count   // 连接到count
);

这种方式简单,但容易出错。我曾经在项目中用过,后来发现端口顺序搞错了,调试了两天才找到问题。从那以后,我再也不用这种方式了。

2. 按端口名称连接(推荐)

counter u_counter (
    .clk   (sys_clk),
    .rst_n (sys_rst_n),
    .count (led_count)
);

这种方式明确指定了每个端口连接什么信号,代码可读性强,也不容易出错。我强烈建议你使用这种方式。

避坑指南:我曾经遇到过一个情况,实例化时漏掉了一个端口。结果综合出来的电路功能不对,但编译又不报错。因为Verilog允许端口悬空。所以实例化时,一定要检查每个端口都正确连接了。

四、模块化设计的核心逻辑

说了这么多,我们来画一张图,看看模块化设计的核心思想是什么。

顶层模块 (top) 子模块 A (uart) 子模块 B (spi) 功能模块 1 功能模块 2 功能模块 3 功能模块 4 底层库模块 (时钟分频、边沿检测等) 模块化设计:顶层 → 子模块 → 功能模块 → 底层库

你看,顶层模块调用子模块,子模块又可以调用更底层的功能模块。这样一层层搭建起来,结构清晰,每个模块只负责自己的功能。你想想看,如果所有代码都写在一个模块里,那得多乱啊!

五、实战中的模块化设计原则

说了这么多理论,我们来聊聊实际项目中怎么用。我总结了几个原则:

  1. 单一职责:一个模块只做一件事。比如计数器就只计数,不要在里面加串口发送的逻辑。
  2. 接口简洁:模块的端口不要太多。我一般控制在10个以内,太多了说明这个模块功能太复杂,需要拆分。
  3. 层次清晰:顶层模块只做连接,不写具体逻辑。具体功能交给子模块实现。
  4. 参数化设计:用parameter定义常量,方便修改。比如数据位宽、计数器最大值等。
我的习惯:每个模块单独一个文件,文件名和模块名保持一致。这样找代码的时候,看文件名就知道是哪个模块。项目大了,这个习惯能帮你省不少时间。

六、一个完整的例子

最后,我们来看一个完整的例子。假设我们要设计一个LED闪烁控制器,它由一个分频模块和一个计数模块组成。

// 分频模块
module clk_div (
    input  wire       clk,
    input  wire       rst_n,
    output reg        clk_out
);
    parameter DIV_CNT = 24_000_000;  // 50MHz分频到约2Hz
    
    reg [24:0] cnt;
    
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            cnt <= 0;
            clk_out <= 0;
        end else if (cnt == DIV_CNT - 1) begin
            cnt <= 0;
            clk_out <= ~clk_out;
        end else begin
            cnt <= cnt + 1'b1;
        end
    end
endmodule

// 计数模块
module led_ctrl (
    input  wire       clk,
    input  wire       rst_n,
    output reg  [3:0] led
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            led <= 4'b0001;
        else
            led <= {led[2:0], led[3]};  // 循环移位
    end
endmodule

// 顶层模块
module top (
    input  wire       clk,
    input  wire       rst_n,
    output wire [3:0] led
);
    wire clk_2hz;
    
    clk_div #(
        .DIV_CNT(24_000_000)
    ) u_clk_div (
        .clk    (clk),
        .rst_n  (rst_n),
        .clk_out(clk_2hz)
    );
    
    led_ctrl u_led_ctrl (
        .clk   (clk_2hz),
        .rst_n (rst_n),
        .led   (led)
    );
endmodule

你看,顶层模块只负责实例化两个子模块,并把它们连接起来。分频模块和计数模块各司其职,互不干扰。这就是模块化设计的魅力。

好了,今天的内容就到这里。模块化设计是FPGA/CPLD设计的基石,一定要掌握好。下次我们聊聊组合逻辑和时序逻辑的区别,这可是个容易踩坑的地方。


专注资料整理