正点原子STM32(基于HAL库)6

TFTLCD(MCU 屏)实验

前面我们介绍了OLED 模块及其显示,但是该模块只能显示单色/双色,不能显示彩色,而且尺寸也较小。本章我们将介绍正点原子的TFT LCD 模块(MCU 屏),该模块采用TFTLCD面板,可以显示16 位色的真彩图片。在本章中,我们将使用开发板底板上的TFTLCD 接口(仅支持MCU 屏,本章仅介绍MCU 屏的使用),来点亮TFT LCD,并实现ASCII 字符和彩色的显示等功能,并在串口打印LCD 控制器ID,同时在LCD 上面显示。

TFTLCD 简介

本章我们将通过STM32F103 的FSMC 外设来控制TFTLCD 的显示,这样我们就可以用STM32 输出一些信息到显示屏上了。

TFTLCD 简介

液晶显示器,即Liquid Crystal Display,利用了液晶导电后透光性可变的特性,配合显示器光源、彩色滤光片和电压控制等工艺,最终可以在液晶阵列上显示彩色的图像。目前液晶显示技术以TN、STN、TFT 三种技术为主,TFTLCD 即采用了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 所示:

在这里插入图片描述

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 显示的过程,就是按其显示驱动芯片的时序,把色彩和位置信息正确地写入对应的寄存器。

液晶显示控制器

正点原子提供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 所示:

在这里插入图片描述
表25.1.2.5 0x2A 指令描述

在默认扫描方式时,该指令用于设置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,随后的规律为:B1R2→G2B2→R3G3→B3R4→G4B4→R5G5…以此类推。如果我们只需要读取一个点的颜色值,那么只需要接收到参数3 即可,如果要连续读取(利用GRAM地址自增,方法同上),那么就按照上述规律去接收颜色数据。
以上,就是操作ILI9341 常用的几个指令,通过这几个指令,我们便可以很好的控制ILI9341显示我们所要显示的内容了。

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 Bank1 存储区选择表

表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 并口的操作,最终完成对液晶控制器的控制。

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 的原理。只有理解了原理,编程时才会得心应手。

硬件设计

  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 个。

程序设计

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) ;
  1. 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.1 TFTLCD(MCU 屏)实验程序流程图

程序解析

  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 头文件一样的,只是这里多了32*32 的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,尝试看看是不是ST7796 */
            {
                lcd_wr_regno(0XD3);
                lcddev.id = lcd_rd_data(); /* dummy read */
                lcddev.id = lcd_rd_data(); /* 读到0X00 */
                lcddev.id = lcd_rd_data(); /* 读取0X77 */
                lcddev.id <<= 8;
                lcddev.id |= lcd_rd_data(); /* 读取0X96 */
                if (lcddev.id != 0x7796)    /* 也不是ST7796,尝试看看是不是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低八位*/
                    lcddev.id = lcd_rd_data(); /* 读回0x55 */
                    lcddev.id <<= 8;
                    lcd_wr_regno(0xC501);       /* 读取ID高八位*/
                    lcddev.id |= lcd_rd_data(); /* 读回0x10 */
                    /* 等待5ms, 因为0XC501指令对1963来说就是软件复位指令,
                    等待5ms让1963复位完成再操作*/
                    delay_ms(5);
                    if (lcddev.id != 0x5510) /* 也不是NT5510,尝试看看是不是ILI9806 */
                    {
                        lcd_wr_regno(0XD3);
                        lcddev.id = lcd_rd_data(); /* dummy read */
                        lcddev.id = lcd_rd_data(); /* 读回0X00 */
                        lcddev.id = lcd_rd_data(); /* 读回0X98 */
                        lcddev.id <<= 8;
                        lcddev.id |= lcd_rd_data(); /* 读回0X06 */
                        if (lcddev.id != 0x9806)    /* 也不是ILI9806,尝试是不是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\r\n", 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 == 0x7796)
    {
        lcd_ex_st7796_reginit(); /* 执行ST7796初始化*/
    }
    else if (lcddev.id == 0x5510)
    {
        lcd_ex_nt35510_reginit(); /* 执行NT35510初始化*/
    }
    else if (lcddev.id == 0x9806)
    {
        lcd_ex_ili9806_reginit(); /* 执行ILI9806初始化*/
    }
    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/7796/9806 等设置坐标*/
    {
        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/7796/9806 等发送读GRAM指令*/
    }
    r = lcd_rd_data(); /* 假读(dummy read) */
    if (lcddev.id == 0X1963)
        return r;      /* 1963 直接读就可以*/
    r = lcd_rd_data(); /* 实际坐标颜色*/
    /* ILI9341/NT35310/NT35510/ST7789/ILI9806 要分2次读出*/
    b = lcd_rd_data();
    /* 对于9341/5310/5510/7789/9806, 第一次读取的是RG的值,R在前,G在后,各占8位*/
    g = r & 0XFF;
    g <<= 8;
    /* 9341/5310/5510/7789/9806 需要公式转换一下*/
    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 函数出不来。

下载验证

下载代码后,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 文件的程序非常简单,这里就不讲解,具体请看源码。

LTDC LCD(RGB 屏)实验

在低17 章,我们介绍了TFTLCD 模块(MCU 屏)的使用,但是高分辨率的屏(超过800*480),一般都没有MCU 屏接口,而是使用RGB 接口的,这种接口的屏,就需要用到STM32F429 的LTDC 来驱动了。在本章中,我们将使用阿波罗STM32F429 开发板核心板上的LCD 接口(仅支持RGB 屏,本章介绍RGB 屏的使用),来点亮LCD,并实现ASCII 字符和彩色的显示等功能,并在串口打印LCD ID,同时在LCD 上面显示。本章分为如下几个部分:

RGBLCD&LTDC 简介

本章我们将通过STM32F429 的LTDC 接口来驱动RGBLCD 的显示,另外,STM32F429的LTDC 还有DMA2D 图形加速,我们也顺带进行介绍。本节分为三个部分,分别介绍RGBLCD、LTDC 和DMA2D。

RGBLCD 简介

在第17 章,我们已经介绍过TFTLCD 液晶了,实际上RGBLCD 也是TFTLCD,只是接口不同而已。接下来我们简单介绍一下RGBLCD 的驱动。
(1)RGBLCD 的信号线
RGBLCD 的信号线如表19.1.1.1 所示:

在这里插入图片描述

表19.1.1.1 RGBLCD 信号线
一般的RGB 屏都有如表19.1.1.1 所示的信号线,有24 根颜色数据线(RGB 各站8 根,即RGB888 格式),这样可以表示最多1600W 色,DE、VS、HS 和DCLK,用于控制数据传输。
(2)RGBLCD 的驱动模式
RGB 屏一般有2 种驱动模式:DE 模式和HV 模式。DE 模式使用DE 信号来确定有效数据(DE 为高/低时,数据有效),而HV 模式,则需要行同步和场同步,来表示扫描的行和列。
DE 模式和HV 模式的行扫描时序图(以800*480 的LCD 面板为例),如图19.1.1.1 所示:

在这里插入图片描述

图19.1.1.1 DE/HV 模式行扫描时序图
从图中可以看出,DE 和HV 模式,时序基本一样,DEN 模式需要提供DE 信号(DEN),而HV 模式,则无需DE 信号。图中的HSD 即HS 信号,用于行同步,注意:在DE 模式下面,是可以不用HS 信号的,即不接HS 信号,液晶照样可以正常工作。
图中的thpw 为水平同步有效信号脉宽,用于表示一行数据的开始;thb 为水平后廊,表示从水平有效信号开始,到有效数据输出之间的像素时钟个数;thfp 为水平前廊,表示一行数据结束后,到下一个水平同步信号开始之前的像素时钟个数;这几个时间非常重要,在配置LTDC的时候,需要根据LCD 的数据手册,进行正确的设置。

图19.1.1.1 仅是一行数据的扫描,输出800 个像素点数据,而液晶面板总共有480 行,这就还需要一个垂直扫描时序图,如图19.1.1.2 所示:
在这里插入图片描述

图19.1.1.2 垂直扫描时序图
图中的VSD 就是垂直同步信号,HSD 就是水平同步信号,DE 为数据使能信号。由图可知,一个垂直扫描,刚好就是480 个有效的DE 脉冲信号,每一个DE 时钟周期,扫描一行,总共扫描480 行,完成一帧数据的显示。这就是800*480 的LCD 面板扫描时序,其他分辨率的LCD
面板,时序类似。
图中的tvpw 为垂直同步有效信号脉宽,用于表示一帧数据的开始;tvb 为垂直后廊,表示垂直同步信号以后的无效行数,tvfp 为垂直前廊,表示一帧数据输出结束后,到下一个垂直同步信号开始之前的无效行数;这几个时间同样在配置LTDC 的时候,需要进行设置。

(3)ALIENTEK RGBLCD 模块
ALIENTEK 目前提供大家三款RGBLCD 模块:ATK-4342(4.3 寸,480272)、ATK-7084(7 寸,800480)和ATK-7016(7 寸,1024*600),这里我们以ATK-7084 为例,给大家介绍。该模块的接口原理图如图19.1.1.3 所示:
在这里插入图片描述

图19.1.1.3 ATK-7084 模块对外接口原理图
图中J1 就是对外接口,是一个40PIN 的FPC 座(0.5mm 间距),通过FPC 线,可以连接到阿波罗STM32F429 开发板的核心板上面,从而实现和STM32F429 的连接。该接口十分完善,采用RGB888 格式,并支持DE&HV 模式,还支持触摸屏(电阻/电容)和背光控制。右侧的几个电阻,并不是都焊接的,而是可以用户自己选择。默认情况,R1 和R6 焊接,设置LCD_LR
和LCD_UD,控制LCD 的扫描方向,是从左到右,从上到下(横屏看)。而LCD_R7/G7/B7 则用来设置LCD 的ID,由于RGBLCD 没有读写寄存器,也就没有所谓的ID,这里我们通过在模块上面,控制R7/G7/B7 的上/下拉,来自定义LCD 模块的ID,帮助MCU 判断当前LCD 面板的分辨率和相关参数,以提高程序兼容性。这几个位的设置关系如表19.1.1.2 所示:

在这里插入图片描述

表19.1.1.2 ALIENTEK RGBLCD 模块ID 对应关系
ATK-7084 模块,就设置M2:M0=001 即可。这样,我们在程序里面,读取LCD_R7/G7/B7,得到M0:M2 的值,从而判断RGBLCD 模块的型号,并执行不同的配置,即可实现不同LCD模块的兼容。
RGBLCD 我们就给大家介绍到这里,接下来,我们介绍LTDC。

LTDC 简介

STM32F429xx 系列芯片都带有TFT LCD 控制器,即LTDC,通过这个LTDC,STM32F429可以直接外接RGBLCD 屏,实现液晶驱动。STM32F429 的LTDC 具有如下特点:
24 位RGB 并行像素输出;每像素8 位数据(RGB888)
2 个带有专用FIFO 的显示层(FIFO 深度64x32 位)
支持查色表(CLUT),每层高达256 种颜色(256x24 位)
可针对不同显示面板编程时序
可编程背景色
可编程HSync、VSync 和数据使能(DE)信号的极性
每层有多达8 种颜色格式可供选择:ARGB8888、RGB888、RGB565、ARGB1555、ARGB4444、
L8(8 位Luminance 或CLUT)、AL44(4 位alpha+4 位luminance)和AL88(8 位alpha+8位luminance)
每通道的低位采用伪随机抖动输出(红色、绿色、蓝色的抖动宽度为2 位)
使用alpha 值(每像素或常数)在两层之间灵活混合
色键(透明颜色)
可编程窗口位置和大小
支持薄膜晶体管(TFT) 彩色显示器
AHB 主接口支持16 个字的突发
高达4 个可编程中断事件
LTDC 控制器主要包含:信号线、图像处理单元、AHB 接口、配置和状态寄存器以及时钟部分,其框图如图19.1.2.1 所示:

在这里插入图片描述

图19.1.2.1 LTDC 控制器框图
① 信号线
这里就包含了我们前面提到的RGBLCD 驱动所需要的所有信号线,这些信号线通过STM32F429 核心板板载的LCD 接口引出,其信号说明和IO 连接关系,见表19.1.2.1:
在这里插入图片描述

LTDC 总共有24 位数据线,支持RGB888 格式,但是我们为了节省IO,并提高图片显示速度,使用RGB565 颜色格式,这样的话,只需要16 个IO 口,当使用RGB565 格式的时候,LCD 面板的数据线,必须连接到LTDC 数据线的MSB,即:LTDC 的LCD_R[7:3]接RGBLCD的R[7:3],LTDC 的LCD_G[7:2]接RGBLCD 的G[7:2],LTDC 的LCD_B[7:3]接RGBLCD 的B[7:3],这样,RGB 数据线分别是5:6:5,即RGB565 格式。表中对应IO 就是我们STM32F429核心板上面,LCD 接口所连接的IO。
② 图像处理单元
此部分先从AHB 接口获取显存中的图像数据,然后经过层FIFO(有2 个,对应2 个层)缓存,每个层FIFO 具有64*32 位存储深度,然后经过像素格式转换器(PFC),把从层的所选输入像素格式转换为ARGB8888 格式,再通过混合单元,把两层数据合并,混合得到单层要显示的数据,最后经过抖动单元处理(可选)后,输出给LCD 显示。
这里的ARGB8888,即带8 位透明通道,即最高8 位为透明通道参数,表示透明度,值越大,则约不透明,值越小,越透明。比如A=255 时,表示完全不透明,而A=0 时,表示完全透明。RGB888 就表示R、G、B 各8 位,可表示的颜色深度为1600W 色。
STM32F429 的LTDC 总共有三个层:背景层、第一层和第二层,其中,背景层只可以是纯色(即单色),而第一层和第二层都可以用来显示信息,混合单元会将三个层混合起来,进行显示,显示关系如图19.1.2.2 所示:

在这里插入图片描述

图19.1.2.2 三个层混合关系
从图中可以看出,第二层位于最顶端,背景层位于最低端,混合单元首先将第一层与背景层进行混合,随后,第二层与第一层和第二层的混合颜色结果再次混合,完成混合后,送给LCD显示。
③ AHB 接口
由于LTDC 驱动RGBLCD 的时候,需要有很多内存来做显存,比如一个800480 的屏幕,按一般的16 位RGB565 模式,一个像素需要2 个字节的内存,总共需要:800480*2=768K 字节内存,STM32 内部是没有这么多内存的,所以必须借助外部SDRAM,而SDRAM 是挂在AHB 总线上的,LTDC 的AHB 接口,就是用来将显存数据,从SDRAM 存储器传输到FIFO 里面。
④ 配置和状态寄存器
此部分包含了LTDC 的各种配置寄存器以及状态寄存器,用于控制整个LTDC 的工作参数,主要有:各信号的有效电平、垂直/水平同步时间参数、像素格式、数据使能等等。LTDC 的同步时序(HV 模式)控制框图,如图19.1.2.3 所示:
在这里插入图片描述

图19.1.2.3 LTDC 同步时序框图
图中有效显示区域,就是我们RGBLCD 面板的显示范围(即分辨率),有效宽度*有效高度,就是LCD 的分辨率。另外,这里还有的参数包括:HSYNC 的宽度(HSW)、VSYNC 的宽度(VSW)、HBP、HFP、VBP 和VFP 等,这些参数的说明,见表19.1.2.2:

在这里插入图片描述

表19.1.2.2 LTDC 驱动时序参数
如果RGBLCD 使用的是DE 模式,LTDC 也只需要设置表19.1.2.2 所示的参数,然后LTDC会根据这些设置,自动控制DE 信号。这些参数通过相关寄存器来配置,接下来,我们介绍一下LTDC 的一些相关寄存器。

首先,我们来看LTDC 全局控制寄存器:LTDC_GCR,该寄存器各位描述如图19.1.2.4 所示:

在这里插入图片描述

图19.1.2.4 LTDC_GCR 寄存器各位描述
该寄存器我们在本章用到的设置有:LTDCEN、PCPOL、DEPOL、VSPOL 和HSPOL 这几个设置,我们将逐个介绍。
LTDCEN:TFT LCD 控制器使能位,也就是LTDC 的开关,该位需要设置为1。
PCPOL:像素时钟极性。控制像素时钟的极性,根据LCD 面板的特性来设置,我们所用的LCD 一般设置为0 即可,表示低电平有效。
DEPOL:数据使能极性。控制DE 信号的极性,根据LCD 面板的特性来设置,我们所用的LCD 一般设置为0 即可,表示低电平有效。
VSPOL:垂直同步极性。控制VSYNC 信号的极性,根据LCD 面板的特性来设置,我们所用的LCD 一般设置为0 即可,表示低电平有效。
HSPOL:水平同步极性。控制HSYNC 信号的极性,根据LCD 面板的特性来设置,我们所用的LCD一般设置为0 即可,表示低电平有效。
接下来,我们看看LTDC 同步大小配置寄存器:LTDC_SSCR,该寄存器各位描述如图19.1.2.5 所示:

在这里插入图片描述

图19.1.2.5 LTDC_SSCR 寄存器各位描述
该寄存器用于设置垂直同步高度(VSH)和水平同步宽度(HSW),其中:
VSH:表示垂直同步高度(以水平扫描行为单位),表示垂直同步脉宽减1,即VSW-1。
HSW:表示水平同步宽度(以像素时钟为单位),表示水平同步脉宽减1,即HSW-1。
接下来,我们看看LTDC 后沿配置寄存器:LTDC_BPCR,该寄存器各位描述如图19.1.2.6所示:

在这里插入图片描述

图19.1.2.6 LTDC_BPCR 寄存器各位描述
该寄存器我们需要配置AVBP 和AHBP:
AVBP:累加垂直后沿(以水平扫描行为单位),表示:VSW+VBP-1(见表19.1.2.2)。
AHBP:累加水平后沿(以像素时钟为单位),表示HSW+HBP-1(见表19.1.2.2,下同)。
接下来,我们看看LTDC 有效宽度配置寄存器:LTDC_AWCR,该寄存器各位描述如图19.1.2.7 所示:
在这里插入图片描述

图19.1.2.7 LTDC_AWCR 寄存器各位描述
该寄存器我们需要配置AAH 和AAW:
AAH:累加有效高度(以水平扫描行为单位),表示:VSW+VBP+有效高度-1。
AAW:累加有效宽度(以像素时钟为单位),表示:HSW+HBP+有效宽度-1。
这里所说的有效高度和有效宽度,是指LCD 面板的宽度和高度(构成分辨率,下同)。
接下来,我们看看LTDC 总宽度配置寄存器:LTDC_TWCR,该寄存器各位描述如图19.1.2.8所示:
在这里插入图片描述

图19.1.2.8 LTDC_TWCR 寄存器各位描述
该寄存器我们需要配置TOTALH 和TOTALW:
TOTALH:总高度(以水平扫描行为单位),表示:VSW+VBP+有效高度+VFP-1。
TOTALW:总宽度(以像素时钟为单位),表示:HSW+HBP+有效宽度+HFP-1。
接下来,我们看看LTDC 背景色配置寄存器:LTDC_BCCR,该寄存器各位描述如图19.1.2.9所示:
在这里插入图片描述

图19.1.2.9 LTDC_BCCR 寄存器各位描述
该寄存器定义背景层的颜色(RGB888),通过低24 位配置,我们一般设置为全0 即可。
接下来,我们看看LTDC 的层颜色帧缓冲区地址寄存器:LTDC_LxCFBAR(x=1/2),该寄存器各位描述如图19.1.2.10 所示:
在这里插入图片描述
图19.1.2.10 LTDC_LxCFBAR 寄存器各位描述
该寄存器用来定义一层显存的起始地址。STM32F429 的LTDC 支持2 个层,所以总共有两个寄存器,分别设置层1 和层2 的显存起始地址。
接下来,我们看看LTDC 的层像素格式配置寄存器:LTDC_LxPFCR(x=1/2),该寄存器只有最低3 位有效,用于设置层颜色的像素格式:000:ARGB8888;001:RGB888;010:RGB565;
011:ARGB1555;100:ARGB4444;101:L8(8 位Luminance);110:AL44(4 位Alpha,4
位Luminance);111:AL88(8 位Alpha,8 位Luminance)。我们一般使用RGB565 格式,即该寄存器设置为:010 即可。
接下来,我们看看LTDC 的层恒定Alpha 配置寄存器:LTDC_LxCACR(x=1/2),该寄存器各位描述如图19.1.2.11 所示:
在这里插入图片描述

图19.1.2.11 LTDC_LxCACR 寄存器各位描述
该寄存器低8 位(CONSTA)有效,这些位配置混合时使用的恒定Alpha。恒定Alpha 由硬件实现255 分频。关于这个恒定Alpha 的使用,我们将在介绍LTDC_LxBFCR 寄存器的时候进行讲解。
接下来,我们看看LTDC 的层默认颜色配置寄存器:LTDC_LxDCCR(x=1/2),该寄存器各位描述如图19.1.2.12 所示:
在这里插入图片描述

图19.1.2.12 LTDC_LxDCCR 寄存器各位描述
该寄存器定义采用ARGB8888 格式的层的默认颜色。默认颜色在定义的层窗口外使用或在层禁止时使用。一般情况下,用不到,所以该寄存器一般设置为0 即可。
接下来,我们看看LTDC 的层混合系数配置寄存器:LTDC_LxBFCR(x=1/2),该寄存器各位描述如图19.1.2.13 所示:
在这里插入图片描述
图19.1.2.13 LTDC_LxBFCR 寄存器各位描述
该寄存器用于定义混合系数:BF1 和BF2。BF1=100 的时候,使用恒定的Alpha 混合系数(由LTDC_LxCACR 寄存器设置恒定Alpha 值),BF1=110 的时候,使用像素Alpha恒定Alpha。像素Alpha 即ARGB 格式像素的A 值(Alpha 值),仅限ARGB 颜色格式时使用。在RGB565格式下,我们设置BF1=100 即可。BF2 同BF1 类似,BF2=101 的时候,使用恒定的Alpha 混合系数,BF2=111 的时候,使用像素Alpha恒定Alpha。在RGB565 格式下,我们设置BF2=101即可。
通用的混合公式为:
BC=BF1C+BF2Cs
其中:BC=混合后的颜色;BF1=混合系数1;C=当前层颜色,即我们写入层显存的颜色值;BF2=混合系数2;Cs=底层混合后的颜色,对于层1 来说,Cs=背景层的颜色,对于层2 来说,Cs=背景层和层1 混合后的颜色。
以使用恒定的Alpha 值,并仅使能第一层为例,给大家讲解一下混色的计算方式。恒定Alpha的值由LTDC_LxCACR 寄存器设置,恒定Alpha=LTDC_LxCACR 设置值/255 。假设:
LTDC_LxCACR=240;C=128;Cs(背景色)=48;那么恒定Alpha=240/255=0.94,则:
BC=0.94*128+(1-0.94)*48=123
则混合后,颜色值变成了123。另外,需要注意的是:BF1 和BF2 的恒定Alpha 值互补,他们之和为1,且BF1 使用的是恒定Alpha 值,BF2 使用的是互补值。一般情况下,我们设置
LTDC_LxCACR 的值为255,这样,在使用恒定Alpha 值的时候,可以得到BC=C,即混合后的颜色,就是显存里面的颜色(不进行混色)。
LTDC 的层支持窗口设置功能,通过LTDC_LxWHPCR 和LTDC_LxWVPCR 这两个寄存器设置,可以调整显示区域的大小,如图19.1.2.14 所示:
在这里插入图片描述

图19.1.2.14 LTDC 层窗口设置关系图
上图中,层中的第一个和最后一个可见像素通过配置LTDC_LxWHPCR 寄存器中的WHSTPOS[11:0]和WHSPPOS[11:0]进行设置。层中的第一个和最后一个可见行通过配置LTDC_LxWVPCR 寄存器中的WHSTPOS[11:0]和WHSPPOS[11:0]进行设置,配置完成后,即可确定窗口的大小。
接下来,我们来介绍这两个寄存器,首先是LTDC 的层窗口水平位置配置寄存器:LTDC_LxWHPCR(x=1/2),该寄存器各位描述如图19.1.2.15 所示:
在这里插入图片描述

图19.1.2.15 LTDC_LxWHPCR 寄存器各位描述
该寄存器定义第1
层或第2
层窗口的水平位置(第一个和最后一个像素),其中:
WHSTPOS:窗口水平起始位置,定义层窗口的一行的第一个可见像素,见图19.1.2.14。
WHSPPOS:窗口水平停止位置,定义层窗口的一行的最后一个可见像素,见图19.1.2.14。
然后,我们介绍LTDC 的层窗口垂直位置配置寄存器:LTDC_LxWVPCR(x=1/2),该寄存器各位描述如图19.1.2.16 所示:
在这里插入图片描述

图19.1.2.16 LTDC_LxWVPCR 寄存器各位描述
该寄存器定义第1 层或第2 层窗口的垂直位置(第一行或最后一行),其中:
WVSTPOS:窗口垂直起始位置,定义层窗口的第一个可见行,见图19.1.2.14。
WVSPPOS:窗口垂直停止位置,定义层窗口的最后一个可见行,见图19.1.2.14。
接下来,我们看看LTDC 的层颜色帧缓冲区长度寄存器:LTDC_LxCFBLR(x=1/2),该寄存器各位描述如图19.1.2.17 所示:
在这里插入图片描述

图19.1.2.17 LTDC_LxCFBLR 寄存器各位描述
该寄存器定义颜色帧缓冲区的行长和行间距。其中:
CFBLL:这些位定义一行像素的长度(以字节为单位)+3。行长的计算方法为:有效宽度
每像素的字节数+3。比如,LCD 面板的分辨率为800480,有效宽度为800,采用RGB565 格式,那么CFBLL 需要设置为:8002+3=1603。
CFBP:这些位定义从像素某行的起始处到下一行的起始处的增量(以字节为单位)。这个设置,其实同样是一行像素的长度,对于800
480 的LCD 面板,RGB565 格式,设置CFBP 为:
800*2=1600 即可。
最后,我们看看LTDC 的层颜色帧缓冲区行数寄存器:LTDC_LxCFBLNR(x=1/2),该寄存器各位描述如图19.1.2.18 所示:

在这里插入图片描述
图19.1.2.18 LTDC_LxCFBLNR 寄存器各位描述
该寄存器定义颜色帧缓冲区中的行数。CFBLNBR 用于定义帧缓冲区行数,比如,LCD 面板的分辨率为800*480,那么帧缓冲区的行数为480 行,则设置CFBLNBR=480 即可。
至此,LTDC 相关的寄存器,基本就介绍完了,通过这些寄存器的配置,我们就可以完成对LTDC 的初始化,控制LCD 显示了。关于LTDC 的详细介绍,和寄存器描述,请看《STM32F4xx中文参考手册-扩展章节.pdf》第16 章。
⑤ 时钟域
LTDC 有三个时钟域:AHB 时钟域(HCLK)、APB2 时钟域(PCLK2)和像素时钟域(LCD_CLK),AHB 时钟域用于驱动AHB 接口,读取存储器的数据到FIFO 里面,APB2 时钟域用配置寄存器,像素时钟域则用于生成LCD 接口信号,LCD_CLK 的输出应按照LCD 面板要求进行配置。
接下来,我们重点介绍下LCD_CLK 的配置过程。LCD_CLK 的时钟来源,如图19.1.2.19
所示:
在这里插入图片描述

图19.1.2.19 LCD_CLK 时钟图
由图可知,LCD_CLK 的来源,为外部晶振(假定外部晶振作为系统时钟源),经过分频器分频(/M),然后经过PLLSAI 倍频器倍频(xN)后,经R 分频因子输出分频后的时钟,得到
PLLLCDCLK,然后在经过DIV 分频和时钟使能后,得到LCD_CLK。接下来,我们简单介绍下配置LCD_CLK 需要用到的一些寄存器。
首先是RCC PLL SAI 配置寄存器:RCC_PLLSAICFGR,该寄存器的各位描述如图19.1.2.20
所示:
在这里插入图片描述
图19.1.2.20 RCC_PLLSAICFGR 寄存器各位描述
这个寄存器主要对PLLSAI 倍频器的:N、Q 和R 等参数进行配置,他们的设置关系(假定使用外部HSE 作为时钟源)为:
f(VCO clock) = f(hse) × (PLLSAIN / PLLM)
f(PLLSACLK) = f(VCO clock) / PLLSAIQ
f(PLLLCDCLK) = f(VCO clock) / PLLSAIR
f(hse)为我们外部晶振的频率,PLLM 就是M 分频因子,PLLSAIN 为PLLSAI 的倍频数,取值范围为:49~432;PLLSAIQ 为PLLSAI 的Q 分频系数,取值范围为:2~15;PLLSAIR 为
PLLSAI 的R 分频系数,取值范围为:2~7;阿波罗STM32F429 核心板所用的HSE 晶振频率为
25Mhz,一般我们设置PLLM 为25,那么输入PLLSAI 的时钟频率就是1Mhz,然后可得:
f(PLLLCDCLK) =1Mhz* PLLSAIN/PLLSAIR
在f(PLLLCDCLK)之后,还有一个分频器(DIV),分频后得到最终的LCD_CLK 频率,该分频由RCC 专用时钟配置寄存器:RCC_DCKCFGR 配置,该寄存器各位描述如图19.1.2.21 所示:
在这里插入图片描述

图19.1.2.21 RCC_ DCKCFGR 寄存器各位描述
在本章,该寄存器我们只关心PLLSAIDIVR 的配置,这两个位用于配置f(PLLLCDCLK)
之后的分频,设置范围为:0~2,表示:2^(PLLSAIDIVR+1)分频。因此,我们最终得到LCD_CLK
的频率计算公式为(前提:HSE=25Mhz,PLLM=25):
f(LCD_CLK)= 1Mhz* PLLSAIN/PLLSAIR/2^(PLLSAIDIVR+1)
以群创AT070TN92 面板为例,查其数据手册,可知DCLK 的频率典型值为:33.3Mhz,我们需要设置:PLLSAIN=396,PLLSAIR=3,PLLSAIDIVR=1,得到:
f(LCD_CLK)= 1Mhz* 396/3/2^(1+1)=33Mhz
最后,我们来看看实现LTDC 驱动RGBLCD,需要对LTDC 进行哪些配置。LTDC 相关
HAL 库操作分布在函数stm32f4xx_hal_ltdc.c 和stm32f4xx_hal_ltdc_ex.c 以及他们对应的头文件中。操作步骤如下:
1)使能LTDC 时钟,并配置LTDC 相关的IO 及其时钟使能。
要使用LTDC,当然首先得开启其时钟。然后需要把LCD_R/G/B 数据线、LCD_HSYNC
和LCD_VSYNC 等相关IO 口,全部配置为复用输出,并使能各IO 组的时钟。GPIO 配置这里我们就不做讲解,LTDC 时钟使能方法为:

__HAL_RCC_LTDC_CLK_ENABLE(); //使能LTDC 时钟

2)设置LCD_CLK 时钟。
此步需要配置LCD 的像素时钟,根据LCD 的面板参数进行设置,LCD_CLK 由PLLSAI
进行配置,前面我们已经讲解非常详细,配置使用到的HAL 库函数为:

触摸屏实验

本章,我们将介绍如何使用STM32F1 来驱动触摸屏,正点原子战舰STM32F103 本身并没有触摸屏控制器,但是它支持触摸屏,可以通过外接带触摸屏的LCD 模块(比如正点原子TFTLCD 模块),来实现触摸屏控制。在本章中,我们将向大家介绍STM32 控制正点原子TFTLCD 模块(包括电阻触摸与电容触摸),实现触摸屏驱动,最终实现一个手写板的功能。

触摸屏简介

触摸屏是在显示屏的基础上,在屏幕或屏幕上方分布一层与屏幕大小相近的传感器形成的组合器件。触摸和显示功能由软件控制,可以独立也可以组合实现,用户可以通过侦测传感器的触点再配合相应的软件实现触摸效果。目前最常用的触摸屏有两种:电阻式触摸屏与电容式触摸屏。下面,我们来分别介绍。

电阻式触摸屏

正点原子2.4/2.8/3.5 寸TFTLCD 模块自带的触摸屏都属于电阻式触摸屏,下面简单介绍下电阻式触摸屏的原理。
电阻触摸屏的主要部分是一块与显示器表面非常贴合的电阻薄膜屏,这是一种多层的复合薄膜,具体结构如下图39.1.1.1 所示。
在这里插入图片描述

图39.1.1.1 电阻触摸屏多层结构图
表面硬涂层起保护作用,主要是一层外表面硬化处理、光滑防擦的塑料层。玻璃底层用于支撑上面的结构,主要是玻璃或者塑料平板。透明隔离点用来分离开外层ITO 和内层ITO。ITO层是触摸屏关键结构,是涂有铟锡金属氧化物的导电层。还有一个结构上图没有标出,就是PET层。PET 层是聚酯薄膜,处于外层ITO 和表面硬涂层之间,很薄很有弹性,触摸时向下弯曲,使得ITO 层接触。
当手指触摸屏幕时,两个ITO 层在触摸点位置就有接触,电阻发生变化,在X 和Y 两个方向上产生电信号,然后送到触摸屏控制器,具体情况如下图39.1.1.2 所示。触摸屏控制器侦测到这一接触并计算出X 和Y 方向上的AD 值,简单来讲,电阻触摸屏将触摸点(X,Y)的物理位置转换为代表X 坐标和Y 坐标的电压值。单片机与触摸屏控制器进行通信获取到AD值,通过一定比例关系运算,获得X 和Y 轴坐标值。
在这里插入图片描述
图39.1.1.2 电阻式触摸屏的触点坐标结构
电阻触摸屏的优点:精度高、价格便宜、抗干扰能力强、稳定性好。
电阻触摸屏的缺点:容易被划伤、透光性不太好、不支持多点触摸。
从以上介绍可知,触摸屏都需要一个AD 转换器,一般来说是需要一个控制器的。正点原子TFTLCD 模块选择的是四线电阻式触摸屏,这种触摸屏的控制芯片有很多,包括:ADS7543、ADS7846、TSC2046、XPT2046 和HR2046 等。这几款芯片的驱动基本上是一样的,也就是你只要写出了XPT2046 的驱动,这个驱动对其他几个芯片也是有效的。而且封装也有一样的,完全PIN-TO-PIN 兼容。所以在替换起来,很方便。
正点原子TFTLCD 模块自带的触摸屏控制芯片为XPT2046 或HR2046。这里以XPT2046作为介绍。XPT2046 是一款4 导线制触摸屏控制器,使用的是SPI 通信接口,内含12 位分辨率125KHz 转换速率逐步逼近型A/D 转换器。XPT2046 支持从1.5V 到5.25V 的低电压I/O 接口。XPT2046 能通过执行两次A/D 转换(一次获取X 位置,一次获取Y 位置)查出被按的屏幕位置,除此之外,还可以测量加在触摸屏上的压力。内部自带2.5V 参考电压可以作为辅助输入、温度测量和电池监测模式之用,电池监测的电压范围可以从0V 到6V。XPT2046 片内集成有一个温度传感器。在2.7V 的典型工作状态下,关闭参考电压,功耗可小于0.75mW。
XPT2046 的驱动方法也是很简单,主要看懂XPT2046 通信时序图,如下图39.1.1.3 所示。
在这里插入图片描述

图39.1.1.3 XPT2046 通信时序图
依照时序图,就可以很好写出这个通信代码,上图具体过程:拉低片选,选中器件→发送命令字→清除BUSY→读取16 位数据(高12 位数据有效即转换的AD 值)→拉高片选,结束操作。这里的难点就是需要搞清楚命令字该发送什么?只要搞清楚发送什么数值,就可以获取到AD 值。命令字的详情如下图39.1.1.4 所示:

在这里插入图片描述
图39.1.1.4 命令字详情图
位7,开始位,置1 即可。位3,为了提供精度,MODE 位清0 选择12 位分辨率。位2,是进行工作模式选择,为了达到最佳性能,首选差分工作模式即该位清0 即可。位1-0 是功耗相关的,直接清0 即可。而位6-4 的值要取决于工作模式,在确定了差分功能模式后,通道选择位也确定了,如图39.1.1.5 所示。
在这里插入图片描述

图39.1.1.5 差分模式输入配置图(SER/DFR=0)
从上图,就可以知道:当我们需要检测Y 轴位置时,A2A1A0 赋值为001;检测X 轴位置时,A2A1A0 赋值为101。结合前面对其他位的赋值,在X,Y 方向与屏幕相同的情况下,命令字0xD0 就是读取X 坐标AD 值,0x90 就是读取Y 坐标的AD 值。假如X,Y 方向与屏幕相反,0x90 就是读取X 坐标的AD 值,而0xD0 就是读取Y 坐标的AD 值。关于这个芯片其他的功能,也可以参考芯片的datasheet。
电阻式触摸屏就介绍到这里。

电容式触摸屏

现在几乎所有智能手机,包括平板电脑都是采用电容屏作为触摸屏,电容屏是利用人体感应进行触点检测控制,不需要直接接触或只需要轻微接触,通过检测感应电流来定位触摸坐标。
正点原子4.3/7 寸TFTLCD 模块自带的触摸屏采用的是电容式触摸屏,下面简单介绍下电容式触摸屏的原理。
电容式触摸屏主要分为两种:
1、表面电容式电容触摸屏。
表面电容式触摸屏技术是利用ITO(铟锡氧化物,是一种透明的导电材料)导电膜,通过电场感应方式感测屏幕表面的触摸行为进行。但是表面电容式触摸屏有一些局限性,它只能识别一个手指或者一次触摸。
2、投射式电容触摸屏。
投射电容式触摸屏是传感器利用触摸屏电极发射出静电场线。一般用于投射电容传感技术的电容类型有两种:自我电容和交互电容。
自我电容又称绝对电容,是最广为采用的一种方法,自我电容通常是指扫描电极与地构成的电容。在玻璃表面有用ITO 制成的横向与纵向的扫描电极,这些电极和地之间就构成一个电容的两极。当用手或触摸笔触摸的时候就会并联一个电容到电路中去,从而使在该条扫描线上的总体的电容量有所改变。在扫描的时候,控制IC 依次扫描纵向和横向电极,并根据扫描前后的电容变化来确定触摸点坐标位置。笔记本电脑触摸输入板就是采用的这种方式,笔记本电脑的输入板采用XY 的传感电极阵列形成一个传感格子,当手指靠近触摸输入板时,在手指和传感电极之间产生一个小量电荷。采用特定的运算法则处理来自行、列传感器的信号来确定手指的位置。
交互电容又叫做跨越电容,它是在玻璃表面的横向和纵向的ITO 电极的交叉处形成电容。
交互电容的扫描方式就是扫描每个交叉处的电容变化,来判定触摸点的位置。当触摸的时候就会影响到相邻电极的耦合,从而改变交叉处的电容量,交互电容的扫面方法可以侦测到每个交叉点的电容值和触摸后电容变化,因而它需要的扫描时间与自我电容的扫描方式相比要长一些,需要扫描检测X
Y 根电极。目前智能手机/平板电脑等的触摸屏,都是采用交互电容技术。正点原子所选择的电容触摸屏,也是采用的是投射式电容屏(交互电容类型),所以后面仅以投射式电容屏作为介绍。
透射式电容触摸屏采用纵横两列电极组成感应矩阵,来感应触摸。以两个交叉的电极矩阵,即:X 轴电极和Y 轴电极,来检测每一格感应单元的电容变化,如图39.1.2.1 所示:
在这里插入图片描述

图39.1.2.1 投射式电容屏电极矩阵示意图
示意图中的电极,实际是透明的,这里是为了方便大家理解。图中,X、Y 轴的透明电极电容屏的精度、分辨率与X、Y 轴的通道数有关,通道数越多,精度越高。以上就是电容触摸屏的基本原理,接下来看看电容触摸屏的优缺点:
电容触摸屏的优点:手感好、无需校准、支持多点触摸、透光性好。
电容触摸屏的缺点:成本高、精度不高、抗干扰能力差。
这里特别提醒大家电容触摸屏对工作环境的要求是比较高的,在潮湿、多尘、高低温环境下面,都是不适合使用电容屏的。
电容触摸屏一般都需要一个驱动IC 来检测电容触摸,正点原子的电容触摸屏使用的是IIC接口输出触摸数据的触摸芯片。正点原子7’TFTLCD 模块的电容触摸屏,采用的是15*10 的驱动结构(10 个感应通道,15 个驱动通道),采用的是GT911/FT5206 作为驱动IC。正点原子4.3’TFTLCD 模块采用的驱动IC 是:GT9xxx(GT9147/GT917S/GT911/GT1151/GT9271),不同型号感应通道和驱动通道数量都不一样,详看数据手册,但是这些驱动IC 驱动方式都类似,这里我们以GT9147 为例给大家做介绍,其他的大家参考着学习即可。
GT9147 与MCU 通过4 根线连接:SDA、SCL、RST 和INT。GT9147 的IIC 地址,可以是0X14 或者0X5D,当复位结束后的5ms 内,如果INT 是高电平,则使用0X14 作为地址,否则使用0X5D 作为地址,具体的设置过程,请看:GT9147 数据手册.pdf 这个文档。本章我们使用0X14 作为器件地址(不含最低位,换算成读写命令则是读:0X29,写:0X28),接下来,介绍一下GT9147 的几个重要的寄存器。
1,控制命令寄存器(0X8040)
该寄存器可以写入不同值,实现不同的控制,我们一般使用0 和2 这两个值,写入2,即可软复位GT9147。在硬复位之后,一般要往该寄存器写2,实行软复位。然后,写入0,即可正常读取坐标数据(并且会结束软复位)。
2,配置寄存器组(0X8047~0X8100)
这里共186 个寄存器,用于配置GT9147 的各个参数,这些配置一般由厂家提供给我们(一个数组),所以我们只需要将厂家给我们的配置,写入到这些寄存器里面,即可完成GT9147 的配置。由于GT9147 可以保存配置信息(可写入内部FLASH,从而不需要每次上电都更新配置),我们有几点注意的地方提醒大家:1,0X8047 寄存器用于指示配置文件版本号,程序写入的版本号,必须大于等于GT9147 本地保存的版本号,才可以更新配置。2,0X80FF 寄存器用于存储校验和,使得0X8047~0X80FF 之间所有数据之和为0。3,0X8100 用于控制是否将配置保存在本地,写0,则不保存配置,写1 则保存配置。
3,产品ID 寄存器(0X8140~0X8143)
这里总共由4 个寄存器组成,用于保存产品ID,对于GT9147,这4 个寄存器读出来就是:
9,1,4,7 四个字符(ASCII 码格式)。因此,我们可以通过这4 个寄存器的值,来判断驱动IC的型号,以便执行不同的初始化。
4,状态寄存器(0X814E)
该寄存器各位描述如表39.1.2.1 所示:

在这里插入图片描述
表39.1.2.1 状态寄存器各位描述
这里,我们仅关心最高位和最低4 位,最高位用于表示buffer 状态,如果有数据(坐标/按键),buffer 就会是1,最低4 位用于表示有效触点的个数,范围是:0~5,0,表示没有触摸,5 表示有5 点触摸。最后,该寄存器在每次读取后,如果bit7 有效,则必须写0,清除这个位,否则不会输出下一次数据!!这个要特别注意!!!
5,坐标数据寄存器(共30 个)
这里共分成5 组(5 个点),每组6 个寄存器存储数据,以触点1 的坐标数据寄存器组为例,如表39.1.2.2 所示:
在这里插入图片描述
表39.1.2.2 触点1 坐标寄存器组描述
我们一般只用到触点的x,y 坐标,所以只需要读取0X8150~0X8153 的数据,组合即可得到触点坐标。其他4 组分别是:0X8158、0X8160、0X8168 和0X8170 等开头的16 个寄存器组成,分别针对触点2~4 的坐标。同样GT9147 也支持寄存器地址自增,我们只需要发送寄存器组的首地址,然后连续读取即可,GT9147 会自动地址自增,从而提高读取速度。GT9147 相关寄存器的介绍就介绍到这里,更详细的资料,请参考:GT9147 编程指南.pdf这个文档。
GT9147 只需要经过简单的初始化就可以正常使用了,初始化流程:硬复位→延时10ms→结束硬复位→设置IIC 地址→延时100ms→软复位→更新配置(需要时)→结束软复位。此时GT9147 即可正常使用了。然后,我们不停的查询0X814E 寄存器,判断是否有有效触点,如果有,则读取坐标数据寄存器,得到触点坐标。特别注意,如果0X814E 读到的值最高位为1,就必须对该位写0,否则无法读到下一次坐标数据。电容式触摸屏部分,就介绍到这里。

触摸控制原理

前面已经简单地介绍了电阻屏和电容屏的原理,并且知道了不同类型的触摸屏其实是屏幕+触摸传感器组成。那么这里就会有两组相互独立的参数:屏幕坐标和触摸坐标。要实现触摸功能,就是要把触摸点和屏幕坐标对应起来。
我们以LCD 显示屏为例,我们知道屏幕的扫描方向是可以编程设定的,而触摸点,在触摸传感器安装好后,AD 值的变化向方向则是固定的,我们以最常见的屏幕坐标方向:先从左到右,再从上到下扫描为例,此时,屏幕坐标和触点AD 的坐标有类似的规律:从坐标原点出发,水平方向屏幕坐标增加时,AD 值的X 方向也增加;屏幕坐标的Y 方向坐标增加,AD 值的Y方向也增加;坐标减少时对应的关系也类似,可以用图39.1.3.1 的示意图来表示这种关系:
在这里插入图片描述
图39.1.3.1 屏幕坐标和触摸坐标的一种对应关系
这里再来引入两个概念,物理坐标和逻辑坐标。物理坐标指触摸屏上点的实际位置,通常以液晶上点的个数来度量。逻辑坐标指这点被触摸时A/D 转换后的坐标值。仍以图39.1.3.1 为例,我们假定液晶最左上角为坐标轴原点A,在液晶上任取一点B(实际人手比像素点大得多,一次按下会有多个触点,此处取十字线交叉中心),B 在X 方向与A 相距100 个点,在Y 方向与A 距离200 个点,则这点的物理坐标B 为(100,200)。如果我们触摸这一点时得到的X 向A/D 转换值为200,Y 向A/D 转换值为400,则这点的逻辑坐标B’为(200,400)。
需要特别说明的是,正点原子的电容屏的参数已经在出厂时由厂家调好,所以无需进行校准,而且可以直接读到转换后的触点坐标;对于电阻屏,请大家理解并熟记物理坐标和逻辑坐标逻辑上的对应关系,我们后面编程需要用到。

硬件设计

  1. 例程功能
    正点原子的触摸屏种类很多,并且设计了规格相对统一的接口。根据屏幕的种类不同,设置了相应的硬件ID(正点原子自编ID),可以通过软件判断触摸屏的种类。
    本章实验功能简介:开机的时候先初始化LCD,读取LCD ID,随后,根据LCD ID 判断是电阻触摸屏还是电容触摸屏,如果是电阻触摸屏,则先读取24C02 的数据判断触摸屏是否已经校准过,如果没有校准,则执行校准程序,校准过后再进入电阻触摸屏测试程序,如果已经校准了,就直接进入电阻触摸屏测试程序。
    如果是4.3 寸电容触摸屏,则执行GT9xxx 的初始化代码;如果是7 寸电容触摸屏(仅支持新款7 寸屏,使用SSD1963+FT5206 方案),则执行FT5206 的初始化代码,在初始化电容触摸屏完成后,进入电容触摸屏测试程序(电容触摸屏无需校准!!)。
    电阻触摸屏测试程序和电容触摸屏测试程序基本一样,只是电容触摸屏支持最多5 点同时触摸,电阻触摸屏只支持一点触摸,其他一模一样。测试界面的右上角会有一个清空的操作区域(RST),点击这个地方就会将输入全部清除,恢复白板状态。使用电阻触摸屏的时候,可以通过按KEY0 来实现强制触摸屏校准,只要按下KEY0 就会进入强制校准程序。
  2. 硬件资源
    1)LED 灯
    LED0 –PB5
    2 ) 独立按键
    KEY0 –PE4
    3)EEPROM AT24C02
    4)正点原子2.8/3.5/4.3/7/10 寸TFTLCD 模块(仅限MCU 屏,16 位8080 并口驱动)
    5)串口1(PA9/PA10 连接在板载USB 转串口芯片CH340 上面)
  3. 原理图
    所有这些资源与STM32F1 的连接图,在前面都已经介绍了,这里我们只针对TFTLCD 模块与STM32F1 的连接端口再说明一下,TFTLCD 模块的触摸屏(电阻触摸屏)总共有5 根线与STM32F1 连接,连接电路图如图39.2.1 所示:
    在这里插入图片描述

图39.2.1 触摸屏与STM32F1 的连接图
从图中可以看出:T_SCK、T_MISO、T_MOSI、T_PEN 和T_CS 分别连接在STM32F1 的PB1、PB2、PF9、PF10 和PF11 上。
如果是电容式触摸屏,我们的接口和电阻式触摸屏一样(上图右侧接口),只是没有用到五根线了,而是四根线,分别是:T_PEN(CT_INT) 、T_CS(CT_RST) 、T_CLK(CT_SCL) 和T_MOSI(CT_SDA)。其中:CT_INT、CT_RST、CT_SCL 和CT_SDA 分别是GT9147/FT5206 的:
中断输出信号、复位信号,IIC 的SCL 和SDA 信号。我们用查询的方式读取GT9147/FT5206 的数据,对于FT5206 没有用到中断信号(CT_INT),所以同STM32F1 的连接,最少只需要3 根线即可,不过GT9147 等IC 还需要用到CT_INT 做IIC 地址设定,所以需要4 根线连接。

程序设计

HAL 库驱动

触摸芯片我们使用到的是IIC 和SPI 的驱动,这部分的时序分析可以参考之前IIC/SPI 的章节,我们直接使用的是软件模拟的方式,所以只需要使用HAL 库的驱动的GPIO 操作部分。触摸IC 驱动步骤
1)初始化通信接口与其IO(使能时钟、配置GPIO 工作模式)
触摸IC 用到的GPIO 口,主要是PB1、PB2、PF9、PF10 和PF11,因为都是用软件模拟的方式,因此在这里我们只需使能GPIOB 和GPIOF 时钟即可。参考代码如下:

HAL_RCC_GPIOB_CLK_ENABLE(); /* 使能GPIOB时钟*/
__HAL_RCC_GPIOF_CLK_ENABLE(); /* 使能GPIOF时钟*/

GPIO 模式设置通过调用HAL_GPIO_Init 函数实现,详见本例程源码。
2)编写通信协议基础读写函数
通过参考时序图,在IIC 驱动或SPI 驱动基础上,编写基础读写函数。读写函数均以一字节数据进行操作。
3)参考触摸IC 时序图,编写触摸IC 读写驱动函数
根据触摸IC 的读写时序进行编写触摸IC 的读写函数,详见本例程源码。
4)编写坐标获取函数(电阻触摸屏和电容触摸屏)
查阅数据手册获得命令词(电阻触摸屏)/寄存器(电容触摸屏),通过读写函数获取坐标数据,详见本例程源码。

程序流程图

在这里插入图片描述
图39.3.2.1 触摸屏实验流程图

程序解析

这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。TOUCH 驱动源码包括如下文件:ctiic.c、ctiic.h、ft5206.c、ft5206.h、gt9xxx.c、gt9xxx.h、touch.c 和touch.h。
由于正点原子的TFTLCD 的型号很多,触摸控制这部分驱动代码根据不同屏幕搭载的触摸芯片驱动而有不同,在我们的屏幕上使用的是LCD ID 来帮助软件上区分。为了解决多种驱动芯片的问题,我们设计了touch.c/touch.h 这两个文件统一管理各类型的驱动。不同的驱动芯片类型可以在touch.c 中集中添加,并通过touch.c 中的接口统一调用,不同的触摸芯片各自编写独立的.c/.h 文件,需要时被touch.c 调用。电阻触摸屏相关代码也在touch.c 中实现。

  1. 触摸管理驱动代码
    因为需要支持的触摸驱动比较多,为了方便管理和添加新的驱动,我们用touch.c 文件来统一管理这些触摸驱动,然后针对各类触摸芯片编写独立的驱动。为了方便管理触摸,我们在touch.h 中定义一个用于管理触摸信息的结构体类型,具体代码如下:
/* 触摸屏控制器*/
typedef struct
{
    uint8_t (*init)(void);    /* 初始化触摸屏控制器*/
    uint8_t (*scan)(uint8_t); /* 扫描触摸屏.0,屏幕扫描;1,物理坐标; */
    void (*adjust)(void);     /* 触摸屏校准*/
    uint16_t x[CT_MAX_TOUCH]; /* 当前坐标*/
    uint16_t y[CT_MAX_TOUCH]; /* 电容屏有最多10组坐标,电阻屏则用x[0],y[0]代表:此次扫
    描时,触屏的坐标,用x[9],y[9]存储第一次按下时的坐标*/
    uint16_t sta;             /* 笔的状态
                               * b15:按下1/松开0;
                               * b14:0,没有按键按下;1,有按键按下.
                               * b13~b10:保留
                               * b9~b0:电容触摸屏按下的点数(0,表示未按下,1表示按下)
                               */
    /* 5点校准触摸屏校准参数(电容屏不需要校准) */
    float xfac; /* 5点校准法x方向比例因子*/
    float yfac; /* 5点校准法y方向比例因子*/
    short xc;   /* 中心X坐标物理值(AD值) */
    short yc;   /* 中心Y坐标物理值(AD值) */
    /* 新增的参数,当触摸屏的左右上下完全颠倒时需要用到.
     * b0: 0, 竖屏(适合左右为X坐标,上下为Y坐标的TP)
     * 1, 横屏(适合左右为Y坐标,上下为X坐标的TP)
     * b1~6: 保留.
     * b7: 0, 电阻屏
     * 1, 电容屏
     */
    uint8_t touchtype;
} _m_tp_dev;
extern _m_tp_dev tp_dev; /* 触屏控制器在touch.c里面定义*/

这里我们定义了函数指针,只要把相对应的触摸芯片的函数指针赋值给它,就可以通过这个通用接口很方便调用不同芯片的函数接口。正点原子不同的触摸屏区别如下:
1、在使用4.3 寸屏、10.1 寸屏电容屏时,使用的是汇顶科技的GT9xxx 系列触摸屏驱动IC,这是一个IIC 接口的驱动芯片,我们要编写gt9xxx 系列芯片的初始化程序,并编写一个坐标扫描程序,这里我们先预留这两个接口分别为gt9xxx_init()和gt9xxx_scan(),在gt9xxx.c 文件中再专门实现这两个驱动,标记使用的为电容屏;
2、类似地,在使用SSD1963 7 寸屏、7 寸800480/1024600 RGB 屏时,我们的屏幕搭载的触摸驱动芯片是ft5206/GT911,FT5206 触摸IC 预留这两个接口分别为ft5206_init()和ft5206_scan(),在ft5206.c 文件中再专门实现这两个驱动,标记使用的为电容屏;GT911 也是调用gtxxx_init()和gt9xxx_scan()接口。
3、当为其它ID 时,默认为电阻屏,而电阻屏默认使用的是SPI 接口的XPT2046 芯片。由于电阻屏存在线性误差,所以在使用前需要进行校准,这也是为什么在前面的结构体类型中存在关于校准参数的成员。为了避免每次都要进行校准的麻烦,所以会使用AT24C02 来存储校准成功后的数据。如何进行校准也会在后面进行讲解。作为电阻屏,它也有一个扫描坐标函数即tp_scan()。
(*init)(void)这个结构体函数指针,默认指向tp_init 的,而在tp_init 里对触摸屏进行初始化并对(*scan)(uint8_t)函数指针根据触摸芯片类型重新做了指向。在这里简单看一下touch.c 的触摸屏初始化函数tp_init,其代码如下:

/**
 * @brief 触摸屏初始化
 * @param 无
 * @retval 0,没有进行校准
 * 1,进行过校准
 */
uint8_t tp_init(void)
{
    GPIO_InitTypeDef gpio_init_struct;
    tp_dev.touchtype = 0;                  /* 默认设置(电阻屏& 竖屏) */
    tp_dev.touchtype |= lcddev.dir & 0X01; /* 根据LCD判定是横屏还是竖屏*/
    if (lcddev.id == 0X5510 || lcddev.id == 0X4342 || lcddev.id == 0X4384 || lcddev.id == 0X1018)
    { /* 电容触摸屏,4.3寸/10.1寸屏*/
        gt9xxx_init();
        tp_dev.scan = gt9xxx_scan; /* 扫描函数指向GT9147触摸屏扫描*/
        tp_dev.touchtype |= 0X80;  /* 电容屏*/
        return 0;
    }
    else if (lcddev.id == 0X1963 || lcddev.id == 0X7084 || lcddev.id == 0X7016)
    { /* SSD1963 7寸屏或者7寸800*480/1024*600 RGB屏*/
        if (!ft5206_init())
        {
            tp_dev.scan = ft5206_scan; /* 扫描函数指向FT5206触摸屏扫描*/
        }
        else
        {
            gt9xxx_init();
            tp_dev.scan = gt9xxx_scan; /* 扫描函数指向GT9147触摸屏扫描*/
        }
        tp_dev.touchtype |= 0X80; /* 电容屏*/
        return 0;
    }
    else
    {
        T_PEN_GPIO_CLK_ENABLE();  /* T_PEN脚时钟使能*/
        T_CS_GPIO_CLK_ENABLE();   /* T_CS脚时钟使能*/
        T_MISO_GPIO_CLK_ENABLE(); /* T_MISO脚时钟使能*/
        T_MOSI_GPIO_CLK_ENABLE(); /* T_MOSI脚时钟使能*/
        T_CLK_GPIO_CLK_ENABLE();  /* T_CLK脚时钟使能*/
        gpio_init_struct.Pin = T_PEN_GPIO_PIN;
        gpio_init_struct.Mode = GPIO_MODE_INPUT;           /* 输入*/
        gpio_init_struct.Pull = GPIO_PULLUP;               /* 上拉*/
        gpio_init_struct.Speed = GPIO_SPEED_FREQ_HIGH;     /* 高速*/
        HAL_GPIO_Init(T_PEN_GPIO_PORT, &gpio_init_struct); /* 初始化T_PEN引脚*/
        gpio_init_struct.Pin = T_MISO_GPIO_PIN;
        HAL_GPIO_Init(T_MISO_GPIO_PORT, &gpio_init_struct); /* 初始化T_MISO引脚* /
        gpio_init_struct.Pin = T_MOSI_GPIO_PIN;
        gpio_init_struct.Mode = GPIO_MODE_OUTPUT_PP; /* 推挽输出*/
        gpio_init_struct.Pull = GPIO_PULLUP;                /* 上拉*/
        gpio_init_struct.Speed = GPIO_SPEED_FREQ_HIGH;      /* 高速*/
        HAL_GPIO_Init(T_MOSI_GPIO_PORT, &gpio_init_struct); /* 初始化T_MOSI引脚*/
        gpio_init_struct.Pin = T_CLK_GPIO_PIN;
        HAL_GPIO_Init(T_CLK_GPIO_PORT, &gpio_init_struct); /* 初始化T_CLK引脚*/
        gpio_init_struct.Pin = T_CS_GPIO_PIN;
        HAL_GPIO_Init(T_CS_GPIO_PORT, &gpio_init_struct); /* 初始化T_CS引脚*/
        tp_read_xy(&tp_dev.x[0], &tp_dev.y[0]);           /* 第一次读取初始化*/
        at24cxx_init();                                   /* 初始化24CXX */
        if (tp_get_adjust_data())
        {
            return 0; /* 已经校准*/
        }
        else /* 未校准? */
        {
            lcd_clear(WHITE); /* 清屏*/
            tp_adjust();      /* 屏幕校准*/
            tp_save_adjust_data();
        }
        tp_get_adjust_data();
    }
    return 1;
}

正点原子的电容屏在出厂时已经由厂家较对好参数了,而电阻屏由于工艺和每个屏的线性有所差异,我们需要先对其进行“校准”,我们在下一点补充说明它的实现。
通过上面的触摸初始化后,我们就可以读取相关的触点信息用于显示编程了,注意到上面还有很多个函数还没实现,比如读取坐标和校准,我们在接下来的代码中将它补充完整。
2. 电阻屏触摸函数
前面我们介绍过了电阻式触摸屏的原理,由于电阻屏的驱动代码都比较类似,我们决定把电阻屏的驱动函数直接添加在touch.c/touch.h 中实现。
在touch.c 的初始化函数tp_init 中,对使用到的SPI 接口IO 进行了初始化。接下来介绍一下获取触摸点在屏幕上坐标的算法:先获取逻辑坐标(AD 值),再转换成屏幕坐标。
如何获取逻辑坐标(AD 值),在前面已经分析过了,所以这里我们看一下tp_read_ad()函数接口:

/**
 * @brief SPI读数据
 * @note 从触摸屏IC读取adc值
 * @param cmd: 指令
 * @retval 读取到的数据,ADC值(12bit)
 */
static uint16_t tp_read_ad(uint8_t cmd)
{
    uint8_t count = 0;
    uint16_t num = 0;
    T_CLK(0);           /* 先拉低时钟*/
    T_MOSI(0);          /* 拉低数据线*/
    T_CS(0);            /* 选中触摸屏IC */
    tp_write_byte(cmd); /* 发送命令字*/
    delay_us(6);        /* ADS7846的转换时间最长为6us */
    T_CLK(0);
    delay_us(1);
    T_CLK(1); /* 给1个时钟,清除BUSY */
    delay_us(1);
    T_CLK(0);
    for (count = 0; count < 16; count++) /* 读出16位数据,只有高12位有效*/
    {
        num <<= 1;
        T_CLK(0); /* 下降沿有效*/
        delay_us(1);
        T_CLK(1);
        if (T_MISO)
            num++;
    }
    num >>= 4; /* 只有高12位有效. */
    T_CS(1);   /* 释放片选*/
    return num;
}

这里我们使用的是软件模拟SPI,遵照时序编写SPI 读函数接口。而发送命令字是通过写函数tp_write_byte 来实现,详看源码。
一次读取的误差会很大,我们采用平均值滤波的方法,多次读取数据并丢弃波动最大的最大和最小值,取余下的平均值。具体可以查看tp_read_xoy 函数内部实现。

/* 电阻触摸驱动芯片数据采集滤波用参数*/
#define TP_READ_TIMES 5 /* 读取次数*/
#define TP_LOST_VAL 1   /* 丢弃值*/
/**
 * @brief 读取一个坐标值(x或者y)
 * @note 连续读取TP_READ_TIMES次数据,对这些数据升序排列,
 * 然后去掉最低和最高TP_LOST_VAL个数, 取平均值
 * 设置时需满足: TP_READ_TIMES > 2*TP_LOST_VAL 的条件
 * @param cmd : 指令
 * @arg 0XD0: 读取X轴坐标(@竖屏状态,横屏状态和Y对调.)
 * @arg 0X90: 读取Y轴坐标(@竖屏状态,横屏状态和X对调.)
 *
 * @retval 读取到的数据(滤波后的), ADC值(12bit)
 */
static uint16_t tp_read_xoy(uint8_t cmd)
{
    uint16_t i, j;
    uint16_t buf[TP_READ_TIMES];
    uint16_t sum = 0;
    uint16_t temp;
    for (i = 0; i < TP_READ_TIMES; i++) /* 先读取TP_READ_TIMES次数据*/
    {
        buf[i] = tp_read_ad(cmd);
    }
    for (i = 0; i < TP_READ_TIMES - 1; i++) /* 对数据进行排序*/
    {
        for (j = i + 1; j < TP_READ_TIMES; j++)
        {
            if (buf[i] > buf[j]) /* 升序排列*/
            {
                temp = buf[i];
                buf[i] = buf[j];
                buf[j] = temp;
            }
        }
    }
    sum = 0;
    for (i = TP_LOST_VAL; i < TP_READ_TIMES - TP_LOST_VAL; i++)
    {                  /* 去掉两端的丢弃值*/
        sum += buf[i]; /* 累加去掉丢弃值以后的数据. */
    }
    temp = sum / (TP_READ_TIMES - 2 * TP_LOST_VAL); /* 取平均值*/
    return temp;
}

有了前述代码,我们就可以通过tp_read_xoy(uint8_t cmd)接口调取需要的x 或者y 坐标的AD 值了。这里我们加上横屏或者竖屏的处理代码,编写一个可以通过指针一次得到x 和y 的两个AD 值的接口,代码如下:

/**
 * @brief 读取x, y坐标
 * @param x,y: 读取到的坐标值
 * @retval 无
 */
static void tp_read_xy(uint16_t *x, uint16_t *y)
{
    uint16_t xval, yval;
    if (tp_dev.touchtype & 0X01) /* X,Y方向与屏幕相反*/
    {
        xval = tp_read_xoy(0X90); /* 读取X轴坐标AD值, 并进行方向变换*/
        yval = tp_read_xoy(0XD0); /* 读取Y轴坐标AD值*/
    }
    else /* X,Y方向与屏幕相同*/
    {
        xval = tp_read_xoy(0XD0); /* 读取X轴坐标AD值*/
        yval = tp_read_xoy(0X90); /* 读取Y轴坐标AD值*/
    }
    *x = xval;
    *y = yval;
}

为了进一步保证参数的精度,我们连续读两次触摸数据并取平均值作为最后的触摸参数,并对这两次滤波值平均后再传给目标存储区,由于AD 的精度为12 位,故该函数读取坐标的值0~4095,tp_read_xy2 的代码如下:

/* 连续两次读取X,Y坐标的数据误差最大允许值*/
#define TP_ERR_RANGE 50 /* 误差范围*/
/**
 * @brief 连续读取2次触摸IC数据, 并滤波
 * @note 连续2次读取触摸屏IC,且这两次的偏差不能超过ERR_RANGE,满足
 * 条件,则认为读数正确,否则读数错误.该函数能大大提高准确度.
 *
 * @param x,y: 读取到的坐标值
 * @retval 0, 失败; 1, 成功;
 */
static uint8_t tp_read_xy2(uint16_t *x, uint16_t *y)
{
    uint16_t x1, y1;
    uint16_t x2, y2;
    tp_read_xy(&x1, &y1); /* 读取第一次数据*/
    tp_read_xy(&x2, &y2); /* 读取第二次数据*/
    /* 前后两次采样在+-TP_ERR_RANGE内*/
    if (((x2 <= x1 && x1 < x2 + TP_ERR_RANGE) || (x1 <= x2 && x2 < x1 + TP_ERR_RANGE)) &&
        ((y2 <= y1 && y1 < y2 + TP_ERR_RANGE) || (y1 <= y2 && y2 < y1 + TP_ERR_RANGE)))
    {
        *x = (x1 + x2) / 2;
        *y = (y1 + y2) / 2;
        return 1;
    }
    return 0;
}

根据以上的流程,可以得到电阻屏触摸点的比较精确的AD 信息。每次触摸屏幕时会对应一组X、Y 的AD 值,由于坐标的AD 值是在X、Y 方向都是线性的,很容易想到要把触摸信息的AD 值和屏幕坐标联系起来,这里需要编写一个坐标转换函数,前面在编写初始化接口时讲到的校准函数这时候就派上用场了。
从前面的知识我们就知道触摸屏的AD 的XAD、YAD可以构成一个逻辑平面,LCD 屏的屏幕坐标X、Y 也是一个逻辑平面,由于存在误差,这两个平面并不重合,校准的作用就是要将逻辑平面映射到物理平面上,即得到触点在液晶屏上的位置坐标。校准算法的中心思想也就是要建立这样一个映射函数现有的校准算法大多是基于线性校准,即首先假定物理平面和逻辑平面之间的误差是线性误差,由旋转和偏移形成。
常用的电阻式触摸屏矫正方法有两点校准法和三点校准法。本文这里介绍的是结合了不同的电阻式触摸屏矫正法的优化算法:五点校正法。其中主要的原理是使用4 点矫正法的比例运算以及三点矫正法的基准点运算。五点校正法优势在于可以更加精确的计算出X 和Y 方向的比例缩放系数,同时提供了中心基准点,对于一些线性电阻系数比较差的电阻式触摸屏有很好的校正功能。校正相关的变量主要有:
⚫ x[5],y[5]五点定位的物理坐标(LCD 坐标)
⚫ xl[5],yl[5]五点定位的逻辑坐标(触摸AD 值)
⚫ KX,KY 横纵方向伸缩系数
⚫ XLC,YLC 中心基点逻辑坐标
⚫ XC,YC 中心基点物理坐标(数值采用LCD 显示屏的物理长宽分辨率的一半)
x[5],y[5]五点定位的物理坐标是已知的,其中4 点分别设置在LCD 的角落,一点设置在LCD 正中心,作为基准矫正点,校正关键点和距离布局如图39.3.3.1 所示。
在这里插入图片描述
图39.3.3.1 电阻屏五点校准法的参考点设定
校正步骤如下:

  1. 通过先后点击LCD 的4 个角落的矫正点,获取4 个角落的逻辑坐标值。
  2. 计算屏幕坐标和四点间距:
S1 = x[1- x[0]
S3 = x[2- x[3]
S2 = y[2- y[1]
S4 = y[3- y[0

一般取点可以人为的设定S1=S3 和S2=S4,以方便运算。
计算逻辑坐标的四点“间距”,由于实际触点肯定会存在误差,所以触摸点会落在实际设定点的更大范围内,在图39.3.1 中,设定点为五个点,但实际采样时触点有时会落在稍大的外圈范围,图中用红色的圆圈标注了,所以有必要设定一个误差范围:

S1’ = xl[1- xl[0]
S3’ = xl[2- xl[3]
S2’ = yl[2- yl[1]
S4’ = yl[3- yl[0

由于触点的误差,对于逻辑点S1’和S3’则大概率不会相等,同样的,S2’和S4’也很难取到相等的点,那么为了简化计算,我们强制以(S1’+S3’)/2 的线长作一个矩形一边,以(S2’+S4’)/2 为矩形另一边,这样构建的矩形在误差范围是可以接受的,也方便计算,于是得到X 和Y 方向的近似缩放系数:

KX =(S1’+ S3’)/ 2 / S1
KY =(S2’+ S4’)/ 2 / S2
  1. 点击LCD 正中心,获取中心点的逻辑坐标,作为矫正的基准点。这里也同样的需要限制误差,之后可以得到一个中心点的AD 值坐标(xl[4],yl[4]),这个点的AD 值我们就作为我们对比的基准点,即xl[4]=XLC,yl[4]=YLC;
  2. 完成以上步骤则校正完成。下次点击触摸屏的时候获取的逻辑值XL 和YL,便可以按下以公式转换为物理坐标:
X = (XL - XLC)/ KX + XC
Y = (YL - YLC)/ KY + YC

最后一步的转换公式可能不好理解,大家换个角度,如果我们求到的缩放比例是正确的,在取新的触摸的时候,这个触摸点的逻辑坐标和物理坐标的转换,必然与中心点在两方向上的缩放比例相等,用中学数学直线斜率相等的情况,变换便可得到上述公式。通过上述得到校准参数后,在以后的使用中,我们把所有得到的物理坐标都按照这个关系式来计算,得到的就是触摸点的屏幕坐标。为了省去每次都需要校准的麻烦,我们保存这些参数到AT24Cxx 的指定扇区地址,这样只要校准一次就可以重复使用这些参数了。根据上面的原理,我们设计的校准函数tp_adjust 如下:

/**
 * @brief 触摸屏校准代码
 * @note 使用五点校准法(具体原理请百度)
 * 本函数得到x轴/y轴比例因子xfac/yfac及物理中心坐标值(xc,yc)等4个参数
 * 我们规定: 物理坐标即AD采集到的坐标值,范围是0~4095.
 * 逻辑坐标即LCD屏幕的坐标, 范围为LCD屏幕的分辨率.
 *
 * @param 无
 * @retval 无
 */
void tp_adjust(void)
{
    uint16_t pxy[5][2]; /* 物理坐标缓存值*/
    uint8_t cnt = 0;
    short s1, s2, s3, s4; /* 4个点的坐标差值*/
    double px, py;        /* X,Y轴物理坐标比例,用于判定是否校准成功*/
    uint16_t outtime = 0;
    cnt = 0;
    lcd_clear(WHITE);                                              /* 清屏*/
    lcd_show_string(40, 40, 160, 100, 16, TP_REMIND_MSG_TBL, RED); /*显示提示信息*/
    tp_draw_touch_point(20, 20, RED);                              /* 画点1 */
    tp_dev.sta = 0;                                                /* 消除触发信号*/
    while (1)                                                      /* 如果连续10秒钟没有按下,则自动退出*/
    {
        tp_dev.scan(1); /* 扫描物理坐标*/
        if ((tp_dev.sta & 0xc000) == TP_CATH_PRES)
        { /* 按键按下了一次(此时按键松开了) */
            outtime = 0;
            tp_dev.sta &= ~TP_CATH_PRES; /* 标记按键已经被处理过了. */
            pxy[cnt][0] = tp_dev.x[0];   /* 保存X物理坐标*/
            pxy[cnt][1] = tp_dev.y[0];   /* 保存Y物理坐标*/
            cnt++;
            switch (cnt)
            {
            case 1:
                tp_draw_touch_point(20, 20, WHITE);              /* 清点1 */
                tp_draw_touch_point(lcddev.width - 20, 20, RED); /* 画点2 */
                break;
            case 2:
                tp_draw_touch_point(lcddev.width - 20, 20, WHITE); /* 清点2 */
                tp_draw_touch_point(20, lcddev.height - 20, RED);  /* 画点3 */
                break;
            case 3:
                tp_draw_touch_point(20, lcddev.height - 20, WHITE); /* 清点3 */
                /* 画点4 */
                tp_draw_touch_point(lcddev.width - 20, lcddev.height - 20, RED);
                break;
            case 4:
                lcd_clear(WHITE); /* 画第五个点了, 直接清屏*/
                /* 画点5 */
                tp_draw_touch_point(lcddev.width / 2, lcddev.height / 2, RED);
                break;
            case 5:                         /* 全部5个点已经得到*/
                s1 = pxy[1][0] - pxy[0][0]; /*第2个点和第1个点的X轴物理坐标差值(AD值)*/
                s3 = pxy[3][0] - pxy[2][0]; /*第4个点和第3个点的X轴物理坐标差值(AD值)*/
                s2 = pxy[3][1] - pxy[1][1]; /*第4个点和第2个点的Y轴物理坐标差值(AD值)*/
                s4 = pxy[2][1] - pxy[0][1]; /*第3个点和第1个点的Y轴物理坐标差值(AD值)*/
                px = (double)s1 / s3;       /* X轴比例因子*/
                py = (double)s2 / s4;       /* Y轴比例因子*/
                if (px < 0)
                    px = -px; /* 负数改正数*/
                if (py < 0)
                    py = -py; /* 负数改正数*/
                if (px < 0.95 || px > 1.05 || py < 0.95 || py > 1.05 ||
                    abs(s1) > 4095 || abs(s2) > 4095 || abs(s3) > 4095 || abs(s4) > 4095 ||
                    abs(s1) == 0 || abs(s2) == 0 || abs(s3) == 0 || abs(s4) == 0)
                { /* 比例不合格,差值大于坐标范围或等于0,重绘校准图形*/
                    cnt = 0;
                    /* 清除点5 */
                    tp_draw_touch_point(lcddev.width / 2, lcddev.height / 2, WHITE);
                    tp_draw_touch_point(20, 20, RED); /* 重新画点1 */
                    tp_adjust_info_show(pxy, px, py); /* 显示当前信息,方便找问题*/
                    continue;
                }
                tp_dev.xfac = (float)(s1 + s3) / (2 * (lcddev.width - 40));
                tp_dev.yfac = (float)(s2 + s4) / (2 * (lcddev.height - 40));
                tp_dev.xc = pxy[4][0]; /* X轴,物理中心坐标*/
                tp_dev.yc = pxy[4][1]; /* Y轴,物理中心坐标*/
                lcd_clear(WHITE);      /* 清屏*/
                lcd_show_string(35, 110, lcddev.width, lcddev.height, 16,
                                "Touch Screen Adjust OK!", BLUE); /* 校准完成*/
                delay_ms(1000);
                tp_save_adjust_data();
                lcd_clear(WHITE); /* 清屏*/
                return;           /* 校正完成*/
            }
        }
        delay_ms(10);
        outtime++;
        if (outtime > 1000)
        {
            tp_get_adjust_data();
            break;
        }
    }
}

注意该函数里面多次使用了lcddev.width 和lcddev.height,用于坐标设置,故在程序调用前需要预先初始化LCD 得到LCD 的一些屏幕信息,主要是为了兼容不同尺寸的LCD(比如320240、480320 和800*480 的屏都可以兼容)。
有了校准参数后,由于我们需要频繁地进行屏幕坐标和物理坐标的转换,我们为电阻屏增加一个tp_scan(uint8_t mode)用于转换,为了实际使用上更灵活,我们使这个参数支持物理坐标和屏幕坐标,设计的函数如下:

/**
 * @brief 触摸按键扫描
 * @param mode: 坐标模式
 * @arg 0, 屏幕坐标;
 * @arg 1, 物理坐标(校准等特殊场合用)
 *
 * @retval 0, 触屏无触摸; 1, 触屏有触摸;
 */
uint8_t tp_scan(uint8_t mode)
{
    if (T_PEN == 0) /* 有按键按下*/
    {
        if (mode) /* 读取物理坐标, 无需转换*/
        {
            tp_read_xy2(&tp_dev.x[0], &tp_dev.y[0]);
        }
        else if (tp_read_xy2(&tp_dev.x[0], &tp_dev.y[0])) /* 读取屏幕坐标, 需要转换*/
        {                                                 /* 将X轴物理坐标转换成逻辑坐标(即对应LCD屏幕上面的X坐标值) */
            tp_dev.x[0] = (signed short)(tp_dev.x[0] - tp_dev.xc) / tp_dev.xfac + lcddev.width / 2;
            /* 将Y轴物理坐标转换成逻辑坐标(即对应LCD屏幕上面的Y坐标值) */
            tp_dev.y[0] = (signed short)(tp_dev.y[0] - tp_dev.yc) / tp_dev.yfac + lcddev.height / 2;
        }
        if ((tp_dev.sta & TP_PRES_DOWN) == 0) /* 之前没有被按下*/
        {
            tp_dev.sta = TP_PRES_DOWN | TP_CATH_PRES; /* 按键按下*/
            tp_dev.x[CT_MAX_TOUCH - 1] = tp_dev.x[0]; /* 记录第一次按下时的坐标*/
            tp_dev.y[CT_MAX_TOUCH - 1] = tp_dev.y[0];
        }
    }
    else
    {
        if (tp_dev.sta & TP_PRES_DOWN) /* 之前是被按下的*/
        {
            tp_dev.sta &= ~TP_PRES_DOWN; /* 标记按键松开*/
        }
        else /* 之前就没有被按下*/
        {
            tp_dev.x[CT_MAX_TOUCH - 1] = 0;
            tp_dev.y[CT_MAX_TOUCH - 1] = 0;
            tp_dev.x[0] = 0xffff;
            tp_dev.y[0] = 0xffff;
        }
    }
    return tp_dev.sta & TP_PRES_DOWN; /* 返回当前的触屏状态*/
}

要进行电阻触摸屏的触摸扫描,只要调取tp_scan()函数,就能灵活地得到触摸坐标。电阻屏的触摸就讲到这里。
3. 电容屏触摸驱动代码
电容触摸芯片使用的是IIC 接口。IIC 接口部分代码,我们可以参考myiic.c 和myiic.h 的代码,为了使代码独立,我们在“TOUCH”文件夹下也是采用软件模拟IIC 的方式实现ctiic.c和ctiic.h,这样IO 的使用更灵活,这里部分参考IIC 章节的知识就可以了,这里不重复介绍了。电容触摸芯片除了IIC 接口相关引脚CT_SCL 和CT_SDA,还有CT_INT 和CT_RST,接口图如图39.3.3.2 所示。
在这里插入图片描述

图39.3.3.2 电容触摸芯片接口图
gt9xxx_init 的实现也比较简单,实现CT_INT 和CT_RST 引脚初始化和调用ct_iic_init 函数实现对CT_SDA 和CT_SCL 初始化。由于电容触摸屏在设计时是根据屏幕进行参数设计的,参数已经保存在芯片内部。所以在初始化后,就可以参考手册推荐的IIC 时序从相对应的坐标数据寄存器中把对应的XY 坐标数据读出来,再通过数据整理转成LCD 坐标。
与电阻屏不同的是,我们是通过IIC 读取状态寄存器的值并非引脚电平。而gt9xxx 系列是支持中断或轮询方式得到触摸状态,本实验使用的是轮询方式:
1、按照读时序,先读取寄存器0x814E,若当前buffer(buffer status为1)数据准备好,则依据有效触点个数到相对应的坐标数据地址处进行坐标数据读取。
2、若在1中发现buffer数据(buffer status为0)未准备好,则等待1ms再进行读取。这样,gt9xxx_scan()函数的实现如下:

/* GT9XXX 10个触摸点(最多) 对应的寄存器表*/
const uint16_t GT9XXX_TPX_TBL[10] =
    {
        GT9XXX_TP1_REG,
        GT9XXX_TP2_REG,
        GT9XXX_TP3_REG,
        GT9XXX_TP4_REG,
        GT9XXX_TP5_REG,
        GT9XXX_TP6_REG,
        GT9XXX_TP7_REG,
        GT9XXX_TP8_REG,
        GT9XXX_TP9_REG,
        GT9XXX_TP10_REG,
};
/**
 * @brief 扫描触摸屏(采用查询方式)
 * @param mode : 电容屏未用到次参数, 为了兼容电阻屏
 * @retval 当前触屏状态
 * @arg 0, 触屏无触摸;
 * @arg 1, 触屏有触摸;
 */
uint8_t gt9xxx_scan(uint8_t mode)
{
    uint8_t buf[4];
    uint8_t i = 0;
    uint8_t res = 0;
    uint16_t temp;
    uint16_t tempsta;
    static uint8_t t = 0; /* 控制查询间隔,从而降低CPU占用率*/
    t++;
    if ((t % 10) == 0 || t < 10)
    {                                              /* 空闲时,每进入10次CTP_Scan函数才检测1次,从而节省CPU使用率*/
        gt9xxx_rd_reg(GT9XXX_GSTID_REG, &mode, 1); /* 读取触摸点的状态*/
        if ((mode & 0X80) && ((mode & 0XF) <= g_gt_tnum))
        {
            i = 0;
            gt9xxx_wr_reg(GT9XXX_GSTID_REG, &i, 1); /* 清标志*/
        }
        if ((mode & 0XF) && ((mode & 0XF) <= g_gt_tnum))
        {
            /* 将点的个数转换为1的位数,匹配tp_dev.sta定义*/
            temp = 0XFFFF << (mode & 0XF);
            tempsta = tp_dev.sta; /* 保存当前的tp_dev.sta值*/
            tp_dev.sta = (~temp) | TP_PRES_DOWN | TP_CATH_PRES;
            tp_dev.x[g_gt_tnum - 1] = tp_dev.x[0]; /* 保存触点0的数据*/
            tp_dev.y[g_gt_tnum - 1] = tp_dev.y[0];
            for (i = 0; i < g_gt_tnum; i++)
            {
                if (tp_dev.sta & (1 << i)) /* 触摸有效? */
                {
                    gt9xxx_rd_reg(GT9XXX_TPX_TBL[i], buf, 4); /* 读取XY坐标值*/
                    if (lcddev.id == 0X5510)                  /* 4.3寸800*480 MCU屏*/
                    {
                        if (tp_dev.touchtype & 0X01) /* 横屏*/
                        {
                            tp_dev.y[i] = ((uint16_t)buf[1] << 8) + buf[0];
                            tp_dev.x[i] = 800 - (((uint16_t)buf[3] << 8) + buf[2]);
                        }
                        else
                        {
                            tp_dev.x[i] = ((uint16_t)buf[1] << 8) + buf[0];
                            tp_dev.y[i] = ((uint16_t)buf[3] << 8) + buf[2];
                        }
                    }
                    else /* 其他型号*/
                    {
                        if (tp_dev.touchtype & 0X01) /* 横屏*/
                        {
                            tp_dev.x[i] = (((uint16_t)buf[1] << 8) + buf[0]);
                            tp_dev.y[i] = (((uint16_t)buf[3] << 8) + buf[2]);
                        }
                        else
                        {
                            tp_dev.x[i] = lcddev.width - (((uint16_t)buf[3] << 8) + buf[2]);
                            tp_dev.y[i] = ((uint16_t)buf[1] << 8) + buf[0];
                        }
                    }
                    // printf("x[%d]:%d,y[%d]:%d\r\n",i,tp_dev.x[i],i,tp_dev.y[i]);
                }
            }
            res = 1;
            if (tp_dev.x[0] > lcddev.width || tp_dev.y[0] > lcddev.height)
            {                         /* 非法数据(坐标超出了) */
                if ((mode & 0XF) > 1) /*有其他点有数据,则复制第二个触点的数据到第一个触点*/
                {
                    tp_dev.x[0] = tp_dev.x[1];
                    tp_dev.y[0] = tp_dev.y[1];
                    t = 0; /* 触发一次,则会最少连续监测10次,从而提高命中率*/
                }
                else /* 非法数据,则忽略此次数据(还原原来的) */
                {
                    tp_dev.x[0] = tp_dev.x[g_gt_tnum - 1];
                    tp_dev.y[0] = tp_dev.y[g_gt_tnum - 1];
                    mode = 0X80;
                    tp_dev.sta = tempsta; /* 恢复tp_dev.sta */
                }
            }
            else
            {
                t = 0; /* 触发一次,则会最少连续监测10次,从而提高命中率*/
            }
        }
    }
    if ((mode & 0X8F) == 0X80) /* 无触摸点按下*/
    {
        if (tp_dev.sta & TP_PRES_DOWN) /* 之前是被按下的*/
        {
            tp_dev.sta &= ~TP_PRES_DOWN; /* 标记按键松开*/
        }
        else /* 之前就没有被按下*/
        {
            tp_dev.x[0] = 0xffff;
            tp_dev.y[0] = 0xffff;
            tp_dev.sta &= 0XE000; /* 清除点有效标记*/
        }
    }
    if (t > 240)
        t = 10; /* 重新从10开始计数*/
    return res;
}

大家可以打开gt9xxx 芯片对应的编程手册,对照时序,即可理解上述的实现过程,只是程序中为了匹配多种屏幕和横屏显示,添加了一些代码。
电容屏驱动ft5206.c/ft5206.h 的驱动实现与gt9xxx 的实现类似,大家参考本例程源码即可。电容屏的触摸实验代码讲解到这里。
4. main 函数和测试代码
在main.c 里面编程如下代码:

void rtp_test(void)
{
    uint8_t key;
    uint8_t i = 0;
    while (1)
    {
        key = key_scan(0);
        tp_dev.scan(0);
        if (tp_dev.sta & TP_PRES_DOWN) /* 触摸屏被按下*/
        {
            if (tp_dev.x[0] < lcddev.width && tp_dev.y[0] < lcddev.height)
            {
                if (tp_dev.x[0] > (lcddev.width - 24) && tp_dev.y[0] < 16)
                {
                    load_draw_dialog(); /* 清除*/
                }
                else
                {
                    tp_draw_big_point(tp_dev.x[0], tp_dev.y[0], RED); /* 画点*/
                }
            }
        }
        else
        {
            delay_ms(10); /* 没有按键按下的时候*/
        }
        if (key == KEY0_PRES) /* KEY0按下,则执行校准程序*/
        {
            lcd_clear(WHITE); /* 清屏*/
            tp_adjust();      /* 屏幕校准*/
            tp_save_adjust_data();
            load_draw_dialog();
        }
        i++;
        if (i % 20 == 0)
            LED0_TOGGLE();
    }
}
/* 10个触控点的颜色(电容触摸屏用) */
const uint16_t POINT_COLOR_TBL[10] = {RED, GREEN, BLUE, BROWN, YELLOW, MAGENTA, CYAN, LIGHTBLUE, BRRED, GRAY};
void ctp_test(void)
{
    uint8_t t = 0;
    uint8_t i = 0;
    uint16_t lastpos[10][2]; /* 最后一次的数据*/
    uint8_t maxp = 5;
    if (lcddev.id == 0X1018)
        maxp = 10;
    while (1)
    {
        tp_dev.scan(0);
        for (t = 0; t < maxp; t++)
        {
            if ((tp_dev.sta) & (1 << t))
            { /* 坐标在屏幕范围内*/
                if (tp_dev.x[t] < lcddev.width && tp_dev.y[t] < lcddev.height)
                {
                    if (lastpos[t][0] == 0XFFFF)
                    {
                        lastpos[t][0] = tp_dev.x[t];
                        lastpos[t][1] = tp_dev.y[t];
                    }
                    lcd_draw_bline(lastpos[t][0], lastpos[t][1], tp_dev.x[t],
                                   tp_dev.y[t], 2, POINT_COLOR_TBL[t]); /* 画线*/
                    lastpos[t][0] = tp_dev.x[t];
                    lastpos[t][1] = tp_dev.y[t];
                    if (tp_dev.x[t] > (lcddev.width - 24) && tp_dev.y[t] < 20)
                    {
                        load_draw_dialog(); /* 清除*/
                    }
                }
            }
            else
            {
                lastpos[t][0] = 0XFFFF;
            }
        }
        delay_ms(5);
        i++;
        if (i % 20 == 0)
            LED0_TOGGLE();
    }
}
int main(void)
{
    HAL_Init();                         /* 初始化HAL库*/
    sys_stm32_clock_init(RCC_PLL_MUL9); /* 设置时钟, 72Mhz */
    delay_init(72);                     /* 延时初始化*/
    usart_init(115200);                 /* 串口初始化为115200 */
    led_init();                         /* 初始化LED */
    lcd_init();                         /* 初始化LCD */
    key_init();                         /* 初始化按键*/
    tp_dev.init();                      /* 触摸屏初始化*/
    lcd_show_string(30, 50, 200, 16, 16, "STM32F103", RED);
    lcd_show_string(30, 70, 200, 16, 16, "TOUCH TEST", RED);
    lcd_show_string(30, 90, 200, 16, 16, "ATOM@ALIENTEK", RED);
    if (tp_dev.touchtype != 0XFF)
    { /* 电阻屏才显示*/
        lcd_show_string(30, 110, 200, 16, 16, "Press KEY0 to Adjust", RED);
    }
    delay_ms(1500);
    load_draw_dialog();
    if (tp_dev.touchtype & 0X80)
    {
        ctp_test(); /* 电容屏测试*/
    }
    else
    {
        rtp_test(); /* 电阻屏测试*/
    }
}

上面没有把main.c 全部代码列出来,只是列出重要函数,这里简单介绍一下这三个函数。rtp_test,该函数用于电阻触摸屏的测试,该函数代码比较简单,就是扫描按键和触摸屏,如果触摸屏有按下,则在触摸屏上面划线,如果按中“RST”区域,则执行清屏。如果按键KEY0按下,则执行触摸屏校准。
ctp_test,该函数用于电容触摸屏的测试,由于我们采用tp_dev.sta 来标记当前按下的触摸屏点数,所以判断是否有电容触摸屏按下,也就是判断tp_dev.sta 的最低5 位,如果有数据,则画线,如果没数据则忽略,且5 个点画线的颜色各不一样,方便区分。另外,电容触摸屏不需要校准,所以没有校准程序。
main 函数,则比较简单,初始化相关外设,然后根据触摸屏类型,去选择执行ctp_tes t 还是rtp_test。
软件部分就介绍到这里,接下来看看下载验证。

下载验证

在代码编译成功之后,我们通过下载代码到开发板上,电阻触摸屏测试如图39.4.1 所示界面:
在这里插入图片描述

图39.4.1 电阻触摸屏测试程序运行效果
图中我们在电阻屏上画了一些内容,右上角的RST 可以用来清屏,点击该区域,即可清屏重画。另外,按KEY0 可以进入校准模式,如果发现触摸屏不准,则可以按KEY0,进入校准,重新校准一下,即可正常使用。
如果是电容触摸屏,测试界面如图39.4.2 所示:
在这里插入图片描述

图39.4.2 电容触摸屏测试界面
图中,同样输入了一些内容。电容屏支持多点触摸,每个点的颜色都不一样,图中的波浪线就是三点触摸画出来的,最多可以5 点触摸。按右上角的RST 标志,可以清屏。电容屏无需校准,所以按KEY0 无效。KEY0 校准仅对电阻屏有效。

外扩SRAM 实验

STM32F103ZET6 自带了64K 字节的RAM,对一般应用来说,已经足够了,不过在一些对内存要求高的场合,比如做华丽效果的GUI,处理大量数据的应用等,STM32 自带的这些内存就可能不太够用了。好在嵌入式方案提供了扩展芯片RAM 的方法,本章将介绍我们开发板上使用的RAM 拓展方案:使用SRAM 芯片,并驱动这个外部SRAM 提供程序需要的一部分RAM空间,对其进行读写测试。

存储器简介

使用电脑时,我们会提到内存和内存条的概念,电脑维修的朋友有时候会说加个内存条电脑就不卡了。实际上对于PC 来说一些情况下卡顿就是电脑同时运行的程序太多了,电脑处理速度变慢的现象。而程序是动态加载到内存中的,一种解决方法就是增加电脑的内存来增加同时可处理的程序的数量。对于单片机也是一样的,高性能有时候需要通过增加大内存来获得。内存是存储器的一种,由于微机架构设计了不同的存储器放置不同的数据,所以我们也简单来了解一下存储器。
存储器实际上是时序逻辑电路的一种,用来存放程序和数据信息。构成存储器的存储介质主要采用半导体器件和磁性材料。存储器中最小的存储单位就是一个双稳态半导体电路或一个CMOS 晶体管或磁性材料的存储元,它可存储一个二进制代码。由若干个存储元组成一个存储单元,然后再由许多存储单元组成一个存储器。按不同的分类方式,存储器可以有表47.1.1 所示的分类:
在这里插入图片描述
表47.1.1 存储器的分类
对于上述分类,在我们STM32 编程学习中我们常常只关心按读写功能分类的ROM 和RAM两种,因为嵌入式程序主要对应到这两种存储器。对于RAM,目前常见的是SRAM 和DRAM,它们因工作方式不同而得名,它们主要有以下的特性,如表47.1.2 所示:
在这里插入图片描述
表47.1.2 SRAM 和DRAM 特性
在STM32 上,我们编译的程序,编译器一般会根据对应硬件的结构把程序中不同功能的数据段分为ZI\RW\RO 这样的数据块,执行程序时分别放到不同的存储器上,这部分参考我们《第九章STM32 启动过程分析》中关于map 文件的描述。对于我们编写的STM32 程序中的变量,在默认配置下是加载到STM32 的RAM 区中执行的。而像程序代码和常量等编译后就固定不变的则会放到ROM 区。
存储器的知识我们就介绍到这里,限于篇幅只能作简单的引用和介绍,大家可以查找资料拓展对各种存储器作一下加深了解。

SRAM 方案简介

RAM 的功能我们已经介绍过了,SRAM 更稳定,但因为结构更复杂且造价更高,所以有更大片上SRAM 的STM32 芯片造价也更高。而且由于SRAM 集成度低的原因,MCU 也不会把片上SRAM 做得特别大,基于以上原因,计算机/微机系统中都允许采用外扩RAM 的方式提高性能。

  1. SRAM 芯片介绍
    IS62WV51216 方案
    IS62WV51216 是ISSI (Integrated Silicon Solution, Inc)公司生产的一颗16 位宽512K(51216,
    即1M 字节)容量的CMOS 静态内存芯片。该芯片具有如下几个特点:
    ⚫ 高速。具有45ns/55ns 访问速度。
    ⚫ 低功耗。
    ⚫ TTL 电平兼容。
    ⚫ 全静态操作。不需要刷新和时钟电路。
    ⚫ 三态输出。
    ⚫ 字节控制功能。支持高/低字节控制。
    IS62WV51216 的功能框图如图47.2.1 所示:
    在这里插入图片描述
    图47.2.1 IS62WV51216 功能框图
    图中A0~18 为地址线,总共19 根地址线(即2^19=512K,1K=1024);IO0~15 为数据线,总共16 根数据线。CS2 和CS1 都是片选信号,不过CS2 是高电平有效CS1 是低电平有效;OE是输出使能信号(读信号);WE 为写使能信号;UB 和LB 分别是高字节控制和低字节控制信号;
    XM8A51216 方案
    国产替代一直是国内嵌入式领域的一个话题,国产替代的优势一般是货源稳定,售价更低,也有专门研发对某款芯片作Pin to Pin 兼容的厂家,使用时无需修改PCB,直接更换元件即可,十分方便。
    正点原子开发板目前使用的一款替代IS62WV51216 的芯片是XM8A5121 ,它与IS62WV51216 一样采用TSOP44 封装,引脚顺序也与前者完全一致。
    XM8A51216 是星忆存储生产的一颗16 位宽512K(512
    16,即1M 位)容量的CMOS 静态内存芯片。采用异步SRAM 接口并结合独有的XRAM 免刷新专利技术,在大容量、高性能和高可靠及品质方面完全可以匹敌同类SRAM,具有较低功耗和低成本优势,可以与市面上同类型SRAM 产品硬件完全兼容,并且满足各种应用系统对高性能和低成本的要求,XM8A51216也可以当做异步SRAM 使用,该芯片具有如下几个特点:
    ⚫高速。具有最高访问速度10/12/15ns。
    ⚫低功耗。
    ⚫TTL 电平兼容。
    ⚫全静态操作。不需要刷新和时钟电路。
    ⚫三态输出。
    ⚫字节控制功能。支持高/低字节控制。
    该芯片与IS62WV51216 引脚和完全兼容,控制时序也类似,大家可以方便地直接替换。
    本章,我们使用FSMC 的BANK1 区域3 来控制SRAM 芯片,关于FSMC 的详细介绍,我们在学习LCD 的章节已经介绍过,我们采用的是读写不同的时序来操作TFTLCD 模块(因为TFTLCD 模块读的速度比写的速度慢很多),但是在本章,因为IS62WV51216/XM8A51216 的读写时间基本一致,所以,我们设置读写相同的时序来访问FSMC。关于FSMC 的详细介绍,请大家看“TFT LCD 实验”和《STM32F10xxx 参考手册_V10(中文版).pdf》。

硬件设计

  1. 例程功能
    本章实验功能简介:开机后,显示提示信息,然后按下KEY0 按键,即测试外部SRAM 容量大小并显示在LCD 上。按下KEY1 按键,即显示预存在外部SRAM 的数据。LED0 指示程序运行状态。
  2. 硬件资源
    1)LED 灯
    LED0 –PB5
    2)按键:
    KEY0:PE4
    KEY1:PE3
    3)SRAM 芯片:
    XM8A51216/IS62WV51216
    4)串口1(PA9/PA10 连接在板载USB 转串口芯片CH340 上面)
    5)正点原子2.8/3.5/4.3/7/10 寸TFTLCD 模块(仅限MCU 屏,16 位8080 并口驱动)
  3. 原理图
    SRAM 芯片与STM32 的连接关系,如下图所示:
    在这里插入图片描述

图47.3.1 STM32 和SRAM 连接原理图(XM8A51216/IS62WV51216 封装相同)
SRAM 芯片直接是接在STM32F1 的FSMC 外设上,具体的引脚连接关系如下表47.3.1 所示。
在这里插入图片描述
表47.3.1 STM32 和SRAM 芯片的连接原理图
在上面的连接关系中,SRAM 芯片的A[0:18]并不是按顺序连接STM32F1 的FMSC_A[0:18],这样设计的好处,就是可以方便我们的PCB 布线。不过这并不影响我们正常使用外部SRAM,因为地址具有唯一性,只要地址线不和数据线混淆,就可以正常使用外部SRAM。

程序设计

操作SRAM 时要通过多个地址线寻址,然后才可以读写数据,在STM32 上可以使用FSMC来实现,在TFT_LCD 一节我们也已经讲解过FSMC 接口的驱动,与之前的用法类似,关于HAL库部分函数介绍我们这里就不重复了。
使用SRAM 的配置步骤
1)使能FSMC 时钟,并配置FSMC 相关的IO 及其时钟使能。
要使用FSMC,当然首先得开启其时钟。然后需要把FSMC_D015,FSMCA018 等相关IO 口,全部配置为复用输出,并使能各IO 组的时钟。
使能FSMC 时钟的方法前面LCD 实验已经讲解过,方法为:

__HAL_RCC_FSMC_CLK_ENABLE();

配置IO 口为复用输出的关键行代码为:

gpio_init_struct.Mode = GPIO_MODE_AF_PP; /* 复用推挽输出*/

2)设置FSMC BANK1 区域3 的相关寄存器。
此部分包括设置区域3 的存储器的工作模式、位宽和读写时序等。本章我们使用模式A、
16 位宽,读写共用一个时序寄存器。
这个是通过调用函数HAL_SRAM_Init 来实现的,函数原型为:

HAL_StatusTypeDef HAL_SRAM_Init(SRAM_HandleTypeDef *hsram,
FSMC_NORSRAM_TimingTypeDef *Timing, FSMC_NORSRAM_TimingTypeDef *ExtTiming)

通过以上几个步骤,我们就完成了FSMC 的配置,初始化FSMC 后就可以访问SRAM 芯片时行读写操作了,这里还需要注意,因为我们使用的是BANK1 的区域3 ,所以HADDR[27:26]=10,故外部内存的首地址为0X68000000。

程序流程图

在这里插入图片描述
图47.4.1.1 SRAM 实验程序流程图

程序解析

  1. SRAM 驱动
    这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。SRAM 驱动源码包括两个文件:sram.c 和sram.h。
    为方便修改,我们在sram.h 中使用宏定义SRAM 的读写控制和片选引脚,它们定义如下:
#define SRAM_WR_GPIO_PORT GPIOD
#define SRAM_WR_GPIO_PIN GPIO_PIN_5
#define SRAM_WR_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOD_CLK_ENABLE();}while(0)
#define SRAM_RD_GPIO_PORT GPIOD
#define SRAM_RD_GPIO_PIN GPIO_PIN_4
#define SRAM_RD_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOD_CLK_ENABLE(); }while(0)
/* SRAM_CS(需要根据SRAM_FSMC_NEX设置正确的IO口) 引脚定义*/
#define SRAM_CS_GPIO_PORT GPIOG
#define SRAM_CS_GPIO_PIN GPIO_PIN_10
#define SRAM_CS_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOG_CLK_ENABLE();}while(0)

根据STM32F1 参考手册,SRAM 可以选择FSMC 对应的存储块1 上的4 个区域之一作为访问地址,它上面有四块相互独立的64M 的连续寻址空间,为了能灵活根据不同的计算出使用的地址空间,我们定义了以下的宏:

/* FSMC相关参数定义
* 注意: 我们默认是通过FSMC块3来连接SRAM, 块1有4个片选: FSMC_NE1~4
*
* 修改SRAM_FSMC_NEX, 对应的SRAM_CS_GPIO相关设置也得改
*/
#define SRAM_FSMC_NEX 3 /* 使用FSMC_NE3接SRAM_CS,取值范围只能是: 1~4 */
/*****************************************************************/
/* SRAM基地址, 根据SRAM_FSMC_NEX 的设置来决定基址地址
* 我们一般使用FSMC的块1(BANK1)来驱动SRAM, 块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
*/
#define SRAM_BASE_ADDR (0X60000000 + (0X4000000 * (SRAM_FSMC_NEX - 1)))

上述定义SRAM_FSMC_NEX 的值为3,即使用FSMC 存储块1 的第3 个地址范围,上面的SRAM_BASE_ADDR 则根据我们使用的存储块计算出SRAM 空间的首地址,存储块3 对应的是0X68000000 ~ 0X6BFFFFFF 的地址空间。
sram_init 的类似于LCD,我们需要根据原理图配置SRAM 的控制引脚,复用连接到SRAM芯片上的IO 作为FSMC 的地址线,根据SRAM 芯片上的进序设置地址线宽度、等待时间、信号极性等,则sram 的初始化函数我们编写如下:

void sram_init(void)
{
    GPIO_InitTypeDef GPIO_Initure;
    FSMC_NORSRAM_TimingTypeDef fsmc_readwritetim;
    SRAM_CS_GPIO_CLK_ENABLE();    /* SRAM_CS脚时钟使能*/
    SRAM_WR_GPIO_CLK_ENABLE();    /* SRAM_WR脚时钟使能*/
    SRAM_RD_GPIO_CLK_ENABLE();    /* SRAM_RD脚时钟使能*/
    __HAL_RCC_FSMC_CLK_ENABLE();  /* 使能FSMC时钟*/
    __HAL_RCC_GPIOD_CLK_ENABLE(); /* 使能GPIOD时钟*/
    __HAL_RCC_GPIOE_CLK_ENABLE(); /* 使能GPIOE时钟*/
    __HAL_RCC_GPIOF_CLK_ENABLE(); /* 使能GPIOF时钟*/
    __HAL_RCC_GPIOG_CLK_ENABLE(); /* 使能GPIOG时钟*/
    GPIO_Initure.Pin = SRAM_CS_GPIO_PIN;
    GPIO_Initure.Mode = GPIO_MODE_AF_PP;
    GPIO_Initure.Pull = GPIO_PULLUP;
    GPIO_Initure.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(SRAM_CS_GPIO_PORT, &GPIO_Initure); /* SRAM_CS引脚模式设置*/
    GPIO_Initure.Pin = SRAM_WR_GPIO_PIN;
    HAL_GPIO_Init(SRAM_WR_GPIO_PORT, &GPIO_Initure); /* SRAM_WR引脚模式设置*/
    GPIO_Initure.Pin = SRAM_RD_GPIO_PIN;
    HAL_GPIO_Init(SRAM_RD_GPIO_PORT, &GPIO_Initure); /* SRAM_CS引脚模式设置*/
    /* PD0,1,4,5,8~15 */
    GPIO_Initure.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_8 | GPIO_PIN_9 |
                       GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13 GPIO_PIN_14 | GPIO_PIN_15;
    GPIO_Initure.Mode = GPIO_MODE_AF_PP;       /* 推挽复用*/
    GPIO_Initure.Pull = GPIO_PULLUP;           /* 上拉*/
    GPIO_Initure.Speed = GPIO_SPEED_FREQ_HIGH; /* 高速*/
    HAL_GPIO_Init(GPIOD, &GPIO_Initure);
    /* PE0,1,7~15 */
    GPIO_Initure.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_7 | GPIO_PIN_8 |
                       GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12 |
                       GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15;
    HAL_GPIO_Init(GPIOE, &GPIO_Initure);
    /* PF0~5,12~15 */
    GPIO_Initure.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 |
                       GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_12 | GPIO_PIN_13 |
                       GPIO_PIN_14 | GPIO_PIN_15;
    HAL_GPIO_Init(GPIOF, &GPIO_Initure);
    /* PG0~5,10 */
    GPIO_Initure.Pin = GPIO_PIN_0 | GPIO_PIN_1 |
                       GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4 | GPIO_PIN_5;
    HAL_GPIO_Init(GPIOG, &GPIO_Initure);
    g_sram_handler.Instance = FSMC_NORSRAM_DEVICE;
    g_sram_handler.Extended = FSMC_NORSRAM_EXTENDED_DEVICE;
    g_sram_handler.Init.NSBank = (SRAM_FSMC_NEX == 1) ? FSMC_NORSRAM_BANK1 : (SRAM_FSMC_NEX == 2) ? FSMC_NORSRAM_BANK2
                                                                         : (SRAM_FSMC_NEX == 3)   ? FSMC_NORSRAM_BANK3
                                                                                                  : FSMC_NORSRAM_BANK4; /* 根据配置选择FSMC_NE1~4 */
    /* 地址/数据线不复用*/
    g_sram_handler.Init.DataAddressMux = FSMC_DATA_ADDRESS_MUX_DISABLE;
    g_sram_handler.Init.MemoryType = FSMC_MEMORY_TYPE_SRAM; /* SRAM */
    /* 16位数据宽度*/
    g_sram_handler.Init.MemoryDataWidth = SMC_NORSRAM_MEM_BUS_WIDTH_16;
    /* 是否使能突发访问,仅对同步突发存储器有效,此处未用到*/
    g_sram_handler.Init.BurstAccessMode = FSMC_BURST_ACCESS_MODE_DISABLE;
    /* 等待信号的极性,仅在突发模式访问下有用*/
    g_sram_handler.Init.WaitSignalPolarity = FSMC_WAIT_SIGNAL_POLARITY_LOW;
    /* 存储器是在等待周期之前的一个时钟周期还是等待周期期间使能NWAIT */
    g_sram_handler.Init.WaitSignalActive = FSMC_WAIT_TIMING_BEFORE_WS;
    /* 存储器写使能*/
    g_sram_handler.Init.WriteOperation = FSMC_WRITE_OPERATION_ENABLE;
    /* 等待使能位,此处未用到*/
    g_sram_handler.Init.WaitSignal = FSMC_WAIT_SIGNAL_DISABLE;
    /* 读写使用相同的时序*/
    g_sram_handler.Init.ExtendedMode = FSMC_EXTENDED_MODE_DISABLE;
    /* 是否使能同步传输模式下的等待信号,此处未用到*/
    g_sram_handler.Init.AsynchronousWait = FSMC_ASYNCHRONOUS_WAIT_DISABLE;
    g_sram_handler.Init.WriteBurst = FSMC_WRITE_BURST_DISABLE; /* 禁止突发写*/
    /* FSMC读时序控制寄存器*/
    /* 地址建立时间(ADDSET)为1个HCLK 1/72M=13.8ns */
    fsmc_readwritetim.AddressSetupTime = 0x00; /* 地址建立时间为1个HCLK=13.8ns */
    fsmc_readwritetim.AddressHoldTime = 0x00;  /* 地址保持时间在模式A未用到*/
    fsmc_readwritetim.DataSetupTime = 0x01;    /* 数据保存时间为2个HCLK=27.6ns */
    fsmc_readwritetim.BusTurnAroundDuration = 0X00;
    fsmc_readwritetim.AccessMode = FSMC_ACCESS_MODE_A; /* 模式A */
    HAL_SRAM_Init(&g_sram_handler, &fsmc_readwritetim, &fsmc_readwritetim);
}

初始化成功后,FSMC 控制器就能根据扩展的地址线访问SRAM 的数据,于是我们可以直接根据地址指针来访问SRAM,我们定义SRAM 的写函数如下;

void sram_write(uint8_t *pbuf, uint32_t addr, uint32_t datalen)
{
    for (; datalen != 0; datalen--)
    {
        *(volatile uint8_t *)(SRAM_BASE_ADDR + addr) = *pbuf;
        addr++;
        pbuf++;
    }
}

同样地,也是利用地址,可以构造出一个SRAM 的连续读函数:

void sram_read(uint8_t *pbuf, uint32_t addr, uint32_t datalen)
{
    for (; datalen != 0; datalen--)
    {
        *pbuf++ = *(volatile uint8_t *)(SRAM_BASE_ADDR + addr);
        addr++;
    }
}

注意以上两个函数是操作uint8_t 类型的指针,当使用其它类型的指针时需要注意指针的偏移量。难点主要是根据SRAM 芯片上的时序来初始化FSMC 控制器,大家参考芯片手册上的时序结合代码来理解这部分初始化的过程。
2. main.c 代码
初始化好SRAM,我们就可以使用SRAM 中的存储进行编程了。我们利用ARM 编译器的特性:可以在某一绝对地址定义变量。为方便测试,我们直接定义一个与SRAM 容量大小类似的数组,由于是1M 位的RAM,我们定义了uint32_t 类型后,大小要除4,故定义的测试数组如下:

/* 测试用数组, 起始地址为: SRAM_BASE_ADDR */
#if (__ARMCC_VERSION >= 6010050)
uint32_t g_test_buffer[250000] __attribute__((section(".bss.ARM.__at_0x68000000")));
#else
uint32_t g_test_buffer[250000] __attribute__((at(SRAM_BASE_ADDR)));
#endif

这里的__attribute__(())是ARM 编译器的一种关键字,它有很多种用法,可以通过特殊修饰指定变量或者函数的属性。大家可以去MDK 的帮助文件里查找这个关键字的其它用法。这里我们要用这个关键字把变量放到指定的位置,而且用了条件编译,因为MDK 的AC5 和AC6 下的语法不同。
通过前面的描述,我们知道SRAM 的访问基地址是0x68000000,如果我们定义一个与SRAM 空间大小相同的数组,而且数组指向的位置就是0x68000000 的话,则这通过数组就可以很方便直接操作这块存储空间。所以回来前面所说的__attribute__这个关键字。对于AC5,它可以用__attribute__((at(地址)))的方法来修饰变量,而且这个地址可以是一个算式,这样编译器在编译时就会通过这个关键字判断并把这个数组放到我们定义的空间,如果硬件支持的情况下,我们就可以访问这些指定空间的变量或常量了。但是对于AC6,同样指定地址,需要用__attribute__((section(“.bss.ARM.__at_地址”)))的方法,指定一个绝对地址才能把变量或者常量放
到我们所需要定义的位置。这里这个地址就不支持算式了,但是这个语法对于相对而言更加地通用,其它平台的编译器如gcc 也有类似的语法,而且AC5 下也可以用AC6 的这种语法来达到相同效果,两者之间的差异,大家可以多实践以进行区分。
在main.c 文件中,我们还定义了一个SRAM 读写测试函数fsmc_sram_test,该函数内部实现比较简单,这里就不多说了。main 函数就是通过调用fsmc_sram_test 函数实现对SRAM 读写测试。
在main 函数中编写代码如下:

int main(void)
{
    uint8_t key;
    uint8_t i = 0;
    uint32_t ts = 0;
    HAL_Init();                         /* 初始化HAL库*/
    sys_stm32_clock_init(RCC_PLL_MUL9); /* 设置时钟, 72Mhz */
    delay_init(72);                     /* 延时初始化*/
    usart_init(115200);                 /* 串口初始化为115200 */
    usmart_dev.init(72);                /* 初始化USMART */
    led_init();                         /* 初始化LED */
    lcd_init();                         /* 初始化LCD */
    key_init();                         /* 初始化按键*/
    sram_init();                        /* SRAM初始化*/
    lcd_show_string(30, 50, 200, 16, 16, "STM32", RED);
    lcd_show_string(30, 70, 200, 16, 16, "SRAM TEST", RED);
    lcd_show_string(30, 90, 200, 16, 16, "ATOM@ALIENTEK", RED);
    lcd_show_string(30, 110, 200, 16, 16, "KEY0:Test Sram", RED);
    lcd_show_string(30, 130, 200, 16, 16, "KEY1:TEST Data", RED);
    for (ts = 0; ts < 250000; ts++)
    {
        g_test_buffer[ts] = ts; /* 预存测试数据*/
    }
    while (1)
    {
        key = key_scan(0); /* 不支持连按*/
        if (key == KEY0_PRES)
        {
            fsmc_sram_test(30, 150); /* 测试SRAM容量*/
        }
        else if (key == KEY1_PRES) /* 打印预存测试数据*/
        {
            for (ts = 0; ts < 250000; ts++)
            { /* 显示测试数据*/
                lcd_show_xnum(30, 170, g_test_buffer[ts], 6, 16, 0, BLUE);
            }
        }
        else
        {
            delay_ms(10);
        }
        i++;
        if (i == 20)
        {
            i = 0;
            LED0_TOGGLE(); /* LED0闪烁*/
        }
    }
}

下载验证

在代码编译成功之后,我们下载代码到开发板上,得到如图47.5.1 所示界面:
在这里插入图片描述

图47.5.1 程序运行效果图
此时,我们按下KEY0,就可以在LCD 上看到内存测试的画面,同样,按下KEY1,就可以看到LCD 显示存放在数组g_test_buffer 里面的测试数据,我们把数组的下标直接写到SRAM中,可以看到这个数据在不断地更新,SRAM 读写操作成功了,如图47.5.2 所示:
在这里插入图片描述

图47.5.2 外部SRAM 测试界面
该实验我们还可以借助USMART 来测试,如图47.5.3 所示:
在这里插入图片描述

图47.5.3 借助USMART 测试外部SRAM 读写

NAND FLASH 实验

阿波罗STM32F429 核心板上面,板载了一颗512MB 的NAND FLASH 芯片,型号为:MT29F4G08,我们可以用它来存储数据,相对于SPI FLASH(W25Q256)和SD 卡等存储设备,NAND FLASH 采用8 位并口访问,具有访问速度快的优势。
本章,我们将使用STM32F429 来驱动MT29F4G08,并结合一个简单的坏块管理与磨损均衡算法,实现对MT29F4G08 的读写控制。

NAND FLASH 简介

本章我们将通过STM32F429 的FMC 接口,来驱动MT29F4G08 这颗NAND FLASH 芯片(SLC,STM32 仅支持SLC 类型的NAND),本节我们将介绍NAND FLASH 相关知识点,包括:1,NAND FLASH 简介;2,FTL 简介;3,FMC NAND FLASH 接口简介;

NAND FLASH 简介

NAND FLASH 的概念是由东芝公司在1989 年率先提出,它内部采用非线性宏单元模式,为固态大容量内存的实现提供了廉价有效的解决方案。NAND FLASH 存储器具有容量较大,改写速度快等优点,适用于大量数据的存储,在业界得到了广泛应用,如:SD 卡、TF 卡、U盘等,一般都是采用NAND FLASH 作为存储的。关于NAND FLASH 的基础知识,请大家自行百度学习。接下来,我们介绍NAND FLASH 的一些重要知识。
(1)NAND FLASH 信号线
NAND FLASH 的信号线如表45.1.1.1 所示:
在这里插入图片描述
表45.1.1.1 NAND FLASH 信号线
因为NAND FLASH 地址/数据是共用数据线的,所以必须有CLE/ALE 信号,告诉NAND FLASH,发送的数据是命令还是地址。
(2)存储单元
NAND FLASH 存储单元介绍,我们以阿波罗STM32F429 开发板所使用的MT29F4G08(x8,8 位数据)为例进行介绍,MT29F4G08 的存储单元组织结构如图45.1.1.1 所示:
在这里插入图片描述
图45.1.1.1 MT29F4G08 存储单元组织结构图
由图可知:MT29F4G08 由2 个plane 组成,每个plane 有2048 个block,每个block 由64个page 组成,每个page 有2K+64 字节(2112 字节)的存储容量。所以,MT29F4G08 的总容量为:2204864*(2K+64)= 553648128 字节(512MB)。其中,plane、block、page 等的个数根据NAND FLASH 型号的不同,会有所区别,大家注意查看对应NAND FLASH 芯片的数据手册。
NAND FLASH 的最小擦除单位是block,对MT29F4G08 来说,是(128+4)K 字节,NAND FLASH 的写操作具有只可以写0,不能写1 的特性,所以,在写数据的时候,必须先擦除block
(擦除后,block 数据全部为1),才可以写入。
NAND FLASH 的page 由2 部分组成:数据存储区(data area)和备用区域(spare area),对MT29F4G08 来说,数据存储区大小为2K 字节,备用区域大小为64 字节。我们存储的有效数据,一般都是存储在数据存储区(data area)。备用区域(spare area),一般用来存放ECC (Error Checking and Correcting)校验值,在本章中,我们将利用这个区域,来实现NAND FLASH 坏块管理和磨损均衡。
NAND FLASH 的地址分为三类:块地址(Block Address)、页地址(Page Address)和列地址(Column Address)。以MT29F4G08 为例,这三个地址,通过5 个周期发送,如表45.1.1.2所示:
在这里插入图片描述
表45.1.1.2 MT29F4G08 寻址说明
表中,CA0~ CA11 为列地址(Column Address),用于在一个Page 内部寻址,MT29F4G08的一个Page 大小为2112 字节,需要12 个地址线寻址;PA0~PA5 为页地址(Page Address),用于在一个Block 内部寻址,MT29F4G08 一个Block 大小为64 个Page,需要6 个地址线寻址;
BA6~BA17 为块地址(Block Address),用于块寻址,MT29F4G08 总共有4096 个Block,需要12 根地址线寻址。
整个寻址过程,分5 次发送(5 个周期),首先发送列地址,在发送页地址和块地址。这里提醒一下:块地址和页地址,其实是可以写在一起的,由一个参数传递即可,所以表中的BA
并不是由BA0 开始的,大家可以理解为这个地址(PA+BA)为整个NAND FLASH 的Page 地址。在完成寻址以后,数据线I/O0~ I/O7 来传输数据了。
(3)控制命令
NAND FLASH 的驱动需要用到一系列命令,这里我们列出常用的一些命令,给大家做一个简单介绍,方便大家了解NAND FLASH 的操作,如表45.1.1.3 所示:

在这里插入图片描述
表45.1.1.3 NADN FLASH 操作常用命令
表45.1.1.3 中,我们需要注意两点:1,有的指令一个周期完成传送,有的指令需要分两次传送(2 个周期);2,指令名称,不同厂家的数据手册里面,标注可能不一样,但是其指令值(HEX 值)一般都是一样的。
上表中,前四条命令相对比较简单,这里我们主要介绍后面五条指令。
1,READ PAGE
该指令用于读取NAND 的一个Page(包括spare 区数据,但不能跨页读),该指令时序如图45.1.1.2 所示:
在这里插入图片描述

图45.1.1.2 READ PAGE 指令时序图
由图可知,READ PAGE 的命令分两次发送,首先发送00H 命令,然后发送5 次地址(Block&Page&Column 地址),指定读取的地址,随后发送30H 命令,在等待RDY 后,即可读取PAGE 里面的数据。注意:不能跨页读,所以最多一次读取一个PAGE 的数据(包括spare区)。
2,WRITE PAGE
该指令用于写一个Page的数据(包括spare区数据,但不能跨页写),该指令时序如图45.1.1.3所示:
在这里插入图片描述

图45.1.1.3 READ PAGE 指令时序图
由图可知,WRITE PAGE 的命令分两次发送,首先发送80H 命令,然后发送5 次地址(Block&Page&Column 地址),指定写入的地址,在地址写入完成后,等待tADL 时间后,开始发送需要写入的数据,在数据发送完毕后,发送10H 命令,最后发送READ STATUS 命令,查询NAND FLASH 状态,等待状态为READY 后,完成一次PAGE 写入操作。
3,ERASE BLOCK
该指令用于擦除NAND 的一个Block(NAND 的最小擦除单位),该指令时序如图45.1.1.4
所示:
在这里插入图片描述

图45.1.1.4 ERASE BLOCK 指令时序图
由图可知,ERASE BLOCK 的命令分两次发送,首先发送60H 命令,然后发送3 次地址(BLOCK 地址),指定要擦除的BLOCK 地址,随后发送D0H 命令,在等待RDY 成功后,完成一个BLOCK 的擦除。
4,READ FOR INTERNAL DATA MOVE
该指令用于在NAND 内部进行数据移动时(页对页),指定需要读取的PAGE 地址,如有必要,可以读取出PAGE 里面的数据。该指令时序如图45.1.1.5 所示:
在这里插入图片描述

图45.1.1.5 READ FOR INTERNAL DATA MOVE 指令时序图
由图可知,READ FOR INTERNAL DATA MOVE 的命令分两次发送,首先发送00H 命令,然后发送5 次地址(Block&Page&Column 地址),指定读取的地址,随后发送35H 命令,在等待RDY 后,可以读取对应PAGE 里面的数据。在内部数据移动过程中,我们仅用该指令指定需要拷贝的PAGE 地址(源地址),并不需要读取其数据,所以最后的Dout 过程,一般都可以省略。
5,PROGRAM FOR INTERNAL DATA MOVE
该指令用于在NAND 内部进行数据移动时(页对页),指定需要写入的PAGE 地址(目标地址),如有必要,在拷贝过程中,可以写入新的数据,该指令时序如图45.1.1.6 所示:
在这里插入图片描述
图45.1.1.6 PROGRAM FOR INTERNAL DATA MOVE 指令时序图
如图45.1.1.6 所示,该指令,首先发送85H 命令,然后发送5 次地址(Block&Page&Column地址),指定写入的页地址(目标地址),源地址则由READ FOR INTERNAL DATA MOVE 指令指定。接下来分两种情况:1,要写入新的数据(覆盖源PAGE 的内容);2,无需写入新的数据;
对于第1 种情况,在等待tWHR(或tADL)之后,开始写入新的数据,数据在页内的起始地址,由C1&C2 指定,写入完成后,发送10H 命令,开始进行页拷贝,在等待RDY 后,完成一次页对页的数据拷贝(带新数据写入)。
对于第2 种情况,在发送完5 次地址后,无需发送新数据,直接发送10H 命令,开始进行页拷贝,在等待RDY 后,完成一次页对页的数据拷贝(不带数据写入)。
注意:页对页拷贝,仅支持同一个plane 里面互相拷贝(源地址和目标地址,必须在同一个plane 里面),如果不是同一个plane 里面的页,则不可以执行页对页拷贝。
NAND FLASH 其他命令的介绍,请大家参考MT29F4G08 的数据手册。
(4)ECC 校验
ECC,英文全称为:Error checking and Correction,是一种对传输数据的错误检测和修正的算法。NAND FLASH 存储单元是串行组织的,当读取一个单元的时候,读出放大器所检测到信号强度会被这种串行存储结构削弱,这就降低了所读信号的准确性,导致读数出错(一般只有1 个bit 出错)。ECC 就可以检测这种错误,并修正错误的数据位,因此,ECC 在NAND FLASH驱动里面,被广泛使用。
ECC 有三种常用的算法:汉明码(Hamming Code)、RS 码(Reed Solomon Code)和BCH 码。
STM32 的FMC 模块就支持硬件ECC 计算,使用的就是汉明码,接下来,我们就给大家简单介绍一下汉明码的编码和使用。
1,汉明码编码
汉明码的编码计算比较简单,通过计算块上数据包得到2 个ECC 值(ECCo 和ECCe)。为计算ECC 值,数据包中的比特数据要先进行分割,如1/2 组、1/4 组、1/8 组等,直到其精度达到单个比特为止。我们以8 bit 即1 字节的数据包为例进行说明,如表45.1.1.4 所示:
在这里插入图片描述
表45.1.1.4 8bit 数据包校验的数据分割
8 位数据可以按:1/2、1/4 和1/8 进行分割(1/8 分割时,达到单个比特精度)。简单说一下上表的看法,以1/2 分割偶校验为例,1/2 分割时,每4 个bit 组成一个新bit,新的bit0 等于原来的bit0~3,新的bit1 等于原来的bit4~7,而我们只要偶数位的数据,也就是新bit0 的数据,实际上就是原来的bit0~3 的数据,这样就获取了1/2 偶校验的数据。其他分割以此类推。
表45.1.1.3 中,数据包上方的三行数据经计算后,得到偶校验值(ECCe),数据包下方的三行数据经计算后,得到奇校验值(ECCo)。1/2 校验值经“异或”操作构成ECC 校验的最高有效位,1/4 校验值构成ECC 校验的次高有效位,最低有效位由具体到比特的校验值填补。ECC校验值(ECCo 和ECCe)的计算过程,如图45.1.1.7 所示:

在这里插入图片描述
图45.1.1.7 计算奇偶ECC 值
即偶校验值ECCe 为“101”,奇校验值ECCo 为“010”。图1 所示为只有1 字节数据的数据包,更大的数据包需要更多的ECC 值。事实上,每n bit 的ECC 数值可满足2^n bit 数据包的校验要求。不过,汉明码算法要求一对ECC 数据(奇+偶),所以总共要求2n bit 的ECC 校验数据来处理2^n bit 的数据包。
得到ECC 值后,我们需要将原数据包和ECC 数值都写入NAND 里面。当原数据包从NAND
中读取时,ECC 值将重新计算,如果新计算的ECC 不同于先前编入NAND 器件的ECC,那么表明数据在读写过程中发生了错误。例如,原始数据01010001 中有1 个单一的比特出现错误,出错后的数据是01010101。此时,重新计算ECC 的过程如图45.1.1.8 所示:
在这里插入图片描述
图45.1.1.8 出错时计算的奇偶ECC 值
可以看到,此时的nECCo 和nECCe 都为000。此时,我们把所有4 个ECC 数值进行按位“异或”,就可以判断是否出现了1 个单一比特的错误或者是多比特的错误。如果计算结果为全“0”,说明数据在读写过程中未发生变化。如果计算的结果为全“1”,表明发生了1 bit 错误,其他情况,则说明有至少2bit 数据出现了错误。不过,汉明码编码算法只能够保证更正单一比特的错误,如果两个或是更多的比特出错,汉明码就无能为力了(可以检测出错误,但无法修正)。不过,一般情况SLC NAND 器件(STM32 仅支持SLC NAND)出现2bit 及以上的错误非常罕见,所以,我们使用的汉明码基本上够用。
对4 个ECC 进行异或的计算方法如下:
ECCe^ ECCo^ nECCe^ nECCo=101^ 010^ 000^000=111
这样,经过上式计算,4 个ECC 的“异或”结果为全1,表示有1 个bit 出错了。对于这种1 bit 错误的情况,出错的地址可通过将原有ECCo 值和新ECCo 值(nECCo)进行按位“异或”来得到,计算方法如下:
ECCo^ nECCo=010^000=010
计算结果为010(2),表明原数据第2 bit 位出现了问题。然后,对出错后的数据的bit2 进行取反(该位与1“异或”即可),就可以得到正确的数据:01010101^00000100=01010001。
一个8 位数据,需要6 位ECC 码,看起来效率不高,但是随着数据的增多,汉明码编码效率将会越来越高。比如,我们一般以512 字节为单位来计算ECC 值,只需要24bit 的ECC 码即可表示(2^12=4096bit=512 字节,12*2=24bit)。汉明码编码算法的原理,我们就给大家介绍到这里。
2,STM32 硬件ECC
STM32 的FMC 支持NAND FLASH 硬件ECC 计算,采用的就是汉明码计算方法。可以实现1bit 错误的修正和2bit 以上错误的检测,支持页大小按256、512、1024、2048、4096 和8192字节为单位进行ECC 计算。
当FMC 的硬件ECC 功能开启后,FMC 模块根据用户设置的参数(计算页大小、数据位宽等)对NAND FLASH 数据线上传递的(读/写)数据进行ECC 计算,数据传输结束后,ECC计算结果自动存放在FMC_ECCRx(x=2/3)寄存器中。不过STM32 的硬件ECC 只负责计算ECC值,并不对数据进行修复。错误检测和数据修复,需要用户自己实现。另外,STM32 的硬件ECC 支持存储区域2 和存储区域3,存储区域4 不支持!!
STM32 硬件ECC 计算结果读取过程(以512 字节页大小为例):
1,设置FMC_PCRx(x=2/3)的ECCEN 位为1,使能ECC 计算
2,写入/读取512 字节数据
3,等待FMC_SRx(x=2/3)的FEMPT 位为1(等待FIFO 空)
4,读取FMC_ECCRx(x=2/3),得到ECC 值
5,设置FMC_PCRx(x=2/3)的ECCEN 位为0,关闭ECC,以便下一次重新计算重复以上步骤,就可以在不同时刻进行读/写数据的ECC 计算。在实际使用的时候,我们在写入/读取数据时,都要开启STM32 的硬件ECC 计算。写入的时候,将STM32 硬件ECC 计算出来的ECC 值写入NAND FLASH 数据所存Page 的spare 区。在读取数据的时候,STM32硬件ECC 会重新计算一个ECC 值(ecccl),而从spare 区对应位置,又可以读取之前写入的ECC值(eccrd),当这两个ECC 值不相等的时候,说明读数有问题,需要进行ECC 校验,ECC 检查和修正代码如下:

// 获取ECC 的奇数位/偶数位
// oe:0,偶数位
//  1,奇数位
// eccval:输入的ecc 值
// 返回值:计算后的ecc 值(最多16 位)
u16 NAND_ECC_Get_OE(u8 oe, u32 eccval)
{
    u8 i;
    u16 ecctemp = 0;
    for (i = 0; i < 24; i++)
    {
        if ((i % 2) == oe)
            if ((eccval >> i) & 0X01)
                ecctemp += 1 << (i >> 1);
    }
    return ecctemp;
}
// ECC 校正函数
// eccrd:读取出来,原来保存的ECC 值
// ecccl:读取数据时,硬件计算的ECC 只
// 返回值:0,错误已修正
//  其他,ECC 错误(有大于2 个bit 的错误,无法恢复)
u8 NAND_ECC_Correction(u8 *data_buf, u32 eccrd, u32 ecccl)
{
    u16 eccrdo, eccrde, eccclo, ecccle;
    u16 eccchk = 0;
    u16 errorpos = 0;
    u32 bytepos = 0;
    eccrdo = NAND_ECC_Get_OE(1, eccrd); // 获取eccrd 的奇数位
    eccrde = NAND_ECC_Get_OE(0, eccrd); // 获取eccrd 的偶数位
    eccclo = NAND_ECC_Get_OE(1, ecccl); // 获取ecccl 的奇数位
    ecccle = NAND_ECC_Get_OE(0, ecccl); // 获取ecccl 的偶数位
    eccchk = eccrdo ^ eccrde ^ eccclo ^ ecccle;
    if (eccchk == 0XFFF) // 全1,说明只有1bit ECC 错误
    {
        errorpos = eccrdo ^ eccclo;               // 计算出错bit 位置
        bytepos = errorpos / 8;                   // 计算字节位置
        data_buf[bytepos] ^= 1 << (errorpos % 8); // 对出错位进行取反,修正错误
    }
    else
        return 1; // 不是全1,说明至少有2bit ECC 错误,无法修复
    return 0;
}

经过以上代码处理,我们就可以利用STM32 的硬件ECC,修正1bit 错误,并报告2bit 及以上错误。

FTL 简介

因为NAND FLASH 在使用过程中可能会产生坏块,且每个BLOCK 的擦除次数是有限制的,超过规定次数后,BLOCK 将无法再擦除(即产生坏块),因此,我们需要这样一段程序,它可以实现:1,坏块管理;2,磨损均衡;从而使应用程序可以很方便的访问NAND FLASH(无需关系坏块问题),且最大限度的延长NAND FLASH 的寿命。
这里给大家介绍FTL,FTL 是Flash Translation Layer 的简写,即闪存转换层,它是一个NAND 闪存芯片与基础文件系统之间的一个转换层,它自带了坏块管理和磨损均衡算法,使得操作系统和文件系统能够像访问硬盘一样访问NAND 闪存设备,而无需关心坏块和磨损均衡问题。
本章,我们将给大家介绍一个比较简单的FTL 层算法,它可以支持坏块管理和磨损均衡,提供支持文件系统(如:FATFS)的访问接口,通过这个FTL,我们可以很容易的实现NAND FLASH 的文件系统访问。
要做好NAND FLASH 的坏块管理,我们有以下几点需要实现:
1,如何识别坏块,标记坏块;
2,转换表
3,保留区
1,如何识别坏块,标记坏块
经过前面的介绍,我知道NAND 在使用过程中,会产生坏块,而坏块我们是不能再用来存储数据的,必须对坏块进行识别和标记,并保存这些标记。
NAND FLASH 的坏块识别有几种方式:1,NAND 厂家出厂的时候,会在每个Block 的第一个page 和第二个page 的spare 区的第一个字节写入非0XFF 的值来表示,我们可以通过这个判断该块是否为坏块;2,通过给每个Block 写入数值(0XFF/0X00),然后读取出来,判断写入的数据和读取的数据是否完全一样,来识别坏块;3,通过读取数据时,校验ECC 错误,来识别坏块。
NAND FLASH 的坏块标记:坏块标记,我们使用每个Block 的第一个page 和第二个page
(第二个page 是备份用的)spare 区的第一个字节来标记,当这个字节的值为0XFF 时,表示该块为好块,当这个字节的值不等于0XFF 时,表示该块为坏块。以MT29F4G08 为例,坏块表示方法,如表45.1.2.1 所示:

在这里插入图片描述
表45.1.2.1 NAND FLASH 坏块标记说明表
上图中,假设某个Block 为坏块,那么它的第一个page 和第二个page 的spare 区第一个字节,就不是0XFF 了(我们改为0XAA),以表示其是一个坏块。如果是好块,这两个字节,必须都是0XFF,只要任何一个不是0XFF,则表示该块是一个坏块。
这样,我们只需要判断每个Block 的第一和第二个page 的spare 区的第一个字节,就可以判断是否为坏块。达到了标记和保存坏块标记的目的。
2,转换表
文件系统访问文件的时候,使用的是逻辑地址,是按顺序编号的,它不考虑坏块情况。而NAND FLASH 存储地址,我们称为物理地址,是有可能存在坏块的。所以,这两个地址之间,必须有一个映射表,将逻辑地址转换为物理地址,且不能指向坏块的物理地址,这个映射表我们称之为逻辑地址-物理地址转换表,简称转换表,如图45.1.2.1 所示:
图45.1.2.1 逻辑-物理地址转换表
图45.1.2.1 表示某个时刻,逻辑地址与物理地址的对应关系。由图可知,逻辑地址(0~n)到物理地址的映射,映射关系不是一一对应的,而是无序的,这个映射关系是不固定的,随时可能会变化,逻辑地址到物理地址,通过映射表进行映射,所以映射表也是随时需要更新的。
图中,我们假定NAND 的第5 个Block 是坏块,那么映射表一定不能将这个块地址映射给逻辑地址,所以,必须对这个块进行坏块标记,不再作为正常块使用,坏块标记请参考前面的介绍。另外,当产生了一个坏块的时候,我们必须从保留区(下文介绍)提取一个未用过的块(Block),来替代这个坏块,以确保所有逻辑地址,都有正常的物理地址可用。
逻辑地址到物理地址的映射关系,我们采用一个数组来存储,这个数组即映射表(简称:lut 表),同时,这个映射表必须存储到NAND FLASH 里面,以便上电后重建。这里,我们也是利用每个Block 的第一个page 的spare 区来存储映射表,另外,还需要标记这个Block 是否被使用了,所以,Block 第一个page 的spare 区规划,如表45.1.2.2 所示:
在这里插入图片描述
表45.1.2.2 每个Block 第一个page 的spare 区前4 个字节规划表
如表45.1.2.2 所示,每个Block 第一个page 的spare 区第一个字节用来表示该块是否为坏块(前面已介绍);第二个字节用来表示该块是否被占用(0XFF,表示未占用;0XCC,表示已被占用);第三和第四个字节,用来存储该块所映射到的逻辑地址,如果为0XFFFF,则表示该块还未被分配逻辑地址,其他值,则表示该块对应的逻辑地址,MT29F4G08 有4096 个Block,所以这两个字节表示的有效逻辑地址范围,就是0~4095。因此,我们要想判断一个Block 是否是一个未被使用的好块,只需读取这个Block 第一个page 的spare 区前四个字节,如果为0XFFFFFFFF 则说明是一个未被使用的好块。
上电的时候,重建映射表(lut 表)的过程就是读取NAND FLASH 每个Block 第一个page的spare 区前4 个字节,当这个块是好块(第一个字节为0XFF),且第三和第四字节组成的u16类型数据(逻辑地址,记为:LBNnum),小于NAND FLASH 的总块数,则这个Block 地址(物理地址,记为:M)就是映射表里面第LBNnum 个元素所对应的地址,即:lut[LBNnum]=M。
3,保留区
保留区有两个作用:1,产生坏块的时候,用来替代坏块;2,在复写数据的时候,用来替代被复写的块,以提高写入速度,并实现磨损均衡处理;
第一个作用,如图45.1.2.1 所示,当产生坏块后(第5 个块),使用一个保留区里面的块(n+2),来替代这个坏块,从而保证每个逻辑地址,都有对应的物理地址(好块地址)。
第二个作用,当文件系统要往某个已经被写过数据的块里面写入新数据的时候,由于NAND 的特性,必须是要先擦除,才能写入新数据。一般的方法:先将整个块数据读出来,然后改写需要写入新数据的部分,然后擦除这个块,然后重新写入这个块,这个过程非常耗时,且需要很大的内存(MT29F4G08 一个Block 大小为128K 字节),所以不太实用。
比较好的办法,是利用NAND FLASH 的页拷贝功能,它可以将NAND FLASH 内部某个Block 的数据,以页为单位,拷贝到另外一个空闲的Block 里面,而且可以写入新的数据(参见45.1.1 节的指令介绍),利用这个功能,我们无需读出整个Block 的数据,只需要在页拷贝过程中,在正确的地址写入我们需要写入的新数据即可。这就要求NAND FLASH 必须有空闲的块,用作页拷贝的目标地址,保留区里面的块,就可以作为空闲块,给页拷贝使用。而且,为了保证不频繁擦除一个块(提高寿命),我们在保留区里面应预留足够的空闲块,用来均分擦除次数,从而实现简单的磨损均衡处理。
这样,FTL 层的坏块管理和磨损均衡原理,就给大家介绍完了。我们根据这个原理,去设计相应的代码,就可以实现FTL 层的功能,从而更好的使用NAND FLASH。
前面提到,我们需要用到ECC 校验,来确保数据的正确性,一般的软件ECC 校验都是由FTL 层实现,我们为了简化代码,并利用STM32 的硬件ECC 计算,加快ECC 计算的速度,我们在FTL 层并不做ECC 计算和校验,而是放到NAND FLASH 的底层驱动去实现。在45.1.1节最后,我们给大家介绍了ECC 原理和纠错方法,每512 个字节的数据,会生成3 个字节的ECC 值,而这个ECC 值是必须存储在NAND FLASH 里面,同样我们将ECC 值存储在每个page的spare 区,而且,为了方便读写,我们用4 个字节来存储这3 个字节的ECC 值,ECC 存储关系如表45.1.2.3 所示:

在这里插入图片描述
表45.1.2.3 每个page 的spare 区ECC 存储关系表
如上表所示,每个page 的一个数据区有2048 个字节,而每512 字节数据生成一个ECC 值,用4 个字节存储,这样,每个page 需要16 个字节用于存储ECC 值。表中的ECCx1~ ECCx4,(x=0~ 63),就是存储的ECC 值,从每个page 的spare 区第16 个字节(0X10)开始存储ECC值,总共占用16 字节,对应关系为:ECCx1数据区0~ 511 字节;ECCx2数据区512~ 1023字节;ECCx3数据区1024~1535 字节;ECCx4数据区1536~2047 字节;
在page 写入数据的时候,我们将STM32 硬件计算出的ECC 值写入spare 区对应的地址,当读取page 数据的时候,STM32 硬件ECC 会计算出一个新的ECC 值,同时,可以在该page的spare 区读取之前保存的ECC 值,比较这两个ECC 值,就可以判断数据是否有误,以及进行数据修复(1bit)。注意:我们对ECC 处理是以512 字节为单位的,如果写入/读取的数据不是512 的整数倍,则不会进行ECC 处理。

FMC NAND FLASH 接口简介

在第十七和十八章,我们对STM32F429 的FMC 接口进行了简介,并利用FMC 接口,实现了对MCU 屏和SDRAM 的驱动。本章,我们将介绍如何利用FMC 接口,驱动NAND FLASH。
STM32F429 FMC 接口的NAND FLASH/PC 卡控制器,具有如下特点:
两个NAND FLASH 存储区域,可独立配置
支持8 位和16 位NAND FLASH
支持16 位PC 卡兼容设备
支持硬件ECC 计算(汉明码)
支持NAND FLASH 预等待功能
通过45.1.1 的介绍,我们对NAND FLASH 已经有了一个比较深入的了解,包括接线、控制命令和读写流程等,接下来,我们介绍一些配置FMC NAND FLASH 控制器需要用到的几个寄存器。
首先,我们介绍NAND FLASH 的控制寄存器:FMC_PCRx,x=2/3,该寄存器各位描述如图45.1.3.1 所示:
在这里插入图片描述

图45.1.3.1 FMC_PCRx 寄存器各位描述
该寄存器只有部分位有效,且都需要进行配置:
PWAITEN:该位用于设置等待特性:0,禁止;1,使能。这里我们设置为0,禁止使用控制器自带的等待特性,因为如果使能的话,将导致RGB 屏抖动(STM32 硬件bug)。
PBKEN:该位用于使能存储区域:0,禁止;1,使能。我们要正常使用某个存储区域,必须设置该位为1,所以,这个位要设置为1。
PTYP:该位用于设置存储器类型:0,PC 卡/CF 卡/CF 卡或PCMCIA;1,NAND FLASH。我们用来驱动NAND FLASH,所以该位设置为1。
PWID:这两个位,用于设置数据总线宽度:00,8 位宽度;01,16 位宽度。我们使用的MT29F4G08 为8 位宽度,所以这里应该设置为:00。
ECCEN:该位用于使能STM32 的硬件ECC 计算逻辑:0,禁止/复位ECC;1,使能ECC计算;每次读写数据前,应该设置该位为1,在数据读写完毕,读取完ECC 值之后,设置该位为0,复位ECC,以便下一次ECC 计算。
TCLR:这四个位用于设置CLE 到RE 的延迟:00001111,表示116 个HCLK 周期。对应NAND FLASH 数据手册的tCLR 时间参数,这里设置的t_clr=(TCLR+SET+2)*THCLK。TCLR就是本寄存器的设置,SET 对应MEMSET 的值(我只用到MEMSET),THCLK 对应HCLK 的周期。MT29F4G08 的tCLR 时间最少为10ns,以180M 主频计算,一个HCLK=5.5ns(下同),我们设置TCLR=1,则t_clr 至少为4 个HCLK 即22ns。
TAR:这四个位用于设置ALE 到RE 的延迟:00001111,表示116 个HCLK 周期。对应
NAND FLASH 数据手册的tAR 时间参数,这里设置的t_ar=(TAR+SET+2)*THCLK。TAR 就是本寄存器的设置,SET 对应MEMSET 的值(我只用到MEMSET),THCLK 对应HCLK 的周期。
MT29F4G08 的tAR 时间最少为10ns,我们设置TAR=1,则t_ar 至少为4 个HCLK 即22ns。
ECCCPS:这三个位用于设置ECC 的页大小:000,256 字节;001,512 字节;010,1024
字节;011,2048 字节;100,4096 字节;101,8192 字节。我们需要以512 字节为单位进行
ECC 计算,所以ECCCPS 设置为:001 即可。
接下来,我们介绍NAND FLASH 的空间时序寄存器:FMC_PMEMx,x=2/3,该寄存器各位描述如图45.1.3.2 所示:
在这里插入图片描述

图45.1.3.2 FMC_PMEMx 寄存器各位描述
该寄存器用于控制NAND FLASH 的访问时序,非常重要。我们先来了解下NAND FLASH
控制器的通用存储器访问波形,如图45.1.3.3 所示:
在这里插入图片描述

图45.1.3.3 NAND FLASH 通用存储器访问波形
由图可知,MEMxSET+MEMxHOLD 控制NWE/NOE 的高电平时间,MEMxWAIT 控制
NWE/NOE 的低电平时间,MEMxHIZ 控制写入时数据线高阻态时间。接下来我们分别介绍这几个参数:
MEMSETx:这八个位定义使能命令(NWE/NOE)前,地址建立所需要的HCLK 时钟周期数,表示NWE/NOE 的高电平时间,0000 0000~1111 1111 表示1~256 个HCLK 周期。MT29F4G08的tREH/tWH 最少为10ns,我们设置MEMSETx=1,即2 个HCLK 周期,约11ns。另外,MEMHOLDx,也可以用于控制NWE/NOE 的高电平时间,在连续访问的时候,MEMHOLDx和MEMSETx 共同构成NWE/NOE 的高电平脉宽时间。
MEMWAITx:这八个位用于设置使能命令(NWE/NOE)所需的最小HCLK 时钟周期数(使能NWAIT 将使这个时间延长),实际上就是NWE/NOE 的低电平时间,0000 0000~ 1111 1111表示1~256 个HCLK 周期。MT29F4G08 的tRP/tWP 最少为10ns,我们设置MEMWAITx =3,即4 个HCLK 周期,约22ns。这里需要设置时间比较长一点,否则可能访问不正常。
MEMHOLDx:这八个位用于设置禁止使能命令(NWE/NOE)后,保持地址(和写访问数据)的HCLK 时钟周期数,也可以用于设置一个读写周期内的NWE/NOE 高电平时间,0000 0000~ 1111 1111 表示0~255 个HCLK 周期。我们设置MEMHOLDx=1,表示1 个HCLK 周期,加上前面的MEMSETx,所以NEW/NOE 高电平时间为3 个HCLK,即16.5ns 左右。
MEMHIZx:这八个位定义通用存储空间开始执行写访问之后,数据总线保持高阻态所持续的HCLK 时钟周期数。该参数仅对写入事务有效,0000~1111 1111 表示1~256 个HCLK 周期。我们设置MEMHIZx=1,表示2 个HCLK 周期,即11ns 左右。
接下来,我们介绍NAND FLASH 的ECC 结果寄存器:FMC_ECCRx(x=2/3),该寄存器各位描述如图45.1.3.4 所示:
在这里插入图片描述

图45.1.3.4 FMC_ ECCRx 寄存器各位描述
该寄存器包含由ECC 计算逻辑计算所得的结果。根据ECCPS 位(在FMC_PCRx 寄存器)的设置,ECCx 的有效位数也有差异,如表45.1.3.1 所示:
在这里插入图片描述

表45.1.3.1 ECC 结果相关位
我们以512 字节为页大小(ECCPS=001),所以ECCx 的低24 位有效,用于存储计算所得的ECC 值。
接下来,我们介绍NAND FLASH 的状态和中断寄存器:FMC_SRx(x=2/3),该寄存器各位描述如图45.1.3.5 所示:
在这里插入图片描述

图45.1.3.5 FMC_SRx 寄存器各位描述
该寄存器我们只关心第6 位:EFMPT 位,该位用于表示FIFO 的状态。当EFMPT=0 时,表示FIFO 非空,表示还有数据在传输;当EFMPT=1 时,表示FIFO 为空,表示数据传输完成。在计算ECC 的时候,我们必须等待EFMPT=1,再去读取ECCRx 寄存器的值,确保数据全部传输完毕。
至此,FMC NAND FLASH 部分的寄存器就介绍完了,关于FMC NAND FLASH 控制器的详细介绍,请大家参考《STM32F4xx 中文参考手册-扩展章节》第37.6 节。通过以上三个小节的了解,我们就可以开始编写NAND FLASH 的驱动代码了。
阿波罗STM32F429 核心板板载的MT29F4G08 芯片挂在FMC NAND FLASH 的控制器3上面(NCE3),其原理图如图45.1.3.6 所示:
在这里插入图片描述

图45.1.3.6 MT29F4G08 原理图
从原理图可以看出,MT29F4G08 同STM32F429 的连接关系:
I/O[0:7]接FMC_D[0:7]
CLE 接FMC_A16_CLE
ALE 接FMC_A17_ALE
WE 接FMC_NWE
RE 接FMC_NOE
CE 接FMC_NCE3
R/B 接FMC_NWAIT
最后,我们来看看实现对MT29F4G08 的驱动,需要对FMC 进行哪些配置。这里我们需要引入stm32f4xx_II_fmc.c/stm32f4xx_hal_nand.c 源文件以及对应的头文件。具体步骤如下:
1)使能FMC 时钟,并配置FMC 相关的IO 及其时钟使能。
要使用FMC,当然首先得开启其时钟。然后需要把FMC_D0~7、FMC_A16_CLE 和
FMC_A17_ALE 等相关IO 口,全部配置为复用输出,并使能各IO 组的时钟。使能FMC 时钟和IO 口初始化方法前面多次讲解,这里就不累赘了。
2)初始化NAND,设置控制参数(设置FMC_PCR3)和时间参数(设置FMC_PMEM3)。
该步骤通过设置寄存器FMC_PCR3(因为使用的是FMC_NAND_BANK3 所以对应寄存器
FMC_PCR3)来设置NAND FLASH 的相关控制参数,比如数据宽度、CLR/AR 延迟、ECC 页大小等,通过设置寄存器FMC_PMEM3(因为使用的是FMC_NAND_BANK3,所以对应寄存器FMC_PMEM3)来设置NAND 的相关时间参数,控制FMC 访问NAND FLASH 的时序。HAL
库提供了NAND FLASH 初始化函数HAL_NAND_Init:

HAL_StatusTypeDef HAL_NAND_Init(NAND_HandleTypeDef *hnand,
	FMC_NAND_PCC_TimingTypeDef *ComSpace_Timing,
	FMC_NAND_PCC_TimingTypeDef *AttSpace_Timing);

该函数有三个入口参数,第一个入口参数hnand 用来设置NAND FLASH 的控制参数,第二个入口参数ComSpace_Timing 用来设置NAND 通用存储器空间时序,第三个入口参数
AttSpace_Timing 用来设置NAND 特性存储器空间时序。这里我们着重讲解第一个入口参数hnand 的定义,该参数为NAND_HandleTypeDef 结构体类型,该结构体定义为:

typedef struct
{
	FMC_NAND_TypeDef *Instance;
	FMC_NAND_InitTypeDef Init;
	HAL_LockTypeDef Lock;
	__IO HAL_NAND_StateTypeDef State;
	NAND_InfoTypeDef Info;
}NAND_HandleTypeDef;

这里我们主要关注成员变量Init 的含义,该成员变量是FMC_NAND_InitTypeDef 结构体类型,该结构体定义为:

typedef struct
{
	uint32_t NandBank; //BANK 编号
	uint32_t Waitfeature; //等待特性使能/失能
	uint32_t MemoryDataWidth; //数据总线宽度:8 位/16 位
	uint32_t EccComputation; //ECC 计算逻辑使能/失能
	uint32_t ECCPageSize; //ECC 页大小:256/512/1024/2048/4096/8192 字节
	uint32_t TCLRSetupTime; //CLE 到RE 的延迟
	uint32_t TARSetupTime; //ALE 到RE 的延迟
}FMC_NAND_InitTypeDef;

这些成员变量设置值对应的是FMC_ PCR 寄存器相应位,这些成员变量的含义我们都已经注释了,如有不理解的地方请参考前面FMC_ PCR 寄存器讲解。
对于FMC 的时序参数,这里我们就不做过都讲解,请参考前面讲解。
HAL 库同样为NAND 初始化提供了MSP 回调函数HAL_NAND_MspInit:

void HAL_NAND_MspInit(NAND_HandleTypeDef *hnand);

该函数内部一般用来使能时钟,初始化IO 口。
3)配置FMC_PCR3 寄存器,使能存储区域3。
在FMC 的配置完成后,最后,设置FMC_PCR3 寄存器的PBKEN 位(bit2)为1,使能存储区域3。如果使用HAL 库,那么在函数HAL_NAND_Init 的尾部有使能存储区域的操作,我们就不需要重复进行此步骤。操作方法为:
__FMC_NAND_ENABLE(hnand->Instance, hnand->Init.NandBank);
通过以上几个步骤,我们就完成了FMC 的配置,可以访问MT29F4G08 了,最后,因为我们使用的是SDRAM 的BANK3,所以MT29F4G08 的访问地址为0X80000000,而NAND FLASH
的命令/地址控制由CLE/ALE 控制,也就是由FMC_A17_CLE 和FMC_A16_ALE 控制,因此,发送命令和地址的语句为:

*(vu8*)(0X80000000|(1<<17))=CMD;
*(vu8*)(0X80000000|(1<<16))=ADDR;

硬件设计

本章实验功能简介:开机后,先检测NAND FLASH
并初始化FTL,如果初始化成功,则显示提示信息,然后按下KEY0
按键,可以通过FTL
读取扇区2
的数据;按KEY1
按键,可以通过FTL
写入扇区2
的数据;按KEY2
按键,则可以恢复扇区2
的数据(防止损坏文件系统);
DS0
指示程序运行状态。
本实验用到的硬件资源有:
1)指示灯DS0
2)KEY0、KEY1 和KEY2 按键
3)串口
4)LCD 模块
5)MT29F4G08
这些我们都已经介绍过(MT29F4G08 与STM32F429 的各IO 对应关系,请参考光盘原理图),接下来我们开始软件设计。

软件设计

打开本章实验工程可以看到,我们在HARDWARE 分组之下添加了nand.c,ftl.c 以及
nandtester.c 三个源文件,同时包含了对应的头文件。
由于代码量比较多,我们这里就不将所有代码贴出来了,仅挑一些重点的函数,给大家介绍,详细的代码请大家打开本例程源码查看。
这里我们需要说明一下,由于ST 官方HAL 库提供的NAND 相关驱动函数我们在使用过程发现了很多兼容性问题,并且没有提供坏块管理操作,使用起来并不是非常方便。所以我们
ALIENTEK 团队重写了一套NAND 操作函数供大家参考。
在nand.c 里面,我们只介绍:NAND_Init、HAL_NAND_MspInit、NAND_ReadPage 和
NAND_WritePage 等三四个函数。NAND_Init 函数代码如下:

// 初始化NAND FLASH
u8 NAND_Init(void)
{
    FMC_NAND_PCC_TimingTypeDef ComSpaceTiming, AttSpaceTiming;
    NAND_Handler.Instance = FMC_NAND_DEVICE;
    NAND_Handler.Init.NandBank = FMC_NAND_BANK3; // NAND 挂在BANK3 上
    NAND_Handler.Init.Waitfeature = FMC_NAND_PCC_WAIT_FEATURE_DISABLE;
    // 关闭等待特性
    NAND_Handler.Init.MemoryDataWidth = FMC_NAND_PCC_MEM_BUS_WIDTH_8;
    // 8 位数据宽度
    NAND_Handler.Init.EccComputation = FMC_NAND_ECC_DISABLE; // 不使用ECC
    NAND_Handler.Init.ECCPageSize = FMC_NAND_ECC_PAGE_SIZE_2048BYTE;
    // ECC 页大小为2k
    NAND_Handler.Init.TCLRSetupTime = 0; // 设置TCLR(tCLR=CLE 到RE 的延
    // 时)=(TCLR+TSET+2)*THCLK,THCLK=1/180M=5.5ns
    NAND_Handler.Init.TARSetupTime = 1; // 设置TAR(tAR=ALE 到RE 的延
    // 时)=(TAR+TSET+2)*THCLK,THCLK=1/180M=5.5n。
    ComSpaceTiming.SetupTime = 2;     // 建立时间
    ComSpaceTiming.WaitSetupTime = 3; // 等待时间
    ComSpaceTiming.HoldSetupTime = 2; // 保持时间
    ComSpaceTiming.HiZSetupTime = 1;  // 高阻态时间
    AttSpaceTiming.SetupTime = 2;     // 建立时间
    AttSpaceTiming.WaitSetupTime = 3; // 等待时间
    AttSpaceTiming.HoldSetupTime = 2; // 保持时间
    AttSpaceTiming.HiZSetupTime = 1;  // 高阻态时间
    HAL_NAND_Init(&NAND_Handler, &ComSpaceTiming, &AttSpaceTiming);
    NAND_Reset(); // 复位NAND
    delay_ms(100);
    nand_dev.id = NAND_ReadID();        // 读取ID
    NAND_ModeSet(4);                    // 设置为MODE4,高速模式
    if (nand_dev.id == MT29F16G08ABABA) // NAND 为MT29F16G08ABABA
    {
        nand_dev.page_totalsize = 4320; // nand 一个page 的总大小(包括spare 区)
        nand_dev.page_mainsize = 4096;  // nand 一个page 的有效数据区大小
        nand_dev.page_sparesize = 224;  // nand 一个page 的spare 区大小
        nand_dev.block_pagenum = 128;   // nand 一个block 所包含的page 数目
        nand_dev.plane_blocknum = 2048; // nand 一个plane 所包含的block 数目
        nand_dev.block_totalnum = 4096; // nand 的总block 数目
    }
    else if (nand_dev.id == MT29F4G08ABADA) // NAND 为MT29F4G08ABADA
    {
        nand_dev.page_totalsize = 2112; // nand 一个page 的总大小(包括spare 区)
        nand_dev.page_mainsize = 2048;  // nand 一个page 的有效数据区大小
        nand_dev.page_sparesize = 64;   // nand 一个page 的spare 区大小
        nand_dev.block_pagenum = 64;    // nand 一个block 所包含的page 数目
        nand_dev.plane_blocknum = 2048; // nand 一个plane 所包含的block 数目
        nand_dev.block_totalnum = 4096; // nand 的总block 数目
    }
    else
        return 1; // 错误,返回
    return 0;
}

该函数用于初始化NAND FLASH,主要是调用函数HAL_NAND_Init 函数初始化NAND,配置相关控制参数和FMC 时序,另外,该函数会读取NAND ID,从而判断NAND FLASH 的型号,执行不同的参数初始化。nand_dev 是我们在nand.h 里面定义的一个NAND 属性结构体,存储NAND FLASH 的一些特性参数,方便驱动。
函数HAL_NAND_MspInit 内容这里我们就不列出来了,该函数是NAND 的MSP 初始化回调函数,用来初始化与MCU 相关的步骤,包括时钟使能和IO 初始化。
接下来,我们看NAND_ReadPage 函数的代码,如下:

// 读取NAND Flash 的指定页指定列的数据(main 区和spare 区都可以使用此函数)
// PageNum:要读取的页地址,范围:0~(block_pagenum*block_totalnum-1)
// ColNum:要读取的列开始地址(也就是页内地址),范围:0~(page_totalsize-1)
//*pBuffer:指向数据存储区
// NumByteToRead:读取字节数(不能跨页读)
// 返回值:0,成功
//  其他,错误代码
u8 NAND_ReadPage(u32 PageNum, u16 ColNum, u8 *pBuffer, u16 NumByteToRead)
{
    vu16 i = 0;
    u8 res = 0;
    u8 eccnum = 0;   // 需要计算的ECC 个数,每NAND_ECC_SECTOR_SIZE 一个ecc
    u8 eccstart = 0; // 第一个ECC 值所属的地址范围
    u8 errsta = 0;
    u8 *p;
    *(vu8 *)(NAND_ADDRESS | NAND_CMD) = NAND_AREA_A; // 发送命令
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)ColNum; // 发送地址
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)(ColNum >> 8);
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)PageNum;
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)(PageNum >> 8);
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)(PageNum >> 16);
    *(vu8 *)(NAND_ADDRESS | NAND_CMD) = NAND_AREA_TRUE1;
    // 下面两行代码是等待R/B 引脚变为低电平,其实主要起延时作用的,等待NAND
    // 操作R/B 引脚。因为我们是通过将STM32 的NWAIT 引脚(NAND 的R/B 引脚)配置
    // 为普通IO,代码中通过读取NWAIT 引脚的电平来判断NAND 是否准备就绪.
    res = NAND_WaitRB(0); // 先等待RB=0
    if (res)
        return NSTA_TIMEOUT; // 超时退出
    // 下面2 行代码是真正判断NAND 是否准备好的
    res = NAND_WaitRB(1); // 等待RB=1
    if (res)
        return NSTA_TIMEOUT; // 超时退出
    if (NumByteToRead % NAND_ECC_SECTOR_SIZE)
    // 不是NAND_ECC_SECTOR_SIZE 的整数倍,不进行ECC 校验
    {
        // 读取NAND FLASH 中的数据
        for (i = 0; i < NumByteToRead; i++)
            *(vu8 *)pBuffer++ = *(vu8 *)NAND_ADDRESS;
    }
    else
    {
        eccnum = NumByteToRead / NAND_ECC_SECTOR_SIZE; // 得到ecc 计算次数
        eccstart = ColNum / NAND_ECC_SECTOR_SIZE;      // 从第几个ECC 开始
        p = pBuffer;
        for (res = 0; res < eccnum; res++)
        {
            FMC_Bank2_3->PCR3 |= 1 << 6;               // 使能ECC 校验
            for (i = 0; i < NAND_ECC_SECTOR_SIZE; i++) // 读取数据
            {
                *(vu8 *)pBuffer++ = *(vu8 *)NAND_ADDRESS;
            }
            while (!(FMC_Bank2_3->SR3 & (1 << 6)))
                ;                                                    // 等待FIFO 空
            nand_dev.ecc_hdbuf[res + eccstart] = FMC_Bank2_3->ECCR3; // 读取ECC 值
            FMC_Bank2_3->PCR3 &= ~(1 << 6);                          // 复位ECC
        }
        i = nand_dev.page_mainsize + 0X10 + eccstart * 4; // 读取spare 区,之前存储的ecc 值
        NAND_Delay(30);                                   // 等待tADL
        *(vu8 *)(NAND_ADDRESS | NAND_CMD) = 0X05;         // 随机读指令
        *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)i;       // 发送地址
        *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)(i >> 8);
        *(vu8 *)(NAND_ADDRESS | NAND_CMD) = 0XE0; // 开始读数据
        NAND_Delay(30);                           // 等待tADL
        pBuffer = (u8 *)&nand_dev.ecc_rdbuf[eccstart];
        for (i = 0; i < 4 * eccnum; i++) // 读取保存的ECC 值
        {
            *(vu8 *)pBuffer++ = *(vu8 *)NAND_ADDRESS;
        }
        for (i = 0; i < eccnum; i++) // 检验ECC
        {
            if (nand_dev.ecc_rdbuf[i + eccstart] != nand_dev.ecc_hdbuf[i + eccstart]) // 不相等
            {
                // 进行ECC 校验,并纠正1bit ECC 错误
                res = NAND_ECC_Correction(p + NAND_ECC_SECTOR_SIZE * i,
                                          nand_dev.ecc_rdbuf[i + eccstart], nand_dev.ecc_hdbuf[i + eccstart]);
                if (res)
                    errsta = NSTA_ECC2BITERR; // 标记2BIT 及以上ECC 错误
                else
                    errsta = NSTA_ECC1BITERR; // 标记1BIT ECC 错误
            }
        }
    }
    if (NAND_WaitForReady() != NSTA_READY)
        errsta = NSTA_ERROR; // 失败
    return errsta;           // 成功
}

该函数用于读取NAND 里面的数据,通过指定页地址(PageNum)和列地址(ColNum),就可以读取NAND FLASH 里面任何地址的数据,不过该函数读数据时不能跨页读,所以一次最多读取一个Page 的数据(包括spare 区数据)。当读取数据长度为NAND_ECC_SECTOR_SIZE
(512 字节)的整数倍时,将执行ECC 校验,ECC 校验完全是按照45.1 节介绍的方法来实现,当出现ECC 错误时,调用NAND_ECC_Correction 函数(45.1.1 节已经介绍)进行ECC 纠错,可以实现1bit 错误纠正,并报告2bit 及以上的错误。
接下来,我们看NAND_WritePage 函数的代码,如下:

// 在NAND 一页中写入指定个字节的数据(main 区和spare 区都可以使用此函数)
// PageNum:要写入的页地址,范围:0~(block_pagenum*block_totalnum-1)
// ColNum:要写入的列开始地址(也就是页内地址),范围:0~(page_totalsize-1)
// pBbuffer:指向数据存储区
// NumByteToWrite:要写入的字节数,该值不能超过该页剩余字节数!!!
// 返回值:0,成功
//  其他,错误代码
u8 NAND_WritePage(u32 PageNum, u16 ColNum, u8 *pBuffer, u16 NumByteToWrite)
{
    vu16 i = 0;
    u8 res = 0;
    u8 eccnum = 0;                                   // 需要计算的ECC 个数,每NAND_ECC_SECTOR_SIZE 一个ecc
    u8 eccstart = 0;                                 // 第一个ECC 值所属的地址范围
    *(vu8 *)(NAND_ADDRESS | NAND_CMD) = NAND_WRITE0; // 发送命令
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)ColNum; // 发送地址
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)(ColNum >> 8);
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)PageNum;
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)(PageNum >> 8);
    *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)(PageNum >> 16);
    NAND_Delay(30); // 等待tADL
    // 不是NAND_ECC_SECTOR_SIZE 的整数倍,不进行ECC 校验
    if (NumByteToWrite % NAND_ECC_SECTOR_SIZE)
    {
        for (i = 0; i < NumByteToWrite; i++)
            *(vu8 *)NAND_ADDRESS = *(vu8 *)pBuffer++; // 写
    }
    else
    {
        eccnum = NumByteToWrite / NAND_ECC_SECTOR_SIZE; // 得到ecc 计算次数
        eccstart = ColNum / NAND_ECC_SECTOR_SIZE;
        for (res = 0; res < eccnum; res++)
        {
            FMC_Bank2_3->PCR3 |= 1 << 6;               // 使能ECC 校验
            for (i = 0; i < NAND_ECC_SECTOR_SIZE; i++) // 写入数据
            {
                *(vu8 *)NAND_ADDRESS = *(vu8 *)pBuffer++;
            }
            while (!(FMC_Bank2_3->SR3 & (1 << 6)))
                ;                                                    // 等待FIFO 空
            nand_dev.ecc_hdbuf[res + eccstart] = FMC_Bank2_3->ECCR3; // 读取ECC 值
            FMC_Bank2_3->PCR3 &= ~(1 << 6);                          // 禁止ECC 校验
        }
        i = nand_dev.page_mainsize + 0X10 + eccstart * 4; // 计算写入ECC 的spare 区地址
        NAND_Delay(30);                                   // 等待
        *(vu8 *)(NAND_ADDRESS | NAND_CMD) = 0X85;         // 随机写指令
        *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)i;       // 发送地址
        *(vu8 *)(NAND_ADDRESS | NAND_ADDR) = (u8)(i >> 8);
        NAND_Delay(30); // 等待tADL
        pBuffer = (u8 *)&nand_dev.ecc_hdbuf[eccstart];
        for (i = 0; i < eccnum; i++) // 写入ECC
        {
            for (res = 0; res < 4; res++)
                *(vu8 *)NAND_ADDRESS = *(vu8 *)pBuffer++;
        }
    }
    *(vu8 *)(NAND_ADDRESS | NAND_CMD) = NAND_WRITE_TURE1;
    if (NAND_WaitForReady() != NSTA_READY)
        return NSTA_ERROR; // 失败
    return 0;              // 成功
}

该函数用于往NAND 里面写数据,通过指定页地址(PageNum)和列地址(ColNum),就可以往NAND FLASH 里面任何地址写数据(包括spare 区),同样,该函数也不支持跨页写。当读取数据长度为NAND_ECC_SECTOR_SIZE(512 字节)的整数倍时,将执行ECC 校验,并将ECC 值写入spare 区对应的地址,以便读取数据时,进行ECC 校验。
nand.c 里面的其他代码以及nand.h 里面的代码,请大家参考本例程源码。接下来,我看ftl.c
里面的代码,该文件,我们只介绍:FTL_Init、FTL_Format、FTL_CreateLUT、FTL_LBNToPBN、
FTL_WriteSectors 和FTL_ReadSectors 等七个函数。
首先,FTL_Init 函数代码如下:

// FTL 层初始化
// 返回值:0,正常其他,失败
u8 FTL_Init(void)
{
    u8 temp;
    if (NAND_Init())
        return 1; // 初始化NAND FLASH
    if (nand_dev.lut)
        myfree(SRAMIN, nand_dev.lut);
    nand_dev.lut = mymalloc(SRAMIN, (nand_dev.block_totalnum) * 2); // 给LUT 表申请内存
    memset(nand_dev.lut, 0, nand_dev.block_totalnum * 2);           // 全部清理
    if (!nand_dev.lut)
        return 1; // 内存申请失败
    temp = FTL_CreateLUT(1);
    if (temp)
    {
        printf("format nand flash...\r\n");
        temp = FTL_Format(); // 格式化NAND
        if (temp)
        {
            printf("format failed!\r\n");
            return 2;
        }
    }
    else // 创建LUT 表成功
    {
        printf("total block num:%d\r\n", nand_dev.block_totalnum);
        printf("good block num:%d\r\n", nand_dev.good_blocknum);
        printf("valid block num:%d\r\n", nand_dev.valid_blocknum);
    }
    return 0;
}

该函数用于初始化FTL,包括:初始化NAND FLASH、为lut 表申请内存、创建lut 表等操作,如果创建lut 表失败,则会通过FTL_Format 函数格式化NAND FLASH。
FTL_Format 函数代码如下:

// 格式化NAND 重建LUT 表
// 返回值:0,成功
//  其他,失败
u8 FTL_Format(void)
{
    u8 temp;
    u32 i, n;
    u32 goodblock = 0;
    nand_dev.good_blocknum = 0;                    // 初始化好块数量为0
#if FTL_USE_BAD_BLOCK_SEARCH == 1                  // 使用擦-写-读的方式,检测坏块
    nand_dev.good_blocknum = FTL_SearchBadBlock(); // 搜寻坏块.耗时很久
#else                                              // 直接使用NAND FLASH 的出厂坏块标志(其他块,默认是好块)
    for (i = 0; i < nand_dev.block_totalnum; i++)
    {
        temp = FTL_CheckBadBlock(i); // 检查一个块是否为坏块
        if (temp == 0)               // 好块
        {
            temp = NAND_EraseBlock(i);
            if (temp) // 擦除失败,认为坏块
            {
                printf("Bad block:%d\r\n", i);
                FTL_BadBlockMark(i); // 标记是坏块
            }
            else
                nand_dev.good_blocknum++; // 好块数量加一
        }
    }
#endif
    if (nand_dev.good_blocknum < 100)
        return 1;                                    // 好块数少于100,则NAND Flash 报废
    goodblock = (nand_dev.good_blocknum * 93) / 100; // 93%的好块用于存储数据
    n = 0;
    for (i = 0; i < nand_dev.block_totalnum; i++) // 在好块中标记上逻辑块信息
    {
        temp = FTL_CheckBadBlock(i); // 检查一个块是否为坏块
        if (temp == 0)               // 好块
        {
            NAND_WriteSpare(i * nand_dev.block_pagenum, 2, (u8 *)&n, 2); // 写入逻辑块编号
            n++;                                                         // 逻辑块编号加1
            if (n == goodblock)
                break; // 全部标记完了
        }
    }
    if (FTL_CreateLUT(1))
        return 2; // 重建LUT 表失败
    return 0;
}

该函数用于格式化NAND FLASH,执行的操作包括:1,检测/搜索整个NAND 的坏块,并做标记;2,分割所有好块,93%用作物理地址(并进行逻辑编号),7%用作保留区;3,重新创建lut 表。此函数,将我们在45.1.2 节介绍的FTL 层坏块管理的几个要点(识别坏块并标记、生成转换表、生成保留区),都实现了,从而完成对NAND FLASH 的格式化(不是文件系统那种格式化,这里的格式化是指针对FTL 层的初始化设置)。
接下来,我们看FTL_CreateLUT 函数,该函数代码如下:

// 重新创建LUT 表
// mode:0,仅检查第一个坏块标记
//  1,两个坏块标记都要检查(备份区也要检查)
// 返回值:0,成功
//  其他,失败
u8 FTL_CreateLUT(u8 mode)
{
    u32 i;
    u8 buf[4];
    u32 LBNnum = 0; // 逻辑块号
    for (i = 0; i < nand_dev.block_totalnum; i++)
        nand_dev.lut[i] = 0XFFFF; // 初始化为无效值
    nand_dev.good_blocknum = 0;
    for (i = 0; i < nand_dev.block_totalnum; i++)
    {
        NAND_ReadSpare(i * nand_dev.block_pagenum, 0, buf, 4);         // 读取4 个字节
        if (buf[0] == 0XFF && mode)                                    // 好块,且需要检查2 次坏块标记
            NAND_ReadSpare(i * nand_dev.block_pagenum + 1, 0, buf, 1); // 第二次检查
        if (buf[0] == 0XFF)                                            // 是好块
        {
            LBNnum = ((u16)buf[3] << 8) + buf[2]; // 得到逻辑块编号
            if (LBNnum < nand_dev.block_totalnum) // 逻辑块号肯定小于总的块数量
            {
                nand_dev.lut[LBNnum] = i; // 更新LUT 表,写LBNnum 对应的物理块编号
            }
            nand_dev.good_blocknum++;
        }
        else
            printf("bad block index:%d\r\n", i);
    }
    for (i = 0; i < nand_dev.block_totalnum; i++) // LUT 表建立完成以后检查有效块个数
    {
        if (nand_dev.lut[i] >= nand_dev.block_totalnum)
        {
            nand_dev.valid_blocknum = i;
            break;
        }
    }
    if (nand_dev.valid_blocknum < 100)
        return 2; // 有效块数小于100,有问题.需要重新格式化
    return 0;     // LUT 表创建完成
}

该函数用于重建lut 表,读取保存在每个Block 第一个page 的spare 区的逻辑编号,存储在
nand_dev.lut 表里,并初始化有效块(nand_dev.valid_blocknum)和好块(nand_dev.good_blocknum)的数量,完成转换表(lut 表)的创建。
接下来,我们看FTL_LBNToPBN 函数,该函数代码如下:

//逻辑块号转换为物理块号
//LBNNum:逻辑块编号
//返回值:物理块编号
u16 FTL_LBNToPBN(u32 LBNNum)
{
	u16 PBNNo=0;
	//当逻辑块号大于有效块数的时候返回0XFFFF
	if(LBNNum>nand_dev.valid_blocknum)return 0XFFFF;
	PBNNo=nand_dev.lut[LBNNum];
	return PBNNo;
}

该函数用于将逻辑块地址改为物理块地址,输入参数:LBNNum,表示逻辑块编号,返回值表示LBNNum 对应的物理块地址。有了该函数,就可以很方便的实现逻辑块地址到物理块地址的映射。
接下来,我们看FTL_WriteSectors 函数,该函数代码如下:

// 写扇区(支持多扇区写),FATFS 文件系统使用
// pBuffer:要写入的数据
// SectorNo:起始扇区号
// SectorSize:扇区大小(不能大于NAND_ECC_SECTOR_SIZE 定义的大小,否则会出错!!)
// SectorCount:要写入的扇区数量
// 返回值:0,成功
//  其他,失败
u8 FTL_WriteSectors(u8 *pBuffer, u32 SectorNo, u16 SectorSize, u32 SectorCount)
{
    u8 flag = 0;
    u16 temp;
    u32 i = 0;
    u16 wsecs;                 // 写页大小
    u32 wlen;                  // 写入长度
    u32 LBNNo;                 // 逻辑块号
    u32 PBNNo;                 // 物理块号
    u32 PhyPageNo;             // 物理页号
    u32 PageOffset;            // 页内偏移地址
    u32 BlockOffset;           // 块内偏移地址
    u32 markdpbn = 0XFFFFFFFF; // 标记了的物理块编号
    for (i = 0; i < SectorCount; i++)
    {
        LBNNo = (SectorNo + i) / (nand_dev.block_pagenum * (nand_dev.page_mainsize / SectorSize)); // 根据逻辑扇区号和扇区大小计算出逻辑块号
        PBNNo = FTL_LBNToPBN(LBNNo);                                                               // 将逻辑块转换为物理块
        if (PBNNo >= nand_dev.block_totalnum)
            return 1; // 物理块号大于总块数,则失败.
        BlockOffset = ((SectorNo + i) % (nand_dev.block_pagenum *
                                         (nand_dev.page_mainsize / SectorSize))) *
                      SectorSize; // 计算块内偏移
        PhyPageNo = PBNNo * nand_dev.block_pagenum + BlockOffset /
                                                         nand_dev.page_mainsize; // 计算出物理页号
        PageOffset = BlockOffset % nand_dev.page_mainsize;                       // 计算出页内偏移地址
        temp = nand_dev.page_mainsize - PageOffset;                              // page 内剩余字节数
        temp /= SectorSize;                                                      // 可以连续写入的sector 数
        wsecs = SectorCount - i;                                                 // 还剩多少个sector 要写
        if (wsecs >= temp)
            wsecs = temp;          // 大于可连续写入的sector 数,则写入temp 个扇区
        wlen = wsecs * SectorSize; // 每次写wsecs 个sector
        // 读出写入大小的内容判断是否全为0XFFFFFFFF(以4 字节为单位读取)
        flag = NAND_ReadPageComp(PhyPageNo, PageOffset, 0XFFFFFFFF, wlen / 4, &temp);
        if (flag)
            return 2; // 读写错误,坏块
        // 全为0XFF,可以直接写数据
        if (temp == (wlen / 4))
            flag = NAND_WritePage(PhyPageNo, PageOffset, pBuffer, wlen);
        else
            flag = 1;                         // 不全是0XFF,则另作处理
        if (flag == 0 && (markdpbn != PBNNo)) // 标记了的物理块与当前物理块不同
        {
            flag = FTL_UsedBlockMark(PBNNo); // 标记此块已经使用
            markdpbn = PBNNo;                // 标记完成,标记块=当前块,防止重复标记
        }
        if (flag) // 不全为0XFF/标记失败,将数据写到另一个块
        {
            temp = ((u32)nand_dev.block_pagenum * nand_dev.page_mainsize - BlockOffset) / SectorSize; // 计算整个block 还剩下多少个SECTOR 可以写入
            wsecs = SectorCount - i;                                                                  // 还剩多少个sector 要写
            if (wsecs >= temp)
                wsecs = temp;          // 大于可连续写入的sector 数,则写入temp 个扇区
            wlen = wsecs * SectorSize; // 每次写wsecs 个sector
            // 拷贝到另外一个block,并写入数据
            flag = FTL_CopyAndWriteToBlock(PhyPageNo, PageOffset, pBuffer, wlen);
            if (flag)
                return 3; // 失败
        }
        i += wsecs - 1;
        pBuffer += wlen; // 数据缓冲区指针偏移
    }
    return 0;
}

该函数非常重要,它是FTL 层对文件系统的接口函数,用于往NAND FLASH 里面写入数据,用户调用该函数时,无需关心坏块和磨损均衡问题,完全可以把NAND FLASH 当成一个
SD 卡来访问。该函数输入参数:SectorNo 用于指定扇区地址,扇区大小由SectorSize 指定,一般我们设置SectorSize=NAND_ECC_SECTOR_SIZE,方便进行ECC 校验处理。
该函数根据SectorNo 和SectorSize,首先计算出逻辑块地址(LBNNo),然后将逻辑块地址转换为物理块地址(PBNNo),然后再计算出块内的页地址(PhyPageNo)和页内的偏移地址(PageOffset),然后计算该页内还可以连续写入的扇区数(如果可以连续写,则可以提高速度),然后判断要写入的区域,数据是否全为0XFF,如果全是0XFF,则直接写入,写入完成对该物理块进行已被使用标记。如果不是全0XFF,则需要利用NAND 页拷贝功能,将本页数据拷贝到另外一个Block,并写入要写入的数据,这个操作由FTL_CopyAndWriteToBlock 函数来完成。
最后,我们看FTL_ReadSectors 函数,该函数代码如下:

// 读扇区(支持多扇区读),FATFS 文件系统使用
// pBuffer:数据缓存区
// SectorNo:起始扇区号
// SectorSize:扇区大小
// SectorCount:要写入的扇区数量
// 返回值:0,成功
//  其他,失败
u8 FTL_ReadSectors(u8 *pBuffer, u32 SectorNo, u16 SectorSize, u32 SectorCount)
{
    u8 flag = 0;
    u32 i = 0;
    u16 rsecs;       // 单次读取页数
    u32 LBNNo;       // 逻辑块号
    u32 PBNNo;       // 物理块号
    u32 PhyPageNo;   // 物理页号
    u32 PageOffset;  // 页内偏移地址
    u32 BlockOffset; // 块内偏移地址
    for (i = 0; i < SectorCount; i++)
    {
        LBNNo = (SectorNo + i) / (nand_dev.block_pagenum * (nand_dev.page_mainsize / SectorSize)); // 根据逻辑扇区号和扇区大小计算出逻辑块号
        PBNNo = FTL_LBNToPBN(LBNNo);                                                               // 将逻辑块转换为物理块
        if (PBNNo >= nand_dev.block_totalnum)
            return 1; // 物理块号大于总块数,则失败.
        BlockOffset = ((SectorNo + i) % (nand_dev.block_pagenum * (nand_dev.page_mainsize /
                                                                   SectorSize))) *
                      SectorSize; // 计算块内偏移
        PhyPageNo = PBNNo * nand_dev.block_pagenum + BlockOffset /
                                                         nand_dev.page_mainsize; // 计算出物理页号
        PageOffset = BlockOffset % nand_dev.page_mainsize;                       // 计算出页内偏移地址
        rsecs = (nand_dev.page_mainsize - PageOffset) / SectorSize;              // 一次最多可以读取多少页
        if (rsecs > (SectorCount - i))
            rsecs = SectorCount - i;                                              // 最多不能超过SectorCount-i
        flag = NAND_ReadPage(PhyPageNo, PageOffset, pBuffer, rsecs * SectorSize); // 读取数据
        if (flag == NSTA_ECC1BITERR)                                              // 对于1bit ecc 错误,可能为坏块,读2 次确认
            flag = NAND_ReadPage(PhyPageNo, PageOffset, pBuffer, rsecs * SectorSize);
        if (flag == NSTA_ECC1BITERR) // 重读数据,再次确认,还是有1BIT ECC 错误
        {
            // 将整个数据,搬运到另外一个block,防止此block 是坏块
            FTL_CopyAndWriteToBlock(PhyPageNo, PageOffset, pBuffer, rsecs * SectorSize);
            flag = FTL_BlockCompare(PhyPageNo / nand_dev.block_pagenum, 0XFFFFFFFF);
            // 全1 检查,确认是否为坏块
            if (flag == 0)
            {
                flag = FTL_BlockCompare(PhyPageNo / nand_dev.block_pagenum, 0X00);
                // 全0 检查,确认是否为坏块
                NAND_EraseBlock(PhyPageNo / nand_dev.block_pagenum); // 检测完擦除
            }
            if (flag) // 全0/全1 检查出错,肯定是坏块了.
            {
                FTL_BadBlockMark(PhyPageNo / nand_dev.block_pagenum); // 标记为坏块
                FTL_CreateLUT(1);                                     // 重建LUT 表
            }
            flag = 0;
        }
        // 2bit ecc 错误,不处理(可能是初次读取数据导致的)
        if (flag == NSTA_ECC2BITERR)
            flag = 0;
        if (flag)
            return 2;                  // 失败
        pBuffer += SectorSize * rsecs; // 数据缓冲区指针偏移
        i += rsecs - 1;
    }
    return 0;
}

该函数也是FTL 层对文件系统的接口函数,用于读取NAND FLASH 里面的数据,同样,用户在调用该函数时,无需关系坏块管理和磨损均衡问题,可以像访问SD 卡一样,调用该函数,实现读取NAND FLASH 的数据。该函数的实现原理,同前面介绍的FTL_WriteSectors 函数基本类似,不过该函数对读数时出现的ECC 错误,进行了处理,对于读取数据时出现1bit ECC错误的Block,进行两次读取(多次确认,以免误操作),如果两次读取都有1bitECC 错误,那么该Block 可能是坏块,当出现此错误后,我们先将该Block 的数据,拷贝到另外一个Block(备份现有数据),然后对该Block 进行擦除和写0,然后判断擦除/写0 是否正常,如果正常,则说明这个块不是坏块,还可以继续使用。如果不正常,则说明该块确实是一个坏块,必须进行坏块标记,并重建lut 表。如果读数时出现2bit ecc 错误,这个不一定就是出错了,而有可能是读取还未写入过数据的Block(未写入过数据,那么ECC 值,肯定也是未写入过,如果进行ECC 校验的话,必定出错),导致的ECC 错误,对于此类错误,我们直接不予处理(忽略)就可以了。
ftl.c 里面的其他代码以及ftl.h 里面的代码,这里就不做介绍了,请大家参考本例程源码。另外,nandtester.c 和nandtester.h 这两个文件,主要用于usmart 调试nand.c 和ftl.c 里面的相关函数,这里也不做介绍了,请大家参考本例程源码。
最后,打开main.c 文件,代码如下:

int main(void)
{
    u8 key, t = 0, *buf, *backbuf;
    u16 i;
    HAL_Init();                      // 初始化HAL 库
    Stm32_Clock_Init(360, 25, 2, 8); // 设置时钟,180Mhz
    delay_init(180);                 // 初始化延时函数
    uart_init(115200);               // 初始化USART
    LED_Init();                      // 初始化LED
    KEY_Init();                      // 初始化按键
    SDRAM_Init();                    // SDRAM 初始化
    LCD_Init();                      // LCD 初始化
    my_mem_init(SRAMIN);             // 初始化内部内存池
    my_mem_init(SRAMEX);             // 初始化外部SDRAM 内存池
    my_mem_init(SRAMCCM);            // 初始化CCM 内存池
    POINT_COLOR = RED;
    LCD_ShowString(30, 50, 200, 16, 16, "Apollo STM32F4/F7");
    LCD_ShowString(30, 70, 200, 16, 16, "NAND TEST");
    LCD_ShowString(30, 90, 200, 16, 16, "ATOM@ALIENTEK");
    LCD_ShowString(30, 110, 200, 16, 16, "2016/2/16");
    LCD_ShowString(30, 130, 200, 16, 16, "KEY0:Read Sector 2");
    LCD_ShowString(30, 150, 200, 16, 16, "KEY1:Write Sector 2");
    LCD_ShowString(30, 170, 200, 16, 16, "KEY2:Recover Sector 2");
    while (FTL_Init()) // 检测NAND FLASH,并初始化FTL
    {
        LCD_ShowString(30, 190, 200, 16, 16, "NAND Error!");
        delay_ms(500);
        LCD_ShowString(30, 190, 200, 16, 16, "Please Check");
        delay_ms(500);
        LED0 = !LED0; // DS0 闪烁
    }
    backbuf = mymalloc(SRAMIN, NAND_ECC_SECTOR_SIZE); // 申请一个扇区的缓存
    buf = mymalloc(SRAMIN, NAND_ECC_SECTOR_SIZE);     // 申请一个扇区的缓存
    POINT_COLOR = BLUE;                               // 设置字体为蓝色
    sprintf((char *)buf, "NAND
            Size : % dMB ",(nand_dev.block_totalnum/1024)*(nand_dev.page_mainsize/1024)*
                       nand_dev.block_pagenum);
    LCD_ShowString(30, 190, 200, 16, 16, buf); // 显示NAND 容量
    FTL_ReadSectors(backbuf, 2, NAND_ECC_SECTOR_SIZE, 1);
    // 预先读取扇区0 到备份区域,防止乱写导致文件系统损坏.
    while (1)
    {
        key = KEY_Scan(0);
        switch (key)
        {
        case KEY0_PRES:                                             // KEY0 按下,读取sector
            key = FTL_ReadSectors(buf, 2, NAND_ECC_SECTOR_SIZE, 1); // 读取扇区
            if (key == 0)                                           // 读取成功
            {
                LCD_ShowString(30, 210, 200, 16, 16, "USART1 Sending Data... ");
                printf("Sector 2 data is:\r\n");
                for (i = 0; i < NAND_ECC_SECTOR_SIZE; i++)
                {
                    printf("%x ", buf[i]); // 输出数据
                }
                printf("\r\ndata end.\r\n");
                LCD_ShowString(30, 210, 200, 16, 16, "USART1 Send Data Over! ");
            }
            break;
        case KEY1_PRES: // KEY1 按下,写入sector
            for (i = 0; i < NAND_ECC_SECTOR_SIZE; i++)
                buf[i] = i + t;
            // 填充数据(随机的,根据t 的值来确定)
            LCD_ShowString(30, 210, 210, 16, 16, "Writing data to sector..");
            key = FTL_WriteSectors(buf, 2, NAND_ECC_SECTOR_SIZE, 1); // 写入扇区
            if (key == 0)
                LCD_ShowString(30, 210, 200, 16, 16, "Write data successed"); // 写入成功
            else
                LCD_ShowString(30, 210, 200, 16, 16, "Write data failed "); // 写入失败
            break;
        case KEY2_PRES: // KEY2 按下,恢复sector 的数据
            LCD_ShowString(30, 210, 210, 16, 16, "Recovering data... ");
            key = FTL_WriteSectors(backbuf, 2, NAND_ECC_SECTOR_SIZE, 1);
            // 写入扇区
            if (key == 0)
                LCD_ShowString(30, 210, 200, 16, 16, "Recovering data OK"); // 恢复成功
            Else
                LCD_ShowString(30, 210, 200, 16, 16, "Recovering data failed"); // 恢复失败
            break;
        }
        t++;
        delay_ms(10);
        if (t == 20)
        {
            LED0 = !LED0;
            t = 0;
        }
    }
}

此部分代码比较简单,我们先初始化相关外设,然后初始化FTL,在FTL 初始化成功以后,先对扇区2 的数据进行备份,随后进入死循环,检测按键,可以通过KEY0/KEY1/KEY2 按键对扇区2 的数据进行读取、写入和还原操作。同样,DS0 闪烁,用于提示程序正在运行。
最后,我们将NAND_EraseChip、NAND_EraseBlock、FTL_CreateLUT、FTL_Format、
test_writepage和test_readpage等函数加入USMART 控制,这样,我们就可以通过串口调试助手,测试NAND FLASH 的各种操作了,方便大家测试。软件部分就给大家介绍到这里。

下载验证

在代码编译成功之后,我们通过下载代码到ALIENTEK 阿波罗STM32 开发板上,得到如图45.4.1 所示界面:
在这里插入图片描述

图45.4.1 程序运行效果图
此时,我们可以按下KEY0/KEY1/KEY2 等按键进行对应的测试。我们按KEY0,可以读取扇区2 里面的数据,通过串口调试助手查看,如图45.4.2 所示:
在这里插入图片描述

图45.4.3 串口观看扇区2 里面的数据
另外,我们还可以利用usmart,调用相关函数,执行不同的操作,如图45.4.4 所示:
在这里插入图片描述
图45.4.4 USMART 调用相关函数

  • 10
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

行稳方能走远

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值