STM32F103 FSMC驱动ILI9486 3.5寸屏实战指南 简介这份资源面向嵌入式初学者与单片机开发者提供STM32F103驱动3.5寸ILI9486触摸屏的完整HAL库工程可直接烧录运行。内容涵盖SPI接口配置、GPIO控制、LCD初始化与绘图、XPT2046触摸读取及中断处理并附STM32CubeMX生成的初始化代码帮助读者跳过底层配置、专注图形界面与人机交互逻辑。压缩包共153个文件约4.77MB以c与h源码为主配合o、d、crf等编译中间文件以及uvprojx、ioc、hex、axf等工程与烧录文件结构完整便于二次开发。目前已有1702人学习下载适合作为STM32外设驱动与触摸屏应用的实战起点。1. 拿到一块 3.5 寸屏和一块 F103 板子怎么当天就点亮很多人第一次接触 STM32 驱动 TFT 屏卡住的地方不是代码写不出来而是根本不知道从哪一步开始。手上有一块 STM32F103 最小系统板一块 3.5 寸 ILI9486 并口屏杜邦线一堆然后打开 Keil 新建工程面对空白的 main.c 发呆。这个项目的价值就在于它把从 GPIO 初始化到 FSMC 时序配置、从 ILI9486 寄存器初始化到触摸坐标读取的整条链路全部写好了HAL 库版本编译通过后直接烧录就能看到画面。适合正在学 STM32 外设驱动、想做嵌入式 HMI 界面、或者需要一个稳定显示方案做二次开发的工程师。你不需要从零啃数据手册先跑通再回头理解每一行配置为什么这么写。2. 硬件链路与工程结构从 F103 到 ILI9486 是怎么连上的2.1 为什么选 FSMC 而不是普通 GPIO 模拟STM32F103 的 FSMC 外设本来是为外部存储器和 LCD 控制器设计的但它对 8080 并口时序的支持非常规整。ILI9486 支持 8 位和 16 位并口模式如果拿普通 GPIO 去翻转写时序刷一屏 480×320 的像素光是循环赋值就能把 CPU 占满刷新率惨不忍睹。用 FSMC 的好处是地址线和数据线由硬件自动产生时序CPU 只需要往一个映射地址写数据剩下的交给 FSMC 控制器。这也是这个项目里最核心的硬件设计决策。具体接线方面ILI9486 的 16 位数据线接 FSMC 的 D0~D15片选 CS 接 FSMC_NE写使能 WR 接 FSMC_NWE读使能 RD 接 FSMC_NOE命令/数据选择线 RS 接某根地址线通常是 A0 或 A16取决于工程里怎么映射。背光和复位各自占一个普通 GPIO。触摸部分如果是电阻屏X、X-、Y、Y- 接 ADC 通道或者 XPT2046 这类触摸控制器如果是电容屏通常走 I2C 接口。注意不同厂家的 3.5 寸 ILI9486 模块引脚定义可能不同尤其是 RS 接哪根地址线直接决定了后面代码里命令地址和数据地址的偏移量。拿到屏先看模块背面的丝印或者卖家给的引脚说明。2.2 工程目录一览这个项目的代码组织比较直白没有上 RTOS裸机跑主循环。目录结构大致如下Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f1xx_hal_conf.h │ │ └── stm32f1xx_it.h │ └── Src/ │ ├── main.c │ ├── stm32f1xx_hal_msp.c │ ├── stm32f1xx_it.c │ └── system_stm32f1xx.c ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ └── CMSIS/ ├── Hardware/ │ ├── lcd.c / lcd.h │ ├── touch.c / touch.h │ └── font.c / font.h └── MDK-ARM/ └── Project.uvprojxHardware目录是重点lcd.c里封装了 FSMC 初始化、ILI9486 寄存器配置、画点画线填充等基础绘图函数touch.c负责触摸采样和坐标换算font.c存的是字库点阵数据。Core里是 CubeMX 生成的标准框架main.c里调用初始化然后进主循环。2.3 FSMC 初始化代码逐段拆先看 FSMC 的 GPIO 和控制器配置。这段代码通常在lcd.c的LCD_Init()里或者单独抽一个MX_FSMC_Init()/* FSMC GPIO 配置数据线 D0~D15控制线 NOE/NWE/NE */ GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOD_CLK_ENABLE(); __HAL_RCC_GPIOE_CLK_ENABLE(); __HAL_RCC_FSMC_CLK_ENABLE(); /* PD0, PD1, PD4, PD5, PD8~PD15 作为 FSMC 数据/控制线 */ gpio.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5 | 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.Mode GPIO_MODE_AF_PP; /* 复用推挽输出 */ gpio.Speed GPIO_SPEED_FREQ_HIGH; /* 高速档保证时序余量 */ HAL_GPIO_Init(GPIOD, gpio); /* PE7~PE15 作为 FSMC 数据线高 8 位 */ gpio.Pin 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; HAL_GPIO_Init(GPIOE, gpio);这里的关键参数是GPIO_SPEED_FREQ_HIGH。FSMC 驱动并口屏时写周期可能压到几十纳秒GPIO 翻转速度不够会出现数据采样错误表现为花屏或者颜色偏移。另一个容易忽略的是GPIO_MODE_AF_PP必须配成复用推挽不能配成普通输出。接下来是 FSMC 控制器本身的时序参数FSMC_NORSRAM_TimingTypeDef timing {0}; FSMC_NORSRAM_InitTypeDef init {0}; init.NSBank FSMC_NORSRAM_BANK1; /* 使用 NE1 片选 */ init.DataAddressMux FSMC_DATA_ADDRESS_MUX_DISABLE; init.MemoryType FSMC_MEMORY_TYPE_SRAM; /* LCD 当 SRAM 访问 */ init.MemoryDataWidth FSMC_NORSRAM_MEM_BUS_WIDTH_16; /* 16 位并口 */ init.BurstAccessMode FSMC_BURST_ACCESS_MODE_DISABLE; init.WaitSignalPolarity FSMC_WAIT_SIGNAL_POLARITY_LOW; init.WriteOperation FSMC_WRITE_OPERATION_ENABLE; init.ExtendedMode FSMC_EXTENDED_MODE_DISABLE; init.AsynchronousWait FSMC_ASYNCHRONOUS_WAIT_DISABLE; init.WriteBurst FSMC_WRITE_BURST_DISABLE; /* 读时序地址建立 2 个 HCLK数据保持 5 个 HCLK */ timing.AddressSetupTime 2; timing.AddressHoldTime 0; timing.DataSetupTime 5; timing.BusTurnAroundDuration 0; timing.CLKDivision 0; timing.DataLatency 0; timing.AccessMode FSMC_ACCESS_MODE_A; HAL_FSMC_NORSRAM_Init(FSMC_NORSRAM_DEVICE, init); HAL_FSMC_NORSRAM_Timing_Init(FSMC_NORSRAM_DEVICE, timing, FSMC_NORSRAM_BANK1); HAL_FSMC_NORSRAM_WriteTiming_Init(FSMC_NORSRAM_DEVICE, timing, FSMC_NORSRAM_BANK1);DataSetupTime 5这个值不是随便填的。F103 在 72MHz 下一个 HCLK 约 13.9ns5 个 HCLK 大约 70ns。ILI9486 的写周期最小要求通常在 66ns 左右所以 5 是刚好够用的保守值。如果你把 HCLK 降到 48MHz这个值要相应加大。读时序可以比写时序更宽松因为读操作对时序容忍度更高。2.4 命令和数据的地址映射FSMC 把外部设备映射到一段内存地址上。BANK1 的基地址是0x60000000。RS 接在哪根地址线上决定了命令和数据的地址偏移#define LCD_BASE ((uint32_t)0x60000000) #define LCD_CMD_ADDR (LCD_BASE) /* RS 0 时写命令 */ #define LCD_DATA_ADDR (LCD_BASE 0x00020000) /* RS 1 时写数据 */ /* 写命令 */ void LCD_WriteCmd(uint16_t cmd) { *(volatile uint16_t *)LCD_CMD_ADDR cmd; } /* 写数据 */ void LCD_WriteData(uint16_t data) { *(volatile uint16_t *)LCD_DATA_ADDR data; }如果 RS 接的是 A16那偏移就是1 16即0x00010000接 A0 就是0x0000000216 位总线时地址要乘 2。这个偏移量搞错了现象是屏幕完全没反应或者只有背光亮。我一般会在初始化之后先读一下 ILI9486 的 ID 寄存器确认地址映射对了再往下走。3. ILI9486 初始化序列与绘图函数从寄存器配置到画点画线3.1 初始化序列为什么不能随便改顺序ILI9486 的上电初始化序列有几十条命令涉及电源控制、伽马校正、内存访问控制、像素格式等。这些命令的顺序是芯片手册规定的不能随意调换。比如必须先退出睡眠模式再配置像素格式最后开显示。顺序错了轻则颜色不对重则白屏。void LCD_Init(void) { /* 硬件复位 */ HAL_GPIO_WritePin(LCD_RST_PORT, LCD_RST_PIN, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(LCD_RST_PORT, LCD_RST_PIN, GPIO_PIN_SET); HAL_Delay(120); /* 退出睡眠 */ LCD_WriteCmd(0x01); /* 软件复位 */ HAL_Delay(120); /* 电源控制 A */ LCD_WriteCmd(0xCB); LCD_WriteData(0x39); LCD_WriteData(0x2C); LCD_WriteData(0x00); LCD_WriteData(0x34); LCD_WriteData(0x02); /* 电源控制 B */ LCD_WriteCmd(0xCF); LCD_WriteData(0x00); LCD_WriteData(0xC1); LCD_WriteData(0x30); /* 驱动时序控制 A */ LCD_WriteCmd(0xE8); LCD_WriteData(0x85); LCD_WriteData(0x00); LCD_WriteData(0x78); /* 内存访问控制设置扫描方向 */ LCD_WriteCmd(0x36); LCD_WriteData(0x48); /* 横屏模式BGR 顺序 */ /* 像素格式16 位 RGB565 */ LCD_WriteCmd(0x3A); LCD_WriteData(0x55); /* 开显示 */ LCD_WriteCmd(0x11); HAL_Delay(120); LCD_WriteCmd(0x29); }0x36这个寄存器控制扫描方向值不同对应横屏/竖屏和镜像翻转。0x48是横屏模式如果你的屏幕装反了改这个值比在代码里做坐标变换方便得多。0x3A设为0x55表示 RGB565 格式每个像素占 2 字节这是最常用的配置兼顾色彩和显存占用。3.2 画点函数是一切绘图的基础画点是所有图形操作的原子操作。它的效率直接决定了填充、画线、刷图的速度void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); /* 设置 1x1 窗口 */ LCD_WriteCmd(0x2C); /* 写显存命令 */ LCD_WriteData(color); } 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 0xFF); LCD_WriteData(x2 8); LCD_WriteData(x2 0xFF); LCD_WriteCmd(0x2B); /* 行地址设置 */ LCD_WriteData(y1 8); LCD_WriteData(y1 0xFF); LCD_WriteData(y2 8); LCD_WriteData(y2 0xFF); }LCD_SetWindow把要操作的区域框出来之后的0x2C写显存命令会在这个区域内自动递增地址。画一个点要发 9 次写操作4 次窗口设置 1 次命令 1 次数据效率不高。所以填充大面积区域时应该先设好窗口然后连续写数据而不是循环调用LCD_DrawPoint。3.3 区域填充和字符显示区域填充是刷背景、清屏、画色块的基础void LCD_Fill(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { uint32_t total (uint32_t)(x2 - x1 1) * (y2 - y1 1); LCD_SetWindow(x1, y1, x2, y2); LCD_WriteCmd(0x2C); while (total--) { LCD_WriteData(color); } }字符显示依赖字库。font.c里存的是 ASCII 字符的点阵数据每个字符 8×16 或 16×16 像素void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t fc, uint16_t bc, uint8_t size) { uint8_t i, j; uint8_t temp; uint8_t *pfont (uint8_t *)asc2_1608[(uint8_t)ch * 16]; /* 取字模 */ for (i 0; i 16; i) { temp pfont[i]; for (j 0; j 8; j) { if (temp 0x80) LCD_DrawPoint(x j, y i, fc); else LCD_DrawPoint(x j, y i, bc); temp 1; } } }这段代码逐位判断字模数据为 1 画前景色为 0 画背景色。asc2_1608是 8×16 的 ASCII 字库数组每个字符占 16 字节。如果要显示中文需要另外加汉字字库通常用取模软件生成。提示LCD_DrawPoint在字符显示里被频繁调用如果觉得刷字符慢可以把LCD_ShowChar改成先设窗口再连续写数据的方式速度能提升好几倍。4. 触摸采样与坐标映射让屏幕能点得准4.1 电阻触摸的 ADC 采样逻辑电阻屏的触摸检测原理是按下时上下两层导电层接触通过测量 X 和 Y 方向的电压分压来确定坐标。以 XPT2046 为例它通过 SPI 接口和 STM32 通信内部有 12 位 ADC#define TOUCH_CS_LOW() HAL_GPIO_WritePin(TOUCH_CS_PORT, TOUCH_CS_PIN, GPIO_PIN_RESET) #define TOUCH_CS_HIGH() HAL_GPIO_WritePin(TOUCH_CS_PORT, TOUCH_CS_PIN, GPIO_PIN_SET) uint16_t Touch_ReadADC(uint8_t cmd) { uint8_t i; uint16_t val 0; TOUCH_CS_LOW(); /* 发送命令字节 */ for (i 0; i 8; i) { TOUCH_CLK_LOW(); if (cmd 0x80) TOUCH_DIN_HIGH(); else TOUCH_DIN_LOW(); cmd 1; TOUCH_CLK_HIGH(); } /* 读取 12 位转换结果 */ for (i 0; i 12; i) { TOUCH_CLK_LOW(); val 1; TOUCH_CLK_HIGH(); if (HAL_GPIO_ReadPin(TOUCH_DOUT_PORT, TOUCH_DOUT_PIN)) val | 1; } TOUCH_CS_HIGH(); return val; }XPT2046 的 SPI 时序是软件模拟的因为它的时钟极性和标准 SPI 模式不完全一致用硬件 SPI 反而要调半天。命令字节里包含了通道选择位读 X 坐标和读 Y 坐标发的命令不同。4.2 坐标校准与映射ADC 读出来的原始值范围大概在 200~3800 之间需要映射到屏幕的 0~479 和 0~319。但每个屏幕的触摸层特性有差异必须做校准/* 校准参数通过触摸四个角计算得出 */ int32_t touch_x_min 200, touch_x_max 3800; int32_t touch_y_min 220, touch_y_max 3750; uint16_t Touch_GetX(void) { int32_t raw Touch_ReadADC(0xD0); /* 读 X 通道 */ int32_t x (raw - touch_x_min) * 480 / (touch_x_max - touch_x_min); if (x 0) x 0; if (x 479) x 479; return (uint16_t)x; }校准参数怎么来常见做法是在屏幕上显示四个十字光标依次点击记录每个点的 ADC 原始值然后算出最小值和最大值。如果屏幕方向是横屏X 和 Y 可能需要对调或者取反这取决于触摸层和显示层的相对安装方向。注意电阻屏的 ADC 值会随温度和按压力度漂移如果发现点击位置偶尔偏几个像素可以在软件里做多次采样取中值或者加一个简单的滑动平均滤波。4.3 触摸中断与轮询的取舍这个项目用的是轮询方式主循环里每隔一段时间读一次触摸判断有没有按下。优点是实现简单不需要配置外部中断。缺点是如果主循环里有耗时操作触摸响应会变迟钝。如果要做流畅的触摸交互建议把触摸检测放到定时器中断里比如 10ms 一次在中断里只做按下判断和坐标采样把结果存到全局变量主循环去读。这样即使主循环在刷屏触摸也不会丢。/* 定时器中断回调里做触摸扫描 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { static uint8_t cnt 0; if (cnt 2) { /* 20ms 一次 */ cnt 0; Touch_Scan(); } } }Touch_Scan()里做按下检测、坐标读取、滤波、更新全局触摸状态。主循环只需要判断touch_pressed标志位就行。5. 避坑与常见问题烧录后白屏、花屏、触摸不准怎么查5.1 白屏或背光亮但无显示现象烧录后屏幕背光亮但没有任何画面或者只有一片白色。原因最常见的是 FSMC 地址映射错误RS 接的地址线和代码里的偏移量不匹配。其次是初始化序列里某条命令的参数不对导致芯片没有正常退出睡眠模式。解决先用万用表确认 RS 实际接在哪根地址线上然后改LCD_CMD_ADDR和LCD_DATA_ADDR的偏移。如果地址没问题在初始化最后加一句读 ID 的代码通过串口打印出来确认能读到0x9486再往下查。5.2 花屏或颜色错乱现象屏幕有显示但颜色明显不对比如红色变蓝色或者画面有雪花点。原因颜色错乱通常是 RGB 和 BGR 顺序反了改0x36寄存器的值即可。雪花点一般是 FSMC 时序太快数据建立时间不够。解决把DataSetupTime从 5 改成 8 或 10 试试。如果改了时序还是花屏检查杜邦线是不是太长或者接触不良FSMC 高速信号对连线质量很敏感。5.3 触摸坐标偏移或跳变现象点击位置和实际响应位置差几十个像素或者手指没松开坐标就乱跳。原因校准参数不准确或者 ADC 采样时没有做滤波。电阻屏在按下瞬间和松开瞬间的 ADC 值不稳定需要加延时确认。解决重新做四角校准确保校准时的点击位置和屏幕显示的光标中心对齐。在Touch_Scan()里加一个简单的滤波连续读 5 次去掉最大值和最小值取中间 3 次的平均。5.4 刷屏速度慢现象填充整个屏幕需要几百毫秒动画明显卡顿。原因逐点画的方式效率太低每次画点都要重设窗口。解决大面积填充时先调一次LCD_SetWindow然后连续写0x2C数据。另外可以把 FSMC 的写时序适当收紧在保证稳定的前提下减少DataSetupTime。如果还是慢考虑用 DMA 把显存数据搬到 FSMC 数据地址CPU 不参与搬运。5.5 编译报错找不到 HAL 库文件现象换了一台电脑或者换了 Keil 版本后编译提示找不到stm32f1xx_hal.h。原因工程里的头文件搜索路径是绝对路径换环境后路径失效。解决在 Keil 的 Options for Target → C/C → Include Paths 里把路径改成相对路径比如..\Drivers\STM32F1xx_HAL_Driver\Inc。同时确认stm32f1xx_hal_conf.h里使能了 FSMC 模块默认这个文件里 FSMC 是关掉的。6. 二次开发改造从能跑到好用几个实用的优化方向拿到这个工程跑通之后下一步通常是把它改成自己项目能用的样子。我一般会从三个方向动手显示效率、触摸交互、代码结构。显示效率方面最直接的优化是把LCD_Fill改成用 DMA 传输。FSMC 的数据地址是固定的DMA 可以从内存里把颜色数据搬到 FSMC 数据寄存器CPU 腾出来做其他事。配置 DMA 时注意源地址递增、目标地址固定、传输宽度 16 位。刷一屏 480×320 的纯色用 DMA 大概能压到 20ms 以内比逐点写快一个数量级。触摸交互方面如果项目需要按钮、滑块这类控件建议在touch.c之上再封一层简单的事件框架。比如定义一个TouchEvent结构体包含按下、抬起、长按、滑动几种状态主循环里轮询这个状态而不是直接读坐标。这样上层界面代码不用关心底层是电阻屏还是电容屏换屏的时候只改驱动层。代码结构方面这个工程把 LCD 和触摸都放在Hardware目录下耦合度不高但lcd.c里混了 FSMC 初始化、ILI9486 命令、绘图函数三部分内容。如果要做成可复用的模块建议拆成三层fsmc_bus.c只管总线读写ili9486.c只管芯片寄存器和初始化lcd_gfx.c提供画点画线填充字符这些通用接口。换一款屏的时候只需要改ili9486.c上层绘图代码完全不用动。还有一个容易被忽略的点字库。font.c里的 ASCII 字库是固定大小的如果项目需要显示中文或者不同字号的英文需要自己用取模软件生成。我一般会把字库做成独立的.c文件每个字号一个数组用的时候通过函数指针或者宏来切换避免把所有字库都编译进最终固件里浪费 Flash。最后说一个血泪教训每次改完 FSMC 时序或者 ILI9486 初始化序列烧录之前先把屏幕的复位引脚手动拉低再松开确保芯片处于确定的初始状态。我有好几次因为芯片没复位干净改了代码烧进去现象没变白白排查了半天。从那以后我每次调屏都强制走一遍硬件复位再烧录省了很多玄学问题。希望帮到你。本文还有配套的精品资源点击获取