
写完这一套我在LCD1602上折腾过的那些操作算是彻底讲透了。这块1998年就定型的字符液晶模块到今天还在各种项目里服役不是没有道理——它便宜、常见、资料多更重要的是它能用最简单的方式让你理解“底层显示控制”这件事。标题里说的三个骚操作光标定位、显示数字、局部滚屏表面上是三个孤立的功能实际上它们背后牵着的都是同一根线——LCD1602内部的DDRAM地址管理。搞懂了这根线你就等于拿到了这块屏的主动权剩下的事情都是在这个基础上做文章。这篇东西适合谁看如果你正在玩STC89C52RC或者任何51单片机手里有一块LCD1602但只会照着网上的例程点亮它如果你因为显示数字总出乱码、光标不听话、想让某一行文字滚动起来却搞不定那这篇就是写给你的。我会把每一个操作背后的原理讲清楚再给你可以直接抄的代码和接线方式最后聊聊我踩过的坑。这不是一份冷冰冰的数据手册翻译是一个老玩家把这块屏拆开揉碎了之后的经验之谈。1. 整体思路拆解为什么这三个操作要放在一起讲1.1 先搞懂LCD1602到底是个什么东西LCD1602的全称是16字符乘以2行的字符型液晶模块它内部的核心是一块HD44780控制器或者兼容控制器。这块控制器才是真正干活的大脑它管理着一块叫做DDRAMDisplay Data RAM显示数据随机存储器的内存区域。你往DDRAM里写什么字符码屏幕对应位置就会显示什么字符。DDRAM一共有多大128个字节从0x00到0x7F但LCD1602只暴露了其中32个位置给你看——第一行16个第二行16个。这里有个特别容易混淆的点很多人以为第一行地址是0x00到0x0F第二行地址是0x10到0x1F结果代码写出来第二行永远显示不正常。实际上HD44780的第二行起始地址是0x40不是0x10。这是硬件设计决定的数据手册上写得很清楚但很多人根本没翻过手册直接照抄别人的代码出问题了也不知道为什么。后面我会专门讲这个坑。1.2 光标定位、数字显示、局部滚屏三者的逻辑关系为什么把这三个操作放在一起写因为它们其实是同一个问题的三个侧面你如何控制一个显示位置上的内容。光标定位解决的是“往哪里写”的问题。LCD1602内部有一个地址计数器AC它指向当前正在操作的那个DDRAM地址。你发送一条设置地址的命令就能把这个计数器指到你想写的位置然后后续发送的字符数据就会依次写入这个位置及之后的位置。这就像你先告诉一个人“站到第2排第5列”然后再说“你开始报数”他报的数就会从那个位置开始填进去。显示数字解决的是“写什么”的问题。LCD1602本质上只能显示ASCII字符而数字字符0到9在ASCII表里的编码是0x30到0x39。也就是说你如果直接往DDRAM里写数值0屏幕上显示的不是数字0而是一个空白或者乱码。必须把数值转换成对应的ASCII码。多位数字还得考虑拆位、拼接的问题不然显示出来就是错的。局部滚屏解决的是“写完了怎么动”的问题。滚屏有两种思路一种是靠硬件指令让整个显示区域在屏幕上左右滑动另一种是自己在软件里重新计算每个位置该显示什么内容手动刷新DDRAM。局部滚屏通常指的是后者因为它更灵活不会影响你不想动的区域。1.3 为什么我推荐从这三步切入我给新手朋友的建议是不要一上来就学什么图形界面、动画效果先把这三个基本功练扎实了你就能应付绝大多数LCD1602的实际场景仪表盘数据显示、菜单翻页、状态信息轮播、滚动播报……这些看起来高级的功能拆解到最后都是光标定位加上内容刷新再加上一点时间延迟的组合。把底层逻辑吃透了上层应用你想怎么玩都行。2. 核心细节解析每一个骚操作背后的规矩2.1 光标定位不是“想放哪就放哪”LCD1602的光标定位本质上就是设置DDRAM的地址指针。HD44780有一条指令专门干这个事将指令最高位设为1低7位就是你要设置的地址。举个例子你要把光标定位到第一行第1列也就是左上角就发送0x80因为第一行起始地址是0x00加上0x80就是0x80。要定位到第二行第1列就发送0xC0因为第二行起始地址是0x40加上0x80就是0xC0。如果你想要一个通用的函数来定位任意位置可以这样写void LCD_SetCursor(unsigned char row, unsigned char col) { unsigned char addr; if (row 0) { addr 0x00 col; // 第一行地址从0x00开始 } else { addr 0x40 col; // 第二行地址从0x40开始 } LCD_WriteCommand(0x80 | addr); // 最高位置1表示这是设置地址指令 }这段代码要注意的是col的范围是0到15row是0或1。如果你传入超出范围的值地址就会跑到DDRAM的别的地方去轻则显示错位重则覆盖到第二行的显示区域出现各种奇怪的问题。光标定位还有一个容易被忽略的细节设置地址后地址计数器会自动地随着你每写一个字符递增——默认是递增1也就是向右移动。所以如果你想在一行里依次输出一串字符只需要设置一次起始位置然后连续发送字符数据就行了不需要每次都重新定位。但如果你要跳着位置写或者想在两个指定位置之间来回切换就必须记得每次写之前先重新设置地址。还有一个和光标相关的概念就是光标的显示与闪烁。LCD1602的显示开关指令里低三位分别控制显示开关、光标显示、光标闪烁。0x0C是开显示关光标0x0E是开显示开光标0x0D是开显示关光标但开闪烁0x0F是开显示开光标开闪烁。我用得最多的是0x0C因为做产品的时候谁也不想看到屏幕上有什么乱七八糟的闪烁方块。只有调试阶段需要确认定位对不对的时候我才会开一下光标看看。2.2 显示数字从字符到数值的转换这是很多新手第一次卡住的地方。LCD1602的DDRAM存的是字符编码不是数值。你往DDRAM写0x01它在字模表里对应的不一定是字符1很可能是一个自定义的控制符号或者日文假名之类的。正确做法是你想要的数字字符必须用它的ASCII码来表示。单个数字最简单直接加上0x30就行。因为0的ASCII码是0x301是0x31依此类推到9是0x39。所以你要是有一个变量temp它的值是0到9之间的整数想让它显示在屏幕上只需要LCD_WriteData(temp 0x30);但现实里你很少只显示单个数字更多时候是显示一个多位数比如温度值、次数、距离这些。这时候就要把这个多位整数拆成一位一位的数字。我最早写这种代码的时候用的是最笨的办法除以10取余数把得到的每一位存到数组里最后倒着输出。void LCD_PrintNumber(unsigned int num) { unsigned char buf[6]; // 假设最多5位数加上结束符 unsigned char i 0; if (num 0) { LCD_WriteData(0); return; } while (num 0) { buf[i] (num % 10) 0x30; // 取个位转成字符 num / 10; } while (i 0) { LCD_WriteData(buf[--i]); // 倒着输出先输出高位 } }这个方法直观、好理解也不依赖任何库函数在资源紧张的51单片机上跑没有任何压力。但如果你开发环境里能用标准库还有更省事的办法直接用sprintf把整数格式化到字符串里然后逐字符发送char str[16]; sprintf(str, %d, num); for (i 0; str[i] ! \0; i) { LCD_WriteData(str[i]); }不过sprintf在51上会占用不少代码空间和栈空间Keil C51默认的小型模式在STC89C52RC上也能跑但如果你的项目还有别的复杂功能就要留意code空间的余量。我在实际项目中大多数时候还是自己写拆位函数可控、省资源、也更容易调试。还有一个小细节显示浮点数的时候比如温度23.5你可以拆成整数部分和小数部分分别处理。小数部分有精度问题比如浮点运算在某些单片机上会很慢我一般会把温度值乘以10存成整数然后显示的时候自己插入小数点这样既快又精确。2.3 局部滚屏硬件滚动和软件滚动的博弈LCD1602的HD44780确实有硬件滚动指令分别是0x18整体左移一位和0x1C整体右移一位还有0x05和0x07用来设置“滚动发生时地址计数器是否同时移动”的模式。但这里有个很多人没注意到的关键点这些指令滚动的是整个显示区域不是你指定的某一个局部区域。也就是说你用0x18让屏幕整体左移屏幕上所有字符都会往左挪一列你没法只滚动第一行而让第二行保持不动。那“局部滚屏”是怎么实现的两个思路。第一个思路是硬件滚动的变通用法利用滚动指令对“显示起始地址”的影响。HD44780允许你设置显示起始地址就是DDRAM地址映射到屏幕左上角的那条指令。每执行一次滚动指令显示区域和DDRAM之间的映射关系就会整体偏移。这种方式做出来的滚动效果非常平滑动画感强但会影响整个屏幕如果只想让一个区域滚动另一块区域的内容就会被挤出去或者移位需要你不停地重写另一块区域的内容来“补位”非常麻烦。第二个思路是我更推荐的纯软件滚动也就是你自己手动刷新DDRAM来实现局部移动。举个例子我想让第二行显示一串很长的文本比如“HELLO THIS IS A TEST MESSAGE”这样30个字符但屏幕只有16列那我就做一个窗口每次只显示其中16个字符每隔一段时间把窗口向右移动一格。实现上很简单把整串文本存到数组里然后用一个偏移量变量控制当前显示的是哪一段每次更新时就重新定位到第二行把对应位置的字符写进去。const char msg[] HELLO THIS IS A TEST MESSAGE; unsigned char offset 0; void UpdateScroll(unsigned char delta) { unsigned char i; // 更新偏移量做到头了重新从0开始 offset (offset delta) % (sizeof(msg) - 17); // 注意这里字符串对齐到16字符窗口 LCD_SetCursor(1, 0); for (i 0; i 16; i) { LCD_WriteData(msg[offset i]); } }这段代码看起来简单但实际使用中有两个坑。第一个坑是得到的屏幕效果是一格一格跳的不是平滑滚动想要平滑就要缩小每次的移动步长并配合更小的延时这个在51上也能做到但刷新频率要控制好太快了人眼反而看不清。第二个坑是每次刷新都重新写一遍16个字符等于额外占用了单片机的处理时间如果你一边滚动一边还做着别的实时任务比如按键扫描、ADC采样就会感觉到明显的卡顿。我一般会把滚动刷新的频率控制在每50到100毫秒刷新一次同时把滚动内容存储在const数组里读取速度快也不占用宝贵的RAM。3. 实操过程在STC89C52RC最小系统板上验证3.1 硬件接线别小看这几根线的顺序我用的开发环境就是最常见的STC89C52RC最小系统板加一块LCD1602模块。接线方式有很多种有8位并口的有4位并口的还有带I2C转接板的。这里我用最标准的4位并口接线来演示因为这是51上最常用的方式省IO口。LCD1602模块上的引脚除了电源和地主要就是RS、RW、E和D4到D7。RS是寄存器选择RS0时你发送的是指令RS1时发送的是数据。RW是读写选择我们大多数情况下只写不读所以RW可以直接接地。E是使能信号每次发送指令或数据时都要在E上产生一个下降沿脉冲告诉LCD“可以读取了”。在4位模式下数据是高4位先发送D7-D4然后低4位再发送。这样只需要用到4根数据线省下4个IO口。我的接线表如下LCD1602引脚接到51单片机的哪个引脚作用VSSGND地VDD5V电源VO10K电位器中间脚对比度调节RSP2.0指令/数据选择RWGND读写选择固定为写EP2.1使能信号D4P2.2数据线4D5P2.3数据线5D6P2.4数据线6D7P2.5数据线7LEDA5V串电阻背光正极LEDKGND背光负极这里要特别提醒一下VO对比度调节这个引脚。很多新手接了屏以后发现屏幕上什么都看不见或者只看到一排黑色的方块第一反应是代码写错了。其实大多数时候是VO引脚悬空或者电压不对。VO的电压决定了字符和背景的对比度你把一个10K电位器接在电源和地之间中间抽头接VO然后慢慢调电位器直到背景清晰、字符明显。我把这个步骤放在最前面讲是因为它几乎是我调试LCD时第一个要查的地方。3.2 驱动初始化LCD1602的开机流程时序写错了后面全是白搭。初始化LCD1602的流程比较固定而且有不少延时要求。我开始学的时候图省事把别人代码里的延时长短随便改短结果就是屏幕不亮或者乱码查了半天才发现是上电等待时间不够。标准的初始化流程是上电后等待大约40毫秒以上让LCD内部控制器完成上电复位。随后发送功能设置指令0x308位模式连续发3次每次间隔至少4.1毫秒这其实是一个和控制器握手的过程目的是让它进入可控的并行模式。然后用0x20切换到4位模式再用0x28设置成4位模式、2行、5x8点阵。接下来开显示0x0C、清屏0x01、设置输入模式0x06地址自动加1。写成代码就是这样的void LCD_Init(void) { DelayMs(50); // 上电等待 LCD_WriteNibble(0x03); // 第一次握手 DelayMs(5); LCD_WriteNibble(0x03); // 第二次握手 DelayMs(5); LCD_WriteNibble(0x03); // 第三次握手 DelayMs(5); LCD_WriteNibble(0x02); // 切换为4位模式 LCD_WriteCommand(0x28); // 4位模式、2行、5x8点阵 LCD_WriteCommand(0x0C); // 开显示关光标不闪烁 LCD_WriteCommand(0x01); // 清屏 LCD_WriteCommand(0x06); // 地址指针自动加1 LCD_WriteCommand(0x80); // 光标定位到第一行第一列 }这个流程我强调一下前三次发0x03在8位模式下的功能设置不是必须的但很多HD44780兼容芯片在实际应用中都要求这么“喂”一下才能稳定进入4位模式。这也是为什么你从网上找到的各种LCD驱动代码看起来都差不多因为这本来就是芯片手册里官方推荐的时序。如果你用的是某个现成的“LCD1602驱动库”源码里大概率也是这一套只是封装起来了而已。3.3 光标定位实战做一个坐标函数有了初始化的基础光标定位就简单了。我在项目里通常会封装一个坐标函数把行列参数直接转换成对应的DDRAM地址这样后面写任何显示逻辑都能用。void LCD_SetCursor(unsigned char row, unsigned char col) { unsigned char addr; if (row 0) { addr 0x80 col; } else { addr 0xC0 col; } LCD_WriteCommand(addr); }注意看我直接用0x80加上行起始偏移。第一行的起始地址是0x00第二行是0x40分别再加上0x80就得到了0x80和0xC0。这种方式简洁也直观。调试技巧执行完LCD_SetCursor之后马上发送0x0F开显示开光标开闪烁这样就能在屏幕上看到当前定位处的闪烁方块。如果方块出现在你预期之外的位置说明地址换算有误这时候检查一下是不是row和col的坐标从0开始算的而你的判断条件写成了从1开始。3.4 显示数字实战让“1234”正确地出现在屏幕上显示数字的完整代码我已经在前面那节给过了这里再说说实际中遇到的情况。假设我要做一个计数器从0加到9999每次加1就刷新显示。如果直接用LCD_PrintNumber它会把小于4位的数字直接显示比如数字7就只显示一个字符“7”但这在很多应用里并不好看我希望它显示成“0007”凑够4位。那就得在函数里加一个“最小显示位数”的参数不够的位就补零。void LCD_PrintNumberPadded(unsigned int num, unsigned char minDigits) { char buf[6]; unsigned char i 0; // 先处理0的情况 if (num 0) { buf[i] 0; } else { while (num 0) { buf[i] (num % 10) 0; num / 10; } } while (i minDigits) { buf[i] 0; // 补零 } while (i 0) { LCD_WriteData(buf[--i]); } }这个函数是我在写参数设置界面时最常用的因为它能保证显示格式的整齐。比如菜单里设置一个阈值初值是10我希望它显示成“010”效果就比光秃秃的“10”好认得多。还有一点如果你在显示数字之前已经用LCD_SetCursor把光标定好了位置并且确认这个位置能放下这么多位字符那就可以直接连续写。如果数字超出了位宽比如定了3位但显示的是四位数多余的数字会继续写到屏幕右侧之外——但LCD1602是不会自动换行的所以超出部分可能就看不到了。这种情况下建议先清掉可能残留的旧字符或者固定每次刷新前都清理整个显示区域。3.5 局部滚屏实战第二行滚动而不影响第一行现在到重头戏了。我要实现的效果是第一行固定显示“STATUS: RUNNING”第二行滚动显示一条长文本滚动过程不影响第一行的内容。我的方案就是前面说的纯软件滚动。核心逻辑是用一个常量字符串保存长文本用一个变量保存当前滚动窗口的起始位置每隔固定时间把窗口偏移一次然后重写第二行的16个字符。完整实现code const char rolling_text[] SENSOR LOG: 2018 35.2C OK; void UpdateRollingText(void) { unsigned char i; static unsigned char startPos 0; unsigned char len sizeof(rolling_text) - 1; // 字符串长度 // 窗口宽度是16最后一个窗口的起始位置是 len - 16 if (startPos len - 16) { startPos 0; // 回到开头形成循环 } LCD_SetCursor(1, 0); for (i 0; i 16; i) { LCD_WriteData(rolling_text[startPos i]); } startPos; }在main函数里用一个定时器或者简单的延时循环每100毫秒调用一次UpdateRollingText就能看到第二行文本在平滑往左滚动。第一行因为一直没被触碰所以内容完全不受影响。这就是局部滚屏的实质——并不是LCD给了一块局部区域供你滚动而是你通过控制写入位置和刷新内容人为地把滚动限制在屏幕的某一个区域里。这里有一个性能上的优化技巧如果第二行的内容很长而每次滚动只移动一个字符那理论上你可以每次只更新新进来的那个字符把旧字符左移一列的操作让硬件滚动指令来做。但实际做起来不如全量刷新省心因为一旦中间任何一步没有和硬件滚动对齐就会导致字符错位。我踩过一次这个坑后就老实了宁可多花点时间全量刷新也不愿意省那点开销然后花两小时调试一个鬼畜般的显示效果。4. 常见问题与排查技巧实录4.1 第一行正常、第二行花屏或者不显示这是LCD1602新手区出现频率最高的问题99%的原因是DDRAM地址写错了。很多人写定位函数时第二行用了0xC0没问题但有的人为了省事直接用0x80加上一个10到15的偏移量等于把光标定位到了第一行的后半段或者更远的DDRAM区域那第二行当然显示不出来。记住一句话第一行的地址是0x00到0x0F第二行的地址是0x40到0x4F不是连续的。这是HD44780硬件设计上的一个怪癖你没法改变它只能适应它。如果你用的是别人写的库也建议自己看一下底层源码里定位函数是怎么算地址的别只用不查出了问题都不知道去哪排查。4.2 光标一直在闪想关掉看驱动初始化的那行LCD_WriteCommand(0x0C)。这个0x0C对应二进制的00001100其中D2位也就是数值4的那一位控制光标显示D1位数值2控制光标闪烁。0x0C的意思是最内两层都关闭。如果你写成了0x0E或者0x0F就会出现光标或者闪烁方块一直在屏幕上跳的情况这在做正式项目时非常碍眼。想要光标而且让它闪烁的时候就发0x0F想显示光标但不闪烁就发0x0E完全不想要就发0x0C。这几种模式之间切换是即时的不需要重新初始化屏幕。4.3 显示数字时出现乱码或者空白一个典型的场景你有一个整数变量value值是100你想把它显示在屏幕上于是写了LCD_WriteData(value)。结果屏幕上出现了某种奇怪的符号。原因就是我前面反复强调的——你需要的是字符1、0、0它们的ASCII码是0x31、0x30、0x30而数值100对应的DDRAM写入内容完全是另一回事。正确写法是LCD_WriteData(value 0)但这只适用于单个数位。多位数字继续用拆位函数。另一个可能的原因是字符串结尾处理你有意无意地往DDRAM里写入了\0也就是0x00HD44780会把这个字符显示成自定义字模数组里的内容默认情况下是一个全空的块看起来就像显示内容突然少了一块。排查方法很简单把显示数字的函数里每个字符都打印出来看ASCII值用调试器或者串口输出一目了然。4.4 滚动的时候另一行内容被干扰如果你用硬件滚动指令0x18/0x1C来滚动导致另一行也跟着动了这非常正常因为硬件滚动作用于整个显示区域。我的建议是真想局部滚动就别用硬件滚动指令改用软件刷新。刚开始你可能觉得软件刷新太原始、效率太低但实际做出来以后你会发现在51这种级别的单片机上也跑得挺顺。我经常用50毫秒的周期刷新16个字符屏幕效果依然流畅肉眼基本看不出刷新过程。如果你一定要用硬件滚动那就必须时刻维护好两块内容一块是屏幕映射区的内容一块是你希望保持不动的区域。每次滚动后立刻重写那个不该动的区域。这种方式能用但代码复杂度高而且容易出错我不推荐新手尝试。4.5 屏幕亮但什么都没有或者只有一片黑块这个问题90%出在对比度引脚VO上面。VO电压过高或者过低都会导致字符和背景融为一体看起来像是屏幕“死了”。用一个电位器把VO电压调节到合适的范围通常是在1V到2V之间字符就能清晰地浮现出来。如果调了电位器还是不行再检查背光供电LEDA和LEDK之间要有正向电压差一般3.3V到5V都能点亮背光接反了背光不亮但屏幕内容仍然可能显示只是你在暗处看不清而已。4.6 常见问题速查表现象最大可能原因快速排查方法第一行正常第二行花屏第二行地址没加0x40检查定位函数第二行起始地址应为0xC0数字显示成符号直接写入了数值而不是ASCII码数值加0x30或者用拆位函数光标一直闪显示指令用了0x0F或0x0E改成0x0C关闭光标屏幕全黑无字符VO对比度没调好电位器调VO到字符清晰为止滚动时另一行被干扰用了硬件滚动指令改用软件刷新DDRAM显示内容偏移或者错位定位时col计算错误确认坐标从0开始而不是从1开始写在最后的一个小技巧我折腾LCD1602这么多年最大的体会是这块屏虽然老旧但它教会你的东西一点都不过时。光标定位的概念到了OLED、TFT彩屏上照样适用只是换成坐标系统的差异而已数字显示的处理逻辑在7段数码管上用得到在串口屏用得到在嵌入式GUI里也用得到局部滚屏的思路更是做任何动态界面的基础。你在这块16乘2的小屏幕上悟出来的底层逻辑会在后面很长一段开发生涯里反复用到。最后分享一个我自己的偏好作为收尾当你需要动态刷新某一行的时候不要频繁地清屏再重写那样屏幕会闪得很厉害看着特别廉价感。正确做法是只在内容真正变化的那一刻才去写那一位静态的内容不要反复刷。比如我做滚屏第一行根本不会去碰它第二行哪怕要滚动也只重写需要更新的一行。这个习惯能让你做出来的界面干净利落观感直接提升一个档次。LCD1602这块屏玩到后来拼的其实不是功能是细节。