☰
FPGA流水灯实战:时钟分频、状态机与硬件约束全解析
2026/11/2 16:07:52 网站建设 项目流程

简介:本资源是一份面向FPGA初学者与数字电路实践者的Verilog开发教程,聚焦流水灯这一经典时序控制实验,系统讲解从时钟管理、状态机设计到模块集成的完整实现流程。资源以1个952KB的Word文档(.docx)形式提供,内容涵盖PLL锁相环配置(含Vivado中Clocking Wizard调用步骤、50MHz→100MHz倍频设置)、time_top时间使能模块(7位计数器设计与parameter参数化定义)、water_led流水灯状态机(4位LED循环控制逻辑)及system_top顶层集成与仿真验证全过程,附带详细框图、代码片段与波形图分析。文档结构清晰,按五大章节组织,每部分均含设计原理、Verilog实现、测试文件编写与仿真结果解读,便于边学边练、理解同步时序电路核心思想。目前已有3409人学习下载,是掌握FPGA基础开发流程、时钟域处理与模块化设计方法的实用入门资料。

1. 流水灯不是“亮一下再亮一下”:它是一套完整的时序控制系统,专为 FPGA 新手练手时钟树、状态机和模块化设计而生

你可能以为流水灯就是让四个 LED 从左到右轮流亮——但真正在 FPGA 上跑通它,你得先搞定三道硬门槛:第一,50MHz 晶振怎么变成稳定可用的 100MHz 主时钟?第二,怎么在高速时钟下精准掐出 1 秒/500ms/200ms 这种人眼可辨的节奏?第三,LED 是共阳极接法,低电平才亮,状态切换时若没处理好复位同步,上电瞬间所有灯全灭或全亮,仿真波形看着正常,烧进板子却黑屏。这个项目不是玩具 Demo,它是某高校数字电路实验课的「通关副本」:用 Verilog 写顶层 + 3 个子模块(PLL、计时使能、流水逻辑),全部走 Vivado 2022.2 流程,支持 xc7a100tfgg484-2 芯片(常见于 Basys3、Nexys4 DDR 等教学板),最终在硬件上实现从左到右、循环滚动、无毛刺、复位即就绪的流水效果。适合刚学完组合逻辑、正准备啃时序电路的开发者——它不考算法,只考你对always @(posedge clk)的敬畏心,对reset异步/同步的拿捏,以及对parameter和reg [n-1:0]位宽定义的肌肉记忆。

2. 顶层设计与模块划分:system_top 是总线调度员,不是胶水代码

整个系统不是单文件堆砌,而是清晰分层的四层结构:顶层system_top负责信号粘合与端口映射;PLL模块提供干净时钟源;time_top模块生成节拍脉冲;water_led模块执行状态流转。这种拆法不是为了炫技,而是解决 FPGA 开发中最痛的两个问题:一是时钟域交叉(比如 PLL 输出的clk_100m和按键消抖用的慢时钟不能混用),二是功能复用(以后想把流水灯改成呼吸灯,只需换掉water_led,其他模块不动)。下面逐层拆解真实代码结构和接口契约。

2.1 system_top:顶层模块的端口定义必须和开发板物理引脚严格对齐

这是你烧写 bit 文件前最后一道校验关。system_top不做任何逻辑运算,只干三件事:例化 PLL、例化 time_top、例化 water_led;把它们的输入输出连起来;把最终led_out[3:0]映射到开发板实际 LED 引脚。注意:共阳极 LED 的硬件特性直接决定了 Verilog 行为——高电平 = 截止 = 灯灭,低电平 = 导通 = 灯亮。所以led_out的每一位,0 才是“点亮”,1 是“熄灭”。很多新手翻车就在这里:仿真里看到led_out = 4'b0001(最右灯亮)觉得对了,结果上板发现最左灯亮——因为引脚约束文件(XDC)里把led_out[0]错绑到了物理最左的 LED 上,而硬件原理图标注的是从左到右为LED0~LED3,对应led_out[3:0]。正确约束必须是:

# XDC 文件关键约束(以 Basys3 为例) set_property PACKAGE_PIN U16 [get_ports {led_out[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {led_out[0]}] set_property PACKAGE_PIN E19 [get_ports {led_out[1]}] set_property IOSTANDARD LVCMOS33 [get_ports {led_out[1]}] set_property PACKAGE_PIN U19 [get_ports {led_out[2]}] set_property IOSTANDARD LVCMOS33 [get_ports {led_out[2]}] set_property PACKAGE_PIN V19 [get_ports {led_out[3]}] set_property IOSTANDARD LVCMOS33 [get_ports {led_out[3]}]

提示:XDC 约束必须在 Vivado 中通过Add Sources → Add or create constraints导入,不能只写在 Verilog 里。约束错误是上板黑屏的头号原因,比代码逻辑错误更难排查。

2.2 PLL 模块:Clocking Wizard 生成的 IP 核不是黑匣子,要懂它的 reset 和 locked 信号怎么用

Vivado 的 Clocking Wizard 生成的 PLL IP(这里命名为PLL)输出两个关键信号:clk_100m和locked。很多人只接clk_100m,忽略locked,结果上电后流水灯乱闪或卡死。locked是 PLL 锁定成功的标志——只有当它拉高,才代表clk_100m相位和频率真正稳定。在system_top中,locked必须参与全局复位的同步化处理:

// system_top.v 片段:用 locked 同步化复位 reg rst_n_sync; always @(posedge clk_100m) begin rst_n_sync <= ~rst_btn || locked; // rst_btn 是开发板按键(低有效),locked 高有效 end // 后续所有子模块的 reset 输入都来自 rst_n_sync

为什么不用原始按键rst_btn直接复位?因为按键是异步信号,可能在时钟边沿附近抖动,导致亚稳态。locked本身是 PLL 内部同步于clk_100m的信号,用它和按键做 OR,能确保复位释放时刻严格对齐clk_100m上升沿。这是 FPGA 开发的血泪经验:所有异步输入,必须经过两级寄存器同步才能进逻辑域。

2.3 time_top 模块:计数器位宽不是拍脑袋定的,要算清时间精度和资源消耗的平衡点

time_top的任务是把 100MHz 时钟(周期 10ns)分频成人类可感知的节拍,比如 500ms 一跳。计算过程必须手算,不能靠感觉:

  • 目标周期:500ms = 500,000,000 ns
  • 基准时钟周期:10 ns
  • 所需计数值:500,000,000 / 10 = 50,000,000
  • 计数器位宽:log₂(50,000,000) ≈ 25.6 → 取26 位(reg [25:0] cnt)

但原文用了 1000ns / 100 次 = 10ns 周期,这其实是100MHz 下的 100 个周期 = 1000ns,对应time_en脉宽 10ns,周期 1000ns(即 1MHz 脉冲)。这个设计意图很明确:不做人眼节拍,而是做最小时间单元,后续water_led用这个高频脉冲去驱动状态机,避免长计数器占用过多 LUT。其 Verilog 实现核心是:

// time_top.v 关键逻辑 parameter CNT_MAX = 26'd100; // 注意:这里是 100,不是 50000000! reg [25:0] cnt; always @(posedge clk_100m or negedge rst_n_sync) begin if (!rst_n_sync) cnt <= 0; else if (cnt == CNT_MAX - 1) cnt <= 0; else cnt <= cnt + 1; end assign time_en = (cnt == CNT_MAX - 1); // 在最后一个计数值产生 1 个周期的高脉冲

time_en是脉冲信号(宽度 10ns),不是电平信号。这意味着water_led必须用always @(posedge time_en)或更稳妥的always @(posedge clk_100m) if (time_en)来采样——后者才是推荐做法,因为time_en是由clk_100m生成的,本质仍是同步信号,没必要跨时钟域采样。

2.4 water_led 模块:流水灯状态机必须用独热码(One-Hot),别碰二进制编码

water_led是行为核心,输入clk_100m、time_en、rst_n_sync,输出led_out[3:0]。关键决策:用什么状态表示“当前哪个 LED 亮”?常见错误是用 2 位二进制state[1:0]表示 4 个状态(00/01/10/11),但这样在state切换瞬间,两位都可能翻转,产生中间态(如 01→10 经过 00 或 11),导致多个 LED 短暂同时亮或全灭。FPGA 上状态机首选独热码:4 个状态用 4 位寄存器state[3:0],每次只有一位为 1(4'b0001,4'b0010,4'b0100,4'b1000),翻转时只有一位变,绝对无竞争。

// water_led.v 独热码状态机 reg [3:0] state; always @(posedge clk_100m or negedge rst_n_sync) begin if (!rst_n_sync) state <= 4'b0001; // 复位后最右灯亮(对应 led_out[0]=0) else if (time_en) begin // 仅在 time_en 上升沿触发状态迁移 case (state) 4'b0001: state <= 4'b0010; // 右→次右 4'b0010: state <= 4'b0100; // 次右→次左 4'b0100: state <= 4'b1000; // 次左→左 4'b1000: state <= 4'b0001; // 左→右,循环 default: state <= 4'b0001; endcase end end // 输出映射:state 每位直接驱动对应 LED,但注意共阳极取反! assign led_out[0] = ~state[0]; // state[0]为1时,led_out[0]=0 → 最右灯亮 assign led_out[1] = ~state[1]; assign led_out[2] = ~state[2]; assign led_out[3] = ~state[3];

这里time_en是使能条件,不是时钟。状态迁移只在time_en为高时的clk_100m上升沿发生,保证了节拍严格受控。default分支不是摆设——综合工具会把它编译成复位逻辑,防止 latch。

3. Vivado 工程构建全流程:从创建项目到生成比特流,每一步都有坑

Vivado 不是 IDE,是硬件编译器。它的报错信息往往晦涩,但根源就那几类。下面按真实操作顺序,把每个环节的必做动作和易错点列清楚。这不是教程搬运,是某开发者在 Basys3 上反复烧录 17 次后总结的 checklist。

3.1 创建工程与芯片选型:xc7a100tfgg484-2 的封装和速度等级必须匹配开发板

新建工程时,Project Settings → General → Project device必须选准:

  • Family:Artix-7
  • Package:fgg484
  • Speed:-2(不是-1或-3!Basys3 原理图标注的是 XC7A35T-2CSG324C,但本项目目标是 xc7a100t,速度等级-2是教学板常用配置,-2 表示最大工作频率 650MHz,足够应付 100MHz 逻辑)

注意:如果选错 speed 等级,综合后Timing Summary里WNS (Worst Negative Slack)会是大负数(如 -5.2ns),意味着时序不满足,烧进去可能功能异常。此时不能硬着头皮生成 bit,必须改回-2重新综合。

3.2 PLL IP 核调用:Clocking Wizard 配置中三个致命选项

双击 Clocking Wizard 后,在Basic页签:

  • Input Clock Information → Input Frequency (MHz):填50.000(必须是数字,不能是50字符串)
  • Output Clocks → clk_out1:Frequency (MHz)填100.000,Phase Shift (degrees)填0.0
  • 最关键:勾选Reset Type → Active Low(因为开发板复位按键是低有效),且Reset输入端口名必须和system_top里声明的一致(如rst_in)

在Optional Output页签:

  • 必须取消勾选Reset(原文步骤六正确!)—— 因为我们自己用locked做同步复位,IP 自带的 reset 会冲突。
  • Locked输出必须勾选(默认已勾),否则system_top拿不到锁定信号。

生成 IP 后,在Sources窗口展开IP Sources,右键PLL→Edit in IP Packager,确认Ports and Interfaces里clk_in1、clk_out1、locked三个端口方向正确(clk_in1和locked是 input,clk_out1是 output)。

3.3 仿真测试:TB_PLL 和 TB_system_top 必须覆盖边界条件

仿真不是走形式。TB_PLL要验证两件事:一是locked是否在约 790ns 后拉高(PLL 锁定时间),二是clk_100m是否在locked拉高后立刻稳定输出。TB_system_top更重要,必须测:

  • 上电复位:rst_btn拉低 100us 后释放,观察led_out是否从4'b1110(最右灯亮)开始滚动
  • 中途复位:在流水进行中(如led_out=4'b1101时)再次拉低rst_btn,检查是否立即回到初始态
  • time_en丢失:手动注释掉time_top中time_en赋值,看led_out是否冻结(验证状态机依赖关系)

仿真波形里,led_out必须用Radix → Binary查看,且勾选Show values on signals,否则4'b1110会显示成十进制14,看不出哪位是 0。

3.4 约束文件(XDC)编写:引脚绑定错一位,整块板子就白忙

XDC 不是可选配置,是硬件描述的一部分。Basys3 的 LED 引脚在官方文档basys3_reference_manual.pdf的第 12 页。必须逐行核对:

  • U16对应LD0(最右 LED),绑定led_out[0]
  • E19对应LD1,绑定led_out[1]
  • U19对应LD2,绑定led_out[2]
  • V19对应LD3(最左 LED),绑定led_out[3]

致命错误:有人把led_out[3:0]当作从左到右顺序,把V19绑给led_out[0],结果代码里state=4'b1000(最左亮)时,硬件上却是最右亮。这种错误只能靠查原理图定位,仿真永远发现不了。

4. 常见问题排查:那些让你对着示波器抓狂的玄学故障

FPGA 开发最磨人的不是写代码,是猜硬件在想什么。下面 4 个问题,都是某开发者在深夜 2 点对着 Basys3 板子拍桌骂娘后记下的真实排障记录,现象、原因、解法全部实锤。

4.1 现象:仿真波形完美,烧进板子后所有 LED 全灭,或全亮,或随机闪烁

原因:XDC 引脚约束未生效,或约束了错误的端口名。Vivado 的I/O Planning视图里,led_out[0]显示为<no constraint>,说明约束文件没被识别。
解决:

  1. 在Sources窗口,确认 XDC 文件在Constraints下,且图标是绿色(已启用);
  2. 右键 XDC →Set as Top Constraint;
  3. 在I/O Planning视图,点击Validate I/O Ports,看报错提示(常见如Port 'led_out[0]' does not exist,说明 Verilog 里声明的是led_out [3:0],但 XDC 写成了led_out[0],少了个空格);
  4. 重新运行Synthesis和Implementation。

4.2 现象:上电后 LED 亮一下就灭,之后再也不动,示波器测clk_100m正常,locked一直为低

原因:PLL 输入时钟源没接对。开发板上clk_50m通常来自E3引脚(Basys3),但system_top里clk_50m端口没在 XDC 中约束,或者约束到了错误引脚(如T10是另一个时钟源)。
解决:

  1. 查开发板原理图,确认 50MHz 晶振输出引脚(Basys3 是E3);
  2. 在 XDC 中添加:set_property PACKAGE_PIN E3 [get_ports clk_50m];
  3. set_property IOSTANDARD LVCMOS33 [get_ports clk_50m];
  4. 重新综合。locked信号必须在clk_50m稳定输入后约 790ns 拉高,如果它一直为低,说明 PLL 根本没收到输入。

4.3 现象:流水灯滚动速度极快(肉眼看不清),或极慢(几秒才动一下)

原因:time_top模块中CNT_MAX参数值错误,或time_en生成逻辑被综合工具优化掉了。
解决:

  1. 打开Implementation → Reports → Report Utilization,搜索CNT_MAX,确认综合后的计数器位宽和值是否匹配(如CNT_MAX=100应生成 7 位计数器);
  2. 如果计数器消失,说明time_en没被任何模块使用,综合时被剪枝。在system_top中临时加一句assign dummy = time_en;(dummy是未用信号),强制保留;
  3. 检查time_top的always块里,cnt是否被正确赋值(常见错误:cnt <= cnt + 1写成cnt = cnt + 1,非阻塞赋值写成阻塞)。

4.4 现象:复位后 LED 亮起顺序错乱(如4'b0010亮,但应该是4'b0001),或状态跳跃(跳过一个 LED)

原因:water_led状态机的default分支缺失,或time_en信号存在毛刺被误触发。
解决:

  1. 检查water_led.v的case语句,必须有default: state <= 4'b0001;,否则综合工具会推断出锁存器(latch),导致上电状态不可控;
  2. 用示波器测time_en,看是否有窄脉冲(<5ns)。如果有,说明time_top的cnt比较逻辑有竞争,改为if (cnt == CNT_MAX - 1) time_en = 1'b1; else time_en = 1'b0;(用 if-else 替代 assign);
  3. 在system_top中,对time_en做一级寄存器同步:reg time_en_sync; always @(posedge clk_100m) time_en_sync <= time_en;,后续用time_en_sync驱动状态机。

5. 核心模块代码详解:从 PLL 实例化到流水状态机,逐行讲透每一处设计意图

光看框架不够,得钻进代码缝里看工程师怎么权衡。下面三段是项目最核心的 Verilog,我逐行加注,解释为什么这么写,而不是那么写。这些细节,决定你的流水灯是“能亮”,还是“亮得稳、改得爽”。

5.1 PLL 实例化模板:.clk_in1(clk_50m)的括号里不能写clk_50m_i这种中间信号

这是 Vivado 自动生成的PLL.veo文件里的实例化代码,必须原样复制到system_top.v:

// PLL instance PLL uut ( .clk_in1(clk_50m), // 输入:开发板晶振,必须直连,不能经缓冲器 .clk_out1(clk_100m), // 输出:100MHz 主时钟 .locked(locked) // 输出:锁定标志,用于复位同步 );

关键点:.clk_in1(clk_50m)的clk_50m必须是顶层端口,不能是内部 wire(如wire clk_50m_i)。因为 Vivado 的时钟网络布线工具(Clock Planner)只认顶层端口为时钟源。如果写成.clk_in1(clk_50m_i),综合后clk_100m会被当成普通 net,无法走专用时钟布线资源,导致时序失败或 jitter 加大。这是新手高频翻车点——总想加一层 wire 做隔离,结果把时钟源“藏”起来了。

5.2 time_top 计数器:CNT_MAX定义为 parameter 而非 localparam,是为了方便顶层重载

// time_top.v module time_top #( parameter CNT_MAX = 26'd100 // 注意:是 parameter,不是 localparam )( input wire clk_100m, input wire rst_n_sync, output reg time_en ); reg [25:0] cnt; always @(posedge clk_100m or negedge rst_n_sync) begin if (!rst_n_sync) cnt <= 0; else if (cnt == CNT_MAX - 1) // 关键:比较用 CNT_MAX-1,不是 CNT_MAX cnt <= 0; else cnt <= cnt + 1; end assign time_en = (cnt == CNT_MAX - 1); // 脉冲宽度 = 1 个 clk_100m 周期

为什么CNT_MAX-1?因为计数范围是0到CNT_MAX-1,共CNT_MAX个值。当cnt计到CNT_MAX-1时,下一个周期归零,此时产生一个time_en高脉冲。如果写成cnt == CNT_MAX,cnt永远达不到(最大是CNT_MAX-1),time_en永远为 0。parameter可被顶层例化时重载:time_top #(.CNT_MAX(26'd50000000)) uut_time (...),这样就能快速切到 500ms 节拍,不用改源码。

5.3 water_led 独热码状态机:state的复位值4'b0001是硬件约定,不是软件习惯

// water_led.v module water_led ( input wire clk_100m, input wire time_en, input wire rst_n_sync, output reg [3:0] led_out ); reg [3:0] state; always @(posedge clk_100m or negedge rst_n_sync) begin if (!rst_n_sync) state <= 4'b0001; // 复位态:最右 LED 亮(对应 led_out[0]=0) else if (time_en) begin case (state) 4'b0001: state <= 4'b0010; // 0001 -> 0010:右→次右 4'b0010: state <= 4'b0100; // 0010 -> 0100:次右→次左 4'b0100: state <= 4'b1000; // 0100 -> 1000:次左→左 4'b1000: state <= 4'b0001; // 1000 -> 0001:左→右,循环 default: state <= 4'b0001; // 必须有,防 latch endcase end end // 共阳极取反:state 某位为 1,对应 LED 点亮(led_out 该位为 0) assign led_out[0] = ~state[0]; // state[0]=1 → led_out[0]=0 → LD0 亮 assign led_out[1] = ~state[1]; assign led_out[2] = ~state[2]; assign led_out[3] = ~state[3];

为什么复位是4'b0001?因为开发板 LED 排列从右到左是LD0, LD1, LD2, LD3,原理图上LD0是最右。用户预期“上电第一个亮的是最右灯”,所以state[0]对应LD0。default分支的4'b0001不是随便写的,它和复位值一致,确保任何意外状态都能安全回归初始态。assign led_out[x] = ~state[x]这一行,是共阳极硬件的铁律,写反了就是全灭。

6. 二次开发实战:如何把流水灯改成“左右来回”或“奇偶交替”,并验证修改正确性

现在你已经跑通了基础流水灯,下一步不是写新项目,而是改造它——这才是工程师的真实日常。下面以两个典型需求为例,给出可落地的修改路径、验证方法和避坑点。改完不是“试试看”,而是“确信它对”。

6.1 需求一:从单向流水(→)改成双向流水(↔),即0001 → 0010 → 0100 → 1000 → 0100 → 0010 → 0001

改造思路:状态机从 4 状态线性循环,升级为 6 状态“之”字形循环。需要增加一个方向控制信号dir(0=右移,1=左移),并在state达到边界时翻转dir。

代码修改(在water_led.v中):

// 新增方向寄存器 reg dir; // 0: right, 1: left always @(posedge clk_100m or negedge rst_n_sync) begin if (!rst_n_sync) begin state <= 4'b0001; dir <= 1'b0; end else if (time_en) begin case (state) 4'b0001: begin state <= 4'b0010; dir <= 1'b0; end // 0001→0010, keep right 4'b0010: begin state <= 4'b0100; dir <= 1'b0; end // 0010→0100, keep right 4'b0100: begin state <= 4'b1000; dir <= 1'b0; end // 0100→1000, reach rightmost 4'b1000: begin state <= 4'b0100; dir <= 1'b1; end // 1000→0100, turn left 4'b0100: begin state <= 4'b0010; dir <= 1'b1; end // 0100→0010, keep left 4'b0010: begin state <= 4'b0001; dir <= 1'b1; end // 0010→0001, reach leftmost default: begin state <= 4'b0001; dir <= 1'b0; end endcase end end

验证方法:

  • 仿真:在TB_water_led中,设置time_en每 100ns 一个脉冲(加快节奏),观察state波形是否按0001→0010→0100→1000→0100→0010→0001循环;
  • 硬件:用手机慢动作录像(120fps),录下 LED 变化,逐帧看是否严格 6 步一循环;
  • 避坑:不要用dir单独控制case分支,必须把dir和state组合成联合判断,否则state=0100时既可能是右移到达,也可能是左移到达,行为不确定。

6.2 需求二:从“每次亮一个”改成“奇偶位交替亮”,即0001, 0100, 0001, 0100...(只亮 LD0 和 LD2)

改造思路:放弃状态机,用计数器模 2 控制。time_en每来一次,就翻转一次led_out[0]和led_out[2],其他位恒为 1(灭)。

代码修改(替换water_led.v全部逻辑):

// 极简实现:用 1 位计数器控制奇偶 reg toggle; always @(posedge clk_100m or negedge rst_n_sync) begin if (!rst_n_sync) toggle <= 1'b0; else if (time_en) toggle <= ~toggle; // 每次 time_en 翻转 end // 输出:toggle=0 时亮 LD0(led_out[0]=0),toggle=1 时亮 LD2(led_out[2]=0) assign led_out[0] = toggle; // 0→亮,1→灭 assign led_out[1] = 1'b1; // 恒灭 assign led_out[2] = ~toggle; // 1→亮,0→灭 assign led_out[3] = 1'b1; // 恒灭

验证方法:

  • 仿真:看led_out波形是否严格在4'b1110(LD0 亮)和4'b1011(LD2 亮)之间切换;
  • 硬件:用万用表测U16(LD0)和U19(LD2)引脚电压,应交替为 0V(亮)和 3.3V(灭);
  • 避坑:toggle必须是reg类型,不能用wire;assign led_out[2] = ~toggle不能写成assign led_out[2] = toggle ? 1'b0 : 1'b1,后者会多出一个反相器,虽功能等效但增加延迟,可能影响时序。

从那以后我每次改完water_led,都强制走一遍三步验证:先仿真看波形逻辑,再用 Vivado 的Debug Core(ILA)抓板子上真实信号,最后用手机录像帧分析。因为 FPGA 的 bug 不在代码里,而在代码和硬件的缝隙中。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询