3. 底层驱动初始化:GPIO模拟并行时序、写命令/写数据函数封装、LCD复位序列、初始化序列配置
好,咱们进入实战环节。上一章我们把硬件连接和时序图都理清了,这一章就要真刀真枪地写代码了。说白了,就是让单片机通过GPIO口,模拟出LCD能看懂的那套并行时序。
我个人习惯,在写任何外设驱动之前,先把最底层的“握手”函数写好。就像两个人要聊天,得先约定好怎么打招呼、怎么传递信息。对于LCD来说,这个“打招呼”就是复位序列,这个“传递信息”就是写命令和写数据。
3.1 GPIO模拟并行时序
为什么不用硬件FSMC或者I8080接口?因为不是所有单片机都有这个外设。我早期做项目时,用了一颗很便宜的Cortex-M0芯片,它就没有并行总线控制器。没办法,只能用GPIO一根一根地“扒拉”电平。
模拟时序的核心,就是严格按照数据手册里的时序图,用代码控制GPIO的高低电平变化。我们以最常用的8位并行接口为例,控制线有:CS(片选)、RS(命令/数据选择)、WR(写使能)、RD(读使能),数据线是DB0-DB7。
写一个字节的流程,我总结为四步:
- 拉低CS:选中LCD,告诉它“我要跟你说话了”。
- 设置RS:如果是写命令,RS=0;如果是写数据,RS=1。
- 数据线赋值:把要发送的字节放到GPIO数据口上。
- 产生写脉冲:拉低WR,再拉高WR。LCD会在WR的上升沿锁存数据。
核心要点: 写脉冲的宽度(WR低电平时间)必须满足LCD的最小要求。我见过有人把延时写得太短,导致显示花屏。查手册,一般要求大于20ns,对于低速单片机来说,一条NOP指令就够了。
下面是我常用的GPIO模拟写时序代码,以STM32为例:
// 定义控制线GPIO
#define LCD_CS_LOW() HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET)
#define LCD_CS_HIGH() HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET)
#define LCD_RS_LOW() HAL_GPIO_WritePin(LCD_RS_GPIO_Port, LCD_RS_Pin, GPIO_PIN_RESET)
#define LCD_RS_HIGH() HAL_GPIO_WritePin(LCD_RS_GPIO_Port, LCD_RS_Pin, GPIO_PIN_SET)
#define LCD_WR_LOW() HAL_GPIO_WritePin(LCD_WR_GPIO_Port, LCD_WR_Pin, GPIO_PIN_RESET)
#define LCD_WR_HIGH() HAL_GPIO_WritePin(LCD_WR_GPIO_Port, LCD_WR_Pin, GPIO_PIN_SET)
// 数据口宏定义(假设使用GPIOA的低8位)
#define LCD_DATA_PORT GPIOA->ODR
#define LCD_DATA_MASK 0x00FF
// 写命令函数
void LCD_WriteCmd(uint8_t cmd)
{
LCD_CS_LOW(); // 选中LCD
LCD_RS_LOW(); // 命令模式
LCD_DATA_PORT = (LCD_DATA_PORT & ~LCD_DATA_MASK) | cmd; // 数据线赋值
LCD_WR_LOW(); // 拉低WR
__NOP(); // 短延时,满足建立时间
__NOP();
LCD_WR_HIGH(); // 拉高WR,数据被锁存
LCD_CS_HIGH(); // 释放片选
}
// 写数据函数
void LCD_WriteData(uint8_t data)
{
LCD_CS_LOW();
LCD_RS_HIGH(); // 数据模式
LCD_DATA_PORT = (LCD_DATA_PORT & ~LCD_DATA_MASK) | data;
LCD_WR_LOW();
__NOP();
__NOP();
LCD_WR_HIGH();
LCD_CS_HIGH();
}
小技巧: 如果你用的单片机速度很快,比如主频200MHz以上,那__NOP()可能不够用。我建议用示波器实测WR引脚的波形,确保低电平时间在50ns以上。我曾经在一个高速项目里,就因为延时不够,导致数据写入不可靠,折腾了两天才找到原因。
3.2 写命令/写数据函数封装
有了最底层的LCD_WriteCmd和LCD_WriteData,我们还需要封装一些更高级的函数。为什么?因为很多LCD命令是带参数的,比如设置显示窗口、设置光标位置等。
我习惯封装成下面这样:
// 带参数的写命令封装
void LCD_WriteCmdParam(uint8_t cmd, uint8_t *params, uint8_t len)
{
LCD_WriteCmd(cmd);
for(uint8_t i = 0; i < len; i++)
{
LCD_WriteData(params[i]);
}
}
// 写一个16位数据(用于RGB565颜色值)
void LCD_WriteData16(uint16_t data)
{
LCD_WriteData(data >> 8); // 先写高字节
LCD_WriteData(data & 0xFF); // 再写低字节
}
你想想看,如果没有这些封装,每次设置坐标都要写好几行代码,既容易出错,又不好维护。封装之后,调用起来就清爽多了。
| 函数名 | 功能 | 适用场景 |
|---|---|---|
LCD_WriteCmd |
发送单字节命令 | 无参数命令,如Sleep Out |
LCD_WriteData |
发送单字节数据 | 设置寄存器参数 |
LCD_WriteData16 |
发送双字节数据 | 写入像素颜色值 |
LCD_WriteCmdParam |
发送带参数的命令 | 设置显示窗口、伽马曲线等 |
3.3 LCD复位序列
LCD上电后,内部状态是不确定的。必须给它一个复位信号,让它回到一个已知的初始状态。复位序列通常有两种方式:硬件复位和软件复位。
硬件复位:通过拉低LCD的RESX引脚,保持一段时间,再拉高。我一般保持10ms以上,有些LCD要求更久,建议看手册。
软件复位:发送命令0x01(Software Reset)。但要注意,软件复位后,也需要等待一段时间才能进行后续操作。
我的习惯是两者都用:先做硬件复位,再做软件复位,双重保险。嗯,这里要注意:复位之后,不要立即发送初始化命令,要等LCD内部振荡器稳定。一般等待120ms比较稳妥。
void LCD_Reset(void)
{
// 硬件复位
HAL_GPIO_WritePin(LCD_RES_GPIO_Port, LCD_RES_Pin, GPIO_PIN_RESET); // 拉低复位引脚
HAL_Delay(10); // 保持10ms
HAL_GPIO_WritePin(LCD_RES_GPIO_Port, LCD_RES_Pin, GPIO_PIN_SET); // 拉高复位引脚
HAL_Delay(120); // 等待内部稳定,这个延时很重要
// 软件复位
LCD_WriteCmd(0x01); // Software Reset
HAL_Delay(120); // 再次等待
}
避坑指南: 我曾经在一个项目中,为了省电,把复位后的延时从120ms改成了50ms。结果部分LCD在低温环境下无法正常显示。后来查手册才发现,LCD内部DC-DC转换器的启动时间在低温下会变长。所以,复位延时宁长勿短,特别是产品要过温循测试的。
3.4 初始化序列配置
复位之后,LCD还处于“睡眠”状态。我们需要通过一系列命令,把它“唤醒”并配置好工作模式。最核心的两个命令就是:Sleep Out (0x11) 和 Display On (0x29)。
初始化序列的顺序是有讲究的。我一般按照这个流程来:
- 复位:让LCD进入初始状态。
- 退出睡眠:发送Sleep Out命令。
- 等待:退出睡眠需要时间,一般等待120ms。
- 配置参数:设置显示方向、颜色格式、扫描方式等。
- 打开显示:发送Display On命令。
下面是一个典型的ILI9341初始化序列(部分):
void LCD_Init(void)
{
// 1. 复位
LCD_Reset();
// 2. 退出睡眠模式
LCD_WriteCmd(0x11); // Sleep Out
HAL_Delay(120); // 必须等待,否则后续命令可能无效
// 3. 配置参数
// 设置像素格式为16位色
LCD_WriteCmdParam(0x3A, (uint8_t[]){0x55}, 1); // 0x55 = 16位色(RGB565)
// 设置内存访问控制(根据硬件接线调整方向)
LCD_WriteCmdParam(0x36, (uint8_t[]){0x48}, 1); // MX=1, MV=1, 竖屏模式
// 设置显示窗口(全屏)
LCD_WriteCmdParam(0x2A, (uint8_t[]){0x00, 0x00, 0x01, 0x3F}, 4); // 列地址范围
LCD_WriteCmdParam(0x2B, (uint8_t[]){0x00, 0x00, 0x01, 0xDF}, 4); // 行地址范围
// 4. 打开显示
LCD_WriteCmd(0x29); // Display On
HAL_Delay(50);
}
关键点: Sleep Out和Display On之间,一定要插入足够的延时。我见过有人把这两个命令连着发,结果屏幕死活不亮。为什么?因为LCD内部的电源管理模块需要时间建立稳定的电压。120ms是个比较安全的数值。
为了让你更直观地理解整个初始化流程,我画了一张流程图:
看到这个流程图,你应该能理解整个初始化的脉络了。说白了,就是“上电 -> 复位 -> 等待 -> 唤醒 -> 配置 -> 点亮”这六个步骤。每一步都有它的意义,跳不得,也急不得。
个人经验: 我建议把初始化序列做成一个结构体数组,把命令、参数、延时都存进去。这样调试的时候,可以很方便地增删改查某个命令。特别是当你需要适配不同型号的LCD时,这种结构化的方式能省不少事。
好了,这一章的内容就到这里。GPIO模拟时序、函数封装、复位序列、初始化配置,这些都是驱动开发的基本功。把这些基础打牢了,后面画点、画线、画圆,甚至滚动显示,都会变得水到渠成。