FPGA电子琴设计与实现:基于Verilog的分频、按键消抖与状态机详解
简介一个基于FPGA与Verilog实现的电子琴设计工程是数字电路与音乐合成方向综合练习项目面向学习硬件描述语言、Altera FPGA开发流程的初学者或课程设计者。工程压缩包共69个文件仅211KB包含Quartus工程配置文件.qsf/.qpf、Verilog源文件.v、AHDL设计文件.tdf、编译仿真报告.rpt/.rvd以及可直接下载到FPGA的.sof/.pof配置文件整体为Quartus自动生成的工程结构便于对照查错。已有421人学习过该工程说明其在同类资源中有一定参考价值。通过该工程可以完整看到音符生成、频率合成等模块的Verilog实现思路也能了解从代码编写到综合、布局布线、生成配置文件的全流程。对于理解FPGA内部逻辑资源分配与音乐信号处理这一工程提供了可实际运行的参考样例。1. 从流水灯到能出声FPGA 电子琴为什么值得写一遍聊到基于 FPGA 的电子琴设计很多人第一反应是“我又不会弹琴做出来也是按键瞎按”但作为拿 Verilog 从头写过完整 RTL 的一线开发者这个项目的真正门槛其实不在音乐而在三个很硬核的技术点频率怎么分才准、按键抖动怎么消才稳、多个键同时按下时逻辑怎么处理才不翻车。它特别适合刚学完时序逻辑、想找一个人人能上板跑通又不用写复杂算法的 FPGA 入口项目的人。看完整篇文章你能拿到一套从系统拆分到验证手段的完整实现思路当自己写的分频方波驱动蜂鸣器发出标准音高时那种成就感比跑通流水灯强得多。2. 系统拆分与顶层设计把电子琴拆成分频、按键、音调三块做 FPGA 设计的第一条铁律是把“听起来很复杂”的系统拆成“每个都能单独验证”的模块。电子琴看起来只是一个蜂鸣器出声实际上它背后有三个职责完全不同的逻辑块按键输入要先被扫描和消抖消抖后的键值要转换成对应的分频系数分频系数再驱动计数器产生指定频率的方波。这三块相互独立最后在顶层模块里用例化把它们串起来。2.1 顶层到底拆成几块三个子模块加一张查找表我一般会把整个工程拆成key_scan、note_lut、divider三个模块。key_scan负责读取 16 个按键并输出一个 4bit 的键号note_lut把 4bit 键号映射成对应频率的 32bit 分频目标值divider负责用这个目标值对 50MHz 基准时钟做偶数分频最终输出方波到蜂鸣器。为什么不把三块合成一个大模块因为分频逻辑要仿真验证按键消抖也要单独给激励测试耦合在一起时一旦上板声音不对你根本分不清是按键扫描出错还是分频计算出错。拆开之后每个模块写一个 testbench谁出问题直接定位到那一块。下面是顶层例化的简化代码省略了部分端口声明重点看模块划分方式module top_electronic_organ ( input wire clk_50m, input wire rst_n, input wire [15:0] key_in, // 16 个独立按键低电平有效 output reg speaker_out // 输出到无源蜂鸣器 ); wire [3:0] key_code; // 扫描模块输出的键号 wire [31:0] div_cnt; // 查找表输出的分频目标值 key_scan u_key_scan( .clk_50m(clk_50m), .rst_n(rst_n), .key_in(key_in), .key_code(key_code) ); note_lut u_note_lut( .key_code(key_code), .div_cnt(div_cnt) ); divider u_divider( .clk_50m(clk_50m), .rst_n(rst_n), .div_cnt(div_cnt), .key_in(key_in), .speaker_out(speaker_out) ); endmodule这段代码里最需要注意的是divider模块的输入不止div_cnt我还把原始key_in也传了进去原因是当没有任何按键按下时分频器必须被强制拉低输出不能让蜂鸣器持续响。如果你把“是否有按键按下”的判断放在note_lut里就会面临“没按键时分频目标值是多少”的边界问题我习惯用一个单独的key_valid信号来处理但初学者最容易忽略这层逻辑。2.2 按键方案选型16 键为什么用独立按键而不是矩阵键盘FPGA 开发板上常见两种按键接法独立按键和矩阵键盘。电子琴这类设计用矩阵键盘很诱人因为 16 个按键只需要 8 个 IO 口省引脚。但矩阵键盘必须做动态行扫描扫描频率本身会干扰音频输出的连续性——当你一边扫键盘一边生成方波时如果扫描节奏设计得不好蜂鸣器会出现可闻的“咔哒”声。独立按键虽然每个键占一个引脚16 键就要 16 个 IO但逻辑极其简单每个键的电平直接进消抖模块不需要行扫描时序音频输出的连续性完全取决于分频器本身。对课程设计级别的 FPGA 开发板来说IO 资源通常足够我选独立按键把工程风险降到最低。按键输入还有一个设计细节低电平有效还是高电平有效。市面主流开发板按键多为低电平有效按下接地默认上拉。如果你的开发板是高电平有效反相逻辑要处理在key_scan内部不要到处打补丁。后面第 5 章会讲这块写反了是上板无声的高频翻车点之一。2.3 引脚约束与时钟输入的检查习惯顶层 RTL 写完后不要急着综合先做两件事。第一件打开原理图确认蜂鸣器是有源还是无源有源蜂鸣器内部自带振荡源你给它一个直流高电平它就响频率不可控无源蜂鸣器需要输入方波才能发声正好匹配 FPGA 分频输出。如果你的开发板是无源蜂鸣器本设计就是对的如果是有源逻辑要改成 PWM 输出才能变调。第二件确认基准时钟频率是 50MHz 还是 100MHz这个值直接决定后面整张分频表的系数。不少人综合时顺手写 50_000_000结果板子实际是 100MHz 时钟出来的声音整体高八度调了半小时才发现是配置脚本里时钟频率写错了。3. 按键扫描与消抖从按下到出声的状态机设计有了顶层框架下面进入最容易写出“看起来对、上板就废”的按键模块。按键消抖的原理不复杂但工程实现里有不少选择要做。3.1 为什么必须消抖机械触点的时间窗口机械按键按下和释放的瞬间触点不是干净地接通或断开而是会在 5ms 到 20ms 内来回弹跳。FPGA 的时钟是纳秒级的如果不做处理一次按键会被识别成几十次触发蜂鸣器会发出嗤啦的杂音。消抖的常规思路是检测到电平跳变后等待 20ms 再确认一次电平。如果 20ms 后电平稳定在目标状态就认为是真实按键否则认为是抖动。这个 20ms 不是硬性规定太快消不净抖动太慢按键响应迟钝。我在实际工程里一般取 16ms 到 24ms 之间开发板实测下来这个区间的手感最舒服。3.2 状态机写法按下确认、锁存、释放复位我建议用三段式状态机来实现消抖逻辑空闲状态检测按键按下进入“按下消抖等待”20ms 稳定后锁存键号并发出有效标志然后进入“释放等待”状态等按键完全松开后再回到空闲。下面给出key_scan模块的核心代码重点看状态转移和锁存时机module key_scan ( input wire clk_50m, input wire rst_n, input wire [15:0] key_in, output reg [3:0] key_code ); parameter IDLE 2d0; parameter PRESS_WAIT 2d1; parameter RELEASE_WAIT 2d2; reg [1:0] state, next_state; reg [19:0] cnt_20ms; reg [15:0] key_sync1, key_sync2; // 两级同步处理异步输入 // 同步打拍避免亚稳态 always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin key_sync1 16hFFFF; key_sync2 16hFFFF; end else begin key_sync1 key_in; key_sync2 key_sync1; end end // 状态转移和按键锁存逻辑略重点在下面两个关键点 wire any_press ~(key_sync2 16hFFFF); // 存在任意按键被按下 reg refresh_cnt; reg key_valid; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin refresh_cnt 0; end else begin refresh_cnt refresh_cnt 1b1; if (refresh_cnt 20d1_000_000) // 每 20ms 刷新一次采样窗口 refresh_cnt 0; end end endmodule这段代码的要点有两个。第一key_sync1和key_sync2是两级同步寄存器处理物理按键这种真正的异步信号不加这层同步直接采静态时序分析会报出亚稳态风险上板偶发误触发是必然的。第二我没有直接用any_press作为状态机触发条件而是通过一个 20ms 周期的采样窗口refresh_cnt去读稳定电平这样消抖和状态机脱钩状态机逻辑更干净。参数说明clk_50m是 50MHz 基准时钟一个周期 20ns计满1_000_000次刚好 20ms。如果你的板子时钟是 100MHz这个常数要改成2_000_000否则实际消抖时间变成 10ms面板上键稍微老化一点就又会抖出杂音。按键低电平有效时key_in默认全高16hFFFF对应没有任何按键按下这也是休闲状态下的安全电平。3.3 多键同时按下的处理策略电子琴必然遇到两个键同时按下的场景这在音乐上是和弦需求但课程设计里如果处理不好会出现声音乱跳的现象。常见策略有三种最高键优先、最低键优先、查表固定优先。实际音乐演奏里高音旋律往往更重要我一般默认让键号最小的也就是音调最低的按键不被抢占而是固定死优先级顺序用一串 if-else 而非 case 来编码。用 if-else 实现优先级有一个隐蔽优势综合工具会自然生成一个串行优先链逻辑清晰且不会产生多路选择器竞争。如果你用 case 语句且没写 default未定义的输入组合会产生锁存器这是 FPGA 综合时最常见的警告来源之一。写上default: key_code 0;锁存器就不会出现。4. 音调生成核心分频系数计算与 Verilog 实现按键消抖做完了接下来是整个项目里最有技术含量的一块怎么用 50MHz 的时钟分频出标准音高。这个模块如果只按公式写会踩到一个很容易被忽略的计数边界问题我把它单独拎出来讲。4.1 分频系数计算从十二平均律频率表到计数值电子琴需要产生一系列标准音高最常见的是基于十二平均律以 A4 440Hz 为基准。我这里选了一段 C4261.6Hz到 D#5622.3Hz的 16 个半音作为基础音域覆盖一个半多八度对入门设计完全够用按键编号从 0 到 15 依次对应音调从低到高。分频的核心公式是div_cnt F_clk / (2 * F_note)因为我们要产生的是 50% 占空比的方波计数器每计到div_cnt翻转一次输出完整周期需要两次计数段所以公式里带一个系数 2。以 C4 的 261.6Hz 为例div_cnt 50_000_000 / (2 * 261.6) ≈ 95567这里的取整方向非常关键。div_cnt必须向下取整因为计数器的比较条件是“计到等于目标值”向下取整会让实际频率略微偏高偏差小于千分之一人耳分辨不出来而四舍五入到更接近的整数反而可能因为除不尽产生一个周期内的相位误差声音发飘。我一般直接让综合工具按整数除法处理配合 Verilog 里的无符号运算自动截断。4.2 查表与计数翻转Verilog 实现的关键细节分频系数的存储有两种方式直接用case语句写死或用ROM例化。对 16 个音调这种小规模需求case语句更直观也不依赖 IP 核仿起来没负担。下面这段代码是分频器的核心module note_lut ( input wire [3:0] key_code, output reg [31:0] div_cnt ); always (*) begin case (key_code) 4d0: div_cnt 32d95567; // C4 261.6Hz 4d1: div_cnt 32d90159; // C#4 277.2Hz 4d2: div_cnt 32d85109; // D4 293.7Hz 4d3: div_cnt 32d80358; // D#4 311.1Hz 4d4: div_cnt 32d75872; // E4 329.6Hz 4d5: div_cnt 32d71633; // F4 349.2Hz 4d6: div_cnt 32d67634; // F#4 370.0Hz 4d7: div_cnt 32d63847; // G4 392.0Hz 4d8: div_cnt 32d60275; // G#4 415.3Hz 4d9: div_cnt 32d56818; // A4 440.0Hz 4d10: div_cnt 32d53710; // A#4 466.2Hz 4d11: div_cnt 32d50680; // B4 493.9Hz 4d12: div_cnt 32d47867; // C5 523.3Hz 4d13: div_cnt 32d45182; // C#5 554.4Hz 4d14: div_cnt 32d42625; // D5 587.3Hz 4d15: div_cnt 32d40230; // D#5 622.3Hz default: div_cnt 32d95567; endcase end endmodule同步给出分频器的计数翻转代码module divider ( input wire clk_50m, input wire rst_n, input wire [31:0] div_cnt, input wire [15:0] key_in, output reg speaker_out ); reg [31:0] cnt; reg any_key_down; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt 0; speaker_out 1b0; end else begin any_key_down ~(key_in 16hFFFF); // 有键按下才允许计数 if (!any_key_down) begin cnt 0; speaker_out 1b0; end else if (cnt div_cnt) begin cnt 0; speaker_out ~speaker_out; // 半周期翻转占空比 50% end else begin cnt cnt 1b1; end end end endmodule这段代码里有一个非常容易翻车的边界计数器比较条件用的是cnt div_cnt还是cnt div_cnt。如果你用相等比较会出现一个周期恰好相等时被连续两次触发的问题实际频率变成原来的两倍。用比较则保证每次计数到目标值后立刻清零不需要额外状态判断。占空比 50% 是因为翻转发生在两次计数到达目标值之间每完整周期高电平和低电平的时间完全相等。参数说明div_cnt用 32bit 是因为最大分频系数 95567 已经超过 16bit 上限而查找表输出位宽统一用 32bit 可以避免后续加功能时重新改位宽。speaker_out的翻转逻辑在当前状态下输出方波如果开发板蜂鸣器是无源蜂鸣器这个方波直接驱动即可不需要额外放大电路。4.3 输出与蜂鸣器的匹配电平、占空比和缓冲寄存器FPGA 引脚直接驱动无源蜂鸣器时要确认开发板原理图上蜂鸣器的驱动电路。很多板子为了增大音量会在 FPGA 引脚和蜂鸣器之间加一个三极管放大电路这时引脚输出极性要反一下——三极管是 NPN 还是 PNP 会决定高电平响还是低电平响。你可以在divider里加一个极性选择信号综合前通过宏或引脚属性配置而不是依赖代码版本切换。另外speaker_out最好打一拍寄存器输出不要直接从组合逻辑出去这样可以避免组合逻辑毛刺被直接送到蜂鸣器。5. 常见问题排查五个上板后最容易翻车的点这一章直接给血泪经验。电子琴这种涉及模拟输出的项目仿真过了不等于上板会响下面五个问题我见过不同的人反复踩每个都按现象、原因、解决的顺序写清楚。5.1 音高整体偏高分频系数取整方式和计数边界出错现象实现出来的声音明显比标准音高高出一截几乎像是整体升了一个小二度甚至更多。原因有两个一是分频系数在计算时用了四舍五入而不是向下取整二是代码里计数器比较条件写成了cnt div_cnt而不是cnt div_cnt。前者的误差是百分之零点几后者会让频率直接翻倍听起来完全不对。解决先把比较条件改成再用公式重新算一遍div_cnt。5.2 松开按键还在响延音逻辑缺失与锁存时机错误现象按键按下出声正常松手后声音还要持续几百毫秒甚至一直响到下一个按键按下。原因状态机里“释放等待”状态没写对键号在按键释放后被错误地持续锁存或者key_valid信号只在按下沿才有效之后一直没有复位。解决在状态机里增加明确的释放确认分支只有检测到按键恢复默认电平并稳定 20ms 后才清除key_valid同时把键号归零。这种问题在仿真里特别容易漏掉因为你可能在激励里只模拟了一次按键按下和立刻松开没有等足消抖时间。我建议 force 几次真实按键毛刺波形放到仿真激励里专门验证释放路径。5.3 上板无声复位极性写反的经典翻车现象所有逻辑仿真波形都正确综合也没有报错但烧进去后怎么按都没声音。原因开发板按键的复位电路极性跟 RTL 里写的不一致。主流的 FPGA 开发板按下复位时输出低电平但也有少数板子是按下输出高电平。RTL 里写negedge rst_n而实际板子是高有效复位整体逻辑就永远被锁在复位态。解决用示波器或逻辑分析仪量开发板复位引脚空闲时的电平然后调整 RTL 里的敏感列表或者加一个反相器。花十分钟查原理图能省掉一下午的“玄学排查”。5.4 多键同时按下声音混乱优先级逻辑缺失或锁存竞争现象同时按两个不同按键出来的声音忽高忽低来回跳而不是固定弹响某一个音。原因键盘编号用case语句直接编码没有优先级处理综合会产生一个并行选择器两个输入同时有效时会竞争。解决改成if-else串行优先级链最高或最低键固定优先并测试三个键同时按下时是否按优先级稳定输出。这里我要强调不要试图去同时响应多键除非你实现了真正的复音合成而那是另一个复杂度的项目。5.5 蜂鸣器声音刺耳或断续方波频率与驱动电路不匹配现象单键按下时声音还算正常但音量忽大忽小或者想发标准音时出来的声音发劈。原因输出方波的频率虽然对但开发板上的蜂鸣器是有源类型被方波驱动时内部振荡源被反复强制复位听起来就是刺耳的断续声也可能引脚驱动电流不足推不动蜂鸣器。解决确认蜂鸣器是无源的再使用本设计如果确实是有源蜂鸣器就放弃方波音调生成改用不同频率的 PWM 信号去调响度仿真音感方案要整体调整。6. 从仿真到上板验证方法、进阶玩法与习惯养成模块写完之后最快的验证方式是行为级仿真。仿真不是跑一遍点亮流水灯那种程度而是要刻意制造真实按键的抖动波形和状态机较劲。我会写这样的 testbench 激励先用 3ms 间隔的抖动毛刺模拟按键按下再让按键稳定 100ms最后松开检查输出方波频率是否等于预期。// testbench 片段模拟 C4 键按下并带 5ms 毛刺 initial begin key_in 16hFFFF; #100 key_in[0] 1b0; // 按下产生第一个沿 #3ms key_in[0] 1b1; // 抖动 1 #2ms key_in[0] 1b0; // 抖动 2 #5ms key_in[0] 1b0; // 稳定按下 #100ms; key_in[0] 1b1; // 松开 #30ms; $finish; end对按键消抖的设计抖动的宽度要跨过消抖窗口的一半左右太宽或太窄都不能说明状态机真的抗抖动。检查仿真波形时不要只看speaker_out有没有翻转要在波形里调出时间光标量一个完整周期的时间是多少纳秒换成频率后和标准音高比对。进阶方向有很多性子急的开发者可以加延音功能——按键松开时让输出维持 200ms 再衰减资源富余的可以加一个包络模块用 PWM 控制音量包络模拟拨弦乐器的衰减感想上难度的可以做多八度切换用矩阵键盘扩展音域。这些扩展都在原来的三个模块上做加法架构不用动。这件事之后我养成了一个习惯每次做 FPGA 带时钟分频或音频输出的设计上板前都强制自己先在行为级仿真里量一遍真实输出频率确认误差在 0.5% 以内才去动开发板免得把“仿真过了但上板不对”的锅甩给玄学。这个项目最好的地方就在这儿它逼你理解分频、消抖和状态机的边界而不是调参数碰运气。希望帮到你。本文还有配套的精品资源点击获取