简介:这是一份基于 Quartus II 与 Verilog 的流水灯设计完整工程包,适合初学 FPGA 或数字逻辑的电子类学生、工程师作为入门练手项目。资源围绕模块定义、有限状态机、时钟分频、移位寄存器等核心知识点展开,从代码编写、综合仿真到硬件下载均有涉及,可帮助读者理解 LED 流水灯从逻辑描述到实际落地的完整流程。压缩包共 132 个文件、约 1.27MB,主体为 Quartus II 工程文件(qpf、qsf、sof、qws)与 ModelSim 仿真相关文件(do、v、wlf),另有大量 cdb、hdb、rpt 等综合与适配过程生成的过程文件,便于对照工程目录了解各类型文件的用途。资源目前已有 568 人学习,适合想要快速体验 Verilog 编程、状态机设计及 Quartus 开发流程的入门者动手实践。通过解压并打开工程,可直接查看源码、仿真脚本与编译报告,节省自行搭建环境与排查报错的时间,是一份实用性较强的 FPGA 教学示例。
1. 流水灯:FPGA 入门的第一道门槛
流水灯就是让一排 LED 依次点亮、熄灭,形成像水流动一样的效果。在 Quartus II 里用 Verilog 写一个流水灯,看起来是“点灯”,实际上是把 FPGA 开发的完整链路走了一遍:时钟处理、计数器设计、组合逻辑与时序逻辑的区分、引脚约束、编译下载、板级验证。很多工程师学 FPGA 的第一个工程就是这个 led.zip,它几乎是所有开发板厂商默认附带的例程。
这个标题适合两类人:一是刚接触 Verilog 的初学者,想找一份能直接跑通的代码,理解计数器怎么控制 LED 亮灭节奏;二是已经在用 Quartus II 做项目、但想回头把基础模块写得更规范的人。需要说明的是,流水灯的实现方式不止一种——移位寄存器、计数器加译码、状态机——选哪种取决于你之后要做什么。如果你只是入门,计数器加移位寄存器是最容易调试的写法;如果你后面要做通信协议,那从状态机角度去写流水灯反而更有练习价值。
2. Verilog 流水灯的核心逻辑:从分频到移位
2.1 为什么不能直接让时钟驱动 LED
开发板上通常是 50MHz 有源晶振,也就是一秒钟翻转五千万次。直接把时钟信号给 LED,人眼根本捕捉不到亮灭变化,看到的只会是常亮或微暗。所以第一步一定是分频。
分频的实质是计数。50MHz 时钟计数到 25_000_000,刚好是 0.5 秒。每计满一次就翻转一个标志信号,这个标志信号的频率就是 1Hz,用来驱动 LED 变化节奏。常见做法是写一个参数化的计数器模块:
module clk_div #( parameter DIV_CNT = 25_000_000 )( input wire clk, input wire rst_n, output reg clk_1hz ); reg [31:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; clk_1hz <= 0; end else if (cnt == DIV_CNT - 1) begin cnt <= 0; clk_1hz <= ~clk_1hz; end else begin cnt <= cnt + 1; end end endmodule这个模块里,DIV_CNT是参数,为什么定义成 25_000_000 而不是 25_000_000 - 1?因为计数器的比较条件是cnt == DIV_CNT - 1。从 0 计到 24_999_999 一共是 25_000_000 个时钟周期,占满 0.5 秒。如果写成cnt == DIV_CNT,那 0 到 25_000_000 是 25_000_001 个周期,频率会差一点点,肉眼虽然看不出来,但作为习惯不值得养成。
clk_1hz的翻转频率是 1Hz(周期 1 秒,占空比 50%),这个信号再给到下一级做流水的节拍。这里有一个很容易理解偏差的点:分频得到的时钟不推荐直接拿来驱动其他模块的时钟端——在 FPGA 里,全局时钟网络是专门给 PLL 输出的,除非用altpll或clk_wiz之类的 IP 做时钟管理,否则直接用内部逻辑产生的信号当 clock 容易引起时序收敛问题。更稳的做法是把分频信号当成普通使能脉冲来用。
2.2 用移位寄存器实现流水
流水效果最常见、也最好理解的写法是移位寄存器。初始值让最低位为 1,其余为 0,每个节拍左移一位,最高位移出后就回到最低位。用 Verilog 写就是:
module led_shift #( parameter WIDTH = 8 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] led ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin led <= {{(WIDTH-1){1'b0}}, 1'b1}; end else if (en) begin led <= led[WIDTH-2:0] | (led[WIDTH-1] ? {1'b0, {(WIDTH-1){1'b0}}} : 0); end end endmodule上面这写法有点绕,因为把“最高位回绕”和“左移”挤在了一行里。工程上我更推荐写得更直白一些:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin led <= {{(WIDTH-1){1'b0}}, 1'b1}; end else if (en == 1'b1) begin if (led[WIDTH-1] == 1'b1) begin led <= 1'b1; end else begin led <= led << 1; end end end逐行解释:复位时 LED 只有最低位亮。en是节拍使能,每来一个脉冲就移一次位。当最高位点亮时,下一次直接回到最低位;否则左移一位。这段代码逻辑上没有开头那段紧凑,但可读性明显更好。流水灯这种简单模块,可维护性比节省几行代码重要得多。
2.3 另一种思路:计数器直接译码
移位寄存器写法适合 LED 数量不太多的情况。如果 LED 数量达到 16 或 32 个,用led <= led << 1这种写法仍然没问题。但有一种场景更推荐计数器加译码:你需要任意控制某个灯亮,比如做成呼吸灯、追逐灯、来回扫的效果。
计数器加译码的思路是:把流水位置定义成一个计数值,然后只让对应位输出 1。比如 8 个 LED,一个 3 位计数器从 0 数到 7,然后用case或优先级逻辑把它转成独热码:
module led_decoder #( parameter WIDTH = 8 )( input wire [3:0] pos, output reg [WIDTH-1:0] led ); always @(*) begin case (pos) 4'd0: led = 8'b0000_0001; 4'd1: led = 8'b0000_0010; // 中间省略 4'd7: led = 8'b1000_0000; default: led = 8'b0000_0000; endcase end endmodule这种写法的优点是位置和灯的关系完全可控,缺点是需要多写一个译码模块。对于 8 个灯以内的流水灯,两种方式差别不大。但如果你打算把代码改成“向两边扩散”或“先左后右再折返”,那计数器版本优势就出来了——你只需要改译码逻辑,不用动时序部分。
3. Quartus II 下创建流水灯工程的完整流程
3.1 新建工程和添加文件
打开 Quartus II,从File -> New Project Wizard进入新建工程向导。有几个地方容易踩坑,逐一说明。
第一,工程路径中不能出现中文和空格。很多人把工程直接放在桌面上的“新建文件夹”里,编译时报路径错误,其实就是这个原因。建议目录结构按项目名单独建一个文件夹,比如D:/fpga_proj/led_flow。
第二,Device 选择要和你的开发板型号一致。选错芯片型号后续编译可能通过,但引脚约束时找不到对应的引脚名。比如 Cyclone IV 系列,EP4CE6F17C8 和 EP4CE10F17C8 引脚排列相同,但资源量不同,如果型号选小了,综合面积大的设计时会报资源不足。
第三,在 Add Files 页面可以直接把之前写好的.v文件添加进来,也可以先跳过,等文件写好后再通过Project -> Add/Remove Files in Project补充。建议先用文本编辑器写好 Verilog 文件再添加,而不是在 Quartus 里新建文件——Quartus 自带的编辑器没有代码补全,写起来效率低。
工程建好后,把上面两段代码分别保存为clk_div.v和led_shift.v,添加进工程。顶层文件这样写:
module led_top ( input wire clk, input wire rst_n, output wire [7:0] led ); wire en_1hz; clk_div #( .DIV_CNT(25_000_000) ) u_clk_div ( .clk(clk), .rst_n(rst_n), .clk_1hz(en_1hz) ); led_shift #( .WIDTH(8) ) u_led_shift ( .clk(clk), .rst_n(rst_n), .en(en_1hz), .led(led) ); endmodule顶层模块把分频器的输出当作 led_shift 的en输入,而不是当时钟用。这个信号每个周期只高电平一个 clk 周期,相当于使能脉冲。这样做的好处是 led_shift 内部仍然用主时钟clk驱动,时序上不会有内部生成时钟带来的额外不确定性。
3.2 引脚分配前的准备
编译前先做引脚分配。在 Quartus II 里打开Assignments -> Pin Planner,左侧列出的是你在顶层模块声明的所有端口,右侧是芯片的物理引脚。不同厂商开发板的 LED 引脚差异很大,必须对照原理图来填。这里列出引脚分配的一些基本原则:
先找到时钟引脚。绝大多数开发板把 50MHz 晶振接到 FPGA 的专用时钟引脚上,如 Cyclone IV 系列常见的是 PIN_E1 或 PIN_M9,具体看板子。复位按键有的接高电平有效,有的接低电平有效,要特别看一下你的模块里rst_n是低有效,如果板子是按键按下为高,那按键没按时是低电平,你的模块会一直处于复位态。
引脚分配完成后保存.qsf文件,Quartus II 会自动写入引脚约束。如果你想手动编辑.qsf,也可以在工程目录下直接改。下面是典型的约束内容:
set_location_assignment PIN_E1 -to clk set_location_assignment PIN_M1 -to rst_n set_location_assignment PIN_A1 -to led[0] set_location_assignment PIN_B1 -to led[1]这些语句的意思分别把clk、rst_n、led[0]和led[1]绑定到芯片的实际引脚上。手动编辑.qsf时要注意:分配引脚之后必须重新编译,否则新约束不会生效。
3.3 编译与下载
编译直接在Processing -> Start Compilation点一下,或者用快捷键Ctrl+L。编译过程分四个阶段:
- Analysis & Synthesis:把 Verilog 代码映射成基本的逻辑单元(LE / ALM),检查语法错误和模块连接是否有未定义端口。
- Fitter(布局布线):把逻辑单元放到芯片的具体位置,并连接布线资源。这一步如果时序约束不合理,会报时序违规。
- Assembler:生成最终配置文件
.sof。 - EDA Netlist Writer:生成给仿真工具使用的网表文件,这一步只有你配置了第三方工具时才执行。
下载程序用Tools -> Programmer,或者双击编译报告里Assembler -> Device Programming File Setup下方的.sof文件,打开 Programmer 后先点Hardware Setup选择 USB-Blaster,然后Start。烧写完如果 LED 没有按预期流水,先把复位键按几次确认复位极性是否正确。
一个需要注意的点是:.sof文件下载到芯片后,断电就丢了。要生成掉电不丢失的.jic文件,需要通过File -> Convert Programming Files,把.sof转换成 JTAG 间接配置文件再烧到配置芯片里。这部分对于只是拿流水灯练手的初学者来说可以先跳过,但如果在做实际项目,这一关早晚要过。
4. 仿真验证与常见报错排查
4.1 用 Modelsim 跑一遍流水灯波形
Quartus II 通常自带 Modelsim-Altera 版本,或者在 Tools 菜单里可以调起。要跑仿真,先得保证工程里设置了仿真工具。在Assignments -> Settings -> EDA Tool Settings -> Simulation里,把仿真工具选为 Modelsim-Altera,语言选 Verilog HDL,然后写一个测试文件。
测试文件是仿真中必须自己写的激励源。和真实硬件不同,仿真里时钟和复位都需要你手动生成,下面是一个可直接用的 testbench:
`timescale 1ns / 1ps module led_top_tb; reg clk; reg rst_n; wire [7:0] led; // 时钟周期 20ns 对应 50MHz always #10 clk = ~clk; initial begin clk = 0; rst_n = 0; // 仿真时间 2 微秒以后释放复位 #2000 rst_n = 1; // 观察足够长时间 #500000 $finish; end led_top u_led_top ( .clk(clk), .rst_n(rst_n), .led(led) ); endmodule把这个文件也加入工程,然后在 Quartus 里点击Tools -> Run Simulation Tool -> RTL Simulation,Modelsim 会自动启动并加载设计。如果一切正常,你能在波形窗口看到led每隔一段固定时间变化一次。
仿真是排错最快的途径。你在板子上看不到 LED 流水,可能是硬件问题也可能是逻辑问题,但通过仿真可以直接观察en_1hz有没有翻转、led有没有移位,从而快速定位到哪个模块出了问题。我一般先仿真分频器的cnt是否按预期计数,再看en_1hz波形是否整齐,最后看led移位。这三层波形逐层确认,基本没有查不出来的逻辑问题。
4.2 仿真 license 报错的三种解法
标题热词里出现过一段报错:17.1 error: failure to obtain a verilog simulation license,这是 Quartus II 17.1 版本在首次调用 Modelsim 时非常典型的错误。原因基本是环境变量没有指向 license 文件,或者 Modelsim 版本不是一个可独立运行的完整版。
遇到这个报错,常见处理顺序是:先检查系统环境变量LM_LICENSE_FILE是否设置到了合法的 license 路径;如果是破解版或学校机房版本,再确认 Modelsim 是从 Quartus 内部启动的,而不是单独打开外部的 Modelsim;最后看 Quartus 的 EDA 仿真工具设置里有没有选错版本位数(32bit / 64bit 必须和你安装的 Modelsim 一致)。
如果你不依赖 Modelsim,也可以去用 Vivado 系或开源工具 Icarus Verilog做仿真——它们的仿真流程和 Modelsim 大同小异,都是编译源文件、跑 testbench、看波形。关键是逻辑设计方法没变化,工具只是一个载体。
4.3 波形出来了但流水方向不对?检查位序
仿真波形中如果发现 LED 是从高位往低位流动,而板子上的灯是反向的,不用改逻辑,先检查引脚约束。看板子原理图里 LED 编号和 FPGA 引脚的关系,比如 LED1 可能对应led[7]而不是led[0]。这种情况下改引脚映射,比反过来写代码更符合硬件设计习惯——代码里保证位序有明确语义,硬件上的对应关系交给约束文件处理。
如果你的板子 LED 是低电平点亮,也就是引脚输出 0 时灯亮,那模块里的初始值和移位方向也需要反转,否则会看到只有一个灯灭,其他灯全亮。最直观的办法是在顶层模块里把输出取反:
assign led_out = ~led;这种方式比修改底层led_shift模块好得多,因为底层模块保持独立的逻辑语义(输出 1 表示点亮),硬件适配问题在上层解决。
5. 三种流水效果的 Verilog 写法对比
| 实现方式 | 核心思路 | 代码量 | 扩展难度 | 适用场景 |
|---|---|---|---|---|
| 移位寄存器 | led <= led << 1 | 最少 | 只能单向和回绕 | 入门,简单示范 |
| 计数器+译码 | 计数值映射独热码 | 中等 | 任意自定义花样 | 复杂花式流水 |
| 状态机 | 每个状态定义一组 LED 输出 | 较多 | 最容易扩展协议 | 学习状态机,同时控制多个外设 |
如果你要做的流水灯只是“点缀”功能,它在整个系统里占用的逻辑不会超过 1%。这时用最简单的方式即可。但如果你打算用流水灯做状态指示——比如系统启动、自检、运行中、报错——那状态机方式更合适。每种状态对应一组 LED 亮灭模式,状态切换由外部信号触发,这其实就是嵌入式开发里常见的“状态指示器”设计。
5.1 用状态机写一个可扩展的流水控制
状态机流水灯的核心是把不同段的亮灭用localparam定义抽象状态,而不是直接操作比特位。下面是一个四状态的简化例子:
localparam IDLE = 2'd0; localparam RUN = 2'd1; localparam ERR = 2'd2; reg [1:0] state; reg [1:0] next_state; always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end always @(*) begin case (state) IDLE: next_state = start ? RUN : IDLE; RUN: next_state = error ? ERR : RUN; ERR: next_state = start ? RUN : ERR; default: next_state = IDLE; endcase end always @(*) begin case (state) IDLE: led = 8'b0000_0001; RUN: led = shift_pattern; ERR: led = 8'b1010_1010; default: led = 8'b0000_0000; endcase end这里不做完整的状态机代码,重点是设计思路:组合逻辑里写状态跳转条件,时序逻辑里寄存状态,输出逻辑里状态对应的 LED 模式。拆成三个always是三段式状态机的标准写法,对初学者来说可能比一段式复杂,但对后续工程维护非常有帮助。状态机和移位寄存器在综合后消耗的资源差不了几个 LE,真正拉开差距的是写代码的可扩展性。
5.2 控制流水速度:参数改哪里
需要改变流水速度时,很多人的第一反应是改DIV_CNT。这确实是最直接的方案,但有个不便:改参数必须重新编译整个工程,而 FPGA 编译一次往往要几分钟到十几分钟。如果只是想快速试几种速度,你有两个选择。
选择一:把DIV_CNT设成多个不同的值,然后用板上的拨码开关或按键选择。这种方法适合在演示时需要现场切换速度的场景。选择二:把分频模块改成可配置的——用输入信号在运行时动态改变计数值。但动态改计数值要注意一个细节:计数到一半时改变量,可能出现短脉冲或者计数跳过,具体表现是流水节奏突然快一下或慢一下。如果项目对速度切换有硬性时序要求,建议设计成“先停计数,再改参数,再恢复”的流程。
对于流水灯这个级别,我一般做法是直接在代码里用parameter,要换速度就改一个数。这也是为什么我在第 2 章特意把DIV_CNT写成参数而不是硬编码——工程习惯就是从这些小地方养成的。
本文还有配套的精品资源,点击获取