简介面向嵌入式初学者的STM32矩阵键盘串口输入完整工程基于STM32F103微控制器演示GPIO行列扫描识别矩阵键盘按键并通过USART将按键值实时发送至主机串口终端。资源包共300个文件压缩后仅7.43MB主要包含c/h源码、编译生成的o/d目标文件、hex/axf固件映像、uvprojx工程文件及实验指导文档代码分层清晰可直接打开工程对照学习。已有679人学习下载。通过学习可掌握STM32的GPIO输入输出配置、矩阵键盘行列扫描与按键去抖逻辑、USART参数初始化及串口收发流程并能理解从底层寄存器配置到上层应用交互的完整链路。适用场景包括课程设计、智能家居控制面板、工业设备人机界面等是嵌入式入门阶段非常实用的综合练习。1. 矩阵键盘扫描结果进不了串口先确认扫描时序再谈发送STM32矩阵键盘串口输入这套组合看着是入门项目实际联调起来坑不少。按下第2行第3列的键串口助手收到的是0xFF或者完全没反应或者按一下发出去两三条重复数据——这类现象在毕设和工装面板开发里很常见根子往往不在串口而在矩阵扫描的行列IO配置和消抖窗口。这篇文章把从矩阵键盘扫描到串口发出数据的完整链路拆开讲行列IO怎么规划、消抖参数怎么设、按键值怎么编码成帧、出问题先查哪几个点。适合正在做按键面板、门禁输入板或者调试工装的开发者手头有一块开发板和4x4矩阵键盘就能照着重现。2. 矩阵扫描原理与IO规划行列反转法之外的三个边界参数2.1 行列结构决定了扫描方向先确定哪一组做输出矩阵键盘之所以省IO是因为用M行加N列的组合就能识别M乘N个键。4x4矩阵只要8个IO而独立按键需要16个。这里的核心是同一时刻只把其中一根行线拉低然后读四根列线的电平状态哪一根列线读到低就说明这一行和这一列交叉点上的按键被按下了。扫描完四行一轮就结束。选择扫描方向时一般以硬件布局为准。板子上行线接在GPIOA的PA0到PA3列线接在GPIOB的PB0到PB3那常见做法就是行做输出、列做输入。反过来也行但代码里必须固定住不能扫描到一半换方向。我见过一种翻车情况行线和列线都配成了输入模式没有一组拉低读回来全是高电平按键永远识别不到。初始化代码可以这样写// 行线PA0-PA3做推挽输出列线PB0-PB3做上拉输入 void MatrixKey_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // 行线推挽输出默认输出高电平 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_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; 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_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); }这段代码里行线用了推挽输出加内部上拉列线用了输入加内部上拉。逻辑说明行线拉低时如果对应按键按下电流路径从列线上拉电阻经过按键到行线低电平列线被拉低读列线就能捕捉到。参数上值得注意两点GPIO_SPEED_FREQ_LOW足够矩阵扫描频率本身只有几十kHz高速模式反而容易引入电磁干扰GPIO_PULLUP在内部上拉和外部上拉同时存在时会并联等效电阻变小拉低边沿变快但释放时上升沿会变缓偶尔会引起重复触发。2.2 外部上拉和内部上拉怎么选看你的模块上有没有电阻市面上常见的矩阵键盘模块往往自带排针和8个贴片电阻没接线的状态下测量引脚对地能看到典型的10k欧姆。这种模块接到开发板上程序里再开内部上拉就会并联两个上拉电阻按键释放时引脚恢复高电平的速度被拖慢。如果你的模块没有外部上拉只有裸按键加杜邦线那就必须开内部上拉否则引脚悬空读回来的电平会在高低之间随机跳变。判断方法非常直接看原理图或者用万用表量一下列引脚和VCC之间有没有阻性连接。有就直接把内部上拉关掉用GPIO_NOPULL浮空输入没有就开GPIO_PULLUP。这个选择在第2章的初始化代码里就是一个枚举值的差别但决定了后面串口数据是干净的一帧还是偶发重复触发。另一个坑是引脚复用冲突。确认一下你的PA0到PA3、PB0到PB3有没有被其他外设占用尤其是PA0和PA1往往和定时器输入捕获、ADC通道混用。初始化时如果没关掉其他外设的复用功能按键扫描会读到乱七八糟的电平。我一般会先注释掉其他外设的初始化单独验证矩阵键盘通断再加回来。3. 消抖与按键事件状态机把一次按压变成一条可用的输入3.1 机械抖动不是玄学消抖窗口到底取多大按键按下和释放的瞬间簧片接触会产生一连串的电平跳变持续时间大约1到5ms。这个时间段里读到的电平不稳定可能按一次被识别成三到四次。消抖的本质是状态必须连续稳定一段时间才认为变化有效。常见的消抖有两种做法阻塞延时的老思路读到电平变化后delay 10ms再读一次以及非阻塞状态机方式用系统tick计数记录状态首次变化的时间之后每次扫描对比时间差。在矩阵键盘这种需要边扫描边处理其他任务的场景里我一般用非阻塞状态机。消抖窗口取多大扫了多个项目后我的习惯是15ms。小于10ms某些按键的弹跳时间覆盖不住大于30ms快速双击操作会被吞掉。如果你发现按下能触发释放时偶尔多补一条事件多半就是释放瞬间的抖动没有完全滤除可以把窗口再加2到3ms比如改成18ms。3.2 单次触发与连发模式用状态机把事件定义清楚矩阵键盘扫描函数每次扫描返回一个状态但主循环关心的是按下和释放这两个事件而不是持续的电平。我习惯用一个简单的结构体来装扫描状态#define KEY_DEBOUNCE_MS 15 #define KEY_REPEAT_DELAY_MS 500 #define KEY_REPEAT_INTERVAL_MS 150 typedef struct { uint8_t lastRaw; // 上一次扫描读到的原始电平组合 uint8_t currentRaw; // 当前扫描读到的原始电平组合 uint8_t confirmedState; // 经过消抖确认后的稳定状态 uint8_t lastEvent; // 上一次上报的事件类型 uint32_t stateChangeTick; // 状态发生变化的时间戳 } MatrixKey_State_t;扫描函数里每一轮先比较当前读到的原始值和上一次的原始值。如果不一致说明正在抖动只更新时间戳并返回无事件。如果一致再检查从上次变化到现在是否超过了消抖窗口。超过才把当前原始值确认成稳定状态并对比确认状态跟上一次有没有变化有变化才上报事件。代码实现// 参数keyState是状态结构体指针rawValue是当前行列扫描的原始值 uint8_t MatrixKey_Update(MatrixKey_State_t *keyState, uint8_t rawValue) { uint8_t event KEY_EVENT_NONE; uint32_t now HAL_GetTick(); if (keyState-currentRaw ! rawValue) { // 原始值还在跳变处于抖动期间记录时间并等待稳定 keyState-currentRaw rawValue; keyState-stateChangeTick now; return KEY_EVENT_NONE; } // 同一电平状态持续超过消抖窗口才认为稳定 if ((now - keyState-stateChangeTick) KEY_DEBOUNCE_MS) { return KEY_EVENT_NONE; } // 对比稳定状态判断按下还是释放 if (keyState-confirmedState ! rawValue) { keyState-confirmedState rawValue; if (rawValue ! 0xFF) { event KEY_EVENT_PRESSED; } else { event KEY_EVENT_RELEASED; } // 如果需要连发在这里记录按下起始时间 } else if (rawValue ! 0xFF) { // 同一按键保持按下超过500ms后每150ms补发一次事件 if ((now - keyState-stateChangeTick) KEY_REPEAT_DELAY_MS) { if ((now - keyState-lastEventTick) KEY_REPEAT_INTERVAL_MS) { keyState-lastEventTick now; event KEY_EVENT_REPEAT; } } } keyState-lastEvent event; return event; }关键逻辑是两层比较第一层比较当前扫描和上一轮扫描差异只用来判定是否在抖动不做业务判断第二层比较经过消抖确认的稳定值和当前值差异才用来产生事件。很多人会把这两层合并成一层结果就是抖动期间反复触发事件。连发参数KEY_REPEAT_DELAY_MS和KEY_REPEAT_INTERVAL_MS需要根据业务调整。普通键盘类应用用上面写的500ms和150ms就有不错的输入手感如果是数字密码输入类建议把连发关掉或者把间隔拉大到500ms以上避免误操作。4. 串口发送链路从按键编号到字节流的完整拼装4.1 USART参数选择波特率不是越大越好矩阵键盘事件是低频事件人手指按压最多也就每秒十几次每帧数据按4字节算9600波特率都绰绰有余。但实际项目中我习惯直接配115200主要原因是调试上位机、逻辑分析仪、串口助手默认都支持这个速率而且程序里一个宏就能改不用纠结。USART初始化代码void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16; if (HAL_UART_Init(huart1) ! HAL_OK) { Error_Handler(); } }注意一个联调细节如果把矩阵扫描放在定时器中断里做串口发送就不能也在中断里直接调用HAL_UART_Transmit。这个函数是阻塞式的115200波特率发4字节大约需要0.35ms看似不长但中断里再叠加扫描、消抖、看门狗喂狗等操作时间片会被挤占。常见做法是扫描中断只往环形缓冲区里丢事件主循环里统一取出来发送。4.2 帧格式设计帧头、键值、校验缺一不可如果只是自己在串口助手里看按键值直接发一个字节就可以。但串口数据之后要接上位机或者转给另一个MCU处理我强烈建议至少加一层帧格式。最简单的帧帧头0xAA、按键编号、按下释放标志、和校验。void MatrixKey_SendEvent(uint8_t keyCode, uint8_t isPressed) { uint8_t frame[4]; frame[0] 0xAA; // 帧头 frame[1] keyCode; // 按键编号 0x00-0x0F frame[2] isPressed; // 0x01按下 0x00释放 frame[3] frame[0] frame[1] frame[2]; // 和校验 HAL_UART_Transmit(huart1, frame, sizeof(frame), 20); }参数说明HAL_UART_Transmit最后一个参数20是超时时间单位毫秒。对115200波特率发4字节来说这个时间足够宽松它存在的意义是防止串口总线异常时主循环被永久卡死。和校验是最低成本的校验方式能查出绝大多数单字节错误但要注意它的一个局限多字节同时出错且和恰好抵消时查不出来。对按键这种低频控制信号来说足够如果将来传更关键的数据建议升级为CRC8。按键编号的映射一般放在数组里扫描函数确定行列后查表得到编号。第4章先按下不表下一章讲怎么让映射表更加灵活。5. 避坑与排查矩阵键盘串口联调中的七个常见故障现场5.1 扫描侧问题电平、消抖和悬空输入现象1串口收到的数据全是0xFF按键按下去没有反映。原因列引脚处于悬空状态没有有效的上拉通路。程序里配置成了GPIO_NOPULL硬件上又没有外接上拉电阻引脚电平被环境噪声带着走。解决先看原理图确认有没有外部上拉电阻。没有就把列线的初始化配置改成GPIO_PULLUP有外部上拉就保持浮空输入。这一条解决了至少三成同类问题。现象2按一下按键串口发出来两条甚至三条帧数据。原因消抖窗口设得太小或者干脆没有做消抖机械触点弹跳期间多次通过稳定性判断。解决检查MatrixKey_Update里的KEY_DEBOUNCE_MS用上面代码里的15ms起步。如果已经用了15ms仍然有重复帧把窗口加到20ms同时检查是不是列线释放时上升沿过于平缓——这种情况需要加上拉电阻外部处理。现象3某个按键永远不触发其他按键正常。原因这一行或列的IO引脚被复用。STM32的PA0、PA1、PB0这类引脚往往在CubeMX里默认挂着ADC、I2C或其他外设功能GPIO初始化如果没有把对应引脚从复用状态切换回来扫描就失效。解决在MatrixKey_Init里显式调用HAL_GPIO_DeInit掉对应引脚或者回到CubeMX重新查看引脚映射确认没有引脚被多个外设占用。排查时把优先级放高这种问题不会因为改代码逻辑而恢复纯粹是配置冲突。5.2 串口侧问题乱码、重复帧与丢失帧现象4串口助手收到乱码数据完全不可读。原因两边的波特率不一致或者TXD和RXD接反。前者常见于主程序里改了初始化值但忘记同步串口助手的设置后者常见于转接板走线方向问题。解决先用逻辑分析仪抓一下数据线上的实际波特率跟配置值对比确认。接反的情况很简单交换TXD和RXD两根线再试。如果手头没有逻辑分析仪可以用一个USB转串口模块自发自收验证固件的发送链路是否正常。现象5按下能够发送但上位机漏掉了一部分帧数据。原因主循环里HAL_UART_Transmit阻塞发送期间新的按键事件被覆盖了。比如发送代码是立即执行没有带缓冲而2ms扫描周期内按键事件已经刷新成新值。解决改成环形缓冲区加队列扫描中断只把按键事件压入队列主循环里逐个取出发送。矩阵键盘事件频率低一个大一点的静态数组缓冲就够了不必引入复杂的数据结构。现象6释放按键的时候偶发重复触发按下本身却正常。原因释放瞬间电平回到高电平的上升沿不够干脆MCU在边界处又读到一个低电平毛刺。这通常是因为内部上拉电阻值偏大恢复时间偏长。解决在列线上外接10k欧姆上拉电阻电源和地的走线尽量靠近按键模块。程序上把KEY_DEBOUNCE_MS加大2到3ms作为临时规避但不能治本。现象7用串口调试正常接上位机就频繁出错。原因上位机解析程序对帧格式太宽松没有校验帧头或者和校验实现方式与发送端不一致。解决把帧格式写进接口文档发送端和上位机各执一份。重点确认键值范围是0x00到0x0F按下标志是0x01释放是0x00校验和是三个字节相加取低8位。这些定义只要有一处偏了就出问题。6. 进阶技巧用映射表和长按状态提升输入体验6.1 行列到按键编号的映射表解耦硬件布局和业务含义扫描函数拿到的是第几行第几列这个原始坐标这个坐标跟硬件走线强相关。如果将来换了板子走线或者同一套固件适配不同面板坐标会变而上位机的协议不应该跟着变。我习惯在扫描和发送中间加一张映射表// row 0-3col 0-3映射到0x00-0x0F const uint8_t KeyCodeMap[4][4] { {0x00, 0x01, 0x02, 0x03}, {0x04, 0x05, 0x06, 0x07}, {0x08, 0x09, 0x0A, 0x0B}, {0x0C, 0x0D, 0x0E, 0x0F} };查表时注意边界检查。通过越界的行列号去访问数组是嵌入式里常见的越界来源别嫌啰嗦加上范围判断再返回值。6.2 长按识别和事件过滤让串口数据更贴近业务需求有些场景下按钮按下500ms以上属于长按要发单独的指令。做法是记录按键事件发生的时间戳在HAL_GetTick()差值超过阈值时把事件类型从KEY_EVENT_PRESSED改成KEY_EVENT_LONG_PRESS。这两个事件帧里的键值相同但按下标志可以定义成0x02表示长按上位机收到的语义就清晰了。事件过滤也是容易被忽略的点。矩阵键盘识别到按键按下后如果用户一直按着不松手扫描程序会持续上报按键状态。我的习惯是只在状态反弹沿上报事件按住不动时不重复发送避免串口被无意义的数据刷屏。这对门禁面板这类应用尤其重要按键被卡住时不应该持续发出信号。这套链路我从矩阵键盘扫描、消抖、事件上报、串口帧拼装这几个环节一路踩过来最大的悟性是调试时别急着改代码先用万用表和逻辑分析仪确认硬件的上拉、通断、波特率这些基础事实。项目里矩阵键盘和串口单独测都正常一合起来就出幺蛾子的案例几乎都是电平配置和消抖窗口这两个问题。从那以后我每接一个新的矩阵键盘模块都会要求先看原理图确认上拉方式再动程序这个习惯帮我省掉了大量联调时间。希望帮到你。本文还有配套的精品资源点击获取