
简介面向FPGA初学者的流水灯Verilog工程包适用于在Xilinx ISE环境中学习数字电路设计与FPGA开发流程。工程包含模块定义、时钟分频、计数器与状态机等典型设计结构展示了如何通过LED顺序亮灭实现数字信号流动效果适合配合开发板完成编译、仿真、综合、下载验证的全过程。资源包共95个文件以.v源文件、.xise工程文件、.prj配置脚本以及ISE仿真生成的.exe、.wdb波形数据等为主整体压缩包约1.57MB。这些文件覆盖工程管理、逻辑综合、行为仿真与结果报告等环节有助于按步骤理解FPGA工具链的运作方式。该资源已有3369人学习内容包含顶层模块、时钟分频等关键Verilog源码及相关仿真报告读者可快速复现流水灯实验并在此基础上尝试添加速度调节、多模式切换等扩展功能。1. 流水灯是FPGA的第一次真刀真枪它考的不是代码是“时钟、复位、引脚”这三件套很多人第一次写FPGA流水灯仿真里波形漂亮得不行一上板子却黑灯、全亮、乱跳。用Verilog代码在FPGA上实现流水灯表面上是“点灯”本质上是把三个之前只停留在文档里的概念一次性逼到墙角时钟怎么分频、复位怎么处理、引脚怎么约束。这三件事任何一个没做对流水灯都不会按你预想的节奏跑。这篇文章不绕弯子直接按“建工程 → 写代码 → 仿真 → 上板排查 → 参数化复用”的顺序把一条能完整复现的路径拆给你。适合两类人刚转FPGA的初学者以及换了新开发板想快速验证工具链和板卡是否正常的开发者。2. 建最小工程从选芯片到把约束文件写对才能拿到第一帧波形2.1 选型逻辑开发板时钟、LED数量和EDA工具链怎么决定代码结构做流水灯之前先明确三个跟硬件绑定的参数板载时钟频率、LED数量、复位按键的有效电平。这三个参数不先查清楚后面写代码全是在猜。常见做法是先打开开发板的原理图找到晶振那一页确认时钟是 50MHz、25MHz 还是 12MHz再到 LED 那一页数一数有几个灯最后看按键电路复位按下时是拉低还是拉高。这三个参数直接影响代码里最核心的一个数分频系数。如果板载时钟是 50MHz想让 LED 每 200ms 切换一次那么计数器要数50_000_000 / 1000 * 200 10_000_000个时钟周期。这个数就是分频计数器要比较的目标值。下面是几种常见板载时钟下流水灯每颗灯停留 200ms 时需要的计数值你在写parameter时直接套用即可板载时钟目标节拍 200ms目标节拍 500ms12MHz2_399_9995_999_99925MHz4_999_99912_499_99950MHz9_999_99924_999_999注意表格里的数为什么都减了 1因为计数器从 0 开始计数数到目标值减 1 就已经经过了完整的N个时钟周期。这个细节很多人第一次写都会漏后面避坑章节专门讲。EDA 工具链的选择影响的是工程创建流程但代码本身是通用的。我一般建议新手先别折腾复杂芯片选一块主流入门级 FPGA 开发板逻辑资源几百 K 级别就足够。工具链方面某厂商的工程向导和另一家的流程略有差异但都是“新建工程 → 添加设计文件 → 分配管脚 → 综合实现 → 生成比特流 → 下载”这条链路。接下来的步骤以通用流程描述你在自己的工具里按对应菜单操作即可。2.2 空工程到比特流图形界面操作的顺序与每个文件的作用从空工程到板子亮灯实际要经过六个环节建工程、写 RTL、写约束、综合、实现、下载。前两步很多人愿意花时间后四步习惯一键跑完这没问题但你要知道每一步在干什么。综合是把 Verilog 代码映射成逻辑网表实现是布局布线比特流才是最终烧进 FPGA 的二进制文件。先给出一个最小的 Verilog 模块骨架哪怕先不写功能也要先把端口定下来。下面这段代码是流水灯模块的外壳端口名要和后面约束文件一一对应module led_flow_shell #( parameter CLK_HZ 50_000_000, // 板载时钟频率单位Hz parameter STEP_MS 200, // 每颗灯停留时间单位ms parameter LED_W 4 // LED数量 )( input wire clk, // 板载时钟 input wire rst_n, // 复位低有效按下为0 output reg [LED_W-1:0] led // LED输出1表示亮 ); // 功能代码后面章节补 always (posedge clk or negedge rst_n) begin if (!rst_n) led {LED_W{1b0}}; end endmodule代码里的parameter是关键CLK_HZ和STEP_MS一旦定义分频系数就可以自动算出来换开发板时只改这两个值流水灯代码一行都不用动。我在实际工程里会把这两个参数放在模块最上方方便后面做参数化复用。rst_n命名里的_n后缀是约定俗成的“低有效”标记看到这个名字就应该知道复位信号按下时为 0。新建工程时综合器会让你选芯片型号这一步不能乱选。如果你的板子原理图上写的芯片型号不对后续引脚约束全部会报错。创建好工程后把上面这个模块添加为设计源文件然后新建一个约束文件。约束文件的写法下一节专门讲。工程能综合通过之后再连接开发板下载器把比特流烧进去。第一次跑通的标志不是灯亮而是下载完成后没有报DONE相关的错误。2.3 约束文件是绝大多数“仿真过、上板黑”的元凶管脚约束写法仿真只验证逻辑功能不关心引脚位置。上板之后FPGA 每个引脚都必须知道自己在物理上连到哪个封装引脚、使用什么电平标准。这一步漏掉综合工具默认按某个 IO 标准处理和板载的外设电压不匹配灯就点不亮。我见过的上电黑灯案例里一半以上都是约束文件没写对。约束文件通常以.xdc或.qsf为后缀不同工具语法有差异但核心内容都是“端口名 → 物理引脚编号 → IO 电平标准”。下面是通用写法PIN_XX这类占位符要替换成你板卡丝印上实际标注的引脚编号# 时钟引脚约束PIN_CLK 替换为板卡晶振所在引脚 set_property PACKAGE_PIN PIN_CLK [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] # 复位按键按下为低有效PIN_KEY 替换为按键所在引脚 set_property PACKAGE_PIN PIN_KEY [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] # LED 输出引脚按板卡丝印逐位替换 set_property PACKAGE_PIN PIN_LED0 [get_ports {led[0]}] set_property PACKAGE_PIN PIN_LED1 [get_ports {led[1]}] set_property PACKAGE_PIN PIN_LED2 [get_ports {led[2]}] set_property PACKAGE_PIN PIN_LED3 [get_ports {led[3]}] set_property IOSTANDARD LVCMOS33 [get_ports {led[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {led[1]}] set_property IOSTANDARD LVCMOS33 [get_ports {led[2]}] set_property IOSTANDARD LVCMOS33 [get_ports {led[3]}]简单梳理一下约束里几个字段的含义PACKAGE_PIN告诉工具信号要接到芯片物理封装上的哪个引脚get_ports后面写的是 RTL 代码里的端口名必须和模块定义完全一致多一个空格都会报错IOSTANDARD是 IO 电平标准绝大多数入门开发板的 LED 都是LVCMOS33也就是 3.3V 的 CMOS 电平。如果你的板载外设供电是 1.8V这里就要改成LVCMOS18否则即便引脚编号正确电平不匹配也会导致信号无法识别。约束文件写完后第一遍综合报错不要慌错误信息里通常会明确告诉你哪个引脚分配无效。把工具提示的错误信息对照原理图逐条改比盲目试要快得多。记住一句话仿真工具不管引脚综合工具只管引脚上板效果只认引脚。3. 用Verilog写流水灯的两个方案移位寄存器省逻辑状态机更可控3.1 分频计数器的位宽计算别让计数器溢出也别让节拍快一倍流水灯的核心不是灯的移位而是“什么时候该移一下”。这个节拍由分频计数器产生。计数器每数满一个目标值就发出一个脉冲这个脉冲叫tick。所有 LED 的移位、状态切换都挂在tick上。所以分频计数器是整段代码的命脉。计数器位宽计算有个现成写法用$clog2函数自动取对数。$clog2(10_000_000)结果是 24意味着计数器需要 24 位。如果手写位宽reg [15:0] cnt计到 65535 就溢出了tick永远拉不高流水灯就会卡在某一颗灯上一动不动。这就是典型的“仿真时没注意上板后灯不动”的翻车现场。下面是分频部分的标准写法配合前面的模块外壳可以直接用localparam CNT_MAX CLK_HZ / 1000 * STEP_MS - 1; // 分频目标值 localparam CNT_W $clog2(CNT_MAX 1); // 计数器所需位宽 reg [CNT_W-1:0] cnt; wire tick; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {CNT_W{1b0}}; else if (tick) cnt {CNT_W{1b0}}; else cnt cnt 1b1; end // tick 在计数到 CNT_MAX-1 时提前拉高下一拍正好清零并触发移位 assign tick (cnt CNT_MAX - 1);这里有一个容易被忽视的细节tick是组合逻辑赋值它在cnt CNT_MAX - 1时为 1下一个时钟上升沿到来时计数器清零同时 LED 移位。为什么用CNT_MAX - 1而不是CNT_MAX因为如果tick在cnt CNT_MAX时才拉高清零也在同一个周期发生那么 LED 实际移位会比预期晚一个周期而且每颗灯多亮一拍。用提前一拍的写法计数器和移位寄存器在同一时钟沿动作节拍最干净。这是我在对比过两种写法后保留的习惯。3.2 方案A移位寄存器版流水灯逐行拆解这一个module移位寄存器版的核心思路用一个寄存器保存当前哪颗灯亮每来一个tick把寄存器整体向左循环移动一位。好处是逻辑非常紧凑不需要额外的译码电路。完整代码如下module led_flow_shift #( parameter CLK_HZ 50_000_000, // 板载时钟频率 parameter STEP_MS 200, // 每颗灯点亮时长 parameter LED_W 4 // LED数量 )( input wire clk, input wire rst_n, output reg [LED_W-1:0] led ); localparam CNT_MAX CLK_HZ / 1000 * STEP_MS - 1; localparam CNT_W $clog2(CNT_MAX 1); reg [CNT_W-1:0] cnt; wire tick; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {CNT_W{1b0}}; else if (tick) cnt {CNT_W{1b0}}; else cnt cnt 1b1; end assign tick (cnt CNT_MAX - 1); // 核心循环左移 always (posedge clk or negedge rst_n) begin if (!rst_n) led {{(LED_W-1){1b0}}, 1b1}; // 复位后最低位亮 else if (tick) led {led[LED_W-2:0], led[LED_W-1]}; // 向左移一位最高位回到最低位 end endmodule逐行看关键部分。复位分支里{{(LED_W-1){1b0}}, 1b1}是位拼接语法意思是把LED_W-1个 0 和 1 个 1 拼在一起对 4 个 LED 来说结果就是4b0001最低位灯亮。这样写的好处是参数化LED 变成 8 个也能正确初始化。循环左移那句{led[LED_W-2:0], led[LED_W-1]}是位拼接取led的低LED_W-1位放到结果的高位再把最高位移到最低位。拿4b0001举例第一次移位变成4b0010第二次4b0100第三次4b1000第四次回到4b0001形成闭环流动。两个 always 块都用了非阻塞赋值这是时序逻辑的标准写法。阻塞赋值在时序逻辑中会导致综合出意想不到的 latch 或竞争这也是新手最容易踩的坑后面单独说。把这段代码添加到工程后替换掉之前的空壳模块直接可以综合。3.3 方案B计数器加独热解码不依赖移位reg方案B 的思路是用一个计数器记住“当前该哪颗灯亮”再用组合逻辑把计数器的值解成独热码输出。它本质上是一个极简状态机状态数量等于 LED 数量。好处是扩展性强以后想改成“亮两颗灯”“跳着亮”都更容易控制。代码如下module led_flow_decode #( parameter CLK_HZ 50_000_000, parameter STEP_MS 200, parameter LED_W 4 )( input wire clk, input wire rst_n, output reg [LED_W-1:0] led ); localparam CNT_MAX CLK_HZ / 1000 * STEP_MS - 1; localparam CNT_W $clog2(CNT_MAX 1); localparam STATE_W $clog2(LED_W); // 计数器位宽4个灯需要2位 reg [CNT_W-1:0] cnt; reg [STATE_W-1:0] state; wire tick; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {CNT_W{1b0}}; else if (tick) cnt {CNT_W{1b0}}; else cnt cnt 1b1; end assign tick (cnt CNT_MAX - 1); // 状态计数器每个tick加1数到LED_W-1后归零 always (posedge clk or negedge rst_n) begin if (!rst_n) state {STATE_W{1b0}}; else if (tick) begin if (state LED_W - 1) state {STATE_W{1b0}}; else state state 1b1; end end // 独热解码state等于几第几个LED就亮 integer i; always (*) begin for (i 0; i LED_W; i i 1) begin led[i] (state i); end end endmodule这个方案的关键在最后的for循环。它不是一个真正的循环执行语句而是综合器的展开指令意思是把state 0的结果赋给led[0]把state 1的结果赋给led[1]依次类推。对于 4 个 LED展开后相当于led 4b0001 state。integer i只是循环变量不会综合成硬件。和方案A相比方案B 多了一个state寄存器但逻辑层次更清晰分频管节拍计数器管状态解码管输出三个环节完全解耦。如果你后续想把流水灯改成呼吸灯或者交通灯方案B 只需要改解码那段逻辑分频和计数完全不用动。方案A 则适合追求最少逻辑资源、不打算扩展的场景。两种方案综合出来的资源消耗差异微乎其微硬件资源真正紧张的场合不会用流水灯这种空转逻辑所以选哪种主要看你的扩展预期。3.4 上板前的最后一道保险最小testbench与“tick”波形检查上板之前一定要跑仿真但仿真的目标不是“看到灯在动”而是验证两件事第一tick产生的周期是否符合预期第二LED 在复位释放后是否按顺序流动没有出现多颗灯同时亮的情况。仿真时如果把节拍设成 200ms需要跑 200ms 的仿真时间才能看到完整一轮流水太慢了。工程上的常规做法是在 testbench 里覆盖STEP_MS参数把它改小到 1ms功能验证通过后再用默认参数上板。timescale 1ns / 1ps module tb_led_flow; reg clk; reg rst_n; wire [3:0] led; // 实例化被测模块仿真时把STEP_MS覆盖为1ms led_flow_shift #( .CLK_HZ (50_000_000), .STEP_MS (1), .LED_W (4) ) dut ( .clk (clk), .rst_n (rst_n), .led (led) ); // 生成50MHz时钟周期20ns initial clk 0; always #10 clk ~clk; // 复位先拉低100ns再释放 initial begin rst_n 0; #100; rst_n 1; end // 仿真6ms后结束足够看6轮完整流水 initial begin #6_000_000; $display(simulation finished); $finish; end endmoduletestbench 里最关键的一行是#10 clk ~clk。#10是延时 10ns时钟每 10ns 翻转一次周期就是 20ns对应 50MHz。如果你的板载时钟是 25MHz这里要改成#20。复位释放前务必要让#100的延时跑完否则模块一直处于复位状态LED 全灭很容易误判成代码问题。仿真跑完打开波形窗口重点看两个信号的对应关系cnt是否在计数到CNT_MAX - 1的同一时刻被清 0led是否在tick拉高的下一个时钟沿发生变化。如果tick拉高的时间点和led变化点之间隔了一个周期说明你的tick判断条件和清零条件没对齐上板后每颗灯的停留时长会比预期多一个时钟周期。虽然肉眼看不出差别但这是排查节奏类问题的重要线索。4. 上板排查实录仿真明明对的为什么一上电就黑灯、全亮、乱跳4.1 上电不亮复位信号悬空还是复位极性写反了现象仿真波形里复位后 LED 顺序点亮板上却一颗灯都不亮按开发板按键也没反应。这种情况常见于第一次用某块新板子的人往往是复位引脚的极性弄反了。原因模块里写的是rst_n低有效也就是按下按键时信号为 0模块复位。但部分开发板的复位按键电路是上拉到高电平、按下接地另一部分则是反过来按键按下输出高电平。如果你的板子按键按下时输出高而代码里用低有效复位那么复位信号始终为 1模块永远不会从复位状态走出来。解决先查原理图确认按键按下时的电平。如果按下为高把代码里的rst_n改成rst复位分支写成if (rst)所有negedge rst_n改成posedge rst。还有一类情况是复位引脚根本没约束到正确的封装引脚上信号悬空FPGA 内部读到的可能是随机电平。用万用表量按键两端电压按下时如果电压在 0 和 3.3V 之间跳变说明电路没问题重点查约束文件里的PACKAGE_PIN是否填对。4.2 只亮第一个灯分频计数器位宽截断导致tick永远拉不高现象上电后最低位 LED 亮起之后一动不动按复位也是这样。仿真里一切正常因为仿真时把STEP_MS覆盖成了 1ms计数器目标值只有 49999用一个 16 位计数器就能放下上板用默认的 200ms目标值变成 9_999_999需要 24 位。原因代码里如果手写了reg [15:0] cnt计数器数到 65535 就溢出永远到不了 9_999_999tick永远不会拉高。这是最常见的位宽截断问题仿真时因为参数被改小目标值恰好落在 16 位范围内掩盖了错误。解决用$clog2(CNT_MAX 1)自动计算位宽不要手写。如果你必须在旧代码上修改把reg [15:0] cnt改成reg [23:0] cnt然后重新综合。改完再看综合报告里的Register Bits一项确认计数器的寄存器宽度和你预期的位宽一致这一步能避免很多“仿真对了但上板翻车”的情况。4.3 灯在闪但节奏不对tick与清零条件不是同一个时钟周期现象LED 确实在流水但肉眼观察节奏忽快忽慢某一颗灯亮的时间明显比其他灯短。用逻辑分析仪抓波形发现tick脉宽只有一个时钟周期但 LED 切换的间隔不相等。原因分频计数器cnt的清零条件和tick的拉高条件不是同一个判断。比如tick在cnt CNT_MAX - 1时拉高但清零分支写的却是cnt CNT_MAX时清零导致tick高电平期间计数器还在继续走LED 移位时刻和节拍基准错位。或者你在两个 always 块里分别对cnt和led做了不同的节拍判断综合后就成了两个独立计数器。解决清零和tick必须统一最稳妥的写法就是一个 always 块管cnttick用assign组合赋值且tick的判断条件与清零条件完全一致。参考 3.2 节的代码tick (cnt CNT_MAX - 1)清零分支也用if (tick)这样tick拉高时刚好是最后一个有效计数周期下一个时钟沿两者同时动作。养成这个习惯后节拍类问题基本不会再来找你。4.4 综合报错multiple drivers多人维护代码时最常见的事故现象综合阶段直接报错提示某个信号被多个进程驱动或者led[0]有多个 driver。这种情况在新手从别处复制代码片段拼接时尤其常见。原因一个输出信号只能在一个 always 块或一个assign语句里赋值。有些代码片段里用了assign led[0] ...后面的 always 块里又对led[0]赋值综合器无法判断该听谁的。还有一种是模块实例化时把两个模块的输出端口接到了同一个wire上。解决回到 RTL 代码搜索所有对led赋值的语句。如果同时有assign和always保留其中一种。通常我会把 LED 输出统一放在一个时序 always 块里其它逻辑用 wire 输给模块内部最后在顶层模块里做一次集中赋值。注意output reg [LED_W-1:0] led的reg类型不能和assign混用要么把端口声明成output wire并在内部用reg信号再assign出去要么直接让端口是reg且只在 always 里赋值。不要两种写法都出现在同一个文件里。4.5 全亮或全灭、按复位才好缺上电初始化补一个异步复位同步释放现象上电瞬间所有 LED 随机点亮或全灭按下复位键后再松开流水灯恢复正常。看起来是“复位就好了”很有迷惑性但如果你依赖用户在开机后手动按复位这个产品就是不合格的。原因FPGA 内部寄存器上电后的初值不确定。虽然代码里写了复位分支if (!rst_n) led ...但如果复位信号在上电后一直为 1非复位状态那复位分支永远不会执行寄存器保持上电随机值。真正的问题不是缺初始化而是没有一个保证上电后自动进入复位状态的机制。解决给复位信号加“异步复位、同步释放”处理让模块内部在时钟稳定后自动产生一个短暂的复位窗口。常见写法是reg rst_r1, rst_r2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rst_r1 1b0; rst_r2 1b0; end else begin rst_r1 1b1; rst_r2 rst_r1; end end wire rst_sync_n rst_r2; // 用这个信号替代外部rst_n这段代码的作用是外部复位释放后内部复位信号延迟两个时钟周期再释放避免复位信号与时钟边沿竞争导致亚稳态。然后把流水灯模块里的复位端口接到rst_sync_n上。这样即使你的开发板按键在开机时没有被按下模块也会在上电后的头几个时钟周期完成初始化。这是工业代码里几乎必备的成熟写法流水灯虽然简单但值得从一开始就建立这个习惯。5. 把流水灯做成参数化冒烟模块验证新板子的五分钟习惯到这一步你已经有了一个能跑的流水灯接下来我强烈建议你做一件事把它封装成一个参数化 IP 风格的模块专门用来做新板子的冒烟测试。所谓冒烟测试就是拿到一块新开发板先不写任何业务逻辑只验证时钟能不能跑、复位有没有效、LED 引脚是否约束正确、下载链路是否通畅。流水灯恰好覆盖了这四件事。参数化改造其实很简单前面给出的两个方案模块已经带了CLK_HZ、STEP_MS、LED_W三个参数不需要再改逻辑。你只需要在工程里保留一个顶层模块把板载时钟、复位按键、LED 引脚全部引到顶层然后实例化流水灯模块。换板子时只改顶层约束和实例化参数三分钟就能完成一次完整验证。我个人的习惯是凡是入库的板子第一件事就是跑一次参数化流水灯把节拍调到 200ms肉眼观察灯流动是否均匀。如果速度不均匀优先怀疑时钟频率参数填错如果某颗灯不亮优先怀疑那颗 LED 的引脚约束或电平标准如果全部不亮先查复位极性再查时钟引脚。这一套排查顺序就是流水灯作为冒烟工具的价值所在。额外补一个对你有用的验证技巧你可以把STEP_MS临时设为 1也就是 1ms 切换一次然后用手机慢动作视频拍下 LED 的流动顺序回放时逐帧对比。这个方法能发现肉眼看不出来的偶发跳变比如某一帧里连续跳了两颗灯说明tick出现了毛刺多半是复位释放瞬间的亚稳态问题。这时候把 4.5 节的异步复位同步释放加进去问题通常就消失了。我自己做 FPGA 开发这几年凡是新接触的板卡或者工具链升级都会先拿一个参数化流水灯冒烟。这个习惯帮我避开了很多后续调试的大坑因为板卡环境是否正常在五分钟内就能暴露不用等到业务代码跑到一半才意识到是硬件底子有问题。希望帮到你。本文还有配套的精品资源点击获取