STM32HAL库使用SPI驱动1.44寸TFTLCD

  关于STM32F4单片机,使用HAL库自带的SPI,驱动TFTLCD屏幕的资料网上好像不太多,正好最近我做了这项工作,把成果分享给大家。我的代码实现了这些功能:任意坐标画点,指定首尾坐标画线,画方框,指定区域显示彩图,显示16* 16或者12* 12的汉字、ASCII码,并附带ASCII码表与少量的汉字字库。

硬件设计

  屏幕选择:使用了一款低成本十六位彩屏,只要十块钱。链接
STM32HAL库使用SPI驱动1.44寸TFTLCD_第1张图片
  厂家看到文章请联系我打广告费,哈哈。
  虽然用这个屏幕的可能不多,但我了解到,只要其控制芯片是ST7735S,那么程序就应该差不多。不同的地方在于,厂家的封装与玻璃不太一样,玻璃有个伽马值不同,会导致颜色看上去不太一样。

  屏幕的引脚信息
STM32HAL库使用SPI驱动1.44寸TFTLCD_第2张图片
  我的原理图设计:使用了STM32F405RG芯片的SPI1,屏幕没有MISO。
STM32HAL库使用SPI驱动1.44寸TFTLCD_第3张图片
STM32HAL库使用SPI驱动1.44寸TFTLCD_第4张图片

cubeMX中SPI的配置大致如下:
STM32HAL库使用SPI驱动1.44寸TFTLCD_第5张图片STM32HAL库使用SPI驱动1.44寸TFTLCD_第6张图片
  其实SPI的速度我选的是21MBITS/s,可能再快一点也行,没有测试。
  其它引脚比较散,都是当做IO来用,CubeMX中的配置过程就不说了,汇总如下

名称 引脚 功能
LCD_RST PC5 屏幕复位
LCD_CD PB0 0数据1指令
SPI_MOSI PB5 数据线
SPI_CLK PB3 时钟线
LCD_CS PB1 片选,低电平有效
LCD_LED PB2 背光,高电平有效

发送数据与指令的基本函数

  在引脚初始化以后,我定义了几个位带操作,方便操作引脚

#define LCD_RST PCout(5)
#define LCD_CD PBout(0)
#define LCD_CS PBout(1)
#define LCD_LED PBout(2)

  不论是发送数据还是引脚,我都采用了HAL库提供的现成的SPI发送函数:

HAL_StatusTypeDef HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout)

  很多人在使用STM32的SPI时都用模拟SPI,说STM32的硬件SPI有问题,我暂时没有发现硬件SPI的问题。不过模拟SPI很容易讲清楚原理,按位发送数据,一般写法是这样的:

      for(i=0;i<8;i++)
      {
          if(dat&0x80)
      {
      SDA=1;

  如果你没有使用HAL库,可以把HAL_SPI_Transmit替换掉。
  发送数据与指令的区别就在于LCD_CD引脚的电平状态,两个函数如下:

/** * @brief 向LCD屏幕写一个字节的命令 * @param 命令内容,具体命令可以参照手册 * @retval None */
static void LCD_WriteCommand(uint8_t temp)
{
	LCD_CD = 0;
	LCD_CS = 0;
	HAL_SPI_Transmit(&hspi1,&temp, 1, 0xffff);
	LCD_CS = 1;
}
/** * @brief 向LCD屏幕写一个字节的数据 * @param 数据 * @retval None */	
static void LCD_WriteData(uint8_t temp)
{
	LCD_CD = 1;
	LCD_CS = 0;
	HAL_SPI_Transmit(&hspi1,&temp, 1, 0xffff);
	LCD_CS = 1;
}

  可以看出来,除了LCD_CD引脚用于切换命令,也需要操作LCD_CS来选中屏幕。个人认为操作过多操作引脚会影响效率,而发送数据的函数应用的十分频繁,特别是对于我们选用的十六位屏幕,每个像素都需要十六位的数据,所以,我们经常用到的功能是发送个十六位的数据。代码可以这么写,调用两次发送8位数据的函数:

static void LCD_WD_U16(u16 temp)
{
    LCD_WriteData(temp>>8);
    LCD_WriteData(temp);
}

  由于要操作两次IO,所以我稍微做了一点优化:

/** * @brief 向LCD屏幕写两个字节的数据 * @param 16位的数据 * @note 此函数可以直接调用LCD_WriteData两次,但是IO的操作是多余的 * 由于每个图片的数据都是16位的,所以此函数很常用,因此稍作优化,减少操作IO * @retval None */	
static void LCD_WD_U16(u16 temp)
{
	u8 tempBuf[2];
	tempBuf[0] = temp>>8;
	tempBuf[1] = temp;
	LCD_CD = 1;
	LCD_CS = 0;
	HAL_SPI_Transmit(&hspi1,tempBuf, 2, 0xffff);
	LCD_CS = 1;
}

  同理写了一个函数,用于发送数组。彩图数组动辄都是上万位的,并且是连续发送数据,所以也不需要操作多次IO。

/** * @brief 向LCD屏幕写一个数组的长度 * @param 数组地址与长度 * @note 此函数可以直接调用LCD_WriteData若干次,但是IO的操作是多余的 * 由于每个图片的数据都是16位的很长的数组,所以此函数很常用,因此稍作优化,减少操作IO,一个图片的数组值操作一次IO * @retval None */	
static void LCD_WD_buf(uint8_t *pData, uint16_t Size)
{
	LCD_CD = 1;
	LCD_CS = 0;
	HAL_SPI_Transmit(&hspi1,pData, Size, 0xffff);
	LCD_CS = 1;
}

初始化与定位

  初始化代码太长,就不放了。其实初始化代码是厂家提供的,只不过原来是51程序,我移植了下。
  屏幕的显示需要坐标系,定位操作其实就是发个特定的命令,表示设置x/y轴,在发送特定的数据,表示具体位置。操作思路在《ST7735S手册》中都有体现,例如设置列地址:
STM32HAL库使用SPI驱动1.44寸TFTLCD_第7张图片
  我们找到了设置列地址的命令,再把自己需要的坐标计算出来,假如全屏显示:

/** * @brief 设置显示区域为全屏 * @param None * @retval None */	
static void Full_Screen(void)
{
	LCD_WriteCommand(0x2A);	    //设置列地址
	LCD_WriteData(0x00);
	LCD_WriteData(0x02);
	LCD_WriteData(0x00);
	LCD_WriteData(0x81);

	LCD_WriteCommand(0x2B);	    //设置行地址
	LCD_WriteData(0x00);
	LCD_WriteData(0x03);
	LCD_WriteData(0x00);
	LCD_WriteData(0x82);

	LCD_WriteCommand(0x2C);   //写内存
}

  设置某个点的坐标:

/** * @brief 设置某个点的坐标 * @param 点的横纵坐标 * @note 坐标的起点为(2,3) * @retval None */	
static void LCD_SetXY(u16 x,u16 y)
{
	LCD_WriteCommand(0x2A);	    //设置横轴
	LCD_WD_U16(x+2);
	
	LCD_WriteCommand(0x2B);	    //设置纵轴
	LCD_WD_U16(y+3);
	
	LCD_WriteCommand(0x2C);   //写内存
}

  设置某个区域的坐标:

/** * @brief 设置某个显示区域的坐标 * @param 区域左上角的坐标与右下角的坐标 * @note 坐标的起点为(2,3) * @retval None */	
static void LCD_SetArea(u16 x0, u16 y0,u16 x1, u16 y1)
{
	LCD_WriteCommand(0x2A);	    //设置横轴
	LCD_WD_U16(x0+2);
	LCD_WD_U16(x1+2);
	
	LCD_WriteCommand(0x2B);	    //设置纵轴
	LCD_WD_U16(y0+3);
	LCD_WD_U16(y1+3);
	
	LCD_WriteCommand(0x2C);   //写内存
}

颜色的确定

  所谓十六位真彩色,意思就是每个像素的颜色由十六位决定。我们在初始化函数中设置的是这样分配的:
STM32HAL库使用SPI驱动1.44寸TFTLCD_第8张图片
  红色5位,绿色6位,蓝色5位
  很容易想到白色的RGB值就是0xffff,黑色是0x0000。其它还有几个颜色的定义如下:

#define RED 0xf800
#define GREEN 0x07e0
#define BLUE 0x001f
#define YELLOW 0xffe0
#define WHITE 0xffff
#define BLACK 0x0000
#define PURPLE 0xf81f

  一定要注意,高位在前。有很多取色工具可以帮我们算出某个颜色的RGB值。

画点、线、框

  前边已经写了确定点坐标的方法,画点就十分简单了:

/** * @brief 画一个点 * @param 点的横纵坐标,点的颜色 * @retval None */	
void LCD_DrawPoint(u16 x,u16 y,u16 color)
{
	LCD_SetXY(x,y);
	LCD_WD_U16(color);
}

  画线函数理论上来讲就是调用多次画点的函数。如果是横平竖直的线,那十分简单了。如果是斜线呢?那就需要考虑斜率了。由于像素是离散的,所以线上的点,我们只处理所谓的整数部分,代码比较复杂,主要是因为整型变量处理四舍五入的小数部分稍微有点吃力。

/** * @brief 画一条线 * @param 线的起点与终点的横纵坐标,颜色 * @note 可以画斜线 * @retval None */	
void LCD_DrawLine(u16 x0, u16 y0,u16 x1, u16 y1,u16 Color)   
{
int dx,             // x轴上的距离
    dy,             // y轴上的距离
    dx2,            // 计算坐标的临时变量
    dy2, 
    x_inc,          // inc表示点的“生长方向” x_inc>1代表从左向右
    y_inc,          // inc表示点的“生长方向” x_inc>1代表从上向下(左上角是坐标原点)
    error,          // 由于坐标点只有整数,是离散的不是连续的,需要变量用于四舍五入的计算
    index;         
	LCD_SetXY(x0,y0);
	dx = x1-x0;//计算x距离
	dy = y1-y0;//计算y距离

	if (dx>=0)
	{
		x_inc = 1;
	}
	else
	{
		x_inc = -1;
		dx    = -dx;  
	} 
	
	if (dy>=0)
	{
		y_inc = 1;
	} 
	else
	{
		y_inc = -1;
		dy    = -dy; 
	} 

	dx2 = dx << 1; //相当于乘以2,如此一来,四舍五入的误差就变成了不到1舍,大于1入
	dy2 = dy << 1;

	if (dx > dy)//x距离大于y距离,那么对于每个x轴上只有一个点,每个y轴可能只有半个点
	{		
		error = dy2 - dx; 
		for (index=0; index <= dx; index++)//要画的点数不会超过x距离
		{
			LCD_DrawPoint(x0,y0,Color);
			if (error >= 0) //如果error>0 说明真实的y的误差>0.5了,实际上应该+1了
			{
				error-=dx2;
				y0+=y_inc;//增加y坐标值
			} 
			error+=dy2;
			x0+=x_inc;//x坐标值每次画点后都递增1
		}
	} 
	else
	{
		error = dx2 - dy; 
		for (index=0; index <= dy; index++)
		{
			LCD_DrawPoint(x0,y0,Color);
			if (error >= 0)
			{
				error-=dy2;
				x0+=x_inc;
			} 
			error+=dx2;
			y0+=y_inc;
		} 
	} 
}

  由于界面中,我们常常需要划一个方框,或者称之为“按钮”,所以我又封装了一个函数:

/** * @brief 画一个方框,或者称之为按钮 * @param 方框左上角和右下角的点的坐标,颜色 * @note 右边和下边的线自带加粗效果 如需花在屏幕最边缘无加粗效果 * @retval None */	
void LCD_DrawBTN(u16 x1,u16 y1,u16 x2,u16 y2,u16 Color)
{
	LCD_DrawLine(x1,  y1,  x2,y1, Color); //H
	LCD_DrawLine(x1,  y1,  x1,y2, Color); //V
	
	LCD_DrawLine(x1+1,y2-1,x2,y2-1, Color);  //H 加粗 多画一条线
	LCD_DrawLine(x1,  y2,  x2,y2, Color);  //H
	LCD_DrawLine(x2-1,y1+1,x2-1,y2, Color);  //V
  LCD_DrawLine(x2  ,y1  ,x2,y2, Color); //V
}

显示纯色背景与图片

  我们已经做到了全屏显示,那么纯色背景的显示就很简单了:

/** * @brief 全屏显示纯色图片,可用作清屏 * @param 颜色 * @retval None */	
void LCD_BG_Color(u16 color)
{
	int i=16384;//128*128
 	Full_Screen();
	while(i-->0)//不可使用无符号数据类型
	LCD_WD_U16(color);
}

  一共有16384个像素,那就调用16384次画点的程序。只不过这里我稍作了一点优化,不用再管点的坐标了,因为已经设置了显示区域是全屏。
  我专门写了一个函数,显示各个纯色图片,可以看出有没有那个像素点颜色显示的不全:

/** * @brief LCD屏幕测试,依次显示纯色照片,可以看出屏幕的每一个像素点是否正常 * @param 每种颜色持续的时间,单位ms * @retval None */
void LCD_Test(u16 delay_Time)
{
	LCD_BG_Color(BLACK);
	HAL_Delay(delay_Time);
	LCD_BG_Color(RED);
	HAL_Delay(delay_Time);
	LCD_BG_Color(GREEN);
	HAL_Delay(delay_Time);
	LCD_BG_Color(BLUE);
	HAL_Delay(delay_Time);
	LCD_BG_Color(WHITE);
	HAL_Delay(delay_Time);
}

  显示全屏幕的背景图片跟纯色背景思路类似,把数据的来源改为数组就好:

/** * @brief 在LCD屏幕中显示一个全屏的背景图片 * @param 无 * @note 只适用于128*128的屏幕,为了效率把长度直接计算了出来 * 由于图片数组要放在ROM,所以用了const,因此要做强制类型转化(u8 *) * 如果是双工,则收发需要同一个数组,数组不能放在ROM * @retval None */	
void LCD_BG_Image(void)
{
	uint32_t i=32768; //Z144_HEIGHT*Z144_WIDTH*2;
 	Full_Screen();
	LCD_WD_buf((u8 *)LCD_MOTOR,i);
}

  接下来就是指定区域显示图片了,函数也很简单:

/** * @brief 按照指定的坐标显示图片 * @param 图片左上角的坐标与右下角的坐标,图片地址 * @note 图片大小与坐标必须对应 * 由于图片数组要放在ROM,所以用了const,因此要做强制类型转化(u8 *) * 坐标范围[0,127] * @retval None */	
void LCD_Show_Image(u16 x0, u16 y0,u16 x1,u16 y1,const unsigned char *p)
{
	int i = (x1-x0)*(y1-y0)*2;
	LCD_SetArea(x0,y0,x1,y1);
	LCD_WD_buf((u8 *)p,i);
}

  那么图片数组从哪里来?
  取模得到。我去网上随便找了一张彩图,然后把尺寸编辑为100* 100像素。取模软件如下设置:
STM32HAL库使用SPI驱动1.44寸TFTLCD_第9张图片
  然后可以得到图片转化为的数组。

显示文字与字符

  中文字符都采用GBK(或者说GB2312)编码,英文字符采用ASCII码。显示文字其实与显示图片的原理是一样一样的,思路在我另一篇博客中介绍过。
  先说汉字的取模设置:
STM32HAL库使用SPI驱动1.44寸TFTLCD_第10张图片
  我使用了结构体储存汉字,结构体的每个元素都包含文字(或者称之为索引)和它对应的编码。因此得到的数组要稍加修改,增加双引号与汉字,例如北京:

//修改前
0x04,0x40,0x04,0x40,0x04,0x40,0x04,0x44,0x04,0x48,0x7C,0x50,0x04,0x60,0x04,0x40,
0x04,0x40,0x04,0x40,0x04,0x40,0x04,0x42,0x1C,0x42,0xE4,0x42,0x44,0x3E,0x04,0x00,/*"北",4*/
0x02,0x00,0x01,0x00,0xFF,0xFE,0x00,0x00,0x00,0x00,0x1F,0xF0,0x10,0x10,0x10,0x10,
0x10,0x10,0x1F,0xF0,0x01,0x00,0x11,0x10,0x11,0x08,0x21,0x04,0x45,0x04,0x02,0x00,/*"京",5*/
//修改后
"北",0x04,0x40,0x04,0x40,0x04,0x40,0x04,0x44,0x04,0x48,0x7C,0x50,0x04,0x60,0x04,0x40,
0x04,0x40,0x04,0x40,0x04,0x40,0x04,0x42,0x1C,0x42,0xE4,0x42,0x44,0x3E,0x04,0x00,/*"北",4*/
"京",0x02,0x00,0x01,0x00,0xFF,0xFE,0x00,0x00,0x00,0x00,0x1F,0xF0,0x10,0x10,0x10,0x10,
0x10,0x10,0x1F,0xF0,0x01,0x00,0x11,0x10,0x11,0x08,0x21,0x04,0x45,0x04,0x02,0x00,/*"京",5*/

  显示的字符要先判断是中文的还是英文的。英文字符的值小于128,显示函数如下:

/** * @brief 输出16*16的汉字或8*16的字符,函数可以自动识别是中文字符还是ASCII * @param 第一个字符的坐标,汉字颜色,背景颜色,需要显示的字符串。背景颜色为0表示不画背景 * @retval None */
void LCD_DrawFont_GBK16(u16 x, u16 y, u16 fc, u16 bc, u8 *s)
{
	unsigned char i,j;
	unsigned short k,x0;
	x0=x;
	while(*s) 
	{	
		if((*s) < 128)        //如果是ASCII码,那么编号小于128
		{
			k=*s;
			if (k==13)        //回车
			{
				x=x0;//记录本行第一个字符的位置
				y+=16;
			}
			else 
			{
				if (k>32) k-=32; else k=0;      //ASCII前32个都不是字符,除了回车,对显示没有影响,所以字库不储存
			  for(i=0;i<16;i++)
				{
					for(j=0;j<8;j++) 
						{
							if(ASC16[k*16+i]&(0x80>>j))	//如有数据,画字体 从高位到低位取出字符
							{
								LCD_DrawPoint(x+j,y+i,fc);
							}
							else //如果没有数据,画背景 如果背景颜色为0 那么不画背景
							{
								if ((bc!=0)&&(fc!=bc)) LCD_DrawPoint(x+j,y+i,bc);
							}
						}
					}
				x+=8;//字符横坐标8个像素,x+8代表下一个字符的坐标
			}
			s++;//画完此字符与背景以后,取出下一个字符
		}
		
		else //GBK编码 说明是中文字符
		{
			for (k=0;k<HZ16_num;k++) //遍历中文字库
			{
			  if ((HZ16[k].Index[0]==*(s))&&(HZ16[k].Index[1]==*(s+1)))  //可以找到索引,说明字库中有此汉字。一个汉字占两个字节 
			  {                        
				  for(i=0;i<16;i++)
				    {
							for(j=0;j<8;j++) //先画左半边
							{
						    if(HZ16[k].Msk[i*2]&(0x80>>j))	LCD_DrawPoint(x+j,y+i,fc);
								else {
									if ((bc!=0)&&(fc!=bc)) LCD_DrawPoint(x+j,y+i,bc);
								}
							}
							for(j=0;j<8;j++) //再画右半边
							{
						    if(HZ16[k].Msk[i*2+1]&(0x80>>j))	LCD_DrawPoint(x+j+8,y+i,fc);
								else 
								{
									if ((bc!=0)&&(fc!=bc)) LCD_DrawPoint(x+j+8,y+i,bc);
								}
							}
				    }
					}
			  }
			s+=2;x+=16;
		} 
	}
}

  由于屏幕只有128* 128,一个汉字占用16,那么一行只能显示8个汉字,太少。16的字太大了,所以我又写了显示12像素字符的函数,思路一样,代码就不放了。
  需要注意的是,汉字是需要字库的。ASCII码不多,全部取模也就是100多个(有效的还不到100个),但是汉字可就多了,比如说GBK编码共收录了21886个汉字和图片,那么按照我们设计的结构体来储存(汉字占2个字节,编码占32个字节,其实还可以优化,不用储存汉字),共需要34* 21886=744124byte = 744KB。理论上来讲,STM32F405RG的flash空间是1024KB,能放下这个字库,只不过程序的编译与下载就会变得很缓慢,十分不利于调试,几乎没人这么干。
STM32HAL库使用SPI驱动1.44寸TFTLCD_第11张图片
  因此,目前,对于汉字,我们还是用到哪个汉字,对哪个汉字取模。
  还需要注意一个问题,工程内文件的编码格式要一致,不能这个文件用UTF-8,那个是GB2312,因为我们的函数查找汉字要依赖于比较编码值,同一个汉字不同编码格式的编码值不一样。

显示效果

  我在主函数中调用以上辛辛苦苦编写的函数,省略无关的代码以后,如下:

  /* USER CODE BEGIN 2 */
	ST7735S_CPT144_Initial();
	LCD_Test(500);		
	LCD_DrawBTN(0,98,126,126,YELLOW);	
	LCD_Show_Image(0,0,99,99,gImage_git100);
	LCD_DrawLine(100,0,127,100,BLUE);
	LCD_DrawLine(127,0,100,100,PURPLE);
	LCD_DrawFont_GBK16(0,99,BLACK,WHITE,"屏幕驱动");
	LCD_DrawFont_GBK16(64,99,BLACK,WHITE,"geekYatao");
	LCD_DrawFont_GBK12(0,115,RED,GREEN,"智联有道");
	LCD_DrawFont_GBK12(48,115,RED,GREEN,"STM32F4+HAL");
	//LCD_BG_Image();

  /* USER CODE END 2 */

  刷屏速度尚可,但还是勉强能看出来刷屏幕的过程。最终显示效果不错。
STM32HAL库使用SPI驱动1.44寸TFTLCD_第12张图片

还可以优化

  调用SPI刷屏是要占用总线的,可以使用DMA+SPI,在定时器内刷屏。如果达到每40ms刷屏一次,那么就能达到25帧,看上去就不会太卡顿。
  使用DMA发送大量数据有优势,因此可以用大数组把每一个点的信息都储存下来。为了方便发送,这个数组类型可以设置为unsigned char,共128* 128* 2=32768个元素。
  另外关于字库,既然单片机的flash放的下,那么无需外接硬件就能实现全字库的功能:把编译过的二进制字库,放到单片机flash中靠后的指定地址(避免被程序覆盖),然后需要字库的时候,让程序来读取指定的地址就好。
本文用到的部分代码在这里代码

你可能感兴趣的:(STM32,HAL,TFT,SPI,LCD,电子设计,STM32)