这一篇在干嘛?

上一首《梁祝》是 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 位的完整格式:

  1. 起始位,逻辑恒为 0;
  2. 8 个数据位,低位在先,时钟下降沿读取;
  3. 1 位奇偶校验位(奇校验),随后第 11 位是停止位,恒为 1;
  4. 必要时 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 一排):

KeyABCDEFGHIJKLMNO
Data1C322123242B3433433B424B3A3144
KeyPQRSTUVWXYZ0123
Data4D152D1B2C3C2A1D22351A45161E26
Key456789`-=\];,.
Data252E363D3E460E4E555D5B4C524149
Key/[F1F2F3F4F5F6F7F8F9F10F11F12KP0
Data4A540506040C030B830A0109780770
KeyKP1KP2KP3KP4KP5KP6KP7KP8KP9KPKP-KP+KP/KP*END
Data69727A6B73746C757D717B794A7C69
KeyBKSPSPACETABCAPSL SHFTL CTRLL CUIL ALTR SHFTR CTRLR CUI
Data66290D5812141F11591427
KeyR ALTAPPSENTERESCINSERTHOMEPG UPDELETEPG DNNUM
Data112F5A76706C7D717A77
KeyU ARROWL ARROWD ARROWR ARROWKP ENSCROLLPRINT SCRNPAUSE
Data756B72745A7E12 7C14

图 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 有效时 flagcount、输出寄存器全部归零、状态机回起点,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输入M16PS/2 时钟线
PS2DATA输入K16PS/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 显示对应简谱码。

现象与总结

这个实验的本质是「把外部串行协议翻译成内部并行数据,再复用已有的发声链路」,带走三件事:

  1. 协议解码三件套:同步(时钟打拍)、采样(下降沿移位接收)、成帧(状态机按位计数)。任何同步串行协议——SPI、I2C、UART——都能套这个框架。
  2. 查表换映射:键盘码到音符序号由 CODE3 决定,音符序号到频率由 F_CODE 决定,改映射不用动解码电路——数据与逻辑分离的好处。
  3. 复位与异常处理同样重要:对外设接口电路,复位路径的完整性和解码路径平级。

想再进一步:解析断码实现「松开按键音符就停」的钢琴手感,或用 SHIFT 扩展出高八度音区。

自测