简介:基于STM32F103的4×4矩阵按键完整示例项目,面向嵌入式初学者与需要快速完成按键交互的开发者,解决了在极少I/O引脚上扫描16个按键、消除机械抖动并处理多键同时按下的问题。包内为zip格式,共80个文件,以C源码和头文件为主,另含启动文件、Keil工程文件、可烧录的HEX文件及辅助脚本,总大小325KB,目录结构规整,下载后可直接打开编译。已有4102人学习使用。工程不仅演示了GPIO推挽输出与上拉输入的配置方式,还实现了逐行扫描、延时去抖动、多按键时记录最后释放键的优先级策略和中断方式实时检测,并集成SysTick定时器、串口通信与语音播报模块。阅读Key驱动和中断服务程序,可清晰理解按键状态机逻辑及工程源码组织,适合用于STM32外设综合实验或迁移到其他矩阵按键场景。
1. 矩阵按键凭什么省一半IO?原理先讲透
1.1 先算一笔IO账:16个按键怎么就变成8个IO了
做STM32F103项目时,我最常被新手问的问题之一就是:为什么用矩阵按键?直接一个按键接一个IO不是更简单吗?道理其实很简单,算一下账就清楚了。16个独立按键,每个按键占用一个IO口,加起来16个IO口。而4x4矩阵按键只用了8个IO口,省了一半。如果放大到8x8,独立按键要64个IO口,矩阵只需要16个,这个节省比例非常夸张。
但省IO口是要付出代价的,代价就是按键不能像独立按键那样直接读,必须靠“扫描”。你在任意时刻只能知道某一行或某一列的电平状态,想要还原出具体是哪个按键被按下,必须让行线和列线配合工作。这个过程本质上就是在用“时间换引脚”。STM32F103的GPIO资源本来就不算富裕,做一个带显示、带通信、带电机控制的小系统,IO口往往捉襟见肘,这时候矩阵按键的价值就体现出来了。
1.2 行列交叉点:按键按下时到底发生了什么
矩阵按键的硬件结构是一个典型的行列交叉网络。4根行线、4根列线,交叉处一共16个结点,每个结点放一个按键。按键按下时,对应的行线和列线就导通。
关键是怎么读。我常用的方案是:行线接GPIO输入并打开内部上拉,列线接GPIO推挽输出。平时所有列线输出高电平,行线全部读到高;扫描时把某一列拉低,其他列保持高,再读四根行线。如果这一列上有按键被按下,对应的行线就会被拉低,于是我们知道“当前扫描的列 + 被拉低的行”就是一个有效按键。
这里有个很容易被忽视的地方:没被扫描到的列线必须维持高电平,否则会出现电流串扰。比如你在扫第1列时,第2列如果也是低电平,而第1列和第2列的两个按键同时按下,行线就分不清是哪个列导致的低电平,直接出现鬼键。所以每次扫描前,先把所有列线置高,再单独把目标列拉低,这个顺序不能乱。
2. 硬件接线:引脚选择、5V保护和防鬼键
2.1 行线列线怎么接最省事
实操层面,我一般从STM32F103最小系统板上直接飞线接按键矩阵。一个典型的接法是行线用PA0到PA3,列线用PB0到PB3。行线配置为上拉输入,因为按键矩阵的公共端不接电源,如果不用内部上拉,行线悬空时电平不确定,扫描结果会完全随机。
这里有个小提醒:内部上拉电阻的阻值在30k到50k欧姆左右,如果按键和单片机之间的连线很长,或者环境电磁干扰比较强,建议在行线上再并联一个10k欧姆的外部上拉电阻,能明显提高稳定性。列线用推挽输出即可,速度不用太高,GPIO_SPEED_FREQ_LOW就够,因为按键扫描本身不是高速信号。
我把引脚分配表列出来,方便直接照着接:
| 功能 | 引脚 | 方向 | 说明 |
|---|---|---|---|
| 行线ROW0 | PA0 | 输入上拉 | 读到低表示该行按键按下 |
| 行线ROW1 | PA1 | 输入上拉 | 同上 |
| 行线ROW2 | PA2 | 输入上拉 | 同上 |
| 行线ROW3 | PA3 | 输入上拉 | 同上 |
| 列线COL0 | PB0 | 推挽输出 | 扫描时逐列拉低 |
| 列线COL1 | PB1 | 推挽输出 | 同上 |
| 列线COL2 | PB2 | 推挽输出 | 同上 |
| 列线COL3 | PB3 | 推挽输出 | 同上 |
2.2 STM32F103的5V容忍和限流问题
热搜词里有个“stm32f103的gpio作为输入能不能承受5v电压”,这个问题在按键电路里确实会遇到。很多人习惯把按键一边接到3.3V或者5V,另一边通过行线进单片机,这样按下时IO口直接就是高电平。
STM32F103的引脚分为FT(5V容忍)和非FT两类。FT引脚可以直接承受5V输入,但非FT引脚只能承受VDD+0.3V左右,直接接5V大概率会损坏。即使FT引脚,我也建议串联一个1k欧姆的限流电阻再接按键,因为5V和内部ESD保护二极管之间一旦出现浪涌,限流电阻能吸收一部分能量,降低闩锁效应风险。
更稳妥的方案是:矩阵按键的电源全部用3.3V,不要碰5V。行线上拉接到3.3V,按键按下后行线最多也就是3.3V,完全不需要担心耐压问题。如果系统确实存在5V电平域,则用三极管或电平转换芯片隔离,不要图省事直连。
2.3 防抖、防鬼键的硬件手段
机械按键有个绕不开的问题:抖动。按键触点闭合和断开时,由于机械弹片反弹,电平会在一段时间内反复跳变,通常持续5到10毫秒。这个抖动在软件层用延时或状态机处理就行,但硬件上并一个小电容(100nF)到地,可以显著减少抖动脉冲,让软件消抖的压力小很多。
另一个硬件坑是鬼键。单按一个键很老实,一旦有人同时按两个或三个键,就可能出现一个根本没按下的按键也触发了。原因刚才说过,行列交叉网络在某些组合下会形成额外的导通路径。最简单的对策是:明确告诉用户这套设计只支持单键操作,扫描时如果检测到两个或两个以上按键同时按下,全部丢弃,等待按键全部释放后再重新开始扫描。如果你确实需要多键同时按下,那就得在每一个按键上串联一个二极管,或者在行线输出端加二极管隔离,成本和复杂度都会上来。
3. 软件扫描:从裸机到RTOS
3.1 逐行扫描法:最简单可靠的实现
先给出最经典的逐列扫描代码,适合裸机环境下跑。上面说行线是PA0到PA3,列线是PB0到PB3。
#define ROW_PORT GPIOA #define ROW_PIN GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 #define COL_PORT GPIOB #define COL_PIN GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 void KEY_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // 行线:上拉输入 GPIO_InitStruct.Pin = ROW_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(ROW_PORT, &GPIO_InitStruct); // 列线:推挽输出,默认全部输出高 GPIO_InitStruct.Pin = COL_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(COL_PORT, &GPIO_InitStruct); HAL_GPIO_WritePin(COL_PORT, COL_PIN, GPIO_PIN_SET); } uint8_t KEY_Scan(void) { for (uint8_t col = 0; col < 4; col++) { // 先把所有列置高,再拉低当前列,避免串扰 HAL_GPIO_WritePin(COL_PORT, COL_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(COL_PORT, (1 << col), GPIO_PIN_RESET); // 读四根行线,被拉低的位置就是按下的行 uint8_t rowVal = 0; if (HAL_GPIO_ReadPin(ROW_PORT, GPIO_PIN_0) == GPIO_PIN_RESET) rowVal |= 0x01; if (HAL_GPIO_ReadPin(ROW_PORT, GPIO_PIN_1) == GPIO_PIN_RESET) rowVal |= 0x02; if (HAL_GPIO_ReadPin(ROW_PORT, GPIO_PIN_2) == GPIO_PIN_RESET) rowVal |= 0x04; if (HAL_GPIO_ReadPin(ROW_PORT, GPIO_PIN_3) == GPIO_PIN_RESET) rowVal |= 0x08; if (rowVal != 0) { // 高位放列号,低位放行号,返回一个字节表示按键 return (col << 4) | rowVal; } } return 0xFF; // 无按键 }这段代码返回0xFF表示没有按键,返回值的低四位表示行,高两位表示列。你可以根据自己的习惯改成返回按键编号0到15,更直观。我一般会在应用层写一个数组映射,比如:
const uint8_t key_code_map[4][4] = { {0, 1, 2, 3}, {4, 5, 6, 7}, {8, 9, 10, 11}, {12, 13, 14, 15}, };扫描返回值通过查表转成真正的按键编号,这样上层逻辑就不用关心矩阵结构了。
3.2 翻转法:一次扫描拿到全部键值
逐列扫描的缺点是要循环4次,虽然对STM32F103来说耗时微不足道,但在一些严格实时性要求的场景下,还能优化。翻转法(也叫行列反转法)只读两次就能拿到键值。
思路是:第一步,行线输出低电平,列线设为上拉输入;读列线,能读到低电平的列就是按下按键所在的列。第二步,把行列角色对调,列线输出低电平,行线设为上拉输入;读行线,能读到低电平的行就是按下按键所在的行。拿到了行号和列号,自然就定位到具体按键。
翻转法代码更简洁,但有一个前提:只能支持单键按下,如果同时按两个键,行列信息会互相干扰。我在实际项目里反而更喜欢翻转法,因为很多产品按键本来就不需要多键同时按下,而且代码短,维护起来轻松。不过要注意角色切换的时候,输出引脚电平要先复位,避免两个方向都输出低电平导致短路。
3.3 状态机消抖和RTOS扫描任务
按键消抖是体现代码质量的细节。初学者最爱用大延时消抖,按下后delay(20),再读一次。这个写法在裸机主循环里问题不大,但如果你用了FreeRTOS,或者系统里有定时器、PWM输出等实时任务,一个20毫秒的阻塞就可能坏事。
我推荐用连续采样计数的方式,每5毫秒扫描一次,连续3次读到同一个键值才认为有效。这个策略用状态机写:
#define KEY_DEBOUNCE_CNT 3 uint8_t KEY_Debounce(void) { static uint8_t lastRaw = 0xFF; static uint8_t stableKey = 0xFF; static uint8_t cnt = 0; uint8_t raw = KEY_Scan(); if (raw == lastRaw) { if (++cnt >= KEY_DEBOUNCE_CNT) { stableKey = raw; } } else { lastRaw = raw; cnt = 0; } return stableKey; }这个函数每次调用都更新状态,但不阻塞。你可以把它放在定时器中断里,也可以放在RTOS的按键任务里。
在FreeRTOS工程中,我会单独建一个按键任务:
void vTaskKey(void *pvParameters) { uint8_t lastKey = 0xFF; for (;;) { uint8_t key = KEY_Debounce(); if (key != 0xFF && key != lastKey) { // 检测到新按键,发送到消息队列,由业务任务处理 xQueueSend(g_keyQueueHandle, &key, 0); } lastKey = key; vTaskDelay(pdMS_TO_TICKS(5)); } }注意vTaskDelay(5)配合KEY_Debounce里的3次采样,实际消抖时间是15毫秒左右,刚好覆盖机械按键的抖动区间。消息队列解耦了按键采集和业务处理,哪怕业务任务里做了耗时操作,按键响应也不会被卡死。
4. 调试实录:从乱按到Stop模式唤醒
4.1 按键乱码、连键的问题排查
调试矩阵按键最常遇到的现象就是“按下第1个键,串口打印出另一个键值”,或者“按一下出来了两个键”。我排查这类问题有一个固定顺序。
先查列线扫描逻辑。确认每次扫描前所有列线都置高,再拉低目标列。遗漏这一步是最常见的鬼键原因。再看行线配置,必须确认打开了内部上拉,如果行线悬空,读到的电平就是浮动的。
排完软件再看硬件。用万用表量一下按键两端,确认没有虚焊、短路。很多莫名其妙的连键问题其实不是代码的锅,是杜邦线插针之间氧化接触不良,或者面包板上引脚靠太近。调试之前先拿酒精擦一遍按键触点,能省很多时间。
最后排查抖动。如果按下一次触发了两次,把KEY_DEBOUNCE_CNT从3调到5,或者把扫描周期从5毫秒改成10毫秒。延时太长会让按键感觉迟钝,太短又滤不掉抖动,一般15到30毫秒是比较舒服的手感。
4.2 低功耗Stop模式下的按键唤醒
热搜词里出现了“stm32f103停机模式”和“低功耗stop模式”,这正好是矩阵按键在低功耗产品里的典型玩法。如果你做一个电池供电的遥控器或者控制面板,MCU大部分时间处于Stop模式,按键按下时通过外部中断唤醒。
关键点在于:按键扫描用的GPIO平时要重新配置成EXTI中断模式。行线接上拉,按键按下时行线电平由高变低,所以配置下降沿触发。
进入Stop前把PA0配置成外部中断:
GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); HAL_NVIC_SetPriority(EXTI0_IRQn, 3, 0); HAL_NVIC_EnableIRQ(EXTI0_IRQn);然后进入Stop模式:
HAL_SuspendTick(); HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI); // 唤醒后需要重新配置时钟 SystemClock_Config(); HAL_ResumeTick();有一件事非常容易被忽略:从Stop模式唤醒后,系统时钟会回到初始状态,必须重新调用SystemClock_Config恢复72MHz主频,否则定时器、串口的时序全乱。唤醒后如果能正常执行按键扫描,说明EXTI配置没问题;如果按键没反应,先检查中断标志有没有清除,再看是不是GPIO时钟在低功耗前被意外关闭了。
4.3 不接外部晶振,用内部HSI跑矩阵按键
有些最小系统板没贴外部8MHz晶振,或者你想省掉这两颗电容一个晶振的成本,这种情况下STM32F103可以完全靠内部HSI时钟工作。8MHz的HSI通过PLL倍频到72MHz,关键是PLL配置。
F103的HAL库配置里,HSI作为PLL源时默认是二分频,得先了解清楚这个细节:
RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI; RCC_OscInitStruct.HSIState = RCC_HSI_ON; RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSI_DIV2; // HSI 8MHz / 2 = 4MHz RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL18; // 4MHz * 18 = 72MHz HAL_RCC_OscConfig(&RCC_OscInitStruct);注意内部HSI的精度比外部晶振差一些,大概在正负1%左右,而且温度变化会带来漂移。如果按键扫描只是读GPIO电平,完全不受影响。但如果这个系统同时跑串口通信,尤其是115200以上波特率,HSI的误差可能导致误码。我的经验是:只有低速USART和通用逻辑控制才敢用HSI,涉及通信偏门参数的项目乖乖上外部晶振。
4.4 实测有效的调试手法
调矩阵按键时,我习惯先把扫描结果通过串口打印出来,格式直接打原始值,不打经过映射的按键编号。为什么要这样?原始值能反映出行、列的对应关系,一旦有问题,可以快速判断是某一行坏了还是某一列坏了。
printf("key scan = 0x%02X\r\n", KEY_Scan());连上串口助手,逐个按按键,观察打印值变化。如果某个按键按下后一直不变化,可能是对应的行线或者列线引脚配置错了,重新核对引脚号。如果某个按键按下后打印出多个值,大概率是抖动滤波不够或者鬼键,按上面说的排查顺序走。
有条件的话上逻辑分析仪看行线波形,能非常直观地看到按下一瞬间的抖动波形,以及列线拉低和行线电平变化之间的时序关系。没有逻辑分析仪,用普通示波器也能凑合看,但按键触发是偶发事件,单次触发模式比较难抓,不如直接把扫描函数放在主循环里,靠串口打印验证功能是否正常。
最后说一个我调按键时踩过几次坑之后的习惯:所有涉及按键的GPIO初始化,都单独写成一个函数,不跟其他外设混在一起。因为按键扫描在低功耗、FreeRTOS、裸机三种模式下配置会不一样,单独封装后切换起来非常方便,改一处就行。这个习惯在我同时维护好几个STM32F103项目时帮了大忙。
本文还有配套的精品资源,点击获取