简介:面向STM32开发者的LCD1602字符液晶驱动基础库,压缩包内仅含2个文件(1个C源文件与1个头文件),体积约1KB,轻量易集成。C文件封装了LCD1602初始化、字符显示、清屏等核心操作,头文件则提供函数原型与常用控制指令宏定义,开发者只需按实际GPIO接线调用即可快速点亮屏幕并显示文本。库将底层时序与寄存器操作集中封装,具有良好的可移植性与模块化特点,便于在不同STM32型号间复用;对正在学习STM32外设驱动或需要为设备添加状态显示界面的入门及中级开发者尤为实用。目前已有1973人学习下载,压缩包虽小,但代码结构清晰,可作为理解并行接口LCD驱动原理和后续二次开发的参考起点。
1. 这个库是什么:不想折腾串口工具时的LCD1602字符屏驱动
调试带传感器采集的小板子时,最烦的不是逻辑写错,而是数据看不到。串口打印要接USB转TTL模块,电脑上找串口助手,驱动不对还要反复换。后来我习惯在板子上挂一块LCD1602字符屏,把关键变量直接刷到屏幕上——两行、每行16个字符,电压、温度、计数值轮着显示,状态一眼能看穿。这个STM32 LCD1602基本库(.c/.h文件)就是干这个的,它把LCD1602的HD44780控制器操作封装成了一个极简驱动:一个头文件暴露接口,一个源文件实现时序,几根杜邦线接上就能用。
这类库在嵌入式工程里属于“显示终端”基础设施,适合三类人:手头有STM32开发板想快速出显示效果的入门开发者;为毕设或课设加一个人机交互屏幕的学生;以及做产品原型时不需要跑GUI、只想低成本显示状态字符的工程师。这个资源的价值在于它比网上零散的例程更“库化”——有明确的API边界,拿过来能当模块用,不是一团胶水代码。
2. 技术栈与设计:为什么用GPIO模拟时序,库的接口长什么样
2.1 选型背景:LCD1602和STM32的适配方式
LCD1602用的是HD44780控制器,这芯片在字符显示领域相当经典,内部有DDRAM(显示数据存储区)、CGRAM(自定义字符存储区)和CGROM(内置ASCII字库)。它对外提供一组并行总线接口,8根数据线加RS、RW、E三根控制线,一次可以传输一个字节。STM32驱动它通常有两条路线:一是看芯片是否支持FSMC外部存储器接口,把LCD挂到FSMC总线上,用硬件时序写屏幕;二是老老实实用GPIO模拟时序,按HD44780的时序图拉高拉低引脚。
这个基本库选的是第二种,GPIO模拟时序。原因很实际:不是所有STM32型号都有FSMC(像F103C8T6这种小封装就没有完整的FSMC总线),而且LCD1602本身速度慢,软件模拟时序绰绰有余,还省去了FSMC的初始化配置。常见做法是把8根数据线接到同一个GPIO端口的连续引脚上(比如PA0-PA7),这样一次赋值就能完成数据输出,代码干净、时序可控。代价是占用的GPIO比较多,这点在第6章二次开发里会讲怎么用4线模式省引脚。
2.2 库文件构成与API设计
这个库只有两个文件,.h和.c各一个,符合嵌入式驱动模块的常见组织方式。头文件里做三件事:引脚宏定义、函数声明、常用字符宏(比如自定义符号的CGRAM地址)。源文件里则是函数实现。区别于网上随手抄的例程,库的API做了分层——底层是时序操作(写命令、写数据),上层是应用接口(初始化、清屏、定位、打印字符串),上层接口不暴露寄存器细节,使用者不用关心E引脚什么时候拉高拉低。
API清单大致是这样的:
| 函数名 | 作用 | 说明 |
|---|---|---|
LCD_Init() | 初始化屏幕 | 上电延时、功能设置、清屏 |
LCD_WriteCmd(uint8_t cmd) | 写命令 | 清屏、归位、显示开关等 |
LCD_WriteData(uint8_t data) | 写显示数据 | 向当前地址写入字符 |
LCD_SetCursor(uint8_t row, uint8_t col) | 设置光标位置 | row取值0/1,col取值0-15 |
LCD_PrintString(char *str) | 打印字符串 | 自动按当前光标位置输出 |
LCD_Clear() | 清屏 | 清除所有字符并归零 |
初始化时传入的参数主要是GPIO端口的时钟使能配置。用STM32标准外设库(SPL)的写法是调用RCC_APB2PeriphClockCmd,用HAL库则是在MX_GPIO_Init里配置。这个库在头文件里用宏定义了每个引脚对应的端口和Pin号,改接线时只需要改宏,不需要动函数内部逻辑——这点对工程集成很重要,我后面会演示具体怎么改。
3. 核心代码拆解:时序、初始化和上层显示接口
3.1 底层时序:写命令和写数据的信号流程
HD44780的写时序并不复杂,核心是E引脚的一个下降沿将数据锁存。写命令时RS必须为低电平,写数据时RS为高电平;RW在整个操作中拉低,表示写方向。下面是库中最底层的两个函数,也是所有显示功能的地基。
// 写命令:RS=0 表示命令模式,E下降沿锁存 void LCD_WriteCmd(uint8_t cmd) { LCD_RS_LOW(); // 命令模式 LCD_RW_LOW(); // 写模式 LCD_GPIO_PORT->ODR = (LCD_GPIO_PORT->ODR & 0xFF00) | cmd; // 高8位放命令 LCD_E_HIGH(); // E拉高,准备锁存 DelayUs(10); // 数据建立时间 LCD_E_LOW(); // E下降沿,数据被锁存进控制器 DelayUs(20); // 总线回收时间 } // 写数据:RS=1 表示数据模式,把字符写入当前DDRAM地址 void LCD_WriteData(uint8_t data) { LCD_RS_HIGH(); // 数据模式 LCD_RW_LOW(); // 写模式 LCD_GPIO_PORT->ODR = (LCD_GPIO_PORT->ODR & 0xFF00) | data; LCD_E_HIGH(); DelayUs(10); LCD_E_LOW(); DelayUs(20); }这里最关键的两个时间参数是DelayUs(10)和DelayUs(20)。前者的含义是E引脚拉高后,数据必须在E下降沿之前稳定在总线上,HD44780手册给出的建立时间大约几十纳秒,STM32主频72MHz时GPIO翻转一次也很快,但为了保险直接延时10微秒。后者是E引脚恢复低电平后,控制器处理这个字节需要一段“执行时间”——LCD1602的典型指令执行时间是37到120微秒,所以20微秒其实偏紧,驱动里在初始化清屏和归位指令后额外加了毫秒级延时来覆盖最慢的操作。
代码里LCD_GPIO_PORT->ODR是标准外设库对GPIO输出数据寄存器的直接操作,配合宏定义的端口和引脚。写命令和写数据两个函数几乎一样,区别只在RS引脚的电平——这正是理解LCD1602工作的关键:命令和数据走同一条总线,靠RS区分用途。如果RS接反,那么你写的每个字节都会被当作命令处理,屏幕上会出现乱码或者完全没反应。
3.2 初始化序列:时序顺序错一步屏幕就罢工
HD44780的初始化是整个驱动里最容易翻车的环节。它不像很多现代芯片上电后自己准备好,需要主机按严格顺序发一串命令,而且中间还有几个“必须等待”的延时点。很多新手上板子黑屏,不是接线错了,就是初始化序列里少了某次延时。
void LCD_Init(void) { DelayMs(50); // 上电等待,内部控制器建立稳定的工作状态 // 三次0x30,每次间隔等待,确保控制器进入8位模式 LCD_WriteCmd(0x30); DelayMs(5); LCD_WriteCmd(0x30); DelayMs(5); LCD_WriteCmd(0x30); DelayUs(200); // 正式功能设置:8位总线、两行显示、5x8点阵字体 LCD_WriteCmd(0x38); // 显示开关控制:先关显示,避免初始化过程中出现噪点 LCD_WriteCmd(0x08); // 清屏,需要等待较长时间 LCD_WriteCmd(0x01); DelayMs(2); // 写入后地址自动加一,光标右移 LCD_WriteCmd(0x06); // 打开显示,不显示光标不闪烁 LCD_WriteCmd(0x0C); }初始化命令参数的含义值得细说。0x30连续发三次,是HD44780数据手册里规定进入8位模式的握手过程——上电瞬间控制器的总线宽度未知,必须用这个序列让它锁定在8位模式。0x38里的后半字节0x08表示两行显示和5x8点阵字体,如果用的是单行屏就要改成0x30,但1602都是双行的,所以这个库里固定写0x38。0x01清屏命令执行时间超过1.64毫秒,延时2毫秒是保守但必要的做法——跳过这个延时的现象是初始化后第一行字符显示不完整或位置偏移。最后的0x0C控制显示开关,中间4位是显示开、光标关、闪烁关,如果想让光标可见就改成0x0E。
3.3 上层显示接口:光标定位、字符串打印和自定义字符
底层时序封装好后,上层接口就可以写得很简洁。LCD_SetCursor的实现核心是把行列坐标换算成DDRAM地址——第一行的起始地址是0x80,第二行的起始地址是0xC0,0x80是设置地址命令的标志位,后面7位才是真正的地址值。
// 设置光标位置:row=0表示第1行,row=1表示第2行,col取值0到15 void LCD_SetCursor(uint8_t row, uint8_t col) { uint8_t addr; if (row == 0) { addr = 0x80 + col; // 第一行 DDRAM 基地址 0x00 } else { addr = 0xC0 + col; // 第二行 DDRAM 基地址 0x40 } LCD_WriteCmd(addr); } // 打印字符串:按当前光标位置逐字符输出 void LCD_PrintString(const char *str) { while (*str != '\0') { LCD_WriteData(*str); str++; } } // 清屏:命令0x01,DDRAM全部写空格,光标归零 void LCD_Clear(void) { LCD_WriteCmd(0x01); DelayMs(2); }补充说明一下LCD_PrintString的参数设计:它是一个典型的C字符串函数,以\0作为终止符。使用前要确保传入的字符串以\0结尾,LCD_WriteData只接收ASCII码,所以中文字符不能在1602上直接显示——这是硬件字库决定的,库里不会有ne0中文支持,需要中文就得用CGRAM自定义字模拼。
关于自定义字符,库中通常会提供一个写CGRAM的接口:
// 向CGRAM地址写入自定义字符的8行点阵数据 void LCD_CustomChar(uint8_t charAddr, const uint8_t *pattern) { uint8_t i; LCD_WriteCmd(0x40 + charAddr * 8); // CGRAM基地址0x40 for (i = 0; i < 8; i++) { LCD_WriteData(pattern[i]); // 每行5位点阵数据,只取低5位 } }CGRAM的地址范围是0x40到0x7F,可以存放8个自定义字符,每个字符由8行字节组成,每字节的低5位对应一行的5个像素点。使用自定义字符时先调用这个函数写入字模,然后在显示时把charAddr(取0到7)当作字符码传给LCD_WriteData。我自己经常用它画箭头、温度计符号、进度条,比纯ASCII文本直观得多。
4. 让项目跑起来:引脚接线、工程集成与烧录验证
4.1 硬件接线与引脚映射
先把接线表列出来,以最常用的STM32F103C8T6最小系统板为例,这个库默认把数据线接到GPIOA的低8位,控制线接到GPIOA的Pin8到Pin10。实际用的时候按自己板子的走线改宏就行。
| LCD1602引脚 | 功能 | 接STM32引脚/位置 |
|---|---|---|
| VSS | 电源地 | GND |
| VDD | 电源正 | 5V(有板子3.3V也能点亮但对比度弱) |
| VO | 对比度调节 | 电位器中点,两端接VDD和GND |
| RS | 数据/命令选择 | PA8 |
| RW | 读/写选择 | PA9(直接接地也可) |
| E | 使能信号 | PA10 |
| D0-D7 | 并行数据 | PA0-PA7 |
| 背光A/K | 背光正负极 | 5V/GND,串一个限流电阻 |
这里有个容易踩的坑是VO引脚。如果直接把VO接地,屏幕上可能会看到一排“方块”或者完全没字;如果悬空,对比度过高显示成一团黑。标准做法是接一个10K电位器,上电后边看边调,直到字符清晰且背景干净。RW引脚在这个驱动里始终是低电平(只写不读),也可以直接接地省一个GPIO,但库代码仍然保留了对RW引脚的控制,方便以后扩展读忙标志功能。
4.2 把库加入工程:Keil和STM32CubeIDE
工程集成的第一步是把lcd1602.h和lcd1602.c复制到工程目录下。如果用Keil MDK,在Project窗口右键点击目标文件夹,选择Add Existing Files,把.c文件加进去,头文件路径在Options for Target -> C/C++ -> Include Paths里加上。如果用STM32CubeIDE,把文件复制到Src和Inc目录后,IDE会自动扫描到,不用手动添加编译路径,只要确认头文件路径包含Inc目录。
第二步是注意引脚初始化。这个库的引脚宏默认使用标准外设库风格——直接操作寄存器,但Cube生成的工程用的是HAL库。如果你的工程是CubeMX生成的,需要先在MX_GPIO_Init里把用到的引脚配置为推挽输出、最大速度50MHz,然后在库的头文件里把LCD_GPIO_PORT宏指向正确的端口。由于F103的GPIO引脚编号在HAL库和标准外设库中一致,这部分改动通常只涉及端口基地址宏的替换。
4.3 第一个显示Demo:从烧录到看到字符
集成完成后,写一个最简单的验证程序:
#include "stm32f10x.h" #include "lcd1602.h" int main(void) { // 系统时钟初始化,用SystemInit默认的72MHz配置 LCD_Init(); while (1) { // 第一行显示固定标题,第二行显示计数 uint8_t count = 0; char buf[16]; LCD_Clear(); LCD_SetCursor(0, 0); LCD_PrintString("STM32 LCD1602 OK"); while (1) { sprintf(buf, "Count: %d", count++); LCD_SetCursor(1, 0); LCD_PrintString(buf); DelayMs(500); } } }这个Demo里的sprintf需要包含<stdio.h>,在Keil MDK里有时还需要勾选MicroLib,否则printf家族的库函数会占用大量Flash空间。如果不想用sprintf,可以自己写一个整数转字符串的小函数,或者直接用LCD_WriteData配合取模运算把每一位数字算出来再转成ASCII码。上电后第一件事不是看屏幕上有没有字,而是先观察背光是否正常——背光亮说明供电没问题,调VO电位器直到两行点阵的痕迹可见,再检查RS、E引脚的连接顺序。
5. 避坑记录与常见问题排查:白屏、乱码、数字不显示的典型场景
5.1 硬件接线问题:背光亮但是没有字符
上去调试最常见的现象是屏幕的背光亮着,第一行可能出现一排小方块,但不管怎么调用函数都不出字符,或者出现几块不规则的暗影。这类现象的原因几乎可以锁定在VO对比度电压没调好。VO的电压决定了LCD液晶的偏置,电压不对时字符和背景的区分度极低;很多开发板为了省事直接把VO接地,亮起来就是纯背景色。
解决方法是接一个10K电位器,用杜邦线把电位器的两个固定端分别接VCC和GND,滑动端接VO,然后一边上电一边旋转,观察屏幕上逐渐出现两行点阵底纹,这个状态下再写代码就能看到字符了。另外排查一下E引脚是否和GND短路或虚接——E是使能信号,悬空时电平不稳定,屏幕会随机显示乱码内容。
5.2 软件时序问题:第一行有字、第二行全乱或字符错位
有一种很典型的现象是程序烧录后,第一行正常显示几个字符,第二行显示的内容却像“挤”在一起,或者原本应该在第5列出现的字符出现在第8列。这是初始化序列里清屏延时不够导致的——0x01命令在LCD1602内部需要1.64毫秒来清空整个DDRAM并归零,如果紧接着就发送下一条命令,控制器还忙在处理上一条,后续指令就会错乱。
解决方法是把清屏后的延迟从2毫秒加到5毫秒,同时在三番0x30握手指令之间的延时也要满足手册的最小值。我的经验是初始化函数里的延时宁多勿少,因为LCD1602是低速外设,多出来的几个毫秒对系统毫无影响。
5.3 数据处理问题:数字变量显示不出来或显示成乱码
很多人写LCD_PrintString时传入一个int变量,编译不报错但屏幕上要么空白要么乱码。原因很直接:LCD_WriteData只接受ASCII字符,数字3的ASCII码是0x33,直接传int型变量等价于传一个不可打印的控制字符。解决方法是先格式化,用sprintf(buf, "%d", value)把数值转成字符串缓冲区,再把缓冲区传给打印函数。另一种更省Flash的做法是手写一个整数转ASCII函数,逐位取模再用LCD_WriteData('0' + digit)输出。
还有一类特殊问题:自定义字符显示成和预期完全不同的图案。这通常是把CGRAM地址算错了,0x40 + charAddr * 8不能写成0x40 + charAddr——每个自定义字符占8行字节,地址必须跨8个字节步进。写错地址后字模会串到别的字符槽去,显示出来的图形就乱了。
5.4 换板子翻车:寄存器版本库在HAL工程上点不亮
库代码里直接操作GPIO寄存器的写法在标准外设库工程下用得很顺,但把工程切换到CubeMX生成的环境时,经常遇到的状况是编译能过、运行没反应。原因在于CubeMX初始化的时钟树和标准外设库的默认配置不完全相同,而且HAL库的GPIO初始化引脚号、工作模式需要在配置阶段就设置好,库头文件里的引脚宏也可能和目标端口不一致。
解决思路是把寄存器操作替换成HAL库的HAL_GPIO_WritePin封装,在头文件里做一层抽象宏。迁移时优先看两个点:一是引脚的GPIO端口时钟有没有在初始化时打开,二是引脚模式是不是GPIO_MODE_OUTPUT_PP推挽输出、速度是否设了GPIO_SPEED_FREQ_HIGH。这部分改动不影响API接口,所以上层显示代码一行都不用动。
6. 二次开发:从8线模式改成4线模式,省出4个GPIO
6.1 4线模式的核心改动
实际项目里GPIO往往很紧张,8根数据线加上控制线就是11个引脚,很多时候挪不出来。HD44780支持4线模式,只使用D4到D7这四根数据线,每个字节分两次传输——先高4位后低4位,控制线RS和E不变,这样能省出4个GPIO。这个基本库在硬件接线上把D0-D3悬空不接,软件改动集中在写数据和写命令的低层函数上。
// 4线模式下的写命令实现,只用到D4-D7 void LCD_WriteCmd(uint8_t cmd) { LCD_RS_LOW(); // 命令模式 LCD_RW_LOW(); // 写模式 // 先发送高4位 LCD_SetNibble(cmd >> 4); LCD_E_HIGH(); DelayUs(10); LCD_E_LOW(); // 再发送低4位 LCD_SetNibble(cmd & 0x0F); LCD_E_HIGH(); DelayUs(10); LCD_E_LOW(); DelayUs(20); }LCD_SetNibble只往D4-D7对应的位写数据,D0-D3保持原来的电平不变,引脚连接也从原来的8根数据线减到4根。初始化序列也要跟着改:三次0x03握手、一次0x02切换到4线模式,之后的功能设置命令从0x38变成0x28(4线双行5x8字体)。库中预留的头文件宏可以把总线宽度定义为LCD_BUS_4BIT来切换两种模式,避免维护两份代码。我自己在做小板时不光把数据线改成4线,连RW也直接接地,控制线只需要RS和E两个引脚,总共6个GPIO就能驱动一块屏幕。
6.2 快速验证二次改动是否成功
改造后建议先用一个最简程序验证初始化序列没有错:不打印字符串,直接调用LCD_Clear,看第一排方块是否全部消失。如果清屏正常说明底层时序没问题;如果清屏后仍有残留字符,重点检查LCD_SetNibble里D4-D7的位映射是否正确,以及握手序列有没有从0x03开始。数据线只接4根时,高4位命令只会出现在D4-D7上,D0-D3即使悬空也不会干扰总线。
从那以后我每次移植驱动类代码都会强制做一件事:先把所有引脚宏列成表格,和实际接线画一遍,再开始写业务逻辑——这已经是我的习惯。这种极简字符屏用对了场景,比串口工具省心得多,希望这些经验能帮你的LCD1602一次点亮。
本文还有配套的精品资源,点击获取