简介:这是一份基于意法半导体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、键盘、预留调试口一次排清
| 模块 | 引脚 | 功能 | 说明 |
|---|---|---|---|
| LCD1602 | PB8~PB11 | D4~D7 | 4位模式数据线,只用高4位 |
| LCD1602 | PB12 | RS | 0=指令 1=数据 |
| LCD1602 | PB13 | RW | 直接接地,只写不读 |
| LCD1602 | PB14 | EN | 使能,下降沿锁存数据 |
| 矩阵键盘 | PA0~PA3 | 行线 Row0~Row3 | 设置为推挽输出 |
| 矩阵键盘 | PA4~PA7 | 列线 Col0~Col3 | 设置为上拉输入 |
| 预留调试 | PA9 / PA10 | USART1 TX / RX | 重定向printf |
| 预留调试 | PA13 / PA14 | SWDIO / 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_WriteCommand和LCD_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 * 3 | 7 | 优先级是否乘除高于加减 |
| ( 1 + 2 ) * 3 | 9 | 括号处理是否正常 |
| 8 / 4 * 2 | 4 | 同优先级从左到右 |
| 5 - 3 + 2 | 6 | 是否误判成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把每一个细节压实。
本文还有配套的精品资源,点击获取