STM32矩阵按键详解——4×4行列扫描与非阻塞消抖(硬件总结六)

前言

独立按键虽然简单,但当产品需要十几个按键时,每个按键独占一个GPIO的接法就变得很不经济。矩阵按键通过"行×列"的交叉结构,仅用N+M个GPIO即可驱动N×M个按键。以最常见的4×4矩阵为例,16个按键仅需8个GPIO,引脚利用率提升整整一倍。

本文将从硬件电路 出发,深入讲解行列扫描算法,给出完整的标准库驱动代码,并实现非阻塞消抖可靠的事件标记机制。所有代码基于STM32F103C8T6,可直接在工程中编译运行。

一、矩阵按键的硬件结构

1.1 物理连接

矩阵按键由行线(Row)与列线(Column)交叉构成。每个按键位于某一行线与某一列线的交点处,按下时使该行与该列导通。

复制代码
     C0   C1   C2   C3
     │    │    │    │
R0 ──┼────┼────┼────┼──
     ╹    ╹    ╹    ╹
R1 ──┼────┼────┼────┼──
     ╹    ╹    ╹    ╹
R2 ──┼────┼────┼────┼──
     ╹    ╹    ╹    ╹
R3 ──┼────┼────┼────┼──
  • 行线(R0~R3):配置为推挽输出,扫描时依次拉低。
  • 列线(C0~C3):配置为上拉输入(内部上拉或外部上拉电阻),默认读高电平。

当某一行被拉低、该行和某列交叉点的按键闭合时,列线通过闭合触点被拉低,程序即可检测到低电平。

1.2 上拉电阻与引脚配置

列线必须接上拉电阻,以保证悬空时读到确定的高电平。推荐使用外部10kΩ上拉电阻。STM32也可直接配置为GPIO_Mode_IPU,利用内部约40kΩ弱上拉,但抗干扰能力较弱。

本文引脚分配如下(使用PA0~PA7):

功能 引脚 说明
行0 PA0 推挽输出
行1 PA1 推挽输出
行2 PA2 推挽输出
行3 PA3 推挽输出
列0 PA4 上拉输入
列1 PA5 上拉输入
列2 PA6 上拉输入
列3 PA7 上拉输入

1.3 安全注意事项

任何时刻只能拉低一行,其余行必须输出高电平。 如果同时有两行分别输出高和低,当同一列上的两个不同行按键同时按下时,高电平的行将与低电平的行发生短路,可能损坏GPIO。这是软件必须保证的约束。推挽输出只要遵守此规则,就完全安全。

1.4 幽灵键问题(Ghost Key)

多键同时按下时,电流可能通过已闭合的触点形成反向通路,导致未按下的按键被误判为按下。对于常规应用,可在软件中检测到多于2个键同时按下时直接丢弃本次扫描结果;若要求绝对可靠,需在每个按键上串联二极管(如1N4148)。


二、行列扫描算法

2.1 基本流程

  1. 将所有行线置高电平。
  2. 逐行扫描:依次将每一行拉低,其余行保持高电平,同时读取所有列线的状态。
  3. 若某列读到低电平,说明被拉低的这一行与该列的交叉点上的按键被按下。
  4. 扫描完所有行后,综合结果可获知全部被按下的按键。

2.2 消抖策略

机械按键存在5~20ms的抖动。我们采用固定周期扫描+状态机消抖

  • 通过SysTick产生10ms定时,在MatrixKey_Scan()中自动限制扫描间隔。
  • 为每个按键维护一个消抖计数器,只有连续两次扫描检测到电平与当前稳定状态不同时,才更新稳定状态。
  • 稳定状态变化时,产生"按下"或"释放"事件,并标记待消费。

三、标准库完整实现(可直接使用)

3.1 头文件与宏

c 复制代码
#include "stm32f10x.h"
#include <stdbool.h>

/* 引脚定义 */
#define KEY_PORT         GPIOA
#define KEY_ROW0_PIN     GPIO_Pin_0
#define KEY_ROW1_PIN     GPIO_Pin_1
#define KEY_ROW2_PIN     GPIO_Pin_2
#define KEY_ROW3_PIN     GPIO_Pin_3
#define KEY_COL0_PIN     GPIO_Pin_4
#define KEY_COL1_PIN     GPIO_Pin_5
#define KEY_COL2_PIN     GPIO_Pin_6
#define KEY_COL3_PIN     GPIO_Pin_7

#define KEY_ROWS         4
#define KEY_COLS         4
#define KEY_NUM          (KEY_ROWS * KEY_COLS)   /* 16 */

/* 消抖参数 */
#define DEBOUNCE_MS      20
#define SCAN_INTERVAL_MS 10        // 扫描间隔10ms,消抖需2次确认

/* LED */
#define LED_GPIO         GPIOB
#define LED_PIN          GPIO_Pin_0

3.2 按键状态结构

c 复制代码
typedef enum {
    KEY_STATE_IDLE = 0,
    KEY_STATE_PRESS,
    KEY_STATE_RELEASE
} KeyState;

typedef struct {
    uint8_t  debounce_cnt;      // 消抖计数
    bool     current_raw;       // 当前原始电平(true=未按下)
    bool     stable;            // 消抖后的稳定状态(true=未按下)
    KeyState state;             // 按键状态
    bool     event_consumed;    // 事件是否已被消费
} KeyInfo;

static KeyInfo key_info[KEY_NUM];

/* 字符映射表 */
static const char key_map[KEY_ROWS][KEY_COLS] = {
    {'1', '2', '3', 'A'},
    {'4', '5', '6', 'B'},
    {'7', '8', '9', 'C'},
    {'*', '0', '#', 'D'}
};

3.3 时基(SysTick)

c 复制代码
volatile uint32_t sysTickUptime = 0;

void SysTick_Init(void) {
    if (SysTick_Config(SystemCoreClock / 1000)) {
        while (1);
    }
    NVIC_SetPriority(SysTick_IRQn, 0x0F);
}

void SysTick_Handler(void) {
    sysTickUptime++;
}

3.4 GPIO初始化

c 复制代码
void MatrixKey_GPIO_Init(void) {
    GPIO_InitTypeDef GPIO_InitStructure;
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    /* 行线 PA0~PA3 推挽输出,初始全高 */
    GPIO_InitStructure.GPIO_Pin   = KEY_ROW0_PIN | KEY_ROW1_PIN |
                                    KEY_ROW2_PIN | KEY_ROW3_PIN;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(KEY_PORT, &GPIO_InitStructure);
    GPIO_SetBits(KEY_PORT, KEY_ROW0_PIN | KEY_ROW1_PIN |
                           KEY_ROW2_PIN | KEY_ROW3_PIN);

    /* 列线 PA4~PA7 上拉输入 */
    GPIO_InitStructure.GPIO_Pin   = KEY_COL0_PIN | KEY_COL1_PIN |
                                    KEY_COL2_PIN | KEY_COL3_PIN;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_IPU;      // 内部上拉
    GPIO_Init(KEY_PORT, &GPIO_InitStructure);
}

/**
 * @brief 初始化按键状态数组,确保上电时状态为"未按下"
 */
void MatrixKey_State_Init(void) {
    for (uint8_t i = 0; i < KEY_NUM; i++) {
        key_info[i].stable         = true;   // 初始化为未按下
        key_info[i].state          = KEY_STATE_IDLE;
        key_info[i].event_consumed = true;
        key_info[i].debounce_cnt   = 0;
    }
}

3.5 底层扫描函数

c 复制代码
/**
 * @brief  读取指定行列按键的原始电平
 * @param  row 行号 (0~3), col 列号 (0~3)
 * @retval true: 未按下(高电平)  false: 按下(低电平)
 */
static bool MatrixKey_ReadRaw(uint8_t row, uint8_t col) {
    const uint16_t row_pin[KEY_ROWS] = {KEY_ROW0_PIN, KEY_ROW1_PIN,
                                         KEY_ROW2_PIN, KEY_ROW3_PIN};
    const uint16_t col_pin[KEY_COLS] = {KEY_COL0_PIN, KEY_COL1_PIN,
                                         KEY_COL2_PIN, KEY_COL3_PIN};

    /* 全部行先拉高,再拉低目标行 */
    GPIO_SetBits(KEY_PORT, KEY_ROW0_PIN | KEY_ROW1_PIN |
                           KEY_ROW2_PIN | KEY_ROW3_PIN);
    GPIO_ResetBits(KEY_PORT, row_pin[row]);

    /* 极短延时等待电平稳定 */
    for (volatile uint8_t d = 0; d < 5; d++);

    /* 读取列状态 */
    return (GPIO_ReadInputDataBit(KEY_PORT, col_pin[col]) != Bit_RESET);
}

3.6 消抖与扫描状态机

c 复制代码
/**
 * @brief  矩阵按键扫描函数(每10ms调用一次)
 *         内部完成消抖和状态迁移,为每个按键产生一次性事件
 */
void MatrixKey_Scan(void) {
    static uint32_t last_scan = 0;
    if (sysTickUptime - last_scan < SCAN_INTERVAL_MS) return;
    last_scan = sysTickUptime;

    for (uint8_t row = 0; row < KEY_ROWS; row++) {
        for (uint8_t col = 0; col < KEY_COLS; col++) {
            uint8_t idx = row * KEY_COLS + col;
            KeyInfo *k = &key_info[idx];

            k->current_raw = MatrixKey_ReadRaw(row, col);

            /* 消抖计数器:与稳定状态不同则累加,相同则清零 */
            if (k->current_raw == k->stable) {
                k->debounce_cnt = 0;
            } else {
                k->debounce_cnt++;
                if (k->debounce_cnt >= (DEBOUNCE_MS / SCAN_INTERVAL_MS)) {
                    // 电平连续2次(20ms)与当前stable不同,更新stable
                    k->stable = k->current_raw;
                    k->debounce_cnt = 0;

                    if (k->stable == false) {
                        /* 确认按下 */
                        if (k->state != KEY_STATE_PRESS) {
                            k->state = KEY_STATE_PRESS;
                            k->event_consumed = false;   // 新事件待消费
                        }
                    } else {
                        /* 确认释放 */
                        k->state = KEY_STATE_RELEASE;
                        k->event_consumed = false;
                    }
                }
            }
        }
    }
}

3.7 应用层API

c 复制代码
/**
 * @brief  检测指定按键是否刚被按下(一次性事件,调用后即清除)
 * @param  row, col 按键位置
 * @retval true: 有新的按下事件  false: 无
 */
bool MatrixKey_IsPressed(uint8_t row, uint8_t col) {
    uint8_t idx = row * KEY_COLS + col;
    KeyInfo *k = &key_info[idx];
    if (k->state == KEY_STATE_PRESS && !k->event_consumed) {
        k->event_consumed = true;
        return true;
    }
    return false;
}

/**
 * @brief  检查指定按键是否处于按住状态(可用于长按连发)
 * @retval true: 按键处于按下状态  false: 未按下
 */
bool MatrixKey_IsDown(uint8_t row, uint8_t col) {
    uint8_t idx = row * KEY_COLS + col;
    return (key_info[idx].state == KEY_STATE_PRESS);
}

/**
 * @brief  获取按键对应的字符
 */
char MatrixKey_GetChar(uint8_t row, uint8_t col) {
    return key_map[row][col];
}

3.8 主函数示例

c 复制代码
int main(void) {
    GPIO_InitTypeDef GPIO_InitStructure;

    /* LED PB0 推挽输出 */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    GPIO_InitStructure.GPIO_Pin   = LED_PIN;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(LED_GPIO, &GPIO_InitStructure);
    GPIO_ResetBits(LED_GPIO, LED_PIN);    // 初始熄灭

    /* 矩阵按键初始化 */
    MatrixKey_GPIO_Init();
    MatrixKey_State_Init();               // 关键:清空状态
    SysTick_Init();

    while (1) {
        MatrixKey_Scan();                 // 每循环都调用,内部自动限速

        /* 处理所有按键事件 */
        for (uint8_t r = 0; r < KEY_ROWS; r++) {
            for (uint8_t c = 0; c < KEY_COLS; c++) {
                if (MatrixKey_IsPressed(r, c)) {
                    char ch = MatrixKey_GetChar(r, c);
                    /* 在此处理按键事件,例如翻转LED */
                    GPIO_WriteBit(LED_GPIO, LED_PIN,
                        (BitAction)(1 - GPIO_ReadOutputDataBit(LED_GPIO, LED_PIN)));
                    // 也可通过串口打印: printf("Key: %c\r\n", ch);
                }
            }
        }

        /* 示例:检查"*"键是否按住(连续动作) */
        if (MatrixKey_IsDown(3, 0)) {   // 第3行第0列,即'*'
            // 执行连续操作,如持续调亮度
        }
    }
}

代码说明

  • MatrixKey_State_Init() 将全部按键的稳定状态初始化为未按下,防止上电误触发。
  • MatrixKey_Scan() 内部由 sysTickUptime 控制10ms间隔,即使主循环调用再快也不会频繁扫描。
  • 每个按键的 event_consumed 保证一次按下只产生一次 IsPressed 事件,长按期间不会重复触发。
  • IsDown() 提供持续按住的状态,可用于实现长按加速等逻辑。

四、扩展建议

  • 长按识别 :可在每个按键上增加按下时间戳,当IsDown()为真且持续时间超过阈值时,触发长按事件(需自行扩展状态机)。
  • 组合键 :同时检查多个按键的IsDown()状态即可。
  • 低功耗 :将MatrixKey_Scan()放入定时中断,主循环空闲时调用__WFI(),可大幅降低功耗。

五、常见问题排查

现象 可能原因 解决方法
按键无反应 行线未输出、列线上拉未使能 检查GPIO_Mode_Out_PPGPIO_Mode_IPU
单次按下触发多次 事件未消费、消抖不足 确认event_consumed机制,检查扫描间隔
多键同时按下误判 幽灵键效应 软件丢弃>2键同时按下的结果,或硬件加二极管
上电后自动触发一次 初始状态未校准 调用MatrixKey_State_Init()
按键响应慢 扫描间隔太长 减小SCAN_INTERVAL_MS(建议10ms)

六、总结

本文从矩阵按键的硬件原理出发,深入讲解了行列扫描算法,并给出了一套完整、可直接使用的标准库驱动。通过固定周期扫描配合消抖状态机,实现了精准、非阻塞的按键识别,且事件消费机制严谨可靠。

这套代码与之前文章中的状态机、调度器及低功耗方案完全兼容,稍作整合即可构建出复杂而稳定的裸机交互系统。

若有任何疑问,欢迎在评论区留言交流!

相关推荐
都在酒里5 小时前
STM32有限状态机(FSM)详解,综合应用总结(二)
stm32·单片机·嵌入式硬件·状态机
嵌入式-老费5 小时前
esp开发与应用(继电器的使用)
单片机·嵌入式硬件
CPETW6 小时前
RS-232 Sniffer 嗅探器 ---- UNI-T电子负载通讯协议抓取-A
网络·科技·stm32·单片机·嵌入式硬件·电子
wotaifuzao6 小时前
指针和中断不是魔法:用第一性原理看穿嵌入式底层(万字解析)
stm32·嵌入式开发·内存模型·c语言指针·arm架构·中断机制·rtos内核
xiangw@GZ6 小时前
倒 F 天线 (IFA/MIFA) 原理深度解析
单片机·嵌入式硬件
救救孩子把6 小时前
66-机器学习与大模型开发数学教程-6-2 矩阵运算的数值误差分析
人工智能·机器学习·矩阵
m0_377108146 小时前
stm32时钟
stm32·单片机·嵌入式硬件
smalming6 小时前
【产品开发】空气波按摩器的一些控制逻辑
嵌入式硬件·嵌入式软件
嗯? 嗯。6 小时前
S32K外设Usart
单片机·嵌入式硬件