简介:这是一份基于STM32F103和HAL库驱动3.5寸ILI9486触摸屏的完整工程代码,面向物联网、工控等嵌入式场景开发者,尤其适合需要快速点亮LCD并加入触摸交互、但又不想从零编写寄存器级驱动的项目。压缩包为RAR格式,共153个文件,约4.77MB,核心是HAL_ILI9486L驱动文件,另外包括51个.h头文件、21个.c源文件,以及o/d/crf等编译中间文件、uvprojx工程文件、ioc配置、hex/axf可执行文件等,拿到后可直接编译烧录到STM32F103开发板。工程完整覆盖ILI9486初始化和清屏、画点、画线、文本显示,以及XPT2046触摸坐标读取等关键功能;SPI接口、GPIO使能信号、中断引脚均通过STM32CubeMX图形化配置生成,方便查看和修改。内容预览中可见stm32f1xx_hal_tim、rcc、gpio等底层文件,便于排查移植问题。目前已有1702人浏览学习,既可作为HAL库工程规范模板,也能作为后续开发简易GUI或人机交互界面的基础。
1. 手里这块 3.5 寸屏能干什么:HAL 库驱动一次说透
做嵌入式设备最绕不开的一件事就是人机交互,而 3.5 寸 320×480 的电阻触摸屏几乎是平衡成本与显示效果的常见选择。我第一次拿到这种屏时,以为接上 SPI 就能点亮,结果白屏、颜色错乱、触摸坐标乱跳轮着来,最后耗了三个晚上才把整套流程理顺。这套 STM32F103 + ILI9486 + XPT2046 的 HAL 库工程,就是把当初踩过的坑全部填平之后的完整版本,从初始化序列到画点画线,再到触摸校准,代码直接烧录就能跑。它适合手头有 F103 核心板、想快速把屏幕点亮而不是从寄存器逐行啃 Datasheet 的开发者,也适合需要做菜单、波形显示、参数设置界面的实际项目。
2. HAL 库工程搭建与 SPI 参数选型:先让屏幕被系统认识
2.1 时钟树与 SPI 预分频:为什么选 18Mbps 而不是最高档
F103 的主频上限是 72MHz,这一点决定了 SPI 外设能跑多快。注意 SPI1 挂在 APB2 上,SPI2/SPI3 挂在 APB1 上,APB1 外设时钟默认是 36MHz,所以同样的预分频系数,SPI2 能跑到的绝对速率只有 SPI1 的一半。我一般把按键和触摸芯片挂 SPI2,把屏幕挂 SPI1,这样刷屏带宽不受 APB1 限制。时钟树里把 PCLK2 设为 72MHz,SPI1 预分频选 2,就能得到 36MHz 的线时钟,但对 ILI9486 来说 36MHz 在 SPI 四线模式下太激进,官方手册里的 AC 时序最高也只保证到 20MHz 上下,所以我实测跑 18Mbps——也就是预分频取 4。不用怀疑这个数值,很多现成工程里写 36Mbps 反而不稳定,长线传输时波形振铃严重。
hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi1);这段是 CubeMX 生成后的标准配置,核心参数集中在几个地方:模式选主机、两线全双工、数据位 8bit、CPOL=0 CPHA=1 即 SPI Mode 0。ILI9486 支持 Mode 0 和 Mode 3,Mode 0 是通用默认值,如果手里的屏模组在别的工程里用的是 Mode 3,改这两项即可,其余不用动。预分频 4 意味着 SPI1 实际波特率为 72MHz/4=18Mbps,如果你发现屏幕初始化偶尔失败或者花屏,优先把预分频改成 8,降到 9Mbps 排查是不是速率问题。
2.2 GPIO 分配与时序角色:DC、RST、CS 谁说了算
HAL 库工程里 GPIO 的分配直接决定了后面写驱动时能不能少折腾。屏幕这边需要 5 根控制线加 2 根数据线:SCK、MOSI、CS、DC、RST,外加背光 BL 可选。SCK 和 MOSI 复用 SPI1 功能,CS、DC、RST 全部用普通推挽输出 GPIO 手动控制,不要依赖硬件 NSS,F103 的硬件 NSS 在 SPI 主机模式下行为比较绕,不如直接用软件拉低拉高来得干净。DC 引脚尤其重要,它决定当前 SPI 总线上的字节是命令还是数据,ILI9486 的所有寄存器操作都依赖这个电平切换,选一个普通 PA 口即可。
| 功能 | 推荐引脚 | 模式 | 说明 |
|---|---|---|---|
| SCK | PA5 | AF_PP | SPI1_SCK |
| MOSI | PA7 | AF_PP | SPI1_MOSI |
| CS | PB6 | OUT_PP | 软件控制,低有效 |
| DC | PB7 | OUT_PP | 低为命令,高为数据 |
| RST | PB8 | OUT_PP | 低有效复位 |
| BL | PB9 | OUT_PP | 高电平点亮 |
触摸芯片 XPT2046 挂在另一个 SPI 总线 SPI2 上时要有独立的 CS 引脚。我常用的触摸 CS 是 PB12,PENIRQ 接 PB13 并配成外部中断输入。在配置 GPIO 时要留意,F103 的 SPI2 复用引脚是 PB13 的 SCK 和 PB15 的 MOSI,别把 MISO 和 MOSI 接反,这种错我犯过一次,触摸数据读出来全是 0xFFFF 或 0x0000。
2.3 工程骨架:main.c 里的初始化顺序有讲究
CubeMX 生成的 main.c 结构基本固定,能够直接用的部分是HAL_Init()、SystemClock_Config()和 GPIO/SPI 的初始化MX_GPIO_Init()、MX_SPI1_Init()。屏幕上电时序一般是:先拉低 RST 至少 10ms,再拉高,等待 120ms,之后才能发初始化序列。注意不要把复位延时写在MX_GPIO_Init()前面,GPIO 外设时钟没打开时操作引脚是无效的,这也是屏幕第一天怎么都不亮的原因。
#define LCD_RST_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET) #define LCD_RST_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET) int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); MX_SPI2_Init(); LCD_RST_LOW(); HAL_Delay(20); LCD_RST_HIGH(); HAL_Delay(150); lcd_init(); touch_init(); lcd_fill_screen(0xFFFF); while (1) { // 主循环里跑触摸轮询或刷帧 } }注意这里MX_SPI2_Init()是给触摸用的,假如你的屏模组触摸和显示共用一条 SPI 总线,只需要一个 SPI 外设和两根 CS 线,这在我的另一块模组上碰到过:确认你的连接方式属于哪种再决定用几个 SPI。模块划分上,我把 LCD 驱动、触摸驱动、GUI 基础绘制分别放三个文件,main.c 只保留调度逻辑,工程大了之后维护起来不会变成一坨代码。
3. ILI9486 初始化序列与绘图驱动:从寄存器到画点画线
3.1 初始化序列背后的设计逻辑:Gamma 与电源控制不能照抄
很多教程的初始化数组是直接从卖家源码里拷的,照抄能亮,但不知道为什么。ILI9486 的初始化本质是两件事:设置电源电压和设置显示扫描方式。0xC0 寄存器控制电源的 GVDD 电平,常见值是 0x1B,对应的是一组内部电荷泵的输出电压,改大会导致对比度异常甚至出现白屏残影。0xE0 和 0xE1 分别是正负 Gamma 校正,一组 15 个字节,直接决定灰阶过渡是否平滑。对大多数模组来说,这套参数是原厂调好的,你只需在初始化时原样写入。
static const uint8_t lcd_init_cmd[] = { 0xE0, 15, 0x00, 0x03, 0x09, 0x0B, 0x1E, 0x28, 0x30, 0x3B, 0x3D, 0x2E, 0x14, 0x15, 0x0E, 0x07, 0x0A, 0xE1, 15, 0x00, 0x03, 0x09, 0x0B, 0x1E, 0x28, 0x30, 0x3B, 0x3D, 0x2E, 0x14, 0x15, 0x0E, 0x07, 0x0A, 0xC0, 2, 0x1B, 0x0B, 0xC1, 1, 0x10, 0xC5, 2, 0x30, 0x30, 0xC7, 1, 0xB7, 0x36, 1, 0x48, 0x3A, 1, 0x55, 0xB1, 2, 0x00, 0x1B, 0xB4, 1, 0x02, 0xB6, 3, 0x02, 0x02, 0x3B, 0x11, 0, 0x29, 0, };这段数组的格式是“命令号 + 参数个数 + 参数列表”,参数个数为 0 表示只有命令没有数据。0x36 的值 0x48 做了两件事:打开 MX 镜像位,同时把 RGB 顺序改成 BGR,如果你的屏幕显示红色和蓝色对调,就把 0x48 改成 0x08。0x3A 写入 0x55 表示像素格式为 16bit RGB565,这是 SPI 四线模式下最常见的配置,如果你把初始化改成 0x66 则进入 18bit 模式,颜色更准确但刷屏带宽大增,不建议效仿。0x11 是 Sleep Out,之后必须延时 120ms 再发 0x29 打开显示,否则屏幕会一直黑着。
3.2 画点函数:set_window 是唯一入口
ILI9486 的坐标管理方式是窗口制:你先告诉芯片要往哪个矩形区域写像素,再连续往这个区域填充数据。一次完整的画点操作包含设置列地址 0x2A、设置行地址 0x2B、然后写 0x2C 进入写像素状态。HAL 库里最直接的做法是每次画点都做一遍窗口设置,但这样做 SPI 事务开销极大。更合理的做法是:画点函数保留低层能力,实际绘制场景尽量用批量填充。
void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { lcd_write_cmd(0x2A); lcd_write_data(x0 >> 8); lcd_write_data(x0 & 0xFF); lcd_write_data(x1 >> 8); lcd_write_data(x1 & 0xFF); lcd_write_cmd(0x2B); lcd_write_data(y0 >> 8); lcd_write_data(y0 & 0xFF); lcd_write_data(y1 >> 8); lcd_write_data(y1 & 0xFF); lcd_write_cmd(0x2C); } void lcd_draw_pixel(uint16_t x, uint16_t y, uint16_t color) { lcd_set_window(x, y, x, y); uint8_t buf[2]; buf[0] = color >> 8; buf[1] = color & 0xFF; HAL_SPI_Transmit(&hspi1, buf, 2, 100); }lcd_write_cmd和lcd_write_data的区别只在 DC 引脚的电平上。写命令时 DC 拉低,写数据时 DC 拉高。这里有个细节是所有地址参数都要拆成高低字节分别发送,先发高位后发低位,顺序反了窗口设置就会错乱。画点的颜色参数用 uint16_t 承载 RGB565,红色是 0xF800,绿色是 0x07E0,蓝色是 0x001F,如果需要从 R8G8B8 转成 RGB565,用((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)。
3.3 批量绘制与矩形填充:把 SPI 事务从两次降到一次
UI 界面大部分元素都是矩形或渐变条,逐点调用画点函数会慢到肉眼可见。正确的批量做法是:先设置目标窗口,然后把要显示区域的像素数据拼成一个大数组一次发送。全屏 320×480 的 RGB565 数据是 307200 字节,堆内存根本放不下。常规方案有两种:一是申请一块矩形区域大小的缓冲,比如 8×8 的字体字模缓冲;二是按行刷,每行 640 字节,分 480 次发送。第二种方案不占用大内存,速度也能接受。
void lcd_fill_rect(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { lcd_set_window(x0, y0, x1, y1); uint32_t pixel_cnt = (uint32_t)(x1 - x0 + 1) * (y1 - y0 + 1); uint32_t bytes = pixel_cnt * 2; uint8_t *buf = (uint8_t *)malloc(bytes); if (buf == NULL) return; for (uint32_t i = 0; i < pixel_cnt; i++) { buf[i * 2] = color >> 8; buf[i * 2 + 1] = color & 0xFF; } HAL_SPI_Transmit(&hspi1, buf, bytes, HAL_MAX_DELAY); free(buf); }这段代码用了 malloc,在小内存设备上有风险,不建议在实际工程里照搬。更稳妥的方法是改成一个固定 640 字节的行缓冲:外层循环 480 次,每次只发送一行像素数据。画线算法方面,Bresenham 在整数运算上是最优解,但如果你把画线实现成“计算出一系列点坐标再逐点调用 draw_pixel”,每一次调用都涉及窗口设置和 SPI 传输,性能会非常差。另一个常见优化是绘制水平或垂直线时先调用lcd_set_window把宽度缩成 1 像素的窄矩形,再按批量填充方式发送,这样一条线只要两次 SPI 事务。
4. XPT2046 触摸读取与校准:坐标映射不是简单的比例换算
4.1 触摸 SPI 时序:命令字节与 12 位转换结果
XPT2046 是一颗 4 线电阻触摸屏的典型控制芯片,它在 SPI 总线上扮演从机,主机发送一个字节的命令,然后读取 3 个字节,转换结果取中间 12 位有效数据。命令字节的高四位决定了要测量的是 X 方向还是 Y 方向,0x90 表示差分模式测量 X 坐标,0xD0 表示测量 Y 坐标。别搞反,X 和 Y 接反的话屏幕上点横坐标会变成纵坐标,界面逻辑会全部错乱。
uint16_t touch_read_raw(uint8_t cmd) { uint8_t tx = cmd; uint8_t rx[3] = {0}; HAL_GPIO_WritePin(TOUCH_CS_GPIO_Port, TOUCH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi2, &tx, 1, 100); HAL_SPI_Receive(&hspi2, rx, 3, 100); HAL_GPIO_WritePin(TOUCH_CS_GPIO_Port, TOUCH_CS_Pin, GPIO_PIN_SET); return ((rx[1] << 8) | rx[2]) >> 3; }注意我在发送命令和读取转换结果之间没有做延时,原因是 XPT2046 的转换期间 SCLK 会继续驱动数据线,主机连续发送 3 字节的读时钟就能拿到完整结果。这三个字节中第一个字节是前次转换的残留,所以要舍掉,只取第 2 和第 3 字节组合后的高 12 位。另一个关键点是触摸 CS 必须在传输期间全程拉低,有些工程只在发命令时拉低、读数据时拉高,结果坐标要么是 0,要么是随机的毛刺数字。
4.2 一次校准实站:四角取点还是三线拟合
电阻屏的坐标系统存在两套逻辑:ADC 原始值和 LCD 像素坐标,二者之间受屏幕材质、贴合工艺、供电电压影响,不同模组的换算比例并不一致。最简单实用的校准方法是取四个角或边缘中点采样,算出 X 方向的比例因子和偏移量:x_screen = (raw_x - x_min) * 320 / (x_max - x_min),Y 方向同理。这个方法在屏幕装配没有明显旋转时已经够用。如果屏幕贴歪了或者你要支持不同批次的模组,就要用三点校准解一个仿射变换矩阵。
typedef struct { int16_t x_min, x_max; int16_t y_min, y_max; uint8_t swap_xy; } touch_cal_t; static touch_cal_t cal = {0}; void touch_map(uint16_t raw_x, uint16_t raw_y, uint16_t *sx, uint16_t *sy) { int16_t x = raw_x, y = raw_y; if (cal.swap_xy) { int16_t t = x; x = y; y = t; } *sx = (uint16_t)((x - cal.x_min) * 320L / (cal.x_max - cal.x_min)); *sy = (uint16_t)((y - cal.y_min) * 480L / (cal.y_max - cal.y_min)); }这段代码里的swap_xy标志是旋转适配的关键。ILI9486 的 3.5 寸屏物理尺寸接近正方形,很多时候为了排版方便会把显示方向做成横屏,这时触摸 ADC 的 X 轴对应的是屏幕的 Y 方向,不交换坐标的话触摸轨迹会和显示内容完全垂直。初始化时在四个角分别采样并把结果存入 cal 结构体,画一个十字光标让用户点击,效果比纯后台自动校准更直观。如果懒一点,也可以直接把 x_min 设为 0、x_max 设为 4095,再乘以一个 0.98 的修正系数,但不同批次的屏误差会明显放大,看重用户体验就别省这个步骤。
4.3 滤波与抗抖动:滑动平均在触摸场景的局限性
拿到原始坐标直接映射到屏幕坐标,手感会很差。电阻屏本质是一个分压电阻网络,按下时接触电阻会波动,表现为坐标在目标点周围跳动。对于这类噪声,我试过滑动平均,效果不理想——它只能平滑高频抖动,对偶尔出现的尖峰毛刺毫无办法。真正稳的方案是先做限幅再做中值滤波:连续采样 5 次,去掉最大值和最小值,对中间 3 个值取平均。
uint16_t touch_filter(uint16_t raw) { static uint16_t buf[5]; static uint8_t idx = 0; buf[idx++] = raw; if (idx >= 5) idx = 0; uint8_t i, j; uint16_t tmp[5], t; for (i = 0; i < 5; i++) tmp[i] = buf[i]; for (i = 0; i < 4; i++) { for (j = i + 1; j < 5; j++) { if (tmp[j] < tmp[i]) { t = tmp[i]; tmp[i] = tmp[j]; tmp[j] = t; } } } return (tmp[1] + tmp[2] + tmp[3]) / 3; }注意这个函数每次touch_read_raw返回后的调用顺序:先读 X 再读 Y,各过一次滤波,然后才做坐标映射。不要在一次采样里先把 X 和 Y 都读回来再统一滤波,那样引入的是不同时间点的采样,坐标会出现明显的拉扯感。除了算法,校验位置还会影响结果:PENIRQ 引脚检测到按下时,芯片内部参考电压还没稳定,所以最好在 PENIRQ 有效后延时 1ms 再发起读取,这个时序细节比任何滤波算法都重要。
5. 避坑清单:这个工程里最容易翻车的五个地方
5.1 白屏但背光亮:问题出在复位时序而非驱动代码
现象是上电后屏幕背光正常但画面全白,发送任何初始化命令都没有反应。我排查了 SPI 波形和初始化数组,最后发现是 RST 引脚复位脉宽太短。F103 上电过程中 GPIO 状态不定,如果 RST 引脚被瞬间拉低又被迅速释放,ILI9486 可能只完成了部分复位,内部状态机停在异常位置。解决方法是复位时序强制拉低 RST 50ms 以上,然后拉高再等 150ms。注意复位后不能立刻发初始化命令,睡眠退出指令 0x11 之后的延时也不能省,否则同样会出现白屏。
5.2 颜色整体偏蓝或红蓝对调:0x36 的 BGR 位和 0x3A 的数据格式
现象是明明写入 0xF800 红色,屏幕显示的却是蓝色,或者颜色整体偏冷色。原因有两个:ILI9486 的 MADCTL 寄存器里有一个 BGR 位,默认是按 RGB 顺序排列的,如果你的模组物理线路是 BGR 走线,就需要在 0x36 的初始值里加上 0x08。第二个原因是像素格式配置,0x3A 写入 0x55 才是 16bit,如果你从某些自制的工程里拷贝了 0x66(18bit),那 SPI 发送的 16bit 数据会被当成 18bit 解析,颜色自然不对。排查顺序:先确认 0x3A,再改 0x36 的低字节。
5.3 触摸坐标乱跳且无规律:片选引脚没有独立控制
现象是触摸采样值在按下时不稳定,放开后还能读到接近 4095 的残留数据。第一次遇到时以为是滤波不够,实际上是我把触摸芯片和屏幕的 CS 挂在同一个引脚上——屏幕操作和触摸操作互相干扰,SCLK 线上混着屏幕的数据包。解决方法是给触摸芯片分配独立 CS 引脚,且在触摸采样时先延时 1ms 等内部上电稳定,再开始 SPI 事务。另一个容易忽略的点是 XPT2046 的 VREF 电压,如果模组没有独立 VREF 而直接使用 AVDD,供电纹波会直接反应到坐标上,这种噪声再多滤波算法都压不住,只能改善电源质量。
5.4 用 DMA 刷屏后触摸失灵:SPI 总线占用冲突
现象是 DMA 方式刷屏时,触摸完全无响应;刷屏完成后触摸能恢复,但坐标偶尔偏移。原因是触摸和显示共用一条 SPI 总线时,DMA 传输占据着总线,触摸读取命令发不出去。我的做法是触摸读取之前检查 SPI 外设状态,用HAL_SPI_GetState或者直接读取 SR 寄存器的 BSY 位,确认 DMA 传输完全结束后再片选触摸。另一个细节:HAL_SPI_Transmit_DMA发起后要等传输完成回调里的标志位置位,不要在中断里立刻切换 CS,DMA 可能还在把数据从内存搬到移位寄存器。
5.5 编译后 Flash 超出:HAL 库的默认配置吃掉了大量空间
现象是 F103C8T6 编译报错,Flash 占用超过 64KB。原因包括 SPI 的 HAL 驱动默认使能了中断处理、assert 宏全量展开、调试接口没关等因素。解决思路是三步:工程选项里把优化等级从 -O0 改成 -O2;用 CubeMX 配置时只勾选实际用的外设,不用的 SPI/USART 不要开启;在 lcd 驱动里把 HAL_SPI_Transmit 的 timeout 改成固定 100,不要用 HAL_MAX_DELAY 占住堆栈。还有一个实际技巧:F103C8T6 只有 20KB SRAM,做全屏缓冲就不可能,所以 fi的方案里我刻意不用大数组。如果项目确实需要全屏缓冲,建议换 F103ZET6 或者给 LCD 单独配一块外部 SRAM 芯片。
6. 把刷屏速度逼到极限的进阶技巧:DMA、方向切换与验证
6.1 用 SPI DMA 刷帧,让 CPU 从长传输里解放出来
批量填充矩形时把HAL_SPI_Transmit换成HAL_SPI_Transmit_DMA能显著降低 CPU 占用。核心逻辑是初始化一个全局像素缓冲,在 UI 绘制完成后调用 DMA 传输,传输完成中断里把 DMA 状态复位。使用 DMA 前一定要先关掉对应的 DMA 通道再重新配置,否则第二次启动传输会失败。另外,结合上一章说的,DMA 传输期间任何触摸读取都必须等待 BSY 位清零。
6.2 屏幕方向旋转后,触摸坐标跟着翻转
| 方向 | 0x36 值 | 触摸 X 映射到 | 触摸 Y 映射到 |
|---|---|---|---|
| 竖屏 | 0x48 | 屏幕 X | 屏幕 Y |
| 横屏 | 0x28 | 屏幕 Y | 屏幕 X |
| 竖屏镜像 | 0x88 | 320-X | 480-Y |
| 横屏镜像 | 0xE8 | 480-Y | 320-X |
表格里的映射方式只要在touch_map()里按旋转换算即可,不用改触摸采样代码。旋转换算最常犯的错是忘记像素坐标范围也随之变化,竖屏 320×480 变横屏就是 480×320。
6.3 用一个彩色渐变页面验证整套工程稳定性
拿到一套新模组时,我不会直接写 UI,而是先跑一个纯色渐变页:左半屏红色、右半屏蓝色,中间一列绿色竖线,然后在触摸区画一圈十字刻度并实时显示坐标点。如果颜色渐变上没有竖条纹,说明像素格式正确;如果触摸光标能准确跟到十字刻度中心,说明校准矩阵可用。这套验证方法让我在三块不同批次屏幕上都能在十分钟内确认驱动是否适配。从那以后我每次换屏模组都强制走一遍这个流程,颜色和触摸的隐藏问题就不会攒到最后一起爆炸。这套工程是我从实际项目里提取出来的,希望能帮你在自己的板子上少走几晚弯路。
本文还有配套的精品资源,点击获取