
简介面向意法半导体STM32F103微控制器的矩阵键盘开发资料包适合嵌入式入门者及需要实现键盘输入功能的开发者可帮助解决从硬件连接到软件扫描的完整实现问题。内容围绕4乘4或5乘5矩阵键盘的开发涵盖通用输入输出GPIO端口模式配置、逐行扫描算法设计、外部中断与嵌套向量中断控制器NVIC配置、按键抖动消除等关键知识点并给出完整代码与硬件连接思路。压缩包共126个文件以C源码、头文件、工程文件为主同时包含编译生成的目标文件、列表文件及烧录文件便于重新编译和下载验证包体大小约2.59兆字节。目前已有1471人次学习下载。借助该工程可快速理解STM32F103的GPIO与中断系统获得可直接复用的矩阵键盘驱动代码和软硬件联调思路对裸机开发、键盘类课程设计或产品原型制作都有较高参考价值。1. 矩阵键盘为什么是STM32F103项目的标配解16个独立按键如果一路一路接IOSTM32F103的GPIO立刻被吃掉一半而矩阵键盘只用8个引脚就能换回16个按键这种“以时间换引脚”的思路在键盘开发里几乎成了默认答案。网上搜到的51单片机矩阵键盘教程大多是用P1口直接扫描换到STM32F103上核心原理不变但GPIO的推挽配置、上拉电阻选择、消抖节奏都要重新设计直接搬51的代码很容易出现漏键、连键甚至烧引脚的问题。这篇讲的就是从扫描原理、标准库驱动、HAL库移植到长按组合键检测的完整落地路径适合正在做stm32项目、手上有一块F103最小系统板但不想浪费IO的开发者。2. STM32矩阵键盘扫描原理与GPIO配置选型2.1 先理解逐列扫描为什么行线必须上拉矩阵键盘的物理结构是行线和列线交叉处各放一个按键4条行线加4条列线覆盖16个触点。检测按下与否的标准做法是逐列扫描先把所有列线输出高电平然后每次只把一列拉低再去读四根行线的电平。如果这一列上某个按键被按下对应的行线就会被拉低于是得到一个唯一的行列坐标。这里有个容易忽略的细节行线必须配置成上拉输入而且最好是内部上拉。原因是按键没有按下时行线悬空如果不接上拉电阻STM32F103读到的电平是随机浮动的串入轻微干扰就会误判。F103内部上拉电阻典型值在30k到50k之间对于2mA左右的按键灌电流场景足够稳定外部再并一个10k电阻可以进一步压低干扰但对于实验板来说内部上拉通常已经够用。2.2 扫描方式的取舍逐列扫描与行列翻转法对比项逐列扫描行列翻转法IO方向切换列线输出、行线输入行列都要反复切换输入输出扫描耗时16次检测约几十微秒只需2轮判定速度更快多键同时按下支持能区分同列多键容易出现误判组合代码复杂度低循环嵌套即可需要两次配置GPIO方向适用场景大多数嵌入式键盘节省CPU时间的场景逐列扫描虽然慢一些但胜在逻辑清晰而且天然支持同列的两个按键同时按下这在矩阵键盘开发里很重要。行列翻转法只有两轮检测速度快但第二轮翻转时如果有多键按下读到的组合是多个按键叠加的结果很难还原成真实键值。我一般默认用逐列扫描只有按键数量超过32个、CPU时间紧张时才考虑翻转法。2.3 STM32F103的GPIO模式选择推挽输出遇上上拉输入配置列线时必须选推挽输出模式这样输出低电平时引脚是真正的灌电流路径而不是开漏输出的弱下拉。行线配成上拉输入后按键按下时电流从内部上拉电阻流向列线低电平形成一个完整的回流路径。整个过程不需要额外限流电阻因为内部上拉已经限制了电流大小但需要注意不能让两个列线同时输出低电平否则同时按下两个不同列的按键时电流会从一行串到另一行导致信号相互干扰。引脚分配上建议避开PA13、PA14、PA15和PB3、PB4这五个引脚在STM32F103上默认复用为JTAG调试接口。如果矩阵键盘接到这些引脚程序烧录时没问题但运行时按键不响应因为内核还占着这组引脚。老式例程里常见的做法是加一句AFIO-MAPR | AFIO_MAPR_SWJ_CFG_JTAGDISABLE来释放PB3和PB4但这句放在标准库工程里容易和调试器抢占冲突不如直接换引脚省心。3. STM32标准库实现4x4矩阵键盘的最小驱动3.1 引脚分配与GPIO初始化代码以STM32F103C8T6为例行线接PA0到PA3列线接PB0到PB3。初始化时行线设置为上拉输入列线设置为推挽输出初始状态全部输出高电平。标准库代码分成matrix_key_init和matrix_key_scan两个函数前者只做GPIO配置后者负责返回当前按下的键值。#define ROW_PORT GPIOA #define COL_PORT GPIOB #define ROW_PIN (GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3) #define COL_PIN (GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3) void matrix_key_init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // 行线上拉输入 GPIO_InitStructure.GPIO_Pin ROW_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(ROW_PORT, GPIO_InitStructure); // 列线推挽输出默认高 GPIO_InitStructure.GPIO_Pin COL_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(COL_PORT, GPIO_InitStructure); GPIO_SetBits(COL_PORT, COL_PIN); }这段代码有一个关键操作列线初始化后立刻全部置高。如果不做这一步列线在上电瞬间是不确定的某个列线可能默认输出低电平第一个扫描周期就会把这一列当成全部按下。GPIO_Speed设成50MHz对扫描速度没有实际帮助但和F103整个工程其他引脚的配置保持一致避免个别引脚速度配置不同导致信号边沿不一致。3.2 扫描函数与键值映射表扫描函数逐列拉低电平读取行线状态命中后通过key_map二维数组映射成键值。这个映射表的价值在于硬件走线不一定要按顺序如果PCB上某一行列接反了直接改表就能修正不用重新飞线。const uint8_t key_map[4][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}, {13, 14, 15, 16}, }; uint8_t matrix_key_scan(void) { uint8_t row, col; for (col 0; col 4; col) { // 先全部拉高再单独拉低一列 GPIO_SetBits(COL_PORT, COL_PIN); GPIO_ResetBits(COL_PORT, (GPIO_Pin_0 col)); // 等电平稳定 for (row 0; row 4; row) { if (GPIO_ReadInputDataBit(ROW_PORT, (GPIO_Pin_0 row)) Bit_RESET) { return key_map[row][col]; } } } return 0; // 没有按键按下 }每次切换列线之前先把所有列线恢复高电平这一点是防止两列同时低的有效手段。如果只把上一列拉高而不先把当前列拉低在切换的瞬间可能出现两个列线都是低电平的情况这时两列上的按键同时导通行线读到的是被两个低电平同时拉低的信号位置判定就错了。GPIO_Pin_0 col直接利用位运算生成引脚编号比单独定义四根引脚再逐一组装要简洁但要注意col不能超过15否则移位结果溢出。3.3 主循环里的调用节奏int main(void) { uint8_t key_val; matrix_key_init(); while (1) { key_val matrix_key_scan(); if (key_val) { // 这里交给上层处理比如串口发送或功能分发 } delay_ms(5); } }扫描函数返回0代表没有按键但这也意味着如果用户按下了映射值为0的按键上层会误判为无按键。实际工程里键值通常从1开始编号0作为空值保留。主循环里必须在扫描后加一个小延时一来给按键信号稳定时间二来让整个扫描周期保持在5ms左右给消抖逻辑一个可靠的时钟基准。如果完全没有延时扫描频率高达几十kHz一次机械抖动可能被当成多次按键上报。4. 按键消抖状态机长按、短按与组合键判定4.1 从延时消抖到状态机消抖矩阵键盘开发里最影响手感的除了硬件走线质量就是消抖策略。最简单的是delay_ms(10)等抖完再读一次但这样会把扫描函数阻塞住主循环里其他任务全停摆。51单片机矩阵键盘教程里常这么写换到STM32上如果还这么干一个按键按下去CPU就空转10ms三个按键连续操作就卡30ms和F103的主频完全不匹配。状态机消抖的思路是把每个按键的检测拆成多个稳定状态只有连续多次读到的电平一致才确认状态切换。常见做法是定义一个四态状态机空闲态、确认按下态、按住态、确认释放态。每5ms扫描一次连续两次读到按下才进入按住态连续两次读到释放才回到空闲态。这样既消掉了机械抖动又不阻塞主循环。#define KEY_SCAN_PERIOD_MS 5 #define KEY_DEBOUNCE_COUNT 2 // 连续2次确认 #define KEY_LONG_PRESS_MS 500 typedef struct { uint8_t state; // 0空闲 1按下确认 2按住 3释放确认 uint8_t debounce_cnt; // 消抖计数 uint16_t press_ticks; // 按住时长计数 uint8_t last_key; // 当前有效按键 } key_state_t; key_state_t key_sm; void key_sm_process(uint8_t current_key) { switch (key_sm.state) { case 0: // 空闲 if (current_key) { key_sm.debounce_cnt; if (key_sm.debounce_cnt KEY_DEBOUNCE_COUNT) { key_sm.state 1; key_sm.last_key current_key; key_sm.press_ticks 0; } } else { key_sm.debounce_cnt 0; } break; case 1: // 按下确认 key_sm.state 2; break; case 2: // 按住 if (current_key 0) { key_sm.debounce_cnt 1; key_sm.state 3; } else { key_sm.press_ticks; if (key_sm.press_ticks (KEY_LONG_PRESS_MS / KEY_SCAN_PERIOD_MS)) { // 触发长按逻辑 } } break; case 3: // 释放确认 if (current_key 0) { key_sm.debounce_cnt; if (key_sm.debounce_cnt KEY_DEBOUNCE_COUNT) { key_sm.state 0; key_sm.last_key 0; } } else { key_sm.debounce_cnt 0; key_sm.state 2; } break; } }press_ticks累加到KEY_LONG_PRESS_MS / KEY_SCAN_PERIOD_MS也就是100次时说明这个键已经稳定按住了500ms。长按和短按的区分在状态机里只是两个不同的触发点按下确认时触发短按按住计数到阈值时触发长按互不干扰。这个方案比单纯在扫描函数里加延时好用得多因为消抖和长按计时共用同一个扫描节拍不会出现按键按了800ms但计时器从第一次抖动就开始算的情况。4.2 多键同时按下返回一个集合而非单个键值上面的matrix_key_scan每次只返回一个键这在同时按下两个键时会漏报。改进方向是改成扫描时把命中的键值写入一个数组主循环处理完后再清空。逐列扫描本身有这个能力因为每一列是独立拉低的同列的两个键一起按下能被识别出来不同列的两个键也不会互相干扰。uint8_t key_hit_list[8]; uint8_t key_hit_count; void matrix_key_scan_batch(void) { uint8_t row, col; key_hit_count 0; for (col 0; col 4; col) { GPIO_SetBits(COL_PORT, COL_PIN); GPIO_ResetBits(COL_PORT, (GPIO_Pin_0 col)); for (row 0; row 4; row) { if (GPIO_ReadInputDataBit(ROW_PORT, (GPIO_Pin_0 row)) Bit_RESET) { key_hit_list[key_hit_count] key_map[row][col]; if (key_hit_count 8) { return; } } } } }实际使用中同时按下的按键一般不会超过两个4x4键盘最多同时4个按键参与组合判定8个容量的数组绰绰有余。组合键逻辑不需要在扫描层完成把命中的键值列表交给上层上层再去判断Ctrl某键这种组合。扫描层只负责忠实地把所有按下的键都报出来不要把过滤逻辑放在扫描函数里否则后续想加组合键功能还得翻底层代码。5. 矩阵键盘换HAL库移植与STM32F103常见踩坑5.1 HAL库版本的核心差异HAL库和标准库在矩阵键盘扫描上最大的区别是GPIO操作API的命名和参数类型。标准库的GPIO_ReadInputDataBit和GPIO_ResetBits在HAL里变成HAL_GPIO_ReadPin和HAL_GPIO_WritePin且每个引脚都要单独传一个GPIO_Pin枚举值不能再靠移位运算批量生成。配置结构体也从GPIO_InitTypeDef变成了GPIO_InitStruct字段一样但初始化过程多了一步HAL_GPIO_Init调用。void matrix_key_hal_init(void) { __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitTypeDef GPIO_InitStruct {0}; // 行线上拉输入 GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // 列线推挽输出 GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3, GPIO_PIN_SET); }HAL库的HAL_GPIO_ReadPin返回的是GPIO_PinState枚举判断时直接和GPIO_PIN_RESET比较。扫描函数主体和标准库没有本质差异但要注意HAL_GPIO_WritePin第二个参数传的是具体引脚不能用GPIO_Pin_0 col这种位运算一次性传多个引脚除非正好传一个组合值。这也是HAL库移植时最容易编译报错的地方标准库能过HAL库过不了。用CubeMX生成工程时在Pinout视图里把PA0到PA3标成GPIO_InputPB0到PB3标成GPIO_Output然后到GPIO配置页把PA0到PA3的Pull-up/Pull-down选成Pull-upPB0到PB3的Maximum output speed选High生成的代码基本可以直接用。唯一的坑是CubeMX默认会把所有没用到的引脚初始化成模拟输入如果之前用过这些引脚做别的功能要把它们改回来否则按键完全不响应。5.2 踩坑记录上拉失效、引脚复用与短路电流矩阵键盘在STM32F103上常见的故障集中在三个点上。第一个是上拉配置失效。行线如果被初始化成浮空输入没有按键按下时读到的电平完全随机扫描函数会随机返回键值表现为按键自动触发。排查方法很简单把行线的GPIO_InitStructure.GPIO_Mode打印出来确认是GPIO_Mode_IPU而不是GPIO_Mode_IN_FLOATING或者在初始化后读一次GPIO_ReadInputDataBit确认高电平。第二个是JTAG引脚复用问题。如果矩阵键盘占用了PA13到PA15或者PB3、PB4调试器正常但按键没反应几乎可以断定是引脚还在被调试模块占用。在标准库工程里用GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)释放引脚后按键就能工作。但要注意禁用JTAG后SWD调试仍然保留不影响烧录程序只是不能再接JTAG四线调试器了。第三个是两列同时拉低引起的短路电流。某些网上的矩阵键盘例程会在扫描时忘了把前一列恢复高电平直接拉低下一列如果两个列线对应的按键同时按下行线会把两列短接在一起瞬间电流可能超出GPIO的承受范围。F103的GPIO虽然耐操但长时间这样操作引脚会发烫甚至造成不可逆损坏。扫描函数里先置高所有列再单独拉低一列这个顺序不能省。5.3 为什么中断方式不适合矩阵键盘有些初学者试图给每根行线配一个外部中断按键按下时触发中断再读列线省去轮询。这个方案在矩阵键盘上有两个致命问题按键抖动会触发多次中断虽然可以在中断里消抖但中断服务函数里加延时会让整个系统的实时性变差另外矩阵键盘的按键按下时由于机械抖动一次按键可能产生几十微秒级别的毛刺外部中断挂在EXTI上会被这些毛刺反复触发严重时直接拖垮CPU。矩阵键盘这类慢速人机交互设备轮询扫描加状态机是比中断更可靠的做法。6. 矩阵键盘验证手段与响应速度优化技巧6.1 用示波器验证扫描波形矩阵键盘的扫描逻辑是否正常不需要靠肉眼按按键来猜示波器量一下列线波形就能看出问题。正常工作时列线上应该出现周期性低脉冲。把示波器探针夹在COL0引脚上触发模式设为下降沿能看到每个扫描周期内COL0被拉低一次低电平持续约50微秒左右周期等于主循环里的扫描间隔。如果列线没有低脉冲说明列线的推挽配置没生效或者GPIO时钟没打开。如果列线一直保持低电平说明某一行线被干扰拉到了低电平导致扫描逻辑反复重置列线状态。行线波形的观察更有意思。按下某个按键时对应的行线会在扫描到那一列时被拉低波形上能看到一个持续几十微秒的低脉冲。松开按键后这个脉冲消失。如果按下按键后行线波形没有变化先不要怀疑程序检查按键本身导通性按键氧化或接触电阻变大后行线可能只会出现一个微弱的下拉读到的电平仍在高电平阈值之上。6.2 串口打印键值定位软件问题硬件波形确认无误后软件层面的问题用串口打印定位。在扫描函数返回键值的位置加一句printf(key:%d\n, key_val)然后逐个按键按下观察串口输出是否和按键位置对应。最容易发现的问题是键值映射表顺序和实际PCB走线不匹配比如第一行第一列的按键按下后输出的是5而不是1。这时候直接改key_map数组把对应顺序交换即可不用调整接线。if (key_val) { printf(key:%d\r\n, key_val); }6.3 响应速度的优化顺序矩阵键盘的响应速度取决于三个环节扫描周期、消抖时间和软件层处理耗时。扫描周期通常5到10ms就能满足人类手指的输入速度追求更低的延迟没有实际意义。真正能感受到的差异在消抖时间10ms的消抖对大多数按键手感正好太短会漏掉机械抖动引起的误触发太长会让人感觉按键闷。如果按键用的是轻触开关而不是硅胶按键消抖时间可以考虑降到5ms手感更利落。最后一个优化技巧是把常用的按键映射到先扫描的列。矩阵键盘扫描顺序是固定的从第0列到第3列先扫描到的列在响应上天然少一次列切换的时间。如果某些按键比如确认键、返回键比其他键用得更频繁把它们放在第0列能让每一次按键操作都少一到两个微秒的列电平建立时间在4x4这种小规模键盘上差异不太明显但8x8以上矩阵时会变得可观。这个技巧同样适用于键值映射表的设计把高频按键排在key_map数组的低位索引上扫描循环能更快命中返回。本文还有配套的精品资源点击获取