STM32计算器实训:LCD1602驱动与矩阵键盘状态机设计

STM32计算器实训:LCD1602驱动与矩阵键盘状态机设计 简介基于STM32的简易计算器项目主要面向嵌入式初学者、电子竞赛备赛者以及单片机课程设计人群。资源选用STM32F103C8T6作为主控外部采用4×4矩阵键盘输入搭配LCD1602完成显示完整走通按键识别、运算逻辑处理与屏幕输出的典型开发链路。压缩包共78个文件软件部分以33个头文件和32个C文件为主涵盖GPIO配置、按键扫描、LCD驱动、延时与系统时钟等模块另有8个汇编启动文件与Keil工程配置文件便于直接打开工程查看或编译烧录整体约197KB轻量易用。资源已有534人学习/下载。通过学习这份源码读者能了解矩阵键盘逐行逐列扫描的消抖与映射方法掌握LCD1602写指令写数据的时序控制以及多位数字与运算符的处理思路在此框架上还能扩展连算、小数、取负号等交互功能是完成单片机课设或练手的小而全参考。1. 为什么用 stm32 做计算器从点灯到人机交互的第一次闭环做计算器是 stm32 入门后第一道稍微完整的综合题点灯只有输出串口只有收发而计算器第一次把按键输入、LCD 显示、运算逻辑全部串成一个闭环。你按下一个键屏幕要立刻出现对应的字符按下等号结果要带着正负号和小数点正确显示出来。这三件事单独拆开都有标准做法但拼在一起时坑就来了扫描太快屏幕闪消抖太慢按键丢显示整数结果时却多出一个.0。这篇就按“硬件接线 → LCD1602 驱动 → 矩阵键盘与状态机 → 调试优化”的顺序把这条路完整走一遍。适合刚点亮过 LED、准备做课程设计或者想给自己的开发板加一个真正人机交互入口的读者。2. LCD1602 与按键矩阵的接线规划4 位模式省引脚的接法2.1 LCD1602 引脚对照表与 4 位模式选型LCD1602 内部控制器是 HD44780它本身支持 8 位并行和 4 位并行两种接线方式。8 位模式需要 DB0-DB7 共 8 根数据线加上 RS、RW、E 三根控制线一共 11 个引脚4 位模式只需要 DB4-DB7 四根数据线加上控制线一共 7 个引脚。对 stm32f103c8t6 这类小封装芯片来说引脚数量虽然够但省下来的 4 个引脚要留给矩阵键盘和串口调试所以实际项目中几乎都选 4 位模式。LCD1602 引脚功能接法建议VSS / VDD电源GND / 3.3VVO对比度调节10k 电位器中点或 1k 电阻分压RS寄存器选择PA0RW读 / 写选择PA1或直接接 GND 只写E使能时钟PA2DB4-DB74 位数据总线PB0-PB3这里有个常见的做法差异RW 引脚如果接 GND模块就永远处于“写”模式可以省掉一个 IO。代价是你读不到忙标志 BF写指令前不能用“读 BF 判断忙否”的标准流程只能靠固定延时替代。对计算器这种低速应用来说固定延时完全够用很多板载 LCD1602 模块甚至把 RW 引脚直接拉低焊死就是这个原因。2.2 矩阵键盘的行列布线4x4 矩阵键盘拆下来是 8 个引脚对应 4 行 4 列。行接 PC0-PC3列接 PC4-PC7这是最省逻辑的分配方式行配置为推挽输出列配置为上拉输入。扫描时一次只把某一行拉低然后读列的电平某列读到 0就说明该行和该列的交点处有按键按下。列为什么用上拉输入而不是浮空输入浮空输入在按键悬空时电平不确定扫描结果里会出现随机“鬼键”。内部上拉能让没按下的列保持高电平读回来的数据才干净。开发板布局上如果 PC4-PC7 被占用可以把行列对调后改代码里的掩码但不要接线的时候随手接——4x4 键盘的引脚顺序并不是每次都和外壳丝印一致接线前用万用表蜂鸣档确认行和列这个步骤能省掉一整晚的排查时间。2.3 从 51 转过来的人最容易踩的引脚坑如果你是 C51 转过来的注意 PA13 和 PA14。这两个引脚是 SWDIO 和 SWCLK用来接 ST-Link 下载调试。很多第一次画板子的人把它们接去 LCD 或按键导致程序烧不进、调试器连不上。stm32 不像 51 那样有专门的 P3.0/P3.1 串口引脚SWD 就是开发板的生命线必须留出来。另外 Keil5 里 C51 和 MDK 可以共存于同一台电脑但芯片支持包要单独装。装完 MDK 后去 Pack Installer 里搜 STM32F1 添加设备包新建工程时器件列表里才能选到 STM32F103C8。如果之前用 CubeMX 生成过工程换型号重新生成时会覆盖外设初始化代码建议直接改 .ioc 重新生成别手动改一半。3. LCD1602 驱动代码初始化时序与显示函数3.1 HD44780 时序4 条线怎么变出 8 位数据LCD1602 的指令和数据共用 DB4-DB7 四根线靠 RS 区分RS 为 0 时写指令RS 为 1 时写数据。E 引脚负责锁存数据线上电平稳定之后把 E 拉高再拉低模块就在下降沿采样。4 位模式下一个 8 位字节被拆成两次发送先发高 4 位再发低 4 位每次都产生一次 E 脉冲。写引脚时最直观的做法是逐位赋值void LCD_WriteNibble(uint8_t nibble) { GPIO_WriteBit(GPIOB, GPIO_Pin_0, (BitAction)(nibble 0x01)); GPIO_WriteBit(GPIOB, GPIO_Pin_1, (BitAction)((nibble 1) 0x01)); GPIO_WriteBit(GPIOB, GPIO_Pin_2, (BitAction)((nibble 2) 0x01)); GPIO_WriteBit(GPIOB, GPIO_Pin_3, (BitAction)((nibble 3) 0x01)); GPIO_SetBits(GPIOA, GPIO_Pin_2); // E 拉高 DelayUs(1); GPIO_ResetBits(GPIOA, GPIO_Pin_2); // E 拉低产生下降沿锁存 DelayUs(1); }这段代码里操作数是四位数据函数只取低 4 位赋给 DB4-DB7。(BitAction)是标准库 GPIO_WriteBit 的参数类型强制转换避免编译器报警告。E 引脚高电平持续 1 微秒是为了让模块确实采样到数据——stm32 主频 72MHz 时 GPIO 翻转速度极快如果 set 和 reset 连在一起脉冲宽度可能只有几十纳秒LCD1602 跟不上这是“显示乱码”类问题最常见的原因之一。3.2 初始化序列没有这一步屏幕永远一片黑网上能下载的 lcd1602 驱动代码很多但大部分是 51 单片机的拿来改时序反而容易踩坑。与其复制粘贴改延时不如自己按 HD44780 手册的初始化序列写一遍遇到显示错乱时你能根据代码推问题在哪。HD44780 上电后要经过一段“确认接口模式”的过程因为模块不知道外部接的是 8 位还是 4 位。初始化时先用 8 位方式发三条 0x30再发一条 0x20 切换到 4 位模式之后才能发送常规功能设置指令。标准流程如下步骤写入内容延时作用10x305ms8 位模式等待电源稳定20x305ms再次确认30x30150us最后一次确认40x2050us切换为 4 位模式50x2850us2 行显示5x7 点阵60x0C50us开显示关光标70x012ms清屏80x0650us光标右移写入后地址加 1注意前三步虽然内容都是 0x30但必须完整走一遍。有资料说可以省略第二步我在实际项目里试过省略后大约 1/5 概率出现首行满格方块。初始化函数写完后用逻辑分析仪抓 E 引脚能明显看到 8 个脉冲前 7 个成对出现前 3 对是 0x30第 4 对进入 4 位模式这是判断驱动是否正常最直接的波形证据。3.3 显示函数的标准写法指令与数据共用一套引脚写指令和写数据只差一个 RS 电平所以两个函数可以共用写半字节的逻辑void LCD_WriteCmd(uint8_t cmd) { GPIO_ResetBits(GPIOA, GPIO_Pin_0); // RS 0指令模式 LCD_WriteNibble(cmd 4); // 高 4 位 LCD_WriteNibble(cmd 0x0F); // 低 4 位 DelayUs(50); } void LCD_WriteData(uint8_t data) { GPIO_SetBits(GPIOA, GPIO_Pin_0); // RS 1数据模式 LCD_WriteNibble(data 4); LCD_WriteNibble(data 0x0F); DelayUs(50); } void LCD_SetCursor(uint8_t row, uint8_t col) { uint8_t addr 0x80 (row * 0x40) col; // 第二行地址偏移 0x40 LCD_WriteCmd(addr); } void LCD_PrintString(char *str) { while (*str) { LCD_WriteData(*str); } }LCD_SetCursor里的地址计算是 HD44780 的固定规则第一行地址从 0x00 开始第二行从 0x40 开始加上 0x80 表示“设置 DDRAM 地址”。row * 0x40是因为每行 40 字节的 DDRAM 空间但 LCD1602 只显示前 16 个超出部分看不到。col 的取值范围是 0-15设置成 16 会写到屏幕外。3.4 先显示固定字符串别急着接键盘写完成驱动后第一件事不是接矩阵键盘而是显示一行固定字符比如STM32 Calc OK。这一步只验证 LCD 链路电源、对比度、时序、初始化都正常后屏幕应该稳定显示这行字。如果这一步都过不了接上键盘只会让你同时面对两个未知变量。字符错乱的排查顺序是先看 VO 对比度然后量 E 引脚波形和脉冲宽度最后检查 4 位模式下高低半字节是否写反。4. stm32 矩阵键盘扫描与四则运算状态机4.1 行列扫描为什么行输出、列输入4x4 矩阵键盘的扫描思路是逐行拉低、读列。行配置成推挽输出列配置成上拉输入。按下某个键时该行和该列在物理上短接行是低电平列就会被拉到低程序读到 0 就确定了按下的位置。uint8_t Matrix_Scan(void) { const uint16_t rowPins[4] {GPIO_Pin_0, GPIO_Pin_1, GPIO_Pin_2, GPIO_Pin_3}; const uint16_t colMask 0x00F0; // PC4-PC7 for (uint8_t row 0; row 4; row) { GPIO_SetBits(GPIOC, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); GPIO_ResetBits(GPIOC, rowPins[row]); // 只拉低当前行 uint16_t cols GPIO_ReadInputData(GPIOC) colMask; if (cols ! colMask) { for (uint8_t col 0; col 4; col) { if ((cols (0x10 col)) 0) { return row * 4 col; } } } } return 0xFF; // 无键按下 }每行扫描前先把整组行引脚全部拉高再单独把当前行拉低是为了避免上一次循环遗留的电平状态影响本次扫描。0x10 col对应 PC4、PC5、PC6、PC7 四个引脚的位掩码。注意 GPIO_ReadInputData 读的是输入数据寄存器配置成输出模式的引脚在这里读不到有效数据所以列必须是输入模式。4.2 消抖与松手检测一个 static 变量就够矩阵键盘按下瞬间机械触点会抖动典型持续时间 5-20ms。不消抖的话按下一次可能被识别成两三次。矩阵扫描本身每轮只有几十微秒所以软件消抖的做法是扫描到按键后延时 20ms 再扫一次确认还是同一个键才采信。另一个问题是重复触发。手指按住按键 1 秒如果代码每次都返回键值计算器会连续输入一长串数字。解决思路是记录上一次按键状态只有“从无键变为有键”的那一次才返回有效值uint8_t Key_Get(void) { static uint8_t lastKey 0xFF; // 上一次有效的按键值 uint8_t now Matrix_Scan(); if (now 0xFF) { lastKey 0xFF; // 已松手复位状态 return 0xFF; } if (lastKey 0xFF) { DelayMs(20); // 按下瞬间消抖 if (Matrix_Scan() now) { lastKey now; return now; // 只返回一次 } } return 0xFF; }这个实现的本质是检测“下降沿”按键从松开状态变为按下状态的瞬间才产生一次事件。延时期间如果扫描结果变了说明是抖动直接丢弃。lastKey在松手后复位这样下一次按下仍然能检测到新的下降沿。4.3 键值映射矩阵位置翻译成计算器按键Matrix_Scan 返回的是 0-15 的矩阵序号要和计算器按键对应起来最简单的方式是一张常量表行 \ 列列 0列 1列 2列 3行 0123行 1456-行 2789*行 3C0/C 键是清零 是计算结果加减乘除对应四则运算。映射表用数组写死即可const char keyMap[16] { 1, 2, 3, , 4, 5, 6, -, 7, 8, 9, *, C, 0, , / };为什么不直接让扫描函数返回字面键值因为行列扫描得到的是位置信息和按键语义是两件事。位置到语义的映射可能调整——比如把 C 和 对调——到时候只改 keyMap不用动扫描逻辑。4.4 四则运算状态机状态少代码好查计算器逻辑拆成四个状态输入第一个操作数、输入运算符、输入第二个操作数、显示结果。状态和中间变量用一组全局量维护typedef enum { ST_OP1, // 输入第一个操作数 ST_OP, // 已输入运算符 ST_OP2, // 输入第二个操作数 ST_DONE // 已计算结果 } CalcState; CalcState state ST_OP1; float displayVal 0; // 当前显示值 float op1 0, op2 0; // 两个操作数 char op 0; // 运算符按键处理函数的核心逻辑void Calc_HandleKey(char key) { if (key 0 key 9) { if (state ST_OP || state ST_OP2) { displayVal 0; // 新操作数从 0 开始 } displayVal displayVal * 10 (key - 0); state (state ST_OP) ? ST_OP2 : ST_OP1; } else if (key || key - || key * || key /) { op1 displayVal; op key; state ST_OP; } else if (key ) { if (state ST_OP) return; // 运算符后直接按 无效 op2 displayVal; switch (op) { case : displayVal op1 op2; break; case -: displayVal op1 - op2; break; case *: displayVal op1 * op2; break; case /: if (op2 0) { displayVal 0; /* 显示 E */ } else displayVal op1 / op2; break; } state ST_DONE; } else if (key C) { op1 op2 0; op 0; displayVal 0; state ST_OP1; } }这个状态机按“顺序运算”处理1 2 * 3的结果是 9 而不是 7。简易计算器课程设计通常不强制要求运算符优先级如果要支持得把中缀表达式转后缀再求值或者用双栈法复杂度会明显上升。另外这个版本不支持小数点和负数输入如果你要扩展按键映射表里加一个小数点键数字处理分支加一个小数位计数变量即可。一个容易忽略的分支用户按完运算符直接按等号比如3 结果应该是什么由你定义但代码里必须有定义。上面的写法是直接忽略还停留在 ST_OP 状态屏幕继续显示 3这是最不容易让使用者困惑的处理方式。5. 从能跑到好用stm32 计算器的防抖、格式化与长按5.1 三步定位先屏后键再运算调试这套系统最有效的顺序是先把 LCD1602 跑通显示固定字符串再把按键值通过串口打印确认扫描正确最后才把运算逻辑接进去。这样每一层只有一个新的变量。串口用 PA9/PA10重定向 printf 到 USART1波特率 115200扫描到按键就发一帧key7之类的文本。ST-Link Utility 里可以直接看寄存器状态但排查按键逻辑时 printf 比调试器直观得多。故障现象优先检查项屏幕不亮VO 对比度电压、背光供电首行满格方块初始化时序、E 脉冲宽度、总线模式切换是否完成按键无反应行列引脚配置、上拉是否启用、串口是否打印扫描值按下一次输入多位数消抖延时是否生效、lastKey 是否在松手后复位等号后结果不对状态机在 ST_OP 按 时的分支、除零处理5.2 整数显示成 X.0一个浮点格式化的小问题用 sprintf 格式化浮点数时最常见的结果是4 / 2 2.0屏幕出现多余的小数位。处理办法是先判断结果是否接近整数float intPart; float fracPart modff(displayVal, intPart); if (fracPart 0.000001f fracPart -0.000001f) { sprintf(buf, %d, (int)intPart); } else { sprintf(buf, %.2f, displayVal); }modff 把浮点数拆成整数部分和小数部分小数部分接近 0 时按整数显示。阈值用 1e-6 是因为浮点运算本身有精度误差直接比较 0可能不成立。另外转换前检查 displayVal 的绝对值不要超出 int 范围stm32 的计算器不会算到 21 亿但如果你扩展了连乘功能数值溢出后会变成负数这种 bug 很难一眼看出来。5.3 C 键的长按扩展同一个键两种功能按键状态机里如果把“按下时刻”也记录下来C 键就能拥有两种操作短按退格删掉最后一位长按 800ms 清屏重来。实现思路是把 lastKey 从单一状态扩展成时间戳主循环里查询 SysTick 的 tick 差值超过阈值就走清屏分支。这个扩展不动 LCD 驱动和运算模块只改按键事件分发那几行代码是性价比很高的人机交互升级。计算器做到这一步已经不光是“能算”的板子LCD1602 的时序你亲手调过矩阵键盘的抖动你亲手压过状态机的边界条件你亲手补过下一步做温湿度计、电子秤或者智能台灯遇到的全是同一类问题——外部设备不准时代码怎么把不准变成可控。本文还有配套的精品资源点击获取