引言:矩阵扫描的本质

矩阵键盘的核心思想是用较少的IO口控制更多按键。例如4x4矩阵只需8个IO(4行+4列)即可管理16个按键,相比独立按键节省一半资源。其原理基于行列交叉检测:将行线设为输出,列线设为输入(或反之),通过逐行拉低(或拉高)并读取列状态,即可定位被按下的按键。

但实际工程中,机械抖动、IO浮空、多键按下等问题会严重影响可靠性。本文将从底层原理到状态机实现,给出一个可复用的解决方案。

硬件连接与IO配置

典型电路

  • 行线(ROW0~ROW3)接单片机GPIO,配置为推挽输出,默认输出高电平。
  • 列线(COL0~COL3)接GPIO,配置为上拉输入(内部上拉或外部10kΩ上拉电阻)。
  • 每个按键跨接在行线与列线交叉点。

注意:若使用外部上拉,建议阻值在4.7kΩ~10kΩ之间,过小会增加功耗,过大会降低抗干扰能力。

扫描原理

  1. 将所有行线输出高电平,列线读取全为高(无按键按下)。
  2. 将第0行拉低,读取所有列线。若某列变低,则说明该行该列交叉处的按键被按下。
  3. 恢复第0行为高,拉低第1行,重复读取。依次扫描所有行。

这种逐行扫描方式称为行列扫描法,时间复杂度为O(行数×列数)。

软件架构:轮询与中断的权衡

轮询扫描

  • 优点:实现简单,无需额外硬件资源。
  • 缺点:占用CPU时间,若主循环任务繁重,可能漏检快速按键。

定时器中断扫描

  • 优点:扫描周期固定,不阻塞主循环,响应及时。
  • 缺点:需占用一个定时器,且中断服务函数需尽量精简。

推荐方案:使用定时器中断(如1ms周期)触发扫描函数,在中断中完成行列读取和状态机更新,主循环只查询按键事件队列。

消抖状态机设计

机械按键在按下和释放瞬间会产生约5~20ms的抖动,表现为电平多次跳变。传统延时消抖(如延时20ms再确认)会阻塞CPU,且不适用于矩阵扫描。

状态机模型

定义三个状态:

  • IDLE:无按键按下,等待首次检测到低电平。
  • DEBOUNCE_PRESS:检测到低电平,开始计时,若持续稳定低电平超过阈值(如10ms),则确认按下,进入PRESSED状态。
  • PRESSED:按键已确认按下,等待释放(高电平),并再次消抖确认释放。

每个按键独立维护一个状态机实例,扫描函数每次更新所有按键的状态。

状态转移图

IDLE --(检测到低)--> DEBOUNCE_PRESS
DEBOUNCE_PRESS --(持续低>10ms)--> PRESSED (触发按键事件)
DEBOUNCE_PRESS --(出现高)--> IDLE (抖动,取消)
PRESSED --(检测到高)--> DEBOUNCE_RELEASE
DEBOUNCE_RELEASE --(持续高>10ms)--> IDLE
DEBOUNCE_RELEASE --(出现低)--> PRESSED (抖动,忽略)

完整代码实现(以STM32为例)

以下代码使用定时器中断(1ms周期)调用MatrixScan_Tick(),状态机在中断上下文中运行。

// matrix_keyboard.h
#ifndef __MATRIX_KEYBOARD_H
#define __MATRIX_KEYBOARD_H

#include <stdint.h>

typedef enum {
    KEY_STATE_IDLE = 0,
    KEY_STATE_DEBOUNCE_PRESS,
    KEY_STATE_PRESSED,
    KEY_STATE_DEBOUNCE_RELEASE
} KeyState_t;

typedef struct {
    KeyState_t state;
    uint8_t debounce_cnt;
    uint8_t event_pressed;  // 按下事件标志(1次)
    uint8_t event_released; // 释放事件标志
} Key_t;

void Matrix_Init(void);
void Matrix_ScanTick(void);  // 1ms调用一次
uint8_t Matrix_GetKeyEvent(uint8_t row, uint8_t col); // 返回1=按下事件,2=释放事件,0=无

#endif
// matrix_keyboard.c
#include "matrix_keyboard.h"
#include "stm32f1xx_hal.h"

#define ROWS 4
#define COLS 4
#define DEBOUNCE_THRESHOLD 10  // 10ms

// 假设行线接PA0-PA3,列线接PB0-PB3
#define ROW_PORT GPIOA
#define COL_PORT GPIOB
#define ROW_PIN_0 GPIO_PIN_0
#define ROW_PIN_1 GPIO_PIN_1
#define ROW_PIN_2 GPIO_PIN_2
#define ROW_PIN_3 GPIO_PIN_3
#define COL_PIN_0 GPIO_PIN_0
#define COL_PIN_1 GPIO_PIN_1
#define COL_PIN_2 GPIO_PIN_2
#define COL_PIN_3 GPIO_PIN_3

static Key_t keys[ROWS][COLS];

static uint8_t ReadColumnState(uint8_t col) {
    uint16_t pin = 0;
    switch(col) {
        case 0: pin = COL_PIN_0; break;
        case 1: pin = COL_PIN_1; break;
        case 2: pin = COL_PIN_2; break;
        case 3: pin = COL_PIN_3; break;
    }
    return (HAL_GPIO_ReadPin(COL_PORT, pin) == GPIO_PIN_RESET) ? 1 : 0; // 低电平有效
}

static void SetRowOutput(uint8_t row, uint8_t level) {
    uint16_t pin = 0;
    switch(row) {
        case 0: pin = ROW_PIN_0; break;
        case 1: pin = ROW_PIN_1; break;
        case 2: pin = ROW_PIN_2; break;
        case 3: pin = ROW_PIN_3; break;
    }
    HAL_GPIO_WritePin(ROW_PORT, pin, level ? GPIO_PIN_SET : GPIO_PIN_RESET);
}

void Matrix_Init(void) {
    // 初始化GPIO:行线推挽输出,列线上拉输入
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOB_CLK_ENABLE();
    
    // 行线配置
    GPIO_InitStruct.Pin = ROW_PIN_0|ROW_PIN_1|ROW_PIN_2|ROW_PIN_3;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(ROW_PORT, &GPIO_InitStruct);
    
    // 列线配置
    GPIO_InitStruct.Pin = COL_PIN_0|COL_PIN_1|COL_PIN_2|COL_PIN_3;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(COL_PORT, &GPIO_InitStruct);
    
    // 所有行输出高
    HAL_GPIO_WritePin(ROW_PORT, ROW_PIN_0|ROW_PIN_1|ROW_PIN_2|ROW_PIN_3, GPIO_PIN_SET);
    
    // 清空按键状态
    for(int r=0; r<ROWS; r++) {
        for(int c=0; c<COLS; c++) {
            keys[r][c].state = KEY_STATE_IDLE;
            keys[r][c].debounce_cnt = 0;
            keys[r][c].event_pressed = 0;
            keys[r][c].event_released = 0;
        }
    }
}

void Matrix_ScanTick(void) {
    // 逐行扫描
    for(int row=0; row<ROWS; row++) {
        // 拉低当前行
        SetRowOutput(row, 0);
        // 短暂延时让电平稳定(可选,若IO速度足够可省略)
        for(volatile int i=0; i<10; i++);
        
        // 读取所有列
        for(int col=0; col<COLS; col++) {
            uint8_t pressed = ReadColumnState(col); // 1表示按下(低电平)
            Key_t *key = &keys[row][col];
            
            switch(key->state) {
                case KEY_STATE_IDLE:
                    if(pressed) {
                        key->state = KEY_STATE_DEBOUNCE_PRESS;
                        key->debounce_cnt = 0;
                    }
                    break;
                    
                case KEY_STATE_DEBOUNCE_PRESS:
                    if(pressed) {
                        if(++key->debounce_cnt >= DEBOUNCE_THRESHOLD) {
                            key->state = KEY_STATE_PRESSED;
                            key->event_pressed = 1;  // 触发按下事件
                        }
                    } else {
                        // 抖动,复位
                        key->state = KEY_STATE_IDLE;
                        key->debounce_cnt = 0;
                    }
                    break;
                    
                case KEY_STATE_PRESSED:
                    if(!pressed) {
                        key->state = KEY_STATE_DEBOUNCE_RELEASE;
                        key->debounce_cnt = 0;
                    }
                    break;
                    
                case KEY_STATE_DEBOUNCE_RELEASE:
                    if(!pressed) {
                        if(++key->debounce_cnt >= DEBOUNCE_THRESHOLD) {
                            key->state = KEY_STATE_IDLE;
                            key->event_released = 1; // 触发释放事件
                        }
                    } else {
                        // 抖动,回到按下状态
                        key->state = KEY_STATE_PRESSED;
                        key->debounce_cnt = 0;
                    }
                    break;
            }
        }
        // 恢复当前行为高
        SetRowOutput(row, 1);
    }
}

uint8_t Matrix_GetKeyEvent(uint8_t row, uint8_t col) {
    if(row >= ROWS || col >= COLS) return 0;
    Key_t *key = &keys[row][col];
    if(key->event_pressed) {
        key->event_pressed = 0;
        return 1;
    }
    if(key->event_released) {
        key->event_released = 0;
        return 2;
    }
    return 0;
}

主循环使用示例

// main.c
int main(void) {
    HAL_Init();
    Matrix_Init();
    // 配置定时器1ms中断调用Matrix_ScanTick()
    
    while(1) {
        for(int r=0; r<4; r++) {
            for(int c=0; c<4; c++) {
                uint8_t evt = Matrix_GetKeyEvent(r, c);
                if(evt == 1) {
                    // 处理按下:例如打印键值 r*4+c
                } else if(evt == 2) {
                    // 处理释放
                }
            }
        }
        // 其他任务
    }
}

进阶优化与注意事项

  • 多键按下处理:上述代码支持任意多键同时按下(每个按键独立状态机)。若需检测组合键,可在主循环中查询所有按键的按下状态,而非仅依赖事件。
  • 扫描周期选择:1ms扫描周期配合10ms消抖阈值,可有效滤除抖动。若系统时钟紧张,可改为2ms扫描,但消抖阈值需相应调整(保持总时间10ms左右)。
  • IO电平反转:若列线使用下拉输入,则需将扫描逻辑反转(行线默认低,拉高检测)。务必根据实际硬件调整。
  • 中断优先级:定时器中断优先级应高于可能阻塞主循环的任务,但低于紧急系统中断(如看门狗)。
  • 功耗优化:若用于低功耗设备,可在无按键按下时停止扫描,通过外部中断唤醒(需在列线上加中断触发)。
  • 防干扰:在列线加RC滤波(如100Ω+100nF)可进一步抑制高频噪声,但会增加成本。

总结

本文从矩阵键盘的硬件原理出发,详细介绍了行列扫描的IO配置方法,并给出了基于状态机的消抖算法。该方案将扫描与消抖解耦,通过定时器中断实现非阻塞处理,极大提升了系统实时性。代码结构清晰,易于移植到其他MCU平台。掌握此架构后,你不仅能应对矩阵键盘,还能将其扩展到编码器、触摸按键等需要状态管理的输入场景。

在实际项目中,建议根据按键数量、扫描周期和MCU负载灵活调整参数,并配合逻辑分析仪验证时序,确保万无一失。