简介:南京邮电大学课程设计中的脉冲按键拨号电路FPGA设计工程包,主要面向FPGA初学者与数字逻辑课程设计学生,解决按键脉冲串行码生成与动态显示需求。工程实现0~9按键对应脉冲序列输出,并将所按键值实时显示,支持基本技术指标与扩展技术指标通过一个按键灵活切换,适合学习状态机设计、分频器、按键消抖与数码管扫描等典型电路。包内共215个文件,约872KB,包含ISE工程入口文件(.xise)、原理图(.sch)、管脚约束(.ucf)、综合与实现报告(.xrpt、.twr)、仿真数据及可烧录的比特流文件(.bit)等,完整覆盖从源码编辑到上板验证的FPGA开发流程。已有6331人学习下载,工程经测试验收通过,可直接打开工程复现整个设计;文件中还保留布局布线后报告与中间过程文件,便于复盘时序收敛和资源占用情况,对期末课设或竞赛准备都有参考价值。
1. 从脉冲按键到脉冲输出:这门FPGA课设到底在验证什么
脉冲拨号里最反直觉的一个点是:按“0”拨号,输出的不是 0 个脉冲,而是 10 个脉冲。很多第一次用 FPGA 做课设的同学在这里翻车——仿真里数字 0 的波形怎么都拉不出高电平,不是电路坏了,而是把十进制 0 直接当成了脉冲个数 0。脉冲按键拨号电路,就是 FPGA 把按键输入转换成电话线路上通断时序的完整闭环。
这份资源是一套某邮电类高校数字逻辑课设级别的 FPGA 工程,覆盖了按键消抖、拨号状态机、脉冲计数、数码管显示锁存四个环节。它解决的核心问题是:怎么把“按下一个键”变成“一串符合电话规范的脉冲”,并且在板子上真实跑通。适合正在做 FPGA 课设、想从仿真走向上板、被状态机绕晕的同学。
2. 顶层架构与模块划分:按键、状态机、计数器、显示四件套
2.1 为什么选脉冲拨号而不是 DTMF:一次课设的选型逻辑
老式旋转电话拨号时,拨号盘每转到一个数字,就产生对应数量的“断—续”脉冲,交换局靠数脉冲个数识别号码。1 拨 1 个脉冲,2 拨 2 个脉冲,0 拨 10 个脉冲。脉冲频率一般按 10PPS 来设计,也就是每 100ms 输出一个脉冲周期,高电平(续)约 33ms,低电平(断)约 67ms。
很多同学会问:现在主流不都是 DTMF 双音多频吗,用 FPGA 做 DTMF 不是更“现代”?但课设选型要看训练点。DTMF 的难点在 DDS 正弦波生成和频率叠加,工作量偏信号处理方向;脉冲拨号则是典型的时序控制项目,状态机与计数器是绝对主角,代码量可控,仿真和上板都好验证。用示波器看脉冲个数,一眼就知道对错,评审也能直观理解。
从资源包里的工程结构来看,它的连线清晰地把训练点拆成了四个模块:按键消抖负责把人手按下时的机械抖动过滤掉,拨号状态机负责安排“什么时候开始拨、拨几个、什么时候停”,脉冲计数器负责产生 100ms 周期的通断波形,显示锁存负责在拨号结束后把号码稳定地送到数码管。每个模块都能独立仿真,这是这套课设设计最聪明的部分。
2.2 顶层模块划分与信号约定
顶层模块的划分直接决定你后面排错要花多少时间。我拆这份工程时,信号命名采用了下划线加模块前缀的方式,比如 key_stable 表示消抖后的稳定电平,dial_start 表示状态机收到的拨号启动信号,dial_done 表示拨号完成握手,层次非常清楚。
| 模块名 | 关键输入 | 关键输出 | 职责 |
|---|---|---|---|
| debounce | clk_50m, rst_n, key_in | key_stable | 20ms 稳定消抖,输出干净按键电平 |
| dial_fsm | clk_50m, rst_n, key_stable | dial_start, number_bcd, pulse_out, dial_done | 状态机调度拨号全过程 |
| display_latch | clk_50m, rst_n, number_bcd, dial_done | seg_data, seg_sel | 拨号结束才锁存号码到数码管 |
顶层例化代码是这样的,信号流向一眼能看穿:
module pulse_dial_top ( input wire clk_50m, // 板载 50MHz 时钟 input wire rst_n, // 低电平复位 input wire [3:0] key_in, // 4 位拨号按键,低电平有效 output wire pulse_out, // 脉冲输出,可直接接示波器 output wire [7:0] seg_data, // 数码管段码 output wire [3:0] seg_sel // 数码管位选 ); wire stable0, stable1, stable2, stable3; wire [3:0] key_stable; wire dial_start; wire [3:0] number_bcd; wire dial_done; // 四路独立消抖 debounce u_d0 (.clk(clk_50m), .rst_n(rst_n), .key_in(key_in[0]), .key_stable(stable0)); debounce u_d1 (.clk(clk_50m), .rst_n(rst_n), .key_in(key_in[1]), .key_stable(stable1)); debounce u_d2 (.clk(clk_50m), .rst_n(rst_n), .key_in(key_in[2]), .key_stable(stable2)); debounce u_d3 (.clk(clk_50m), .rst_n(rst_n), .key_in(key_in[3]), .key_stable(stable3)); assign key_stable = {stable3, stable2, stable1, stable0}; dial_fsm u_fsm ( .clk (clk_50m), .rst_n (rst_n), .key_stable (key_stable), .dial_start (dial_start), .number_bcd (number_bcd), .pulse_out (pulse_out), .dial_done (dial_done) ); display_latch u_display ( .clk (clk_50m), .rst_n (rst_n), .number_bcd (number_bcd), .dial_done (dial_done), .seg_data (seg_data), .seg_sel (seg_sel) ); endmodule注意顶层里我没有直接把 key_in 接进状态机,而是先经过 debounce。这样做的目的是把“按键是否稳定”和“按键值是什么”两件事解耦,后面排查时不需要在状态机里同时处理毛刺和业务逻辑。number_bcd 是拨号数字的 BCD 编码,dial_done 在状态机全部输出完成后拉高一个周期,作为显示锁存的使能。
2.3 时钟分频与全局复位:先把基准时序做对
做脉冲拨号,先定基准时序再写业务逻辑。按 10PPS 标准,一个脉冲周期是 100ms,高电平占 33ms。50MHz 时钟下一个周期是 20ns,那么:
- 20ms 消抖需要 1_000_000 个时钟周期
- 33ms 高电平需要 1_650_000 个时钟周期
- 67ms 低电平需要 3_350_000 个时钟周期
- 100ms 完整周期需要 5_000_000 个时钟周期
参数建议全部用localparam定义,不要魔法数字满天飞。这里我一般用计数器产生分频使能脉冲,而不是直接分频出慢时钟。原因是分频时钟在 FPGA 内部作为异步时钟使用,容易产生跨时钟域的时序隐患,而计数器使能脉冲只在原时钟域里工作,综合时序干净很多。这个习惯在课设里能帮你避开至少一半的时序玄学问题。
localparam CLK_FREQ = 50_000_000; localparam DEBOUNCE_CNT = 1_000_000; // 20ms @50MHz localparam PULSE_HIGH = 1_650_000; // 33ms 续 localparam PULSE_LOW = 3_350_000; // 67ms 断全局复位建议统一用异步复位、同步释放的方式。单纯异步复位在按键抖动严重的板卡上,可能出现复位释放瞬间逻辑没准备好的偶发问题。复位信号 rst_n 低有效,进入每个模块后建议先做两级同步再参与逻辑判断。
3. 核心逻辑落地:消抖、脉冲生成与计数的 Verilog 实现
3.1 按键消抖模块:20ms 延时加边沿检测
机械按键按下时,簧片接触会产生 5ms 到 15ms 的抖动,直接接进状态机会导致一次按下被识别成多次。消抖思路很简单:检测到按键电平变化后开始计时,如果 20ms 后电平仍然保持不变,才认为按键稳定。
module debounce #( parameter CNT_MAX = 1_000_000 // 20ms @ 50MHz )( input wire clk, // 50MHz input wire rst_n, // 低电平复位 input wire key_in, // 按键输入,低电平有效 output reg key_stable // 稳定后的按键电平,低表示按下 ); reg [19:0] cnt; reg key_sync0, key_sync1; reg key_press_flag; wire key_edge; assign key_edge = (!key_sync0) && key_sync1; // 检测到下降沿 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync0 <= 1'b1; key_sync1 <= 1'b1; cnt <= 0; key_stable <= 1'b1; key_press_flag <= 1'b0; end else begin key_sync0 <= key_in; key_sync1 <= key_sync0; if (key_edge) begin cnt <= 0; end else if (cnt < CNT_MAX) begin cnt <= cnt + 1; end if (cnt == CNT_MAX - 1) begin key_stable <= key_sync0; // 20ms 后电平仍稳定,锁存 key_press_flag <= 1'b1; // 给状态机一个单周期脉冲 end end end endmodule这段代码里两个关键点。一是边沿检测用key_sync0和key_sync1两级打拍,避免亚稳态;二是cnt计到CNT_MAX - 1时锁存电平,而不是用cnt等于某个中间值。CNT_MAX - 1保证了完整计满 20ms,如果写成== CNT_MAX会导致多一个周期的偏差,仿真里看不出来,上板后偶发不稳定。key_press_flag我习惯保留给状态机做启动信号,实际工程里如果不需要可以直接删掉。
3.2 拨号状态机:两段式 FSM 与十进制的 10 个脉冲
状态机是整个设计的核心。我见过很多初学者把拨号过程写在计数器里,逻辑堆成一坨,最后自己都解释不清。标准做法是用两段式状态机:第一段做状态转移,第二段做组合逻辑输出,把“状态”和“动作”分开。
localparam IDLE = 2'd0; localparam PULSE_GEN = 2'd1; localparam WAIT_REL = 2'd2; reg [1:0] state, next_state; reg [3:0] pulse_total; // 本数字需要输出的脉冲总数 reg [3:0] pulse_done_cnt; // 已完成的脉冲数 reg [22:0] pulse_timer; // 脉冲周期计时 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; end else begin state <= next_state; end end always @(*) begin next_state = state; case (state) IDLE: begin if (key_press_flag) next_state = PULSE_GEN; end PULSE_GEN: begin if (pulse_done_cnt == pulse_total) next_state = WAIT_REL; end WAIT_REL: begin if (key_stable == 1'b1) next_state = IDLE; // 等待按键松开 end default: next_state = IDLE; endcase end这里最核心的一行是确定pulse_total的写法:
always @(*) begin pulse_total = number_bcd; if (number_bcd == 0) pulse_total = 4'd10; // 0 拨 10 个脉冲 endnumber_bcd是拨号数字的 BCD 编码,范围 0 到 9。0 的 BCD 编码是0000,如果直接把它当作脉冲总数,状态机会在pulse_done_cnt == 0的瞬间结束拨号,结果就是按 0 没波形。这个坑在课设里出现频率极高,原因就是“十进制 0”和“脉冲个数 0”被混为一谈。加一行条件赋值,问题解决。
状态机回到 IDLE 前必须等待按键松开。如果不加这个条件,按住的按键会在状态机回 IDLE 的瞬间再次触发key_press_flag,导致同一个键被拨两遍。这是“一次按键拨出两次号码”的经典原因。
3.3 脉冲生成与显示锁存:拨完这一位再亮这一位
脉冲生成逻辑放在 PULSE_GEN 状态内部。每个脉冲周期 100ms,高电平 33ms,低电平 67ms。用pulse_timer做周期计时,输出pulse_out在高电平时段拉高:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin pulse_timer <= 0; pulse_out <= 1'b0; pulse_done_cnt <= 0; end else if (state == PULSE_GEN) begin if (pulse_timer == PULSE_HIGH + PULSE_LOW - 1) begin pulse_timer <= 0; pulse_out <= 1'b0; pulse_done_cnt <= pulse_done_cnt + 1'b1; end else begin pulse_timer <= pulse_timer + 1'b1; pulse_out <= (pulse_timer < PULSE_HIGH) ? 1'b1 : 1'b0; end end else begin pulse_timer <= 0; pulse_out <= 1'b0; end endpulse_timer在一个周期结束时清零,同时pulse_done_cnt加一。pulse_out的表达式(pulse_timer < PULSE_HIGH)让前 33ms 输出高、后 67ms 输出低。注意比较用的是<而不是<=,这样在pulse_timer == PULSE_HIGH那一拍就切到低电平,符合 33ms 高电平的边界。状态机的pulse_done_cnt == pulse_total判断,会在最后一个脉冲的低电平结束后退出。
显示锁存模块必须在dial_done信号到来时才锁存number_bcd。dial_done在状态机进入 WAIT_REL 且脉冲全部输出的那一拍拉高,这时数据才是可信的。如果在按键刚按下时就锁存,波形还没拨完,数码管就已经变成下一个数字,表现就是显示乱跳。
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin display_bcd <= 4'b0; end else if (dial_done) begin display_bcd <= number_bcd; end end看到没,锁存条件只有dial_done一个。很多工程会顺手写成if (key_press_flag),那就要出问题。拨号是一个持续上百毫秒的过程,必须等过程完整结束,才允许结果被看见。
4. 上板避坑与常见问题排查:仿真过了不等于能拨号
仿真通过只是第一步,上板才是真正容易翻车的地方。以下五条是我拆这份课设和实际调试中遇到的高频故障,每一条都按“现象 → 原因 → 解决”给出,你可以直接对照排查。
4.1 现象一:拨“5”只吐两个脉冲就停
现象:示波器接pulse_out,按下数字 5,正常应该看到 5 个脉冲,实际只出现两三个,然后波形一直保持高电平不动。
原因:状态机的脉冲周期计数器和消抖模块共用了同一个时钟使能信号。比如我在调试时发现,某同事的工程把消抖的 20ms 使能直接接到了脉冲计数器的使能端,导致pulse_timer每 20ms 才动一次,100ms 的周期被拉长成 500ms,状态机在超时时间内收不到完整的pulse_done_cnt递增,于是提前退出。
解决:把脉冲周期计时器和消抖计数器彻底分离,让pulse_timer独立地在每个时钟上升沿累加,只在PULSE_GEN状态下使能。用一个 always 块只干一件事,不要图省事共用条件。
4.2 现象二:按下“0”完全没波形
现象:拨 1 到 9 都正常,唯独拨 0 时pulse_out一帧波形都没有。
原因:pulse_total = number_bcd,0 的 BCD 编码是0000,等于说这一位要拨 0 个脉冲。状态机进入 PULSE_GEN 后立刻满足pulse_done_cnt == pulse_total,瞬间跳走。
解决:补上三目运算符或条件赋值:pulse_total = (number_bcd == 0) ? 4'd10 : number_bcd;这是脉冲拨号最重要的映射,没有之一。数字与脉冲数对应关系如下:
| 拨号数字 | BCD 编码 | 脉冲个数 |
|---|---|---|
| 1 | 0001 | 1 |
| 5 | 0101 | 5 |
| 9 | 1001 | 9 |
| 0 | 0000 | 10 |
4.3 现象三:数码管在拨号过程中一直乱闪
现象:按下按键还没拨完,数码管已经显示出了别的数字,或者几位数码管来回跳变。
原因:display_latch的锁存条件写的是key_press_flag或key_stable,导致按键刚稳定时,还没拨出去的 BCD 值就被锁存了。拨号过程 100ms 到 1s 不等,这期间内部信号还在变化,数码管自然跟着乱。
解决:锁存条件必须改成dial_done信号。dial_done只在整个拨号时序完整结束后拉高一拍,这时候number_bcd才是本次拨号真正的结果。如果你发现改了之后拨号结束但显示不更新,检查dial_done是否在状态机退出 WAIT_REL 时被覆盖成了 0。
4.4 现象四:仿真全对,上板第一拨就没反应
现象:testbench 跑得完美,波形和预期完全一致,上电后第一次按键却毫无反应,多按几次偶尔又正常。
原因:按键引脚默认电平不确定。板卡上按键通常一端接地、一端接 FPGA 引脚,引脚如果没有内部上拉,悬空时电平随机,消抖模块的下降沿检测逻辑可能一直处于复位或计数状态。另一个原因是异步复位释放与按键动作产生了竞争。
解决:在约束文件里为key_in引脚配置内部上拉,例如set_property PULLUP true [get_ports key_in[0]]。复位改成异步复位、同步释放,推荐写法是两级触发器同步后再本地复位。测试阶段先用拨码开关替代按键,确认逻辑本身没问题,再切回按键排查 IO 问题。
4.5 现象五:示波器看到占空比不是想要的 33%
现象:脉冲个数对,但高电平宽度明显偏宽或偏窄,要么是 40ms,要么是 25ms。
原因:pulse_out引脚负载过重,示波器探头带来了等效电容,加上 FPGA IO 的 slew rate 如果配置成 slow,边沿变缓,示波器触发电平不同,测出来的高电平宽度就会偏离 RTL 设计值。还有一个可能是你把PULSE_HIGH参数直接写成了PULSE_HIGH = 1_650_000,但时钟频率不是 50MHz,比如板卡实际是 25MHz,那高电平实际是 66ms。
解决:先用逻辑分析仪采样确认设计值,再用示波器对比。检查板卡实际时钟频率与localparam CLK_FREQ是否一致。约束文件里给pulse_out配置slew rate fast。如果只是课设验收,用逻辑分析仪测的脉宽数据比示波器更靠谱。
4.6 一张排查顺序表
| 现象 | 第一时间查什么 | 常见根因 |
|---|---|---|
| 无任何输出 | 复位是否释放、时钟是否起振 | rst_n 被按键芯片拉低 |
| 脉冲数不对 | number_bcd 与 pulse_total 映射 | 0 未映射到 10 |
| 显示乱跳 | display_latch 锁存条件 | 用了 key_press_flag |
| 上电第一次无效 | key_in 引脚上拉配置 | 引脚悬空 |
| 时序与设计不符 | PULSE_HIGH/LOW 参数 | 时钟频率不匹配 |
排查时建议按从上到下的顺序走:先看复位和时钟,再看映射,再查锁存,最后查 IO 配置。这个顺序能覆盖 90% 的问题。
5. 板级验证与单步拨号法:把课设报告里的数据填满
最后一个环节,教你把板子上的验证做成可复现的数据。推荐“单步拨号法”:用拨码开关模拟按键,一次只拨一位,等dial_done信号指示完成后再拨下一位。这样每拨一位,就在逻辑分析仪上截一段波形,和仿真对照。
testbench 里模拟一次按键“5”,间隔 400ms 等待拨号完成:
initial begin clk = 0; rst_n = 0; key_in = 4'hF; #100; rst_n = 1; #100; // 模拟按下数字 5:key_in[2] 拉低 key_in = 4'b1011; #400_000_000; // 松开按键 key_in = 4'hF; #200; // 数一下 pulse_out 上升沿个数 end always @(posedge pulse_out) begin pulse_cnt = pulse_cnt + 1; end验证时记录一张表格,报告里直接能用:
| 拨号数字 | 期望脉冲数 | 实测脉冲数 | 高电平宽度 | 脉冲周期 |
|---|---|---|---|---|
| 1 | 1 | 1 | 33ms | 100ms |
| 5 | 5 | 5 | 33ms | 100ms |
| 0 | 10 | 10 | 33ms | 100ms |
上板前,我习惯在仿真里强制把每一路按键从 0 到 9 完整拨一遍,生成一个全序列波形。这个习惯救过我很多次:有一次是状态机 WAIT_REL 条件写反了,仿真里拨第 3 个数字才暴露出来,要是在板子上现场找,估计一下午就没了。从那以后,每次上板前我都先跑一遍全按键序列仿真,宁可多花 20 分钟,也不在示波器前靠猜排错。希望帮到你。
本文还有配套的精品资源,点击获取