第三章 Verilog基础与流水灯:组合逻辑与时序逻辑,模块化设计

各位同学,欢迎来到第三章。这一章我们开始真正动手写代码了。

说实话,很多初学者一上来就被Verilog的语法吓到了。什么assign、always、阻塞赋值、非阻塞赋值……搞得一头雾水。我当年也是这样,看了三天书,脑子里还是一团浆糊。后来我发现,其实你只要搞明白两件事——组合逻辑和时序逻辑,剩下的都是套路。

3.1 组合逻辑 vs 时序逻辑:本质区别

先问一个问题:一个数字电路,如果输入变了,输出会不会立刻跟着变?

如果答案是「会」,那就是组合逻辑。比如一个与门,A和B输入1,输出马上就是1。没有延迟的概念(理想情况下)。

如果答案是「不会,要等时钟沿来了才变」,那就是时序逻辑。比如一个D触发器,数据在时钟上升沿才被锁存到输出端。

我在项目中遇到过最典型的坑:有人把组合逻辑和时序逻辑混在一起写,结果仿真对了,上板子就乱跳。为什么?因为组合逻辑的路径延迟没考虑,导致时序违规。

核心记忆点:

  • 组合逻辑:用 assign 或 always @(*) 描述,输出只依赖当前输入
  • 时序逻辑:用 always @(posedge clk) 描述,输出依赖时钟沿和之前的状态

3.2 模块化设计:把大问题拆成小零件

写FPGA代码和写软件不一样。软件你可以一个main函数写几千行,但硬件不行。一个模块如果超过200行,我建议你立刻拆开。

模块化设计说白了就是「分而治之」。每个模块只干一件事,接口清晰,内部实现随便改。

举个例子,一个流水灯系统,你可以拆成三个模块:

  1. 时钟分频模块:把50MHz的系统时钟分频成1Hz的闪烁时钟
  2. 计数器模块:对闪烁时钟计数,产生流水灯的位移信号
  3. 输出控制模块:根据计数值,点亮对应的LED

你看,每个模块都很简单。调试的时候,哪个模块出问题就单独测哪个。我曾经在一个大项目里,因为没做模块化,一个bug查了三天——最后发现是某个信号命名冲突了。从那以后,我每个模块都加前缀,比如 led_ctrl_、clk_div_,再也没出过这种低级错误。

3.3 流水灯实验:从零开始写代码

好,理论讲完了,我们直接上手。流水灯实验的目标:8个LED,从左到右依次点亮,循环往复。

3.3.1 顶层模块设计

先画个结构图,看看各个模块怎么连。我习惯用SVG画这种小框图,一目了然。

时钟分频模块 clk_div 计数器模块 counter 输出控制模块 led_ctrl clk_50m rst_n led_out[7:0] 流水灯系统模块结构图

嗯,这张图很清楚了。顶层模块只负责例化子模块,连线。你想想看,如果以后要改成跑马灯,只需要改输出控制模块,其他两个完全不用动。这就是模块化的好处。

3.3.2 时钟分频模块

系统时钟一般是50MHz,周期20ns。人眼能分辨的闪烁频率大概在10Hz左右。所以我们得把50MHz分频成1Hz,这样LED每0.5秒切换一次状态。

module clk_div (
    input  wire       clk_in,   // 50MHz
    input  wire       rst_n,    // 低电平复位
    output reg        clk_out   // 1Hz
);

    // 50_000_000 / 2 = 25_000_000 次翻转一次
    parameter CNT_MAX = 25_000_000 - 1;
    reg [24:0] cnt;

    always @(posedge clk_in or negedge rst_n) begin
        if (!rst_n) begin
            cnt <= 0;
            clk_out <= 0;
        end else if (cnt == CNT_MAX) begin
            cnt <= 0;
            clk_out <= ~clk_out;
        end else begin
            cnt <= cnt + 1;
        end
    end

endmodule

个人习惯:我写分频器时,参数名喜欢用全大写加下划线,比如 CNT_MAX。这样一眼就能看出是常量,不是信号。另外,复位信号我统一用 rst_n,后缀_n表示低电平有效。团队协作时,这种命名规范能省很多沟通成本。

3.3.3 计数器模块

有了1Hz的时钟,我们用它来驱动一个3位计数器。3位可以表示0~7,正好对应8个LED。

module counter (
    input  wire       clk,
    input  wire       rst_n,
    output reg [2:0]  count
);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 0;
        else
            count <= count + 1;
    end

endmodule

这里有个细节:count 是 reg 类型,但在 always 块里被赋值。为什么不用 wire?因为时序逻辑的输出必须用 reg 来存储状态。说白了,reg 对应硬件里的寄存器,wire 对应连线。

3.3.4 输出控制模块

最后一步,把计数器的值翻译成LED的点亮模式。最简单的流水灯:计数值为0时,点亮LED0;计数值为1时,点亮LED1……以此类推。

module led_ctrl (
    input  wire [2:0]  count,
    output reg  [7:0]  led
);

    always @(*) begin
        case (count)
            0: led = 8'b0000_0001;
            1: led = 8'b0000_0010;
            2: led = 8'b0000_0100;
            3: led = 8'b0000_1000;
            4: led = 8'b0001_0000;
            5: led = 8'b0010_0000;
            6: led = 8'b0100_0000;
            7: led = 8'b1000_0000;
            default: led = 8'b0000_0001;
        endcase
    end

endmodule

注意!这个模块用的是 always @(*),也就是组合逻辑。组合逻辑里必须用阻塞赋值 =,不能用 <=。我曾经见过有人在这里用了非阻塞赋值,结果仿真波形完全不对,LED乱跳。记住:组合逻辑用 =,时序逻辑用 <=,这是铁律。

3.3.5 顶层模块:把零件拼起来

module top_led (
    input  wire       clk_50m,
    input  wire       rst_n,
    output wire [7:0] led
);

    wire        clk_1hz;
    wire [2:0]  count;

    // 例化时钟分频
    clk_div u_clk_div (
        .clk_in (clk_50m),
        .rst_n  (rst_n),
        .clk_out(clk_1hz)
    );

    // 例化计数器
    counter u_counter (
        .clk   (clk_1hz),
        .rst_n (rst_n),
        .count (count)
    );

    // 例化输出控制
    led_ctrl u_led_ctrl (
        .count(count),
        .led  (led)
    );

endmodule

你看,顶层模块就是纯粹的连线。每个子模块的接口都清清楚楚,以后想换功能,比如改成「两个LED同时流水」,只需要改 led_ctrl 模块里的case表就行。

3.4 避坑指南:我踩过的那些雷

  • 复位信号没处理好:我曾经有一次忘记把复位信号连到所有模块,结果上电后LED状态随机。后来我养成了习惯:每个时序模块都必须有复位,而且复位信号统一从顶层接入。
  • 分频系数算错:50MHz分频到1Hz,需要计数25_000_000次。但计数器是从0开始的,所以最大值是24_999_999。如果你写成25_000_000,频率就变成0.9999Hz,肉眼看不出来,但如果你级联多个分频器,误差会累积。
  • 仿真和上板不一致:仿真时时钟是理想的,但上板后时钟有抖动。如果你的分频器对时钟边沿敏感,可能会误触发。解决办法:在分频器里加一个同步器,或者用PLL生成时钟。

3.5 本章小结

这一章我们做了三件事:

知识点 核心要点 代码对应
组合逻辑 输出随输入变化,用 assign 或 always @(*) led_ctrl 模块
时序逻辑 输出在时钟沿变化,用 always @(posedge clk) clk_div、counter 模块
模块化设计 每个模块只干一件事,接口清晰 top_led 顶层例化

说实话,流水灯虽然简单,但它包含了数字电路设计的全部核心思想:时钟、复位、分频、计数、译码。你把这个实验吃透了,后面学I2C、SPI、DDR控制器,思路都是一样的——拆成小模块,一个一个搞定。

好,这一章就到这里。代码你可以直接复制到Vivado或Quartus里跑一下,看看LED是不是真的在流水。如果有问题,回头看看避坑指南,多半能解决。

课后小练习:试着把流水灯改成「呼吸灯」效果——LED先慢慢变亮,再慢慢变暗。提示:用PWM加计数器实现。

可可精品资料库