☰
STM32计算器工程实战:从按键消抖到表达式求值
2026/10/27 18:01:31 网站建设 项目流程

简介:这份工程源码面向嵌入式初学者与STM32开发者,用于在单片机上实现一个可运行的基础计算器,解决从外设驱动到人机交互的完整练手需求。硬件以STM32F103ZET6为核心,搭配正点原子2.5寸LCD屏,兼容战舰开发板,可完成加减乘除等基本运算并实时显示结果,适合课程设计、入门实验或自学参考。压缩包共177个文件,约4.27MB,包含32个c源文件、25个h头文件以及d、o、crf等编译中间文件,另有uvprojx、uvoptx工程配置、hex烧录文件与map、lst等构建产物,源码与工程文件齐全,导入Keil即可编译下载。目前已有4864人学习下载,配套效果视频演示地址可供对照验证。读者可从中获取完整的计算器工程结构、LCD显示与按键处理思路、运算逻辑实现方式,以及可直接复用的STM32工程模板,便于在此基础上扩展更多功能。

1. 从一块闲置的 STM32 最小系统板说起:这个计算器工程到底能跑出什么

手头有块 STM32 最小系统板,屏幕、按键、电源都焊好了,却一直找不到一个「麻雀虽小五脏俱全」的练手项目——这是很多嵌入式新手的真实处境。这份「基于 STM32 设计的计算器(实现基本运算)工程源码」正好卡在这个位置上:它不是点灯、不是串口打印,而是一个带按键输入、带显示输出、带运算逻辑的完整闭环小系统。加减乘除四则运算听起来简单,但真正落到单片机上,你要处理按键消抖、运算符优先级、连续运算的状态切换、结果显示与清屏,这些恰好是嵌入式 UI 交互的入门骨架。它适合刚学完 GPIO 和定时器、想找一个能跑通「输入—处理—输出」全链路的初学者,也适合需要快速搭一个交互 Demo 框架的熟手拿来改。下面我按「这是什么 → 怎么用 → 坑在哪」的顺序,把这份工程拆开讲透。

2. 工程结构与核心模块拆解:按键、显示、运算三块怎么咬合

拿到一份 STM32 工程源码,第一件事不是急着编译,而是先看清楚它的目录骨架和模块划分。这份计算器工程按典型的分层思路组织:底层是 STM32 标准外设库或 HAL 库的驱动,中间是按键扫描、LCD 显示、运算逻辑三个功能模块,顶层是 main 里的主循环调度。理解这个结构,你才能知道改哪里、加什么。

2.1 目录骨架与文件职责

一份合格的计算器工程,目录通常长这样(不同作者命名略有差异,但职责划分大同小异):

目录/文件职责你大概率要动的地方
Core/Src/main.c主循环、初始化调度主循环里的按键轮询与状态机
Drivers/库文件(标准库或 HAL)一般不动
Hardware/Key/按键扫描与消抖按键引脚定义、消抖时间
Hardware/LCD/显示驱动与字符/数字绘制显示坐标、清屏逻辑
App/Calculate/四则运算与表达式处理运算优先级、除零处理
MDK-ARM/ 或 EWARM/工程文件芯片型号、下载器配置

先确认工程用的是标准库还是 HAL 库,这决定了你后续查手册的方向。标准库的 GPIO 初始化是GPIO_InitTypeDef结构体直接配置寄存器,HAL 库则是HAL_GPIO_Init()配合GPIO_InitTypeDef。两者在按键读取上的写法差别不大,但时钟使能和外设初始化的调用链完全不同。如果你打算把这份工程移植到自己的板子上,第一步就是核对库类型和芯片型号是否匹配。

2.2 按键扫描:消抖与状态识别

计算器的输入全靠按键,而机械按键的抖动是绕不开的问题。工程里常见的做法是「定时器周期扫描 + 计数消抖」,而不是delay阻塞式消抖。下面是一段典型的按键扫描逻辑:

// 每 10ms 调用一次,key_raw 为当前引脚电平(按下为 0) #define KEY_DEBOUNCE_CNT 3 // 连续 3 次采样一致才确认,即 30ms 消抖 typedef enum { KEY_NONE, KEY_0, KEY_1, /* ... */ KEY_ADD, KEY_SUB, KEY_MUL, KEY_DIV, KEY_EQ, KEY_CLR } KeyType; KeyType Key_Scan(void) { static uint8_t cnt = 0; static uint8_t last = 1; uint8_t cur = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin); // 读当前电平 if (cur != last) { cnt = 0; // 电平变化,重新计数 last = cur; } else if (cnt < KEY_DEBOUNCE_CNT) { cnt++; if (cnt == KEY_DEBOUNCE_CNT && cur == 0) { return Key_Map(); // 确认按下,映射到具体按键 } } return KEY_NONE; }

这段代码的关键点有三个:一是扫描周期固定(10ms),保证消抖时间可预期;二是用「连续 N 次一致」代替delay,不阻塞主循环;三是只在「确认按下」的边沿返回一次键值,避免长按重复触发。参数KEY_DEBOUNCE_CNT是消抖强度的核心,设太小会误触发,设太大会感觉按键迟钝,一般 3 到 5 对应 30 到 50ms 比较跟手。如果你的板子按键没有硬件 RC 滤波,这个值可以适当加大。

2.3 显示模块:数字与运算符的绘制

显示部分取决于你用的屏幕。常见的是 LCD1602 字符屏或 ILI9341 这类 TFT 彩屏。字符屏简单,直接往 DDRAM 写 ASCII 码;彩屏则需要字库或取模。工程里一般会封装一个LCD_ShowString(x, y, str)和LCD_Clear(),计算器只需要在固定位置刷新数字和运算符即可。

// 在屏幕指定位置显示当前输入和结果 void Calc_Display(void) { char buf[20]; LCD_Clear(); // 清屏,避免残影 sprintf(buf, "%s", g_expr); // g_expr 保存当前表达式字符串 LCD_ShowString(10, 10, (uint8_t *)buf); if (g_has_result) { sprintf(buf, "=%s", g_result); LCD_ShowString(10, 40, (uint8_t *)buf); } }

这里有个容易忽略的点:sprintf在 Keil 里默认可能不支持浮点,如果你要显示小数结果,需要在工程选项里勾选Use MicroLIB或者改用整数运算。另外LCD_Clear()每次全屏刷新在彩屏上会有明显闪烁,熟手的做法是只刷新变化区域,或者用局部填充覆盖旧内容。

2.4 运算逻辑:从按键序列到结果

计算器的核心是把按键序列翻译成运算。最简单的实现是「即时运算」:按下运算符时先算上一步,再存新运算符。这种方式不支持括号和优先级,但胜在逻辑清晰,适合入门工程。

// 即时运算状态机(简化版) static int32_t num1 = 0, num2 = 0; static char op = 0; static uint8_t input_stage = 0; // 0:输入第一操作数 1:输入第二操作数 void Calc_OnKey(KeyType key) { if (key >= KEY_0 && key <= KEY_9) { if (input_stage == 0) num1 = num1 * 10 + (key - KEY_0); else num2 = num2 * 10 + (key - KEY_0); } else if (key == KEY_ADD || key == KEY_SUB || key == KEY_MUL || key == KEY_DIV) { if (input_stage == 1) { Calc_Compute(); num1 = g_result_int; num2 = 0; } op = Key_ToOp(key); input_stage = 1; } else if (key == KEY_EQ) { Calc_Compute(); input_stage = 0; } else if (key == KEY_CLR) { num1 = num2 = 0; op = 0; input_stage = 0; g_has_result = 0; } }

input_stage是状态机的灵魂,它区分你当前在输入第一个数还是第二个数。按下运算符时如果已经在输入第二个数,说明是连续运算,先把前面的算掉再存新运算符。这个逻辑不复杂,但边界情况多——比如连按运算符、除数为零、结果溢出,都需要单独处理。除零判断一定要加,否则在 Cortex-M 上整数除零会触发 HardFault,直接死机。

3. 从零编译到上板运行:环境搭建与下载配置

工程源码能不能跑起来,一半取决于代码,一半取决于环境。STM32 开发环境的坑集中在芯片包、库版本和下载器配置上,这一章把完整流程走一遍。

3.1 开发环境选择与芯片包安装

主流选择是 Keil MDK 或 STM32CubeIDE。Keil 的优势是工程文件通用、调试器支持广;CubeIDE 免费且和 CubeMX 集成好。如果你拿到的是.uvprojx文件,用 Keil;如果是.project或.cproject,用 CubeIDE。

Keil 下第一步是装芯片包(Device Family Pack)。打开 Pack Installer,搜索你的芯片系列,比如 STM32F103,安装对应的 DFP。芯片包没装或版本不对,编译时会报「Device not found」。这一步网络不稳的话会卡很久,建议提前下好离线包。

# 如果用命令行烧录(STM32CubeProgrammer CLI),确认连接 STM32_Programmer_CLI -c port=SWD -w build/calculator.hex -v -rst

这条命令的含义:-c port=SWD指定 SWD 接口连接,-w写入 hex 文件,-v写入后校验,-rst复位运行。如果你用 ST-Link 加 Keil,直接在 Options for Target → Debug 里选 ST-Link Debugger,Flash Download 里勾选 Reset and Run 即可。

3.2 库版本与工程配置核对

标准库和 HAL 库不能混用。如果工程用的是标准库(stm32f10x_gpio.c这类),你却在 CubeMX 里生成了 HAL 代码往里塞,编译一定报重复定义。核对方法很简单:看Drivers目录下是STM32F10x_StdPeriph_Driver还是STM32F1xx_HAL_Driver。

时钟配置也是高频翻车点。标准库工程通常在SystemInit()里配好时钟树,HAL 库则在SystemClock_Config()里。如果你的板子晶振是 8MHz,工程却按 12MHz 配置,串口波特率会偏、定时器周期会错,按键消抖时间也跟着不准。核对HSE_VALUE宏定义和实际晶振是否一致,这一步别偷懒。

3.3 下载与运行验证

编译通过只是第一步,上板运行才算数。下载后先看屏幕有没有正常初始化——如果花屏或白屏,多半是 LCD 初始化序列或引脚定义不对。然后逐个按键测试,观察显示是否跟手。

验证顺序建议这样走:先测单个数字键能否正确显示,再测运算符键能否切换状态,最后测等号能否出结果。如果某个键没反应,用万用表量引脚电平,确认是硬件接触问题还是代码映射问题。串口打印是个好帮手,在按键扫描函数里加一句printf输出键值,能快速定位是扫描没触发还是映射错了。

提示:下载后如果程序不跑,先检查 BOOT0/BOOT1 跳线是否在 Flash 启动模式,再检查复位电路。这两个是「代码没问题但板子没反应」的常见元凶。

4. 避坑与排查:那些让计算器「算不对」的细节

代码能跑不代表算得对。计算器这类交互程序,bug 往往藏在状态切换和边界条件里。下面是我在实际调试中踩过的几个坑,按「现象 → 原因 → 解决」列出来。

4.1 连续运算结果错乱

现象:输入1 + 2 + 3 =,结果显示 3 而不是 6。 原因:按下第二个+时没有先把1+2算掉,而是直接用num2覆盖了结果,导致前一步丢失。 解决:在运算符处理分支里加判断——如果input_stage == 1,先调用Calc_Compute()把当前结果算出来存回num1,再存新运算符并重置num2。这个顺序不能反。

4.2 除零导致死机

现象:输入5 / 0 =,屏幕卡死或复位。 原因:Cortex-M 的整数除法指令遇到除数为零会触发 UsageFault 或 HardFault,程序直接跑飞。 解决:在Calc_Compute()里对除法单独判断,除数为零时把结果设为一个错误标志(比如显示Error),不执行除法指令。浮点除法虽然不触发硬件异常,但会得到inf,同样要拦截。

4.3 按键长按重复触发

现象:按住数字键不放,屏幕上连续出现多个相同数字。 原因:扫描函数在按键持续按下时每次都返回键值,没有做「边沿检测」。 解决:在扫描逻辑里加一个「上次按键状态」变量,只在「上次未按下、本次按下」时才返回键值。或者用「按下标志位」,处理完一次后清零,直到松手才允许下次触发。

4.4 显示残影与闪烁

现象:刷新结果时旧数字没被完全覆盖,或者整屏闪烁严重。 原因:LCD_Clear()全屏刷新耗时,且新旧内容长度不同时残留字符。 解决:固定显示区域宽度,用空格填充补齐;或者只刷新变化的那一行。彩屏可以用局部填充函数LCD_Fill(x, y, x+w, y+h, BACK_COLOR)覆盖旧区域,比全屏清快得多。

4.5 结果溢出显示异常

现象:计算9999 * 9999后显示负数或乱码。 原因:int类型溢出,结果超出 32 位有符号范围。 解决:根据需求选择int64_t或加溢出判断。如果只是教学 Demo,可以在运算前检查操作数范围,超出就提示Overflow。别小看这个,溢出后的值参与后续运算会一路错下去。

5. 进阶改造:把即时运算升级成带优先级的表达式求值

基础版计算器只能从左到右算,2 + 3 * 4会得到 20 而不是 14。如果你想让这个工程更有含金量,可以把它改造成支持运算符优先级的表达式求值器。这一步不难,但能让你的工程从「练手」变成「能写进简历」。

5.1 用双栈法实现优先级

经典做法是两个栈:一个存操作数,一个存运算符。遍历表达式时,遇到运算符就和栈顶运算符比优先级,栈顶优先级高就先弹出计算。下面是核心逻辑:

// 双栈表达式求值(支持 + - * / 和括号) #define MAX_STACK 32 static int32_t num_stack[MAX_STACK]; static char op_stack[MAX_STACK]; static int num_top = -1, op_top = -1; int OpPriority(char op) { if (op == '+' || op == '-') return 1; if (op == '*' || op == '/') return 2; return 0; // '(' 或结束符 } void ApplyOp(void) { int32_t b = num_stack[num_top--]; int32_t a = num_stack[num_top--]; char op = op_stack[op_top--]; int32_t r = 0; switch (op) { case '+': r = a + b; break; case '-': r = a - b; break; case '*': r = a * b; break; case '/': r = (b == 0) ? 0 : a / b; break; // 除零保护 } num_stack[++num_top] = r; } int32_t EvalExpression(const char *expr) { num_top = op_top = -1; for (int i = 0; expr[i]; ) { if (expr[i] >= '0' && expr[i] <= '9') { int32_t v = 0; while (expr[i] >= '0' && expr[i] <= '9') v = v * 10 + (expr[i++] - '0'); num_stack[++num_top] = v; } else if (expr[i] == '(') { op_stack[++op_top] = expr[i++]; } else if (expr[i] == ')') { while (op_top >= 0 && op_stack[op_top] != '(') ApplyOp(); op_top--; // 弹出 '(' i++; } else { while (op_top >= 0 && OpPriority(op_stack[op_top]) >= OpPriority(expr[i])) ApplyOp(); op_stack[++op_top] = expr[i++]; } } while (op_top >= 0) ApplyOp(); return num_stack[num_top]; }

这段代码的关键在于OpPriority和ApplyOp的配合:遇到新运算符时,只要栈顶优先级不低于它,就先算栈顶,保证高优先级先执行。括号的处理是遇到右括号就一直弹到左括号为止。MAX_STACK要根据你的表达式长度调整,太小会栈溢出,太大会浪费 RAM——STM32F103 只有 20KB RAM,32 深度一般够用。

5.2 从按键序列到表达式字符串

双栈法吃的是字符串,而计算器产生的是按键序列。你需要在按键处理里维护一个表达式缓冲区,把数字和运算符拼成字符串,等号按下时再调用EvalExpression。

static char g_expr[64]; static uint8_t g_expr_len = 0; void Calc_OnKey_Advanced(KeyType key) { if (key >= KEY_0 && key <= KEY_9) { if (g_expr_len < sizeof(g_expr) - 1) g_expr[g_expr_len++] = '0' + (key - KEY_0); } else if (key == KEY_ADD) { g_expr[g_expr_len++] = '+'; } else if (key == KEY_SUB) { g_expr[g_expr_len++] = '-'; } else if (key == KEY_MUL) { g_expr[g_expr_len++] = '*'; } else if (key == KEY_DIV) { g_expr[g_expr_len++] = '/'; } else if (key == KEY_EQ) { g_expr[g_expr_len] = '\0'; int32_t r = EvalExpression(g_expr); sprintf(g_result, "%ld", (long)r); g_has_result = 1; g_expr_len = 0; // 清空,准备下一次输入 } else if (key == KEY_CLR) { g_expr_len = 0; g_has_result = 0; } g_expr[g_expr_len] = '\0'; Calc_Display(); }

这里要注意缓冲区边界:g_expr_len每次自增前都要检查是否越界,否则长表达式会踩内存。sprintf用%ld对应int32_t,别用%d,否则在 64 位机上可能出问题——虽然 STM32 是 32 位,但养成类型匹配的习惯没坏处。

5.3 验证方法与一个实用技巧

改完之后怎么验证优先级对不对?准备一组测试用例,覆盖各种组合:

表达式期望结果考察点
2+3*414乘法优先
(2+3)*420括号优先
10-2-35同级左结合
8/2/22连续除法
5/0Error除零保护
2+3*4-6/211混合运算

在 PC 上先用同样的逻辑跑一遍这些用例,确认算法没问题再烧到板子上。这样能把「算法错」和「硬件/显示错」分开排查,省很多时间。

一个实用技巧:把EvalExpression单独抽成一个.c文件,不依赖任何硬件,这样你可以在 PC 上用 gcc 编译测试,几秒钟跑完几十个用例。嵌入式开发里,能把纯逻辑和硬件解耦的部分尽量解耦,调试效率会高一个量级。从那以后我每次写这类交互逻辑,都强制先在 PC 上把算法跑通再上板,这个习惯帮我省下了无数个对着屏幕猜的夜晚。希望这份拆解能帮你把这块板子真正跑起来。

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

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

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

立即咨询