FPGA入门(八):一篇讲透跑马灯、闪烁灯、呼吸灯的原理与模拟波形分析

FPGA入门


文章目录


前言

很多人学习 FPGA 的第一个实战项目都是 LED 点灯,但不少人停留在 "抄代码能跑,换个需求就懵" 的阶段:

为什么计数器最大值总要减 1?

呼吸灯的 4 秒周期是怎么算出来的?

循环跑马灯到底怎么实现的?

PWM 控制亮度的本质是什么?

这篇文章就把这三类 LED 效果彻底讲透,从基础的分频计数,到进阶的 PWM 渐变,再到综合的模块化设计,由浅入深,一篇打通。


一、整体系统架构

核心功能

这是一个基于 FPGA 的 8 路可编程霓虹灯系统,通过 5 位拨码开关实现全交互控制:

高 2 位开关:4 档速度调节,控制流水灯、随机灯等动态效果的切换快慢

低 3 位开关:8 种显示模式切换,覆盖固定、流水、往返、呼吸、扩散、随机等经典 LED 效果

纯硬件时序逻辑实现,无软件参与,效果流畅无卡顿

整体系统架构

整个工程采用分层模块化设计,这也是工业级 FPGA 项目的标准开发思路:顶层只做信号互联,功能逻辑全部下沉到子模块,解耦性强、易调试、易拓展。

整体架构分为 4 层,信号流向从输入到输出单向传递:

二、顶层模块设计:系统总装与信号互联

顶级设计代码

c 复制代码
// 仿真时间尺度定义:时间单位1ns,仿真精度1ps
// 必须和所有子模块保持一致,保证仿真时序准确
`timescale 1ns / 1ps

//////////////////////////////////////////////////////////////////////////////////
// 模块名称:neon_light
// 模块类型:顶层模块(Top Module)
// 核心功能:整个霓虹灯系统的总装配,对外引出硬件引脚,内部连接所有子模块
// 设计思想:模块化分层设计,顶层只做信号互联,不实现具体功能逻辑
// 输入输出:
//   clk     : 系统时钟输入(开发板晶振,常用50MHz)
//   rst_n   : 全局复位,低电平有效
//   sw[4:0]: 5位拨码开关输入
//            ├─ sw[4:3] 高2位:速度档位控制,共4档
//            └─ sw[2:0] 低3位:显示模式选择,共8种
//   led[7:0]: 8路LED输出,直接驱动开发板上的LED灯
//////////////////////////////////////////////////////////////////////////////////
module neon_light(
    clk,
    rst_n,
    sw,
    led
    );
    
    // 端口方向声明
    input clk;                // 系统时钟输入
    input rst_n;              // 全局低电平复位
    input [4:0] sw;           // 5位拨码开关输入
    output [7:0] led;         // 8位LED输出
    
    // ===================== 内部连线定义(wire型) =====================
    // wire型:纯连线,无存储功能,用于子模块之间传递信号
    wire clk_up;               // 分频模块输出的低速刷新脉冲,控制动态效果切换速度
    wire pwm_out;              // 呼吸灯模块输出的PWM渐变信号
    wire [7:0] rnd;            // 随机数模块输出的8位伪随机数


    // ===================== 子模块例化1:时钟分频模块 =====================
    // 功能:将高频系统时钟降频,生成可调速的刷新脉冲
    clk_div clk_div_inst(
     .clk(clk),                // 接入全局系统时钟
     .rst_n(rst_n),            // 接入全局复位
     .speed(sw[4:3]),          // 拨码高2位作为速度选择输入
     .clk_up(clk_up)           // 分频后的刷新脉冲输出到内部连线
    );


    // ===================== 子模块例化2:PWM呼吸灯模块 =====================
    // 功能:持续生成占空比渐变的PWM波形,用于呼吸灯效果
    pwm_led pwm_led_inst(
        .clk    (clk),         // 接入全局系统时钟
        .rst_n  (rst_n),       // 接入全局复位
        .pwm_out(pwm_out)      // PWM渐变信号输出到内部连线
    );
    

    // ===================== 子模块例化3:LFSR伪随机数模块 =====================
    // 功能:生成8位伪随机序列,用于随机闪烁灯效果
    lfsr8 u_lfsr_inst(
        .clk    (clk),         // 接入全局系统时钟
        .rst_n  (rst_n),       // 接入全局复位
        .rnd    (rnd)          // 8位随机数输出到内部连线
    );


    // ===================== 子模块例化4:LED模式控制核心 =====================
    // 功能:系统核心调度,根据拨码选择对应模式,计算最终LED输出
    led_ctrl led_ctrl_inst(
        .clk    (clk),         // 接入全局系统时钟
        .rst_n  (rst_n),       // 接入全局复位
        .clk_up   (clk_up),    // 接入分频刷新脉冲,控制动态效果速度
        .mode_sel(sw[2:0]),    // 拨码低3位作为模式选择输入
        .random (rnd),         // 接入随机数信号,供模式7使用
        .pwm    (pwm_out),     // 接入PWM呼吸信号,供模式5使用
        .led    (led)          // 最终LED控制信号输出到顶层端口
    );
    
endmodule

顶层模块 neon_light 是整个工程的 "总装车间",它的核心原则是:只做信号接线,不实现功能逻辑

所有计数、移位、判断逻辑全部下沉到子模块,顶层只负责:

  1. 引出对接硬件的外部端口
  2. 定义内部连接线,传递子模块之间的信号
  3. 例化所有功能子模块,完成引脚对接
    这种设计的好处非常明显:修改某一种灯光效果时,只需要改对应的子模块,不会影响其他功能;新增模式也只需要拓展控制模块,不用动顶层结构。

端口定义与信号拆分

c 复制代码
module neon_light(
    clk,        // 系统全局时钟,开发板晶振输入
    rst_n,      // 全局低电平复位,按下清零所有寄存器
    sw,         // 5位拨码开关输入
    led         // 8路LED输出,对接硬件LED
);
input clk;
input rst_n;
input [4:0] sw;
output [7:0] led;

最关键的设计是拨码开关的信号拆分:

5 位开关没有混在一起用,而是按功能分成两段:

  • sw4:3(高 2 位):专门送入分频模块,控制刷新速度
  • sw2:0(低 3 位):专门送入控制模块,选择显示模式
    这种 "高位控速度、低位控模式" 的拆分方式,是人机交互设计的常用思路,操作逻辑清晰,代码也更容易维护

子模块例化与信号互联

顶层内部定义了 3 根内部连接线,作为子模块之间的信号桥梁:

c 复制代码
wire clk_up;    // 分频后的刷新脉冲,控制动态效果切换节奏
wire pwm_out;   // PWM呼吸亮度信号
wire [7:0] rnd; // 8位随机数

然后通过命名端口映射的方式,把所有子模块串联起来:

c 复制代码
// 1. 分频模块:高2位开关控制速度,输出刷新脉冲
clk_div clk_div_inst(
 .clk(clk),
 .rst_n(rst_n),
 .speed(sw[4:3]),     
 .clk_up(clk_up)        
);

// 2. 呼吸灯模块:独立运行,持续输出渐变PWM信号
pwm_led pwm_led_inst(
    .clk    (clk),
    .rst_n  (rst_n),
    .pwm_out(pwm_out)
);

// 3. 随机数模块:独立运行,持续生成8位随机值
lfsr8 u_lfsr_inst(
    .clk    (clk),
    .rst_n  (rst_n),
    .rnd    (rnd)
);

// 4. 核心控制模块:汇总所有信号,输出最终LED电平
led_ctrl led_ctrl_inst(
    .clk    (clk),
    .rst_n  (rst_n),
    .clk_up(clk_up),
    .mode_sel(sw[2:0]),  // 低3位开关直接传入,选择模式
    .random (rnd),
    .pwm    (pwm_out),
    .led    (led)
);

可以看到,拨码开关的模式选择信号 sw2:0 没有经过任何处理,直接直通到了 led_ctrl 的 mode_sel 端口,这就是模式切换的信号起点。

三、核心机制:拨码开关如何切换显示模式?

整个项目的模式切换逻辑100% 集中在 led_ctrl 模块,这是整个系统的 "大脑"。下面拆解它的实现原理。

模式切换的完整代码

c 复制代码
`timescale 1ns / 1ps

//////////////////////////////////////////////////////////////////////////////////
// 模块名称:led_ctrl
// 模块类型:核心控制子模块
// 核心功能:根据模式选择信号,切换8种不同的LED显示效果
// 设计细节:
//  1. 呼吸模式单独做最高优先级判断,实时更新,不依赖分频脉冲,保证流畅
//  2. 其他7种动态模式仅在clk_up脉冲到来时更新,控制切换速度
//  3. mode_last寄存器记录上一模式,切换模式时自动初始化状态,避免显示乱码
//////////////////////////////////////////////////////////////////////////////////

module led_ctrl(
    input           clk,        // 系统时钟输入
    input           rst_n,      // 低电平复位
    input           clk_up,     // 低速刷新脉冲,动态效果的更新节拍
    input   [2:0]   mode_sel,   // 3位模式选择输入,共8种模式
    input   [7:0]   random,     // 8位随机数输入,模式7专用
    input           pwm,        // PWM呼吸信号,模式5专用
    output reg [7:0] led        // 8位LED最终输出
);


    reg [2:0] pos;              // 扩散灯模式专用:位置偏移计数器
    reg dir;                     // 往返灯模式专用:移动方向寄存器
    reg [2:0] mode_last;         // 记录上一个时钟的模式值,用于检测模式切换

    
    // ===================== 时序逻辑:往返灯方向自动控制 =====================
    // 功能:检测LED是否移动到边界,到达边缘时自动反转移动方向
    always @(posedge clk or negedge rst_n) begin
        // 复位:默认初始方向
        if(!rst_n)
            dir = 1'b0;
        // LED移动到最左侧(bit7亮):切换为向右移动
        else if (led == 8'b1000_0000)
            dir = 1'b1;
        // LED移动到最右侧(bit0亮):切换为向左移动
        else if (led == 8'b0000_0001)
            dir = 1'b0;
    end


    // ===================== 核心时序逻辑:模式选择与LED输出 =====================
    always @(posedge clk or negedge rst_n) begin
        // 复位状态:LED输出默认图案,扩散位置计数器清零
        if(!rst_n) begin
            led <= 8'b1001_1001;
            pos <= 0;
        end 
        else begin
            // ========== 模式5:呼吸灯(最高优先级,实时更新) ==========
            // 单独判断,不依赖clk_up脉冲,保证PWM高频刷新,呼吸效果流畅
            if(mode_sel == 3'd5) begin
                // {8{pwm}} 位复制语法:将1位pwm信号复制8份,8个LED同步呼吸
                led <= {8{pwm}};     
            end 
            
            // ========== 其他7种模式:仅在刷新脉冲到来时更新 ==========
            else if(clk_up) begin
                // 记录当前模式,用于下一个时钟判断是否发生了模式切换
                mode_last <= mode_sel;
                
                // 多路分支选择:根据模式值执行对应逻辑
                case(mode_sel)
                    // ---------------- 模式0:固定图案 ----------------
                    // 静态显示固定的10011001图案,作为默认待机状态
                    3'd0: led <= 8'b1001_1001;

                    // ---------------- 模式1:循环左移流水灯 ----------------
                    3'd1: begin
                        // 刚切换到本模式:初始化状态,最右侧1颗灯亮
                        if (mode_last != mode_sel)
                            led <= 8'b0000_0001; 
                        // 正常运行:循环左移
                        // {led[6:0],led[7]}:低7位左移到高位,最高位移到最低位
                        else
                            led <= {led[6:0],led[7]};
                    end                  
                    
                    // ---------------- 模式2:循环右移流水灯 ----------------
                    3'd2: begin
                        // 刚切换到本模式:初始化状态,最左侧1颗灯亮
                        if (mode_last != mode_sel)
                            led <= 8'b1000_0000;   
                        // 正常运行:循环右移
                        // {led[0],led[7:1]}:高7位右移到低位,最低位移到最高位
                        else
                            led <= {led[0],led[7:1]};
                    end
                    
                    // ---------------- 模式3:双向往返跑马灯 ----------------
                    3'd3: begin
                        // 刚切换到本模式:初始化状态,最右侧1颗灯亮
                        if (mode_last != mode_sel)
                            led <= 8'b0000_0001;  
                        // 方向为0:向左移动
                        else if(!dir)
                            led <= led << 1;
                        // 方向为1:向右移动
                        else
                            led <= led >> 1;
                    end

                    // ---------------- 模式4:奇偶位交替闪烁 ----------------
                    3'd4: begin
                        // 三目运算符:当前是10101010则切为01010101,否则切回
                        led <= (led == 8'b10101010) ? 8'b01010101 : 8'b10101010;
                    end
                    
                    // ---------------- 模式6:对称扩散灯 ----------------
                    3'd6: begin
                        // 位置计数器自增,控制扩散进度
                        pos <= pos + 1;
                        // 同时点亮左右两个对称位置
                        // (1<<pos):左侧亮灯位置,从右往左移动
                        // (1<<(7-pos)):右侧亮灯位置,从左往右移动
                        // 按位或运算,两个位置同时亮,实现从中间向两边扩散
                        led <= (8'b1 << pos) | (8'b1 << (7-pos));
                    end

                    // ---------------- 模式7:随机闪烁灯 ----------------
                    // 直接将LFSR生成的随机数赋值给LED,每刷新一次换一组随机状态
                    3'd7: led <= random;

                    // 默认情况:全灭,防止出现未定义状态
                    default: led <= 8'b00000000;

                endcase
            end
        end
    end

endmodule

3.1 模式切换的核心骨架:case 多路分支

模式切换最核心的代码就是 case(mode_sel) 语句,这是数字电路中实现多选一功能的标准写法。

3 位的 mode_sel 刚好对应 0~7 共 8 种取值,每种取值对应一个独立的 LED 运算逻辑,拨码开关改变 mode_sel 的值,就会自动进入对应的分支,输出不同的 LED 效果。

c 复制代码
case(mode_sel)
    3'd0: 执行模式0逻辑;
    3'd1: 执行模式1逻辑;
    3'd2: 执行模式2逻辑;
    ...
    3'd7: 执行模式7逻辑;
endcase

3.2 关键细节 1:为什么大部分模式要等 clk_up 脉冲?

代码中,case 语句被包裹在 if(clk_up) 条件里:

c 复制代码
if(clk_up) begin
    case(mode_sel)
        // 所有模式逻辑
    endcase
end

原理很简单:

  • 流水、往返、随机这些效果,本质是 "一帧一帧切换画面",不需要高频实时更新;
  • clk_up 是分频后的低速脉冲(比如 0.5 秒一次),每来一个脉冲,LED 状态变一次,人眼看到的就是流畅的动态效果;
  • 如果不用分频脉冲,直接用 50MHz 高频时钟刷新,LED 会每秒切换几千万次,人眼根本看不出动态效果。

3.3 关键细节 2:为什么呼吸模式要单独拎出来判断?

代码中模式 5(呼吸灯)没有放在 case 里,而是做了最高优先级的单独判断:

c 复制代码
if(mode_sel == 3'd5) begin
    led <= {8{pwm}};     // 8个灯同步跟随PWM
end else if(clk_up) begin
    // 其他7种模式
end

这是一个非常巧妙的设计:

  • 呼吸灯的本质是亮度连续渐变,依赖高频 PWM 信号,如果也等 clk_up 脉冲才更新,亮度会一顿一顿,完全没有呼吸的流畅感;
  • 因此呼吸模式不受分频脉冲控制,实时跟随 PWM 信号输出,保证亮度渐变丝滑;
  • 同时放在 if 最前面,保证切换到呼吸模式时立刻生效,没有延迟。

3.4 关键细节 3:mode_last 解决切换乱码问题

代码里定义了一个 mode_last 寄存器,用来记录上一次的模式:

c 复制代码
reg [2:0] mode_last;

// 在clk_up脉冲到来时更新
mode_last <= mode_sel;

它的作用是模式切换时初始化状态:

  • 比如从模式 0 切到模式 1(左移流水灯),如果没有初始化,LED 会保留模式 0 的图案,直接开始移位,显示就会乱掉;
  • 通过 if (mode_last != mode_sel) 判断 "刚切换到新模式",自动给该模式设置一个初始状态(比如流水灯从最右侧一颗灯开始),保证切换模式时效果干净、正确。
c 复制代码
3'd1: begin
    if (mode_last != mode_sel)
        led <= 8'b0000_0001; // 刚切换过来,重置初始状态:最右一颗灯亮
    else
        led <= {led[6:0],led[7]}; // 正常运行:循环左移
end

3.5 速度调节如何生效?

速度开关 sw4:3 不直接参与模式切换,它只改变 clk_div 模块的分频系数:

  • 分频系数越大,clk_up 脉冲间隔越长,灯光切换越慢;
  • 分频系数越小,clk_up 脉冲间隔越短,灯光切换越快;
  • 只改变动态效果的快慢,不会改变模式本身的显示逻辑

四、8 种显示模式逐模式详解

下面逐个拆解 8 种模式的效果、核心代码与实现原理。

模式 0:固定图案

效果:8 颗 LED 固定显示 10011001,即第 0、3、4、7 位亮,其余灭,作为默认待机状态。

核心代码:

c 复制代码
3'd0: led <= 8'b1001_1001;

原理:最简单的固定电平输出,每次 clk_up 脉冲到来都赋同一个值,LED 保持不变。

模式 1、2:循环左、右移流水灯

循环右移流水灯

效果:一颗亮灯从最右侧向最左侧移动,移到最左后从最右重新出现,循环往复。

核心代码:

c 复制代码
3'd1: begin
    if (mode_last != mode_sel)
        led <= 8'b0000_0001; // 初始:最右灯亮
    else
        led <= {led[6:0], led[7]}; // 循环左移
end

原理:位拼接实现循环移位。{led6:0, led7} 把低 7 位左移到高 7 位,最高位挪到最低位,实现环形左移。

循环右移流水灯

效果:一颗亮灯从最左侧向最右侧移动,移到最右后从最左重新出现,循环往复。

核心代码:

c 复制代码
3'd2: begin
    if (mode_last != mode_sel)
        led <= 8'b1000_0000; // 初始:最左灯亮
    else
        led <= {led[0], led[7:1]}; // 循环右移
end

原理:与左移对称,把最高 7 位右移,最低位挪到最高位,实现环形右移。

模式 3:单灯双向往返

效果:一颗亮灯从右往左移,碰到最左边缘自动反向,从左往右移,碰到最右再反向,像乒乓球一样往返跑动。

核心代码:

c 复制代码
3'd3: begin
    if (mode_last != mode_sel)
        led <= 8'b0000_0001;
    else if(!dir)
        led <= led << 1; // 左移
    else
        led <= led >> 1; // 右移
end

原理:通过 dir 寄存器控制移位方向,额外逻辑检测 LED 位置:移到最左(8'b10000000)时置 dir=1(右移),移到最右(8'b00000001)时置 dir=0(左移),实现自动往返。

模式 4:奇偶位交替闪烁

效果:8 颗灯在 "奇位亮、偶位灭" 和 "偶位亮、奇位灭" 两种状态间来回切换,整体闪烁。

c 复制代码
3'd4: begin
    led <= (led == 8'b10101010) ? 8'b01010101 : 8'b10101010;
end

原理:三目运算符实现状态翻转。当前是 10101010 就切到 01010101,否则就切回来,每来一个 clk_up 脉冲翻转一次。

模式 5:全灯同步呼吸

效果:8 颗 LED 同时从全暗慢慢变亮,到最亮后再慢慢变暗,循环往复,像呼吸一样。

核心代码:

c 复制代码
if(mode_sel == 3'd5) begin
    led <= {8{pwm}};
end

原理:{8{pwm}} 把 1 位 PWM 信号复制 8 位,让 8 颗灯同步接收同一个亮度信号。PWM 信号由专门的呼吸灯模块生成,通过动态改变占空比控制平均亮度,利用人眼视觉暂留实现平滑渐变。

模式 6:对称扩散灯

效果:亮灯从中间位置向左右两边对称扩散,扩散到两端后重新从中间开始,呈现 "张开 - 合拢" 的视觉效果。

核心代码:

c 复制代码
3'd6: begin
    pos <= pos + 1;
    led <= (8'b1 << pos) | (8'b1 << (7-pos));
end

原理:用 pos 计数器记录偏移量,同时点亮两个对称位置:

左侧位置:1 << pos,从第 0 位向左移动

右侧位置:1 << (7-pos),从第 7 位向右移动

两个位置相或,就实现了左右对称点亮、同步扩散的效果。

模式 7:随机闪烁灯

效果:8 颗 LED 的亮灭完全随机,每刷新一次就换一组随机状态,呈现杂乱无章的闪烁效果。

核心代码:

c 复制代码
3'd7: led <= random;

原理:直接把 LFSR 模块生成的 8 位伪随机数赋值给 LED。每来一个 clk_up 脉冲,就更新一组随机值,LED 对应位为 1 就亮、为 0 就灭,实现随机闪烁。

五、三大辅助子模块核心原理

5.1 clk_div:时钟分频器

clk_div完整代码

c 复制代码
`timescale 1ns / 1ps

//////////////////////////////////////////////////////////////////////////////////
// 模块名称:clk_div
// 模块类型:功能子模块
// 核心功能:可编程时钟分频,根据速度档位输出不同频率的单周期脉冲
// 设计原理:通过计数器对系统时钟累加计数,计满设定值后输出一个高电平脉冲
// 脉冲特性:clk_up高电平仅持续1个系统时钟周期,作为使能信号触发LED状态更新
//////////////////////////////////////////////////////////////////////////////////
module clk_div(
    input       clk,           // 系统时钟输入
    input       rst_n,         // 低电平复位
    input [1:0] speed,         // 2位速度选择输入,共4个档位
    output reg  clk_up         // 分频输出脉冲,高电平有效
);

    reg [31:0] cnt;            // 32位计数器,对系统时钟累加计数
    reg [31:0] div_val;        // 32位分频阈值寄存器,根据速度档位动态切换


    // ===================== 组合逻辑:根据速度档位设置分频阈值 =====================
    // always @(*):组合逻辑写法,输入变化时立即更新输出
    // 说明:计数从0开始,计N个数的最大值为N-1,因此阈值都做了减1处理
    always @(*) begin
        case(speed)
            // 档位00:中档速度,50MHz下对应0.5秒一个脉冲
            2'b00:  div_val = 32'd25_000000 - 1; 
            // 档位01:最快速度,50MHz下对应0.25秒一个脉冲
            2'b01:  div_val = 32'd12_500000 - 1;
            // 档位10:最慢速度,50MHz下对应2秒一个脉冲
            2'b10:  div_val = 32'd100_000000 - 1;
            // 默认档位:和00档一致,防止出现未定义状态
            default: div_val = 32'd25_000000 - 1;
        endcase
    end


    // ===================== 时序逻辑:计数器循环计数 + 生成脉冲 =====================
    // 敏感列表:时钟上升沿 + 复位下降沿(低电平复位标准写法)
    always @(posedge clk or negedge rst_n) begin
        // 复位状态:计数器清零,输出脉冲拉低
        if(!rst_n) begin
            cnt  <= 0;
            clk_up <= 0;
        end 
        // 正常工作状态
        else begin
            // 计数器达到阈值:清零计数器,输出一个高电平脉冲
            if(cnt >= div_val) begin
                cnt  <= 0;
                clk_up <= 1;
            end 
            // 未达到阈值:计数器自增1,输出脉冲保持低电平
            else begin
                cnt  <= cnt + 1;
                clk_up <= 0;
            end
        end
    end

endmodule

核心作用:把 50MHz 高频系统时钟,降速为几百毫秒一次的低速脉冲 clk_up。

实现原理:计数器累加计数,计到设定的分频阈值时,输出一个时钟周期的高脉冲,同时计数器清零重新计数。

速度档位:通过 2 位开关切换 4 种分频阈值,对应 4 档速度。

clk_div 是整个霓虹灯系统的 「节拍器」:
它把 FPGA 上几十 MHz 的高频系统时钟,降频成人眼能看清的低速更新脉冲 clk_up
,用来控制流水灯、往返灯、随机灯这些动态效果「多久变一次」。

一、先搞懂:为什么必须要有分频?直接用系统时钟不行吗?

  1. 系统时钟太快了,人眼根本跟不上
    你的开发板晶振一般是 50MHz,也就是时钟周期只有 20 纳秒,一秒钟有 5000 万个时钟上升沿。
    如果流水灯直接用系统时钟控制移位:
    每 20 纳秒 LED 就左移一位
    一秒钟能移 500 万位
    人眼看到的不是 "流动",而是 8 颗灯全亮、完全不动,因为闪烁速度远超了人眼的视觉暂留极限。
    就像你翻书翻得太快,只能看到一片模糊,看不清画面内容。
  2. 我们需要一个 "慢节拍"
    我们需要让 LED每隔 0.25 秒 / 0.5 秒 / 2 秒才变一次状态,人眼才能清晰看到 "流水、闪烁、扩散" 的动态效果。
    clk_div 干的就是这件事:把高频时钟 "降速",生成一个慢速的 "更新指令脉冲"。

二、clk_up 到底是什么信号?

很多新手误以为 clk_up 是一个新的 "低频时钟",其实不是。

它是一个单周期脉冲信号:

平时一直是低电平

每隔固定时间(比如 0.5 秒),输出一个只持续 1 个系统时钟周期的高电平

然后立刻变回低电平,等下一个周期再输出

二、clk_up 到底是什么信号?

很多新手误以为 clk_up 是一个新的 "低频时钟",其实不是。

它是一个单周期脉冲信号:

平时一直是低电平

每隔固定时间(比如 0.5 秒),输出一个只持续 1 个系统时钟周期的高电平

然后立刻变回低电平,等下一个周期再输出

对应代码里的逻辑:

c 复制代码
// 计满阈值时,输出1个时钟的高电平脉冲,其余时间都是0
if(cnt >= div_val) begin
    cnt  <= 0;
    clk_up <= 1;   // 只在这一个时钟周期是高电平
end else begin
    cnt  <= cnt + 1;
    clk_up <= 0;   // 其余时间都是低电平
end

它的本质是「更新使能信号」

它不是给后面模块当 "时钟" 用的,而是告诉 led_ctrl:

"现在到点了,你可以更新一下 LED 的状态了"

没到点的时候,LED 保持原来的样子不动。

三、clk_div 和核心控制模块 led_ctrl 怎么配合?

你看 led_ctrl 里的代码,除了呼吸灯模式,所有动态效果都包在 if(clk_up) 里面:

c 复制代码
// 只有 clk_up 脉冲到来的时候,才执行下面的模式逻辑、更新LED
if(clk_up) begin
    mode_last <= mode_sel;
    case(mode_sel)
        3'd0: ...  // 固定图案
        3'd1: ...  // 左移流水
        3'd2: ...  // 右移流水
        3'd3: ...  // 往返跑马
        3'd4: ...  // 奇偶闪烁
        3'd6: ...  // 扩散灯
        3'd7: ...  // 随机闪烁
    endcase
end

举个具体例子:流水灯

  • clk_up 每 0.5 秒来一个脉冲
  • 每来一个脉冲,流水灯就左移 1 位
  • 最终效果:LED 每隔 0.5 秒移动一格,人眼清晰看到 "流水" 效果
    如果没有 clk_div,流水灯每 20 纳秒移一位,你根本看不见流动。

四、拨码开关怎么控制速度?

sw4:3 这两位拨码,就是用来改 clk_div 里的分频阈值 div_val 的:

c 复制代码
case(speed)
    2'b00:  div_val = 32'd25_000000 - 1;  // 0.5秒一个脉冲 → 中速
    2'b01:  div_val = 32'd12_500000 - 1;  // 0.25秒一个脉冲 → 最快
    2'b10:  div_val = 32'd100_000000 - 1; // 2秒一个脉冲 → 最慢
    default: div_val = 32'd25_000000 - 1;
endcase

原理很简单:

  • 分频阈值越大 → 计数器要数更久才满 → clk_up 脉冲间隔越长 → LED 动得越慢
  • 分频阈值越小 → 计数器很快就数满 → clk_up 脉冲间隔越短 → LED 动得越快
    你拨不同的档位,就改变了 "节拍器的快慢",也就控制了所有动态效果的速度。

5.2 pwm_led:PWM 呼吸发生器(重点)

pwm_led完整代码

c 复制代码
`timescale 1ns / 1ps

//////////////////////////////////////////////////////////////////////////////////
// 模块名称:pwm_led
// 模块类型:功能子模块
// 核心功能:生成占空比自动渐变的PWM波形,实现LED呼吸效果
// 实现原理:两层计数器嵌套
//          1. 内层cnt:生成固定周期的PWM载波
//          2. 外层duty:每过一个PWM周期,调整一次占空比,实现亮度渐变
// 视觉原理:利用人眼视觉暂留效应,高频PWM的平均占空比对应人眼感知的亮度
//////////////////////////////////////////////////////////////////////////////////
module pwm_led(
    input       clk,           // 系统时钟输入
    input       rst_n,         // 低电平复位
    output      pwm_out        // PWM波形输出,高电平LED亮
);

    reg [31:0] duty;           // 占空比阈值寄存器,数值越大亮度越高
    reg [31:0] cnt;            // PWM载波计数器,生成固定周期的方波
    reg dir;                    // 亮度方向寄存器:0=占空比递增(变亮),1=占空比递减(变暗)


    // ===================== 时序逻辑1:载波计数 + 占空比渐变 =====================
    // 功能:内层cnt循环计数生成PWM周期,每计满一个周期,更新一次duty占空比
    always @(posedge clk or negedge rst_n) begin
        // 复位:占空比清零(全暗),计数器清零
        if(!rst_n) begin
            duty <= 0;
            cnt <= 0;
        end 
        else begin
            // 计满一个PWM周期:计数器清零,同时根据方向调整占空比
            // 注:仿真时用5便于快速观察,上板改为10000实现4秒呼吸周期
            if(cnt == 32'd5 - 1) begin
                cnt <= 0;
                // 方向为0:占空比+1,亮度升高
                if(!dir)
                    duty <= duty + 1;
                // 方向为1:占空比-1,亮度降低
                else
                    duty <= duty - 1;
            end 
            // 未计满周期:计数器正常自增
            else begin
                cnt <= cnt + 1;
            end
        end
    end
    

    // ===================== 时序逻辑2:亮度方向自动反转 =====================
    // 功能:检测占空比的极值,到达边界时自动切换增减方向
    always @(posedge clk or negedge rst_n) begin
        // 复位:默认从暗往亮渐变
        if(!rst_n)
            dir <= 0;
        // 占空比达到最大值:切换为变暗模式
        else if(duty == 32'd5) 
            dir <= 1;   
        // 占空比降到0:切换为变亮模式
        else if(duty == 0)      
            dir <= 0;
    end
        

    // ===================== 组合逻辑:PWM比较输出 =====================
    // 核心公式:计数器值小于占空比阈值时输出高电平,否则低电平
    // 原理:占空比 = 高电平时间 / 总周期,占空比越高,LED平均亮度越高
    assign pwm_out = (cnt < duty);

endmodule

核心作用:生成占空比自动渐变的 PWM 波,驱动 LED 实现呼吸效果。

实现原理:

固定周期计数器循环计数,生成 PWM 载波;

占空比寄存器自动递增 / 递减,到极值自动反转方向;

比较器输出:计数器值小于占空比时输出高电平,否则低电平。

占空比越大,LED 平均亮度越高,渐变过程就是呼吸效果。

很多朋友刚接触呼吸灯的时候,最困惑三个问题:

我想要指定呼吸时间(比如 4 秒一次),怎么反推出计数器该设多大?

同样是和数字 10000 相关,为什么cnt要写10000-1,duty却直接写10000?

一个只有 1 位的dir信号,是怎么让 LED 自动 "亮了又暗、暗了又亮" 的?

这一节我们用最通俗的方式,把这几个底层逻辑彻底讲透。

一、先搞懂:为什么呼吸灯必须是「两层计数器」?

在讲计算之前,先明白一个最基础的问题:为什么不能用一个计数器直接做出呼吸灯?

我们先回忆 PWM 控制亮度的本质:

LED 硬件只有 "亮" 和 "灭" 两种状态,人眼之所以能看到 "亮度变化",是因为我们让 LED 高速地亮灭切换。

单位时间内,亮的时间越长,平均亮度就越高;亮的时间越短,平均亮度就越低。

这个 "亮的时间占总周期的比例",就叫占空比。

如果只有一层计数器,我们只能做出固定亮度的 PWM------ 比如占空比 50%,LED 就一直是半亮状态,不会变化。

想要做出 "呼吸渐变" 的效果,我们就需要让占空比自己慢慢变大、再慢慢变小。这就自然引出了第二层计数器:

  • 第一层(内层) :PWM 载波计数器 cnt
    负责快速循环,生成固定频率的 PWM 方波,保证肉眼看不到闪烁。它决定了 PWM 的频率,也就是 "开关灯的速度"。
  • 第二层(外层) :占空比调节器 duty
    每过完一个完整的 PWM 周期,就把占空比调大一点点(或者调小一点点)。它决定了亮度变化的快慢,也就是 "呼吸的速度"。

二、从「4 秒呼吸周期」反推参数:

我们的目标很明确:我想要 LED 4 秒完成一次 "全暗→最亮→全暗" 的完整呼吸,系统时钟是 50MHz(时钟周期 20ns),怎么算出计数器该设多少?

第一步:拆解呼吸周期的组成

一次完整呼吸 = 「增亮过程」 + 「减亮过程」

  • 增亮:占空比从 0 涨到最大值,共 M 个亮度台阶;
  • 减亮:占空比从最大值跌回 0,同样 M 个亮度台阶;
  • 所以一次呼吸总共有 2×M 个亮度台阶。
    而每一个亮度台阶,都要持续整整一个 PWM 周期,否则亮度还没稳定显示就变了,会出现闪烁。
    每个 PWM 周期包含 N 个系统时钟(也就是 cnt 从 0 数到 N-1 的总个数)。
    第二步:写出总时间公式
    一次呼吸的总时钟数 = 亮度台阶数 × 每个台阶的时钟数
    总时钟数 = 2 × M × N
    总呼吸时间 = 总时钟数 × 单个时钟周期

    总呼吸时间 = 总时钟数 × 单个时钟周期

    第三步:为什么要开平方?
    到这里有两个未知数 M 和 N,怎么取值最方便?
    工程上有个非常省心的做法:让 PWM 周期的时钟数 = 亮度台阶总数,也就是让 M = N。
    这样做有两个好处:
  1. 参数好算,只需要求一个值;
  2. 亮度等级数和 PWM 周期一致,渐变细腻度刚好合适。
    于是公式就简化成了平方关系:

    我们把目标时间和时钟周期代进去,反过来求 N,自然就需要开平方:

    第四步:代入数值
    我们代入数值:
  • PWM 载波计数器 cnt 数 10000 个时钟为一个周期;
  • 占空比 duty 从 0 到 10000 共 10000 级亮度;
  • 两者相乘再乘 2,刚好就是 4 秒的完整呼吸周期。
    实用技巧:以后你想改呼吸时间,直接用这个公式算就行。
    比如想要 2 秒呼吸一次,代入公式算出来 N≈7071,取整 7000 左右就可以。

三、最容易踩坑:为什么 cnt 要 - 1,duty 不用 - 1?

这是 90% 的新手都会写错的地方,核心原因是:这两个变量的 "身份" 不一样,一个是循环计数器,一个是比较阈值,遵循的规则完全不同。

  1. 为什么 cnt 必须写 10000 - 1?
    cnt 是一个从 0 开始、到点清零的循环计数器。
    我们先做个最简单的测试:我要数 3 个数,从 0 开始数,数到几结束?
    数一下:0、1、2。
    没错,数到 2 就刚好数了 3 个数,最大值是 3-1=2。
    同理,我们要让 cnt 数 10000 个时钟周期:
    第 1 个时钟上升沿:cnt=0
    第 2 个时钟上升沿:cnt=1
    ...
    第 10000 个时钟上升沿:cnt=9999
    数到第 10000 个的时候,cnt 的值是 9999。这时候我们检测到 cnt == 9999,就让它清零,刚好完成一个周期。
    如果写成 cnt == 10000 会怎么样?
    那就要数到第 10001 个时钟才会清零,一个周期就变成了 10001 个时钟,和我们设计的 10000 个不符,呼吸周期也就不准了。
    所以记住一个通用规则:
    从 0 开始计数、计满 N 个数就清零的循环计数器,判断条件永远是 N-1。
    这是 Verilog 计数器的标准写法,以后所有分频、计数都遵循这个规律。
  2. 为什么 duty 直接写 10000,不用减 1?
    因为duty不是循环计数器,它是占空比的比较阈值,代表 "一个周期内有多少个时钟是亮的"。
    我们回到 PWM 输出的核心代码:
c 复制代码
assign pwm_out = (cnt < duty);

翻译成人话就是:计数器的值小于 duty 的时候,LED 亮;大于等于 duty 的时候,LED 灭。

我们知道 cnt 的取值范围是 0~9999,一共 10000 个值。我们看几个极端情况:

  • 当 duty = 0:cnt 永远不可能小于 0 → 全程不亮 → 占空比 0% → 全暗
  • 当 duty = 5000:cnt=0~4999 时亮,共 5000 个时钟 → 占空比 50% → 半亮
  • 当 duty = 10000:cnt 最大才 9999,永远小于 10000 → 全程亮 → 占空比 100% → 最亮
    你看,duty 等于 10000 的时候,刚好对应 100% 全亮的状态。
    如果 duty 也写成 9999,那最大占空比就是 9999/10000,永远达不到真正的全亮,呼吸到顶的时候会差一点点。
    所以 duty 的取值范围是 0~10000,一共 10001 级亮度,刚好覆盖从全暗到全亮的所有状态。它不需要减 1。
    一句话总结区别
  • cnt 是数个数的:数 10000 个,从 0 开始,数到 9999 就满了 → 要减 1;
  • duty 是量长度的:量出 10000 个时钟的高电平,对应 100% 亮度 → 不减 1。
    四、dir 信号:怎么实现自动 "呼 - 吸" 往复?
    dir 是整个呼吸灯的 "方向开关",它只有 0 和 1 两个状态,却能让 LED 自动在 "变亮" 和 "变暗" 之间来回切换,就像电梯到了顶楼自动往下走,到了一楼自动往上走。
  1. dir 的含义
    dir = 0:增亮模式,占空比持续 + 1,LED 越来越亮;
    dir = 1:减亮模式,占空比持续 - 1,LED 越来越暗。
  2. 什么时候换向?
    代码里写了两个换向条件,非常好理解:
c 复制代码
else if(duty == 32'd10000 ) 
    dir <= 1;   // 到顶了,最亮了,开始变暗
else if(duty == 0)      
    dir <= 0;   // 到底了,全暗了,开始变亮

就像电梯:

到了最高层(duty=10000),自动切换成下行(dir=1);

到了最底层(duty=0),自动切换成上行(dir=0)。

  1. dir 怎么控制 duty 的增减

在每个 PWM 周期结束的时候,根据 dir 的方向,决定 duty 是加还是减:

c 复制代码
if(cnt == 32'd10000 - 1) begin
    cnt <= 0;
    if(!dir)
        duty <= duty + 1;  // 方向0:往上走,亮度+1
    else
        duty <= duty - 1;  // 方向1:往下走,亮度-1
end

5.3 lfsr8:8 位伪随机数生成器

lfsr8完整代码

c 复制代码
`timescale 1ns / 1ps

//////////////////////////////////////////////////////////////////////////////////
// 模块名称:lfsr8
// 模块类型:功能子模块
// 核心功能:8位线性反馈移位寄存器,生成伪随机序列
// 算法原理:基于本原多项式 x^8 + x^5 + x^4 + x^3 + 1
//          抽取特定bit做异或运算作为反馈,移位寄存器循环迭代
// 特性:周期长度为2^8-1=255,输出序列均匀分布,硬件资源占用极小
// 注意事项:初始种子(复位值)不能为全0,否则会卡死在全0状态
//////////////////////////////////////////////////////////////////////////////////

module lfsr8(
    input       clk,           // 系统时钟输入
    input       rst_n,         // 低电平复位
    output reg [7:0] rnd       // 8位伪随机数输出
);

    // ===================== 组合逻辑:计算反馈值 =====================
    // 抽取第7、5、4、3位进行异或运算,结果作为移位的输入
    // 对应本原多项式的抽头位置,保证最长周期
    wire feedback = rnd[7] ^ rnd[5] ^ rnd[4] ^ rnd[3];


    // ===================== 时序逻辑:移位寄存器迭代 =====================
    always @(posedge clk or negedge rst_n) begin
        // 复位:加载初始种子(非零值,这里用8'hA5 = 二进制10100101)
        if(!rst_n)
            rnd <= 8'hA5;        
        // 正常工作:整体左移1位,最低位填充反馈计算值
        // {rnd[6:0], feedback} 位拼接语法:高7位取原数据低7位,最低位补feedback
        else
            rnd <= {rnd[6:0], feedback};
    end

endmodule

核心作用:用最少的硬件资源生成 8 位伪随机序列,供随机闪烁模式使用。

实现原理:线性反馈移位寄存器(LFSR),选取 8 阶本原多项式的抽头做异或运算,寄存器整体左移,最低位填入异或结果。

周期为 255 个时钟,255 种状态循环,随机分布均匀,资源占用极小。

六、模拟仿真波形检验

一、首先Testbench的设计

c 复制代码
// 仿真时间尺度定义:时间单位是1ns,仿真精度是1ps
// 意思是代码里写 #10 就代表延迟10纳秒,仿真器最小能分辨1皮秒的时间差
// 这个必须和待测试设计(DUT)的timescale保持一致,保证时序对齐
`timescale 1ns / 1ps

//////////////////////////////////////////////////////////////////////////////////
// 模块名称:pwm_led_tb
// 模块类型:Testbench(测试平台/仿真激励文件)
// 功能:给pwm_led呼吸灯模块提供时钟、复位等激励信号,观察pwm_out输出波形
//       验证呼吸灯的功能是否正确,不需要对外引出硬件端口,纯仿真用
// 说明:Testbench是FPGA仿真的核心,相当于给设计搭一个虚拟测试环境
//////////////////////////////////////////////////////////////////////////////////
module pwm_led_tb;

    // ====================== 1. 测试信号定义 ======================
    // 规则:给待测试模块的输入信号,在Testbench里定义为reg型(我们要主动给它赋值)
    //       待测试模块的输出信号,在Testbench里定义为wire型(我们只需要观察,不主动驱动)
    reg clk;         // 系统时钟输入,我们生成50MHz方波给DUT
    reg rst_n;       // 低电平复位信号,我们控制复位的开始和结束
    wire pwm_out;    // PWM输出信号,从DUT输出出来,我们只观察波形
    
    // ====================== 2. 例化待测试模块(DUT) ======================
    // 语法:模块名 实例名( .端口名(连接的信号名) );
    // 这里用"命名端口映射",顺序可以随便写,不会出错,是工业界标准写法
    // 把我们Testbench里的信号,和呼吸灯模块的端口一一对应连接起来
    pwm_led pwm_led(
    .clk(clk),          // 把我们生成的clk时钟,接到DUT的clk输入
    .rst_n(rst_n),      // 把我们控制的rst_n复位,接到DUT的rst_n输入
    .pwm_out(pwm_out)   // 把DUT的pwm_out输出,接到我们的观测信号pwm_out上
    );
    
    // ====================== 3. 生成50MHz系统时钟 ======================
    // initial块:只在仿真开始的时候执行一次,用来给信号赋初始值
    initial clk = 1;    // 仿真0时刻,时钟初始化为高电平
    
    // always块:循环重复执行,永远停不下来,适合生成周期性的时钟
    // #10 代表延迟10ns,然后把时钟翻转一次
    // 周期计算:高电平10ns + 低电平10ns = 20ns一个周期 → 频率1/20ns = 50MHz
    // 刚好对应大多数FPGA开发板的晶振频率
	always #10 clk = ~clk;
	
    // ====================== 4. 复位控制 + 仿真流程控制 ======================
    // initial块:整个仿真只执行一次,按时间顺序从上到下执行
	initial begin
        // 第0ns:复位信号拉低,进入复位状态
        // 所有寄存器都会回到初始值,避免仿真出现不定态X
	    rst_n = 0;
        
        // 延迟210ns:保持复位状态一段时间
        // 为什么是210ns?
        // 1. 复位要至少持续一个时钟周期以上,保证所有寄存器都采样到复位
        // 2. 20ns一个时钟,210ns刚好是10个多时钟周期,复位足够稳定
        // 3. 错开时钟沿,避免和时钟上升沿同时变化,观察波形更清楚
	    #210
        
        // 210ns之后:复位信号拉高,复位结束,模块开始正常工作
	    rst_n = 1;
        
        // 再延迟10000ns(也就是10微秒),让模块运行一段时间
        // 注意:原设计呼吸周期是4秒,这里只跑10us看不到完整呼吸
        // 只是用来观察PWM波形是否正常、占空比是否在增加
	    #10000
        
        // 系统任务:停止仿真
        // $stop 会暂停仿真,还能继续看波形、手动继续跑
        // 区别于$finish:$finish会直接结束仿真退出
	    $stop;
	end

endmodule

二、Testbench 编写方法手把手教学

很多新手不知道 Testbench 从何下手,其实写仿真文件有固定的套路,所有模块的 Testbench 都遵循同一个结构,掌握了就能一通百通。

2.1 编写 Testbench 的固定 6 步

对应上面的代码结构,按顺序写就不会错:

2.2 核心语法新手必懂

① reg 和 wire 怎么选?

很多新手最懵的就是这个,记住一句话就行:

你要主动赋值、主动控制的信号,用reg(比如时钟、复位、拨码开关这些你要给值的输入)

你只用来观察、不主动驱动的信号,用wire(比如 LED 输出、PWM 输出这些从模块里出来的信号)

对应到 Testbench 里:

DUT 的输入 → tb 里是 reg

DUT 的输出 → tb 里是 wire

② initial 和 always 的区别

都是过程块,但执行逻辑完全不同:

initial:只执行 1 次,仿真开始的时候从上到下跑一遍就结束了。适合赋初值、写一次性的激励流程(复位、拨码切换)。

always:永远循环执行,只要仿真不停止就一直重复跑。适合生成周期性的时钟信号。

③ 延迟符号 #

#数字 代表延迟多少个时间单位,单位由timescale定义。

比如timescale 1ns/1ps里,#10就是延迟 10 纳秒。

④ 系统任务 stop / finish

$stop:暂停仿真,仿真器不会退出,你还能看波形、手动继续运行,调试的时候推荐用这个。

$finish:直接结束仿真,关闭仿真进程,适合全自动回归测试用。

三、观察波形

一、仿真前准备

我们已经完成了pwm_led.v模块的编写,并且搭建了 Testbench 仿真文件。

💡 仿真小技巧:为了快速看到呼吸效果,我们把代码里 PWM 计数器的最大值从10000改成了5(参数化设计的优势),这样几百纳秒就能看到完整的占空比变化,上板时再改回原值即可。

打开 Vivado 工程,点击左侧 Run Simulation → Run Behavioral Simulation,进入行为仿真界面。

二、步骤 1:将子模块内部信号添加到波形窗口

默认波形窗口只有顶层的输入输出信号,我们看不到cnt、duty、dir这些内部寄存器的变化,需要手动添加:

在仿真界面左侧找到 Scope 窗口,这里展示了工程的模块层级结构。

逐层展开顶层neon_light_tb → 实例化的neon_light模块,找到子模块pwm_led并点击选中。

右侧 Objects 窗口会显示该模块的所有内部信号,按住Ctrl选中我们要观察的信号:duty31:0、cnt31:0、dir。

右键选中的信号,点击 Add to Wave Window,即可将信号添加到右侧的波形窗口中。

三、步骤 2:设置总线信号的数制(Radix)

默认添加的总线信号(如duty、cnt)是十六进制显示,不便于我们直观查看数值大小,我们把它改成无符号十进制:

在波形窗口中,选中duty31:0和cnt31:0信号。

右键选中的信号,找到 Radix 选项。

在子菜单中选择 Unsigned Decimal(无符号十进制)。

设置完成后,信号的数值就会以十进制数字显示,方便我们直接对比占空比大小和计数进度。

四、步骤 3:重新运行仿真(Relaunch Simulation)

添加新信号后,之前的仿真波形不会自动更新新信号的数据,需要重新运行仿真:

点击波形窗口上方工具栏的 Relaunch Simulation 按钮(圆形刷新图标),Vivado 会重新跑一遍仿真,所有添加的信号都会生成完整的波形数据。

五、步骤 4:波形缩放与全局查看

重新仿真后,我们可以通过缩放工具查看完整波形:

点击工具栏的 Zoom Fit 按钮(全屏缩放图标),波形会自动适配窗口,展示从仿真开始到结束的完整时序。

六、深度波形分析:彻底搞懂 PWM 呼吸灯原理

完成以上操作,我们就能看到完整的信号波形了,接下来我们从单 PWM 周期 → 占空比渐变 → 方向反转 → 完整呼吸周期逐层分析。

6.1 单周期 PWM:占空比怎么控制亮度?

我们先放大看一个 PWM 周期的细节,核心看三个信号:clk(系统时钟)、cnt(载波计数器)、pwm_out(输出信号)、duty(占空比阈值)

原理回顾

代码中 PWM 输出的逻辑是:

c 复制代码
assign pwm_out = (cnt < duty);

翻译过来就是:计数器cnt的值小于占空比阈值duty时,输出高电平(LED 亮);否则输出低电平(LED 灭)。

波形验证

我们的仿真中,cnt从 0 计数到 4(共 5 个时钟周期,对应一个 PWM 周期):

对应的代码

c 复制代码
  if(cnt == 32'd5- 1) begin//板子上改为10000
                cnt <= 0;
                if(!dir)
                    duty <= duty + 1;
                else
                    duty <= duty - 1;

因为方便仿真先改成5

当duty=1时:只有cnt=0满足cnt < 1,因此 1 个时钟周期高电平,4 个低电平。

占空比 = 1/5 = 20%,对应 LED 微亮。

当duty=2时:cnt=0、1满足条件,2 个时钟周期高电平。

占空比 = 2/5 = 40%,LED 亮度提升。

以此类推,duty越大,高电平时间越长,LED 越亮;duty=5时,全程高电平,LED 最亮。

6.2 占空比渐变:呼吸的 "渐亮" 过程

我们拉长时间轴看全局波形,会发现duty的值在每个 PWM 周期结束后自动 + 1:

第 1 个 PWM 周期:duty=1

第 2 个 PWM 周期:duty=2

第 3 个 PWM 周期:duty=3

...

第 5 个 PWM 周期:duty=5

对应到波形上,pwm_out的高电平宽度逐周期变宽,LED 亮度逐渐升高,这就是呼吸灯的 "吸气"(渐亮)过程

对应代码逻辑:

c 复制代码
if(cnt == CNT_MAX - 1) begin
    cnt <= 0;
    if(!dir)
        duty <= duty + 1;  // dir=0时,占空比递增
end

只有当cnt计满一个周期时,才会调整一次duty,保证亮度平滑变化。

呼气同理


总结

相关推荐
Rambo.xia3 小时前
AXI4-Full突发传输掉数据——突发长度算错、WRAP边界、窄传输字节错位,调试一周才发现是协议理解错了
fpga开发
zlinear数据采集卡3 小时前
硅片里的“自动纠错“:硬核拆解LHAMP188自动归零技术原理与三种封装的PCB布局实战
arm开发·单片机·嵌入式硬件·fpga开发·开源
Mr-pn-junction15 小时前
clk_gate
单片机·嵌入式硬件·fpga开发
知行合一←_←1 天前
误码仪与时钟极性
fpga开发
upper20201 天前
vivado使用那些事之综合策略
fpga开发·vivado·vivado综合策略
2CM_Embed1 天前
Quartus II 工程编译与 FPGA 下载流程记录
嵌入式硬件·fpga开发
千寻xun1 天前
视频图像学习笔记
fpga开发
国科安芯2 天前
航天电子模拟前端三大支柱:精密运放、高速运放与电压监控的协同设计方法——ASL8522S/ASL622S/ASL706S技术解析
前端·单片机·嵌入式硬件·fpga开发·架构·安全性测试
謓泽2 天前
【6.26】芯片测试入门 从零搭自动化测试框架|PyVISA+OOP 保姆级教程
stm32·单片机·fpga开发·雷达·tr