电容触摸屏

电容触摸屏 触摸屏又称触控面板,它是一种把触摸位置转化成坐标数据的输入设备,根据触摸屏的检测原理,主要分为电阻式触摸屏和电容式触摸屏。 相对来说,电阻屏造价便宜,能适应较恶劣的环境,但它只支持单点触控(一次只能检测面板上的一个触摸位置),触摸时需要一定的压力, 使用久了容易造成表面磨损,影响寿命;而电容屏具有支持多点触控、检测精度高的特点,电容屏通过与导电物体产生的电容效应来检测触摸动作, 只能感应导电物体的触摸,湿度较大或屏幕表面有水珠时会影响电容屏的检测效果。区分电阻屏和电容屏一般通过绝缘物体点击是否响应来区别。
电阻触摸屏一般会引出四根线,通过XPT2046触摸控制芯片驱动;f407可用GT5688驱动电容屏。
电阻屏结构及其原理

电阻屏结构
电容触摸屏
文章图片

它主要由表面硬涂层、两个ITO层、间隔点以及玻璃底层构成, 这些结构层都是透明的,整个触摸屏覆盖在液晶面板上,透过触摸屏可看到液晶面板。表面涂层起到保护作用, 玻璃底层起承载的作用,而两个ITO层是触摸屏的关键结构,它们是涂有铟锡金属氧化物的导电层。 两个ITO层之间使用间隔点使两层分开,当触摸屏表面受到压力时,表面弯曲使得上层ITO与下层ITO接触,在触点处连通电路。
电阻屏电压原理
两个ITO涂层的两端分别引出X-、X+、Y-、Y+四个电极,见下图 XY的ITO层结构 , 这是电阻屏最常见的四线结构,通过这些电极,外部电路向这两个涂层可以施加匀强电场或检测电压。电容触摸屏
文章图片

通过按压点和xy轴的比例关系,来确定按压点
电容触摸屏
文章图片

计算X坐标时,在X+电极施加驱动电压Vref,X-极接地,所以X+与X-处形成了匀强电场,而触点处的电压通过Y+电极采集得到, 由于ITO层均匀导电,触点电压与Vref之比等于触点X坐标与屏宽度之比,从而:
电容触摸屏
文章图片

计算Y坐标时,在Y+电极施加驱动电压Vref,Y-极接地,所以Y+与Y-处形成了匀强电场,而触点处的电压通过X+电极采集得到, 由于ITO层均匀导电,触点电压与Vref之比等于触点Y坐标与屏高度之比,从而:
电容触摸屏
文章图片

注:可计算出坐标的基础是ITO上的电压是均匀的。上文说的四根线就是X、Y的正负。
为了方便检测触摸的坐标,一些芯片厂商制作了电阻屏专用的控制芯片,控制上述采集过程、采集电压, 外部微控制器直接与触摸控制芯片通讯直接获得触点的电压或坐标。XPT2046芯片控制4线电阻触摸屏,STM32与XPT2046采用SPI通讯获取采集得的电压,然后转换成坐标。
电容屏原理
与电阻式触摸屏不同,电容式触摸屏不需要通过压力使触点变形,再通过触点处电压值来检测坐标,它的基本原理是利用充电时间检测电容大小,从而通过检测出电容值的变化来获知触摸信号。
电容屏的最上层是玻璃(不会像电阻屏那样形变), 核心层部分也是由ITO材料构成的,导电材料在屏幕里构成了静电网,静电网由多行X轴电极和多列Y轴电极构成,两个电极之间会形成电容。 触摸屏工作时,X轴电极发出AC交流信号,而交流信号能穿过电容,即通过Y轴能感应出该信号,当交流电穿越时电容会有充放电过程,检测该充电时间可获知电容量。 若手指触摸屏幕,会影响触摸点附近两个电极之间的耦合,从而改变两个电极之间的电容量,若检测到某电容的电容量发生了改变, 即可获知该电容处有触摸动作。
电容屏ITO工作过程及有效原理
电容触摸屏
文章图片

X轴电极与Y轴电极在交叉处形成电容,即这两组电极构成了电容的两极,这样的结构覆盖了整个电容屏,每个电容单元在触摸屏中都有其特定的物理位置, 即电容的位置就是它在触摸屏的XY坐标。检测触摸的坐标时,第1条X轴的电极发出激励信号,而所有Y轴的电极同时接收信号, 通过检测充电时间可检测出各个Y轴与第1条X轴相交的各个互电容的大小,各个X轴依次发出激励信号,重复上述步骤,即可得到整个触摸屏二维平面的所有电容大小。 当手指接近时,会导致局部电容改变,根据得到的触摸屏电容量变化的二维数据表,可以得知每个触摸点的坐标,因此电容触摸屏支持多点触控。最终STM32通过IIC协议与电容屏芯片进行通讯来获得具体的坐标。
电容触摸屏可看作是多个电容按键组合而成,就像机械按键中独立按键和矩阵按键的关系一样,甚至电容触摸屏的坐标扫描方式与矩阵按键都是很相似的。
电容触摸屏芯片
内部结构框图电容触摸屏
文章图片

解释如下
电容触摸屏
文章图片

ADC用来检测电压;DSP处理数据。
上电时序与I2C设备地址:GT5688触控芯片有两个备选的I2C通讯地址,这是由芯片的上电时序设定的。上电时序有Reset引脚和INT引脚生成,若Reset引脚从低电电平转变到高电平期间,INT引脚为高电平的时候,触控芯片使用的I2C设备地址为0x28/0x29(8位写、读地址), 7位地址为0x14;若Reset引脚从低电电平转变到高电平期间,INT引脚一直为低电平,则触控芯片使用的I2C设备地址为0xBA/0xBB(8位写、读地址),7位地址为0x5D。
选择设备地址
电容触摸屏
文章图片

寄存器配置
上电复位后,GT5688芯片需要通过外部主控芯片加载寄存器配置,设定它的工作模式,这些配置通过I2C信号线传输到GT5688, 它的配置寄存器地址都由两个字节来表示,这些寄存器的地址从0x8047-0x8135,一般来说,我们实际配置的时候会按照GT5688生产厂商给的默认配置来控制芯片, 仅修改部分关键寄存器。
(此处使用旧型号芯片GT9157的寄存器表截图来进行说明,这两个型号大部分兼容)电容触摸屏
文章图片

寄存器介绍如下:
(1) 配置版本寄存器
0x8047配置版本寄存器,它包含有配置文件的版本号,若新写入的版本号比原版本大,或者版本号相等,但配置不一样时, 才会更新配置文件到寄存器中。其中配置文件是指记录了寄存器0x8048-0x80FE控制参数的一系列数据。
为了保证每次都更新配置,我们一般把配置版本寄存器设置为“0x00”,这样版本号会默认初始化为‘A’, 这样每次我们修改其它寄存器配置的时候,都会写入到GT5688中。
(2) X、Y分辨率
0x8048-0x804B寄存器用于配置触控芯片输出的XY坐标的最大值,为了方便使用,我们把它配置得跟液晶面板的分辨率一致, 这样就能使触控芯片输出的坐标一一对应到液晶面板的每一个像素点了。
(3) 触点个数
0x804C触点个数寄存器用于配置它最多可输出多少个同时按下的触点坐标,这个极限值跟触摸屏面板有关, 如我们本章实验使用的触摸面板最多支持5点触控。
(4) 模式切换
0x804D模式切换寄存器中的X2Y位可以用于交换XY坐标轴;而INT触发方式位可以配置不同的触发方式, 当有触摸信号时,INT引脚会根据这里的配置给出触发信号。
(5) 配置校验
0x80FF配置校验寄存器用于写入前面0x8047-0x80FE寄存器控制参数字节之和的补码,GT5688收到前面的寄存器配置时, 会利用这个数据进行校验,若不匹配,就不会更新寄存器配置。
(6) 配置更新
0x8100配置更新寄存器用于控制GT5688进行更新,传输了前面的寄存器配置并校验通过后, 对这个寄存器写1,GT5688会更新配置。
读取坐标
坐标寄存器
上述寄存器主要是由外部主控芯片给GT5688写入配置的,而它则使用图 坐标信息寄存器 中的寄存器向主控器反馈信息。
电容触摸屏
文章图片

(1) 产品ID及版本
0x8140-0x8143 寄存器存储的是产品ID,上电后我们可以利用I2C读取这些寄存器的值来判断I2C是否正常通讯, 这些寄存器中包含有“5688”字样; 而0x8144-0x8145则保存有固件版本号,不同版本可能不同。
(2) X/Y分辨率
0x8146-0x8149寄存器存储了控制触摸屏的分辨率,它们的值与我们前面在配置寄存器写入的XY控制参数一致。 所以我们可以通过读取这两个寄存器的值来确认配置参数是否正确写入。
(3) 状态寄存器
0x814E地址的是状态寄存器,它的Buffer status位存储了坐标状态,当它为1时,表示新的坐标数据已准备好,可以读取, 0表示未就绪,数据无效,外部控制器读取完坐标后,须对这个寄存器位写0 。number of touch points位表示当前有多少个触点。
(4) 坐标数据
从地址0x814F-0x8156的是触摸点1的坐标数据,从0x8157-0x815E的是触摸点2的坐标数据,依次还有存储3-10触摸点坐标数据的寄存器。 读取这些坐标信息时,我们通过它们的track id来区分笔迹,多次读取坐标数据时,同一个track id号里的数据属于同一个连续的笔划轨迹。
读坐标流程
上电、配置完寄存器后,GT5688就会开监测触摸屏,若我们前面的配置使INT采用中断上升沿报告触摸信号的方式,整个读取坐标信息的过程如下:
(1) 待机时INT引脚输出低电平;
(2) 有坐标更新时,INT引脚输出上升沿;
(3) INT输出上升沿后,INT 脚会保持高直到下一个周期(该周期可由配置 Refresh_Rate 决定)。 外部主控器在检测到INT的信号后,先读取状态寄存器(0x814E)中的number of touch points位获当前有多少个触摸点, 然后读取各个点的坐标数据,读取完后将 bufferstatus位写为 0。外部主控器的这些读取过程要在一个周期内完成,该周期由0x8056地址的Refresh_Rate寄存器配置;
(4) 上一步骤中INT输出上升沿后,若主控未在一个周期内读走坐标, 下次 GT5688即使检测到坐标更新会再输出一个 INT 脉冲但不更新坐标;
(5) 若外部主控一直未读走坐标, 则 GT5688会一直输出 INT 脉冲。
电容触摸屏
文章图片

其中的PD3和PD7没有硬件I2C功能,所以需要软件模拟的方式模拟I2C
代码相关
1.I2C配置
I2C的GPIO配置时(下为I2C__GPIO_Config()部分函数体)
/*配置SCL引脚 */ GPIO_InitStructure.GPIO_Pin = GTP_I2C_SCL_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_OD; GPIO_InitStructure.GPIO_PuPd= GPIO_PuPd_NOPULL; GPIO_Init(GTP_I2C_SCL_GPIO_PORT, &GPIO_InitStructure); /*配置SDA引脚 */ GPIO_InitStructure.GPIO_Pin = GTP_I2C_SDA_PIN; GPIO_Init(GTP_I2C_SDA_GPIO_PORT, &GPIO_InitStructure); /*配置RST引脚,下拉推挽输出 */ GPIO_InitStructure.GPIO_Pin = GTP_RST_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd= GPIO_PuPd_DOWN; GPIO_Init(GTP_RST_GPIO_PORT, &GPIO_InitStructure); /*配置 INT引脚,下拉推挽输出,方便初始化 */ GPIO_InitStructure.GPIO_Pin = GTP_INT_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;

【电容触摸屏】SDA和SCL引脚都应该设置为浮空开漏输出,而RST和INT应该设置为下拉推挽输出。INT引脚需要接收选择时序的,因此需要设置为推挽输出模式。(下拉即为默认值为低电平,方便初始化)
配置完GPIO,选择好时序后,再次配置I2C,将INT设置为下拉推挽输出,方便初始化(下为I2C_ResetChip()部分函数体)
GPIO_InitStructure.GPIO_Pin = GTP_INT_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd= GPIO_PuPd_DOWN; GPIO_Init(GTP_INT_GPIO_PORT, &GPIO_InitStructure);

初始化完后紧接着就把INT引脚设置为浮空输入模式,方便接收中断信号
/*把INT引脚设置为浮空输入模式,以便接收触摸中断信号*/ GPIO_InitStructure.GPIO_Pin = GTP_INT_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_PuPd= GPIO_PuPd_NOPULL; GPIO_Init(GTP_INT_GPIO_PORT, &GPIO_InitStructure);

对于整个I2C_ResetChip()函数:
void I2C_ResetChip(void) { GPIO_InitTypeDef GPIO_InitStructure; /*配置 INT引脚,下拉推挽输出,方便初始化 */ GPIO_InitStructure.GPIO_Pin = GTP_INT_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; //设置为下拉,方便初始化 GPIO_InitStructure.GPIO_PuPd= GPIO_PuPd_DOWN; //INT的GPIO初始化配置 GPIO_Init(GTP_INT_GPIO_PORT, &GPIO_InitStructure); /*初始化GT5688,rst为高电平,int为低电平, 则gt5688的设备地址被配置为0xBA*//*设置复位引脚 复位为低电平,为初始化做准备*/ GPIO_ResetBits (GTP_RST_GPIO_PORT,GTP_RST_GPIO_PIN); Delay(0x0FFFFF); /*拉高一段时间,进行初始化*/ GPIO_SetBits (GTP_RST_GPIO_PORT,GTP_RST_GPIO_PIN); Delay(0x0FFFFF); //上面两个操作实现了选择设置地址为0xBA/0xBB时序/*把INT引脚设置为浮空输入模式,以便接收触摸中断信号*/ GPIO_InitStructure.GPIO_Pin = GTP_INT_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_PuPd= GPIO_PuPd_NOPULL; GPIO_Init(GTP_INT_GPIO_PORT, &GPIO_InitStructure); }//复位引脚中断引脚设置部分define如下 /*复位引脚*/ #define GTP_RST_GPIO_PORTGPIOD #define GTP_RST_GPIO_CLKRCC_AHB1Periph_GPIOD #define GTP_RST_GPIO_PINGPIO_Pin_6 /*中断引脚*/ #define GTP_INT_GPIO_PORTGPIOG #define GTP_INT_GPIO_CLKRCC_AHB1Periph_GPIOG #define GTP_INT_GPIO_PINGPIO_Pin_8 #define GTP_INT_EXTI_PORTSOURCEEXTI_PortSourceGPIOG #define GTP_INT_EXTI_PINSOURCEEXTI_PinSource8 #define GTP_INT_EXTI_LINEEXTI_Line8 #define GTP_INT_EXTI_IRQEXTI9_5_IRQn

I2C中断引脚配置
void I2C_GTP_IRQEnable(void) { EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; GPIO_InitTypeDef GPIO_InitStructure; /*配置 INT 为浮空输入 */ GPIO_InitStructure.GPIO_Pin = GTP_INT_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_PuPd= GPIO_PuPd_NOPULL; GPIO_Init(GTP_INT_GPIO_PORT, &GPIO_InitStructure); /* 连接 EXTI 中断源 到INT 引脚 */ SYSCFG_EXTILineConfig(GTP_INT_EXTI_PORTSOURCE, GTP_INT_EXTI_PINSOURCE); /* 选择 EXTI 中断源 */ EXTI_InitStructure.EXTI_Line = GTP_INT_EXTI_LINE; EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising; EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); /* 配置中断优先级 */ NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1); /*使能中断*/ NVIC_InitStructure.NVIC_IRQChannel = GTP_INT_EXTI_IRQ; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); //中断define见上文 }

上三个函数运行
void I2C_Touch_Init(void) { I2C_GPIO_Config(); I2C_ResetChip(); I2C_GTP_IRQEnable(); }

写软件I2C时,需要自己写函数。下面介绍几个相关函数。
void i2c_Start(void) { /* 当SCL高电平时,SDA出现一个下跳沿表示I2C总线启动信号 */ I2C_SDA_1(); //设置SDA引脚为高电平 I2C_SCL_1(); //设置SCL引脚为高电平 i2c_Delay(); I2C_SDA_0(); //设置SDA引脚为低电平 i2c_Delay(); I2C_SCL_0(); //设置SCL引脚为低电平 i2c_Delay(); } //相关宏 //软件IIC使用的宏 #define I2C_SCL_1()GPIO_SetBits(GTP_I2C_SCL_GPIO_PORT, GTP_I2C_SCL_PIN)/* SCL = 1 */ #define I2C_SCL_0()GPIO_ResetBits(GTP_I2C_SCL_GPIO_PORT, GTP_I2C_SCL_PIN)/* SCL = 0 */#define I2C_SDA_1()GPIO_SetBits(GTP_I2C_SDA_GPIO_PORT, GTP_I2C_SDA_PIN)/* SDA = 1 */ #define I2C_SDA_0()GPIO_ResetBits(GTP_I2C_SDA_GPIO_PORT, GTP_I2C_SDA_PIN)/* SDA = 0 */#define I2C_SDA_READ()GPIO_ReadInputDataBit(GTP_I2C_SDA_GPIO_PORT, GTP_I2C_SDA_PIN) /* 读SDA口线状态 */ //i2c_Delay()函数: static void i2c_Delay(void) { uint8_t i; /*  下面的时间是通过逻辑分析仪测试得到的。 工作条件:CPU主频180MHz ,MDK编译环境,1级优化循环次数为50时,SCL频率 = 333KHz 循环次数为30时,SCL频率 = 533KHz, 循环次数为20时,SCL频率 = 727KHz, */ for (i = 0; i < 10*2; i++); }

I2C硬件电路时序图电容触摸屏
文章图片

根据时序图,要先把SDA和SCL线都拉成高电平。延时后SDA由高变低,SCL没变化,则Start信号成功产生,为进行后续操作,SCL线也拉成低电平。SCL为高电平时SDA由高变低产生的这个时序信号就是I2C启动信号。
类似的停止信号:
void i2c_Stop(void) { /* 当SCL高电平时,SDA出现一个上跳沿表示I2C总线停止信号 */ I2C_SDA_0(); I2C_SCL_1(); i2c_Delay(); I2C_SDA_1(); }

软件I2C实际就是CPU控制引脚电平发送I2C信号;硬件I2C就是通过控制STM32的寄存器产生I2C信号。
其他常用软件I2C函数如下
(1)
i2c_SendByte(uint8_t _ucByte) CPU向I2C总线设备发送8bit数据
形参:_ucByte : 等待发送的字节
void i2c_SendByte(uint8_t _ucByte) { uint8_t i; /* 先发送字节的高位bit7 */ for (i = 0; i < 8; i++) { if (_ucByte & 0x80) { I2C_SDA_1(); } else { I2C_SDA_0(); } i2c_Delay(); I2C_SCL_1(); i2c_Delay(); I2C_SCL_0(); if (i == 7) { I2C_SDA_1(); // 释放总线 } _ucByte <<= 1; /* 左移一个bit */ i2c_Delay(); } }

形参为要发送的数据,8个数据位,如果这个数据位是1,SDA线产生高电平,是0产生低电平。
(2)
函数名: i2c_ReadByte(); CPU从I2C总线设备读取8bit数据
返回值: 读到的数据
uint8_t i2c_ReadByte(void) { uint8_t i; uint8_t value; /* 读到第1个bit为数据的bit7 */ value = https://www.it610.com/article/0; for (i = 0; i < 8; i++) { value <<= 1; I2C_SCL_1(); i2c_Delay(); if (I2C_SDA_READ()) { value++; } I2C_SCL_0(); i2c_Delay(); } return value; } //通过SCL由高变低,对SDA引脚进行采样,采样其每个数据位,采样八个数据位则读到一个字节

(3)
i2c_WaitAck() ;功能: CPU产生一个时钟,并读取器件的ACK应答信号
返回值: 返回0表示正确应答,1表示无器件响应
uint8_t i2c_WaitAck(void) { uint8_t re; I2C_SDA_1(); /* CPU释放SDA总线 */ i2c_Delay(); I2C_SCL_1(); /* CPU驱动SCL = 1, 此时器件会返回ACK应答 */ i2c_Delay(); if (I2C_SDA_READ()) /* CPU读取SDA口线状态 */ { re = 1; } else { re = 0; } I2C_SCL_0(); i2c_Delay(); return re; }

(4)
i2c_Ack();功能 CPU产生一个ACK信号
void i2c_Ack(void) { I2C_SDA_0(); /* CPU驱动SDA = 0 */ i2c_Delay(); I2C_SCL_1(); /* CPU产生1个时钟 */ i2c_Delay(); I2C_SCL_0(); i2c_Delay(); I2C_SDA_1(); /* CPU释放SDA总线 */ }

(5)
i2c_NAck();功能说明: CPU产生1个NACK信号
void i2c_NAck(void) { I2C_SDA_1(); /* CPU驱动SDA = 1 */ i2c_Delay(); I2C_SCL_1(); /* CPU产生1个时钟 */ i2c_Delay(); I2C_SCL_0(); i2c_Delay(); }

(6)ReadBytes和WriteBytes
#define I2C_DIR_WR 0/* 写控制bit */ #define I2C_DIR_RD 1/* 读控制bit *//** * ClientAddr:从设备地址 * pBuffer:存放由从机读取的数据的缓冲区指针 * NumByteToRead:读取的数据长度 */ uint32_t I2C_ReadBytes(uint8_t ClientAddr,uint8_t* pBuffer, uint16_t NumByteToRead) { /* 第1步:发起I2C总线启动信号 */ i2c_Start(); /* 第2步:发起控制字节,高7bit是地址,bit0是读写控制位, 0表示写,1表示读 */ i2c_SendByte(ClientAddr | I2C_DIR_RD); /* 此处是读指令 */ /* 第3步:等待ACK */ if (i2c_WaitAck() != 0) { goto cmd_fail; /* 器件无应答 */ } while(NumByteToRead) { if(NumByteToRead == 1) { i2c_NAck(); /* 最后1个字节读完后,CPU产生NACK信号 (驱动SDA = 1) *//* 发送I2C总线停止信号 */ i2c_Stop(); }*pBuffer = i2c_ReadByte(); /* 读指针自增 */ pBuffer++; /*计数器自减 */ NumByteToRead--; i2c_Ack(); /* 中间字节读完后,CPU产生ACK信号(驱动SDA = 0) */ } /* 发送I2C总线停止信号 */ i2c_Stop(); return 0; /* 执行成功 */cmd_fail: /* 命令执行失败后,切记发送停止信号, 避免影响I2C总线上其他设备 */ /* 发送I2C总线停止信号 */ i2c_Stop(); return 1; }/** * ClientAddr:从设备地址 * pBuffer:缓冲区指针 * NumByteToWrite:写的字节数 */ uint32_t I2C_WriteBytes(uint8_t ClientAddr,uint8_t* pBuffer, uint8_t NumByteToWrite) { uint16_t m; /* 第0步:发停止信号,启动内部写操作 */ i2c_Stop(); /* 通过检查器件应答的方式,判断内部写操作是否完成, 一般小于 10ms CLK频率为200KHz时,查询次数为30次左右 */ for (m = 0; m < 1000; m++) { /* 第1步:发起I2C总线启动信号 */ i2c_Start(); /* 第2步:发起控制字节,高7bit是地址,bit0是读写控制位, 0表示写,1表示读 */ i2c_SendByte(ClientAddr | I2C_DIR_WR); /* 此处是写指令 *//* 第3步:发送一个时钟,判断器件是否正确应答 */ if (i2c_WaitAck() == 0) { break; } } if (m== 1000) { goto cmd_fail; /* EEPROM器件写超时 */ } while(NumByteToWrite--) { /* 第4步:开始写入数据 */ i2c_SendByte(*pBuffer); /* 第5步:检查ACK */ if (i2c_WaitAck() != 0) { goto cmd_fail; /* 器件无应答 */ }pBuffer++; /* 地址增1 */ } /* 命令执行成功,发送I2C总线停止信号 */ i2c_Stop(); return 0; cmd_fail: /* 命令执行失败后,切记发送停止信号,避免影响I2C总线上其他设备 */ /* 发送I2C总线停止信号 */ i2c_Stop(); return 1; }

驱动GT5688芯片
1.I2C_Transfer(struct i2c_msg *msgs,int num)函数
static int I2C_Transfer( struct i2c_msg *msgs,int num) { int im = 0; int ret = 0; GTP_DEBUG_FUNC(); //下面的for实际就是执行num次 for (im = 0; ret == 0 && im != num; im++) { if ((msgs[im].flags&I2C_M_RD))//根据flag判断是读数据还是写数据 { ret = I2C_ReadBytes(msgs[im].addr, msgs[im].buf, msgs[im].len); //IIC读取数据 } else { ret = I2C_WriteBytes(msgs[im].addr,msgs[im].buf, msgs[im].len); //IIC写入数据 } } if(ret) return ret; return im; //正常完成的传输结构个数 }//mag结构体定义 struct i2c_msg { uint8_t addr; /*从设备的I2C设备地址 */ uint16_t flags; /*控制标志*/ uint16_t len; /*读写数据的长度*/ uint8_t *buf; /*存储读写数据的指针 */ };

此函数就是STM32使用I2C传输数据必要的函数,应用关键在于读数据和写数据两个函数。此读取函数应用在其他方面时主要替换的是就是实现读数据写数据的这两个函数,让I2C读取数据和写入数据。
GTP的输入输出函数:
/** * @brief从IIC设备中读取数据 * @param *@arg client_addr:设备地址 *@argbuf[0~1]: 读取数据寄存器的起始地址 *@arg buf[2~len-1]: 存储读出来数据的缓冲buffer *@arg len:GTP_ADDR_LENGTH + read bytes count(寄存器地址长度+读取的数据字节数) * @retvali2c_msgs传输结构体的个数,2为成功,其它为失败 */ #define GTP_ADDR_LENGTH2 static int32_t GTP_I2C_Read(uint8_t client_addr, uint8_t *buf, int32_t len) { struct i2c_msg msgs[2]; int32_t ret=-1; int32_t retries = 0; GTP_DEBUG_FUNC(); /*一个读数据的过程可以分为两个传输过程: * 1. IIC写入 要读取的寄存器地址 * 2. IIC读取数据 * */msgs[0].flags = !I2C_M_RD; //写入(非读即意思为写入) msgs[0].addr= client_addr; //IIC设备地址 msgs[0].len= GTP_ADDR_LENGTH; //寄存器地址一般为2字节(即写入两字节的数据) msgs[0].buf= &buf[0]; //buf[0~1]存储的是要读取的寄存器地址msgs[1].flags = I2C_M_RD; //读取 msgs[1].addr= client_addr; //IIC设备地址 msgs[1].len= len - GTP_ADDR_LENGTH; //要读取的数据长度 msgs[1].buf= &buf[GTP_ADDR_LENGTH]; //buf[GTP_ADDR_LENGTH]之后的缓冲区存储读出的数据while(retries < 5) { ret = I2C_Transfer( msgs, 2); //调用IIC数据传输过程函数,有2个传输过程 if(ret == 2)break; retries++; } if((retries >= 5)) { GTP_ERROR("I2C Read: 0x%04X, %d bytes failed, errcode: %d! Process reset.", (((uint16_t)(buf[0] << 8)) | buf[1]), len-2, ret); } return ret; }/** * @brief向IIC设备写入数据 * @param *@arg client_addr:设备地址 * *@argbuf[0~1]: 要写入的数据寄存器的起始地址 *@arg buf[2~len-1]: 要写入的数据 *@arg len:GTP_ADDR_LENGTH + write bytes count(寄存器地址长度+写入的数据字节数) * @retvali2c_msgs传输结构体的个数,1为成功,其它为失败 */ static int32_t GTP_I2C_Write(uint8_t client_addr,uint8_t *buf,int32_t len) { struct i2c_msg msg; int32_t ret = -1; int32_t retries = 0; GTP_DEBUG_FUNC(); /*一个写数据的过程只需要一个传输过程: * 1. IIC连续 写入 数据寄存器地址及数据 * */ msg.flags = !I2C_M_RD; //写入 msg.addr= client_addr; //从设备地址 msg.len= len; //长度直接等于(寄存器地址长度+写入的数据字节数) msg.buf= buf; //直接连续写入缓冲区中的数据(包括了寄存器地址) //尝试发送五次 while(retries < 5) { ret = I2C_Transfer(&msg, 1); //调用IIC数据传输过程函数,1个传输过程 if (ret == 1)break; retries++; } if((retries >= 5)) {GTP_ERROR("I2C Write: 0x%04X, %d bytes failed, errcode: %d! Process reset.", (((uint16_t)(buf[0] << 8)) | buf[1]), len-2, ret); } return ret; }

buf[0-1]: 读取数据寄存器的起始地址,buf[2~len-1]: 存储读出来数据的缓冲buffer。例如,往0x8143中写入0xFE,三个参数为——0xBA,
(buf 0 0x81; buf 1 0x43; buf 2 0xFE),len 3; 若要向0x8144写入0xFF向0x8143写入0xFE,则写如下:0xBA,(buf 0 0x81; buf 1 0x43; buf 2 0xFE,buf 3 0xFF),len 4.则读到了两个字节。
GTP_I2C_Read(uint8_t client_addr, uint8_t *buf, int32_t len)函数的
msgs[0].flags = !I2C_M_RD; //写入(非读即意思为写入) msgs[0].addr= client_addr; //IIC设备地址 msgs[0].len= GTP_ADDR_LENGTH; //寄存器地址一般为2字节(即写入两字节的数据) msgs[0].buf= &buf[0]; //buf[0~1]存储的是要读取的寄存器地址msgs[1].flags = I2C_M_RD; //读取 msgs[1].addr= client_addr; //IIC设备地址 msgs[1].len= len - GTP_ADDR_LENGTH; //要读取的数据长度 msgs[1].buf= &buf[GTP_ADDR_LENGTH]; //buf[GTP_ADDR_LENGTH]之后的缓冲区存储读出的数据

部分,msgs[0]储存的是设备地址,msgs[1]储存的是后面的数据,而设备地址占两个字节,因此后面的数据占len - GTP_ADDR_LENGTH,读的数据也是地址后的 msgs[1].buf = &buf[GTP_ADDR_LENGTH];
GTP_Init_Panel()函数分析
int32_t GTP_Init_Panel(void) { int32_t ret = -1; int32_t i = 0; uint16_t check_sum = 0; int32_t retry = 0; const uint8_t* cfg_info; uint8_t cfg_info_len; uint8_t* config; uint8_t cfg_num =0 ; //需要配置的寄存器个数GTP_DEBUG_FUNC(); //uint8_t config[GTP_CONFIG_MAX_LENGTH + GTP_ADDR_LENGTH] //= {GTP_REG_CONFIG_DATA >> 8, GTP_REG_CONFIG_DATA & 0xff}; config = (uint8_t *)malloc (GTP_CONFIG_MAX_LENGTH + GTP_ADDR_LENGTH); config[0] = GTP_REG_CONFIG_DATA >> 8; config[1] =GTP_REG_CONFIG_DATA & 0xff; I2C_Touch_Init(); //I2C初始化ret = GTP_I2C_Test(); //数据测试 if (ret < 0) { GTP_ERROR("I2C communication ERROR!"); return ret; } GTP_Read_Version(); //获取触摸IC的版本号和型号#if UPDATE_CONFIG //根据IC的型号指向不同的配置 if(touchIC == GT5688) { cfg_info =CTP_CFG_GT5688; //指向寄存器配置//#define CFG_GROUP_LEN(p_cfg_grp)(sizeof(p_cfg_grp) / sizeof(p_cfg_grp[0])) //计算数组元素的宏 cfg_info_len = CFG_GROUP_LEN(CTP_CFG_GT5688); //计算配置表的大小 } /* #define GTP_ADDR_LENGTH2 #define GTP_CONFIG_MIN_LENGTH 186 #define GTP_CONFIG_MAX_LENGTH 256 */ memset(&config[GTP_ADDR_LENGTH], 0, GTP_CONFIG_MAX_LENGTH); //memset函数是c语言库函数,会把内存空间清零 //在此处作用就是把指针指向的该数据地址的内存空间清零 memcpy(&config[GTP_ADDR_LENGTH], cfg_info, cfg_info_len); //memcpy函数是c语言库函数, //在上文中的作用是复制cfg_info的内容到config[2]后面的内容里去cfg_num = cfg_info_len; GTP_DEBUG("cfg_info_len = %d ",cfg_info_len); GTP_DEBUG("cfg_num = %d ",cfg_num); GTP_DEBUG_ARRAY(config,6); /* //根据液晶扫描方向而变化的XY像素宽度 //调用ILI9806G_GramScan函数设置方向时会自动更改 uint16_t LCD_X_LENGTH = ILI9806G_MORE_PIXEL; uint16_t LCD_Y_LENGTH = ILI9806G_LESS_PIXEL; */ /*根据LCD的扫描方向设置分辨率*/ config[GTP_ADDR_LENGTH+1] = LCD_X_LENGTH & 0xFF; //取低字节 config[GTP_ADDR_LENGTH+2] = LCD_X_LENGTH >> 8; //取高字节 config[GTP_ADDR_LENGTH+3] = LCD_Y_LENGTH & 0xFF; //取低字节 config[GTP_ADDR_LENGTH+4] = LCD_Y_LENGTH >> 8; //取高字节/* //液晶屏扫描模式,本变量主要用于方便选择触摸屏的计算参数 //参数可选值为0-7 //调用ILI9806G_GramScan函数设置方向时会自动更改 //LCD刚初始化完成时会使用本默认值 uint8_t LCD_SCAN_MODE =6; */ /*根据扫描模式设置X2Y交换*/ switch(LCD_SCAN_MODE) { //其实就是配置控制xy坐标交换的寄存器,让其坐标交换 //#define X2Y_LOC(1<<3) case 0:case 2:case 4: case 6: config[GTP_ADDR_LENGTH+6] &= ~(X2Y_LOC); break; case 1:case 3:case 5: case 7: config[GTP_ADDR_LENGTH+6] |= (X2Y_LOC); break; }//计算要写入checksum寄存器的值 check_sum = 0; for (i = GTP_ADDR_LENGTH; i < (cfg_num+GTP_ADDR_LENGTH -3); i += 2) { check_sum += (config[i] << 8) + config[i + 1]; }check_sum = 0 - check_sum; GTP_DEBUG("Config checksum: 0x%04X", check_sum); //更新checksum //下面是往倒数第一个、第二个分别赋值check_sum的高字节 //最后在倒数第一个写1,寄存器更新上述数据,至此config变量重新配置 config[(cfg_num+GTP_ADDR_LENGTH -3)] = (check_sum >> 8) & 0xFF; config[(cfg_num+GTP_ADDR_LENGTH -2)] = check_sum & 0xFF; config[(cfg_num+GTP_ADDR_LENGTH -1)] = 0x01; //写入配置信息 for (retry = 0; retry < 5; retry++) { ret = GTP_I2C_Write(GTP_ADDRESS, config , cfg_num + GTP_ADDR_LENGTH+2); if (ret > 0) { break; } } Delay(0xfffff); //延迟等待芯片更新//下面代码平时不使用,调试模式才时使用 //使用时if后的0改为1,使用调试模式 #if 0 //读出写入的数据,检查是否正常写入 //检验读出的数据与写入的是否相同 { uint16_t i; uint8_t buf[300]; buf[0] = config[0]; buf[1] =config[1]; //寄存器地址GTP_DEBUG_FUNC(); ret = GTP_I2C_Read(GTP_ADDRESS, buf, sizeof(buf)); GTP_DEBUG("read "); GTP_DEBUG_ARRAY(buf+2,cfg_num+2); GTP_DEBUG("write "); GTP_DEBUG_ARRAY(config,cfg_num); //不对比版本号 for(i=1; i

debug 的define
// Log define #define GTP_INFO(fmt,arg...)printf("<<-GTP-INFO->> "fmt"\n",##arg) #define GTP_ERROR(fmt,arg...)printf("<<-GTP-ERROR->> "fmt"\n",##arg) #define GTP_DEBUG(fmt,arg...)do{\ if(GTP_DEBUG_ON)\ printf("<<-GTP-DEBUG->> [%d]"fmt"\n",__LINE__, ##arg); \ }while(0)#define GTP_DEBUG_ARRAY(array, num)do{\ int32_t i; \ uint8_t* a = array; \ if(GTP_DEBUG_ARRAY_ON)\ {\ printf("<<-GTP-DEBUG-ARRAY->>\n"); \ for (i = 0; i < (num); i++)\ {\ printf("0x%02X,", (a)[i]); \ if ((i + 1 ) %10 == 0)\ {\ printf("\n"); \ }\ }\ printf("\n"); \ }\ }while(0)#define GTP_DEBUG_FUNC()do{\ if(GTP_DEBUG_FUNC_ON)\ printf("<<-GTP-FUNC->> Func:%s@Line:%d\n",__func__,__LINE__); \ }while(0)#define GTP_SWAP(x, y)do{\ typeof(x) z = x; \ x = y; \ y = z; \ }while (0)//

check_sum计算的校验码位置
电容触摸屏
文章图片

上面函数中的GTP_Read_Version()函数:
此函数就是初始化时读取设备的ID号版本号打印出来。
int32_t GTP_Read_Version(void) { int32_t ret = -1; uint8_t buf[8] = {GTP_REG_VERSION >> 8, GTP_REG_VERSION & 0xff}; //寄存器地址,定义第一字节第二字节为设备寄存器地址,读取设备id号GTP_DEBUG_FUNC(); ret = GTP_I2C_Read(GTP_ADDRESS, buf, sizeof(buf)); if (ret < 0) { GTP_ERROR("GTP read version failed"); return ret; } if (buf[2] == '5')//从两个字节的设备地址后的字节开始读取 { GTP_INFO("IC1 Version: %c%c%c%c_%02x%02x", buf[2], buf[3], buf[4], buf[5], buf[7], buf[6]); //打印IC的版本号lowbyte和highbyte//GT5688芯片 if(buf[2] == '5' && buf[3] == '6' && buf[4] == '8'&& buf[5] == '8') touchIC = GT5688; } else if (buf[5] == 0x00) { GTP_INFO("IC2 Version: %c%c%c_%02x%02x", buf[2], buf[3], buf[4], buf[7], buf[6]); //GT911芯片 if(buf[2] == '9' && buf[3] == '1' && buf[4] == '1') touchIC = GT911; } else { GTP_INFO("IC3 Version: %c%c%c%c_%02x%02x", buf[2], buf[3], buf[4], buf[5], buf[7], buf[6]); //GT9157芯片 if(buf[2] == '9' && buf[3] == '1' && buf[4] == '5' && buf[5] == '7') touchIC = GT9157; } return ret; }

GTP_Get_Info()函数
static int32_t GTP_Get_Info(void) { uint8_t opr_buf[10] = {0}; int32_t ret = 0; uint16_t abs_x_max = GTP_MAX_WIDTH; uint16_t abs_y_max = GTP_MAX_HEIGHT; uint8_t int_trigger_type = GTP_INT_TRIGGER; opr_buf[0] = (uint8_t)((GTP_REG_CONFIG_DATA+1) >> 8); opr_buf[1] = (uint8_t)((GTP_REG_CONFIG_DATA+1) & 0xFF); ret = GTP_I2C_Read(GTP_ADDRESS, opr_buf, 10); if (ret < 0) { return FAIL; }abs_x_max = (opr_buf[3] << 8) + opr_buf[2]; abs_y_max = (opr_buf[5] << 8) + opr_buf[4]; GTP_DEBUG("RES"); GTP_DEBUG_ARRAY(&opr_buf[0],10); opr_buf[0] = (uint8_t)((GTP_REG_CONFIG_DATA+6) >> 8); opr_buf[1] = (uint8_t)((GTP_REG_CONFIG_DATA+6) & 0xFF); ret = GTP_I2C_Read(GTP_ADDRESS, opr_buf, 3); if (ret < 0) { return FAIL; } int_trigger_type = opr_buf[2] & 0x03; GTP_INFO("X_MAX = %d, Y_MAX = %d, TRIGGER = 0x%02x", abs_x_max,abs_y_max,int_trigger_type); return SUCCESS; }

上文产生中断时,执行中断服务函数
void GTP_IRQHandler(void) { if(EXTI_GetITStatus(GTP_INT_EXTI_LINE) != RESET) //确保是否产生了EXTI Line中断 { LED2_TOGGLE; GTP_TouchProcess(); EXTI_ClearITPendingBit(GTP_INT_EXTI_LINE); //清除中断标志位 } }

其中执行 GTP_TouchProcess();
void GTP_TouchProcess(void) { GTP_DEBUG_FUNC(); //测试函数,不管 Goodix_TS_Work_Func(); //坐标获取函数,获取各个触点的坐标,寄存器如下}

不完全图:电容触摸屏
文章图片

Goodix_TS_Work_Func(); 函数体如下
#define GTP_READ_COOR_ADDR0x814E//就是上文所说的寄存器地址 static void Goodix_TS_Work_Func(void) { uint8_tend_cmd[3] = {GTP_READ_COOR_ADDR >> 8, GTP_READ_COOR_ADDR & 0xFF, 0}; uint8_tpoint_data[2 + 1 + 8 * GTP_MAX_TOUCH + 1]={GTP_READ_COOR_ADDR >> 8, GTP_READ_COOR_ADDR & 0xFF}; uint8_ttouch_num = 0; uint8_tfinger = 0; static uint16_t pre_touch = 0; static uint8_t pre_id[GTP_MAX_TOUCH] = {0}; uint8_t client_addr=GTP_ADDRESS; uint8_t* coor_data = https://www.it610.com/article/NULL; int32_t input_x = 0; int32_t input_y = 0; int32_t input_w = 0; uint8_t id = 0; int32_t i= 0; int32_t ret = -1; GTP_DEBUG_FUNC(); ret = GTP_I2C_Read(client_addr, point_data, 12); //10字节寄存器加2字节地址 if (ret < 0) { GTP_ERROR("I2C transfer error. errno:%d\n ", ret); return; }finger = point_data[GTP_ADDR_LENGTH]; //状态寄存器数据if (finger == 0x00)//没有数据,退出 { return; }if((finger & 0x80) == 0)//判断buffer status位 { goto exit_work_func; //坐标未就绪,数据无效 }touch_num = finger & 0x0f; //坐标点数 if (touch_num > GTP_MAX_TOUCH) { goto exit_work_func; //大于最大支持点数,错误退出 }if (touch_num > 1)//不止一个点 { uint8_t buf[8 * GTP_MAX_TOUCH] = {(GTP_READ_COOR_ADDR + 10) >> 8, (GTP_READ_COOR_ADDR + 10) & 0xff}; ret = GTP_I2C_Read(client_addr, buf, 2 + 8 * (touch_num - 1)); memcpy(&point_data[12], &buf[2], 8 * (touch_num - 1)); //复制其余点数的数据到point_data }if (pre_touch>touch_num)//pre_touch>touch_num,表示有的点释放了 { for (i = 0; i < pre_touch; i++)//一个点一个点处理 { uint8_t j; for(j=0; j= touch_num-1)//遍历当前所有id都找不到pre_id[i],表示已释放 { GTP_Touch_Up( pre_id[i]); } } } }if (touch_num) { for (i = 0; i < touch_num; i++)//一个点一个点处理 { coor_data = https://www.it610.com/article/&point_data[i * 8 + 3]; id = coor_data[0] & 0x0F; //track id pre_id[i] = id; input_x= coor_data[1] | (coor_data[2] << 8); //x坐标 input_y= coor_data[3] | (coor_data[4] << 8); //y坐标 input_w= coor_data[5] | (coor_data[6] << 8); //size{ /*根据扫描模式更正X/Y起始方向*/ switch(LCD_SCAN_MODE) { case 0:case 7: input_y= LCD_Y_LENGTH - input_y; break; case 2:case 3: input_x= LCD_X_LENGTH - input_x; input_y= LCD_Y_LENGTH - input_y; break; case 1:case 6: input_x= LCD_X_LENGTH - input_x; break; default: break; }GTP_Touch_Down( id, input_x, input_y, input_w); //数据处理 } } } else if (pre_touch)//touch_ num=0 且pre_touch!=0 { for(i=0; i

应用层
Palette_Init(uint8_t LCD_Mode)函数
void Palette_Init(uint8_t LCD_Mode) {uint8_t i; ILI9806G_GramScan ( LCD_Mode ); /* 整屏清为白色 */ LCD_SetBackColor(CL_WHITE); //设置背景为白色 ILI9806G_Clear(0,0,LCD_X_LENGTH,LCD_Y_LENGTH); //清屏 /* 初始化按钮 */ Touch_Button_Init(); /* 描绘按钮 */ for(i=0; itouch_flag == 0) { /*背景为功能键相应的颜色*/ LCD_SetColors(ptr->para,CL_WHITE); ILI9806G_DrawRectangle(ptr->start_x, ptr->start_y, ptr->end_x - ptr->start_x, ptr->end_y - ptr->start_y,1); } else/*按键按下*/ { /*白色背景*/ LCD_SetColors(CL_WHITE,CL_WHITE); ILI9806G_DrawRectangle(ptr->start_x, ptr->start_y, ptr->end_x - ptr->start_x, ptr->end_y - ptr->start_y,1); } /*按钮边框*/ LCD_SetColors(CL_BLUE4,CL_WHITE); ILI9806G_DrawRectangle(ptr->start_x, ptr->start_y, ptr->end_x - ptr->start_x, ptr->end_y - ptr->start_y,0); } /** * @brief在 ILI9806G 显示器上画一个矩形 * @paramusX_Start :在特定扫描方向下矩形的起始点X坐标 * @paramusY_Start :在特定扫描方向下矩形的起始点Y坐标 * @paramusWidth:矩形的宽度(单位:像素) * @paramusHeight:矩形的高度(单位:像素) * @paramucFilled :选择是否填充该矩形 *该参数为以下值之一: *@arg 0 :空心矩形 *@arg 1 :实心矩形 * @note 可使用LCD_SetBackColor、LCD_SetTextColor、LCD_SetColors函数设置颜色 * @retval 无 */ void ILI9806G_DrawRectangle ( uint16_t usX_Start, uint16_t usY_Start, uint16_t usWidth, uint16_t usHeight, uint8_t ucFilled ) { if ( ucFilled ) { ILI9806G_OpenWindow ( usX_Start, usY_Start, usWidth, usHeight ); ILI9806G_FillColor ( usWidth * usHeight ,CurrentTextColor); } else { ILI9806G_DrawLine ( usX_Start, usY_Start, usX_Start + usWidth - 1, usY_Start ); ILI9806G_DrawLine ( usX_Start, usY_Start + usHeight - 1, usX_Start + usWidth - 1, usY_Start + usHeight - 1 ); ILI9806G_DrawLine ( usX_Start, usY_Start, usX_Start, usY_Start + usHeight - 1 ); ILI9806G_DrawLine ( usX_Start + usWidth - 1, usY_Start, usX_Start + usWidth - 1, usY_Start + usHeight - 1 ); }}

    推荐阅读