☰
STM32矩阵按键扫描法详解:CubeMX配置与HAL库实现
2026/10/28 7:03:04 网站建设 项目流程

简介:一套基于STM32F103RCT6芯片的4×4矩阵按键控制工程,面向嵌入式初学者和需要快速掌握HAL库开发方式的工程师。资源围绕按键扫描方案展开,完整给出了从CubeMX图形化配置到HAL库驱动编写,再到扫描法识别按键的实践流程。矩阵按键利用行线与列线的交叉结构,仅用8个GPIO即可管理16个按键,代码中通过逐行拉低、读取列线状态的方式判断按下位置,并考虑了按键抖动处理。压缩包内共183个文件,大小约9.49MB,主要包含C语言与头文件源码、Keil工程文件、CubeMX的ioc配置、可烧录的hex与axf文件,以及map、lst等编译辅助文件,结构完整,可对照工程理解每个模块。目前已有1382人学习下载,适合希望直接参考完整工程、理解矩阵键盘行扫描与列检测原理,并在此基础上加入去抖、中断处理等扩展逻辑的开发者。

1. 矩阵按键为什么绕不开扫描法:STM32F103RCT6的4x4入门实践

矩阵按键是 STM32 入门里最容易让人“自以为会了、一接就翻车”的外设。4x4 个按键,如果每个按键单独占一个 IO,需要 16 个引脚;用矩阵接法,把按键放在行线和列线的交叉点上,只需要 8 个引脚就能读回 16 个键的状态。这种接法下,你必须在同一时刻只驱动一行,再读列线来判断哪个键被按下,这就是扫描法。STM32F103RCT6 作为 LQFP64 封装的芯片,引脚数量看着不少,但实际项目里串口、OLED、编码器、传感器一接,IO 立刻紧张起来,矩阵键盘这种省引脚方案很实用。本文会直接围绕 CubeMX 配置、HAL 库 GPIO 操作、扫描函数实现这几个环节展开,适合刚学 STM32、从标准库往 HAL 库迁移,以及准备做密码锁、菜单按键、小键盘的人。你别指望读完后能理解所有底层寄存器,但至少照着做能跑通,并且知道踩坑时该看哪里。

2. CubeMX配置4x4矩阵按键:引脚分组、上下拉与时钟树

2.1 行线与列线,先定输入输出方向

矩阵按键的电路看起来完全对称,但单片机的配置必须分清楚。习惯上把连接单片机行扫描输出的那 4 根线称为行线,连接按键另一端的 4 根线称为列线。行线配置成推挽输出,列线配置成输入上拉。扫描时,单片机将某一行输出低电平,比如行 0 拉到 GND;如果这一行上有按键被按下,按键开关就把对应列线也拉到低电平。列线读到的低电平,就是“这一行某个键按下”的信号。

这个方向不能反。如果把行线配成输入、列线配成输出,逻辑上也能扫,但你的按键矩阵引脚定义、扫描顺序、键值映射全部要反过来,而且容易和网上大多数例程对不上。最直观的做法是:行线负责主动拉低,列线负责被动读取。所有行线默认保持高电平,只有正在扫描的那一行才拉低。列线必须带上拉电阻,未按下时稳定在高电平,按下时被行线拉低。如果你把列线配置成下拉,未按下是低,按下也是低,程序根本区分不了按键状态,所以上下拉方向是最先需要确认的点。

2.2 用CubeMX分配引脚并生成工程

实际使用 CubeMX 时,我习惯把行线放在 PA0-PA3,列线放在 PA4-PA7。这样在代码里可以用一个 GPIOA 端口完成大部分操作,扫描函数写起来更整齐。如果你的板子 PA0/PA1 被板载 ADC 电位器占用,可以换成 PB0-PB3 做行、PB4-PB7 做列,但要注意 PB3/PB4 和 PA15 默认有 JTAG 复用,配置成普通 GPIO 前需要处理调试口,这个坑后面会专门讲。

CubeMX 里的操作步骤:

  1. 打开 STM32CubeMX,选择芯片 STM32F103RCT6。
  2. 在 Pinout 视图,把 PA0-PA3 点击成 GPIO_Output,PA4-PA7 点击成 GPIO_Input。
  3. 进入 System Core -> GPIO 面板,把 PA0-PA3 配置成 Output Push Pull,速度选 Low;PA4-PA7 配置成 Input mode,Pull-up 选择 Pull-up。
  4. 在 Clock Configuration 里,如果板上有 8MHz 外部晶振,选择 HSE Crystal,PLL Source 选 HSE,PLLMUL 设成 x9,得到 SYSCLK 72MHz。
  5. Project Manager 里选择对应 IDE,生成工程。

引脚配置对应关系:

功能引脚GPIO 模式速度/上下拉说明
行 0PA0输出推挽Speed Low扫描时拉低
行 1PA1输出推挽Speed Low扫描时拉低
行 2PA2输出推挽Speed Low扫描时拉低
行 3PA3输出推挽Speed Low扫描时拉低
列 0PA4输入内部上拉读低电平表示键按下
列 1PA5输入内部上拉同上
列 2PA6输入内部上拉同上
列 3PA7输入内部上拉同上

CubeMX 生成的 GPIO 初始化代码大致如下。生成完之后,不要直接拿去跑,先确认几个关键参数:

HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0|GPIO_PIN_1|GPIO_PIN_2|GPIO_PIN_3, GPIO_PIN_SET); GPIO_InitStruct.Pin = GPIO_PIN_0|GPIO_PIN_1|GPIO_PIN_2|GPIO_PIN_3; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_4|GPIO_PIN_5|GPIO_PIN_6|GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

第一行HAL_GPIO_WritePin(..., GPIO_PIN_SET)不是 CubeMX 一定会自动生成的,我经常看到生成代码里只初始化 GPIO,却没有把输出引脚初始电平设为高。如果你的行线初始是低电平,第一次扫描前所有行都为低,列线会全部被拉低,按键状态完全错乱。所以要在 GPIO 初始化完成后手动加上这一行。

参数说明:

  • GPIO_MODE_OUTPUT_PP是推挽输出。行线拉低时能输出强低电平,拉高时也能稳定输出 3.3V。如果选成开漏,高电平只能靠外部上拉维持,按键按下时的电流路径不完整,容易出问题。
  • GPIO_MODE_INPUT配合GPIO_PULLUP,让列线空闲时保持高。HAL 库把输入输出模式和上下拉分成两个字段,很多人从标准库转过来,只写了 Mode,忘记写 Pull,结果引脚悬空,读取的电平随机变化。
  • GPIO_SPEED_FREQ_LOW已经足够。按键扫描是毫秒级操作,不需要高速翻转。低速能减少信号边沿带来的振铃,降低误触发概率。

2.3 时钟树里那些看似和按键无关的设置

按键扫描本身对时间精度要求不高,但 CubeMX 工程里如果时钟树配置错了,后面用 HAL_Delay、串口打印按键值、定时器中断都会出现问题。F103RCT6 的最高主频是 72MHz,APB2 总线最高 72MHz,APB1 总线最高 36MHz。常规配置是外部 8MHz 晶振,PLLMUL 设 x9,PLLCLK 输出 72MHz。

如果你的板载晶振不是 8MHz,比如是 12MHz,PLLMUL 就要改成 x6,最终主频仍然是 72MHz。这点经常有人忽略,直接把网上例程的倍频数搬过来,导致系统时钟不是预期的 72MHz,串口波特率偏差很大。按键扫描虽然还能用,但定时器计数时间、消抖延时全都会偏离预期。使用标准库时,时钟配置要手写 RCC 寄存器;HAL 库配合 CubeMX,只要在图形界面点几下就能生成。很多从标准库切过来的开发者不习惯 HAL 库的 GPIO 结构体写法,比如GPIO_MODE_OUTPUT_PP对应标准库的GPIO_Mode_Out_PP,GPIO_PULLUP对应标准库的上拉配置。理解了这些对应关系,看 CubeMX 生成的代码就不容易发懵。

3. 用HAL库写出第一版扫描函数:逐行拉低与列读取

3.1 扫描法的核心:一轮只拉低一行

扫描法的原理听起来简单,但写代码时要注意时序。初始化完成后,所有行线都保持高电平,所有列线因为上拉也保持高电平。扫描第一步,把行 0 拉低,然后依次读列 0 到列 3。如果行 0 和列 2 交叉处的按键被按下,列 2 就被拉到低,这时程序记录下行 0 列 2 对应的键值。扫完行 0 后,把行 0 恢复成高电平,再把行 1 拉低,继续读列。这样循环 4 次,就能把 16 个按键全部检查完。

这里有一个容易被忽略的点:每次切换下一行之前,必须先把上一行恢复成高电平。如果行 0 一直处于低电平,用户同时按下行 1 的某个键,列线可能被行 0 和行 1 共同影响,造成“同一根矩阵线路上的多个按键集体失效”的现象。正确的顺序应该是:全部行拉高 -> 拉低当前行 -> 延时稳定 -> 读所有列 -> 恢复当前行为高 -> 进入下一行。

3.2 基于HAL_GPIO_ReadPin的完整扫描函数

下面是一个最小可用版本。引脚定义使用数组,方便以后换引脚。函数返回 0 表示没有按键按下,返回 1 到 16 表示对应键值。

#include "main.h" #define KEY_ROW_NUM 4 #define KEY_COL_NUM 4 /* 行线引脚定义 */ GPIO_TypeDef* key_row_port[KEY_ROW_NUM] = {GPIOA, GPIOA, GPIOA, GPIOA}; uint16_t key_row_pin[KEY_ROW_NUM] = {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3}; /* 列线引脚定义 */ GPIO_TypeDef* key_col_port[KEY_COL_NUM] = {GPIOA, GPIOA, GPIOA, GPIOA}; uint16_t key_col_pin[KEY_COL_NUM] = {GPIO_PIN_4, GPIO_PIN_5, GPIO_PIN_6, GPIO_PIN_7}; uint8_t MatrixKey_Scan(void) { uint8_t row, col; uint8_t key = 0; for (row = 0; row < KEY_ROW_NUM; row++) { /* 先把所有行恢复高电平,避免上一轮残留低电平 */ for (col = 0; col < KEY_ROW_NUM; col++) { HAL_GPIO_WritePin(key_row_port[col], key_row_pin[col], GPIO_PIN_SET); } /* 拉低当前行 */ HAL_GPIO_WritePin(key_row_port[row], key_row_pin[row], GPIO_PIN_RESET); /* 空循环延时,让电平稳定 */ for (volatile uint32_t d = 0; d < 200; d++); /* 读当前行对应的4个列 */ for (col = 0; col < KEY_COL_NUM; col++) { if (HAL_GPIO_ReadPin(key_col_port[col], key_col_pin[col]) == GPIO_PIN_RESET) { /* 等待按键释放,返回键值 */ while (HAL_GPIO_ReadPin(key_col_port[col], key_col_pin[col]) == GPIO_PIN_RESET) { for (volatile uint32_t d = 0; d < 200; d++); } key = row * KEY_COL_NUM + col + 1; return key; } } /* 当前行扫描完成,恢复高电平 */ HAL_GPIO_WritePin(key_row_port[row], key_row_pin[row], GPIO_PIN_SET); } /* 所有行扫完都没有按键 */ return 0; }

逻辑说明:

  • 函数进入 for 循环后,先执行一次“所有行恢复高电平”。这个动作放在每轮开头,保证即使函数在前一次调用中途返回,下一次调用也能从干净状态开始。
  • 拉低当前行之后,空循环d < 200大约产生几微秒延时。GPIO 电平转换需要时间,如果按键到单片机的走线比较长,寄生电容会让波形边沿变缓,不加延时直接读列线容易读到中间电平,造成误判。
  • HAL_GPIO_ReadPin返回GPIO_PIN_RESET或GPIO_PIN_SET,对应低电平和高电平。判断低电平,是因为列线默认被上拉到高,按键按下才变成低。
  • key = row * KEY_COL_NUM + col + 1是把行列坐标映射成 1 到 16 的编号。如果你想输出 ASCII 码,可以再加一张映射表,比如按下第 1 行第 1 列返回字符'1',按下第 4 行第 3 列返回字符'#'。
  • 函数里的while等待释放是“只触发一次”的土办法。用户按住按键不松手,程序会一直停在这个 while 循环里,直到释放才返回。这样做简单粗暴,不会产生一次按下被主循环多次识别的问题,但缺点也很明显:按键按住期间,单片机完全被卡住,其他任务全部暂停。

3.3 这个扫描函数能用的前提

第一个前提是 CubeMX 里的上下拉配置必须正确。列线必须是输入模式且内部上拉,行线必须是推挽输出。第二个前提是按键本身质量要正常,按下时导通电阻要足够小,能把列线拉低;如果使用薄膜按键,接触电阻变大会让列线只能被拉到 1V 左右,HAL 库读引脚时可能仍然判定为高电平。第三个前提是你实际接线时,行线和列线不要接反。有些按键模块丝印标注不明确,我一般在排线两端量一下导通关系,先确定哪一端是公共端,再接入单片机。

3.4 为什么这个版本不擅长检测复合按键

很多人搜索“矩阵键盘能检测复合按键吗”,答案是可以,但不能用上面这个函数。因为这个函数在发现第一个按下的键后就立刻返回,剩下的行不会再扫描。即便去掉 while 等待释放,同时按下多个键还会产生鬼影现象,也就是没有按下的键被误读。矩阵键盘的交叉线路天然存在寄生路径,按下三个键时,可能让第四个交点被误判为按下。所以,单独识别单键用上面的函数没问题,要识别组合键,必须换第 5 章的定时器扫描方法,并且硬件上最好加二极管隔离。

4. 扫描法避坑:鬼影、集体失效、电平异常……排查4个高频问题

4.1 现象:只按一个键,却读出一串键码,这是鬼影

出现这种问题时,先不要怀疑单片机和代码,多半是矩阵按键的电气特性导致的。4x4 矩阵按键的关键点是:按键本质上是一组开关,行列线交叉但电气上并不隔离。当你同时按下 3 个按键,且它们刚好组成矩形的三个顶点,第四个顶点即使没有按键,也会因为行线和列线通过另外三个按键连通,被单片机读取为按下状态,这就是鬼影。

解决方向有两个。如果你只做密码锁、数字菜单这类单键输入,可以不管它,因为单键操作不会触发鬼影。如果有组合键需求,比如同时按下“上”和“确认”执行某个功能,最稳妥的办法是在每个按键上串联一个 1N4148 二极管,让电流只能单向流动,阻断寄生路径。程序层面可以做逻辑过滤,比如当检测到超过两个键同时按下时,认为状态无效并丢弃,但这只是补救,无法根治。

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

这个词来自实际排错场景:某一根行线或列线上的所有按键都没反应,其他线路正常。原因通常是引脚配置和预期不一致。比如把行线配置成了输入模式,行线就永远不会主动拉低;或者把列线配置成了下拉,按下前后列线都是低电平,程序无法识别变化。

遇到这种情况,先拿万用表量列线电压。未按下时列线应该接近 3.3V,按下对应行上的按键后应该降到 0V 左右。如果电压一直为 0V,说明列线被外部下拉或者按键线路被短路;如果电压一直为 3.3V,说明按键按下没有形成有效通路。检查 CubeMX 生成的初始化代码,确认引脚模式是GPIO_MODE_OUTPUT_PP和GPIO_MODE_INPUT,再确认Pull字段是GPIO_PULLUP。还有一种隐蔽原因是该引脚被配置成其他外设功能,比如 PA0 同时作为 ADC 输入,GPIO 写入就会失效,这在实际工程中很常见。

4.3 现象:松开按键后程序仍然不断触发,出现粘连

这种现象多见于按键引脚悬空电平漂移,以及扫描函数里缺少释放检测。列线在按键释放后需要一定时间恢复高电平,如果按键到单片机之间的导线很长,或者线路板上有较大寄生电容,电压爬升会变慢。程序如果检测到一次低电平后直接返回,主循环再次调用扫描时,可能仍然读到低电平,于是反复触发。

解决办法是在按下确认后加 10ms 到 20ms 消抖,然后在函数里等待释放。等待释放的 while 循环不要退出得太早,至少要确认列线恢复到高电平并稳定一段时间。更规范的做法是用状态机保存“上一次按键状态”,只有状态从“未按下”变成“按下”时才上报一次,这样即使没有 while 等待也能避免重复触发。主循环里调用扫描函数时,不要随便用 HAL_Delay 代替电平稳定延时,HAL_Delay 依赖于 SysTick 中断,如果你的中断优先级配置不当,会出现卡死。

4.4 现象:CubeMX 生成代码后,列线引脚一直读到低电平

如果确认上下拉、引脚模式都正确,但列线还是低电平,要优先检查是否占用了调试口引脚。PA13、PA14、PA15、PB3、PB4 在 STM32F103 上默认是 SWD/JTAG 调试功能,CubeMX 虽然允许把它们强行配置成普通 GPIO,但芯片在上电复位时这些引脚并不完全由 GPIO 寄存器控制。如果列线接到 PA15,读到的数据可能一直不对。

解决方法是把行线和列线尽量安排到普通 IO 上,比如 PA0-PA7 或者 PB0-PB7。如果实在要用 PA15、PB3、PB4,可以在初始化代码里调用__HAL_AFIO_REMAP_SWJ_NOJTAG()关闭 JTAG,保留 SWD。要注意不能调用__HAL_AFIO_REMAP_SWJ_DISABLE(),那样连调试器都会断开,以后只能靠串口 ISP 下载程序,调试体验会很差。我一般不会用调试口做矩阵按键,节省的一个引脚不值得牺牲调试便利性。

4.5 现象:按键按下时能读到,但扫描函数一运行整个系统变卡

原因很直接:第 3 章的扫描版本里有 while 等待释放,这个循环在按键被按住时会被无限拉长。主循环里还有其他任务,比如 OLED 刷新、串口发送,只要按键不松开,后续代码全部停滞。还有种情况是电平稳定延时里使用HAL_Delay(1),每次扫描至少占用 1ms,四行加释放等待累计时间很长。

解决方法是把扫描方式从“按下一个键就卡住等待释放”改成“记录按键状态”。主循环每次调用扫描时,只更新当前的行列状态,不等待释放。还可以用定时器中断,每 5ms 采样一次,把状态存入全局变量,主循环只读变量,具体做法放在下一章。

5. 进阶:用定时器中断扫描代替主循环延时,顺带解决组合按键

5.1 定时器扫描的最大价值

把按键扫描放进定时器中断,不是为了炫技,而是为了把“等待释放”的阻塞从主循环里拿掉。定时器每 5ms 进入一次中断,在中断里逐行扫描,把 16 个按键的按下状态保存在一个全局变量key_state中。主循环里读取这个变量,就能知道哪些按键处于按下状态,也就能判断组合键了。

5.2 用TIM6做5ms定时扫描的代码框架

CubeMX 里把 TIM6 的时钟源打开,预分频设为 72-1,自动重装值设为 5000-1,72MHz 时钟下就能得到 5ms 中断。中断回调函数里调用扫描状态函数,扫描函数不再死等释放,而是完整记录每一行每一列的状态。

volatile uint16_t key_state = 0; /* bit0~bit15 对应16个按键,1表示按下,0表示释放 */ void MatrixKey_ScanState(void) { uint8_t row, col; GPIO_PinState pin_state; /* 全部行先恢复高电平 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0|GPIO_PIN_1|GPIO_PIN_2|GPIO_PIN_3, GPIO_PIN_SET); for (row = 0; row < 4; row++) { HAL_GPIO_WritePin(GPIOA, key_row_pin[row], GPIO_PIN_RESET); /* 短延时稳定电平,这里不要用HAL_Delay */ for (volatile uint32_t d = 0; d < 20; d++); for (col = 0; col < 4; col++) { pin_state = HAL_GPIO_ReadPin(key_col_port[col], key_col_pin[col]); if (pin_state == GPIO_PIN_RESET) { key_state |= (uint16_t)(1u << (row * 4 + col)); } else { key_state &= (uint16_t)~(1u << (row * 4 + col)); } } /* 恢复当前行高电平 */ HAL_GPIO_WritePin(GPIOA, key_row_pin[row], GPIO_PIN_SET); } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { MatrixKey_ScanState(); } }

主循环里使用key_state时,要注意中断里可能正在修改它。最稳妥的办法是先读取一份快照,再基于快照做判断,避免判断过程中状态被更新造成逻辑错乱。按键消抖可以在主循环里做,比如连续两次读到相同状态才确认按键有效。由于扫描周期固定为 5ms,20ms 消抖就相当于连续读 4 次一致。

5.3 复合按键能检测,但鬼影仍可能

有了完整状态变量,检测组合键就变成简单的位运算,比如判断按键 1 和按键 5 是否同时按下,就看key_state对应两个 bit 是否都为 1。但要注意,矩阵键盘不隔离时鬼影依旧存在。如果按键板上没有二极管,同时按下 3 个构成矩形顶点的键,key_state里可能多出第 4 个无效按键。所以检测复合按键前,最好统计一下key_state中 1 的个数,超过设定数量就认为是干扰,不响应。

5.4 验证扫描是否正常

验证方法很简单:在 Keil 调试模式下把key_state加入 Watch 窗口,按一个键,看对应 bit 是否翻转。再用逻辑分析仪抓列线波形,正常能看到每 5ms 出现一组低脉冲。最后通过串口把key_state发到电脑,按住组合键观察数据变化,确认释放状态也能正确清除。我自己现在做这类项目,会把所有按键扫描统一放进定时器中断,然后主循环只做逻辑处理。血泪经验就是不要在任何按键函数里用 while 死等,否则系统一旦卡在按键上,其他外设全都会跟着翻车。希望这个方法能帮到你少走这段弯路。

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

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

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

立即咨询