最近帮一个学弟看他的课程设计,题目就是那种网上特别常见的编号题目,S014A,基于STM32单片机计算器,LCD1602显示。东西本身不算复杂,但麻雀虽小五脏俱全,GPIO、时序、按键扫描、状态机、表达式解析全占齐了。我干脆自己从零搭了一遍,顺手把整个设计思路、驱动代码和踩过的坑都整理成文。如果你正在做单片机课设,或者刚入门STM32想找个完整案例练手,这篇应该能帮你省不少时间。
这种计算器项目在毕设和课设里出现频率极高,核心需求就一句话:用单片机读取矩阵键盘输入,完成加减乘除四则运算,并实时显示在LCD1602上。听起来简单,实际上要写明白驱动、消抖、求值算法,里面坑不少。下面我就从硬件选型讲到代码实现,尽量把每个决定背后的原因都说清楚。
1. 项目整体设计与硬件选型解析
1.1 这个计算器项目到底在做什么
先把这个项目的功能边界定下来。我们这里做的是一个支持四则运算、支持连续输入多位数字、能正确处理乘除优先级、并且有清零功能的简易计算器。用户通过4x4矩阵键盘输入数字和运算符,LCD1602分两行显示当前输入内容或计算结果。
这个项目为什么值得做?因为它的知识密度很高。表面上只是一个计算器,但拆开来看,它覆盖了单片机开发的几大基础模块:GPIO的推挽输出与上拉输入配置、LCD1602的类时序驱动、矩阵键盘的行列扫描、机械按键的消抖处理、以及中缀表达式的解析求值。这些模块在后续做温湿度计、电子秤、智能小车时同样会用到。做完这个计算器,你对STM32的IO控制、延时逻辑、状态机思路会有非常直观的理解。
对于课程设计而言,这个题目也天然适合做成报告:硬件部分可以画原理图和PCB,软件部分可以分模块讲驱动和算法,最后还能演示实物。比起纯软件类题目,这种软硬结合的题目更容易出彩。
1.2 硬件选型:为什么是STM32F103C8T6和LCD1602
先说主控。STM32F103C8T6这颗芯片在课程设计里几乎成了默认选项,它的优势不在于性能多强,而在于三点:第一,价格便宜,淘宝上一块系统板也就十几二十块,甚至还有兼容的国产替代型号,烧录器用ST-Link或者串口都行;第二,资料极其丰富,无论你是用标准外设库还是HAL库,遇到问题都能搜到大量例程;第三,外设够用,内部Flash有64KB,RAM有20KB,跑一个计算器绰绰有余。
有人可能会问,为什么不用51单片机?51也能做计算器,但STM32在开发体验上有一个明显的区别:你能够使用真正的IDE(Keil MDK或者STM32CubeIDE),可以打断点调试,可以随时查看变量值。这对训练代码调试能力非常有帮助。另外,如果想给计算器增加额外功能,比如掉电存储、串口通信,STM32的资源也足够支撑。
再说显示设备。LCD1602是字符型液晶屏,16列2行,每行16个字符,内部驱动芯片是HD44780。它最大的优点就是便宜、稳定、容易驱动。一块屏几块钱,驱动逻辑非常经典,网上能找到海量资料。对比之下,数码管只能显示数字,OLED虽然有SPI/I2C接口显示更炫,但对新手来说驱动难度更高,而且OLED在强光下可视性反而不如LCD。做课设讲究的是稳定和好讲原理,LCD1602是最合适的选择。
1.3 引脚分配与硬件接线
这里给出一个可以直接照抄的接线方案,其中LCD1602采用4位数据模式,这样总共只占用6个GPIO,比8位模式省了4个引脚。RW引脚直接接地,也就是只写不读,省去读忙检测的麻烦。
| LCD1602引脚 | 连接到STM32F103C8T6 | 说明 |
|---|---|---|
| VSS | GND | 电源地 |
| VDD | 5V | 注意很多LCD1602模块需要5V供电 |
| V0 | 10K电位器中点 | 对比度调节,接GND时对比度最高 |
| RS | PA1 | 寄存器选择,0写指令,1写数据 |
| RW | GND | 只写模式,直接接地 |
| E | PA2 | 使能信号,下降沿锁存数据 |
| D4 | PA3 | 数据线4 |
| D5 | PA4 | 数据线5 |
| D6 | PA5 | 数据线6 |
| D7 | PA6 | 数据线7 |
| A/K | 5V/GND | 背光,串一个100欧电阻限流 |
矩阵键盘采用4x4行列式结构,我这里把列的扫描引脚放在PB0到PB3,行输入引脚放在PB4到PB7。列引脚设为推挽输出,行引脚设为上拉输入。按下按键后,行引脚会被拉低,通过逐列扫描即可确定键值。
注意:STM32F103C8T6的大部分引脚是5V容忍的,也就是可以承受5V电平输入,但LCD1602的数据线是5V输出,接的时候要先确认你的板子引脚或者模块是否带了电平转换。如果用的是淘宝上常见的蓝色PZ6806L开发板,板载接口一般已经处理好了;如果是裸芯片或最小系统板,建议把LCD1602的VDD接到5V,而D0到D7以及RS、E这些信号线,务必确认STM32供电和电平匹配。
2. 开发环境搭建与LCD1602驱动细节
2.1 用STM32CubeMX快速初始化工程
我习惯用STM32CubeMX生成初始化代码,再把业务逻辑写进去。这样配置时钟树和外设非常省事。新建工程时选STM32F103C8Tx,然后在Pinout视图中把用到的引脚全部配置好:PA1到PA6设置为GPIO_Output,PB0到PB3设置为GPIO_Output,PB4到PB7设置为GPIO_Input并开启上拉。
时钟树部分,如果让CubeMX自动配置,默认会跑在HSI 8MHz,性能不高。我建议在Clock Configuration里把PLL倍频到72MHz,这是F103的最高主频。配置完成之后,Project Manager里选择MDK-ARM版本,生成代码,然后用Keil打开工程。
这里有个小技巧:如果你只是做仿真,比如在Proteus里跑,也是完全可行的。Proteus里有STM32F103C8模型和LCD1602模型,代码写成HAL库或者标准库都能仿真。不过仿真时LCD1602的时序可能会和实物有一点差异,遇到不显示的情况先从初始化延时时长着手排查。
2.2 LCD1602的驱动协议到底怎么回事
LCD1602其实是一个控制逻辑比较简单的模块,难在时序的记忆。它内部有一个HD44780控制器,你对它写入指令寄存器的值叫控制命令,写入数据寄存器的值就是要显示的字符的ASCII码。
每次通信都通过E引脚产生一个下降沿,也就是先拉高E再拉低E,让LCD锁存当前数据线上的电平状态。RS引脚决定你访问的是指令寄存器还是数据寄存器,RS为0时写命令,RS为1时写数据。RW为0时写,RW为1时读。我们这里把RW直接接地,就固定为写模式,少控制一根线。
4位模式的意思是,每次只发送8位数据的高4位,E引脚拉一个下降沿,再发送低4位,再拉一个下降沿。这样一个完整的8位命令或者数据就通过两次传输完成。4位模式的初始化比8位模式讲究,因为上电时LCD默认在8位模式,你必须先向它发送三次特定的握手指令,它才会切换到4位模式。
标准初始化序列如下:
void LCD_Init(void) { delay_ms(20); // 等待LCD上电稳定 LCD_WriteNibble(0x03); // 第一次握手:8位模式 delay_ms(5); LCD_WriteNibble(0x03); // 第二次握手 delay_us(150); LCD_WriteNibble(0x03); // 第三次握手 LCD_WriteNibble(0x02); // 切换到4位模式 LCD_WriteCommand(0x28); // 功能设置:4位、2行、5x7点阵 LCD_WriteCommand(0x08); // 显示关闭 LCD_WriteCommand(0x01); // 清屏 delay_ms(2); LCD_WriteCommand(0x0C); // 显示开启,光标关闭 LCD_WriteCommand(0x06); // 写入后光标自动右移 }其中LCD_WriteNibble是往高4位数据线上发送一个字节的高4位,LCD_WriteCommand则是先后发送高4位和低4位,每次发送后都产生一个E下降沿。第一次握手时必须用延时等待,不能依赖忙检测,因为LCD刚上电还没准备好。
2.3 驱动代码要点与移植技巧
写LCD1602驱动时有一个常见争议:要不要用忙检测。很多51例程会读BF标志位判断LCD是否空闲,但STM32主频高、GPIO翻转快,如果一直忙检测,反而可能因为读时序不标准导致卡死。更省心的做法就是像上面一样,所有耗时步骤都用延时来兜底:初始化时用毫秒级延时,写命令后用100微秒级延时,写数据后用40微秒级延时。实测下来非常稳。
下面是两个核心函数的实现:
void LCD_WriteNibble(uint8_t data) { // 取低4位放到PA3-PA6上 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_6, (data >> 3) & 1); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, (data >> 2) & 1); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, (data >> 1) & 1); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, data & 1); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET); // E拉低 delay_us(1); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET); // E拉高 delay_us(1); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET); // E拉低,产生下降沿 delay_us(50); }显示字符串就简单了,逐字符调用写数据函数即可。注意第一行的地址是0x80,第二行是0xC0:
void LCD_ShowString(uint8_t row, uint8_t col, char* str) { uint8_t addr = (row == 0) ? (0x80 + col) : (0xC0 + col); LCD_WriteCommand(addr); while (*str) { LCD_WriteData(*str++); } }移植提示:如果你不想自己写驱动,网上确实能下载到各种LCD1602驱动文件,但拿过来以后一定要检查GPIO引脚和延时单位是否匹配。很多问题并不是驱动本身错了,而是引脚没改全,或者延时函数实际延时和注释不符。
3. 矩阵键盘扫描与按键消抖实战
3.1 4x4矩阵键盘的扫描原理
矩阵键盘的巧妙之处在于用8根线管16个按键。4根行线、4根列线,每个按键跨接在一根行线和一根列线之间。扫描的核心思路是逐列拉低,然后读取行的电平状态;如果某一行变成了低电平,就说明当前这一列的某个按键被按下。
具体到代码,我习惯这样写:先把全部列引脚输出低电平,然后读行引脚。如果行引脚全部为高,说明没有任何按键按下,直接返回0。如果检测到有低电平,就进入逐列扫描:把当前列设为低电平,其他列设为高电平,再读行。此时行引脚中的低电平位置就对应按键行号。
const uint8_t KeyMap[4][4] = { {'1', '2', '3', '+'}, {'4', '5', '6', '-'}, {'7', '8', '9', '*'}, {'C', '0', '=', '/'} }; uint8_t Key_Scan(void) { uint8_t row, col, colPin; // 所有列输出低 for (col = 0; col < 4; col++) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0 << col, GPIO_PIN_RESET); } // 读行 uint16_t rowState = (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_4) << 3) | (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_5) << 2) | (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_6) << 1) | HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_7); if ((rowState & 0x0F) == 0x0F) { return 0; // 没有按下 } for (col = 0; col < 4; col++) { // 当前列拉低,其他列拉高 for (int c = 0; c < 4; c++) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0 << c, (c == col) ? GPIO_PIN_RESET : GPIO_PIN_SET); } delay_us(10); rowState = 读行电平; for (row = 0; row < 4; row++) { if ((rowState & (0x10 << row)) == 0) { return KeyMap[row][col]; } } } return 0; }这段代码里我特意在每列之间加了10微秒延时,原因是GPIO引脚存在建立时间,如果拉低列之后立刻读行,可能读到上一列的残留电平。这个细节很容易被忽略,但往往就是扫描乱跳的原因。
3.2 按键消抖的两种常用做法
机械按键在按下和释放的瞬间,金属触点会产生约5到20毫秒的抖动。如果不做处理,一次按下会被误判成多次。最简单的方案是延时消抖:检测到按键按下后,延时10毫秒再读一次,如果还是低电平说明确实按下了。
uint8_t Key_GetKey(void) { uint8_t key = Key_Scan(); if (key == 0) { return 0; } delay_ms(10); uint8_t key2 = Key_Scan(); if (key2 != key) { return 0; } // 等待按键释放,避免一次按压多次触发 while (Key_Scan() != 0); return key; }这里有一个很多新手容易踩的坑:在一个while循环里等待按键释放,会卡住整个主循环,导致LCD不能刷新。最佳实践是把消抖和等待释放拆成多个状态,在主循环里轮询。比如用状态机记录按键状态,从“检测到按下”到“确认按下”到“等待释放”,每次主循环只执行一个状态。
这个设计我在第5章会详细展开,这里先记住结论:按键处理千万别在中断里做延时,也别在获取按键值函数里做长阻塞,否则整个系统就卡住了。
3.3 按键映射与计算器操作约定
4x4键盘一共16个键,我们需要的功能有数字0到9、加减乘除、等号、清零,一共是14个键,刚好放下。我上面的KeyMap里就把C键放到了第3行第1列,等号放在第2行第3列。
操作逻辑约定如下:用户输入一个数字后按运算符,再输入第二个数字,按等号得出结果。如果输入过程中按C,则清空所有输入重新开始。为了让LCD显示不混乱,我通常是第一行显示当前输入的表达式,第二行显示计算后的结果或错误提示。这样演示的时候观感非常好,老师一眼就能看到输入和输出的对应关系。
4. 计算器核心算法设计与实现
4.1 为什么要用双栈而不是“一个数一个符号循环”
如果只做一位数加减乘除,确实不需要什么算法,用状态机记录两个操作数和一个运算符就行。但既然按键支持多位数字输入,用户就可能输入“12+34*5-6”这样的表达式,如果不做优先级处理,结果就会变成(12+34)×5-6=224,而正确结果应该是12+170-6=176。这个差别在演示时非常尴尬。
所以核心算法我建议用双栈求值:一个栈存数字,一个栈存运算符。遍历表达式时,遇到数字就解析并压入数字栈;遇到运算符就先比较栈顶运算符和当前运算符的优先级,如果栈顶优先级更高或相等,就先算一步,再把当前运算符压栈。遇到等号时,把栈里剩余的运算符全部弹出计算。
这个方法背后的逻辑很像我们在纸上算题的习惯:看到加减法不着急算,先把数字放一边;看到乘除法才立刻算。对应到代码里,就是用一个栈结构把还没计算的数字和运算符暂时存起来,等优先级关系明确后再统一处理。
4.2 双栈求值的实现思路与代码骨架
先写一个优先级函数和运算函数:
int priority(char op) { if (op == '*' || op == '/') { return 2; } if (op == '+' || op == '-') { return 1; } return 0; } double apply(double a, double b, char op) { switch (op) { case '+': return a + b; case '-': return a - b; case '*': return a * b; case '/': return a / b; default: return 0; } }然后是对表达式字符串的解析,这里我用了一个简化版,把输入表达式存成字符数组,然后逐个字符扫描:
double evalExpression(char* expr) { double numStack[32]; char opStack[32]; int nTop = 0, oTop = 0; while (*expr) { if (*expr >= '0' && *expr <= '9' || *expr == '.') { char numBuf[16]; int len = 0; while (*expr >= '0' && *expr <= '9' || *expr == '.') { numBuf[len++] = *expr++; } numBuf[len] = '\0'; numStack[nTop++] = atof(numBuf); continue; } if (*expr == '(') { opStack[oTop++] = *expr++; continue; } if (*expr == ')') { while (oTop > 0 && opStack[oTop-1] != '(') { double b = numStack[--nTop]; double a = numStack[--nTop]; char op = opStack[--oTop]; numStack[nTop++] = apply(a, b, op); } oTop--; // 弹出左括号 expr++; continue; } // 处理加减乘除 while (oTop > 0 && priority(opStack[oTop-1]) >= priority(*expr)) { double b = numStack[--nTop]; double a = numStack[--nTop]; char op = opStack[--oTop]; numStack[nTop++] = apply(a, b, op); } opStack[oTop++] = *expr++; } while (oTop > 0) { double b = numStack[--nTop]; double a = numStack[--nTop]; char op = opStack[--oTop]; numStack[nTop++] = apply(a, b, op); } return numStack[0]; }这段代码里最需要注意的地方是运算符优先级比较那一步。比如“1+23”,扫描到号时,栈顶是+,因为+的优先级是1,的优先级是2,不满足“栈顶优先级 >= 当前优先级”,所以不计算,直接把压栈。扫描到3之后,表达式结束,这时才把栈里的+和依次弹出,先算23再算1+6,得到7。这里用到的原则就是:只在栈顶运算符优先级不低于当前运算符时,才先算栈顶。
4.3 从按键到表达式:输入的构建与错误处理
按键输入是个逐步往字符缓冲区填内容的过程。我定义一个全局字符数组g_expr[32]和一个索引g_idx。每次按数字键就往g_expr[g_idx]里写入该数字并右移;按运算符也类似。等号键触发表达式求值,清屏键清空整个缓冲区。
错误处理是这个项目里容易被忽略但很重要的一块。除数为0时会出现浮点数无穷大,LCD上会显示“inf”,非常难看。我在运算函数里加了一个判断,如果除法运算的除数为0,直接返回一个特殊标记,在主循环里显示“ERROR”。另外,用户可能按下连续两个运算符,比如“1++2”,这种表达式在双栈算法里会出错,所以输入阶段就应该阻止:如果当前输入缓冲区为空或者最后一个字符是运算符,则不允许再输入运算符。
输入构建还有一个细节:LCD1602只有16列,表达式长了会溢出。我的处理是只保留最近显示的部分,也就是只维护一个长度为15的环形缓冲区,或者干脆在输入阶段限制最长输入长度为15个字符。实际使用中15个字符足够演示“123+456*789”这样的表达式了。
5. 系统整合:状态机驱动的完整流程
5.1 用有限状态机串起整个计算流程
前面两章分别做好了显示驱动、按键扫描、表达式求值,现在需要把他们组织起来。我在这里强烈建议把整体逻辑用有限状态机来实现,而不是在main函数里用一堆if-else硬堆。
状态机的好处是逻辑清晰、容易扩展。我定义了这几个状态:
| 状态 | 含义 | 行为 |
|---|---|---|
| S_INPUT | 等待用户输入数字或运算符 | 把按键写入表达式缓冲区并刷新显示 |
| S_RESULT | 按下等号后显示结果 | 显示计算结果,等待新输入 |
| S_ERROR | 出现除零或表达式非法 | 显示ERROR,只接受清屏键 |
在S_RESULT状态下,如果用户再按一个运算符,可以把刚才的结果当作新表达式的第一个操作数继续参与运算;如果用户按数字键,则清空缓冲区重新开始。这个逻辑是用状态机的关键,也是演示计算器“连续运算”能力的地方。
uint8_t state = S_INPUT; char g_expr[32]; uint8_t g_idx = 0; double g_result = 0; while (1) { uint8_t key = Key_GetKeyNonBlock(); if (key) { switch (state) { case S_INPUT: if (key == '=') { double r = evalExpression(g_expr); if (isError) { LCD_ShowString(0, 0, " ERROR "); state = S_ERROR; } else { g_result = r; LCD_ShowString(0, 0, g_expr); LCD_ShowString(1, 0, formatResult(r)); state = S_RESULT; } } else if (key == 'C') { clearExpr(); } else { appendToExpr(key); LCD_ShowString(0, 0, g_expr); } break; case S_RESULT: if (key == 'C') { clearExpr(); state = S_INPUT; } else if (isDigit(key)) { clearExpr(); appendToExpr(key); LCD_ShowString(0, 0, g_expr); LCD_ShowString(1, 0, ""); state = S_INPUT; } else if (isOperator(key)) { clearExpr(); appendNumberToExpr(g_result); appendToExpr(key); LCD_ShowString(0, 0, g_expr); LCD_ShowString(1, 0, ""); state = S_INPUT; } break; case S_ERROR: if (key == 'C') { clearExpr(); state = S_INPUT; } break; } } LCD_Refresh(); }这里的Key_GetKeyNonBlock是非阻塞按键获取函数,每次调用只扫描一次并返回当前按键,不等待释放。按键释放状态由按键状态机在内部维护,避免堵塞主循环。
5.2 显示刷新与按键响应配合
你可能会发现上面的主循环里每次都调用LCD_Refresh,但实际上LCD1602的内容只有在表达式变更时才需要重写。如果每次都全量刷新,会产生肉眼可见的闪烁。我这里的做法是加了一个dirty标志,只有按键导致表达式或结果变化时,才执行LCD_ShowString。dirty在每次写入表达式或结果之后置1,刷新完毕后清零。
这样做还有一个附带好处:LCD1602的写周期大约几十微秒,如果在主循环里高频刷新,会占用大量CPU时间。虽然这个项目对实时性要求不高,但养成“变量变化才刷新外设”的习惯,以后做传感器数据采集时会少走很多弯路。
6. 常见问题排查与优化经验
6.1 高频问题速查表
| 现象 | 可能原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| LCD上电白屏无显示 | 对比度没调好 | 旋转V0电位器,看是否有黑色方块出现 | 调整对比度到合适位置,通常中间偏GND方向 |
| LCD显示乱码 | 4位模式接线错误 | 核对D4-D7数据线顺序 | 重点检查D4-D7是否接到了正确的GPIO |
| LCD不亮但电源正常 | 背光限流电阻断开或接反 | 万用表量背光两端电压 | 确保背光正极接5V,负极串联100欧电阻到GND |
| 按键按下没反应 | 行引脚没配置上拉 | 检查GPIO配置 | 把行引脚配置为Input pull-up |
| 按一次键显示多个字符 | 消抖没做或等待释放没做 | 观察现象是否规律 | 加消抖状态机和等待释放逻辑 |
| 按等号后结果不对 | 表达式解析错误 | 检查表达式缓冲区和优先级逻辑 | 逐步打印栈内元素定位逻辑问题 |
| 除零后屏幕显示inf | 缺少除零保护 | 在apply函数里判断b==0 | 设置错误标志并显示ERROR |
| 输入超过16位被截断 | 缓冲区或显示行溢出 | 检查表达式长度 | 限制输入长度最长15字符 |
6.2 我个人踩过的一些坑
第一个坑是LCD1602的对比度。当时我焊好板子,上电之后屏幕什么都没显示,我以为是驱动代码写错了,花了整整一个晚上排查时序、反复烧写程序。后来才发现是V0引脚悬空导致对比度太高或太低,屏幕内容根本看不见。从那以后我所有带LCD的项目,第一步都是先把对比度电位器拧到中间位置,再上电调显示。记住:白屏优先怀疑对比度,不是代码。
第二个坑是scanf和sprintf这类浮点格式化在嵌入式里的显示问题。STM32的HAL库中,默认的printf是重定向到串口的,而我不想用串口,直接用sprintf把浮点数转成字符串显示。但Keil MDK如果不勾选“Use MicroLIB”,sprintf的浮点支持可能无法正常工作,输出全是“%f”之类的占位符。解决方法是把工程设置里的Use MicroLIB勾上,或者在代码里自行实现浮点转字符串函数。对于课程设计,勾MicroLIB是最快的。
第三个坑是依赖下载的驱动程序但没验证时序。网上随便搜LCD1602驱动,能搜到十几种写法,有的用标准库有的用HAL库,有的延时是微秒级有的是毫秒级。如果直接拷贝而不检查底层引脚和延时函数,很容易出现“屏幕闪一下就不动了”的怪问题。我的建议是拿到任何驱动,先把初始化序列和写数据函数逐行读一遍,确认引脚映射和延时逻辑再集成。
还有一个我一直强调的细节:按键扫描函数千万不要放定时器中断里长时间执行。我之前为了按键响应快,试图把扫描放在定时器中断里,结果一按键盘就卡死。原因很简单,中断里做循环扫描会有延时,而延时会严重干扰中断响应。后来我把所有扫描和消抖逻辑都放到主循环,用标志位驱动,问题就消失了。
如果你打算在这个项目基础上做扩展,我建议往三个方向想:一是加上括号支持,双栈算法已经预留了括号解析逻辑,很容易实现;二是增加串口功能,通过串口助手发送表达式,结果回传,这样能展示STM32的通信能力;三是加掉电保存功能,把计算结果存入Flash,重启后还能显示。这些方向随便加一个,课设的评分档次都会不一样。