STM32矩阵按键完整方案:从硬件接线到状态机消抖
简介基于STM32F103的4x4矩阵按键完整工程包面向嵌入式初学者与STM32应用开发者解决多按键检测、消抖及冲突处理等常见问题非常适合作为单片机驱动设计和综合实验参考。压缩包共80个文件以31个C语言源文件与32个头文件为主体覆盖按键扫描、去抖动、串口、语音播报和系统定时器等模块另有8个汇编启动文件、Keil工程配置、HEX烧录文件及批处理脚本方便直接打开工程运行并烧录验证整体仅325KB目录结构清晰。已有4105人学习下载。工程采用行列扫描方式将行线设为推挽输出、列线设为输入上拉通过循环拉低单一行线并读取列状态识别按键同时运用延时滤波消除机械抖动并设计多按键同时按下时记录最后释放按键的优先级机制兼顾实时性与可靠性。分析源码可掌握GPIO配置、状态扫描、中断处理以及开发调试的完整链路也能在此基础上迁移复用快速构建点名器、密码锁等交互应用。1. 一块开发板就能把 16 个按键压到 8 个引脚矩阵按键到底在解决什么问题拿到 STM32F103 做交互第一个绕不过去的问题就是 IO 不够用。一个最简单的温控器要 3 个按键一个密码锁要 12 个按键一个计算器要 16 个按键。如果每个按键独占一个引脚16 个按键就要 16 个 IOF103 最小的 LQFP48 封装总共才 37 个可用 IO光按键就吃掉一半后面还要接显示屏、传感器、通信根本排不开。4×4 矩阵按键的思路是用 4 根行线加 4 根列线在交叉点放按键16 个按键只占 8 个 IO省下来一半引脚。这篇笔记就把从硬件连线到扫描代码、从消抖到长按识别的完整方案拆开讲新手能照着接线上手老手可以跳过原理只看排错和进阶写法。2. 矩阵按键的硬件原理与接线为什么行列交叉就能省引脚矩阵按键省 IO 的本质是让按键不独占引脚而是通过行列交叉来定位。这个思路在单片机资源紧张时非常实用但它换来的是扫描时序的复杂度以及一些独立按键没有的限制搞懂了才好动手接线。2.1 行列交叉的电气结构按下按键等于连通一根行线和一根列线矩阵按键的电路结构并不复杂。4 根行线 Row0Row34 根列线 Col0Col3每一个按键放在一根行线和一根列线的交叉点上。按下按键等效于把这一行和这一列短接。比如第二行第三列的按键被按下Row1 和 Col2 之间就导通。判断哪个按键被按下最常用的方法是行扫描法。先把所有行线设为输出依次让某一行输出低电平其余行输出高电平然后把所有列线设为输入并带上拉。这时去读列线的电平如果某一列读到低电平就说明这一行和这一列交叉处的按键被按下了。举个例子Row1 被拉低读 Col0Col3发现 Col2 是低电平那被按下的就是 Row1 和 Col2 交叉处的按键。一次只扫一行4 行全部扫完就是一轮完整的扫描耗时不超过几毫秒肉眼看起来所有按键像是同时被检测的。这里有个容易被忽略的细节行线为什么必须用推挽输出列线为什么必须用上拉输入如果行线用开漏输出低电平要靠外部下拉才能保证如果列线没有上拉按键释放时列线会悬空读到的电平随机跳变误触发就来了。2.2 把 4×4 矩阵接到 STM32F103引脚选择和上下拉的处理接线的第一步是分配引脚。常见做法是从 F103 的 GPIOA、GPIOB 各挑几个连续的引脚方便代码里用位操作批量控制。比如 GPIOA 的 Pin0Pin3 做行线GPIOB 的 Pin0Pin3 做列线这个分配不是固定的但建议避开默认的下载引脚 PA13、PA14避免调试器冲突。STM32F103 的 GPIO 内部没有独立的上拉电阻开关吗其实是有的但很多开发板外部还挂了额外的上下拉电阻接线前要先看原理图确认。如果板子上列线已经接了外部上拉到 3.3V代码里就不用再开内部上拉如果外部什么都没接就需要在初始化时使能内部上拉。两个都开也不会有问题只是上下拉并联后等效电阻更小功耗略高一点。先按最常用的接法列一个参数表后面代码就按这个来功能引脚建议GPIO 模式备注行线 Row0Row3PA0PA3推挽输出默认输出高电平扫描时逐行拉低列线 Col0Col3PB0PB3上拉输入或外部上拉读取电平低电平表示有按键按下按键排列16 个4×4无源触点行线和列线交叉处这个表里的引脚分配不是唯一答案核心是行的输出模式和列的输入模式必须配套。行线如果配成输入、列线配成输出也能扫但代码逻辑正好反过来新手容易绕晕建议第一版就按行输出、列输入来写。2.3 初始化代码骨架HAL 库的 GPIO 配置这么写用 STM32CubeMX 生成工程后GPIO 初始化在MX_GPIO_Init里。行线的配置是推挽输出列线是上拉输入。void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; /* 使能 GPIOA 和 GPIOB 的时钟 */ __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); /* 行线 PA0-PA3推挽输出默认输出高 */ GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); /* 列线 PB0-PB3上拉输入 */ GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); /* 把所有行线置高所有列线都读到高电平表示当前无按键 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3, GPIO_PIN_SET); }初始化里有两个容易写错的点。第一个是行线默认电平必须和高否则上电瞬间会误判有按键第二个是列线的Pull参数要确认和硬件上外部电阻的配置一致如果外部已经有上拉这里可以保持GPIO_NOPULL少一份重复配置就少一处冲突。Speed 用 LOW 就够了按键扫描不是高速通信没必要跑满。3. 从原理到代码4×4 矩阵按键扫描程序怎么写硬件接好、初始化通过后核心就是扫描函数。扫描的好与坏直接决定按键是灵敏还是乱跳。常见写法有两种直接扫描和状态机扫描。直接扫描适合快速验证状态机适合产品化。3.1 最小扫描函数4 行循环拉低读回 4 列先看一个能跑的最小实现。这个函数的逻辑是每一轮扫描把当前行拉低其余行拉高然后读列线。如果读到某一列是低就记录下当前行号和列号换算成按键编号。uint8_t Key_Scan(void) { uint8_t row 0; uint8_t col 0; const uint8_t row_pins[4] {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3}; const uint8_t col_pins[4] {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3}; for (row 0; row 4; row) { /* 先将所有行线置高再单独拉低当前行避免出现两行同时为低 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOA, row_pins[row], GPIO_PIN_RESET); /* 延时一小段时间等电平稳定后再读列线 */ for (volatile int i 0; i 200; i); for (col 0; col 4; col) { if (HAL_GPIO_ReadPin(GPIOB, col_pins[col]) GPIO_PIN_RESET) { /* 行列都知道按键编号 行 * 4 列 */ return (row * 4 col); } } } return 0xFF; /* 0xFF 表示本轮没有任何按键按下 */ }这里return (row * 4 col)的换算逻辑是第一行第一列是 0第一行第二列是 1第二行第一列是 4和数组下标对齐方便后续查表映射。返回 0xFF 表示无按键这不是一个合法键值用来区分“有按键”和“没按键”。这个函数有一个明显的问题没有消抖。机械按键按下和释放的瞬间触点会有 520ms 的抖动单片机读到的电平会在这段时间里跳变多次。直接把这个函数放在主循环里跑一分钟内就会出现一次按键触发两次、或者键值随机漂移的现象。3.2 消抖的两种写法延时消抖适合入门状态机消抖适合产品延时消抖是最容易理解的做法检测到按键按下后等 1020ms再读一次如果依然是按下确认按键有效。把上面的扫描函数改造成带消抖的版本uint8_t Key_Scan_Debounce(void) { uint8_t key Key_Scan(); if (key ! 0xFF) { /* 第一次读到按下延时避开抖动窗口 */ HAL_Delay(20); /* 再读一次如果还是同一个键说明是稳定按下 */ if (Key_Scan() key) { return key; } } return 0xFF; }写法虽然简单但它有一个副作用HAL_Delay会让 CPU 停在这里干等 20ms。如果主循环里还有显示屏刷新或者通信任务这 20ms 就会造成明显的卡顿。对只有按键需求的裸机程序问题不大但程序稍一复杂就不合适了。非阻塞状态机消抖的做法是每次调用扫描函数时读取原始电平把结果放进一个状态机里靠多次扫描的连续一致来判断按键稳定。这样不需要延时等待CPU 可以一边扫按键一边干别的。3.3 按键值映射让键值不再是 015而是你想要的逻辑值扫描得到的键值是按排列顺序来的 015但你产品上的按键不一定按这个顺序排。比如一个四行四列的面板最左上角的按键可能代表“菜单”下面那个代表“确认”硬件排列和逻辑功能完全对不上。常见做法是用一张映射表把扫描键值转成逻辑键值const uint8_t key_map[16] { 1, 2, 3, 4, /* 第一行数字键 1 2 3 4 */ 5, 6, 7, 8, /* 第二行数字键 5 6 7 8 */ 9, 10, 11, 12, /* 第三行数字键 9 0 确认 取消 */ 13, 14, 15, 16 /* 第四行功能键 */ }; uint8_t Key_GetLogicValue(void) { uint8_t raw Key_Scan_Debounce(); if (raw 0xFF) { return 0xFF; } return key_map[raw]; }映射表的顺序完全由你的硬件排线决定不要照抄网上的任何代码。拿到自己的板子先用一根杜邦线逐个短接行线和列线记录扫描函数返回的值再填进这张表这一步省不了。4. 按下、释放、连按从电平扫描升级到按键事件直接返回键值的扫描函数在真实产品里是不够用的。因为大多数场景关心的不只是“当前有没有按键被按下”而是“这个按键什么时候被按下、什么时候被释放、有没有长按”。这一章把扫描函数升级成事件模型。4.1 事件与电平的区别一个完整的按键动作包含按下和释放两次跳变先看一个问题如果用第 3 章的扫描函数主循环里检测到键值不等于 0xFF 就执行某个动作那么按键按下去的那几十毫秒里主循环可能已经执行了几十次循环动作被重复触发了十几次直到手指松开才停。电平检测的问题就在这里只要电平还是低的它就认为按键一直被按着。要用事件模型来解决。所谓事件是指把“按下”和“释放”当成两个独立的事件。第一次检测到按键从无到有时上报一个“按下事件”按键从有到无时上报一个“释放事件”。中间的电平持续状态不再重复上报。这样设计的好处是逻辑层只需要处理一次按下事件比如执行一次加一操作长按检测则可以通过统计按下持续的时间来实现。4.2 用状态机区分按下、长按和释放一个函数搞定全部状态转移按键状态机通常有 4 个状态空闲态、按下确认态、长按触发态、释放等待态。每次调用扫描函数根据当前状态和原始电平决定要不要转移。typedef enum { KEY_STATE_IDLE 0, /* 空闲没有按键 */ KEY_STATE_PRESSED, /* 已确认按下 */ KEY_STATE_LONG_PRESSED, /* 长按已触发 */ KEY_STATE_RELEASED /* 已释放等待回到空闲 */ } key_state_t; uint8_t Key_ProcessEvent(void) { static key_state_t state KEY_STATE_IDLE; static uint8_t last_key 0xFF; static uint32_t press_tick 0; uint8_t key Key_Scan(); /* 读取原始键值不含消抖 */ switch (state) { case KEY_STATE_IDLE: if (key ! 0xFF) { /* 第一次读到按键进入按下确认 */ state KEY_STATE_PRESSED; last_key key; press_tick HAL_GetTick(); /* 确认按下事件在下面统一处理这里不处理 */ } break; case KEY_STATE_PRESSED: if (key last_key) { /* 同一个键持续被按下超过 500ms触发长按 */ if (HAL_GetTick() - press_tick 500) { state KEY_STATE_LONG_PRESSED; return KEY_EVENT_LONG_PRESS; /* 上报长按事件 */ } } else if (key 0xFF) { /* 按键已经释放上报一次按下事件 */ state KEY_STATE_RELEASED; return KEY_EVENT_PRESS; } else { /* 换了一个按键视为重新按下 */ state KEY_STATE_PRESSED; last_key key; press_tick HAL_GetTick(); } break; case KEY_STATE_LONG_PRESSED: if (key 0xFF) { /* 长按结束后释放 */ state KEY_STATE_RELEASED; return KEY_EVENT_RELEASE; } break; case KEY_STATE_RELEASED: if (key 0xFF) { /* 彻底松开回到空闲 */ state KEY_STATE_IDLE; } else { /* 还没松开维持在释放态 */ } break; default: state KEY_STATE_IDLE; break; } return KEY_EVENT_NONE; }这段代码里最需要理解的是状态转移的时机。按下事件不是在空闲态里返回的而是在释放态确认按键松开后才返回这样保证了一次完整的按下到释放过程只上报一次按下事件。长按事件在按下持续 500ms 时上报之后一直维持长按态直到松开再上报释放事件。HAL_GetTick()是 HAL 库自带的毫秒时基它依赖于 SysTick 中断所以在使用状态机之前要确认工程里已经配置了中断。另一个值得注意的点是状态机的轮询间隔要尽可能稳定否则HAL_GetTick()的差值会因为主循环里其他任务而抖动长按边界会不稳。4.3 多键组合怎么处理矩阵键盘天生不擅长这个矩阵键盘天生有一个硬伤不支持任意多键同时按下。两个按键同时按下时如果它们不在同一行也不在同一列会造成行列短路读到一组根本不存在于键盘上的“鬼键”。比如 Row1-Col1 和 Row2-Col2 同时按下Row1 和 Col2 也被间接导通读起来像是第三个按键被按下。处理办法有三个层次。第一层是结构层面4×4 矩阵键盘只适合单键操作或者简单的两键组合如果你的产品需要很多组合键换成独立 IO 按键或者扫描式电容按键更合适。第二层是软件层面在扫描时发现同一行检测到多个列导通或者同一列检测到多个行导通直接丢弃本轮结果当作无效数据。第三层是二极管方案在每个按键上串联一个二极管阻止反向导通电流硬件成本增加不大实现真正的多键同时按下但 4×4 矩阵键盘很少这么做。5. 避坑与排查矩阵按键移植和调试中的 6 个常见问题接手一个矩阵按键工程最常见的问题不在代码逻辑而在硬件连接和初始化细节。下面这几条都是实际调试中反复遇到的每一条都按现象、原因、解决来写。5.1 按键按下去没反应但杜邦线短接行线和列线却能读到现象按键装上去怎么按都没反应用杜邦线直接短接行线列线却有正确键值。原因按键本身没有导通。常见原因是按键引脚氧化、焊接虚焊或者按键的四个引脚没有区分清楚接错了对角。解决先用万用表二极管档测按键按下时两端应该导通并有蜂鸣不导通就直接换按键。再检查 PCB 封装四脚按键的两对引脚是常开触点接的时候必须接在同一对的引脚上。上次一个同事把行线接在一对引脚、列线接在另一对引脚相当于按键按下只是把两个空引脚短接整个矩阵成了一个断路状态。5.2 一个按键触发相邻按键也跟着触发现象按下第 2 行第 3 列的按键偶尔会读到第 1 行第 3 列或者第 2 行第 2 列的值。原因行线扫描时其余行没有完全拉高。由于 GPIO 输出高电平的驱动能力有限某些行的电平被干扰导致相邻行也被列线读到低电平。解决确认扫描代码里每次切换行之前先把所有行线全部写高再单独拉低目标行。另一个更彻底的方案是不使用的列线读完之后继续扫描下一行之前加一个极短延时等电平稳定再读。如果问题依旧用示波器量行线波形看拉低和拉高沿是否干净必要时把行线的 Speed 从 LOW 改成 MEDIUM。5.3 按下一次按键动作执行了两三次现象逻辑代码只在一个键值出现时执行一次操作但实际每按一次操作执行了两三次。原因没有消抖或者消抖时间不够。机械按键的抖动时间一般 10ms 左右有些廉价按键能抖到 20ms。消抖只做了 5ms抖动窗口没完全覆盖。解决把消抖时间抬到 20ms如果用的是状态机把状态切换条件改为“连续两次读取到同一键值”而不是“只读一次就确认”。如果按键本身质量差可以在消抖函数里加一个循环连续三次读到的键值一致才算有效。5.4 上电瞬间误触发一次按键现象系统上电初始化完成主循环还没跑逻辑层就收到了一次按键按下事件。原因GPIO 初始化完成到行线全部写高的空隙列线读到的电平不稳定被误判为按下。解决换一个执行顺序。先把行线全部置高再初始化列线为输入上拉最后再使能中断或者启动扫描任务。如果扫描是在定时器中断里做的初始化的最后再启动定时器中间不要留空档。5.5 按键长按没问题但长按释放后立刻又触发一次按下事件现象长按 5 秒松开后本该只执行一次长按逻辑结果紧接着又来了一次按下事件。原因状态机在长按释放后没有正确回到空闲态而是先进入了释放等待态再进入了空闲态。释放等待态的判断条件是只要读到按键就上报释放事件然后直接跳回空闲但如果松开手的过程有抖动状态机在空闲态又读到一次按下就会再触发一次事件。解决在释放等待态里增加时间保护比如释放后 100ms 内扫描到的任何按键变化都忽略确保回到空闲态时触点已经彻底稳定。或者把释放逻辑改成只有连续 2 次扫描都为无按键才允许转到空闲态。5.6 整机功耗下不来明明没按键按电流却多了好几毫安现象低功耗模式下电流比预期高排查发现是按键扫描电路的问题。原因列线全部上拉行线全部输出高当按键没有按下时行线和列线之间如果存在外部短路或者焊锡残留就会形成持续电流通路。另外如果上拉电阻选得太小功耗也会成倍增加。解决先量列线对地电阻排除短路再检查上拉电阻阻值常见的 10kΩ 上拉在按键不通时不会形成静态电流如果是 1kΩ 就会明显增加功耗。低功耗产品建议在进入休眠前把行线全部设为模拟输入模式切断电平驱动回路醒来后再重新初始化。6. 进阶把扫描放进定时器中断让主循环只做按键消费前面所有示例都把扫描函数放在主循环里轮询这在简单产品里没有问题。但一旦主循环出现长时间的阻塞操作比如驱动 OLED 刷新一行、往 Flash 里写一页数据扫描就会卡住按键响应变得不实时。这种情况下把扫描和状态机搬进定时器中断是最稳妥的写法。常见做法是用 F103 的定时器比如 TIM3配置一个 5ms 的中断周期。每次进入中断执行一次原始电平扫描把结果喂给状态机状态机不在这里消费事件只把事件写入一个缓冲区主循环里的程序随时去取。volatile uint8_t key_event_buf KEY_EVENT_NONE; volatile uint8_t key_value_buf 0xFF; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { /* 每次中断只扫描一次不做延时消抖 */ uint8_t key Key_Scan(); /* 状态机处理后的结果写入缓冲区 */ key_event_buf Key_UpdateStateMachine(key); key_value_buf key; } } uint8_t Key_GetEvent(uint8_t *key_val) { uint8_t evt key_event_buf; if (evt ! KEY_EVENT_NONE) { key_event_buf KEY_EVENT_NONE; *key_val key_value_buf; return evt; } return KEY_EVENT_NONE; }这个方案里几个参数值得调。TIM3 的中断周期5ms 是兼顾实时性和 CPU 占用率的选择如果按键灵敏度要求更高可以改成 2ms但中断开销也翻倍。状态机里的长按判定时间原来是用HAL_GetTick() - press_tick 500现在有了固定 5ms 的节拍可以改成一个计数器累积 100 次就是 500ms这种方式在中断里更精确也不会受主循环阻塞影响。中断版的状态机还有两个隐藏收益。第一个是消抖可以做得更严谨因为扫描周期是固定的 5ms连续 4 次读到的键值一致就认为稳定本身就是一次 20ms 的消抖第二个是低功耗唤醒后的恢复定时器扫描在睡眠时自动暂停醒来后事件缓冲区清零不会因为睡眠期间的变化而产生误动作。这套写法我用了很多年最大的教训是按键事件缓冲区一定要在初始化时清零否则上电后第一次读键会拿到一个残留的垃圾事件。希望这篇笔记能帮你把矩阵按键的方案顺畅落地遇到按键抖动或串键时先查硬件再看时序多半不是玄学。本文还有配套的精品资源点击获取