☰
矩阵键盘与串口通信:STM32扫描、消抖到发送的完整联调指南
2026/10/13 10:57:08 网站建设 项目流程

简介:面向嵌入式初学者的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-3,col = 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表示长按,上位机收到的语义就清晰了。

事件过滤也是容易被忽略的点。矩阵键盘识别到按键按下后,如果用户一直按着不松手,扫描程序会持续上报按键状态。我的习惯是:只在状态反弹沿上报事件,按住不动时不重复发送,避免串口被无意义的数据刷屏。这对门禁面板这类应用尤其重要,按键被卡住时不应该持续发出信号。

这套链路我从矩阵键盘扫描、消抖、事件上报、串口帧拼装这几个环节一路踩过来,最大的悟性是:调试时别急着改代码,先用万用表和逻辑分析仪确认硬件的上拉、通断、波特率这些基础事实。项目里矩阵键盘和串口单独测都正常,一合起来就出幺蛾子的案例,几乎都是电平配置和消抖窗口这两个问题。从那以后,我每接一个新的矩阵键盘模块,都会要求先看原理图确认上拉方式再动程序,这个习惯帮我省掉了大量联调时间。希望帮到你。

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

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

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

立即咨询