简介:这套基于STM32F103的3.5英寸ILI9486触摸屏HAL库工程,面向嵌入式和单片机开发者,解决屏幕驱动与触摸交互的快速落地问题。工程使用STM32CubeMX生成底层初始化,结合中景园ILI9486驱动代码,通过SPI接口完成显示和触摸读取,适合需要彩屏界面又希望降低底层开发门槛的中级开发者。压缩包共153个文件,以51个.h、21个.c源文件为主体,同时包含.o、.d、.hex、.axf、.map及uvprojx/uvoptx等工程与编译产物,整体仅4.77MB,体积紧凑但内容完整。项目已围绕HAL库封装好LCD控制和触摸读取逻辑,附有.ioc配置,可直接打开、重新生成或直接烧录hex验证。目前已有1689人学习下载,对理解STM32外设交互、HAL库SPI/GPIO编程和简单人机界面设计都有明显参考价值。 前阵子手头项目需要快速验证一块 3.5 寸 SPI 触摸屏,不想再翻标准库老代码,就直接用 STM32F103C8T6 最小系统 + HAL 库从头整理了一套 ILI9486 驱动。折腾完发现网上能找到的资料绝大多数还是标准库的写法,HAL 库版本要么残缺、要么只是个大杂烩,新手抄完直接编译都过不了。所以我把这套完整能跑的方案整理出来,接线、CubeMX 配置、LCM 初始化、触摸读取都放在下面,烧录即可用,适合手里有 STM32F103 最小系统和 3.5 寸 ILI9486 触摸屏的嵌入式爱好者直接抄作业。
1. 项目思路与整体设计
1.1 这一块“3.5寸屏”用到什么芯片
很多新手拿到 3.5 寸屏幕,第一反应是“这就是一块屏幕,一个驱动芯片搞定”。实际上这类 SPI 触摸屏模块里面至少有两个独立芯片:
- ILI9486:负责显示面板,分辨率 320x480,支持 RGB 接口/SPI 接口,我们通过 SPI 四线方式操作它。
- XPT2046:负责触摸采样,是一个 12 位 ADC 型四线电阻触摸控制器,和显示驱动完全独立,只是做在同一个 PCB 上。
这两个芯片挂在同一个 SPI 总线上,靠各自的片选脚(CS)区分。LCD_CS 拉低时操作屏幕,TP_CS 拉低时读触摸,互不干扰。搞懂这一层,后续接线和代码就不会乱。
另外注意,模块供电一般标 3.3V 或 5V,但 SPI 信号电平千万要接 3.3V。STM32F103 的 GPIO 是 5V 容忍(大多数引脚),可这不代表你可以无脑灌 5V 逻辑电平到屏幕的 SCLK/MOSI,尤其是长时间运行,发热和花屏的隐患很大。我这次直接全部用 3.3V。
1.2 引脚分配:能直接抄的接线表
我手头是最常见的 STM32F103C8T6 蓝色 pill 板,SPI 用 SPI1,硬件引脚固定是 PA5/PA6/PA7,其余控制脚全是普通 GPIO。完整接线如下:
| 屏幕/触摸引脚 | STM32F103C8T6 | 说明 |
|---|---|---|
| VCC | 3.3V | 供电,不要用 5V 给逻辑脚 |
| GND | GND | 共地 |
| LCD_SCLK | PA5 | SPI1_SCK,时钟 |
| LCD_MOSI | PA7 | SPI1_MOSI,主发从收 |
| LCD_MISO | PA6 | SPI1_MISO,触摸 XPT2046 数据返回 |
| LCD_CS | PA4 | 显示片选,软件控制 |
| LCD_DC/RS | PA2 | 命令/数据选择 |
| LCD_RST | PA1 | 复位,低有效 |
| LCD_BL | PA0 | 背光控制,GPIO 拉高点亮 |
| TP_CS | PA3 | 触摸芯片片选 |
| TP_IRQ | PB0 | 触摸中断信号,低电平表示有按下 |
如果你的模块上触摸引脚名字写的是 T_CLK、T_DIN、T_DOUT,不要慌,它们就对应 SCLK、MOSI、MISO。T_CS 接 PA3,T_IRQ 接 PB0,逻辑完全一样。
1.3 CubeMX 配置要点
工程我是在 STM32CubeMX 里生成的,工具链用 STM32CubeIDE 或者 Keil 都能编译,关键配置就几处:
- RCC:选择 HSE 外部晶振,我板子上是 8MHz 晶振,时钟树配置 PLL 到 72MHz。
- SYS:Debug 选 Serial Wire,否则烧录一次后 SWD 口容易被占用。
- SPI1:配置为 Full-Duplex Master,波特率预分频选 4。因为 APB2 总线时钟是 72MHz,72/4=18MHz,这是 F103 的 SPI 极限。
- SPI1 参数:CPOL=Low,CPHA=1Edge(模式 0),8 位数据,MSB First,关闭硬件 NSS。
- GPIO 输出脚 PA0-PA4 配成 Push-Pull Output,初始电平建议:CS 脚高电平、RST 脚高电平、BL 脚高电平。
- PB0 配成 GPIO_Input,内部上拉。这样不按下时读到高电平,按下时被拉低。
这里解释一下为什么不用硬件 NSS:STM32 的硬件 NSS 自动管理经常会和软件操作打架,控制不好就会出现偶发花屏。LCD 这类设备我们对时序要求是“发完当前数据再拉高片选”,软件 GPIO 最直接可控。
2. ILI9486 显示驱动:从初始化到画点
2.1 命令/数据切换和 SPI 总线操作
ILI9486 通过 DC(也叫 RS)引脚区分当前发的是命令还是数据。DC 为低时写入的是命令字节,DC 为高时写入的是数据。理论上写命令和写数据都要先拉低 CS,写完再拉高,但有一个特殊情况:当你在发一长串像素数据时,CS 要一直保持低,不能每发一个字节就抖一下,否则屏幕的“内存写模式”会被打断。
我封装的最底层发送函数如下:
void LCD_SendBytes(uint8_t *data, uint16_t len) { HAL_SPI_Transmit(&hspi1, data, len, HAL_MAX_DELAY); } void LCD_WriteCmd(uint8_t cmd) { LCD_CS_0(); LCD_DC_0(); LCD_SendBytes(&cmd, 1); LCD_CS_1(); } void LCD_WriteData(uint8_t data) { LCD_CS_0(); LCD_DC_1(); LCD_SendBytes(&data, 1); LCD_CS_1(); }很多新手会问,为什么感觉比标准库的代码多包了一层 HAL 函数?因为 HAL_SPI_Transmit 内部会检查 SPI 状态、忙标志,还会带超时,比标准库那种直接操作寄存器更稳。代价就是函数调用多一点,但对于初始化、设置窗口这种低频操作,完全无所谓。
2.2 初始化到底做了什么
ILI9486 上电后并不是直接能显示的,要先经历复位、退出睡眠、设置像素格式、设置扫描方向、开显示这几个步骤。下面是精简后可稳定工作的初始化序列:
void ILI9486_Init(void) { LCD_RST_0(); HAL_Delay(10); LCD_RST_1(); HAL_Delay(120); LCD_WriteCmd(0x11); // Sleep Out HAL_Delay(120); LCD_WriteCmd(0x36); LCD_WriteData(0x48); // 扫描方向 + BGR LCD_WriteCmd(0x3A); LCD_WriteData(0x55); // RGB565,16bit 像素格式 LCD_WriteCmd(0x20); // Inversion Off LCD_WriteCmd(0x29); // Display ON HAL_Delay(10); LCD_Clear(0xFFFF); // 清屏为白色 }这里有几个容易踩的坑。0x36 是“Memory Access Control”,里面每一位控制扫描方向、行列交换、RGB/BGR 顺序。我给的是 0x48,适合竖屏 320x480,但如果你的模块方向和我相反,或者镜面效果不对,就试 0x08、0x28、0x88 三组值,总有一组正常。
0x3A 设成 0x55 表示 16 位 RGB565 色彩,每个像素 2 字节。ILI9486 本身能支持 18 位色彩,如果设成 0x66,每个像素要发 3 字节,速度更慢而且颜色并不一定更好。我这次按 2 字节像素处理,数据量小,刷新速度快。如果你的屏幕偏色严重,优先检查 0x36 的 BGR 位,而不是急着把 0x3A 改成 0x66。
2.3 填充函数为什么快了很多
最直观的画点函数是“设置窗口,写一个像素”,但画矩形或清屏时如果每个像素都单独调一次,光 SPI 片选翻转的时间就够你等半天。正确做法是先用 0x2A 设置列地址、0x2B 设置行地址,再发 0x2C 进入连续写内存模式,之后一路发像素数据,发完再把 CS 拉高。
void LCD_SetWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { LCD_WriteCmd(0x2A); LCD_WriteData(x1 >> 8); LCD_WriteData(x1); LCD_WriteData(x2 >> 8); LCD_WriteData(x2); LCD_WriteCmd(0x2B); LCD_WriteData(y1 >> 8); LCD_WriteData(y1); LCD_WriteData(y2 >> 8); LCD_WriteData(y2); LCD_WriteCmd(0x2C); }清屏函数可以这样写:准备 1KB 的纯色缓冲,然后反复把这段缓冲区喂给 SPI。这里必须注意,HAL_SPI_Transmit 的第三个参数是 uint16_t 类型,最大只能传 65535 字节。320x480 的 RGB565 全屏数据是 307200 字节,直接塞给 HAL_SPI_Transmit 会溢出,所以一定要分块发送:
void LCD_Clear(uint16_t color) { uint8_t buf[1024]; uint32_t total = 320 * 480; for (uint32_t i = 0; i < 1024; i += 2) { buf[i] = color >> 8; buf[i + 1] = color & 0xFF; } LCD_SetWindow(0, 0, 319, 479); LCD_CS_0(); LCD_DC_1(); for (uint32_t sent = 0; sent < total; sent += 512) { uint16_t len = 1024; if (sent + 512 > total) { len = (total - sent) * 2; } HAL_SPI_Transmit(&hspi1, buf, len, HAL_MAX_DELAY); } LCD_CS_1(); }第一次写代码的人特别容易在len和像素数量之间把自己绕晕。记住一个原则:收发的都是“字节数”,一个 RGB565 像素占 2 字节,缓冲区 1024 字节等于 512 个像素。
3. 触摸屏 XPT2046:读坐标、滤波、校准
3.1 SPI 读坐标的基本姿势
XPT2046 的读取方式和显示器完全不同,它相当于一个挂在 SPI 上的 12 位 ADC。操作流程是:先拉低 TP_CS,发送一个控制字节,再读两个字节回来,拼成一个 12 位坐标值。
我用的控制字节是 0xD0 读 X、0x90 读 Y。如果你的屏读出来 X/Y 是反的,把这两个命令互换即可,这不是芯片坏了,只是模块布线定义不同。
uint16_t TP_Read(uint8_t cmd) { uint8_t tx[2] = {0x00, 0x00}; uint8_t rx[2] = {0x00, 0x00}; TP_CS_0(); HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY); HAL_SPI_TransmitReceive(&hspi1, tx, rx, 2, HAL_MAX_DELAY); TP_CS_1(); return ((uint16_t)(rx[0] << 8 | rx[1])) >> 3; }注意返回值的>> 3。XPT2046 是 12 位 ADC,但 SPI 输出时数据一般是对齐在 15..4 位或者 11..0 位,不同资料写法不一样。我碰到的模块实测是“右移 3 位后落在 0-4095”,如果你的坐标范围明显不对,可以试试右移 4 位或者直接& 0x0FFF,以实际量程为准。
3.2 软件滤波与两点校准
直接读回来的一次原始值会很抖,尤其是手指轻轻搭在上面的时候,坐标值能跳动几十甚至上百。用中值滤波最省事:连续读 5 次,去掉最大和最小,剩下 3 次取平均值。
uint16_t TP_ReadAverage(uint8_t cmd) { uint16_t samples[5]; for (uint8_t i = 0; i < 5; i++) { samples[i] = TP_Read(cmd); } for (uint8_t i = 0; i < 4; i++) { for (uint8_t j = i + 1; j < 5; j++) { if (samples[j] < samples[i]) { uint16_t tmp = samples[i]; samples[i] = samples[j]; samples[j] = tmp; } } } return (samples[1] + samples[2] + samples[3]) / 3; }原始 ADC 值还不能直接用,因为屏幕的物理坐标和 ADC 读数之间存在旋转、镜像和缩放关系。最简单的校准是两点校准:显示左上角十字,记录 raw_x1、raw_y1;显示右下角十字,记录 raw_x2、raw_y2。然后计算比例:
float kx = (float)(RIGHT_X - LEFT_X) / (raw_x2 - raw_x1); float offset_x = LEFT_X - raw_x1 * kx; float ky = (float)(BOTTOM_Y - TOP_Y) / (raw_y2 - raw_y1); float offset_y = TOP_Y - raw_y1 * ky;使用时直接screen_x = raw_x * kx + offset_x。这套校准参数建议算完后存到 STM32F103 内部 Flash,否则每次断电重新校准很烦。STM32F103 的 Flash 操作本身不复杂,但要注意按页擦除,别把程序区擦没了。
3.3 判断触摸按下,避免一直刷屏
TP_IRQ 引脚在手指按下时会拉低,所以主循环里先判断这个引脚,再决定要不要读坐标。不要傻乎乎地每轮循环都去读 ADC,那样不仅 CPU 浪费,还会因为触摸芯片不断采样导致数字噪声。
while (1) { if (HAL_GPIO_ReadPin(TP_IRQ_GPIO_Port, TP_IRQ_Pin) == GPIO_PIN_RESET) { uint16_t rx = TP_ReadAverage(TP_CMD_X); uint16_t ry = TP_ReadAverage(TP_CMD_Y); uint16_t sx = (uint16_t)(rx * kx + offset_x); uint16_t sy = (uint16_t)(ry * ky + offset_y); LCD_DrawPixel(sx, sy, 0xF800); // 画红色点 HAL_Delay(10); } }我自己实际测试下来,10ms 的延时不会影响画线连贯性,反而能避免一次按下读出很多重复点。如果想要更顺滑的手绘效果,可以把延时改成 5ms,但滤波还是要保留。
4. 工程整合、性能实测与常见问题排查
4.1 主循环与现有代码整合
整个工程不复杂,只要你用 CubeMX 生成的代码,再把lcd.c、lcd.h、touch.c、touch.h加进去,主循环里按顺序调用ILI9486_Init()和触摸读取函数就行。注意一件事:CubeMX 生成后默认 SPI 初始化顺序在 GPIO 之前,你不需要手动改,main 函数里先调用MX_GPIO_Init()再调用MX_SPI1_Init()的顺序已经是正确的。
如果你想把显示内容做得更丰富,比如显示汉字,需要加字库和取模工具。我用的是常见的“PCtoLCD2002”取模,取模方向选“逐行式”,字体大小 16x16,然后把取模后的数组按同样方式喂给 ILI9486。取模方向如果和屏幕扫描方向不一致,汉字会“躺倒”或者乱码,这个不是驱动逻辑问题,而是方向匹配问题。
4.2 实测性能:可以接受,但别当高刷屏用
SPI 工作在 18MHz 时,理论速率 2.25MB/s。RGB565 全屏一帧数据是 320x480x2=307200 字节,理论全屏填色时间约 136ms,加上命令开销和函数调用,实测大概 150ms 一帧,相当于每秒 6-7 帧。
这个速度显示静态界面、按钮、仪表盘完全够用,但如果你要做动画、跑马灯这类对帧率敏感的效果,建议上 DMA 或者直接选带 FSMC 并口的高分屏。DMA 方案并不复杂,核心就是把HAL_SPI_Transmit换成HAL_SPI_Transmit_DMA,但一定要在 DMA 传输完成回调里拉高 CS,不能在启动 DMA 后立刻拉高,否则最后一段数据会被截掉。
4.3 常见问题速查表
| 现象 | 大概率原因 | 排查/解决办法 |
|---|---|---|
| 白屏或黑屏无反应 | RST 时序不对 / 背光没亮 | 检查 PA0 是否输出高电平;RST 拉低至少 10ms 再拉高 |
| 花屏、有明显横条纹 | SPI 速率过高或杜邦线太长 | 分频从 4 改成 8,即 9MHz;线长尽量控制在 10cm 内 |
| 画面上下颠倒/左右镜像 | 0x36 扫描方向不对 | 依次试 0x08、0x28、0x48、0x88 |
| 颜色偏红偏蓝或反色 | BGR 位不对 | 0x36 的最后一位,也就是 bit3,取反重试 |
| 显示正常但触摸没反应 | TP_CS/IRQ 接错或 MISO 没接 | 确认 PA6 接了触摸输出,PB0 是否有下拉变化 |
| 触摸坐标只在一个角落跳 | 控制字节或右移位数不对 | 换 0xD0/0x90,右移试试 3/4 位 |
| HAL_SPI_Transmit 卡死 | SPI 通信异常或 timeout 太短 | timeout 直接用 HAL_MAX_DELAY,先排除硬件接线 |
最后再分享一个我自己的教训:一开始我把触摸检测接到了外部中断里,想在按下瞬间立刻读坐标,结果 SPI 中断和触摸中断相互干扰,偶尔读回 0xFFFF 或者坐标漂移。后来改成主循环轮询 TP_IRQ,不但代码更短,稳定性反而上来了。对于这种低速触摸屏,真的没必要强行用中断。
这个项目目前驱动部分已经很完整,后面如果你想扩展,可以往“多页面菜单 + 触摸按键”方向走,或者把 ESP8266/ESP32 串口接进来做远程数据显示。硬件基础都是同一套,核心就是先把 ILI9486 和 XPT2046 这两个芯片的时序吃透。
本文还有配套的精品资源,点击获取