标题里说的这几个功能我估计玩单片机的人都折腾过。LCD1602算是入门级显示模块里最常见的一块但真要把光标挪到位、把数字显示对、再让某一行局部滚动起来光是看数据手册上那几条指令新手很容易卡壳。尤其是“光标定位”和“局部滚屏”看着只是几行代码的事实际上牵扯到DDRAM地址映射、指令时序、字符串和数值的转换逻辑任何一个环节没理顺显示效果就会跟你对着干。我这篇文章就把这些“骚操作”挨个拆开讲从最底层的工作原理到可以直接抄走的代码片段全部过一遍。如果你正在用51单片机或者STM32驱动LCD1602或者刚把模块焊上线却发现显示不对这篇内容应该能帮你省下不少查资料的时间。1. 内容整体设计与思路拆解1.1 LCD1602到底“骚”在哪里LCD1602本质上是一个16列2行的字符型液晶模块内部集成了HD44780控制器或者兼容控制器。它本身不能显示图片也不能显示汉字只能显示ASCII字符和日文假名、希腊字母这类内置字符。它的“骚”不在于显示能力多强而在于那个经典的控制方式通过8条数据线也可以只用4条和3条控制线就能把一个带缓冲区的显示系统玩出花来。很多人把LCD1602当成一个“高级数码管”来用上电初始化写几个字符完事。但真正深入进去你会发现它内部有一个80字节的DDRAM显示数据存储器而屏幕上能看到的只有16×232个字符位。这就意味着你写进DDRAM的数据≠屏幕上显示的内容地址和屏幕位置之间有一张隐射表搞懂这张表你才能随心所欲地把字符写在想写的位置。光标的本质是什么呢HD44780内部维护了一个“地址指针”Address Counter你写入的数据会被送到这个指针指向的DDRAM地址。所谓光标定位说白了就是修改这个内部指针让它指向你希望写入的DDRAM位置。指针和光标是绑定的0x80这个指令码加上你要跳转的地址就能让指针和光标同时动起来。局部滚屏又是另一层功夫了。很多人一提到滚屏直接就对整屏执行左移或右移指令结果发现两行内容一起动了想滚哪一行根本控制不了。想做到“局部”就得跳出指令集本身自己去操控DDRAM里的数据矩阵让那一行“看起来”在滚其他行纹丝不动。1.2 为什么要把光标、数字、滚屏放在一起讲因为这三件事在实际项目里往往是同时出现的。举个例子你做一个温湿度计屏幕第一行显示“Temp: 25.6C”第二行可能是一个滚动提示“Please press KEY1 to record”。温度那个数字要实时刷新25.6变成25.7的时候你总不能把整行都重写一遍吧这时候光标定位就有用了把指针定到数字的起始位置覆盖写入新的数字即可。而滚动提示那条如果整屏都跟着动第一行的固定信息就毁了这就逼着你去实现局部滚屏。这三块能力叠加起来才算把LCD1602用出了“生产力”。单独会初始化、会显示字符串那只是能用能把显示区域当画布一样精细操作那才是会用。这篇文章的重点就是让你从“能用”跨到“会用”。1.3 我的方案选型为什么选择手动操作DDRAM先交代一下我的技术路线省得你看后面的代码犯迷糊。我用的主控是STC89C52RC经典中的经典开发板上最常见的那颗51单片机。驱动方式我选了4线模式也就是只接D4~D7四条数据线两条控制线RS和E再加一条RW其实RW可以直接接地读忙标志用不到的话。这样总共用6个IO口比8线模式省2个。之所以选4线模式倒不是因为省那两根线有多重要而是因为4线模式强制你把数据拆成高4位和低4位分两次发送这个拆分过程能让你更清楚地理解HD44780的时序要求。等你把4线的时序搞明白了回头再看8线模式几乎不用动脑子就能写出来。局部滚屏的实现我放弃了HD44780自带的“显示移位”指令0x18系列改用“手动重写DDRAM”的方式。原因很简单自带的移位指令是整屏移或者说是整行移没法指定“只移动第3列到第10列的区域”。手动重写虽然代码多一些但胜在控制精确——你想让哪一段滚就让哪一段滚其他位置的数据完全不受影响。2. 核心细节解析与实操要点2.1 DDRAM地址映射与光标的关系LCD1602的DDRAM空间是0x00~0x67共104个字节但实际接出来的只有前面一部分。以最常见的16×2字符排列为例第一行的显示地址范围0x00 ~ 0x0F第二行的显示地址范围0x40 ~ 0x4F注意第二行不是从0x10开始的而是从0x40开始的。这是HD44780硬件设计上最坑的一个地方也是无数新手把第二行字符写到第一行末尾的原因。地址指针从0x0F再往后走不会自动跳到第二行而是继续在0x10~0x27这段“虚拟空间”里移动这部分字符在屏幕上不可见除非你执行移位指令把它们推出来。按照常规的“设置DDRAM地址”指令格式命令字是0x80加上7位地址bit7固定为1。所以把光标定位到第一行第1列0x80 | 0x00 0x80把光标定位到第一行第5列0x80 | 0x04 0x84把光标定位到第二行第1列0x80 | 0x40 0xC0把光标定位到第二行第3列0x80 | 0x42 0xC2这段代码你肯定在很多例程里见过但你知道为什么第一行第5列是加0x04而不是加0x05吗因为地址从0开始计数第1列的偏移量是0第5列的偏移量是4。记住这个“列号减一等于偏移量”的口诀定位就永远不会错。我画了一张等效映射表方便你查屏幕位置DDRAM地址指令码第1行第1列0x000x80第1行第8列0x070x87第1行第16列0x0F0x8F第2行第1列0x400xC0第2行第6列0x450xC5第2行第16列0x4F0xCF2.2 光标定位的两种模式移动光标和显示移位HD44780的光标控制其实有两套体系。一套是“移动光标本身”另一套是“移动显示内容”两者通过0x10这条指令里的S/C位和R/L位来区分。具体是0x14光标右移一位光标动显示不动0x10光标左移一位光标动显示不动0x1C整屏显示右移一位光标跟着动显示内容整体右移0x18整屏显示左移一位光标跟着动显示内容整体左移很多人的误区就在这里以为0x18/0x1C是“把当前行的内容移动一下”实际上它是整个显示区域一起动。所以你要是只写了“0x18”就想让某一行单独左移结果往往是两行都动了甚至会出现第一行末尾的内容溜到第二行开头去——因为DDRAM地址是连续的移位过程中数据会跨行流动。那什么时候用移位指令什么时候用光标移动指令呢我的习惯是如果只是想让光标在字符之间来回走用0x14/0x10如果想做整屏滚动选单、左右滑动展示长文本用0x18/0x1C但此时要注意屏幕内容和光标是一起被“推”着走的写数据之前必须重新定位光标否则你会写到一个你看不见的地方去。2.3 数字显示的字符码转换原理LCD1602不认识数字。你往DDRAM写0x05屏幕上显示的不是“5”而是ASCII码0x05对应的那个控制字符通常显示为空白或者一个奇怪的符号。想让屏幕上出现“5”你要写入的是0x35也就是字符‘5’的ASCII码。这个转换规则很简单数字0~9的ASCII码是0x30~0x39。所以要显示单个数字直接用“数字 0x30”或者说“数字 0”就行。比如变量temp的值是7你想显示7写入0x37即可代码就是LCD_WriteData(temp 0)。但单个数字好办多位数就麻烦了。比如温度是25你往屏幕上写0x32和0x35屏幕上才会显示“25”。如果直接用sprintf拼接单片机内存可能吃不消特别是51那种128字节RAM的芯片sprintf很容易把堆栈撑爆。稳妥的做法是手写一个整数转字符串的函数先对数字做除法取商和余数依次拆出百位、十位、个位再各自加上0x30写入屏幕。我后面会给出参考代码。2.4 滚屏实现中容易忽略的边界问题局部滚屏最容易被忽略的问题是“边界”。假设你要让第二行的第3到第10列这8个字符循环左移那你得先把这8个位置的内容读出来再把第4列的内容挪到第3列、第5列挪到第4列……最后把第一个字符放到第10列。这个操作有两个坑第一个坑DDRAM是不可读的其实HD44780支持读操作但读取之前要设置地址还要处理忙标志很多人的底层驱动根本没写读DDRAM的函数。所以我常用的办法是不依赖读操作自己在单片机内存里维护一个缓冲区把要显示的那一行内容全部存进一个数组滚屏时先挪数组再把数组整体写回屏幕。这样既绕开了读DDRAM的麻烦又保证了数据的实时可控。第二个坑写回时不能用连续写一整行的方式因为那样会覆盖这一行其他位置的内容。正确做法是只重写你关心的那一段区域。比如上面那个例子你只需要把光标定位到第3列然后连续写8个字符即可其他列的数据没有动过自然就不会被破坏。3. 实操过程与核心环节实现3.1 底层驱动4线模式下的初始化与写命令先上一个最精简的底层驱动这是我实测过无数遍的版本STC89C52RC、12MHz晶振下稳定运行。别的51芯片只要时序差不多也能直接用。#include reg52.h #define LCD_DATA P0 sbit LCD_RS P2^0; sbit LCD_RW P2^1; sbit LCD_E P2^2; void LCD_Delay(unsigned int t) { while(t--); } void LCD_WriteNibble(unsigned char dat) { LCD_DATA 0x0F; // 清高4位 LCD_DATA | (dat 0xF0); // 写入高4位 LCD_E 1; LCD_Delay(10); LCD_E 0; LCD_Delay(10); } void LCD_WriteByte(unsigned char dat) { LCD_RS 0; // 写命令 LCD_RW 0; LCD_WriteNibble(dat); // 先发高4位 LCD_WriteNibble(dat 4); // 再发低4位 } void LCD_WriteData(unsigned char dat) { LCD_RS 1; // 写数据 LCD_RW 0; LCD_WriteNibble(dat); LCD_WriteNibble(dat 4); } void LCD_Init() { LCD_Delay(1000); LCD_WriteNibble(0x30); // 8位模式切换指令兼容性初始化 LCD_Delay(500); LCD_WriteNibble(0x30); LCD_Delay(500); LCD_WriteNibble(0x30); LCD_Delay(500); LCD_WriteNibble(0x20); // 切换为4线模式 LCD_Delay(500); LCD_WriteByte(0x28); // 4线模式2行5x8点阵 LCD_WriteByte(0x0C); // 显示开光标关闪烁关 LCD_WriteByte(0x01); // 清屏 LCD_WriteByte(0x06); // 写入后光标自增 LCD_Delay(2000); }这里有几个细节值得你注意。首先是初始化那段“三次0x30”这是数据手册里规定的兼容性初始化流程目的是让模块无论之前处于什么乱七八糟的状态都能被拉回8位模式。即使你后面要用4线模式这一段也必须完整执行少了它模块可能不亮或者显示乱码。其次LCD_WriteNibble函数里我直接把P0口的高4位覆盖成要发送的数据低4位清零。所以你的数据线必须接在P0.4~P0.7上也就是D4接P0.4、D5接P0.5、D6接P0.6、D7接P0.7。如果你用的引脚不是高4位那这个函数里的掩码就要相应调整。然后是清屏指令0x01它除了清屏还会把DDRAM地址指针归零、把“光标自增/自减”的设置重置为自增。所以清屏之后一定要重新执行0x06设置否则后续用0x80定位光标时写入数据的光标方向可能是错的。我第一次写驱动时没注意这个结果每次清屏后第一个字符显示位置都偏了一位排查了半天才发现是0x06被清屏复位了。3.2 光标定位的完整应用秒表刷新实战有了底层的写命令和写数据函数光标定位就水到渠成了。我举个典型的场景做一个秒表显示第一行显示“Time: 00:00”第二行显示“Count: 0000”数字每秒变化。你需要做的不是每次变化都清屏重写而是先把固定不变的文本写一次然后每次更新数字时把光标定位到数字的起始位置重写那几位数字即可。void DisplayTime(unsigned char hour, unsigned char minute, unsigned char second) { // 定位到第1行第7列因为前面写了“Time: ”占6个字符 LCD_WriteByte(0x80 | 0x06); LCD_WriteData(0 hour / 10); LCD_WriteData(0 hour % 10); LCD_WriteData(:); LCD_WriteData(0 minute / 10); LCD_WriteData(0 minute % 10); LCD_WriteData(:); LCD_WriteData(0 second / 10); LCD_WriteData(0 second % 10); }这个函数的核心思路是每次调用时通过0x80 | 0x06把光标钉在第一行第7列然后把时分秒逐位写进去。因为LCD1602的字符写入是自动覆盖的你只需要把新数字写上去旧数字会被自动盖住不用手动清掉。但这里有个小坑如果上一次显示的是“09”这次变成“10”你逐位写“1”和“0”没问题。可如果上一次是“10”这次变成了“9”你只写一位“9”那么原来那个“0”还会留在屏幕上变成“90”。所以要么你每次都固定写满两位用hour / 10和hour % 10要么在更新之前先在那个位置上写一个空格。我强烈建议采用“固定位数”的办法。秒表显示就是固定两位温湿度显示保留一位小数也固定位数。这种约定俗成的做法可以避免一大堆“显示残影”的问题。3.3 数字显示进阶不带库的整数转字符串如果你要显示的数值是动态变化的比如ADC采集到的0~1023之间的数逐位拆分就有点麻烦了。手写一个转换函数比调用sprintf省内存、速度快也更可控。void LCD_ShowNumber(unsigned int num) { unsigned char buf[5]; unsigned char i 0; if (num 0) { buf[0] 0; i 1; } else { while (num 0) { buf[i] 0 (num % 10); num / 10; } } // 倒序输出 while (i 0) { LCD_WriteData(buf[--i]); } }这个函数的思路是先把数字从低位到高位逐个取出存入临时数组然后再倒序写出来。为什么不能直接从高位开始拆因为在不知道数字有几位的情况下直接取最高位需要先算出10的幂次反而麻烦。先存到数组再倒序输出是最直观也最不容易错的办法。另一个细节是如果这个数字放在屏幕的某个固定位置而它的位数变化了比如从999变成1000那原来的高位就会被新数字覆盖但低位可能残留。解决方法是固定一个显示宽度每次先写空格再写数字或者干脆把数字定位到右对齐——比如固定5位先算出数字占多少位前面用空格补齐。这个“空白补齐”的思路在做仪表类界面时非常实用。3.4 局部滚屏的实现方案手动重写法现在进入今天的重头戏局部滚屏。前面说了自带移位指令没法实现局部滚动所以我的方案是“维护一行缓冲区 手动重写DDRAM”。假设我要让第二行的第3~10列显示一个不断左移的字符串“HELLO LCD”其他位置保持不变。#define SCROLL_ROW_START 0x40 // 第二行起始地址 #define SCROLL_COL_START 2 // 从第3列开始下标从0计 #define SCROLL_COL_END 9 // 到第10列结束 unsigned char lineBuf[16] HELLO LCD ; // 16个字符刚好一行 void ScrollLeftOnce() { unsigned char i; unsigned char temp; // 保存滚动区域的第一个字符 temp lineBuf[SCROLL_COL_START]; // 区域内的字符整体左移一位 for (i SCROLL_COL_START; i SCROLL_COL_END; i) { lineBuf[i] lineBuf[i 1]; } // 最后一个位置补上刚才保存的第一个字符 lineBuf[SCROLL_COL_END] temp; // 把更新后的缓冲区块写回LCD LCD_WriteByte(0x80 | (SCROLL_ROW_START SCROLL_COL_START)); for (i SCROLL_COL_START; i SCROLL_COL_END; i) { LCD_WriteData(lineBuf[i]); } }这段代码的执行逻辑很清晰先在内存数组里做搬家把第4列的内容搬到第3列第5列搬到第4列……然后把最后一个位置填上第一个被挪走的字符整个区域看起来就左移了一位。最后通过光标定位把更新后的数组内容整体写回屏幕。为什么中间那8个字符要先在数组里挪而不是直接在LCD上挪因为LCD1602的DDRAM读操作比较麻烦而且读出来的数据是ASCII码你还得再做一次有效性判断。直接用数组做中转速度快、代码简单也不容易出bug。代价是多占16个字节的RAM但对51来说这点内存还是舍得给的。右移的实现完全对称只是搬家的方向反过来从最右边开始依次往右挪然后第一个位置填上最后一个被移走的字符。3.5 局部滚屏的扩展双区域滚动掌握了单区域滚动的原理双区域滚动其实是同一套方法的线性组合。比如第一行的第1~8列滚动显示一组字符第二行的第5~12列滚动显示另一组字符只需要两个独立的缓冲区分别调用各自的移位函数即可。我在一个项目里做过三区域同时滚动第一行第1列到第6列显示时间固定不动第7列到第16列滚动显示新闻标题第二行第1列到第8列滚动显示温度曲线符号第9列到第16列显示状态指示符。每个区域都有自己的缓冲区互不干扰。调用顺序是ScrollTitleLeft(); // 第一行右侧区域左移一次 ScrollTempLeft(); // 第二行左侧区域左移一次 ScrollStatusLeft(); // 第二行右侧区域左移一次每次滚动之间的间隔用一个定时器来控制。比如定时器每500ms触发一次主循环里检查标志位再调用滚动函数滚动速度就可以精确控制了。这个方法比裸延时加循环要可靠得多尤其是在系统里还有其他任务需要处理的时候。4. 常见问题与排查技巧实录4.1 初始化后屏幕不亮或显示黑块这个问题几乎每个人都遇到过。先别急着怀疑模块坏了按照下面这个顺序排查第一步检查硬件连接。VSS接地VDD接5VV0对比度调节脚接一个10K电位器的中间抽头电位器两端分别接5V和地。很多开发板上的1602不亮就是V0直接接地或者悬空导致的对比度过高/过低屏幕上要么全黑要么全白。调整电位器让屏幕出现一行小黑块就说明硬件基本正常。第二步确认电源稳定。LCD1602的背光LED和逻辑电路共用一个5V电源如果你的电源带不动或者滤波不好容易出现显示闪烁、字符乱跳的情况可以在VDD和GND之间加一个100uF电解电容。第三步排查初始化时序。HD44780对上电时序非常敏感我的初始化代码里有三次0x30的延迟那是必须的。如果初始化太急、延时太短模块可能进入未知状态。把初始化里的延时调大试试一般能解决。4.2 第二行不显示或者第一行末尾出现乱码如果你的代码明明写了第二行的地址0x40但屏幕上第二行没反应第一行的后半部分却出现了不该有的字符那十有八九是地址计算错了。记住第二行首地址是0x40不是0x10。0x80 | 0x10会把光标定位到第一行第17列——那个位置在DDRAM空间里存在但屏幕上根本看不到。如果第二行完全黑块或者一直亮着可能是初始化时的0x28命令没写对。0x28是“4线模式、2行显示”如果你误写成0x20那就是“4线模式、1行显示”第二行自然不工作。检查一下初始化代码里的0x28改成0x388线模式、2行也行只要和你的接线方式对得上。4.3 数字刷新时出现残影和“叠字”这个问题我在前面提过典型现象是上次显示“25.6”这次显示“8.3”结果屏幕上出现了“28.6”——旧数字和新数字混在一起了。解决办法很简单在写入新数字之前先把旧的数字区域用空格擦干净。比如你固定显示“XX.X”格式那就在定位光标后先写5个空格再重新把光标定位回起点写新数字。或者你干脆写“8.3 ”带一个尾随空格用空格把多余位置的字符覆盖掉。我的经验是显示数值的代码一定要设计成“固定宽度”不够的位数用空格或0补齐这是避免残影最省心的办法。秒表用前导0仪表读数用前导空格都能达到一样的效果。4.4 滚屏后出现整行内容错乱如果你执行了局部滚屏结果整行的内容都乱了最可能的原因是你在写回缓冲区时把起始地址搞错了。比如滚屏区域是第3~10列但你写回时定位到了第1列然后连续写了8个字符那第1、2列原有的内容就被覆盖了整行看起来当然就乱了。我在上面的ScrollLeftOnce函数里写了LCD_WriteByte(0x80 | (SCROLL_ROW_START SCROLL_COL_START))这就是定位到区域的起始列。改区域范围的时候这个起始地址一定要跟着改否则就会出现整行乱掉的问题。还有一个容易错的地方是循环边界。for (i SCROLL_COL_START; i SCROLL_COL_END; i)里面用的是因为要包含最后一个位置。如果你习惯用那就要把SCROLL_COL_END改成SCROLL_COL_END 1。这种边界问题在数组操作里最常见排查时逐个确认循环变量就好。4.5 常见问题速查表问题现象可能原因解决方法屏幕全黑调节对比度无效V0悬空或接地不当接10K电位器调整到正常对比度只有一个黑块行初始化时0x28写成0x20改为0x28或0x38第二行无显示地址用了0x10而非0x40定位到第二行用0xC0字符显示乱码写入的数据不是ASCII码数字要加0x30再写入数字叠字新旧字符长度不一致固定显示宽度或先用空格清除滚屏整行错乱写回区域起始地址错误检查0x80光标一直在闪初始化用了0x0F把初始化命令改成0x0C写入后位置自动跳变清屏后0x06设置被复位清屏后重新执行0x064.6 我踩过的一个隐藏大坑最后说一个比较隐蔽的问题。LCD1602在上电瞬间如果单片机的IO口输出高电平可能会把模块的背光点亮、进入一个未知状态导致初始化不成功。解决方法是在LCD_Init()函数开头先把所有控制线拉低再延时一下最后才开始正常的初始化流程。我写的那个Init函数里第一行LCD_Delay(1000)之前最好加上LCD_RS0、LCD_RW0、LCD_E0这几条。另外如果你用P0口驱动数据线记得P0口是开漏输出必须在外部接上拉电阻通常10K排阻就能满足。如果你的开发板没有给P0口接上拉那1602的数据线永远读不到高电平显示就会各种诡异。我调试时遇到过屏幕偶尔亮偶尔不亮最后发现就是P0口上拉电阻虚焊导致的。这块一旦出问题排查起来能让人怀疑人生先检查为上。5. 从固定显示到动态界面的进阶建议学完了光标定位、数字显示、局部滚屏你其实已经具备了做一个“带动态刷新界面”的基础能力。往深了走还有几个方向可以琢磨。第一个方向是菜单系统。利用光标定位和局部刷新的能力你可以做一个二级菜单第一行显示菜单标题第二行显示当前选项按按键切换选项时只需要重写第二行的内容。这个比整屏清屏重绘要流畅得多用户体验也好得多。第二个方向是曲线显示。LCD1602虽然不能画真正的曲线但你可以用空格加自定义字符CGRAM来模拟简易的波形。CGRAM可以让你自己定义8个5×8点阵的字符把波形离散成16列每列用不同的图案表示高度配合局部刷新能在1602上看出一条动态波形。这个进阶玩法要注意CGRAM地址和DDRAM地址是相互独立的写入CGRAM要先设置CGRAM地址0x40 | 地址切回DDRAM再设置0x80 | 地址。第三个方向是数据记录。如果你有外部存储器比如AT24C02可以定期把缓冲区里的数据存下来下次上电时恢复显示。这个对缓冲区设计和DDRAM操作的要求更高但也是把1602用出“产品感”的必经之路。以我个人的使用体验来说做LCD1602相关项目时与其东拼西凑找各种别人写好的函数库不如自己把底层驱动和基础显示函数吃透。这个模块的指令集非常规范只要懂了原理写起来比用抽象库更顺手排查问题也更快。你把这些积累了以后换任何带HD44780兼容控制器的屏基本上都能无缝衔接。
