☰
STM32矩阵按键扫描法实战:CubeMX+HAL库实现4x4识别
2026/10/28 7:12:59 网站建设 项目流程

简介:一套基于STM32F103RCT6芯片的4×4矩阵按键控制完整工程,以HAL库驱动并结合CubeMX图形化配置,详细演示矩阵扫描法识别按键的原理与实现。适合正在学习STM32输入输出、外部中断与按键消抖的嵌入式初学者,也可作为课程设计或项目开发的基础模板。压缩包内共183个文件,主体为C源文件与头文件,同时包含Keil工程配置、CubeMX的ioc工程描述、编译链接生成的hex与axf,以及crf、o、d等过程文件,整体大小仅9.49MB,目录结构清晰,便于对照源码理解硬件抽象层的调用机制。该工程已有1382人学习或下载。除了最基本的行列扫描逻辑,还覆盖了行线置低、列线读取的完整流程,梳理了HAL_GPIO_ReadPin与HAL_GPIO_WritePin的使用方式,以及中断服务、按键去抖和稳定性优化等关键知识点。借助这套工程,可以完整掌握从CubeMX初始化配置、生成代码到编写逻辑、烧录验证的流程,并能在此基础上继续扩展长按、短按或组合按键等功能。

1. 4x4矩阵按键:8个引脚背后的扫描法真相

很多刚接触STM32的人都会遇到同一个坎:板子上的独立按键不够用,想多接几个,却发现GPIO被串口、OLED、传感器占得七七八八。而矩阵按键正是冲着这个问题来的——4x4矩阵只用8个IO就能拿到16个按键,代价是代码里必须有一套“扫描”逻辑把这16个键逐一遍历出来。这份资源就是一套基于STM32F103RCT6、用HAL库配合CubeMX配置、扫描法实现的4x4矩阵按键工程,适合刚把流水灯和串口玩明白、想解决“按键不够用”的开发者和学生。它不是一篇泛泛的原理文章,而是可以直接对照硬件接线、把代码烧进去就能看到键值的完整工程。这篇笔记我会把原理、CubeMX配置、扫描代码和踩过的坑拆开讲清楚,尤其是按键“集体失效”、串键、连按这几个高频问题,很多是电路和配置层面的坑,并不是代码本身写错了。

2. 矩阵按键与扫描法:8个引脚如何拆出16个键位

2.1 为什么用矩阵:IO数量与成本账

先算一笔账。独立按键的接法是一个按键占一个IO,一端接GPIO,另一端接地,按键按下时IO读到低电平。16个独立按键意味着16个IO,而且这些IO还得配置成输入模式。对于STM32F103RCT6这种LQFP64封装的芯片来说,虽然引脚数量看着不少,但真正能自由分配给按键的IO往往只有二三十个——你要是同时接了TFT屏幕、串口、I2C传感器、SPI Flash,16个IO做按键成本就太高了。

矩阵按键的接法是把按键排成行列交叉的形式:4根行线加4根列线,每个按键放在一行和一列的交叉点上。4x4矩阵只用8个IO,整整省下一半。省下来的IO可以拿去接编码器、接蜂鸣器、接第二个串口。这也是为什么矩阵按键在低成本面板、遥控器、键盘类产品里几乎成了标准方案。

但省IO是有代价的。独立按键随时可以读,矩阵按键必须“主动去找”——每次只能问一列,4列问完才知道这一轮有没有按键、按的是哪个。这个“主动去找”的动作,就是扫描法。理解了这个交换,后面看代码就不会觉得扫描逻辑多余了。

2.2 扫描法原理:一次只问一列

扫描法的核心思想是分时巡检。把列线作为“选通信号”,行线作为“读回信号”。具体做法:先把所有列线输出高电平,然后逐列拉低。比如拉低第0列,其他列保持高电平,这时候去读4根行线——如果第0列和第2行交叉处的按键被按下了,第2行就会被这个低电平拉低,于是读回的行值里第2位是0。根据“当前是哪一列被拉低”和“哪一行读到了低电平”这两个信息,就能确定按键的位置。

4x4矩阵扫描一轮要做4次这样的操作:拉低第1列、读行、恢复;拉低第2列、读行、恢复;直到4列全部问完。整个扫描一轮算下来,单片机要执行的指令量很小,对72MHz的F103来说,一次完整扫描加消抖也就几十微秒到几毫秒的量级,不会对主循环造成可见负担。

这里有个容易混淆的点:行列角色可以互换。有些人习惯行线输出、列线输入,效果一样,只要代码和接线保持一致就行。我一般固定用“列输出、行输入”的模型,因为列输出的选通逻辑在思维上更直白——你是在“一列一列地问问题”,行线只是负责“回答”。

2.3 GPIO模型:谁输出、谁输入、上拉还是下拉

矩阵按键的IO配置有一个关键点:行线作为输入,必须配上拉。因为按键按下时,行线是被列线的低电平拉低的,那么按键没按下的时候,行线必须保持一个确定的电平——高电平。如果行线浮空,按键没按的时候行线电平随机漂移,扫描结果就会乱七八糟,偶尔还会“鬼键”。

所以行线的配置是:输入模式 + 内部上拉。列线的配置是:输出模式 + 推挽 + 默认输出高电平。推挽输出能提供足够的驱动能力,把低电平稳稳地送到按键那一端;默认输出高电平是为了避免上电瞬间所有列都是低电平,导致行线被误拉低。

一个具体的引脚分配参考如下(工程里也是这么接的):

功能引脚GPIO模式内部上下拉说明
行0PA0InputPull-Up按键按下时读到低电平
行1PA1InputPull-Up同上
行2PA2InputPull-Up同上
行3PA3InputPull-Up同上
列0PB0Output Push-Pull无初始输出高
列1PB1Output Push-Pull无初始输出高
列2PB2Output Push-Pull无初始输出高
列3PB3Output Push-Pull无初始输出高

选择PA和PB两个端口还有一个好处:行线集中在PA0-PA3,列线集中在PB0-PB3,代码读行值时可以直接取GPIOA的低4位,写列值时只改GPIOB的低4位,不需要在多个端口间来回切换,扫描函数可以写得很紧凑。矩阵按键模块通常有8个引出脚,标着R1-R4和C1-C4,对应关系就是行线接PA0-PA3、列线接PB0-PB3。

3. CubeMX配置与初始化:从选芯片到生成代码的关键步骤

3.1 选芯片、时钟和系统时钟的关联

这个工程用的是STM32F103RCT6,在CubeMX里新建工程时直接搜索“STM32F103RCT6”选LQFP64封装即可。芯片选完之后第一件事不是配GPIO,而是先配时钟——因为GPIO的速率、定时器的时基都依赖系统时钟。

F103最常见的方法是外部8MHz晶振经PLL倍频到72MHz,这是官方手册里标称的最高主频。配置路径是RCC -> HSE -> Crystal/Ceramic Resonator,然后在Clock Configuration里把HCLK设成72MHz,CubeMX会自动算好PLL的分频倍频系数。如果你的板子上没有焊外部晶振,一定要把HSE改成HSI,否则代码烧进去后时钟起不来,现象就是程序完全跑不动或者跑得极慢,串口打印乱码——这个坑我在早期调板子时踩过一次,花了一晚上才发现是晶振没焊。判断方法很简单:看板子上有没有8MHz那个小金属壳晶振,没有就把时钟源切到HSI,省得白折腾。

时钟配置的连带影响是:HAL_Delay、串口波特率、定时器分频全都跟着系统时钟走。时钟没配对,按键消抖的10ms延时可能变成100ms或者1ms,表现就是按键反应迟钝或者消抖失效。

3.2 GPIO 配置的具体步骤与参数含义

CubeMX里配置GPIO的路径是Pinout & Configuration -> GPIO,先点击芯片视图里的PA0引脚,选择GPIO_Input,再点PB0选择GPIO_Output,逐个把8个引脚配完。不要把引脚视图里的模式配成Analog或者Alternate Function,那是给ADC和复用功能用的,在这里只会让按键失效。

配置完引脚后,在GPIO设置面板里需要手动修改几个参数:

参数行线(PA0-PA3)列线(PB0-PB3)
GPIO modeInput ModeOutput Push Pull
GPIO Pull-up/Pull-downPull-up不设置
Maximum output speed不用管Low 即可
User LabelRow0-Row3Col0-Col3

User Label这一项建议顺手填上。CubeMX会根据标签自动生成宏定义,比如Row0对应GPIO_PIN_0,Row0_GPIO_Port对应GPIOA。生成代码之后,你写的扫描函数里可以直接用这些宏,不用自己去查引脚号,代码可读性会好很多,后续改引脚接法时也只需要在CubeMX里改标签然后重新生成。

配置完成后可以在Pinout视图里看到PA0-PA3显示为绿色(输入),PB0-PB3显示为灰色/绿色(输出),并且引脚旁边会带上GPIO_Input或GPIO_Output的标签。这时候再点右上角的Generate Code,选择Toolchain为MDK-ARM,生成工程。

3.3 生成代码后先检查三件事

生成完代码,别急着写按键逻辑,先花两分钟检查三个地方:

第一,打开Core/Src/gpio.c,查看MX_GPIO_Init函数,确认Mode、Pull、Speed三个字段和预期一致。CubeMX偶尔会在自动布局时把某个引脚的模式改掉,尤其是当你先后配置了多个功能时,引脚复用冲突会让CubeMX自动重设模式。

第二,打开main.c里的SystemClock_Config,确认HCLK确实是72MHz。如果你改了时钟源但没重新生成代码,这个函数不会自动更新。

第三,在while循环里临时放一个HAL_Delay(500),再翻转一个LED或串口打印,确认基本运行节奏是正常的。这一步能排除时钟源选择错误造成的“看似在跑、实际跑飞”的情况。

gpio.c里初始化代码的关键段落长这样:

GPIO_InitTypeDef GPIO_InitStruct = {0}; /* GPIO Ports Clock Enable */ __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); /*Configure GPIO pins : Row0_Pin Row1_Pin Row2_Pin Row3_Pin */ GPIO_InitStruct.Pin = Row0_Pin | Row1_Pin | Row2_Pin | Row3_Pin; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /*Configure GPIO pins : Col0_Pin Col1_Pin Col2_Pin Col3_Pin */ GPIO_InitStruct.Pin = Col0_Pin | Col1_Pin | Col2_Pin | Col3_Pin; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

关键参数是GPIO_MODE_INPUT配合GPIO_PULLUP,行线内部上拉生效;列线用GPIO_MODE_OUTPUT_PP推挽输出。如果这里Pull被配成了GPIO_PULLDOWN,按键按下时行线依然读到高电平,扫描永远返回“没按键”——这是检查时最容易发现的问题。

4. 扫描代码落地:消抖、键值映射与连按控制

4.1 键值映射表:让扫描结果变成你认识的数字

硬件层面扫到的是“第几行、第几列”,但业务层面你需要的是“1、2、3、4”或者“上、下、左、确认”。这两者之间需要一个映射表。最常见的做法是用二维数组,行下标是行号,列下标是列号,数组内容就是你想让这个位置返回的键值。

// 键值映射表:key_map[行][列] // 布局对应实际4x4按键模块的丝印 const uint8_t key_map[4][4] = { {1, 2, 3, 10}, // 第0行:1 2 3 A(10) {4, 5, 6, 11}, // 第1行:4 5 6 B(11) {7, 8, 9, 12}, // 第2行:7 8 9 C(12) {13, 0, 14, 15} // 第3行:* 0 # D (用13/14/15代替* # D) }; #define KEY_NONE 0xFF // 无按键时返回值

数组逻辑是:扫描函数判断出“第2行第1列被按下”,就返回key_map[2][1],也就是8。这个表的好处是硬件接法和按键含义解耦:如果你的按键模块丝印布局不是标准电话键盘排列,只要改这个数组的内容,扫描函数完全不用动。

4.2 核心扫描函数:一次完整扫描的实现

行列分别集中在PA和PB的低4位,扫描函数可以写得很紧凑。我一般直接用寄存器操作IDR和ODR,在HAL库工程里这样写是允许的,而且比反复调用HAL_GPIO_ReadPin要直观得多——4列扫完只调用4次写操作加1次读操作,如果你用HAL函数逐引脚读写,一轮扫描要调十几个函数,代码冗余不说,性能也差一些。

// 返回键值,KEY_NONE表示无按键 uint8_t key_scan(void) { uint16_t col_val; uint16_t row_val; uint8_t row_bit; uint8_t key_val = KEY_NONE; static uint8_t last_key = KEY_NONE; // 上一次按键状态 for (uint8_t col = 0; col < 4; col++) { // 列值:只把当前列拉低,其余列保持高电平 col_val = GPIOB->ODR & 0xFFF0; // 先清低4位,保持高4位不变 col_val |= ~(0x0001 << col) & 0x000F; // 第col列输出0,其它列输出1 GPIOB->ODR = col_val; // 写列之后留一个极短的建立时间,防止读的时候电平还没稳定 __NOP(); __NOP(); __NOP(); // 读行值:PA0-PA3,只取低4位 row_val = GPIOA->IDR & 0x000F; // 逐行检查哪一行被拉低了 for (uint8_t row = 0; row < 4; row++) { row_bit = (row_val >> row) & 0x01; if (row_bit == 0) { key_val = key_map[row][col]; break; } } if (key_val != KEY_NONE) break; // 找到按键就不再往下扫 } // 消抖 + 防连按: // 只有“上一次无按键”且“本次扫到按键”时才返回键值, // 否则返回KEY_NONE,避免按键按住不放时重复触发 if (key_val != KEY_NONE && last_key == KEY_NONE) { last_key = key_val; return key_val; } else if (key_val == KEY_NONE) { last_key = KEY_NONE; } return KEY_NONE; }

逻辑说明:这个函数做两件事。第一件事是4列逐列扫描——每次把一列拉低,读回4行,一旦发现某行为0就查表得到键值并退出扫描。第二件事是防连按逻辑——用静态变量last_key记录上一次的按键状态,只有“上一次没有按键、这一次按下了”这个跳变沿才返回有效键值,按住不放时返回KEY_NONE。这样处理之后,按键按下一次只触发一次,不会因为手抖或按住不放造成重复触发。

参数说明:col_val那句的0xFFF0含义是“保留ODR高12位不受影响”,因为高4位可能还控制着其他外设引脚,比如LED或蜂鸣器,如果把整个ODR直接赋值,其他引脚的电平会被打乱。~(0x0001 << col) & 0x000F这一步的结果是:col=0时得到0xE(1110),col=1时得到0xD(1101),依此类推。__NOP()是空指令,用来拖几个时钟周期,让列电平在引脚上稳定后再去读行线。如果你用HAL_Delay(1)也能起到同样的作用,但会让扫描周期变长,按键响应会变慢。

4.3 消抖与防连按:代码之外的细节

矩阵按键和独立按键一样,存在机械抖动问题。按键内部的金属弹片在闭合瞬间会反弹,持续几毫秒到十几毫秒,如果扫描程序在弹跳期间读到电平,可能会把一次按下识别成多次。我在上面的扫描函数里用“相邻两次扫描结果一致”的思路消抖,但更稳妥的做法是检测到按下后延时10ms再读一次,两次都为低才确认是有效按键。

实际的工程里,我见过不少人在这个位置栽跟头,症状就是“按一下键,串口打印了好几个相同键值”。这里补充一个完整的消抖逻辑,你可以在主循环里调用:

uint8_t key_read_debounced(void) { uint8_t key1, key2; key1 = key_scan(); if (key1 == KEY_NONE) return KEY_NONE; // 第一次就没读到,直接返回 HAL_Delay(10); // 跳过机械抖动窗口 key2 = key_scan(); if (key1 == key2) return key1; // 两次一致,确认有效 return KEY_NONE; // 两次不一致,认为是抖动 }

参数说明:10ms是机械按键抖动的一个经验值,大多数轻触开关的抖动时间在5-15ms之间,取10ms能覆盖大部分情况。有些质量差的按键模块抖动能到20ms以上,如果你发现偶尔还是会误触发,把延时改成20ms再试。延时消抖的代价是牺牲响应速度,10ms延时对人工按键来说完全无感。

主循环里的调用方式也很简单:

uint8_t key = key_read_debounced(); if (key != KEY_NONE) { printf("Key Pressed: %d\r\n", key); }

这样每按一次键,串口只会打印一次键值。如果你用的是我前面写的key_scan函数,它在内部已经做了防连按处理,不需要再包一层debounce,二选一即可,叠两层也不会冲突,只是代码冗余。

5. 避坑与排查:矩阵按键最常见的五个翻车现场

5.1 现象:同一根矩阵线路上的多个按键集体失效

整个第2列的所有按键都没反应,或者某一行全部失灵,但其他行列正常。这种情况十有八九是公共线路问题,不是代码问题——因为同一列共享一根导线,这一根断了或者没接好,整列都废。

排查思路是先测硬件再查配置。用万用表蜂鸣档测该列的模块引脚和单片机对应GPIO之间是否导通,重点检查杜邦线是否插到了旁边的孔、模块排针是否虚焊。排除了硬件之后,再看CubeMX里对应的列引脚是不是被配置成了输入模式或者Analog——列线必须是推挽输出,如果你的某个列引脚在CubeMX里被复用成了JTAG或调试功能,输出会异常。

这里有个容易忽略的点:STM32F103的PB3、PB4、PA13、PA15等引脚默认是JTAG调试功能,不是普通GPIO。如果矩阵按键的列线不小心选到了这些脚,CubeMX会默认配置成复用功能,必须先去GPIO设置里把AF功能关闭或者改到其他引脚。这是“集体失效”的一个隐蔽原因。

5.2 现象:按键按下没反应,或者偶尔串键

单按一个键时没问题,但快速按不同按键时偶尔出现两个键同时触发,或者明明按的是3却识别成6。这个问题的根源通常是扫描周期内列线切换时电平还没稳定,或者行线内部上拉没有生效。

行线浮空时,按键没按下它的电平会随机漂移,扫描期间读到的行值是乱的。先检查CubeMX里行的Pull配置是否真的是Pull-up,如果是No pull-up and no pull-down,把内部上拉打开。其次,列线切换和读行线之间要留出足够的时间,GPIO输出电平建立时间大约在几十纳秒,理论上不需要额外延时,但如果你选了非常慢的GPIO速度等级,或者线上长了、寄生电容大,电平翻转会被拖慢。

解决方法是把列线的GPIO speed从Low改成Medium,或者在扫描代码里保留__NOP()那几个时钟周期的等待。不要一上来就用HAL_Delay(1)做等待——虽然能解决稳定问题,但会把扫描周期拖到几十毫秒,按键响应明显变肉。

5.3 现象:一次按键触发多次,串口打印一堆重复键值

这个现象我在前面提过,本质是机械抖动加上了软件的连按判定缺失。按键弹片的抖动周期内,扫描程序可能连续读到几次“按下->释放->按下”,于是业务层执行了多次相同操作。

解决方式是三层验证:机械层面,按键模块的两个焊点是固定死的,只能保证弹片本身质量;电气层面,可以在行列线上并一个小电容做硬件RC滤波,100nF即可;软件层面,必须做消抖,要么用10ms延时二次确认,要么用状态机记录“上一次状态”和“本次状态”,只有跳变沿才触发。多数情况下软件消抖已经足够,RC电容属于锦上添花,尤其是当按键线缆比较长、环境有干扰时,加电容能显著减少毛刺。

5.4 现象:扫描正常但返回的键值和丝印对不上

按下数字“5”却返回了“A”,或者1和2位置是反的。这是键值映射表和真实接线方向不一致导致的。矩阵按键模块的丝印是给用户看的正面布局,而模块背面排针的R1-R4、C1-C4排列顺序往往不是按正面从左到右、从上到下排列的——有些模块C1在左边,有些在右边,有些R1在顶部,有些在底部。

处理方法是先做一个“盲扫测试”:写一小段测试代码,把key_map临时改成打印“行号-列号”,比如“2-1”,然后逐个按键按下并记录。扫完16个键,你就得到了真实的“物理位置到行列号”的对应关系表,再把这个表换算进key_map。这个操作看起来繁琐,但一次做完就不用再改,比我当时拿到模块只看丝印就开始写映射表、结果返工一下午要强得多。

5.5 现象:按下无反应但用万用表测按键两端有通断

这种情况往往不是按键本身的问题,而是GPIO配置中的一个隐藏参数:列线输出初始电平不对。有些配置里列线默认输出低电平,行线读到的也是低电平,于是程序以为“所有按键同时按下”。

解决方法是确认所有列线在初始化阶段输出的是高电平。CubeMX里可以在GPIO设置面板的GPIO_InitStruct中直接指定初始输出电平,或者生成代码后手动改一行:HAL_GPIO_WritePin(GPIOB, Col0_Pin | Col1_Pin | Col2_Pin | Col3_Pin, GPIO_PIN_SET)。如果忘了这一步,扫描前先拉低一列再读行,就会把所有行都拉低,得到的结果是全键按下。

6. 验证与进阶:从跑通到状态机按键

工程跑通之后,建议你先做一次完整的功能验证再收工。把串口波特率设成115200,主循环里轮询按键并打印键值,然后一个键一个键地按过去,对照串口助手的输出确认16个键全部正确。这一步能同时验证键值映射表、消抖和防连按逻辑,是排查前面那些坑的最直接手段。如果手头有OLED或者LCD屏,也可以把键值直接显示在屏幕上,省去串口转接的麻烦。

验证完之后,可以思考一下“矩阵键盘能检测复合按键吗”这个问题——这是矩阵按键方案的一个天然边界。标准的无二极管矩阵,同时按下两个键时,电流会通过按键交叉点倒灌,产生所谓的“鬼键”。比如同时按下第0列第0行和第1列第1行,系统可能错误地识别出第0列第1行也有按键。如果产品逻辑需要支持组合键(比如Shift+字母),纯矩阵方案是做不到的,需要每个按键串联一个二极管做隔离,或者直接换独立IO方案。所以矩阵按键适合的场景是“大量按键、每次只按一个”,不适合“需要多键组合”的人机交互。

进一步说,如果你想让按键响应更细腻,比如区分短按、长按和双击,建议把轮询扫描改成状态机结构。简单的做法是用三个静态变量记录按键状态:上次扫描结果、当前扫描结果、按键持续时间。在while循环里每次扫描后让持续时间递增,当状态从“无按键”跳到“有按键”时记下起始时间,持续超过500ms判定为长按,500ms内弹起判定为短按。这个“边沿触发 + 计时”的模型,比单纯在循环里堵一个HAL_Delay等待释放要优雅得多,而且不会阻塞其他外设的轮询。

矩阵按键这个坑说起来不深,但坑底全是细节:引脚模式、上下拉、列线初值、消抖窗口、键值映射方向,任何一环出错,表象都是“按键不听话”。我从第一次调矩阵按键的翻车,到后来做完整键盘面板,最大的教训就是先花几分钟做一次行列扫盲测试、确认物理接法和逻辑映射,再开始写业务代码。从那以后,我每次拿到新的按键模块都强制走一遍这个流程,再也没有在按键识别上返过工。希望这篇笔记也能帮你把矩阵按键一次调通。

如果你手头正好缺一套能直接用的工程模板,这份基于CubeMX+HAL库的4x4矩阵按键源码包就是一个不错的选择——拿到手打开.ioc文件、对着引脚表接好线、烧录,串口就能看到正确的键值输出。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询