
简介基于STM32F103RCT6微控制器设计实现的简易计算器完整工程包面向嵌入式初学者和参加电子设计竞赛的学生解决从零搭建STM32计算器项目时外设驱动、按键扫描、LCD显示与运算逻辑整合的难题。工程基于Keil uVision开发曾获校级电子设计竞赛一等奖涵盖Cortex-M3内核配置、GPIO与中断管理、HAL库驱动编写等关键知识点具备较高的参考价值。包内共998个文件压缩包约27.86MB。563个C源文件和247个H头文件构成完整源码.s启动文件与.uvprojx工程文件帮助快速打开项目.hex/.axf可执行文件可直接烧录验证另有.lib数学库、.map映射文件等便于分析程序结构。目前已有882人学习下载适合希望深入STM32实战、参考完整竞赛方案或学习代码组织方式的读者。1. 一块 256KB Flash 的单片机做计算器误打误撞踩中了嵌入式系统的门槛直接摆一个有点反常规的结论简易计算器这个需求用 STM32F103RCTx 属于典型的资源过剩但资源过剩恰恰是这个项目最大的价值。Cortex-M3 内核跑到 72MHz256KB Flash、48KB SRAM、LQFP64 引出 51 个 GPIO做四则运算浪费得离谱可它正好卡在“点灯太简单、做产品又不够”的尴尬区间。真正让新手和中级工程师拉开差距的是从矩阵键盘到 LCD 显示再到表达式解析这一整条数据通路而不是某个外设的单点操作。这篇文章从选型开始讲把硬件接线、时钟配置、输入状态机、双栈求值一路走完最后落到怎么用示波器和 GPIO 翻转验证时序。适合正在从裸机点灯往小型系统设计过渡的人也适合给课程设计找一个能写清楚、能拍视频演示的完整方案。2. 内存、引脚与时钟STM32F103RCTx 最小系统到底怎么搭2.1 LQFP64 的引脚账为什么 RCTx 比 C8T6 更从容F103 系列里最常被拿来对比的是 C8T6 和 RCTx。C8T6 是 LQFP48 封装64KB Flash、20KB SRAMGPIO 引出 37 个RCTx 则是 LQFP64256KB Flash、48KB SRAMGPIO 引出 51 个。计算器的外设需求并不复杂16 个矩阵按键占 8 根 IOLCD1602 用 4 线模式占 6 根 IO加上预留的 SWD 调试口和几个指示灯C8T6 的 37 个引脚理论上也够用。但真正让 RCTx 显得从容的是封装和引脚分布PA0 到 PA15、PB0 到 PB15、PC0 到 PC15 排列整齐可以把矩阵键盘的行列分到两个完整端口上扫描代码不用做位运算拼接直接把一个端口的 ODR 拉低再读另一个端口的 IDR 就行。这种“端口天然对齐”的好处在写扫描程序的时候会明显地感受到。选 RCTx 还有一个隐藏理由48KB SRAM 对表达式解析是一个很舒服的上限。字符串缓冲区开 64 字节、运算符栈开 32 字节、数字栈开 32 个 double每个 8 字节共 256 字节加起来不到 1KB剩下的 SRAM 想加历史记录、菜单界面甚至浮点格式化输出都宽裕。如果换成 C8T6 的 20KB代码密集起来之后堆栈碰撞的概率会高不少。做计算器这种教学性质的项目没必要在第一步就给自己设限。2.2 给计算器分配引脚矩阵键盘与 LCD1602 的接线表硬件设计遵循一个原则键盘的行和列各占一个完整端口LCD 的数据线按顺序贴在另一个端口的低 4 位或高 4 位这样固件里能直接读写寄存器不用逐个 bit 映射。常见的分配方式如下可以直接抄用。模块信号引脚说明矩阵键盘 4x4行 R0-R3PC0-PC3开漏输出平时输出高扫描时逐行拉低矩阵键盘 4x4列 C0-C3PC4-PC7上拉输入读 IDR 高 4 位判断按下位置LCD1602DB4-DB7PB0-PB34 线模式数据线引脚顺序必须与代码一致LCD1602RSPB40命令 1数据LCD1602EPB5下降沿锁存数据预留SWDPA13/PA14调试下载不可挪作他用注意 PF0/PF1 和 PD0/PD1 在很多 LQFP64 封装上也能引出但 PD0/PD1 是 OSC_IN/OSC_OUT 的复用位置接了外部 8MHz 晶振之后这两个引脚就不可用了。表格里键盘和 LCD 不碰 PA 口目的就是把 PA 的调试口完整保留下来。2.3 时钟树配置把 8MHz HSE 拉到 72MHz 而不是 64MHz计算器这种低速交互设备对 CPU 频率不敏感但系统时钟会影响串口波特率误差、定时器 tick 精度和 LCD 延时函数的计算方式。最稳妥的组合是 8MHz 无源晶振 PLL 倍频到 72MHzFlash 等待周期设 2。标准外设库风格的配置如下。void RCC_Configuration(void) { RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); /* 启用外部高速晶振 */ while (RCC_GetFlagStatus(RCC_FLAG_HSERDY) RESET); RCC_HCLKConfig(RCC_SYSCLK_Div1); /* AHB SYSCLK 72MHz */ RCC_PCLK1Config(RCC_HCLK_Div2); /* APB1 36MHz注意不能超36MHz上限 */ RCC_PCLK2Config(RCC_HCLK_Div1); /* APB2 72MHz */ FLASH_PrefetchBufferCmd(FLASH_PrefetchBuffer_Enable); FLASH_SetLatency(FLASH_Latency_2); /* 72MHz必须配2个等待周期 */ RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); /* 8MHz * 9 72MHz */ RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() ! 0x08); /* 0x08 表示 PLL 作为系统时钟 */ }参数上有两个常见坑。一个是 APB1 的频率上限是 36MHz如果分频配置写错USART2/3、I2C1/2 和定时器 2-7 的时钟会直接越界表现为外设偶发不工作。另一个是 Flash 等待周期72MHz 必须设FLASH_Latency_2设成 1 会出现取指错误现象是代码随机跑飞或者某些函数调用时死机。后面接 GPIO 时RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | GPIOB | GPIOC, ENABLE)一步把三个端口时钟全打开别一个一个写逻辑一样但代码少三行。3. 矩阵键盘扫描、消抖与事件分发计算器的输入层怎么组织3.1 从按下到入队按键状态机怎么定义很多人写按键扫描习惯用一个GPIO_ReadInputDataBit加Delay_ms(20)的流程检测到低电平先延时再确认。这个写法在计算器上能用但有个隐患消抖期间整个系统被阻塞如果此时 LCD 正在输出一帧数据会出现显示闪烁。更接近工程实践的做法是把按键逻辑拆成离散状态每个状态只在固定的扫描周期内判断一次条件是否满足把消抖时间变成状态迁移的时间门槛。typedef enum { KEY_IDLE, /* 无按键按下 */ KEY_DEBOUNCE, /* 检测到按下等待消抖确认 */ KEY_PRESSED, /* 确认按下已经派发事件 */ KEY_RELEASE_WAIT /* 等待释放避免一次按压重复触发 */ } KeyState_t; KeyState_t keyState KEY_IDLE; uint32_t keyTime 0; /* 记录状态进入时刻 */状态迁移规则很简单IDLE 扫描到有键按下记录时间进 DEBOUNCEDEBOUNCE 中两次间隔 10ms 再扫描若按键仍保持按下判定有效并派发事件进 PRESSEDPRESSED 里按键一旦释放回 IDLE。这个状态机对一次“按压-释放”只产生一个事件Double 按键和连发都由上层处理。3.2 非阻塞矩阵扫描用 HAL_GetTick 做消抖矩阵扫描的核心操作是逐行输出低电平然后读列。下面给出非阻塞写法扫描频率由外部主循环控制每次调用只做一次行扫描或状态判断不等待。uint8_t MatrixScan_GetKey(void) { static uint8_t activeRow 0; uint8_t col 0; GPIO_Write(GPIOC, ~(0x01u activeRow)); /* 当前行拉低其余行保持高 */ col (GPIO_ReadInputData(GPIOC) 4) 0x0F; /* 读 PC4-PC7 */ if (col ! 0x0F) { for (uint8_t i 0; i 4; i) { if ((col (0x01u i)) 0) { return activeRow * 4 i; /* 键号 行 * 4 列 */ } } } activeRow (activeRow 1) % 4; /* 换下一行周而复始 */ return 0xFF; /* 无按键 */ }矩阵扫描有个经典细节行引脚必须先写成输出模式并且初始输出高电平再在执行这句代码时把目标位清零。上面GPIO_Write(~(0x01 activeRow))的原型是GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal)它写的是整个 ODR。参数0x01 activeRow对应 PC0、PC1、PC2、PC3取反后其余行输出高当前行输出低列引脚 PC4-PC7 保持输入上拉。返回值约定0xFF表示没有键按下使用时先判断再处理。状态机的消抖逻辑可以写成独立函数每 10ms 由 SysTick 或者定时器中断触发一次void KeyScan_Tick(void) /* 每10ms调用一次 */ { uint8_t key MatrixScan_GetKey(); switch (keyState) { case KEY_IDLE: if (key ! 0xFF) { keyTime HAL_GetTick(); keyState KEY_DEBOUNCE; } break; case KEY_DEBOUNCE: if (key 0xFF) { keyState KEY_IDLE; /* 抖动或误触直接复位 */ } else if ((HAL_GetTick() - keyTime) 10) { KeyEvent_Send(key); /* 触发事件写入队列 */ keyState KEY_PRESSED; } break; case KEY_PRESSED: if (key 0xFF) { keyState KEY_RELEASE_WAIT; keyTime HAL_GetTick(); } break; case KEY_RELEASE_WAIT: if (key ! 0xFF) { keyState KEY_PRESSED; /* 中途又被按下忽略 */ } else if ((HAL_GetTick() - keyTime) 20) { keyState KEY_IDLE; /* 确认完全释放 */ } break; } }这里的消抖时间固定在 10ms。机械按键的抖动通常在 5-15ms 之间10ms 是一个平衡值如果用的按键比较旧或者按压力度不稳可以把 10改成 20代价是长按响应变慢计算器场景完全可接受。KEY_RELEASE_WAIT这个状态的目的是保证一次物理按压最多发出一次事件否则手指轻微抖动会让表达式里重复出现同一个数字。3.3 LCD1602 4 线模式最小驱动代码与初始化序列LCD1602 的数据引脚只有 8 根4 线模式只接 DB4-DB7能省 4 个 IO。写一个字节时先发高 4 位再发低 4 位每次发完都产生一个下降沿作为锁存信号。最小驱动代码如下。#define LCD_RS_PORT GPIOB #define LCD_RS_PIN GPIO_Pin_4 #define LCD_E_PORT GPIOB #define LCD_E_PIN GPIO_Pin_5 #define LCD_DATA_PORT GPIOB #define LCD_DATA_MASK 0x0F /* PB0-PB3 对应 DB4-DB7 */ void LCD_WriteNibble(uint8_t nibble) { GPIO_Write(LCD_DATA_PORT, (GPIO_ReadOutputData(LCD_DATA_PORT) ~LCD_DATA_MASK) | (nibble 0x0F)); GPIO_SetBits(LCD_E_PORT, LCD_E_PIN); /* E 拉高 */ GPIO_ResetBits(LCD_E_PORT, LCD_E_PIN); /* E 拉低下降沿锁存数据 */ } void LCD_WriteByte(uint8_t rs, uint8_t data) { GPIO_WriteBit(LCD_RS_PORT, LCD_RS_PIN, rs ? Bit_SET : Bit_RESET); LCD_WriteNibble(data 4); /* 先高四位 */ LCD_WriteNibble(data 0x0F); /* 再低四位 */ }初始化序列是 1602 芯片手册里固定的流程不同厂商的模组略有差异但下面这组在绝大多数屏上都能稳定工作。void LCD_Init(void) { HAL_Delay(50); LCD_WriteNibble(0x03); HAL_Delay(5); LCD_WriteNibble(0x03); HAL_Delay(5); LCD_WriteNibble(0x03); HAL_Delay(5); LCD_WriteNibble(0x02); HAL_Delay(5); /* 切换到 4 线模式 */ LCD_WriteByte(0, 0x28); /* 4 线、双行、5x8 点阵 */ LCD_WriteByte(0, 0x0C); /* 显示开光标关闪烁关 */ LCD_WriteByte(0, 0x01); /* 清屏 */ LCD_WriteByte(0, 0x06); /* 写入后地址自动加一 */ HAL_Delay(2); }初始化序列里最容易被忽略的是开头那三个0x03。上电后 1602 处于 8 线模式必须先连续发送三次0x03让控制器对齐到 8 位指令边界再发0x02切到 4 线。如果上来直接发0x28屏幕大概率显示乱码。实际排错时用逻辑分析仪挂在 E 引脚上看每次LCD_WriteNibble后 E 上必须有一个完整的下降沿没有下降沿就是 GPIO 模式配错最常见的是把 E 引脚设成了开漏输出且外部没接上拉。3.4 一个简单的事件派发按键值到表达式字符把按键值映射成表达式字符是一步很容易被低估的逻辑。计算器上“1”键按下后不仅要往显示缓冲区写字符还要在内存中维护一个状态当前输入的是操作数还是运算符、是否已经按过小数点、表达式长度是否超过 LCD 一行 16 字符的限制。我一般用一个 64 字节的环形字符数组做输入缓冲配合一个光标位置索引每次按键事件到达时执行简单的 switch-case 映射。char exprBuf[64]; /* 表达式缓冲区 */ uint8_t exprLen 0; void KeyEvent_Handle(uint8_t keyCode) { char ch KeyToChar(keyCode); /* 0-9 - 0-910-11--12-*13-/14-15-C */ if (ch C) { exprLen 0; exprBuf[0] \0; LCD_Clear(); return; } if (exprLen 63) return; /* 防溢出超出直接丢弃 */ exprBuf[exprLen] ch; exprBuf[exprLen] \0; LCD_ShowString(0, 0, exprBuf); /* 每次按键刷新一次显示 */ }KeyToChar是一张查表函数不建议直接写 16 个 case用const char keyMap[16] {...}更清晰。缓冲区溢出检查放在写入之前即使连续快速按键也不会破坏栈。显示刷新策略是每次都把整行重绘计算器一屏最多 16 个字符清屏加写字符串总耗时在毫秒级不会产生可见闪烁。4. 从按键流到运算结果简易计算器的表达式解析与双栈求值4.1 先选算法三种四则求值方案怎么取舍输入层交出的是一个中缀表达式字符串比如12*345/9。把它变成结果有几种常见路线各自的复杂度差异很大。方案核心思路代码量适用场景直接求值扫描时遇到数字就累计遇到运算符立即按当前优先级计算最少只支持两个数字一次运算调度场算法 双栈运算符进栈时先弹高优先级运算符结合数字栈边转边算中等完整四则运算、带括号、连续运算递归下降按语法规则拆表达式、项、因子逐层递归较多需要错误定位、支持一元运算符和复杂语法简易计算器只用 、-、*、/ 和括号调度场算法最合适。它在一次扫描中完成中缀到后缀的转换和求值不需要把后缀表达式另外存一份内存占用比递归下降小逻辑又比直接求值完整得多。STM32F103RCTx 的 48KB SRAM 跑这个算法绰绰有余但算法精简的意义不在省内存而是让代码更容易被逐行审查和测试。4.2 双栈求值器的 C 实现双栈的意思是准备一个数字栈存操作数、一个运算符栈存操作符和左括号。扫描表达式时数字直接进数字栈运算符则要先把栈顶所有优先级不低于当前运算符的运算弹出来算完再把自己压栈遇到右括号弹到最近的左括号为止。double Calc_Eval(const char *expr, int *ok) { double numStack[32]; char opStack[32]; int topN 0, topO 0; int i 0; *ok 1; while (expr[i] ! \0) { if (expr[i] 0 expr[i] 9) { double v 0; while (expr[i] 0 expr[i] 9) { v v * 10 (expr[i] - 0); i; } if (expr[i] .) { /* 处理小数部分一位位缩小 */ i; double frac 0.1; while (expr[i] 0 expr[i] 9) { v (expr[i] - 0) * frac; frac * 0.1; i; } } if (topN 32) { *ok 0; return 0; } numStack[topN] v; continue; } if (expr[i] () { opStack[topO] (; i; continue; } if (expr[i] )) { while (topO 0 opStack[topO-1] ! () { if (!Calc_Pop(numStack, topN, opStack, topO, ok)) return 0; } if (topO 0) topO--; /* 弹出左括号 */ i; continue; } if (expr[i] || expr[i] - || expr[i] * || expr[i] /) { while (topO 0 opStack[topO-1] ! ( Calc_Priority(opStack[topO-1]) Calc_Priority(expr[i])) { if (!Calc_Pop(numStack, topN, opStack, topO, ok)) return 0; } opStack[topO] expr[i]; i; continue; } i; /* 遇到空格等非法字符直接跳过 */ } while (topO 0) { if (!Calc_Pop(numStack, topN, opStack, topO, ok)) return 0; } if (topN ! 1) { *ok 0; return 0; } return numStack[0]; }运算符优先级用Calc_Priority实现加减返回 1乘除返回 2左括号返回 0。弹栈运算函数Calc_Pop从数字栈取两个操作数按运算符计算把结果压回数字栈。这里最关键的一行是弹栈条件它保证相同优先级的运算符也按从左到右的顺序求值。数值栈用double53 位尾数在 16 位 LCD 显示范围内误差可以忽略如果只用int10/3会直接得到 3这和真实计算器行为不符。4.3 边界与缓冲除零、连续运算、溢出怎么兜住双栈算法暴露出来的第一类问题是除零。10/(5-5)在扫描到右括号时才执行除法此时才报错已经太晚界面需要返回一个可辨识的错误码。我在Calc_Pop里遇到除数为 0 时直接置*ok 0并返回上层拿到 0 后显示Error而不是继续打印一个无穷大。第二类问题是表达式末尾缺操作数比如12*。扫描结束后运算符栈还有乘号但数字栈里只剩 1Calc_Pop取数时会越界。栈数组大小 32 只是上限真正的防御是靠函数入口处的topN 2判断凡是弹栈前数字栈不足两个就判定表达式非法。这类错误在人工测试时很难把所有组合敲一遍建议写一个简单的参数化测试函数把12、12*、1/0、(12)*3、12*3-4/2这些用例一次性跑完。第三类风险是数值溢出。计算器用 double 类型1e308 * 1e308会得到 inf显示层需要判断isinf(result)或isnan(result)。另外 LCD1602 只有 16 列超过 16 位的数字字符串需要进行滚动显示或改用科学计数法格式化常见的做法是sprintf(buf, %.6g, result)它能自动在小数位过多时切换指数格式。5. 用 GPIO 翻转测出按键处理耗时再给“扫描-显示-计算”解耦5.1 用 GPIO 翻转验证扫描周期和 LCD 写入时序按键消抖和 LCD 驱动都是时间敏感逻辑凭感觉调参容易留下隐患。我习惯在扫描函数开头翻转一次某个空闲 GPIO比如 PA8然后让示波器或者逻辑分析仪抓 PA8 的波形。正常运行时 PA8 应该输出一组周期稳定的方波周期就是主循环调用KeyScan_Tick的间隔。如果波形宽窄不一说明主循环里有长延时阻塞如果周期漂移超过 20%要考虑HAL_Delay的使用位置是否合理。LCD 时序的验证方法可以把 PA8 的翻转挪到LCD_WriteNibble函数里同时抓 E 引脚和 PA8。两个下降沿之间的时间差就是一次 nibble 写入的耗时正常情况下应该小于 5us。如果发现 E 的下降沿和 PA8 翻转之间间隔在几十微秒以上说明 GPIO 初始化参数里把输出速率设成了低档应改用GPIO_Speed_50MHz。5.2 把“扫描-显示-计算”拆成三个独立模块最后一步不是加功能而是把代码切成三个不互相引用的模块key管扫描和状态机lcd管显示calc管求值。模块之间只通过两个接口通信KeyEvent_Handle收到的每个按键事件都写入表达式缓冲计算结束后把结果通过LCD_ShowString输出。这样做的好处是单元测试时可以直接用字符串调用Calc_Eval不需要碰键盘硬件更换显示模组时只改lcd模块计算逻辑纹丝不动。工程上判断模块拆分是否合理的标准是把calc模块单独复制到 PC 上加一个main函数能不能用标准 C 编译器直接跑通所有测试用例。如果能这套计算器的逻辑核心就与硬件完全解耦了。本文还有配套的精品资源点击获取