您现在的位置是:首页 >技术杂谈 >【正点原子STM32连载】 第二十五章 TFT-LCD(MCU屏)实验 摘自【正点原子】STM32F103 战舰开发指南V1.2网站首页技术杂谈

【正点原子STM32连载】 第二十五章 TFT-LCD(MCU屏)实验 摘自【正点原子】STM32F103 战舰开发指南V1.2

正点原子 2024-08-03 12:01:02
简介【正点原子STM32连载】 第二十五章 TFT-LCD(MCU屏)实验 摘自【正点原子】STM32F103 战舰开发指南V1.2

1)实验平台:正点原子stm32f103战舰开发板V4
2)平台购买地址:https://detail.tmall.com/item.htm?id=609294757420
3)全套实验源码+手册+视频下载地址: http://www.openedv.com/thread-340252-1-1.html

第二十五章 TFT-LCD(MCU屏)实验

前面我们介绍了OLED模块及其显示,但是该模块只能显示单色/双色,不能显示彩色,而且尺寸也较小。本章我们将介绍正点原子的TFT-LCD模块(MCU屏),该模块采用TFT-LCD面板,可以显示16位色的真彩图片。在本章中,我们将使用开发板底板上的TFTLCD接口(仅支持MCU屏,本章仅介绍MCU屏的使用),来点亮TFT-LCD,并实现ASCII字符和彩色的显示等功能,并在串口打印LCD控制器ID,同时在LCD上面显示。
本章分为如下几个小节:
25.1 TFTLCD和FSMC简介
25.2 硬件设计
25.3 程序设计
25.4 下载验证

25.1 TFT-LCD简介

本章我们将通过STM32F103的FSMC外设来控制TFT-LCD的显示,这样我们就可以用STM32输出一些信息到显示屏上了。
25.1.1 TFT-LCD简介
液晶显示器,即Liquid Crystal Display,利用了液晶导电后透光性可变的特性,配合显示器光源、彩色滤光片和电压控制等工艺,最终可以在液晶阵列上显示彩色的图像。目前液晶显示技术以TN、STN、TFT三种技术为主,TFT-LCD即采用了TFT(Thin Film Transistor)技术的液晶显示器,也叫薄膜晶体管液晶显示器。
TFT-LCD与无源TN-LCD、STN-LCD的简单矩阵不同的是,它在液晶显示屏的每一个象素上都设置有一个薄膜晶体管(TFT),可有效地克服非选通时的串扰,使显示液晶屏的静态特性与扫描线数无关,因此大大提高了图像质量。TFT式显示器具有很多优点:高响应度,高亮度,高对比度等等。TFT式屏幕的显示效果非常出色,广泛应用于手机屏幕、笔记本电脑和台式机显示器上。
由于液晶本身不会发光,加上液晶本身的特性等原因,使得液晶屏的成像角受限,我们从屏幕的的一侧可能无法看清液晶的显示内容。液晶显示器的成像角的大小也是评估一个液晶显示器优劣的指标,目前,规格较好的液晶显示器成像角一般在120°~160°之间。
正点原子TFT-LCD模块(MCU屏)有如下特点:
1,2.8’/3.5’/4.3’/7’等4种大小的屏幕可选。
2,320×240的分辨率(3.5’分辨率为:320480,4.3’和7’分辨率为:800480)。
3,16位真彩显示。
4,自带触摸屏,可以用来作为控制输入。
本章,我们以正点原子2.8寸(此处的寸是代表英寸,下同)的TFT-LCD模块为例介绍,(其他尺寸的LCD可参考具体的LCD型号的资料,也比较类似),该模块支持65K色显示,显示分辨率为320×240,接口为16位的8080并口,自带触摸功能。
该模块的外观如图25.1.1.1所示:
在这里插入图片描述

图25.1.1.1 正点原子2.8寸TFTLCD外观图
模块原理图如图25.1.1.2所示:
在这里插入图片描述

图25.1.1.2 正点原子TFTLCD模块原理图
TFTLCD模块采用2*17的2.54公排针与外部连接,即图中TFT_LCD部分。从图25.1.1.2可以看出,正点原子TFTLCD模块采用16位的并方式与外部连接。图25.1.1.2还列出了触摸控制的接口,但触摸控制是在显示的基础上叠加的一个控制功能,不配置也不会对显示造成影响,我们放到以后的章节再介绍触摸的用法。该模块与显示功能的相关的信号线如表25.1.1.1:
在这里插入图片描述

表25.1.1.1 TFT-LCD接口信号线
上述的接口线实际是对应到液晶显示控制器上的,这个芯片位于液晶屏的下方,所以我们从外观图上看不到。控制LCD显示的过程,就是按其显示驱动芯片的时序,把色彩和位置信息正确地写入对应的寄存器。
25.1.2 液晶显示控制器
正点原子提供2.8/3.5/4.3/7寸等4种不同尺寸和分辨率的TFTLCD模块,其驱动芯片为:ILI9341/ST7789/NT35310/NT35510/SSD1963等(具体的型号,大家可以通过下载本章实验代码,通过串口或者LCD显示查看),这里我们仅以ILI9341控制器为例进行介绍,其他的控制基本都类似,我们就不详细阐述了。
ILI9341液晶控制器自带显存,可配置支持8/9/16/18位的总线中的一种,可以通过3/4线串行协议或8080并口驱动。正点原子的TFT-LCD模块上的电路配置为8080并口方式,其显存总大小为172800(24032018/8),即18位模式(26万色)下的显存量。在16位模式下,ILI9341采用RGB565格式存储颜色数据,此时ILI9341的18位显存与MCU的16位数据线以及RGB565的对应关系如图25.1.2.1所示:
在这里插入图片描述

图25.1.2.1 16位数据与显存对应关系图
从图中可以看出,ILI9341在16位模式下面,18位显存的B0和B12并没有用到,对外的数据线使用DB0-DB15连接MCU的D0-D15实现16位颜色的传输(使用8080 MCU 16bit I型接口,详见9341数据手册7.1.1节)。
这样MCU的16位数据,最低5位代表蓝色,中间6位为绿色,最高5位为红色。数值越大,表示该颜色越深。另外,特别注意ILI9341所有的指令都是8位的(高8位无效),且参数除了读写GRAM的时候是16位,其他操作参数,都是8位的。
知道了屏幕的显色信息后,我们如何驱动它呢?OLED的章节我们已经描述过8080方式操作的时序,我们通过《ILI9341_DS.pdf》来加深一下在8080并口方式下如何操作这个芯片。
以写周期为例,8080方式下的操作时序如图25.1.2.2所示。
在这里插入图片描述

图25.1.2.2 8080方式下对液晶控制器的写操作
上图中的各个控制线与我们在表25.1.1.1提到的命名有些许差异,因为我们在原理图时往往为了方便自己记忆会对命名进行微调,为了方便读者对照,我们把图25.1.2.2中列出的引脚引脚与我们的TFTLCD模块的的对应关系再列出,如表25.1.2.1所示。
在这里插入图片描述

表25.1.2.1 TFT-LCD引脚与液晶控制器的对应关系
这下我们再来分析一下图25.1.2.2所示的写操作的时序,控制液晶的主机,在整个写周期内需要控制片选CSX拉低(标注为①),之后对其它的控制线的电平才有效。在标号②表示的这个写命令周期中,D/CX被位低(参考ILI9341的引脚定义),同时把命令码通过数据线D[17:0](我们实际只用了16个引脚)按位编码。注意到③处,需要数据线在入电平拉高后再操持一段时间以便数据被正确采样。
图25.1.2.2中⑤表示写数据操作,与前面描述的写命令操作只有D/CX的操作不同,读者们可以尝试自己分析一下。更多的关于ILI9341的读写操作时序则参考《ILI9341_DS.pdf》。
通过前述的时序分析,我们知道了对于ILI9341来说,控制命令有命令码、数据码之分,接下来,我们介绍一下ILI9341的几个重要命令。因为ILI9341的命令很多,我们这里就不全部介绍了,有兴趣的大家可以找到ILI9341的datasheet看看。里面对这些命令有详细的介绍。我们将介绍:0xD3,0x36,0x2A,0x2B,0x2C,0x2E等6条指令。
指令0xD3,是读ID4指令,用于读取LCD控制器的ID,该指令如表25.1.2.2所示:
在这里插入图片描述

表25.1.2.2 0xD3指令描述
从上表可以看出,0xD3指令后面跟了4个参数,最后2个参数,读出来是0x93和0x41,刚好是我们控制器ILI9341的数字部分,从而,通过该指令,即可判别所用的LCD驱动器是什么型号,这样,我们的代码,就可以根据控制器的型号去执行对应驱动IC的初始化代码,从而兼容不同驱动IC的屏,使得一个代码支持多款LCD。
接下来看指令:0x36,这是存储访问控制指令,可以控制ILI9341存储器的读写方向,简单的说,就是在连续写GRAM的时候,可以控制GRAM指针的增长方向,从而控制显示方式(读GRAM也是一样)。该指令如表25.1.2.3所示:
在这里插入图片描述

表25.1.2.3 0x36指令描述
从上表可以看出,0x36指令后面,紧跟一个参数,这里主要关注:MY、MX、MV这三个位,通过这三个位的设置,我们可以控制整个ILI9341的全部扫描方向,如表25.1.2.4所示:
在这里插入图片描述

表25.1.2.4 MY、MX、MV设置与LCD扫描方向关系表
这样,我们在利用ILI9341显示内容的时候,就有很大灵活性了,比如显示BMP图片,BMP解码数据,就是从图片的左下角开始,慢慢显示到右上角,如果设置LCD扫描方向为从左到右,从下到上,那么我们只需要设置一次坐标,然后就不停的往LCD填充颜色数据即可,这样可以大大提高显示速度。
实验中,我们默认使用从左到右,从上到下的扫描方式。
接下来看指令:0x2A,这是列地址设置指令,在从左到右,从上到下的扫描方式(默认)下面,该指令用于设置横坐标(x坐标),该指令如表25.1.2.5所示:
在这里插入图片描述

在默认扫描方式时,该指令用于设置x坐标,该指令带有4个参数,实际上是2个坐标值:SC和EC,即列地址的起始值和结束值,SC必须小于等于EC,且0≤SC/EC≤239。一般在设置x坐标的时候,我们只需要带2个参数即可,也就是设置SC即可,因为如果EC没有变化,我们只需要设置一次即可(在初始化ILI9341的时候设置),从而提高速度。
与0X2A指令类似,指令:0X2B,是页地址设置指令,在从左到右,从上到下的扫描方式(默认)下面,该指令用于设置纵坐标(y坐标)。该指令如表25.1.2.6所示:
在这里插入图片描述

表25.1.2.6 0X2B指令描述
在默认扫描方式时,该指令用于设置y坐标,该指令带有4个参数,实际上是2个坐标值:SP和EP,即页地址的起始值和结束值,SP必须小于等于EP,且0≤SP/EP≤319。一般在设置y坐标的时候,我们只需要带2个参数即可,也就是设置SP即可,因为如果EP没有变化,我们只需要设置一次即可(在初始化ILI9341的时候设置),从而提高速度。
接下来看指令:0X2C,该指令是写GRAM指令,在发送该指令之后,我们便可以往LCD的GRAM里面写入颜色数据了,该指令支持连续写,指令描述如表25.1.2.7所示。
在这里插入图片描述

表25.1.2.7 0X2C指令描述
由表25.1.2.7可知,在收到指令0X2C之后,数据有效位宽变为16位,我们可以连续写入LCD GRAM值,而GRAM的地址将根据MY/MX/MV设置的扫描方向进行自增。例如:假设设置的是从左到右,从上到下的扫描方式,那么设置好起始坐标(通过SC,SP设置)后,每写入一个颜色值,GRAM地址将会自动自增1(SC++),如果碰到EC,则回到SC,同时SP++,一直到坐标:EC,EP结束,期间无需再次设置的坐标,从而大大提高写入速度。
最后,来看看指令:0X2E,该指令是读GRAM指令,用于读取ILI9341的显存(GRAM),该指令在ILI9341的数据手册上面的描述是有误的,真实的输出情况如表25.1.2.8所示:
在这里插入图片描述

表25.1.2.8 0X2E指令描述
该指令用于读取GRAM,如表25.1.2.7所示,ILI9341在收到该指令后,第一次输出的是dummy数据,也就是无效的数据,第二次开始,读取到的才是有效的GRAM数据(从坐标:SC,SP开始),输出规律为:每个颜色分量占8个位,一次输出2个颜色分量。比如:第一次输出是R1G1,随后的规律为:B1R2G2B2R3G3B3R4G4B4R5G5…以此类推。如果我们只需要读取一个点的颜色值,那么只需要接收到参数3即可,如果要连续读取(利用GRAM地址自增,方法同上),那么就按照上述规律去接收颜色数据。
以上,就是操作ILI9341常用的几个指令,通过这几个指令,我们便可以很好的控制ILI9341显示我们所要显示的内容了。
25.1.3 FSMC简介
ILI9341的8080通讯接口时序可以由STM32使用GPIO接口进行模拟,但这样效率太低,STM32提供了一种更高效的控制方法——使用FSMC接口实现8080时序,但FSMC是STM32片上外设的一种,并非所有的STM32都拥有这种硬件接口,使用何种方式驱动需要在芯片选型时就确定好。我们的开发板支持FSMC接口,下面我们来了解一下这个接口的功能。
FSMC,即灵活的静态存储控制器,能够与同步或异步存储器和16位PC存储器卡连接,FSMC接口可以通过地址信号,快速地找到存储器对应存储块上的数据。STM32F1的FSMC接口支持包括SRAM、NAND FLASH、NOR FLASH和PSRAM等存储器。F1系列的大容量型号,且引脚数目在100脚及以上的STM32F103芯片都带有FSMC接口,正点原子战舰STM32F103的主芯片为STM32F103ZET6,是带有FSMC接口的。
FSMC接口的结构如图25.1.3.1所示:
在这里插入图片描述

图25.1.3.1 FSMC框图
从图25.1.3.1我们可以看出,STM32的FSMC可以驱动NOR/PSRAM、NAND、PC卡这3类设备,他们具有不同的CS以区分不同的设备。本部分我们要用到的是NOR/PSRAM的功能。①为FSMC的总线和时钟源,②为STM32内部的FSMC控制单元,③是连接硬件的引脚,这里的“公共信号”表示不论我们驱动前面提到的3种设备中的哪种,这些IO是共享的,所以如果需要用到多种功能的情况,程序上还要考虑分时复用。④是NOR/PSRAM会使用到的信号控制线,③和④这些信号比较重要,它们的功能如表25.1.3.1:
在这里插入图片描述

表25.1.3.1 FSMC信号线的的功能
在数电的课程中有介绍过存储器的知识,它是可以存储数据的器件。复杂的存储器为了存储更多的数据,常常通过地址线来管理数据存储的位置,这样只要先找到需要读写数据的位置,然后对进行数据读写的操作。由于存储器的这种数据和地址对应关系,采用FSMC这种专门硬件接口就能加快对存储器的数据访问。
STM32F1的FSMC将外部存储器划分为固定大小为256M字节的四个存储块,FSMC的外部设备地址映像如图25.1.3.2所示:
在这里插入图片描述

图25.1.3.2 FSMC存储块地址映像
从上图可以看出,FSMC总共管理1GB空间,拥有4个存储块(Bank),FSMC各Bank配置寄存器如表25.1.3.2:
在这里插入图片描述

表25.1.3.2 FSMC各Bank配置寄存器表
本章,我们用到的是块1,所以在本章我们仅讨论块1的相关配置,其他块的配置,请参考《STM32F10xxx参考手册_V10(中文版).pdf》第19章(324页)的相关介绍。
STM32F1的FSMC存储块1(Bank1)被分为4个区,每个区管理64M字节空间,每个区都有独立的寄存器对所连接的存储器进行配置。Bank1的256M字节空间可以通过28根地址线(HADDR[27:0])寻址后访问。这里HADDR是内部AHB地址总线,其中HADDR[25:0]来自外部存储器地址FSMC_A[25:0],而HADDR[26:27]对4个区进行寻址。如表25.1.3.3所示:
在这里插入图片描述

表25.1.3.3中,我们要特别注意HADDR[25:0]的对应关系:
当Bank1接的是16位宽度存储器的时候:HADDR[25:1]→FSMC_A[24:0]。
当Bank1接的是8位宽度存储器的时候:HADDR[25:0] →FSMC_A[25:0]。
不论外部接8位/16位宽设备,FSMC_A[0]永远接在外部设备地址A[0]。这里,TFTLCD使用的是16位数据宽度,所以HADDR[0]并没有用到,只有HADDR[25:1]是有效的,对应关系变为:HADDR[25:1]FSMC_A[24:0],相当于右移了一位,具体来说,比如地址:0x7E,对应二进制是:01111110,此时FSMC_A6是0而不是1,因为要右移一位,这里请特别注意。
另外,HADDR[27:26]的设置,是不需要我们干预的,比如:当你选择使用Bank1的第三个区,即使用FSMC_NE3来连接外部设备的时候,即对应了HADDR[27:26]=10,我们要做的就是配置对应第3区的寄存器组,来适应外部设备即可。对于NOR FLASH控制器,主要是通过FSMC_BCRx、FSMC_BTRx和FSMC_BWTRx寄存器设置(其中x=1~4,对应4个区)。通过这3个寄存器,可以设置FSMC访问外部存储器的时序参数,拓宽了可选用的外部存储器的速度范围。FSMC的NORFLASH控制器支持同步和异步突发两种访问方式。选用同步突发访问方式时,FSMC将HCLK(系统时钟)分频后,发送给外部存储器作为同步时钟信号FSMC_CLK。此时需要的设置的时间参数有2个:
1,HCLK与FSMC_CLK的分频系数(CLKDIV),可以为2~16分频;
2,同步突发访问中获得第1个数据所需要的等待延迟(DATLAT)。
对于异步突发访问方式,FSMC主要设置3个时间参数:地址建立时间(ADDSET)、数据建立时间(DATAST)和地址保持时间(ADDHLD)。FSMC综合了SRAM/ROM、PSRAM和NOR Flash产品的信号特点,定义了4种不同的异步时序模型。选用不同的时序模型时,需要设置不同的时序参数,如表25.1.3.4所列:
在这里插入图片描述

表25.1.3.4 NOR FLASH控制器支持的时序模型
在实际扩展时,根据选用存储器的特征确定时序模型,从而确定各时间参数与存储器读/写周期参数指标之间的计算关系;利用该计算关系和存储芯片数据手册中给定的参数指标,可计算出FSMC所需要的各时间参数,从而对时间参数寄存器进行合理的配置。
模式A支持独立的读写时序控制。这个对我们驱动TFTLCD来说非常有用,因为TFTLCD在读的时候,一般比较慢,而在写的时候可以比较快,如果读写用一样的时序,那么只能以读的时序为基准,从而导致写的速度变慢,或者在读数据的时候,重新配置FSMC的延时,在读操作完成的时候,再配置回写的时序,这样虽然也不会降低写的速度,但是频繁配置,比较麻烦。而如果有独立的读写时序控制,那么我们只要初始化的时候配置好,之后就不用再配置,既可以满足速度要求,又不需要频繁改配置。模式A的写操作及读操作时序分别如图25.1.3.3和图25.1.3.4所示:
在这里插入图片描述

图25.1.3.3 模式A写操作时序
在这里插入图片描述

图25.1.3.4 模式A读操作时序图
图25.1.3.3和图25.1.3.4中的ADDSET与DATAST,是通过不同的寄存器设置的。
以图25.1.3.3所示的写操作时序为例,该图表示一个存储器操作周期由地址建立周期(ADDSET)、数据建立周期(DATAST)组成。在地址建立周期中,数据建立周期期间NWE信号拉低发出写信号,接着FSMC把数据通过数据线传输到存储器中。注意:NEW拉高后的那1个HCLK是必要的,以保证数据线上的信号被准确采样。
读操作模式时序类似,区别是它的一个存储器操作周期由地址建立周期(ADDSET)和数据建立周期(DATAST)以及2个HCLK周期组成,且在数据建立周期期间地址线发出要访问的地址,数据掩码信号线指示出要读取地址的高、低字节部分,片选信号使能存储器芯片;地址建立周期结束后读使能信号线发出读使能信号,接着存储器通过数据信号线把目标数据传输给FSMC,FSMC把它交给内核。
当FSMC外设被配置成正常工作,并且外部接了PSRAM,若向0x60000000地址写入数据如0xABCD,FSMC会自动在各信号线上产生相应的电平信号,写入数据。FSMC会控制片选信号NE1输出低电平,相应的PSRAM芯片被片选激活,然后使用地址线A[25:0]输出0x60000000,在NWE写使能信号线上发出低电平的写使能信号,而要写入的数据信号0xABCD则从数据线D[15:0]输出,然后数据就被保存到PSRAM中了。
到这里大家发现没有,之前讲的液晶控制器的8080并口模式与FSMC接口很像,区别是FSMC通过地址访问设备数据,并且可以自动控制相应电平,而8080方式则是直接控制,且没有地址线。对比图25.1.2.2和图25.1.3.3,不难发现它们的相似点,我们概括如表25.1.3.5:
在这里插入图片描述

表25.1.3.5 FSMC(NOR/PSRAM)方式和8080并口对比
如果能用某种方式把FSMC的地址线和8080方式下的等效起来,那不就可以直接用FSMC等效8080方式操作LCD屏的显存了?FSMC利用地址线访问数据,并自动设置地址线和相关控制信号线的电平,如果我们对命令操作和数据操作采用不同的地址来访问,同时使得操作数据时,地址线上的一个引脚的电平为高,操作命令时,同一个引脚的电平为低的话,就可以完美解决这个问题了!
战舰STM32开发板把TFT-LCD就是用的FSMC_NE4做片选,把RS连接在A10上面的。我们来分析一下要让实现上面的通过地址自动切换命令和数据的实现方式。
首先NOR/PSRAM储块地址范围:0X6000 0000 ~ 0X6FFF FFFF,基地址是0X6000 0000,每个存储块是64MB,那么这时候我们访问LCD的地址应该是第4个存储块,编号从1开始,访问LCD的起始地址就是 0x6000 0000 + (0x400 0000 * (x - 1)) = 0x6C00 0000,即从0x6C00 0000起的64MB内存地址都可以去访问LCD。
FSMC_A10对应地址值:2^10 * 2 = 0x800(16位模式时,参考表25.1.3.3及之后对HADDR和FSMC地址线对应关系的描述:HADDR[25:1]FSMC_A[24:0],所以这里计算时还需要乘2);则写命令时的地址为:0x6C00 0000 + 2^10 * 2 = 0x6C00 0800。写数据的地址就是使FSMC_A10为0的其它任意地址。(大家不要被地址访问的思路带进去了,以为接下来就是用FSMC的地址偏移来操作显存了,实际显存的操作还是归MCU屏管理。我们使能了FSMC功能后,就可以直接在我们设置的地址读写数据。实际上我们只用到了两个固定的地址:一个地址把FSMC_A10位置1,另一个把该位置0,但要保证这两个地址在各个BANK的管理范围内)。
STM32F1的FSMC支持8/16位数据宽度,我们这里用到的LCD是16位宽度的,所以在设置的时候,选择16位宽就OK了。向这两个地址写的16进制数据会被直接送到数据线上,根据地址自动解析为命令或者数据,通过这样一个过程,我们就完成了用FSMC模拟8080并口的操作,最终完成对液晶控制器的控制。
25.1.4 FSMC关联寄存器简介
接下来我们讲解一下Bank1的几个控制寄存器。
首先,我们介绍SRAM/NOR闪存片选控制寄存器:FSMC_BCRx(x=1~4),该寄存器各位描述如图25.1.4.1所示:
在这里插入图片描述

图25.1.4.1 FSMC_BCRx寄存器各位描述
该寄存器我们在本章用到的设置有:EXTMOD、WREN、MWID、MTYP和MBKEN这几个设置,我们将逐个介绍。
EXTMOD:扩展模式使能位,也就是是否允许读写不同的时序,很明显,我们本章需要读写不同的时序,故该位需要设置为1。
WREN:写使能位。我们需要向TFTLCD写数据,故该位必须设置为1。
MWID[1:0]:存储器数据总线宽度。我们的TFTLCD是16位数据线,所以设置该值为01。
MTYP[1:0]:存储器类型。前面提到,我们把TFTLCD当成SRAM用,所以需要设置该值为00。
MBKEN:存储块使能位。这个容易理解,我们需要用到该存储块控制TFTLCD,当然要使能这个存储块了。
接下来,我们看看SRAM/NOR闪存片选时序寄存器:FSMC_BTRx(x=1~4),该寄存器各位描述如图25.1.4.2所示:
在这里插入图片描述

图25.1.4.2 FSMC_BTRx寄存器各位描述
这个寄存器包含了每个存储器块的控制信息,可以用于SRAM、ROM和NOR闪存存储器。如果FSMC_BCRx寄存器中设置了EXTMOD位,则有两个时序寄存器分别对应读(本寄存器)和写操作(FSMC_BWTRx寄存器)。因为我们要求读写分开时序控制,所以EXTMOD是使能了的,也就是本寄存器是读操作时序寄存器,控制读操作的相关时序。本章我们要用到的设置有:ACCMOD、DATAST和ADDSET这三个设置。
ACCMOD[1:0]:访问模式。本章我们用到模式A,故设置为00。
DATAST[7:0]:数据保持时间。对ILI9341来说,其实就是RD低电平持续时间,一般为355ns。而一个HCLK时钟周期为13.9ns左右(1/72Mhz),为了兼容其他屏,我们这里设置DATAST为15,也就是16个HCLK周期,时间大约是222ns(未计算数据存储的2个HCLK时间,对9341来说超频了,但是实际上是可以正常使用的)。
ADDSET[3:0]:地址建立时间。其建立时间为:ADDSET个HCLK周期,最大为15个HCLK周期。对ILI9341来说,这里相当于RD高电平持续时间,为90ns,本来这里我们应该设置和DATAST一样,但是由于STM32F103 FSMC的性能问题,就算设置ADDSET为0,RD的高电平持续时间也超过90ns。所有,我们这里可以设置ADDSET为较小的值,本章我们设置ADDSET为1,即2个HCLK周期。
最后,我们再来看看SRAM/NOR闪写时序寄存器:FSMC_BWTRx(x=1~4),该寄存器各位描述如图25.1.4.3所示:
在这里插入图片描述

图25.1.4.3 FSMC_BWTRx寄存器各位描述
该寄存器在本章用作写操作时序控制寄存器,需要用到的设置同样是:ACCMOD、DATAST和ADDSET这三个设置。这三个设置的方法同FSMC_BTRx一模一样,只是这里对应的是写操作的时序,ACCMOD设置同FSMC_BTRx一模一样,同样是选择模式A,另外DATAST和ADDSET则对应低电平和高电平持续时间,对ILI9341来说,这两个时间只需要15ns就够了,比读操作快得多。所以我们这里设置DATAST为1,即2个HCLK周期,时间约为28ns。然后ADDSET(也存在性能问题)设置为0,即1个HCLK周期,实际WR高电平时间就可以满足。
至此,我们对STM32F1的FSMC介绍就差不多了,通过以上两个小节的了解,我们可以开始写LCD的驱动代码了。不过,这里还要给大家做下科普,在MDK的寄存器定义里面,并没有定义FSMC_BCRx、FSMC_BTRx、FSMC_BWTRx等这个单独的寄存器,而是将他们进行了一些组合。
FSMC_BCRx和FSMC_BTRx,组合成BTCR[8]寄存器组,他们的对应关系如下:
BTCR[0]对应FSMC_BCR1,
BTCR[1]对应FSMC_BTR1
BTCR[2]对应FSMC_BCR2
BTCR[3]对应FSMC_BTR2
BTCR[4]对应FSMC_BCR3
BTCR[5]对应FSMC_BTR3
BTCR[6]对应FSMC_BCR4
BTCR[7]对应FSMC_BTR4
FSMC_BWTRx则组合成BWTR[7],他们的对应关系如下:
BWTR[0]对应FSMC_BWTR1,
BWTR[2]对应FSMC_BWTR2,
BWTR[4]对应FSMC_BWTR3,
BWTR[6]对应FSMC_BWTR4,
BWTR[1]、BWTR[3]和BWTR[5]保留,没有用到。
通过上面的讲解,通过对FSMC相关的寄存器的描述,大家对FSMC的原理有了一个初步的认识,如果还不熟悉的朋友,请一定要搜索网络资料理解FSMC的原理。只有理解了原理,编程时才会得心应手。
25.2 硬件设计

  1. 例程功能
    使用开发板的MCU屏接口连接正点原子 TFTLCD模块(仅限MCU屏模块),实现TFTLCD模块的显示。通过把LCD模块插入底板上的TFTLCD模块接口,按下复位之后,就可以看到LCD模块不停的显示一些信息并不断切换底色。同时该实验会显示LCD驱动器的ID,并且会在串口打印(按复位一次,打印一次)。LED0闪烁用于提示程序正在运行。
  2. 硬件资源
    1)LED灯
    LED0 – PB5
    2)串口1(PA9/PA10连接在板载USB转串口芯片CH340上面)
    3)正点原子 2.8/3.5/4.3/7/10寸TFTLCD模块(仅限MCU屏,16位8080并口驱动)
  3. 原理图
    TFTLCD模块的电路见图25.1.1.2,而开发板的LCD接口和正点原子 TFTLCD模块直接可以对插,开发板上的LCD接口如图25.2.1所示:
    在这里插入图片描述

图25.2.1 TFTLCD模块与开发板对接的LCD接口示意图
在这里插入图片描述

图25.2.2 TFTLCD模块与开发板的连接原理图
在硬件上,TFTLCD模块与开发板的IO口对应关系如下:
LCD_BL(背光控制)对应PB0;
LCD_CS对应PG12即FSMC_NE4;
LCD_RS对应PG0即FSMC_A10;
LCD_WR对应PD5即FSMC_NWE;
LCD_RD对应PD4即FSMC_NOE;
LCD _D[15:0]则直接连接在FSMC_D15~FSMC_D0;
这些线的连接,开发板的内部已经连接好了,我们只需要将TFTLCD模块插上去就好了。
需要说明的是,开发板上设计的TFT-LCD模块插座,已经把模块模块的RST信号线直接接到我们开发板的复位脚上,所以不需要软件控制,这样可以省下来一个IO口。另外我们还需要一个背光控制线来控制LCD的背光灯,因为LCD不会自发光,没有背光灯的情况下我们是看不到LCD上显示的内容的。所以,我们总共需要的IO口数目为22个。
25.3 程序设计
25.3.1 FSMC和SRAM的HAL库驱动
SRAM和FMC在HAL库中的驱动代码在stm32f1xx_ll_fsmc.c/stm32f1xx_hal_sram.c以及stm32f1xx_ll_fsmc.h/ stm32f1xx_hal_sram.h中。

  1. HAL_SRAM_Init函数
    SRAM的初始化函数,其声明如下:
    HAL_StatusTypeDef HAL_SRAM_Init(SRAM_HandleTypeDef *hsram,
    FSMC_NORSRAM_TimingTypeDef *Timing, FSMC_NORSRAM_TimingTypeDef *ExtTiming);
    函数描述:
    用于初始化 SRAM,注意这个函数不限制一定是SRAM,只要时序类似,均可使用。前面说过,这里我们把LCD当作SRAM使用,因为他们时序类似。
    函数形参:
    形参1是SRAM_HandleTypeDef结构体类型指针变量,其定义如下:
    typedef struct
    {
    FSMC_NORSRAM_TypeDef Instance; / 寄存器基地址 */
    FSMC_NORSRAM_EXTENDED_TypeDef Extended; / 扩展模式寄存器基地址 /
    FSMC_NORSRAM_InitTypeDef Init; /
    SRAM初始化结构体 /
    HAL_LockTypeDef Lock; /
    SRAM锁对象结构体 /
    __IO HAL_SRAM_StateTypeDef State; /
    SRAM设备访问状态 */
    DMA_HandleTypeDef hdma; / DMA结构体 */
    } SRAM_HandleTypeDef;
    1)Instance:指向FSMC寄存器基地址。我们直接写FSMC_NORSRAM_DEVICE即可,因为HAL库定义好了宏定义FSMC_NORSRAM_DEVICE,也就是如果是SRAM设备,直接填写这个宏定义标识符即可。
    2)Extended:指向FSMC扩展模式寄存器基地址,因为我们要配置的读写时序是不一样的。前面讲的FSMC_BCRx寄存器的EXTMOD位,我们会配置为1允许读写不同的时序,所以还要指定写操作时序寄存器地址,也就是通过参数Extended来指定的,这里设置为FSMC_NORSRAM_EXTENDED_DEVICE。
    3)Init:用于对FSMC的初始化配置,这个比较重要,后面再来讲解。
    4)Lock:用于配置锁状态。
    5)State:SRAM设备访问状态。
    6)hdma:在使用DMA时候才使用,这里就先不讲解了。
    成员变量Init是FSMC_NORSRAM_InitTypeDef结构体指针类型,该变量才是真正用来设置SRAM控制接口参数的。下面详细了解这个结构体定义:
typedef struct
{
  uint32_t NSBank;             		/* 存储区块号 */
  uint32_t DataAddressMux;         	/* 地址/数据复用使能 */
  uint32_t MemoryType;              	/* 存储器类型 */
  uint32_t MemoryDataWidth;        	/* 存储器数据宽度 */
  uint32_t BurstAccessMode;         	/* 突发模式配置 */
  uint32_t WaitSignalPolarity;     	/* 设置等待信号的极性 */
  uint32_t WrapMode;                 	/* 突发下存储器传输使能*/
uint32_t WaitSignalActive;       	/* 等待状态之前或等待状态期间 */
uint32_t WriteOperation;         	/* 存储器写使能 */
  uint32_t WaitSignal;              	/* 使能或者禁止通过等待信号来插入等待状态 */
  uint32_t ExtendedMode;            	/* 使能或者禁止使能扩展模式 */
  uint32_t AsynchronousWait;       	/* 用于异步传输期间,使能或者禁止等待信号 */
uint32_t WriteBurst;               	/* 用于使能或者禁止异步的写突发操作 */
uint32_t PageSize;                 	/* 设置页大小 */
}FSMC_NORSRAM_InitTypeDef;

NSBank用来指定使用到的存储块区号,我们硬件设计时使用的存储块区号4,所以选择值为FSMC_NORSRAM_BANK4。
DataAddressMux用来设置是否使能地址/数据复用,该变量仅对NOR/PSRAM有效,所以这里我们选择不使能地址/数据复用值FSMC_DATA_ADDRESS_MUX_DISABLE 即可。
MemoryType用来设置存储器类型,这里我们把LCD当SRAM使用,所以设置为FSMC_MEMORY_TYPE_SRAM 即可。
MemoryDataWidth用来设置存储器数据总线宽度,可选8位还是16位,这里我们选择16位数据宽度FSMC_NORSRAM_MEM_BUS_WIDTH_16。
WriteOperation用来设置存储器写使能,也就是是否允许写入。毫无疑问我们会进行存储器写操作,所以这里设置为FSMC_WRITE_OPERATION_ENABLE。
ExtendedMode用来设置是否使能扩展模式,也就是是否允许读写使用不同时序,前面讲解过本实验读写采用不同时序,所以设置值为使能值FSMC_EXTENDED_MODE_ENABLE。
其他参数WriteBurst,BurstAccessMode,WaitSignalPolarity,WaitSignalActive,WaitSignal,AsynchronousWait等是用在突发访问和异步时序情况下,这里我们不做过多讲解。
形参2 Timing和形参3 ExtTiming都是FSMC_NORSRAM_TimingTypeDef结构体类型指针变量,其定义如下:

typedef struct
{
uint32_t AddressSetupTime;         	/* 地址建立时间 */ 
uint32_t AddressHoldTime;            	/* 地址保持时间 */
  uint32_t DataSetupTime;               	/* 数据建立时间 */
  uint32_t BusTurnAroundDuration;     	/* 总线周转阶段的持续时间 */
  uint32_t CLKDivision;                  	/* CLK时钟输出信号的周期 */
uint32_t DataLatency;                  	/* 同步突发NOR FLASH的数据延迟 */
uint32_t AccessMode;                   	/* 异步模式配置 */
}FSMC_NORSRAM_TimingTypeDef;

对于本实验,读速度比写速度慢得多,因此读写时序不一样,所以对于Timing和ExtTiming要设置了不同的值,其中Timing设置写时序参数,ExtTiming设置读时序参数。
下面解析一下结构体的成员变量:
AddressSetupTime用来设置地址建立时间,可以理解为RD/WR的高电平时间。
AddressHoldTime用来设置地址保持时间,模式A并没有用到。
DataSetupTime用来设置数据建立时间,可以理解为RD/WR的低电平时间。
BusTurnAroundDuration用来配置总线周转阶段的持续时间,NOR FLASH用到。
CLKDivision用来配置CLK时钟输出信号的周期,以HCLK周期数表示。若控制异步存储器,该参数无效。
DataLatency用来设置同步突发NOR FLASH的数据延迟。若控制异步存储器,该参数无效。
AccessMode用来设置异步模式,HAL库允许其取值范围为FSMC_ACCESS_MODE_A、FSMC_ACCESS_MODE_B、FSMC_ACCESS_MODE_C和FSMC_ACCESS_MODE_D,这里我们用是异步模式A,所以取值为FSMC_ACCESS_MODE_A。
函数返回值:
HAL_StatusTypeDef枚举类型的值。
注意事项:
和其他外设一样,HAL库也提供了SRAM的初始化MSP回调函数,函数声明如下:
void HAL_SRAM_MspInit(SRAM_HandleTypeDef *hsram) ;
2. FSMC_NORSRAM_Extended_Timing_Init函数
FSMC_NORSRAM_Extended_Timing_Init函数是初始化扩展时序模式函数。其声明如下:

HAL_StatusTypeDef  FSMC_NORSRAM_Extended_Timing_Init(
FSMC_NORSRAM_EXTENDED_TypeDef *Device, FSMC_NORSRAM_TimingTypeDef *Timing,
 uint32_t Bank, uint32_t ExtendedMode);

函数描述:
该函数用于初始化扩展时序模式。
函数形参:
形参1是FSMC_NORSRAM_EXTENDED_TypeDef结构体类型指针变量,扩展模式寄存器基地址选择。
形参2是FSMC_NORSRAM_TimingTypeDef结构体类型指针变量,可以是读或者写时序结构体。
形参3是储存区块号。
形参4是使能或者禁止扩展模式。
函数返回值:
HAL_StatusTypeDef枚举类型的值。
注意事项:
该函数我们用于重新配置写或者读时序。
FSMC驱动LCD显示配置步骤
1)使能FSMC和相关GPIO时钟,并设置好GPIO工作模式
我们通过FSMC控制LCD,所以先需要使能FSMC以及相关GPIO口的时钟,并设置好GPIO的工作模式。
2)设置FSMC参数
这里我们需要设置FSMC的相关访问参数(数据位宽、访问时序、工作模式等),以匹配液晶驱动IC,这里我们通过HAL_SRAM_Init函数完成FSMC参数配置,详见本例程源码。
3)初始化LCD
由于我们例程兼容了很多种液晶驱动IC,所以先要读取对应IC的驱动型号,然后根据不同的IC型号来调用不同的初始化函数,完成对LCD的初始化。
注意:这些初始化函数里面的代码,都是由LCD厂家提供,一般不需要改动,也不需要深究,我们直接照抄即可。
4)实现LCD画点&读点函数
在初始化LCD完成以后,我们就可以控制LCD显示了,而最核心的一个函数,就是画点和读点函数,只要实现这两个函数,后续的各种LCD操作函数,都可以基于这两个函数实现。
5)实现其他LCD操作函数
在完成画点和读点两个最基础的LCD操作函数以后,我们就可以基于这两个函数实现各种LCD操作函数了,比如画线、画矩形、显示字符、显示字符串、显示数字等,如果不够用还可以根据自己需要来添加。详见本例程源码。
25.3.2 程序流程图
在这里插入图片描述

图25.3.2.1 TFTLCD(MCU屏)实验程序流程图
25.3.2 程序解析

  1. LCD驱动代码
    这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。液晶(LCD)驱动源码包括四个文件:lcd.c、lcd.h、lcd_ex.c和lcdfont.h。
    lcd.c和lcd.h文件是驱动函数和引脚接口宏定义以及函数声明等。lcd_ex.c存放各个LCD驱动IC的寄存器初始化部分代码,是lcd.c文件的补充文件,起到简化lcd.c文件的作用。lcdfont.h头文件存放了4种字体大小不一样的ASCII字符集(1212、1616、2424和3232)。这个跟oledfont.h头文件一样的,只是这里多了3232的ASCII字符集,制作方法请回顾OLED实验。
    下面我们还是先介绍lcd.h文件,首先是LCD的引脚定义:
    /
    LCD RST/WR/RD/BL/CS/RS 引脚 定义
  • LCD_D0~D15,由于引脚太多,就不在这里定义了,直接在lcd_init里面修改.所以在移植的时候,除了
  • 改这6个IO口, 还得改LCD_Init里面的D0~D15所在的IO口.
 */

/* RESET 和系统复位脚共用 所以这里不用定义 RESET引脚 */

#define LCD_WR_GPIO_PORT          	GPIOD
#define LCD_WR_GPIO_PIN            	GPIO_PIN_5
#define LCD_WR_GPIO_CLK_ENABLE()	do{ __HAL_RCC_GPIOD_CLK_ENABLE();}while(0)

#define LCD_RD_GPIO_PORT           	GPIOD
#define LCD_RD_GPIO_PIN             	GPIO_PIN_4
#define LCD_RD_GPIO_CLK_ENABLE()  	do{ __HAL_RCC_GPIOD_CLK_ENABLE();}while(0)   

#define LCD_BL_GPIO_PORT           	GPIOB
#define LCD_BL_GPIO_PIN            	GPIO_PIN_0
#define LCD_BL_GPIO_CLK_ENABLE() 	do{ __HAL_RCC_GPIOB_CLK_ENABLE();}while(0)

/* LCD_CS(需要根据LCD_FSMC_NEX设置正确的IO口) 和 LCD_RS(需要根据LCD_FSMC_AX设置正确的IO口) 引脚 定义 */
#define LCD_CS_GPIO_PORT           	GPIOG
#define LCD_CS_GPIO_PIN           	GPIO_PIN_12
#define LCD_CS_GPIO_CLK_ENABLE()	do{ __HAL_RCC_GPIOG_CLK_ENABLE();}while(0) 

#define LCD_RS_GPIO_PORT           	GPIOG
#define LCD_RS_GPIO_PIN             	GPIO_PIN_0
#define LCD_RS_GPIO_CLK_ENABLE()  	do{ __HAL_RCC_GPIOG_CLK_ENABLE();}while(0)
第一部分的宏定义是LCD WR/RD/BL/CS/RS/DATA 引脚定义,需要注意的是:LCD的RST引脚和系统复位脚连接在一起,所以不用单独使用一个IO口(节省一个IO口)。而DATA引脚直接用的是FSMC_D[x]引脚,具体可以查看前面的描述。
下面介绍我们在lcd.h里面定义的一个重要的结构体:
/* LCD重要参数集 */
typedef struct
{
    uint16_t width;		/* LCD 宽度 */
    uint16_t height;		/* LCD 高度 */
    uint16_t id;  		/* LCD ID */
    uint8_t dir;      	/* 横屏还是竖屏控制:0,竖屏;1,横屏。 */
    uint16_t wramcmd; 	/* 开始写gram指令 */
    uint16_t setxcmd;  	/* 设置x坐标指令 */
    uint16_t setycmd;  	/* 设置y坐标指令 */
} _lcd_dev;

extern _lcd_dev lcddev; /* 管理LCD重要参数 /
/
LCD的画笔颜色和背景色 /
extern uint32_t g_point_color; /
默认红色 /
extern uint32_t g_back_color; /
背景颜色.默认为白色 */
该结构体用于保存一些LCD重要参数信息,比如LCD的长宽、LCD ID(驱动IC型号)、LCD横竖屏状态等,这个结构体虽然占用了十几个字节的内存,但是却可以让我们的驱动函数支持不同尺寸的LCD,同时可以实现LCD横竖屏切换等重要功能,所以还是利大于弊的。最后声明_lcd_dev结构体类型变量lcddev,lcddev在lcd.c中定义。
紧接着就是g_point_color和g_back_color变量的声明,它们也是在lcd.c中被定义。g_point_color变量用于保存LCD的画笔颜色,g_back_color则是保存LCD的背景色。
下面是LCD背光控制IO口的宏定义:

/* LCD背光控制 */
#define LCD_BL(x)   do{ x ? 
          HAL_GPIO_WritePin(LCD_BL_GPIO_PORT, LCD_BL_GPIO_PIN, GPIO_PIN_SET) : 
          HAL_GPIO_WritePin(LCD_BL_GPIO_PORT, LCD_BL_GPIO_PIN, GPIO_PIN_RESET); 
                     }while(0)
本实验,我们用到FSMC驱动LCD,通过前面的介绍,我们知道TFTLCD的RS接在FSMC的A10上面,CS接在FSMC_NE4上,并且是16位数据总线。即我们使用的是FSMC存储器1的第4区,我们定义如下LCD操作结构体(在lcd.h里面定义):
/* LCD地址结构体 */
typedef struct
{
    volatile uint16_t LCD_REG;
    volatile uint16_t LCD_RAM;
} LCD_TypeDef;

/* LCD_BASE的详细解算方法:
 * 我们一般使用FSMC的块1(BANK1)来驱动TFTLCD液晶屏(MCU屏), 块1地址范围总大小为256MB,均分成4块:
 * 存储块1(FSMC_NE1)地址范围: 0X6000 0000 ~ 0X63FF FFFF
 * 存储块2(FSMC_NE2)地址范围: 0X6400 0000 ~ 0X67FF FFFF
 * 存储块3(FSMC_NE3)地址范围: 0X6800 0000 ~ 0X6BFF FFFF
 * 存储块4(FSMC_NE4)地址范围: 0X6C00 0000 ~ 0X6FFF FFFF
 *
 * 我们需要根据硬件连接方式选择合适的片选(连接LCD_CS)和地址线(连接LCD_RS)
 * 战舰F103开发板使用FSMC_NE4连接LCD_CS,FSMC_A10连接LCD_RS,16位数据线,计算方法如下:
 * 首先FSMC_NE4的基地址为: 0X6C00 0000;    NEx的基址为(x=1/2/3/4): 0X6000 0000 + (0X400 0000 * (x - 1))
 * FSMC_A10对应地址值: 2^10 * 2 = 0X800;  FSMC_Ay对应的地址为(y = 0 ~ 25): 2^y * 2
 *
 * LCD->LCD_REG,对应LCD_RS = 0(LCD寄存器); LCD->LCD_RAM,对应LCD_RS = 1(LCD数据)
 * 则 LCD->LCD_RAM的地址为:  0X6C00 0000 + 2^10 * 2 = 0X6C00 0800
 *    LCD->LCD_REG的地址可以为 LCD->LCD_RAM之外的任意地址.
 * 由于我们使用结构体管理LCD_REG 和 LCD_RAM(REG在前,RAM在后,均为16位数据宽度)
 * 因此 结构体的基地址(LCD_BASE) = LCD_RAM - 2 = 0X6C00 0800 -2
 *
 * 更加通用的计算公式为((片选脚FSMC_NEx)x=1/2/3/4, (RS接地址线FSMC_Ay)y=0~25):
 *          LCD_BASE = (0X6000 0000 + (0X400 0000 * (x - 1))) | (2^y * 2 -2)
 *          等效于(使用移位操作)
 *          LCD_BASE = (0X6000 0000 + (0X400 0000 * (x - 1))) | ((1 << y) * 2 -2)
 */
#define LCD_BASE   (uint32_t)((0X60000000 + (0X4000000 * (LCD_FSMC_NEX - 1))) |
 (((1 << LCD_FSMC_AX) * 2) -2))
#define LCD             ((LCD_TypeDef *) LCD_BASE)

其中LCD_BASE,必须根据我们外部电路的连接来确定,我们使用BANK1的存储块4的寻址范围为0X6C000000~6FFFFFFF,我们需要在这个地址范围内找到两个地址,实现对RS位(FSMC_A10位)的0和1的控制。这两个地址的取值方法,我们在前面的25.1.3的末尾已经详细说明了。为了方便控制和节省内存,我们使这两个地址变成相邻的两个16进制指针,这样就可以用前面定义的LCD_TypeDef来管理这两个地址了。
根据我们的算法和定义,我们将这个地址强制转换为LCD_TypeDef结构体地址,那么可以得到LCD->LCD_REG的地址就是0X6C00 07FE,对应A10的状态为0(即RS=0),而LCD->LCD_RAM的地址就是0X6C00 0800(结构体地址自增),对应A10的状态为1(即RS=1)。
所以,有了这个定义,当我们要往LCD写命令/数据的时候,可以这样写:

LCD->LCD_REG = CMD;  	/* 写命令 */
LCD->LCD_RAM = DATA; 	/* 写数据 */
而读的时候反过来操作就可以了,如下所示:
CMD = LCD->LCD_REG;		/* 读LCD寄存器 */
DATA = LCD->LCD_RAM;		/* 读LCD数据 */
这其中,CS、WR、RD和IO口方向都是由FSMC硬件自动控制,不需要我们手动设置了。
最后是一些其他的宏定义,包括LCD扫描方向和颜色,以及SSD1963相关配置参数。
下面开始对lcd.c文件介绍,先看LCD初始化函数,其定义如下:
/**
 * @brief     	初始化LCD
 *   @note      	该初始化函数可以初始化各种型号的LCD(详见本.c文件最前面的描述)
 *
 * @param       	无
 * @retval      	无
 */
void lcd_init(void)
{
    GPIO_InitTypeDef gpio_init_struct;
    FSMC_NORSRAM_TimingTypeDef fsmc_read_handle;
    FSMC_NORSRAM_TimingTypeDef fsmc_write_handle;

    LCD_CS_GPIO_CLK_ENABLE();   /* LCD_CS脚时钟使能 */
    LCD_WR_GPIO_CLK_ENABLE();   /* LCD_WR脚时钟使能 */
    LCD_RD_GPIO_CLK_ENABLE();   /* LCD_RD脚时钟使能 */
    LCD_RS_GPIO_CLK_ENABLE();   /* LCD_RS脚时钟使能 */
    LCD_BL_GPIO_CLK_ENABLE();   /* LCD_BL脚时钟使能 */
    
    gpio_init_struct.Pin = LCD_CS_GPIO_PIN;
    gpio_init_struct.Mode = GPIO_MODE_AF_PP;           		/* 推挽复用 */
    gpio_init_struct.Pull = GPIO_PULLUP;                		/* 上拉 */
    gpio_init_struct.Speed = GPIO_SPEED_FREQ_HIGH;        	/* 高速 */
    HAL_GPIO_Init(LCD_CS_GPIO_PORT, &gpio_init_struct);  	/* 初始化LCD_CS引脚 */

    gpio_init_struct.Pin = LCD_WR_GPIO_PIN;
    HAL_GPIO_Init(LCD_WR_GPIO_PORT, &gpio_init_struct);   	/* 初始化LCD_WR引脚 */

    gpio_init_struct.Pin = LCD_RD_GPIO_PIN;
    HAL_GPIO_Init(LCD_RD_GPIO_PORT, &gpio_init_struct);   	/* 初始化LCD_RD引脚 */

    gpio_init_struct.Pin = LCD_RS_GPIO_PIN;
    HAL_GPIO_Init(LCD_RS_GPIO_PORT, &gpio_init_struct);   	/* 初始化LCD_RS引脚 */

    gpio_init_struct.Pin = LCD_BL_GPIO_PIN;
    gpio_init_struct.Mode = GPIO_MODE_OUTPUT_PP;
    HAL_GPIO_Init(LCD_BL_GPIO_PORT, &gpio_init_struct);		/* 初始化LCD_BL引脚 */

    g_sram_handle.Instance = FSMC_NORSRAM_DEVICE;
    g_sram_handle.Extended = FSMC_NORSRAM_EXTENDED_DEVICE;
    
g_sram_handle.Init.NSBank = FSMC_NORSRAM_BANK4;      	/* 使用NE4 */
/*地址/数据线不复用*/
g_sram_handle.Init.DataAddressMux = FSMC_DATA_ADDRESS_MUX_DISABLE;     
/*16位数据宽度*/
g_sram_handle.Init.MemoryDataWidth = FSMC_NORSRAM_MEM_BUS_WIDTH_16;    
/*是否使能突发访问,仅对同步突发存储器有效,此处未用到*/
g_sram_handle.Init.BurstAccessMode = FSMC_BURST_ACCESS_MODE_DISABLE;   
/*等待信号的极性,仅在突发模式访问下有用*/
g_sram_handle.Init.WaitSignalPolarity = FSMC_WAIT_SIGNAL_POLARITY_LOW; 
/* 存储器是在等待周期之前的一个时钟周期还是等待周期期间使能NWAIT */
g_sram_handle.Init.WaitSignalActive = FSMC_WAIT_TIMING_BEFORE_WS;      
/* 存储器写使能 */
g_sram_handle.Init.WriteOperation = FSMC_WRITE_OPERATION_ENABLE;       
/* 等待使能位,此处未用到 */
g_sram_handle.Init.WaitSignal = FSMC_WAIT_SIGNAL_DISABLE;             
/* 读写使用不同的时序 */
g_sram_handle.Init.ExtendedMode = FSMC_EXTENDED_MODE_ENABLE; 
/* 是否使能同步传输模式下的等待信号,此处未用到 */
    g_sram_handle.Init.AsynchronousWait = FSMC_ASYNCHRONOUS_WAIT_DISABLE;  
    g_sram_handle.Init.WriteBurst = FSMC_WRITE_BURST_DISABLE;	/* 禁止突发写 */
    
/*FSMC读时序控制寄存器*/
/* 地址建立时间(ADDSET)为1个HCLK 1/72M=13.9ns */
    fsmc_read_handle.AddressSetupTime = 0;            
fsmc_read_handle.AddressHoldTime = 0;
/* 数据保存时间(DATAST)为16个HCLK  13.9*16=222.4ns */
    fsmc_read_handle.DataSetupTime = 15; /* 部分液晶驱动IC读数据时,速度不能太快 */
fsmc_read_handle.AccessMode = FSMC_ACCESS_MODE_A;	/* 模式A */

/*FSMC写时序控制寄存器*/
/* 地址建立时间(ADDSET)为1个HCLK 13.9ns */
    fsmc_write_handle.AddressSetupTime = 0;           
fsmc_write_handle.AddressHoldTime = 0;
/* 数据保存时间(DATAST)为2个HCLK 13.9*2= 27.8ns */
    fsmc_write_handle.DataSetupTime = 1;   
    fsmc_write_handle.AccessMode = FSMC_ACCESS_MODE_A;	/* 模式A */
    HAL_SRAM_Init(&g_sram_handle, &fsmc_read_handle, &fsmc_write_handle);
delay_ms(50);

    /* 尝试9341 ID的读取 */
    lcd_wr_regno(0XD3);
    lcddev.id = lcd_rd_data();			/* dummy read */
    lcddev.id = lcd_rd_data();  			/* 读到0X00 */
    lcddev.id = lcd_rd_data();  			/* 读取93 */
    lcddev.id <<= 8;
    lcddev.id |= lcd_rd_data(); 		/* 读取41 */

    if (lcddev.id != 0X9341)    			/* 不是 9341 , 尝试看看是不是 ST7789 */
    {
        lcd_wr_regno(0X04);
        lcddev.id = lcd_rd_data(); 		/* dummy read */
        lcddev.id = lcd_rd_data();  		/* 读到0X85 */
        lcddev.id = lcd_rd_data();  		/* 读取0X85 */
        lcddev.id <<= 8;
        lcddev.id |= lcd_rd_data(); 		/* 读取0X52 */
        
        if (lcddev.id == 0X8552)   		/* 将8552的ID转换成7789 */
        {
            lcddev.id = 0x7789;
        }

        if (lcddev.id != 0x7789)        	/* 也不是ST7789, 尝试是不是 NT35310 */
        {
            lcd_wr_regno(0XD4);
            lcddev.id = lcd_rd_data();  	/* dummy read */
            lcddev.id = lcd_rd_data();  	/* 读回0X01 */
            lcddev.id = lcd_rd_data();  	/* 读回0X53 */
            lcddev.id <<= 8;
            lcddev.id |= lcd_rd_data(); 	/* 这里读回0X10 */

            if (lcddev.id != 0X5310)    	/* 也不是NT35310,尝试看看是不是NT35510 */
            {
                /* 发送秘钥(厂家提供,照搬即可) */
                lcd_write_reg(0xF000, 0x0055);
                lcd_write_reg(0xF001, 0x00AA);
                lcd_write_reg(0xF002, 0x0052);
                lcd_write_reg(0xF003, 0x0008);
                lcd_write_reg(0xF004, 0x0001);
                
                lcd_wr_regno(0xC500);  		/* 读取ID高8位 */
                lcddev.id = lcd_rd_data();	/* 读回0X55 */
                lcddev.id <<= 8;

                lcd_wr_regno(0xC501);     		/* 读取ID低8位 */
                lcddev.id |= lcd_rd_data();	/* 读回0X10 */
                delay_ms(5);

                if (lcddev.id != 0X5510)     /* 也不是NT5510,尝试看看是不是SSD1963 */
                {
                    lcd_wr_regno(0XA1);
                    lcddev.id = lcd_rd_data();
                    lcddev.id = lcd_rd_data();  /* 读回0X57 */
                    lcddev.id <<= 8;
                    lcddev.id |= lcd_rd_data(); /* 读回0X61 */
/* SSD1963读回的ID是5761H,为方便区分,我们强制设置为1963 */
                    if (lcddev.id == 0X5761)lcddev.id = 0X1963;
                }
            }
        }
    }

    /* 特别注意, 如果在main函数里面屏蔽串口1初始化, 则会卡死在printf
     * 里面(卡死在f_putc函数), 所以, 必须初始化串口1, 或者屏蔽掉下面
     * 这行 printf 语句 !!!!!!!
     */
    printf("LCD ID:%x
", lcddev.id); /* 打印LCD ID */

    if (lcddev.id == 0X7789)
    {
        lcd_ex_st7789_reginit();   	/* 执行ST7789初始化 */
    }
    else if (lcddev.id == 0X9341)
    {
        lcd_ex_ili9341_reginit();   	/* 执行ILI9341初始化 */
    }
    else if (lcddev.id == 0x5310)
    {
        lcd_ex_nt35310_reginit();   	/* 执行NT35310初始化 */
    }
    else if (lcddev.id == 0x5510)
    {
        lcd_ex_nt35510_reginit();   	/* 执行NT35510初始化 */
    }
    else if (lcddev.id == 0X1963)
    {
        lcd_ex_ssd1963_reginit();   	/* 执行SSD1963初始化 */
        lcd_ssd_backlight_set(100);	/* 背光设置为最亮 */
    }

    lcd_display_dir(0); /* 默认为竖屏 */
    LCD_BL(1);          	/* 点亮背光 */
    lcd_clear(WHITE);
}

该函数先对FSMC相关IO进行初始化,然后使用HAL_SRAM_Init函数初始化FSMC控制器,同时我们使用HAL_SRAM_MspInit回调函数来初始化相应的IO口,最后读取LCD控制器的型号,根据控制IC的型号执行不同的初始化代码,这样提高了整个程序的通用性。为了简化lcd.c的初始化程序,不同控制IC的芯片对应的初始化程序(如:lcd_ex_st7789_reginit()、lcd_ex_ili9341_reginit()等)我们放在lcd_ex.c文件中,这些初始化代码完成对LCD寄存器的初始化,由LCD厂家提供,一般是不需要做任何修改的,我们直接调用就可以了。
下面是6个简单,但是很重要的函数:

/**
 * @brief       	LCD写数据
 * @param       	data: 要写入的数据
 * @retval      	无
 */
void lcd_wr_data(volatile uint16_t data)
{
    data = data;            /* 使用-O2优化的时候,必须插入的延时 */
    LCD->LCD_RAM = data;
}

/**
 * @brief    	LCD写寄存器编号/地址函数
 * @param     	regno: 寄存器编号/地址
 * @retval   	无
 */
void lcd_wr_regno(volatile uint16_t regno)
{
    regno = regno;         	/* 使用-O2优化的时候,必须插入的延时 */
    LCD->LCD_REG = regno;   	/* 写入要写的寄存器序号 */
}

/**
 * @brief    	LCD写寄存器
 * @param     	regno:寄存器编号/地址
 * @param       	data:要写入的数据
 * @retval      	无
 */
void lcd_write_reg(uint16_t regno, uint16_t data)
{
    LCD->LCD_REG = regno;  	/* 写入要写的寄存器序号 */
    LCD->LCD_RAM = data;   	/* 写入数据 */
}

/**
 * @brief     	LCD延时函数,仅用于部分在mdk -O1时间优化时需要设置的地方
 * @param       	t:延时的数值
 * @retval      	无
 */
static void lcd_opt_delay(uint32_t i)
{
    while (i--);	 /*使用AC6时空循环可能被优化,可使用while(1) __asm volatile(""); */
}

/**
 * @brief   		LCD读数据
 * @param       	无
 * @retval      	读取到的数据
 */
static uint16_t lcd_rd_data(void)
{
volatile uint16_t ram; 	/* 防止被优化 */
lcd_opt_delay(2);
    ram = LCD->LCD_RAM;
    return ram;
}

/**
 * @brief    	准备写GRAM
 * @param       	无
 * @retval      	无
 */
void lcd_write_ram_prepare(void)
{
    LCD->LCD_REG = lcddev.wramcmd;
}

因为FSMC自动控制了WR/RD/CS等这些信号,所以这6个函数实现起来都非常简单,我们就不多说,注意,上面有几个函数,我们添加了一些对MDK –O2优化的支持,去掉的话,在-O2优化的时候会出问题。这些函数实现功能见函数前面的备注,通过这几个简单函数的组合,我们就可以对LCD进行各种操作了。
下面要介绍的函数是坐标设置函数,该函数代码如下:

/**
 * @brief    	设置光标位置(对RGB屏无效)
 * @param       	x,y: 坐标
 * @retval      	无
 */
void lcd_set_cursor(uint16_t x, uint16_t y)
{
    if (lcddev.id == 0X1963)
    {
        if (lcddev.dir == 0)   	/* 竖屏模式, x坐标需要变换 */
        {
            x = lcddev.width - 1 - x;
            lcd_wr_regno(lcddev.setxcmd);
            lcd_wr_data(0);
            lcd_wr_data(0);
            lcd_wr_data(x >> 8);
            lcd_wr_data(x & 0XFF);
        }
        else                    	/* 横屏模式 */
        {
            lcd_wr_regno(lcddev.setxcmd);
            lcd_wr_data(x >> 8);
            lcd_wr_data(x & 0XFF);
            lcd_wr_data((lcddev.width - 1) >> 8);
            lcd_wr_data((lcddev.width - 1) & 0XFF);
        }

        lcd_wr_regno(lcddev.setycmd);
        lcd_wr_data(y >> 8);
        lcd_wr_data(y & 0XFF);
        lcd_wr_data((lcddev.height - 1) >> 8);
        lcd_wr_data((lcddev.height - 1) & 0XFF);

    }
    else if (lcddev.id == 0X5510)
    {
        lcd_wr_regno(lcddev.setxcmd);
        lcd_wr_data(x >> 8);
        lcd_wr_regno(lcddev.setxcmd + 1);
        lcd_wr_data(x & 0XFF);
        lcd_wr_regno(lcddev.setycmd);
        lcd_wr_data(y >> 8);
        lcd_wr_regno(lcddev.setycmd + 1);
        lcd_wr_data(y & 0XFF);
    }
    else    /* 9341/5310/7789 等 设置坐标 */
    {
        lcd_wr_regno(lcddev.setxcmd);
        lcd_wr_data(x >> 8);
        lcd_wr_data(x & 0XFF);
        lcd_wr_regno(lcddev.setycmd);
        lcd_wr_data(y >> 8);
        lcd_wr_data(y & 0XFF);
    }
}
该函数实现将LCD的当前操作点设置到指定坐标(x,y)。因为9341/5310/1963/5510等的设置有些不太一样,所以进行了区别对待。
接下来介绍画点函数,其定义如下:
/**
 * @brief       	画点
 * @param       	x,y: 坐标
 * @param       	color: 点的颜色(32位颜色,方便兼容LTDC)
 * @retval      	无
 */
void lcd_draw_point(uint16_t x, uint16_t y, uint32_t color)
{
    lcd_set_cursor(x, y);      	/* 设置光标位置 */
    lcd_write_ram_prepare();   	/* 开始写入GRAM */
    LCD->LCD_RAM = color;
}

该函数实现比较简单,就是先设置坐标,然后往坐标写颜色。lcd_draw_point函数虽然简单,但是至关重要,其他几乎所有上层函数,都是通过调用这个函数实现的。
下面介绍读点函数,用于读取LCD的GRAM,这里说明一下,为什么OLED模块没做读GRAM的函数,而这里做了。因为OLED模块是单色的,所需要全部GRAM也就1K个字节,而TFTLCD模块为彩色的,点数也比OLED模块多很多,以16位色计算,一款320×240的液晶,需要320×240×2个字节来存储颜色值,也就是也需要150K字节,这对任何一款单片机来说,都不是一个小数目了。而且我们在图形叠加的时候,可以先读回原来的值,然后写入新的值,在完成叠加后,我们又恢复原来的值。这样在做一些简单菜单的时候,是很有用的。这里我们读取TFTLCD模块数据的函数为LCD_ReadPoint,该函数直接返回读到的GRAM值。该函数使用之前要先设置读取的GRAM地址,通过lcd_set_cursor函数来实现。lcd_read_point的代码如下:

/**
 * @brief       	读取个某点的颜色值
 * @param       	x,y:坐标
 * @retval      	此点的颜色(32位颜色,方便兼容LTDC)
 */
uint32_t lcd_read_point(uint16_t x, uint16_t y)
{
    uint16_t r = 0, g = 0, b = 0;

if (x >= lcddev.width || y >= lcddev.height)return 0; /* 超过了范围,直接返回 */

    lcd_set_cursor(x, y);      	/* 设置坐标 */

    if (lcddev.id == 0X5510)
    {
        lcd_wr_regno(0X2E00);  	/* 5510 发送读GRAM指令 */
    }
    else
    {
        lcd_wr_regno(0X2E);    	/* 9341/5310/1963/7789 等发送读GRAM指令 */
    }

    r = lcd_rd_data();         	/* 假读(dummy read) */

    if (lcddev.id == 0X1963)return r;   /* 1963 直接读就可以 */

    r = lcd_rd_data();  			/* 实际坐标颜色 */

    /* 9341/5310/5510/7789 要分2次读出 */
b = lcd_rd_data();
/* 对于 9341/5310/5510/7789, 第一次读取的是RG的值,R在前,G在后,各占8位 */
    g = r & 0XFF; 
g <<= 8;
/* 9341/5310/5510/7789 需要公式转换一下 */
    return (((r >> 11) << 11) | ((g >> 10) << 5) | (b >> 11)); 

}
在lcd_read_point函数中,因为我们的代码不止支持一种LCD驱动器,所以,我们根据不同的LCD驱动器((lcddev.id)型号,执行不同的操作,以实现对各个驱动器兼容,提高函数的通用性。
第十个要介绍的是字符显示函数lcd_show_char,该函数同前面OLED模块的字符显示函数差不多,但是这里的字符显示函数多了1个功能,就是可以以叠加方式显示,或者以非叠加方式显示。叠加方式显示多用于在显示的图片上再显示字符。非叠加方式一般用于普通的显示。该函数实现代码如下:

/**
 * @brief     	在指定位置显示一个字符
 * @param       	x,y  : 坐标
 * @param       	chr  : 要显示的字符:" "--->"~"
 * @param       	size : 字体大小 12/16/24/32
 * @param       	mode : 叠加方式(1); 非叠加方式(0);
 * @retval      	无
 */
void lcd_show_char(uint16_t x, uint16_t y, char chr, uint8_t size, 
uint8_t mode, uint16_t color)
{
    uint8_t temp, t1, t;
    uint16_t y0 = y;
    uint8_t csize = 0;
    uint8_t *pfont = 0;
/* 得到字体一个字符对应点阵集所占的字节数 */
csize = (size / 8 + ((size % 8) ? 1 : 0)) * (size / 2); 
/* 得到偏移后的值(ASCII字库是从空格开始取模,所以-' '就是对应字符的字库) */
    chr = chr - ' ';    

    switch (size)
    {
        case 12:
            pfont = (uint8_t *)asc2_1206[chr];  /* 调用1206字体 */
            break;

        case 16:
            pfont = (uint8_t *)asc2_1608[chr];  /* 调用1608字体 */
            break;

        case 24:
            pfont = (uint8_t *)asc2_2412[chr];  /* 调用2412字体 */
            break;

        case 32:
            pfont = (uint8_t *)asc2_3216[chr];  /* 调用3216字体 */
            break;

        default:
            return ;
    }

    for (t = 0; t < csize; t++)
    {
        temp = pfont[t]; 		/* 获取字符的点阵数据 */

        for (t1 = 0; t1 < 8; t1++)  	/* 一个字节8个点 */
        {
            if (temp & 0x80)		/* 有效点,需要显示 */
            {
                lcd_draw_point(x, y, color);    	/* 画点出来,要显示这个点 */
            }
            else if (mode == 0) 	/* 无效点,不显示 */
            {
/* 画背景色,相当于这个点不显示(注意背景色由全局变量控制) */
                lcd_draw_point(x, y, g_back_color);
            }

            temp <<= 1;	/* 移位, 以便获取下一个位的状态 */
            y++;

            if (y >= lcddev.height)return;  		/* 超区域了 */

            if ((y - y0) == size)  	/* 显示完一列了? */
            {
                y = y0;	/* y坐标复位 */
                x++;  		/* x坐标递增 */

                if (x >= lcddev.width)return;		/* x坐标超区域了 */

                break;
            }
        }
    } 
}

在lcd_show_char函数里面,我们用到了四个字符集点阵数据数组asc2_1206、asc2_1608、asc2_2412和asc2_3216。
lcd.c的函数比较多,其他的函数请大家自行查看源码,都有详细的注释。
2. main.c代码
在main.c里面编写如下代码:

int main(void)
{
    uint8_t x = 0;
    uint8_t lcd_id[12];

    HAL_Init();                              	/* 初始化HAL库 */
    sys_stm32_clock_init(RCC_PLL_MUL9);  	/* 设置时钟, 72Mhz */
    delay_init(72);                         	/* 延时初始化 */
    usart_init(115200);                     	/* 串口初始化为115200 */
    led_init();                            		/* 初始化LED */
lcd_init();                             		/* 初始化LCD */

    g_point_color = RED;
sprintf((char *)lcd_id, "LCD ID:%04X", lcddev.id); /* 将id打印到lcd_id数组 */

    while (1)
    {
       switch (x)
       {
        case 0: 	lcd_clear(WHITE); 	break;
        case 1: 	lcd_clear(BLACK);	break;
        case 2: 	lcd_clear(BLUE);	break;
        case 3: 	lcd_clear(RED);		break;
        case 4: 	lcd_clear(MAGENTA);	break;
        case 5: 	lcd_clear(GREEN);	break;
        case 6: 	lcd_clear(CYAN); 	break;
        case 7: 	lcd_clear(YELLOW); 	break;
        case 8: 	lcd_clear(BRRED); 	break;
        case 9: 	lcd_clear(GRAY); 	break;
        case 10: lcd_clear(LGRAY); 	break;
        case 11: lcd_clear(BROWN); 	break;
       }

        lcd_show_string(10, 40, 240, 32, 32, "STM32", RED);
        lcd_show_string(10, 80, 240, 24, 24, "TFTLCD TEST", RED);
        lcd_show_string(10, 110, 240, 16, 16, "ATOM@ALIENTEK", RED);
        lcd_show_string(10, 130, 240, 16, 16,(char *)lcd_id, RED); /*显示LCD_ID*/
        
x++;
        if (x == 12)
            x = 0;

        LED0_TOGGLE(); 	/* 红灯闪烁 */
        delay_ms(1000);
    }
}

main函数功能主要是显示一些固定的字符,字体大小包括3216、2412、168和126四种,同时显示LCD驱动IC的型号,然后不停的切换背景颜色,每1s切换一次。而LED0也会不停的闪烁,指示程序已经在运行了。其中我们用到一个sprintf的函数,该函数用法同printf,只是sprintf把打印内容输出到指定的内存区间上,最终在死循环中通过lcd_show_strinig函数进行屏幕显示,sprintf的详细用法,请百度学习。
特别注意:
usart_init函数,不能去掉,因为在lcd_init函数里面调用了printf,所以一旦去掉这个初始化,就会死机!实际上,只要你的代码有用到printf,就必须初始化串口,否则都会死机,停在usart.c里面的fputc函数出不来。
25.4 下载验证
下载代码后,LED0不停的闪烁,提示程序已经在运行了。同时可以看到TFTLCD模块的显示背景色不停切换,如图25.4.1所示:
在这里插入图片描述

图25.4.1 TFTLCD显示效果图
此外,为了让大家能直观的了解LCD屏的扫描方式,我们额外编写了两个main.c文件(main1.c和main2.c,放到User文件夹中),方便大家编译下载,观察现象。
使用方法:关闭工程后,先把原实验中的main.c改成其他名字,然后把main1.c重命名为main.c,双击keilkill.bat清理编译的中间文件,最后打开工程重新编译下载,就可以观察实验现象。观察了main1.c,可以再观察main2.c,main2.c文件的操作方法类似。这两个main.c文件的程序非常简单,这里就不讲解,具体请看源码。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。