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:寄存器选择。RS=0写命令,RS=1写数据。
- RW:读写选择。RW=0写,RW=1读。
- 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; // RS=0,选择命令寄存器 LCD_RW = 0; // RW=0,写模式 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; // RS=1,选择数据寄存器 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 ~ 0x4F |
LCD1602的写地址命令格式是:最高位固定为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~15,row是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虽然老,但胜在便宜、稳定、驱动简单。把光标定位、数字显示、局部滚屏这三套操作吃透,已经能覆盖大部分数据展示型项目的需求。后面不管你是要写一个多级菜单、做一个温度监控站,还是给仪器加一个滚动状态栏,底层逻辑都是今天说的这些。