简介:面向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 | 目标节拍 500ms |
|---|---|---|
| 12MHz | 2_399_999 | 5_999_999 |
| 25MHz | 4_999_999 | 12_499_999 |
| 50MHz | 9_999_999 | 24_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{1'b0}}; 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{1'b0}}; else if (tick) cnt <= {CNT_W{1'b0}}; else cnt <= cnt + 1'b1; 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{1'b0}}; else if (tick) cnt <= {CNT_W{1'b0}}; else cnt <= cnt + 1'b1; end assign tick = (cnt == CNT_MAX - 1); // 核心:循环左移 always @(posedge clk or negedge rst_n) begin if (!rst_n) led <= {{(LED_W-1){1'b0}}, 1'b1}; // 复位后最低位亮 else if (tick) led <= {led[LED_W-2:0], led[LED_W-1]}; // 向左移一位,最高位回到最低位 end endmodule逐行看关键部分。复位分支里{{(LED_W-1){1'b0}}, 1'b1}是位拼接语法,意思是把LED_W-1个 0 和 1 个 1 拼在一起,对 4 个 LED 来说结果就是4'b0001,最低位灯亮。这样写的好处是参数化,LED 变成 8 个也能正确初始化。
循环左移那句{led[LED_W-2:0], led[LED_W-1]}是位拼接:取led的低LED_W-1位放到结果的高位,再把最高位移到最低位。拿4'b0001举例,第一次移位变成4'b0010,第二次4'b0100,第三次4'b1000,第四次回到4'b0001,形成闭环流动。
两个 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{1'b0}}; else if (tick) cnt <= {CNT_W{1'b0}}; else cnt <= cnt + 1'b1; 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{1'b0}}; else if (tick) begin if (state == LED_W - 1) state <= {STATE_W{1'b0}}; else state <= state + 1'b1; 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 = 4'b0001 << 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的同一时刻被清 0,led是否在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_999,tick永远不会拉高。这是最常见的位宽截断问题,仿真时因为参数被改小,目标值恰好落在 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 块管cnt,tick用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 <= 1'b0; rst_r2 <= 1'b0; end else begin rst_r1 <= 1'b1; 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 开发这几年,凡是新接触的板卡或者工具链升级,都会先拿一个参数化流水灯冒烟。这个习惯帮我避开了很多后续调试的大坑,因为板卡环境是否正常在五分钟内就能暴露,不用等到业务代码跑到一半才意识到是硬件底子有问题。希望帮到你。
本文还有配套的精品资源,点击获取