STM32F103RCTx计算器实战:从矩阵键盘到状态机完整实现
2026/9/13 19:03:14 网站建设 项目流程

简介:这是一份基于意法半导体STM32F103RCT6微控制器实现的简易计算器完整工程资源,适合嵌入式初学者、参加电子设计竞赛的学生以及希望借助具体项目掌握ARM Cortex-M3开发流程的工程师。该设计来自作者所在学校电子设计竞赛一等奖项目,开发环境为Keil uVision,内容涵盖GPIO控制、中断处理、LCD显示、按键扫描、HAL库/LL库驱动、硬件接口设计等嵌入式开发要点。压缩包共998个文件,总大小27.86MB,文件类型以C源码、头文件为主,辅以汇编启动文件、链接脚本、库文件、Keil工程配置以及编译生成的中间文件和可烧录文件,包含从源代码到固件的完整工程链。当前已有882人学习下载。借助该工程,读者可以系统梳理外设初始化、输入检测与计算逻辑的组织方式,参考获奖项目的代码风格、调试流程和整体设计思路,快速复现或扩展自己的计算器应用。

1. 两毛钱的主控,凭什么能把计算器做出花来

把“基于STM32F103RCTx设计的简易计算器”这行字拆开看,真正值得琢磨的不是“计算器”,而是那颗F103RCTx。很多人第一反应是:一个四则运算的计算器,用51或者8毛钱的STM32C8T6不就行了?但RCTx这颗料在F1系列里恰好是一个“甜点位”——52KB RAM、256KB Flash、37个GPIO、5个USART,还有一个我能拿来当扫描节拍器的基本定时器。做计算器意味着你要同时处理矩阵键盘扫描、LCD显示刷新、表达式解析和按键消抖,这些任务在时间片上是要打架的。F103RCTx的72MHz主频和充裕的引脚,让这套系统可以用最朴素的状态机实现,而不必迁就资源去搞什么花式调度。

这篇文章会按“硬件引线怎么接 → 程序状态机怎么转 → 表达式怎么算 → 乱码和误触怎么查 → 还能怎么改”的顺序走完一整套落地路径。目标很简单:照着接,照着编译,下载进芯片,按键能算、显示不乱跳。适合刚把手从Arduino挪到标准外设库的开发者,也适合想把裸机状态机的边界摸清楚的人——别以为计算器简单,它其实是“输入-处理-输出”闭环最典型的教学具象。

2. 硬件引线还能这么分:矩阵键盘与LCD1602的引脚分配逻辑

2.1 选型先算账:为什么RCTx在引脚上能“宽裕”到随意折腾

STM32F103RCTx是LQFP64封装,用Cortex-M3内核跑72MHz,学过F1系列的人对这套外设不会陌生。但别把RCTx和C8T6搞混:C8T6是64KB Flash、20KB RAM;RCTx有256KB Flash、48KB RAM,关键是引脚多了接近一半。做计算器用不了这么多引脚,但正是这份富余降低了新手翻车的概率——你不必为了复用JTAG引脚去改复用寄存器,也不用为了省一个端口把按键设计成ADC分压式。全部用普通GPIO推挽输出,逻辑简单到可以物理接线一次成功。

芯片引脚多了,布线反而有讲究。PA9和PA10是USART1,以后调试打印就靠它们;PA13、PA14、PA15、PB3、PB4是SWD和JTAG复用脚,接了外设后调试器可能连不上。我这里给出一套经过验证的分配方案,把LCD的数据线尽量放在同一个端口的高8位,按键矩阵的行线和列线分别集中在两个口上,扫一眼代码就能在板子上找到线。

2.1.1 引脚分配表:LCD、键盘、预留调试口一次排清
模块引脚功能说明
LCD1602PB8~PB11D4~D74位模式数据线,只用高4位
LCD1602PB12RS0=指令 1=数据
LCD1602PB13RW直接接地,只写不读
LCD1602PB14EN使能,下降沿锁存数据
矩阵键盘PA0~PA3行线 Row0~Row3设置为推挽输出
矩阵键盘PA4~PA7列线 Col0~Col3设置为上拉输入
预留调试PA9 / PA10USART1 TX / RX重定向printf
预留调试PA13 / PA14SWDIO / SWCLK不对接外设

这套接线里最关键的细节是RW直接接地。想读忙标志的话需要把PB13拉高再读数据线,但初始化后每次写指令之间延时几十微秒就足够1602完成内部动作,读忙标志只会拖慢速度。焊好之后,给芯片上电,用万用表量PB8和PB9有没有3.3V——如果蓝白排针焊反,第一个现象就是LCD背光亮但不出字。

2.2 矩阵扫描的原理:为什么一轮扫描能省出9个引脚

独立按键接法是每个按键一个GPIO,计算器至少要16个键:0~9、+、-、*、/、=、C。16个引脚直接把人逼疯。矩阵接法把16个键排成4行乘4列,行线接GPIO输出,列线接GPIO输入。扫描时逐行拉低、逐列读取,一轮下来4+4只有8个引脚。

扫描时序不是随便写的。行线输出模式,列线带上拉输入,把第0行拉低、其余行拉高,然后读PA4~PA7。若PA4读到低电平,说明第二行第一列的按键被按下。这样定位到单个键之后,还要防抖——按键按下瞬间金属触点会反弹,持续几百微秒到几毫秒不等的抖动。用延时消抖法最省事:检测到低电平后延时10ms再读一次,如果仍然是低电平,判定为有效按下。

2.3 给LCD1602的4位传输写一个能用的初始化序列

LCD1602的4位模式只需4根数据线,传输一个字节要拆成高4位和低4位两次送。初始化时序比8位模式讲究:芯片上电后必须先送3次0x30,让LCD确认工作在8位模式,再切到4位模式。这个时序错了,屏上就是一堆方块。以下是我常用的初始化代码:

void LCD1602_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_11 | GPIO_Pin_12 | GPIO_Pin_14; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); delay_ms(50); LCD_WriteNibble(0x03); delay_ms(5); LCD_WriteNibble(0x03); delay_ms(5); LCD_WriteNibble(0x03); delay_ms(5); LCD_WriteNibble(0x02); delay_ms(1); LCD_WriteCommand(0x28); // 4位模式,2行,5x7点阵 LCD_WriteCommand(0x0C); // 显示开,光标关 LCD_WriteCommand(0x01); // 清屏 delay_ms(2); }

初始化里最容易忽略的是LCD_WriteNibble(0x02)——它把数据线状态固定到4位模式。如果这一步漏了,后面所有命令都会错位。LCD_WriteCommand(0x0C)是把光标关掉,否则数字后面跟着一个闪烁的下划线,运行时看得很干扰。

3. 计算器不只是按键加法:状态机是串起扫描与计算的胶水

3.1 主循环的骨架:按键扫描、显示刷新、运算判断如何不打架

计算器的主循环如果写成“扫到键 → 立刻算 → 立刻刷新”,会出现两个问题:按键在运算期间再触发一次导致重复输入;LCD刷新占掉几十毫秒导致扫描漏键。裸机下最稳的方案是一个节拍式主循环,用SysTick产生1ms时基,主循环每次只处理一个任务:

while (1) { uint8_t key = Key_Scan(); if (key != KEY_NONE) { Key_Process(key); } LCD_DisplayUpdate(); delay_ms(1); }

逻辑上,按键处理函数里完成状态迁移,显示函数里把当前缓冲区的字符串刷到LCD。扫描和运算互不嵌套,简单可靠。有人用定时器中断里扫描键盘,主循环只做刷新,这样更实时,但中断里调延时函数是禁忌——我在第5章会解释为什么。

3.2 一个七状态的状态机:从待机到输出结果,按键事件如何驱动迁移

计算器的核心是一张状态迁移表。按键被分成三类:数字键、运算符键、等号/清零键。状态机需要区分的是“当前正在输入第一个操作数”还是“已经按下运算符”。我设计的状态集合如下:

状态含义允许的按键
IDLE待机显示0数字、C
INPUT_OP1输入第一个操作数数字、运算符、C
AWAIT_OP已输入运算符,待第二个操作数数字、C
INPUT_OP2输入第二个操作数数字、=、C
RESULT已显示结果运算符、数字、C

核心规则是:在AWAIT_OP状态按数字键,清屏后从新数字开始;在INPUT_OP2状态按运算符,说明用户想连续计算,此时先算一步再存结果。这个迁移逻辑用switch-case写,比表驱动更直观,适合作为教学演示。

void Key_Process(uint8_t key) { switch (calc_state) { case STATE_IDLE: if (is_digit(key)) { current_num = key - '0'; calc_state = STATE_INPUT_OP1; } break; case STATE_INPUT_OP1: if (is_digit(key)) { current_num = current_num * 10 + (key - '0'); } else if (is_operator(key)) { saved_op = key; calc_state = STATE_AWAIT_OP; } break; case STATE_AWAIT_OP: if (is_digit(key)) { current_num = key - '0'; calc_state = STATE_INPUT_OP2; } break; case STATE_INPUT_OP2: if (is_digit(key)) { current_num = current_num * 10 + (key - '0'); } else if (key == KEY_EQUAL) { result = Calculate(saved_op, saved_num, current_num); calc_state = STATE_RESULT; } else if (is_operator(key)) { result = Calculate(saved_op, saved_num, current_num); saved_op = key; current_num = result; calc_state = STATE_AWAIT_OP; } break; case STATE_RESULT: if (is_digit(key)) { current_num = key - '0'; calc_state = STATE_INPUT_OP1; } else if (is_operator(key)) { saved_num = result; saved_op = key; calc_state = STATE_AWAIT_OP; } break; } }

这段代码有两个边界要解释。第一,AWAIT_OP状态下按等号,按传统计算器习惯是“重复上一次运算”,我这里直接把等号丢弃,在后续优化里可以加一个last_op变量补上。第二,INPUT_OP2状态下连续按运算符,意味着先完成一次运算,把结果作为新操作数继续运算——这对应计算器上的连续运算体验,1+2+3键入后得到6,而不是在第三次按键时清空。

3.3 中缀转后缀:让加减乘除的优先级问题交给逆波兰

如果只有两个操作数,直接算就行。但如果输入1+2*3,等号按下去的那一刻要知道先算乘后算加。裸机MCU上做语法树解析有点重,更常见的是把中缀表达式转成后缀(逆波兰表达式),再用一个栈计算结果。调度场算法(Shunting-yard)在这颗72MHz的芯片上运行毫无压力,整个表达式最多二三十个字符,栈深度不超过8。

中缀转后缀的规则很简单:数字直接输出到队列;运算符入栈前,比它优先级高或相等的运算符先出栈;左括号入栈,右括号让栈内运算符出到左括号为止。计算阶段,数字入栈,遇到运算符弹出两个数做运算,再把结果入栈。这里给出一段简化版的流程代码:

uint8_t priority(char op) { switch (op) { case '*': case '/': return 2; case '+': case '-': return 1; default: return 0; } } // 假定有字符数组 expr 保存中缀表达式 void InfixToRPN(char *rpn, const char *expr) { int op_stack[32]; int op_top = -1; int out_idx = 0; for (int i = 0; expr[i] != '\0'; i++) { char ch = expr[i]; if (is_digit(ch)) { rpn[out_idx++] = ch; } else if (ch == '+' || ch == '-' || ch == '*' || ch == '/') { while (op_top >= 0 && priority(op_stack[op_top]) >= priority(ch)) { rpn[out_idx++] = op_stack[op_top--]; } op_stack[++op_top] = ch; } } while (op_top >= 0) { rpn[out_idx++] = op_stack[op_top--]; } rpn[out_idx] = '\0'; }

这段代码要跟状态机配合才完整。做法是在INPUT_OP2状态收到等号后,把当前数字追加到全局数组,然后把整个数组交给InfixToRPN解析,再计算后缀表达式。单字符数字可能不够用,实际项目中rpn数组里每个token占一个字节,多位数需要额外编码,比如用高位标志位区分数字和运算符。我在第4章给出的完整实现里用数组存储浮点数结果来解决这个问题。

4. 从建工程到算对1+2*3:完整代码骨架与关键驱动实现

4.1 建工程的板级支持:标准外设库初始化时钟与GPIO的固定写法

标准外设库(SPL)是F1系列的老伙伴,虽然ST官方早停止维护,但工程模板到处都是。起步时只需要配置好外部8MHz晶振,让PLL倍频到72MHz。SysTick是1ms中断的时基来源——延时函数delay_ms和主循环的节拍都靠它。

void SystemClock_Config(void) { ErrorStatus HSEStartUpStatus; RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); HSEStartUpStatus = RCC_WaitForHSEStartUp(); if (HSEStartUpStatus == SUCCESS) { RCC_HCLKConfig(RCC_SYSCLK_Div1); RCC_PCLK1Config(RCC_HCLK_Div2); // APB1 = 36MHz RCC_PCLK2Config(RCC_HCLK_Div1); // APB2 = 72MHz RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET) {} RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() != 0x08) {} } }

APB1和APB2的分频是有讲究的:USART1挂在APB2上跑72MHz,USART2/3挂在APB1上跑36MHz。如果以后要扩展蓝牙模块和串口通信,分频配错会导致波特率偏差。Flash等待周期也别忘了设置——FLASH_SetLatency(FLASH_Latency_2),72MHz主频下必须插入2个等待周期,否则程序跑飞是家常便饭。

4.2 矩阵键盘扫描完整代码:行列扫描加软件消抖,一次按下一键

矩阵键盘和LCD驱动的联合测试,是验证整块板子焊得是否成功的第一个里程碑。这里给出矩阵键盘扫描函数,包含行扫描和消抖:

uint8_t Key_Scan(void) { uint8_t row, col; static uint8_t last_key = KEY_NONE; uint8_t current_key = KEY_NONE; for (row = 0; row < 4; row++) { GPIO_Write(GPIOA, ~(0x01 << row) & 0x0F); // 拉低当前行 for (col = 0; col < 4; col++) { if (GPIO_ReadInputDataBit(GPIOA, (0x10 << col)) == RESET) { current_key = row * 4 + col; break; } } if (current_key != KEY_NONE) break; } if (current_key == last_key) { return current_key; // 两次扫描结果一致,确认按下 } else { last_key = current_key; return KEY_NONE; // 第一次扫描,等待下一轮确认 } }

这段代码把消抖藏在“两次扫描结果一致才返回有效键值”的逻辑里。GPIO_Write(GPIOA, ~(0x01 << row) & 0x0F)的意思是:先把PA0~PA3中除了当前行的其他行全部写成高电平,只有当前行拉低。列线PA4~PA7读到的低电平就是按键位置。配合主循环中delay_ms(1)的节拍,每次调用间隔1ms,两次一致约2ms——这个消抖时间略短,实际应用中可以在两次一致的基础上再追加一个5ms的确认延时。

4.3 计算器的最终表达式执行:解析加求值,32位整数还是浮点数

计算器用整数还是浮点数,是很多人开始写代码前没想清楚的问题。F103的FPU?不存在的——Cortex-M3内核没有硬件浮点单元,所有float运算都由软件模拟库实现,一次除法可能要消耗上百个周期。如果只做整数运算,用int32_t,范围是正负21亿,做加减乘除搓绰有余,但除法会丢小数。做浮点的话,显示时需要格式化,printf的浮点打印会占用大量代码空间,需要重写一个简单的ftoa

我的建议是:这个项目里直接上浮点。理由很简单,计算器按下去要立刻出结果,那点软件浮点开销在72MHz下完全无感;换成整数反而要为小数点的显示位置写一堆逻辑。计算阶段用float,显示阶段把结果先放大100倍取整,再用整数方式显示:

void Float_To_String(char *str, float value) { long int_part = (long)value; long frac_part = (long)((value - int_part) * 100); if (frac_part < 0) frac_part = -frac_part; if (frac_part >= 100) { int_part++; frac_part = 0; } sprintf(str, "%ld.%02ld", int_part, frac_part); }

这段代码处理了显示的核心问题:保留两位小数,四舍五入隐含在frac_part >= 100的进位逻辑里。str数组长度要留10个字节以上,因为int_part可能到9位数加符号位。用sprintf替代printf是因为后者会占据大量可执行空间,且重定向到LCD时格式格式化函数更容易控制输出宽度。

4.4 LCD1602显示驱动:清屏与光标管理的几行关键逻辑

LCD1602每行16个字符,显示一行数字和符号绰绰有余。显示策略是:维护一个全局缓冲数组display_buf[17],每次在状态机迁移后更新缓冲区内容,主循环里判断缓冲区是否变化,变化了才整行刷新,避免反复清屏造成闪烁。写字符位置的函数如下:

void LCD_SetCursor(uint8_t row, uint8_t col) { uint8_t addr = (row == 0) ? (0x80 + col) : (0xC0 + col); LCD_WriteCommand(addr); } void LCD_DisplayString(uint8_t row, char *str) { LCD_SetCursor(row, 0); while (*str) { LCD_WriteData(*str++); } }

液晶模块的物理特性决定了写入速度不能太快。LCD_WriteCommandLCD_WriteData内部都要有至少40微秒的延时。要不要检查忙标志位?RW引脚直接接地的情况下读不了忙位,延时是唯一选择。注意LCD_WriteData前需要把RS拉高,LCD_WriteCommand前把RS拉低——这个小细节错了,屏上会出现错位字符,排查半天发现是RS状态搞反了。

5. 按下等号不出结果?管脚电压、初始化顺序和显示异常的排除顺序

5.1 LCD1602显示乱码:先查电压再查时序,最后才是寄存器配置

LCD1602的V0引脚接一个10K电位器到地,用来调节对比度。如果屏上只看到“方块”而没有任何字符,先不要怀疑代码,用万用表量V0引脚电压——通常是0.5V到1.2V之间能有清晰显示,低于0.2V或者高于2V全都是一排方块。这个问题在开发板上很常见,因为板载电位器可能拧到了极端位置。

排除电压之后,再看初始化波形。用逻辑分析仪抓EN引脚的时序,正常时序应该是:写Nibble时EN先拉高,保持至少450ns,再拉低,数据在下降沿被锁存。如果初始化序列的延时不够,LCD状态机卡在某个中间阶段,显示的就是随机字符。我调试时习惯在初始化代码里加一个延时100ms再发第一条命令,等LCD上电稳定——有些老化屏上电需要更长稳定时间。

5.2 按键按一次触发两次:抖动消除的取舍,延时10ms还是扫描确认

按键抖动的本质是机械触点闭合/断开瞬间的电平跳变。STM32的GPIO输入读取速度远高于人手按键速度,一次物理按键会产生多次电平翻转。用延时消抖的写法是:

if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_4) == RESET) { delay_ms(10); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_4) == RESET) { // 确认按键按下 } }

但如果主循环里扫描、延时、刷新写在一起,延时期间会漏掉其他列的按键。所以我的建议是用状态机扫描法——也就是第4章Key_Scan函数的写法,把两次扫描一致性作为判定标准,配合1ms节拍不会漏键。唯一需要注意的是,如果系统进入低功耗模式或者被中断长时间占据,扫描节拍被打断,按键判定时间会拉长到几十毫秒,此时用户会觉得按键迟钝。

5.3 显示的数字和按键不符:事件缓冲区的必要性

状态机里直接修改display_buf有个隐患:如果按键处理函数里做了一次运算,运算耗时几毫秒,LCD刷新读到的缓冲区可能还是旧数据。解决方案是加一个事件队列——按键扫描只把键值放进环形缓冲区,主循环消费队列,处理完再刷新显示。移植到中断方式时,这个队列尤其重要:中断里只做压队操作,主循环里做出队和运算。

#define QUEUE_SIZE 16 uint8_t key_queue[QUEUE_SIZE]; uint8_t queue_head = 0, queue_tail = 0; void Key_Enqueue(uint8_t key) { uint8_t next = (queue_head + 1) % QUEUE_SIZE; if (next != queue_tail) { key_queue[queue_head] = key; queue_head = next; } } uint8_t Key_Dequeue(void) { uint8_t key = KEY_NONE; if (queue_head != queue_tail) { key = key_queue[queue_tail]; queue_tail = (queue_tail + 1) % QUEUE_SIZE; } return key; }

队列深度16对一个计算器来说绰绰有余——正常人一秒钟按键不超过10次,1ms的扫描周期内最多进队1个键。用取模运算管理队头和队尾的循环关系,避免了数据搬移的开销。这个模式写顺了,后面做串口指令解析时也能直接复用。

5.4 运算结果错误:优先检查运算符优先级和类型转换

如果1+2*3算出9而不是7,说明调度场算法里优先级比较写反了,或者状态机在INPUT_OP2阶段收到运算符时提前算了。用几个固定用例做自测:

输入期望结果错误时的检查点
1 + 2 * 37优先级是否乘除高于加减
( 1 + 2 ) * 39括号处理是否正常
8 / 4 * 24同优先级从左到右
5 - 3 + 26是否误判成3

同优先级从左到右的规则容易错。8/4*2按数学习惯等于4,但如果解析器把乘除的优先级设成完全相同且不区分左右结合,可能算出1或者16。调度场算法里,同优先级运算符要弹出的条件是优先级(栈顶) >= 优先级(新来的),这个>=不能写成>,否则就违背了左结合规则。

6. 让计算器脱胎换骨:按键长按连续输入与表达式可视化输入

一个值得加进去的改进是长按连发。现在的状态机要求每次按键必须释放再按下才触发一次,连续删数字时要按好多次。实现长按的逻辑在扫描函数里加一个计时器:同一键值持续有效超过800ms后,每150ms自动触发一次。代码上用SysTick计数器记录键值首次有效的时刻,主循环里判断当前时刻与记录时刻的差值。

还有一个方向是把输入过程可视化。目前的状态机在用户按下运算符之前看不到已经输入的内容。可以改一下显示逻辑:在输入第一个操作数的过程中,每按一位数字就把它追加到LCD第一行,运算符按下后追加到运算符位,这样整个表达式是实时可见的。改起来很小——把状态机迁移时修改display_buf的动作,改成每次按键后重建整个表达式字符串,再整体刷新。这样比只显示结果的操作体验高出一个档次。

最后一个加分项是把结果保存在EEPROM里。F103RCTx没有真正意义上的EEPROM,但可以用Flash模拟:把结果区放在最后一个扇区,每次写入前先擦除再编程。配合独立的存储按键,可以让计算器在断电后仍保留上次计算结果。这里不再展开具体代码,但思路是申请一块__attribute__((at(0x0803F000)))的数组,用Flash编程接口写入——注意Flash擦写期间CPU会暂停取指,不要在中断里做这个操作。

计算器做完整之后,你会发现自己已经把GPIO操作、状态机建模、数据结构里的栈、中断与队列协作都摸了一遍。这些能力往任何一个方向延伸都走得通:换一块OLED显示器就是把LCD驱动替换成SPI或I2C发送;加一个DS18B20温度传感器,就是把矩阵键盘的扫描节拍改成温度转换的等待超时;把这些模块拼在一起,一台小型气象站的主控和软件框架已经有了。下次拿到一个看起来简单的板级项目,不妨先想想状态机怎么画、缓冲区怎么管理,再用F103RCTx把每一个细节压实。

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

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

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

立即咨询