FPGA入门实战:Verilog实现按键控制数码管计数

发布时间:2026/6/6 13:44:22

FPGA入门实战:Verilog实现按键控制数码管计数 1. 项目概述从单片机到FPGA的思维跃迁作为一名从单片机MCU开发转向FPGA/Verilog的工程师我深知第一个成功“跑起来”的程序所带来的巨大鼓舞。这不仅仅是技术上的一个小胜利更是思维模式成功切换的里程碑。我手头有一块老旧的“火龙刀”FPGA学习板资料稀缺例程还是VHDL写的对当时的我来说无异于天书。但正是这种“从零开始”的环境逼着我沉下心来用三天时间调试成功了第一个Verilog程序用两个独立按键控制一个共阳数码管实现0-9的加减计数。这个项目麻雀虽小五脏俱全。它几乎涵盖了数字逻辑设计的几个核心概念时钟域处理、按键消抖、状态机虽未显式声明但逻辑上已是、组合逻辑译码。对于刚从C语言顺序执行思维跳转过来的朋友这个过程会非常“拧巴”因为你必须开始用并发的、硬件描述的视角去思考问题。我的目标是通过详细拆解这个简单的项目不仅让你能复现结果更能理解每一个代码片段背后的硬件思维帮你跨过那道从“软件编程”到“硬件描述”的认知鸿沟。无论你是学生还是希望拓展技能的嵌入式工程师这个案例都是一个绝佳的起点。2. 核心设计思路与硬件平台解析2.1 硬件平台老骥伏枥的“火龙刀”学习板我使用的这块“火龙刀”FPGA学习板型号已不可考但其核心架构具有典型性。主控是一颗Xilinx Spartan系列FPGA具体型号因板而异在ISE 10.1环境下能识别并下载即可。板上资源包括多个七段数码管本实验只用一个、若干独立按键、时钟晶振通常为50MHz、以及LED、拨码开关等外设。关键硬件接口定义clk 连接板载晶振提供全局时钟信号。这是所有同步逻辑的“心跳”。key1,key2 连接两个机械按键。按键默认上拉为高电平按下时接地变为低电平。这是最需要小心处理的部分。led_db[7:0] 8位总线驱动数码管的段选信号a, b, c, d, e, f, g, dp。共阳数码管段信号低电平有效点亮。led_cs 数码管位选信号。板上有多个数码管通过位选分时复用。本例只用一个故直接使能置高。注意 硬件平台的具体引脚分配Pin Assignment至关重要。你必须根据自己板子的原理图或官方约束文件.ucf文件将代码中的信号clk,key1,key2,led_db,led_cs映射到正确的物理引脚上。在ISE中这通常通过编辑UCF文件完成。引脚分配错误是导致“程序编译成功但板子没反应”的最常见原因。2.2 整体设计思路一个简单的状态机模型虽然代码中没有显式地写出state machine但其逻辑清晰地构成了一个状态机。我们可以这样理解状态State 当前数码管显示的数字即寄存器num4位宽可表示0-15。时钟Clock 全局时钟clk所有寄存器动作的同步源。输入Input 经过消抖和边沿检测后的按键有效脉冲key1_f和key2_f。状态转移逻辑Next State Logic 在always (posedge clk)块中根据key1_f和key2_f决定num是加1、减1还是保持。如果key1_f为真且num 9则下一状态为num 1。如果key2_f为真且num 0则下一状态为num - 1。否则下一状态保持为当前num。输出逻辑Output Logic 一个纯组合逻辑的always (num)块或等效的case语句将状态num翻译成对应的7段数码管段码led_dbr。这个隐式的状态机模型是理解绝大多数FPGA时序逻辑的基础。整个系统的数据流如下图所示概念上----------------- | 按键消抖与 | | 边沿检测模块 | ---------------- | key1_f, key2_f (单时钟周期脉冲) v ---------------- | | | 核心控制逻辑 | always (posedge clk) | (状态机) | 根据脉冲更新 num ---------------- | v (num) ---------------- | 七段译码器 | always (num) | (组合逻辑) | 将 num 转为段码 ---------------- | v [数码管显示]3. 代码逐行深度解析与实操要点下面我们结合原始代码深入每一个模块解释“为什么这么写”并指出关键细节和可优化空间。3.1 模块声明与接口定义module key(clk, key1, key2, led_cs, led_db); input clk, key1, key2; output led_cs; output [7:0] led_db;module 定义了一个名为key的硬件模块。你可以把它理解为一个芯片的黑盒子规定了管脚。端口列表 明确了盒子对外的连接信号。这里clk, key1, key2是输入led_cs, led_db是输出。注意led_db是8位宽的向量对应a-g和dp段。实操要点 良好的端口命名应见名知义。key1不如key_add或btn_inc明确。在项目初期养成好习惯后续维护和团队协作会轻松很多。3.2 寄存器与变量声明reg [7:0] led_dbr; reg [19:0] cnt; reg [3:0] num; reg key1_flag, key2_flag, key1_flag_r, key2_flag_r;reg类型 在Verilog中reg并不完全等同于硬件寄存器。在always块中被赋值的信号应声明为reg它可能被综合成寄存器触发器也可能被综合成连线组合逻辑。本例中的led_dbr,cnt,num,key1_flag等因为在always (posedge clk)块中被赋值所以最终会被实现为D触发器即真正的硬件寄存器。位宽选择cnt[19:0] 20位计数器。为什么是20位这关系到消抖延时。假设clk50MHz周期T20ns。计数到20hfffff即1,048,575需要的时间是1,048,575 * 20ns ≈ 21ms。这是一个典型的机械按键消抖延时时间10-20ms。这就是参数计算的来源。num[3:0] 4位寄存器足够存储0-9需要4位也为后续扩展如显示0-15留有余地。initial块的使用initial begin num4d0; end initial begin cnt20h00000; end initial begin key1_flag1b0; key2_flag1b0; key1_flag_r1b0; key2_flag_r1b0; end功能 在仿真开始时或理论上电瞬间为寄存器赋予初始值。对于FPGA上电后的初始状态是不确定的取决于配置位流initial块在综合时通常会被忽略。问题与替代方案 正如作者所说因为复位键失灵所以用initial做仿真初始化。但在真实的硬件设计中必须有一个全局复位信号如rst_n并在每个时钟驱动的always块中处理复位逻辑以确保系统从一个已知的状态开始运行。这是可靠设计的基本要求。正确做法示例always (posedge clk or negedge rst_n) begin if (!rst_n) begin num 4d0; cnt 20d0; key1_flag 1b0; // ... 其他寄存器复位 end else begin // 正常的业务逻辑 end end3.3 按键消抖与边沿检测最易出错的环节这是整个项目的核心难点也是从单片机思维转换的关键点。单片机中你可能会在while循环里延时delay_ms(20)再去读键值。但在FPGA中你必须用纯硬件的方式实现。原始代码分析采样与延时always (posedge clk) if(cnt20hfffff) begin key1_flag key1; key2_flag key2; endcnt是一个自由运行的计数器每20ms计满时采样一次按键key1和key2的当前电平存入key1_flag和key2_flag。这相当于一个20ms采样周期的低通滤波器滤除了持续时间小于20ms的抖动毛刺。潜在问题cnt的循环周期是20ms但判断条件cnt20hfffff只在一个时钟周期内为真。如果按键按下恰好发生在这个采样点之外则本次按键事件会被延迟近20ms才被捕获导致响应“迟钝”。更通用的做法是当cnt计满后用key_flag寄存器持续锁存稳定的键值。边沿检测always (posedge clk) begin key1_flag_r key1_flag; key2_flag_r key2_flag; end wire key1_f (~key1_flag) key1_flag_r; wire key2_f (~key2_flag) key2_flag_r;这是经典的边沿检测电路。key1_flag_r是key1_flag延迟一个时钟周期的值。key1_f (~key1_flag) key1_flag_r检测的是key1_flag的下降沿。因为key1是低电平有效按下为0所以key1_flag稳定后的值按下时为0松开时为1。key1_flag从1变0的瞬间key1_flag_r还是1key1_flag已是0因此key1_f产生一个时钟周期的高电平脉冲。重要 这意味着原始代码检测的是按键释放的边沿而不是按下的边沿。这与代码注释“按键key1一次数码管加一”的直觉可能不符。通常我们更希望检测按下事件。若要检测按下下降沿公式应为key1_f key1_flag_r (!key1_flag)。若要检测松开上升沿则为key1_f (!key1_flag_r) key1_flag。这里需要根据硬件共阳数码管常亮和需求仔细甄别。优化后的按键处理模块思路一个更健壮、响应更快的按键处理模块可以这样设计// 假设 key 低电平有效检测按下事件 reg [19:0] cnt_debounce; reg [1:0] key_sync; // 同步器消除亚稳态 reg key_stable; // 消抖后的稳定键值 reg key_stable_r; // 稳定键值打拍 wire key_pressed; // 按键按下脉冲 // 1. 同步器将异步的按键信号同步到clk时钟域 always (posedge clk) begin key_sync {key_sync[0], key1}; // 假设处理key1 end // 2. 消抖计数器 always (posedge clk) begin if (key_sync[1] key_stable) begin // 如果同步后信号与稳定值相同 cnt_debounce 20d0; // 计数器清零 end else begin // 如果出现变化可能是抖动或真实按键 cnt_debounce cnt_debounce 1b1; if (cnt_debounce 20d999_999) begin // 计数满20ms (50MHz * 0.02s 1,000,000) key_stable key_sync[1]; // 更新稳定键值 end end end // 3. 边沿检测检测按下即稳定值从1变0 always (posedge clk) begin key_stable_r key_stable; end assign key_pressed (!key_stable) key_stable_r; // 下降沿检测这个设计包含了同步器对抗亚稳态、消抖计数器、边沿检测是工业级设计的基础。3.4 核心控制逻辑与显示译码控制逻辑状态转移always (posedge clk) begin if(key1_f) begin if(num 9) num num 4d1; else num 9; end if(key2_f) begin if(num 0) num num - 4d1; else num 0; end end在时钟上升沿检查按键脉冲。注意key1_f和key2_f是组合逻辑产生的单周期脉冲。这是关键确保了每次有效的按键动作无论按下多久只触发一次加或减。加了边界检查num9和num0防止数字溢出或 underflow这是健壮性设计的基本体现。思考 如果同时按下两个键会怎样这段代码中两个if是并行的在同一个时钟周期内如果key1_f和key2_f同时为高几乎不可能因为消抖和边沿检测则num的最终值取决于Verilog的仿真语义最后赋值生效综合后可能产生不可预料的结果。更好的做法是使用if-else if结构明确优先级或者设计成同时按下时忽略或执行其他功能。显示译码组合逻辑parameter seg0 8hc0, seg1 8hf9, ... seg9 8h90; always (num) case (num) 4d0: led_dbr seg0; ... default: ; endcaseparameter定义了常量提高代码可读性和可维护性。这些8位十六进制数对应共阳数码管显示0-9时各段a-g, dp的电平。例如8hc0即二进制1100_0000对应段a-f亮g和dp灭显示数字“0”。always (num)是一个敏感列表为num的组合逻辑always块。只要num变化立即重新计算led_dbr的值。这里使用了阻塞赋值原始代码用了非阻塞在组合逻辑中虽然仿真可能没问题但不符合设计规范可能产生警告或错误。规范写法应使用阻塞赋值。规范写法示例always (*) begin // 使用 (*) 自动匹配敏感列表更安全 case (num) 4d0: led_dbr seg0; 4d1: led_dbr seg1; // ... default: led_dbr seg0; // 最好有default避免生成锁存器(Latch) endcase end生成锁存器警告 如果case语句没有覆盖所有num的可能值4‘b1010到4’b1111且没有default分支综合工具可能会推断出锁存器来保持led_dbr的值这是组合逻辑设计的大忌应避免。输出赋值assign led_db led_dbr; assign led_cs 1b1;将内部寄存器led_dbr的值连续驱动到输出端口led_db。led_cs直接赋值为1使能对应的数码管常亮。如果板子是多个数码管动态扫描这里就需要一个扫描计数器轮流控制led_cs了。4. 工程实现、仿真与上板调试全流程4.1 开发环境搭建与工程创建工具选择 原始项目使用Xilinx ISE 10.1。对于老款Spartan FPGAISE仍然是合适的选择。如果你是新手建议使用Vivado针对Xilinx 7系列及更新器件或Quartus Prime针对Intel/Altera FPGA它们界面更现代功能更强大。创建工程打开ISE选择File - New Project。输入项目名称和路径。选择对应的器件家族Family、具体型号Device、封装Package和速度等级Speed Grade。这些信息必须与你的“火龙刀”开发板完全一致通常写在板子或芯片上。添加已有的Verilog源文件key.v或新建一个。编写约束文件.ucf 这是连接逻辑设计和物理引脚的关键一步。在ISE中新建一个“Implementation Constraints File”。根据板卡原理图编写引脚位置约束。例如NET clk LOC P125 | IOSTANDARD LVCMOS33; # 假设时钟引脚是P125 NET key1 LOC P88 | IOSTANDARD LVCMOS33 | PULLUP; # 按键启用内部上拉 NET key2 LOC P89 | IOSTANDARD LVCMOS33 | PULLUP; NET led_db7 LOC P50 | IOSTANDARD LVCMOS33; # 段a NET led_db6 LOC P51 | IOSTANDARD LVCMOS33; # 段b ... # 分配所有段 NET led_cs LOC P70 | IOSTANDARD LVCMOS33; # 位选IOSTANDARD指定IO电平标准通常是3.3V LVCMOSPULLUP为按键引脚启用内部弱上拉电阻。4.2 功能仿真Simulation验证逻辑在烧录到板子前仿真是验证逻辑正确性的高效手段。我们需要编写一个测试平台Testbench。创建Testbench文件(tb_key.v)timescale 1ns / 1ps module tb_key(); reg clk; reg key1, key2; wire led_cs; wire [7:0] led_db; // 实例化被测试模块 key uut ( .clk(clk), .key1(key1), .key2(key2), .led_cs(led_cs), .led_db(led_db) ); // 生成50MHz时钟 initial clk 0; always #10 clk ~clk; // 周期20ns - 50MHz // 激励信号 initial begin // 初始化 key1 1b1; // 按键未按下假设高电平 key2 1b1; #1000; // 等待一段时间 // 模拟按下key1加 key1 1b0; // 按下 #30000000; // 按住30ms远大于消抖时间 key1 1b1; // 释放 #20000000; // 等待20ms // 模拟快速按下key2两次减 repeat(2) begin key2 1b0; #15000000; // 按住15ms key2 1b1; #15000000; // 间隔15ms end // 模拟按键抖动可选 key1 1b1; #1000000; key1 1b0; // 开始抖动 #100000; // 0.1ms抖动 key1 1b1; #50000; // 0.05ms抖动 key1 1b0; // 真实按下 #25000000; // 按住25ms key1 1b1; #30000000; $stop; // 结束仿真 end endmodule运行仿真 在ISE中将tb_key.v设置为仿真顶层然后运行行为仿真Behavioral Simulation。观察波形图中的num,led_db,key1_f等信号验证时钟clk是否正常。按键按下后是否经过约20ms消抖才产生key1_f脉冲。num是否在脉冲到来时正确加1或减1。led_db的输出是否随num变化且段码值正确。4.3 综合、实现与上板调试综合Synthesis 点击“Synthesize - XST”。这个过程将Verilog代码转换为门级网表。检查综合报告关注是否有警告如锁存器推断或错误。实现Implement Design 包含翻译Translate、映射Map、布局布线Place Route。这个过程将逻辑网表映射到具体的FPGA资源查找表LUT、触发器FF、布线资源等。生成编程文件Generate Programming File 生成.bit文件。上板调试连接FPGA开发板USB-Blaster/JTAG等。在ISE中打开iMPACT工具配置FPGA。将.bit文件下载到板子中。观察现象 按下key1数码管数字应加1按下key2数字应减1。数字应在0-9之间循环。如果没反应 这是最考验人的阶段。请按以下顺序排查检查电源和下载线 板子电源灯亮吗下载线连接可靠吗确认引脚分配 这是最高频的错误来源。逐一对.ucf文件中的引脚号和板子原理图。检查电平标准 数码管是共阳还是共阴段码有效电平是低还是高按键是低有效还是高有效这决定了你的代码逻辑如assign led_cs1‘b1是常亮还是常灭和约束文件中的PULLUP/PULLDOWN。简化测试 写一个最简单的LED闪烁程序分频器先验证时钟和基本IO是否正常。使用内部逻辑分析仪 如果FPGA支持如Xilinx的ChipScope Intel的SignalTap可以将key1_f,num等内部信号引到虚拟IO上观察这是最强大的调试手段。5. 项目优化、扩展与常见问题深度排查5.1 从“能跑”到“跑得好”代码优化建议原始代码是一个伟大的起点但从工程角度可以优化以提高可读性、可维护性和可靠性。添加复位信号 如前所述这是必须的。增加一个rst_n输入端口并在所有时序always块中加入复位处理逻辑。模块化设计 将按键消抖检测、数码管译码等功能分离成独立的模块debounce.v,seg7_decoder.v。这样主模块key_top.v只需要实例化并连接它们结构更清晰也便于复用。// debounce.v module debounce #(parameter CNT_WIDTH20) ( input clk, rst_n, key_in, output reg key_pressed ); // ... 消抖与边沿检测逻辑 endmodule // seg7_decoder.v module seg7_decoder ( input [3:0] num, output reg [7:0] seg ); // ... 译码逻辑 endmodule // key_top.v module key_top( input clk, rst_n, key1_raw, key2_raw, output led_cs, output [7:0] led_db ); wire key1_pulse, key2_pulse; reg [3:0] num; debounce u_debounce1 (.clk(clk), .rst_n(rst_n), .key_in(key1_raw), .key_pressed(key1_pulse)); debounce u_debounce2 (.clk(clk), .rst_n(rst_n), .key_in(key2_raw), .key_pressed(key2_pulse)); always (posedge clk or negedge rst_n) begin if(!rst_n) num 4d0; else begin if(key1_pulse num 9) num num 1b1; else if(key2_pulse num 0) num num - 1b1; end end seg7_decoder u_decoder (.num(num), .seg(led_db)); assign led_cs 1b1; endmodule参数化设计 将消抖时间常数、时钟频率等定义为模块参数提高通用性。状态机显式化 对于更复杂的逻辑显式定义状态变量和状态转移图代码意图更明确不易出错。5.2 功能扩展从0-9到0-99甚至更多作者提到了想扩展到0-100。这需要驱动多个数码管进行动态扫描。数据拆分 需要一个二进制到BCD二十进制的转换模块将8位的二进制数0-100对应0-1100100转换成个位、十位、百位的BCD码。动态扫描增加一个扫描计数器如scan_cnt以较高频率如1kHz循环。根据scan_cnt的值轮流选通不同的数码管位选信号led_cs[3:0]。同时根据当前选通的数码管位置从个位、十位、百位的BCD码中选择一个送入七段译码器输出到段选信号led_db。利用人眼视觉暂留看到的是稳定的多位数字。5.3 常见问题排查速查表现象可能原因排查步骤编译无错误但下载后板子无任何反应1. 引脚分配错误。2. 时钟未连接或约束错误。3. 程序未正确启动缺复位。4. 硬件损坏。1. 仔细核对UCF文件与原理图。2. 用示波器测量时钟引脚是否有波形。3. 编写一个最简单的LED闪烁程序测试最小系统。4. 检查电源电压。按键按下无反应或反应极其迟钝1. 按键消抖逻辑错误如采样周期太长。2. 边沿检测逻辑反了检测的是释放而非按下。3. 按键引脚约束错误如上拉未启用。4.key_f脉冲未被正确捕获。1. 仿真验证消抖和边沿检测波形。2. 检查key_f生成逻辑确认是检测按下还是释放。3. 确认约束文件中按键引脚是否有PULLUP。4. 在代码中将key_f信号引到一个LED上观察按键时LED是否闪烁。数码管显示乱码或某些段不亮1. 段码表seg0~seg9数据错误。2. 数码管共阳/共阴弄反。3. 段选或位选信号驱动能力不足罕见。4. 引脚分配中段信号顺序接错。1. 对照数码管数据手册核对段码值。2. 确认板子数码管类型。共阳数码管段信号低电平亮共阴则高电平亮。可能需要取反段码。3. 尝试只点亮一个段如只给led_db8‘b1111_1110看是否对应段亮起。同时按下两个键行为异常控制逻辑中if语句并行未处理冲突。修改控制逻辑使用if-else if结构明确按键优先级或定义同时按下的特殊行为。Modelsim/ISim仿真正常上板不正常1. 仿真未覆盖真实情况如异步信号亚稳态。2. 综合/实现后的时序不满足建立/保持时间违规。1. 在Testbench中加入异步信号和时钟的相位随机性。2. 查看布局布线后的时序报告Timing Report看是否有红色警告。确保时钟约束正确。5.4 思维转换的终极心得调试这个简单项目的过程也是我强迫自己从MCU思维向FPGA思维转换的过程。最大的感悟有以下几点并发 vs 顺序 C语言是顺序执行的而Verilog描述的是并发的硬件结构。always块之间是并行的always块内部的非阻塞赋值也是并发的在时钟沿同时更新。理解“并行”是第一步。时间概念 软件中的delay()是“等待”硬件中的计数器是“在每一个时钟沿自动加1”。时间在硬件描述中是通过时钟周期来度量的。硬件思维 写代码时要时刻想象正在“画”电路图。这段代码会综合成几个触发器几个查找表它们之间的连线是什么这种具象化的思考方式能极大避免写出无法综合或性能低下的代码。调试方法 告别“printf大法”。拥抱仿真波形图看信号随时间变化和硬件逻辑分析仪看真实芯片内部的信号。这是硬件调试的利器。这个按键控制数码管的项目就像学习编程时的“Hello, World!”。它简单但完整地串联了FPGA开发的核心流程和关键概念。当你成功点亮数码管并看到它随着你的按键而跳动时那种对硬件直接操控的实感是软件编程无法给予的。以此为起点你可以逐步尝试更复杂的项目PWM调光、串口通信、VGA显示、甚至简单的CPU内核。每一步都记得先想清楚硬件如何实现再用Verilog把它描述出来。这条路充满挑战但也乐趣无穷。

相关新闻