FPGA入门实战:Verilog流水灯设计与仿真上板全解析
2026/9/8 1:33:22 网站建设 项目流程

简介:面向FPGA入门学习者的流水灯Verilog设计包,基于Xilinx ISE工具链开发,完整呈现LED顺序亮灭的数字逻辑实现过程。包内共95个文件,以.v源码、ISE工程配置(.xise/.projectmgr)、仿真波形(.wdb)、综合报告与可执行仿真工具为主,整体仅1.57MB,便于快速获取与反复练习。代码涵盖模块定义、输入输出信号声明、计数器和寄存器构建、有限状态机切换、assign组合逻辑以及时钟与复位处理等必备知识点,并附带多个测试激励和仿真脚本,帮助读者在ISim中观察波形并验证功能。工程文件结构完整,既适合FPGA初学者对照学习,也可直接作为数字电路实验或课程设计的参考模板。目前已有3363人浏览学习,通过该资源可系统掌握Verilog语法、时序控制与ISE设计流程,还能进一步扩展流水灯速度或花式切换。 聊到 FPGA 入门,流水灯几乎是绕不开的第一个实战项目。不管是学校课程、培训班,还是自己买开发板折腾,大部分人第一次认认真真写完、仿真、下载到板子上跑起来的 Verilog 代码,八成都是它。你别看流水灯实现起来似乎不复杂,但这个项目背后涉及到的时序逻辑概念、计数器设计、分频思想、移位寄存器操作,甚至后续的仿真调试流程和上板验证流程,全都是 FPGA 开发里天天要用的基本功。把这一个项目吃透,比你囫囵吞枣地抄十个例程都有用得多。

这篇文章我就结合自己的开发经验,把 FPGA 实现流水灯的完整思路、代码设计、仿真方法和上板实操从头到尾捋一遍。你跟着走一遍,不仅能跑通流水灯,还能把 Verilog 里最核心的时序逻辑写法搞明白,为后面的串口通信、状态机、图像处理这些复杂设计打下一个比较扎实的基础。

1. 项目概述与整体技术思路

1.1 为什么流水灯是 FPGA 入门首选

很多初学者上手 FPGA 时,容易陷入一个误区:一上来就想搞复杂的项目,比如图像采集、PCIe 通信、以太网传输,结果被各种协议和高深概念劝退。我见过不少同学买了两千多的开发板,最后吃灰的比使用的多,原因就是起步太难,挫败感太强。

流水灯这个项目恰好避开了这些坑。它需要的硬件资源极少,随便一块入门级开发板都能跑,甚至连 FPGA 最小系统板加几个 LED 都够用。从代码量看,核心逻辑可能就几十行 Verilog,就算是从零开始学语法,一天之内也能写完并仿真通过。但麻雀虽小五脏俱全,它完整覆盖了一个真实 FPGA 项目的全流程:需求分析、模块划分、时钟分频、时序逻辑设计、仿真验证、引脚约束、上板调试。这套流程走一遍,你后续做再复杂的项目,框架也是这么搭的。

另外,流水灯的底层逻辑牵扯到计数器(Counter)和移位寄存器(Shift Register),这两个东西在数字电路设计里属于“出现频率最高”的基础模块。你现在把流水灯里的计数器写利索了,后面做波特率发生器、PWM 输出、定时器中断,全都是这套思路的变体。可以说,流水灯不是终点,而是你踏入时序逻辑世界的第一级台阶。

1.2 整体方案设计:分频、计数与移位三件套

在写代码之前,先把整体架构想清楚,这是我后来养成的习惯。拿到一个需求,先别急着打开编辑器,脑子里得有框图——哪怕画在草稿纸上也行。流水灯的整体逻辑其实很简单:让 LED 按顺序逐个点亮,循环往复,形成“流水”的视觉效果。

要让人眼能看清流水效果,LED 的变化频率不能太快也不能太慢,一般控制在 0.5Hz 到 2Hz 比较合适,也就是每 0.5 到 2 秒移动一位。但开发板上的时钟通常是 50MHz 或 100MHz,这就面临一个矛盾:时钟太快,怎么降到人眼能感知的频率?

答案就是分频。分频的本质是用计数器对时钟脉冲进行计数。比如 50MHz 的时钟,要用它产生一个 1Hz 的使能信号,只需要计数 25000000 次(50,000,000 / 2)即可,因为计数到一半,信号翻转一次,这样周期正好是 1 秒。这里有个细节很多人容易搞混:如果你想要每 1 秒移一位,那么计数器计满 50,000,000 个时钟周期即可,不需要除以 2,因为每计满一次就产生一个 tick 脉冲(不是翻转电平),用这个 tick 去驱动移位操作。

有了 tick 之后,流水逻辑就有两条路可走:一条是用移位寄存器,让一个 1 在寄存器组里循环移动;另一条是直接用计数器值译码,比如在 0 到 N 之间循环计数,计到某个值就点亮对应的 LED。两种写法我在下文都会给出代码,你先理解思路:分频产生慢节奏的时钟节拍,计数或移位实现流水效果,顶层模块把这两部分组合起来。

2. 开发环境与工具链准备

2.1 硬件与软件选型建议

做 FPGA 开发,没有开发板那就是纸上谈兵。市面上常见的有 Xilinx 的 Artix-7 系列(比如 EGO1 开发板)、Intel(原 Altera)的 Cyclone IV 系列,以及国产的紫光同创、安路等。EGO1 这个型号在高校里特别常见,因为很多课程设计用的就是它,板载资源也够用,自带 LED、按键、数码管、VGA 接口,做入门学习绰绰有余。

软件方面,Xilinx 的 Vivado 和 Intel 的 Quartus II 是两大主流 IDE。如果你用的是 EGO1 这类 Xilinx 芯片,那就装 Vivado;如果是 Cyclone 系列,就用 Quartus。我这里以 Vivado 为例讲流程,但 Quartus 的操作逻辑大差不差,核心都是建工程、写代码、仿真、分配引脚、下载烧录。

还有一个必须装的工具是仿真软件。Vivado 自带的仿真器(xsim)其实够用,但我个人更习惯用 ModelSim 或者 QuestaSim,因为它们的波形查看和调试体验更成熟,尤其在工程复杂的时候,QuestaSim 的性能优势很明显。不过对于流水灯这个项目,用哪个都行,你自己顺手就好。

2.2 新建工程的三个关键配置项

新建工程的时候,有几个配置项我建议你留个心眼,不然后期会返工。

第一是顶层模块名称。Vivado 默认创建的顶层模块名是 project_name_wrapper 或者类似的名字,你最好在 Create RTL Project 这一步就直接把它改成 led_flow 这种有意义的名称,并且和你后面写的module名保持一致。第二是芯片型号的选择,如果你不知道自己的板子具体是哪颗芯片,去开发板说明书里翻一下,或者直接问卖家,选错了后面综合会报错。第三是语言版本,Verilog 有 Verilog-2001 和 SystemVerilog 两种,流水灯这种简单设计选 Verilog-2001 完全够用,别一上来就选 SystemVerilog,虽然它兼容 Verilog,但在旧工程复用方面可能踩坑。

3. Verilog 代码核心实现:从语法到逻辑

3.1 时钟分频与计数器:参数计算是基本功

咱先写第一个模块:分频器。在 50MHz 的系统时钟下,要产生一个 1Hz 的 tick 信号,需要在计数器从 0 累加到 49,999,999 后,输出一个单时钟周期的高电平脉冲。你可能会问,为什么要计数 50,000,000 - 1?因为计数器是从 0 开始的,50MHz 时钟一秒内有 50,000,000 个上升沿,从 0 数到 49,999,999 正好数了 50,000,000 次,每次计满归零,同时输出一个脉冲。

module clock_divider #( parameter CLK_FREQ = 50_000_000, // 输入时钟频率 50MHz parameter TICK_T = 1 // 期望的节拍周期,单位秒 )( input wire clk, input wire rst_n, output reg tick ); // 计算计数器最大值,注意位宽要足够 localparam CNT_MAX = CLK_FREQ * TICK_T - 1; localparam CNT_WIDTH = $clog2(CNT_MAX + 1); reg [CNT_WIDTH-1:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 'd0; tick <= 1'b0; end else if (cnt == CNT_MAX) begin cnt <= 'd0; tick <= 1'b1; end else begin cnt <= cnt + 1'b1; tick <= 1'b0; end end endmodule

这里有几个细节值得你注意。$clog2是 Verilog-2001 里非常好用的系统函数,它能自动算出需要多少位二进制数才能表示最大值,省得自己手算位宽。我之前见过不少新手直接写reg [25:0] cnt,如果换了更高的时钟频率或者更大的分频系数,位宽不够就会出现计数器溢出,流水灯直接乱跳,排查半天才发现是位宽问题。用$clog2一劳永逸。

第二个细节是同步复位和异步复位的选择。上面的代码用的是异步复位,也就是复位信号 rst_n 不需要经过时钟,直接就能让寄存器归零。这种方式在复位瞬间响应快,但对复位信号的毛刺比较敏感,可能造成时序问题。在简单设计中用异步复位问题不大,但在复杂高速设计中,更推荐同步复位。流水灯这里两种写法都能跑,但你要有这个意识。

3.2 流水逻辑实现:移位寄存器与译码器两种写法对比

有了 tick 信号,流水逻辑就有了“节拍”。接下来我给出两种主流写法。

写法一:移位寄存器方式

module led_shift #( parameter LED_WIDTH = 8 )( input wire clk, input wire rst_n, input wire tick, output reg [LED_WIDTH-1:0] led ); reg [LED_WIDTH-1:0] led_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) led_r <= {{LED_WIDTH-1{1'b0}}, 1'b1}; // 初始只有最右边 LED 亮 else if (tick) begin // 循环左移:最高位移到最低位 led_r <= {led_r[LED_WIDTH-2:0], led_r[LED_WIDTH-1]}; end end assign led = led_r; endmodule

这种写法的核心思想是:用一个寄存器组保存当前 LED 状态,每次 tick 到来时,把所有位向左移动一位,同时把最高位补到最低位,形成环形移位。你想象一下,一个 1 在 8 位寄存器里左移,超出最高位的 1 又从最低位钻回来,循环往复,就是流水灯的效果。

代码里有一行可能让新手困惑:{{LED_WIDTH-1{1'b0}}, 1'b1}。这是 Verilog 的拼接语法,把 LED_WIDTH-1 个 0 和一个 1 拼在一起,组成一个 LED_WIDTH 位宽的向量,效果就是复位后让最低位置 1。不用怕复杂,这行的意思就是“LED 状态初始化为最右边那个灯亮”。

写法二:计数器译码方式

module led_counter #( parameter LED_WIDTH = 8, parameter STEP_MAX = 8 )( input wire clk, input wire rst_n, input wire tick, output reg [LED_WIDTH-1:0] led ); reg [$clog2(STEP_MAX)-1:0] cnt_step; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_step <= 'd0; led <= {{LED_WIDTH-1{1'b0}}, 1'b1}; end else if (tick) begin if (cnt_step == STEP_MAX - 1) cnt_step <= 'd0; else cnt_step <= cnt_step + 1'b1; end end always @(*) begin led = {{LED_WIDTH-1{1'b0}}, 1'b1} << cnt_step; end endmodule

第二种方式的思路是直接用计数器值做译码:计数值是几,就让第几位的 LED 亮。代码中的移位操作<<在这里其实是用组合逻辑实现的输出译码,跟第一种用寄存器时序移位的方式在硬件结构上有区别:第一种是纯时序的移位寄存器,第二种是“计数器 + 组合逻辑译码”的结构。两者实现的视觉效果一样,但如果你在后面要扩展其它功能(比如让 LED 亮度渐变),第一种可能会更灵活一些。

3.3 顶层模块整合:模块化设计的第一次真正实践

有了分频器和流水逻辑,最后一步是把它们像搭积木一样拼接到顶层模块里。

module led_flow_top #( parameter CLK_FREQ = 50_000_000, parameter TICK_T = 1, parameter LED_WIDTH = 8 )( input wire clk, input wire rst_n, output wire [LED_WIDTH-1:0] led ); wire tick; clock_divider #( .CLK_FREQ (CLK_FREQ), .TICK_T (TICK_T) ) u_clk_div ( .clk (clk), .rst_n (rst_n), .tick (tick) ); led_shift #( .LED_WIDTH (LED_WIDTH) ) u_led_shift ( .clk (clk), .rst_n (rst_n), .tick (tick), .led (led) ); endmodule

顶层模块做的事情并不多,就是例化两个子模块,并用 wire 信号把它们连接起来。其中#(...)是参数传递的语法,用来把顶层参数传给子模块。这种模块化设计的思想,我现在做大型项目时依然在用。把大功能拆分成小模块,每个模块职责单一,再用信号把它们连起来,调试的时候可以单独仿真每个子模块,定位问题会快得多。

在 Vivado 里写完这几个模块之后,有一个小技巧:综合前先用 Analysis 阶段的 RTL Elaboration 检查一下代码语法和模块例化关系,它会画出一张 RTL 原理图,你能清晰地看到clock_dividerled_shift两个模块就像两块积木一样连接在一起。我第一次看到这张图时,对 FPGA 开发的抽象理解瞬间提升了一个台阶——原来我写的代码真的在“画电路”,而不是在执行程序。

4. 仿真验证与上板实操

4.1 ModelSim 仿真:别让 5000 万计数拖垮你的仿真速度

写完代码,别急着上板。硬件上板之前先仿真,这是 FPGA 开发的不成文规矩。你想想,板子上的器件就那几个,一旦烧进去有问题,查起来又费时又费劲。仿真可以在电脑上把功能验证好了,再把代码烧到板子上,大概率就不会出大问题。

仿真第一步是写 testbench(TB),它的作用是给被测模块(DUT)施加激励信号,比如产生时钟、产生复位、观察输出波形。流水灯的测试比较特殊:如果你仿真真实的分频参数 1 秒一个 tick,那仿真 8 个 LED 流完一整圈需要 8 秒,而在 ModelSim 里仿真 1 秒的数字电路耗时可能很长,这会让你等到怀疑人生。

最有效的解决办法是参数重载。testbench 里例化 DUT 时,把分频参数改小,比如把CLK_FREQ设为 10(意思是每 10 个周期产生一个 tick),这样整个流水过程几微秒就仿真完了,你可以快速验证功能正确性。等仿真通过了,再上板时沿用默认的 50MHz 参数,板子上的效果就会是正常的秒级流水。

`timescale 1ns / 1ps module tb_led_flow; reg clk; reg rst_n; wire [7:0] led; // 产生 50MHz 时钟 initial clk = 1'b0; always #10 clk = ~clk; // 复位激励 initial begin rst_n = 1'b0; #100; rst_n = 1'b1; #5000; $stop; end led_flow_top #( .CLK_FREQ (10), // 便于仿真,实际可改为 50_000_000 .LED_WIDTH (8) ) uut ( .clk (clk), .rst_n (rst_n), .led (led) ); endmodule

看到这里你可能会问:CLK_FREQ = 10意味着多久移一位?其实这里的参数含义是“多少时钟周期产生一个 tick”,不是具体的频率值。因为 testbench 里的时钟周期由#10决定(10ns),所以当CLK_FREQ = 10时,每 10 个时钟周期产生一个 tick,对应 100ns。这个时间足够你在波形图里看清 LED 的移动过程了。

4.2 上板调试:引脚约束和下载的完整流程

仿真通过之后,就可以上板了。上板之前有一件必不可少的事:分配管脚约束(Pin Constraints)。Vivado 默认不知道你的clk信号应该连接到板子上哪个物理引脚,你需要告诉它。常见做法是直接编辑 XDC 约束文件,或者用 Vivado 的 Pin Planning 图形界面。

以 EGO1 开发板为例,板载 50MHz 时钟源可能连接到 FPGA 的 E1 引脚(具体以你的板卡原理图为准),8 个 LED 可能连接在 P1~P8 之类的引脚上。新建 XDC 文件后,格式大致如下:

set_property PACKAGE_PIN E1 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN P1 [get_ports {led[0]}] set_property PACKAGE_PIN P2 [get_ports {led[1]}] ...

写约束文件时最容易犯的错误是引脚号和数组对应错位,比如 led[0] 和 led[7] 接反了,后果是流水方向反着跑。不用担心,这在板子上肉眼很明显,改一下约束重新烧录就好,属于小事一桩。但引脚号脱离开发板手册瞎填的话,轻则功能异常,重则可能因为 FPGA 引脚同时被多个外设占用甚至直接使用时序冲突,所以我建议你第一步就翻开发板自带原理图,一个引脚一个引脚地核对清楚。

下载环节同样简单:连接好 JTAG 下载器,在 Vivado 里点击 Open Hardware Manager,然后 Program Device,选择生成的 bitstream 文件,点下载,看到进度条走完,板子上的灯就开始跑了。如果没跑起来,优先级最高的排查方向是复位按键和时钟引脚有没有接对,这两个出问题的概率能占一半以上。

5. 常见问题与排查技巧实录

5.1 仿真与上板对不上?大概率是复位或约束问题

流水灯虽然简单,但做实验时遇到的问题未必都简单。我自己带过不少同学做这个项目,归纳下来最常见的问题集中在三类。

第一类是仿真波形正常,上板灯却不亮。这个问题优先级最高的怀疑对象是复位引脚。如果你的开发板复位按键按下时输出电平是 1(上拉),而代码里用的是低电平复位rst_n,那板子一上电就等于一直在复位,LED 永远不亮。解决办法是看一下板子的原理图,确认复位的有效电平,必要时把代码改成if (rst)或者 XDC 约束里取反。

第二类是仿真一切正常,板子上 LED 全亮或者全灭。通常问题出在 IOSTANDARD 配置上,如果你的 FPGA 是 3.3V bank,但 LED 通过推挽输出连接,LVCMOS33 是最常用的标准;如果出现了输出短路之类的问题,就要检查引脚配置是否和其他模块冲突。还有一种可能是你把引脚约束到了一个没有连接到板子上的“悬空”引脚,看起来自然不亮。

第三类是流水速度太快或者太慢。速度太快,说明分频的周期设置太短;太慢,说明计数溢出后 tick 丢失或时钟频率理解偏差。你只要验算一遍CNT_MAX的数值和实际分频后的频率:期望 tick 频率 = 时钟频率 / (CNT_MAX + 1)。代入数字,一算便知。

5.2 经验心得:从流水灯开始建立调试验证思维

我自己的体验是,学 FPGA 和学软件有个非常大的不同:软件崩了可以直接打印日志,硬件出了问题只能看波形、量电平、猜原因。所以从流水灯开始,你就应该有意识地建立一套“分层定位”的调试思路:先看仿真波形对不对,再看综合报告里的警告有没有异常,接着看 RTL 视图的模块连线是否符合预期,最后才上板用逻辑分析仪(或者板载 LED)去观察内部信号。

另外我特别想提一点:尽量别用“改一段代码、全量综合、重新上板”这种蛮力调试法。流水灯还好,等你做大项目,一次综合加布局布线动辄几十分钟,你再循环往复,一天的时间就没了。更好的做法是每一个小改动都先在 testbench 里快速验证,仿真过了再上板。仿真跑得慢没关系,用参数重载把仿真时间缩短;仿真复杂也没关系,把你的代码按功能拆成小块分别验证。这套方法论我从流水灯开始用到现在,一直受益。

6. 后续扩展:流水灯之外还能玩出什么花样

流水灯跑通了,说明你已经掌握了 FPGA 开发的基本功。但这只是开始,接下来有太多好玩的功能可以在这个基础项目上做扩展。我简单说几个方向,你按兴趣挑一个往下走就行。

第一个方向:加入按键控制。比如按下按键流水暂停,再按一下恢复;或者按键切换流水方向;长按调整流水速度。这个项目能让你学会按键消抖——按键的机械弹跳会导致一次按下被识别成很多次,后续做任何交互功能都要面对这个问题。

第二个方向:把 LED 亮度做出来。用 PWM 技术,让 LED 从暗到亮渐变,再配合流水移动,效果会比单纯亮灭炫酷得多。PWM 本质上还是计数器那一套,你在流水灯里已经用过了。

第三个方向:换一种更快的通信接口。如果你手头有第二个 FPGA 开发板或者单片机,可以尝试用 UART 串口让两个设备协作,比如上位机发一个指令控制流水灯模式。这就涉及到跨时钟域、同步握手这些更深入的概念,是 FPGA 工程化的核心内容之一。

第四个方向:增加显示输出。把流水灯的状态同时显示在板载数码管或 LCD 屏幕上。这一步能让你初步接触显示驱动的解码逻辑,后面做 VGA 或 HDMI 也不至于两眼一抹黑。

总的来说,流水灯项目像是你走进 FPGA 世界的第一扇门。别嫌它简单,把背后的计数器、分频、移位、仿真、约束这一整套逻辑都吃透,你后面的路会顺畅非常多。我始终觉得,数字电路设计拼的不是会多少个写法,而是遇到问题时能不能快速定位到是逻辑错误、时序错误还是约束错误。这个能力,恰恰就是从一个简简单单的流水灯开始培养起来的。

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

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

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

立即咨询