引言:矩阵扫描的本质
矩阵键盘的核心思想是用较少的IO口控制更多按键。例如4x4矩阵只需8个IO(4行+4列)即可管理16个按键,相比独立按键节省一半资源。其原理基于行列交叉检测:将行线设为输出,列线设为输入(或反之),通过逐行拉低(或拉高)并读取列状态,即可定位被按下的按键。
但实际工程中,机械抖动、IO浮空、多键按下等问题会严重影响可靠性。本文将从底层原理到状态机实现,给出一个可复用的解决方案。
硬件连接与IO配置
典型电路
- 行线(ROW0~ROW3)接单片机GPIO,配置为推挽输出,默认输出高电平。
- 列线(COL0~COL3)接GPIO,配置为上拉输入(内部上拉或外部10kΩ上拉电阻)。
- 每个按键跨接在行线与列线交叉点。
注意:若使用外部上拉,建议阻值在4.7kΩ~10kΩ之间,过小会增加功耗,过大会降低抗干扰能力。
扫描原理
- 将所有行线输出高电平,列线读取全为高(无按键按下)。
- 将第0行拉低,读取所有列线。若某列变低,则说明该行该列交叉处的按键被按下。
- 恢复第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负载灵活调整参数,并配合逻辑分析仪验证时序,确保万无一失。