ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32矩阵按键驱动开发:从GPIO配置到扫描算法与消抖实战

2026/9/20 23:45:32 拓冰建站 浏览量
STM32矩阵按键驱动开发:从GPIO配置到扫描算法与消抖实战 简介STM32基于HAL库的4×4矩阵按键驱动程序面向嵌入式入门者与需要快速实现按键交互的开发者解决STM32工程中矩阵键盘的配置、扫描、去抖与事件检测问题。包内提供完整示例工程采用HAL库完成GPIO初始化、行列扫描、中断触发及软件去抖并给出可读性较好的驱动结构便于按需移植到实际项目中。资源共150个文件以.h头文件与.c源文件为主另含Keil工程文件.uvprojx、CubeMX配置.ioc、编译输出.axf、.hex以及STM32F1系列HAL库底层驱动既能直接打开工程验证也方便对照学习GPIO、定时器、串口等外设的初始化与调用方式压缩包整体仅4.49MB。内容预览中还可见RCC、Flash、DMA等模块的HAL库文件说明资源兼顾了工程扩展与底层配置参考。目前已有1386人学习下载对于课程设计、毕业设计或项目开发均能省去重复搭建环境的时间快速收获一套可复用的矩阵按键驱动方案。1. 为什么4×4矩阵按键能省下一半IO原理先行很多人第一次接触矩阵按键时都会有个疑问16个按键为什么不直接一个按键接一个IO非要搞成矩阵答案是IO不够用。一个STM32F103C8T6满打满算也就37个IO如果做一个16键的数字键盘就占掉16个那其他外设直接不用玩了。矩阵按键的核心思想就是利用分时复用来减少IO占用。4×4矩阵把16个按键排成4行4列只占用8个IO口比直接接法省了一半。这个思路其实和内存条里的DRAM寻址很像——用行列坐标来定位而不是给每个存储单元单独拉一根线。具体来说矩阵键盘的接线方式是4根行线ROW和4根列线COL交叉形成16个交叉点每个交叉点放一个按键按键一端接行线一端接列线。按下某个按键时对应的行线和列线就会被导通。扫描的基本思路有两种行扫描法逐行扫描把行线设为输出列线设为输入。一行一行地拉低电平然后读取列线状态判断哪一列被按下。反转法高低电平反转先把行线输出低电平、列线输出高电平读列线再把行线输出高电平、列线输出低电平读行线。两次读取的结果组合出按键坐标。两种方案对比方案IO配置复杂度扫描速度抗干扰能力代码量逐行扫描较低较快一般较少高低电平反转较高快一般中等我实际项目里两种都写过逐行扫描逻辑更直白适合HAL库这种注重可读性的开发方式。反转法的优势在于一次扫描只需两步就能确定按键位置但要注意引脚模式的切换时机稍不注意就会出现误读。下面我基于逐行扫描法来展开完整驱动实现。2. 引脚规划与HAL库GPIO配置动手前的关键选择2.1 引脚分配选IO有讲究以STM32F103C8T6为例我的推荐分配方案如下矩阵引脚推荐GPIO模式说明ROW0PA0推挽输出支持WKUP方便调试ROW1PA1推挽输出ROW2PA2推挽输出ROW3PA3推挽输出COL0PB0上拉输入COL1PB1上拉输入COL2PB10上拉输入COL3PB11上拉输入选引脚时有几个实际考量。首先COL输入引脚优先选择带外部中断能力的引脚比如PB0/PB1后面做“按键按下立即唤醒”功能时不用重新改板子。其次尽量避免选择JTAG/SWD占用的引脚比如PA13/PA14/PA15、PB3/PB4除非你确定不用调试功能否则每次下载程序都会出问题。最后行线和列线尽量不要分布在两个差异很大的GPIO组比如PA和PB混用没问题但别一边用PA0~3一边用PC13~15距离远了走线美观度和抗干扰都会受影响。2.2 CubeMX配置输入引脚别忘了上拉打开STM32CubeMX按上面的引脚表配置。这里最容易出错的是输入引脚的上下拉设置。列线输入必须配置为上拉输入Pull-up。因为矩阵扫描时行线输出低电平如果按键按下对应列线就会被拉低到0如果按键没按下列线要维持高电平否则无法区分按下和未按下。如果配置成浮空输入引脚电平会悬空读数随机按键直接乱报。行线输出配置为推挽输出Push-Pull初始电平设为高。这样做的原因后面扫描算法里会详细说简单理解就是只有行线为低时按按键才会把列线拉低扫描过程由行线低电平逐个“扫”过去。生成的初始化代码在MX_GPIO_Init()函数里HAL库会自动把GPIO_PIN_和GPIO_MODE_等宏设置好。这里埋一个伏笔HAL库生成的代码默认会把没有用到的引脚配置为模拟输入如果你的板子其他外设占用了这些引脚后面会遇到“莫名奇妙的问题”这个在第4部分展开讲。3. 扫描算法与驱动代码实现从状态机到回调3.1 核心数据结构设计在写具体代码之前先定义清楚几个关键数据结构。矩阵按键驱动不只是“返回哪个键被按下”还需要处理按下、释放、长按、组合键等状态。我的做法是定义一个按键事件结构体typedef struct { uint8_t row; uint8_t col; uint8_t state; // 0: 释放, 1: 按下 uint8_t is_long_press; // 是否长按事件 } KeyEvent_t;事件用枚举表示会更清晰typedef enum { KEY_EVENT_NONE 0, KEY_EVENT_PRESSED, // 按下 KEY_EVENT_RELEASED, // 释放 KEY_EVENT_LONG_PRESS, // 长按 KEY_EVENT_COMBINATION // 组合键 } KeyEventType_t;驱动程序对外只需要暴露一个接口KEY_Scan()由主循环周期调用返回一个KeyEvent_t结构体。这样上层应用完全不需要关心矩阵扫描的底层细节拿到坐标和事件类型直接用就行。3.2 逐行扫描函数实现核心扫描逻辑分三步拉低一行其他行保持高电平读列线电平判断是否有按键被按下判断具体是哪个键行号和列号交叉就能得到按键坐标void KEY_Scan(KeyEvent_t *event) { static uint8_t last_state[16] {0}; // 上一次扫描的按键状态 uint8_t cur_row 0, cur_col 0; uint8_t key_found 0; uint8_t i, j; for (i 0; i 4; i) { // 将所有行线设为高电平 HAL_GPIO_WritePin(ROW_PORT, ROW0_PIN | ROW1_PIN | ROW2_PIN | ROW3_PIN, GPIO_PIN_SET); // 将当前扫描的行线拉低 switch (i) { case 0: HAL_GPIO_WritePin(ROW_PORT, ROW0_PIN, GPIO_PIN_RESET); break; case 1: HAL_GPIO_WritePin(ROW_PORT, ROW1_PIN, GPIO_PIN_RESET); break; case 2: HAL_GPIO_WritePin(ROW_PORT, ROW2_PIN, GPIO_PIN_RESET); break; case 3: HAL_GPIO_WritePin(ROW_PORT, ROW3_PIN, GPIO_PIN_RESET); break; } // 延时一点等待电平稳定 delay_us(10); // 读取列线状态 for (j 0; j 4; j) { GPIO_PinState col_state GPIO_PIN_SET; switch (j) { case 0: col_state HAL_GPIO_ReadPin(COL_PORT, COL0_PIN); break; case 1: col_state HAL_GPIO_ReadPin(COL_PORT, COL1_PIN); break; case 2: col_state HAL_GPIO_ReadPin(COL_PORT, COL2_PIN); break; case 3: col_state HAL_GPIO_ReadPin(COL_PORT, COL3_PIN); break; } if (col_state GPIO_PIN_RESET) { cur_row i; cur_col j; key_found 1; break; } } if (key_found) break; } // 根据扫描结果更新事件 uint8_t key_index cur_row * 4 cur_col; if (key_found) { if (last_state[key_index] 0) { // 从释放变为按下触发按下事件 event-type KEY_EVENT_PRESSED; event-row cur_row; event-col cur_col; last_state[key_index] 1; } else { // 持续按住可以在这里处理长按逻辑 event-type KEY_EVENT_NONE; } } else { // 没有按键按下检查是否有按键释放 for (i 0; i 16; i) { if (last_state[i] 1) { event-type KEY_EVENT_RELEASED; event-row i / 4; event-col i % 4; last_state[i] 0; break; } event-type KEY_EVENT_NONE; } } }这个函数简洁但功能完整last_state静态数组保存了16个按键上一次的状态用来区分“按下”和“持续按住”。如果每次扫描到低电平都触发事件主循环会收到大量重复的按下事件实际上是同一个动作被重复上报这对上层逻辑是非常不友好的。3.3 主循环调用与事件处理驱动的调用方式很灵活可以直接放在while(1)里轮询也可以放进定时器中断里固定扫描周期。我推荐后一种方式——用定时器每5ms扫描一次这样既稳定又不会阻塞主循环处理其他任务。// 放在定时器中断回调中 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { KeyEvent_t event; KEY_Scan(event); if (event.type ! KEY_EVENT_NONE) { KEY_HandleEvent(event); } } }KEY_HandleEvent是事件处理函数具体逻辑由应用层决定。比如一个简易的计算器键盘应用void KEY_HandleEvent(KeyEvent_t *event) { if (event-type KEY_EVENT_PRESSED) { uint8_t key_code event-row * 4 event-col; // 映射到具体字符或功能 char ch key_map[key_code]; display_char(ch); } }这里我特别想强调一点驱动层和应用层一定要分开。驱动层只负责返回“事件”应用层负责决定“怎么响应”。很多同学把按键处理逻辑直接写进扫描函数后来项目复杂了就会发现完全没法复用。4. 实测踩坑记录消抖、组合键、长按和误触4.1 硬件消抖还是软件消抖按键按下和释放的瞬间机械触点会产生抖动通常持续5~20ms。如果不做处理一次物理按键会产生多次电平跳变驱动程序会连续上报很多次按下事件。硬件方案是在按键两端并联一个0.1uF电容RC滤波吸收抖动。软件方案则是在检测到电平变化后延时10~20ms再读取一次确认电平稳定后再确认状态变化。我的实测结论是软件消抖足够但别用阻塞延时。在扫描函数里加HAL_Delay(20)会导致整个系统卡死如果同时还要处理屏幕刷新、电机控制这类任务用户体验会非常糟糕。正确做法是“两次扫描确认”第一次扫描发现按键状态变化先不立即上报等下一次扫描5ms后再读一次如果状态一致才认为按键有效。这样只增加了一个周期的等待不会阻塞其他任务。// 消抖逻辑示例 static uint8_t confirm_count[16] {0}; // 在扫描到按键状态变化时 if (current_state ! last_state[key_index]) { confirm_count[key_index]; if (confirm_count[key_index] 2) { // 连续两次扫描一致确认为有效状态变化 confirm_count[key_index] 0; last_state[key_index] current_state; // 上报事件... } } else { confirm_count[key_index] 0; }注意这里的 2是经验值对应约10ms的消抖时间5ms扫描周期×2次。如果板子上的按键质量较差可以把阈值调到3或4对应15~20ms效果会更好。4.2 组合键和长按怎么实现不冲突矩阵键盘的痛点在于多键同时按下时会出现“鬼键”。比如按下了(0,0)和(1,1)如果(0,1)也有按键它会被“间接导通”识别为按下。这就是矩阵键盘的多键冲突问题。要解决这个问题常见的做法是在按键扫描时只检测第一个被按下的键我上面的代码就是这种策略或者安装二极管做硬件隔离。对于大多数4×4键盘应用计算器、键盘、密码锁支持单键和双键组合就足够了不需要追求全键无冲。组合键的实现思路是记录当前所有处于按下状态的按键当检测到第二个按键按下时触发组合事件。这里的关键是“第一个键不能释放”——如果用户按下A键还没松手再按下B键这时候应该报“AB组合”而不是“B按下”。我常用的状态机如下空闲态无按键按下单键态检测到一个按键按下上报PRESSED事件组合等待态第一个键保持按住此时扫描到第二个键按下上报COMBINATION事件长按逻辑同样要放在这个状态机里。我的经验值是从“确认按下”开始计时持续1秒上报一次LONG_PRESS事件之后每500ms重复上报直到按键释放。4.3 一个隐蔽的坑CubeMX把未配置引脚设为模拟输入这是我实际项目中踩过最深的一个坑花了一个晚上才定位。当时我把矩阵键盘接在了PA0~PA3和PB0/PB1/PB10/PB11上代码逻辑看起来完全没问题但按键扫描就是时好时坏偶尔还出现完全失灵的情况。排查到最后发现CubeMX生成代码时会把工程中未使用的引脚统一初始化为模拟输入模式。我的板子上还有其他外设其中就有几个引脚被误设成了模拟输入而刚好有一个外设的中断线和矩阵键盘的某根列线复用了同一个引脚。解决方法是生成代码后在MX_GPIO_Init()里主动把所有用到的矩阵键盘引脚重新初始化一遍或者去CubeMX里配置GPIO_InitStruct.Mode和GPIO_InitStruct.Pull确保模式正确后再复制到代码中。提示用HAL_GPIO_Init()可以对单个引脚重新初始化比直接改寄存器安全得多。但如果引脚已经被复用为外设功能比如USART、ADC需要先HAL_GPIO_DeInit()解除复用。4.4 扫描周期和延时不稳定会引起的随机性问题矩阵扫描时行线电平切换后引脚和走线存在寄生电容电平不能瞬间跳变。我实测在F103的GPIO翻转速度下2~3us的延时足够但如果你用的是PCB走线较长或者飞线搭的测试板建议把延时提到10us左右。另一个容易被忽略的是扫描周期抖动。如果在中断里做扫描中断优先级低的话可能会被其他中断打断导致扫描周期不稳定。我建议把按键扫描放在一个中等优先级的中断里比如TIM2更新中断并且扫描函数里不要调用任何可能阻塞的HAL库函数比如HAL_Delay()。5. 让驱动更通用回调注册与多实例复用5.1 注册回调函数驱动和应用解耦的最佳实践在第3部分的代码里驱动通过返回值给应用层传事件已经很清晰了。但更进一步注册回调函数能让驱动的通用性再上一个台阶。typedef void (*KeyEventCallback)(KeyEvent_t *event); static KeyEventCallback key_callback NULL; void KEY_RegisterCallback(KeyEventCallback cb) { key_callback cb; } // 在扫描到事件后调用 if (key_callback ! NULL) { key_callback(event); }这样驱动里完全不关心事件怎么处理应用层只需要在初始化时注册一个回调函数就行。这个模式在复杂项目里尤其好用——比如你同时在用按键、触摸屏、旋钮编码器它们的输入事件统一回调到一个input_manager里统一分发。5.2 多实例支持写一次驱动多处复用如果你的系统里有两组矩阵键盘比如一个控制面板加一个数字小键盘上面基于静态变量的写法就不能直接复用了。解决办法是把驱动改造成实例化模式typedef struct { GPIO_TypeDef *row_ports[4]; uint16_t row_pins[4]; GPIO_TypeDef *col_ports[4]; uint16_t col_pins[4]; uint8_t last_state[16]; uint8_t confirm_count[16]; } KeyMatrix_t; void KEY_MatrixInit(KeyMatrix_t *km, GPIO_TypeDef **row_ports, uint16_t *row_pins, GPIO_TypeDef **col_ports, uint16_t *col_pins); void KEY_MatrixScan(KeyMatrix_t *km, KeyEvent_t *event);这种写法多了一点点代码量但换来的是驱动的完全可复用。以后在新项目里要用矩阵键盘直接#include key_matrix.h初始化一个实例就能跑不用再改任何代码。我实际经验是即使现在只有一个键盘也建议写成多实例模式。因为产品迭代很快说不定哪天产品经理就要求加一个侧边快捷键键盘了。当时代码已经写好改起来费时费力从一开始就留好扩展点其实更省事。5.3 后续还可以扩展的方向矩阵按键驱动写完之后还有一些进阶方向可以考虑GPIO扩展芯片当IO实在不够用或者需要扫描更大的矩阵比如6×6可以用PCF8574、MCP23017这类I2C/SPI接口的GPIO扩展芯片驱动只需改底层的读写函数。电容触摸矩阵基于触摸感应原理的矩阵按键是另一个方向STM32的TSCTouch Sensing Controller外设可以直接实现不需要机械按键寿命和防水性能都更好。把扫描放到DMA或专用外设虽然矩阵按键扫描用轮询已经足够但在极端低功耗场景下可以考虑用EXTI外部中断把CPU从休眠中唤醒按键扫描完再重新进入休眠。就我自己这些年的开发经验来说矩阵按键属于那种“看着简单写好了不容易”的外设驱动。掌握了原理和状态机设计思路后面不管是换MCU平台、调整按键数量、还是增加交互逻辑都能很从容地应对。这里面最重要的不是代码本身而是把“扫描-消抖-事件生成”这三个层次理清楚驱动写起来就会顺手很多。本文还有配套的精品资源点击获取