STM32驱动ILI9341屏幕:HAL库、触摸与DMA优化实战 简介本资源是一套面向嵌入式初学者与STM32进阶开发者的ILI9341 TFT LCD驱动实践方案聚焦于降低图形显示开发门槛解决无DMA/中断依赖下实现稳定触控与高效绘图的常见痛点。资源包共35个文件含13个头文件.h定义接口与配置、11个源文件.c实现驱动核心逻辑如SPI通信、图形渲染、触摸校准、5个备份文件.zbak及README.md等辅助文档整体压缩后仅12MB轻量易集成。已有149人学习下载适用于STM32F7系列以Nucleo-F746ZG为参考平台的课程实验、毕业设计或小型HMI原型开发。读者可直接获取模块化程度高、结构清晰的完整工程模板包含HAL库初始化框架、ILI9341主控驱动、增强型GFX图形库支持字体/几何图形绘制及电阻式触摸屏控制模块并附带详细调用范例与环境配置指引便于快速移植、裁剪功能或二次扩展。 一次性项目搞定的屏我前前后后刷过不下十块从最早的标准库手撕寄存器到后来全面切到HAL库中间踩过的坑能装一箩筐。这篇就围绕STM32 ILI9341这个经典组合把HAL库驱动写法、触摸屏控制、以及常见的性能优化和调试方法一次说透。不管你是刚入门想点亮一块屏还是已经在做带触摸交互的小项目这篇都能给你一些直接能用的东西。1. 项目定位为什么要费劲写一个ILI9341驱动库1.1 这块屏幕能做什么解决了什么问题ILI9341是当前小尺寸TFT LCD里最主流的控制芯片之一常见的2.4寸、2.8寸、3.2寸屏分辨率基本都是240x320用的就是这颗IC。它支持SPI、8080并口、RGB等多达三种接口但在STM32小项目里最常用的就是SPI接口因为省引脚、连线简单一个SPI外设加几个GPIO就能把屏点亮。这块屏能解决的问题很具体你需要一个带色彩的交互界面时它比OLED屏幕信息量更大显示中文、图片、小图标都更从容它比串口屏便宜且可控性高想怎么画就怎么画刷新逻辑完全掌握在你自己手里。配合触摸屏就能做一个带按钮、滑条、状态显示的人机界面像温度控制面板、小型仪表盘、菜单设置页这种需求它都能扛。1.2 HAL库与标准库之争我的选型理由我发现不少朋友还在纠结要不要学HAL库尤其是看过江科大风格的标准库教程入门的总觉得HAL库包装太多、代码啰嗦。但说实话2024年了ST官方已经停止维护标准库新出的芯片基本只有HAL库和LL库可选。你迟早要切过来。HAL库的好处在于抽象层统一。同样是SPI发送标准库里你得操作SPI1-DR、查SPI1-SR换一颗芯片可能寄存器就不一样了HAL库里HAL_SPI_Transmit()基本通用代码迁移动成本极低。坏处就是看似慢但实际测下来在48MHz~72MHz主频下SPI刷屏的瓶颈往往在液晶屏自身的像素时钟上而不是HAL库的封装开销。后面我会用DMA把CPU从刷屏里彻底解放出来那点封装损耗基本可以忽略。所以这个项目我直接用STM32CubeMX生成HAL工程再用SPIDMA刷新。整个代码分两层底层跟硬件相关的封装得薄一点上层显示逻辑写得厚一点以后就算换了屏或者换了主控改底层就行。2. 硬件层SPI接线与ILI9341的初始化密码2.1 SPI参数怎么配时钟、极性与相位ILI9341的SPI接口支持Mode 0CPOL0CPHA0和Mode 3CPOL1CPHA1我习惯用Mode 0因为大多数STM32默认SPI配置就是Mode 0不容易出错。时钟频率方面ILI9341的数据手册标称SPI写时钟可以达到10MHz左右实际用起来普通驱动板建议先从1MHz起步点亮之后再往上调排除干扰之后F103平台跑到9MHz没有任何问题F407以上平台把SPI时钟写到18MHz快速刷新场景下也稳定我实测过直接拉到最高时钟会导致花屏因为杜邦线太长、或者模块上拉电阻匹配不好时信号沿不干净。建议PCB走线或者短线连接时满速跑飞线连接时适当降速。CubeMX里的配置大概是这样的思路SPI1全双工主机、8位数据、MSB先行、软件NSS因为ILI9341的CS是普通GPIO控制更灵活分频系数按你的主频去算。2.2 初始化序列那些看着像魔法的寄存器命令ILI9341的初始化序列是大家最容易抄错的地方。原因是市面上流传的初始化代码版本太多有标准寄存器版本有厂家精简版不同屏幕模组对颜色格式、扫描方向、Gamma校正的要求还不一样。我说一下我的处理方式直接拿Adafruit或ST官方给的标准初始化序列打底把关键的寄存器抽出来解释一下0x01软复位开场必发0x11退出睡眠模式发完要延时120ms以上不然屏幕不亮0x3A像素格式设置RGB565或RGB666我固定用0x55即16位色RGB5650x36MADCTL控制扫描方向和颜色顺序这个直接决定画面是不是倒的、颜色是不是反的0x29打开显示常见新手操作是三四十行初始化代码一股脑全发发完发现画面歪了或者颜色不对然后开始怀疑屏坏了。其实只要抓住上面这几个关键寄存器大部分问题都能定位。扫描方向错了就改0x36参数颜色不对基本就是RGB还是BGR的开关没拨对。2.3 供电与背光新手最容易忽略的坑我见过太多人配置好了所有GPIO和SPI屏幕就是不亮仔细一看背光引脚悬空了。背光控制引脚LEDA/BLK需要接一个GPIO拉高或者直接接3.3V。供电电流这块也得注意。2.8寸屏背光全开时电流能到50mA左右如果用STM32芯片的3.3V LDO直接带电压会掉影响SPI信号电平。我一般建议单独用一颗LDO给屏幕供电或者至少保证3.3V供电余量充足。还有一个坑是逻辑电平问题。很多便宜的ILI9341模块板上自带了电平转换电路但有的没有如果STM32是5V供电、IO输出5V电平直接把SPI信号灌到ILI9341里可能烧坏模块。虽然大部分模块用3.3V供电还带保护但稳妥起见在接线前看一下模块背面有没有TXB0104或者类似的电平转换芯片。3. 驱动库实现从HAL底层封装到API设计3.1 最底层的两个函数写命令与写数据驱动库的核心其实就两个函数写命令和写数据。ILI9341的数据/命令选择由DC引脚的电平决定——DC低电平发的是命令高电平发的是数据。配合CS片选、WR上升沿锁存一个完整的SPI写周期就是拉低CS、设置DC、SPI发送字节、拉高CS。HAL库写起来大概是这个感觉void LCD_WriteCmd(uint8_t cmd) { LCD_CS_LOW(); LCD_DC_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); LCD_CS_HIGH(); } void LCD_WriteData(uint8_t data) { LCD_CS_LOW(); LCD_DC_HIGH(); HAL_SPI_Transmit(hspi1, data, 1, HAL_MAX_DELAY); LCD_CS_HIGH(); }这个实现没问题但如果你后面要刷整屏一字节一字节地发太慢了。所以通常还会加一个批量写数据函数直接传缓冲区指针和长度void LCD_WriteDataBuffer(uint8_t *buf, uint32_t len) { LCD_CS_LOW(); LCD_DC_HIGH(); HAL_SPI_Transmit(hspi1, buf, len, HAL_MAX_DELAY); LCD_CS_HIGH(); }有了这三个函数后面所有画点画线都是在这之上叠加。需要注意的一点是HAL_SPI_Transmit的Timeout参数我一般填HAL_MAX_DELAY因为SPI主机模式发送是同步阻塞的正常情况下不会超时。如果你在中断里调用并且填了有限超时一旦系统繁忙就会返回HAL_TIMEOUT然后后续状态机混乱。这个坑我在早期调试串口空闲中断时也踩过类似的。3.2 显示驱动框架的层级划分一个能长期维护的显示驱动不能把所有函数堆在一个文件里。我习惯分成三层硬件抽象层HAL底层管SPI、GPIO、DMA只暴露LCD_WriteCmd、LCD_WriteDataBuffer这几个函数驱动核心层管ILI9341的寄存器操作坐标窗口设置、颜色填充、屏幕旋转、清屏等应用层画点、画线、画矩形、显示字符、显示图片、UI组件分层的好处是你调试触摸屏时不需要动显示底层你换主控平台时只需要改硬件抽象层。很多人的代码写成一坨后期加个按钮都要在几百行里找函数就是因为没分层。坐标窗口这个机制是ILI9341刷屏的核心。它的原理是先告诉屏幕一个矩形区域的范围CASET行地址、PASET列地址之后所有写入的像素数据会自动填充到这个矩形中。我封装了这样一个函数void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); // CASET LCD_WriteData(x0 8); LCD_WriteData(x0 0xFF); LCD_WriteData(x1 8); LCD_WriteData(x1 0xFF); LCD_WriteCmd(0x2B); // PASET LCD_WriteData(y0 8); LCD_WriteData(y0 0xFF); LCD_WriteData(y1 8); LCD_WriteData(y1 0xFF); LCD_WriteCmd(0x2C); // RAMWR }设置完窗口之后紧接着推送的像素数据就会按从左到右、从上到下的顺序填满这个窗口。这个机制是后面所有加速手段的基础。3.3 画点、画线、填充的加速技巧画点最简单设置窗口为单像素点然后写一个RGB565颜色值。但刷整屏如果逐点画要发240x32076800次设置窗口命令光是命令开销就够喝一壶了。所以实际写驱动时画大面积色块都是先设置一个窗口然后连续写数据。画一条水平线只要设置一个宽为len、高为1的窗口然后连续发len个颜色值。画垂直线类似。这样比逐点画快几十倍。填充整个屏幕或者某个矩形也是同样的道理只不过窗口更大。我把填充函数封装成void LCD_FillRect(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint32_t pixel_count (x1 - x0 1) * (y1 - y0 1); uint32_t i; LCD_SetWindow(x0, y0, x1, y1); LCD_DC_HIGH(); LCD_CS_LOW(); for (i 0; i pixel_count; i) { HAL_SPI_Transmit(hspi1, (uint8_t *)color, 2, HAL_MAX_DELAY); } LCD_CS_HIGH(); }但注意这里(uint8_t *)color是平台依赖的STM32小端模式下颜色低字节在前而ILI9341期望高字节在前所以直接发color会导致红蓝互换。我一般定义一个联合体或者手动拆字节uint8_t color_buf[2] { color 8, color 0xFF };这种细节最后都会体现为为什么颜色不对的玄学问题后面排查章节再细说。如果想要更高的填充效率可以开一个局部缓冲区把颜色字节拼好再一次性HAL_SPI_Transmit发出。比如缓冲区256字节一次发128个像素减少HAL调用次数速度能再涨一截。4. 触摸屏控制坐标读取、滤波与两点校准4.1 XPT2046是怎么把按压变成数字的这块屏配套的触摸方案通常是一颗XPT2046或ADS7846芯片驱动的是四线电阻式触摸屏。原理说白了就是触摸屏上下左右各有一条导电层按下时上下层接触通过分压电阻网络X轴和Y轴各产生一个随触摸位置变化的模拟电压触摸芯片通过内部ADC把电压转换成12位数字值再通过SPI读出来。所以触摸读数读到的不是像素坐标而是原始ADC值。这个原始值跟屏幕分辨率没有直接关系它取决于触摸屏的导电层均匀性和ADC参考电压。这也是为什么必须做校准——原始值到像素坐标的映射每块屏都不一样。XPT2046的SPI读取时序有一点特殊它是一个典型的双向SPI器件主机发命令字节它边收边发把上次转换结果在下一个字节的时钟里送出来。所以一次完整的触摸读取通常要连续收发三个字节第一个字节是命令后两个字节里含12位有效数据。我的读取函数大概长这样static uint16_t TP_ReadADC(uint8_t cmd) { uint8_t buf[3] {0}; uint8_t recv[3] {0}; buf[0] cmd; HAL_GPIO_WritePin(TP_CS_GPIO_Port, TP_CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi2, buf, recv, 3, HAL_MAX_DELAY); HAL_GPIO_WritePin(TP_CS_GPIO_Port, TP_CS_Pin, GPIO_PIN_SET); return ((uint16_t)recv[1] 8 | recv[2]) 3; }我习惯把触摸芯片挂独立的SPI外设因为触摸读取频繁且时序要求高跟显示SPI混在一起容易出现互相干扰。如果硬件上已经共用了SPI那需要加锁机制麻烦很多。4.2 触摸坐标读取与滤波直接用TP_ReadADC读出来的值噪声比较大手指按压时轻微抖动会导致ADC值波动几十甚至上百直接映射成坐标就会在界面上乱跳。所以滤波是必须的。我用的方案是简单的中值滤波加滑动平均连续读5次排序取中间值再把中间值跟上次的值做一次加权平均。这个方法算下来延迟小、效果也不错uint16_t TP_ReadFiltered(uint8_t cmd) { uint16_t samples[5]; for (int i 0; i 5; i) { samples[i] TP_ReadADC(cmd); } // 排序取中值 for (int i 0; i 4; i) { for (int 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[2]; }排序5个元素的开销在72MHz主频下可以忽略。如果你需要更高采样率减少到3次采样也够用但边缘位置点靠屏幕边界时噪声会明显增大我建议保持5次采样不降。按压检测也不只是读取坐标这么简单。XPT2046有一个PENIRQ引脚触摸按下时拉低。硬件上把它接到STM32的外部中断引脚就能实现按下才读坐标、松开不读的机制。没有这个引脚也问题不大可以用读取Y轴时的压力值判断但延迟会高一点。4.3 两点校准法的原理与代码校准这件事很多教程直接忽略默认原始ADC值和屏幕坐标是线性关系然后通过测量屏幕两个角的原始值做缩放。这个粗糙做法在屏幕尺寸小、精度要求低时能用但一旦屏幕贴了保护膜或者触摸层老化映射就会偏移。我的做法是经典的两点校准加线性插值。校准原理很简单屏幕上有两个已知的像素位置比如60, 60和180, 220用户触摸这两个点记录下对应的原始ADC值。然后根据ADC值变化是线性的这一假设计算每个像素坐标对应X方向和Y方向上的比例系数scale_x (raw_x2 - raw_x1) / (pixel_x2 - pixel_x1) scale_y (raw_y2 - raw_y1) / (pixel_y2 - pixel_y1)之后任何触摸原始值换算成像素坐标就用pixel_x (raw_x - raw_x1) / scale_x pixel_x1 pixel_y (raw_y - raw_y1) / scale_y pixel_y1注意X轴的原始值通常跟屏幕Y方向有关因为触摸屏X电极和Y电极的摆放位置跟LCD行列不一定是正对应的所以校准参数矩阵实际可能包含交叉项。很多现成UI库用的是五点校准甚至九点校准来解决畸变问题。但对于简单的按钮交互两点加方向判断就够了。我实际项目中是这么处理的上电后如果检测到Flash里没有有效的校准参数就在屏幕上显示两个校准点让用户依次点击然后把参数存到Flash里的固定扇区。以后每次上电直接读取不用重复校准。4.4 做一个小Demo触摸屏控制温度的软件说一个真实做过的小项目可以算是触摸屏控制温度的软件的落地版用STM32F103C8T6 ILI9341触摸屏 DS18B20温度传感器 一个继电器控制加热棒做成一个恒温控制面板。界面分三个区域顶部显示实时温度中间显示目标温度底部是加号和减号两个触摸按钮。触摸屏负责交互用户按加号目标温度增加0.5度按减号减少0.5度主循环里读DS18B20温度跟目标温度比较决定继电器通断。核心代码其实很直接while (1) { TP_Scan(); // 扫描触摸更新触摸坐标 if (touch_pressed) { if (pt_in_rect(touch_x, touch_y, btn_plus)) { target_temp 0.5f; if (target_temp 40.0f) target_temp 40.0f; } if (pt_in_rect(touch_x, touch_y, btn_minus)) { target_temp - 0.5f; if (target_temp 10.0f) target_temp 10.0f; } } current_temp DS18B20_GetTemp(); if (current_temp target_temp) { HAL_GPIO_WritePin(RELAY_PIN, GPIO_PIN_RESET); // 接通加热 } else { HAL_GPIO_WritePin(RELAY_PIN, GPIO_PIN_SET); // 断开加热 } UI_Update(current_temp, target_temp); HAL_Delay(50); }这个项目里的关键点其实不在温度控制逻辑而在UI状态的刷新策略。如果每50ms重绘一次整个界面屏幕会闪烁得很厉害。我的做法是只在数据变化时才刷新对应区域。比如温度从25.0变成25.2只需要把那一个小区域重绘而不是整屏刷新。触摸坐标的命中检测也不难判断触点是否落在按钮矩形内就行但要留一点容错范围因为用户手指点按的位置跟视觉中心会有偏移。我给按钮矩形扩展了5个像素的命中区域实测误触率明显降低。5. 性能优化DMA刷新与显示策略5.1 为什么刷屏会卡瓶颈在哪一块240x320的屏幕RGB565格式一帧数据是240x320x2 153600字节。如果用8MHz SPI刷理论最少耗时19.2毫秒但这是纯数据搬运时间还没算命令开销和循环开销。如果不小心用了阻塞方式一字节一字节发实际刷新一帧可能要100毫秒以上也就是10帧不到肉眼可见地卡顿。刷屏最忌讳的就是在for循环里反复调用HAL_SPI_Transmit。每调用一次HAL函数都有参数检查、状态判断、循环这些CPU周期积累起来非常可观。5.2 用DMA把CPU从刷屏中解放出来正确做法是SPI DMA传输。CubeMX里把SPI的发送DMA请求打开生成代码后发送大块数据直接调用HAL_SPI_Transmit_DMA(hspi1, buf, len);DMA搬运数据不占CPU发送期间CPU可以继续跑逻辑代码。但有个地方要特别注意DMA传输完成后需要检查传输完成标志或者注册传输完成回调不能立刻修改buf或者再次启动DMA否则会数据错乱。我的建议是维护一个简单的DMA完成状态标志volatile uint8_t spi_dma_busy 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi hspi1) { spi_dma_busy 0; } }发送前检查spi_dma_busy如果还在忙就等待否则直接启动新的DMA传输。在刷整幅图片或者全屏填充时DMA效果最为明显。CPU可以在DMA搬运数据的同时执行其他任务比如读取触摸、更新温度、处理按键整个系统的响应性会好很多。5.3 局部刷新和缓冲区的取舍即使有DMA整屏刷新一帧20毫秒的代价还是不能随便付。界面交互场景里真正高效的做法是局部刷新哪块变了刷哪块。我一般的UI刷新策略是静态背景只画一次不重复刷动态数据区域比如温度数值、进度条单独划定矩形区域数据变化时只刷新这个区域触摸按钮只在按下和释放时各刷一次避免持续刷新缓冲区方面小内存单片机放不下全屏帧缓冲区153KB但开一个很小的行缓冲区或者矩形缓冲区是可行的。比如在STM32F103上开一个1KB的缓冲区填充矩形时就先填满缓冲区再DMA发出去而不是逐像素循环。这样既发挥DMA优势又不占用太多RAM。如果你用的是F407或者F429这类带LTDC的芯片可以考虑SDRAM 全屏帧缓冲但F103级别就不要想这件事了局部刷新是最现实的方案。6. 常见问题与排查实录6.1 上电黑屏/白屏的排查顺序这是最多人遇到的问题。我排查顺序一般是这样的第一确认背光有没有亮。背光亮但屏幕黑说明电源和背光正常问题在初始化或SPI通信背光不亮先查供电和背光引脚。第二确认复位引脚是否拉高。有些模块的RESET引脚必须由MCU控制上电拉低再拉高完成复位。如果模块是RC复位电路需要把初始化里的软复位延时拉长。第三确认SPI时钟和引脚配置。用逻辑分析仪或者示波器看SCLK是否有波形MOSI上是否有数据CS/DC电平是否正常。没有示波器的话可以在初始化代码里加一个GPIO翻转的调试点看程序有没有跑进去。第四确认初始化序列是否完整。有些精简版初始化代码省略了延时导致屏幕在睡眠模式里没出来就直接写数据屏幕自然不亮。我习惯于在退出睡眠0x11后固定延时120ms以上打开显示0x29后再延时20ms。6.2 颜色不对、花屏怎么解决颜色不对十有八九是RGB字节序问题。ILI9341默认接收的RGB565格式高字节在前RRRRRGGGGGGBBBBB。但是STM32在内存里是小端16位数的高字节存在高地址如果你直接把uint16_t数组通过DMA发送发送顺序是低字节在前红蓝就会互换。解决办法是发送前把颜色值转换一次即color_byte0 color 8; color_byte1 color 0xFF或者反过来发。花屏一般是SPI时钟太快、或者时序不对导致的。可以先降低SPI分频系数看看花屏是否消失。如果降速正常说明是信号完整性问题优先检查杜邦线长度和供电。另外花屏也可能是DMA传输未完成就切换了DC/CS引脚破坏了一帧数据的完整性需要检查DMA完成回调是否真的触发了。6.3 触摸漂移、触点乱跳怎么办触摸漂移最常见的原因是校准参数不对或者压根没校准。先做一个原始值测试程序把TP_ReadADC读到的X/Y原始值实时打印到串口用手指点屏幕四个角和中心观察数值是否呈单调变化、范围是否合理。如果某个方向的原始值完全不变化检查触摸芯片的对应通道配置可能是命令字节写错了。XPT2046选择X通道和Y通道的命令不一样而且最后两位控制PDPower Down模式我一般用0x90读Y坐标、0xD0读X坐标这样的经典配置。如果单点采样时坐标乱跳多半是滤波没做好把采样次数加上去就行。还有一种情况是触摸屏的VCC纹波大特别是背光PWM调光频率设置不当的时候ADC参考电压被干扰导致读数漂移。解决办法是触摸芯片VCC加一个10uF电容并且把背光PWM频率提高到1kHz以上。6.4 一张速查表现象可能原因排查办法背光不亮供电不足或背光引脚配置错误测电压、检查BL引脚GPIO状态背光亮但黑屏初始化未完成、SPI不通信逻辑分析仪看SCLK/MOSI波形画面花屏SPI时钟过快、DMA时序错乱降速测试、检查DMA完成标志颜色红蓝互换RGB字节序错误发送前交换高低字节中文显示乱码字库取模方式错误确认取模为逐行式还是逐列式触摸无响应触摸IC供电异常、SPI配置错误读原始值打印串口触摸坐标往一边偏未校准或校准参数错误重新执行两点校准并保存校准参数触摸偶尔乱跳滤波不足、电源纹波大增加采样次数、加强滤波、加电容7. 写在最后一点个人体会驱动ILI9341这个活儿说难不难说简单也不简单。难的地方不在点亮那一瞬间的兴奋而在后续的工程化细节怎么把驱动写得简洁易维护怎么把刷新效率提上去怎么让触摸交互不飘、不卡、不误触。这些靠看芯片手册和抄代码都学不来得一块板子一块板子地试一个坑一个坑地踩。我个人的习惯是每做完一个项目就把驱动代码重新整理一遍把硬件相关的部分和算法逻辑彻底分开。这套ILI9341的驱动库我在F103C8、F103ZET、F407上分别跑过只需要改SPI句柄和几个引脚宏其余代码几乎原封不动。把底层接口抽象好了后面换屏只是换驱动换主控也只是改底层整个项目的生命周期会舒服很多。最后再分享一个小技巧调试触摸屏界面时不要直接在真实触摸事件上加逻辑先在屏幕固定位置打几个调试坐标点用串口把原始坐标、校准后坐标、按钮命中结果都打出来逐个环节排查。这个习惯能让你在半小时内解决别人折腾一整天的触摸问题。本文还有配套的精品资源点击获取