简介:这套基于STM32的简易计算器项目工程包,面向单片机毕设、课设、大作业及竞赛等场景,适合需要完整源码与工程结构参考的嵌入式学习者。系统以普中STM32精灵1开发板为基础,功能涵盖OLED中文欢迎界面、左右两组各四位数码管的闪烁输入、0~9数字及加减乘除求余运算、K3作为等号、K2清空,并支持按键外部中断与轮询方式;OLED实时显示按键符号,便于观察运算过程,代码可直接验证或移植至其他STM32板卡。压缩包共324个文件,以c/h源程序、axf/hex可执行文件、uvprojx工程配置为主,另有o/crf/d等编译中间文件及map/lst调试辅助文件,整体约50.56MB,目录模块清晰,适合二次开发。当前已有220人学习下载,包内除源码工程外,还提供演示视频与测试说明,可帮助快速复现计算器逻辑,并据此扩展更丰富的功能,是毕业设计、课程设计或实训项目的高价值参考。
1. 一块 STM32 计算器,值钱的不是题目,是按键到显示的那条链路
深夜把 STM32F103C8T6 最小系统板接上 OLED,按下 1 + 2 × 3,屏幕能给出 9,而不是 7,这个项目才算真正落地。很多人把“基于 STM32 的计算器”当成一个 GPIO 练习,实际上它横跨了嵌入式开发的三个核心断面:按键输入的去抖与状态机、中缀表达式的解析与求值、浮点结果的格式化与异常处理。任何一个断面偷懒,程序就会以“按键不灵”“算式顺序错”“除零死机”的形式在答辩现场找回来。
这篇文字写给三类人:正在做毕设或课设、手里有标准库工程但不知道从哪下手的在校生;想把裸机程序从“能跑”推进到“可复现、可排错”的初级工程师;以及需要在 STM32 上实现类似键盘输入的竞赛团队。标题里的“资料编号 22”只代表文件打包方式,和这篇方案无关。以下内容不依赖任何现成源码包,直接按硬件、输入、解析、显示、验证五个环节展开,每段都给出能抄的参数和代码。
2. 硬件输入链路:矩阵键盘扫描、去抖与键值队列
2.1 显示方案选型:数码管、LCD1602 还是 OLED
计算器的显示方案决定了工程规模。三选一先看需求:只显示一个结果,4 位或 8 位数码管足够,占 2 个 GPIO(74HC595)到 12 个 GPIO(直接驱动),代码简单但无法展示表达式本身;LCD1602 能显示两行字符,接线 11 根,适合课设里“看到完整输入过程”的要求;OLED 128×64 走 I2C 或 SPI,I2C 模式仅占用 PB6、PB7 两个引脚,同时支持显示运算符和错误提示,是当前 STM32 项目里最常见的搭配。
选型参考下表,按“引脚占用 + 驱动难度 + 显示信息量”三个维度对比:
| 显示方案 | 引脚占用 | 字符刷新时机 | 典型场景 |
|---|---|---|---|
| 4 位数码管(动态扫描) | 8~12 | 每 2ms 刷新一位 | 只显示结果,最小系统验证 |
| LCD1602(4 线模式) | 6 | 每次按键后刷新 | 课设展示表达式与结果 |
| OLED 128×64(I2C) | 2 | 全量刷新或局部刷新 | 毕设综合项目、菜单扩展 |
推荐用 OLED I2C 方案。常见驱动库是 0.96 寸、SSD1306 控制器的屏,地址一般是 0x3C 或 0x3D。硬件连接时注意 SCL 和 SDA 上拉电阻,板载模块通常已带,若是裸屏则需要外接 4.7kΩ 到 3.3V。STM32F103C8T6 的内部上拉能力不足以可靠驱动 I2C,这点在飞线时很容易忽略。
2.2 键盘结构与扫描方式的选择
计算器至少需要 16 个键:0~9、四个运算符、等号、清除、退格。4×4 矩阵键盘正好对应 16 个按键位,占用 8 个 GPIO,相比独立按键每个占用一个引脚的方式省了一半资源。引脚分配习惯上会把行线接低四位、列线接高四位,例如行 PA0~PA3、列 PA4~PA7。扫描方法有两种,列扫描法和行列反转法。
列扫描法的思路是:先把所有行线设为输出低电平,读列线电平判断是否有键按下;然后再逐行拉低,配合列读值定位具体键位。行列反转法更节省指令周期,但需要引脚在输入输出间切换,代码可读性稍差。计算器按键频率低,远达不到性能瓶颈,选择代码直观的列扫描即可。一次完整扫描耗时不到 0.1ms,不会阻塞主循环。
按键去抖是另一个必须处理的环节。机械按键按下瞬间会产生 5~20ms 的电平抖动,常见处理是延迟消抖:检测到按下后等待 20ms 再读一次,两次一致才确认有效。这个方案在主循环里用 delay 就能实现,但对需要同时维护采样周期和 OLED 刷新的裸机程序来说,更好的做法是在定时器中断里做状态机扫描。
2.3 定时器驱动的按键状态机实现
把按键扫描放进定时器中断,由硬件定时器产生 1ms 周期节拍,每次中断都执行一次采样,用状态机记录“未按下—可能按下—已按下”,连续 20 次(约 20ms)采样为低才确认按键有效。这样可以彻底去掉主循环中的阻塞延时,让按键扫描和 OLED 刷新互不干扰。下面给出可移植到标准库的写法,使用 TIM2,分频后频率为 1kHz:
void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) == SET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); key_scan_tick(); /* 每 1ms 调用一次采样函数 */ } } static uint8_t key_state = 0; /* 0: 释放 1: 按下确认 2: 释放确认 */ static uint8_t key_debounce_cnt = 0; void key_scan_tick(void) { static uint8_t last_sample = 1; uint8_t sample = KEY_READ_ALL(); /* 读八位键值,按位取反后 1 表示按下 */ if (sample == 0x00) { if (key_state == 1) { /* 从按下回到释放,产生一次释放事件 */ key_state = 2; key_fifo_push(KEY_NONE); /* 可在此处理长按逻辑 */ } key_debounce_cnt = 0; last_sample = 0; return; } if (sample == last_sample) { if (key_state == 0) { key_debounce_cnt++; if (key_debounce_cnt >= 20) { key_state = 1; key_fifo_push(sample); /* 消抖确认,把键值推进队列 */ } } } else { key_debounce_cnt = 0; /* 采样值变化,重新计数 */ } last_sample = sample; }这段代码的要点在key_debounce_cnt的累计逻辑:连续 20 次采样值相同才认为按键稳定,比单纯延时 20ms 更抗干扰。KEY_READ_ALL()返回一个 8 位掩码,哪一位为 1 表示该键按下。按键确认后,把掩码推入 FIFO 队列而不是直接处理,这样主循环可以按自己的节奏取键值,不会因为某个按键处理耗时导致后续按键丢失。队列长度取 16 即可,正常情况下用户不可能在几十毫秒内连按超过 16 个键。
提示:TIM2 的预分频系数计算方式为
Prescaler = 72MHz ÷ 1000Hz - 1 = 7199,即TIM_TimeBaseStructure.TIM_Prescaler = 7200 - 1。这里减一是因为 STM32 的定时器计数是从 0 开始的。
3. 表达式解析:从中缀到后缀的调度场算法
3.1 为什么计算器核心不用递归下降
拿到“计算正确优先级的计算器”这个需求,第一反应是用递归下降解析器,把文法写成expr → term (+|-) term,再逐层下降处理乘法。这在 PC 上是标准做法,代码结构清晰、扩展函数和括号容易。但放在 STM32 裸机上要权衡两个问题:一是递归调用会占用栈空间,C8T6 的 SRAM 只有 20KB,启动文件默认栈大小 1KB,递归深度稍大就会溢出;二是维护一个递归下降解析器需要更长的代码量,对毕设和课设来说,调试成本偏高。
计算器这个场景有个更紧凑的方案:先把中缀表达式转换成后缀表达式(逆波兰式),再通过一个数值栈完成求值。整个过程只需要两个数组和一个指针,不涉及递归调用,代码量约 60 行。四种运算符加等号,这个算法在可读性、正确性和 STM32 资源占用之间取得了平衡。
3.2 运算符优先级与负号歧义处理
中缀转后缀遵循两条规则:数字直接输出到后缀队列;运算符入场前,把栈中优先级不低于自己的运算符先弹出。优先级映射为+和-是 1,×和÷是 2。处理负数时需要特殊标记:当-出现在表达式开头或紧跟另一个运算符时,当作一元负号处理,将其转换为在数字前补0再执行减法,这样-3被转换成0 - 3,与常规数学语义一致。
按键输入与表达式解析之间的状态区分也很重要。用户按“1 + 2 =”和用户按“1 + 2 × 3 =”时,程序接收到的都是一串顺序按键事件。解析器面对的是两个不同的输入缓冲:数字拼接缓冲区和完整表达式缓冲区。每按一个数字键,先拼到当前数字缓冲;每按一个运算符,把当前数字缓冲刷新进表达式,再把运算符追加进去;等号按下时,把缓冲好的完整字符串交给解析器。
| 用户按键序列 | 数字缓冲内容 | 表达式缓冲区内容 |
|---|---|---|
| 1 | "1" | "" |
| + | "" | "1+" |
| 2 | "2" | "1+" |
| × | "" | "1+2×" |
| 3 | "3" | "1+2×" |
| = | 解析触发 | "1+2×3" |
这个表格演示的是边界情况:乘法比加法优先级高,所以“1+2×3”的结果必须是 7 而不是 9。很多实现直接按“读两个数做一次运算”的方式处理,得到 9 说明跳过了表达式优先级这一步,这是计算器项目最容易暴露逻辑漏洞的地方。
3.3 调度场算法在裸机上的 C 实现
下面给出完整的表达式解析与求值代码。输入是用户按顺序拼好的中缀字符串,例如"1+2*3",输出浮点结果。考虑到代码长度,这里用*和/替代×和÷,移植时把键值映射改一下即可:
#include <string.h> #include <stdio.h> #define MAX_EXPR_LEN 64 static double value_stack[MAX_EXPR_LEN]; static int vs_top = -1; static char op_stack[MAX_EXPR_LEN]; static int os_top = -1; static void push_val(double v) { value_stack[++vs_top] = v; } static double pop_val(void) { return value_stack[vs_top--]; } static void push_op(char c) { op_stack[++os_top] = c; } static char pop_op(void) { return op_stack[os_top--]; } static int op_priority(char c) { if (c == '+' || c == '-') return 1; if (c == '*' || c == '/') return 2; return 0; } double evaluate_expression(const char *expr) { vs_top = -1; os_top = -1; push_op('#'); /* 栈底哨兵,避免空栈判断 */ int len = strlen(expr); int i = 0; while (i < len) { char c = expr[i]; if (c >= '0' && c <= '9') { double num = 0; while (i < len && (expr[i] >= '0' && expr[i] <= '9')) { num = num * 10.0 + (expr[i] - '0'); i++; } push_val(num); continue; } if (c == '+' || c == '-' || c == '*' || c == '/') { while (op_priority(op_stack[os_top]) >= op_priority(c)) { char op = pop_op(); double b = pop_val(); double a = pop_val(); if (op == '+') push_val(a + b); else if (op == '-') push_val(a - b); else if (op == '*') push_val(a * b); else if (op == '/') { if (b == 0.0) return 0.0 / 0.0; /* 除零标记 NaN */ push_val(a / b); } } push_op(c); i++; continue; } i++; /* 其他字符如空格、括号,按需扩展 */ } while (os_top >= 0 && op_stack[os_top] != '#') { char op = pop_op(); double b = pop_val(); double a = pop_val(); if (op == '+') push_val(a + b); else if (op == '-') push_val(a - b); else if (op == '*') push_val(a * b); else if (op == '/') { if (b == 0.0) return 0.0 / 0.0; push_val(a / b); } } return pop_val(); }这段代码把运算符弹出并求值的逻辑重复写了两次:一次在扫描过程中,一次在表达式结束后清理栈内剩余运算符。更工程化的写法是抽一个apply_op函数,这里保持展开是为了便于单步调试观察两个栈的变化。return 0.0 / 0.0会产生 NaN 表示除零,调用方通过isnan()判断并显示错误提示。
注意:这个实现只处理正整数输入。如果要支持负数和小数点,需要增加状态标记:
-前一个是运算符或开头,则判定为一元负号;数字解析循环中加入小数点分支,用%/的倍数累加。毕设若表现小数,这两个分支必须补上。
4. 显示与状态管理:OLED 刷新机制和浮点精度兜底
4.1 printf 重定向到串口与 OLED 的同步策略
调试计算器逻辑时,串口打印比看 OLED 高效得多。先把 printf 重定向到 USART1,利用 STM32 标准库中fputc的宏重定义:
int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); return ch; }参数说明:USART_FLAG_TXE表示发送数据寄存器空,等待它置位再写下一个字符,可以确保多字节字符串输出时不会丢字符。OLED 刷新则走 SSD1306 的绘制接口。常见驱动中,OLED_ShowString(0, 0, buf, 16)按 16×16 点阵写入显存,之后调用OLED_Refresh()一次把全部显存推送到屏幕。全程不实用 delay,按键扫描由 TIM2 中断维持,OLED 刷新放到主循环。
显示模块与计算逻辑之间通过共享缓冲区传递数据。主循环每消费一个键值,更新按键缓冲与表达式显示区域;等号事件触发解析器计算,把结果格式化后写进 OLED。OLED 是慢速设备,I2C 时钟通常设为 400kHz,一屏数据约 1KB,全量刷新耗时约 5ms。计算器界面简单,全量刷新足以维持流畅。
4.2 float 与 double 的选择:为什么解析器用 double 但显示要掐位数
代码里求值栈用了double,这是刻意的。C8T6 是 Cortex-M3 内核,没有硬件 FPU,double运算和float一样靠软浮点库,运算时间无法从内核指令直接获益。但在范围捕捉上,double的有效数字位数更高,计算类似1 / 3时保存更多中间位,连续运算的累计误差小于float。内存代价是每个值从 4 字节变成 8 字节,64 项的值栈最多多占 256B,可接受。
显示端要处理的是另一类问题:浮点格式化输出。printf("%.2f", 3.14159)会输出3.14,但%f对 0.1 和 0.2 的二进制近似会产生 0.30000000000000004 之类的尾差。最简单的方式是统一显示两位小数:
char buf[20]; double result_val = evaluate_expression(expr_buffer); if (isnan(result_val)) { OLED_ShowString(32, 0, "Error", 16); /* 除零直接显示错误 */ } else { if (result_val == (int)result_val) { sprintf(buf, "%d", (int)result_val); /* 整数不显示小数点 */ } else { sprintf(buf, "%.2f", result_val); /* 其余显示两位小数 */ } OLED_ShowString(0, 0, buf, 16); }整数判断result_val == (int)result_val对超大结果有溢出风险,计算器场景下结果范围一般可控,超过int上限的情况在毕业后继续保持否则没必要做专门处理。按需加上限制即可:结果绝对值超过 9999999 时显示Err,既避免显示溢出也符合计算器常规习惯。
4.3 连续运算状态机:按“=”后的下一次按键行为
计算器常见的隐藏需求是“连算”:按下=得出结果后,接着按+,显示器上应该把结果当作第一个操作数;接着按数字键,则应该重新开始。这个逻辑如果不在主循环里设置状态位,会出现“算出结果后按数字键,数字直接粘在旧结果后面”的奇怪现象。
实现方式是在计算完成后设置一个result_done标志。按键分发函数读取该标志:如果下个键是数字,则清空表达式缓冲区重新拼数字;如果是运算符,则把当前结果显示为第一操作数,继续追加运算符。这个标志在每次按键按下时都会重新判断,所以不会出现状态遗漏。
5. 用 PC 端测试替身验证解析器:把算法层单独编译跑通
5.1 把算法抽成纯 C 模块是第一步
计算器项目里最不容易一次写对的是表达式求值逻辑,它不依赖任何 STM32 外设。与其反复烧录调试,不如把解析和求值代码拆成独立的expr.c和expr.h,在 PC 上用 gcc 编译一个命令行版本,输入算式直接输出结果。这套做法本质上是用 host 端的开发效率换目标板的烧录时间,和驱动层的HAL无关,只依赖标准 C 库。
Linux 或 Windows 的 MSYS2 环境下执行:
gcc -o expr_test expr.c main.c -lm ./expr_test "1+2*3"main.c里写一个简单的循环,从命令行参数读取中缀表达式,调用evaluate_expression(),用printf打印结果。这样写的好处是可以批量准备测试用例,比如把二十种边界情况写进一个 shell 脚本,一键验证。下面是一个配合脚本的测试表:
| 测试输入 | 期望输出 | 验证点 |
|---|---|---|
1+2*3 | 7 | 乘法优先级高于加法 |
2*3+1 | 7 | 乘除在加减前计算 |
8/2-1 | 3 | 混合运算顺序 |
9/0 | nan | 除零不崩溃,返回 NaN |
0-5 | -5 | 一元负号处理 |
5.2 写一个小型断言测试函数
在命令行版本里加一个断言测试函数,替代脚本比对的方式,直接输出 PASS/FAIL:
#include <assert.h> #include <math.h> void test_cases(void) { assert(fabs(evaluate_expression("1+2*3") - 7.0) < 1e-9); assert(fabs(evaluate_expression("2*3+1") - 7.0) < 1e-9); assert(fabs(evaluate_expression("8/2-1") - 3.0) < 1e-9); assert(isnan(evaluate_expression("9/0"))); assert(fabs(evaluate_expression("0-5") - (-5.0)) < 1e-9); printf("all test cases passed\n"); }浮点断言必须用fabs(...) < 1e-9而不是==,因为double的二进制表示无法精确表达所有十进制小数。1e-9这个容差对应计算器场景下两位小数的显示精度完全足够。断言失败时assert会直接终止程序并输出行号,定位问题比看输出日志更快。
5.3 把超时机制加进主循环防死机
解析器在 PC 上通过全部测试之后,移植回 STM32 只改显示和按键读取。此时还有一个隐藏问题:如果用户按键顺序非法,例如开头就按*,解析器会不会陷入死循环?前面给出的evaluate_expression遇到*时会尝试pop_op()和pop_val(),而栈为空时vs_top减到 -1 以下,数组越界,结果不可预知。
解决办法是在表达式入解析前做一个快速合法性检查:
int validate_expr(const char *expr) { int len = strlen(expr); if (len == 0) return 0; char first = expr[0]; if (first == '*' || first == '/') return 0; /* 不能以乘除开头 */ if (expr[len-1] == '+' || expr[len-1] == '-' || expr[len-1] == '*' || expr[len-1] == '/') return 0; return 1; }如果非法则直接显示Error,不进入解析器。这行防御性检查会把绝大多数手滑误触拦截在解析边界之外。最后再强调一下调试优先级:先用 PC 版本把解析器打死,再上板调按键和显示,剩下的问题就只剩硬件本身了。
本文还有配套的精品资源,点击获取