☰
STM32F103 HAL库驱动ILI9486 SPI触摸屏完整指南
2026/10/17 7:40:12 网站建设 项目流程

简介:基于STM32F103的3.5英寸ILI9486触摸屏驱动完整工程,采用HAL库编写,面向嵌入式初学者及需要快速实现LCD显示与触摸交互的开发者。工程涵盖SPI接口配置、ILI9486显示驱动、触摸屏ADC读取与坐标换算,并配合STM32CubeMX初始化代码,可直接烧录运行。资源共153个文件,主要包括51个头文件、21个C源码,以及编译生成的o、d、Hex、axf等目标文件,另含IOC工程配置与uvprojx工程文件,压缩包仅4.77MB,目录结构清晰,便于二次开发。目前已有1702人学习下载。代码基于中景园驱动移植,系统展示了屏显初始化、画点画线、文本及图像显示、触摸事件处理等核心实现,细致解析HAL_GPIO、HAL_SPI等外设操作,是理解STM32与屏幕交互的优质参考,也可作为后续图形界面和人机交互设计的基础模板。

1. STM32F103驱动3.5寸ILI9486:为什么建议直接烧录HAL库版本

手头这块3.5寸触摸屏在抽屉里吃灰大半年,同事给的寄存器版驱动在旧工程里跑得欢,换到HAL库后连白屏都点不亮。后来我花两天把整套驱动按HAL库重写:SPI通信、ILI9486初始化序列、XPT2046触摸读取、线性标定,全部代码在同一块单片机板上烧录验证过。这套资源解决的是嵌入式HAL库环境下把320×480的ILI9486触摸屏跑起来的完整链路,适合正在做单片机项目或课程设计的开发者,照着接线、烧录、校准三步就能点亮屏幕,不用再花时间对着数据手册啃寄存器。

2. 硬件接线与方案选型:SPI还是FSMC,引脚怎么分配

2.1 SPI方案为什么比FSMC更适合新手

ILI9486驱动IC原生支持并行DBI接口和4线串行(SPI)接口。3.5寸模块上最常见的封装就是SPI:CS、SCK、SDI、DC、RST、BL,部分模块还带SDO用于读回。FSMC方案需要至少16根数据线加控制线,而市面上多数3.5寸模块根本没把完整并口信号引出来,强行上FSMC反而要在转接板上做文章。SPI方案只占5到6个GPIO,接线少,代码量也小,更适合快速复现。

速度上也没那么不堪。320×480像素、RGB565每像素2字节,一帧数据约307千字节。SPI时钟按并联分频后9MHz计算,理论刷一帧约34毫秒,实际用DMA搬运能到40到60毫秒一帧。对菜单界面、简易仪表盘、波形绘制完全够用。FSMC确实能把整屏刷新压进10毫秒,但前提是模块支持并口,大部分手头这种3.5寸屏并不支持。所以选SPI不是妥协,是这个尺寸模块最务实的接入方式。

2.2 一套可复用的引脚映射

下面这张表是我在这份资源里用的映射,基于STM32F103的SPI2外设。SPI2挂在APB1总线,时钟36MHz,分频后得到9MHz或18MHz的SCK,先用9MHz起步更稳。

模块信号STM32F103引脚说明
LCD_CSPB12屏幕片选,软件控制
LCD_SCKPB13SPI2_SCK
LCD_SDIPB15SPI2_MOSI,写数据给屏幕
LCD_DCPB0数据/命令切换
LCD_RSTPB1硬件复位
LCD_BLPA1背光,高电平点亮
TP_CSPB2触摸芯片片选
TP_IRQPA2触摸中断,轮询时可不接
TP_SDOPB14SPI2_MISO,读触摸数据

如果你的板子上SPI2引脚被占用,这套代码只需要改两个地方:GPIO初始化里引脚对应的端口和引脚号,以及MX_SPI2_Init里重新映射。DC和CS用普通GPIO控制,不要用硬件NSS,因为HAL库的硬件NSS管理比较绕,软件控制最直接。

GPIO初始化代码放在工程里是这样的:

static void LCD_GPIO_Init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); /* CS、DC、RST、BL均作普通输出 */ gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; gpio.Pin = LCD_CS_Pin | LCD_DC_Pin | LCD_RST_Pin; HAL_GPIO_Init(LCD_CS_GPIO_Port, &gpio); gpio.Pin = LCD_BL_Pin; HAL_GPIO_Init(LCD_BL_GPIO_Port, &gpio); /* 默认CS高,DC高,RST高,背光亮 */ HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LCD_RST_GPIO_Port, LCD_RST_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LCD_BL_GPIO_Port, LCD_BL_Pin, GPIO_PIN_SET); /* 触摸片选 */ gpio.Pin = TP_CS_Pin; HAL_GPIO_Init(TP_CS_GPIO_Port, &gpio); HAL_GPIO_WritePin(TP_CS_GPIO_Port, TP_CS_Pin, GPIO_PIN_SET); }

代码逻辑说明:LCD_CS_Pin、LCD_DC_Pin这些宏在头文件里统一define,方便换引脚时只改宏。速度档位选HIGH是因为SPI信号边沿要求够快。背光直接拉高用最简方式点亮,等后面要调亮度再换PWM。

SPI2的初始化配置:

static void MX_SPI2_Init(void) { hspi2.Instance = SPI2; hspi2.Init.Mode = SPI_MODE_MASTER; /* 主机 */ hspi2.Init.Direction = SPI_DIRECTION_2LINES; /* 全双工,触摸需要读 */ hspi2.Init.DataSize = SPI_DATASIZE_8BIT; hspi2.Init.CLKPolarity = SPI_POLARITY_LOW; /* CPOL=0 */ hspi2.Init.CLKPhase = SPI_PHASE_1EDGE; /* CPHA=0,模式0 */ hspi2.Init.NSS = SPI_NSS_SOFT; /* 软件片选 */ hspi2.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; /* 9MHz */ hspi2.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi2); }

参数说明:CPOL=0、CPHA=0对应SPI模式0,ILI9486写数据在SCK上升沿采样,实测模式0能正常点亮大部分模块。如果你烧录后屏幕花得不成样子,把CLKPhase改成SPI_PHASE_2EDGE再试,一部分翻新屏就是吃模式3。分频4得到9MHz,如果屏线和杜邦线都短,可以改成SPI_BAUDRATEPRESCALER_2跑到18MHz,但线超过10厘米就别贪快。

2.3 触摸芯片XPT2046的接入方式

XPT2046是一颗12位SAR型ADC,和屏幕共用SPI2总线,区别只在片选。TP_CS拉低后它才开始响应SPI时钟,和屏幕互不干扰。模块上的TP_IRQ脚按下时拉低,理论上可以触发外部中断,但实际中电阻屏的抖动比较明显,我用轮询方式更省事。

这里有个容易忽略的点:XPT2046的DOUT在转换结束后会输出数据,如果模块本身不带触摸或者MISO没接,SPI2的MISO引脚悬空,屏幕写入不会受影响,但初始化代码里一旦走读ID或读触摸的路径,就会读到一堆0xFF。所以不带触摸的模块,确定不需要触摸读取后,别把读代码加进主循环。

3. HAL库初始化序列与底层读写:十几个关键寄存器逐个说

3.1 复位时序与四线模式确认

ILI9486的硬件复位要求RST低电平至少10微秒,但实际用HAL_Delay更稳妥,因为单片机刚上电时电源还没稳,短复位容易起不来。软件复位命令0x01之后要等120毫秒再继续,否则后面寄存器会写不进去。

static void ILI9486_Reset(void) { LCD_RST_HIGH(); HAL_Delay(20); LCD_RST_LOW(); HAL_Delay(50); LCD_RST_HIGH(); HAL_Delay(120); }

时序说明:先拉高20ms让IC稳定,再拉低50ms保证复位有效,最后拉高等120ms等内部电路起来。有些老手嫌延时太长,改成10us也能跑,但换到低温环境或劣质电源时就会偶发性白屏,没必要省这点时间。

四线模式指的是CS、SCK、SDI、DC,RST通常单独控制。模块上如果只引出三根线加RST,那多半是9位SPI协议,每9个bit的第一位就是DC值,但那不是这批资源覆盖的场景。四线SPI下DC为低时传输的命令字节,DC为高时传输的数据字节,一字节一字节区分。

3.2 写命令与写数据函数的HAL实现

这块是整个驱动的地基。命令和数据走同一个SPI通道,靠DC引脚电平区分。

void ILI9486_WriteCmd(uint8_t cmd) { LCD_DC_LOW(); /* 命令模式 */ LCD_CS_LOW(); HAL_SPI_Transmit(&hspi2, &cmd, 1, 100); LCD_CS_HIGH(); } void ILI9486_WriteData(uint8_t data) { LCD_DC_HIGH(); /* 数据模式 */ LCD_CS_LOW(); HAL_SPI_Transmit(&hspi2, &data, 1, 100); LCD_CS_HIGH(); } void ILI9486_WriteDataBuf(uint8_t *buf, uint32_t len) { LCD_DC_HIGH(); LCD_CS_LOW(); HAL_SPI_Transmit(&hspi2, buf, len, 1000); /* 批量写 */ LCD_CS_HIGH(); }

逻辑说明:HAL_SPI_Transmit是阻塞调用,函数返回时数据已经发完,所以CS可以立刻拉高。命令阶段DC拉低只发一个字节,数据阶段DC拉高。批量写函数用于向GRAM连续填充颜色数据,比逐字节调用WriteData效率高一个数量级。

参数说明:超时值设100ms,正常情况下1字节在9MHz下连1微秒都用不了。如果SPI异常,HAL库会返回HAL_TIMEOUT,调试时可以检查返回值。DC在命令写完后要恢复到高,否则下一帧数据会被误判成命令。这也是新手最容易翻车的地方,函数内部必须保证DC状态一致。

3.3 核心初始化序列:0x3A、0x36、0xE9、0xF7怎么配

初始化序列直接决定屏幕能不能亮、颜色对不对。我在这套代码里用的初始化序列是反复验证过的:

void ILI9486_Init(void) { ILI9486_Reset(); ILI9486_WriteCmd(0x11); /* Sleep Out */ HAL_Delay(120); ILI9486_WriteCmd(0xE9); /* 电源控制,部分批次必须写00 */ ILI9486_WriteData(0x00); ILI9486_WriteCmd(0xC0); /* 电源控制1 */ ILI9486_WriteData(0x10); ILI9486_WriteData(0x10); ILI9486_WriteCmd(0xC1); /* 电源控制2 */ ILI9486_WriteData(0x41); ILI9486_WriteCmd(0xC5); /* VCOM电压 */ ILI9486_WriteData(0x00); ILI9486_WriteData(0x22); ILI9486_WriteData(0x80); ILI9486_WriteCmd(0x36); /* 存储器访问控制 */ ILI9486_WriteData(0x48); /* MV=1, BGR=1 */ ILI9486_WriteCmd(0x3A); /* 像素格式 */ ILI9486_WriteData(0x55); /* 16位RGB565 */ ILI9486_WriteCmd(0xB1); /* 帧率控制 */ ILI9486_WriteData(0xB0); ILI9486_WriteData(0x11); ILI9486_WriteCmd(0xB4); /* 显示反转 */ ILI9486_WriteData(0x02); ILI9486_WriteCmd(0xB6); /* 显示功能 */ ILI9486_WriteData(0x02); ILI9486_WriteData(0x02); ILI9486_WriteData(0x3B); ILI9486_WriteCmd(0xF7); /* 泵电路 */ ILI9486_WriteData(0xA9); ILI9486_WriteData(0x51); ILI9486_WriteData(0x2C); ILI9486_WriteData(0x82); ILI9486_WriteCmd(0x29); /* Display On */ HAL_Delay(50); }

逐个说关键项。0x3A是像素格式,0x55表示RGB565,16位撞色;如果代码里后续传RGB888颜色值,这里就得改成0x66,18位模式。0x36的每一位控制扫描方向和RGB顺序,bit7是MY、bit6是MX、bit5是MV、bit3是BGR。0x48是0100 1000,MV=1且BGR=1,这个组合对应竖屏从左往右、从上往下扫描。如果你的屏装完画面倒过来,把0x48改成0x28或0x88,逐个试,三分钟就能找到对自己接法的那组。

0xE9这个寄存器是典型的玄学寄存器。有一部分屏不写或者写错,初始化后整屏颜色不对,偏紫偏绿都有。0xF7是内部升压电路,0xA9 0x51 0x2C 0x82这套参数是ILI9486数据手册参考值,实际应用中我见过不同批次模块对这个序列极其敏感,从0xA9改成0xA8可能就能解决闪屏。

4. 刷屏与触摸驱动代码:从窗口函数到坐标校准

4.1 画点、画矩形和RGB565颜色换算

画任何图形最终都落到画点。但不要逐点调用写函数,那是灾难。ILI9486支持窗口模式:先设定列地址0x2A、行地址0x2B,然后连续向0x2C写像素数据,芯片会自动换行填充窗口。这才是刷屏的正路。

static void ILI9486_SetWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { ILI9486_WriteCmd(0x2A); /* 列地址 */ ILI9486_WriteData(x1 >> 8); ILI9486_WriteData(x1 & 0xFF); ILI9486_WriteData(x2 >> 8); ILI9486_WriteData(x2 & 0xFF); ILI9486_WriteCmd(0x2B); /* 行地址 */ ILI9486_WriteData(y1 >> 8); ILI9486_WriteData(y1 & 0xFF); ILI9486_WriteData(y2 >> 8); ILI9486_WriteData(y2 & 0xFF); ILI9486_WriteCmd(0x2C); /* 写入内存 */ }

坐标参数说明:x1、y1是窗口左上角,x2、y2是右下角,坐标范围0到319和0到479。两个字节传坐标是因为320和480都超过255。命令0x2A传列地址,0x2B传行地址,0x2C表示之后的数据全部写入GRAM。

颜色转换函数:

static inline uint16_t RGB888_TO_RGB565(uint8_t r, uint8_t g, uint8_t b) { return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); }

位宽说明:RGB565把24位压到16位,红色保留5位、绿色6位、蓝色5位。右移位数对应各分量在16位中的占比。LCD_WriteDataBuf要求传的缓冲区必须是这个函数产出的16位数组,直接把测试用的const数组塞进去会看到完全不对的颜色。

4.2 DMA刷屏配置:速度和稳定性怎么取舍

DMA刷屏的关键不是DMA本身多复杂,而是传输完成前CS不能拉高。HAL_SPI_Transmit_DMA只是把缓冲区地址交给DMA控制器,函数立刻返回,如果此时立刻拉高CS,SPI移位寄存器里剩下的最后几个字节全部丢进空气,画面右下角会出现一块颜色错乱的区域。

void LCD_Flush(uint16_t *buf, uint32_t len) { LCD_CS_LOW(); LCD_DC_HIGH(); HAL_SPI_Transmit_DMA(&hspi2, (uint8_t *)buf, len); while (HAL_SPI_GetState(&hspi2) != HAL_SPI_STATE_READY) ; /* 等待DMA搬完 */ LCD_CS_HIGH(); }

逻辑说明:DMA把SPI2的数据寄存器当作外设目标,内存里的颜色缓冲区按字节连续搬运。len是缓冲区字节数,不是像素数。320×480全屏一个缓冲区的len是307200字节。等待循环用HAL_SPI_GetState判断,状态回到READY说明SPI空闲,此时拉高CS才安全。

有个更稳的做法是用DMA传输完成回调,在回调里拉高CS。裸机环境下轮询状态够用,跑RTOS时再用信号量唤醒刷屏任务。DMA数据宽度这里必须8位,因为SPI2是以字节为单位传输,把DMA配成16位会让数据进出错乱,刷出来全是横条纹。

4.3 XPT2046读取与两点校准

XPT2046的控制字决定转换通道。0xA4读X坐标,0x94读Y坐标,发送控制字后,SPI时钟继续给16个周期,DOUT把12位结果从高位开始送出来。

static uint16_t TP_ReadADC(uint8_t ctrl) { uint8_t rx[2] = {0, 0}; TP_CS_LOW(); HAL_SPI_Transmit(&hspi2, &ctrl, 1, 50); HAL_SPI_Receive(&hspi2, rx, 2, 50); TP_CS_HIGH(); return ((rx[0] << 8) | rx[1]) >> 3; /* 取12位有效数据 */ }

参数说明:读回两字节后右移3位得到0到4095的ADC值。实际使用中单次采样噪声很大,至少做五次采样取中值:

static uint16_t TP_Filter(uint8_t ctrl) { uint16_t samples[5]; for (int i = 0; i < 5; i++) samples[i] = TP_ReadADC(ctrl); /* 冒泡三趟拿中间值,省内存 */ for (int i = 0; i < 3; i++) for (int j = 0; j < 4 - i; j++) if (samples[j] > samples[j + 1]) { uint16_t tmp = samples[j]; samples[j] = samples[j + 1]; samples[j + 1] = tmp; } return samples[2]; }

校准采用两点线性映射。屏幕上先画两个十字,一个在左上角、一个在右下角,记录各自触摸ADC值,就能算出缩放系数和偏移。

typedef struct { uint16_t raw_x, raw_y; } TP_RawPoint; typedef struct { uint16_t scr_x, scr_y; } TP_ScreenPoint; static TP_RawPoint tp_raw[2]; static TP_ScreenPoint tp_scr[2]; static float tp_kx, tp_ky; static int32_t tp_bx, tp_by; void TP_SetCalibration( uint16_t rx1, uint16_t ry1, uint16_t rx2, uint16_t ry2, uint16_t sx1, uint16_t sy1, uint16_t sx2, uint16_t sy2) { tp_raw[0].raw_x = rx1; tp_raw[0].raw_y = ry1; tp_raw[1].raw_x = rx2; tp_raw[1].raw_y = ry2; tp_scr[0].scr_x = sx1; tp_scr[0].scr_y = sy1; tp_scr[1].scr_x = sx2; tp_scr[1].scr_y = sy2; tp_kx = (float)(tp_scr[1].scr_x - tp_scr[0].scr_x) / (tp_raw[1].raw_x - tp_raw[0].raw_x); tp_ky = (float)(tp_scr[1].scr_y - tp_scr[0].scr_y) / (tp_raw[1].raw_y - tp_raw[0].raw_y); tp_bx = tp_scr[0].scr_x - (int32_t)(tp_kx * tp_raw[0].raw_x); tp_by = tp_scr[0].scr_y - (int32_t)(tp_ky * tp_raw[0].raw_y); } void TP_GetScreenXY(uint16_t *sx, uint16_t *sy) { uint16_t rx = TP_Filter(0xA4); uint16_t ry = TP_Filter(0x94); *sx = (uint16_t)(tp_kx * rx + tp_bx); *sy = (uint16_t)(tp_ky * ry + tp_by); }

公式说明:scale系数是屏幕分辨率跨度除以触摸ADC跨度,offset系数用两个校准点反推。校准点越远,线性度越好;如果两个点距离太近,分母很小,误差会被放大。建议左上点选屏幕坐标(20,20)附近,右下点选(299,459)附近。

5. 避坑与常见问题排查:翻车记录和血泪经验

5.1 现象:初始化后白屏,背光正常

背光亮但屏幕上什么都没有,说明电源和背光都正常,问题在初始化序列没生效。常见原因是复位后延时不够,0x11之后必须等120毫秒,有些模块内部DCDC起来慢,不等它起来后面写寄存器全部无效。解决方法是把ILI9486_Reset里的最后一个延时加到200毫秒,问题能消除一大半。另一个原因是SPI相位不对,CPOL和CPHA改成模式3再试。

5.2 现象:图像颜色整体偏色或反色

偏紫偏绿通常出在0x3A和0x36两个寄存器上。0x3A写成0x66但代码里按RGB565传了2字节数据,每两个像素错位成三字节,颜色会乱成彩虹状。确认初始化里是0x55后,再看0x36的BGR位,BGR位反了红色和蓝色互换,白色和黑色不受影响,但皮肤色和黄色会变得非常奇怪。解决方法是把BGR位反过来,读出当前值异或0x08再写回去。

5.3 现象:DMA刷屏有横条纹、最后一帧错位

这是CS拉高过早的典型症状。HAL_SPI_Transmit_DMA返回后DMA可能还没搬完最后一字节,此时拉高CS直接掐断传输。我用轮询等待状态后问题消失。还有个隐蔽坑是DMA配置成循环模式,传输完成标志永远不会触发,状态永远在READY之前打转。解决方法是确认DMA的CircularMode是DISABLE,只做单次搬运。

5.4 现象:触摸读到的坐标全是0xFFFF

XPT2046没有响应。先从硬件查,TP_SDO是不是真的接到了PB14。很多裸屏模块的触摸输出脚标的是T_DO,不是SDO,容易漏接。其次查SPI时钟相位,XPT2046要求数据在下降沿输出,模式0下读回数据会差半个时钟周期,把CLKPhase改成SPI_PHASE_2EDGE再试。还有一点,TP_CS拉低前要确保SPI处于空闲状态,屏幕读写刚结束时立刻读触摸,偶尔会读到上一笔残留数据,加一个1ms延时再读就能避开。

5.5 现象:背光不亮但画面能看到残影

拿手电照屏幕能看到图标轮廓,说明驱动和显示正常,问题只在背光电路。有些模块的BL是低电平点亮,我拿到后习惯性先高后低,点不亮再翻转一次就正常了。还有的模块BL已经串联了限流电阻,直接接3.3V没问题,但有些商家模块把BL做过电平转换,需要接到5V才能完全点亮,翻车几次后我拿到新屏第一件事就是拿万用表量BL脚的静态电平。

6. 用示波器验证时序,把刷屏任务放进RTOS

验证驱动程序对不对,最快的方式不是看画面,而是读取屏幕ID。ILI9486支持读取ID命令0xD3,回传三个字节,第一个字节是制造商ID,通常能读到0x94。读ID能一次性确认SPI接线、时钟极性、初始化序列三件事。

static uint32_t ILI9486_ReadID(void) { uint8_t id[3] = {0, 0, 0}; uint8_t cmd = 0xD3; LCD_CS_LOW(); LCD_DC_LOW(); /* 命令阶段 */ HAL_SPI_Transmit(&hspi2, &cmd, 1, 100); LCD_DC_HIGH(); /* 数据阶段 */ HAL_SPI_Receive(&hspi2, id, 3, 100); LCD_CS_HIGH(); return (id[0] << 16) | (id[1] << 8) | id[2]; }

运行后把返回值通过串口打印,读到0x948066说明通信畅通。如果读回全0xFF,先检查模块是否引出了SDO引脚,有些简化模块压根没有读取功能。示波器量SCK引脚能看到9MHz方波,再量CS拉低期间是否完整覆盖了整个传输周期,这是排DMA花屏最直观的手段。

进RTOS后,我习惯把刷屏和触摸拆成两个任务。刷屏任务里DMA传输完成回调释放二值信号量,任务等信号量再拉高CS并标记缓冲可复用。触摸任务每20毫秒轮询一次XPT2046,做滤波后写入全局坐标结构体。需要双缓冲:一个缓冲区正被DMA读走,另一个缓冲区让UI任务填充颜色,两者交替才不会闪烁。

这套代码我反复烧录过几十次,最后一次踩坑是DMA参数配错导致刷新一半卡死,从那以后只要是DMA相关改动,我强制走一遍示波器抓CS时序再上机,希望这份血泪经验帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询