
简介面向西南交通大学数字电路课程设计基于EP4CE6E22开发板的FPGA电子琴系统完整项目包适合正在学习FPGA、Verilog硬件描述语言及数字电路实验的本科生。该设计覆盖从琴键扫描、按键消抖到蜂鸣器音频输出的完整链路实现36键模拟琴键、数码管音符显示、八度切换、自动演奏预设乐曲与实时响应等功能可作为数电综合实验的参考方案。压缩包共53个文件大小约237KB包含Verilog源码、工程备份、Quartus工程文件、TDF配置、MIDI乐曲、说明文档及Python转换脚本等目录划分清晰便于按功能模块查阅包内还提供自动演奏模块、状态切换器等关键设计并配有README与结构说明可直接在Quartus中打开工程理解各模块交互方式。目前已有117人学习下载。通过该设计可掌握数字电路时序逻辑设计、按键消抖、数码管驱动、分频计数、音频输出实时性及FPGA资源优化等关键技能是课程设计或FPGA入门实践的实用资料。1. 用 EP4CE6E22 做 36 键电子琴难的不是出声而是时序数电实验做到最后多数人还停在跑马灯能出声的少能弹出带八度切换的电子琴更少。这个基于 EP4CE6E22 开发板的 36 键电子琴系统是西南交大数电实验的课程设计作品36 个模拟琴键、蜂鸣器音频输出、数码管音符显示、八度切换、预置乐曲自动演奏都齐了。初看以为难点在音频实际动手后发现最影响体验的是时序机械按键按下有 5-20ms 抖动键盘扫描、按键消抖、计数器分频三件事落在同一个时钟域顺序不对就丢键、声音发飘。对做 FPGA 入门和数字电路课设的人它的模块拆分很值得抄对有经验的人来说亮点是用统一的音符索引把按键、显示、播放三条通路串起来。下面从顶层结构拆开讲。2. 顶层模块拆分与数据通路键盘扫描、按键去抖和蜂鸣器驱动如何衔接拆 FPGA 工程先拆数据通路这句话什么时候都好用。把fpga.v打开要解决的第一件事不是“声音怎么生成”而是“音符从哪里来”。这套工程的物理输入是 36 个按键输出是蜂鸣器和数码管控制核心是模式选择器。琴键按下后经过键盘扫描、按键去抖、模式选择最终进入分频器产生方波同时音符索引送进数码管显示缓冲。自动演奏模式下song_player.v从 ROM 读出音符序列替代实时按键作为分频器输入。两条路径在模式选择器汇合所以顶层最重要的信号就是那一组音符索引。工程里structure.png和codes.md已经把模块关系画出来了但实际走一遍代码会更清楚。2.1 36 个琴键怎么接矩阵扫描的选型理由EP4CE6E22 虽然 IO 够多但 36 个按键全用独立 IO 会让布线变复杂开发板的物理空间也未必放得下。常见做法是 6×6 矩阵扫描行线和列线各 6 根覆盖 36 个交点。轮流拉低一行读取列线的电平行列同时为低表示对应交点的按键被按下。扫描周期需要控制在 2-5ms 之内这直接决定了实时性人的快速连击可以短到 30ms 左右扫描一轮至少要快一个数量级按键才不会被丢。// keyboard_scanner.v行轮流拉低列输入输出当前按键行号 module keyboard_scanner( input wire clk, input wire [5:0] col_in, output reg [5:0] row_out, output reg [1:0] row_sel ); reg [9:0] scan_cnt; always (posedge clk) begin scan_cnt scan_cnt 1; row_sel scan_cnt[9:8]; // 行号 0-3完整工程应到 5 case (row_sel) 2d0: row_out 6b111110; 2d1: row_out 6b111101; 2d2: row_out 6b111011; 2d3: row_out 6b110111; endcase end endmodule这段代码里row_sel只有两 bit实际 6 行要扩成 3-bit 的scan_cnt[11:9]用 case 列出 6 种行驱动值每一行只有一个 0 其余是 10 对应被拉低的行。col_in必须配置成内部弱上拉否则行线为高时列线悬空噪声会被当成按键Quartus 里的上拉设置放在 QSF 中第 5 章会给出例子。scan_cnt用高位截取是为了省掉每行单独的计数器代价是各行扫描时间不完全均等但对音频触发来说这个误差完全可以忽略。2.2 顶层例化关系与信号约定fpga.v的例化顺序把数据通路画得很清楚。键盘扫描器输出行列信息经过行列组合译码得到按键索引去抖器处理 36 个按键中的每一个输入模式选择器在实时路径上叠加八度和模式自动播放与录音回放把各自的音符索引送到同一个分频器输入。seven_segment_decoder.v和scrolling_display_buffer.v属于显示通路它们只消费音符索引不参与音频路径。模块文件数据通路位置对外输出的关键内容keyboard_scanner.v输入采集按键位置 / 行列扫描码debouncer.v输入净化稳定后的按键电平mode_sequencer.v模式调度模式位、八度偏移song_player.v / practice_player.v自动演奏音符索引 持续时间piano_recorder.v录音回放录制的音符序列分频逻辑可写在 fpga.v 内核心发音蜂鸣器方波scrolling_display_buffer.v显示缓冲多位数码管位选seven_segment_decoder.v显示译码七段码模块之间的“音符索引”约定是这套工程最耐看的地方。工程直接用 MIDI 音高号当索引C4 是 60C#4 是 61B4 是 71C5 是 72。这样song_player.v从 ROM 里读出的数可以原样送给分频器实时演奏时键盘扫描器输出 0-35 的键号经过key_base key_idx octave_offset变成 MIDI 音高号。显示模块拿到的不是 C、D、E 的 ASCII而是数字音高号由seven_segment_decoder.v把它换算成音名段码。// fpga.v 顶层数据选择自动/回放 与 实时按键 两路共用分频器 wire [8:0] key_note; // 实时模式按键 八度偏移 wire [8:0] seq_note; // 自动播放或录音回放 assign speaker_note mode_auto ? seq_note : key_note; tone_gen u_tone( .clk (clk_50m), .note_idx (speaker_note), .speaker (beep) );mode_auto来自mode_sequencer.vtone_gen是分频器模块的命名。这样接的好处是新增模式不用改分频器文件里那些.bak后缀比如song_player.v.bak、mode_sequencer.v.bak其实就是早期版本让各模块直接输出频率值后来调了很多轮才收敛成统一输出音高号工程演化痕迹都留在这些备份文件里。3. 分频、消抖与动态扫描让音符频率和数码管显示同时稳住的 Verilog 细节这一章全是数字电路基础内容的实战化。键盘扫描、电平消抖、分频、动态扫描在课本里各是一节但组合进一个项目时优先级和参数选择都得重新排。按我的习惯顺序是消抖、分频、显示因为消抖问题不解决后面调音准和显示都会看到假象。3.1 按键消抖的计数窗口与状态机写法机械按键按下和释放都有 5-20ms 抖动直接把 GPIO 读进来的信号送进分频器会出现两种很影响体验的现象按一下发两个音或者声音音量抖动。工程里的debouncer.v用“同步 窗口计数”消抖两级触发器把异步信号同步到时钟域然后连续计数只有电平保持稳定超过 10ms 才接受这次变化中途有跳变就清空计数重新等待。// debouncer.vCNT_MAX 在 50MHz 下取 500000 即 10ms module debouncer #(parameter CNT_MAX 500_000) ( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); reg [19:0] cnt; reg sync1, sync2, sync3; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync1 1b0; sync2 1b0; sync3 1b0; cnt 0; key_out 1b0; end else begin sync1 key_in; sync2 sync1; // 两级同步消除亚稳态 if (sync2 ! sync3) begin // 有变化重新计时 cnt 0; sync3 sync2; end else if (cnt CNT_MAX) begin key_out sync3; // 稳定时间足够更新输出 end else begin cnt cnt 1; end end end endmoduleCNT_MAX是消抖窗口500000 在 50MHz 下就是 10ms。这个值不是越大越好窗口越大两次按键之间的最小间隔越长快速琶音会丢音。如果实测发现个别老化的按键偶尔连发合理做法是把窗口调到 15ms而不是改消抖逻辑本身。还有一种“边沿消抖”只认下降沿对自动演奏这类需要识别按下的场合够用但录音和练习模式要区分按下和释放必须保留电平窗口。3.2 音符频率生成计数器分频与查表蜂鸣器发声不需要 DDS也不需要查正弦表。无源蜂鸣器用方波驱动就能出基频谐波成分反而让声音有辨识度。分频原理是把 50MHz 时钟数到半周期翻转一次得到占空比 50% 的方波翻转周期就是目标频率。每个音符对应一个半周期计数值放在一个只读查找表里。// tone_gen.vnote_idx 是 MIDI 音高号60 为中央 C module tone_gen( input wire clk, input wire [8:0] note_idx, output reg speaker ); reg [15:0] half_cnt; reg [15:0] cnt; always (*) begin case (note_idx) 9d60: half_cnt 16d95542; // C4 261.63Hz 9d61: half_cnt 16d90194; // C#4 277.18Hz 9d62: half_cnt 16d85133; // D4 293.66Hz 9d64: half_cnt 16d75829; // E4 329.63Hz 9d65: half_cnt 16d71586; // F4 349.23Hz 9d67: half_cnt 16d63776; // G4 392.00Hz 9d69: half_cnt 16d56818; // A4 440.00Hz 9d71: half_cnt 16d50608; // B4 493.88Hz 9d72: half_cnt 16d47771; // C5 523.25Hz default: half_cnt 16d0; endcase end always (posedge clk) begin if (half_cnt 0) begin speaker 1b0; // 休止输出固定电平 cnt 16d0; end else if (cnt half_cnt) begin cnt 0; speaker ~speaker; // 半周期到翻转方波 end else begin cnt cnt 1; end end endmodule半周期系数的计算式是half_cnt f_clk / (2 * f_note)50MHz 下 A4 得到 56818表中取这个值实际输出是 440.006Hz误差不到 0.01%。要注意的是half_cnt必须用整数C4 的 95542 余数很小低音区比如 E2 的 65Hz计数值变大舍入误差占的比例反而变小所以整体音准在中高音区都比较稳。音符MIDI 号频率(Hz)half_cnt(50MHz)实际频率(Hz)C460261.6395542261.66E464329.6375829329.69A469440.0056818440.01C572523.2547771523.333.3 数码管动态扫描与显示缓冲数码管显示在工程里不是独立的一块逻辑它受消抖后的按键和播放状态共同驱动。如果直接取扫描器的原始列值去显示按下瞬间会闪现一串乱码必须在消抖之后更新显示缓冲。scrolling_display_buffer.v保存最近几个音符seven_segment_decoder.v把音高号转成七段码两部分通过动态扫描串起来。// scrolling_display_buffer.v每 1ms 刷新一位点亮对应数码管 reg [2:0] disp_pos; always (posedge clk) begin if (refresh_tick) begin disp_pos disp_pos 1; case (disp_pos) 0: begin seg_data note_name[disp_pos]; seg_bit 3b110; end 1: begin seg_data note_name[disp_pos]; seg_bit 3b101; end 2: begin seg_data note_name[disp_pos]; seg_bit 3b011; end endcase end end刷新率要大于 100Hz 才不会闪这里 1ms 刷一位是安全的。seg_bit的位序和共阳共阴相关共阳极数码管用低电平点亮位选常数和段码都要按低有效设计反了会出现“鬼影”或亮度不均。还有个细节段码输出后要等一个时钟周期再切位选避免切换瞬间把上一段字形闪到新一位上。调试时可以临时把刷新节拍降到 10Hz直接看每一位的亮度很快能定位是哪一位没有正常导通。4. 预置乐曲自动演奏MIDI 转 ROM 的 Python 脚本与播放状态机自动演奏是这个课程设计的加分项也是最体现工程完整度的地方。FPGA 里直接解析 MIDI 不现实做法是用上位机把 MIDI 文件转成“音高、时长”对写入 FPGA 的 ROM然后一个状态机按节拍输出。4.1 convert.py 如何把 MIDI 转成 ROM 初始化文件工程里有一批 MIDIbadapple.mid、极乐净土.mid、国际歌.mid、lemon.mid 等配套的convert.py要解决 MIDI 格式的两个麻烦VLQ 变长时间和多轨事件交错。这些在 PC 端做一次转换就够不需要写进 Verilog。# convert.py 核心逻辑从 note_on / note_off 提取音高和起止时间 import mido def midi_to_events(path): mid mido.MidiFile(path) tick 0 active {} events [] for msg in mid: tick msg.time if msg.type note_on and msg.velocity 0: active[msg.note] tick elif msg.type in (note_off, note_on) and msg.note in active: start active.pop(msg.note) events.append((start, tick, msg.note)) return sorted(events)tick是 MIDI 内部时间单位最终要乘ticks_per_beat再换算成毫秒。因为蜂鸣器是单音输出同一时刻多个音符同时发声时只能保留一个保留策略常见有两种选音量最大的或选音高最高的。工程里预置乐曲多为主旋律单音轨转换后数据量很小几百个音符最多占几百个字块 RAM 完全放得下。convert.py最终输出 Verilog 可读的文本每行包含起始时间、音高、持续时间。Quartus 工程里出现的altsyncram_0tg1.tdf就是生成的 ROM IP 包装初始化文件由$readmemh加载或者在 MegaWizard 里直接指定.mif。提示MIDI 的 tick 基准各不相同转换脚本里ticks_per_beat必须参与计算否则同一首歌在不同 MIDI 文件里播放速度会差很多这是自动播放最容易被忽略的坑。4.2 song_player 状态机时间基准、地址跳跃与单音输出播放状态机的核心是“把一个音符播够时长再取下一个”。song_player.v的骨架是四个状态空闲、取指、发音、等待。等待状态用计数器产生 1ms tickdur_cnt存的是 tick 数不是毫秒数这样改主频不用改 ROM 数据。// song_player.v单音播放状态机示意 parameter IDLE 2d0, FETCH 2d1, PLAY 2d2, WAIT 2d3; reg [1:0] state; reg [7:0] rom_addr; reg [8:0] play_note; reg [15:0] dur_cnt; always (posedge clk) begin case (state) IDLE: if (play_start) state FETCH; FETCH: begin play_note note_rom[rom_addr][15:8]; dur_cnt note_rom[rom_addr][7:0]; rom_addr rom_addr 1; state PLAY; end PLAY: state WAIT; WAIT: if (dur_cnt 0) begin if (rom_addr ROM_LEN) state IDLE; else state FETCH; end else begin if (tick_1ms) dur_cnt dur_cnt - 1; end endcase endWAIT里的tick_1ms是独立分频器生成的节拍信号dur_cnt每个 tick 减一减到零就去取下一个音符。PLAY状态只停一拍但它是必须的给分频器至少一个周期去加载新的half_cnt否则下一个音可能沿用上一个音的分频值产生一个短促的错音。地址推进放在FETCH里不能放在WAIT出口否则计时和取指会互相拖累乐曲会越来越慢。状态进入条件退出条件关键输出IDLE复位完成play_start 有效音符清零蜂鸣器静音FETCHplay_start 或 WAIT 结束ROM 读取完成载入音符和时长地址 1PLAYFETCH 完成一个时钟周期分频器加载新的分频值WAITPLAY 完成dur_cnt 减到 0节拍计数消耗时长4.3 模式切换、八度偏移与录音回放mode_sequencer.v处理的不只是手动/自动切换八度偏移也在这里。实时演奏时键盘输出的 0-35 表示半音顺序要变成 MIDI 音高号才方便和自动播放互通。计算公式里最容易写错的就是八度偏移它不是乘一个二进制位而是乘 12。// 八度偏移把按键音高平移 12 个半音 reg [7:0] oct_offset; always (*) begin case (octave) 2d0: oct_offset 8d0; 2d1: oct_offset 8d12; 2d2: oct_offset 8d24; 2d3: oct_offset 8d36; endcase end assign real_note key_base key_idx oct_offset;如果把real_note直接写成key_base key_idx {octave, 4b0}就变成乘 16 而不是乘 12每个八度都偏高 4 个半音。数码管显示音名时通常只显示 0-11 的 pitch class所以这种错误从显示看不出来但示波器测频率就会对不上。验证方法是用示波器看蜂鸣器输出周期或者在录音后和原曲并轨对比。录音回放piano_recorder.v是 song_player 的镜像录音时把key_note和经过消抖后的击键时间一起写入 RAM回放时从 RAM 读出音符给 mode_sequencer 的 mux。练习模式practice_player.v也走这条路区别在于它把“当前应该按哪个键”也编码进 ROM比较实际按键和期望按键演示或验收时不容易翻车。5. Quartus 工程约束与调试技巧让蜂鸣器在 EP4CE6E22 上稳定出声排错优先级应该是先让一个按键发声再让八度切换准确最后调自动播放。这个顺序能最大限度减少变量。工程里的fpga.qsf、fpga.qpf就是 Quartus 工程文件改引脚和约束都在这两个文件里。5.1 引脚绑定、上拉与时钟约束的一线做法拿到工程后第一件事不是打开顶层代码而是确认fpga.qsf里的引脚分配。EP4CE6E22 是 BGA 封装很多引脚带多路复用功能位置必须和开发板原理图对得上。set_location_assignment PIN_A7 -to clk_50m set_location_assignment PIN_B7 -to col_in[0] set_location_assignment PIN_C7 -to col_in[1] set_instance_assignment -name IO_STANDARD 3.3-V LVCMOS -to col_in[0] set_instance_assignment -name WEAK_PULL_UP_RESISTOR ON -to col_in[0] create_clock -name clk_50m -period 20.000 [get_ports {clk_50m}]PIN_A7、PIN_B7是示例占位实际引脚要看开发板原理图。列输入必须开弱上拉这是矩阵扫描能正常工作的前提create_clock告诉时序分析器输入时钟 50MHz否则分频和自动演奏的计数器时序约束会全红。数码管和蜂鸣器引脚同样要配 IO 标准开发板若用 3.3V 电平就统一写3.3-V LVCMOS裸跑默认 LVTTL 在部分板子上能工作但没必要留隐患。调试时最有效的做法是把内部信号引到开发板 LED 上。把mode_sequencer.v的state和消抖后的key_note引出来快速按同一个键看 LED 是否只闪一次闪两次说明消抖窗口不够按下去超过 100ms 才有反应说明扫描周期和消抖时长累加过头需要减小CNT_MAX或加快扫描。蜂鸣器接示波器测 60 号音符周期应该是约 3.82ms差太多就回头查half_cnt是按 50MHz 还是 25MHz 算的。现象常见原因检查方法自动播放越来越慢WAIT 状态计时与地址推进不同步核对 tick_1ms 是否每个时钟周期只加一次高音区偏低分频系数按 50MHz 算但板载晶振是 25MHz用示波器测 clk_50m 实测频率按键偶发连音消抖窗口过短或列线未开上拉调大 CNT_MAX检查 QSF 上拉数码管亮度不均位选和段码更新不同步段码先寄存一拍再切换位选工程里自带一条验证路径把practice_player.v当自检程序上电后等待琴键输入弹对了进入下一句弹错停在当前行整个过程数码管会显示乐谱位置。演示或答辩前先全程跑一遍练习模式按键扫描、消抖、分频、显示、模式切换五条链路一次验收完比临时写测试模块直接得多。本文还有配套的精品资源点击获取