设计并实现PS/2键盘接口控制器,掌握PS/2串行通信协议,实现扫描码的接收、奇偶校验和FIFO缓冲。PS/2是8位复古电脑最经典的键盘输入接口——理解它,你就理解了异步串行通信的核心原理。
PS/2接口诞生于1987年的IBM PS/2系列电脑,虽然已被USB取代,但它的协议设计精巧、实现简单,是学习串行通信的绝佳案例。PS/2协议的核心特点:
| 参数 | 最小值 | 典型值 | 最大值 |
|---|---|---|---|
| 时钟频率 | 10 KHz | ~15 KHz | 16.7 KHz |
| 时钟高电平 | 30 μs | - | - |
| 时钟低电平 | 30 μs | - | - |
| 数据到时钟建立时间 | 5 μs | - | - |
| 时钟到数据保持时间 | 5 μs | - | - |
PS/2键盘发送的不是ASCII码,而是扫描码(Scan Code)。IBM兼容键盘使用三套扫描码集,最常用的是第二套(Scan Code Set 2):
| 按键 | 通码(Make) | 断码(Break) | 说明 |
|---|---|---|---|
| A | 1C | F0 1C | 按下发1C,释放发F0+1C |
| B | 32 | F0 32 | 断码前缀固定为F0 |
| Enter | 5A | F0 5A | 回车键 |
| Space | 29 | F0 29 | 空格键 |
| Esc | 76 | F0 76 | ESC键 |
| Left Shift | 12 | F0 12 | 左Shift |
| Ctrl | 14 | F0 14 | 控制键 |
| Extended | E0 xx | E0 F0 xx | 扩展键前缀 |
我们的PS/2键盘控制器分为四个核心模块:
串行接收器是整个控制器的基础——它负责从PS/2的串行比特流中提取出完整的8位扫描码:
// ps2_keyboard.v - PS/2键盘接口控制器
// 功能:接收PS/2串行协议数据,输出扫描码
module ps2_keyboard (
input wire clk, // 系统时钟
input wire rst_n, // 异步复位(低有效)
input wire ps2_clk, // PS/2时钟线
input wire ps2_data, // PS/2数据线
output reg [7:0] scan_code, // 接收到的扫描码
output reg scan_ready, // 扫描码就绪标志
output reg scan_error, // 奇偶校验错误
input wire scan_ack // CPU应答(读取扫描码)
);
// PS/2时钟同步(消除亚稳态)
// 使用3级同步器,MTBF远超实际需求
reg [2:0] ps2_clk_sync;
reg [2:0] ps2_data_sync;
always @(posedge clk) begin
ps2_clk_sync <= {ps2_clk_sync[1:0], ps2_clk};
ps2_data_sync <= {ps2_data_sync[1:0], ps2_data};
end
// 下降沿检测:键盘在下降沿更新数据,我们在下降沿捕获
wire ps2_clk_falling = (ps2_clk_sync[2:1] == 2'b10);
// 接收状态机
localparam IDLE = 4'd0;
localparam START = 4'd1;
localparam D0 = 4'd2;
localparam D1 = 4'd3;
localparam D2 = 4'd4;
localparam D3 = 4'd5;
localparam D4 = 4'd6;
localparam D5 = 4'd7;
localparam D6 = 4'd8;
localparam D7 = 4'd9;
localparam PARITY = 4'd10;
localparam STOP = 4'd11;
reg [3:0] state;
reg [7:0] shift_reg; // 数据移位寄存器
reg parity_reg; // 奇偶校验计算
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= IDLE;
shift_reg <= 8'd0;
parity_reg <= 1'b0;
scan_code <= 8'd0;
scan_ready <= 1'b0;
scan_error <= 1'b0;
end else begin
// 应答后清除就绪标志
if (scan_ack && scan_ready)
scan_ready <= 1'b0;
if (ps2_clk_falling) begin
case (state)
IDLE: begin
if (ps2_data_sync[2] == 1'b0) begin
state <= D0;
shift_reg <= 8'd0;
parity_reg <= 1'b0;
scan_error <= 1'b0;
end
end
D0: begin
shift_reg[0] <= ps2_data_sync[2];
parity_reg <= parity_reg ^ ps2_data_sync[2];
state <= D1;
end
D1: begin
shift_reg[1] <= ps2_data_sync[2];
parity_reg <= parity_reg ^ ps2_data_sync[2];
state <= D2;
end
D2: begin
shift_reg[2] <= ps2_data_sync[2];
parity_reg <= parity_reg ^ ps2_data_sync[2];
state <= D3;
end
D3: begin
shift_reg[3] <= ps2_data_sync[2];
parity_reg <= parity_reg ^ ps2_data_sync[2];
state <= D4;
end
D4: begin
shift_reg[4] <= ps2_data_sync[2];
parity_reg <= parity_reg ^ ps2_data_sync[2];
state <= D5;
end
D5: begin
shift_reg[5] <= ps2_data_sync[2];
parity_reg <= parity_reg ^ ps2_data_sync[2];
state <= D6;
end
D6: begin
shift_reg[6] <= ps2_data_sync[2];
parity_reg <= parity_reg ^ ps2_data_sync[2];
state <= D7;
end
D7: begin
shift_reg[7] <= ps2_data_sync[2];
parity_reg <= parity_reg ^ ps2_data_sync[2];
state <= PARITY;
end
PARITY: begin
// 奇校验:数据位+校验位 = 奇数个1
if (parity_reg ^ ps2_data_sync[2])
state <= STOP;
else begin
scan_error <= 1'b1;
state <= IDLE;
end
end
STOP: begin
if (ps2_data_sync[2] == 1'b1) begin
scan_code <= shift_reg;
scan_ready <= 1'b1;
end else
scan_error <= 1'b1;
state <= IDLE;
end
default: state <= IDLE;
endcase
end
end
end
endmodule
接收到的原始扫描码需要解析出按键事件(按下/释放)和扩展键标志:
// ps2_scancode_parser.v - 扫描码解析器
// 功能:解析PS/2扫描码序列,识别通码/断码/扩展码
module ps2_scancode_parser (
input wire clk,
input wire rst_n,
input wire [7:0] scan_code,
input wire scan_ready,
output reg [7:0] key_code, // 按键码(去除F0/E0前缀)
output reg key_press, // 1=按下 0=释放
output reg key_extended, // 扩展键标志
output reg key_valid // 按键事件有效
);
localparam NORMAL = 2'd0; // 等待普通扫描码
localparam F0 = 2'd1; // 收到F0,等待断码
localparam E0 = 2'd2; // 收到E0,扩展键
localparam E0_F0 = 2'd3; // E0+F0,扩展键断码
reg [1:0] state;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= NORMAL;
key_code <= 8'd0;
key_press <= 1'b0;
key_extended <= 1'b0;
key_valid <= 1'b0;
end else begin
key_valid <= 1'b0; // 默认无效
if (scan_ready) begin
case (state)
NORMAL: begin
case (scan_code)
8'hF0: state <= F0; // 断码前缀
8'hE0: state <= E0; // 扩展键前缀
default: begin
key_code <= scan_code;
key_press <= 1'b1;
key_extended <= 1'b0;
key_valid <= 1'b1;
end
endcase
end
F0: begin
key_code <= scan_code;
key_press <= 1'b0;
key_extended <= 1'b0;
key_valid <= 1'b1;
state <= NORMAL;
end
E0: begin
if (scan_code == 8'hF0)
state <= E0_F0;
else begin
key_code <= scan_code;
key_press <= 1'b1;
key_extended <= 1'b1;
key_valid <= 1'b1;
state <= NORMAL;
end
end
E0_F0: begin
key_code <= scan_code;
key_press <= 1'b0;
key_extended <= 1'b1;
key_valid <= 1'b1;
state <= NORMAL;
end
endcase
end
end
end
endmodule
键盘输入速度不可预测——用户可能快速连按多个键。FIFO缓冲区确保不会丢失按键:
// keyboard_fifo.v - 键盘FIFO缓冲
// 功能:16深度FIFO,缓冲按键事件防止丢失
module keyboard_fifo (
input wire clk,
input wire rst_n,
// 写端口(来自扫描码解析器)
input wire [7:0] wr_data,
input wire wr_en,
// 读端口(CPU读取)
output reg [7:0] rd_data,
input wire rd_en,
// 状态
output reg fifo_empty,
output reg fifo_full,
output reg [4:0] fifo_count
);
reg [7:0] mem [0:15]; // 16×8 FIFO存储
reg [3:0] wr_ptr;
reg [3:0] rd_ptr;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_ptr <= 4'd0;
rd_ptr <= 4'd0;
fifo_count <= 5'd0;
fifo_empty <= 1'b1;
fifo_full <= 1'b0;
rd_data <= 8'd0;
end else begin
// 写操作
if (wr_en && !fifo_full) begin
mem[wr_ptr] <= wr_data;
wr_ptr <= wr_ptr + 4'd1;
fifo_count <= fifo_count + 5'd1;
end
// 读操作
if (rd_en && !fifo_empty) begin
rd_data <= mem[rd_ptr];
rd_ptr <= rd_ptr + 4'd1;
fifo_count <= fifo_count - 5'd1;
end
fifo_empty <= (fifo_count == 5'd0);
fifo_full <= (fifo_count == 5'd16);
end
end
endmodule
最终我们需要将扫描码映射为CPU可以直接处理的ASCII码:
// ps2_ascii_table.v - 扫描码到ASCII转换ROM
// 功能:将Scan Code Set 2映射为ASCII字符
module ps2_ascii_table (
input wire [7:0] scan_code,
input wire shift_on, // Shift键状态
input wire caps_lock, // Caps Lock状态
output reg [7:0] ascii_out,
output reg ascii_valid // 是否为可打印字符
);
// Scan Code Set 2 → ASCII 查找表(简化版)
always @(*) begin
ascii_valid = 1'b1;
if (!shift_on && !caps_lock) begin
case (scan_code)
8'h1C: ascii_out = 8'h61; // a
8'h32: ascii_out = 8'h62; // b
8'h21: ascii_out = 8'h63; // c
8'h23: ascii_out = 8'h64; // d
8'h24: ascii_out = 8'h65; // e
8'h2B: ascii_out = 8'h66; // f
8'h34: ascii_out = 8'h67; // g
8'h33: ascii_out = 8'h68; // h
8'h43: ascii_out = 8'h69; // i
8'h3B: ascii_out = 8'h6A; // j
8'h42: ascii_out = 8'h6B; // k
8'h4B: ascii_out = 8'h6C; // l
8'h3A: ascii_out = 8'h6D; // m
8'h31: ascii_out = 8'h6E; // n
8'h44: ascii_out = 8'h6F; // o
8'h4D: ascii_out = 8'h70; // p
8'h15: ascii_out = 8'h71; // q
8'h2D: ascii_out = 8'h72; // r
8'h1B: ascii_out = 8'h73; // s
8'h2C: ascii_out = 8'h74; // t
8'h3C: ascii_out = 8'h75; // u
8'h2A: ascii_out = 8'h76; // v
8'h1D: ascii_out = 8'h77; // w
8'h22: ascii_out = 8'h78; // x
8'h35: ascii_out = 8'h79; // y
8'h1A: ascii_out = 8'h7A; // z
8'h29: ascii_out = 8'h20; // Space
8'h5A: ascii_out = 8'h0D; // Enter
8'h76: ascii_out = 8'h1B; // Esc
8'h66: ascii_out = 8'h08; // Backspace
8'h0E: ascii_out = 8'h60; // `
8'h4E: ascii_out = 8'h2D; // -
8'h55: ascii_out = 8'h3D; // =
8'h54: ascii_out = 8'h5B; // [
8'h5B: ascii_out = 8'h5D; // ]
8'h5D: ascii_out = 8'h5C; // \
8'h4C: ascii_out = 8'h3B; // ;
8'h52: ascii_out = 8'h27; // '
8'h41: ascii_out = 8'h2C; // ,
8'h49: ascii_out = 8'h2E; // .
8'h4A: ascii_out = 8'h2F; // /
default: begin
ascii_out = 8'h00;
ascii_valid = 1'b0;
end
endcase
end
else begin
// Shift或Caps Lock状态:大写字母和符号
case (scan_code)
8'h1C: ascii_out = 8'h41; // A
8'h32: ascii_out = 8'h42; // B
8'h21: ascii_out = 8'h43; // C
8'h23: ascii_out = 8'h44; // D
8'h24: ascii_out = 8'h45; // E
8'h2B: ascii_out = 8'h46; // F
8'h34: ascii_out = 8'h47; // G
8'h33: ascii_out = 8'h48; // H
8'h43: ascii_out = 8'h49; // I
8'h3B: ascii_out = 8'h4A; // J
8'h42: ascii_out = 8'h4B; // K
8'h4B: ascii_out = 8'h4C; // L
8'h3A: ascii_out = 8'h4D; // M
8'h31: ascii_out = 8'h4E; // N
8'h44: ascii_out = 8'h4F; // O
8'h4D: ascii_out = 8'h50; // P
8'h15: ascii_out = 8'h51; // Q
8'h2D: ascii_out = 8'h52; // R
8'h1B: ascii_out = 8'h53; // S
8'h2C: ascii_out = 8'h54; // T
8'h3C: ascii_out = 8'h55; // U
8'h2A: ascii_out = 8'h56; // V
8'h1D: ascii_out = 8'h57; // W
8'h22: ascii_out = 8'h58; // X
8'h35: ascii_out = 8'h59; // Y
8'h1A: ascii_out = 8'h5A; // Z
8'h0E: ascii_out = 8'h7E; // ~
8'h4E: ascii_out = 8'h5F; // _
8'h55: ascii_out = 8'h2B; // +
8'h54: ascii_out = 8'h7B; // {
8'h5B: ascii_out = 8'h7D; // }
8'h5D: ascii_out = 8'h7C; // |
8'h4C: ascii_out = 8'h3A; // :
8'h52: ascii_out = 8'h22; // "
8'h41: ascii_out = 8'h3C; // <
8'h49: ascii_out = 8'h3E; // >
8'h4A: ascii_out = 8'h3F; // ?
default: begin
ascii_out = 8'h00;
ascii_valid = 1'b0;
end
endcase
end
end
endmodule
// tb_ps2_keyboard.v - PS/2键盘接收器测试平台
module tb_ps2_keyboard;
reg clk;
reg rst_n;
reg ps2_clk;
reg ps2_data;
wire [7:0] scan_code;
wire scan_ready;
wire scan_error;
reg scan_ack;
// 实例化被测模块
ps2_keyboard uut (
.clk(clk),
.rst_n(rst_n),
.ps2_clk(ps2_clk),
.ps2_data(ps2_data),
.scan_code(scan_code),
.scan_ready(scan_ready),
.scan_error(scan_error),
.scan_ack(scan_ack)
);
// 系统时钟:10ns周期 (100MHz)
initial clk = 0;
always #5 clk = ~clk;
// PS/2时钟:75us周期 (~13.3KHz)
localparam PS2_HALF = 37500; // 37.5us半周期
// 发送一个PS/2字节的任务
task send_ps2_byte;
input [7:0] data;
reg [7:0] d;
reg parity;
integer i;
begin
d = data;
parity = 1'b0;
for (i = 0; i < 8; i = i + 1)
parity = parity ^ d[i];
// 起始位 (0)
ps2_data = 1'b0;
#PS2_HALF; ps2_clk = 1'b0;
#PS2_HALF; ps2_clk = 1'b1;
// 数据位 (LSB first)
for (i = 0; i < 8; i = i + 1) begin
ps2_data = d[i];
#PS2_HALF; ps2_clk = 1'b0;
#PS2_HALF; ps2_clk = 1'b1;
end
// 奇校验位
ps2_data = ~parity;
#PS2_HALF; ps2_clk = 1'b0;
#PS2_HALF; ps2_clk = 1'b1;
// 停止位 (1)
ps2_data = 1'b1;
#PS2_HALF; ps2_clk = 1'b0;
#PS2_HALF; ps2_clk = 1'b1;
end
endtask
// 测试流程
initial begin
// 初始化
rst_n = 1'b0;
ps2_clk = 1'b1;
ps2_data = 1'b1;
scan_ack = 1'b0;
#100;
rst_n = 1'b1;
#100;
// 测试1:发送扫描码 0x1C (A键按下)
$display("--- Test 1: Send 0x1C (A key) ---");
send_ps2_byte(8'h1C);
#10000;
if (scan_ready && scan_code == 8'h1C)
$display(" PASS: Received 0x1C");
else
$display(" FAIL: Expected 0x1C, got 0x%02h, ready=%b", scan_code, scan_ready);
scan_ack = 1'b1;
#100;
scan_ack = 1'b0;
// 测试2:发送扫描码 0xF0 (断码前缀)
$display("--- Test 2: Send 0xF0 0x1C (A key released) ---");
send_ps2_byte(8'hF0);
#10000;
if (scan_ready && scan_code == 8'hF0)
$display(" PASS: Received F0 prefix");
scan_ack = 1'b1;
#100;
scan_ack = 1'b0;
send_ps2_byte(8'h1C);
#10000;
if (scan_ready && scan_code == 8'h1C)
$display(" PASS: Received break code 0x1C");
// 测试3:奇偶校验错误
$display("--- Test 3: Parity error test ---");
// 发送带错误校验位的数据(直接操纵信号)
ps2_data = 1'b0; // 起始位
#PS2_HALF; ps2_clk = 1'b0;
#PS2_HALF; ps2_clk = 1'b1;
// 发送0x55 = 01010101,正确奇校验=1,我们故意发0
ps2_data = 1'b1; #PS2_HALF; ps2_clk = 1'b0; #PS2_HALF; ps2_clk = 1'b1;
ps2_data = 1'b0; #PS2_HALF; ps2_clk = 1'b0; #PS2_HALF; ps2_clk = 1'b1;
ps2_data = 1'b1; #PS2_HALF; ps2_clk = 1'b0; #PS2_HALF; ps2_clk = 1'b1;
ps2_data = 1'b0; #PS2_HALF; ps2_clk = 1'b0; #PS2_HALF; ps2_clk = 1'b1;
ps2_data = 1'b1; #PS2_HALF; ps2_clk = 1'b0; #PS2_HALF; ps2_clk = 1'b1;
ps2_data = 1'b0; #PS2_HALF; ps2_clk = 1'b0; #PS2_HALF; ps2_clk = 1'b1;
ps2_data = 1'b1; #PS2_HALF; ps2_clk = 1'b0; #PS2_HALF; ps2_clk = 1'b1;
ps2_data = 1'b0; #PS2_HALF; ps2_clk = 1'b0; #PS2_HALF; ps2_clk = 1'b1;
// 错误的奇校验位
ps2_data = 1'b0; #PS2_HALF; ps2_clk = 1'b0; #PS2_HALF; ps2_clk = 1'b1;
#10000;
if (scan_error)
$display(" PASS: Parity error detected");
else
$display(" FAIL: Parity error NOT detected");
$display("--- All tests complete ---");
$finish;
end
endmodule
verilator --lint-only 检查,无错误无警告。
键盘控制器通过内存映射I/O与CPU通信:
| 地址 | 位 | 功能 | 读/写 |
|---|---|---|---|
| $FF02 | [7:0] | 键盘数据:FIFO中最前端的ASCII码 | R |
| $FF03 | [0] | 按键就绪:1=FIFO非空 | R |
| $FF03 | [1] | FIFO溢出:1=按键丢失 | R |
| $FF03 | [7:2] | FIFO中待读按键数 | R |
当前实现只处理了字母键的ASCII转换。请添加数字键0-9的扫描码到ASCII映射(扫描码:0x45=0, 0x16=1, 0x1E=2, 0x26=3, 0x25=4, 0x2E=5, 0x36=6, 0x3D=7, 0x3E=8, 0x46=9),并处理Shift+数字键产生符号(!@#$%^&*())。
PS/2协议是双向的——主机也可以向键盘发送命令。实现主机到键盘的发送功能:当主机拉低时钟线超过100μs再拉低数据线时,进入"命令模式"。键盘需要响应0xFF(复位)、0xED(设置LED)、0xF3(设置重复率)等命令。
在真实硬件中,PS/2信号可能存在噪声。添加超时检测机制:如果接收一个字节的过程中,两个PS/2时钟沿之间的间隔超过2ms,则重置接收状态机,丢弃不完整的数据。
编写一个完整的仿真测试:模拟用户输入"HELLO"的全过程——5个按键的通码发送,经过接收器→解析器→ASCII转换→FIFO缓冲,最终CPU端读出字符序列 0x48 0x45 0x4C 0x4C 0x4F。
你实现了从PS/2物理信号到ASCII字符的完整链路!这包括:
键盘输入是人机交互的基石——有了它,你的8位电脑终于可以"听"了!