这一篇在干嘛?
上一首《梁祝》是 FPGA 自己「照谱演奏」,这一篇反过来——把标准 PS/2 键盘插到开发板上,你按下数字键,蜂鸣器就发出对应音符,LED 同步显示简谱码。核心工作量在「听懂键盘」:解析 PS/2 协议的 11 位串行帧,把键盘通码翻译成音符。
实验目标与原理
实验任务原文:学习对 PS2 键盘数据程序的设计;掌握 PS2 键盘应用技术。
PS/2 协议是一种双向同步串行通信协议:主机与外设通过时钟线同步、通过数据线交换数据,双方都可控制,但主机优先级更高;时钟总是由外设产生,最大时钟频率 33kHz,多数外设工作在 10~20kHz,数据以短帧格式(11 或 12 位)传送。

图 1.1 PS2 键盘的时序图
读取基本键盘数据不需要 FPGA 发送任何东西——PS2 协议对数据的读取是「Clock 的下降沿」有效:每次键盘发送 11 个 clock 信号,我们要做的就是在每个时钟的下降沿读取数据线。一帧 11 位的完整格式:
- 起始位,逻辑恒为 0;
- 8 个数据位,低位在先,时钟下降沿读取;
- 1 位奇偶校验位(奇校验),随后第 11 位是停止位,恒为 1;
- 必要时 1 个答应位,用于主机对设备的通信。
只需要盯住 8 个数据位
对 PS2 进行解码时,除第 1~8 位数据位外,其余位都可以不做考虑——起始位、停止位没有信息量,校验位初学阶段可跳过。把精力集中在「下降沿采样、低位在先」这八个位上。
键盘的编码有「通码」(Make)和「断码」(Break)之分:通码是按键的「按下事件」,断码是「释放事件」,两者编码相互独立。本实验只处理通码——按下一个键,键盘发出该键的通码字节,FPGA 收到后查表得到音符,完整对照见后文图 2.4。
解码电路由两个功能块组成:「电平检测模块」检查 PS2 时钟信号的下降沿,使模块结构更简单;「ps2 解码模块」对每帧 11 位数据进行解码和过滤,最后输出一字节数据,并产生一个「确认完成」的高脉冲表示一次操作结束。由于 PS2 一帧数据的第 0 位是开始位,解码时无视;其后 8 位从最低位开始、最高位结束,把 PS2_Data_Pin_In 引脚上的读值转为并行数据存储即可。

图 2.2 键盘解码组合模块图

图 2.3 PS2 键盘控制模型电子琴电路 RTL 原理图
图 2.3 是顶层设计:除 PS2 通信模块 PS2_PIANO 和 CODE3 模块外,其余模块与电路功能、工作原理均与实验十相同。信号链是:PS2CLK/PS2DATA 进 PIANO 解码 → 输出 8 位键盘码 → CODE3 翻译成 4 位音符序号 key → F_CODE 查表得分频预置数 TO(与实验十三同款)→ SPKER 数控分频 → D 触发器均衡占空比 → 蜂鸣器 SPK 发声。
电气层面:PS/2 键盘接口是 6 脚连接器,其中 4 脚分别是时钟、数据、+5V 电源和地,键盘依靠 PS/2 端口供电。设备发送数据前先检查时钟端:若为低电平说明主机抑制通信,需缓存数据等待总线;若为高电平则开始发送。实际应用中键盘始终是发送方、由键盘产生时钟,因此 PS2 键盘的两个工作端口实际都是单向输出口。数据位在时钟高电平时准备好,主机在下降沿读取。
键盘码对照表
图 2.4 给出键名与输出码的完整对照(本实验主要用到数字键 0~9 一排):
| Key | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O |
| Data | 1C | 32 | 21 | 23 | 24 | 2B | 34 | 33 | 43 | 3B | 42 | 4B | 3A | 31 | 44 |
| Key | P | Q | R | S | T | U | V | W | X | Y | Z | 0 | 1 | 2 | 3 |
| Data | 4D | 15 | 2D | 1B | 2C | 3C | 2A | 1D | 22 | 35 | 1A | 45 | 16 | 1E | 26 |
| Key | 4 | 5 | 6 | 7 | 8 | 9 | ` | - | = | \ | ] | ; | ’ | , | . |
| Data | 25 | 2E | 36 | 3D | 3E | 46 | 0E | 4E | 55 | 5D | 5B | 4C | 52 | 41 | 49 |
| Key | / | [ | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10 | F11 | F12 | KP0 |
| Data | 4A | 54 | 05 | 06 | 04 | 0C | 03 | 0B | 83 | 0A | 01 | 09 | 78 | 07 | 70 |
| Key | KP1 | KP2 | KP3 | KP4 | KP5 | KP6 | KP7 | KP8 | KP9 | KP | KP- | KP+ | KP/ | KP* | END |
| Data | 69 | 72 | 7A | 6B | 73 | 74 | 6C | 75 | 7D | 71 | 7B | 79 | 4A | 7C | 69 |
| Key | BKSP | SPACE | TAB | CAPS | L SHFT | L CTRL | L CUI | L ALT | R SHFT | R CTRL | R CUI | ||||
| Data | 66 | 29 | 0D | 58 | 12 | 14 | 1F | 11 | 59 | 14 | 27 | ||||
| Key | R ALT | APPS | ENTER | ESC | INSERT | HOME | PG UP | DELETE | PG DN | NUM | |||||
| Data | 11 | 2F | 5A | 76 | 70 | 6C | 7D | 71 | 7A | 77 | |||||
| Key | U ARROW | L ARROW | D ARROW | R ARROW | KP EN | SCROLL | PRINT SCRN | PAUSE | |||||||
| Data | 75 | 6B | 72 | 74 | 5A | 7E | 12 7C | 14 | |||||||
图 2.4 PS2 键盘键控与输出码对照表
例如按下数字键 1,键盘发出的通码是 0x16;按下 9,通码是 0x46。CODE3 模块做的就是把这些键盘码映射到 4 位音符序号上。
Verilog 实现
【例 1-1】顶层模块,原文代码一字不改:
// Create Date: 2021/03/18 16:25:53
// Design Name: PS2 键盘电子琴
// Module Name: ps2_piano
module ps2_piano(
CLK,
PS2DATA,
PS2CLK,
RST,
SPK,
code
);
input CLK;
input PS2CLK;
input PS2DATA;
input RST;
output SPK;
output [3:0] code;
wire [3:0] code;
wire c0;
clk_div clk_div_inst(.clk(CLK),.clk1mhz(c0));
wire [7:0] ps2DOUT;
PIANO piano(
.reset(!RST),
.clk(CLK),
.ps2_clk(PS2CLK),
.ps2_data(PS2DATA),
.ps2_data1(ps2DOUT)
);
wire [3:0] key;
CODE3 code3 (
.DIN(ps2DOUT),
.KEY(key)
);
wire H;
wire [10:0] TO;
F_CODE f_code (
.INX(key),
.CODE(code),
.H(H),
.TO(TO)
);
wire SPKS;
SPKER spker(
.CLK(c0),
.TN(TO),
.SPKS(SPKS)
);
wire Q;
DFF dff (
.CLK(SPKS),
.D(!Q),
.Q(Q)
);
assign SPK = Q;
endmodule【例 1-2】键盘码解码模块 PIANO,本实验核心,原文代码一字不改(分片拼接为连续清单):
module PIANO (reset, clk, ps2_clk, ps2_data ,ps2_data1);
input reset; input clk; input ps2_clk; input ps2_data;
output[7:0] ps2_data1;
reg ps2_clk_s; reg enk; reg ps2_clk_r; reg flag; reg always_one;
reg[6:0] lowtimecount; reg[3:0] count;
reg[11:0] senddata;
integer TONE;
wire PreCLK; wire FullSpkS; wire ps2_data_s;
reg[7:0] ps2_data1; reg[7:0] ps2_data2; reg[7:0] ps2_data4;
parameter[10:0] ps2_f4 = 13'b10_11110100_0;
reg[8:0] q_data1_x; reg[8:0] q_data2_x; reg[8:0] q_data3_x; reg[8:0] q_data4_x;
reg[7:0] q_data1; reg[7:0] q_data2; reg[7:0] q_data3; reg[7:0] q_data4;
//----
parameter
m2_st0=0,m2_new=1,m2_st1=2,m2_st2=3,m2_st3=4,m2_st4=5,m2_st5=6,m2_st6=7;
parameter m2_st7=8,
m2_st8=9,m2_st9=10,m2_st10=11,m2_st11=12,m2_st12=13,m2_st13=14;
parameter
m2_st14=15,m2_st15=16,m2_st16=17,m2_st17=18,m2_st18=19,m2_st19=20,m2_st20=21;
parameter
m2_st21=22,m2_st22=23,m2_st23=24,m2_st24=25,m2_st25=26,m2_st26=27,m2_st27=28;
parameter
m2_st28=29,m2_st29=30,m2_st30=31,m2_st31=32,m2_st32=33,m2_st33=34,m2_st34=35;
parameter
m2_st35=36,m2_st36=37,m2_st37=38,m2_st38=39,m2_st39=40,m2_st40=41,m2_st41=42;
parameter m2_st42=43,m2_st43=44, m2_st44=45,m2_st45=46,m2_st46=47;
reg[5:0] m2_state; reg[5:0] m2_nexts; integer Tone2; reg[7:0] INDEXV;
//----
assign ps2_data_s = senddata[0];
always @(reset) begin
if (reset==1'b0) flag <= 1'b1 ; end
always @(negedge clk or posedge reset) begin : u1
if (reset==1'b1) begin lowtimecount <= {7{1'b0}} ; enk <= 1'b0 ; ps2_clk_s <= 1'b1 ;
end else begin if (flag == 1'b1)
begin if (lowtimecount < 100)
begin lowtimecount<=lowtimecount+1 ; ps2_clk_s<=1'b0 ; end
else if (lowtimecount < 120)
begin lowtimecount <= lowtimecount + 1 ; enk <= 1'b1 ; end
else begin ps2_clk_s <= 1'b1 ; enk <= 1'b0 ; end
end end end
always @(negedge clk) begin : u2
ps2_clk_r <= ps2_clk ; end
always @(negedge ps2_clk_r or posedge enk) begin : u3
if (enk == 1'b1) begin senddata <= {1'b1, ps2_f4} ; end
else begin senddata[11:0] <= {1'b1, senddata[11:1]} ; end
always @(posedge enk or negedge ps2_clk_r) begin : con
if (enk == 1'b1) begin count <= {4{1'b0}} ; end
else begin if (count < 15) //receive at rising_edfe
begin count <= count + 1 ; end
end end
always @(posedge clk) begin : u4
m2_state <= m2_nexts ; end
always @(posedge ps2_clk_r) begin : u5
if (count < 10) begin m2_nexts <= m2_st0 ; end
else begin
case (m2_state)
m2_st0 : begin m2_nexts<=m2_st1; end
m2_st1 : begin m2_nexts<=m2_st3; end
m2_st3 : begin m2_nexts<=m2_st4; end//start
m2_st4 : begin m2_nexts<=m2_st5; q_data1_x<={ps2_data, q_data1_x[8:1]}; end
m2_st5 : begin m2_nexts<=m2_st6; q_data1_x<={ps2_data, q_data1_x[8:1]}; end
m2_st6 : begin m2_nexts<=m2_st7; q_data1_x<={ps2_data, q_data1_x[8:1]}; end
m2_st7 : begin m2_nexts<=m2_st8; q_data1_x<={ps2_data, q_data1_x[8:1]}; end
m2_st8 : begin m2_nexts<=m2_st9; q_data1_x<={ps2_data, q_data1_x[8:1]}; end
m2_st9 : begin m2_nexts<=m2_st10; q_data1_x<={ps2_data, q_data1_x[8:1]}; end
m2_st10 : begin m2_nexts<=m2_st11; q_data1_x<={ps2_data, q_data1_x[8:1]}; end
m2_st11 : begin m2_nexts<=m2_st12; q_data1_x<={ps2_data, q_data1_x[8:1]}; end
m2_st12 : begin m2_nexts<=m2_st13; q_data1_x<={ps2_data, q_data1_x[8:1]};
q_data1 <= q_data1_x[8:1]; end
m2_st13 : begin m2_nexts<=m2_st14; end
m2_st14 : begin m2_nexts<=m2_st15; end
m2_st15 : begin m2_nexts<=m2_st16; q_data2_x<={ps2_data, q_data2_x[8:1]}; end
//data0
m2_st16 : begin m2_nexts<=m2_st17; q_data2_x<={ps2_data, q_data2_x[8:1]}; end
m2_st17 : begin m2_nexts<=m2_st18; q_data2_x<={ps2_data, q_data2_x[8:1]}; end
m2_st18 :
begin
m2_nexts <= m2_st19 ;
q_data2_x <= {ps2_data, q_data2_x[8:1]} ;
always_one <= ps2_data ;
end
m2_st19 :
begin
m2_nexts <= m2_st20 ;
q_data2_x <= {ps2_data, q_data2_x[8:1]} ;
end
m2_st20 :
begin
m2_nexts <= m2_st21 ;
q_data2_x <= {ps2_data, q_data2_x[8:1]} ;
end
m2_st21 :
begin
m2_nexts <= m2_st22 ;
q_data2_x <= {ps2_data, q_data2_x[8:1]} ;
end
m2_st22 :
begin
m2_nexts <= m2_st23 ;
q_data2_x <= {ps2_data, q_data2_x[8:1]} ; //data7
end
m2_st23 :
begin
m2_nexts <= m2_st24 ;
q_data2_x <= {ps2_data, q_data1_x[8:1]} ;
q_data2 <= q_data2_x[8:1] ;
end
m2_st24 :
begin
if (always_one == 1'b1)
begin
m2_nexts <= m2_st25 ; //stop2
end
else
begin
m2_nexts <= m2_st14 ;
end
end
m2_st25 :
begin
m2_nexts <= m2_st26 ;
end
m2_st26 :
begin
m2_nexts <= m2_st27 ;
q_data3_x <= {ps2_data, q_data3_x[8:1]} ; //data0
end
m2_st27 :
begin
m2_nexts <= m2_st28 ;
q_data3_x <= {ps2_data, q_data3_x[8:1]} ;
end
m2_st28 :
begin
m2_nexts <= m2_st29 ;
q_data3_x <= {ps2_data, q_data3_x[8:1]} ;
end
m2_st29 :
begin
m2_nexts <= m2_st30 ;
q_data3_x <= {ps2_data, q_data3_x[8:1]} ;
end
m2_st30 :
begin
m2_nexts <= m2_st31 ;
q_data3_x <= {ps2_data, q_data3_x[8:1]} ;
end
m2_st31 :
begin
m2_nexts <= m2_st32 ;
q_data3_x <= {ps2_data, q_data3_x[8:1]} ;
end
m2_st32 :
begin
m2_nexts <= m2_st33 ;
q_data3_x <= {ps2_data, q_data3_x[8:1]} ;
end
m2_st33 :
begin
m2_nexts <= m2_st34 ;
q_data3_x <= {ps2_data, q_data3_x[8:1]} ; //data7
end
m2_st34 :
begin
m2_nexts <= m2_st35 ;
q_data3_x <= {ps2_data, q_data1_x[8:1]} ;
q_data3 <= q_data3_x[8:1] ;
end
m2_st35 :
begin
m2_nexts <= m2_st36 ; //stop3
end
m2_st36 :
begin
m2_nexts <= m2_st37 ; //start
end
m2_st37 :
begin
m2_nexts <= m2_st38 ;
q_data4_x <= {ps2_data, q_data4_x[8:1]} ; //data0
end
m2_st38 :
begin
m2_nexts <= m2_st39 ;
q_data4_x <= {ps2_data, q_data4_x[8:1]} ;
end
m2_st39 :
begin
m2_nexts <= m2_st40 ;
q_data4_x <= {ps2_data, q_data4_x[8:1]} ;
end
m2_st40 :
begin
m2_nexts <= m2_st41 ;
q_data4_x <= {ps2_data, q_data4_x[8:1]} ;
end
m2_st41 :
begin
m2_nexts <= m2_st42 ;
q_data4_x <= {ps2_data, q_data4_x[8:1]} ;
end
m2_st42 :
begin
m2_nexts <= m2_st43 ;
q_data4_x <= {ps2_data, q_data4_x[8:1]} ;
end
m2_st43 :
begin
m2_nexts <= m2_st44 ;
q_data4_x <= {ps2_data, q_data4_x[8:1]} ;
end
m2_st44 :
begin
m2_nexts <= m2_st45 ;
q_data4_x <= {ps2_data, q_data4_x[8:1]} ; //data7
end
m2_st45 :
begin
m2_nexts <= m2_st46 ;
q_data4_x <= {ps2_data, q_data1_x[8:1]} ;
q_data4 <= q_data4_x[8:1] ;
end
m2_st46 :
begin
m2_nexts <= m2_st14 ; //stop4
end
default :
begin
end
endcase
end
end
always @(reset or q_data2)
begin : u6
if (reset == 1'b1)
begin
ps2_data1 <= {8{1'b0}} ;
end
else
begin
ps2_data1 <= q_data2 ;
end
end
endmodule读这段状态机的四个要点:
- 时钟先打两拍:
u2块用系统时钟把ps2_clk同步为ps2_clk_r,避免亚稳态,随后所有解码动作围绕ps2_clk_r的沿展开。 - 移位接收:状态机在连续状态里反复执行
q_dataX_x <= {ps2_data, q_dataX_x[8:1]}——每个时钟沿把数据线上的 1 位从左边塞进移位寄存器,8 次后一帧键盘码完成串并转换,正是「低位在先」的硬件写照。 - 多帧连续接收:从
m2_st14起连续安排 q_data2/3/4 三组接收段,对应键盘连续发出多字节帧(如断码 E0 前缀 + 键码)的情形;count计满 15 后回m2_st0重新同步。 - 复位路径:
reset有效时flag、count、输出寄存器全部归零、状态机回起点,u6块把q_data2送往输出ps2_data1。
PS/2 接口不支持热插拔
带电插拔可能损坏接口或键盘:上电前先插好键盘再给开发板上电。按键没反应时优先检查 SW5 是否拨到 BUZ 端、键盘是否为标准 PS/2 圆口(USB 转接的不一定按 PS/2 时序输出)。
引脚分配
原文管脚约束一字不改:
set_pin_assignment { CLK } { LOCATION = R7; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { PS2CLK } { LOCATION = M16; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { PS2DATA } { LOCATION = K16; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { RST } { LOCATION = A2; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { SPK } { LOCATION = H11; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { code[0] } { LOCATION = A4; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { code[1] } { LOCATION = A3; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { code[2] } { LOCATION = C10; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { code[3] } { LOCATION = B12; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }| 信号 | 方向 | 引脚 | 说明 |
|---|---|---|---|
| CLK | 输入 | R7 | 系统时钟 |
| PS2CLK | 输入 | M16 | PS/2 时钟线 |
| PS2DATA | 输入 | K16 | PS/2 数据线 |
| RST | 输入 | A2 | 复位按键 |
| SPK | 输出 | H11 | 蜂鸣器驱动 |
| code[3:0] | 输出 | A4/A3/C10/B12 | 简谱码 LED 显示 |
PS2CLK、PS2DATA 两个输入都开了上拉,与 PS/2 集电极开路、靠上拉维持高电平的电气特性一致。
上板流程与现象
将 PS/2 键盘连接到扩展模块对应 PS\2 接口。进行程序下载,程序下载完毕后,将 SW5 开关拨上(BUZ)端。当按下键盘上方一排 0-9 按键时,蜂鸣器演奏对应音符,此时 LED 灯会显示当前按下的音符对应的值。

流程归纳:插好键盘 → 上电 → 下载程序 → SW5 拨到 BUZ → 按数字键 0~9 弹奏 → LED 显示对应简谱码。
现象与总结
这个实验的本质是「把外部串行协议翻译成内部并行数据,再复用已有的发声链路」,带走三件事:
- 协议解码三件套:同步(时钟打拍)、采样(下降沿移位接收)、成帧(状态机按位计数)。任何同步串行协议——SPI、I2C、UART——都能套这个框架。
- 查表换映射:键盘码到音符序号由 CODE3 决定,音符序号到频率由 F_CODE 决定,改映射不用动解码电路——数据与逻辑分离的好处。
- 复位与异常处理同样重要:对外设接口电路,复位路径的完整性和解码路径平级。
想再进一步:解析断码实现「松开按键音符就停」的钢琴手感,或用 SHIFT 扩展出高八度音区。
自测
自测
- PS/2 一帧数据共多少位?各位的含义是什么?FPGA 应该在时钟的哪个沿采样?
- 通码和断码分别对应什么事件?本实验为什么只处理通码?
- 代码中
q_data1_x <= {ps2_data, q_data1_x[8:1]}实现了什么功能?为什么对应「低位在先」?- 为什么 PS2CLK 要先用系统时钟打一拍得到 ps2_clk_r 再使用?
- 按下数字键 1 时键盘发出的通码是什么?它经过哪些模块才变成蜂鸣器的声音?