基于STM32单片机计算器(LCD1602显示)项目从零拆解
最近后台有不少读者在问基于STM32的计算器该怎么做,正好手头有一个典型的课题:基于STM32单片机计算器,配LCD1602显示,项目编号S014A。这个项目是很多学校的课程设计和毕业设计题目,看起来简单,但真要把它讲透,涉及的东西其实不少:GPIO操作、LCD1602时序驱动、矩阵键盘扫描、按键消抖、运算解析、甚至状态机设计。我这次把整个项目的实现思路、硬件接线、代码逻辑以及调试中容易踩的坑全部整理出来,给准备做这个题目的朋友一份可以直接照着做的参考,也聊聊怎么把这个“课设级”项目做出点自己的设计感。
先给没接触过这块的朋友一句话说明:这个项目要做的事情就是——用STM32单片机读取用户通过键盘输入的数字和运算符,完成加减乘除运算,再把输入表达式和结果实时显示在LCD1602液晶屏上。听起来不难,但真正动手你会发现,显示驱动、键盘扫描、运算状态管理任何一个环节没处理好,做出来的东西就是“按着按着数字乱跳”“算到一半结果丢了”“屏幕只亮不显示字”这类经典问题。下面我按模块逐一拆。
1. 整体设计思路与方案选型
1.1 为什么核心选STM32而不是51
很多计算器课题默认用51单片机,因为学校教的就是51,代码简单,资料也多。但选STM32做这个题目,我个人的判断是——这是一个“同样的题目,更高的完成度”的选择。
STM32F103系列(最常用的是C8T6这个型号)主频72MHz,51单片机常见的是12MHz晶振、12个时钟周期执行一条指令,两者计算能力差了不止一个数量级。计算器这种对实时性有要求、而且要做按键消抖和LCD时序驱动的应用,STM32跑起来非常轻松,定时器、外部中断、I2C、SPI这些资源也完全够用,后续如果想扩展(比如加上温度传感器、语音播报、蓝牙传数据),STM32的余量也比51充裕得多。
而且从学习角度讲,STM32是ARM Cortex-M3内核,学会了它的GPIO配置、时钟树、中断机制,你以后接触任何ARM芯片都能很快上手。借助STM32CubeMX这个图形化配置工具,初始化代码都是自动生成的,我们只需要专心写自己的业务逻辑。
另外要提醒一点,选型时尽量选Flash在64KB以上的型号。计算器的功能虽然不复杂,但如果你把LCD驱动、矩阵键盘、运算逻辑、显示刷新这些代码都写上,再加上可能加的一些扩展功能(比如历史记录、按键音),代码量很容易超过32KB。C8T6是64KB Flash、20KB RAM,做计算器绰绰有余。
1.2 LCD1602显示方案的取舍
显示部分,课题指定用LCD1602,这是工业界最经典的字符型液晶屏,16列2行,每行最多显示16个字符。它只能显示ASCII字符和少量日文假名、自定义字符,想要显示完整的表达式(比如123+456=),一行16个字符是刚好够用的。
LCD1602有两种接法:8位并行和4位并行。
- 8位接法:一次传输8位数据,占用DB0~DB7共8个引脚外加RS、RW、EN三个控制引脚,一共11个IO。
- 4位接法:数据分两次传输,高4位在前低4位在后,只需要DB4~DB7共4个引脚,加上3个控制引脚一共7个IO。
我强烈建议用4位接法,理由很实际:STM32F103C8T6虽然有32个GPIO可用,但一旦电路上接了矩阵键盘(4x4需要8个IO)、LCD(7个IO)、可能还有LED指示灯、蜂鸣器、串口下载电路,IO资源就变得紧张了。4位接法省下的4个IO,用途很大。而且4位模式在时序上只是多了一次数据写入,对72MHz主频来说几乎没有延迟感。
1.3 输入方案的两种思路对比
键盘输入有独立按键和矩阵键盘两种方案。
独立按键最简单:每个按键单独占用一个IO口,读取直接判断高低电平。但这只适合按键数量少的场景(4个方向键、2~3个功能键),计算器至少需要0~9十个数字键、加、减、乘、除四个运算符、等号、清除,总共16个键。16个独立按键要占16个IO,这在STM32上很浪费。
所以主流方案是4x4矩阵键盘:4根行线加4根列线,一共8个IO口,就能组合出16个按键。扫描原理是“行列反转法”或者“逐行扫描法”:先把行线设为输出、列线设为输入,依次拉低每一行,检测哪一列变成低电平,就能唯一确定按下的按键。这个原理本身不难,但要注意按键的硬件消抖和软件消抖配合。
综合来看,我选的方案是:STM32F103C8T6 + 4x4矩阵键盘 + LCD1602(4位模式),供电采用USB转TTL模块的5V输出,板载AMS1117-3.3稳压到3.3V给MCU供电,LCD1602直接用5V供电(它的逻辑电平兼容3.3V输入,这个后面细说)。
2. 硬件接线与关键电路解析
2.1 最小系统与供电设计
STM32F103C8T6的最小系统需要三样东西:3.3V电源、8MHz外部晶振、复位电路。
这个项目的常用做法是直接买一块“STM32最小系统板”来用,板上已经集成了晶振、复位按键、LDO稳压、BOOT选择跳线、Type-C接口,不用自己画PCB就能把精力聚焦在功能实现上。不过如果你是想自己画板子做实物,需要注意这几个细节:
- VDD和VDDA引脚都要接3.3V,并且每个电源引脚旁边放一个100nF的去耦电容,靠近引脚放置;
- NRST引脚接一个10kΩ上拉电阻到3.3V,再加一个100nF电容到地,构成低电平复位的RC电路;
- VDDA需要额外的磁珠或小电阻隔离,减少电源噪声对ADC精度的影响。
LCD1602的工作电压是4.7V~5.3V,典型应用就是5V供电。这里有个新手经常疑惑的点:STM32的GPIO是3.3V电平,能直接驱动5V供电的LCD1602吗?
答案是可以的。LCD1602的逻辑输入高电平门槛大约是2V(TTL电平标准),STM32输出的3.3V高电平完全高于这个门槛。所以数据引脚直接连就行,不需要电平转换。但这有个前提:你的LCD1602模块是标准并口模块,如果买的是带I2C转接板的,那就是另一套玩法了。我这次以标准并口为例。
2.2 LCD1602四线制接线对照表
以最小系统板的PA口和PB口来分配引脚,我常用的分配如下:
| LCD1602引脚 | 功能 | 接STM32引脚 |
|---|---|---|
| VSS | 电源地 | GND |
| VDD | 电源正 | 5V |
| VO | 对比度调节 | 接10k电位器中间脚 |
| RS | 寄存器选择(1数据/0指令) | PB0 |
| RW | 读写选择(1读/0写) | PB1(直接接地,只写不读) |
| E | 使能信号 | PB10 |
| DB4 | 数据位4 | PB12 |
| DB5 | 数据位5 | PB13 |
| DB6 | 数据位6 | PB14 |
| DB7 | 数据位7 | PB15 |
| LEDA | 背光正极 | 串联220Ω电阻接5V |
| LEDK | 背光负极 | GND |
有几个关键点:
RW引脚直接接地。LCD1602的读操作基本用不上,我们只需要往里面写指令和数据。RW接地后,GPIO只做输出,引脚占用从11个降到10个,而且在驱动代码里省了读忙标志(BF)的环节,唯一需要做的就是“写完指令/数据后延时等待”。
VO对比度引脚必须接电位器,不能直接接地或接电源。LCD1602上电后不显示字、只亮背光,八成是这个引脚的电平没调好。用一个10k电位器,中间脚接VO,两端分别接5V和GND,上电后慢慢旋转电位器,直到屏幕出现清晰的两行黑块或者字符。这一步是100%会遇到的,先有这个心理预期。
2.3 4x4矩阵键盘的接线与扫描思路
矩阵键盘我分配在PA0~PA3做行线,PA4~PA7做列线,具体对应关系如下表:
| 按键 | 行/列 | 对应字符 |
|---|---|---|
| (0,0) | PA0/PA4 | 1 |
| (0,1) | PA0/PA5 | 2 |
| (0,2) | PA0/PA6 | 3 |
| (0,3) | PA0/PA7 | A(清屏) |
| (1,0) | PA1/PA4 | 4 |
| (1,1) | PA1/PA5 | 5 |
| (1,2) | PA1/PA6 | 6 |
| (1,3) | PA1/PA7 | B(退格) |
| (2,0) | PA2/PA4 | 7 |
| (2,1) | PA2/PA5 | 8 |
| (2,2) | PA2/PA6 | 9 |
| (2,3) | PA2/PA7 | C(负号) |
| (3,0) | PA3/PA4 | 0 |
| (3,1) | PA3/PA5 | + |
| (3,2) | PA3/PA6 | - |
| (3,3) | PA3/PA7 | × ÷ = |
矩阵键盘最经典的扫描办法是逐行扫描:先把4根行线(PA0~PA3)设为推挽输出,4根列线(PA4~PA7)设为上拉输入。然后依次让某一行的输出为低电平,其他行为高电平,再读列线,如果哪一根列线读到低电平,说明该行该列的按键被按下了。
如果担心按键接触抖动导致误判,软件上做两件事就够了:检测到按下后延时10ms再读一次确认;松开时也要消抖,不然容易一次按键触发两三次操作。
2.4 蜂鸣器与指示电路(可选扩展)
如果想让作品看起来更完整,可以加一个无源蜂鸣器到PB5,通过TIM3的PWM输出驱动,实现按键提示音。这个功能代码量很小,但给评审老师的印象分会明显不一样。具体实现可以直接用TIM3输出频率可调的方波,按键按下时调用一次Buzzer_Beep(2000, 50),表示“输出2kHz频率、持续50ms”,人耳听到就是很清脆的“嘀”一声。
3. LCD1602驱动与按键扫描代码解析
3.1 初始化流程与双层结构
LCD1602的驱动,我习惯封装成一个独立的模块文件lcd1602.c,对外只暴露LCD_Init()、LCD_Clear()、LCD_SetCursor(row, col)、LCD_WriteString(char* str)这几个接口。这样主程序里不用关心底层的时序细节,代码结构非常清爽。
LCD1602的初始化时序是网上资料最多也最容易抄错的地方,其实跟厂商数据手册对照来写就不容易错。核心流程是:
- 上电后延时至少40ms(让内部电路稳定);
- 连续三次写入指令 0x38(这里有个细节:在4位模式下,这个0x38要分两次发送,先发高4位0x3再发低4位0x8,但网上很多代码这里写的是0x30);
- 然后依次写入 0x08(显示关闭)、0x01(清屏)、0x06(光标右移)、0x0C(显示开、光标关、不闪烁)。
这里有一个很经典的坑:如果用STM32CubeMX生成的代码,GPIO初始化和LCD初始化都在main函数里执行,LCD的初始化必须放在延时函数正常工作的前提下。启动时系统时钟已经是72MHz,HAL_Delay依赖SysTick中断,如果初始化阶段系统中断还没配好,延时函数会失效。
解决方法是:LCD_Init() 放在MX_GPIO_Init()之后调用,并且确保 HAL_Init() 已经被调用。另外,如果用的是其他厂商的库或者自己写的延时函数,第一次写LCD代码时建议把延时时间放大一倍,等确认显示正常了再把延时降到数据手册的标准值,这种方式排查问题会省心很多。
3.2 写指令与写数据时序
LCD1602的底层操作就是两条:写命令和写数据。4位模式下,每次都是高4位先送、低4位再送。核心代码如下:
void LCD_Write4Bit(uint8_t data) { GPIOB->ODR = (GPIOB->ODR & 0x0FFF) | ((data & 0x0F) << 12); LCD_E_HIGH; delay_us(1); LCD_E_LOW; delay_us(1); } void LCD_WriteByte(uint8_t data) { LCD_Write4Bit(data >> 4); // 高4位 LCD_Write4Bit(data & 0x0F); // 低4位 } void LCD_WriteCmd(uint8_t cmd) { LCD_RS_LOW; LCD_WriteByte(cmd); delay_us(40); } void LCD_WriteData(uint8_t data) { LCD_RS_HIGH; LCD_WriteByte(data); delay_us(40); }代码里的GPIOB->ODR操作是寄存器层面的写法,意思是在不改动其他引脚输出的情况下,把PB12~PB15这四位设置成对应的数据。这里的0x0FFF是掩码,保留低12位的状态,把高4位清零,再通过<< 12把数据放到PB12~PB15的位置上。
不推荐在LCD驱动里用HAL_GPIO_WritePin一位一位地写,速度慢不说,一个字节要写8次代码也啰嗦。寄存器操作或者直接操作BSRR寄存器,效率高得多。用BSRR的方式更安全,可以一次性用ODR设置,但要注意不要影响不相关的引脚。
时序参数方面,LCD1602数据手册要求E引脚的高电平脉冲宽度最小是230ns(典型值450ns),对于72MHz的STM32来说,两条语句之间只有几纳秒的间隔,所以必须插入至少1微秒的延时。delay_us(1)用DWT(数据观察点与跟踪单元)实现即可,也可以直接用for循环空转几次。
3.3 按键扫描与状态机消抖
矩阵键盘扫描代码我推荐用“状态机”的方式写,而不是简单地“检测到按下就返回键值”。状态机的思路是定义三个状态:释放态、按下确认态、持续按住态。每次主循环里先读取当前IO状态,再根据状态转移条件决定是否返回有效键值。
简化版本可以这样做:
uint8_t Key_Scan(void) { uint8_t row, col, key = 0xFF; // 行线置为输出低电平,列线检测 for (row = 0; row < 4; row++) { GPIOA->ODR = ~(0x01 << row) & 0x0F; // 第row行拉低 delay_us(10); // 等待电平稳定 uint8_t col_val = (GPIOA->IDR >> 4) & 0x0F; if (col_val != 0x0F) { for (col = 0; col < 4; col++) { if ((col_val & (1 << col)) == 0) { key = row * 4 + col; break; } } break; } } return key; }这个函数返回的是0~15的矩阵编号,具体对应哪个字符,有一个按键映射表KeyMap[16],把每个编号映射成'0'~'9'、'+'、'-'、'*'、'/'、'='、'C'。这个分离的好处是换按键布局只需要改映射表,不影响扫描逻辑。
关于消抖,我实际测试下来,机械按键的抖动时间一般在5ms~10ms之间。所以消抖策略很简单:第一次扫描到键值后,延时10ms,再扫描一次,如果两次结果一致就确认按下,否则认为是一次抖动。松键检测同样重要,否则一按“5”可能出来“55”。实现上用一个变量记录“上一次有效键值”,只有当上一次键值为0xFF且本次不为0xFF时,才认为是一次“新按下”。
3.4 主循环框架与实时性
计算器的主循环不需要像工业控制那样追求微秒级响应,但要有清晰的框架。我在这个项目里用的是“轮询+状态标志”的方式:
while (1) { uint8_t key = Key_Scan(); if (key != 0xFF) { Buzzer_Beep(2000, 50); // 按键提示音 Calculator_Input(key); // 处理按键输入 } Calculator_UpdateDisplay(); // 刷新显示 delay_ms(5); }这里Calculator_Input()是计算器核心逻辑模块的入口,它接收按键编号,更新内部的表达式缓冲区、运算状态和结果。显示刷新单独抽出来,是因为LCD1602写一次字符串大约要几百微秒,如果每次按键都全屏重写,不仅闪烁感明显,而且会和按键扫描互相干扰。
4. 计算器核心逻辑与算法设计
4.1 表达式如何存储与解析
计算器核心要解决的一个问题是:用户任意输入数字、运算符、等号,程序如何知道现在处于什么状态、下一步该干什么。
我推荐用“状态机”管理计算流程,定义几个阶段:
- STATE_INPUT_NUM1:正在输入第一个操作数
- STATE_INPUT_OP:已经输入运算符,等待第二个操作数
- STATE_INPUT_NUM2:正在输入第二个操作数
- STATE_RESULT:已经算出结果
每次按键进来,根据当前状态决定处理方式:
- 如果按的是数字键,当前状态是STATE_INPUT_NUM1,就把数字追加到第一个操作数缓冲区后面;如果状态是STATE_RESULT,说明用户要开始新计算,先清空缓冲区再追加。
- 如果按的是运算符,记录运算符,进入STATE_INPUT_OP状态;
- 如果按的是等号,把待计算的表达式交给运算函数,得出结果,进入STATE_RESULT状态。
操作数缓冲区用字符串数组存,比如char num1[12], num2[12];,通过atoi()或atof()转成数字做运算。这样做的优势是:显示什么就是什么,用户输入的原始内容都保存着,后续想实现“历史记录”“表达式回显”都很容易。直接用变量接收数字换算,会导致无法区分用户输入的是“00”还是“0”(缓冲区都是字符串“0”)。
4.2 运算优先级怎么处理
计算器要不要支持“先乘除后加减”?这取决于课题要求。如果只需要做“顺序计算”(A 加/减/乘/除 B 再 加/减/乘/除 C……),那非常简单,每次拿到等号就直接result = num1 op num2。但如果要求完整表达式的运算优先级,就复杂一些。
基于课设的通常要求,我采用的是中缀表达式转后缀表达式(逆波兰表达式)再计算的方案。具体做法是:
- 维护两个栈:一个运算符栈、一个数字栈;
- 遍历表达式,遇到数字就入数字栈;
- 遇到运算符,如果运算符栈空或者当前运算符优先级高于栈顶,就入栈;否则先弹出栈顶运算符参与计算,再入栈;
- 遍历结束后,把运算符栈全部弹出参与计算。
这样1+2*3正确结果是7,而不是顺序计算的9。代码量大约50行,但对课设来说能明显拉开“普通完成”和“优秀完成”的差距。
如果你只想做顺序计算,那在等号按键处理里直接算就行;但要注意,用户的输入以字符串形式存在,如果用户输入的是23,要转换成整数23,转换函数要注意溢出问题——计算器能显示的最大位数由LCD1602的16列决定,操作数位数通常不超过7位,int32足够用。
4.3 除数为零与小数问题
计算器项目最容易在“除数为零”这个边界情况上翻车。不做保护的代码会直接进入硬件错误中断(HardFault),屏幕冻住,按键全部失灵。正确的处理方式是:在做除法前判断num2 == 0,如果是,LCD显示"Error! Div 0",清空状态机,等待用户按C键复位。
另一个容易忽略的是小数运算。普通课设如果只要求整数运算,那简单,整数除法在C语言里直接截断,但要告诉用户这一点,不然输入5/2显示成2可能让人觉得你算错了。更好的做法是引入浮点运算,用sprintf把浮点数转成字符串显示,显示时限制小数位数。我建议至少支持一位小数,然后处理成整数避免浮点显示兼容性问题。比如内部用int32存储100倍的结果,显示时手动加小数点,这种方法避开了sprintf的浮点格式化开销,也避免了浮点数精度误差,实测非常稳定。
4.4 显示刷新与字符长度控制
LCD1602每行最多16个字符,计算器的显示策略一般是:第一行显示表达式,第二行显示结果。但如果用户输入的表达式超过16个字符,要自动左移还是停止输入?我在项目里做的是滚动显示:缓冲区最多存20个字符,超过16个字符时,LCD显示缓冲区末尾的16个字符,这样用户总是能看到最新输入的数字。
显示刷新有个细节:如果每次都全清第一行再重写,屏幕会闪,肉眼感觉就是一直“抖”。解决方法是记录上一次显示的字符串,比较新旧字符串,内容没变的行不做写操作。这个对LCD1602这种只能整屏写的液晶来说,输出体验的提升很明显。
另外要处理的一个坑是:LCD1602的清屏指令0x01执行耗时约1.6ms,而lcd_clear()里如果清屏后立刻接着写字,可能第一个字符写不进去。所以清屏后要加一个2ms左右的延时,或者清屏时直接把“清屏+写入”串在一个函数里,中间不留异步空间。
5. 常见问题与调试实录
5.1 LCD1602只亮背光不显示字符
这个问题在我的私信里出现频率最高,原因八九不离十是VO对比度没调好。LCD1602在5V供电下,VO引脚电压在0.4V~1.5V之间才显示清晰。如果你用了一个固定电阻分压而不是电位器,一旦阻值不合适,字就不出来。
排查步骤:
- 确认VO接的是电位器中间脚,旋转电位器看有没有变化;
- 如果旋转过程中屏幕出现一行黑块,说明LCD本身工作正常,问题在初始化代码;
- 如果怎么旋转都没有任何反应,检查电源(VDD和VSS有没有接反)、检查E引脚有没有波形(示波器或者万用表频率档看)、检查RW是否接地。
还有一个容易忽略的是:STM32F103的PB3、PB4、PA15这几个引脚上电默认是JTAG调试口功能,不是普通GPIO。如果LCD的数据线不小心接到了这几个口上,GPIO初始化了也没用,因为默认是复用功能,必须先把__HAL_AFIO_REMAP_SWJ_NOJTAG()或禁用JTAG才能当普通IO用。这个坑我当年踩过,排查了一整个下午。所以LCD引脚尽量选PB5以后或者PA不使用JTAG的引脚,省心。
5.2 按键扫描有“串键”现象
按下键盘的“8”,结果显示“5”或者跳两个数字,这种现象的本质是扫描时序问题。一个是行线和列线的电平没有稳定就读取,另一个是消抖不彻底。
排查思路:
- 每次切换行线输出后,加入至少10us的延时再读列线,避免引脚翻转的瞬态过程干扰;
- 消抖延时从5ms提高到15ms;
- 检查硬件接线,矩阵键盘的每根信号线最好都接一个10kΩ的上拉电阻(列线内部上拉也可以,但如果有长杜邦线连接,外部上拉更可靠);
- 杜邦线不要飞得太长、互相缠绕,矩阵键盘本身是低电平有效,线间电容耦合可能导致误触发。
我实测过用面包板搭矩阵键盘,线长超过15cm就开始出现偶发串键,缩短线长、用排线或者直接焊在PCB上,问题基本消失。
5.3 结果算错或者出现负数溢出
如果你做的是整数运算,999999*999999已经超过int32的21亿上限。显示出来的是一个莫名其妙的负数,这其实是整数溢出,不是程序bug,但用户不会理解这一点。处理方法是在乘法前先判断操作数位数,如果两个操作数加起来超过9位,直接显示“Overflow”,算是一种保护机制。
如果用浮点运算,还要注意sprintf(buf, "%f", result)输出的字符串在LCD上显示不下。比如0.000001展开后很长。建议用sprintf(buf, "%.2f", result)限制两位小数,不做四舍五入的复杂需求时也可以用%.1f。
5.4 Keil工程无法下载程序
STM32计算器最常见的下载问题是用ST-Link下载时报 “No target connected” 或者 “Target not found”。处理顺序:
- 确认ST-Link的SWDIO、SWCLK、GND三根线连接正确,注意有些ST-Link需要接3.3V供电,有些不需要;
- 确认BOOT0跳线接到0(低电平),BOOT1不需要管;
- 进Keil的Options -> Debug -> Settings,看是否能识别到目标芯片IDCODE,如果不能,多半是硬件接线;
- 如果之前往芯片里烧录过一个把SWD引脚(PA13、PA14)配置成普通GPIO的程序,调试接口会被禁用,用“先用串口ISP擦除整片Flash”的方式救回来。
顺便说一个很常见的现象:下载器连着板子的时候能正常识别,一拔掉再单独给板子上电程序就跑飞。这个通常是电源问题——电脑USB口供电给ST-Link再转给板子,电压只有4.7V左右,板载LDO输出可能只有3.1V,芯片不稳定。解决办法是单独给板子接5V电源,或者换一个电压更准的USB口。
5.5 计算器按键响应延迟明显
如果主循环里每次扫描键盘后都要刷新LCD,再加上延时,可能导致按键按下去1秒才有反应。检查点:
- LCD刷新函数里是不是调用了清屏指令
0x01,这个指令耗时长; - 按键扫描函数里的for循环是不是在每次判断按键时都重新初始化GPIO,GPIO初始化很耗时;
- 延时函数是不是用了HAL_Delay这种依赖系统滴答的阻塞延时,主循环里建议用非阻塞的
HAL_GetTick()做时间片轮询。
我最后的优化做法是给按键扫描和LCD显示各分配一个5ms的时间片,用HAL_GetTick()计数而不是阻塞,整个系统反应速度提升非常明显,从“明显卡顿”到“随按随应”。
6. 工程组织与代码框架建议
6.1 模块划分与头文件设计
项目虽小,工程结构还是要规范。我习惯把代码拆成4个模块:
main.c:系统初始化、主循环调度;lcd1602.c/h:LCD底层驱动和应用显示接口;key.c/h:矩阵键盘扫描、消抖、键值映射;calc.c/h:运算状态机、表达式解析、结果格式化。
这种分法的好处是每个模块都能独立测试。我先单独跑LCD显示字符串的例子(在屏幕上显示任意字符串),确认LCD没问题;再单独跑按键扫描程序(把按键值通过串口发到电脑上的串口助手),确认键盘没问题;最后才把两个模块接到一起写运算逻辑。每次出现bug,问题范围就会缩小到一个小小的模块内,而不是全部代码都要查。
6.2 使用STM32CubeMX快速初始化
现在做STM32开发基本离不开STM32CubeMX。这个项目的CubeMX配置非常快:
- 选择芯片STM32F103C8T6;
- SYS -> Debug 选择 Serial Wire(这个必须选,否则下载一次后SWD引脚被占用);
- RCC -> HSE 选择 Crystal/Ceramic Resonator;
- 时钟树里把HCLK设置到72MHz(可以输入72回车,软件自动算PLL参数);
- GPIO直接默认,引脚分配在代码里用寄存器操作;
- 生成工程时选择Makefile或者Keil Toolchain都可以。
CubeMX生成的GPIO初始化里,需要注意把不用的引脚设置成模拟输入,这样功耗低也稳定。但这个项目无所谓,默认就行。
6.3 对课设/毕设的更高完成度扩展
如果你做这个题目希望在答辩时拿个高分,除了基础功能,还可以加几个低成本高亮点的扩展:
- 连续运算:按下等号后继续按运算符,直接用上一次结果参与运算;
- 按键提示音:加一个无源蜂鸣器,按键和报错有不同的音调;
- LCD背光控制:用PWM控制LCD背光亮度,按键时闪一下;
- 上电自检:开机时LCD显示“System OK”并在1秒后进入计算界面;
- 掉电记忆:使用STM32内部Flash,保存上次关机时的结果,开机恢复。这里要注意,操作Flash时最好把中断关掉,避免擦写过程中被打断。
这些扩展每一项代码量都不大,但每一件都能跟评审老师聊上两分钟,对“工作量”的体现非常明显。
7. 调试工具与常用技巧
7.1 串口打印调试法
很多STM32初学者忽略了串口调试功能。我强烈建议在开发阶段预留一个串口打印通道:PA9(TX)、PA10(RX)接USB转TTL模块,通过printf重定向到USART1。
具体的重定向代码是:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }这样在代码里写printf("key=%d, state=%d\n", key, state),电脑上就能实时看到按键扫描结果和计算器状态机的变化。硬件调试器断点可以查问题,但计算器这种轮询程序,用串口日志弹性更好,不需要暂停CPU就能看运行轨迹。
7.2 用逻辑分析仪抓时序
如果你手头有一个几十块钱的8通道逻辑分析仪,强烈建议在调试LCD1602驱动时用一次。把逻辑分析仪的通道接到RS、E、DB4~DB7上,按一次初始化按键,就能非常直观地看到:E引脚的高低电平和数据引脚上电平变化是否符合数据手册的时序要求。
我实际抓过几次波形,发现新手写的代码很容易出现E引脚高电平时间太短的问题,代码逻辑看起来没问题但LCD就是没反应,用逻辑分析仪一看,E脉冲宽度只有几百纳秒,低于规格书要求的230ns。加了几个空指令延时后,问题立即消失。
这种调试手段的效果是教科书上完全学不到的,建议有兴趣的朋友尽早入手一个逻辑分析仪,几十块钱能帮你省下几天排查时间。
7.3 硬件复位的技巧
调试时板子偶尔会进入一种“跑飞”状态,现象是什么都不显示、按键无反应。这时按下板子上的复位按键,程序从头开始运行,现象可能消失也可能复现。如果复位后能恢复正常,说明是程序运行中某个状态没有处理好;如果复位后依然卡死,就要检查硬件(电源、晶振)和初始化代码了。
还有一个经验是:给STM32供电的电源,尽量不要直接从ST-Link的3.3V引脚取。ST-Link的3.3V输出能力比较弱,驱动LCD1602背光(背光典型电流约20mA~50mA)加上STM32本身的工作电流会让电压跌落,导致系统不稳定。用外部5V供电再接板载LDO,最稳。
8. 最终效果复盘与几点个人体会
整个项目做完,你手里的东西应该是一个能完成整数四则运算,能显示表达式和结果的单片机计算器。如果不做扩展,裸机代码量在300行左右,硬件成本(最小系统板加LCD加按键)不超过30块钱,但整个过程中涉及的知识点覆盖了STM32开发的大部分核心内容。
我在实际调试这个项目的过程中,最深刻的体会是:计算器这种“看起来简单”的题目,恰恰是把单片机基础串起来最好的练习。LCD驱动让你理解了时序和外设操作的本质;矩阵键盘让你理解了IO方向切换和消抖的原理;运算状态机让你第一次接触“程序状态”这个概念,这不是背代码能学到的。
最后分享一个实用小技巧:如果你用的是LCD1602 4位模式,找一张纸把每个引脚的接法画出来贴在工作台上,不然每次换板子、换引脚都要重新理一遍线,特别容易接错。我在做了三次这个项目以后,已经把引脚分配固定成一套标准,只要新项目用LCD1602,就直接套用同一套分配。标准化接线和标准化驱动代码,能省掉不少重复劳动。
这个项目后续可以扩展的方向不少,比如加上I2C转接板让LCD只占用两根线、用FreeRTOS把按键和显示拆成两个任务、加入DS1302时钟模块做成带时间显示的计算器,甚至通过ESP8266把运算结果传到手机App上。但不管扩展成什么样,底层的LCD驱动、按键扫描、运算状态机这三块内容是不会变的,把它吃透了,其他应用都是在这个基础上的迁移和组合。