☰
STM32 TFTLCD FSMC并口驱动与ILI9341显示调试实战
2026/10/30 10:31:33 网站建设 项目流程

简介:面向STM32初学者与嵌入式显示开发者的TFTLCD显示实验程序,围绕在STM32平台上完成液晶屏驱动、图形绘制与屏幕刷新这一典型任务展开,适合正在学习GPIO、SPI/I2C外设配置及LCD驱动流程的读者参考实践。压缩包共40个文件、约87KB,以18个h头文件和11个c源文件为主体,配合Keil工程文件、启动汇编、HEX烧录文件及少量说明文本,构成可直接编译与对照阅读的完整工程骨架;其中头文件与源文件对应底层驱动、系统配置和实验模块,工程文件便于在Keil中打开调试。已有5190人学习,说明该实验在STM32显示入门中具有较高参考价值。内容覆盖驱动芯片初始化、显示缓冲区管理、画点画线与字符图形绘制、数据传输和刷新等关键环节,并保留实验目的、配置示例、完整显示程序及使用注意事项,可帮助读者理解STM32与TFTLCD的连接方式和驱动LCD的基本流程,也能作为嵌入式图形显示、外设接口编程与调试排错的对照材料。

1. 一块 2.8 寸屏接上 STM32,最先卡住的往往不是代码

背光已经点亮,屏幕一片白,串口里printf跑得好好的,可 TFTLCD 就是不出画面。这类场景在 STM32 显示调试里太常见了,最后查出来往往不是驱动写错,而是 FSMC 地址线偏移少移了一位,或者复位脚少拉了一个电平。这套 STM32 的 TFTLCD 显示程序把 240×320 分辨率、ILI9341 控制器、16 位 8080 并口这套组合从头到尾串了一遍:寄存器和显存读写、开窗、画点画线、ASCII 与汉字取模、图片显示、触摸校准都包含在内。适合刚把 GPIO 和串口摸熟、想做第一块带屏交互的开发板的人,也适合手上有现成工程、只想找一份能直接对照的驱动层参考的人。

2. 接口选型和工程骨架:这块屏为什么走 FSMC 而不是 SPI

拿到一块 TFTLCD,第一件事不是写代码,是看排线。同样的 ILI9341 控制器,有的模块引出 16 位并口,有的只给 4 线 SPI,还有的引出 8 位并口加一堆控制脚。接口不同,后面所有的驱动结构都会跟着变,所以这一步定错,后面基本要重写。

2.1 并口 FSMC、SPI、软件模拟的取舍

把三种常见接法放在一起对比,选择的依据就很清楚了:

接口方式典型速率CPU 占用接线数量适用场景
FSMC 16 位并口单像素约 70ns,全屏理论 5ms 级极低,写显存就是一次内存写数据 16 + 控制 4~5有 FSMC 外设的型号,需要刷图、做 UI
硬件 SPI(4 线)18MHz 下全屏数百毫秒中,靠 DMA 才降得下来4~6 根引脚紧张的小封装,界面刷新频率低
GPIO 软件模拟并口取决于翻转速度,通常比 FSMC 慢一个量级高,全程占着 CPU同并口没有 FSMC 的型号,或者只需要偶尔刷一屏

这套工程选的是 FSMC 16 位并口,原因是它把「写一个像素」变成了「往一个固定地址写一个uint16_t」,驱动层里几乎没有循环开销,后面做整屏填充、图片显示才不会卡成幻灯片。如果手上是 48 脚的型号没有 FSMC,那也不用硬套,把接口层换成 SPI 加 DMA 就行,上层绘图函数一行都不用改——这一点在第 6 章会把抽象层拆开讲。

提示:不要用「软件模拟并口能不能跑起来」来判断屏幕好坏,模拟方式本身速度不稳,一些初始化命令之间的间隔会被拉长,反而容易掩盖真实的时序问题。

2.2 工程目录:驱动、字库、应用三层分开放

很多能跑的工程最后没法维护,问题不在代码质量,在文件全塞进main.c。这套工程的目录按职责切得比较干净,移植到别的型号时,通常只需要动HARDWARE/LCD和寄存器初始化那一段:

Project/ ├── CORE/ # 启动文件、内核外设访问 ├── STM32F10x_FWLib/ # 标准外设库 ├── USER/ │ ├── main.c # 只做初始化和界面调度 │ └── stm32f10x_it.c ├── SYSTEM/ │ ├── delay.c # 微秒级延时,初始化命令间隔靠它 │ └── usart.c # 调试输出,坐标和 ID 都从这里打印 └── HARDWARE/ ├── LCD/ │ ├── lcd.c # 寄存器读写、初始化、绘图原语 │ ├── lcd.h # 对外接口与颜色宏 │ ├── lcdfont.h # 8×16、16×16、24×24 点阵字库 │ └── bmp.h # Image2Lcd 导出的数组 └── TOUCH/ └── touch.c # XPT2046 读取与四点校准

lcd.c只依赖delay.c和 FSMC 的读地址宏,不引用任何应用逻辑;main.c里除了初始化和几个演示函数,不出现任何寄存器名字。这条边界守住了,把驱动搬到另一块板子上就是改引脚和时序参数的事。

2.3 引脚映射和地址线偏移量

FSMC 的接线是固定的,不能随便挑引脚,必须对着型号的数据手册找复用功能表。以常见的 144 脚型号为例,NE4 作片选、A6 作命令/数据选择、16 位数据线分在 PD 和 PE 上:

/* lcd.h —— 地址映射是整份驱动的地基,写错一个位就整屏花 */ #define LCD_BASE ((uint32_t)0x6C000000) /* Bank1 · NOR/SRAM 子区 4,NE4 */ #define LCD_REG (*(volatile uint16_t *)(LCD_BASE)) #define LCD_RAM (*(volatile uint16_t *)(LCD_BASE + (1 << 7))) /* 命令/数据选择脚接的是 FSMC_A6, 16 位数据宽度下内部地址右移一位,所以外部 A6 对应 bit7 */

这段宏是很多人的第一道坎。LCD_BASE由片选决定:NE1 到 NE4 分别对应0x60000000、0x64000000、0x68000000、0x6C000000,接哪个就用哪个,接错的表现是写进去没反应、读出来全是0xFFFF。后半段1 << 7更容易翻车:因为数据总线是 16 位,控制器把内部地址HADDR[25:1]映射到外部FSMC_A[24:0],外部第 6 根地址线实际对应内部 bit7。所以接 A6 就写(1 << 7),接 A16 就要写(1 << 17)。写成(1 << 6)的话,命令和数据会落到同一个地址上,现象是屏幕能亮但内容全是噪点——这个坑我在第 5 章还会单独拎出来说。

引脚分配上还有一点要注意:数据线的顺序不能乱。D0 到 D15 分散在 PD14、PD15、PD0、PD1、PD8 到 PD10、PE7 到 PE15 上,接反了不会报错,只会让每种颜色都偏一点,看起来像 Gamma 没调好,实际是高低字节接反了。

3. 初始化链路:从 FSMC 时序到 ILI9341 吐出第一帧

驱动能不能亮,八成取决于初始化这一段。它由三部分组成:FSMC 把并行总线配出来、GPIO 把复位和背光控住、命令序列把控制器从休眠里叫醒。三段里任何一段参数不对,后面的绘图函数写得再漂亮都没用。

3.1 FSMC 初始化与时序参数的计算

时序参数不是抄来的魔法数字,是可以算的。ILI9341 的写周期最小 66ns,读周期最小 160ns,FSMC 时钟来自 HCLK,72MHz 下一个周期约 13.9ns。写时序按模式 A 计算,一个完整的写周期是(ADDSET+1) + (DATAST+1)个 HCLK:

/* lcd.c —— FSMC 通路配置,只做一次 */ static void LCD_FSMC_Config(void) { FSMC_NORSRAMInitTypeDef init; FSMC_NORSRAMTimingInitTypeDef wt, rt; GPIO_InitTypeDef gpio; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_FSMC, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD | RCC_APB2Periph_GPIOE | RCC_APB2Periph_GPIOF | RCC_APB2Periph_GPIOG, ENABLE); gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; /* D0~D3、D13~D15 在 GPIOD */ gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_14 | GPIO_Pin_15; GPIO_Init(GPIOD, &gpio); /* D4~D12 在 GPIOE */ gpio.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; GPIO_Init(GPIOE, &gpio); /* A6 作 RS */ gpio.GPIO_Pin = GPIO_Pin_12; GPIO_Init(GPIOF, &gpio); /* NE4 作片选,NOE/NWE 分别是 PD4/PD5 */ gpio.GPIO_Pin = GPIO_Pin_12; GPIO_Init(GPIOG, &gpio); gpio.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5; GPIO_Init(GPIOD, &gpio); /* 写时序:(1+1)+(2+1) = 5 个 HCLK ≈ 69ns,刚好盖住 66ns */ wt.FSMC_AddressSetupTime = 1; wt.FSMC_AddressHoldTime = 0; wt.FSMC_DataSetupTime = 2; wt.FSMC_BusTurnAroundDuration = 0; wt.FSMC_CLKDivision = 0; wt.FSMC_DataLatency = 0; wt.FSMC_AccessMode = FSMC_AccessMode_A; /* 读时序放宽,ILI9341 读显存要求 160ns 以上,读得少就无所谓慢 */ rt = wt; rt.FSMC_DataSetupTime = 15; FSMC_NORSRAMStructInit(&init); init.FSMC_Bank = FSMC_Bank1_NORSRAM4; init.FSMC_DataWidth = FSMC_DataWidth_16; init.FSMC_ExtendedMode = ENABLE; /* 读写用不同时序 */ init.FSMC_WriteOperation = ENABLE; init.FSMC_WaitSignal = FSMC_WaitSignal_Disable; init.FSMC_MemoryType = FSMC_MemoryType_SRAM; init.FSMC_WriteTimingStruct = &wt; init.FSMC_ReadTimingStruct = &rt; FSMC_NORSRAMInit(&init); FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM4, ENABLE); }

几个参数的含义值得说清楚。FSMC_DataWidth_16决定地址偏移的计算方式,选 8 位的话1 << 7那句就得改成1 << 6,两者必须一致。FSMC_ExtendedMode打开后读写可以各用一套时序,读时序单独放宽到 15 个周期,是因为读显存和读 ID 本来就慢,而且只用几次,没必要为了它把写速度拖下去。FSMC_MemoryType_SRAM是必须的,选 PSRAM 之类会引入额外的等待信号判断,屏幕根本不会响应。

如果写出来是花屏、颜色块错位,第一个要怀疑的就是DataSetupTime给小了。先把wt.FSMC_DataSetupTime从 2 加到 5 试一下,能好说明是时序余量不够,多半是杜邦线太长或者排线接触电阻大。

3.2 复位与初始化命令序列

ILI9341 上电后必须先硬件复位,再软件退出睡眠,最后才写一堆电源和 Gamma 参数。顺序错了,屏幕可能亮但对比度极低,或者局部区域发灰。

/* lcd.c —— 寄存器/数据写入口,所有命令都走这两个宏 */ #define LCD_WR_REG(r) do { LCD_REG = (r); } while (0) #define LCD_WR_DATA(d) do { LCD_RAM = (d); } while (0) void LCD_Init(void) { GPIO_InitTypeDef gpio; /* 背光和复位脚先配成推挽输出 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; /* BL、RESET */ GPIO_Init(GPIOB, &gpio); GPIO_ResetBits(GPIOB, GPIO_Pin_1); /* 拉低复位 */ delay_ms(20); GPIO_SetBits(GPIOB, GPIO_Pin_1); /* 释放,等内部稳压稳定 */ delay_ms(120); LCD_FSMC_Config(); /* 总线就绪后才能写命令 */ LCD_WR_REG(0xCF); /* 电源控制 B */ LCD_WR_DATA(0x00); LCD_WR_DATA(0xC1); LCD_WR_DATA(0x30); LCD_WR_REG(0xED); /* 上电顺序控制 */ LCD_WR_DATA(0x64); LCD_WR_DATA(0x03); LCD_WR_DATA(0x12); LCD_WR_DATA(0x81); LCD_WR_REG(0xE8); /* 驱动时序控制 A */ LCD_WR_DATA(0x85); LCD_WR_DATA(0x10); LCD_WR_DATA(0x78); LCD_WR_REG(0x36); /* 扫描方向 */ LCD_WR_DATA(0x48); /* 竖屏,BGR 位按模块实际改 */ LCD_WR_REG(0x3A); /* 像素格式 */ LCD_WR_DATA(0x55); /* 16 位 RGB565 */ LCD_WR_REG(0x11); /* 退出睡眠 */ delay_ms(120); /* 这里必须等,不能省 */ LCD_WR_REG(0x29); /* 开显示 */ LCD_Clear(WHITE); /* 清一屏,验证写入通路 */ }

0x3A写0x55表示 16 位色,写0x65就是 18 位,后者会让每个像素占 3 个字节,绘图函数里所有按uint16_t写的代码全部要改,没有特殊需求不要动。0x36是最常被改的一条:0x48和0x08决定横竖屏,bit3 决定 RGB 与 BGR 的顺序,模块红色显示成蓝色就是这一位反了,改这一位比在代码里到处交换 R 和 B 要干净得多。0x11后面那 120ms 是硬性要求,很多人图省事写成 10ms,结果低温下偶尔白屏,属于典型的玄学问题,其实是内部电荷泵还没稳。

3.3 读 ID:先确认你在跟谁说话

同一块排线,贴的可能是 ILI9341,也可能是 ST7789 或者 ILI9325,初始化序列完全不同。所以驱动里应该有一个读 ID 的步骤,把结果从串口打出来:

/* lcd.c —— 读 ID 用来区分控制器型号,顺便验证读通路 */ uint16_t LCD_ReadID(void) { uint16_t id; LCD_WR_REG(0xD3); /* Read ID4 */ LCD_RAM; /* 第一次读是空读,丢弃 */ id = LCD_RAM << 8; /* 高字节 */ id |= LCD_RAM; /* 低字节,ILI9341 通常是 0x9341 */ return id; }

空读那一次不能省,这是 8080 并口读操作的固定行为,第一次读回来的是上一个周期的残留。如果读出来是0x0000或0xFFFF,先别怀疑控制器,回头看读时序的DataSetupTime是不是太小、RS 地址偏移是不是算错。这两个地方没问题,读 ID 几乎不会失败。

3.4 点亮第一帧的最小验证

初始化写完,别急着上绘图函数,先用三行代码做一次最小验证:整屏填红、填绿、填蓝,各停 500ms。三种颜色都对,说明地址线、数据线、像素格式、扫描方向全部正常,后面画点画线才有意义。

/* main.c —— 上电自检,比任何调试器都直观 */ int main(void) { delay_init(); uart_init(115200); LCD_Init(); printf("LCD ID = 0x%04X\r\n", LCD_ReadID()); while (1) { LCD_Clear(RED); delay_ms(500); LCD_Clear(GREEN); delay_ms(500); LCD_Clear(BLUE); delay_ms(500); } }

如果三色正常但顺序不对(红显示成蓝),改0x36的 bit3;如果整屏只有一半有内容,多半是LCD_SetWindow里的坐标上限写成了 240 而实际高度是 320,或者LCD_Clear的循环次数少算了一行。

4. 绘图原语:所有图形都从「开一个窗口然后连续写」长出来

驱动层往上,画点、画线、写字、贴图看着是四套逻辑,实际底层只有一条路径:设置一块矩形窗口,然后往显存里连续灌 16 位数据。理解这一点,后面加任何新图形都只是算坐标的事。

4.1 开窗机制:坐标、方向与自增

ILI9341 内部维护一个写指针,0x2A和0x2B把窗口定好,之后每写一个像素指针就自动右移,到行尾回到下一行行首,越过窗口右下角就回到左上角。所以整屏填充只需要设一次窗口:

/* lcd.c —— 所有绘图的公共入口 */ 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; /* 76800 */ LCD_SetWindow(0, 0, LCD_W - 1, LCD_H - 1); while (n--) { LCD_RAM = color; /* 一次写就是一次内存写,没有函数调用 */ } }

参数上唯一容易错的是边界:窗口坐标是闭区间,所以宽 240 要传0到239,传240会多出一列,写进去的像素落到下一行开头,整屏图像就斜了。LCD_Clear里那个while (n--)也别小看,改成for循环加函数调用之后,全屏时间可能从 20ms 涨到 60ms,因为每次函数调用都要保存现场。

注意:如果在清屏时想中途打断(比如响应按键),必须重新开窗,因为写指针停在了中间位置,直接接着写会从上次的断点继续。

4.2 画点、画线与填充矩形

画点是最小单位,做法是把窗口缩到 1×1:

void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); LCD_RAM = color; }

这个实现简单但开销大,一条 240 像素的横线要开 240 次窗口,每次窗口操作是 10 次寄存器写。所以画线要用另一套逻辑:先判断是横线、竖线还是斜线,横竖线整段开窗一次写完,斜线用 Bresenham 逐点算。

/* Bresenham 画线,整数运算,不需要浮点 */ void LCD_DrawLine(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { int16_t dx = (x1 > x0) ? (x1 - x0) : (x0 - x1); int16_t dy = (y1 > y0) ? (y1 - y0) : (y0 - y1); int16_t sx = (x0 < x1) ? 1 : -1; int16_t sy = (y0 < y1) ? 1 : -1; int16_t err = dx - dy; while (1) { LCD_DrawPoint(x0, y0, color); if (x0 == x1 && y0 == y1) break; if ((err << 1) > -dy) { err -= dy; x0 += sx; } if ((err << 1) < dx) { err += dx; y0 += sy; } } }

dx、dy取绝对值,sx、sy保存方向,err是误差累积量,每次比较用左移一位代替乘 2,这是 Bresenham 的标准写法。填充矩形则直接开一个w×h的窗口,循环w*h次写颜色,比逐点画快两个数量级。圆和圆弧用八分法,只算 0 到 45 度那一段,其余七个对称点一次算出来,代码里三行对称赋值就能省掉七倍的计算。

4.3 字符与汉字:点阵索引怎么算

ASCII 字库是一个二维数组,8×16 的点阵每个字符 16 字节,一字节表示一列 8 个像素。显示时按位取,取到 1 画前景色,取到 0 画背景色或者跳过:

/* lcdfont.h 里 asc2_1608 是 95×16 的数组,从空格 0x20 开始 */ void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t fc, uint16_t bc, uint8_t mode) { uint8_t i, j, t; const uint8_t *p = &asc2_1608[(ch - ' ') * 16]; /* 每个字符 16 字节 */ for (i = 0; i < 16; i++) { t = p[i]; /* 第 i 行,一列像素 */ for (j = 0; j < 8; j++) { if (t & 0x80) LCD_DrawPoint(x + j, y + i, fc); else if (mode == 0) LCD_DrawPoint(x + j, y + i, bc); t <<= 1; } } }

ch - ' '是偏移,字符集从空格开始,所以空格对应索引 0;0x80是最左一位,左移逐位判断。mode为 1 时不画背景,字符可以叠加在已有图像上,做半透明效果或者状态栏刷新时用它。

汉字的索引要绕一点,得先把 GB2312 编码换算成区位码:

/* 16×16 汉字,每字 32 字节;24×24 每字 72 字节 */ void LCD_ShowChinese(uint16_t x, uint16_t y, const uint8_t *str, uint16_t fc, uint16_t bc, uint8_t size) { uint16_t index; const uint8_t *p; if (str[0] < 0x81) return; /* 不是汉字,直接退出 */ index = (str[0] - 0xA1) * 94 + (str[1] - 0xA1); /* 区码 × 94 + 位码 */ p = (size == 16) ? &hz16[index * 32] : &hz24[index * 72]; /* 后面按点阵宽度逐行取位,和 ASCII 一样的思路 */ }

- 0xA1是因为 GB2312 的区码和位码从0xA1起算,各减掉之后得到 0 到 93 的下标,乘以 94 再加位码就是连续序号。字库文件的生成顺序必须和这个公式一致,如果字库是按 GBK 或者 Unicode 顺序导出的,算出来的下标会整体错位,现象是每个字都显示成别的字,看起来像「乱码」,其实是索引错位。这一点在第 6 章会讲到怎么用脚本批量生成并验证。

4.4 图片显示:取模参数必须和代码对上

Image2Lcd 这类工具导出的是纯 RGB565 数组,但头几个字节有没有包含宽高、是逐行还是逐列扫描、有没有做字节交换,这三点必须和代码里的解析一一对应。常见配置是「16 位真彩色、包含图像头、水平扫描」:

/* bmp.h 里 gImage_logo 的前四字节是宽高,每字节按大端存 */ void LCD_ShowPicture(uint16_t x, uint16_t y, const uint8_t *pic) { uint16_t w = (pic[0] << 8) | pic[1]; uint16_t h = (pic[2] << 8) | pic[3]; const uint8_t *data = pic + 4; uint32_t n = (uint32_t)w * h; uint16_t i; LCD_SetWindow(x, y, x + w - 1, y + h - 1); for (i = 0; i < n; i++) { /* 数据流是高字节在前,拼回 16 位再写 */ LCD_RAM = (data[0] << 8) | data[1]; data += 2; } }

如果没有包含图像头,pic + 4就要删掉,宽高改成宏定义写死。字节序那一步是最容易漏的:有的工具导出的是低字节在前,直接按uint16_t*强转写进去,颜色会整体变成「青紫色调」,因为每个像素的高低字节被交换了。判断方法很简单,先用纯色块验证,红绿蓝三色都正确之后再贴复杂图片。

5. 排查清单:白屏、花屏、颜色反了,分别该查哪一段

这套驱动我在不同板子上跑过不少次,出问题的位置高度集中。下面按「上电、显示、运行」三个阶段整理,每条都按现象、原因、解决写,遇到问题直接对号入座。

5.1 上电阶段:背光亮了但屏是死的

现象一:背光正常,屏幕全白或者全黑,读 ID 返回0xFFFF。原因通常是 FSMC 根本没配通,或者片选接错。LCD_BASE和实际接的 NE 引脚必须对应,接 NE3 却用0x6C000000,写进去的数据根本没有落到屏幕上。另一种可能是 GPIO 复用模式配成了普通输出,FSMC收不到信号。解决办法是先量一下 CS 和 WR 脚上有没有波形,没有波形就是配置问题,有波形还读不到就是地址偏移算错,把1 << 7换成1 << 6试一次,能读出正确的0x9341就说明是这个问题。

现象二:读 ID 正常,但初始化之后屏幕一直是灰白,没有任何颜色。多半是0x11之后的延时太短。内部电荷泵需要时间建立,120ms 是手册给的典型值,实际低温环境下建议留到 150ms。还有一种情况是0x29(开显示)写在了0x11之前,顺序反了控制器会忽略。

现象三:复位脚悬空,只有上电瞬间显示正常,按一下复位键就白屏。原因是复位脚没接或者被配成了浮空输入。硬件上加 10k 上拉、软件里在LCD_Init开头主动拉低 20ms,两件事都做,才能保证每次复位都走到同一个状态。

5.2 显示阶段:内容出来了但不对

现象四:满屏雪花噪点,颜色杂乱,能看到类似图像的轮廓。这是地址线偏移只错一位的典型表现,命令和数据落到相邻地址上,每次写像素实际写的是寄存器。改LCD_RAM的偏移,或者检查数据线有没有接反、有没有虚焊。用杜邦线接的板子尤其容易出现,线长超过 10cm 就该考虑降DataSetupTime的速度,把它从 2 加到 4 再试。

现象五:图像整体斜切,每一行比上一行右移几个像素。窗口宽度参数多传了 1。LCD_SetWindow的右下角是闭区间,宽 240 要传239,传240会让每一次换行都多写一个像素,累积起来就是斜的。所有用到LCD_W、LCD_H的地方都要检查一遍是不是忘了减 1。

现象六:红色显示成蓝色,或者整体偏青。两个地方二选一:0x36的 bit3 是 RGB/BGR 顺序位,改它;或者数据线高低字节接反了,改硬件或者改成(data[1] << 8) | data[0]。区分方法是在屏幕上画一个纯红矩形,如果显示成蓝色就是顺序位,显示成青紫色就是字节交换。

5.3 运行阶段:能显示但表现不稳

现象七:刷新时明显闪烁,界面像在抖。这是先清屏再重绘的必然结果。整屏清成背景色之后,屏幕有一段空白期,人眼能捕捉到。解决办法是不要整屏清,只重绘发生变化的区域,第 6 章会给一个脏矩形合并的实现。另一条路是把刷新频率固定在 30Hz 以上,让闪烁落到视觉暂留范围之外。

现象八:跑一段时间后偶发死机,复位才好。检查是不是在中断里调用了绘图函数。LCD_SetWindow是一串寄存器写,中途被高优先级中断打断,另一个任务又去写显存,窗口和写指针就对不上了,严重时会把显存写到越界区域。绘图函数应该是不可重入的,要么加临界区,要么只在一个任务里调用。

现象九:贴大图时速度慢到能看出一行一行刷下来。这不是 bug 是带宽问题。240×320 全屏 76800 个像素,每个像素一次 16 位写,按 70ns 算理论 5.4ms,加上开窗和循环开销实际在 20 到 40ms,看起来就是从左上到右下扫过。想要更快只能上 DMA,让 FSMC 自己搬,但那需要把数据组织成连续内存块,小内存型号放不下一整屏,得按行分块搬。

6. 二次开发:把驱动拆成能承接业务的显示层

直接拿现成驱动写业务,很快就会遇到两个问题:换个控制器整个工程瘫痪、界面一刷新就闪。解决思路是把驱动拆成「控制器无关的绘图接口」加「控制器相关的寄存器操作」两层,再在上面加一个脏矩形刷新。

先做抽象。用函数指针把变化的部分隔离出来,上层只认lcd_dev_t,不认 ILI9341:

/* lcd.h —— 控制器无关的接口表,换芯片只换实现 */ typedef struct { uint16_t width; uint16_t height; uint16_t id; void (*init)(void); void (*set_window)(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1); void (*write_pixels)(const uint16_t *buf, uint32_t n); } lcd_dev_t; extern lcd_dev_t g_lcd;

write_pixels是关键,它把「一串像素」作为一个整体交给驱动。并口实现里就是一个while循环往LCD_RAM写,SPI 实现里就是启动一次 DMA 传输,上层代码一行都不用改。

再解决闪烁。思路是记录哪些区域变脏,每帧只重绘这些区域,并且把一帧内的多次失效合并成一个外接矩形,避免窗口被切得太碎:

/* ui.c —— 脏矩形合并,一帧只开一次窗 */ typedef struct { uint16_t x0, y0, x1, y1; uint8_t valid; } rect_t; static rect_t g_dirty; void ui_invalidate(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { if (!g_dirty.valid) { g_dirty = (rect_t){x0, y0, x1, y1, 1}; return; } if (x0 < g_dirty.x0) g_dirty.x0 = x0; if (y0 < g_dirty.y0) g_dirty.y0 = y0; if (x1 > g_dirty.x1) g_dirty.x1 = x1; if (y1 > g_dirty.y1) g_dirty.y1 = y1; } void ui_flush(void) { if (!g_dirty.valid) return; g_lcd.set_window(g_dirty.x0, g_dirty.y0, g_dirty.x1, g_dirty.y1); /* 逐行渲染到行缓冲,再整块交给 write_pixels */ g_dirty.valid = 0; }

按这套结构改下来,通常要动的地方集中在几处:

改造点现状建议做法收益
接口抽象直接调LCD_WriteReg收拢到lcd_dev_t换 SPI 屏无需重写业务
方向切换改0x36后手动改宽高宏封装lcd_set_rotation()统一处理横竖屏切换不出坐标错位
刷新策略每次整屏LCD_Clear脏矩形合并 + 行缓冲闪烁消失,帧率提升明显
字库来源手写常量数组脚本按区位码批量生成并校验下标避免显示成别的字
调试手段靠看屏幕猜串口打印 ID、坐标、耗时定位时间从几小时压到几分钟

最后说个习惯。从那以后我每次接一块新屏,都强制先走三步:读 ID 打印到串口、纯色三连闪、画一个描边矩形框住屏幕边界。这三步能覆盖地址偏移、时序余量、坐标越界、颜色顺序四类问题,一分钟之内就能判断到底是硬件、总线还是驱动逻辑的问题,比直接上业务代码然后对着花屏猜要快得多。希望帮到你。

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

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

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

立即咨询