从零开始玩转FPGA:Quartus数字电路实验全栈实战手记
你有没有过这样的经历?
在实验室里连上开发板,烧录完程序后按下复位键——结果LED不亮、数码管乱闪、按键毫无反应。你盯着那根USB-Blaster线发呆:“我代码没错啊,仿真明明跑通了……”
别急,这几乎是每个初学FPGA的人都踩过的坑。
今天,我们就来一次真实还原式教学:不堆术语,不列大纲,就从一个电子工程学生的视角出发,带你完整走一遍基于Quartus Prime + Verilog HDL + ModelSim + Cyclone IV 开发板的数字电路实验全过程。从第一个module写起,到看见LED真正按你的节奏闪烁为止。
为什么是Quartus?它真适合入门吗?
市面上主流的FPGA开发工具有两家:Xilinx的Vivado 和 Intel(原Altera)的Quartus Prime。如果你是学生、用的是学校配发的开发板,十有八九拿到的是基于Cyclone系列的Altera板卡——那你的开发环境只能选Quartus。
但别小看它“老派”的界面和略显陈旧的操作流程。相比Vivado动辄20GB安装包、吃内存如喝水的特性,Quartus启动快、资源占用低,一台十年前的老笔记本也能流畅运行。这对教学场景太友好了。
更重要的是,它的设计流程极其清晰:
写代码 → 编译 → 仿真 → 引脚分配 → 下载 → 看现象
每一步都看得见、摸得着,特别适合建立对FPGA开发的系统性认知。
先搞清楚一件事:FPGA到底在做什么?
很多新手一开始就被“可编程逻辑”这个词吓住。其实你可以把FPGA想象成一块巨大的“乐高积木板”,上面有成千上万个可以自由连接的小单元(LUT、触发器、布线通道等)。我们写的Verilog代码,本质上是在告诉工具:“把这些小单元拼成一个计数器”、“把那几个搭成一个状态机”。
和单片机执行指令不同,FPGA是硬件并行工作的。比如你写了两个计数器模块,它们不是轮流运行,而是同时运行,就像两条独立的电路焊在一起。
这也是为什么我们必须学会仿真——因为一旦下板失败,靠肉眼根本看不出哪条“电路”出了问题。
第一步:搭个最简单的计数器练手
我们先不整复杂的,就做一个四位二进制加法计数器,驱动四个LED依次点亮。
核心代码就这么几行:
// counter_4bit.v module counter_4bit ( input clk, // 板载50MHz时钟 input rst_n, // 低电平复位 output reg [3:0] count ); always @(posedge clk or negedge rst_n) begin if (!rst_n) count <= 4'b0000; else count <= count + 1; end endmodule这段代码的关键点在于:
always @(posedge clk ...)表示这是一个同步时序逻辑,只在时钟上升沿动作;reg类型用于表示需要存储状态的信号(即会“记住”上次值);- 复位采用异步方式(
negedge rst_n),响应更快;
看起来很简单?但它已经包含了FPGA设计中最核心的思想:时钟驱动 + 状态保持 + 条件跳转。
第二步:别急着下载!先仿真验证逻辑正确性
这是我当年被导师骂醒的一课:“没仿真的设计就是裸奔。”
打开ModelSim(Quartus自带一键启动功能),新建一个Testbench来喂数据给这个计数器。
测试平台代码如下:
// tb_counter.v module tb_counter; reg clk, rst_n; wire [3:0] count; // 实例化被测模块 counter_4bit uut ( .clk(clk), .rst_n(rst_n), .count(count) ); // 生成50MHz时钟(周期20ns) initial begin clk = 0; forever #10 clk = ~clk; // 每10时间单位翻转一次 end // 施加复位信号 initial begin rst_n = 0; #20 rst_n = 1; // 20ns后释放复位 #200 $stop; // 运行200ns后停止仿真 end endmodule运行仿真后你会看到波形图:
count在复位期间为0000- 复位释放后,每个时钟上升沿自动递增
count[3:0]从0000 → 0001 → 0010 → ... → 1111 → 0000循环
✅ 逻辑正确!现在你可以放心编译了。
⚠️ 小贴士:如果跳变不是整齐对齐时钟边沿,说明用了阻塞性赋值
=而不是非阻塞<=,会导致综合出错或行为异常!
第三步:回到Quartus,完成编译与引脚锁定
打开Quartus,创建工程 → 添加.v文件 → 设置目标器件(比如EP4CE6E22C8)→ 开始全编译。
第一次编译可能要几分钟。完成后你会发现一个问题:
“我的
count[0]应该接哪个LED?”
这就涉及到一个关键步骤:引脚分配(Pin Planning)。
使用Pin Planner绑定物理I/O
假设你的开发板手册标明:
- LED0 接 PIN_R1
- LED1 接 PIN_N2
- LED2 接 PIN_N1
- LED3 接 PIN_H2
而你在代码中定义的是output [3:0] count,那么你需要将:
| 信号名 | 对应引脚 |
|---|---|
| count[0] | R1 |
| count[1] | N2 |
| count[2] | N1 |
| count[3] | H2 |
在Quartus中打开Assignments > Pin Planner,手动填入即可。
🔥 坑点提醒:忘记锁引脚是最常见的“下载成功但无现象”原因!
此外,还要确保:
-clk接到了板载晶振对应的引脚(通常是PIN_E1)
-rst_n接到了KEY0按键引脚(注意是低电平有效)
第四步:下载到FPGA,看LED亮起来!
连接USB-Blaster线,插上开发板电源(顺序不能错:先供电,再连线)。
点击 Quartus 菜单栏的Tools > Programmer,加载生成的.sof文件。
正常情况下,你会看到:
- USB-Blaster 被识别为 “USB-Blaster [USB-0]”
- 目标器件显示为你设置的型号(如 EP4CE6)
点击Start,几秒钟后提示“Configuration Succeeded”。
此时观察开发板上的四个LED:
💡 它们应该正以极快速度循环点亮(因为50MHz时钟下,计数器每秒增加五千万次!)
但我们想看到的是“肉眼可见”的变化怎么办?
第五步:加入分频器,让节奏慢下来
直接拿50MHz驱动LED显然不行。我们需要一个分频模块,把高频时钟降为1Hz左右。
分频器实现原理:
使用计数器累计时钟周期,达到一半周期时翻转输出。例如:
// clk_divider.v module clk_divider ( input clk_in, // 50MHz输入 input rst_n, output reg clk_out // 1Hz输出 ); parameter COUNT_MAX = 25_000_000 - 1; // 50M / 2 = 25M,半周期计数 reg [25:0] counter; always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin counter <= 26'd0; clk_out <= 1'b0; end else if (counter == COUNT_MAX) begin counter <= 26'd0; clk_out <= ~clk_out; // 翻转输出,实现二分频效果 end else begin counter <= counter + 1; end end endmodule然后修改顶层模块,用clk_out作为计数器的时钟源:
// top_module.v wire slow_clk; clk_divider u_div ( .clk_in(clk), .rst_n(rst_n), .clk_out(slow_clk) ); counter_4bit u_cnt ( .clk(slow_clk), // 改用慢速时钟 .rst_n(rst_n), .count(count) );重新编译、下载,这次你会看到:
🟢 四个LED每隔一秒依次递增,像流水灯一样缓缓前进!
高阶技巧:按键去抖、数码管显示、状态机控制
当你掌握了上述流程,就可以挑战更复杂的项目了,比如做一个数字钟。
这类项目通常涉及以下关键技术点:
✅ 按键消抖处理(软件法)
机械按键按下时会产生毫秒级抖动,可能导致误触发多次。解决方案:
// key_debounce.v reg [19:0] cnt; wire key_raw; reg key_sync, key_press; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; key_sync <= 1; key_press <= 0; end else begin key_sync <= key_raw; // 同步化防亚稳态 if (key_sync != key_press) // 检测变化 cnt <= cnt + 1; else if (cnt >= 20_000_000) // 达到约20ms key_press <= key_sync; end end✅ 数码管动态扫描
共阴极六位数码管通过位选+段选轮询显示,扫描频率需高于1kHz以防闪烁。
// display_scan.v always @(posedge clk_1kHz) begin case(sel) 0: begin seg <= num[3:0]; bit_sel <= 6'b000001; sel <= 1; end 1: begin seg <= num[7:4]; bit_sel <= 6'b000010; sel <= 2; end ... endcase end✅ 状态机管理校时模式
typedef enum {NORMAL, SET_MIN, SET_HOUR} state_t; state_t current_state; always @(posedge clk) begin case(current_state) NORMAL: if (set_key) current_state <= SET_MIN; SET_MIN: if (exit_key) current_state <= SET_HOUR; else if (confirm_key) current_state <= NORMAL; SET_HOUR: ... endcase end这些模块组合起来,就能做出一个完整的数字钟系统。
实验常见问题排查清单
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 下载失败 | USB驱动未装、JTAG接触不良 | 重插线缆,安装USB-Blaster驱动 |
| LED全亮/全灭 | 引脚分配错误 | 检查Pin Planner是否锁定 |
| 计数器不动 | 时钟未接入或频率过高 | 检查晶振引脚,务必加分频 |
| 按键失灵 | 未消抖或电平极性搞反 | 加消抖模块,确认按键是低电平有效 |
| 数码管重影 | 扫描频率太低 | 提高至1~5kHz |
| 功能紊乱 | 未复位或异步信号跨时钟域 | 统一使用同步复位,避免毛刺传播 |
记住一句话:所有硬件问题,都可以回退到仿真阶段定位。
写在最后:FPGA教会我们的不只是技术
做FPGA实验的过程,其实是一场思维训练。
它逼你学会:
-严谨:少一个非阻塞赋值,整个时序就崩了;
-系统观:从代码到物理信号,每一个环节都不能断链;
-调试力:不会“热插拔”地试错,而是要有逻辑地推演、分段隔离问题。
而当你终于看到那个由你自己定义的逻辑,在硅片上真实运转的时候——那种成就感,远超任何一句“Hello World”。
所以,别怕犯错。
插上线,烧进去,哪怕第一次全军覆没也没关系。
只要你知道下一步该看仿真波形、该查引脚约束、该调分频参数……你就已经在成为一名真正的硬件工程师的路上了。
如果你正在准备课程实验、毕业设计,或者只是想亲手做一个属于自己的数字系统,欢迎留言交流。我们可以一起拆解你的项目难点,甚至帮你 review 一段代码。
毕竟,这条路我们都走过。✨