简介:基于STM32微控制器的计算器仿真项目,完整演示了嵌入式系统中四则运算与人机交互的实现流程。资源面向单片机爱好者、嵌入式初学者及STM32开发者,涵盖从GPIO配置、按键中断读取、LCD/串口显示到运算逻辑与异常处理等环节,适合入门ARM Cortex-M开发和课程设计参考。 RAR压缩包大小16.07MB,内含390个文件,包含Keil/STM32CubeIDE工程源码(.c/.h)、编译中间文件(.o/.d/.crf)、烧录文件(.axf/.hex)及链接脚本和调试配置(.sct/.map/.uvprojx)等,便于直接打开工程查看代码、重新编译或烧录验证。 目前已有3456人学习下载。通过这套工程,可以学习STM32 HAL库的外设初始化、按键扫描与防抖、运算表达式处理和结果显示模块,并能在此基础上扩展科学计算功能,对系统理解STM32开发流程与软硬件联合调试有实际帮助。
1. 选择STM32计算器仿真的形态:真机、Proteus 与 Wokwi 的边界
做过嵌入式开发的人都有过这种经历:板子还没到手,或者LCD时序调了半天看不出问题,最后发现是初始化顺序错了。STM32计算器仿真这个题目,正好把外设驱动、按键消抖、算法封装这三件事压在一起,既能当课程设计,也能当入职后的练手项目。真正决定仿真好不好用的,不是画了个STM32芯片加几个按键,而是你选择在哪一层做仿真:是直接在Proteus里搭电路,还是用Wokwi跑起来看串口日志,又或者干脆编译一个固件扔进QEMU里跑裸机代码。这些形态的调试手段完全不同,踩的坑也不一样。本文就以STM32计算器仿真为主线,从按键、显示、表达式解析一直讲到边界测试,给出可直接复现的工程做法。
2. 按键矩阵与LCD显示:STM32最小外设闭环
2.1 按键矩阵扫描的两种实现方式
计算器输入最常用的方案是4x4矩阵键盘,16个按键刚好覆盖0-9、加减乘除、等号和清除。矩阵扫描的原理是:把4根行线设为输入,4根列线设为推挽输出。扫描时逐列拉低,再读回行线电平,组合出按键编号。这个方案比16个独立GPIO省了8个引脚,但代价是需要处理“多键同时按下”的情况,以及抖动带来的误触。
我一般会在仿真阶段直接用轮询扫描,不用中断。原因很简单:计算器不是高频交互设备,按键事件最多每秒几次,轮询完全够用。在Proteus或者Wokwi里做仿真时,中断反而会引入时序抖动,干扰你观察LCD和按键的因果关系。
// 4x4矩阵扫描:每次调用返回一个按键编号,0xFF表示无按键 // key_map的行列索引与GPIO引脚的对应关系见下文表格 uint8_t matrix_scan(void) { uint8_t row, col; for (col = 0; col < 4; col++) { // 拉低当前列,其余列保持高电平 HAL_GPIO_WritePin(COL_PORT, COL_PINS[col], GPIO_PIN_RESET); // 小延时等待电平稳定,仿真环境下尤其需要 delay_us(10); for (row = 0; row < 4; row++) { if (HAL_GPIO_ReadPin(ROW_PORT, ROW_PINS[row]) == GPIO_PIN_RESET) { // 等待释放,避免一次按下触发多次 while (HAL_GPIO_ReadPin(ROW_PORT, ROW_PINS[row]) == GPIO_PIN_RESET); return key_map[row][col]; } } // 恢复该列为高电平,再拉低下一列 HAL_GPIO_WritePin(COL_PORT, COL_PINS[col], GPIO_PIN_SET); } return 0xFF; }这段代码里有个关键点:while等待按键释放会让主循环阻塞,如果按键一直不松开,LCD刷新会卡住。实际项目里可以改成状态机,把“等待释放”变成一个非阻塞的按键状态寄存器。但对于仿真调试,阻塞式更容易观察信号波形,所以我建议先在仿真里跑通阻塞式,再改成状态机。
按键引脚的分配直接决定PCB布线或者仿真连线的工作量。一个常用做法是把PC0-PC3分配为行线,PC4-PC7分配为列线,这样在Proteus里连线最短。
| 引脚 | 方向 | 对应按键行/列 |
|---|---|---|
| PC0-PC3 | 输入上拉 | 行0-行3 |
| PC4-PC7 | 输出推挽 | 列0-列3 |
| PB8-PB15 | 输出 | LCD1602数据线D0-D7 |
2.2 LCD1602时序的仿真注意点
计算器显示一般用LCD1602就够了,16x2字符刚好显示一行表达式和一行结果。LCD1602的读写作时序有三个要点:使能脉冲至少要维持几十纳秒,RS和RW的电平要在使能下降沿之前建立,以及初始化时序必须严格按数据手册的延时来。
在Proteus里跑LCD1602时,最容易踩的坑是初始化顺序不对。芯片上电后需要等待40ms以上,再依次写入8位初始化指令。如果你用STM32CubeMX生成的工程,默认HSE时钟是8MHz外部晶振,系统时钟配到72MHz。LCD的延时函数不能直接用HAL_Delay,因为HAL_Delay基于SysTick,如果在中断里调用会卡死。
// LCD1602写指令:RS=0,RW=0,E引脚产生一个下降沿 void lcd_cmd(uint8_t cmd) { HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_RW_PORT, LCD_RW_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_DB_PORT, LCD_DB_PINS, cmd); // 8位并口 HAL_GPIO_WritePin(LCD_E_PORT, LCD_E_PIN, GPIO_PIN_SET); delay_us(1); HAL_GPIO_WritePin(LCD_E_PORT, LCD_E_PIN, GPIO_PIN_RESET); delay_us(40); } void lcd_init(void) { delay_ms(50); // 上电等待 lcd_cmd(0x38); // 8位模式,2行,5x7字体 lcd_cmd(0x0C); // 显示开,光标关 lcd_cmd(0x06); // 写入后指针自动加1 lcd_cmd(0x01); // 清屏 }这段代码里的0x38、0x0C、0x06、0x01四条命令是LCD1602初始化的标准序列,顺序不能换。在仿真里,如果你发现屏幕只显示一排黑块,先查0x38是否发送成功,再看RS引脚是不是被错误地设成了高电平。另一个仿真特有的问题是,Proteus的LCD模型对时序要求没有真实芯片那么严格,有时候代码里时序有问题但仿真依然正常显示,这时不要高兴得太早,换到Wokwi或者真机验证时,问题就会暴露出来。
2.3 用STM32CubeMX配置时钟与引脚
开发环境里有一个高频搜索词叫“keil5兼容c51和stm32安装”,这说明很多人卡在环境搭建上。一个干净的仿真环境建议这样配:STM32CubeMX生成初始化代码,Keil MDK编译,Proteus或Wokwi加载hex文件。CubeMX里把PC0-PC7设为GPIO_Input/GPIO_Output,PB8-PB15设为GPIO_Output,时钟树里HSE选择Crystal/Ceramic Resonator,PLL倍频到72MHz。
时钟配置有个细节:仿真器里晶振频率要和CubeMX里填的完全一致。Proteus里双击STM32芯片属性,把晶振频率改成8MHz,如果你用CubeMX默认的HSE_VALUE=25000000,那么生成的延时函数时间全偏了,LCD初始化可能刚好卡在临界点。这就是热词里“stm32 晶振电容计算”的实际意义——虽然仿真不需要电容,但晶振频率的数值一致性直接决定串口波特率和延时是否准确。
3. 表达式解析与IEEE754浮点:把按键序列变成计算结果
3.1 中缀转后缀:调度场算法
计算器最核心的算法是把12+3*4这种中缀表达式转成后缀12 3 4 * +,再用栈计算。为什么不用中缀直接算?因为中缀表达式有优先级和括号,需要回溯。调度场算法是Dijkstra提出的经典方案,用两个栈:一个存操作数,一个存运算符。遍历输入序列,遇到数字压入操作数栈,遇到运算符则弹出优先级不低于当前运算符的栈顶运算符,直到栈顶优先级更低,再把当前运算符压栈。
// 运算符优先级表:数值越大优先级越高 // 用宏定义避免在函数里散落魔数 #define PRIO_ADD 1 #define PRIO_SUB 1 #define PRIO_MUL 2 #define PRIO_DIV 2 // 获取运算符优先级,非法字符返回-1 int get_prio(char op) { switch (op) { case '+': return PRIO_ADD; case '-': return PRIO_SUB; case '*': return PRIO_MUL; case '/': return PRIO_DIV; default: return -1; } } // 中缀转后缀:输入infix,输出postfix,返回转换后的长度 int infix_to_postfix(const char *infix, char *postfix) { char stack[32]; int top = -1; int len = 0; for (int i = 0; infix[i] != '\0'; i++) { char ch = infix[i]; if (ch >= '0' && ch <= '9') { postfix[len++] = ch; // 数字直接输出 } else if (ch == '(') { stack[++top] = ch; } else if (ch == ')') { while (top >= 0 && stack[top] != '(') { postfix[len++] = stack[top--]; } top--; // 弹出左括号 } else if (get_prio(ch) > 0) { while (top >= 0 && get_prio(stack[top]) >= get_prio(ch)) { postfix[len++] = stack[top--]; } stack[++top] = ch; } } while (top >= 0) { postfix[len++] = stack[top--]; } postfix[len] = '\0'; return len; }这个实现有几个工程细节:运算符栈大小只要32就够,因为计算器表达式一般不超过20个字符;数字直接输出意味着多位数会被拆成单个字符,所以后续计算时要扫描连续数字字符合并成浮点数。while (top >= 0 && get_prio(stack[top]) >= get_prio(ch))这一行的>=保证了相同优先级运算符从左到右结合,这是1-2-3能算出-4而不是2的关键。
3.2 用两个栈浮点计算后缀表达式
后缀表达式计算比中缀简单:遇到数字就压栈,遇到运算符就弹出两个操作数,计算后把结果再压回栈。注意弹出顺序:第一个弹出的是右操作数,第二个弹出的是左操作数,减法和除法搞反了结果就错了。
// RPN浮点计算:输入后缀表达式,输出结果 // 成功返回0,除零或栈溢出返回-1 int rpn_eval(const char *postfix, double *result) { double stack[32]; int top = -1; int i = 0; while (postfix[i] != '\0') { if (postfix[i] >= '0' && postfix[i] <= '9') { // 解析连续数字字符为一个浮点数 double val = 0; while (postfix[i] >= '0' && postfix[i] <= '9') { val = val * 10 + (postfix[i] - '0'); i++; } stack[++top] = val; } else if (postfix[i] == '+' || postfix[i] == '-' || postfix[i] == '*' || postfix[i] == '/') { if (top < 1) return -1; // 操作数不足 double b = stack[top--]; double a = stack[top--]; switch (postfix[i]) { case '+': stack[++top] = a + b; break; case '-': stack[++top] = a - b; break; case '*': stack[++top] = a * b; break; case '/': if (b == 0) return -1; // 除零保护 stack[++top] = a / b; break; } } i++; } if (top != 0) return -1; // 栈里应该只剩一个结果 *result = stack[top]; return 0; }这个计算核心用了double而不是float。可能有人觉得STM32F103是单精度浮点单元,用double会让编译器调用软浮点库。实际上Cortex-M3根本没有硬件浮点单元,不管是float还是double都要走软浮点库函数。差别是double精度更高,但代码体积更大。对于计算器场景,double的误差在15位有效数字,足够显示一个8位结果了。
软浮点库的一个坑是:默认的Keil工程可能没启用--library_type=microlib,导致链接时找不到浮点打印函数的实现。如果你在LCD上显示结果时发现小数部分全变成0,检查一下工程配置是不是用了MicroLIB。这个选项在Options for Target -> Target -> Code Generation里,勾选后浮点打印和printf重定向会顺畅很多。
3.3 浮点数显示:IEEE754在线计算器能帮你查什么
仿真计算器的显示是个容易被低估的问题。double在内存里是IEEE754格式,64位包括1位符号位、11位指数位和52位尾数位。当你把0.1+0.2算出来时,结果是0.30000000000000004,LCD上要显示成0.3还是0.30000000000000004?答案是显示成0.3,因为计算器的实际使用场景不需要打印全部浮点数位。
常见做法是用sprintf加%g格式符,它会自动去掉尾随零,并处理科学计数法。但%g在Keil的MicroLIB实现里有一点需要注意:它的默认精度是6位有效数字,对于12345678这样的整数会输出1.23457e+07,此时要改用%.8g。
// 浮点结果转字符串输出到LCD // 对于超出范围或除零错误,直接显示错误提示 char buf[16]; if (rpn_eval(postfix, &result) == 0) { if (result >= 1e9 || result < 1e-7) { sprintf(buf, "%.3e", result); // 科学计数法 } else { sprintf(buf, "%.5f", result); // 固定小数位 } } else { strcpy(buf, "Error"); }这里的判断阈值是工程经验:结果显示在LCD第2行,最多16个字符,%.5f最多输出类似12345.67890的12个字符,够用。如果你做的是“科学计算器计算方差”这类统计功能,结果可能很小,固定小数位会显示成0.00000,这时就要切到科学计数法。浮点数计算器在线工具只能帮你算单次结果,真正的边界条件要靠自己写测试用例覆盖。
4. 扩展度分秒、方差与RTC:让STM32计算器仿真贴近真实使用
4.1 度分秒计算的角度模式切换
有些计算器会带“度分秒计算器”功能,通常出现在测绘或导航场景。实现角度计算时,STM32的浮点三角函数直接给的是弧度值,你需要先判断当前模式。常见的模式变量是一个uint8_t,0表示角度,1表示弧度,2表示梯度。每次按模式切换键时,取模循环切换。
// 角度模式切换 // 当前模式保存在全局变量angle_mode中 void toggle_angle_mode(void) { angle_mode = (angle_mode + 1) % 3; switch (angle_mode) { case 0: lcd_set_cursor(0, 15); lcd_write_str("D"); break; case 1: lcd_set_cursor(0, 15); lcd_write_str("R"); break; case 2: lcd_set_cursor(0, 15); lcd_write_str("G"); break; } } // 统一将输入转换成弧度,供三角函数使用 double to_radian(double val) { if (angle_mode == 0) return val * 3.14159265358979 / 180.0; else if (angle_mode == 2) return val * 3.14159265358979 / 200.0; else return val; // 已经是弧度 }to_radian的常量要不要用M_PI?在Keil环境里,math.h的M_PI默认是不可用的,除非在编译选项里定义_USE_MATH_DEFINES。为了避免这种跨编译器问题,直接写宏常量更省事。这里的3.14159265358979是double精度,够用。
一个隐藏在角度切换里的坑:三角函数的误差。当输入是3.14159265358979时,sin函数理论上应该返回0,但实际会因为浮点舍入返回1.2246e-16。如果你在仿真里测试sin(180度)显示成1.2246e-16,这不一定是代码bug,而是浮点运算的正常误差。此时可以加一个阈值判断:当结果绝对值小于1e-12时直接显示0。
4.2 用Welford算法算方差
热词里“科学计算器计算方差”出现的频率不低。计算器算方差有两种方式:一种是先输入全部数据,再按下统计功能键;另一种是边输入边累积。嵌入式环境内存有限,第二种更合适,用Welford在线算法。它只保存均值mean和平方差累加值m2,不需要存全部数据。
// Welford在线方差算法 // 每输入一个数据调用一次update_stats() double mean, m2, variance; uint32_t count; void stats_reset(void) { count = 0; mean = 0; m2 = 0; } void stats_update(double x) { count++; double delta = x - mean; mean += delta / count; double delta2 = x - mean; m2 += delta * delta2; } double stats_variance(void) { if (count < 2) return 0; return m2 / (count - 1); // 样本方差 }注意stats_variance里除以count-1而不是count。如果你在计算器里做的是统计标准差,通常用户期望的是总体标准差,即除以count。nei这个差别在数据量小的时候很明显:count=2时,除以count-1的是(x1-mean)^2,而除以count给出一半,这两种结果在工程上对应的是“抽样统计”和“总体统计”,计算器上一般默认总体统计。如果你不确定,可以在功能菜单里做个选项,或者至少在产品文档里注明算法。
4.3 仿真里模拟外部信号输入
如果计算器仿真需要支持“信号发生器仿真”这种外部输入场景——比如用STM32采集信号幅度、做检波有效值计算——那仿真侧的标准做法是用串口或GPIO模拟输入。在Proteus里可以用虚拟终端发送数字量,然后用scanf或串口中断接收。一个可行的框架是:串口收到逗号分隔的数值流,发送键确认后调用stats_update批量录入。
// UART接收回调,每收到一个数字结束符'#'就启动统计更新 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { if (rx_data == '#') { stats_update(current_value); current_value = 0; is_filling = 0; } else if (rx_data >= '0' && rx_data <= '9') { current_value = current_value * 10 + (rx_data - '0'); is_filling = 1; } HAL_UART_Receive_IT(&huart2, &rx_data, 1); } }这段代码在仿真里很好用,也暴露了一个关键问题:回调里直接做浮点运算会占用中断时间。Welford算法的浮点除法虽然不慢,但在F103上最好还是把标志位留给主循环处理,中断里只是接收数据。这种“中断收数据、主循环算算法”的模式,在真机上也是标配,仿真早点养成习惯没坏处。
5. 仿真验证的4个技巧:从波形到边界测试
5.1 用逻辑分析仪检查按键与LCD时序
Proteus自带虚拟逻辑分析仪,Wokwi有个diagram.json的"type": "logic-analyzer"组件。把按键的行线和LCD的E引脚接上逻辑分析仪,就能观察按下按键时行线上有没有毛刺。常见的波形异常有三种:列扫描周期过快导致按键电平没建立、按键释放时产生多个下降沿、LCD的E引脚脉冲宽度不足。在仿真里测时序比真机方便,但要注意仿真模型不会模拟按键机械抖动,所以你其实没法在Proteus里看到真实的抖动波形——这是仿真发散的一个典型表现。真机上需要20ms左右的消抖,仿真里反而要人为构造抖动来测试你的消抖逻辑。
5.2 用printf重定向辅助调试
计算器显示结果只有一行,很多调试信息放不下。推荐的做法是把printf重定向到USART1,在PC上用串口工具看调试日志。在Keil里勾选MicroLIB后,重写fputc即可:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 10); return ch; }调试日志里除了打印最终结果,还应打印转换后的后缀表达式。比如表达式3.5*(2+1)转换后应该输出3.5 2 1 + *。这样一旦计算结果不对,直接比对后缀序列就能定位是中缀转后缀的优先级出错,还是RPN计算时取出操作数的顺序出错。最常见的错误是2 1 -算成了1 - 2。
5.3 用随机测试脚本生成边界用例
手动测试算不了多少组,我给计算器项目写测试脚本时,会用一个Python脚本生成随机表达式,然后跟Python的eval结果比对。
# 表达式随机测试,与Python eval比对 import random ops = ['+', '-', '*', '/'] for i in range(1000): expr = f"{random.randint(1,9)}{random.choice(ops)}{random.randint(1,9)}{random.choice(ops)}{random.randint(1,9)}" expected = round(eval(expr), 6) actual = simulate_calc(expr) # 调用你的STM32固件或仿真器 if abs(expected - actual) > 1e-6: print(f"Mismatch: {expr} expected {expected} got {actual}")simulate_calc可以是一个你在仿真环境里暴露出来的测试入口。如果固件支持串口接收表达式并把结果发回,这个脚本就能直接在PC侧跑回归。测试用例里必须包含几类边界:1/0的除零行为、999999999+1的溢出、连续按下等号键的重复执行、负数显示(开头的负号要单独处理)。IEEE754在线计算器能告诉你0.1+0.2的精确浮点值,但计算器产品要显示的永远是0.3,所以结果显示层的舍入策略要提前定好,而不是等发现测试不通过再改。
5.4 编译优化等级导致的计时漂移
最后给一个仿真常见的坑:Keil的优化等级对延时函数影响极大。在-O0下,delay_us(10)可能实际延了10微秒,但开到-O2后,循环被优化掉,延时变成1微秒不到。LCD时序和按键消抖对这些敏感。要么统一用-O0编译(仿真时无所谓),要么用DWT或者SysTick做精确延时,不要依赖空循环。检查方法很简单:代码里加一个GPIO翻转,用逻辑分析仪量脉冲宽度,看不同优化等级下差别多大。这个技巧花十分钟能排查出一晚上的玄学问题,对STM32计算器仿真这种混合了IO时序和浮点算法的项目尤其值得纳入常规验证步骤。
本文还有配套的精品资源,点击获取