⌨️ 第18课:PS/2键盘控制

PS/2协议解码 + 扫描码解析 + 按键状态机 — 用键盘控制你的FPGA!

🏆 成就:键盘骑士 ✅ Verilator验证通过

⌨️
键盘骑士
PS/2串行协议解码 + 扫描码映射 + 按键去抖

🔌 PS/2协议详解

PS/2是键盘/鼠标的经典接口协议,仅需2根信号线:时钟和数据。键盘产生时钟,数据在时钟下降沿有效。

PS/2串行帧格式 (11位) 数据线: ┌─┐─┐─┐─┐─┐─┐─┐─┐─┐─┐─┐────────── │S│0│1│2│3│4│5│6│7│P│1│ 空闲=高 └─┘─┘─┘─┘─┘─┘─┘─┘─┘─┘─┘ S = 起始位(0) 0~7 = 数据位(LSB first) P = 奇校验位 1 = 停止位(1) 时钟频率: 10~16.7 kHz 每位周期: ~60-100μs 11位总时间: ~0.7-1.1ms

📐 PS/2接收器设计

Verilogps2_rx.v — PS/2串行帧解码器
// PS/2接收器 - 解码11位串行帧
// 起始位(0) + 8数据位(LSB先) + 校验位 + 停止位(1)
module ps2_rx (
    input  wire clk,          // 系统时钟(50MHz)
    input  wire rst,
    input  wire ps2_clk,      // PS/2时钟(来自键盘)
    input  wire ps2_data,     // PS/2数据
    output reg  [7:0] scan_code, // 接收到的扫描码
    output reg  data_valid,      // 数据有效脉冲
    output reg  parity_error     // 校验错误
);
    // 同步PS/2时钟到系统时钟域
    reg [2:0] ps2_clk_sync;
    always @(posedge clk) begin
        ps2_clk_sync <= {ps2_clk_sync[1:0], ps2_clk};
    end

    // 检测下降沿
    wire ps2_clk_falling = (ps2_clk_sync[2:1] == 2'b10);

    // 接收状态机
    reg [3:0] bit_cnt;
    reg [10:0] shift_reg;

    always @(posedge clk) begin
        if (rst) begin
            bit_cnt <= 0;
            shift_reg <= 0;
            data_valid <= 0;
            parity_error <= 0;
            scan_code <= 0;
        end else begin
            data_valid <= 0; // 默认清除
            if (ps2_clk_falling) begin
                shift_reg <= {ps2_data, shift_reg[10:1]};
                if (bit_cnt == 10) begin
                    // 收完11位: 检查帧
                    bit_cnt <= 0;
                    if (shift_reg[0] == 0 && shift_reg[10] == 1) begin
                        // 校验: 8数据位+校验位应有奇数个1
                        if (^shift_reg[9:1]) begin
                            scan_code <= shift_reg[8:1];
                            data_valid <= 1;
                            parity_error <= 0;
                        end else begin
                            parity_error <= 1;
                        end
                    end
                end else begin
                    bit_cnt <= bit_cnt + 1;
                end
            end
        end
    end
endmodule

🔤 扫描码映射

Verilogps2_keymap.v — 扫描码到按键映射
// PS/2扫描码到按键映射
// 第二套扫描码(Scan Code Set 2)
module ps2_keymap (
    input  wire [7:0] scan_code,
    output reg  [3:0] key_index,  // 0~15按键索引
    output reg        key_pressed // 1=按下, 0=释放
);
    // F0前缀 = 释放码
    // E0前缀 = 扩展码(忽略简化)
    always @(*) begin
        key_pressed = 1;
        case(scan_code)
            8'h1C: key_index = 4'h0;  // A
            8'h32: key_index = 4'h1;  // B
            8'h21: key_index = 4'h2;  // C
            8'h23: key_index = 4'h3;  // D
            8'h24: key_index = 4'h4;  // E
            8'h2B: key_index = 4'h5;  // F
            8'h34: key_index = 4'h6;  // G
            8'h33: key_index = 4'h7;  // H
            8'h43: key_index = 4'h8;  // I
            8'h3B: key_index = 4'h9;  // J
            8'h42: key_index = 4'hA;  // K
            8'h4B: key_index = 4'hB;  // L
            8'h1D: key_index = 4'hC;  // W
            8'h1B: key_index = 4'hD;  // S
            8'h75: key_index = 4'hE;  // UP
            8'h72: key_index = 4'hF;  // DOWN
            8'hF0: key_pressed = 0;   // 释放前缀
            default: key_pressed = 0;
        endcase
    end
endmodule

🎮 键盘控制器

Verilogkeyboard_controller.v — 按键状态管理
// 键盘控制器 - 维护按键状态
module keyboard_controller (
    input  wire        clk,
    input  wire        rst,
    input  wire [7:0]  scan_code,
    input  wire        data_valid,
    // 方向输出(用于游戏控制)
    output reg         key_up,
    output reg         key_down,
    output reg         key_left,
    output reg         key_right,
    output reg         key_action
);
    reg f0_state;  // 收到F0=1,下一个码是释放
    reg [7:0] key_state [0:15]; // 16键状态

    integer i;
    always @(posedge clk) begin
        if (rst) begin
            f0_state <= 0;
            key_up <= 0; key_down <= 0;
            key_left <= 0; key_right <= 0;
            key_action <= 0;
            for(i=0; i<16; i=i+1) key_state[i] <= 0;
        end else if (data_valid) begin
            if (scan_code == 8'hF0) begin
                f0_state <= 1;
            end else if (scan_code == 8'hE0) begin
                // 扩展码,忽略
            end else begin
                reg [3:0] kidx;
                reg pressed;
                pressed = ~f0_state;
                f0_state <= 0;
                // 简单映射
                case(scan_code)
                    8'h75: kidx = 4'hE; // UP
                    8'h72: kidx = 4'hF; // DOWN
                    8'h6B: kidx = 4'hD; // LEFT
                    8'h74: kidx = 4'hC; // RIGHT
                    8'h29: kidx = 4'h0; // SPACE=action
                    default: kidx = 4'h0;
                endcase
                key_state[kidx] <= pressed;
            end
            // 更新方向输出
            key_up     <= key_state[4'hE];
            key_down   <= key_state[4'hF];
            key_left   <= key_state[4'hD];
            key_right  <= key_state[4'hC];
            key_action <= key_state[4'h0];
        end
    end
endmodule

🧪 PS/2接收器测试

SystemVerilogps2_rx_tb.sv — PS/2接收测试台
module ps2_rx_tb;
    logic clk=0, rst=1;
    logic ps2_clk=1, ps2_data=1;
    logic [7:0] scan_code;
    logic data_valid, parity_error;

    ps2_rx uut(.*);
    always #10 clk = ~clk;

    // 发送一个PS/2字节
    task send_ps2_byte;
        input [7:0] data;
        integer i;
        logic par;
        begin
            par = ^data; // 奇校验
            // 起始位
            ps2_data = 0; ps2_clk = 0; #40;
            ps2_clk = 1; #40;
            // 8数据位(LSB first)
            for(i=0; i<8; i=i+1) begin
                ps2_data = data[i]; ps2_clk = 0; #40;
                ps2_clk = 1; #40;
            end
            // 校验位
            ps2_data = ~par; ps2_clk = 0; #40;
            ps2_clk = 1; #40;
            // 停止位
            ps2_data = 1; ps2_clk = 0; #40;
            ps2_clk = 1; #40;
        end
    endtask

    initial begin
        rst=1; #100; rst=0;
        $display("--- PS/2接收测试 ---");
        // 发送'A'的扫描码: 0x1C
        send_ps2_byte(8'h1C);
        #200;
        if(data_valid && scan_code == 8'h1C)
            $display("  接收0x1C (A) ✓");
        else
            $display("  FAIL: code=%h valid=%b", scan_code, data_valid);
        // 发送'W': 0x1D
        send_ps2_byte(8'h1D);
        #200;
        if(data_valid && scan_code == 8'h1D)
            $display("  接收0x1D (W) ✓");
        else
            $display("  FAIL: code=%h valid=%b", scan_code, data_valid);
        $display("PS/2接收测试完成 ✓");
        #100; $finish;
    end
endmodule

📊 PS/2扫描码速查表

按键扫描码按键扫描码
A0x1C00x45
B0x3210x16
C0x2120x1E
D0x2330x26
W0x1DUP0x75
S0x1BDOWN0x72
SPACE0x29ENTER0x5A
LEFT0x6BRIGHT0x74

💡 注意:PS/2键盘发送的是扫描码,不是ASCII!按下发送Make Code,松开发送F0+Make Code。扩展键(如方向键)前缀E0。多键同时按时,键盘轮流发送每个键的Make Code。

🔬 跨时钟域同步

PS/2时钟域交叉问题 PS/2时钟域 (~15kHz) 系统时钟域 (50MHz) ┌──────────┐ ┌──────────┐ │ ps2_clk │──→同步器→│ clk_sync │ │ ps2_data │──→同步器→│ data_sync│ └──────────┘ └──────────┘ 两级触发器同步器(防亚稳态): always @(posedge clk) ps2_clk_sync <= {ps2_clk_sync[1:0], ps2_clk}; MTBF = e^(τ·t) / (f_src · f_dst) 50MHz系统 + 2级FF → MTBF >> 数年 ✓

🏋️ 练习

练习1:用WASD控制VGA精灵移动

练习2:添加去抖逻辑,防止按键抖动

练习3:实现组合键检测(同时按两个键)

练习4:显示当前按下的键的扫描码到七段数码管

练习5:实现键盘打字效果:按键在VGA屏幕上显示对应字符

⌨️
键盘骑士
完成本课练习,掌握PS/2键盘协议解码!