STM32计算器实战:从按键去抖到表达式解析的完整链路 简介这套基于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 个 GPIO74HC595到 12 个 GPIO直接驱动代码简单但无法展示表达式本身LCD1602 能显示两行字符接线 11 根适合课设里“看到完整输入过程”的要求OLED 128×64 走 I2C 或 SPII2C 模式仅占用 PB6、PB7 两个引脚同时支持显示运算符和错误提示是当前 STM32 项目里最常见的搭配。选型参考下表按“引脚占用 驱动难度 显示信息量”三个维度对比显示方案引脚占用字符刷新时机典型场景4 位数码管动态扫描8~12每 2ms 刷新一位只显示结果最小系统验证LCD16024 线模式6每次按键后刷新课设展示表达式与结果OLED 128×64I2C2全量刷新或局部刷新毕设综合项目、菜单扩展推荐用 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分频后频率为 1kHzvoid 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 ”时程序接收到的都是一串顺序按键事件。解析器面对的是两个不同的输入缓冲数字拼接缓冲区和完整表达式缓冲区。每按一个数字键先拼到当前数字缓冲每按一个运算符把当前数字缓冲刷新进表达式再把运算符追加进去等号按下时把缓冲好的完整字符串交给解析器。用户按键序列数字缓冲内容表达式缓冲区内容111221×12×3312×解析触发12×3这个表格演示的是边界情况乘法比加法优先级高所以“12×3”的结果必须是 7 而不是 9。很多实现直接按“读两个数做一次运算”的方式处理得到 9 说明跳过了表达式优先级这一步这是计算器项目最容易暴露逻辑漏洞的地方。3.3 调度场算法在裸机上的 C 实现下面给出完整的表达式解析与求值代码。输入是用户按顺序拼好的中缀字符串例如12*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 内核没有硬件 FPUdouble运算和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 12*3main.c里写一个简单的循环从命令行参数读取中缀表达式调用evaluate_expression()用printf打印结果。这样写的好处是可以批量准备测试用例比如把二十种边界情况写进一个 shell 脚本一键验证。下面是一个配合脚本的测试表测试输入期望输出验证点12*37乘法优先级高于加法2*317乘除在加减前计算8/2-13混合运算顺序9/0nan除零不崩溃返回 NaN0-5-5一元负号处理5.2 写一个小型断言测试函数在命令行版本里加一个断言测试函数替代脚本比对的方式直接输出 PASS/FAIL#include assert.h #include math.h void test_cases(void) { assert(fabs(evaluate_expression(12*3) - 7.0) 1e-9); assert(fabs(evaluate_expression(2*31) - 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 版本把解析器打死再上板调按键和显示剩下的问题就只剩硬件本身了。本文还有配套的精品资源点击获取