ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32单片机计算器项目实战:LCD1602显示与矩阵键盘驱动开发

STM32单片机计算器项目实战:LCD1602显示与矩阵键盘驱动开发 最近帮一个学弟看他的课程设计题目就是那种网上特别常见的编号题目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有64KBRAM有20KB跑一个计算器绰绰有余。有人可能会问为什么不用51单片机51也能做计算器但STM32在开发体验上有一个明显的区别你能够使用真正的IDEKeil 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说明VSSGND电源地VDD5V注意很多LCD1602模块需要5V供电V010K电位器中点对比度调节接GND时对比度最高RSPA1寄存器选择0写指令1写数据RWGND只写模式直接接地EPA2使能信号下降沿锁存数据D4PA3数据线4D5PA4数据线5D6PA5数据线6D7PA6数据线7A/K5V/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_OutputPB0到PB3设置为GPIO_OutputPB4到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第二行是0xC0void 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 为什么要用双栈而不是“一个数一个符号循环”如果只做一位数加减乘除确实不需要什么算法用状态机记录两个操作数和一个运算符就行。但既然按键支持多位数字输入用户就可能输入“1234*5-6”这样的表达式如果不做优先级处理结果就会变成(1234)×5-6224而正确结果应该是12170-6176。这个差别在演示时非常尴尬。所以核心算法我建议用双栈求值一个栈存数字一个栈存运算符。遍历表达式时遇到数字就解析并压入数字栈遇到运算符就先比较栈顶运算符和当前运算符的优先级如果栈顶优先级更高或相等就先算一步再把当前运算符压栈。遇到等号时把栈里剩余的运算符全部弹出计算。这个方法背后的逻辑很像我们在纸上算题的习惯看到加减法不着急算先把数字放一边看到乘除法才立刻算。对应到代码里就是用一个栈结构把还没计算的数字和运算符暂时存起来等优先级关系明确后再统一处理。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]; }这段代码里最需要注意的地方是运算符优先级比较那一步。比如“123”扫描到号时栈顶是因为的优先级是1的优先级是2不满足“栈顶优先级 当前优先级”所以不计算直接把压栈。扫描到3之后表达式结束这时才把栈里的和依次弹出先算23再算16得到7。这里用到的原则就是只在栈顶运算符优先级不低于当前运算符时才先算栈顶。4.3 从按键到表达式输入的构建与错误处理按键输入是个逐步往字符缓冲区填内容的过程。我定义一个全局字符数组g_expr[32]和一个索引g_idx。每次按数字键就往g_expr[g_idx]里写入该数字并右移按运算符也类似。等号键触发表达式求值清屏键清空整个缓冲区。错误处理是这个项目里容易被忽略但很重要的一块。除数为0时会出现浮点数无穷大LCD上会显示“inf”非常难看。我在运算函数里加了一个判断如果除法运算的除数为0直接返回一个特殊标记在主循环里显示“ERROR”。另外用户可能按下连续两个运算符比如“12”这种表达式在双栈算法里会出错所以输入阶段就应该阻止如果当前输入缓冲区为空或者最后一个字符是运算符则不允许再输入运算符。输入构建还有一个细节LCD1602只有16列表达式长了会溢出。我的处理是只保留最近显示的部分也就是只维护一个长度为15的环形缓冲区或者干脆在输入阶段限制最长输入长度为15个字符。实际使用中15个字符足够演示“123456*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是否接到了正确的GPIOLCD不亮但电源正常背光限流电阻断开或接反万用表量背光两端电压确保背光正极接5V负极串联100欧电阻到GND按键按下没反应行引脚没配置上拉检查GPIO配置把行引脚配置为Input pull-up按一次键显示多个字符消抖没做或等待释放没做观察现象是否规律加消抖状态机和等待释放逻辑按等号后结果不对表达式解析错误检查表达式缓冲区和优先级逻辑逐步打印栈内元素定位逻辑问题除零后屏幕显示inf缺少除零保护在apply函数里判断b0设置错误标志并显示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重启后还能显示。这些方向随便加一个课设的评分档次都会不一样。
返回列表