简介:南京邮电大学脉冲按键拨号电路FPGA设计课程设计资料包,面向电子通信类专业学生及FPGA初学者,完整实现0~9按键输入、串行脉冲序列输出与动态显示功能,并支持按键切换基本/扩展指标,为课堂项目或课设提供可复现的完整工程方案。资源共215个文件,包含Verilog/VHDL源文件(c)、Xilinx工程文件(xise/ucf/bit)、仿真报告(log/html)以及原理图文件(sch)、位流配置文件(bit)等,压缩包约897KB,便于下载与快速部署。已有128人学习下载。工程已经测试验收,可在开发板上直接运行,读者可基于源码理解有限状态机、按键消抖、动态扫描等数字电路核心设计,也可参考其基本指标与扩展指标的功能切换思路,用于课程答辩或二次开发。对准备数字逻辑实验或FPGA课设的学生来说,是一份具实操价值的参考资料。
1. 脉冲按键拨号电路FPGA设计:把随机按键整理成一串标准脉冲
课程设计拿到这个题目,第一反应多半是“电话都进博物馆了,还拨什么脉冲”。但脉冲按键拨号电路练的并不是电话,而是 FPGA 入门最该掌握的一套组合动作:按键消抖、边沿检测、计数器分频、时序状态机。真实世界的按键是异步的,按下瞬间电平乱跳;而拨号协议要求每个号码变成一串严格定宽的脉冲,交换机的计数继电器才能识别。用 FPGA 把“一头随机、一头严格”的两端接起来,正好把数字电路和 Verilog 的核心技巧全过一遍,而且结果能用 LED、示波器或逻辑分析仪直接验收。这个题目在南邮的现代数字系统课程设计里反复出现,做法不唯一,但验收点很统一:脉冲个数不能错,每个脉冲高电平 40ms、低电平 60ms,号码之间有 300ms 以上的位间隔。
2. 脉冲拨号的时序规格与FPGA顶层模块划分
2.1 脉冲拨号协议:一位数字就是一串“断断续续”
脉冲拨号是电话交换机最早的自动选线方式。老式旋转电话机上有个带弹簧的拨号盘,拨到指定挡位后松手,机械触点按固定节奏断开、闭合,断开一次就算一个脉冲。交换机那边用计数继电器把脉冲数量翻译成电话号码的一位。这个“断开—闭合”的动作输出到电路上,就是一个严格周期的高/低电平序列:高电平 40ms、低电平 60ms,合成一个 100ms 的脉冲周期,也就是 10pps 的拨号速率。
号码里的每个数字都对应固定的脉冲个数:拨 5,线路就断 5 次;拨 0 最有意思,它不是 0 个脉冲,而是拨 10 个脉冲,为了与“没拨号”区分开。如果把旋转拨号盘换成按键输入,FPGA 要做的事情就变成:用户按一个数字键,在 dial_out 引脚上输出对应数量的标准脉冲,每个脉冲都是 40ms 高电平加 60ms 低电平;号码发完后,再维持至少 300ms 的静默作为位间隔,交换机看到静默就知道一位号码结束。整个过程可以浓缩成下面的对应关系:
| 号码 | 脉冲数 | 发完耗时(不含位间隔) | 说明 |
|---|---|---|---|
| 1 | 1 | 100ms | 最短的号码位 |
| 2 | 2 | 200ms | 依次递增 |
| 5 | 5 | 500ms | 中间档 |
| 9 | 9 | 900ms | 接近 1 秒 |
| 0 | 10 | 1000ms | 10 个脉冲,最耗时 |
连续按 3 个数字键,就得在拨号线上输出三组这样的脉冲串,中间用位间隔隔开。如果两组脉冲之间没有间隔,交换机会把前后两串当成一个号码,比如“34”可能被识别成“7”。这个“串起来”的节奏控制,正是状态机存在的意义。
2.2 顶层模块划分:按键、编码、发脉冲、显示各管一段
FPGA 课程设计不能只写一个大 always 块,工程需要按功能拆模块。很多同学刚上手把全部逻辑堆在顶层,仿真时一旦时序不对,根本说不清是消抖没做干净、计数器溢出,还是状态机跳飞了。处理“按键转脉冲”这类题目,我一般会拆成四个模块:
- key_debounce:把按键输入两级同步化、滤掉抖动,输出稳定后的电平,以及按下次瞬间的单周期脉冲;
- number_encoder:把按键的 BCD 码映射成脉冲数量,0 对应 10,1~9 对应自身;
- pulse_dial_fsm:拨号状态机,输入 start 和 BCD 码,输出拨号脉冲与 busy 信号;
- display:把当前拨出号码显示到数码管,板级调试时能直观看到“号码有没有被正确锁存”。
顶层例化时模块之间只有几根线,逻辑分析仪要抓的信号也一目了然。典型例化结构如下:
// pulse_dial_top.v 顶层例化框架 wire key_pulse; // 按键消抖后单周期脉冲 wire [3:0] key_code; // 按键 BCD 码 wire dial_out; // 拨号脉冲输出 wire busy; // 忙信号 key_debounce u_debounce ( .clk (clk_50m), .rst_n (rst_n), .key_in (key_row), // 原始按键输入 .key_out (key_stable), // 消抖后稳定电平 .key_pulse (key_pulse) // 按下瞬间的单周期脉冲 ); pulse_dial_fsm u_dial ( .clk_50m (clk_50m), .rst_n (rst_n), .start (key_pulse), .key_num (key_code), .dial_pulse (dial_out), .busy (busy) );参数说明:key_code 是 4 位 BCD 码,对应数字 0~9。这里特意把按键解码单独留给键盘扫描模块,而不是让状态机直接处理 16 个按键的扫描编码,好处是后面从独立按键换成 4x4 矩阵键盘,状态机代码一行都不用改。模块间的握手规则是:key_pulse 是单周期脉冲,busy 为高期间状态机正在发脉冲,新按键应该被忽略;busy 拉低后,下一拍才能接受新的 start。这个约定必须在设计文档里写清楚,否则仿真时很容易出现“拨号还没结束就触发第二次按键”的竞争。
3. 按键消抖与号码编码映射的FPGA实现
3.1 用两级同步器和20ms计数器完成按键消抖
按键按下和松开的过程中,机械触点会弹跳,波形上表现为几百微秒到十几毫秒的毛刺。如果不处理,状态机可能把一次按下当成好几下,拨号脉冲数就会凭空多出来。课程设计里最常用的消抖办法是“20ms 电平稳定判定”:检测到按键状态变化后开始计数,只有持续 20ms 保持同一电平才认为状态稳定。
除了抖动脉冲,还有亚稳态问题。按键相对时钟是异步信号,直接进 always 块的触发条件,寄存器的建立/保持时间可能被违反。所以第一步应该是两级同步器,把异步信号变成与时钟对齐的信号,再交给消抖状态机。下面这段消抖代码可以直接放进工程:
// key_debounce.v module key_debounce ( input wire clk, // 50MHz 系统时钟 input wire rst_n, // 低电平复位 input wire key_in, // 异步按键输入,按下为高 output reg key_out, // 消抖后的稳定电平 output reg key_pulse // 按下边沿产生的单周期脉冲 ); localparam CNT_MAX = 1_000_000; // 20ms @50MHz = 20 * 50000 reg [19:0] cnt; reg key_sync0, key_sync1; // 两级同步器 reg key_prev; // 第一段:两级同步器,消除亚稳态 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync0 <= 1'b0; key_sync1 <= 1'b0; end else begin key_sync0 <= key_in; key_sync1 <= key_sync0; end end // 第二段:20ms 稳定计数 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_out <= 1'b0; end else if (key_sync1 == key_out) begin // 与当前稳定输出一致,说明没有跳变 cnt <= 20'd0; end else begin // 检测到新电平,开始累计稳定时间 if (cnt == CNT_MAX) begin key_out <= key_sync1; // 电平稳定,更新输出 cnt <= 20'd0; end else begin cnt <= cnt + 1; end end end // 第三段:边沿检测,按键按下的瞬间拉高一个时钟周期 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_prev <= 1'b0; key_pulse <= 1'b0; end else begin key_prev <= key_out; key_pulse <= key_out & ~key_prev; end end endmodule逻辑说明:key_sync1 == key_out 判断的是“新输入与当前输出是否一致”。一致说明按键没有变化,计数器清零;不一致说明电平跳变了,开始累计 20ms。20ms 之后 key_out 才翻转,同时计数器清零,为下一次跳变做准备。key_prev 保存 key_out 上一拍的值,当 key_out 从 0 变 1 时,key_pulse 拉高一个 clk 周期,这个脉冲可以直接作为状态机的启动条件。
参数说明:CNT_MAX = 20ms / 1 个时钟周期 = 20 × 50_000 = 1_000_000,计数器宽度 20 位刚好够。如果板子时钟不是 50MHz,例如 100MHz,就要改成 2_000_000。消抖时间也不是越大越好:20ms 是行业常见值,大部分机械按键的抖动都在 5~15ms 内结束。如果觉得按键反馈迟钝,可以压到 10ms,但不要低于 5ms,否则抖动量大的按键会失效。
3.2 数字到脉冲数的编码映射表
状态机真正要的不是按键编号,而是这个键要发几个脉冲。号码到脉冲数的映射是拨号协议的核心,也是仿真里最容易查的一项。0 对应 10 个脉冲、1~9 对应自身,具体的映射关系如下:
| 按键 | BCD 码 | 脉冲数 | 十六进制 |
|---|---|---|---|
| 0 | 4'b0000 | 10 | 4'hA |
| 1 | 4'b0001 | 1 | 4'h1 |
| 2 | 4'b0010 | 2 | 4'h2 |
| 3 | 4'b0011 | 3 | 4'h3 |
| 4 | 4'b0100 | 4 | 4'h4 |
| 5 | 4'b0101 | 5 | 4'h5 |
| 6 | 4'b0110 | 6 | 4'h6 |
| 7 | 4'b0111 | 7 | 4'h7 |
| 8 | 4'b1000 | 8 | 4'h8 |
| 9 | 4'b1001 | 9 | 4'h9 |
这个映射用 Verilog 写,比 case 更简洁,0 特殊处理一下就是完整逻辑:
wire [3:0] pulse_count_target; assign pulse_count_target = (key_num == 4'd0) ? 4'd10 : key_num;参数说明:pulse_count_target 是 4 位无符号数,最大 10,最小 1,不存在 0 值。状态机只要进入拨号流程,必然至少发一个脉冲。最容易写错的是直接把 key_num 当成脉冲数,这样拨 0 时一个脉冲都没有,交换机根本认不出这个号码,而仿真里看似“正常”,实际违背了拨号协议。如果板上的键盘带的是 4x4 矩阵,按键扫描模块先输出行列编码,再经译码器转成 BCD 码,这张映射表的位置不变,状态机不用关心键盘物理结构。
4. 拨号脉冲发生器的时序控制状态机
4.1 三个必调参数:MAKE、BREAK、GAP
状态机的任务把“脉冲数量”变成一个真实波形。波形由三段组成:
- MAKE:脉冲高电平导通时间,标准 40ms;
- BREAK:脉冲低电平断开时间,标准 60ms;
- GAP:整个号码发完后、下一个号码开始前的静默时间,标准不小于 300ms。
10pps 的脉冲周期由 MAKE+BREAK 决定,40ms + 60ms 正好 100ms。在 50MHz 时钟下,各段时间对应的计数器值如下:
| 参数 | 时间 | 时钟周期数 | 允许计数器位宽 |
|---|---|---|---|
| MAKE_TICKS | 40ms | 2_000_000 | 至少 21 bit |
| BREAK_TICKS | 60ms | 3_000_000 | 至少 22 bit |
| GAP_TICKS | 300ms | 15_000_000 | 至少 24 bit |
GAP_TICKS 最大值为 15_000_000,小于 2^24 = 16_777_216,所以 timer 取 24 位最稳妥。如果板级调试想加快节奏,可以把这三个参数临时缩小 1000 倍,拨号过程就从 1 秒量级缩到 1ms 量级,方便示波器观察;综合下载前记得改回来。
4.2 拨号控制状态机的可综合实现
状态机使用 4 个状态:S_IDLE 等待按键、S_MAKE 输出高电平、S_BREAK 输出低电平、S_GAP 位间隔。pulse_count 寄存器记录剩余脉冲个数,进入 S_BREAK 时递减,递减到 0 后进入 S_GAP。核心代码如下:
// pulse_dial_fsm.v module pulse_dial_fsm ( input wire clk_50m, input wire rst_n, input wire start, // key_debounce 输出的单周期脉冲 input wire [3:0] key_num, // BCD 码 0~9 output reg dial_pulse, // 拨号脉冲输出 output reg busy // 高电平表示正在拨号 ); // 50MHz 时钟时间参数 localparam MAKE_TICKS = 2_000_000; // 40ms localparam BREAK_TICKS = 3_000_000; // 60ms localparam GAP_TICKS = 15_000_000; // 300ms localparam S_IDLE = 2'd0; localparam S_MAKE = 2'd1; localparam S_BREAK = 2'd2; localparam S_GAP = 2'd3; reg [1:0] state; reg [23:0] timer; reg [3:0] pulse_count; // 剩余脉冲数 reg [3:0] dial_num; // 锁存的号码 wire [3:0] target_pulses = (dial_num == 4'd0) ? 4'd10 : dial_num; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; timer <= 24'd0; pulse_count <= 4'd0; dial_num <= 4'd0; dial_pulse <= 1'b0; busy <= 1'b0; end else begin case (state) // 空闲:等待按键,锁存号码 S_IDLE: begin dial_pulse <= 1'b0; busy <= 1'b0; if (start) begin dial_num <= key_num; pulse_count <= target_pulses; busy <= 1'b1; timer <= 24'd0; state <= S_MAKE; end end // 脉冲高电平阶段 S_MAKE: begin dial_pulse <= 1'b1; if (timer == MAKE_TICKS - 1) begin timer <= 24'd0; state <= S_BREAK; end else begin timer <= timer + 1; end end // 脉冲低电平阶段,结束后判断是否有下一个脉冲 S_BREAK: begin dial_pulse <= 1'b0; if (timer == BREAK_TICKS - 1) begin timer <= 24'd0; pulse_count <= pulse_count - 1; if (pulse_count == 4'd1) begin // 最后一个脉冲,进入位间隔 state <= S_GAP; end else begin state <= S_MAKE; end end else begin timer <= timer + 1; end end // 位间隔:号码结束后静默 300ms S_GAP: begin dial_pulse <= 1'b0; if (timer == GAP_TICKS - 1) begin timer <= 24'd0; busy <= 1'b0; state <= S_IDLE; end else begin timer <= timer + 1; end end endcase end end endmodule逻辑说明:S_IDLE 把 key_num 锁存到 dial_num,同时 pulse_count 载入 target_pulses。这里 target_pulses 由“上一拍的 dial_num”计算得到,第一次按键时 dial_num 是复位值 0,映射出来是 10,会导致拨 3 却发 10 个脉冲。这是时序逻辑里常见的“当前值取到上一拍旧值”问题,修复方式是在 S_IDLE 判断里用 key_num 直接计算,而不是用 dial_num:
if (start) begin dial_num <= key_num; pulse_count <= (key_num == 4'd0) ? 4'd10 : key_num; busy <= 1'b1; timer <= 24'd0; state <= S_MAKE; endS_BREAK 的判定逻辑要注意,pulse_count == 4'd1判断的是“当前正处于最后一个脉冲”。进入 S_BREAK 前 pulse_count 等于 1,则这个 BREAK 结束后递减成 0,并进入 S_GAP;如果 pulse_count 大于 1,递减后继续回到 S_MAKE 发下一个脉冲。这样脉冲总数始终等于号码映射值,不会多也不会少。
参数说明:这套状态机可以直接替换成 DTMF 双音多频方案,只需把脉冲输出改为并行输出两个正弦波频率。不过课程设计做脉冲拨号,状态机本身就是验收重点,所以不要在 DTMF 上展开。想改成 20pps 拨号速率时,把 MAKE_TICKS 减半为 1_000_000、BREAK_TICKS 减为 1_500_000,其他逻辑不变;但交换机侧的拨号速率是双方约定,课程设计一般都固定 10pps,不需要做成可配置参数。
5. 仿真验证与板级联调的三个关键技巧
5.1 用 Testbench 数脉冲个数,顺便检查脉宽
状态机跑完整拨号流程需要几百毫秒仿真时间,在 50MHz 时钟下大约 3000 万个周期,ModelSim、Vivado Simulator 都能承受。Testbench 里最该做两件事:数 dial_pulse 的上升沿个数,以及测量每个高电平持续的时间。数上升沿可以直接用一个计数器:
// tb_pulse_dial.v 片段 reg [3:0] edge_cnt; reg dial_pulse_d; wire pos = dial_pulse & ~dial_pulse_d; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin dial_pulse_d <= 1'b0; edge_cnt <= 4'd0; end else begin dial_pulse_d <= dial_pulse; if (pos) edge_cnt <= edge_cnt + 1; end end激励文件里连续给两组号码,观察边缘计数是否与协议一致:
initial begin rst_n = 1'b0; repeat(10) @(posedge clk_50m); rst_n = 1'b1; @(posedge clk_50m); key_num = 4'd3; start = 1'b1; @(posedge clk_50m); start = 1'b0; wait (busy == 1'b0); // 等待第一组号码拨完 $display("first dial edge_cnt = %d", edge_cnt); // 期望是 3 key_num = 4'd0; start = 1'b1; @(posedge clk_50m); start = 1'b0; wait (busy == 1'b0); $display("second dial edge_cnt = %d", edge_cnt); // 期望是 10 end这里的 wait (busy == 1'b0) 是仿真时同步“上一组号码已经拨完”的方式。实际板级运行时不存在这种等待句法,如果要在拨完后触发其他动作,应该用 busy 下降沿采样。参数说明:edge_cnt 在每次拨号前没有自动清零,上面演示里第二次计数会叠加第一次的 3,所以实际工程里应该在 start 拉高前手动清零,或者把计数结果与起始值做差,才能得到本组号码的脉冲数。
5.2 数码管动态显示和缩小时间参数的板级验收
下载到 FPGA 板卡后,最直观的验收方式是把 dial_pulse 接到 LED,拨号时能看到 LED 以 40ms 亮、60ms 灭的节奏闪烁。拨 5,灯就闪 5 次;拨 0,灯闪 10 次。这个现象足够证明脉冲计数正确,但没法定量确认脉宽。有条件的话,把 dial_pulse 用跳线引到示波器或逻辑分析仪,看到的应该是一串 100ms 周期的方波,占空比 40%,后面跟着 300ms 低电平。没有示波器时,把 busy 接到另一个 LED 也能辅助判断位间隔是否结束:busy 亮代表拨号中,灭掉后 300ms 才允许下一组开始。
板级调试最实用的技巧是临时把时间参数缩小,比如把 MAKE_TICKS 改成 2_000、BREAK_TICKS 改成 3_000、GAP_TICKS 改成 15_000,这样整个拨号过程从秒级变成毫秒级,按键反应快,示波器上一屏就能看到完整序列。改完参数后综合下载,观察 LED 闪动节奏是否均匀,连同数码管动态显示当前号码一起验证。
另一个亲身踩过的坑是矩阵键盘的消抖位置。如果板子只提供了 4x4 矩阵键盘,不要对 16 个按键各做一路消抖,而是在行列扫描之后共享一个消抖通道。扫描到某一行某一列的电平稳定后,统一进消抖模块,再输出 BCD 码给状态机。显示屏上如果显示 5,但状态机发出 10 个脉冲,问题一定出在键盘扫描编码与 BCD 码的对应关系上,跟拨号状态机无关。把问题边界切到模块边界,排错会快很多。
最后强调一个容易忽略的时序约束:dial_pulse 直接由状态机寄存器输出,没有任何组合逻辑,不需要在 XDC/SDC 里加特殊约束。如果板子上 dial_pulse 要通过 PCB 走线驱动继电器或三极管,注意查一下 FPGA IO 的驱动能力,推挽输出默认即可;要驱动真正的电话线,必须经外部三极管放大,不能拿 FPGA 引脚直接接负载。
本文还有配套的精品资源,点击获取