STM32矩阵键盘计算器实战:从扫描原理到状态机设计
发布时间:2026/10/15 11:48:03 锦皓数字建站

简介这是一份基于 STM32F103C8T6 的矩阵键盘计算器完整工程资源面向嵌入式初学者、电子竞赛选手及课程设计开发者适合学习 STM32 基本外设驱动、矩阵键盘扫描和 OLED 显示控制。项目围绕行列扫描识别按键、定时器与中断处理、运算结果刷新显示等核心环节展开帮助读者建立从硬件初始化、按键消抖到应用层交互的完整开发思路。资源包为 ZIP 格式共 192 个文件大小约 7.36MB以 C 源码、头文件、Keil 工程文件uvprojx、链接脚本、编译生成的目标文件与中间文件为主目录结构清晰可直接导入开发环境对照学习目前已有 640 人浏览/学习。内容涵盖矩阵键盘扫描消抖、计算器四则运算与错误处理、OLED 驱动与显示刷新等关键代码并保留调试构建脚本适合作为课程设计或毕业设计模板也便于深入分析 STM32 标准外设库的初始化流程、中断服务程序和定时器扫描机制掌握从零搭建一个嵌入式小项目的完整流程。1. 矩阵键盘计算器几块钱的板子为什么值得花一个周末做出来如果你刚接触 STM32或者已经点亮过流水灯、玩过 OLED却总觉得缺一个“完整得像产品”的小项目那这个基于 STM32F103C8T6 的矩阵键盘计算器恰好是性价比最高的进阶练习。它把 GPIO 输入、行列扫描、状态机、数码管或 LCD 输出、按键消抖这几个嵌入式最高频的考点全部串在一起而且硬件成本低到可以忽略——一块几块钱的 F103C8T6 核心板、一个 4x4 矩阵键盘、一块 0.96 寸 OLED 就够起步。做完这一个你再去读任何一款带触摸或键盘的产品源码至少不会再被“按键扫描”这个黑匣子吓住。更关键的是这个项目不是跑通就完事它有大量真实坑矩阵键盘的鬼键怎么过滤、F103C8T6 的 IO 复用怎么配置、长按和连击怎么区分、计算状态机怎么避免“输入 1234 变成 46”这类翻车现场。我见过不少同学点灯很熟练一写矩阵键盘就卡在“按键无反应”或“按下一次触发两次”上最后怀疑是板子坏了——其实九成是扫描逻辑或者消抖时序的问题。这篇笔记就把我调通这个项目的完整路径写出来包括原理、代码、接线和排错目标是你照着做一个晚上能跑出能用的版本。2. 矩阵键盘扫描原理与行列信号解析搞懂这两个坑代码怎么写都顺2.1 行列扫描为什么比独立按键省引脚又为什么更容易误触发独立按键的方案很好理解每个按键占一个 GPIO4x4 就是 16 个引脚。而矩阵键盘把按键排列成行列结构4 根行线加 4 根列线用 8 个引脚管住 16 个按键。它的扫描思路是分时驱动先给全部行线输出低电平然后轮流读取每一列的电平变化再反过来给列线输出低电平读每一行的电平变化。只有行列交叉点对应的两个电平都为低时才判定这个位置的按键被按下。这个方案省引脚但有一个天然缺陷——鬼键。假设第 1 行第 1 列和第 2 行第 2 列的两个按键同时按下扫描程序可能会错误地判断第 1 行第 2 列和第 2 行第 1 列也被按下因为这两条线通过按键形成了电气通路。解决鬼键最简单的方法是把要扫描的引脚之一设置为上拉输入或推挽输出让另一端变成高阻或主动驱动阻断那个不该出现的电流路径。实际工程里还有一种更稳妥的“反转法”先把行设为输出低电平、列设输入上拉扫描到低电平的列再把列设为输出低、行设输入扫描低电平的行两次结果取交。如果你只是做一个 4x4 计算器我的建议是用“行扫描法”而不是“反转法”。行扫描法逻辑更直白依次把每一行拉低然后读四列。这样做代码量少、好调试而且计算器使用场景下很少出现多键同时按下的情况。反转法虽然更严谨但需要动态切换 GPIO 的输入输出模式在 F103C8T6 上配置稍不留神就会因为模式切换时序不对产生毛刺。2.2 STM32F103C8T6 的 GPIO 模式分配与上下拉电阻选择STM32F103C8T6 的 GPIO 在矩阵键盘这里需要规划好三种模式。行线我建议设置成推挽输出且初始电平为高列线设置成上拉输入这样没有按键按下时读到的是高电平一旦某行拉低、对应列按键按下列线就被拉低很容易检测。但这个方案下有一个细节推挽输出高电平时驱动能力够但行线如果和列线之间漏电可能导致误触发。所以我一般还会在列线上启用内部下拉电阻的替代方案——不行运算一下还是上拉输入最稳因为按键按下后你要的是“能拉低”的效果上拉配合低电平驱动是天作之合。接下来看具体的 GPIO 配置代码。F103C8T6 的 GPIO 配置需要使用库函数我以标准外设库为例GPIO_InitTypeDef GPIO_InitStructure; // 开启 GPIOB 和 GPIOC 时钟根据实际接线改 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_GPIOC, ENABLE); // 列线PC0~PC3 设置为上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOC, GPIO_InitStructure); // 行线PB0~PB3 设置为推挽输出初始为高 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); // 行线初始化为高电平 GPIO_SetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3);这段代码的核心是先开时钟再配模式最后置初始电平。顺序不能乱先开时钟再初始化是硬性规定否则寄存器写不进去。上拉输入模式不需要额外设置上下拉电阻值内部上拉大概在 30~50kΩ 范围对按键这种低速信号完全够用。推挽输出高电平的驱动能力在 F103 上是约 20mA不需要串电阻但如果你用的是 3.3V 供电的 OLED 或 LCD要注意总电流别超过板载 LDO 的承受范围。2.3 扫描函数的核心实现逐行拉低、逐列读取、消抖归一先给一个最朴素的扫描函数它负责把按下的键编号返回没按键时返回一个特殊值uint8_t MatrixKey_Scan(void) { uint8_t row, col, key_value 0xFF; // 行线数组与列线读引脚对应 uint16_t row_pins[4] {GPIO_Pin_0, GPIO_Pin_1, GPIO_Pin_2, GPIO_Pin_3}; uint16_t col_pins[4] {GPIO_Pin_0, GPIO_Pin_1, GPIO_Pin_2, GPIO_Pin_3}; for (row 0; row 4; row) { // 先将全部行线输出高再把当前行拉低 GPIO_SetBits(GPIOB, row_pins[0] | row_pins[1] | row_pins[2] | row_pins[3]); GPIO_ResetBits(GPIOB, row_pins[row]); // 小延时等待电平稳定经验值 2~5us 足够 Delay_us(5); for (col 0; col 4; col) { if (GPIO_ReadInputDataBit(GPIOC, col_pins[col]) 0) { // 计算键值 行 * 4 列 key_value row * 4 col; // 注意这里不应该立即返回稍后说明 break; } } if (key_value ! 0xFF) break; } return key_value; }这个函数有四个关键点。第一每次扫描一行时要先把所有行恢复成高电平再拉低目标行否则上一行遗留的低电平会影响本次读取。第二2~5 微秒的延时是经验值太短电平还没稳定太长会影响主循环响应实际中我用 5us 居多。第三检测到某列为低只代表“这一行这一列存在低电平路径”不一定是目标按键也可能是鬼键所以更严谨的做法是检测到值后延时再读一次确认。第四函数返回的键值从 0 到 15后续需要映射到计算器的具体符号。3. 用 F103C8T6 搭出最小硬件系统接线图、供电逻辑和显示设备选型3.1 核心板选型与最小系统接线哪些引脚能碰、哪些引脚要躲F103C8T6 最常见的是蓝色 pills 核心板板载 8MHz 晶振、USB 转串口、3.3V LDO、复位电路基本是拿来即用。矩阵键盘接线时我习惯把行线接 PB0~PB3列线接 PC0~PC3这两个端口默认不是 JTAG/SWD 引脚不用担心调试器冲突。如果你非要用 PA13~PA15 或者 PB3/PB4 这种带复用功能的引脚就要小心了这些引脚默认被调试接口占用需要在代码里先禁用 JTAG 才能当普通 GPIO 用新手在这里翻车概率极高。供电上核心板通过 USB 取 5V板载 LDO 转 3.3V 给芯片。矩阵键盘不需要额外供电直接连接 GPIO 即可。但如果你选的 OLED 是 SSD1306 款它同样从核心板的 3.3V 取电整个系统功率不超过 100mA板载 LDO 毫无压力。要注意的是别让键盘线跨过晶振和天线区域STM32F103 虽然抗干扰能力不弱但按键线束过长、走线靠近晶振容易在按下瞬间引入毛刺导致误触发这个我后面专门讲。3.2 显示方案数码管还是 OLED计算器的状态怎么展示矩阵键盘计算器最经典的显示搭档是 4 位数码管用 TM1650 或直接段选位选扫描驱动成本更低但显示不了符号只能显示数字和少数字母。我建议第一次做直接用 0.96 寸 OLEDSSD1306I2C 接口好处是能显示完整的表达式比如“1234”这种体验完全不一样。I2C OLED 接线只要 SCL、SDA、VCC、GND 四根线SCL 接 PB6、SDA 接 PB7这是 F103C8T6 硬件 I2C1 的默认引脚或者用软件模拟 I2C 随便接两个 GPIO 都行。如果你手头只有数码管模块也不影响做这个项目只是计算器的逻辑显示部分要自己设计“当前输入值”和“历史结果”的切换显示。我的经验是先把整个计算器逻辑在串口上调试通再加显示部分这样排查问题时会清爽很多。串口用 PA9TX、PA10RX配合板载 USB 转串口直接 printf 打印键值和运算状态这是嵌入式调试的基本功比单步仿真效率高得多。3.3 接线清单和通电自检流程没有逻辑分析仪也能定位接触问题给你一份我常用的接线清单。矩阵键盘模块的 8 根排针与 F103C8T6 的引脚的对应关系没有统一标准不同厂家模块丝印可能不同所以拿到键盘的第一件事是用万用表蜂鸣档测绘出每个按键对应哪一行哪一列不要相信丝印。我的经验是找公共端4x4 键盘通常有 4 根行线和 4 根列线用万用表一支笔固定在一个针脚另一支笔扫其他针脚按下不同按键看通断很快就能画出映射表。通电后的自检步骤我会按这个顺序走。第一步用跑马灯程序确认所有 GPIO 引脚都能正常输出翻转排除板子本身问题。第二步把 4 根行线设为推挽输出轮流拉低用万用表测列线电平变化确认键盘模块的电气连通性正常。第三步烧录扫描函数串口打印原始键值。这三个步骤走完硬件部分基本可以确认没问题剩下的都是代码层面的逻辑和时序问题。4. 计算器核心逻辑状态机设计、连按与消抖、表达式求值思路4.1 两层状态机按键状态机与计算状态机的职责划分矩阵键盘计算器最容易写乱的地方是把“按键识别”和“计算逻辑”混在一起写。我建议分两层底层是按键状态机负责把物理按键转换成“单击、长按、连击”等逻辑事件上层是计算状态机负责把逻辑按键按顺序组合成表达式并计算结果。底层按键状态机的核心是检测“按下沿”而不是“按下电平”。所谓“按下沿”是指从没按下到按下的那一次跳变避免同一个按键被按住不放而重复触发。实现上用一个变量保存上一次扫描结果当前扫描结果与上一次进行对比uint8_t last_key 0xFF; uint8_t current_key; uint8_t Key_GetEvent(void) { current_key MatrixKey_Scan(); // 消抖延时后再读一次两次一致才认为是有效按下 if (current_key ! 0xFF) { Delay_ms(10); if (MatrixKey_Scan() ! current_key) { return 0xFF; // 第一次读到的是抖动丢弃 } } // 有按下事件上一次没有按键本次有按键 if (current_key ! 0xFF last_key 0xFF) { last_key current_key; return current_key; } // 无变化要么一直没按要么一直按住都不触发事件 last_key current_key; return 0xFF; }这个函数的关键在于按键释放时返回 0xFF按下时返回具体的键值而且只在按下的那一刻返回一次。消抖延时选 10ms这是经典的按键消抖经验值太短滤不掉抖动太长会吃掉快速连按的响应。注意这里的消抖方式不是简单的“延时后确认”而是在确认确实有键按下之后才进入等待释放的分支避免按住不放导致重复触发计算器这种场景下这个逻辑尤其重要。4.2 计算状态机的四种基础状态与输入规则计算器逻辑我不推荐直接解析表达式因为 4x4 键盘只有 16 个键输入不了括号也无法区分负号和减号所以用状态机反而是最稳的。四个基础状态待输入、输入第一个操作数、输入第二个操作数、显示结果。用户输入一串数字然后按一个运算符再输入一串数字最后按等号程序按照这个流程转折。核心规则有两条第一按数字键时把当前操作数追加在已有数字后面但要排除前导零和超出显示长度的情况第二按运算符时如果第一个操作数还没输入完直接记录运算符如果第一个操作数已经输入完且已经有运算符那就先计算中间结果再作为新的第一个操作数这就是连续运算的实现基础。typedef struct { int32_t operand1; int32_t operand2; uint8_t op; // 运算符编码0~3 代表 - x / uint8_t state; // 状态机当前状态 uint8_t input_buf[8]; // 当前输入的数字缓冲区 uint8_t input_len; int32_t result; } Calculator_t;状态机的状态转移应该遵循“操作数未就绪时运算符无效等号未按下时计算结果不覆盖第一个操作数”这两条约束。实际代码里我遇到最多的问题是用户连续按运算符比如输入“1234-56”这时候要决定是忽略第二个运算符还是替换第一个我的选择是替换更符合普通计算器的行为。4.3 等号处理和除法零边界这些分支最容易被人忽略等号处理是整个计算器的重头戏。输入“1234”此时第一个操作数是 12运算符是 第二个操作数是 34程序取出三者计算结果 46。但如果用户按了“1234”之后又按数字“5”新输入的数字应该开启全新的表达式而不是追加到结果 46 后面变成 465。实现这个逻辑需要有一个“结果已显示”标志位等号计算完成后下一次输入数字键要先清空状态机再输入新数字下一次输入运算符则直接把结果作为新的第一个操作数继续运算。除法之中除数为零的处理更是硬需求。我的方案是让状态机进入一个错误态显示屏上输出错误信息用户必须按清零键才能恢复不能直接继续输入。如果不做这个处理程序在除以零时会得到无意义的整数溢出值比如 2147483647 这种东西用户完全看不懂是怎么来的。还有多位数溢出的问题int32_t 最大能表示约 21 亿计算器项目的输入缓冲区长度必须限制我限制为 7 个数字位超过后按键直接忽略这样最坏情况运算结果也不会溢出。5. 矩阵键盘避坑指南从“按下没反应”到“一按出两个字符”的 5 个高频事故5.1 坑一按下按键没反应万用表一量却通着现象是最常见的代码烧进去串口没有任何输出按下按键就像没按一样。用万用表量按键两端却发现确实是导通的。原因是 GPIO 的模式配置错了。最常见的是行列线都配成了推挽输出两个推挽输出引脚之间靠按键短接时读不到有效的电平差。另一种可能是行线虽然配了输出但初始电平不是高导致“拉低某一行”这个动作没有实际意义。解决方法是先检查初始化代码里的模式行线推挽输出列线上拉输入。然后在扫描函数开头加一个整行全部拉低的自检逻辑如果按下任何按键都没有反应用万用表量行线输出电压应该在 0V 和 3.3V 之间翻转量列线电压未按下时应该接近 3.3V。这两个量完基本就能定位到是初始化还是接线的问题。5.2 坑二每按一次按键串口会打印两个或三个键值现象是只按了一下“1”串口却打印了“1”和“11”或者“11”。按一次触发多次这是消抖逻辑没生效的标志。原因有好几层最常见的是消抖延时放的位置不对。有些人把延时放在“检测到按下”之前每次扫描都要等 10ms结果主循环周期被拖长用户一次短暂的按键恰好被扫描到两次。另外如果消抖逻辑是“读出低电平立即返回键值”然后在主循环里调用扫描函数只要用户手指按下时间稍长主循环每隔几毫秒就会再次读到低电平自然就重复了。解决办法是严格使用“按下沿”触发也就是上一章那段代码的思路只有从无按键状态跳变为有按键状态的那一次才返回值后续持续按住的扫描都返回 0xFF。消抖延时放的位置必须是在确认新按键出现之后、再次读取确认之前不能在扫描函数开头无脑延时。5.3 坑三按“1”显示“1”再按“2”变成“12”但按“”后数字突然跳变现象是输入数字阶段一切正常按运算符后再输入数字显示器上出现了奇怪的数字比如输入“91”按下“”之后显示的不是 9 而是 90或者输入到一半的数字被清空但状态没有切换对。原因是计算状态机的状态迁移没处理好。输入第一个操作数时每次追加数字都在原缓冲区后拼接但按下运算符后缓冲区要被“定格”为 operand1然后清空用于输入 operand2。如果代码里忘记在运算符按键的处理分支中保存 operand1 并清空缓冲区就会出现按下运算符后继续追加的尴尬情况——缓冲区里可能是“9”运算符按下后应该把它存到 operand1然后 input_len 置 0。检查时重点看运算符分支有没有做这两个动作。另一个隐蔽原因是按运算符的时候不小心把运算符也追加进了数字缓冲区。比如按下“”键对应的按键映射返回的字符是 , 但键盘扫描函数返回的原始键值 0~15映射表如果写错把 1 号键映射成字符 且没有从数字缓冲区分流处理就会出现上面说的数字里混进运算符的怪相。解决方案是把数字键和功能键的映射分开两种键走不同的处理分支不要用一个 switch 贯穿所有按键。5.4 坑四连接 OLED 后按键扫描突然变得不规律现象是单独测试键盘一切正常单独测试 OLED 显示也正常两个一接上按键就时不时丢一个或者重复触发一次。原因是 I2C 总线上的上拉电阻和按键扫描共用了一个 GPIO 端口的部分引脚产生了一定干扰但更本质的问题是中断优先级和主循环时序。SSD1306 的 I2C 通信如果开了中断中断频繁打断主循环里的键盘扫描延时导致消抖延时被拉长或缩短抖动滤不干净。另外一个常见原因是 OLED 的电源引脚与矩阵键盘的某根行线在面包板上离得太近OLED 刷新瞬间电流变化导致行线电平毛刺。解决办法有三步。第一把 I2C 通信改成阻塞式而不是中断式OLED 刷新帧率不用太高计算器场景每秒刷 10 次足够。第二OLED 的 VCC 和 GND 用单独的飞线从核心板引出不要和键盘线共用面包板的同一排电源轨。第三把键盘扫描的消抖延时从 Delay_ms(10) 改成读取系统 tick 的方式防止中断把延时打断导致实际延时不准。5.5 坑五按下“等号”后计算结果只有第一次是对的后续运算全部错误现象是输入“23”显示 5没问题再输入“56”显示的不是 11 而是 56 或者其他奇怪数字。原因是“结果已显示”的标志位没有重置。等号计算出结果后状态机进入“显示结果”状态此时 operator 和两个操作数都保留了上一次的值。如果你按下数字 5没有先清空整个状态机那 5 会追加到结果 5 的缓冲区变成“55”然后按 “” 时又拿错误的 operand1 做运算一路错下去。解决方式是在等号计算出结果后只保留这一个结果值作为 operand1同时把 operator 清空state 切换到“等待输入新运算符或新数字”——如果来的是数字先清空所有状态如果来的是运算符直接复用结果作为 operand1。这是最多人踩的状态机逻辑坑我调这个项目时花了整整一个小时排查才发现是标志位顺序的问题代码里一步顺序之差行为差之千里。6. 从能跑到好看加长按连发、优化显示布局做一台有“手感”的计算器矩阵键盘计算器做完基本功能后我建议你做三个升级每一个都能让你的项目从作业级别进阶到作品级别而且技术要求都不高非常适合用来熟练 STM32 的定时器、中断和状态机。第一个升级是给数字键加“长按连发”功能。初始你可以做一个简单的实验用定时器中断记录按键持续按下的时间超过 800ms 之后每 120ms 自动触发一次该键现实中输入“10000000”这样的长串数字就不用一下下按。实现方法是复用之前的“按下沿”检测逻辑在事件返回时记录按键编号到全局变量然后在主循环或定时器回调中检测这个键位是否仍处于按住状态。注意连发期间不要再走消抖延时否则手感会肉。第二个升级是优化 OLED 显示布局。整个计算器可以分成三行第一行显示输入过程中的表达式比如“123456”第二行大字号显示当前输入的数字或计算结果第三行显示状态提示比如“等待输入”、“计算完毕”、“错误”。SSD1306 驱动里可以预置两套字体一个 6x8 一个 16x32前者显示表达式后者显示当前值。这需要一个屏幕缓冲区数组总共 1024 字节F103C8T6 的 20KB SRAM 完全装得下。布局做好了这台计算器在视觉上会非常像一个真实产品。第三个升级是增加按键盘的“手感反馈”。最简单的方案是用板载 LED 作为按键指示按下瞬间点亮 50ms松手熄灭每次有效运算完成时做一个双闪。不需要额外硬件但能让你在演示时明显感受到系统在响应按键尤其对面试或课上展示帮助很大。更进阶的方案是加一个蜂鸣器在按键事件中翻转 GPIO 输出不同频率的方波确认音和市场廉价计算器的“滴”声不同你自己调一版就知道什么叫交互细节。调试到这一步我自己的习惯是用串口打印所有事件记录“按键按下键值、消抖结果、状态机转移、运算结果”这四个信息格式做成一条流水日志。比如按下 1、按下 、按下 2、按下 串口输出应该是[EVT] key1 (digit) [EVT] key12 (op: ) [EVT] key2 (digit) [EVT] key15 (eq) [LOG] operand11 op operand22 result3这样可以 30 秒内定位任何一次计算错误比用调试器打断点快得多。把这套习惯保留下来以后做遥控器、门禁键盘、密码锁全都是同一个套路。我回头看这个项目最大的收获不是学会了矩阵键盘而是养成了“先分模块、再加状态机、最后串口验证”的调试思路这一套思路在后来做任何带按键的嵌入式设备时都在帮大忙。希望这篇笔记能帮你少走我走过的弯路一天内把属于你自己的矩阵键盘计算器跑起来。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。