基于正点原子潘多拉开发板(STM32L475),TFTLCD屏幕驱动(ST7789V)编写实战经验分享
分享一下驱动IC为st7789v系列的TFT-LCD屏的显示原理和使用的经验(使用的硬件平台是正点原子的潘多拉STM32L475。屏幕大小为240*240)。st7789v、ILI9341等都是市面上常见的液晶显示屏驱动ic,使用上大同小异。
本人的技术有限,如果有错误的地方欢迎指出,非常感谢。
什么是LCD屏?
LCD,也就是液晶显示屏,它不像OLED屏那样可以自发光,而是通过改变内部晶体结构的排布来让光源透过时呈现不一样的颜色,所以我们需要在驱动引脚当中给它提供一个背光(对应下面原理图中的LCD_PWR引脚)。由于它可以显示多种颜色,所以它每个像素点携带的数据也更多(本文使用RGB565真彩色,也就是16位数据),相对于单种颜色的显示屏来说更加消耗mcu的性能。
本文观看的前提要求是掌握了SPI协议,在本文不会对SPI协议进行过多介绍,下面我们通过HAL库来实现LCD屏的显示。
st7789v这款ic可以使用8080并口、3线SPI、4线SPI等协议,本文使用的是4线SPI协议、所用引脚对应驱动IC手册当中的下图。
CSX也就是片选信号线CS(有的也叫NSS),WRX也就是WR引脚(或DC),决定了发送的是数据还是命令,DCX就是SPI协议中的SCL时钟线,SDA也就是SPI协议中的MOSI输出线。
LCD 的底层通信引脚初始化
首先我们要根据原理图进行引脚的配置
引脚的配置参考stm32f10xx参考手册
这里我们需要配置引脚的复用模式,使用SPI3(具体看自己用的开发板)
SPI3初始化代码
基于HAL库的SPI3初始化代码,由于我们只需要对显示屏写入数据,故不需要实现读取函数。如果想要读取显示屏中某个点的颜色数据,也可以参考st7789手册当中的读内存命令。
在lcd.h中定义了要用的引脚和时钟初始化
// 定义使用的引脚
#define LCD_GPIO_PORT GPIOB
#define LCD_SCK GPIO_PIN_3 //时钟线
#define LCD_WR GPIO_PIN_4 //写数据/命令
#define LCD_SDA GPIO_PIN_5 //数据线
#define LCD_RES GPIO_PIN_6 //复位线
#define LCD_PWR GPIO_PIN_7 //背光线
// 时钟使能
#define LCD_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0)
#define LCD_CS_PORT GPIOD
#define LCD_CS GPIO_PIN_7 //片选信号线
// CS引脚的时钟使能
#define LCD_CS_CLK_ENABLE() do{ __HAL_RCC_GPIOD_CLK_ENABLE(); }while(0)
spi3.c文件:
#include "spi3.h"
#include "lcd.h" // lcd屏幕驱动,使用其中的引脚定义
SPI_HandleTypeDef SPI3_Handler;
// SPI3的初始化,主机模式
void SPI3_Init(void)
{
SPI3_Handler.Instance = SPI3;
SPI3_Handler.Init.Mode = SPI_MODE_MASTER; // 主机模式
SPI3_Handler.Init.CLKPhase = SPI_PHASE_2EDGE; // 第一个边沿进行发送,第二个边沿进行采样
SPI3_Handler.Init.CLKPolarity = SPI_POLARITY_HIGH; // SCK空闲高电平
SPI3_Handler.Init.DataSize = SPI_DATASIZE_8BIT; // 8位数据模式
SPI3_Handler.Init.Direction = SPI_DIRECTION_2LINES; // MOSI/MISO都使用
SPI3_Handler.Init.FirstBit = SPI_FIRSTBIT_MSB; // 数据传输高位先行
SPI3_Handler.Init.NSS = SPI_NSS_SOFT; // 软件片选
SPI3_Handler.Init.TIMode = SPI_TIMODE_DISABLE; // 不需要TI模式(TI配合硬件片选,自动输出片选信号)
SPI3_Handler.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; // 传输的波特率分频,选最少的分频提高速度
SPI3_Handler.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; // 不进行CRC校验
SPI3_Handler.Init.CRCPolynomial = 7; //随便,不使用校验
if(HAL_SPI_Init(&SPI3_Handler) != HAL_OK) // 初始化SPI3
{
while(1); // 初始化失败进入死循环
}
__HAL_SPI_ENABLE(&SPI3_Handler); // 使能SPI3
}
// SPI3的GPIO初始化回调,使能时钟和GPIO
void HAL_SPI_MspInit(SPI_HandleTypeDef *hspi)
{
GPIO_InitTypeDef GPIO_Handle;
if(hspi->Instance == SPI3)
{
__HAL_RCC_SPI3_CLK_ENABLE(); // 使能SPI3时钟
LCD_GPIO_CLK_ENABLE(); // LCD使用的GPIO的时钟初始化
GPIO_Handle.Pin = LCD_SCK | LCD_SDA;
GPIO_Handle.Mode = GPIO_MODE_AF_PP; // 复用推挽输出
GPIO_Handle.Pull = GPIO_PULLDOWN; // 上拉
GPIO_Handle.Speed = GPIO_SPEED_FREQ_VERY_HIGH; // 最高速,80Mhz
GPIO_Handle.Alternate = GPIO_AF6_SPI3; // 引脚复用功能6
HAL_GPIO_Init(LCD_GPIO_PORT,&GPIO_Handle); // 初始化引脚
}
}
// SPI3发送字节数据,Data为要发的数据的地址(数组),Size为发送数量
void SPI3_Transmit_Byte(uint8_t *Data,uint8_t Size)
{
HAL_SPI_Transmit(&SPI3_Handler,Data,Size,1000);
}
LCD的功能引脚初始化
首先我在lcd.h中定义了对引脚操作的宏函数
/* 引脚设置函数 */
// 写数据/命令
#define LCD_WR_DATA() do{ HAL_GPIO_WritePin(LCD_GPIO_PORT,LCD_WR,GPIO_PIN_SET); }while(0)
#define LCD_WR_CMD() do{ HAL_GPIO_WritePin(LCD_GPIO_PORT,LCD_WR,GPIO_PIN_RESET); }while(0)
// 使能和失能背光
#define LCD_PWR_SET() do{ HAL_GPIO_WritePin(LCD_GPIO_PORT,LCD_PWR,GPIO_PIN_SET); }while(0)
#define LCD_PWR_RESET() do{ HAL_GPIO_WritePin(LCD_GPIO_PORT,LCD_PWR,GPIO_PIN_RESET); }while(0)
// 复位设置
#define LCD_RES_SET() do{ HAL_GPIO_WritePin(LCD_GPIO_PORT,LCD_RES,GPIO_PIN_SET); }while(0)
#define LCD_RES_RESET() do{ HAL_GPIO_WritePin(LCD_GPIO_PORT,LCD_RES,GPIO_PIN_RESET); }while(0)
// 片选使能和失能
#define LCD_CS_SET() do{ HAL_GPIO_WritePin(LCD_CS_PORT,LCD_CS,GPIO_PIN_SET); }while(0)
#define LCD_CS_RESET() do{ HAL_GPIO_WritePin(LCD_CS_PORT,LCD_CS,GPIO_PIN_RESET); }while(0)
lcd.c中对剩余的功能引脚初始化并设置初始的引脚电平
// LCD的接口初始化
static void LCD_GPIO_Init(void)
{
GPIO_InitTypeDef LCD_GPIO_Init;
// 初始化引脚时钟
LCD_GPIO_CLK_ENABLE();
LCD_CS_CLK_ENABLE();
// 初始化剩余的PWR\RES\WR
LCD_GPIO_Init.Pin = LCD_WR | LCD_RES | LCD_PWR;
LCD_GPIO_Init.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出
LCD_GPIO_Init.Pull = GPIO_PULLUP; // 默认上拉 // 输出无所谓
LCD_GPIO_Init.Speed = GPIO_SPEED_FREQ_VERY_HIGH; // 高速
HAL_GPIO_Init(LCD_GPIO_PORT,&LCD_GPIO_Init);
LCD_WR_CMD(); // 先拉低引脚
LCD_RES_RESET();
LCD_PWR_RESET();
// 初始化CS片选引脚
LCD_GPIO_Init.Pin = LCD_CS;
HAL_GPIO_Init(LCD_CS_PORT,&LCD_GPIO_Init);
LCD_CS_RESET(); // 拉低CS,选中从机
LCD_PWR_RESET(); // 先关闭背光
LCD_RES_RESET(); // 先拉低进行复位
delay_ms(120);
LCD_RES_SET(); // 拉高结束复位
// 初始化SPI3
SPI3_Init();
}
LCD内部DRAM
由上图我们可以看出,我们并不是对显示屏中的每个像素点直接进行写入数据操作,而是通过一个RAM进行内存映射,当我们通过对列地址设置和行地址的设置之后,发送一个内存写入命令(0x2C)就能开始写入显示的数据(RGB565,共16位颜色数据),直到设置的行列地址范围被写满。
写入数据时不需要操作写入的地址,当我们写入一个RGB数据后,列/行地址会自动向后递增(具体的递增方式手册中可以通过命令配置),默认使用的递增方式是列地址递增,当一行被写完之后,列地址指针才会移动到下一行的列起始。
注意: 240*240的LCD屏幕的显存并不是对应的240*240大小,而是240*320
内存映射图
通过改变MADCTR寄存器(0x36)的MX和MY位,可以配置不同的扫描方向,默认上电为从上到下,从左到右进行扫描。
命令写入模式时序
写周期是指主机通过接口向显示器写入信息(命令/数据)。在原理图中的WR引脚控制SDA引脚发送的是数据还是命令,有些原理图上这个引脚也叫做DC(数据\命令)。
在4线SPI接口中,数据包只包含要传输的数据,具体要发送的数据是命令还是数据,根据WR(或者DC)引脚的高低电平决定,数据的发送为高位先行(MSB),当我们将片选线CS(有的也叫NSS)拉低时,表示数据的传输可以开始。
根据上图我们可以总结出,如果要对显示屏进行操作,就需要先发送一个命令字节,接着再发送寄存器所要写入的数据即可,具体需要发多少数据参考手册。
LCD屏幕初始化序列
我们进行数据/命令的发送需要通过SPI的SDA和SCL引脚进行,接着我们要配置发送16位数据(用于发送颜色数据)和发送8位数据/命令的函数,用于后续对LCD屏的初始化和数据发送。
// LCD批量发送数据函数
static void LCD_SPI_Send(uint8_t *data, uint32_t size)
{
uint32_t i;
uint32_t delta;
//一次最多65536个uint8_t类型数据,超过分多次发送
delta = size/0xFFFF;
// size小于0xFFFF时,相当于执行一次
for(i = 0; i<=delta; i++)
{
if( i==delta ) /* 发送最后一帧数据 */
SPI3_Transmit_Byte(&data[i*0xFFFF], size%0xFFFF);
else /* 超长数据一次发送0xFFFF字节数据 */
SPI3_Transmit_Byte(&data[i*0xFFFF], 0xFFFF);
}
}
// LCD使用的发送命令函数
static void LCD_SendCmd(uint8_t cmd)
{
LCD_WR_CMD(); // 写命令
LCD_SPI_Send(&cmd,1);
}
// LCD使用的发送数据函数
static void LCD_SendData(uint8_t data)
{
LCD_WR_DATA(); //写数据
LCD_SPI_Send(&data,1);
}
// 发送半字16位数据函数
static void LCD_SendHalfByte(uint16_t HalfByte)
{
uint8_t arr[2] = {0};
arr[0] = HalfByte>>8;
arr[1] = HalfByte;
LCD_SPI_Send(arr,2);
}
无论你是LCD屏幕还是OLED屏幕,在上电之后它并不能直接的使用,需要根据厂家提供的初始化序列对屏幕的显示扫描方式、电压阈值等进行配置,才能让屏幕正常的与单片机进行交互和显示。
下面是厂家提供的初始化序列:
// LCD初始化序列
void LCD_Init(void)
{
// 执行初始化并复位
LCD_GPIO_Init();
delay_ms(120); // 延迟120ms
LCD_SendCmd(0x11); // 关闭睡眠模式
delay_ms(120); //ms
LCD_SendCmd(0x36); // 配置扫描模式
LCD_SendData(0x00); // 从上到下从左到右扫描,RGB模式
LCD_SendCmd(0x3A); // 配置接口像素
LCD_SendData(0x55); // 65K-RGB 16位
LCD_SendCmd(0xB2); // 手册中的默认配置的上电后复位顺序
LCD_SendData(0x0C);
LCD_SendData(0x0C);
LCD_SendData(0x00);
LCD_SendData(0x33);
LCD_SendData(0x33);
LCD_SendCmd(0xB7); // 电压门控
LCD_SendData(0x72); //VGH=14.97V, VGL=-8.23V
LCD_SendCmd(0xBB); // VCOM
LCD_SendData(0x3D); // 1.625V
LCD_SendCmd(0xC0); // 控制命令36H中的配置
LCD_SendData(0x2C); // 默认上电配置
LCD_SendCmd(0xC2); // VDV和VRH命令使能
LCD_SendData(0x01); // 默认配置
LCD_SendCmd(0xC3); //GVDD
LCD_SendData(0x19);
LCD_SendCmd(0xC4);
LCD_SendData(0x20);
LCD_SendCmd(0xC6); // 帧率控制
LCD_SendData(0x0F); // 60帧
LCD_SendCmd(0xD0); // 电源控制
LCD_SendData(0xA4);
LCD_SendData(0xA1);
LCD_SendCmd(0xE0); // 正电压伽玛控制
LCD_SendData(0xD0);
LCD_SendData(0x04);
LCD_SendData(0x0D);
LCD_SendData(0x11);
LCD_SendData(0x13);
LCD_SendData(0x2B);
LCD_SendData(0x3F);
LCD_SendData(0x54);
LCD_SendData(0x4C);
LCD_SendData(0x18);
LCD_SendData(0x0D);
LCD_SendData(0x0B);
LCD_SendData(0x1F);
LCD_SendData(0x23);
LCD_SendCmd(0xE1); //负电压伽玛控制
LCD_SendData(0xD0);
LCD_SendData(0x04);
LCD_SendData(0x0C);
LCD_SendData(0x11);
LCD_SendData(0x13);
LCD_SendData(0x2C);
LCD_SendData(0x3F);
LCD_SendData(0x44);
LCD_SendData(0x51);
LCD_SendData(0x2F);
LCD_SendData(0x1F);
LCD_SendData(0x1F);
LCD_SendData(0x20);
LCD_SendData(0x23);
LCD_SendCmd(0x21); // 开启显示反转(0x20关闭)
LCD_SendCmd(0x29); // 开启显示
// 打开背光
LCD_PWR_ON();
// 以默认颜色清除屏幕
LCD_ClearALL();
}
写入数据到RAM
对屏幕进行初始化之后我们应该考虑的一件事情就是,怎么让屏幕显示我们想要的颜色数据?这时候就需要用到三条至关重要的指令了,分别如下:
0x2A: 列地址设置
0x2B: 行地址设置
0x2C: 内存写入
列地址设置的命令格式: 1字节命令+4字节设置参数。这4个字节参数分别为列起始地址高8位,列起始地址低8位,列终止地址高8位,列终止地址低8位。
同理,行地址也是如此设置,不需要过多赘述,但有需要注意的点,根据前面的内存映射图,我们对240*240的屏幕进行写入时,RAM的行地址范围并不止240,而是320(重要,如果使用更大的屏幕需要关注它的RAM地址范围),所以我们要在程序中进行相应的限制,防止超过范围。
首先设置WR(DC)引脚为低电平电平发送命令,设置行列地址,然后发送内存写入命令(0x2C),最后置WR(DC)引脚为高电平,表示接下来要发送的是数据,接着写入的数据会直接被写入RAM中,当我们写入完成后,对应的范围内就可以显示出想要的颜色。
设置写入范围的代码如下:
我们对三个写入命令在lcd.h中进行宏定义,并且定义了屏幕的宽高(限制设置的x,y范围)
// 定义屏幕的大小
#define Width 240 // 宽度,x轴
#define Height 240 // 高度,y轴
/******命令表*****/
#define SET_COL (0x2A) // 设置列地址
#define SET_ROW (0x2B) // 设置行地址
#define SET_RAM (0x2C) // 写入RAM
/****************/
接着调用这几个命令进行列行地址的设置,并开启内存写入
/*
函数功能: 设置写入ram的范围
参数: xs:x轴起始 xe:x轴终止 (0~239)
ys:y轴起始 ye:y轴终止 (0~239)
返回值: 无
*/
void LCD_SetDisplayRange(uint16_t xs,uint16_t xe,uint16_t ys,uint16_t ye)
{
// 对输入的范围进行限制,如果超出范围不允许写入(start=end表示写入一行/列)
if(xs > (Width-1) || xe > (Width-1) || xe < xs) return;
if(ys > (Height-1) || ye > (Height-1) || ye < ys) return;
// 设置列地址
LCD_SendCmd(SET_COL);
LCD_SendData(xs>>8);
LCD_SendData(xs);
LCD_SendData(xe>>8);
LCD_SendData(xe);
// 设置行地址
LCD_SendCmd(SET_ROW);
LCD_SendData(ys>>8);
LCD_SendData(ys);
LCD_SendData(ye>>8);
LCD_SendData(ye);
// 写入内存
LCD_SendCmd(SET_RAM);
}
写入的地址控制
以下内容摘自手册,略有修改
地址计数器设置用于写入和读取显示数据 RAM 的地址。 数据按像素写入 驱动IC 的 RAM 矩阵。根据数据格式收集一个像素或两个像素的数据(RGB 6-6-6 位 )。这里我们使用16位数据模式(RGB-5-6-5),一旦像素数据信息输入完毕,就会激活 RAM 的 "写入访问"。RAM 的位置由地址指针寻址。
地址范围为 X=0 至 X=239 (Efh),Y=0 至 Y=319 (13Fh)。这些范围之外的地址是不允许的。在写入 RAM 之前,必须定义 一个要写入的窗口。窗口可通过指定起始地址的命令寄存器 XS、YS 和指定终止地址的命令寄存器 XE、YE 进行编程。
例如,将写入整个显示内容,窗口由以下值定义:XS=0 (0h) YS=0 (0h) 和 XE=239 (Efh), YE=319 (13Fh)。 也就是先确定LCD屏幕显示的X轴起始和X终止,Y轴起始和Y轴终止。
在垂直寻址模式(MV=1)下,Y 地址在每个字节后递增,在最后一个 Y 地址(Y=YE)后,Y 绕到 YS, X 递增以寻址下一列。在水平寻址模式(V=0)下,X 地址在每个字节后递增,在最后一个 X 地址(X=XE) 后,X 绕到 XS,Y 递增以寻址下一行。在最后一个地址(X=XE 和 Y=YE)之后,地址指针绕到地址(X=XS 和 Y=YS)。
本文中我们使用水平寻址的模式,如果使用不同的寻址模式,需要重写驱动的代码中数据,可以自行尝试,不做过多解释。
为了灵活处理各种显示结构,"CASET、RASET 和 MADCTL "命令定义了 MX 和 MY 标志,允许镜像 X 地址和 Y 地址。所有标志的组合都是允许的。ST7789V驱动IC手册第 8.12 节介绍了写入显示 RAM 的可用组合。当 MX、MY 和 MV 发生变化时,数据将被重新写入显示 RAM。
LCD指定位置画点
当我们完成了上面步骤的代码,在主函数中调用LCD_Init()函数并烧录,显示屏就会出现花屏,这并不是出现了故障,这表示着初始化是正常的,LCD内部电荷泵能够正常的供电,只是我们还没有给屏幕填充颜色。
接着我们就可以开始写画点函数!!!
首先我们在前面设置了显示模式位RGB-565,也就是一个像素点包含了16位的数据,所以我们可以先确定一个RGB565的颜色表(百度搜索RGB565二进制数据)
定义在lcd.h中如下,更多颜色可自行添加
/****RGB565****/
// 定义颜色类型(数值从小到大)
typedef enum{
BLACK = 0x0000,
BLUE = 0x001F,
GREEN = 0x0400,
GRAY = 0x8410,
LBLUE = 0xAEDC,
RED = 0xF800,
YELLOW = 0xFFE0,
WHITE = 0xFFFF
}rgb565_t;
/**************/
/*******设置默认清屏颜色*********/
#define Color_Clear WHITE
编写以下画点函数:
/*
函数功能: LCD指定X、Y轴画一个点
参数: x:点的x轴,y:点的y轴
(从上到下,从左到右,0~239范围)
Color: 写入的颜色
返回值: 无
*/
void LCD_DrawPoint(uint8_t x,uint8_t y,rgb565_t Color)
{
LCD_SetDisplayRange(x,x,y,y); // 显示点,起始和终止位置相同
// 写入颜色(16位)
LCD_SendHalfByte(Color);
}
编写清屏函数(以默认颜色):
/*
函数功能: 以背景颜色清除整个屏幕
参数: 无
返回值: 无
*/
void LCD_ClearALL(void)
{
uint32_t i,j;
uint8_t data[2] = {0};
data[0] = ((uint16_t)Background)>>8;
data[1] = (uint8_t)Background;
LCD_SetDisplayRange(0,Width-1,0,Height-1);
// 填充缓冲区
for(i = 0; i < LCD_BUF_SIZE/2; i++)
{
LCD_BUF[i * 2] = data[0];
LCD_BUF[i * 2 + 1] = data[1];
}
LCD_WR_DATA(); //写数据
for(j = 0; j < SPI_SendCount; j++)
{
LCD_SPI_Send(LCD_BUF,LCD_BUF_SIZE);
}
}
因为设置清屏和背景颜色都是全写入,故使用一个缓冲区进行存放数据
在lcd.c上面定义如下全局变量和宏:
// 默认的背景色
static rgb565_t Background = WHITE;
/***********缓冲区适合写入大量数据******/
// 定义发送缓冲区的最大大小
#define LCD_BUF_MAXSIZE (Width*Height*2)
// 定义发送的次数
#define SPI_SendCount 100
// 单次发送的缓冲区大小
#define LCD_BUF_SIZE (LCD_BUF_MAXSIZE/SPI_SendCount)
// 单次发送缓冲区
static uint8_t LCD_BUF[LCD_BUF_SIZE];
编写背景色填充函数:
/*
函数功能: 重新设置默认背景颜色
参数: Color:重设的背景色
返回值: 无
*/
void LCD_SetBackground(rgb565_t Color)
{
uint32_t i,j;
uint8_t data[2] = {0};
Background = Color;
data[0] = ((uint16_t)Background)>>8;
data[1] = (uint8_t)Background;
LCD_SetDisplayRange(0,Width-1,0,Height-1);
// 填充缓冲区
for(i = 0; i < LCD_BUF_SIZE/2; i++)
{
LCD_BUF[i * 2] = data[0];
LCD_BUF[i * 2 + 1] = data[1];
}
LCD_WR_DATA(); //写数据
for(j = 0; j < SPI_SendCount; j++)
{
LCD_SPI_Send(LCD_BUF,LCD_BUF_SIZE);
}
}
最后在main函数中验证:
int main(void)
{
HAL_Init(); //初始化HAL库
SystemClock_Config(); //系统时钟初始化为80M
delay_init(80); //设置systick时钟的频率
led_init();
LCD_Init(); // 初始化LCD
LCD_ClearALL(); // 以默认背景色清屏
LCD_SetBackground(BLUE); // 重设背景色
while(1)
{
delay_ms(10);
}
}
如下图成功在显示屏上显示蓝色:
资料已上传网盘
链接:https://pan.baidu.com/s/1cPXKntQ-BB12r6e_ZzbhfA?pwd=9wrd
提取码:9wrd
作者:lugao77