
做嵌入式老哥大概都有过这种经历4×4矩阵键盘单键怎么按都对一按下组合键屏幕上就多蹦出一个你根本没碰过的键。我第一次被这问题折磨还是做考勤机菜单按键板那会儿测试员随手按下三个分散的键结果系统把第四个没按到的键也识别进去了。后来我把按键板电路重画了一版给每个按键串了一颗二极管问题才算彻底根治。今天就把这套“二极管隔离法”完整拆开讲清楚顺手把STM32的接线方式和扫描代码一起放出来供正在调键盘的朋友参考。1. 矩阵键盘为什么会“串键”1.1 一行一列矩阵扫描的基本玩法矩阵键盘的核心目的就是省GPIO。4×4一共16个按键如果每个按键单独接一个IO接满就得16个引脚在STM32F103这种引脚不算富裕的芯片上很容易捉襟见肘。矩阵接法只需要8个IO就能扫出16个按键原理其实特别简单把按键排成4行4列行线和列线分别引到MCU每个按键本质上就是一根跨接在行线和列线之间的开关。扫描的时候MCU一次只把某一行拉低然后把所有列引脚当成输入读取。如果这一行上有按键被按下对应的列就会被拉低于是就知道“这一行、那一列的交叉点被按了”。扫完第一行把第一行恢复成高电平再去拉第二行依次扫完四行一轮扫描就结束了。按键的编号一般用行号 × 每行列数 列号来算比如row * 4 col。这里有一个很常见的细节行线一般配成推挽输出列线配成输入并且打开内部上拉。因为列线默认被上拉到高电平按键按下把行线低电平传过来后列线才能可靠变成低。也有人反过来做让列输出、行输入逻辑是一样的只是引脚角色互换。本文后面的代码统一按“行输出低、列输入上拉”来讲。1.2 同时按三个键第四个键跟着冒出来矩阵扫描在单键场景下基本不会出事问题全出在“多键同时按下”。我用实际板子复现过现象非常典型4×4键盘上按下一组“两行两列”交叉点里的任意三个按键扫描结果却有可能会读出四个键多出来的那个键恰恰是没按下的第四个交叉点。为什么会这样原因可以理解为路径串扰。在按键矩阵里行线和列线都是导通的金属走线按键只是触点的开关。当三个键同时按下围绕着那两行两列电流或者电平就有了“绕路”的可能某些行线上的电平通过一个闭合按键走到列线再通过另一个列线、另一个按键串回其他行线。本质上就是形成了一个闭合回路把不该拉低的引脚也拉低了。我在文章开头说的考勤机案例就是这个情况。单键测试怎么按都正常只要操作员同时按了三个按键系统就会误判第四个键被按下表现在菜单逻辑上就成了“自动选了另一个选项”轻则操作错乱重则直接导致生产测试无法通过。1.3 哪些应用最容易踩到这个坑不是所有项目都需要担心串键。如果你做的是单按键菜单、简单数字输入绝大多数操作都是单键动作那问题不大。真正容易踩坑的是下面几类场景需要组合键的项目比如“Shift 字母”“菜单键 确认键”同时按下。带游戏方向键的键盘玩家经常同时按住两个方向。收银键盘、考勤机、密码键盘输入快的时候手指很容易同时碰到多个键。任何一键对应一个“真实操作”的工业设备面板误判按键可能直接触发执行机构。在这些场景里串键不仅影响体验还可能造成安全隐患。反正我现在的观点是如果键盘面板要过正规测试或者使用者不会刻意避讳同时按键那就从一开始把防串键做进硬件里别指望软件兜底。2. 二极管隔离法一根二极管解决的事别硬扛2.1 二极管单向导电怎么用在这里二极管这个东西很多做单片机的人只在电源防反接、DC-DC降压续流里见过其实它在按键矩阵里也是神器。它最核心的特性就是单向导电电流只能从阳极流到阴极反向基本截止。把它串进按键回路就相当于给每个按键装了一个“止回阀”。回到刚才的串键问题。串键之所以发生是因为低电平能通过多个闭合按键组成的回路“绕”到不相关的位置。如果每个按键都串联一个方向正确的二极管电流路径就被强制成单方向的你想绕路二极管直接截止路径断掉低电平只能老老实实从当前扫描行走到对应列再也没法通过别的按键串到别的行线上。这个思路和液压系统里的单向阀一模一样水流只能朝一个方向走管道里再复杂也不会倒灌。二极管在键盘矩阵里干的就是这件事把一个双向可导通的无源触点变成一个方向受控的单向导通路径。2.2 4×4键盘的二极管理想接法以最常见的“行扫描、列读取、低电平有效”方案为例行线接STM32的推挽输出列线接内部上拉输入。给每个按键串联一颗二极管方向是二极管的阳极接列线方向阴极接行线方向也就是电流从列线流向行线。为什么要这个方向因为正常按下时列线是高电平行线被拉低电流必须能从列线走到行线MCU才能读到按键按下。二极管阳极接列线、阴极接行线正好满足。反过来接的话按下时电流被二极管挡住这列引脚永远读不到低电平按键反而失效了。比较直观的连接示意列线C1 ──►|──[按键R1C1]── 行线R1 列线C1 ──►|──[按键R2C1]── 行线R2 列线C2 ──►|──[按键R1C2]── 行线R1 列线C2 ──►|──[按键R2C2]── 行线R2注意这里“按键R1C1”指的是R1和C1交叉点上的那个按键触点。二极管可以放在按键和列线之间也可以放在按键和行线之间但极性必须保证从列到行导通。实际画PCB时我习惯把二极管贴片放在按键焊盘附近布线更干净。另外要强调一点二极管一定要串在“按键触点”的支路里而不是放在整条行线或整条列线的公共端。只有每个按键都单独隔离才能彻底切断所有回灌路径。只在一端放一个公共二极管能挡住一部分干扰但对复杂的多键组合仍然会有漏网之鱼。2.3 1N4148为主选型、方向、焊接要点二极管选型不需要纠结常规1N4148就够。它的反向恢复时间短、开关速度快做按键扫描绰绰有余额定电流150mA按键回路里那点微安级别电流根本碰不到上限。PCB上如果用贴片封装推荐SOD-123封装的1N4148W或者1N4148WS体积小手工也能焊。需要特别注意的是不要把1N4007拿来代替。1N4007是工频整流管开关速度慢反向恢复时间长虽然在这种低速扫描场景不一定立刻出问题但它体积大、压降高做在键盘板上完全不合适。为了省事用了它后面调试出奇葩问题还得回头检查没必要。焊接时最容易犯的错就是二极管方向接反。二极管上面有条色环或者丝印横线那一端是阴极。按照我们“阳极接列线、阴极接行线”的方案丝印横线那一端应该朝向行线。批量打样回来后我建议先用万用表二极管档抽查几个点的方向别等到焊完整个板子才发现反了拆焊的酸爽谁拆谁知道。3. STM32工程落地从引脚初始化到完整扫描代码3.1 引脚分配与CubeMX配置下面的代码以STM32F103C8T6为例开发环境是Keil MDK STM32CubeMX生成的HAL库工程。引脚分配如下行线PA0、PA1、PA2、PA3配置为推挽输出默认输出高电平。列线PA4、PA5、PA6、PA7配置为输入模式开启内部上拉。在CubeMX里直接把对应引脚拉到GPIO_Output和GPIO_Input状态然后在GPIO配置界面把输入引脚的Pull-up选上其余保持默认就可以。时钟、串口这些按你自己的工程来不影响键盘部分。这个引脚分配不是死的可以根据你的板子换。比如行线用PB系列、列线用PA系列也完全没问题。只要在代码里改引脚表就行。不过我建议尽量把同一组行线放在同一个GPIO端口上这样以后想用寄存器高位操作提速也方便。3.2 底层扫描函数怎么写我把按键部分做成一个独立的key_board.c方便复制到任何HAL工程里。先看引脚配置和结构定义/* key_board.h */ #ifndef __KEY_BOARD_H #define __KEY_BOARD_H #include stm32f1xx_hal.h #define ROW_NUM 4 #define COL_NUM 4 #define KEY_NONE 0xFF typedef struct { GPIO_TypeDef *port; uint16_t pin; } KeyPin; static const KeyPin rowPins[ROW_NUM] { {GPIOA, GPIO_PIN_0}, {GPIOA, GPIO_PIN_1}, {GPIOA, GPIO_PIN_2}, {GPIOA, GPIO_PIN_3}, }; static const KeyPin colPins[COL_NUM] { {GPIOA, GPIO_PIN_4}, {GPIOA, GPIO_PIN_5}, {GPIOA, GPIO_PIN_6}, {GPIOA, GPIO_PIN_7}, }; void Key_GPIO_Init(void); uint8_t Key_Scan(void); uint8_t Key_Read(void); #endifGPIO初始化函数就是标准的HAL写法。这里有个细节初始化行线之前先统一把行线写为高再初始化成推挽输出防止上电瞬间引脚误动作导致按键假触发。/* key_board.c */ #include key_board.h static uint8_t s_lastKey KEY_NONE; static uint8_t s_reportedKey KEY_NONE; static uint16_t s_sampleCnt 0; void Key_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); for (uint8_t i 0; i ROW_NUM; i) { HAL_GPIO_WritePin(rowPins[i].port, rowPins[i].pin, GPIO_PIN_SET); } for (uint8_t i 0; i ROW_NUM; i) { GPIO_InitStruct.Pin rowPins[i].pin; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(rowPins[i].port, GPIO_InitStruct); } for (uint8_t i 0; i COL_NUM; i) { GPIO_InitStruct.Pin colPins[i].pin; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(colPins[i].port, GPIO_InitStruct); } }再来看最核心的扫描函数。每次扫描先把所有行都保证在高电平然后逐行拉低等几个微秒让电平稳定再去读四根列线。只要读到某一列为低就说明这一行的对应按键被按下直接返回键值并结束本轮扫描。uint8_t Key_Scan(void) { uint8_t row, col; uint8_t key KEY_NONE; for (row 0; row ROW_NUM; row) { HAL_GPIO_WritePin(rowPins[row].port, rowPins[row].pin, GPIO_PIN_RESET); /* 简单延时保证行线电平稳定 */ for (volatile uint32_t i 0; i 200; i); for (col 0; col COL_NUM; col) { if (HAL_GPIO_ReadPin(colPins[col].port, colPins[col].pin) GPIO_PIN_RESET) { key row * COL_NUM col; break; } } HAL_GPIO_WritePin(rowPins[row].port, rowPins[row].pin, GPIO_PIN_SET); if (key ! KEY_NONE) { break; } } return key; }代码里那个for (volatile uint32_t i 0; i 200; i);是临时延时用的。在72MHz主频下粗略算下来大约有几个微秒足够电平稳定。如果工程里有现成的微秒延时函数直接替换成delay_us(10)更规范。实际项目里我更建议用DWT或者SysTick做个微秒延时但这里为了代码精简先用循环代替原理不受影响。3.3 带消抖和重复触发的按键读取函数上面Key_Scan()返回的是原始扫描结果直接拿进主循环用是不行的因为机械按键存在抖动按下和释放瞬间电平会震荡好几次不消抖的话一次按键会被识别成多次。我习惯做一个状态机函数Key_Read()统一解决消抖和重复触发问题。设计思路很简单连续多次调用Key_Scan()如果读到同一个非空键值就认为按键确实按下。上报一次后只有等到扫描结果变成KEY_NONE再重新按下才会再上报新的键值。这样既不会因为抖动反复触发也不会出现“按住不放就每秒重复触发几千次”的问题。uint8_t Key_Read(void) { uint8_t cur Key_Scan(); if (cur s_lastKey) { if (s_sampleCnt 0xFFFF) { s_sampleCnt; } if (s_sampleCnt 2 cur ! KEY_NONE cur ! s_reportedKey) { s_reportedKey cur; return cur; } if (cur KEY_NONE) { s_reportedKey KEY_NONE; } } else { s_lastKey cur; s_sampleCnt 0; } return KEY_NONE; }调用周期建议放在5~10ms一次。如果太快消抖计数会让位给抖动如果太慢快速连击会被漏掉。主循环里用HAL_Delay(5)或者放定时器中断里调用都行。需要注意的是这个函数返回KEY_NONE表示没有新按键事件只有发生了一次有效的“按下并超过去抖时间”的操作才会返回键值。3.4 主循环里怎么用主程序里初始化完GPIO和串口之后循环调用Key_Read()就行。下面是一个最简单的示范int main(void) { HAL_Init(); SystemClock_Config(); UART_Init(); Key_GPIO_Init(); while (1) { uint8_t key Key_Read(); if (key ! KEY_NONE) { printf(Key %d pressed\r\n, key 1); /* 这里根据key做业务逻辑 */ } HAL_Delay(5); } }如果工程里没有重定向printf可以直接用一个全局变量把键值丢出来或者接个LED观察。按键编号从0开始我习惯加1显示看起来更符合人的直觉。这套代码在加了二极管的板子上实测同时按下三个键也不会再多出第四个键。实测时我用示波器观察列线电平未按下的那列纹丝不动这就是硬件隔离的底气。4. 常见问题、排查方法与应用扩展4.1 不加二极管的软件规避办法如果板子已经做完了硬件改不了那只能用软件补救。常见的做法有三种第一种叫“单键优先”。扫描到同时有多个键按下时放弃输出或者只取行号最小、列号最小的那一个。适合只有单键操作需求的面板比如菜单选择、数字输入。缺点是确实需要组合键时无解。第二种叫“鬼影识别”。扫描完一整轮后如果发现所有低电平的按键恰好构成了某个2×2矩形的三个角那就认为第四个角是鬼影要么丢弃本次扫描要么想办法用当前按下的三个键重新映射到业务逻辑。这个方案能挡掉一部分误判但对更复杂的导通路径还是不够可靠。第三种是“逐键扫描降速”。扫描间隔拉长每次扫描之后对列线做短暂放电再读有些低端键盘用这个方法来缓解电容残留引起的伪按键。效果有限但代码量最少。说到底软件方案都是在跟症状搏斗硬件隔离才是治本。如果你的产品还可能改版强烈建议下一版把二极管补上。4.2 实测中常见的6个坑调试按键板这几年我踩过不少坑整理几个有代表性的放在下面。现象可能原因解决办法所有按键完全无反应行线或列线GPIO初始化不对或者行列定义反了先检查GPIO模式行必须是推挽输出列必须是带上拉的输入某个按键按了没反应对应的二极管方向焊反或者焊盘虚焊用万用表测二极管正反向确认丝印方向按键出现随机跳动扫描间隔太快消抖没生效把Key_Read()调用周期控制在5~10ms按一下触发两次没有等待释放或者释放瞬间抖动也被判定为按下用上面的状态机代码必须回到KEY_NONE后才能再次上报同一列按键全部失效这一列公共线断了或者列引脚配置成了输出量一下列线到MCU引脚的连通性加二极管后某个按键反而读不到这个按键的二极管的极性搞反了把二极管反向重新焊接再量压降这里面最容易忽略的是“按键间距太近导致人手同时碰到两个键”。有时候不是硬件和代码的问题而是按键帽排列太紧凑用户手指只要按凸起一点两个触点就一起闭合了。遇到这种情况可以适当调整按键间距或者提高判定阈值。4.3 二极管隔离之外还有哪些方案除了本文的二极管隔离法实际工程里一般还有三条路可以选。第一条是直接用带扫描功能的I2C键盘扩展芯片比如TCA8418这类。芯片内部自己处理矩阵扫描和去抖MCU只要读寄存器就行。点一下省心但会多一颗物料代码也要依赖驱动。第二条是用ADC按键。把每个按键通过不同阻值的电阻分压按下之后MCU读ADC电压就能识别是哪个键。几颗电阻就能搞定省引脚但组合键几乎做不了同时按两个键电压就混了。第三条是换用带N-Key Rollover的成品键盘控制器比如一些游戏键盘主控芯片。它们内部有更完整的防冲突机制成本相对高适合做批量产品不适合自己动手调板子玩。就我个人的偏好来讲如果是自己搭的矩阵键盘面积允许、成本敏感度不高最省心的还是“每个按键串一颗1N4148”的方案。理由很简单原理透明调试容易实测可靠还不需要跟驱动芯片打交道。画PCB时只要在封装库里面把二极管焊盘位置留好后面手焊也方便。做产品的话这个方案在量产端也经得起折腾坏一颗二极管换一颗就行不像专用芯片坏了整片得返工。