FPGA入门
文章目录
- FPGA入门
- 前言
- 一、整体系统架构
- 二、顶层模块设计:系统总装与信号互联
- 三、核心机制:拨码开关如何切换显示模式?
-
- 模式切换的完整代码
- [3.1 模式切换的核心骨架:case 多路分支](#3.1 模式切换的核心骨架:case 多路分支)
- [3.2 关键细节 1:为什么大部分模式要等 clk_up 脉冲?](#3.2 关键细节 1:为什么大部分模式要等 clk_up 脉冲?)
- [3.3 关键细节 2:为什么呼吸模式要单独拎出来判断?](#3.3 关键细节 2:为什么呼吸模式要单独拎出来判断?)
- [3.4 关键细节 3:mode_last 解决切换乱码问题](#3.4 关键细节 3:mode_last 解决切换乱码问题)
- [3.5 速度调节如何生效?](#3.5 速度调节如何生效?)
- [四、8 种显示模式逐模式详解](#四、8 种显示模式逐模式详解)
- 五、三大辅助子模块核心原理
-
- [5.1 clk_div:时钟分频器](#5.1 clk_div:时钟分频器)
-
- clk_div完整代码
- 一、先搞懂:为什么必须要有分频?直接用系统时钟不行吗?
- [二、clk_up 到底是什么信号?](#二、clk_up 到底是什么信号?)
- [三、clk_div 和核心控制模块 led_ctrl 怎么配合?](#三、clk_div 和核心控制模块 led_ctrl 怎么配合?)
- 四、拨码开关怎么控制速度?
- [5.2 pwm_led:PWM 呼吸发生器(重点)](#5.2 pwm_led:PWM 呼吸发生器(重点))
-
- pwm_led完整代码
- 一、先搞懂:为什么呼吸灯必须是「两层计数器」?
- [二、从「4 秒呼吸周期」反推参数:](#二、从「4 秒呼吸周期」反推参数:)
- [三、最容易踩坑:为什么 cnt 要 - 1,duty 不用 - 1?](#三、最容易踩坑:为什么 cnt 要 - 1,duty 不用 - 1?)
- [5.3 lfsr8:8 位伪随机数生成器](#5.3 lfsr8:8 位伪随机数生成器)
- 六、模拟仿真波形检验
-
- 一、首先Testbench的设计
- [二、Testbench 编写方法手把手教学](#二、Testbench 编写方法手把手教学)
-
- [2.1 编写 Testbench 的固定 6 步](#2.1 编写 Testbench 的固定 6 步)
- [2.2 核心语法新手必懂](#2.2 核心语法新手必懂)
- 三、观察波形
-
- 一、仿真前准备
- [二、步骤 1:将子模块内部信号添加到波形窗口](#二、步骤 1:将子模块内部信号添加到波形窗口)
- [三、步骤 2:设置总线信号的数制(Radix)](#三、步骤 2:设置总线信号的数制(Radix))
- [四、步骤 3:重新运行仿真(Relaunch Simulation)](#四、步骤 3:重新运行仿真(Relaunch Simulation))
- [五、步骤 4:波形缩放与全局查看](#五、步骤 4:波形缩放与全局查看)
- [六、深度波形分析:彻底搞懂 PWM 呼吸灯原理](#六、深度波形分析:彻底搞懂 PWM 呼吸灯原理)
-
- [6.1 单周期 PWM:占空比怎么控制亮度?](#6.1 单周期 PWM:占空比怎么控制亮度?)
- [6.2 占空比渐变:呼吸的 "渐亮" 过程](#6.2 占空比渐变:呼吸的 “渐亮” 过程)
- 总结
前言
很多人学习 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 是整个工程的 "总装车间",它的核心原则是:只做信号接线,不实现功能逻辑 。
所有计数、移位、判断逻辑全部下沉到子模块,顶层只负责:
- 引出对接硬件的外部端口
- 定义内部连接线,传递子模块之间的信号
- 例化所有功能子模块,完成引脚对接
这种设计的好处非常明显:修改某一种灯光效果时,只需要改对应的子模块,不会影响其他功能;新增模式也只需要拓展控制模块,不用动顶层结构。
端口定义与信号拆分
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,用来控制流水灯、往返灯、随机灯这些动态效果「多久变一次」。
一、先搞懂:为什么必须要有分频?直接用系统时钟不行吗?
- 系统时钟太快了,人眼根本跟不上
你的开发板晶振一般是 50MHz,也就是时钟周期只有 20 纳秒,一秒钟有 5000 万个时钟上升沿。
如果流水灯直接用系统时钟控制移位:
每 20 纳秒 LED 就左移一位
一秒钟能移 500 万位
人眼看到的不是 "流动",而是 8 颗灯全亮、完全不动,因为闪烁速度远超了人眼的视觉暂留极限。
就像你翻书翻得太快,只能看到一片模糊,看不清画面内容。 - 我们需要一个 "慢节拍"
我们需要让 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。
这样做有两个好处:
- 参数好算,只需要求一个值;
- 亮度等级数和 PWM 周期一致,渐变细腻度刚好合适。
于是公式就简化成了平方关系:

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

第四步:代入数值
我们代入数值:


- PWM 载波计数器 cnt 数 10000 个时钟为一个周期;
- 占空比 duty 从 0 到 10000 共 10000 级亮度;
- 两者相乘再乘 2,刚好就是 4 秒的完整呼吸周期。
实用技巧:以后你想改呼吸时间,直接用这个公式算就行。
比如想要 2 秒呼吸一次,代入公式算出来 N≈7071,取整 7000 左右就可以。
三、最容易踩坑:为什么 cnt 要 - 1,duty 不用 - 1?
这是 90% 的新手都会写错的地方,核心原因是:这两个变量的 "身份" 不一样,一个是循环计数器,一个是比较阈值,遵循的规则完全不同。
- 为什么 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 计数器的标准写法,以后所有分频、计数都遵循这个规律。 - 为什么 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 自动在 "变亮" 和 "变暗" 之间来回切换,就像电梯到了顶楼自动往下走,到了一楼自动往上走。
- dir 的含义
dir = 0:增亮模式,占空比持续 + 1,LED 越来越亮;
dir = 1:减亮模式,占空比持续 - 1,LED 越来越暗。 - 什么时候换向?
代码里写了两个换向条件,非常好理解:
c
else if(duty == 32'd10000 )
dir <= 1; // 到顶了,最亮了,开始变暗
else if(duty == 0)
dir <= 0; // 到底了,全暗了,开始变亮
就像电梯:
到了最高层(duty=10000),自动切换成下行(dir=1);
到了最底层(duty=0),自动切换成上行(dir=0)。
- 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,保证亮度平滑变化。
呼气同理