☰
STM32F103 HAL库驱动ILI9486触摸屏:代码实战与避坑指南
2026/10/17 7:40:12 网站建设 项目流程

简介:面向STM32嵌入式开发者,这套基于HAL库的完整工程可直接烧录到STM32F103,用于驱动一块三点五寸ILI9486触摸屏,可以快速解决显示与触摸输入的开发难题。压缩包共一百五十三个文件,大小约四点七七MB,其中包含五十一个头文件与二十一个C语言源文件,以及Keil工程、CubeMX初始化配置和hex烧录文件,便于直接使用与二次开发。代码基于STM32CubeMX生成,通过SPI接口与ILI9486通信,并集成XPT2046触摸控制器的ADC读取与坐标转换,覆盖GPIO配置、外设初始化、画点画线及文本显示等完整驱动流程。其中HAL_ILI9486L驱动源码层次结构清晰,可逐函数理解LCD时序与SPI收发逻辑。已有一千七百零二人学习下载,对希望快速上手图形界面开发或参考HAL库驱动写法的读者,既能学习外设交互原理,也可直接移植到其他项目。

1. 一套现成的STM32F103驱动ILI9486触摸屏方案:HAL库、全代码可烧录

如果你手头正好有一块3.5寸、320×480的ILI9486电阻触摸屏幕,又不想从寄存器到驱动器一点点啃数据手册,这套基于STM32F103的HAL库工程会很对胃口。整个代码用CubeMX生成的工程骨架打底,把LCD初始化和触摸采样两层驱动全部剥开写清楚,从烧录到点亮屏幕大概只要半小时,很适合「想快速搭一个带触摸屏的小仪表盘」的单片机开发者和嵌入式硬件入门者。我也拆过不少类似代码,多数版本是标准库或者直接跑寄存器操作,换成HAL库之后反而清爽很多,因为它把SPI、DMA、Flash这些外设的细节都封装好了,后面想往F407或者其它ARM Cortex-M平台迁移会省掉大量重复工作。

2. 工程结构拆解:为什么这套HAL库代码适合直接拿来改

2.1 架构分层:标准库退场之后,HAL库是怎么把LCD驱动包起来的

很多老工程还在用标准外设库(Standard Peripheral Library),但ST早就停更了,新出的Cube工具链全部围绕HAL库展开。这套代码用CubeMX生成初始工程,外设初始化分布在Core/Src/main.c和对应的外设文件里,而LCD和触摸相关代码单独抽成lcd.c、lcd.h和touch.c、touch.h,改动时基本不用碰其它文件。

文件结构大致是这样:

Project/ ├─ Core/ │ ├─ Inc/ // main.h、lcd.h、touch.h │ └─ Src/ // main.c、lcd.c、touch.c、stm32f1xx_it.c ├─ Drivers/ │ ├─ CMSIS/ │ └─ STM32F1xx_HAL_Driver/ └─ .ioc // CubeMX工程文件,改引脚时还要用

我在写串口、SPI这类驱动时有一个习惯:凡是跨板子的部分尽量按「外设句柄」来组织。HAL库天然就是这个模式,比如SPI的所有操作都围绕SPI_HandleTypeDef结构体,所以lcd.c里你经常看到类似spi->Instance == SPI1的判断,这样做的好处是代码换到SPI2引脚时,外层函数根本不用改,只改一下句柄初始化和宏定义。这套代码也沿用了这个风格,全局搜索LcdSpiHandle、TouchSpiHandle就能快速定位到底用的是哪路SPI。

  • 优点:外设初始化全部交给CubeMX生成,不用手配时钟
  • 微控制器适配度:F103的72MHz主频跑320×480屏,SPI刷屏不追求高帧率的话足够用
  • 扩展性:后续想把显示代码挪到F407上,只需重做CubeMX外设配置并保持lcd.c接口不变

2.2 引脚分配与CubeMX配置:一表看清所有接线

这块屏接线常见的坑是「LCD和触摸各占一路SPI」,实际上很多模块厂家把两者挂在同一路SPI上,靠片选区分。这套代码采用共用一个SPI1、CS分别控制的方案,引脚分配如下:

引脚作用说明
PA5SPI1_SCKLCD与触摸共用时钟
PA7SPI1_MOSI4线SPI写模式,不读MISO
PB6LCD_CSLCD片选,低有效
PB7LCD_DC1为数据,0为命令
PB8LCD_RST复位引脚,低电平拉低再拉高
PB9LCD_BL背光控制,高电平点亮
PB12TP_CS触摸屏片选
PB13TP_IRQ触摸中断/忙信号

CubeMX里SPI1配置成Master、8Bit、Mode 0,波特率预分频选4。F103的APB2时钟是72MHz,4分频后得到18MHz SPI时钟,正好卡住ILI9486的规格上限,再快就很容易出现花屏和随机条纹。背光和复位用普通GPIO输出,触摸IRQ在CubeMX里配置成GPIO_EXTI或普通输入都可以,我建议用普通输入加轮询,省掉一个中断优先级问题。

2.3 从烧录到点亮:验证工程的三个检查点

拿到代码第一件事不是改功能,而是先确认硬件能不能动起来。F103没有USB DFU,通常用SWD接口或UART一键下载电路。烧录步骤是:先打开CubeMX工程,把给的代码重新生成一次,确认没有文件缺失;再用J-Flash或STM32CubeProgrammer直接下载编译好的hex;最后看屏幕是否出现出厂自检画面或者调色板色块,背光有没有被点亮。

我一般会在main函数里加三个自检点,就像下面这样:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); // 背光、复位、片选等GPIO MX_SPI1_Init(); // SPI速率设为18M LCD_Init(); // ILI9486初始化 LCD_Clear(RGB565(255, 0, 0)); // 红屏自检 HAL_Delay(500); LCD_Clear(RGB565(0, 255, 0)); // 绿屏自检 while (1) { // 主循环 } }

如果看到红绿交替画面,说明基本链路全部打通:GPIO配置正常、SPI通信正常、ILI9486芯片已经响应初始化命令。若始终白屏,优先查背光PB9有没有拉高,再查复位延时,这两处是最容易翻车的玄学点。

3. LCD驱动核心拆解:从初始化到DMA刷屏的完整链路

3.1 ILI9486初始化序列:先让屏醒来再设置扫描方向

ILI9486和ST7735这类小屏最大的不同点在于它是18位色,像素格式通常配置成RGB666,一个像素点要发3个字节。初始化本质是一串命令加延时,代码里把固定序列放在数组里循环发送。下面截取最关键的初始化函数部分:

void LCD_Init(void) { // 复位序列:拉低至少10ms再拉高 HAL_GPIO_WritePin(LCD_RST_PORT, LCD_RST_PIN, GPIO_PIN_RESET); HAL_Delay(50); HAL_GPIO_WritePin(LCD_RST_PORT, LCD_RST_PIN, GPIO_PIN_SET); HAL_Delay(50); // 让屏进入睡眠唤醒状态 lcd_send_cmd(0x11); // Sleep Out HAL_Delay(120); // 像素格式设为 RGB666 lcd_send_cmd(0x3A); lcd_send_data(0x66); // MADCTL: 控制扫描方向和颜色顺序,0x48 适合320x480竖屏 lcd_send_cmd(0x36); lcd_send_data(0x48); lcd_send_cmd(0x29); // Display ON LCD_SetWindow(0, 0, 319, 479); LCD_Clear(0x0000); // 清屏为黑色 }

这里lcd_send_data也写成写寄存器格式,但DC引脚电平会区分命令和数据。0x3A命令里的0x66是选中18位色,很多改过ST7735代码的人习惯写0x05(RGB565),照搬过来就是花屏、颜色错乱的第一大原因。0x36是方向控制寄存器,MADCTL的第七位到第四位分别控制行列方向与RGB顺序,0x48这个值表示竖屏且颜色顺序为RGB,如果你手上的屏是横屏显示,可以试0x28或0x68,改完之后触摸坐标也要跟着转90度,不然GUI上点击位置永远对不上。

3.2 窗口设置与写像素:刷新画布的核心原语

屏幕本质上就是一个矩形显存,往任意位置画图前,先要给屏设定一个地址窗口,之后所有送往显存的数据都会落到窗口内。ILI9486的窗口命令是0x2A(列地址)和0x2B(行地址),配置窗口后只需要连续发像素数据即可。这里给出一个高效的画矩形填充函数:

// 设置显示窗口 void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { lcd_send_cmd(0x2A); // 列地址 lcd_send_data(x0 >> 8); lcd_send_data(x0 & 0xFF); lcd_send_data(x1 >> 8); lcd_send_data(x1 & 0xFF); lcd_send_cmd(0x2B); // 行地址 lcd_send_data(y0 >> 8); lcd_send_data(y0 & 0xFF); lcd_send_data(y1 >> 8); lcd_send_data(y1 & 0xFF); lcd_send_cmd(0x2C); // 开始写入GRAM } // 用单色填充整个窗口,适合做背景、状态条 void LCD_Fill_Rect(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint32_t size = (uint32_t)(x1 - x0 + 1) * (y1 - y0 + 1); // RGB565拆成R(5bit) G(6bit) B(5bit)后,按填到RGB666要求补位 uint8_t rgb[3]; rgb[0] = (color >> 8) & 0xF8; // R 高字节 rgb[1] = (color >> 3) & 0xFC; // G 取6位 rgb[2] = (color << 3) & 0xF8; // B LCD_SetWindow(x0, y0, x1, y1); HAL_GPIO_WritePin(LCD_DC_PORT, LCD_DC_PIN, GPIO_PIN_SET); // 连续发送每个像素的3字节 for (uint32_t i = 0; i < size; i++) { spi_write_byte(rgb[0]); spi_write_byte(rgb[1]); spi_write_byte(rgb[2]); } }

调用LCD_SetWindow前一定要先算好坐标是否越界,ILI9486在窗口非法的情况下有时会整屏乱画。RGB565到RGB666的转换原理是直接保留高位:红色5bit要左移3位变6bit吗?其实ILI9486的RGB666是每色深6位,所以R取565的高5位再补零变成6位,G本身就是6位直接保留,B取高5位补零。命令0x2C写GRAM时,DC必须置为数据模式,这一步写错了整个画面颜色会完全乱掉。

3.3 SPI加DMA刷屏:从卡顿到可用

如果只用HAL_SPI_Transmit在主循环里一字节一字节发,一个320×480的全屏就要46万个字节,在18MHz时钟下也要大约半秒多,看起来像慢动作。真正让刷屏可以接受的关键是引入DMA传输,把SPI从CPU轮询中解放出来。代码里对大面积填充建议走DMA:

_Static_assert(sizeof(LCD_DMABuffer) % 2 == 0, "DMA buffer size must be even"); void LCD_FillRect_DMA(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint16_t w = x1 - x0 + 1; uint16_t h = y1 - y0 + 1; uint32_t size = w * h; LCD_SetWindow(x0, y0, x1, y1); HAL_GPIO_WritePin(LCD_DC_PORT, LCD_DC_PIN, GPIO_PIN_SET); HAL_SPI_Transmit_DMA(&hspi1, lcdDmaBuf, 1); // 触发一次DMA,先形成时序 }

真实工程中更常用的做法是准备一块稍大的静态数组,把颜色字节按RGB666填充进去,再调用HAL_SPI_Transmit_DMA(&hspi1, buf, len)。DMA把SPI的TX缓冲占满后自动搬运,CPU可以去做触摸采样或者界面逻辑。这里有个容易踩的坑:DMA和CPU共用数组时,必须避免读写冲突,至少保证在DMA传输完成标志位置位前不要修改缓冲区,否则画面会出现撕裂条纹。我一般用HAL_SPI_TxCpltCallback中置一个全局标志位来同步。

  • 传输速度: 18MHz SPI下,DMA刷全屏理论上约76ms,肉眼可接受的动态刷新
  • 内存消耗: RGB666一像素三字节,DMA缓冲填满需要约1.4KB,对小内存芯片也友好
  • 兼容性注意: 滚动的波形图表要固定用DMA填充,否则帧率低且会拖累整个系统

4. 触摸驱动与坐标校准:从ADC原始值到屏幕坐标的完整公式

4.1 XPT2046采样时序:读懂那个片选信号和12位数据

这块3.5寸屏的触摸部分几乎都是XPT2046或者兼容的ADS7846方案,通过SPI读取。触摸屏相当于两个电阻分压层,读取时给X方向通电,从Y方向分压读出电压,再反过来给Y方向通电读取。XPT2046的核心是一颗12位ADC,命令格式见代码:

static uint16_t TP_ReadADC(uint8_t cmd) { uint8_t buf[2]; uint16_t value = 0; HAL_GPIO_WritePin(TP_CS_PORT, TP_CS_PIN, GPIO_PIN_RESET); // 拉低片选 SPI_TxRxByte(cmd); // 发送读X或读Y的命令 value = (SPI_TxRxByte(0) << 8) | SPI_TxRxByte(0); // 读取12位 HAL_GPIO_WritePin(TP_CS_PORT, TP_CS_PIN, GPIO_PIN_SET); // 释放片选 return value >> 4; // 12位实际有效 }

命令字节的bit7是起始位必须为1,bit6到bit4选通道:读X时写0x94,读Y时写0xD4。读取触摸前最好让片选保持拉低一小段时间,两块电阻层建立稳定电压需要一点时间和电容充电过程,没有这个稳定过程读回来的值会跳动很大。

采样回来之后不能直接用,因为触摸屏本身会有噪声和按压抖动。我用的是最简单的滑动平均滤波:连续采5次,去掉最大最小值,再对剩下3个值取平均。这样触摸点在静止状态下基本能稳定在±2个ADC码以内,UI点击不会乱跳。如果发现点了以后指针飞走,多半是5次采样间隔太短,加一个1ms到2ms的延时,让前端稳定。

4.2 两点校准法:把ADC区间映射到屏幕分辨率

触摸屏出厂时电阻层的均匀性和安装位置有偏差,必须做校准,否则会出现“点右上角却点到左上角”的灾难。校准的基本思路是:记录四个屏幕角落的ADC读数,建立线性映射。代码里用一个结构体保存校准参数:

typedef struct { uint16_t left; // X方向最小ADC值 uint16_t right; // X方向最大ADC值 uint16_t top; // Y方向最小ADC值 uint16_t bottom; // Y方向最大ADC值 } TP_Calibration; // 将ADC原始坐标转换为屏幕像素坐标 void TP_GetScreenPoint(uint16_t adc_x, uint16_t adc_y, uint16_t *screen_x, uint16_t *screen_y) { // 防止除零和越界 if (calib.right <= calib.left) calib.right = calib.left + 1; if (calib.bottom <= calib.top) calib.bottom = calib.top + 1; uint32_t x = ((uint32_t)(adc_x - calib.left) * 320) / (calib.right - calib.left); uint32_t y = ((uint32_t)(adc_y - calib.top) * 480) / (calib.bottom - calib.top); // 截断非法范围,触到边缘也能保证在屏幕内 *screen_x = (x >= 320) ? 319 : (uint16_t)x; *screen_y = (y >= 480) ? 479 : (uint16_t)y; }

公式里的乘除顺序很讲究,如果先除再加,丢掉的低位精度会变成触摸不跟手的根源。我推荐用32位中间变量,把分子算成ADC偏移量,再乘以屏幕分辨率,最后除以ADC量程,整个过程中间不丢小数部分。这套线性校准在小屏幕上足够准确,误差通常在1~2个像素内。

4.3 校准数据写进Flash避免二次校准

每次开机都校准是反人类的,工程上把校准参数存到F103片内Flash末尾一个扇区,比如0x0800F800,那里通常没有被代码占用。读写Flash时先擦后写,代码如下:

void TP_SaveCalib(const TP_Calibration *cal) { // 解锁Flash并擦除用户扇区 HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase; erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.PageAddress = CALIB_ADDR; erase.NbPages = 1; uint32_t pageErr = 0; HAL_FLASHEx_Erase(&erase, &pageErr); uint32_t addr = CALIB_ADDR; uint32_t data = (cal->left << 16) | cal->right; HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, data); addr += 4; // 继续写top/bottom… HAL_FLASH_Lock(); }

上电时读取这个Flash区域,检查魔数是否有效,有效就直接采用,无效再进入校准流程。这样量产时每台设备只需要手工校准一次,后边重启不会丢参数。常见失误是没确认Flash扇区地址没有被代码占用,写进去之后程序直接跑飞,我在这个坑上翻过车,一度以为代码中毒了,最后发现是趴在自己的校准数据上。

5. 常见问题与避坑记录:白屏、花色、触摸偏差逐个排查

5.1 白屏不亮:先查背光,再查复位

现象:烧录成功后屏幕完全没反应,背光也不亮。解决顺序先看GPIO配置,PB9有没有正确拉高;再看LCD复位引脚,初始化函数里延时不够,ILI9486上电后没完成内部复位就接收命令会直接忽略全部指令。推荐在上电后做一次200ms以上的延时再开始发命令,比手册要求的更长一点,可靠性高很多。

5.2 花屏是SPI速度太快的典型症状

现象:显示颜色有彩色噪点、乱纹,一字排开看像雪花。原因是SPI时钟被配置成了36MHz(2分频),超过了屏模块能承受的极限;或者数据线太长导致信号边沿过尖。解决:把SPI预分频改成4分频,降到18MHz;同时检查接线长度,超过20厘米时加100Ω串联电阻或降低时钟。

这条是高频踩坑点,尤其是手边只有高速逻辑分析仪才能看见波形异常的人,往往是「看着别人能跑我也行」的玄学判断导致翻车。记住F103的APB2是72MHz,SPI预分频2就是36MHz,对这个屏来说没有容错空间。

5.3 触摸坐标乱跳:ADC采样间隔太短

现象:点固定位置,读回来的坐标在一个大范围内漂移。原因是XPT2046的前端RC滤波电路的建立时间被忽略了,命令发出后立刻读数据,采样到的电压还在爬升。解决:在发出通道选择命令和读取数据之间加1ms稳定延时,或者连续采三次,每次间隔2ms,取中位值。

5.4 画面颜色蓝红颠倒:ADJCTL或像素格式不对

现象:屏幕能显示,但红色变成蓝色,肤色变成紫色。检查0x36命令的RGB顺序位和0x3A的像素格式。ILI9486有时候初始化时把RGB和BGR顺序搞反,调整0x36即可;如果颜色还是怪,再看一下转换补位是否把绿色当成六位而不是五位的逻辑做错了。避免办法是在LCD_Fill_Rect里单独用RGB565(255,0,0)测试一次纯红,出问题就一目了然。

5.5 DMA缓冲区和CPU共用:刷新时出现割裂条纹

现象:用DMA刷进度条时,图片上下两部分颜色或内容不一致,有时出现横纹。原因是DMA还没发完所有数据,CPU就把缓冲区的数据改了,DMA读到新数据造成画面撕裂。解决:给DMA中断回调加一个dmaBusy标志,主循环传递数据之前先检查是否忙,忙就等待,或者准备一块双缓冲交替使用。

6. 二次开发:往LVGL和RTOS迁移时的改造路径与内存预算

6.1 从裸机驱动向LVGL移植的缓冲策略

如果你想在这套代码上做真正的图形界面,LVGL是非常自然的下一步,但F103的小内存是最大的门槛。LVGL做全屏双缓冲对STM32F103C8T6的20KB SRAM来说根本不现实,一个480×320屏幕的RGB565缓冲就要307KB,这还不算UI对象的开销。最务实的做法是让LVGL只在局部刷新:把缓冲区设为横屏一行或者1/10屏,脏矩形机制只刷改动区域。绘制回调里把LVGL送来的像素数据先转成RGB666,再调用前面写的LCD_FillRect_DMA往屏上推。这样静态界面几乎不卡,动态控件稍微牺牲一点流畅度换内存余量。

6.2 任务拆分与时序同步

接上FreeRTOS后,我把实时性要求拆成三块:触摸任务每隔20ms采集一次坐标并做滤波;显示任务监听触摸任务发送的队列,只刷新按下的控件;主UI任务负责菜单逻辑和参数更新。DMA传输完成中断和触摸的SPI访问如果都挂在SPI1上,就要避免两个任务同时在一条总线上发送数据,我给SPI1加了一个互斥量,谁先拿到锁谁先刷。

动手改之前,我把自己的教训写在最后:任何一次改动,不管是加字体还是调屏参,都先在main函数里保留那张红绿自检色块,确认硬件没挂再去怀疑代码;从那以后我每次烧录都会强制走一遍三色屏自检,界面逻辑出问题时,第一反应不再是把初始化代码推倒重写,而是先看色块是否正常。希望这个习惯能帮你少走几段弯路,祝你能一次点亮这块屏。

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

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

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

立即咨询