
简介这是一份基于STM32F103RCT6微控制器实现的简易计算器完整工程包源自某高校电子设计竞赛一等奖作品非常适合学习STM32或备战嵌入式竞赛的开发者。工程基于Keil MDK构建完整保留了项目源码、启动文件、链接脚本、编译中间文件及ST官方DSP数学库压缩包约27.86MB。包内共998个文件以C源文件563个和头文件247个为主辅以.s启动文件、.icf链接配置、.hex烧录文件与.axf调试文件等便于直接打开工程查看运行效果。目前已有882人学习/下载其代码覆盖GPIO、中断、LCD显示、按键扫描等外设驱动以及计算器运算逻辑和工程组织方式导入Keil后既能复现获奖方案也可灵活扩展是课程设计与竞赛项目的高质量参考模板。1. 用一块 Cortex-M3 把计算器做到竞赛一等奖靠的不是算得有多快很多人觉得 STM32F103RCTx 做计算器是课设里最普通的题目但这个工程能拿到电子设计竞赛一等奖核心竞争力藏在细节里4x4 矩阵键盘的扫描时序、LCD1602 的 4 位模式初始化、中缀表达式转逆波兰的栈深度控制以及 Keil 工程里浮点数学库的正确链接方式。这篇文章按照这个工程的实际组织方式从 STM32F103RCT6 的引脚规划和 Keil 工程配置讲起一直拆到表达式求值函数和调试技巧。适合刚入门 STM32 想看到完整代码结构的学生也适合已经会用 HAL 库但想回头把寄存器级细节补全的开发者。2. STM32F103RCTx 的资源规划与 Keil 工程骨架2.1 引脚分配和时钟配置STM32F103RCTx 是 Cortex-M3 内核256 KB Flash、48 KB SRAMLQFP64 封装。做计算器不需要用到太多外设但引脚分配要先避开调试接口。SWDIO 和 SWCLK 固定在 PA13、PA14很多初学者把矩阵键盘接在 PA 口高位结果发现无法仿真就是因为调试口被占用。下面这套分配没有踩这个坑模块引脚模式说明矩阵键盘行PA0 ~ PA3推挽输出扫描时逐行拉低其余行保持高矩阵键盘列PB0 ~ PB3上拉输入按键按下时读到低电平LCD RSPC0推挽输出高电平表示数据低电平表示命令LCD RWPC1推挽输出接地可只写保留控制位更灵活LCD ENPC2推挽输出下降沿锁存数据LCD D4 ~ D7PC3 ~ PC6推挽输出4 位数据线接液晶的高四位时钟配置是整个工程的基底。标准外设库的写法如下8 MHz 外部晶振经过 PLL 倍频到 72 MHzAPB1 为 36 MHzAPB2 为 72 MHz。void SystemClock_Config(void) { RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); if (RCC_WaitForHSEStartUp() SUCCESS) { FLASH_PrefetchBufferCmd(FLASH_PrefetchBuffer_Enable); FLASH_SetLatency(FLASH_Latency_2); RCC_HCLKConfig(RCC_SYSCLK_Div1); RCC_PCLK1Config(RCC_HCLK_Div2); RCC_PCLK2Config(RCC_HCLK_Div1); RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() ! 0x08); } }这段代码先把时钟恢复到默认状态再打开外部高速晶振等待起振最后配置 AHB、APB1、APB2 分频和 PLL 倍频。注意 APB1 定在 36 MHz而定时器 TIM2~TIM5 挂在 APB1 上它们的内部时钟会被倍频到 72 MHz。如果你后面用定时器做键盘扫描预分频值要按 72 MHz 计算而不是按 PCLK1 的 36 MHz。如果开发板上晶振不是 8 MHz必须同步调整RCC_PLLMul_x否则串口波特率、LCD 延时全部偏掉。GPIO 初始化里最容易犯的错是把列引脚配置成浮空输入。浮空输入没有确定电平按键抖动时容易误触发。正确做法是内部上拉void GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_GPIOC, ENABLE); // 键盘行 PA0-PA3推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 键盘列 PB0-PB3上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOB, GPIO_InitStructure); }GPIO_Speed_50MHz比GPIO_Speed_2MHz的压摆率更高信号沿更陡但也会带来更大的过冲和 EMI。LCD 控制线其实用 2 MHz 就够D4~D7 在数据建立时只需要几十纳秒高速模式容易让 EN 信号产生毛刺。竞赛作品里一般会按信号频率分档设置键盘扫描和 LCD 控制都用 2 MHz只有需要快速翻转的 PWM 引脚才用 50 MHz。2.2 Keil 工程里的数学库与 .axf 文件压缩包里出现的iar_cortexM3b_math.a、iar_cortexM3l_math.a、libarm_cortexM3l_math.a来自不同编译器环境。b是 big-endian 大端l是 little-endian 小端Cortex-M3 默认小端模式所以 Keil 下只需要链接libarm_cortexM3l_math.a。IAR 和 Keil 生成的静态库 ABI 不同如果全部加进工程链接器会报出一堆未定义符号。Keil 中添加独立静态库的路径是 Options for Target - Linker - Misc controls或者在工程组中添加 .a 文件。更推荐后者能直接看见库文件依赖关系。然后在 C/C 编译选项里加上 CMSIS DSP 的 include 路径写完#include arm_math.h就能调用优化过的浮点函数#include arm_math.h float32_t result; arm_status st; void calc_sqrt(float32_t x) { st arm_sqrt_f32(x, result); if (st ! ARM_MATH_SUCCESS) { result 0.0f; } }arm_sqrt_f32的输入参数x是被开方数第二个参数是结果地址返回值是arm_status枚举。和标准库sqrtf相比它不需要额外开启浮点打印支持也不会引入完整的 C 数学库链接体积更小。计算器如果只做四则运算不调用这个库也能跑但工程里保留了它说明原作者在竞赛中有可能加了开方或三角函数功能。.axf是 Keil 的编译输出映像里面包含 DWARF 调试信息仿真器通过它把指令地址映射回源码行。不要删除这个文件也不要把它当作普通文件拷来拷去调试时 Keil 会在工程输出目录里自动刷新。3. 矩阵键盘扫描与 LCD1602 驱动的时序细节3.1 4x4 键盘扫描与消抖计算器输入靠 4x4 矩阵完成键位映射如下行 \ 列列 0列 1列 2列 3行 0123行 1456-行 2789*行 3C0/扫描思路是逐行拉低检测四列上是否出现低电平。因为列引脚配置了内部上拉按键未按下时读回高电平按下后行和列短接列被拉低。每次只拉低一行另外三行保持高电平就能避免跨行误触发。#define ROW_PORT GPIOA #define COL_PORT GPIOB const uint8_t key_map[4][4] { {1,2,3,}, {4,5,6,-}, {7,8,9,*}, {C,0,,/} }; uint8_t key_scan(void) { uint8_t row, col; for (row 0; row 4; row) { GPIO_SetBits(ROW_PORT, 0x000F); GPIO_ResetBits(ROW_PORT, (uint16_t)(1 row)); for (col 0; col 4; col) { if (GPIO_ReadInputDataBit(COL_PORT, (uint16_t)(1 col)) Bit_RESET) { delay_ms(10); if (GPIO_ReadInputDataBit(COL_PORT, (uint16_t)(1 col)) Bit_RESET) { while (GPIO_ReadInputDataBit(COL_PORT, (uint16_t)(1 col)) Bit_RESET); return key_map[row][col]; } } } } return 0; }GPIO_SetBits(ROW_PORT, 0x000F)先把 PA0~PA3 全部置高GPIO_ResetBits再单独拉低当前行。这种写法比直接操作 ODR 寄存器更安全不会影响这一组端口里其他引脚的电平。delay_ms(10)是软件消抖但要注意如果按下键后一直不放手while循环会卡住主程序LCD 刷新也会暂停。计算器是单任务场景用户可以接受按键释放后界面才更新所以这个阻塞式写法能用。如果你想把架构改得更健壮可以把消抖放到定时器中断里用状态机记录按键事件。返回值是key_map中的字符而不是行列编号。这样主循环可以直接用char类型拼接表达式省去一层 ASCII 码转换。注意0这个返回值本身有含义实际按键0的 ASCII 码是 48不会和0冲突所以key_scan返回 0 可以表示“无按键”。3.2 LCD1602 的 4 位模式初始化LCD 用 4 位数据线能省 4 个 GPIO。初始化时的同步时序是最大难点LCD 上电时默认工作在 8 位模式必须先连续发送三个0x03让控制器识别成 8 位命令再发送0x02切到 4 位模式。如果直接发送0x28液晶会花屏。void LCD_SendNibble(uint8_t nibble, uint8_t rs) { GPIO_WriteBit(GPIOC, GPIO_Pin_0, rs ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOC, GPIO_Pin_2, Bit_RESET); GPIO_WriteBit(GPIOC, GPIO_Pin_3, (nibble 0x01) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOC, GPIO_Pin_4, (nibble 0x02) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOC, GPIO_Pin_5, (nibble 0x04) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOC, GPIO_Pin_6, (nibble 0x08) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOC, GPIO_Pin_2, Bit_SET); delay_us(1); GPIO_WriteBit(GPIOC, GPIO_Pin_2, Bit_RESET); }nibble的低 4 位对应 PC3~PC6 的电平rs控制当前字节是命令还是数据。EN 引脚要先拉低再拉高然后在下降沿锁存数据这是 1602 手册要求的时序。先拉低的好处是防止上一次 EN 高电平残留导致误锁存。初始化函数如下void LCD_Init(void) { delay_ms(50); LCD_SendNibble(0x03, 0); LCD_SendNibble(0x03, 0); LCD_SendNibble(0x03, 0); LCD_SendNibble(0x02, 0); LCD_WriteCommand(0x28); LCD_WriteCommand(0x0C); LCD_WriteCommand(0x01); delay_ms(2); LCD_WriteCommand(0x06); } void LCD_WriteCommand(uint8_t cmd) { LCD_SendNibble(cmd 4, 0); LCD_SendNibble(cmd 0x0F, 0); delay_us(50); } void LCD_WriteData(uint8_t data) { LCD_SendNibble(data 4, 1); LCD_SendNibble(data 0x0F, 1); delay_us(50); }0x28表示 2 行模式、5x7 点阵0x0C开显示但不显示光标0x01清屏需要等待 2 ms0x06让地址自动加一。写命令和写数据的差别只在第三参数命令写 0数据写 1。清屏后如果要显示浮点数小数点和负号在 1602 上是没有问题的它是 ASCII 字符集。3.3 显示缓冲与刷新策略计算器的输入串和结果都是字符每次按键事件发生后直接更新 LCD 第一行运算结果放到第二行。写一个精简的字符串显示函数void LCD_ShowString(uint8_t x, uint8_t y, const char *str) { LCD_WriteCommand(0x80 (y ? 0x40 : 0x00) x); while (*str) { LCD_WriteData(*str); } }x是列地址 0~15y是行地址 0~1。LCD1602 第一行 DDRAM 地址从0x80开始第二行从0xC0开始0x40偏移量正好对应这个关系。每次按键后调用这个函数比在 While 循环里反复刷新整个屏幕更省 CPU也避免 LCD 频繁写入产生的闪烁。4. 计算器表达式求值中缀转后缀与浮点栈运算4.1 中缀表达式转逆波兰表达式从按键缓冲区拿到的是类似123*4的中缀表达式人看着顺眼但单片机需要一个栈来处理优先级。常见做法是先把它转成逆波兰表达式再通过操作数栈计算。int priority(char op) { switch (op) { case : case -: return 1; case *: case /: return 2; case (: return 0; default: return -1; } } int infix_to_postfix(const char *infix, char *postfix) { char stack[32]; int top -1, dst 0; while (*infix) { if ((*infix 0 *infix 9) || *infix .) { while ((*infix 0 *infix 9) || *infix .) postfix[dst] *infix; postfix[dst] ; } else if (*infix || *infix - || *infix * || *infix /) { while (top 0 priority(stack[top]) priority(*infix)) postfix[dst] stack[top--]; stack[top] *infix; } else if (*infix () { stack[top] *infix; } else if (*infix )) { while (top 0 stack[top] ! () postfix[dst] stack[top--]; if (top 0) top--; infix; } else { return -1; } } while (top 0) postfix[dst] stack[top--]; postfix[dst] \0; return 0; }priority函数只给四种运算符和左括号定义了优先级左括号返回 0 是为了保证括号外的运算符不会把它弹出。当扫描到加减乘除时只要栈顶运算符优先级不低于当前运算符就先弹出并填入输出缓冲。例如12*3读到*时栈顶是优先级 1 低于*所以不弹出*进栈读到表达式结束再从栈里把和*依次弹出得到1 2 3 * 。数字和小数点连续复制后追加一个空格是为了让后面的求值函数能区分独立数字。缓冲区建议开到表达式的两倍长度32 字符的输入给 64 字节输出足够。注意这个实现没有处理一元负号比如输入-53会把-当成减号。竞赛作品一般用按键逻辑规避这个问题表达式为空时按键-自动在前面补0最终显示0-53避免修改解析器。4.2 后缀表达式的浮点运算后缀表达式计算不需要考虑优先级遇到数字压栈遇到运算符弹两个数计算再压栈。float eval_postfix(const char *postfix, int *error) { float stack[16]; int top -1; while (*postfix) { if ((*postfix 0 *postfix 9) || *postfix .) { char buf[16]; int n 0; while ((*postfix 0 *postfix 9) || *postfix .) buf[n] *postfix; buf[n] \0; stack[top] atof(buf); } else if (*postfix ) { postfix; } else if (*postfix || *postfix - || *postfix * || *postfix /) { float b, a; if (top 1) { *error 1; return 0.0f; } b stack[top--]; a stack[top--]; switch (*postfix) { case : stack[top] a b; break; case -: stack[top] a - b; break; case *: stack[top] a * b; break; case /: if (b 0.0f) { *error 1; return 0.0f; } stack[top] a / b; break; default: break; } postfix; } else { *error 1; return 0.0f; } } if (top ! 0) { *error 1; return 0.0f; } *error 0; return stack[0]; }注意弹出顺序先弹出的是右操作数b再弹出的是左操作数a。减法a-b和除法a/b如果写成b-a、b/a结果会完全错误。栈大小取 16 是因为 4x4 键盘的最复杂输入长度有限32 个字符的表达式转成后缀后同时存活的数不会超过 12 个。b 0.0f是精确比较对于直接输入0够用如果将来接入传感器做除法改成fabsf(b) 1e-6f更稳健。atof来自标准库Keil 默认的 microlib 支持它的字符串转浮点功能比sscanf轻量。如果不开 microlib链接器可能报找不到_atof这时需要在 Options for Target 的 C/C 标签页勾选 Use MicroLIB。4.3 主循环状态机和错误处理按键处理不能每次直接执行还要防止连续按运算符导致表达式非法。工程里可以用一个简单的状态变量enum { STATE_NUM, STATE_OP, STATE_RESULT } state STATE_NUM; char expr[32]; int expr_len 0; char postfix[64]; uint8_t key key_scan(); if (key) { if (key C) { expr_len 0; expr[0] \0; LCD_ShowString(0, 0, ); LCD_ShowString(0, 1, ); state STATE_NUM; } else if (key ) { int err; float result; if (infix_to_postfix(expr, postfix) ! 0) { LCD_ShowString(0, 1, ERROR); continue; } result eval_postfix(postfix, err); if (err) { LCD_ShowString(0, 1, ERROR); } else { char buf[17]; FormatFloat(result, 6, buf); LCD_ShowString(0, 1, buf); } state STATE_RESULT; } else { if (state STATE_RESULT) { expr_len 0; expr[0] \0; LCD_ShowString(0, 0, ); state STATE_NUM; } if (expr_len 31) { expr[expr_len] key; expr[expr_len] \0; LCD_ShowString(0, 0, expr); } } }STATE_NUM表示当前可以输入数字STATE_OP表示可以输入运算符STATE_RESULT表示刚显示完结果。按后如果继续输入数字先清空表达式这个交互逻辑和大多数实体计算器一致。错误处理集中在error指针和函数返回值上避免异常值显示成 0 被误解。5. Keil 调试与竞赛级优化技巧5.1 利用 .axf 做变量级定位工程编译后会生成Calculator3.axf在 Keil 中进入 Debug 模式后这文件会被自动加载。不要把调试器切到汇编窗口去查错误直接在eval_postfix的switch处打条件断点比如设定top 0时停止就能在弹出操作数之前抓出栈下溢。打开 View - Watch 窗口手动添加a、b、top和postfix。单步执行减法表达式时重点观察弹出顺序如果a是 2、b是 5却看到b-a的结果说明在弹栈时先弹了左操作数。这类问题用 printf 输出调试也可以但串口打印本身会改变时序在按键消抖和 LCD 写入的场景下会造成误判。5.2 屏蔽 IAR 库只留小端 ARM 库如果工程里同时引入iar_cortexM3b_math.a和libarm_cortexM3l_math.a链接器会报重复定义或者 ABI 不匹配。Cortex-M3 默认小端b结尾的大端库完全没有必要出现直接在工程管理中排除iar_*文件。确认方法是在 Options for Target - Linker 勾选 Map 文件查看链接日志里是否只出现libarm_cortexM3l_math.a的引用。浮点数显示不要直接sprintf(buf, %f, val)Keil 的 microlib 默认不启用完整浮点格式支持。用一个简单拼整数和小数的方式void FormatFloat(float val, int precision, char *out) { int ipart (int)val; int fpart (int)((val - (float)ipart) * powf(10, precision)); if (val 0) { ipart (int)val - 1; fpart (int)(((int)val 1.0f - val) * powf(10, precision)); *out -; } out sprintf(out, %d., ipart); sprintf(out, %0*d, precision, fpart); }precision控制小数位数powf(10, precision)用于换算小数部分。这个函数完全绕开了%f链接体积更小而且输出长度可控不会因为浮点精度把 LCD 撑爆。处理负数时先修正整数部分再计算小数部分保证-0.5显示为-0.500000而不是0.500000。5.3 用逻辑分析仪验证明时序竞赛评委通常会问两个问题按键会不会双击LCD 有没有偶发花屏。按键双击大概率是消抖时间不够长但一味增大延时会让手感变差。把延时从 10 ms 改成 15 ms 测试如果问题消失就说明按键抖动窗口已经盖住如果仍偶尔触发就要查列引脚是否漏配上拉电阻。LCD 花屏常见原因是数据线和 EN 上升沿的建立时间不够。用示波器或逻辑分析仪抓 PC2 的 EN 引脚观察LCD_SendNibble中GPIO_WriteBit(EN, Bit_SET)到delay_us(1)之间的波形宽度对照 LCD1602 手册里要求的 400 ns 最小使能高电平时间。如果逻辑分析仪看到 EN 高电平只有 300 ns把delay_us(1)改成delay_us(2)即可。本文还有配套的精品资源点击获取