免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

LCD1602实用指南:光标定位、数字显示与局部滚屏深度解析

LCD1602实用指南:光标定位、数字显示与局部滚屏深度解析 LCD1602应该算是我在单片机这条路上打交道最多的外设之一。早些年入门的时候能点亮一个“Hello World”就觉得自己已经征服它了但真到了做项目才发现显示静态字符串只是最基本的热身。光标定位、显示动态数字、局部滚屏这些“骚操作”才是日常项目里真正绕不开的需求。这篇文章我就把这几个操作从原理到代码完整拆开讲每一步都给出可以直接抄的写法也把当年踩过的坑一并交代清楚。无论你是刚点亮过LCD1602的新手还是想在项目里用得更顺手的进阶玩家这篇应该都能帮上忙。先说清楚一个概念上的东西LCD1602本质上是一个只能显示ASCII字符和自定义字符的字符型液晶屏。它内部有一块叫DDRAM显示数据存储器的“显存”屏幕显示什么内容取决于DDRAM里存了什么数据。你做的所有操作——光标定位、显示数字、局部滚屏本质上都是在跟这块“显存”打交道。把这一层想明白了后面所有操作都顺理成章。1. 别急着写代码先把LCD1602的显示模型搞明白1.1 为什么LCD1602只能显示字符“显存”到底是什么LCD1602的核心工作原理并不复杂它的控制芯片最常见的是HD44780内部有一块DDRAM容量是80×2也就是每行80个字节、共2行总共160个字节的存储空间。每一个字节对应屏幕上的一个字符位置写入0x41就显示大写A写入0x30就显示数字0。但注意屏幕实际只显示16×2。这就是问题所在DDRAM空间大于可见区域。DDRAM每行有80个字节而物理屏幕只能显示最前面的16列。剩下的64列不是不存在而是“藏在屏幕外面”。这个设计非常关键局部滚屏的核心思想就藏在这里。你可以把DDRAM想象成一卷很长的报纸物理屏幕只是一个16列宽的取景框。默认情况下取景框对准的是报纸最左边的16列。只有当内容写入DDRAM的0x00到0x0F这一小段时才能被看到。这卷“报纸”的另一部分需要靠后面讲的移屏指令或主动刷新窗口才能显示出来。1.2 驱动层的三个基本功写命令、写数据、读忙网上关于LCD1602的驱动代码一搜一大把但说实话我不推荐直接去下载。原因很简单各家的引脚定义不同、主控不同、延时节奏不同下载下来要么点不亮要么显示乱码最后还是得自己改。不如花十分钟把最底层的三个函数写透一劳永逸。首先要搞清楚LCD1602的控制引脚RS寄存器选择。RS0写命令RS1写数据。RW读写选择。RW0写RW1读。E使能引脚下降沿锁存数据。这三根引脚配合8根数据线DB0~DB7组成了最基础的通信时序。写命令和写数据的区别仅仅在于RS引脚电平的不同。核心代码如下#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_write_cmd(unsigned char cmd) { LCD_RS 0; // RS0选择命令寄存器 LCD_RW 0; // RW0写模式 LCD_DATA cmd; LCD_E 1; lcd_delay(10); LCD_E 0; // 下降沿数据被锁存 lcd_delay(50); } void lcd_write_data(unsigned char dat) { LCD_RS 1; // RS1选择数据寄存器 LCD_RW 0; LCD_DATA dat; LCD_E 1; lcd_delay(10); LCD_E 0; lcd_delay(50); }这段代码有几个细节值得展开。第一E引脚的高电平持续时间不能太短否则数据来不及锁存我用lcd_delay(10)保证至少有几个微秒的稳定时间。第二每次写完都要留一点间隔这是给LCD内部的控制器处理数据的时间。网上很多驱动代码点不亮十有八九是这里延时不够。初始化流程也有固定套路按照HD44780手册推荐的顺序来稳得很void lcd_init(void) { lcd_delay(15000); // 上电等待让LCD内部完成复位 lcd_write_cmd(0x38); // 8位数据、2行显示、5x8点阵 lcd_write_cmd(0x08); // 显示关闭 lcd_write_cmd(0x01); // 清屏DDRAM清零地址归零 lcd_write_cmd(0x06); // 写入数据后地址指针自动1 lcd_write_cmd(0x0C); // 显示打开光标关闭 }这里有一个常见的初学者误区只发0x38就以为完成了初始化。实际上0x38只是设置了接口模式和显示行数如果不关显示、不清屏、不设置地址指针移动方向后续操作很容易出现莫名其妙的偏移。理论上还要在读操作前检查忙标志位BF位即DDRAM地址或数据是否在忙状态但很多实际项目图省事直接用延时替代。延时方案在8位模式下、主频不高时完全够用。但如果你的代码跑得很快、操作间隙很短建议还是老老实实加一个lcd_busy()从DB7引脚读忙标志不然数据写快了会丢失。2. 光标定位本质就是写一个地址进去2.1 DDRAM地址和光标位置的映射关系很多人在LCD1602上遇到过这样一个问题想让光标跑到第二行第5列就直接写lcd_write_cmd(0x80 5)结果光标跑到了第一行或者干脆不显示。原因在于他忽略了第二行的DDRAM起始地址不是0x00而是0x40。看一下DDRAM地址和屏幕位置的对应关系屏幕位置DDRAM地址范围第1行第1~16列0x00 ~ 0x0F第2行第1~16列0x40 ~ 0x4FLCD1602的写地址命令格式是最高位固定为1低7位是DDRAM地址。也就是说你发送0x80实际是把“地址指针”设置到0x00即第一行的行首。要让光标定位到第一行第n列命令是0x80 n要定位到第二行第n列命令是0xC0 n因为0x80 0x40 n 0xC0 n。完整的定位函数是这个样子void lcd_set_cursor(unsigned char row, unsigned char col) { unsigned char addr; if (row 0) { addr 0x00 col; } else { addr 0x40 col; } lcd_write_cmd(0x80 | addr); }注意col的范围是0~15row是0或1。很多人的代码在这里埋了隐患col传入了16甚至更大的值地址就越界了。虽然表面上看起来还是“写进去了”但显示位置会跑到不该出现的地方。2.2 三种光标玩法不显示、下划线、闪烁块光标定位之后接下来要考虑光标本身的显示状态。LCD1602的光标控制命令是0x08加上几个标志位组合出来的。核心就三组命令效果0x0C显示开光标关闪烁关0x0E显示开下划线光标开0x0F显示开下划线光标开光标闪烁0x0D显示开光标关但光标位置的字符闪烁这四种模式我在实际项目里全都用过。0x0C是默认状态适合普通显示场景0x0E适合做菜单选项的高亮提示下划线光标会出现在当前字符位置下方0x0F适合做输入框闪烁块提示用户“这里可以输入”0x0D这个模式比较冷门它会让光标位置的字符整体闪烁适合做倒计时的数字提醒效果比下划线醒目。还有一组移动光标的指令不能漏0x10光标左移一位、0x14光标右移一位、0x18整屏左移、0x1C整屏右移。这几个指令和写地址定位不太一样它们不会改变DDRAM里的数据只是改变“取景框”的位置属于硬件层面的移动。用好了可以实现很多特殊效果比如跑马灯式的菜单项轮流展示。2.3 代码示例做一个密码输入框的光标闪烁提示把上面这些内容串起来可以做一个非常实用的组合密码输入框。用户每输入一位数字光标自动右移一位而且光标保持闪烁提示“该输入下一位了”。void display_password_input(void) { unsigned char i; lcd_write_cmd(0x01); // 清屏 lcd_write_cmd(0x0F); // 显示开光标开闪烁开 lcd_set_cursor(0, 0); for (i 0; i 6; i) { lcd_write_data(*); // 用*占位模拟密码输入后的显示 } lcd_set_cursor(0, 0); // 光标回到起始位置开始等待输入 // 之后每收到一次按键就把光标右移一位 }这里有一个我踩过的坑清屏之后直接发0x0F光标默认回到第一行行首但因为是“闪烁块”模式整个屏幕看起来像有6个格子都在闪。这时候一定要先画好占位符、再定位光标到起始位置否则光标闪烁会让用户以为出bug了。光标定位这块掌握了接下来可以处理一个更实际的问题怎么让LCD1602显示出来自变量的数字。切到数字显示这个主题顺便把ADC采样电压显示、温度传感器数值显示这类场景一次性解决干净。3. 显示数字给MCU当“翻译官”的中间层3.1 为什么直接send_char(123)显示不出来有读者可能尝试过直接这样写lcd_write_data(123);然后屏幕上一个字符都没显示或者显示成了一个乱码。原因很简单LCD1602只能显示ASCII字符你要它显示数字3写入DDRAM的值必须是字符3的ASCII码也就是0x33。直接写入123对应的是ASCII码表里第123个字符那是一个你没见过的控制字符或特殊符号屏幕当然显示不对。这里的本质是MCU里存的123是二进制数值而LCD1602认的是ASCII字符编码。一个数值要显示出来中间必须有一个“翻译”过程把数值逐位拆成对应的ASCII码。字符0到9的ASCII码是0x30到0x39正好是连续排列的所以“翻译”的公式极其简单数字 0x30 对应字符的ASCII码。3.2 多位数拆分算法从后往前拆和从前往后拆有了单个数字到字符的转换剩下的就是把一个多位整数拆成一位一位的数字。最经典的方法是循环取模void lcd_print_int(unsigned int num) { unsigned char buf[6]; unsigned char i 0; if (num 0) { lcd_write_data(0); // 0的单独处理很多坑都出在这 return; } while (num 0) { buf[i] (num % 10) 0x30; // 取出最低位转成字符 num / 10; } while (i 0) { lcd_write_data(buf[--i]); // 逆序输出因为刚才拆出来的是反的 } }这个算法里有两个细节必须有名字。第一num 0的情况必须单独处理否则while循环一次都不进函数什么都不打印屏幕上留个空位还没人能看懂为什么。第二因为取模是先从个位开始拆所以要先存到临时数组里再倒序逐个发送顺序才是十进制的正常顺序。如果你希望数字固定占满几位比如显示时间时希望显示“09:05”而不是“9:5”可以在前面补一个‘0’字符。这种需求补空格还是补0要看场景菜单项显示一般补空格右对齐时间显示必须补0。3.3 进阶方案用sprintf做格式化一次搞定负数和小数如果项目里需要显示负数或者浮点数手写拆分就有点力不从心了。负数要先判断符号再对绝对值做拆分浮点数就更麻烦精度控制、四舍五入、小数位对齐全是细节。这时候直接用C标准库的sprintf写起来又快又稳#include stdio.h void lcd_print_num(long num) { char buf[12]; sprintf(buf, %ld, num); lcd_print_string(buf); } void lcd_print_string(char *str) { while (*str) { lcd_write_data(*str); } }注意51单片机用sprintf会有不小的资源开销尤其涉及到浮点时链接出来的代码体积会明显增大。我的建议是整数场景直接用sprintf没问题但浮点场景要慎重。比如要显示一个温度值25.6℃可以先把浮点放大10倍变成256再手动插入小数点这样既不依赖浮点格式化又省代码空间。这招在实际项目里很常用是资源紧张的MCU上的标准做法。其实想显示带小数的数值还有一种更优雅的写法同时兼容整数的通用思路把小数部分和整数部分拆开计算然后拼接字符串。比如// temp 25.6℃要显示成 25.6 int int_part (int)temp; int frac_part (int)((temp - int_part) * 10 0.5); // 0.5是为了四舍五入 sprintf(buf, %d.%d, int_part, frac_part);这里的0.5是个老生常谈的细节很多新手没注意到直接截断会导致显示值比实际值小0.1做温度控制时误差很容易被放大。3.4 代码示例一个通用的lcd_print_num函数把整数、负数、定长补零这些需求揉到一起可以写一个稍微好用点的通用函数void lcd_print_int_fixed(long num, unsigned char width) { char buf[12]; unsigned char len, i; sprintf(buf, %ld, num); len strlen(buf); if (len width) { for (i 0; i width - len; i) { lcd_write_data( ); // 位数不够就用空格补右对齐 } } lcd_print_string(buf); }width参数可以在显示菜单序号、多组数据对齐时派上大用场比每次都手动拼空格省心太多。比如显示榜单前三名名次显示结果第1名空格空格1第12名空格12第123名123右对齐的效果在LCD上看起来整齐得多做UI的时候体验提升明显。4. 局部滚屏把LCD当成可移动的取景框4.1 局部滚屏的两种实现路线滚屏的需求一般来自两类场景一是要显示的文本长度超过16个字符比如一段说明文字、一条长传感器数据二是一行里要滚动显示多条信息像跑马灯一样轮流展示。但LCD1602没有真正意义上的“局部滚屏”指令要做成这个效果有两条路线选。第一是硬件移屏指令也就是前面提到的0x18和0x1C。这两个指令能让整屏内容左移或右移一格。问题在于它是“整屏移动”不是“局部滚屏”——你只想让第二行滚起来第一行也会跟着跑完全没法接受。硬件移屏的最好归宿是制作整屏动画比如开机时屏幕向左滑入做欢迎动画还是很好用的。第二是“缓冲区刷新”方案也是实现局部滚屏最正统的方案。思路是在MCU的RAM里维护一个比屏幕更宽的字符缓冲区比如2行×20列。然后定义屏幕的“可见窗口”每次刷新时把窗口中对应位置的内容重新写入DDRAM。要滚动时只要改变窗口的起始偏移量再整体刷新一遍即可。这个方案的精髓在于LCD1602的DDRAM不是只能写0x00~0x0F这几个地址你可以往0x10~0x4F这些“不可见区域”写数据然后通过移屏指令让它们进到可见窗口。但缓冲区刷新方案连这部分都不用管直接每次刷新可见窗口的16列就行稳、简单、局部可控。4.2 显示缓冲区的设计20列窗口怎么塞进16列屏幕下面以一行20个字符、从左向右滚动为例具体算一算怎么做。这是“局部滚屏”最常见的场景适合放在第二行第一行保持固定显示标题。定义一个缓冲区unsigned char code line2[20] 0123456789ABCDEFGHIJ; // 要滚动显示的20个字符 unsigned char offset 0; // 可见窗口的起始偏移量范围0~4为什么offset上限是4因为20个字符减去16列可见窗口能滑动的最大偏移量是20 - 16 4。超过4右边就会露出空白看起来就露馅了。每次滚动的刷新逻辑void refresh_scroll_line(void) { unsigned char i; lcd_set_cursor(1, 0); // 定位到第二行行首 for (i 0; i 16; i) { lcd_write_data(line2[offset i]); } }每次刷新前只要把offset加1再调用这个刷新函数第二行就像被向左推了一格。如果到末尾让offset重置为0就形成了循环滚动的效果。整个过程看起来是屏幕“吃掉”了最左侧的一个字符右侧新露出一列行云流水。4.3 代码示例两行独立局滚把固定行和滚动行结合起来就能实现一种非常实用的UI模式第一行显示菜单或状态第二行滚动显示详细内容。下面是整合后的代码框架void scroll_display(void) { unsigned char i; static unsigned char offset 0; // 刷新第一行固定内容 lcd_set_cursor(0, 0); lcd_print_string(Sensor Status ); // 固定显示占满16列 // 刷新第二行滚动内容 lcd_set_cursor(1, 0); for (i 0; i 16; i) { lcd_write_data(line2[offset i]); } offset; if (offset 4) { offset 0; // 回到头部循环滚动 } }这个函数每次被调用就完成一帧刷新。我建议在主循环里用一个定时标志来控制调用频率比如每200ms调用一次滚动速度刚好适合人眼阅读。如果放到while里空转刷新太快字符会闪成一片糊的观感极差。还有一点必须注意刷新第二行时只写了16个字符。如果原来的内容比16个长而滚动到末尾时后面没有内容了屏幕最右侧可能会残留上一次显示的旧字符残影。稳妥的做法是每次刷新前先在要刷新的行首写16个空格“清屏”然后再写新的滚动内容。虽然多花一点时间但能避免很多视觉脏数据问题。4.4 滚屏节奏控制定时器 vs 延时函数的取舍说到调用频率这里必须多写几句。新手最喜欢在while(1)里直接套delay来控制滚屏速度比如while (1) { scroll_display(); delay_ms(200); }这个写法在单任务的裸机程序里没问题但你一旦同时要扫描按键、刷新ADC数据、驱动蜂鸣器整个程序就会卡在delay_ms里面其他任务全部停摆。用定时器加标志位的方式明显更靠谱unsigned char flag_scroll 0; // 定时器0中断每200ms置一次标志 void timer0_isr(void) interrupt 1 { TH0 0xFC; TL0 0x18; // 定时器重装值假设12MHz晶振 flag_scroll 1; } void main(void) { // 主循环 while (1) { if (flag_scroll) { flag_scroll 0; scroll_display(); } // 这里还能干别的读按键、刷新LED、处理传感器…… } }定时器方案的另外一个好处是滚动节奏稳定不会因为主循环里其他任务的时长变化而忽快忽慢。滚屏这种东西最怕的就是速度不稳定看着像抽风。5. 调试实录踩过的坑和排查思路速查表5.1 常见问题白屏、乱码、错位、闪烁LCD1602调试过程中会遇到的坑说来说去就那么几类但每一类都能卡住人好几天。我整理了一个速查表基本覆盖了常见情况现象可能原因排查建议屏幕亮但无字符对比度电位器没调好调节Vo引脚的电位器直到出现黑色小方块上电完全白屏初始化时序不对或电源问题检查5V供电、E引脚延时、是否先等15ms字符乱码数据线接错或时序中的建立时间不足用逻辑分析仪对比数据线上电平与手册要求显示位置对不上第二行地址算错确认0xC0才是第二行行首数字显示成乱码直接把数值写进了DDRAM检查是否做了“数值0x30”的ASCII转换局部刷新有残影刷新区域没先清空刷新前先补16个空格滚屏速度忽快忽慢用delay阻塞控制节奏改用定时器标志位白屏不显示这个问题我多说一句。我遇到过很多次最后发现不是代码问题而是对比度电位器拧到了最暗。LCD1602的Vo引脚电压决定屏幕对比度电压太高字迹会变淡甚至消失。第一次上电时先调电位器看有没有小方块出现是最快的判断方法。你可以手动把电位器旋到中间位置再上电大概率能看到那两排小方块。5.2 排查技巧逻辑分析仪和“最小复现法”如果你的程序写了好几屏发现定位和数字显示都不对别着急一行一行盯代码。最快的办法是“最小复现法”写一个只有初始化和字符显示的空程序让屏幕显示一行固定的“ABCDEFGHIJKLMNOP”如果这都显示不对问题一定出在底层驱动如果这行显示正常再逐层往上加功能加到哪一步出问题问题就在哪一步。有条件的话逻辑分析仪是排查LCD1602时序最好的帮手。把RS、RW、E这3根线和DB0~DB7接上去抓一段写命令和写数据的波形和HD44780数据手册里的时序图对比一下。绝大多数“偶发乱码”、“莫名错位”的问题一眼就能看出是建立时间不够或者数据线上有毛刺。5.3 关于“哪里可以下载LCD1602的驱动代码”这件事经常有朋友问哪里能下载LCD1602的驱动代码。我的回答是如果你已经读到这里真的就不需要再去找现成的下载了直接手写一套不香吗LCD1602的驱动就那么几个函数初始化、写命令、写数据、定光标、清屏。加起来不超过100行而且是自己写的引脚、延时、时钟频率全都心里有数出问题也能快速定位。退一步说就算去下载了网上现成的代码也很可能因为引脚定义和你的板子不一样或者初始化顺序和你的晶振频率不匹配点不亮、显示乱码。到时候你还是得读懂别人的代码、自己改费的功夫比从零写一套多得多。驱动代码这种底层的东西越早自己掌握越好。还有一个经验把底层驱动和应用层严格分开。底层只提供几个函数接口比如lcd_init()、lcd_set_cursor()、lcd_put_char()、lcd_print_string()。应用层爱怎么玩就怎么玩显示菜单也好、滚屏也好、显示传感器数据也好一律只调用这些接口不要直接在应用层操作RS、RW这些引脚更不要裸写0x80、0xC0这样的DDRAM地址。这样无论后面怎么改版、换主控应用层代码几乎不用动。5.4 关于光标闪烁和动态刷新的一个小技巧最后分享一个我一直在用的小技巧。当LCD1602同一行里既有静态的标题文字又有动态变化的数字时如果每次都把整行重刷一遍就能看到明显的闪烁。正确的做法是只刷新数字所在的那几个字符位置标题部分不动。具体来说用lcd_set_cursor(1, 10)把光标定位到数字起始列然后只往这个位置写入新的数字字符刷新后立刻用空格把数字后面多出来的字符覆盖掉避免旧数字残留。这样刷新区域最小屏幕观感比整行刷新稳得多。还有一种更稳的做法是利用光标定位实现“精准更新”。比如电压显示固定在第2行第0到第3列更新的时候就只重写这4列的内容。实时性要求高的场合这种“局部更新”策略几乎是必须的否则动态画面闪得人眼睛疼。在实际项目里LCD1602虽然老但胜在便宜、稳定、驱动简单。把光标定位、数字显示、局部滚屏这三套操作吃透已经能覆盖大部分数据展示型项目的需求。后面不管你是要写一个多级菜单、做一个温度监控站还是给仪器加一个滚动状态栏底层逻辑都是今天说的这些。
返回列表