☰
STM32F103C8T6矩阵键盘计算器:从硬件接线到代码实现全解析
2026/10/15 11:48:06 网站建设 项目流程

简介:STM32F103C8T6矩阵键盘计算器是一份面向嵌入式初学者的完整工程源码包,基于ARM Cortex-M3内核MCU实现矩阵键盘输入、运算逻辑处理与OLED实时显示,适合学习GPIO控制、行列扫描及外设驱动编程的开发者。压缩包共包含192个文件,其中33个c源文件、35个h头文件与34个o编译目标文件构成核心工程代码,配合uvprojx工程配置、axf烧录文件、map映射文件等,可直接在Keil下打开编译烧录验证,整体仅7.36MB,并附htm文档说明与bat清理脚本。已有640人学习下载。通过这份源码可掌握矩阵键盘行列扫描与消抖、按键事件处理、定时器中断、OLED驱动及计算器运算核心,同时理解除零与溢出等错误处理的工程化写法,是入门STM32综合外设开发的优质参考项目。

1. 基于stm32f103c8t6的矩阵键盘计算器:一块蓝板子能做什么

如果你手里正好有一块STM32F103C8T6最小系统板,想做一个不靠手机、不靠电脑、通电就能用的计算器,那这个标题对应的东西就是最经典的练手项目。它把GPIO输入、按键消抖、行列扫描、状态机和数码管/OLED显示串在一条完整链路上,做完之后你对“单片机怎么和人交互”这件事会有非常具体的体感,而不是停留在点灯阶段。

我第一次做这个项目的时候,最惊讶的是4x4矩阵键盘只需要8个IO口就能识别16个按键,而独立按键方案要16个IO口——对C8T6这种48脚封装的芯片来说,IO资源省下来意味着你可以同时挂OLED、蜂鸣器和串口调试。这篇文章写的是按照标题完整复现一个计算器的过程,包含硬件连接、扫描算法、消抖逻辑、运算解析,以及调试过程中最容易翻车的几个细节。新手可以直接照着搭,熟手可以重点看第三部分的解析思路和第四部分的避坑清单。

2. 硬件选型与矩阵键盘的接法:为什么偏偏是C8T6和4x4

2.1 选型逻辑:C8T6的资源和矩阵键盘的匹配度

STM32F103C8T6属于F1系列中的小容量型号,72MHz主频、20KB RAM、64KB Flash,片上资源做计算器绰绰有余。很多人纠结过要不要上F407或者ESP32,实际上C8T6的优势在于:一是价格低,几块钱一片,烧了不心疼;二是3.3V逻辑电平直接兼容绝大多数OLED模块和矩阵键盘,不需要电平转换;三是ST的标准外设库和HAL库资料极多,遇到问题随便搜就有答案。

矩阵键盘这边,4x4是最常用的规格,16个按键刚好覆盖数字0-9、加减乘除、等号和清除键,甚至还能多出一个键做退格。它比独立按键省IO的本质是:把16个按键排列成4行4列的网格,每个按键相当于连接某一行和某一列的交叉点。MCU只需要8个IO口,4个做行输出,4个做列输入(或者反过来),就能通过扫描确定哪个按键被按下。

2.2 连接方式与内部上拉的选择

常见的接法是把矩阵键盘的8个引脚分成两组:R1-R4接STM32的PA0-PA3,C1-C4接PA4-PA7。这里有一个很重要但容易被忽略的细节:列引脚必须配置成上拉输入模式,行引脚配置成推挽输出模式。

为什么不能都用开漏输出?因为扫描的本质是“行给低电平,列读电平,读到低说明该行该列的按键被按下”。如果列引脚没有上拉,按键没有按下时列引脚是悬空的,电平随机,读出来的结果完全不可靠。我一般会使用STM32内部的Pull-Up电阻,省掉外部4颗10K电阻;如果你用的是外部上拉,注意电阻值别太小,否则按键按下时电流会偏大。

初始化代码示意如下:

// 行引脚:PA0-PA3,推挽输出 GPIO_InitTypeDef GPIO_InitStructure = {0}; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); 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(GPIOA, &GPIO_InitStructure); // 列引脚:PA4-PA7,上拉输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure);

这段代码的逻辑是:先把PA0-PA3设为推挽输出,用于逐行拉低;再把PA4-PA7设为上拉输入,用于读取每列的电平状态。参数方面,GPIO_Speed_50MHz是GPIO翻转速度的上限配置,矩阵键盘扫描这种低频场景用2MHz也够,但统一设成50MHz不会出问题。GPIO_Mode_IPU是内部上拉输入模式,这是扫描正确性的关键。

2.3 显示模块的选择与接线

计算器没有显示就没有意义。最省事的方案是0.96寸I2C接口的SSD1306 OLED,4根线搞定:VCC接3.3V,GND接GND,SCL接PB6,SDA接PB7。C8T6的I2C1硬件引脚就是PB6/PB7,用硬件I2C还是软件模拟I2C都可以,但我的经验是:SSD1306这种模块用软件模拟I2C更稳,因为硬件I2C在某些C8T6板子上有坑,具体在第四部分细说。

如果你手头是数码管或者LCD1602,也能做,只是代码量和接线复杂度会上升。1602需要至少6个IO口做并口通信,而且5V逻辑电平需要确认C8T6引脚是否容忍5V输入——PA/PB/PB系列引脚标称是FT(5V容忍)的可以用,但最好还是加限流电阻或者用3.3V版本模块。

3. 按键扫描与计算逻辑:从按下到算出结果的完整路径

3.1 行列扫描算法:行扫描法为什么比反转法更省事

矩阵键盘最常用的算法有两种:行扫描法和行列反转法。行扫描法的思路是:先把第1行拉低,其他行保持高电平,然后读4个列引脚,如果某一列读到低,说明第1行那一列的按键被按下;然后第2行拉低,再读列……如此循环。4行全部扫一遍,就能知道16个键里哪一个被按下。

行列反转法需要先把行设输出、列设输入,读完一轮之后交换方向再读一轮,逻辑上行得通,但代码里要反复切换GPIO模式,调试的时候容易乱。对于计算器这种低速人机交互场景,行扫描法足够快——4行逐行拉低,加上消抖时间,一轮扫描也就几毫秒,而人手按键的物理过程最快也要几十毫秒,完全不会丢键。

扫描函数的核心代码如下:

uint8_t MatrixKey_Scan(void) { uint8_t row, col; uint8_t key_value = 0; for (row = 0; row < 4; row++) { // 先把所有行输出高 GPIO_Write(GPIOA, 0x0FFF); // PA0-PA7全部高电平 // 再把当前行拉低 GPIO_ResetBits(GPIOA, GPIO_Pin_0 << row); for (col = 0; col < 4; col++) { // 读取列引脚状态 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_4 << col) == Bit_RESET) { // 等待按键释放,顺便做消抖 while (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_4 << col) == Bit_RESET) ; key_value = row * 4 + col + 1; // 1~16 return key_value; } } } return 0; // 没有按键按下 }

这段代码有几个关键逻辑。GPIO_Write(GPIOA, 0x0FFF)把PA0-PA7全部拉高,然后GPIO_ResetBits把当前行拉低。为什么要先全部拉高?因为上一个循环结束时,前一行是低电平,如果不复位,下一行扫描时可能有两行同时为低,按键识别就会错乱。返回值的计算方式row * 4 + col + 1把行号和列号映射成1到16的数字,后续查表就能得到对应的按键字符。

注意这里用了一个while等待按键释放的写法,这是最简单的消抖思路:检测到按键按下后,程序死等在这个while循环里,直到按键松开才返回。这样做的好处是代码极短,而且天然消除了机械按键的抖动——按下瞬间的抖动会在while循环中被吃掉;坏处是阻塞CPU,如果主循环里还有其他任务要处理,就必须改成状态机或者定时器扫描的方式。这个项目里计算器本身没有并发任务,所以阻塞式完全可接受。

3.2 按键映射表与计算器状态机设计

扫描得到1到16的数字之后,需要一张映射表把这16个数字翻译成计算器的按键含义。我常用的布局是:

扫描返回值按键含义扫描返回值按键含义
1793
2810+
3911-
4÷12×
54130
6514=
7615C(清除)
8退格16.(小数点)

映射表本身就是一个const数组,在C语言里用switch-case或者查表都行。但真正需要动脑子的地方是计算器的状态机。一个最简单的计算器逻辑需要处理这些输入:数字键连续输入组成多位数,运算符键保存当前值和运算符然后清空输入缓冲区,等号键做运算并显示结果,清除键复位所有状态。

我把状态拆成三个:输入第一个操作数、输入第二个操作数、显示结果。状态转移图可以这样理解:

  • 初始状态:缓冲区空,等待第一个操作数
  • 输入数字 → 追加到缓冲区 → 仍然在“输入第一个操作数”状态
  • 输入运算符 → 保存当前值为A,保存运算符 → 切换到“输入第二个操作数”状态
  • 输入数字 → 追加到缓冲区 → 仍然在“输入第二个操作数”状态
  • 输入等号 → 计算A op B → 显示结果 → 切换到“显示结果”状态
  • 在“显示结果”状态下按数字键 → 清空缓冲区,开始输入新的操作数

C语言实现的核心部分:

typedef struct { float value_A; float value_B; float result; char op; char input_buf[16]; uint8_t input_len; uint8_t state; // 0:输入A, 1:输入B, 2:显示结果 } Calculator_t; void Calc_ProcessKey(Calculator_t *calc, char key_char) { if (key_char >= '0' && key_char <= '9') { // 显示结果状态下手动清除,重新开始输入 if (calc->state == 2) { calc->input_len = 0; calc->state = 0; } if (calc->input_len < 15) { calc->input_buf[calc->input_len++] = key_char; calc->input_buf[calc->input_len] = '\0'; } } else if (key_char == '*' || key_char == '+' || key_char == '-' || key_char == '/') { if (calc->state == 0) { calc->value_A = atof(calc->input_buf); calc->op = key_char; calc->state = 1; calc->input_len = 0; calc->input_buf[0] = '\0'; } } else if (key_char == '=') { if (calc->state == 1) { calc->value_B = atof(calc->input_buf); switch (calc->op) { case '+': calc->result = calc->value_A + calc->value_B; break; case '-': calc->result = calc->value_A - calc->value_B; break; case '*': calc->result = calc->value_A * calc->value_B; break; case '/': calc->result = (calc->value_B == 0) ? 0 : calc->value_A / calc->value_B; break; } sprintf(calc->input_buf, "%.2f", calc->result); calc->input_len = strlen(calc->input_buf); calc->state = 2; } } else if (key_char == 'C') { calc->value_A = 0; calc->value_B = 0; calc->op = 0; calc->state = 0; calc->input_len = 0; calc->input_buf[0] = '\0'; } }

这段代码的关键点是:用了一个输入缓冲区字符数组,数字键不断追加,一旦按下运算符就把缓冲区内容用atof转成float保存。除法的除零判断直接返回0,这是最简单的处理方式,更完善的做法是在屏上显示“Error”。结果显示用sprintf格式化到两位小数,避免浮点精度问题在屏幕上显示很长一串。input_len上限15是为了配合16字节缓冲区,留一个字节给字符串结束符'\0'。

3.3 主循环整合:扫描、解析、显示三者怎么配合

把上面的模块串起来的main函数并不复杂:

int main(void) { uint8_t key_code; char key_char; char display_str[16]; OLED_Init(); OLED_Clear(); Calc_Init(&calc); while (1) { key_code = MatrixKey_Scan(); if (key_code != 0) { key_char = KeyMap_GetChar(key_code); Calc_ProcessKey(&calc, key_char); OLED_ShowString(0, 2, calc.input_buf); } } }

主循环的逻辑非常直观:不断扫描键盘,有键按下就查表得到字符,送入状态机处理,然后把最新的输入缓冲区显示到OLED上。注意OLED_ShowString每次刷新整个字符串,如果缓冲区变短了,没有显示的部分会残留,所以更好的做法是先清除一整行再显示,或者用空格填充到固定长度。

这里有个细节:MatrixKey_Scan函数内部自带“等按键释放”的while,所以主循环不会对同一个按键多次触发——这个设计在阻塞式扫描里是加分项,代价是按键按得越久,主循环卡在while里的时间越长。如果你在调试时发现OLED闪烁,多半是这个原因。

4. 避坑指南:矩阵键盘计算器最常见的6个翻车点

4.1 按键识别错乱:行和列接反了,或者上拉没生效

现象:按下某个键,显示的是另一个键的字符;或者同一行按键都触发同一列的回读值。

原因:最常见的是物理接线把行列搞反。比如你心里想着PA0-PA3做行,实际焊的时候把PA0接到了列引脚上。另一个常见原因是GPIO_Mode_IPU配置没生效——有些人用了GPIO_Mode_IN_FLOATING(浮空输入),按键没按下时列电平随机,扫描结果完全不可靠。

解决:先用万用表确认8根线的通断和对应关系,再确认代码里列引脚配置的是GPIO_Mode_IPU。如果是浮空输入,改成上拉输入立刻就能解决。调试时先把扫描函数返回值直接显示在OLED上,不经过计算逻辑,确认每一格按键的数字都唯一后再往下接。

4.2 按键按下没反应:扫描周期和消抖时间的配合问题

现象:快速按键时偶尔丢键,按下十次有两次识别不到。

原因:阻塞式消抖把CPU卡在while里还没出来,下一次按键就已经按下了;或者你在扫描函数里加了delay延时,总扫描周期超过了按键的物理闭合时间窗口。

解决:把消抖方式从“等待释放”改成“延时判断”——检测到按键按下后延时10ms再读一次,确认还是低电平才判定有效,然后不等释放直接返回。这样主循环可以尽快回到扫描起点,多检几次按键。延时可以用SysTick做非阻塞计时,也可以用简单的Delay函数,计算器场景对实时性要求不高,阻塞延时也够用。注意消抖时间10ms是针对机械按键的典型值,5ms可能不够,20ms则可能让快速连按变得迟钝。

4.3 OLED显示乱码或不显示:I2C硬件外设和软件模拟的选择

现象:用硬件I2C初始化SSD1306,上电后屏幕不亮或者显示随机色块,代码逻辑看了好几遍都没问题。

原因:STM32F103C8T6的硬件I2C存在一些已知的异常状态处理问题——总线忙标志位卡死、Fault标志被误置。这个问题在网络上被讨论过很多次,根因和芯片的I2C外设状态机有关,软件上需要额外处理错误标志,而大多数OLED例程默认用的是软件模拟I2C。

解决:这个项目里直接用软件模拟I2C是最稳的姿势。GPIO配置成开漏输出加外部上拉(或者内部上拉),然后代码里用延时翻转时钟和数据线。SSD1306的时序要求并不苛刻,几百kHz的软件模拟完全够用。如果你坚持用硬件I2C,记得每次通信前检查I2C_GetFlagStatus的Busy标志,必要时调用I2C_GenerateSTOP强制复位——但这属于给自己加戏。

4.4 运算结果不对:浮点数精度和显示的坑

现象:计算5÷3显示1.67没问题,但计算0.1+0.2显示0.30,而不是0.3;或者连乘之后结果末尾多出诡异的误差。

原因:二进制浮点数无法精确表示0.1这样的十进制小数,误差在做多次运算后会累积。计算器这种低精度场景,问题主要出在sprintf格式化上——如果你用%.2f,0.30这种输出是格式化的产物,不是计算错误。

解决:显示统一用%.2f格式化,先接受“显示精度到两位小数”这个设定。如果实在介意,可以用整数运算代替浮点——把输入的数字先乘以100存成整数,显示时手动加小数点。代价是除法运算需要自己实现定点化逻辑,代码会变长,但结果完全精确。我的建议:这个项目阶段用浮点即可,重点在练通链路。

4.5 按一次键跳两个字符:扫描函数的return位置有问题

现象:按一下数字“5”,屏幕上跳出“55”,或者跳到“5+”。

原因:扫描函数在一次按键期间被调用了多次。通常是消抖逻辑只在按下沿做了判断,但主循环调用频率太高,按键还没释放就又调了一次MatrixKey_Scan。而阻塞式的while等待释放方案通常不会出现这个问题,出现这个现象大概率是你改成了非阻塞消抖但没有记录“上次按键状态”。

解决:用一个静态变量保存按键状态。检测到按下且上一次状态是释放,才认为这是一次有效按压;如果一直处于按下状态,直接返回0。代码如下:

uint8_t MatrixKey_ScanNonBlock(void) { static uint8_t last_state = 0; uint8_t key_code = 0; uint8_t current_state = 0; // 扫描代码... 略去重复部分 // 假设检测到第5个键按下,key_code = 5 if (key_code != 0) current_state = 1; if (current_state && !last_state) { last_state = current_state; return key_code; } last_state = current_state; return 0; }

这段代码的要点是:current_state代表“这一轮扫描中有没有按键按下”,last_state记录上一轮有没有按键按下。只有从0变1的上升沿才返回键值。变量声明为static,保证每次调用时状态得以保留。

4.6 程序下载后不跑:BOOT0跳线和复位电容的玄学

现象:代码编译下载成功,但板子没反应,OLED不亮,LED不闪。

原因:C8T6最小系统板上的BOOT0跳线帽如果被插到1的位置,芯片每次上电都进入BootLoader而不是运行Flash里的程序,程序当然不跑。另外有些劣质板子复位电容偏大,上电时复位时间过长,看起来像“没反应”。

解决:检查BOOT0跳线帽是否在0的位置(通常标注为0或GND)。复位问题可以手动按一下复位键,如果按下后程序开始运行,说明是上电复位电路的问题,换一个复位电容(常见的0.1uF换成1uF试试)或者干脆用按键手动复位。这个问题看起来低级,但它确实是我见过最多的“假故障”。

5. 进阶玩法:从计算器到通用键盘输入系统的三个技巧

5.1 用状态机替代阻塞扫描,把主循环让给显示刷新

前面提到了阻塞式扫描的局限:扫描函数里的while等待释放会卡住CPU,OLED刷新和按键响应之间互相影响。改成状态机之后,扫描、消抖、等待释放三个阶段分布在多次主循环调用中完成,每次函数调用只做一小步,不阻塞任何环节。核心思路是定义一个枚举状态:SCAN_IDLE、SCAN_DEBOUNCE、SCAN_WAIT_RELEASE,主循环每次调用都推进状态机一次。

这个改造的价值不在于计算器本身,而在于你学会了“非阻塞”这个嵌入式开发的核心习惯。之后再做菜单系统、多级界面、带超时机制的输入框,都是同一套路数。

5.2 把键盘扫描从4x4扩展到8x8,IO不够时的矩阵复用方案

如果你后面想做带字母输入的键盘、密码锁或者游戏手柄,16个键就不够了。8x8矩阵需要16个IO口,C8T6完全扛得住——PA全部16个引脚刚好够,但如果你还要接OLED、蜂鸣器、串口,IO就会紧张。常见做法是用两个74HC165移位寄存器把8行并转串接进来,MCU只占用2个IO(时钟和数据)就能读取8x8矩阵的全部状态。扫描逻辑从“逐行拉低读列”变成“逐行拉低然后把列状态并转串读回”,硬件变复杂了,但代码逻辑几乎没变。

5.3 增加按键声音反馈和长按连发功能

计算器加上蜂鸣器反馈,体验感会提升一大截。按键按下判定有效时,蜂鸣器鸣叫10ms;如果支持长按连发,需要在状态机里增加一个计时分支,按键持续按下超过500ms后,每隔100ms自动重复触发一次。这个功能在计算器里没什么用,但如果你用同一套板子做秒表或者菜单滚动,长按连发就是刚需。实现方式是在按下状态里记录“已经按住多久了”,追加计数变量,饱和到最大值,每隔固定阈值就返回一次同样键值。

做这个项目最终给我的收获是:一次完整的按键处理,远不止“读到电平变化”那么简单。它涉及机械抖动的物理特性、GPIO模式与外设上拉的配合、状态机的设计思路、阻塞与非阻塞的取舍,甚至还有浮点数精度这种程序员通用问题。把这些边界都摸清楚之后,回头再看任何带键盘的产品——收银机、密码锁、微波炉控制面板——你都能一眼分辨出它们用的是哪种扫描方案。

希望这篇文章能帮你把板子上的计算器跑起来。你先跑通功能,再试着按第5章的路子重构一遍,那种“从能用到好用再到清爽”的迭代手感,才是做单片机最值钱的经验。

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

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

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

立即咨询