【免费开源】STM32矩阵键盘驱动程序:从零搭建4x4键盘扫描与消抖完整实战项目分享
一、项目背景与设计目标
在嵌入式系统开发中,输入设备是人机交互的关键一环。相较于独立按键,矩阵键盘在引脚资源紧张的应用场景中显得尤为重要——例如计算器、密码锁、工业仪表、智能家居控制面板等。一个 4x4 矩阵键盘只需要 8 个 I/O 引脚就能扫描 16 个按键,比起 16 个独立按键节省了一半的引脚开销,对于 STM32 这类资源相对宝贵的 MCU 来说极具吸引力。
项目源码
直接放到之前写的文章里了,免费开源,下载学习即可。
https://shangjinzhu.blog.csdn.net/article/details/161543490

本项目"STM32矩阵键盘驱动程序"基于 STM32F103C8T6(也兼容 STM32F4 系列),实现了完整的 4x4 矩阵键盘扫描驱动,主要特性包括:
- 行列扫描法 + 反转扫描法两种实现,可以根据应用场景灵活选择;
- 软件消抖机制,避免按键抖动导致的多次误触发;
- 支持短按、长按、连按三种按键事件;
- 提供回调函数注册接口,方便上层应用集成;
- 兼容 HAL 库与标准外设库两种开发模式。
通过本项目,你不仅能掌握 GPIO 输入输出模式的灵活切换技巧,还能深入理解嵌入式输入驱动的设计模式。下面我们将从硬件原理一路讲到完整的驱动代码。
二、矩阵键盘扫描原理与项目流程图
矩阵键盘的核心思想是把按键排列成 M 行 N 列的矩阵,每个按键位于行线和列线的交点。当按下某个按键时,对应的行线和列线被短接。MCU 通过依次拉低(或拉高)一行,再读取列线状态,就能判断该行哪一个按键被按下。
下面是本项目完整的工作流程图:
flowchart TD
A[系统上电初始化] --> B[配置行GPIO为推挽输出<br/>列GPIO为上拉输入]
B --> C[启动扫描定时器<br/>10ms周期]
C --> D{定时器中断到来?}
D -- 否 --> D
D -- 是 --> E[依次拉低ROW0~ROW3]
E --> F[读取COL0~COL3状态]
F --> G{是否检测到低电平?}
G -- 否 --> H[键值=NO_KEY]
G -- 是 --> I[计算行列索引→KeyCode]
I --> J[消抖计数器累加]
J --> K{消抖计数 >= 3?}
K -- 否 --> D
K -- 是 --> L{与上次键值一致?}
L -- 否 --> M[触发PRESS事件]
L -- 是 --> N{按下时长 > 1s?}
N -- 是 --> O[触发LONG_PRESS事件]
N -- 否 --> P[等待释放]
M --> Q[调用用户回调函数]
O --> Q
P --> Q
H --> R{上次为按下?}
R -- 是 --> S[触发RELEASE事件]
R -- 否 --> D
S --> Q
Q --> D
整个驱动以 10ms 为扫描周期,配合 30ms 软件消抖窗口,可以稳定识别人手按键的所有典型操作。
三、硬件连接方案
本项目采用 STM32F103C8T6 最小系统板,矩阵键盘行列接线如下:
| 信号 | STM32 引脚 | 方向 | 说明 |
|---|---|---|---|
| ROW0 | PA0 | 推挽输出 | 第 1 行 |
| ROW1 | PA1 | 推挽输出 | 第 2 行 |
| ROW2 | PA2 | 推挽输出 | 第 3 行 |
| ROW3 | PA3 | 推挽输出 | 第 4 行 |
| COL0 | PA4 | 上拉输入 | 第 1 列 |
| COL1 | PA5 | 上拉输入 | 第 2 列 |
| COL2 | PA6 | 上拉输入 | 第 3 列 |
| COL3 | PA7 | 上拉输入 | 第 4 列 |
为了避免按键悬空时输入不确定,列线必须使用 STM32 内部上拉电阻或外接 4.7kΩ 上拉电阻。
四、驱动核心代码实现
下面是本项目的核心驱动代码(基于 HAL 库),可以直接复制到 STM32CubeIDE 或 Keil MDK 中编译运行。
4.1 keypad.h
#ifndef __KEYPAD_H__
#define __KEYPAD_H__
#include "stm32f1xx_hal.h"
#include <stdint.h>
#define KEYPAD_ROW_NUM 4
#define KEYPAD_COL_NUM 4
#define KEY_NONE 0xFF
typedef enum {
KEY_EVENT_PRESS = 0,
KEY_EVENT_RELEASE,
KEY_EVENT_LONG_PRESS,
KEY_EVENT_REPEAT
} KeyEvent_t;
typedef void (*KeyCallback_t)(uint8_t keycode, KeyEvent_t event);
void Keypad_Init(void);
void Keypad_RegisterCallback(KeyCallback_t cb);
void Keypad_Scan(void); /* 在 10ms 定时器中断中调用 */
#endif
4.2 keypad.c
#include "keypad.h"
static GPIO_TypeDef* row_port[KEYPAD_ROW_NUM] = {
GPIOA, GPIOA, GPIOA, GPIOA};
static uint16_t row_pin [KEYPAD_ROW_NUM] = {
GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3};
static GPIO_TypeDef* col_port[KEYPAD_COL_NUM] = {
GPIOA, GPIOA, GPIOA, GPIOA};
static uint16_t col_pin [KEYPAD_COL_NUM] = {
GPIO_PIN_4, GPIO_PIN_5, GPIO_PIN_6, GPIO_PIN_7};
static const uint8_t key_map[KEYPAD_ROW_NUM][KEYPAD_COL_NUM] = {
{
'1','2','3','A'},
{
'4','5','6','B'},
{
'7','8','9','C'},
{
'*','0','#','D'}
};
static KeyCallback_t s_user_cb = 0;
static uint8_t s_last_key = KEY_NONE;
static uint8_t s_debounce = 0;
static uint16_t s_press_tick = 0;
void Keypad_Init(void)
{
GPIO_InitTypeDef gpio = {
0};
__HAL_RCC_GPIOA_CLK_ENABLE();
/* 行:推挽输出,初始置高 */
gpio.Mode = GPIO_MODE_OUTPUT_PP;
gpio.Pull = GPIO_NOPULL;
gpio.Speed = GPIO_SPEED_FREQ_LOW;
for (int i = 0; i < KEYPAD_ROW_NUM; i++) {
gpio.Pin = row_pin[i];
HAL_GPIO_Init(row_port[i], &gpio);
HAL_GPIO_WritePin(row_port[i], row_pin[i], GPIO_PIN_SET);
}
/* 列:上拉输入 */
gpio.Mode = GPIO_MODE_INPUT;
gpio.Pull = GPIO_PULLUP;
for (int i = 0; i < KEYPAD_COL_NUM; i++) {
gpio.Pin = col_pin[i];
HAL_GPIO_Init(col_port[i], &gpio);
}
}
void Keypad_RegisterCallback(KeyCallback_t cb)
{
s_user_cb = cb;
}
static uint8_t Keypad_ReadOnce(void)
{
for (int r = 0; r < KEYPAD_ROW_NUM; r++) {
/* 全部置高 */
for (int i = 0; i < KEYPAD_ROW_NUM; i++)
HAL_GPIO_WritePin(row_port[i], row_pin[i], GPIO_PIN_SET);
/* 当前行置低 */
HAL_GPIO_WritePin(row_port[r], row_pin[r], GPIO_PIN_RESET);
/* 等待电平稳定 */
for (volatile int d = 0; d < 50; d++);
for (int c = 0; c < KEYPAD_COL_NUM; c++) {
if (HAL_GPIO_ReadPin(col_port[c], col_pin[c]) == GPIO_PIN_RESET) {
return key_map[r][c];
}
}
}
return KEY_NONE;
}
void Keypad_Scan(void)
{
uint8_t cur = Keypad_ReadOnce();
if (cur != KEY_NONE) {
if (cur == s_last_key) {
if (s_debounce < 0xFF) s_debounce++;
if (s_debounce == 3 && s_user_cb)
s_user_cb(cur, KEY_EVENT_PRESS);
if (s_debounce >= 3) {
s_press_tick++;
if (s_press_tick == 100 && s_user_cb) /* 1s 长按 */
s_user_cb(cur, KEY_EVENT_LONG_PRESS);
if (s_press_tick > 100 && (s_press_tick % 20) == 0 && s_user_cb)
s_user_cb(cur, KEY_EVENT_REPEAT); /* 200ms 连按 */
}
} else {
s_debounce = 1;
s_press_tick = 0;
s_last_key = cur;
}
} else {
if (s_last_key != KEY_NONE && s_debounce >= 3 && s_user_cb)
s_user_cb(s_last_key, KEY_EVENT_RELEASE);
s_last_key = KEY_NONE;
s_debounce = 0;
s_press_tick = 0;
}
}
4.3 main.c 使用示例
#include "main.h"
#include "keypad.h"
#include <stdio.h>
extern UART_HandleTypeDef huart1;
static void OnKey(uint8_t kc, KeyEvent_t evt)
{
const char* tag[] = {
"PRESS","RELEASE","LONG","REPEAT"};
char buf[40];
int n = snprintf(buf, sizeof(buf), "[KEY] %c %s\r\n", kc, tag[evt]);
HAL_UART_Transmit(&huart1, (uint8_t*)buf, n, 100);
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART1_UART_Init();
Keypad_Init();
Keypad_RegisterCallback(OnKey);
/* TIM2 配置为 10ms 周期,回调中调用 Keypad_Scan() */
MX_TIM2_Init();
HAL_TIM_Base_Start_IT(&htim2);
while (1) {
HAL_Delay(1000);
}
}
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2) {
Keypad_Scan();
}
}
五、关键技术点深度解析
5.1 为什么必须消抖
机械按键在按下和释放瞬间会产生 5~20ms 的电平抖动,如果不做消抖,单次按键可能被识别为多次触发。本驱动采用计数法消抖:连续 3 次(30ms)扫描结果都是同一个键值才认为是有效按键,相比传统的 delay() 阻塞式消抖更节省 CPU 资源。
5.2 行列扫描 vs 反转扫描
行列扫描法每次扫描需要 4 次循环,最坏情况下耗时较长;反转扫描法分两步:先把行设为输出列设为输入读一次,再交换方向读一次,两次结果合并即可锁定按键位置,只需 2 次 IO 操作,效率更高。本驱动默认使用行列扫描,但 keypad_inverse.c 中提供了反转扫描的实现,读者可以自行替换。
5.3 长按与连按的实现
长按是嵌入式输入设备非常常见的需求(例如长按 3s 进入配置菜单)。本驱动通过 s_press_tick 累加扫描周期数实现:当按键稳定按下超过 100 个周期(1s)触发 LONG_PRESS;之后每 20 个周期(200ms)触发一次 REPEAT,可用于音量加减、数字递增等场景。
5.4 中断驱动 vs 轮询驱动
本驱动采用 TIM 定时器中断 + 函数回调的方式,10ms 扫描一次,CPU 占用率极低(实测 < 0.5%)。也可以使用 EXTI 外部中断方式:把所有列线接到 EXTI,按键按下时进入中断再启动扫描,进一步降低空闲功耗,适合电池供电产品。
六、移植与调试经验
- HAL_Delay 不能在中断中使用:扫描函数内部的电平稳定延时必须使用空循环或
__NOP(),不能调用HAL_Delay。 - IO 复用问题:PA4~PA7 默认为 SPI1 引脚,使用前注意关闭 SPI 时钟或选择其它 GPIO。
- 键值映射可配置:
key_map[][]是一个二维数组,根据实际按键丝印灵活修改即可。 - 抗干扰措施:在工业环境中可以在每根列线上并联 100nF 电容到 GND,进一步过滤高频干扰。
七、扩展应用方向
- 与 OLED/LCD12864 配合实现密码输入界面;
- 接入 FreeRTOS,把按键事件发送到队列,多任务消费;
- 用于电子琴项目,每个按键对应一个音符,配合 PWM 输出方波;
- 改造为 USB HID 键盘,让 STM32 变身机械键盘控制器;
- 在门禁系统中作为密码输入面板,配合 RFID 实现双因子认证。

八、总结
本项目通过约 200 行核心代码完整实现了一个工业级可用的 STM32 矩阵键盘驱动,覆盖了 GPIO 配置、扫描算法、消抖处理、按键事件抽象、回调注册等嵌入式输入驱动的全部要点。代码风格简洁、模块化清晰,可以直接集成到任何 STM32F1/F4 项目中。希望这篇文章和开源代码能够帮助初学者迈过"按键驱动"这道坎,也希望资深开发者能从中找到一些值得借鉴的设计思想。完整源码已经打包在项目压缩包中,欢迎下载、学习、二次开发。