基于STM32与HAL库的计算器仿真:从按键扫描到中缀表达式求值

发布时间:2026/9/16 21:07:46

基于STM32与HAL库的计算器仿真:从按键扫描到中缀表达式求值 简介基于STM32的计算器仿真工程面向嵌入式学习者与单片机开发者以ARM Cortex-M内核微控制器为核心实现了加减乘除四则运算、按键输入及LCD/串口显示等功能覆盖从硬件初始化到结果输出的完整流程适合用于课堂实验、课程设计或入门实战。压缩包为RAR格式整体16.07MB共390个文件包含HAL/LL库驱动源码.c/.h、Keil工程文件.uvprojx、编译生成的.o/.axf/.hex等中间文件以及中断配置、调试脚本和备份文件既可直接阅读源码理解底层逻辑也可重新编译烧录验证效果。目前已有3456人学习工程内还涉及GPIO配置、外部中断、按键去抖、运算溢出与除零处理等关键细节并保留多个版本和调试记录便于对照排错。通过该仿真项目读者可以系统地掌握STM32外设驱动、人机交互设计和嵌入式调试方法为后续扩展科学计算或复杂功能打下扎实基础。1. 为什么用STM32做计算器仿真而不是直接画一个LCD界面仿真和真机最大的区别在于仿真器里跑的是逻辑真机里跑的是时序。用STM32仿真计算器表面上只是按键输入和四则运算实际上要处理的是GPIO扫描、中断响应、状态转换、串口调试、LCD驱动这条完整的嵌入式链路。对刚接触ARM Cortex-M的开发者这个项目能在一周内把HAL库的配置流程、按键消抖、运算溢出和调试手段全部过一遍。适合做课程设计也适合想从裸机过渡到RTOS的开发者先把它写成前后台结构。这里基于STM32F103VE和Keil uVision工程展开涉及HAL库的UART、TIM、I2C等模块所有代码以可复现为准。2. 工程结构分析与HAL库外设选型STM32F103VE的计算器骨架2.1 从文件清单看工程组织方式从项目文件列表可以看到典型的Keil MDK工程Fire_F103VE.uvguix.*是用户界面布局文件Fire_F103VE.hex.asm是反汇编列表Fire_F103VE.axf是调试用的ELF文件stm32f1xx_hal_*.c是HAL库驱动。这里需要区分.uvguix不是源码不要提交到版本库.axf在仿真调试时才会刷新真机烧录只依赖.hex。真正需要维护的是HAL库源码和用户代码而不是这些中间产物。工程里出现stm32f1xx_hal_i2c.c、stm32f1xx_hal_tim.c、stm32f1xx_hal_spi.c、stm32f1xx_hal_sd.c、stm32f1xx_hal_uart.c说明这块板子的外设很全。计算器本身只需要GPIO和UART但I2C可用于扩展LCDSPI和SD卡可用于保存历史记录TIM用于按键消抖计时。实际开发时HAL库通过stm32f1xx_hal_conf.h统一管理模块开关用不到的模块直接注释掉这样能明显缩短编译时间也能减少固件体积。下面给出一个外设资源分配的参考表读者可以按自己的板子调整外设用途说明GPIOA按键矩阵列读取配置为输入上拉GPIOB按键矩阵行扫描配置为推挽输出TIM21ms时基中断用于消抖和状态轮询USART1调试日志输出PA9/PA10波特率115200I2C1LCD1602显示PB6/PB7地址0x27这个分配不是固定的比如LCD也可以选择SPI接口。但在仿真阶段I2C占用的引脚少接线简单更适合验证逻辑。需要注意的是HAL_I2C_Master_Transmit的地址是7位地址实际调用时要左移一位否则会一直检测不到设备。2.2 时钟树配置计算器响应速度的地基所有外设的初始化顺序里时钟先行。STM32F103VE最高主频72MHz通过HSE外部晶振经过PLL倍频得到。在HAL库中HAL_RCC_ClockConfig()完成系统时钟配置然后每个外设用__HAL_RCC_GPIOA_CLK_ENABLE()之类的宏开启对应总线时钟。这里有个常见误区只配置了GPIO模式忘了使能GPIO时钟结果引脚电平一直是高仿真器里怎么看都正常真机就是不工作。以下是我惯用的系统时钟配置写法void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct {0}; RCC_OscInitStruct.OscillatorType RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState RCC_HSE_ON; RCC_OscInitStruct.PLL.PLLState RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLMUL RCC_PLL_MUL9; // 8MHz * 9 72MHz if (HAL_RCC_OscConfig(RCC_OscInitStruct) ! HAL_OK) { Error_Handler(); } RCC_ClkInitStruct.ClockType RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider RCC_HCLK_DIV2; // 36MHz max RCC_ClkInitStruct.APB2CLKDivider RCC_HCLK_DIV1; // 72MHz HAL_RCC_ClockConfig(RCC_ClkInitStruct, FLASH_LATENCY_2); }这里PLLMUL RCC_PLL_MUL9的前提是HSE为8MHz。如果你的板子是12MHz晶振倍频必须是6否则SysTick定时和UART波特率全部漂移。这也是从仿真移植到自绘PCB时最容易踩的坑之一。FLASH_LATENCY_2是72MHz下必须设置的两周期等待漏掉它程序会随机跑飞表现成按键偶尔失灵或计算结果莫名错误。2.3 GPIO初始化与按键硬件连接计算器的按键阵列一般接在GPIO的上下拉模式上。独立按键可以直接接PA0-PA3配置为GPIO_MODE_INPUT配合内部上拉按下时引脚接地读取为低电平。矩阵键盘占用引脚少例如4x4键盘使用8个引脚需要行扫描。仿真阶段建议先用独立按键验证逻辑再换矩阵键盘否则行列扫描代码和硬件连接一旦对不上排查成本很高。使用HAL库初始化GPIO的典型代码如下void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); GPIO_InitStruct.Pin GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); }这里的逻辑是GPIOA的低四位作为列输入GPIOB的低四位作为行输出。初始化时列引脚设置上拉行引脚设置推挽输出。实际扫描时先全部输出高电平然后依次将某一行拉低再读取列引脚的电平。只要对应行和列的引脚同时被拉低就说明该键被按下。GPIO_SPEED_FREQ_LOW对按键足够避免高速翻转引入额外噪声。GPIO初始化完成后可以写一个自检函数把16个按键分别短接串口打印按下的键值确认硬件连接无误。这个自检步骤在仿真环境里容易被跳过但在真机上能节省大量时间。3. 四则运算核心从按键扫描到中缀表达式求值3.1 状态机驱动按键输入计算器输入本质是一个有限状态机至少需要四个状态输入第一个操作数、输入运算符、输入第二个操作数、等待等号。每个状态对按键的响应不同。比如输入第一个操作数时按数字键拼接数字按运算符则锁定当前值输入第二个操作数时按运算符应该替换当前运算符而不是重新开始。按键扫描函数返回一个键值码。这里用二维数组定义4x4键盘的键值映射const uint8_t key_map[4][4] { {1, 2, 3, }, {4, 5, 6, -}, {7, 8, 9, *}, {C, 0, , /} };矩阵扫描时先把所有行设为高再逐行拉低每行间隔10ms。读列状态后综合得到键值。扫描结果经过消抖后送入状态机。关键是区分短按和长按计算器不需要长按重复所以key_flag置位后由主循环消费消费完立即清零保证一次按键只触发一次。消抖代码放在定时器中断里用连续10次采样确认电平稳定void debounce_update(key_debounce_t *key, GPIO_TypeDef *port, uint16_t pin) { uint8_t level (HAL_GPIO_ReadPin(port, pin) GPIO_PIN_RESET) ? 1 : 0; key-current level; if (key-current key-filtered) { key-stable_count; } else { key-stable_count 0; key-filtered key-current; } if (key-stable_count 10) { if (key-filtered 1 key-previous 0) { key-key_flag 1; } key-previous key-filtered; } }参数说明key指向按键状态结构体port和pin是GPIO端口与引脚。level为1表示按下0表示释放。stable_count记录相同电平的连续采样次数只有连续10次都为按下才把key_flag置位。这里把消抖和边沿检测合在了一起既滤除抖动又只产生一次触发信号。3.2 用双栈处理中缀表达式简单的计算器直接按输入顺序计算例如12*3会得到9不符合数学优先级。所以需要两个栈一个操作数栈、一个运算符栈。经典做法是调度场算法扫描表达式的每个token数字入操作数栈运算符根据优先级弹出并计算。下面给出一个适合单片机的简化实现输入是C字符串输出是int32_t结果#include stdint.h #include string.h #define MAX_STACK 32 static int32_t num_stack[MAX_STACK]; static char op_stack[MAX_STACK]; static uint8_t num_top 0; static uint8_t op_top 0; static void push_num(int32_t val) { num_stack[num_top] val; } static int32_t pop_num(void) { return num_stack[--num_top]; } static void push_op(char op) { op_stack[op_top] op; } static char pop_op(void) { return op_stack[--op_top]; } static int32_t apply_op(int32_t a, int32_t b, char op) { switch (op) { case : return a b; case -: return a - b; case *: return a * b; case /: if (b 0) return INT32_MAX; // 除零特殊标记 return a / b; default: return 0; } } static int32_t precedence(char op) { if (op * || op /) return 2; return 1; } int32_t eval_expression(const char *expr, int32_t *error) { num_top 0; op_top 0; *error 0; int32_t current 0; uint8_t has_num 0; for (size_t i 0; i strlen(expr); i) { char ch expr[i]; if (ch 0 ch 9) { current current * 10 (ch - 0); has_num 1; } else if (ch || ch - || ch * || ch / || ch ) { if (has_num) { push_num(current); current 0; has_num 0; } if (ch ) break; while (op_top 0 precedence(op_stack[op_top - 1]) precedence(ch)) { int32_t b pop_num(); int32_t a pop_num(); int32_t r apply_op(a, b, pop_op()); if (r INT32_MAX (a 0 || b 0)) { *error 1; return 0; } push_num(r); } push_op(ch); } } while (op_top 0) { int32_t b pop_num(); int32_t a pop_num(); char op pop_op(); int32_t r apply_op(a, b, op); if (r INT32_MAX (a 0 || b 0)) { *error 1; return 0; } push_num(r); } return pop_num(); }逻辑说明precedence函数定义优先级乘除为2加减为1。当新运算符到达时先把栈中优先级不低于它的运算符全部弹出并计算保证12*3先算乘法。数字通过current * 10 digit累加。作为表达式结束标志遇到即跳出循环。除零检测采用INT32_MAX作为异常哨兵值并在弹出时检查是否为除零场景。需要注意如果表达式本身结果就是2147483647这个哨兵值可能被误判所以在应用层应该限制输入位数避免真实结果等于哨兵值。参数说明expr是待计算的中缀表达式字符串以结尾error是输出参数返回1表示除零或溢出。这个算法不支持括号和一元负号但对标准四则运算足够。如果要在状态机里直接使用可以在每次按键后构造新的字符串再调用该函数。3.3 溢出与边界处理int32_t范围是-2147483648到2147483647。计算器输入9位数后继续输入必然溢出。我建议在数字输入阶段限制位数为9位并在累加前判断if (current 214748364L) { current 2147483647L; // 钳位 }这个判断的阈值是214748364因为下一位最多再加9仍然接近上限。如果是负数则需要把符号位单独保存数字部分按正数处理。另外负数输入和处理是另一个边界。-53如果直接按表达式解析会先遇到-操作数栈为空运算符栈也空最终运算缺操作数。常见解法是增加一个状态机标志当第一个数字尚未输入时按-就把后续数字都当作负值处理或者更通用的做法是专门处理一元负号把它看作一个优先级最高的伪运算符。但在计算器这种受限场景里在输入阶段维护is_negative标志更简单。4. 显示与调试LCD输出、串口打印和时序配合4.1 格式化结果输出到LCD显示部分采用1602 LCD通过I2C接口控制占用引脚少仿真时也容易观测。HAL库的HAL_I2C_Master_Transmit()负责字节传输LCD的初始化命令序列需要在用户代码里实现。典型初始化包括0x28设置4位模式、0x0C开启显示不显示光标、0x01清屏、0x06光标右移。每个命令之间需要延时至少40微秒I2C总线速度设为100kHz即可。将计算结果转换成字符串并显示需要处理负数、小数和错误状态。整数计算器只显示整数但除法可能产生小数所以要么做整除要么用浮点。如果使用浮点需要解决snprintf体积大的问题。可以自己实现一个简单的整数转字符串void int_to_str(int32_t val, char *buf, uint8_t len) { char tmp[12]; uint8_t i 0; uint8_t neg 0; if (val 0) { neg 1; val -val; } do { tmp[i] 0 (val % 10); val / 10; } while (val 0 i len - 1); uint8_t j 0; if (neg) buf[j] -; while (i 0) buf[j] tmp[--i]; buf[j] \0; }这个函数从低位开始逐个取余写入临时数组最后逆序复制到目标缓冲区。len参数用来防止缓冲区越界目标缓冲区长度至少12字节因为INT32_MIN带负号是11个字符。如果显示小数可以先乘100变成整数再在适当位置插入小数点。4.2 串口输出与仿真验证开发阶段串口是最快的调试手段。HAL_UART_Transmit(huart1, (uint8_t *)msg, len, 1000)是阻塞发送1000是超时时间单位毫秒。如果外部设备没有及时读走数据超过1000毫秒函数会返回超时错误。HAL_UART_Receive同理但在计算器项目里不常用因为按键输入通过GPIO实现串口只负责输出调试信息。要在Keil中使用printf重定向到UART需要重写fputcint fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; }参数说明ch是要输出的字符f是C标准库的文件流指针在重定向函数中通常不使用。重写后printf发送的每个字符都会调用fputc再由UART1发出。注意HAL_UART_Transmit的阻塞特性发送一个字符大约需要80微秒115200波特率下如果在中断回调里调用printf会拖慢系统时基所以串口打印只放在主循环或单独的任务中。4.3 时序配合定时器中断与主循环的分工按键消抖、LCD刷新、表达式计算这三部分如果都放在主循环里响应速度会不稳。推荐的架构是TIM2产生1ms中断中断里只做按键扫描和消抖状态更新主循环消费key_flag执行状态机逻辑和表达式计算LCD只在结果变化时刷新。中断服务函数尽量短避免阻塞调用。下面是一个标准的HAL中断写法void TIM2_IRQHandler(void) { HAL_TIM_IRQHandler(htim2); } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { key_scan_keypad(); // 扫描矩阵键盘更新键值数组 debounce_update_all(); // 更新所有按键的消抖状态 } }主循环里判断key_flag后再调用process_key()。这样做的好处是计算逻辑不会被中断频繁打断也方便单步调试。注意debounce_update_all()内部不要打印日志否则中断执行时间超过1ms下一个中断就直接丢弃最终表现为按键响应越来越慢。5. 从仿真到真机验证四则运算边界条件的三个技巧仿真器能跑通不代表真机按键手感正确。第一个技巧是给按键消抖时间做减法仿真时钟固定但真机机械按键的抖动可能持续10ms到20ms如果消抖窗口只有5ms按一次会被识别成两次。反过来窗口太大响应延迟明显50ms会让快速输入连续出错。我用可变窗口先连续采样10次只有前两次和后两次稳态一致才确认中间允许一次电平跳变。这个方案在多数薄膜按键上都能兼顾速度和稳定。第二个技巧是除零验证。不要只在代码里判断除数为零要分别验证5/0、0/0、5/02这组序列。最后一个用例如果错误传播结果可能变成2而不是报错原因是除零返回的哨兵值又参与了后续加法。我的修复方式是增加一个错误标志位一旦apply_op检测到除零直接跳过后续所有运算直到用户按C清零并让LCD显示Error。第三个技巧是边界值与显示位数。用21474836471验证溢出用-2147483648-1验证负数下溢。如果调试时看到显示异常优先检查int_to_str的缓冲区长度。INT32_MIN需要12字节缓冲区加负号和数字共11个字符末尾补\0。另外LCD1602是16列超过16个字符会换到第二行如果结果超过12位需要决定是滚动显示还是分段显示。在这个基础上扩展科学计算器时可以把字符串表达式解析器换成支持括号和单目运算的版本输入状态里增加“度分秒”编码。例如DD.MMSS格式转十进制度的公式是度 分/60 秒/3600显示时再反向转换。这个扩展只需要增加一个输入模式状态复用已有的双栈求值框架不会破坏原有四则运算的稳定性。最后真机调试时在eval_expression入口和返回处各设一个硬件断点同时查看操作数栈和运算符栈的内容比单独打印中间变量更直观。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/9/16 21:07:46

STM32与US-100串口触发测距:协议、接线与代码实现

简介:面向STM32嵌入式开发者与物联网、自动化设备及机器人测距场景的US100超声波模块资料包,重点解析利用串口(UART)触发模式实现非接触测距的方法。资源基于STM32微控制器,覆盖串口初始化、接收中断、超声波发送与回波…

2026/9/16 21:07:46

Rust实现ffmpeg:安全高效的音视频处理新选择

1. 项目背景:为什么需要纯Rust实现的ffmpeg?在音视频处理领域,ffmpeg堪称瑞士军刀级别的存在。这个开源项目几乎成了多媒体处理的代名词,从简单的格式转换到复杂的流媒体处理,ffmpeg都能胜任。但它的C语言实现也带来了…

2026/9/16 21:57:54

量子计算如何重塑韧性供应链优化

1. 项目背景与核心价值当全球供应链面临前所未有的不确定性时,传统物流优化方法已显疲态。去年一家跨国零售企业因单一供应商断供导致季度亏损23亿美元的事件,彻底暴露了现有供应链模型的脆弱性。而沃伦巴菲特的伯克希尔哈撒韦公司却能在同期保持供应链稳…

2026/9/16 21:57:54

Spring Boot数据后台实战:批量写、接口对接与自动化部署

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/16 21:57:54

一晚打通自托管游戏串流:Sunshine 实战

一晚打通自托管游戏串流:Sunshine 实战 【免费下载链接】Sunshine Self-hosted game stream host for Moonlight. 项目地址: https://gitcode.com/GitHub_Trending/su/Sunshine 你有一台显卡不错的 PC,想让电视或平板直接玩它上面的游戏&#xff…

2026/9/16 21:52:53

Java异常与文件处理:从JVM栈展开到编码实战的完整指南

1. 异常与文件:Java开发者绕不开的两座山做Java开发这几年,我几乎每天都要跟这两样东西打交道:异常和文件。刚入行那会儿,以为异常就是try-catch一下,文件就是FileInputStream读进来,真到了生产环境才发现&…

2026/9/16 12:52:37

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/16 0:04:09

PHP源码部署实战:从环境配置到运行情侣游戏全攻略

简介:这是一套面向情侣互动场景的PHP完整源码,集成情侣飞行棋、真心话大冒险、情趣骰子等玩法,并内置完整分销制度,可自定义多种返佣比例,源码完全开源无加密,支持微信无感自动授权登录与第三方授权&#x…

2026/9/15 14:22:53

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/15 21:31:11

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/15 11:42:23

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码