简介:面向STM32嵌入式初学者与需要完成TFTLCD显示实验的开发者,这是一套经过实际测试可运行的TFTLCD显示程序,重点解决液晶屏初始化、驱动控制、显存管理与图像绘制等入门问题,也适合作为课程实验和开发板例程参考。压缩包共40个文件,约87KB,以18个.h头文件和11个.c源文件为主体,配有Keil工程配置、启动汇编、HEX烧录文件及少量说明文本,目录按SYSTEM、LCD、KEY、TIMER、LED、WDG等模块组织,便于按外设拆分阅读。已有5190人学习下载,说明其在STM32显示类资源中具有一定参考热度。资源围绕ALIENTEK MINISTM32实验10展开,可帮助读者理解GPIO与SPI时序配置、LCD驱动芯片初始化、字符与图形显示流程,并通过完整工程快速对照实验现象、积累嵌入式显示排错思路。
1. 一块屏点亮之前:这套 STM32 TFTLCD 程序到底能省你几天
手里那块 2.8 寸 TFT 模块,背面丝印印着控制器型号,卖家发来的示例工程打开一看,引脚对不上、初始化序列里夹着几行来历不明的延时。烧进去不是白屏就是花屏,示波器一测,片选和数据线的时序根本对不上。这一步卡住的人不在少数,后面画点、写字、贴图全得等着。这套 STM32 TFTLCD 显示程序要解决的就是这个卡点:把 FSMC 并口和 SPI 两种接法下的驱动层封装好,初始化序列按控制器手册逐条对齐,上层给出画点、画线、字符、图片四类基础显示接口,改完引脚就能出图。
它适合正在做嵌入式课设、要给设备补一块人机界面、或者手头有闲置屏想先验证硬件好坏的从业者。程序不追求做成通用图形库,目标是把“点亮—出字—出图”这条最短路径铺平,剩下的图形算法自己往上叠就行。下面从驱动层怎么拆、工程怎么跑通、显示内容怎么组织、翻车点在哪几处,一路说到用 DMA 把帧率拉起来。
2. 驱动层拆解:从控制器 ID 到 FSMC 写时序的三个参数
驱动层是整个程序里最不该随便改的部分,也是最容易改错的部分。很多人一上来就照抄网上的初始化数组,结果屏能亮但颜色不对,或者能出图但一刷新就花。问题往往不在初始化数组本身,而在两件事:控制器认错了、总线时序没对齐。这一章把这两个根节点拆开讲。
2.1 先认清屏上的控制器,别对着丝印发呆
丝印上写的通常是模组型号,不是控制器型号。同一块模组可能换过几批控制器,长得一模一样,指令集却不同。判断控制器最可靠的办法是读 ID,而不是看卖家描述。常见控制器里,ILI9341 读 ID4 会返回 0x009341,ST7789 读 ID 会返回 0x8585 一类值,SSD1963 的读 ID 又是另一套编码。程序里最好把读 ID 单独做成一个函数,上电后先打印出来,这一步能省掉后面大量瞎猜。
/* 读控制器 ID:8080 并口下需要处理假读,第一次读到的是总线残留 */ uint32_t LCD_ReadID(void) { uint32_t id = 0; LCD_WR_REG(0xD3); /* ILI9341 的读 ID4 指令;ST7789 用 0x04 */ LCD_RD_DATA(); /* 丢弃第一次假读 */ id = (uint32_t)LCD_RD_DATA() << 24; id |= (uint32_t)LCD_RD_DATA() << 16; id |= (uint32_t)LCD_RD_DATA() << 8; id |= (uint32_t)LCD_RD_DATA(); return id; }这段代码的关键不在读几位,而在那个被丢弃的第一次读。8080 并口读操作有个固定动作,第一次读回来的数据是上一次总线上的残留,业内叫假读,不处理就会整体错位一个字节。另外,读操作要求数据线在读取瞬间切为输入方向,FSMC 模式下这个切换由硬件完成,但读时序参数必须单独配,不能只配写时序。如果读出来是 0xFFFFFF 或 0x000000,先别怀疑控制器型号,回头查背光和片选,这两个没弄对,读什么都是全 1 或全 0。
2.2 FSMC 并口模式下,时序参数该按图算还是按经验填
FSMC 驱动 TFTLCD 走的是 NOR/PSRAM 模式 A,核心参数有三个:ADDSET(地址建立时间)、DATAST(数据建立时间)、ADDHLD(地址保持时间,模式 A 下一般不用)。手册给出的写周期近似值是(ADDSET + DATAST + 1) × HCLK,读周期还要再加上总线周转时间。这个公式只能当起点用,因为屏模组上的排线、电平转换芯片都会引入额外延迟,最终得靠“先宽后紧”的办法试出来。我一般会让 DATAST 从偏大的值开始,确认能稳定出图后再逐格往下压,压到出现花屏就退一格。
| HCLK | ADDSET | DATAST | 近似写周期 | 适用场景 |
|---|---|---|---|---|
| 72 MHz | 2 | 6 | 约 125 ns | 排线较长的模组,保守起点 |
| 72 MHz | 1 | 4 | 约 83 ns | 板载屏,短排线 |
| 168 MHz | 3 | 10 | 约 83 ns | F4 系列,按比例放大 |
| 168 MHz | 2 | 6 | 约 53 ns | 时序余量较大的模组 |
下面这段是 F1 系列寄存器写法,重点看每个字段在表达什么,换到标准库或 HAL 时对应字段名会变,逻辑不变。
/* FSMC Bank1 NE1 + A16 做 RS:地址第 16 位决定写寄存器还是写数据 */ #define LCD_BASE ((uint32_t)0x6C000000) #define LCD_REG_ADDR (LCD_BASE) /* A16 = 0,写寄存器 */ #define LCD_RAM_ADDR (LCD_BASE | (1 << 16)) /* A16 = 1,写数据 */ void LCD_FSMC_Config(void) { /* BCR1:存储块使能、SRAM 类型、16 位数据宽度、写使能 */ FSMC_Bank1->BTCR[0] = (1 << 0) /* MBKEN */ | (0 << 2) /* MTYP = 00,SRAM */ | (1 << 4) /* MWID = 01,16 位 */ | (1 << 12); /* WREN */ /* BTR1:模式 A,读写共用同一组时序 */ FSMC_Bank1->BTCR[1] = (2 << 0) /* ADDSET = 2 */ | (6 << 8) /* DATAST = 6 */ | (0 << 16); /* 不开扩展模式 */ }参数说明:ADDSET 决定片选拉低后到读写信号有效之间的等待,DATAST 决定读写信号有效到数据被锁存的窗口。两个值都太小,数据没建立就被采走,表现为花屏或偶发错位;太大则刷新变慢,但不会出错。调试阶段宁可先大后小。还有一个容易忽略的点:如果 RS 接的是 A16 而不是 A17,LCD_RAM_ADDR里的偏移要跟着改,改错的表现是寄存器写进去了、数据没写进去,屏上什么都不显示。
2.3 初始化序列不能照抄,但可以按模板改
初始化序列之所以不能整段照抄,是因为伽马曲线、VCOM 电压、驱动能力这些参数跟具体面板绑定,同一颗控制器配不同面板,参数就不一样。但序列的骨架是通用的:软件复位 → 退出休眠 → 像素格式 → 行列扫描方向 → 伽马 → 开显示。把骨架写成表驱动结构,比一长串裸寄存器调用好维护得多,换屏时只改表不改逻辑。
/* 表驱动初始化:命令 + 数据长度 + 数据 + 命令后延时 */ typedef struct { uint8_t cmd; uint8_t len; uint8_t data[16]; uint16_t delay_ms; } lcd_init_cmd_t; static const lcd_init_cmd_t ILI9341_InitSeq[] = { {0x01, 0, {0}, 120}, /* 软件复位,手册要求 5ms 以上,取 120 稳妥 */ {0x28, 0, {0}, 0}, /* 关闭显示,改参数前先关 */ {0xCF, 3, {0x00, 0xC1, 0x30}, 0}, /* 电源控制 B */ {0xED, 4, {0x64, 0x03, 0x12, 0x81}, 0}, /* 电源控制 A */ {0xE8, 3, {0x85, 0x00, 0x79}, 0}, /* 驱动时序 A */ {0x36, 1, {0x48}, 0}, /* MADCTL:BGR + 横屏,颜色反了先改这里 */ {0x3A, 1, {0x55}, 0}, /* COLMOD:RGB565,16 位色 */ {0x11, 0, {0}, 120}, /* 退出休眠,必须等 120ms */ {0x29, 0, {0}, 20}, /* 打开显示 */ }; void LCD_Init(void) { uint32_t i; LCD_HardReset(); /* 硬件复位引脚拉低再拉高,别省 */ for (i = 0; i < sizeof(ILI9341_InitSeq) / sizeof(ILI9341_InitSeq[0]); i++) { LCD_WR_REG(ILI9341_InitSeq[i].cmd); if (ILI9341_InitSeq[i].len) { LCD_WR_DataBuf(ILI9341_InitSeq[i].data, ILI9341_InitSeq[i].len); } if (ILI9341_InitSeq[i].delay_ms) { LCD_DelayMs(ILI9341_InitSeq[i].delay_ms); } } }表里 0x36 那条最值得单独说。它控制行列扫描方向和 RGB/BGR 顺序,写 0x48 和写 0x08 出来的颜色是反的,很多人把红蓝反了归罪于取模工具,其实根子在这条命令。0x3A 写 0x55 表示 RGB565,如果写成 0x65 就是 18 位色,数据宽度对不上会直接花屏。延时字段里 0x11 后面那 120ms 是硬性要求,少了就是随机白屏,这条踩过的人不少。
3. 把工程跑起来:改引脚、编译烧录到第一个色块
拿到一个能跑的工程,第一件事不是急着编译,而是把工程配置和硬件对一遍。芯片型号、外部晶振频率、下载器类型这三项对不上,后面所有现象都不可信。对完之后再动引脚,最后用最小代码验证链路,顺序不能反。
3.1 打开工程先看三处:芯片型号、晶振、下载器
工程属性里 Device 一栏必须和板上芯片完全一致,型号选错会链接到错误的启动文件,表现是能编译但一上电就进 HardFault。Xtal 频率要和板上晶振一致,常见是 8MHz,如果写成 12MHz,延时函数会整体偏快,初始化里那些毫秒级等待全部缩短,屏就时好时坏。Debug 页里的下载器类型要选对,ST-Link 和 J-Link 的接线和配置不一样,选错下载器常见现象是“能识别芯片但烧录失败”。
提示:改完这三项先别急着烧,点一次全编译看有没有警告。启动文件不匹配、栈大小不够这类问题,警告里通常有线索。
3.2 引脚改配的四个位置
引脚改配不止改宏定义,涉及四个地方:FSMC 数据线和控制线的 GPIO 复用配置、RS 对应的地址线、背光引脚、复位引脚。数据线一般是 PE0~PE15 整组复用,控制线里 NE 做片选、A16 或 A17 做 RS,这几个必须和硬件原理图一一对上。
/* FSMC 引脚复用:数据线 PE0~PE15,NE1 用 PD7,A16 用 PD11 */ void LCD_GPIO_Init(void) { GPIO_InitTypeDef gpio; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_FSMC, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD | RCC_APB2Periph_GPIOE, ENABLE); /* D0~D15:整组复用推挽 */ gpio.GPIO_Pin = GPIO_Pin_All; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOE, &gpio); /* PD7 = FSMC_NE1,PD11 = FSMC_A16 */ gpio.GPIO_Pin = GPIO_Pin_7 | GPIO_Pin_11; GPIO_Init(GPIOD, &gpio); /* 背光和复位用普通推挽输出,单独控制 */ gpio.GPIO_Pin = GPIO_Pin_1; /* 假设背光接 PB1 */ gpio.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOB, &gpio); }背光引脚单独拎出来,是因为它不是总线信号,改起来最容易被漏掉。屏亮但全白、什么内容都没有,八成是背光开了但数据没写进去;屏完全不亮,先量背光引脚电平。复位引脚也一样,硬件复位那一下拉低时间不够,控制器可能停在半初始化状态,表现是时亮时不亮。
3.3 从清屏到画点,最小验证链路
引脚和时序都配好之后,用最短的代码验证整条链路。先写寄存器、再写数据、设窗口、填色,四步走通,后面所有显示功能都是在这四步上叠。
/* 两个宏是整个驱动的地基:往不同地址写,就是往寄存器或显存写 */ #define LCD_WR_REG(reg) (*(volatile uint16_t *)LCD_REG_ADDR = (reg)) #define LCD_WR_DATA(dat) (*(volatile uint16_t *)LCD_RAM_ADDR = (dat)) void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WR_REG(0x2A); /* 列地址设置 */ LCD_WR_DATA(x0 >> 8); LCD_WR_DATA(x0 & 0xFF); LCD_WR_DATA(x1 >> 8); LCD_WR_DATA(x1 & 0xFF); LCD_WR_REG(0x2B); /* 行地址设置 */ LCD_WR_DATA(y0 >> 8); LCD_WR_DATA(y0 & 0xFF); LCD_WR_DATA(y1 >> 8); LCD_WR_DATA(y1 & 0xFF); LCD_WR_REG(0x2C); /* 开始写显存,后续数据自动填充 */ } void LCD_Clear(uint16_t color) { uint32_t n = (uint32_t)LCD_W * LCD_H; LCD_SetWindow(0, 0, LCD_W - 1, LCD_H - 1); while (n--) { LCD_WR_DATA(color); } }LCD_WR_REG和LCD_WR_DATA的区别只在地址第 16 位,这就是 RS 接 A16 的意义。LCD_SetWindow里的坐标要先左后右、先上后下,x1 必须大于等于 x0,设反了整块区域会被裁掉。LCD_Clear里那个 while 循环每次写 16 位,全屏 240×320 就是 76800 次写,刷新一次大概几十毫秒,这也是后面要上 DMA 的原因。验证顺序建议是:先清成红色,再清成绿色,再清成蓝色,三色都对,说明像素格式和字节顺序没问题;三色里有一个不对,回去查 0x3A 和 0x36。
4. 显示层实战:字符点阵、图片取模与中文字库的三种排布
驱动层通了之后,上面要放的三样东西:字符、图片、汉字。三者的数据组织方式不一样,踩的坑也不一样。字符看的是点阵表的排列方向,图片看的是取模工具的扫描方向,汉字看的是字库索引算得对不对。
4.1 英文字符点阵表怎么排
ASCII 字符点阵常用 8×16,每个字符 16 字节,按列优先排列,也就是每字节代表一列 8 个像素。这种排法写起来顺手,取模工具默认也多是这个方向。字符表从空格 0x20 开始,索引直接用ch - ' '。
/* 8x16 ASCII 点阵,每个字符 16 字节,列优先,高位在上 */ static const uint8_t ascii_8x16[][16] = { {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, /* ' ' */ {0x00,0x00,0x00,0xF8,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x33,0x30,0x00,0x00,0x00}, /* '!' */ /* 其余字符按 ASCII 顺序补齐 */ }; void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t fc, uint16_t bc) { uint8_t i, j; uint8_t idx = (uint8_t)ch - ' '; if (idx > 94) return; /* 越界保护,别省 */ LCD_SetWindow(x, y, x + 8 - 1, y + 16 - 1); for (i = 0; i < 16; i++) { uint8_t line = ascii_8x16[idx][i]; for (j = 0; j < 8; j++) { LCD_WR_DATA((line & 0x80) ? fc : bc); line <<= 1; } } }这段代码里两个细节值得看:idx > 94的越界保护,以及line <<= 1逐位判断。前者防止传入非 ASCII 字符时读到表外的随机数据,后者决定高位先画还是低位先画,方向反了字会左右镜像。前景色和背景色都传参,比写死一个颜色灵活,代价是每个像素都要判断一次,追求速度的话可以改成整字节展开。
4.2 图片取模:RGB565 数组与扫描方向
图片显示最容易翻车的地方在取模工具的输出格式。工具里要选对三样:输出格式 RGB565、扫描方向与 MADCTL 一致、字节序大端还是小端。输出成uint16_t数组最省事,直接往显存里灌;输出成uint8_t数组就得手动拼字节,拼反了颜色会整体偏。
/* 取模工具导出为 uint16_t 数组,尺寸和扫描方向要与 MADCTL 一致 */ extern const uint16_t logo_240x135[240 * 135]; void LCD_ShowPicture(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const uint16_t *pic) { uint32_t n = (uint32_t)w * h; LCD_SetWindow(x, y, x + w - 1, y + h - 1); while (n--) { LCD_WR_DATA(*pic++); } }w和h必须和数组实际尺寸一致,写大了会读越界,写小了图会被截断。扫描方向如果和MADCTL里设的方向不一致,图会上下颠倒或者左右镜像,这时候不用改代码,回头改取模工具里的扫描方向选项更省事。还有一个常被忽略的点:extern数组如果放到外部 Flash,读之前要确认地址映射已经配好,否则读到的是全 0xFF。
4.3 中文显示:GB2312 字库裁剪与索引
汉字点阵比 ASCII 麻烦的地方在索引。GB2312 编码里,汉字从区 16 开始,区号和位号分别由高低字节减去 0xA0 得到,字库里每个 16×16 汉字占 32 字节。如果字库是从第一个汉字开始裁的,偏移量就是(高字节 - 0xB0) × 94 + (低字节 - 0xA1),再乘 32。
/* 从裁剪后的 GB2312 16x16 字库中取一个汉字的点阵首地址 */ const uint8_t *GB2312_GetFont(const char *hanzi) { uint8_t high = (uint8_t)hanzi[0]; uint8_t low = (uint8_t)hanzi[1]; uint32_t idx; /* 只处理汉字区,非汉字返回空,避免索引算出天文数字 */ if (high < 0xB0 || high > 0xF7 || low < 0xA1 || low > 0xFE) { return NULL; } idx = ((uint32_t)(high - 0xB0) * 94 + (low - 0xA1)) * 32; return &gb2312_16x16[idx]; }高位范围 0xB0~0xF7、低位 0xA1~0xFE 这个判断不能省,字符串里混进标点或数字时,不做判断索引会直接飞出字库范围。字库裁剪建议按项目实际用到的字来裁,一个 16×16 全字库大概 260KB,裁到几百个字只有几 KB,对 Flash 紧张的片子差别很大。显示函数和LCD_ShowChar类似,只是每个字 16 行、每行 2 字节。
5. 避坑与排查:白屏、花屏、偏色、撕裂、HardFault 五类现场
屏能亮只是开始,真正耗时间的是各种“亮了但不对”。下面五类现场是按我自己踩坑频率排的,每类都按现象、原因、解决三层写,对着查能少走不少弯路。
5.1 白屏:背光、复位、片选三个开关
现象:上电后整屏纯白或纯黑,读 ID 返回 0xFFFF 或 0x0000,程序跑起来没有任何反应。
原因:三种情况最常见。背光引脚没拉高,屏其实在工作但看不见;硬件复位引脚悬空或复位脉冲太窄,控制器停在未初始化状态;片选地址和实际接线对不上,所有寄存器写操作都落在空地址上。
解决:先万用表量背光引脚电压,再示波器看复位引脚有没有低电平脉冲、宽度够不够 10ms。片选对不上这种,拿原理图核对 CS 到底接的是 NE1 还是 NE2,改LCD_BASE的基地址,别改别的。
5.2 花屏:有反应但画面是乱的
现象:能看出屏上有内容在动,但每行错位、颜色呈雪花状,或者只有一部分区域正常。
原因:九成是 FSMC 时序太快,数据还没建立就被锁存;剩下一成是窗口坐标设反了,x1 小于 x0 导致整块区域被裁掉或重复填充。
解决:先把 DATAST 从 6 调到 10 再试,花屏消失就说明是时序问题,再逐格往下压找临界值。坐标问题直接打印LCD_SetWindow的四个入参,看一眼就知道。
5.3 偏色与反色:RGB/BGR 位和 565 组装
现象:红色显示成蓝色,或者整体偏暗、偏绿,灰度图看着发青。
原因:MADCTL(0x36)里的 BGR 位没设对,或者取模输出的字节序和LCD_WR_DATA的写入顺序相反。RGB565 里红色在高 5 位、绿色在中间 6 位,拼错一位整体色偏。
解决:把 0x36 的参数在 0x48 和 0x08 之间切换,看颜色是否恢复正常。字节序问题用纯色块验证最快:清红、清绿、清蓝,三色都对说明 565 组装没错。
5.4 撕裂与闪烁:刷新策略和总线占用
现象:整屏刷新时能看到一条横向撕裂线,或者画面明显闪烁。
原因:写显存的过程中被中断打断,或者刷新频率和显示刷新不同步。逐点写显存本身就慢,加上中断抢占,撕裂几乎必然出现。
解决:把整屏刷新改成 DMA 整块搬运,中断里只置标志不写屏;或者用局部刷新,只重绘变化区域。这两种做法的效果比单纯提高主频明显得多。
5.5 能编译但一运行就 HardFault
现象:编译零错误,烧录成功,一上电就停在HardFault_Handler。
原因:FSMC 时钟没使能就访问外部地址,或者字库索引算错导致数组越界读。前者是配置顺序问题,后者是边界判断缺失。
解决:确认RCC_AHBPeriph_FSMC在访问任何LCD_REG_ADDR之前已经使能;在GB2312_GetFont里把范围判断补上。HardFault 调试时可以看 LR 和 PC 寄存器,定位到具体出错的函数,比盲猜快。
6. 进阶与验证:DMA 搬运加局部刷新,把帧率提上去
逐点写显存在小面积刷新时够用,整屏或大面积刷新就吃不消了。240×320 全屏 76800 个像素,每个像素一次 16 位写,加上循环开销,刷一次几十毫秒,动画根本跑不动。把整块数据交给 DMA 搬运,CPU 只负责设窗口和启动传输,帧率能翻几倍。
/* 整屏填充用 DMA:先把颜色铺到内存缓冲,再一次性搬到显存 */ static uint16_t dma_buf[LCD_W]; /* 一行缓冲,够用且省 RAM */ void LCD_FillDMA(uint16_t color) { uint32_t i, total = (uint32_t)LCD_W * LCD_H; for (i = 0; i < LCD_W; i++) { dma_buf[i] = color; } LCD_SetWindow(0, 0, LCD_W - 1, LCD_H - 1); /* 每次搬一行,源地址固定,目的地址固定为显存数据口 */ while (total) { uint32_t n = (total > LCD_W) ? LCD_W : total; LCD_DMA_Start(dma_buf, LCD_RAM_ADDR, n); LCD_DMA_WaitDone(); total -= n; } }这里的取舍是缓冲大小。整屏缓冲要 150KB,F1 系列根本放不下;一行缓冲只要 480 字节,代价是启动多次 DMA,但每次传输本身是硬件完成的,总体仍然比逐点写快得多。如果用的是 F4 系列,RAM 宽裕,可以直接开整屏双缓冲,配合局部刷新做脏矩形更新,只重绘发生变化的区域。
局部刷新的核心是维护一个脏矩形:记录这次要更新的最小外接矩形,只对这个区域设窗口、只搬这个区域的数据。做仪表盘、菜单这类界面时,大部分区域不变,脏矩形能把每次刷新的数据量压到全屏的十分之一以下。验证是否真的只在刷局部,可以在刷新函数里翻转一个空闲 GPIO,用示波器看高电平持续时间,比打印日志直观。
验证一块屏调得好不好,我习惯跑三步:第一步纯色渐变,看有没有色阶断层和偏色;第二步黑白棋盘格,看相邻像素有没有串扰;第三步快速整屏刷新,看有没有撕裂和闪烁。三步都过,这块屏才算真正调通。
说个自己的教训。早些年调一块并口屏,颜色一直偏绿,我先后怀疑过取模工具、怀疑过排线、甚至换了块屏,最后发现是MADCTL里 BGR 位设反了,一行代码的事耗了一下午。从那以后我每次新屏上手,都强制先跑纯红、纯绿、纯蓝三个
本文还有配套的精品资源,点击获取