公司动态
基于STM32的计算器设计与实现:从按键扫描到OLED显示的完整工程解析
简介本资源是一套基于STM32F103ZET6单片机实现的嵌入式计算器工程源码面向嵌入式初学者与课程设计实践者解决基础人机交互与算术运算逻辑在裸机环境下的落地问题。项目适配正点原子战舰开发板及2.5寸LCD显示屏完整实现数字输入、四则运算、结果实时显示等核心功能配套效果视频直观展示运行流程。压缩包共177个文件含32个C源文件如jisuanqi.c、paint.c、25个头文件.h、33个编译中间文件.o/.d及Keil工程配置文件.uvprojx、.uvoptx、可执行镜像.hex和调试配置.dbgconf总大小4.27MB结构清晰便于理解STM32固件库开发全流程。已有4857人学习下载提供可直接编译烧录的完整工程涵盖硬件驱动、GUI绘制、按键扫描与表达式解析等关键模块是掌握STM32外设协同与小型应用系统开发的实用参考。 最近整理了一份基于STM32设计的计算器工程源码支持加、减、乘、除四则基本运算配套矩阵按键输入和OLED显示压缩包里有完整的Keil工程和烧录说明。这次就顺着这个项目把从硬件选型、按键扫描、运算逻辑到调试踩坑的整个过程拆开讲讲。无论你是刚学完STM32基础想找个练手项目还是想搞懂计算器这种实时交互系统怎么设计这篇都可以直接照着做。很多人会觉得计算器太简单无非是读取按键、算个结果、显示出来。但真正实现起来里面藏着不少坑按键抖动怎么消连续运算怎么写除数为零怎么处理负数显示要不要支持浮点结果怎么格式化。这些细节恰恰是嵌入式开发的典型问题做完这个项目对中断、定时器、状态机、外设驱动这些概念的理解都会上个台阶。1. 内容整体设计与思路拆解1.1 为什么选STM32实现计算器做计算器用51单片机也能做用树莓派也能做但STM32是嵌入式领域最主流的MCU选它有几个实在的理由。首先是外设资源够用。计算器需要按键输入和显示输出STM32的GPIO、定时器、I2C/SPI接口都绰绰有余。如果用带触摸屏的方案甚至可以用FSMC接口驱动TFT为后续扩展留足余量。其次是开发环境成熟Keil MDK、STM32CubeIDE、标准外设库、HAL库、LL库随便选资料多遇到问题随便搜都有答案。第三是性能冗余STM32F103系列主频72MHz做计算器连1%的性能都用不到但这正好意味着你能在上面跑更复杂的算法比如中缀表达式解析、带优先级运算、甚至科学计算器功能而不是被性能卡住。这个项目用的主控是STM32F103C8T6也就是俗称的“蓝丸”核心板上的那颗芯片。72MHz主频、64KB Flash、20KB RAM对于这套计算器逻辑绰绰有余。你完全可以用别的型号比如STM32F407或者G030只要引脚配置对应改一下就行。核心逻辑是平台无关的这也是这个项目源码值得参考的原因。1.2 基本运算功能需求梳理所谓“基本运算”我们定义为四则运算加、减-、乘、除/。在这个基础上还需要配套的操作符优先级判断也就是乘除优先于加减。例如输入234结果应该是14而不是20。具体功能需求列表如下支持整数和浮点数输入小数点在显示区可以切换支持正负数显示负数用-前缀标识支持连续运算例如1234*5-这种边输入边显示的过程支持等号后继续运算按下等号出结果后再按数字键会以当前结果为起点继续输入支持清除C、退格Backspace等基本编辑操作除数为0时显示Error并允许用户清除后继续操作结果显示位数要截断到合理范围不能出现1.9999999这种浮点误差这些需求看似简单但每一条都对应一个实现细节。比如连续运算你既要保存当前结果又要保留待运算的运算符还要处理好“等号后继续输入”的状态切换。这就是典型的有限状态机应用场景。1.3 系统架构与状态机设计从软件架构上我把整个系统分成三层输入层、处理层、显示层。输入层负责按键扫描、去抖、键值映射最终产出一个“按键事件”。处理层是一个核心状态机维护当前输入值、操作数栈、运算符以及各种状态标志。显示层将处理层的结果格式化成字符串刷到屏幕上。核心状态机的状态划分如下状态含义触发条件动作INPUT_A输入第一个操作数开机或清除后接收数字键、小数点OP_WAIT等待选择运算符操作数输入完成接收 - * /必要时计算INPUT_B输入第二个操作数已选运算符接收数字键、小数点CALC_DONE运算完成按下等号显示结果支持继续运算ERROR错误状态除数为0或数据溢出显示Error仅清除有效举个例子按下12时状态从INPUT_A切到OP_WAIT同时把12暂存。按下34时状态切到INPUT_B显示区变成34。再按时处理层会先完成1234的加法得到46然后被当作新运算符状态回到OP_WAIT显示区显示46。这种即时计算方式叫“逐步计算法”适合基本四则运算不需要复杂的表达式转后缀。如果你想实现带括号和优先级更高的计算器那就要用中缀转后缀表达式逆波兰式再配合双栈计算。但这次是基本运算逐步计算法代码量小、状态清晰非常适合教学和二次开发。2. 硬件电路设计与按键方案2.1 主控最小系统与引脚分配硬件上我直接用了市面上常见的STM32F103C8T6最小系统板板载晶振、复位电路、USB转串口省去自己画底板的麻烦。但如果你要自己做PCB最小系统必须包含8MHz主晶振及两个20pF负载电容、32.768kHz低速晶振若使用RTC、上电复位电路10kΩ上拉 100nF电容、VTref/VDDA/VSSA等去耦电容、BOOT0和BOOT1配置。引脚分配上按键矩阵用PA0-PA3做行PB0-PB3做列OLED显示屏用I2C1PA5做SCL、PA6做SDA。串口使用PA9、PA10方便打印调试信息。每个按键的GPIO都配置为上拉输入列线配置为推挽输出扫描时逐列拉低。有一件事必须注意STM32的PB3、PB4引脚默认复用为JTAG调试口JTDO和NJTRST如果你们把矩阵键盘配到这两个脚上会发现在线调试正常但脱离调试器后按键不工作。解决方法是禁用JTAG仅保留SWD——调用GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)。这个坑我在第一个版本踩过花了一个晚上才定位到。2.2 矩阵键盘 vs 独立按键为什么选择矩阵键盘而不是独立按键原因很简单节省GPIO。一个4x4矩阵键盘用8个GPIO就能支持16个按键而独立按键要16个GPIO。STM32F103C8T6虽然有37个GPIO但还要留给出显示和串口独立按键资源紧张。矩阵键盘的原理是行列分时导通每列输出低电平其余列输出高电平然后读取所有行引脚电平若某行读到低说明该行与当前列交叉的按键被按下。实际按键布局我用了16个键分别是按键功能按键功能0-9数字加.小数点-减计算*乘C清除/除Back退格/-负号切换如果你不需要负号切换可以把4x4改成3x4矩阵节省2个IO给其他用途。拓展一下想要更多按键比如三角函数、括号、pi可以换成7x11或者用I2C扩展芯片PCF8574一块芯片就能扩8个IO。2.3 显示方案怎么选显示方案常见的有三种数码管、OLED、LCD1602/2004。数码管显示数字美观但无法显示字符ErrorLCD1602可以显示两行16个字符价格便宜但模块体积大、接线多除I2C转接板以外OLED0.96英寸SSD1306驱动体积小、支持I2C接口、显示自由很适合计算器这种信息量不大的场景。我最终用的0.96寸OLED分辨率128x64。一屏能显示8行16个英文字符或者4行三号大字。为了显示效果我把结果区放在底部一行用大字体显示把表达式历史或操作提示放在上部。OLED的控制核心是SSD1306控制器通过I2C发送命令和数据。需要熟悉的关键命令有显示开关0xAE/0xAF、页地址设置0xB0-B7、列地址设置0x00-0x0F低四位 0x10-0x1F高四位、对比度设置0x81。如果使用HAL库可以简化成两个函数OLED_WriteCommand和OLED_WriteData然后用DMA或阻塞方式发送。有一点值得提醒很多OLED模块的I2C地址是0x3C但也有0x3D的。驱动不了时先检查地址这个比排查代码快得多。还有一种方式是使用4线SPI速度快一些但对于刷新一帧只有几十字节的场景I2C完全够用。2.4 电路连接与电源注意事项连接关系非常简单但有几个电源和信号完整性的坑。按键矩阵的行线分别接PA0-PA3列线接PB0-PB3。所有按键的一端接行线另一端接列线通过行列交叉点连通。这里要注意的是行线必须配置为上拉输入模式因为扫描列时未被选中的列输出高电平被选中的列输出低电平行线通过按键与列线相连。若按键按下行线被拉低单片机读到低电平。如果没有内部上拉悬空状态下电平不确定按键误触发分分钟教你做人。OLED的SDA和SCL必须接上拉电阻。很多模块本身带了上拉如果没带在I2C总线上加两个4.7kΩ到3.3V。电源方面STM32和OLED都是3.3V供电按键不需要额外电源。注意不要让5V进入I2C引脚否则时间长了会损坏MCU。静电问题也要重视。在干燥环境中手指触摸按键容易产生静电虽然有内部保护二极管但反复放电也可能导致复位或数据错误。可以在每个按键靠近MCU的一端加一个100pF电容到地起到简单的ESD滤波作用。实际测试中加与不加在普通环境下区别不明显但在工业环境就会体现差异。3. 软件核心模块实现3.1 按键扫描与消抖处理按键扫描的基本思路是每10ms执行一次每次驱动一列输出低电平然后读取4行电平翻到下一条列线继续。一个完整周期扫描4列需要40ms对应25Hz的扫描率人手的按键持续时间通常超过100ms不会丢键。消抖处理有两种做法硬件消抖RC滤波和软件消抖。我采用软件消抖其实也是滤波思想连续两次扫描得到的键值一致才认为按键有效。具体状态如下uint8_t KeyScan(void) { uint8_t key 0; Key_CtrlColumn(LOW, COL0); // 拉低第0列 Key_DelayUs(10); // 等待电平稳定 if (Key_ReadRow() ROW0_MASK) key 1; // 读取行 if (Key_ReadRow() ROW1_MASK) key 4; //... Key_SetAllColumns(HIGH); // 恢复所有列 // 依次扫描其他列 return key; }但更实用的消抖方式是使用一个“状态机定时器”的结构。我定义了一个key_debounce_driver每个按键有释放态、按下确认态、长按态。对于计算器这种短促输入只需要“按下确认”和“释放”两种事件。每次扫描到按下时记录时间戳如果同一个键持续被扫描到超过20ms就认为是一次有效按键然后把键值放入一个环形缓冲区。这个缓冲区的存在能有效防止按键期间CPU正在做耗时运算比如浮点格式化导致丢键。3.2 核心状态机与运算逻辑整个运算逻辑可以写成一个大函数ProcessKey(uint8_t key)内部根据当前状态决定处理方式。这是我最想分享的部分因为很多初学者会把运算过程写成“按一个键处理一锤子”结果按等号时不知道该怎么把结果带出来。我把关键状态机用结构体表示typedef struct { char display[16]; // 显示缓冲区 double accumulator; // 累加器存第一个操作数或中间结果 double current_input; // 当前输入的操作数 char op; // 当前运算符 uint8_t state; // 状态机状态 uint8_t decimal_place; // 小数位计数 } Calc_t; Calc_t calc;加法减法很容易但乘除的过程必须注意“运算符优先级”在逐步计算中的正确处理。比如输入234时按照逐步计算法按下先不做加法按下3后再按时系统会判断当前运算符是而新运算符优先级更高于是不能立刻计算23必须先算34不对这种思路在逐步计算法里反而复杂化了。更简单的做法是不处理优先级直接从左到右计算。这样23*4会得到20。想要得到14就必须采用“待定运算符”的机制当新运算符优先级大于当前运算符时不计算仅保存当前运算符和新运算符等后续低优先级运算符进来时再统一计算。但这样会引入更复杂的控制逻辑。这里我选择一种折中方案使用“双变量 单运算符”的传统简单计算器逻辑。它不处理同级运算的优先级问题但可以支持连续四则运算。例如输入 1234当前是INPUT_B显示34。再按 因为当前运算符是先把1234算出来得到46显示46并保存运算符。再输入 5显示5。按 计算46*5230显示230。这种逻辑适合学习也基本符合普通用户的使用预期。如果你希望严格按数学优先级那需要像前面说的一样实现表达式解析。源码包我注释里也加了宏定义可以通过开关切换“严格优先级模式”但严格优先级模式会引入中缀转后缀逻辑代码量多出200多行。建议先从简单模式跑通再逐步加深。等号处理有一个容易忽略的点按等号时如果当前没有第二个操作数例如用户按了12后直接按应该将第二个操作数视为与第一个相同执行121224还是忽略我采用的是忽略直接不响应等号。这更符合直觉。3.3 浮点显示与精度控制基本运算中除法会产生小数。例如10/33.3333333333333335。直接格式化到串口会显示一长串非常难看。因此显示模块需要把double型结果按最多8位有效数字显示并去掉尾部多余的0。实现方式snprintf(buffer, sizeof(buffer), %g, value);如果value是整数%g会输出整数格式如果是小数输出科学计数法当数值过大或过小时。为了避免科学计数法可以用自定义格式化函数遍历小数点后的位数当超过6位时截断。对于整数结果也要防止输出12.000000。我的做法是先检查value与round(value)的差值是否小于1e-6如果是直接按整数显示否则按浮点显示。这里还要注意float和double的选择。STM32F103做乘法时使用浮点运算是通过软件模拟的性能不高但只要你不做高频率计算10毫秒内完成一次乘除法没压力。如果你改用STM32F4系列硬件FPU会让浮点运算快十几倍。不过计算器场景F1足够。3.4 OLED显示驱动与刷新策略显示刷新要避免“闪屏”。如果你的OLED驱动是整屏全刷每秒刷新几十次会出现闪烁。正确做法是用“分区刷新”或“帧缓冲”。我采用128字节的RAM缓存先在内存里画好一帧然后调用一次OLED_Clear再整屏写入。OLED本身有显存不需要外置帧缓冲但为了简化绘制我维护了一个屏幕缓冲区并映射到GPIO。实际驱动SSD1306的I2C写一个像素的方法是设置页地址0~7和列地址0~127。我封装的OLED_DrawChar和OLED_DrawString函数会从字模数组中取模数据填充到缓冲区。用到的大字模是16x32点阵适合显示大号数字。由于计算器需要频繁更新数字我采用“局部刷新”策略只在结果区域的矩形范围内刷新。比如结果区是屏幕的第5行到第7行页地址0x05~0x07列地址0x20~0x5F。每次刷新只写这3页数据速度显著提升肉眼看不到闪烁。代码实现中需要注意I2C通信的超时处理。如果总线挂死read/ write函数会一直卡住。用HAL库时将I2C通信的超时设置为10ms超过就重启I2C外设。4. 实操过程与核心环节实现4.1 源码目录结构与工程配置拿到工程源码后先看清目录结构大致如下STM32_Calculator/ |-- Core/ | |-- Inc/ // 头文件 | |-- Src/ // 主函数、中断 |-- Drivers/ | |-- BSP/ // OLED、MatrixKey、Systick | |-- CMSIS/ |-- MDK-ARM/ // Keil工程文件 |-- Doc/ // 说明文档工程基于标准外设库Standard Peripheral Library3.5版本如果你不习惯可以改到HAL库。源码里所有的驱动都放在BSP层硬件相关的实现与上层逻辑隔离。这样换硬件平台时只需要改BSP不需要动核心计算逻辑。在Keil MDK中打开工程前先确认自己安装了对应芯片的Device Family Pack比如Keil.STM32F1xx_DFP。工程选项里Target标签页的晶振频率要填8.0MHz后面代码里延时和波特率都依赖这个配置。如果是用内部时钟HSI需要修改SystemInit中的宏这点容易忽略。还有一点如果使用V6编译器标准外设库的很多代码会报warning甚至error。建议选择AC5编译器兼容性最好。如果你坚持用AC6需要关闭“非标准语法”等编译选项工程里我已经配置好了但其他项目不一定。4.2 编译环境与烧录流程Windows下推荐直接装Keil MDK5安装包比较庞大但一次性配置好。安装完成后需要安装STM32F1系列支持包。打开Pack Installer搜索F1点击Install即可。打开工程后先编译。如果一切正常会在MDK-ARM目录下生成Calculator.hex。如果没有生成检查Output标签页是否勾选了Create HEX File。烧录有两种方式ST-Link和USB转串口ISP。ST-Link需要接线SWDIO接PA13SWCLK接PA14GND接GND3.3V接电源。然后打开ST-Link Utility连接后点击Programming按钮选择刚编译的hex文件地址默认0x08000000。烧录成功后拔掉ST-Link按一下复位按键计算器就开始运行。如果你用ST-Link仿真还可以在线查看calc结构体的值跟踪状态机的变化这是调试逻辑问题的强大利器。我强烈建议在开发过程中使用仿真而不是盲目烧录看显示结果。4.3 核心代码详解按键事件与计算流程下面截取一段核心的ProcessKey处理代码方便大家理解整体流程void ProcessKey(uint8_t key) { if (key 0 key 9) { if (calc.state CALC_DONE) { // 等号后按数字重新开始新运算 ResetCalc(); calc.state INPUT_A; } // 追加数字到当前输入 AppendDigit(key - 0); UpdateDisplay(); } else if (key || key - || key * || key /) { if (calc.state ERROR) return; if (calc.state OP_WAIT) { // 当前已有一个运算符替换之 calc.op key; } else { // 先执行上一次运算 CalcResult(); calc.op key; calc.state OP_WAIT; } // 清空当前输入准备第二个操作数 calc.current_input 0; calc.decimal_place 0; UpdateDisplay(); } // 其他按键处理类似 }AppendDigit函数负责把数字追加到current_input上同时处理小数位void AppendDigit(uint8_t digit) { if (calc.decimal_place 0) { calc.current_input calc.current_input * 10 digit; } else { double factor pow(10, calc.decimal_place); calc.current_input digit / factor; calc.decimal_place; } }CalcResult函数根据运算符执行运算void CalcResult(void) { switch(calc.op) { case : calc.accumulator calc.current_input; break; case -: calc.accumulator - calc.current_input; break; case *: calc.accumulator * calc.current_input; break; case /: if (calc.current_input 0) { calc.state ERROR; SetDisplayString(Error); return; } calc.accumulator / calc.current_input; break; default: break; } // 结果保存到累加器 calc.current_input calc.accumulator; FormatResult(calc.accumulator, calc.display); }这里有个关键点计算完成后把累积器值赋给current_input这样“按等号后再按数字键”就能从结果继续输入。如果不这样做新输入会把结果清掉。4.4 仿真与验证用例编译烧录后建议按下面的测试用例逐一验证操作序列期望显示说明12 34 46简单加法12 34 * 5 230或严格优先级170取决于模式100 / 7 14.2857浮点除法截断1 / 0 Error除数为03 . 14 3.14小数输入5 5等号无操作12 34 C0清除8 - 2 6 3 9等号后连续运算注意严格优先级模式需要打开源码中的USE_PRECEDENCE宏并重新编译。两种模式在源码包中都预置了测试用例。仿真调试时建议在ProcessKey函数设置断点观察每一次按键后calc结构体的状态变化。逐个比对状态字段是否按预期跳转。如果发现state在某个按键后跳到了意外值通常是某个分支缺少else或者未在分支末尾更新state。这种问题用仿真很容易定位。5. 常见问题与排查技巧实录5.1 按键失灵或误触发的处理按键失灵最常见的原因是引脚配置错误。检查GPIO初始化代码确保行线是上拉输入列线是推挽输出。其次要检查扫描函数中列电平的翻转顺序例如扫描完第0列后是否把第0列恢复高电平再拉低第1列。否则两个列同时为低按键会发生串键。误触发多半是电平不稳定或者扫描间隔太短。把扫描周期改为10ms并在扫描到按键后延时20ms再确认一次左右两边的抖动都能滤掉。如果在按键扫描期间发生串口中断中断里如果执行了延时函数也会导致扫描周期不固定可以在进入扫描前关中断扫描完再开。5.2 显示乱码、花屏的排查OLED显示乱码通常有两种原因I2C地址错误或SCL/SDA接反。先检查代码中的OLED_ADDR是否等于模块实际地址。然后用逻辑分析仪抓I2C波形看设备地址后是否有ACK。如果没有ACK说明模块地址不对或外部上拉缺失。还有一种情况是字模取模方式不匹配。我用的字模是纵向取模高位在前如果你换了一个字模软件取模方式可能变成了横向取模就会显示乱码。解决方法是统一使用Standard模式纵向取模8位为一行宽度倍数为16。计算器数字使用的字模我放在了oled_font.c中可以直接复用。5.3 运算结果不对的常见原因排除硬件问题后运算结果错误一般出在状态解析上。例如输入1020得到30但输入10却得到20那是因为执行运算时把current_input当成了0但等号处理逻辑里没有初始化current_input。正确的思路是按下等号时如果当前没有第二操作数应判断为无效操作或者复用当前显示结果。另外计算中途更改运算符也要注意。比如输入10又按下用户期望是10而不是10*。我在处理中把新运算符直接赋给calc.op同时保持accumulator不变这样显示区不会出现多余的加号。如果此时显示逻辑仍然把旧运算符打印出来用户会困惑。建议显示区只显示当前部分或者显示表达式提示行。浮点数精度也是常见坑。比如0.10.2可能得到0.30000000000000004。我在FormatResult函数里对结果做了近似处理如果误差小于1e-6调整到合法小数位。具体做法是将结果加上0.0000005后取小数点后6位再去掉尾部0。这个补丁能消除绝大部分浮点误差问题。5.4 扩展建议升级为科学计算器运行起来之后可以往这几个方向扩展增加括号和复杂表达式解析使用中缀表达式转后缀表达式实现带优先级的完整计算增加32位浮点或64位浮点模式切换甚至实现大数运算用数组模拟十进制数增加历史记录功能用EEPROM或Flash存储最近20条运算记录增加键盘背光或声音反馈用无源蜂鸣器播放按键音效果会好很多更换更大屏幕比如1.3寸OLED或2.4寸TFT显示更多信息增加低功耗模式在无操作一段时间后进入STOP模式按键唤醒源码中有一个扩展接口Calc_ExecuteWithExpression(char* expr)预留了字符串表达式运算入口。如果后续接上位机或蓝牙可以通过串口直接把表达式发过来计算而不需要按键逐个输入。这个接口目前没有完全实现但给你留好了位置。6. 源码使用与后续学习建议如果你下载了这个工程源码建议不要只停留在烧录成功。把源代码完整读一遍特别是BSP驱动的写法然后尝试自己重写一遍Calc逻辑。重写时可以先不接OLED和矩阵键盘用串口调试助手输入按键字符观察计算器逻辑是否正确。这个“无硬件调试法”能省下很多焊接和接线的时间。我实际测试时用串口发送字符 1,2,,3,4, 就能得到计算结果。如果你也想用串口调试只需要在串口中断回调里把接收到的字符代换成ProcessKey即可。最后说一个个人经验做计算器这类交互系统工程上的难点不是计算本身而是输入输出时序的配合。按键扫描、显示刷新、运算逻辑三者的调度关系远比算法本身有趣。你把这三个模块捋顺之后再做菜单系统、波形显示、甚至简单游戏都会顺手很多。这套源码里的BSP驱动是通用的矩阵键盘扫描和OLED驱动copy到其他项目也能直接用。用STM32做项目最重要的就是攒一套自己的底层驱动库以后换项目就是拼积木。计算器这个项目正好帮你把这套积木的首批组件打磨出来。本文还有配套的精品资源点击获取