基于STM32与LCD1602的矩阵键盘计算器设计与实现

发布时间:2026/9/15 1:49:03
基于STM32与LCD1602的矩阵键盘计算器设计与实现 1. 项目整体设计与思路拆解1.1 为什么选STM32做计算器而不是51单片机先聊点实在的。这个题目叫“基于STM32单片机计算器”用LCD1602做显示。很多刚入门的朋友第一反应是计算器这种项目不是51单片机就能做吗为什么非要上STM32没错用51做计算器确实是经典课设功能也完全够用。但如果把眼光放远一点STM32做这个项目真正的价值在于你可以用同样的外设操作流程体验到从8位单片机到32位单片机的思维切换。GPIO怎么配置、时序怎么控制、中断怎么管理、定时器怎么用这些底层逻辑在51和STM32上是完全不同的。你在51上写P0 0x38直接给端口赋值就行但在STM32上你得先开时钟、配置模式、设置速度再操作ODR寄存器——这个过程本身就是ARM Cortex-M架构入门最值钱的部分。另外STM32F103C8T6这块芯片现在的价格已经非常友好淘宝上几块钱一片算上最小系统板也就十几块。你做完这个计算器后续想扩展成智能台灯、鱼缸控制器、四开关Buck-Boost数字电源STM32的资源和性能都还足够不像51那样很快就到天花板。换句话说计算器只是你熟悉STM32开发流程的载体这个载体选得越简单越好因为核心目标是把“STM32怎么玩”这件事打通。1.2 系统组成选型LCD1602 4x4矩阵键盘的经典组合再来看系统组成。显示部分用LCD1602输入部分用4x4矩阵键盘这个组合基本是单片机人机交互项目的“出厂配置”。LCD160216列2行字符液晶好处是驱动逻辑简单字符显示直接不需要字库、不需要图形处理非常适合新手理解“并行数据传输时序”这一概念。虽然现在很多项目会加I2C转接板省IO口但我个人建议你第一遍做的时候坚持用并行模式——GPIO直接控制RS、RW、EN和数据引脚你能亲眼看到“拉高电平—送数据—拉低使能”这个过程是怎么实现的。这一套时序搞懂了以后驱动OLED、TFT屏、甚至模拟一些自定义协议都会轻松很多。键盘用4x4矩阵而不是独立按键原因很直接计算器至少需要0-9十个数字键加上加减乘除、等号、清除怎么着也得15个以上的按键。如果全用独立按键IO口根本不够用。矩阵键盘8根线搞定16个键正好匹配STM32F103C8T6的IO资源这个方案是经过大量项目验证的、性价比最高的选择。整个系统的数据流是这样的矩阵键盘扫描 - 识别按键值 - 送入计算逻辑模块进行表达式解析和运算 - 将结果显示到LCD1602。这个链路里键盘扫描是输入端LCD是输出端运算逻辑是核心处理。三块内容分别对应了嵌入式的三大基本功外设驱动、状态管理、算法实现。做完这个项目你基本就把单片机开发的完整工作流走了一遍。2. 硬件连接与电路设计细节2.1 引脚分配方案硬件连接是整个项目的地基。引脚分配不合理后面写代码会非常痛苦。我先给出我实际验证过的分配方案再解释为什么这么分。LCD1602引脚分配LCD1602引脚功能STM32引脚VSS电源地GNDVDD电源正5V或3.3VVO对比度调节接10K电位器中间脚RS寄存器选择PA0RW读写选择PA1E使能信号PA2D0-D3数据位4位模式不用悬空D4-D7数据位PA3-PA6LED背光正极串220Ω电阻接3.3VLED-背光负极GND矩阵键盘引脚分配矩阵键盘引脚功能STM32引脚R1-R4行线PB0-PB3C1-C4列线PB4-PB7这么分配有几个考虑LCD用的PA0-PA6连在一起代码配置GPIO时可以用一组循环搞定干净利落键盘用的PB0-PB7也是整组端口扫描时直接操作IDR寄存器也能方便一些。不过要注意PB4和PB5在STM32F103C8T6上默认是JTAG引脚NJTRST和JTDO如果你把这俩引脚当普通GPIO用必须在代码里先关闭JTAG复用功能只保留SWD。这个坑我后面在常见问题部分再详细说。2.2 硬件焊接与接线注意事项接线这件事看着简单但实际翻车率极高。我把自己踩过的坑总结成几条电源一定要干净。STM32F103C8T6的工作电压是2.0-3.6V最小系统板一般带稳压芯片你直接USB供电就行。但LCD1602的背光要注意有些模块的背光电流比较大如果是5V版本背光串电阻接3.3V可能亮度不够接5V又可能超压。我用的办法是LCD1602的VDD接5V模块上有稳压或者本身兼容5V/3.3V数据引脚直接接STM32的PA口因为STM32的IO口是容忍5V输入的FT引脚这样通信电平没问题。如果你的板子不是全引脚FT容忍5V稳妥起见还是统一用3.3V供电背光暗一点不影响显示。对比度电位器必须接。LCD1602的VO脚如果不接电位器调节对比度经常出现两种情况要么屏幕上全是黑块要么完全没字。我习惯用10K电位器把中间抽头接VO两边分别接VDD和GND。上电之后一边调一边看屏幕调出清晰的字符后再固定。这一步很多人忽略结果程序明明没问题就是显示不出来最后折腾半天发现是对比度没调好。按键模块建议用现成的4x4矩阵键盘模块几块钱一个带固定孔省去自己飞线焊按键的麻烦。如果你打算自己用轻触开关搭务必注意共地问题——按键两端一端接GPIO另一端必须可靠接地行列之间的压降要稳定。排线尽量短。你用的是杜邦线连接线一长干扰就大尤其是LCD的数据线四根数据线长度差异过大时信号时序会出现skew偏移导致显示乱码。我试过最长的一根线和最短的一根差了15厘米结果1602随机花屏后来把所有线等长修剪到差不多长度问题就消失了。这种玄学问题在你调错代码之前先检查一下硬件。3. 软件核心模块实现详解3.1 LCD1602驱动从时序到代码LCD1602驱动是整个软件部分最基础也最典型的一块。实际上你不需要完全理解内部控制器HD44780的所有寄存器细节只需要掌握两个核心操作写命令和写数据。所有显示功能都是这两个操作组合出来的。先看写命令的时序。RS拉低表示接下来写入的是命令RW拉低表示写入模式数据放到D4-D7上然后E引脚产生一个下降沿从高到低的变化数据就被锁存进去了。写数据的时序和写命令几乎一样唯一的区别是RS要拉高表示写入的是显示数据。我推荐用4位模式而不是8位模式。4位模式只占4根数据线省IO口虽然要多写一轮数据先高4位后低4位但性能完全够用代码也不复杂。初始化代码如下void LCD_Init(void) { delay_ms(50); // 上电等待LCD内部自检 LCD_WriteCmd(0x03); // 8位模式初始化指令需要连发三次 delay_ms(5); LCD_WriteCmd(0x03); delay_us(100); LCD_WriteCmd(0x03); delay_us(100); LCD_WriteCmd(0x02); // 切换到4位模式 delay_us(100); LCD_WriteCmd(0x28); // 4位模式、2行、5x7点阵 LCD_WriteCmd(0x0C); // 显示开、光标关、不闪烁 LCD_WriteCmd(0x01); // 清屏 delay_ms(2); LCD_WriteCmd(0x06); // 写入后地址自动加1 }这套初始化序列是HD44780芯片手册上的标准流程很多网上的代码都在用但新手经常漏掉前面那三次0x03。为什么必须发这三次因为上电后芯片可能处于8位模式也可能处于4位模式总线的初始状态不确定。连续发三次0x03再发一次0x02可以确保无论芯片原来在什么模式都能被引导到4位模式。这是一个保险机制不是多余的。写命令和写数据的具体函数如下void LCD_WriteCmd(uint8_t cmd) { LCD_RS_LOW(); LCD_RW_LOW(); // 先发高4位 LCD_D4 (cmd 4) 0x01; LCD_D5 (cmd 5) 0x01; LCD_D6 (cmd 6) 0x01; LCD_D7 (cmd 7) 0x01; LCD_EN_PULSE(); // E引脚产生高-低脉冲 // 再发低4位 LCD_D4 cmd 0x01; LCD_D5 (cmd 1) 0x01; LCD_D6 (cmd 2) 0x01; LCD_D7 (cmd 3) 0x01; LCD_EN_PULSE(); }LCD_EN_PULSE()是关键它必须是一个宽度足够的脉冲。HD44780要求E引脚的高电平持续时间至少450纳秒STM32F103主频72MHz下你随便几条语句的时间就超过了这个值所以用普通的GPIO翻转就行void LCD_EN_PULSE(void) { LCD_E_HIGH(); delay_us(1); // 拉高保持1微秒 LCD_E_LOW(); delay_us(1); // 拉低后等待 }实际测试下来延时用delay_us(1)已经非常稳定。有些教程喜欢用delay_ms(1)也能用但屏幕刷新会明显变慢做计算器这种需要频繁刷新显示的场景会感觉卡顿。3.2 4x4矩阵键盘扫描消抖、状态机与键值识别矩阵键盘的扫描原理本质上是“分时轮询”。8根线分成4行4列先让所有行线输出高电平然后逐行拉低同时读取列线的电平变化。哪一行被拉低后某一列变成了低电平就可以确定按键位置。具体到实现我推荐“逐行扫描法”而不是“行列反转法”。行列反转法虽然代码看起来简单但要求IO口在输入输出模式之间切换处理不好容易出问题。逐行扫描法思路更直观uint8_t Key_Scan(void) { uint8_t key 0xFF; // 默认无按键 // 第1行拉低 GPIOB-ODR ~(1 0); // PB0输出低 GPIOB-ODR | (1 1) | (1 2) | (1 3); // 其余行拉高 if ((GPIOB-IDR (1 4)) 0) key 0; // C1被按下 if ((GPIOB-IDR (1 5)) 0) key 1; if ((GPIOB-IDR (1 6)) 0) key 2; if ((GPIOB-IDR (1 7)) 0) key 3; // 第2行拉低... // 第3行拉低... // 第4行拉低... return key; }这里有个细节不按的时候列线到底是高电平还是低电平这取决于你有没有接上拉电阻。如果列线接了上拉电阻到3.3V那么不按时列线默认为高某一行拉低后按键按下时对应列的GPIO被拉低读到的就是低电平。如果你的矩阵键盘模块上没有上拉电阻那就在STM32初始化时把列线配置为上拉输入模式GPIO_Mode_IPU。这一步千万别漏不然扫描永远读不到按下状态。有了原始的键值还有两个必须处理的问题机械抖动和重复触发。机械抖动是物理按键的固有属性按下和松开的瞬间电平会有几毫秒到十几毫秒的不稳定期。直接读引脚判断的话一次按下可能被识别成三四次。常用解决方案有硬件消抖RC滤波和软件消抖延时重读。我倾向于软件消抖实现简单且可靠uint8_t Key_GetValue(void) { uint8_t val Key_Scan(); if (val ! 0xFF) { delay_ms(10); // 第一次检测到按下延时消抖 if (Key_Scan() val) { // 再次确认确实是按下 while (Key_Scan() val); // 等待松开避免长按重复触发 return val; } } return 0xFF; }这里用了一个“按下等待松开的死循环”。好处是每次按键只触发一次动作符合计算器的使用习惯——你不会希望按一下数字键屏幕上蹦出一串同样的数字。但也要注意这个等待释放的过程会阻塞主循环对于计算器这种低速交互设备完全没问题如果你的项目后续要加定时中断或其他任务就要改成状态机的方式。3.3 计算器核心逻辑表达式解析与逆波兰算法按键识别出来了显示驱动也写好了接下来是整个项目最烧脑的部分怎么把一串按键输入变成计算结果。最简单的做法是“硬编码”——记录第一个操作数、运算符、第二个操作数按等号时直接算。这种写法代码量很小但只能处理ab这种单次运算遇到23*4就会算错因为没考虑运算符优先级。作为课设交上去老师一眼就能看出没用心做。我推荐的做法是引入逆波兰表达式后缀表达式。思路是计算器维护两个栈——操作数栈和运算符栈。读入数字时压入操作数栈读入运算符时如果运算符栈顶的优先级大于等于当前运算符就把栈顶运算符弹出并计算结果压回操作数栈直到栈顶优先级低于当前运算符再把当前运算符入栈。表达式结束时把运算符栈里剩余的运算符依次弹出计算最终操作数栈里剩下的就是结果。以23*4为例跑一遍读入2 - 操作数栈[2] 读入 - 运算符栈[] 读入3 - 操作数栈[2, 3] 读入* - 的优先级低于*所以直接入栈 - 运算符栈[,] 读入4 - 操作数栈[2, 3, 4] 读入表达式结束 - 弹出计算3412操作数栈[2, 12] 弹出计算21214操作数栈[14]结果是14符合数学优先级规则。这一段代码写出来大概是这样的int32_t Calc_Priority(char op) { switch (op) { case : case -: return 1; case *: case /: return 2; default: return 0; } } int32_t Calc_Execute(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) { LCD_ShowString(0, 1, Div by 0!); return 0; } return a / b; default: return 0; } }主循环里维护一个表达式数组把按键值不断拼进去按等号时用上述算法一次性计算。注意数据类型的选用STM32F103是32位MCUint32_t是4字节单次加减乘除的中间结果不会溢出但如果是连续运算比如99999999*99999999结果会超过int32_t的最大值2,147,483,647此时你必须显示“Overflow!”并复位。这个问题我建议在代码里提前加上判断算是个比较好的完整性加分项。4. 常见问题与调试经验实录4.1 LCD1602不显示、乱码、只有方块这几个现象几乎是每个做LCD1602的人都会遇到的。根据我实操的经验排查顺序应该是对比度 - 初始化时序 - 引脚配置 - 电平兼容。现象“屏幕上全是方块”大概率是对比度没调好。VO引脚的电压不合适会让显示内容全部变成深色方块。你先把电位器从一端拧到另一端看有没有某个位置能让方块消失出现字符。如果怎么拧都是方块那才考虑其他原因。现象“白屏完全无显示”优先检查初始化时序。很多人把初始化代码里的延时随意删减比如把delay_ms(50)改成delay_ms(5)或者把三次0x03中间的延迟去掉就会导致LCD没完成上电自检就收到错误指令整个模块处于不可用状态。建议严格按照HD44780手册的时序来上电后至少等40毫秒再发第一条指令。现象“显示乱码、字符错位”大概率是数据线接错或者4/8位模式不匹配。比如你代码里初始化用的4位模式但实际只接了D0-D7中的某些线那肯定乱。还有可能就是PA口的某个引脚刚好被其他外设占用了数据线被拉高拉低干扰。这时候用示波器或者万用表量一下每个数据引脚的电压基本能定位。我实际遇到过最坑的一次是LCD1602的数据线接到了PB4和PB5上结果死活不显示。后来查资料才想起来PB4是JTAG的NJTRST引脚上电默认是JTAG功能不复用的话GPIO输出根本不生效。解决方法是加一行代码禁用JTAGGPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);加了这行代码之后PB4和PB5才变成普通IO口。这个坑网上很多人提过但新手往往不知道值得单独列出来提醒。4.2 按键扫描失灵、误触发、无响应按键问题比较多样我按频率从高到低排列一下扫描失灵偶尔有按键没反应多见于行扫描时某一行拉低了但还没读取下一行又拉到低电平导致电平冲突。解决办法是每次只拉低一行其余行全部拉高读取完这一行的所有列状态后再切换下一行。代码上可以用一个for循环控制行索引每次循环内只操作当前行对应的引脚。误触发没按却自己触发优先检查GPIO是否配置为带上拉输入。如果列线浮空输入外界一点点静电干扰就可能导致电平跳变。还有一种情况是按键模块的排线太长而且没有接任何滤波电容。我在项目里加一个0.1uF的电容在按键电源附近误触发就再也没出现过。无响应按键完全没反应先确认引脚编号对不对。STM32F103C8T6的PB0-PB7在代码里你直接用GPIOB端口和对应的GPIO_Pin_0到GPIO_Pin_7定义很多新手把GPIOB和GPIOA写混了或者把GPIO_Pin_0写成GPIO_Pin_1结果当然没反应。这种低级错误用一个简单办法检查写一个测试程序把每个引脚都接到LED上逐个点亮看是否对应。4.3 计算逻辑错误和溢出处理如果显示和按键都正常但计算结果不对基本可以确定是计算逻辑的问题。最常见的几个场景加减乘除单次运算不对检查操作数出栈顺序。栈是后进先出的执行减法时先出栈的是右操作数后出栈的是左操作数。如果你写的顺序反了8-3会算成3-8结果变成-5。这个坑我当年踩了半小时最后用串口打印中间变量才发现。除法同理。运算优先级不对比如12*3算出9而不是7。这是没有做优先级比较就直接从左往右算了。处理方式就是前面说的遇到高优先级运算符时先看运算符栈顶是否有优先级不低于它的运算符有就先弹出计算。调试时可以在每个运算符入栈前把当前栈顶内容用串口打印出来肉眼观察栈的变化过程。算大数显示乱码int32_t最大支持约21亿如果超出范围LCD上显示的数字会变成一个异常负值。建议在每次计算完结果后增加范围判断超出就显示Overflow并清空所有栈数据让用户重新输入。除零操作也需要单独提示不然a/0在MCU上不会主动报错只是得到一个不确定值。5. 完整工程实现与调试技巧5.1 开发环境配置Keil MDK STM32CubeMX软件开发环境我用的是STM32CubeMX生成初始化代码 Keil MDK编译调试的组合。这套组合也是目前工业界和教学领域最主流的配置你以后做其他STM32项目也能直接复用这套工作流。CubeMX里需要配置的部分有RCC时钟HSE外部晶振主频拉到72MHz。如果你的核心板没有外部晶振可以直接用HSI内部振荡器配置为64MHz也没问题。GPIOPA0-PA6全部配置为输出模式初始电平低PB0-PB3配置为输出模式初始电平高PB4-PB7配置为输入模式开启上拉电阻。SWD如果你要用ST-Link调试确保SWDIO和SWCLK这两个引脚PA13、PA14没有被复用为其他功能。生成代码后你只需要在main.c的while(1)循环里添加键盘扫描和显示刷新逻辑。初始化代码CubeMX已经帮你做好了重点精力可以放在业务逻辑上。Keil这边有两个重要的配置项。一个是芯片型号选择STM32F103C8T6对应的是STM32F103C8。另一个是Flash烧录算法的选择如果选错型号算法下载时会报No Algorithm found错误。下载器我用的ST-Link V2二十来块钱稳定够用比J-Link便宜很多功能完全覆盖这个项目。5.2 使用串口打印辅助调试调试计算器逻辑时最有效的工具是串口打印。LCD1602毕竟只能显示两行想同时看到多个变量的中间值完全不够用。我会在代码里加上UART1输出把运算过程中的栈操作逐个打印出来。比如你按下64*3再按等号串口应该输出[DEBUG] push num: 6 [DEBUG] push op: [DEBUG] push num: 4 [DEBUG] push op: * (top of op stack: , priority 1 2, direct push) [DEBUG] push num: 3 [DEBUG] pop op: *, calc 4 * 3 12 [DEBUG] pop op: , calc 6 12 18 [DEBUG] result: 18这样你能非常直观地看到每一步操作是否符合预期。定位逻辑错误就变成了一件很简单的事。串口打印代码本身也很简单STM32的USART1通过PA9TX和PA10RX连到USB转TTL模块波特率115200用任意串口助手软件接收就行。这里有一个性能上的小建议调试完以后如果追求LCD刷新速度可以把串口打印关掉。因为printf重定向到串口后字符串格式化非常耗时72MHz主频下一个printf可能要几十微秒到几百微秒对于计算器这种按键驱动的低频交互来说没影响但如果你的后续扩展业务里有定时任务该优化的还是要优化。5.3 防抖策略的选择阻塞等待 vs 状态机扫描前面在键盘部分我用了while等待松开的写法这在计算器场景里简单直接。但说实话这种写法在工程上是有局限的。如果你的程序里同时要处理LCD刷新、定时器中断、串口通信一个while死等松开会霸占CPU其他任务都被卡住了。工程上更优雅的方案是“状态机扫描”。定义按键的几种状态松开态、按下确认态、按下等待释放态。主循环每次都调用扫描函数根据当前状态和这次的扫描结果转移到下一个状态。这样即使按键一直没有松开主循环也能继续做别的事只是在状态机还没回到松开态之前不会重复上报键值。计算器这种低频交互设备用哪种方案都行。但如果你想把同一个代码框架迁移到更复杂的项目里我建议从第一次做就开始用状态机。别担心状态机难学它本质就是一个switch...case加上几个状态变量。用熟了以后你会发现所有按键交互都能用这一套逻辑统一处理比散落的while死循环规范得多。我做这个项目时还额外加了一个“长按清除”功能在状态机里增加一个计时计数按键持续按下超过1秒视为长按触发清零操作。这种灵活交互用状态机实现非常顺手用while阻塞轮询反而要额外开定时器麻烦不少。5.4 代码组织模块化拆分是长期可维护性的关键最后一个建议也是我特别想强调的一点不要把代码全堆在main.c里。很多人做课设图省事键盘扫描、LCD驱动、运算逻辑全部写在主循环里几百行代码挤在一起。当时看着方便但一旦出问题调试起来简直噩梦。推荐的工程结构是这样Project/ ├── Core/ │ ├── Inc/ │ └── Src/ │ ├── main.c │ ├── lcd1602.c // LCD驱动函数 │ ├── keypad.c // 矩阵键盘扫描 │ ├── calculator.c // 表达式解析与运算 │ └── uart_debug.c // 串口调试输出 ├── Drivers/ │ └── STM32F1xx_HAL_Driver/每个模块只对外暴露必要接口。比如lcd1602.c对外只提供LCD_Init()、LCD_ShowString()、LCD_Clear()几个函数keypad.c对外只提供Key_GetValue()calculator.c对外只提供Calc_ProcessKey()和Calc_GetResult()。整个系统的主循环会变得非常简洁while (1) { uint8_t key Key_GetValue(); if (key ! 0xFF) { Calc_ProcessKey(key); LCD_RefreshDisplay(); } // 其他任务... }这种模块化设计的好处是每个模块都能独立测试LCD部分不对就单独调试LCD键盘部分不对就单独调试键盘不用在整个项目里来回翻代码。而且以后你想移植到其他平台只需要替换对应模块业务逻辑几乎不用动。这是我从实际项目中体会最深的一点强烈建议你从这个小项目开始养成模块化编程的习惯。我在实际调试这个计算器项目时还有个小经验准备一个500行的测试用例清单把各种边界情况都跑一遍。比如0/0、99999*99999、连按多个运算符、计算完再继续输入数字等等。正常使用流程不会碰到这些情况但程序一旦在这些边界场景下出问题说明你的状态管理和运算逻辑还不够健壮。把这些问题都处理掉以后这个“简单”的计算器项目才算真正打磨完成了。