ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32矩阵按键鬼键问题详解:二极管隔离法与完整扫描代码

STM32矩阵按键鬼键问题详解:二极管隔离法与完整扫描代码 最近调一个基于STM32的4x4矩阵按键面板又遇到了矩阵按键最经典的那个坑单键怎么按都正常但只要三个键同时按下去第四个键就像凭空出现一样被单片机读出来。面板用的就是最普通的行列扫描硬件上没加任何隔离排查了半天才确认是矩阵按键扫描冲突。后来按老方案加上二极管做隔离问题当场消失。这篇文章把冲突原理、二极管隔离法的接线方式、以及一套可以直接拿去用的完整代码都整理出来给还在为鬼键挠头的朋友做个参考。如果你做的也涉及STM32矩阵按键尤其是音乐键盘、多按键组合面板这类场景建议先看完冲突原理再动手焊板子能省不少返工时间。1. 矩阵按键冲突的本质三个键按下去第四个键凭空出现1.1 矩阵按键为什么要用扫描矩阵按键的动机很简单省引脚。4x4的按键如果用独立IO每个按键一个输入需要16个GPIO改成矩阵结构后4根行线加4根列线8个GPIO就能读完8x8的按键阵列更是只需要16个GPIO。代价就是按键不能再直读了必须靠扫描轮流判断。扫描时序的常规做法是行线全部设为输入列线设为推挽输出。程序让某列输出高电平其他列输出低电平然后读一次行线。被按下的按键如果正好在这列上行线就会被拉高随后换下一列输出高再读一次如此往复。4列全部扫完相当于把16个键的状态都读了一遍。逻辑听起来很顺但硬件上有个致命隐患按键是双向导通的开关不是单向导通的器件。当多个按键同时按下时行线和列线之间会形成额外的电流通路扫出来的结果就不再是这16个键的真实状态而是被串扰污染过的状态。1.2 鬼键是怎么形成的一个2x2方阵的现场还原以4x4矩阵为例行线R1、R2列线C1、C2。假设你同时按下这样三个键(R1,C1)、(R1,C2)、(R2,C1)也就是一个2x2方阵的三个角缺少右下角(R2,C2)没按。按扫描流程走一遍。扫C1时C1输出高C2输出低R1和R2被对应按键拉高正确此时单片机认为(R1,C1)和(R2,C1)按下。扫C2时C2输出高C1输出低R1被(R1,C2)拉高也正确。问题出在R2上R2本来没有被任何C2列的按键连接但它通过(R2,C1)连到了C1而C1此时是低电平输出这条通路正常不会让R2变高。麻烦在于C1这个非扫描列并不是孤立的。它一边连着(R2,C1)另一边连着(R1,C1)。于是R1被C2拉高后电流会从R1→(R1,C1)→C1→(R2,C1)→R2把R2也顶起来。单片机会以为(R2,C2)这个根本没按的键也被按下了。这就是三个键按下去第四个键凭空出现的现场还原。如果C1在非扫描期间设置成高阻输入而不是低电平推挽情况更糟回流路径畅通无阻鬼键概率更高。如果C1设置成低电平推挽虽然回流会被C1的低电平吸掉但R1的检测结果也可能被C1拉低表现成漏检。不管哪种结果都是错的。1.3 哪些按键组合最容易出问题根据上面的路径分析出问题的组合都有共同特征存在一个2x2的方阵其中三个角被按下。空缺的那个角就是被误判出来的第4个键。所以以下场景会中招音乐类按键面板用户同时按三四个音阶键。游戏摇杆加功能键的组合操作方向键和动作键同时触发。需要组合键的输入设备比如按下AB再按C。任何允许用户无意中同时碰到三个以上键的普通面板。如果产品只允许单键操作、用户也不可能同时按多个键那么不加二极管的矩阵方案勉强能用。但只要出现多点触控需求二极管隔离基本是绕不开的解法。2. 二极管隔离法让电流只走一条路的原理与可行性2.1 二极管放哪、方向怎么定二极管隔离法的核心就一句话在按键的电流路径上串一个单向导通器件阻止行线向列线倒灌。我的4x4按键面板用的是高电平扫描列线输出高来选中行线作为输入读取。此时二极管的接法是阳极朝向列线阴极朝向按键开关。按压路径为列线 → 二极管 → 按键开关 → 行线电流方向是列线流向行线。放的位置有两种选择。一种是每条列线串一颗二极管放在列线和按键阵列之间4列就4颗。另一种是每个按键串一颗16颗。两者都能解决行线倒灌到列线的问题差别主要在于灵活性和焊接量。单片机和按键之间的接线顺序不能反。二极管放反了等于在按键路径上开了一个单向阀扫描列的高电平根本送不到行线上按键会完全没反应。后面调试经验里会细说。2.2 为什么四颗二极管就能切断回流路径回到刚才那个方阵例子按(R1,C1)、(R1,C2)、(R2,C1)扫C2时R2被误判为高路径是R1→(R1,C1)→C1→(R2,C1)→R2。给每条列线串了二极管之后这条路径上的(R1,C1)要从C1端往R1端走但二极管方向是列线→按键即C1→R1允许R1→C1被截止。电流被堵在(R1,C1)的二极管上C1这边收不到来自R1的电流自然也就没法通过(R2,C1)去抬R2。鬼键的传播路径从源头断掉了。同理任何从行线经按键回流到非扫描列的通路最终都会被二极管挡住。扫描列自身的高电平依然能正常经过自己的二极管送到行线真实按键不受影响。有一点需要强调非扫描列的输出状态依然建议保持低电平而不是高阻。二极管负责切断回流低电平负责把该列直接摁住。两者不冲突配合起来效果最干净。2.3 二极管选型1N4148为什么够用优先推荐1N4148这是最常见的开关二极管一颗几分钱。反向恢复时间约4ns对按键扫描这种频率极低的场合绰绰有余。正向压降在1mA下大概0.6到0.7V对3.3V电平的单片机来说读到的高电平大约还有2.6V依然超过STM32高电平判定阈值逻辑识别没问题。如果板子供电电压偏低或者扫描线走得特别长、电压被拉得厉害可以换成肖特基二极管比如BAT54S正向压降只有0.3V左右高电平余量更大。代价是反向漏电流比1N4148大一点但矩阵按键这种场景根本不影响。不推荐用大功率整流管比如1N4007。不是不能用而是体积大、响应慢对按键扫描没有好处纯粹给自己焊接添堵。3. 硬件改造实操从洞洞板飞线到现成PCB补焊3.1 两种接线方案的取舍每列一颗还是每键一颗方案A每列一颗。适合手头已经有成品矩阵按键板想把回流路径断开的情况。在列线进入按键阵列之前找一处合适位置切断串上二极管。改动量小4颗二极管搞定焊接难度低。缺点是二极管只放在列线上行线之间如果还有其他干扰路径覆盖不到。方案B每键一颗。每个交叉点上的按键都串一颗二极管阳极朝列线阴极朝按键。这是最彻底、最不容易出问题的方案。缺点是焊接量翻4倍板上会比较拥挤。如果按键密度高还要小心相邻焊点短路。我的建议很直接新设计用方案B改旧板用方案A。新板的PCB可以提前把二极管位置画出来每个按键串联一颗焊起来虽然多点但后续排查故障省心。旧板已经做好没法改版用方案A飞线补上也能解决大部分鬼键问题。接线示意方案B高电平扫描列线C1 o--||--o 按键 ---o 行线R1 列线C1 o--||--o 按键 ---o 行线R2 列线C2 o--||--o 按键 ---o 行线R1 列线C2 o--||--o 按键 ---o 行线R2箭头方向就是二极管导通方向列线高电平能通过二极管到按键反过来会被截止。3.2 焊接与飞线步骤先准备材料1N4148若干、10k电阻若干如果行线不用内部下拉就要外接、洞洞板、飞线、电烙铁、万用表。实操步骤按下面顺序走先固定按键矩阵。用洞洞板的话按键按4x4排好行线和列线分别用飞线引出先用万用表确认每个按键在没有二极管时能正常通断。在列线和按键交叉点之间插二极管。判断方向二极管本体上有一圈白色横杠那是阴极标记对1N4148就是阴极。高电平扫描方案里阳极朝向列线、阴极朝向按键。每根列线单独串也行每个按键单独串也行按你前面选好的方案焊。焊完检查相邻焊点有没有桥接尤其是按键密集区。行线接到单片机输入引脚配合内部下拉。如果使用外部下拉在每根行线上接10k电阻到GND能更稳。列线分别引出到单片机推挽输出引脚。上电前先用万用表二极管挡验证方向再检查列线和行线没有短路。3.3 上电前的万用表检查二极管焊接最容易出错的就是方向。一块板子十几颗二极管万一其中一颗方向反了按键可能就是局部失灵很难查。我的习惯是焊完先用万用表二极管挡逐颗量一遍。红表笔接列线端黑表笔接按键端应该显示0.5到0.7V左右的正向导通压降反过来再接应该是无穷大或者OL表示反偏截止。如果有某颗反向也能导通这一颗肯定焊错了重新处理。另外把每行和每列之间扫描一遍用万用表电阻挡看有没有异常导通。没按下按键时行线和列线之间应该是不通的按下后因为二极管存在只有从列线到行线这个方向才导通反方向量会显示不通。4. 完整工程代码扫描、消抖、组合键识别全流程4.1 引脚分配与初始化下面这套代码基于STM32F103C8T6和HAL库用标准库的话照着引脚配置改一下也容易移植。我用的引脚分配是PB0到PB3做列线输出PB4到PB7做行线输入方便接线。/* key_config.h */ #ifndef __KEY_CONFIG_H #define __KEY_CONFIG_H #include main.h #define KEY_COL_PORT GPIOB #define KEY_COL1 GPIO_PIN_0 #define KEY_COL2 GPIO_PIN_1 #define KEY_COL3 GPIO_PIN_2 #define KEY_COL4 GPIO_PIN_3 #define KEY_ROW_PORT GPIOB #define KEY_ROW1 GPIO_PIN_4 #define KEY_ROW2 GPIO_PIN_5 #define KEY_ROW3 GPIO_PIN_6 #define KEY_ROW4 GPIO_PIN_7 #define KEY_ROWS 4 #define KEY_COLS 4 #define KEY_ALL_COLS (KEY_COL1 | KEY_COL2 | KEY_COL3 | KEY_COL4) #endifGPIO初始化里特别要注意列线用推挽输出初始状态全部拉低行线用输入模式打开内部下拉。很多同学忘记给行线配置下拉结果按键没按时引脚悬空读出来的状态乱飘。/* key_scan.c */ #include key_scan.h static void delay_us(uint32_t us) { if (us 0) return; CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000U); while ((DWT-CYCCNT - start) ticks); } void Key_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin KEY_ALL_COLS; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(KEY_COL_PORT, GPIO_InitStruct); HAL_GPIO_WritePin(KEY_COL_PORT, KEY_ALL_COLS, GPIO_PIN_RESET); GPIO_InitStruct.Pin KEY_ROW1 | KEY_ROW2 | KEY_ROW3 | KEY_ROW4; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLDOWN; HAL_GPIO_Init(KEY_ROW_PORT, GPIO_InitStruct); }4.2 核心扫描函数一次扫出16个键的完整位图扫描函数返回一个16位的变量每一位代表一个按键。这样设计的好处后面会提到先看代码static const uint16_t colPins[KEY_COLS] { KEY_COL1, KEY_COL2, KEY_COL3, KEY_COL4 }; static const uint16_t rowPins[KEY_ROWS] { KEY_ROW1, KEY_ROW2, KEY_ROW3, KEY_ROW4 }; uint16_t Key_Scan(void) { uint16_t keyState 0; for (uint8_t col 0; col KEY_COLS; col) { /* 先把所有列拉低 */ HAL_GPIO_WritePin(KEY_COL_PORT, KEY_ALL_COLS, GPIO_PIN_RESET); /* 当前扫描的列拉高 */ HAL_GPIO_WritePin(KEY_COL_PORT, colPins[col], GPIO_PIN_SET); /* 等待电平稳定20us 足够 */ delay_us(20); /* 读整行 */ for (uint8_t row 0; row KEY_ROWS; row) { if (HAL_GPIO_ReadPin(KEY_ROW_PORT, rowPins[row]) GPIO_PIN_SET) { keyState | (1U (row col * KEY_ROWS)); } } } /* 扫描结束所有列回到低电平 */ HAL_GPIO_WritePin(KEY_COL_PORT, KEY_ALL_COLS, GPIO_PIN_RESET); return keyState; }这里的位图映射关系是低4位是第0列的4个行按钮对应(第0行,第0列)到(第3行,第0列)第4到7位是第1列的4个按钮以此类推。单片机读的是位不是键号。按键编号可以这样定义/* key_map.h */ #ifndef __KEY_MAP_H #define __KEY_MAP_H #define KEY_00 (1U 0) /* 第0行第0列 */ #define KEY_10 (1U 1) /* 第1行第0列 */ #define KEY_20 (1U 2) #define KEY_30 (1U 3) #define KEY_01 (1U 4) /* 第0行第1列 */ #define KEY_11 (1U 5) #define KEY_21 (1U 6) #define KEY_31 (1U 7) #define KEY_02 (1U 8) #define KEY_12 (1U 9) #define KEY_22 (1U 10) #define KEY_32 (1U 11) #define KEY_03 (1U 12) #define KEY_13 (1U 13) #define KEY_23 (1U 14) #define KEY_33 (1U 15) #endif注意我不能把每个键都傻乎乎地当成独立中断来想用位图才能支持组合键。判断某两个键是否同时按下只需要看这两个宏的与运算结果。4.3 消抖和按键事件检测按键扫描必须处理机械抖动。普通轻触开关按下到稳定的时间大约5到10ms我习惯用两次扫描取交集的方式消抖第一次扫描结束后延时10ms再扫一次把两次结果做按位与只有两次都为1的位才保留。/* key_scan.c */ uint16_t Key_GetState(void) { uint16_t s1 Key_Scan(); HAL_Delay(10); uint16_t s2 Key_Scan(); return (s1 s2); }为什么取交集而不是并集因为机械抖动会产生大量毛刺毛刺往往只出现在某一次扫描里两次都有才认为稳固导通能有效滤掉大部分抖动。如果只取第一次扫描结果噪声可能导致误触发。按键事件的生成需要结合上一次的状态uint16_t Key_GetEvent(uint16_t curr, uint16_t *prev) { uint16_t press curr (~(*prev)); /* 当前按下且之前没按 */ *prev curr; return press; }需要区分按下还是释放的话可以再返回一个释放事件void Key_GetEventEx(uint16_t curr, uint16_t *prev, uint16_t *press, uint16_t *release) { *press curr (~(*prev)); *release (~curr) (*prev); *prev curr; }4.4 组合键识别与主循环示例主循环里先拿当前扫描状态再算事件然后做组合判断。因为扫描函数返回的是位图组合键判断非常直接。/* main.c 简化示意 */ #include key_scan.h #include key_map.h int main(void) { HAL_Init(); SystemClock_Config(); Key_GPIO_Init(); uint16_t prevKey 0; while (1) { uint16_t currKey Key_GetState(); uint16_t pressEvent Key_GetEvent(currKey, prevKey); if (pressEvent (KEY_00 | KEY_11)) { /* 同时按下了(0,0)和(1,1)执行组合动作 */ } else if (pressEvent KEY_00) { /* 单独按下(0,0) */ } else if (pressEvent (KEY_01 | KEY_02 | KEY_03)) { /* 第0行右侧三个键同时按下 */ } /* 其他业务代码 */ } }这里判断用的是相等想表达的是这一瞬间发生的事件正好等于这个组合。因为加了消抖重复触发的问题基本被压住了。如果还要处理长按、连发需要再加一个计时状态机不属于本文范围但这个扫描框架可以直接往上加。5. 实测对比与调试经验我焊完测量出来的几个关键数据5.1 加二极管前后的实测对比我用同一块4x4矩阵按键板只改二极管其他条件不变用串口把每次扫描的位图打出来对比。测试动作未加二极管加二极管后单键按下正常正常同列两个键同时按下正常正常同列三个键同时按下偶尔漏检全部识别2x2方阵三个角同按必现鬼键缺角被误报只报真实按键2x2方阵四键全按状态混乱四个键全部识别任意三个键随机组合一部分组合误判全部正确未加二极管时我最常遇到的就是2x2方阵三个角按下去缺角那个被误判成按下。加完之后同一套组合连按几十次串口输出全部正确。二极管带来的正向压降也没有影响高电平判定STM32读行线依然稳定。5.2 调节消抖延时和扫描速度的边界消抖延时不能太小太小滤不掉抖动也不能太大太大会让人觉得按键反应慢。我实测时的参数供参考扫描函数里每列拉高后延时20us整个4列扫完约80us消抖间隔用HAL_Delay(10ms)。主循环里一次完整获取按键状态约为10ms多。实际手感很好没有明显延迟也没有抖动误触。如果你的按键是机械开关消抖10ms是稳妥的。如果用了自锁开关或者导电橡胶建议拉到15ms到20ms。如果以后要在中断里扫描不要把HAL_Delay直接塞进中断换成状态机或定时器节拍。5.3 新手最容易踩的三个坑第一个坑是二极管方向焊反。焊反的直接表现是按键毫无反应因为高电平根本过不去。排查时用万用表二极管挡逐个量即可前面已经说过方法。第二个坑是非扫描列没有拉低。很多人扫完某一列后把该列设为高阻输入以为是释放结果鬼键照样出现因为在某些组合下回流路径依然能通过高阻态传过去。正确做法是非扫描列保持低电平推挽输出扫描列才拉高。第三个坑是行线没有下拉。内部下拉在部分引脚下拉阻值偏大如果接线较长或者环境湿度高没按键时行线也可能读到高电平。稳妥方案是用外部10k电阻下拉到GND实测比内部下拉更稳。我个人的习惯是新画板子时直接把每颗按键的二极管位号预留出来哪怕当下只做单键操作也为以后扩展组合键留了余地。二极管成本几分钱但能省掉后续改版的巨大麻烦。这次实测解决完鬼键后我又把同样的方案用到了另一块8x8按键板上逻辑完全一致只是扫描循环从4变成了8依然稳定这个思路可以直接平移到更大的矩阵上去。
返回列表