简介:基于STM32F103的3.5寸ILI9486触摸屏HAL库驱动完整工程,适合嵌入式入门开发者、单片机爱好者以及需要快速点亮LCD并进行触摸交互的工程师。项目提供完整源码,可直接烧录验证,主要涵盖SPI接口配置、GPIO控制、LCD初始化与绘图命令、XPT2046触摸解析以及STM32CubeMX工程集成。压缩包共153个文件,包含51个.h头文件、21个.c源码文件、工程配置文件(.uvprojx/.ioc/.hex)及编译中间文件等,整体约4.77MB,结构清晰便于学习与二次移植。目前已有1702人学习下载。通过该工程可以理解HAL库的外设操作方式,跟踪从寄存器到底层驱动的封装过程,并掌握触摸屏坐标转换与显示刷新的实现思路;同时工程内附带的map、lst与axf文件也能辅助分析编译链接细节,适合对照研读。
1. 一块 3.5 寸 ILI9486 屏的 HAL 移植:为什么我说“拿到就能用”
做嵌入式硬件的人应该都有过这种经历:项目里用的还是 STM32F103 平台,屏幕模块先到货,商家扔过来一份标准外设库版本的例程,自己的工程却是 HAL 库,逐行翻译寄存器既费时又容易翻车。我手里这份资源就是当初为了解决这个问题整理的:STM32F103 + FSMC 并口 + 3.5 寸 ILI9486 + XPT2046 触摸,全部代码基于 HAL,烧进去就能点亮。它适合正在做单片机产品、需要出简单界面的开发者,尤其是对 F103 平台比较熟、但不想在 LCD 驱动上耗时间的人。你不用理解每个寄存器的物理含义,按顺序配好总线、抄对序列、校准触摸,屏幕就能跑起来。不过有几个前提条件不确认,代码大概率点不亮——所以先看工程结构和选型门槛。
2. 工程骨架与总线选型:先看懂这份 HAL 代码的搭建方式
2.1 资源里到底有哪些文件
拿到压缩包先别急着烧,心里要有数。我一般会把工程目录翻一遍,确认驱动文件组织方式,这样后面改引脚、调时序时能找到地方。下面的清单是这套资源里最常见的组织方式,你拿到手后对照着看:
| 文件/目录 | 作用 | 需要关注的点 |
|---|---|---|
Core/Src/main.c | 主循环、外设初始化入口 | 确认 FSMC 初始化是否在MX_FSMC_Init()里被调用 |
Core/Src/fsmc.c | FSMC 总线时序配置 | 地址建立时间、数据建立时间是否匹配 ILI9486 |
Bsp/lcd_bsp.c/h | ILI9486 初始化序列、画点、填充、区域设置 | 初始化序列里的伽马寄存器是否被注释掉 |
Bsp/touch_bsp.c/h | XPT2046 读取、坐标转换、滤波 | 看它是用 SPI1 还是 SPI2,对应引脚是否和你的板子一致 |
Bsp/delay.c/h | 毫秒/微秒延时 | 确认是 DWT 还是 SysTick,DWT 在调试停驻时更准 |
Hardware/LCD_FSMC.h | LCD 命令/数据地址宏定义 | RS 接在哪根地址线上,宏定义里的偏移是否匹配 |
这里有个容易被忽略的细节:很多商家给的例程里lcd_bsp.c和main.c耦合得很深,直接在主函数里初始化寄存器。这套 HAL 代码把 LCD 相关操作封装在Bsp层,主函数里只调LCD_Init()和Touch_Init()。如果发现你的版本不是这种结构,建议先把LCD_Init()涉及的 GPIO、FSMC、背光、复位四件事理清,再往下走。
2.2 为什么总线选择 FSMC 而不是 SPI
ILI9486 是一颗 8080 接口的 TFT 驱动芯片,虽然它也支持 SPI 模式,但 320×480 的分辨率用 SPI 刷整屏是灾难。算一下:一个像素 RGB565 是 2 字节,一整屏 320×480×2 = 307200 字节,SPI 跑 20MHz,理论也要 1.5 秒以上,实际加上命令开销逼近 2 秒。这个帧率连做菜单切换都卡,更别说动画。
F103 的 FSMC 的 Bank1 可以映射 NOR Flash 时序,而 ILI9486 的 8080 并口写时序和 NOR 写时序高度吻合:都有片选、写使能、地址锁存的概念。所以代码里最常见的做法是,把 ILI9486 当成一块挂在 FSMC 上的“伪 SRAM”,CPU 往某个地址写一个 16 位数据,FSMC 自动帮你产生片选拉低、写脉冲、数据建立这套时序,不需要手动去翻转引脚。这一步省掉的是大量的GPIO_WriteBit循环,刷新效率完全不在一个量级。
那 RS(数据/命令选择)怎么处理?一般接 FSMC 的地址线,比如 A6。当 CPU 访问的地址里 A6 对应位是 0,FSMC 会把 A6 拉低,屏认为是命令周期;访问的地址里该位是 1,FSMC 拉高 A6,屏认为是数据周期。这就是后面会看到的地址宏定义 0x60000000 和 0x60000080 的来历。理解了这一层,网上的代码你就不是“拿来主义”而是“看得懂地抄”。
2.3 引脚分配与时钟输入
FSMC 是一组固定功能的引脚,不像 GPIO 那样随意映射。F103 上如果你用 Bank1 NE1 片选,数据线用的是 PD0-PD15,控制线用的是 PE 口,RS(DC)可以接任意一根地址线,一般取 A6 或 A16。触摸芯片 XPT2046 走 SPI,常见接法是 SPI1 或者 SPI2,CS 用普通 GPIO 控制。背光引脚没有固定要求,我一般选 PA8,因为可以顺便用定时器输出 PWM 调亮度。
| 功能 | 引脚 | 说明 |
|---|---|---|
| 片选 | FSMC_NE1 (PD7) | 对应地址 0x60000000 |
| 命令/数据选择 | FSMC_A6 | 地址偏移 0x80 |
| 数据线 | PD0-PD15 | 8080 并口 16 位 |
| 读/写控制 | FSMC_NOE / FSMC_NWE | FSMC 自动控制 |
| 复位 | 任意 GPIO,比如 PB0 | 低电平复位,至少拉低 10ms |
| 背光 | 任意 GPIO 或 PWM 引脚,比如 PA8 | 默认高电平点亮 |
| 触摸 CS | 任意 GPIO,比如 PB12 | 软件控制片选 |
| 触摸 IRQ | 任意 GPIO | 可选,轮询时可不接 |
时钟方面,F103 系统时钟 72MHz,AHB 不分频时 FSMC 时钟也是 72MHz。FSMC 的时序参数单位是 HCLK 周期,后面配置时要注意:一个 HCLK 约 13.9ns,如果你填的周期数太大,屏幕初始化会奇慢,但太小可能触发建立时间不足,颜色出现噪点。具体参数下一章给。
2.4 拿到代码先做三件事
第一件:确认你的 MCU 引脚数。代码是以 FSMC 并口方式驱动的,要求芯片必须有 FSMC 外设。F103C8T6 这种 48 脚的芯片没有 FSMC,只有 100 脚的 F103VET6、144 脚的 F103ZET6 才有。如果你的板子是 C8T6,这套代码里的 FSMC 部分对你没用,要么换芯片,要么改用 SPI 版本的 ILI9486 驱动。这是最常见的一个“烧进去点不亮”的原因。
第二件:确认屏幕模组的排线是否引出了完整 16 位并口。有些 3.5 寸屏模块虽然核心是 ILI9486,但商家只引出 SPI 接口,这种模组上没有 PD0-PD15 对应的焊盘,你用 FSMC 代码没法驱动它。看排线数量:FSMC 16 位并口至少需要 18 根以上信号线,如果排线只有 8 根,肯定是 SPI 版本。
第三件:确认触摸 IC 是 XPT2046 还是其他型号。3.5 寸电阻屏最常见的触摸控制芯片是 XPT2046,走 SPI 接口,四线制:CLK、CS、DIN、DOUT。但也有部分电容屏模组用的是 I2C 接口的触摸芯片,驱动方式完全不同。看一眼触摸排线附近的芯片丝印,如果是 2046,继续用这套代码;如果是别的型号,触摸部分需要换驱动。
提示:不要跳过芯片丝印确认这一步。很多卖家页面写着“3.5寸触摸屏”,但同一块屏可能同时有电阻和电容版本,代码里的触摸初始化完全不同。
3. 在 HAL 工程里点亮屏:CubeMX 配置与初始化实操
3.1 在 CubeMX 里配置 FSMC
如果你习惯用 CubeMX 生成初始化代码,FSMC 的配置是关键。打开 CubeMX,选择你的芯片型号,先在 Pinout 视图里把 PD0-PD15、PE7-PE12 等 FSMC 引脚设置为复用功能,然后找到 FSMC 外设,启用 Bank1 NOR/PSRAM,选择片选 NE1。
| 参数项 | 取值 | 说明 |
|---|---|---|
| Memory type | NOR | 8080 接口时序兼容 NOR |
| Data bus width | 16 bit | ILI9486 并口标准是 16 位 |
| Write operation | Enabled | 必须开启写操作 |
| Address setup time | 1 HCLK | 地址建立约 14ns,ILI9486 可接受 |
| Address hold time | 1 HCLK | 地址保持时间 |
| Data setup time | 3 HCLK | 数据建立约 42ns,留足余量 |
| Bus turnaround | 1 HCLK | 总线周转时间 |
| Read/write timing | 共用一套即可 | 读时序在调试时可放宽 |
为什么数据建立时间填 3 个 HCLK?ILI9486 的数据手册里写时序要求数据在写信号有效前建立一段时间,典型值大约 30-100ns。72MHz 下 3 个 HCLK 是 41.7ns,加上引脚翻转延迟,刚好覆盖绝大多数模组的实际需求。模板尽量别低于 2 个 HCLK,有些屏对时序很敏感,太快会出现颜色错位或者花屏。
3.2 GPIO 初始化与复用配置
CubeMX 生成代码后,FSMC 引脚是由它自动帮你初始化的。但如果你和我一样喜欢手写 BSP,至少要保证以下几点:GPIO 模式设为复用推挽输出,速度设为 HIGH,输出数据寄存器初始值无所谓,因为 FSMC 外设会接管它们。下面这段是手动初始化 FSMC 数据线的典型写法:
void LCD_GPIO_Init(void) { __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOD_CLK_ENABLE(); __HAL_RCC_GPIOE_CLK_ENABLE(); GPIO_InitTypeDef GPIO_Init = {0}; /* 数据线 PD0-PD15,复用推挽 */ GPIO_Init.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | 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; GPIO_Init.Mode = GPIO_MODE_AF_PP; GPIO_Init.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOD, &GPIO_Init); }这段代码的逻辑是:先把用到的 GPIO 端口时钟打开,然后把 PD0-PD15 这 16 根数据线配成复用推挽模式。这里强调两点:一是复用推挽必须,不能是普通推挽,否则 FSMC 外设对引脚的控制权没有生效;二是速度等级必须 HIGH,FSMC 在 72MHz 下翻转速度不够时,时序边缘会变缓,屏幕初始化偶尔失败,但不至于完全点不亮。
控制线方面,片选 NE1、读写 NOE/NWE 属于 FSMC 复用引脚,初始化方式类似。RS 如果接的是 A6,则这根地址线也要配成复用推挽。记住:凡是接到屏幕的 FSMC 信号线,都必须配置为GPIO_MODE_AF_PP,而复位、背光这些由你手动控制的引脚,才是普通推挽输出。
3.3 复位与背光上电时序
复位时序是新手容易忽视的上电环节。很多模组要求电源稳定后再给复位信号,复位低电平保持时间至少要 10ms,释放后要等 120ms 以上再发初始化命令。如果复位时序太短,屏内部的上电复位电路可能没有完全触发,这时收发命令都不会报错,但屏幕就是白屏或者花屏。我一般会做得更保守一些:
void LCD_Reset(void) { HAL_GPIO_WritePin(RESET_PORT, RESET_PIN, GPIO_PIN_RESET); HAL_Delay(50); /* 低电平保持 50ms,避免电源纹波导致复位不彻底 */ HAL_GPIO_WritePin(RESET_PORT, RESET_PIN, GPIO_PIN_SET); HAL_Delay(150); /* 释放后等待内部电路稳定 */ }一个容易忽略的问题:背光最好在复位拉高之后再打开。如果上电瞬间背光先亮,屏幕会闪一下白,之后再初始化就是黑屏变亮,观感上没问题。但如果背光一直不亮,先查背光引脚的电平状态,再看是直接接了 3.3V 还是通过三极管驱动。很多模块的背光是共阳接法,需要 MCU 引脚拉低才能点亮,这一点和代码里默认高电平点亮的逻辑相反,需要手动取反。
3.4 验证 FSMC 总线是否走通:读 ID
初始化序列发送之前,先做一件事:读回屏幕的 ID。ILI9486 读取 ID 的寄存器地址是 0x04,正常读回结果是 0x9486。这个验证步骤能帮你区分“总线没配好”和“初始化序列不对”两种故障,省掉后面的无效调试时间。
uint16_t LCD_ReadID(void) { uint16_t id = 0; LCD_WriteCmd(0x04); /* 发读 ID 命令 */ id = LCD_ReadData(); /* 读回两个字节 */ return id; }如果读回来的值是 0xFFFF,说明片选没有拉低,或者 RS 信号配置反了。如果读回来是 0x0000,大概率是数据线里某几根虚焊或者 GPIO 模式没有配成复用在推挽。如果读到 0x9486,恭喜你,FSMC 总线完全正常,接下来初始化序列才有意义。
这里要注意一个接线顺序问题:ILI9486 的 ID 寄存器在并口模式下,命令周期由 RS 低电平决定。如果 RS 接到 FSMC_A6,而你的地址宏定义里 RS 偏移写错了,那么发出去命令会变成数据,屏幕自然不响应。如何核对?用一根杜邦线量一下 A6 引脚电平,在发送命令时应该看到低脉冲。换一个角度说,地址宏定义是整个代码里最容易出错且最难排查的地方,命令和数据地址的差值是(1 << (RS引脚编号 + 1)),因为 FSMC 内部 16 位宽寻址时地址线会错位一位,所以 A6 对应 CPU 地址 bit7,即偏移 0x80。
4. ILI9486 初始化序列与显示驱动:把寄存器序列变成屏幕上可见的像素
4.1 初始化序列为什么不能换个屏就照抄
ILI9486 的初始化序列,网上能找到很多版本,但如果你把 A 屏的序列抄到 B 屏上,大概率会出现颜色偏暗、偏红、或者显示范围偏移。原因在于,同是 ILI9486,不同批次、不同模组厂出厂时对伽马寄存器、VCOM 电压的微调值不一样。这些值写在 0xE0、0xE1 伽马控制寄存器里,最明显的影响是白色是纯白还是发黄。所以拿到代码后,第一件事不是跑起来,而是把初始化序列中 0xC0、0xC1、0xC5、0xE0、0xE1 这几组值和你的屏幕模组说明书对应一下。如果商家没给说明书,就保持代码原来的值先点亮,再根据显示效果调。
这部分代码的核心思路是:向 ILI9486 的指定寄存器写入配置数据。常用寄存器地址 0xC0 是电源控制,0x36 是扫描方向,0x3A 是像素格式,0xB1 是帧率控制。下面的初始化函数只列了关键部分,完整序列在资源的lcd_bsp.c里。
void LCD_Init(void) { LCD_Reset(); LCD_WriteCmd(0xC0); /* 电源控制 1 */ LCD_WriteData(0x19); /* 调节 GVDD 电压 */ LCD_WriteData(0x1A); LCD_WriteCmd(0xC5); /* VCOM 电压 */ LCD_WriteData(0x36); LCD_WriteCmd(0x36); /* 扫描方向控制 */ LCD_WriteData(0x48); /* 根据需要调整 BGR 位和翻转位 */ LCD_WriteCmd(0x3A); /* 像素格式 */ LCD_WriteData(0x55); /* 16 位色深 RGB565 */ LCD_WriteCmd(0xB1); /* 帧率控制 */ LCD_WriteData(0x10); LCD_WriteCmd(0x11); /* 退出休眠 */ HAL_Delay(120); LCD_WriteCmd(0x29); /* 开显示 */ }这段初始化顺序是有讲究的:先设电压相关寄存器,再设扫描方向和像素格式,最后退出休眠并开显示。很多新手把 0x11 和 0x29 放在最前面,屏幕虽然也会亮,但可能出现初始化过程中屏幕闪白。0x11 退出休眠后必须等 120ms,让 DC-DC 转换器稳定下来再继续发 0x29,否则内部电源可能没建立完,导致开显示后花屏。0x36 的值需要特别关注:如果你发现文字是镜像的,通常改这里而不是改坐标代码,0x36 里的第 5、6、7 位控制行/列方向,把 0x48 改成 0x28 会让整体上下翻转。
4.2 命令/数据写入的地址宏
前面说过了,FSMC 模式下命令和数据是通过不同地址区访问来区分的。下面是代码里最核心的宏定义,理解这三行,你就理解这整份驱动的一半逻辑:
#define LCD_BASE 0x60000000UL /* FSMC Bank1 NE1 基地址 */ #define LCD_RS_PIN 6 /* RS 接到 FSMC_A6 */ #define LCD_CMD (*(volatile uint16_t *)(LCD_BASE)) #define LCD_DATA (*(volatile uint16_t *)(LCD_BASE | (1UL << (LCD_RS_PIN + 1))))注意看最后一个宏:(1UL << (LCD_RS_PIN + 1))。A6 是外部地址线,FSMC 内部以 16 位为单位寻址,地址线会右移一位,所以想拉高外部 A6,CPU 地址的第 7 位要写 1。这也是为什么很多参考代码上写的是0x00000080而不是0x00000040。如果 RS 接的是 A16,就把LCD_RS_PIN改为 16,宏自动算出偏移1 << 17,即 0x20000。这个设计让你换板子时只需要改一个宏,不用去翻整个工程的地址运算。
volatile关键字必须保留。FSMC 映射的地址是内存映射 I/O,编译器在开优化时可能会把连续写同一个地址的操作合并或缓存,加volatile是告诉编译器每次访问都真实发出总线周期,否则刷屏可能出现奇怪的残影和个别像素丢失。
4.3 区域设置、画点与整屏填充
ILI9486 的显存操作方式是:先告诉它操作的矩形区域,再连续写入像素数据,数据会自动填充到窗口里,写满后指针回到窗口左上角。这个机制关键在处理半透明叠加和局部刷新时非常有用。下面这段代码是底层的窗口设置:
void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); /* 列地址设置:对水平方向 */ LCD_WriteData(x0 >> 8); LCD_WriteData(x0 & 0xFF); LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteCmd(0x2B); /* 行地址设置:对垂直方向 */ LCD_WriteData(y0 >> 8); LCD_WriteData(y0 & 0xFF); LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteCmd(0x2C); /* 存储器写命令,后续数据都进显存 */ }需要解释一下为什么 0x2A 发送的是两个四段数据:0x2A 是列地址,范围允许最大 4095,所以要拆成高 8 位和低 8 位各发一次。坐标 x0、x1 必须满足 x0 <= x1,否则窗口设置无效,后续写像素会落到显存之外被丢弃。如果你刷图出现屏幕边缘有一条固定颜色的竖带,多半就是窗口边界超了屏幕范围,比如 x1 传成 480 而实际最大是 479。
基于窗口设置,画一个点的函数就非常轻量:
static void LCD_DrawPixel(uint16_t x, uint16_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); /* 窗口只框住一个像素 */ LCD_WriteData(color); /* 写一个 RGB565 像素 */ }这里有个性能细节:画点函数不要在主循环里一个一个调用绘制界面。每调用一次就要发送 6 条命令,颜色数据只占 2 字节,效率很低。正确做法是直接操作窗口:比如画一个按钮,先算出按钮的矩形区域,一次性设置窗口,然后把按钮背景色用循环连续写入。整屏填充更快的方式是设置全屏窗口后连续写 307200 字节,这一步建议配合 FIFO 或者 DMA 的突发传输,下一章专门讲。另一个常见误区是以为 ILI9486 有显存回读功能可以用来做局部擦除,实际上它的读显存速度很慢,读一帧回来再修改远比重画全屏耗资源,界面设计时尽量不要依赖读回。
4.4 颜色格式的坑:RGB565 与 RGB666
ILI9486 本身是 18 位 RGB666 驱动,每种颜色 6 位,从硬件上接 18 根数据线。但 F103 的 FSMC 是 16 位总线,一次最多传 16 位,所以代码里像素格式必须设置成0x55,即 RGB565。这时发送一个 16 位像素,屏控制芯片内部会把 5 位红色、6 位绿色、5 位蓝色转换成 6 位每通道,转换时最低位一般直接丢弃或者复制。这就是为什么有些屏幕显示渐变色时会出现轻微色块感,不是屏幕坏了,是 16 位升 18 位时的量化误差,肉眼几乎分辨不出来。
如果代码里像素格式配置成0x66(RGB666),F103 又没法传 18 位,那么驱动里就要把两个像素拼成 3 字节发送,流程复杂且慢。所以所有 F103 平台上的 ILI9486 工程都统一用 RGB565。要验证设置正确,画一个纯红色矩形:RGB565 里红色是0xF800,屏幕显示应该是正红,如果显示成了红中带绿,说明 0x3A 像素格式寄存器没有写成 0x55,驱动数据流和屏内部解析错位了。
另一个偏色原因常常被忽略:初始化序列中 0xB6 显示功能控制里有一个 BGR 位。默认 0 表示 RGB 顺序,有些模组上电默认是 BGR。如果屏幕显示的颜色中红和蓝互换,不需要改像素格式,改 0x36 扫描方向寄存器里的 BGR 位即可。颜色问题记住一条调试路径:先查 0x3A,再查 0x36 的 bit3,最后才怀疑数据线接错。
5. 触摸调试与坐标映射:五个最容易翻车的细节
触摸部分永远是 LCD 驱动里花时间最多的地方,因为显示是单向的,错了你能看到;触摸是双向的,要么没反应,要么乱跳,问题定位路径长。这一章集中写我在调试过程中遇到的高频问题,全部按“现象、原因、解决”的顺序说明。
5.1 触摸芯片型号没确认:SPI 驱动写给了 I2C 芯片
现象:代码下载后屏幕点亮,但触摸完全没反应,手指按上去 ADC 值不变。
原因:不同版本的 3.5 寸模组,触摸芯片不一样。电阻屏方案常用 XPT2046,走 SPI,4 根信号线。电容屏方案常用 I2C 接口的触摸控制器,地址、寄存器协议完全不一样。代码里的Touch_Init()初始化的是 SPI 接口,对 I2C 芯片发 SPI 命令,芯片根本不会理你。
解决:先看模组排线和芯片丝印。XPT2046 的封装丝印一般是 2046,周围有 4 个电阻和 1 个小电容。如果丝印上是 FT 开头的型号,确认是电容触摸,需要把touch_bsp.c整个替换成对应芯片的 I2C 驱动。查丝印时注意别只看 PCB 丝印,要拿放大镜看芯片表面字样。
5.2 SPI 模式不匹配:读到的 ADC 值恒为 0 或全 F
现象:触摸按下时,读出的原始 ADC 值永远是0x0FFF,或者一直是0x0000,随手指位置变化没有任何反应。
原因:XPT2046 支持 SPI Mode 0 和 Mode 1,区别在于 CLK 空闲电平是低还是高、数据在上升沿还是下降沿采样。如果 MCU 侧的 SPI 配置和芯片期望不一致,读出来就是固定值。另外 F103 的 SPI 外设在接收时需要手动触发,很多代码在读取 12 位 ADC 结果时,忽略了先发送一个伪字节来产生时钟。
解决:先在Touch_Init()里把 SPI 参数改为 Mode 0(CPOL=0,CPHA=1)试一次,不行再换 Mode 1。XPT2046 支持两种模式,但不同批次对 CPHA 的兼容性有差异。如果你用的是软件模拟 SPI,检查读函数里有没有“发送 0x00 读回数据”的时钟驱动逻辑,时序不对时还要检查触发电压,触摸供电低于 3.0V 时 ADC 转换结果会严重偏低。
5.3 坐标镜像:屏幕显示方向和触摸方向打架
现象:点屏幕左上角,光标出现在右下角;或者水平方向正常,垂直方向完全反了。
原因:屏幕显示坐标由初始化序列里的 0x36 扫描方向控制,触摸坐标由模组安装方向和 ADC 轴定义决定。两者没有必然的坐标系一致性,横屏使用、竖屏安装时最容易出现这种问题。
解决:在坐标映射函数里做轴翻转即可,不需要改硬件。假设屏幕分辨率是 320×480,触摸 ADC 最大值是 TOUCH_MAX_X 和 TOUCH_MAX_Y,一般做法是:
uint16_t Touch_MapX(uint16_t raw_x) { /* 如果左右镜像,用最大值减去原始值 */ return (uint16_t)((uint32_t)(TOUCH_MAX_X - raw_x) * 320 / TOUCH_MAX_X); } uint16_t Touch_MapY(uint16_t raw_y) { /* 如果上下正常,直接比例缩放 */ return (uint16_t)((uint32_t)raw_y * 480 / TOUCH_MAX_Y); }这里参数说明:TOUCH_MAX_X不是屏幕分辨率,是触摸 ADC 在有效区域内读到的最大原始值。我一般会写一个调试函数,把原始 ADC 值直接打印到串口,手动点击屏幕四个角,记录坐标范围,再代入公式。镜像判断快方法:点左上角,串口打印 raw_x 和 raw_y,如果 raw_x 接近 TOUCH_MAX_X,说明 x 轴镜像了。
5.4 触摸抖动导致按钮误触
现象:单次点击按钮,偶尔触发两次或者有时按下不触发,坐标连续变化时指针乱晃。
原因:电阻触摸屏的本质是压力感应,ADC 采样值本身带噪声,且手指接触面积变化会导致中心点偏移。不做滤波直接映射坐标,就会出现点击不稳定。
解决:对连续采样值做滑动平均滤波。下面这段代码实现一个简化版:
#define TOUCH_FILTER_SIZE 5 static uint16_t touch_buf[TOUCH_FILTER_SIZE]; static uint8_t touch_index; uint16_t Touch_Filter(uint16_t new_value) { uint32_t sum = 0; uint8_t i; touch_buf[touch_index] = new_value; touch_index = (touch_index + 1) % TOUCH_FILTER_SIZE; for (i = 0; i < TOUCH_FILTER_SIZE; i++) { sum += touch_buf[i]; } return (uint16_t)(sum / TOUCH_FILTER_SIZE); }这段代码的逻辑是把最近 5 次采样值存进循环缓冲区,每次取平均作为有效值。滤波深度选 5 是经验值:太小滤不掉单点噪声,太大则手指快速滑动时坐标更新滞后,系统感觉“拖影”。注意滤波对象是 ADC 原始值而不是映射后的坐标,因为 ADC 噪声在映射前后的幅值差异会导致非线性放大。
5.5 坐标校准:比例缩放之后偏差还是大
现象:屏幕中心和边缘的点击精度不一致,中间准、四角偏,或者点击位置和显示位置整体偏移一段距离。
原因:触摸 ADC 的零点并不一定正好对应屏幕坐标系的原点。屏幕模组贴合时存在装配公差,XPT2046 的 ADC 参考电压也可能略有偏差,简单的比例缩放只有一个比例因子,没有包含偏移量。真正精度要求高的场景,需要做两点或四点校准。
解决:在工程初始化时增加一个Touch_Calibrate()函数,屏幕四个角依次显示一个十字光标,让用户点击,记录该点的 ADC 值和屏幕坐标,然后用仿射变换矩阵求映射参数。三点校准够用,四点校准更稳。这套资源里触摸部分默认是线性映射,如果你做的是带按钮的正式产品,建议在首次开机时强制校准一次,把映射参数存入 Flash。
校准后有一个常见的排错方向:如果你的界面程序里同时做了触摸坐标变换和显示坐标变换,注意单位要统一。显示坐标是像素,触摸坐标是 ADC 原始值,计算映射公式时分子分母不能混用整数溢出。F103 是 32 位整数运算,乘法时强制转成uint32_t,避免中间结果溢出成负数导致坐标突变。
6. 把刷新率提上去:用 32 位访问和窗口裁剪优化刷屏体验
驱动能点亮、触摸能响应,这只是第一步。真正做界面时你会发现,用最朴素的方式刷一帧全屏,大概要 300ms 以上,菜单切换像幻灯片。这一章讲两个不伤筋动骨的优化技巧,不引入 DMA 也不换控制器,就靠改代码结构把帧率提上去。
第一个优化是让编译器用 32 位指令连续写两个像素。FSMC 数据线是 16 位,但 CPU 可以发起 32 位写访问,FSMC 会自动把一次 32 位写拆成两次 16 位外部写周期,连续写到同一个地址。这样循环体里一次处理两个像素,循环次数减半,配合编译器优化能省掉不少取指和比较指令。
static void LCD_WritePixels32(uint16_t *buf, uint32_t len) { uint32_t *p = (uint32_t *)buf; uint32_t i; for (i = 0; i < len / 2; i++) { /* 小端模式下,低地址像素放低位,高地址像素放高位 */ *(volatile uint32_t *)(LCD_DATA_ADDR) = p[i]; } }参数说明:buf内存放的是 RGB565 像素数组,len是像素个数。函数把缓冲区强转为 32 位指针,每次取 4 字节,内部包含两个像素,一次性写入 FSMC 地址。注意len必须是偶数,填充区域宽度是奇数时,最后剩一个像素单独写。这个技巧能稳定带来 1.3 到 1.6 倍的提升,具体取决于 FSMC 的等待周期数和编译器优化级别。
第二个技巧是窗口裁剪。刷图时不要总觉得“刷整屏才算刷新”,页面元素变化往往只占屏幕的一部分。比如一个设置界面,用户只改动数值区域,背景根本没有变化。这时只对数值区域的矩形调用LCD_SetWindow,再填充新的内容,需要传输的像素只有原来的几十分之一。具体做法是把界面按控件划分成独立刷新区域,每个控件的绘制函数只负责自己的矩形窗口,不做全屏重绘。
如果后续你打算移植轻量级图形库,会发现这些图形库内部也是按控件区域提交刷新任务,底层调用的正是类似的窗口设置接口。所以把屏幕驱动层和界面上层分开写,驱动层用LCD_DrawPixel、LCD_SetWindow、LCD_FillRect三个接口覆盖所有基础绘制需求,上层不管屏幕具体时序细节,界面开发效率会高很多。
从那以后,我每次拿到一块新屏幕,第一件事不是急着接线、烧代码,而是先看芯片丝印、确认总线类型、核对引脚,然后强制自己先跑通读 ID 这一关再往下做。这一步能过滤掉八成以上的低级问题,也让后面的触摸校准和时序调整有了明确的起点。希望你这次移植也能少踩几个坑,一次点亮。
本文还有配套的精品资源,点击获取