ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32矩阵键盘扫描原理与实现:4×4键盘驱动代码详解

STM32矩阵键盘扫描原理与实现:4×4键盘驱动代码详解 1. 从按键需求到矩阵方案为什么偏偏是4×4做嵌入式开发的朋友尤其是玩STM32的迟早会遇到按键设计的问题。项目简单点的一个GPIO接一个轻触开关读电平就能判断。但一旦按键数量超过四个、八个比如密码锁、菜单选择、计算器界面你总觉得引脚不够用。实际上STM32的GPIO口数量并不少但真到了产品设计阶段每个引脚都要精打细算——要留足串口、I2C、SPI、ADC、PWM这些外设通道再富余的引脚也架不住一颗按键占一个IO。这时候就应该用矩阵扫描结构。4×4矩阵键盘核心思路就是用8个GPIO口驱动16个独立按键通过对行列的组合检测来识别哪个键被按下。相比独立按键方案省了一半以上的引脚资源而且扩展性极强——3×3是9键、4×4是16键、5×5是25键只要行线和列线够用按键数量随便涨。不少初学者第一次接触矩阵键盘是在开发板上板载一个4×4的薄膜键盘拿到手就开始写扫描代码结果发现要么按键没反应要么按一个键同时触发好几个键。这个问题几乎每个人都会遇到原因不复杂要么是引脚配置不对要么是扫描逻辑里漏掉了消抖和防串键处理。我从第一次写完矩阵键盘驱动到稳定运行前前后后踩了不少坑这篇就把整个设计过程完整拆开讲从硬件原理到代码实现都给你理一遍。这个方案适合谁正在做STM32入门项目的学生、准备毕业设计的本科生、做小型工控或智能家居项目需要多按键输入的工程师都能从这套思路里直接拿代码改改就用。我不讲那些花哨的技巧就讲最实在的扫描原理、代码架构和调试方法。2. 矩阵键盘的核心原理行列交叉与扫描时序2.1 4×4矩阵的物理结构矩阵键盘外观上是一个4行×4列的按键阵列但电路板上的走线方式完全不同。你需要先明白它的物理本质16个按键被排列在8条导线的交叉点上每条行线连接4个按键的一端每条列线连接4个按键的另一端。实际接线一般是这样的行线Row1~Row4接到单片机的GPIO并配置为输入模式带上拉列线Col1~Col4接到另外4个GPIO并配置为推挽输出。扫描时让列线逐列输出低电平同时读行线电平如果哪一行变成了低电平就说明这个交叉点上的按键被按下了。比如第2列输出低、第3行读到低那就是第3行第2列的按键被触发。这个交叉点的概念是关键。很多人画原理图的时候习惯把16个按键分别编号但在矩阵扫描程序里按键的位置是“行号列号”的组合坐标而不是一个绝对编号。所以做硬件设计的时候按键布局最好画成规规矩矩的4×4网格软件里用坐标映射表转换成实际键值不然写代码的时候算坐标能算到怀疑人生。2.2 逐列扫描到底在扫什么逐列扫描法是矩阵键盘最常用的读取方式你每次只驱动一列为低电平其余列保持高电平然后读取全部行线的状态。4列就需要4次这样的操作每次操作能确认4个按键的状态组合起来就是16个按键的完整状态。我画个简单的逻辑说明第1次Col10Col2/3/41读Row1~Row4第2次Col20Col1/3/41读Row1~Row4第3次Col30Col1/2/41读Row1~Row4第4次Col40Col1/2/31读Row1~Row44轮扫描完成后你手上就有4组4位的数据每组数据对应一列下四个行的状态。把这16个bit凑齐就是整个键盘的瞬时状态快照。实际代码里很少真的做4次完整的“输出低电平→读输入→改输出”循环那样太慢。更常见的做法是循环遍历列索引每次循环设置对应的列引脚输出低、其余输出高然后立即读GPIO端口的输入寄存器把行值提取出来。对STM32来说一次完整的16键扫描只需要几十微秒主频72MHz下基本可以忽略不计。2.3 为什么行列必须加电阻这里必须强调下拉/上拉电阻的配置。如果用STM32的标准库或HAL库行引脚可以配置为输入上拉模式内部就有上拉电阻可用省掉外接电阻。但如果你用的是老式51单片机或者裸芯片方案外部必须加上拉电阻否则按键悬空时行引脚电平不确定读回来的数据会乱七八糟。另外一个容易忽略的点列引脚作为输出理论上不需要接限流电阻但当按键按下时列引脚和行引脚之间是直接导通的。如果行引脚配置成带上拉的输入内部上拉电阻通常30~50kΩ电流非常小不会出问题。但如果行引脚配置成了推挽输出麻烦就大了——两个推挽输出短接一高一低就是短路风险轻则读数错误重则烧引脚。所以引脚方向配置一定要谨慎行就是输入、列就是输出别搞混。3. 硬件连接与引脚规划建工程前先想清楚的事3.1 引脚分配策略STM32的GPIO资源丰富但并不代表你可以随意挑8个引脚出来接键盘。连接位置会影响后续的软件复杂度和代码可读性我建议按以下规则规划4条行线尽量接在同一个GPIO端口比如PA0~PA3这样读取的时候可以直接操作IDR寄存器一次读出一个端口的所有引脚状态不用挨个调用GPIO_ReadPin。4条列线也尽量接在同一个端口比如PB0~PB3设置输出时可以用BSRR寄存器一次搞定多个引脚的电平切换。行列引脚不要和调试接口、下载接口共用。STM32的PA13/PA14是SWD调试引脚PA9/PA10是USART1如果你把键盘接在这些引脚上每次下载程序或者串口调试都要拔线非常折腾。如果项目里同时用到ADC、PWM等模拟功能行列引脚要避开这些有特殊功能的引脚优先选普通的GPIO。按这套规划接完线你会发现代码写起来非常顺——行值读取可以用一个常量掩码从IDR寄存器里直接提出来列扫描只需要循环切换一个变量然后写BSRR整个驱动函数不超过50行就能搞定。3.2 硬件实物连接示例假设我们使用STM32F103C8T6最小系统板典型的引脚分配如下行0PA0行1PA1行2PA2行3PA3列0PB0列1PB1列2PB2列3PB3矩阵键盘模块的排针通常是按行和列分别引出两组4Pin接口你用杜邦线一一对应接好就行。如果是自制PCB就在原理图里把16个按键排成4×4阵列行线连到一起、列线连到一起然后接到MCU引脚。提示很多现成的4×4矩阵键盘模块的引脚排列顺序并不是标准的先4行后4列买回来之后务必先看模块背面的丝印标注或者用万用表蜂鸣档量一下哪个引脚对应哪一行哪一列。我见过好几个人因为引脚顺序搞反代码里行列坐标彻底错乱查了半天才发现是接线问题。3.3 用CubeMX配置GPIO的要点如果你用STM32CubeMX生成初始化代码配置其实很快。把PA0~PA3配置为GPIO_InputPull-up上拉PB0~PB3配置为GPIO_Output初始电平设为High。生成代码时注意看一下GPIO初始化函数里的模式参数行引脚一定是GPIO_MODE_INPUT带上拉列引脚是GPIO_MODE_OUTPUT_PP也就是推挽输出。CubeMX生成的初始化代码默认把所有GPIO的速度都设置成Low对按键扫描来说完全够用不需要改成High。有些人在这一步纠结要不要开启时钟之类的其实你只要确保GPIOA和GPIOB的时钟都使能了就行初始化代码里CubeMX会自动处理。硬件这块看似简单但至少占掉整个项目三分之一的工作量。连接质量直接决定后续调试的效率插排线的时候多花一分钟确认后面能省一小时查bug的时间。4. 核心代码实现三种扫描策略的对比与取舍4.1 基础轮询扫描最直观的写法轮询扫描就是在主循环里不停地执行扫描函数每次获取完整键盘状态并处理。这是最基础的写法也最适合理解矩阵键盘工作原理。#define ROW_PORT GPIOA #define ROW_PINS (GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3) #define COL_PORT GPIOB #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 uint8_t key_state[4][4]; void MatrixKey_Scan(void) { uint8_t col_pins[4] {COL_PIN_0, COL_PIN_1, COL_PIN_2, COL_PIN_3}; uint8_t row_values; for (uint8_t col 0; col 4; col) { // 先将所有列置高 HAL_GPIO_WritePin(COL_PORT, COL_PIN_0 | COL_PIN_1 | COL_PIN_2 | COL_PIN_3, GPIO_PIN_SET); // 当前列拉低 HAL_GPIO_WritePin(COL_PORT, col_pins[col], GPIO_PIN_RESET); // 稍微延时等待电平稳定 for (volatile uint8_t i 0; i 50; i); // 读取行值4个行引脚在同一端口 row_values HAL_GPIO_ReadPin(ROW_PORT, ROW_PINS); // row_values里只保留了行引脚对应的位逐一判断 for (uint8_t row 0; row 4; row) { if ((row_values (GPIO_PIN_0 row)) 0) { key_state[row][col] 1; // 按键按下 } else { key_state[row][col] 0; // 按键释放 } } } }这段代码的逻辑很直白每次扫描把4列轮一遍和2.2节描述的过程完全一致。代码里的延时是为了等待GPIO输出稳定和信号传输延迟这个延时大小和主频有关一般几微秒到几十微秒就够。你写完后可以在主循环里连续调用这个扫描函数在串口里打印按键状态来验证。不过轮询扫描有个天生的弱点如果主循环里还有其他任务比如屏幕刷新、传感器读取按键扫描的频率就会不稳定。极端情况下主循环被阻塞几十毫秒按键响应就会出现明显延迟。解决这个问题的方法就是下面要讲的定时器扫描。4.2 定时器中断扫描稳定且不占用主循环这个方案是我在实际项目里用得最多的。思路是用一个定时器产生固定频率的中断比如1ms一次在中断服务函数里执行矩阵键盘扫描逻辑。按键检测的实时性和主循环的负载完全解耦无论主循环忙成什么样按键都能按固定节奏被扫描。// 定时器中断服务函数1ms进入一次 void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim2, TIM_FLAG_UPDATE) ! RESET) { __HAL_TIM_CLEAR_FLAG(htim2, TIM_FLAG_UPDATE); MatrixKey_Scan(); MatrixKey_DebounceHandle(); } }MatrixKey_DebounceHandle是消抖处理函数后面会详细讲。定时器扫描方案的好处是扫描周期固定消抖、长按、连击这些时间参数都变得好算了——你知道每次进入中断的间隔用简单的计数器就能实现精准的时间控制。唯一要注意的是中断服务函数里不要写太复杂的逻辑。扫描本身很轻量但如果你在中断里做按键事件的业务处理比如触发某个协议发送、更新显示就可能影响主程序的实时性。正确的做法是中断里只负责更新按键状态和事件标志主循环检测到标志后再处理业务。4.3 外部中断扫描低功耗场景的选择如果项目要求低功耗系统大部分时间处于睡眠状态轮询和定时器方案都不合适——轮询要CPU持续跑定时器要周期性唤醒CPU。这时候可以考虑用外部中断把4条列线接到EXTI引脚按键按下时列线电平变化触发中断在中断里唤醒CPU再执行完整扫描。STM32的EXTI支持16条外部中断线你可以选4条列线对应的引脚做EXTI输入。但要注意外部中断触发的可靠性依赖按键按下的电平跳变。如果按键按下时列线没有产生足够明显的边沿中断可能漏触发。所以这个方案通常配合一个额外的“按键按下检测”引脚或者利用行线的电平变化来做唤醒源。外部中断方案的代码复杂度比前两种都高而且调试也更麻烦。低功耗之外的需求不建议一上来就选它先用轮询或定时器跑通功能最后再根据功耗需求切换方案。4.4 三种方案怎么选三种方案各有适用场景我做了个对比方案代码复杂度实时性CPU占用适用场景轮询扫描低受主循环影响低~中学习验证、简单项目定时器扫描中高固定周期极低绝大多数实际项目外部中断扫描高高按需唤醒低低功耗设备个人建议如果你在做正式项目直接上定时器扫描方案。轮询扫描适合快速验证硬件连接写个demo看看每个按键能不能正常响应但产品代码用轮询的话后面主循环越来越复杂的时候你就等着头疼吧。5. 消抖与防串键矩阵键盘最容易翻车的两个细节5.1 机械按键的抖动问题机械按键按下和释放的瞬间触点会因为机械弹跳产生一系列短暂的不稳定电平持续时间通常5~20ms。如果不做消抖处理一次按下可能会被识别成多次触发轻则按键多跳一下重则干扰整个系统的状态机。消抖的传统方案是延时消抖检测到电平变化后延时10~20ms再读一次如果电平稳定就确认为有效变化。这个方法在轮询扫描中可以这样写uint8_t MatrixKey_GetDebouncedState(uint8_t row, uint8_t col) { uint8_t first_state key_state[row][col]; HAL_Delay(15); uint8_t second_state key_state[row][col]; return (first_state second_state) ? first_state : 0; }但这种方法有个问题HAL_Delay会阻塞整个CPU 15ms如果主循环还有别的事这15ms就是纯纯的浪费。定时器扫描方案里更好的做法是用连续采样计数连续N次扫描都读到同一个状态才认为状态有效。uint8_t debounce_count[4][4] {0}; void MatrixKey_DebounceHandle(void) { for (uint8_t row 0; row 4; row) { for (uint8_t col 0; col 4; col) { if (key_state[row][col] 1) { if (debounce_count[row][col] 3) { debounce_count[row][col]; if (debounce_count[row][col] 3) { // 连续3次读到按下确认按键事件 key_event[row][col] 1; } } } else { debounce_count[row][col] 0; } } } }这个逻辑的核心思想是按键必须先“连续3次扫描都检测到按下”才被确认为有效事件任何一次的释放都会清零计数。由于定时器中断固定1ms进入一次这3次计数实际上等效于3ms的消抖时间但比单纯的延时消抖灵活得多——你想调消抖时间改计数阈值就行不用动延时函数。5.2 串键现象为什么按下两颗键会乱码矩阵键盘有个特殊的问题叫串键或鬼键。假设你同时按下第1行第1列和第2行第2列的按键这时候第1行第2列的交点处虽然没被按下但因为行线1和行线2都被列线1和列线2拉到了通路状态程序可能会误判第1行第2列的按键也被按下了。我举个例子解释得更清楚Row1-Col1接通Row2-Col2接通这时候Row1和Col2之间并没有按键导通但当Col1输出低电平扫描时电流路径是Col1→Row1→Row2→Col2Row2读取到了低电平程序就会误认为Row2-Col1也被按下。解决串键有两种思路。第一种是硬件方案在每颗按键上串联一颗二极管方向从行线到列线阻止电流倒灌。这个方案在正规键盘上很常见但会增加硬件成本。第二种是软件方案在扫描时只接受单键按下检测到多个键同时按下就直接丢弃本轮扫描结果不产生任何按键事件。产品里如果允许组合键比如游戏手柄的多个方向键同时按下那软件方案行不通必须加二极管。但大多数场合比如密码输入、菜单选择用户不会同时按两个键软件方案完全够用也省事。5.3 长按与连击的事件处理按键事件不一定只是按下和释放两种状态实际产品里经常需要区分短按、长按和连击。基于定时器扫描框架这个需求实现起来很自然。在每次扫描中如果检测到按键有效按下就把这个键的按下次数加1。当计数达到短按阈值比如50也就是50ms仍未释放触发一次短按事件继续计数到500500ms后触发一次长按事件如果长按期间每隔200ms触发一次连击事件就是手动实现了一个类似电脑键盘的连发功能。这些阈值参数可以根据你的使用场景随意调整。我建议把短按、长按、连击的阈值定义成宏不要写死在代码里后面调节试参数的时候不用翻逻辑代码。6. 完整驱动代码与项目实战一个密码锁的例子6.1 驱动层代码封装思路前面几节的代码片段是打散的真正要用到项目里最好封装成一个独立模块。我习惯建两个文件matrix_key.h和matrix_key.c对外只暴露几个接口内部细节全部隐藏。// matrix_key.h #ifndef __MATRIX_KEY_H #define __MATRIX_KEY_H #include main.h #define KEY_PRESS 1 #define KEY_RELEASE 0 typedef enum { KEY_0, // Row0-Col0 KEY_1, // Row0-Col1 KEY_2, // Row0-Col2 KEY_3, // Row0-Col3 KEY_4, // Row1-Col0 // ... 按实际布局对应 KEY_15 } MatrixKey_Num; typedef enum { KEY_EVENT_NONE, KEY_EVENT_SHORT, KEY_EVENT_LONG, KEY_EVENT_REPEAT } MatrixKey_Event; void MatrixKey_Init(void); void MatrixKey_Scan(void); void MatrixKey_Task(void); // 在定时器中断里调用 uint8_t MatrixKey_GetPressedKey(void); MatrixKey_Event MatrixKey_GetEvent(void); #endif在.c文件里扫描函数和消抖函数都被MatrixKey_Task封装起来主程序只需要调用Task然后轮询读取按键事件。这样主程序里的业务代码看起来很干净——你不需要关心扫描细节、消抖计数只用关心“拿到了哪个键、这个键是短按还是长按”。6.2 密码锁业务逻辑怎么把这16个键用起来拿一个常见的应用场景举例4×4矩阵键盘做6位密码锁。密码为“123456”键盘布局和电话键盘一致第1行1 2 3 A第2行4 5 6 B第3行7 8 9 C第4行* 0 # D主循环的逻辑应该是这样的uint8_t password[6] {1, 2, 3, 4, 5, 6}; uint8_t input_buffer[6] {0}; uint8_t input_index 0; while (1) { MatrixKey_Event event MatrixKey_GetEvent(); uint8_t key MatrixKey_GetPressedKey(); if (event KEY_EVENT_SHORT key ! 0xFF) { // 将键值映射为字符 char key_char KeyMapToChar(key); if (key_char #) { // 确认密码 if (memcmp(input_buffer, password, 6) 0) { // 密码正确执行开锁操作 Unlock(); } else { // 密码错误提示 ShowError(); } input_index 0; } else if (key_char *) { // 删除一位 if (input_index 0) input_index--; } else { if (input_index 6) { input_buffer[input_index] key_char; } } } }这样按键驱动层和业务层就彻底分离了。想换成8×8键盘驱动层改扫描逻辑业务层完全不用动想改密码位数业务层改一个数组大小就行。6.3 调试过程实录一次串键问题的排查我第一次做矩阵键盘密码锁时遇到一个很诡异的现象输入“123456”时系统时不时会报密码错误。后来我在串口里把每次按键的键值打出来发现输入“3”的时候偶尔会同时抓到一个“6”的事件。排查过程是这样的先怀疑硬件连接问题用万用表量了所有行线和列线确定没有虚焊或短接。然后怀疑消抖逻辑不够把连续采样次数从3提高到5发现问题还在。最后我在扫描函数里加了打印发现按下Row0-Col2也就是“3”时Row0-Col1偶尔读到低电平这就是典型的串键——因为“2”和“3”在相邻的列上按下“3”时机械结构可能轻微带动了旁边的“2”。最终解决办法是调整扫描策略每次扫描只记录单键按下检测到多个键同时按下时丢弃本帧数据并增加一个5ms的屏蔽窗口。改完之后这个现象就消失了后来再也没复发过。7. 常见问题与排查技巧实录扫雷指南7.1 按键完全无响应的排查路径矩阵键盘接上后一个键都没反应这是新手最容易遇到的问题。排查路线其实很固定按顺序来就行第一步检查电源和地。模块供电是否正常MCU和模块是否共地。行线和列线如果分别接在两个不同的电源域上电平参考不一致读回来的数据肯定是错的。第二步检查引脚初始化配置。用调试器或者示波器量列引脚电平看扫描时是否确实在轮番变化。如果列引脚始终是高电平说明初始化配置可能有问题或者代码里根本没有执行扫描函数。第三步检查行引脚读取。把行引脚配置成输入上拉在按键按下的时候量行引脚电平确认能拉低。如果行引脚内部上拉没生效按键按下时读到的可能是高阻态代码判断就会出错。第四步检查扫描函数是否被调用。常见问题是一开始把扫描函数写在主循环里但主循环被某个阻塞函数卡住了扫描一直没执行。你可以在扫描函数里放一个计数器然后在串口或LED上输出确认它在跑。7.2 按键错乱与串键的快速定位按键错乱分两种一种是固定的错位比如按下“1”识别成“3”这种大概率是接线顺序错了——行线和列线没有一一对应另一种是偶发的多键触发这种多半是串键问题需要加强行读取逻辑里的防串键处理。排查错位问题最快的方法是在扫描函数里把所有16个按键的原始坐标打印出来然后逐个按键按下记录每个键对应的行列组合。整理成一个表格对照实际接线就能发现是哪条线接错了。7.3 STM32调试环境相关的高频问题写矩阵键盘这类基础外设驱动调试环境本身也经常出问题。我列几个典型的Keil5里下载程序时提示找不到目标芯片大概率是Debug配置里选错了调试器型号或者SWD引脚被复用成了其他功能。PA13和PA14默认是SWD如果你把这俩引脚配置成普通GPIO且程序里没有禁用JTAG的选项下载器就连不上了。解决办法是把BOOT0拉高进入串口下载模式或者按住复位键的同时点下载时机掌握好就能连上。另外一个常见问题GPIO配置了内部上拉但读到的电平不稳定。这个和外部电路的走线长度有关如果按键模块离MCU比较远建议用短一点的杜邦线或者接个100nF的电容在按键两端做硬件滤波。keil5新建工程时找不到STM32芯片型号是因为没有装对应的芯片包去Pack Installer里安装STM32F1系列的Device Family Pack就行。STM32F103的USART1在PA9和PA10如果你用了串口打印调试信息这俩引脚就不能再接键盘。我就吃过这个亏接线都焊好了才发现串口和矩阵键盘撞了引脚最后只能飞线换引脚非常痛苦。7.4 关于GPIO的速度设置的取舍STM32的GPIO输出速度有Low、Medium、High三档。很多人以为输出速度越快越好其实不是。对矩阵键盘这种低频信号Speed选Low完全够用选High反而可能因为信号边沿太陡导致电磁干扰和串扰。同样的道理也适用于I2CI2C的速率都不快GPIO速度选Low反而会让波形更干净。这个知识点对做硬件的新手很有用能少踩很多坑。8. 经验总结与进阶方向做完一个4×4矩阵键盘驱动其实你已经打通了STM32 GPIO输入输出、定时器、中断、状态检测这几项最基础也是最重要的技能。这个项目看似简单但里面蕴含的知识点是很扎实的GPIO模式配置、端口寄存器操作、定时器中断框架、消抖算法、状态机思想全部都是后续做复杂项目的基础。我用了很多次矩阵键盘每换一个平台就重写一遍驱动但核心思路从来没变过。后来我还把这份驱动移植到STM32F407、GD32和ESP32上改动量很小。你只要把GPIO操作相关的宏和底层函数换一下上面的消抖和事件逻辑完全可以复用。进阶方向上你可以给这个驱动加上“组合键”支持比如同时按下“1”和“3”触发特殊功能这在某些工控面板上很实用。也可以把扫描结果通过DMA直接搬运到内存彻底解放CPU不过对4×4键盘来说有点杀鸡用牛刀8×8以上的矩阵才有必要。最后分享一个我个人的习惯每次拿到一块新的矩阵键盘模块我做的第一件事不是急着写驱动而是先用万用表把所有行线和列线之间的导通关系测一遍画一个引脚映射表出来。这个步骤十分钟就能做完但能省掉后面无数次的怀疑和重查。纸上开门见山心里不慌。
返回列表