☰
STM32 TFTLCD显示驱动实战:FSMC/SPI初始化、画点与刷新优化
2026/10/30 8:57:05 网站建设 项目流程

简介:这是一份面向STM32初学者与嵌入式显示开发入门者的TFTLCD显示实验程序,围绕STM32平台下的液晶屏驱动与图形显示展开,亲测可运行,适合用来理解GPIO、SPI等外设配置以及LCD驱动芯片初始化流程。压缩包共40个文件,约87KB,以18个h头文件和11个c源文件为核心,辅以txt说明、Keil工程文件、hex目标文件、启动汇编、批处理与配置文件,覆盖工程组织、编译下载和实验说明等环节。内容按SYSTEM、USER、HARDWARE等模块组织,涉及延时、串口、按键、LED、OLED、LCD、定时器与看门狗等外设,便于读者从工程结构入手梳理显示程序的依赖关系。已有5190人学习下载,说明其作为入门参考具有一定认可度。读者可从中获得TFTLCD初始化、显示缓冲区管理、绘图函数调用和完整示例代码等实践素材,并借助使用说明与注意事项理解驱动流程、排查常见显示问题,为后续图形界面开发打基础。

1. 从“点不亮”到“能跑”:一套 STM32 TFTLCD 显示程序的适用边界

一块 240×320 的 TFTLCD 模组,排针焊好、插上开发板、烧进从网上抄来的初始化代码,屏幕却是纯白——十有八九不是屏坏了,而是初始化序列、RS 地址偏移或像素格式里有一项对不上。这份 STM32 的 TFTLCD 显示程序解决的就是这一段:把 FSMC 并口与 SPI 两种接口的底层读写、初始化序列、开窗画点、矩形填充、ASCII 与 16×16 中文点阵、RGB565 图片显示都封成可直接调用的函数,改几个宏就能换屏、换分辨率、换方向。它适合刚拿到模组、不想在初始化序列里熬上三天的新手,也适合要把显示层塞进现有工程、又不愿被某个图形库绑死的老手。前提说清楚:硬件连线正确、驱动 IC 与初始化序列匹配,软件替不了接错的线。

2. 接口选型:8080 并口与 SPI 的带宽账,差的不只是引脚数

2.1 一个像素的旅程:从 MCU 到 GRAM 到底走了几步

TFTLCD 模组本身带 GRAM,MCU 不直接操作液晶,只是往驱动 IC 的显存里写数。写一个像素就是写 2 字节(RGB565),这条路径上的每一步都要花时间。以 8080 并口为例:拉低片选、把 RS 拉低表示写命令、送命令码、把 RS 拉高、送 16 位数据、拉高片选。如果用通用 GPIO 模拟这套时序,一个像素少说十几个时钟周期,240×320 全屏就是 7 万多次 GPIO 翻转,刷一帧几百毫秒,动画基本没戏。真正让并口有意义的是 FSMC:把 TFTLCD 的接口当成一片外部 SRAM 来访问,读写时序由硬件产生,CPU 只发一条写指令,剩下的地址建立、数据保持、总线周转全交给控制器。所以选型的第一层不是“并口快还是 SPI 快”,而是“有没有硬件总线帮 CPU 扛住时序”。

2.2 带宽实测与取舍表:18MHz SPI 和 FSMC 到底差几倍

算一笔干净的账。一帧 240×320、16 位色,数据量是 240×320×2 = 153600 字节,约 150KB。SPI 在 18MHz 时钟下,理论吞吐是 18M/8 = 2.25MB/s,纯数据传输 68ms;实际还要加上每条命令的 DC 拉低拉高、片选切换、函数调用开销,80~110ms 是常态,也就是 9~12fps,做参数显示、菜单翻页够用,做波形和动画就卡。FSMC 16 位模式下,把数据保持时间配到 2 个 HCLK、地址建立 1 个 HCLK,72MHz 主频下一次 16 位写大约 70ns,理论接近 28MB/s,实际受限于驱动 IC 的写周期,跑 8~15ms 一帧很常见,60fps 有余量。这中间的差距不是“快一点”,是二十倍量级的差别,直接决定产品形态能不能成立。

对比项8080 并口 + FSMC四线 SPI
数据线数量8 位或 16 位,常用 161 根 MOSI
控制线CS、WR、RD、RS、RSTCS、SCK、MOSI、DC、RST
占引脚约 22~24 根约 6 根
全屏刷新(240×320)8~15ms80~110ms
CPU 占用极低,写指令即完成中等,逐字节等待或 DMA
适合场景动画、波形、摄像头预览参数面板、菜单、低频刷新
常见翻车点时序过快、走线过长时钟虚标、DC 电平错位

2.3 引脚预算与走线成本:被低估的那一半

新手容易只看速度,忽略布线的隐性成本。16 位并口要 16 根数据线加 6 根控制线,如果 MCU 引脚本来就紧张,这一下就吃掉大半,还要求这 16 根尽量在同一组端口上连续排列,否则 FSMC 根本映射不出来。更麻烦的是走线:16 根数据线如果长短差太多、又没有地线隔离,高速翻转时互相串扰,表现就是花屏、雪花点,而且这种故障时好时坏,非常玄学。SPI 只有 4 根信号线,排线可以做得很短很干净,代价是速度。我一般会这样分:桌面设备、有充足 PCB 空间、需要流畅刷新的,直接上 FSMC 并口;手持设备、引脚紧张、刷新频率低于 15fps 就够的,老老实实 SPI,把省下来的引脚拿去做按键和传感器。中间态也有——8 位并口,速度比 SPI 快三四倍,引脚只要十来根,很多人不知道这条路。

3. 工程落地:从引脚表到第一个像素上屏的四步

3.1 上电前的硬件自检清单

软件调试最怕硬件没准备好,先花五分钟把这几件事确认掉,能省掉后面一半的怀疑。第一,看模组丝印上的驱动型号,ILI9341、ST7789、ILI9488 的初始化序列差别很大,尤其 9488 是 18 位色,RGB565 写进去颜色会不对。第二,确认逻辑电平和供电,多数模组是 3.3V,背光 LED 串需要单独限流,直接接 3.3V 可能偏暗也可能烧。第三,确认 RST 是接 MCU 还是接了 RC 复位电路,接了 RC 的,软件里那次硬复位就不起作用,得靠软复位指令。第四,确认 RS(有的标 DC)接的是哪根地址线,这决定后面地址偏移怎么算,接错了表现是命令当数据写、屏幕永远不亮。第五,用万用表量一下背光引脚,背光不亮和驱动不工作是两种完全不同的故障,别混在一起查。

3.2 两个原语函数与 RS 地址偏移的算法

所有上层显示函数最终都落到两个动作:写命令、写数据。FSMC 接法下,靠地址线区分这两者。假设 RS 接在 FSMC_A16 上,16 位数据总线意味着地址每加 1 对应 2 字节,所以 A16 对应的偏移是 (1<<16)*2 = 0x20000。这是最容易算错的地方,很多人写成 0x10000,结果命令和数据全写进了同一个地址,屏幕毫无反应。

/* FSMC Bank1 NOR/SRAM1,NE1 接 CS,基址 0x60000000 */ #define LCD_BASE ((uint32_t)0x60000000) /* RS 接 A16:(1 << 16) * 2 bytes = 0x20000,写成 0x10000 是经典错误 */ #define LCD_CMD_ADDR (*(volatile uint16_t *)(LCD_BASE)) #define LCD_DAT_ADDR (*(volatile uint16_t *)(LCD_BASE + 0x20000)) static inline void LCD_WriteCmd(uint16_t cmd) { LCD_CMD_ADDR = cmd; /* RS = 0 */ } static inline void LCD_WriteData(uint16_t dat) { LCD_DAT_ADDR = dat; /* RS = 1 */ }

这两个函数写成static inline很关键,画一个点要调两次,一帧几万次调用,函数调用的开销在这个量级上不能忽略。如果 RS 接的是 A18,偏移就变成 (1<<18)*2 = 0x80000;接 A0 且用 8 位总线,偏移是 1。改之前先看清楚原理图上那根线的标号,别凭印象。

3.3 读 ID 与初始化序列:先自检再配置

初始化之前先读一次 ID,这是整段调试里性价比最高的一步。读出 0x9341,说明总线通、时序基本对;读出来是 0x0000 或 0xFFFF,说明根本没通信上,后面所有初始化都是白写。注意读 ID 需要 RD 引脚接上,很多模组为了省线把 RD 悬空,那就读不了,只能靠现象判断。

static uint16_t LCD_ReadID(void) { uint16_t id; LCD_WriteCmd(0x04); /* 读 D3~D0 的 ID 寄存器 */ LCD_ReadData(); /* 第一次读是空读,丢掉,这一步别省 */ id = LCD_ReadData() << 8; id |= LCD_ReadData(); return id; } static void LCD_Init(void) { LCD_RST_LOW(); HAL_Delay(20); LCD_RST_HIGH(); HAL_Delay(120); /* 复位后必须等 120ms,省掉会随机不亮 */ LCD_WriteCmd(0xCF); LCD_WriteData(0x00); LCD_WriteData(0xC1); LCD_WriteData(0x30); LCD_WriteCmd(0xED); LCD_WriteData(0x64); LCD_WriteData(0x03); LCD_WriteData(0x12); LCD_WriteData(0x81); LCD_WriteCmd(0xE8); LCD_WriteData(0x85); LCD_WriteData(0x00); LCD_WriteData(0x78); LCD_WriteCmd(0xCB); LCD_WriteData(0x39); LCD_WriteData(0x2C); LCD_WriteData(0x00); LCD_WriteData(0x34); LCD_WriteData(0x02); LCD_WriteCmd(0xF7); LCD_WriteData(0x20); LCD_WriteCmd(0xC0); LCD_WriteData(0x23); /* 电源控制,影响亮度与对比 */ LCD_WriteCmd(0xC1); LCD_WriteData(0x10); LCD_WriteCmd(0xC5); LCD_WriteData(0x3E); LCD_WriteData(0x28); LCD_WriteCmd(0x36); LCD_WriteData(0x48); /* MADCTL:方向与 RGB/BGR */ LCD_WriteCmd(0x3A); LCD_WriteData(0x55); /* 16 位色,必须和上层一致 */ LCD_WriteCmd(0xB1); LCD_WriteData(0x00); LCD_WriteData(0x18); LCD_WriteCmd(0xB6); LCD_WriteData(0x08); LCD_WriteData(0x82); LCD_WriteData(0x27); LCD_WriteCmd(0x11); /* 退出睡眠 */ HAL_Delay(120); /* 这里等不够会白屏 */ LCD_WriteCmd(0x29); /* 开显示 */ }

参数里最值得盯的是三个:0x36 的 MADCTL,它同时控制行列扫描方向、是否镜像、RGB 还是 BGR,颜色红蓝互换基本都在这里改;0x3A 的像素格式,0x55 是 16 位,0x66 是 18 位,写错了颜色会发灰或错位;0x11 之后那 120ms,是驱动内部稳压和振荡器起来的等待时间,很多“上电偶尔白屏”就是这里省了。

3.4 方向校准与第一个色块

初始化跑通后,先别急着显示文字,画三个色块确认三件事:坐标方向对不对、颜色通道对不对、边界有没有偏移。开窗指令 0x2A 设列地址、0x2B 设行地址,范围必须和分辨率严格一致,多一行就出现花边。

void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); /* 列地址 */ LCD_WriteData(x0 >> 8); LCD_WriteData(x0 & 0xFF); LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteCmd(0x2B); /* 行地址 */ LCD_WriteData(y0 >> 8); LCD_WriteData(y0 & 0xFF); LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteCmd(0x2C); /* 开始写 GRAM */ } void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { if (x >= LCD_W || y >= LCD_H) return; /* 越界判断,省掉会写乱整屏 */ LCD_SetWindow(x, y, x, y); LCD_WriteData(color); }

验证顺序建议是:先画 (0,0) 一个红点,看它出现在哪个角;再画 (LCD_W-1, LCD_H-1),看它在不在对角。两点位置对,说明开窗和分辨率匹配;如果点出现在屏幕中间偏一点的位置,说明分辨率宏写错了,常见于把 240×320 的屏按 320×240 配。颜色方面,红蓝互换就是 MADCTL 的 BGR 位没设对,把 0x48 换成 0x40 试试,这个改动只要一行,值得反复确认。

4. 显示层封装:画点、开窗、字符与中文字模怎么写才不返工

4.1 画点是原子操作,也是性能黑洞

所有图形最终都归到画点,但直接拿画点拼矩形是灾难。每画一个点都要发一次开窗命令,等于 5 条命令加 1 条数据,一个 100×100 的方块就是 10000 次开窗,几秒钟都画不完。正确的分层是:画点只服务于零散像素和曲线,矩形、图片、字符全部走“开窗一次 + 连续写数据”的路径。这个分层看着简单,但大量现成代码把字符显示写成逐点调用 DrawPoint,一屏文字刷下来几百毫秒,用户看到的就是一行一行往下爬。

4.2 区域填充:决定界面流畅度的关键函数

填充函数的写法直接决定刷新率。开窗一次,然后连续写 n 个颜色值,中间不做任何地址操作,让驱动 IC 自己递增 GRAM 指针。这里有个细节:循环变量用 32 位而不是 16 位,240×320 的像素总数 76800 已经超过 16 位的一半,用 uint16_t 会溢出成死循环。

void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint32_t n = (uint32_t)(x1 - x0 + 1) * (y1 - y0 + 1); /* 必须 32 位 */ LCD_SetWindow(x0, y0, x1, y1); while (n--) { LCD_WriteData(color); } }

如果编译器开了优化,这个循环会被展开成连续的存储指令,速度已经很接近总线极限。想再快,只能上 DMA(SPI 场景)或者把整屏数据准备好一次写入,但那时候 RAM 就成瓶颈了:150KB 的帧缓冲,很多小容量型号根本放不下,这也是为什么很多方案选择局部刷新而不是双缓冲。

4.3 ASCII 字模:取模约定不统一是乱码的根源

字模乱码几乎全部来自约定不一致。取模软件里要确认四件事:阴码还是阳码(1 表示点亮还是熄灭)、逐行还是逐列取模、高位在前还是低位在前、字宽字高。常见做法是用 12×6 的 ASCII 字库,每行 1 字节,共 12 字节一个字,逐行取模、高位在前、阴码,符合这个约定的字库可以直接用,不符合的自己转一遍。

/* 12x6 ASCII,每字符 12 字节,逐行取模,高位在前,1 为点亮 */ void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t fc, uint16_t bc, uint8_t size) { uint8_t i, j; const uint8_t *p = &asc2_1206[(ch - ' ') * 12]; LCD_SetWindow(x, y, x + 6 - 1, y + 12 - 1); for (i = 0; i < 12; i++) { uint8_t bits = p[i]; for (j = 0; j < 6; j++) { LCD_WriteData((bits & 0x80) ? fc : bc); bits <<= 1; } } }

这里一次开窗覆盖整个字符区域,然后按位判断写前景色或背景色,不再逐点开窗,一个字 72 个像素只发一次开窗命令。如果想做透明背景(叠加在图片上),把背景色分支改成跳过不写是不行的,因为 GRAM 指针会自动递增,跳过会错位;正确做法是先读后写,或者干脆不做透明,用背景色覆盖。

4.4 16×16 中文与 RGB565 图片的落地

中文点阵常见是 16×16,每个字 32 字节,逐行取模,每行 2 字节。字库一般用数组或者外部 Flash 存储,前者占 Flash 空间但访问快,后者省空间但要写读取接口。字模数组的索引方式是(汉字编码 - 起始编码) * 32,如果字库不是从第一个汉字开始连续排列的,就得建一张索引表,这也是很多人抄了字库却显示乱码的原因——字库本身没问题,是索引算错了。

图片显示的关键在颜色空间转换。电脑上的图片是 RGB888,屏要 RGB565,转换公式别写错位:

/* RGB888 转 RGB565:R 取高 5 位,G 取高 6 位,B 取高 5 位 */ uint16_t RGB888_To_565(uint8_t r, uint8_t g, uint8_t b) { return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3); }

用取模软件导出图片时,选 RGB565、高位在前、不勾选字节交换,导出的数组直接开窗写入即可。如果显示出来是花屏的彩色噪点,八成是字节序反了,把数组按字节对调再试;如果是整体偏绿或偏紫,多半是 RGB 和 BGR 搞混了,回去改 MADCTL。

5. 排查手册:白屏、花屏、偏色的五个真实翻车现场

5.1 上电与总线类故障

现象一:烧完程序屏幕全白,背光亮但没有任何图像。原因通常有三层:复位没生效、初始化序列不匹配、总线根本没通。先量 RST 引脚有没有一次低高低的过程,如果 RST 接的是 RC 电路,软件那次拉低等于没做;再看驱动型号,把 ILI9341 的序列烧到 ST7789 上就是纯白。解决办法是接上 RD 读一次 ID,读不到就查 CS、WR、RS 三根线的连接和 FSMC 配置,读到了但对不上型号就换序列。这一步不做,后面全是盲猜。

现象二:屏幕有图像但布满雪花点,动一下排线会变。原因是并口时序太快或者走线太长。FSMC 的数据保持时间如果配到 1 个 HCLK,在杜邦线连着的情况下基本必花。解决是把 DATAST 从 1 加到 3 甚至 5,先把速度降下来确认稳定,再逐步往上试;同时把 16 根数据线尽量缩短、加地线隔离。这类问题在面包板上尤其明显,能焊板子就别用杜邦线。

现象三:只有屏幕一部分能显示,另一部分是花屏或纯色。这是分辨率与开窗范围不匹配的典型表现。常见于把 240×320 的屏按 320×240 配置,或者初始化里 0x2A/0x2B 的结束地址写成了 239/319 之外的数。解决是把 LCD_W、LCD_H 两个宏和实际屏参数对齐,然后单独测四个角坐标,看是否都在屏内。

5.2 图像与字符类故障

现象四:红色显示成蓝色,整体色调偏。原因就是 MADCTL 的 BGR 位。0x36 参数里第 3 位控制 RGB/BGR 顺序,0x48 和 0x40 的区别就在这一位。解决很简单,把这两个值来回换一次,哪个对用哪个。注意有些屏出厂时排线就是 BGR 接法,所以同型号不同批次可能要改,别以为是自己代码错了。

现象五:中文显示成方块或者乱码,英文正常。英文正常说明总线和 ASCII 字库都没问题,问题一定在字模约定或者索引。先确认取模设置是不是和代码里假设的一致(逐行、高位在前、阴码),再确认字库起始编码和索引公式。我见过一次是字库按 GB2312 区位码排序,代码却按 Unicode 减偏移,两边差了几千个字节,显示出来全是随机图形。解决办法是用一个小工具打印前几个字的点阵,肉眼比对是不是想要的形状,这一步比读代码快。

6. 从点亮到流畅:局部刷新与 SPI DMA 的实测技巧

界面能显示之后,下一个瓶颈通常是刷新。全屏刷一帧 150KB,SPI 场景下要 80ms 以上,直接做动画一定卡。真正有效的做法是脏矩形:只重绘发生变化的那一小块区域,比如一个数字从 25 变成 26,只刷那 12×6 个像素。实现上维护一个待刷新矩形,把所有绘制操作都映射成对这个矩形的扩展,然后在主循环末尾统一刷一次。这个思路不复杂,但能把刷新量从 150KB 降到几百字节,帧率提升是数量级的。

SPI 接口还有一招是 DMA。注意 FSMC 挂的是外部总线,DMA 不参与,所以这招只对 SPI 有效。做法是开窗之后把 DC 拉高,然后启动 DMA 把一行像素数据搬出去,用传输完成中断切到下一行。中间最容易翻车的是 DC 电平——DMA 搬运期间 DC 必须保持高,一旦被别的任务拉低,那一行数据就被当成命令,屏幕上会出现一条横向的彩色噪点。我一般在启动 DMA 前把 DC 引脚锁住,搬完再释放。

/* 按行搬运,避免一次占满 RAM;buf 里是已打包好的 RGB565 大端字节序 */ void LCD_FlushLine_DMA(uint16_t y, uint8_t *buf) { LCD_SetWindow(0, y, LCD_W - 1, y); /* 开一行窗口,内部已发 0x2C */ LCD_DC_HIGH(); /* 之后的内容全是数据,不能再拉低 */ HAL_SPI_Transmit_DMA(&hspi1, buf, LCD_W * 2); /* 在 DMA 完成回调里拉高 CS、切下一行,不要在这里死等 */ }

实测一组数据供参考:240×320 屏,SPI 18MHz,全屏填充约 95ms,逐行 DMA 后降到 78ms,提升有限,因为瓶颈在总线上限;但脏矩形把单次刷新压到 1ms 以内,界面响应立刻跟手。所以顺序应该是先做局部刷新,再考虑 DMA,反过来做收益很小。从那以后我每换一块屏,第一件事都是烧一个只画三色横条和四个角点的小工程,确认 ID、方向、颜色三件事都对,再往上叠字库和图片——这套自检流程帮我省下的时间,比任何调试技巧都多。希望帮到你。

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

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

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

立即咨询