ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

STM32计算器实战:LCD1602驱动与矩阵键盘扫描全解析

STM32计算器实战:LCD1602驱动与矩阵键盘扫描全解析 说实话基于STM32和LCD1602的计算器应该是国内单片机课程设计里出现频率最高的题目之一。它看着特别简单——一个屏幕显示、一个矩阵键盘输入、内部再做点四则运算——但真到调试阶段就会发现问题一堆显示屏亮了没字、按键像扫地一样跳数、算个 234 结果乱七八糟。这篇文章就按我自己做这个项目时的顺序把硬件接线、HD44780液晶驱动、矩阵键盘扫描、运算逻辑和调试排错整个链路完整走一遍顺便把我翻过车的细节都标出来。刚接触STM32的新手可以照着一步步做已经做着卡壳的也能对照排查。1. 硬件准备模块选型与引脚分配里的三个坑1.1 LCD1602买哪种裸屏加转接板要看场景LCD1602市面上常见的形态有两种一种是直接引出16个排针的裸屏内部控制器几乎都是HD44780或其兼容芯片另一种是在背面焊了PCF8574转接板通过I2C接口连接的模块。裸屏的好处是能看到真实的总线时序所有操作都是自己控制RS、RW、E和数据线对理解液晶工作原理非常有帮助。做基于STM32的计算器课程设计我建议优先用裸屏因为答辩时老师大概率会问LCD怎么初始化的4线模式和8线模式有什么区别这些真用过才有东西讲。I2C转接板适合IO口不够用的场合但它把并行时序封装在背面芯片里了一旦显示异常排查链路会变长而且PCF8574的模拟I2C代码对新手来说反而比直接操作GPIO更绕。另外要注意一点裸屏模块上通常会有一个电位器那是调对比度用的不是装饰。很多屏不显示的问题最后都是电位器拧到头的锅。这一点后面专门展开。1.2 引脚分配先绕开BOOT0、晶振和I2C调试脚STM32F103C8T6最小系统板是这类项目最常用的平台。它引脚多但并不是每个引脚都能随便接。分配引脚时我踩过一个很典型的坑把PB3、PB4当作普通IO去接LCD数据线结果屏幕上的数据永远不对。原因是这两个引脚默认复用为JTAG的JTDO和NJTRST上电时处于调试功能状态要先把AFIO重映射关掉才能当普通GPIO用。虽然技术上可以配置但对新手来说没必要和它较劲直接避开最省事。NPX这张引脚表是按我实测稳定的一套方案列的你可以直接抄LCD1602引脚连接到STM32说明VSSGND电源地VDD3.3V逻辑电源VL电位器中点对比度调节务必接RSPB0寄存器选择RWPB1读写选择接地为只写EPB2使能信号D4PB10数据线4位模式D5PB11数据线5位模式D6PB12数据线6位模式D7PB13数据线7位模式A经100Ω电阻接3.3V背光正极KGND背光负极矩阵键盘我接在PA0到PA7上前4个引脚作为行线后4个作为列线。如果板子上还有其它外设务必先查原理图确认有没有引脚冲突比如有些最小系统板的PA9、PA10默认接了板载串口调试芯片你用它们做普通IO虽然能用但调试信息会受影响。1.3 对比度电位器和背光供电显示问题的头号元凶LCD1602能不能清晰显示字符很大程度取决于VL引脚的电压。这个引脚的内部是LCD偏压发生器电压不合适里面的字符要么看不见要么整个屏幕都是深色方块。理论上讲5V供电时VL电压通常在0.4V到1.5V之间能显示清楚3.3V供电时范围会略有变化。实操中的做法是把模块自带的10K电位器调到中间位置上电后一边观察屏幕一边慢慢旋转直到字符清晰、背景干净。背光供电也是一个容易被忽略的点。STM32的3.3V能够点亮LCD1602的背光但屏幕亮度会比5V供电时略暗这是正常的。如果在3.3V电压下背光电流不够导致屏幕看起来发黄可以在背光正极串一个100Ω左右的限流电阻减小电阻可以提升亮度但不要直接去掉限流长期大电流对LED灯珠和稳压芯片都不好。如果你的最小系统板上有5V输出引脚LCD的VDD也可以接5V逻辑电平仍然是兼容的但STM32引脚输出3.3V高电平对于5V供电的LCD来说逻辑高电平最低阈值通常是2.0V所以3.3V高电平完全满足要求。我的习惯是全部用3.3V这样接线最简单也不会出现电平转换问题。2. LCD1602驱动HD44780时序、初始化与写数流程2.1 8线模式还是4线模式LCD1602的HD44780控制器原生支持8位和4位两种总线模式。8位模式一次传输一个字节需要DB0到DB7共8根数据线加上RS、RW、E控制线总共11个IO。4位模式只需要DB4到DB7四根数据线加上控制线共7个IO但每次传输一个字节要分两个半字节发送先送高四位再送低四位。STM32F103C8T6的IO数量足够但为了布线方便和控制线集中我一直用4位模式。4位模式在逻辑上多了一步分两次发送的操作不过代码封装好之后对上层调用者是透明的并不会增加多少复杂度。这里的关键点在于初始化切换到4位模式时有一套固定的握手时序不是直接发0x28命令就行。2.2 操作时序究竟在做什么先看最简单的写命令时序。LCD1602内部是一个带地址指针的显存你给它什么命令它就执行什么操作。写命令时RS必须为低电平RW接地只写模式数据放到数据线上然后E引脚产生一个高脉冲数据在E下降沿被锁存进控制器。用STM32操作时流程是这样的设置RS为低电平表示即将写命令把数据放到D4到D7上E引脚拉高保持至少几百纳秒E引脚拉低让LCD在下降沿锁存数据。为什么E要做一个脉冲而不是直接给高电平因为HD44780内部是边沿触发的锁存逻辑。数据线在E低电平期间准备好E拉高后经过短暂建立时间下降沿把数据写入内部寄存器。如果不做一个完整的上升再下降过程数据根本进不去。2.3 STM32上的时序代码在72MHz主频的STM32F103上一条GPIO写操作本身就有几十纳秒的时间开销所以用软件翻转GPIO去模拟时序完全可行。关键是要保证E高电平持续时间足够。我写的一个底层函数是这样的void LCD_WriteNibble(uint8_t nibble) { HAL_GPIO_WritePin(LCD_D4_PORT, LCD_D4_PIN, (nibble 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_D5_PORT, LCD_D5_PIN, (nibble 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_D6_PORT, LCD_D6_PIN, (nibble 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_D7_PORT, LCD_D7_PIN, (nibble 0x08) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_EN_PORT, LCD_EN_PIN, GPIO_PIN_SET); delay_us(2); HAL_GPIO_WritePin(LCD_EN_PORT, LCD_EN_PIN, GPIO_PIN_RESET); } void LCD_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, GPIO_PIN_RESET); LCD_WriteNibble(cmd 4); LCD_WriteNibble(cmd 0x0F); } void LCD_WriteData(uint8_t data) { HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, GPIO_PIN_SET); LCD_WriteNibble(data 4); LCD_WriteNibble(data 0x0F); }E高电平保持2微秒实际数据手册要求只需450纳秒所以2微秒有足够裕量。而且使用HAL库函数操作GPIO本身有调用开销实际高电平时间会比2微秒更长完全满足要求。2.4 初始化流程与每条命令的含义HD44780的初始化是整个驱动最讲究的部分。网上有些代码照抄能跑但一旦换一块屏或者换一个主控就乱码原因多半是初始化握手顺序不对。推荐在4线模式下的标准初始化流程void LCD_Init(void) { delay_ms(50); // 等待LCD内部上电复位完成 HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, GPIO_PIN_RESET); LCD_WriteNibble(0x03); // 8位模式握手第一步 delay_ms(5); LCD_WriteNibble(0x03); // 8位模式握手第二步 delay_us(150); LCD_WriteNibble(0x03); // 8位模式握手第三步 LCD_WriteNibble(0x02); // 切换为4位模式 LCD_WriteCmd(0x28); // 4位模式、2行显示、5x7点阵 LCD_WriteCmd(0x0C); // 显示开、光标关、闪烁关 LCD_WriteCmd(0x01); // 清屏 LCD_WriteCmd(0x06); // 写入后地址指针自动加1 }这里有个很多新手不理解的地方为什么初始化要往4线模式下发0x03这种命令因为LCD上电后默认是8位模式你发的每一条命令它都按8位来接收。要向它说明接下来要切到4位模式必须先连续发三次0x03的8位模式命令序列最后发0x02告诉它切换。如果上电后直接发0x28在8位模式下它收到的是高低两个半字节的组合结果完全不可预料。0x28这个命令本身也很有讲究它把LCD配置为4线接口、2行显示、5x7点阵字符。0x0C控制显示和光标如果发成0x0A就会出现有光标的情况会影响显示效果。0x01清屏需要时间执行后最好等待2毫秒再发下一条命令。2.5 写数据前需要等待忙检测和延时二选一LCD1602内部处理命令需要时间比如清屏指令要1.5毫秒左右。有两种方式确保上一条命令执行完再发下一条第一种是忙标志检测。LCD的DB7引脚在忙时输出高电平读RW和RS配合E脉冲可以把状态读回来。但读操作需要把STM32的引脚从输出模式切换为输入模式HAL库下切换麻烦而且时序要更小心。第二种是固定延时等待简单粗暴只要延时时间大于LCD最大处理时间就稳定可靠。做课程设计或者个人项目我推荐固定延时。指令间隔统一用delay_ms(2)数据写入间隙可以用一个小的忙等待处理因为写DDRAM数据一般只需要40微秒左右。实测下来4位模式下每条命令后加2毫秒延时即使连续快速写入也不会丢字符。这样代码简单还不用频繁切换引脚方向。显示位置控制也很关键。LCD1602内部的DDRAM地址和屏幕位置不是连续的第二行开头地址是0x40。计算器项目里我封装了一个设置光标位置的函数void LCD_SetCursor(uint8_t row, uint8_t col) { if (row 0) { LCD_WriteCmd(0x80 col); } else { LCD_WriteCmd(0x80 0x40 col); } }第二行从第0列开始显示地址就是0x80 0x40这个细节写错了会出现第二行从第17列开始这种诡异现象。3. 矩阵键盘扫描行列反转、防抖状态机设计3.1 4x4矩阵键盘的行列扫描原理矩阵键盘比独立按键省IO16个按键只需要8个引脚。我用的接法是PA0到PA3接行线PA4到PA7接列线行线配置为推挽输出列线配置为上拉输入。扫描过程每一个周期做四件事把第0行输出低电平其余行输出高电平读取4根列线如果某一列检测到低电平说明该行该列的交叉点被按下依次把4行都扫一遍根据行列编号映射出键值。为什么列线用上拉输入因为矩阵键盘内部没有上拉电阻列线悬空时电平不确定不用上拉的话手一靠近就会误触发。STM32内部上拉电阻大约30到50K欧按键导通后能把列线拉低读数稳定省去了外部上拉电阻。按键识别后还要处理一个问题一次扫描周期通常只有几毫秒手指按一个键可能持续几十毫秒程序会在多个扫描周期里读到同一次按下。这时需要做防抖和去重处理。3.2 软件防抖的两种思路和推荐做法机械按键的触点抖动时间一般在5到20毫秒按下和释放瞬间都会出现多次电平跳变。如果扫描到一次低电平就立刻返回键值可能会在同一物理按键的一次按下中被误判为多次触发。最简单的防抖是检测到低电平后延时20毫秒再读一次如果仍然是低电平就确认按键有效。这种方法优点是代码直观缺点是延时期间整个程序被阻塞LCD刷新、运算逻辑都得等。更好的做法是状态机防抖。用一个状态变量记录按键的当前状态typedef enum { KEY_STATE_IDLE 0, KEY_STATE_PRESSED, KEY_STATE_CONFIRMED, KEY_STATE_RELEASE_WAIT } KeyState;状态迁移规则是IDLE状态下读到低电平进入PRESSED状态PRESSED状态下连续几次扫描仍然读到低电平进入CONFIRMED状态此时返回有效键值CONFIRMED状态下读到高电平进入RELEASE_WAIT状态RELEASE_WAIT状态下确认高电平持续一段时间回到IDLE。这样做的本质是把确认按键是否有效从时间维度拉长既消除了抖动又不会让程序卡在延时里扫描函数每次调用只执行几条判断就返回特别适合放在主循环或者中断里循环执行。3.3 计算器场景下的按键状态机计算器的按键输入有个特殊性同一个按键按下和释放时用户期望的行为不同。比如连续按三次1期望显示111但如果在等号之后连续按三次1期望的是重新输入1而不是在结果后面追加数字。这种差异要在上层运算逻辑里处理键盘层只需要保证每一次物理按下只上报一次键值。我在实际项目中把键盘扫描函数设计成非阻塞的原型如下uint8_t Key_Scan(void);有按键按下并确认时返回键值没有按键返回0xFF。主循环每隔2毫秒调用一次配合状态机完成防抖和释放检测。实测效果是快速连续输入数字时不会漏键按住不动也不会重复触发松开再按一次才会触发第二次。扫描间隔的选择也有讲究。间隔太长快速输入时可能正好跳过某个按键的按下窗口间隔太短状态切换太快防抖效果变差。我用的2毫秒间隔配合20毫秒确认窗口也就是PRESSED状态下连续扫描10次都读到低电平才确认表现稳定。3.4 扫描放到主循环还是定时器中断两种方案我都试过。放定时器中断的优势是扫描周期固定不受主循环其它逻辑影响适合按键事件要求严格的场景缺点是如果中断里直接调用LCD刷新这种耗时操作会影响主循环时序。计算器项目我对按键响应的实时性要求没那么苛刻所以把扫描放在主循环里用简单的时间片轮询控制扫描频率。主循环结构大致是while (1) { uint8_t key Key_Scan(); if (key ! KEY_NONE) { Calc_HandleKey(key); } LCD_Refresh(); delay_ms(2); }这里有一个容易忽略的问题如果LCD刷新函数耗时较大比如清屏后再逐字符写入一次主循环可能要几十毫秒按键扫描就被拖慢了。解决办法是LCD刷新不要每次都整个重绘只在显示内容变化时才调用或者把刷新函数做成增量式的。计算器场景中按键频率本来就不高每次按键后刷新一次完全够用。4. 计算逻辑实现从按键值到显示数字的完整链路4.1 数字拼接为什么不能边按边算很多第一次写计算器代码的人会这样做按一个数字就把显示结果乘10再加这个数字然后直接更新显示。这在简单模式下没问题但一旦引入运算符和连续运算这种每按一次就算一次的思路会让逻辑变得非常混乱。正确的思路是区分当前正在输入的数字和已经确定参与运算的数值。我用四个状态变量管理整个计算过程float currentInput 0; // 当前正在输入的数字 float storedValue 0; // 已暂存的数值 char pendingOp 0; // 待执行的运算符 uint8_t isNewInput 1; // 是否正在开始输入新数字数字拼接的逻辑放在按键处理里void Calc_OnDigit(uint8_t digit) { if (isNewInput) { currentInput digit; isNewInput 0; } else { currentInput currentInput * 10 digit; } }这么一个简单的处理能解决计算器中最烦人的输入前导零和连续输入数字拼接问题。按小数点的情况稍微复杂需要增加一个小数位标志课设如果只要求整数运算可以不支持小数点但如果要支持需要再维护一个小数位数变量。4.2 连续运算和等号结算的状态设计连续运算2 3 4 是检验计算器逻辑是否合格的金标准。很多半成品计算器在输入完2 3后立即显示5再按再按4结果却变成9也就是把3加了两遍。我的处理方式是按运算符时如果当前已经有待执行的运算符并且当前正在输入新数字则先把当前输入值和暂存值做一次运算把结果存入storedValue再更新运算符。如果当前并没有输入新数字即刚按完运算符又按了一个运算符则直接替换运算符不执行运算。等号的处理是把currentInput和storedValue按pendingOp进行最终运算结果存入storedValue并把currentInput清掉。然后处理等号后再按数字的场景一旦按下数字键isNewInput被设为1开始全新的输入。整个处理逻辑用文字描述是这样的按下数字键如果isNewInput为1则currentInputdigit否则currentInputcurrentInput*10digit按下运算符键如果pendingOp存在且isNewInput为0执行storedValue storedValue pendingOp currentInput然后pendingOp更新为新运算符isNewInput置1如果pendingOp不存在则storedValuecurrentInputpendingOp更新isNewInput置1按下等号键如果pendingOp存在执行storedValue storedValue pendingOp currentInput显示storedValuependingOp清空isNewInput置1按下清屏键所有状态变量复位。这套逻辑能自然处理按完等号直接输入新数字的场景是实际测试中稳定性很高的一种设计。4.3 除零、负数、溢出这些小边界除零必须专门处理。用浮点数运算时除零不会像整数那样触发硬件错误但结果会变成inf或者nanLCD上会显示一串乱码。我在执行除法之前显式判断if (op / fabs(currentInput) 0.000001) { LCD_Clear(); LCD_PrintString(Error: /0); Calc_Reset(); return; }负数支持需要根据需求决定。如果只是最基础的计算器可以不支持负号输入直接输数字和四则运算等号结果为负时正常显示在LCD上没问题LCD1602的字符库里有横杠-。但如果想要按键输入负数比如输入(-5)就需要增加一个正负号按键并且改变数字输入逻辑在isNewInput置1时按下正负号键设置负数标志。这个功能看似简单实现的复杂度比预想高课设阶段建议先按需求决定不要为了加功能而加。溢出处理上LCD1602一屏只有16个字符单行最多显示16个字符。计算器结果如果超过10位整数加小数点显示会被截断或者换行乱掉。我在格式化输出前进行判断结果绝对值大于999999999时直接显示Overflow。浮点数的printf格式化也必须小心直接printf(%f)会显示6位小数结果数字太长我使用%g让C库自动选择整数或科学计数法表示。4.4 数字转字符串在LCD上的格式化输出LCD1602只能按字符写入不能用一条命令打印一个数字。需要先把数值格式化为字符串再逐字符写入。我的做法是char buf[17]; sprintf(buf, %g, result); LCD_Clear(); LCD_SetCursor(0, 0); LCD_PrintString(buf);使用%g格式时会自动去掉多余的末尾零比如计算结果10.5显示为10.5而不是10.500000。如果结果很大%g会自动切换为科学计数法比如1e07这种形式在LCD上也能显示不算Bug。注意一个问题LCD1602的字符库没有百分号没有小于号等符号但数字、字母、加减乘除、小数点、负号、空格这些都是有的。sprintf输出的字符串如果包含LCD字符库中不存在的字符显示出来就是一个空白或者怪异的符号运算结果场景基本不会遇到这个问题但如果是打印自定义信息要注意。4.5 代码文件怎么组织才能不重写一个计算器项目虽然不大但代码文件分好类调试和移植会方便很多。我的文件组织方式是这样的bsp_lcd.c / bsp_lcd.hLCD引脚定义、初始化、写命令、写数据、打印字符串、设置光标bsp_key.c / bsp_key.h矩阵键盘扫描、状态机防抖、键值映射calc.c / calc.h计算核心逻辑包括状态变量、数字按键处理、运算符处理、等号处理、结果格式化main.c初始化和主循环调度。这么分的好处是如果以后想换一个LCD模块只需要改bsp_lcd层想换成独立按键只需要改bsp_key层运算逻辑完全不受硬件变化影响。这也是很多工程型代码的通用分层思路。5. 调试实录点亮、按键、计算逐个环节翻车后的排查思路5.1 上电只亮屏没有字符先拧对比度电位器这基本上是LCD1602项目里最高频的故障。现象是上电后背光亮了第二行或者整屏出现一排深色方块但无论怎么初始化都不显示正常字符。这个问题的物理根源在VL引脚的偏置电压。如果不接电位器或者电位器拧到了极端位置LCD内部的字符显示电压就不对即使数据正确也看不见字。排查步骤是先用万用表量VL引脚电压正常显示时电压应该在0.4V到1.5V之间如果不是调整电位器。如果电压对了还是不显示再检查初始化代码。我有一个经验看到屏幕有方块的暗色比屏幕完全白亮更好至少说明LCD内部已经开始工作。如果屏幕完全白亮无任何方块优先检查VDD和VL如果屏幕有方块但字符不出来优先检查初始化时序和数据线接线。5.2 显示乱码或字符错位初始化时序和接线位序乱码最常见的原因是4线模式下初始化握手没走对。有些代码跳过了连续发0x03的步骤直接发0x28结果LCD停留在错误的模式里后续命令全部错位。还有一种很隐蔽的坑是数据线接线位序不一致。比如代码里D4接的是PB10但实际把LCD的D5接到了PB10高低四位错位所有字符都会变成乱码。排查时从D4到D7逐个量通断确保代码映射和实物一致。另外注意STM32的PB3、PB4这两个引脚如果被复用为JTAG功能上电后状态不受控制如果LCD数据线接到它们上面显示内容会间歇性错乱。这类问题在静态检查时就要避开。5.3 按键错乱不触发方向、上下拉和消抖按键阵列最常见的故障是按1出来的是4或者按下去没反应。首先检查行列定义和键值映射表看看是不是行列扫描顺序和映射数组对不上。其次是上下拉配置错误。行线为输出、列线为输入上拉如果配置成反了列线输出低电平时可能会和行线的输出相互冲突按键扫描结果不稳定。用一个简单的测试程序逐行扫描并通过串口打印扫描结果往往能快速定位是哪根线的问题。消抖不足的典型表现是按一次键LCD上连续出现多个相同数字。这个不需要改硬件把状态机里的确认扫描次数增加即可比如原来的确认窗口是10次扫描20毫秒可以增加为15次甚至20次让确认窗口达到40毫秒左右稳定性明显提升。5.4 下载失败和程序不运行Boot0与接线程序写好后下载失败也是课设高频事故。使用ST-Link下载时如果提示连接不到芯片先检查ST-Link的SWDIO、SWCLK和GND三根线是否接好很多情况下是杜邦线接触不良。再有就是芯片的Boot0引脚如果被拉高芯片上电后会进入系统引导程序而不是运行用户代码程序下载后看起来烧录成功但一复位就进入不了main函数。在使用最小系统板时Boot0默认通过跳线帽接地但如果跳线帽松了或者被人动过就会出现这种诡异现象。排查完接线再用ST-Link Utility或者Keil的下载日志确认连接状态基本上能定位。5.5 写在最后的一点项目心得如果给这个项目一个硬性的验收标准我自己的底线是连续输入、四则运算、连续运算、除零保护、清屏重置这几个功能全部稳定工作按键响应没有抖动LCD显示不乱码。达到这个标准答辩时提到使用了状态机防抖采用分层模块化设计处理了除零和溢出边界老师基本不会再往下深挖。做完这个项目之后我最大的体会是STM32计算器看似简单但它把单片机开发的几大核心能力全串起来了——GPIO读写、时序模拟、状态机思路、数据格式化、模块化编程。如果这个项目能独立顺畅跑通后面去做智能家居、温度采集、小车控制这类项目底子是够的。那些第一次点不亮屏幕的经历回头看反而是收获最大的部分。
返回列表