ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

LCD1602显示进阶:DDRAM地址映射、光标定位与局部滚屏实战

2026/9/15 1:58:53 拓冰建站 浏览量
LCD1602显示进阶:DDRAM地址映射、光标定位与局部滚屏实战 我第一次用LCD1602调东西的时候犯过一个特别蠢的错误——第二行数据显示在第14列之后的所有内容全部跑到屏幕右边去了。当时以为是排线接触不良换了一块屏还是这样。查了半晚上才发现不是我代码写错了而是我压根没搞懂DDRAM的地址映射。这其实也是大部分新手玩转LCD1602的第一道坎。很多人能点亮屏幕、显示两行hello world但一旦要做到光标定位、显示数字、局部滚屏这种稍微“骚”一点的操作就开始翻车。原因就一个对LCD1602内部的地址机制、数据流方向理解不到位只是在抄例程。这篇文章把我调LCD1602积累的这些经验一次性倒出来围绕标题里的三个关键操作展开光标怎么精准落位、数字怎么显示不花屏、局部区域怎么滚动而其他位置纹丝不动。内容面向正在学51单片机、STM32或者用Arduino接LCD1602的开发者尤其是那些已经能跑通基础例程、想进一步玩明白的人。1. 地址映射是全部前提0x00到0x67的账要算明白LCD1602内部的核心是一颗HD44780控制器它提供了一块显示数据RAM也就是DDRAM。这块RAM的地址空间从0x00到0x67总共104个字节。但是1602的屏幕只有2行每行16个字符实际显示用到的地址只有80个也就是第一行0x00~0x27第二行0x40~0x67。这一段的第一个坑就在这里第一行地址从0x00开始第二行地址却是从0x40也就是十进制64开始。为什么是64因为HD44780还支持1604、2002、2004等多种屏0x40是给第二行预留的起始地址0x28~0x3F的24个字节属于第二行的“隐藏区域”普通1602上根本显示不出来。1.1 为什么第一行是0x00开头第二行却是0x40很多人的第一反应是“第二行地址应该从0x14开始才对”因为第一行只有16个字符。但HD44780不是这么设计的。它的地址指针是8位宽一个指令字节里既要区分“这是地址设置指令”还是“这是数据写入指令”DDRAM地址在指令中的分布是从bit0到bit6bit7固定为1表示这是设置地址指令。而第二行的起始地址在硬件上就被固定成0x40这是厂商遵循HD44780数据手册做的硬件连线决定不是你想改就能改的。真正要命的是0x00~0x27之间的地址在一行里连续递增但一旦触碰0x28~0x3F屏幕上不会显示任何东西你写入的数据只是默默进了一块看不见的缓冲。如果你在做滚屏或者清屏没注意这个边界数据很容易写到“没有屏幕”的区域里然后显示效果就变得莫名其妙。1.2 1602显示“错位”的大多数原因地址和列号混用我后来排查过不少朋友写的驱动代码发现最典型的错误是直接写0x80 row * 16 col来定位。row是0或1col是0到15公式本身是很多教程给的。但问题是这个公式算出来第二行的地址是0x80 0x10 col也就是0x90开始。0x90对应的DDRAM偏移是0x10不是0x40。把0x10当成第二行起点结果是什么第一行第17个字符的位置在屏幕上根本不存在。数据写到那个位置之后地址自动递增到0x11、0x12……而屏幕已经显示不了了。所以你会看到第二行“好像能显示”但显示位置总是比预期偏甚至第一行数据被第二行数据“串扰”了。正确的地址公式是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); }我这里建议一个习惯不要死记0x80 0x40 col这种写法而是把它拆成“行基地址 列偏移”。第一行的基地址是0x00第二行是0x40屏幕每行只有16列可用。先算行基地址再加列号最后或上0x80指令位。把这个逻辑写进一个函数里后面所有光标定位、显示数字、滚屏操作都调它不直接裸写指令。DDRAM地址表我直接列出来方便贴在屏背面行起始地址地址范围屏幕显示范围第一行0x000x00~0x270x00~0x0F第1~16列第二行0x400x40~0x670x40~0x4F第1~16列说句实在话这张表才是LCD1602最重要的东西。你后面做的所有骚操作本质都是在和各种地址打交道。地址搞明白了光标定位就是一条指令的事。2. 光标定位0x80这条指令背后藏着一个指针LCD1602内部有一个地址指针它指向DDRAM中的某个位置。你写数据就是写到这个指针指向的地址你读数据也是从指针指向的地址读。光标定位的本质就是把地址指针搬到你想要的位置。这里要纠正一个常见误解很多人以为“光标定位”就是控制那个闪烁的下划线光标。其实LCD1602的0x80 | addr指令真正的功能是“设置DDRAM地址指针”跟屏幕上有没有可见的光标是两回事。你设置了地址屏幕上的光标如果开了的话会跟着跑但即使不显示光标地址指针也已经就位了下一条数据写入就会落到新地址上。2.1 光标定位三件套清屏、归位、选地址实际项目里光标定位很少是孤立操作的往深了说是一个“三件套”清屏、归位、选地址。清屏指令0x01会把整个DDRAM清零同时把地址指针归位到0x00。如果你只用0x01不开光标屏幕内容会被清光但地址指针已经回到最左上角。归位指令0x02不清内容只是把地址指针归位屏幕上的内容原地不动。选地址指令就是我上一节写的0x80 | addr它把地址指针搬到具体行列。注意选地址本身不改变DDRAM的内容只是“告诉屏幕接下来要往哪里写”。所以你在屏幕上看到光标跳动只是指针移动的副产物不是DDRAM内容变了。举个例子我要在第二行第5列显示一个数字7lcd_write_cmd(0x80 | (0x40 4)); // 第二行第5列列号从0开始所以是4 lcd_write_data(7); // 写入字符7这里的0x40 4就是第二行第5列的DDRAM地址。注意第5列是物理位置编号从1数到16但内部列号是从0数的所以第5列对应col4。2.2 地址增量模式自动右移是默认的但能改很多教程初始化的时候直接发0x06说“进入模式光标右移”但没人告诉你0x06其实是两个参数的组合地址增量/减量位bit1和显示移位使能位bit0。bit1为1代表每次写完一个字符地址指针自动加1bit1为0则自动减1数据会从右往左写。bit0为1代表“写数据时整个屏幕跟着移动”这个模式非常容易搞出诡异效果——你在第一行第3列写一个字母整个屏幕的显示内容全部左移一格。这不是bug是你把bit0置1了。正常情况下bit0保持0屏幕固定只有地址指针在动。我建议初始化的时候明确设成0x06也就是地址自动加1、屏幕不移动。这个配置是95%以上应用场景的默认选项。如果你在做特殊效果比如从右往左依次填充字符可以临时切换成0x04地址减1用完记得调回来。还有两个光标相关指令值得认识一下0x10是光标左移0x14是光标右移0x1C是整屏右移。这些指令移动的是显示位置或者光标位置不会修改DDRAM内容。我做局部滚屏的时候最初就是被0x18/0x1C这类整屏移位指令坑过后面会详细说。3. 显示数字把0~9换算成ASCII是一场进制游戏LCD1602是字符型液晶它本身不认识“数字7”是什么概念它只认识8位数据总线上传来的字符编码。在HD44780的字符发生器ROM里ASCII码和字形是绑定的字符‘0’对应的编码是0x30‘1’是0x31依次到‘9’是0x39。这就是很多新手第一次显示数字失败的原因直接lcd_write_data(7)结果屏幕上显示的不是7而是一个类似桃心或者小方块的图案因为编码7对应的是字符表里第7个图案不是数字字形。3.1 ASCII映射00x30是一条铁律数字0~9的ASCII码正好是0x30到0x39。也就是说要把数字n0n9显示在屏上只需要计算0 n或者0x30 n。关键来了如果你要显示的数字只有一位这个映射足够用。但如果你要显示一个两位数、三位数比如当前温度是25直接0 25等于0x49对应ASCII字符‘I’屏幕上出来个字母I不是25。所以多位数字必须先拆分再逐个转换。拆分方法用整型除法和取模本质就是“从低位到高位依次取出每一位”void lcd_display_number(unsigned int num) { unsigned char buf[5]; unsigned char i 0; if (num 0) { lcd_write_data(0); return; } while (num 0 i 5) { buf[i] 0 (num % 10); num / 10; } while (i 0) { lcd_write_data(buf[--i]); } }这个函数先把每一位的ASCII码缓存到数组里最后反着输出因为拆分的过程是从个位开始的而屏幕上要从最高位开始显示。buf[--i]这个写法就是逆序输出的关键。3.2 多位数字拆分除法和取模解决不了补零上面这个函数有个短板如果数字是5显示出来就是“5”不是“05”也不是“005”。但很多场景下比如倒计时、计时器、温度显示需要固定位宽个位数也要显示成“05”或者“007”。补零的思路是在拆完所有位之后检查数字长度如果不足目标位数前面补0x30void lcd_display_fixed(unsigned int num, unsigned char width) { unsigned char buf[5]; unsigned char i 0; unsigned char j; if (num 0) { buf[i] 0; } while (num 0 i 5) { buf[i] 0 (num % 10); num / 10; } while (i width) { buf[i] 0; } for (j i; j 0; j--) { lcd_write_data(buf[j - 1]); } }这个函数里第二个while就是在做补零。输入width3、num5拆分得到buf {5}i1补两个‘0’进去变成{5,0,0}逆序输出就是“005”。负数的情况是很多人的盲区。LCD1602没有负号、正号、小数点这些“符号字形”它们都在ASCII表里负号-是0x2D小数点.是0x2E。显示负数的逻辑是先判断符号输出负号再对绝对值做正常的拆位if (temp 0) { lcd_write_data(-); temp -temp; } lcd_display_fixed((unsigned int)temp, 3);3.3 自定义字符往CGRAM里写点阵数字和字母的问题解决了但LCD1602的ASCII字符表里藏着一个大坑没有摄氏度符号°C没有上箭头↑没有下箭头↓也没有希腊字母。如果你要做一个温控面板显示“25.5°C”就卡住了因为字符发生器ROM里根本没有°这个字形。解决办法是往CGRAM里写自定义字符。CGRAM是HD44780提供的一块64字节的可读写RAM用来存放8个自定义字符每个字符由8行点阵组成每行低5位有效对应5x8点阵。往CGRAM写数据的方式和往DDRAM写数据几乎一样只是地址要从0x40开始设置void lcd_create_custom_char(unsigned char index, unsigned char *data) { unsigned char i; lcd_write_cmd(0x40 (index * 8)); for (i 0; i 8; i) { lcd_write_data(data[i]); } lcd_set_cursor(0, 0); // 恢复DDRAM指针防止后续写串 }一个自定义字符index对应CGRAM地址0x40 index * 8。比如我要做一个摄氏度符号的右上角小圆圈unsigned char degree_symbol[8] { 0b00110, 0b01001, 0b01001, 0b00110, 0b00000, 0b00000, 0b00000, 0b00000 };然后把index0这个自定义字符写到显示位置lcd_create_custom_char(0, degree_symbol); lcd_write_data(0); // 输出编号为0的自定义字符这一个操作就能让LCD1602显示“25.5°C”里的那个度符号。注意写完CGRAM之后DDRAM地址指针会被打乱必须重新用光标定位函数设回去否则你接着写的数据会落到不知道哪个角落。这个坑我踩过一次调了一个多小时才发现。4. 局部滚屏当移位指令滚走整屏时我只想动一列局部滚屏是我标题里的“骚操作”重头戏。场景很典型一个温控系统第一行固定显示“Temp: 25.5 C”第二行偶尔要滚动显示一段较长的提示信息。又或者第一行左侧是静态标签右侧是一个数值当数值变化时需要局部重绘。很多人第一反应是直接用LCD1602自带的整屏移位指令0x18左移或者0x1C右移。这两条指令表面上确实能让屏幕上的内容向左或者向右移动但副作用很大整个屏幕的内容一起动DDRAM里的数据根本没变只是显示窗口平移了。4.1 为什么不能直接调用0x18左移指令0x18这类整屏移位指令移动的是“显示窗口”不是“数据本身”。窗口左移一格原来在屏幕最左边的内容就被推出屏幕外屏幕最右边补进来的是DDRAM里对应地址的原始内容可能是垃圾数据也可能是空数据。这在单行滚动场景里或许可以接受但在“局部”需求面前完全没戏。我要的是第二行右侧的信息区滚动第一行纹丝不动第二行左半部分也纹丝不动。用0x18一滚第一行直接跟着跑了这不是局部滚屏这是全屏滚屏。4.2 读DDRAM滚动的第一步是先把要动的内容“抄出来”既然不能用移位指令那就只能自己操作DDRAM里的数据。思路是这样的第一步把要滚动区域内的每个字符都读出来存到一个缓冲数组里。第二步在数组里做循环移位比如左移一位就是buf[0]buf[1]buf[1]buf[2]……最后一位用原本第一位的内容填充。第三步把更新后的数组重新写回对应的DDRAM区域。这个方案的本质是“局部刷新”而不是“窗口平移”。屏幕其他区域的数据完全没被动过只有指定行列范围内的字符被重新写了一遍。而“读DDRAM”这一步是整个过程的技术难点。LCD1602的读操作需要RS1、RW1然后把使能引脚E拉高读取数据总线上的内容。但hd44780的读时序有个特点第一次读返回的不是你想要的数据而是当前DDRAM地址对应的那个字节需要“假读”一次第二次才能真正读出数据。这个很多人不知道直接一读就读出垃圾值。下面是一个稳扎稳打的读函数每次读都重新设置DDRAM地址避免地址指针混乱unsigned char lcd_read_data_at(unsigned char row, unsigned char col) { unsigned char data; lcd_set_cursor(row, col); LCD_RS 1; LCD_RW 1; LCD_EN 1; _nop_(); data LCD_DATA; // 第一次读丢弃 LCD_EN 0; delay_us(10); LCD_EN 1; _nop_(); data LCD_DATA; // 第二次读才是真正数据 LCD_EN 0; LCD_RW 0; return data; }注意读数据时RS和RW都要拉高读完后要把RW拉回低电平不然之后写指令、写数据的时序都会被带乱。4.3 循环搬移从右往左逐个覆盖把DDRAM内容读出来之后在内存里做循环移位很轻松。真正要小心的是回写顺序。比如区域是第2行第10列到第2行第15列要左移一位回写时应该从左往右逐个写还是从右往左回写顺序取决于你是“读-改-写”还是“先缓存再写”。如果你边读边写也就是把第12列的内容搬到第11列然后又把第13列的内容搬到第12列……这种逐字节搬移的方式必须从右往左因为左边的数据被覆盖之前要先把它读出来。但如果像我下面这样先把整个区域的数据快照到数组里再整体回写那顺序就无所谓了unsigned char region_snapshot[16]; void snapshot_region(unsigned char row, unsigned char start_col, unsigned char len) { unsigned char i; for (i 0; i len; i) { region_snapshot[i] lcd_read_data_at(row, start_col i); } } void write_region(unsigned char row, unsigned char start_col, unsigned char len) { unsigned char i; lcd_set_cursor(row, start_col); for (i 0; i len; i) { lcd_write_data(region_snapshot[i]); } } void scroll_region_left(unsigned char row, unsigned char start_col, unsigned char len) { unsigned char tmp; unsigned char i; snapshot_region(row, start_col, len); tmp region_snapshot[0]; for (i 0; i len - 1; i) { region_snapshot[i] region_snapshot[i 1]; } region_snapshot[len - 1] tmp; write_region(row, start_col, len); }用快照法的好处是读写总线上每个字符只需要操作两次相比逐字节边读边写刷新动画更流畅而且不会因为覆盖顺序出错把区域搞乱。代价是占用了少量RAM但对于51单片机来说几个字节的数组完全不是问题。4.4 完整示例温控面板上的局部数字滚屏我把这套逻辑串成一个实际可用的场景第一行显示“Temp: 25.5 C”第二行显示一个滚动提示“Please set target 32 C”。第二行的第1~5列是固定前缀“Please”第7列到第10列是一个数字每次按键修改时这个数字区域向右滚动整数位其他位置不动。滚动函数调用// 第二行从第11列(下标10)开始的5个字符做右移循环 scroll_region_right(1, 10, 5);其中scroll_region_right实现方式和上面的左移版本是对称的void scroll_region_right(unsigned char row, unsigned char start_col, unsigned char len) { unsigned char tmp; unsigned char i; snapshot_region(row, start_col, len); tmp region_snapshot[len - 1]; for (i len - 1; i 0; i--) { region_snapshot[i] region_snapshot[i - 1]; } region_snapshot[0] tmp; write_region(row, start_col, len); }这里有个视觉细节每次滚动完由于整个区域的字符都重写了一遍CPU占用导致中间会有一次肉眼可见的“闪烁”。要减轻闪烁可以加快总线时序、用4线模式时注意延时不要过长。如果要求比较高把读取、移位、回写全部封装进关闭中断的临界区里让滚动过程一气呵成不会被按键中断打断效果会好很多。5. 三个月后回头看滚屏代码里最容易翻车的三个细节说实话局部滚屏这个功能本身不难逻辑通了半小时就能写出来。难的是藏在细节里的坑。我把我自己踩过、帮别人排查过的三个典型问题都列出来这些才是真正省时间的部分。5.1 读操作假读一次RS1 RW1的时序不是想读就读很多人在实现lcd_read_data_at的时候只写了一次读操作结果读回来的全是0xFF或者0x00。原因就是没有做HD44780的“假读”处理。读时序要求第一次读取的数据是无效的第二次读取才是正好想要的字节。这个假读不是玄学是HD44780内部总线机制决定的第一次读操作之前数据总线上可能还残留着上一条指令的数据控制器需要一次读周期来把总线切换到正确的状态第二次读才能稳定输出DDRAM数据。所以遇到读回全0xFF的情况不要急着重插线先检查你的读函数里是不是少了一次“哑读”。5.2 列边界算错5个字符的滚动区域实际只有4格可动又是一个经典的越界问题。写代码时声明“从第11列开始滚动5个字符”如果第11列的下标是10那么第11~15列总共是5个字符最后一列下标是14循环上限应该小于start_col len而不是小于等于。很多人在for循环里多减了一次或少减了一次滚动一次之后区域边缘会出现一个被复制的字符看起来像重影。我排查过的代码里最常见的bug是for (i len - 1; i 0; i--)写成i 0。char类型无符号时i 0永远为真直接死循环。建议循环变量用unsigned char时让循环在0处停止用i 0做判断然后单独处理i0的数据这是写这类滚动代码最稳的方式。5.3 滚屏和中断打架每次搬移都要重置光标最后一个是关于中断的。很多人把滚屏函数放在主循环里但同时又开了定时器中断去扫描按键或者刷新AD采样。中断一旦在滚屏过程中触发如果中断服务程序里也恰好有LCD操作比如显示实时电压两个函数各自修改DDRAM地址指针地址指针就会被打乱滚屏搬移的数据就会写到错误的位置上屏幕内容直接花掉。我的处理方式是在滚屏函数执行期间用EA 0关闭总中断搬移完成后EA 1恢复。如果中断里的LCD操作比较长还可以用一个标志位“忙”来避免冲突中断里发现主循环正在操作LCD就跳过这次刷新。这种“互斥访问”的思维在以后接12864、OLED屏的时候一样适用。现在回头看LCD1602它的指令集其实简单得可怜总共也没几条命令。难的全在“应用层的逻辑组织”上地址映射关系、数据缓存与回写顺序、时间时序配合。调到后面我发现真正值钱的不是那几条指令的写法而是你心里有没有那张0x00到0x67的地址表以及遇事先画数据流图的习惯。最后再分享一个小技巧如果你的项目里LCD1602要被反复刷新比如传感器数值实时变化不要每次都全屏重写只用光标定位函数跳到数值所在的位置然后用“清尾重写”的方式更新那一小段。这样从视觉上和单片机负载上都能比全屏刷新舒服太多。这个习惯我沿用到了后来的OLED和彩屏项目里收益一直很稳定。