本文将分享一套完整的矩阵键盘驱动设计,重点分析如何解决按键"按一次触发多次"的经典问题,并提供可直接使用的代码。
一、引言
本文将给出一个基于STM32 HAL库的矩阵键盘驱动,采用硬件抽象层(HAL) 思想,让键盘核心逻辑与具体引脚完全解耦。经过改进后的代码,能够保证按键按下时只触发一次回调,直到松开后再次按下才会重新触发。
二、硬件连接与工作原理
2.1 矩阵键盘原理
4×4矩阵键盘由4行(ROW)和4列(COL)组成。通过逐行拉低,读取列电平来判断哪个按键被按下。以常见的"行列扫描法"为例:
-
将所有行输出高电平,列配置为上拉输入。
-
依次将某一行拉低,读取所有列的电平。
-
若某列读取到低电平(因为列上拉,按键按下时行拉低会把列也拉低),则说明该行该列的按键被按下。
-


2.2 硬件连接(示例)
本驱动使用GPIOA的0-3作为行输出,4-7作为列输入。用户可根据实际板子修改keyboard_port.h中的引脚定义,无需改动核心代码。
| 行/列 | 引脚 |
|---|---|
| R0 | PA0 |
| R1 | PA1 |
| R2 | PA2 |
| R3 | PA3 |
| C0 | PA4 |
| C1 | PA5 |
| C2 | PA6 |
| C3 | PA7 |
三、软件架构设计
为了使驱动易于移植和维护,我们将代码分为三层:
-
硬件抽象层(HAL) :
keyboard_port.h和keyboard_port.c定义引脚宏、GPIO初始化、行/列操作宏。移植时只需修改这里的引脚映射。
-
核心扫描层 :
keyboard.c和keyboard.h实现行列扫描、消抖、状态机、事件回调。与具体硬件无关。
-
定时器层 :
tim_key.c和tim_key.h配置一个10ms定时中断,周期性调用
KEY_TimerTick()。可根据实际需求更换定时器或改用RTOS任务。
这样的分层使得驱动可以轻松迁移到其他STM32系列甚至其他MCU(只需实现相应的GPIO操作宏)。
四、关键代码解析
4.1 硬件抽象层(keyboard_port.h)
// 行输出引脚配置
#define R0_PORT GPIOA
#define R0_PIN GPIO_PIN_0
// ... 类似定义R1~R3
// 列输入引脚配置
#define C0_PORT GPIOA
#define C0_PIN GPIO_PIN_4
// ... 类似定义C1~C3
// 硬件操作宏
#define R_SET_LOW(port, pin) HAL_GPIO_WritePin(port, pin, GPIO_PIN_RESET)
#define R_SET_HIGH(port, pin) HAL_GPIO_WritePin(port, pin, GPIO_PIN_SET)
#define C_READ(port, pin) HAL_GPIO_ReadPin(port, pin)
所有与GPIO有关的操作都封装为宏,移植时只需修改引脚宏和这些宏内部实现即可。
4.2 核心扫描逻辑(keyboard.c)
4.2.1 单次扫描函数 KEY_ScanOnce()
static key_value_t KEY_ScanOnce(void) {
for(uint8_t row=0; row<KEY_ROWS; row++) {
// 所有行置高
for(uint8_t i=0; i<KEY_ROWS; i++)
R_SET_HIGH(R_Ports[i], R_Pins[i]);
// 当前行拉低
R_SET_LOW(R_Ports[row], R_Pins[row]);
// 读取列
for(uint8_t col=0; col<KEY_COLS; col++) {
if(C_READ(C_Ports[col], C_Pins[col]) == 0)
return Key_Map[row][col];
}
}
return KEY_NONE;
}
该函数一次完整扫描,返回第一个检测到的按键值。注意:扫描完后所有行恢复高电平,避免影响下次扫描。
4.2.2 定时扫描与消抖(改进版)
原始的消抖逻辑存在"按住时反复触发"的缺陷,原因在于触发后立即清零 last_key,导致按键持续期间重新开始消抖计数。改进后的代码引入 triggered 标志:
static uint8_t triggered = 0; // 当前按键是否已触发
void KEY_TimerTick(void) {
key_value_t cur_key = KEY_ScanOnce();
if(cur_key != KEY_NONE) {
if(cur_key == last_key) {
if(++debounce_cnt >= KEY_DEBOUNCE_CNT) {
debounce_cnt = 0;
if(!triggered) {
triggered = 1;
if(g_key_callback != NULL)
g_key_callback(cur_key);
}
// 注意:不清空 last_key
}
} else {
// 按键改变,重置所有状态
debounce_cnt = 0;
last_key = cur_key;
triggered = 0;
}
} else {
// 无按键,重置所有状态
debounce_cnt = 0;
last_key = KEY_NONE;
triggered = 0;
}
}
关键改动:
-
增加
triggered标志,记录当前按键是否已经触发过事件。 -
消抖完成后,若未触发过才执行回调,并置位
triggered。 -
不再在触发后清空
last_key,避免重新积累计数。 -
只有当按键变化(
cur_key != last_key)或按键释放(cur_key == KEY_NONE)时,才重置triggered和last_key。
这样,在按键按下的整个过程中,无论按住多久,只会触发一次回调。松开后再按才会再次触发。
4.2.3 回调注册
用户可以通过 KEY_SetCallback() 注册自己的按键处理函数,驱动会在检测到有效按键时调用该函数。
void KEY_SetCallback(KeyEventCallback_t cb) {
g_key_callback = cb;
}
4.3 定时器配置(tim_key.c)
以STM32F1为例,配置TIM2产生10ms中断:
void TIM_KeyScan_Init(void) {
__HAL_RCC_TIM2_CLK_ENABLE();
htim_key.Instance = TIM2;
htim_key.Init.Prescaler = 7200 - 1; // 72MHz / 7200 = 10kHz
htim_key.Init.Period = 100 - 1; // 10kHz / 100 = 100Hz → 10ms
// ...
HAL_TIM_Base_Init(&htim_key);
HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(TIM2_IRQn);
}
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
if(htim->Instance == TIM2) {
KEY_TimerTick();
}
}
用户只需调用 TIM_KeyScan_Start() 即可开始扫描。
五、使用方法
-
修改引脚定义 :在
keyboard_port.h中根据实际电路修改行/列的端口和引脚。 -
初始化 :在
main()中调用KEY_InitGPIO()和TIM_KeyScan_Init()。 -
启动扫描 :调用
TIM_KeyScan_Start()。 -
注册回调 :在某个地方调用
KEY_SetCallback(YourKeyHandler),其中YourKeyHandler原型为void YourKeyHandler(key_value_t key)。 -
处理按键:在回调函数中实现具体逻辑,例如将按键值发送到串口、控制LED等。
六、常见问题与解决
Q1:为什么按下按键有时不触发,有时触发多次?
A:可能是消抖次数设置不当或硬件抖动过大。建议将 KEY_DEBOUNCE_CNT 调整为2或3(对应20ms或30ms),并确保定时器周期为10ms左右。如果硬件抖动严重,可适当增加消抖次数。
Q2:如何实现长按重复触发?
A:可以在 KEY_TimerTick() 中增加长按计时器,当按键持续按下超过一定时间后,每隔固定周期触发一次回调。但注意不要与单次触发逻辑混淆。
Q3:能否支持更多按键?
A:可以。只需修改 KEY_ROWS 和 KEY_COLS 宏,并扩展 Key_Map 数组。同时,确保硬件引脚配置正确。
七、总结

本文分享的矩阵键盘驱动采用分层设计,具有良好的可移植性。通过引入"触发标志"改进消抖逻辑,彻底解决了"按一次触发多次"的经典问题。整个代码简洁高效,非常适合作为嵌入式项目的基础组件。读者可以根据自己的硬件平台稍作修改,即可快速实现键盘输入功能。
如果你有任何疑问或改进建议,欢迎在评论区留言交流!
八、参考代码
通过网盘分享的文件:keyBoard_STM32F103C8T6.zip
链接: https://pan.baidu.com/s/13emfCdVl8Ld59cXzRMh3SQ?pwd=wjmm 提取码: wjmm
--来自百度网盘超级会员v8的分享