STM32 FSMC驱动ILI9341彩屏:时序配置与花屏排查

发布时间:2026/11/5 4:37:16
STM32 FSMC驱动ILI9341彩屏:时序配置与花屏排查

简介:面向STM32嵌入式学习者与显示驱动开发者的TFT2.8彩屏驱动资料包,围绕STM32F103VET6经FSMC接口驱动ILI9341控制器展开,提供标准库与HAL库两套实现,并配有STM32F10x手册、工程配置和调试产物,便于理解GPIO连接、FSMC时序参数、ILI9341初始化、屏幕方向、显示窗口及颜色格式设置。压缩包共524个文件、约29.62MB,以C源码和H头文件为主体,同时包含Keil工程文件、编译链接中间产物、少量说明文档与PDF资料,目录覆盖外设初始化、驱动封装和应用示例,适合对照移植和复现实验。目前已有1586人学习下载。内容从底层寄存器配置延伸到完整驱动工程搭建,可帮助读者掌握240×320分辨率TFT屏的并口驱动流程、标准库与HAL库的差异及常见调试思路,既适合嵌入式显示技术入门与课程实验,也可作为项目移植时的代码参考。

1. 一块 2.8 寸 ILI9341 彩屏,为什么值得从软件模拟换到 FSMC

手上有块 2.8 寸、240×320 的并口彩屏,驱动 IC 是 ILI9341,主控是 STM32F10x,这套组合在工控面板、手持终端、教学实验箱里出现频率极高。tft2.8.zip 装的正是这件事:标准库与 HAL 两套工程骨架、FSMC 初始化、ILI9341 驱动源码,外加一个能直接编译下载的显示 Demo。它解决的不是“能不能点亮”,而是“点亮之后别再靠玄学”。

多数人第一次点屏走的是软件模拟 8080 并口:写一个字节翻转好几次 IO,刷一屏动辄上百毫秒,滚动条一拉肉眼可见撕裂。FSMC 把并口时序交给硬件,同样的屏、同样的 72MHz,整屏填充能压到个位数毫秒。适合手上已有这块屏、想把界面做顺、又不想在时序上反复翻车的人。往下按接线、初始化、驱动、排错、进阶五步走。

2. 接线与时序:ILI9341 的 8080 并口怎么对到 FSMC 的 Bank1

2.1 8080-18 并口里,哪几根线真正决定成败

ILI9341 支持 8/9/16/18 位并口,靠模块上的 IM0/IM1/IM2 三个配置脚选。市面上绝大多数 2.8 寸模块默认焊死成 16 位,也就是 8080-18 里的 D0~D15 全用(实际只用到 16 根数据线,剩下两根接地处理)。16 位的好处很直接:一个像素 RGB565 刚好 16 位,一次写就完事;8 位模式要拆高低字节,同样的刷屏动作凭空多一倍总线周期。

控制线一共五根,缺一不可:

屏引脚作用STM32 侧FSMC 信号
CSX片选,低有效PD7FSMC_NE1
WRX写使能,上升沿锁存PD5FSMC_NWE
RDX读使能,低有效PD4FSMC_NOE
D/CX1 写数据,0 写命令PF0FSMC_A0
RESX硬复位,低有效PG12普通 GPIO

这里最容易翻车的是 D/CX。它不是普通 IO,必须挂在地址线上,让硬件在访问不同地址时自动翻转电平——否则每次写命令、写数据都要软件去拉一次 IO,FSMC 的意义就没了一半。剩下的 D0~D15 分别落在 PD0/PD1/PD8~PD10/PD14/PD15 和 PE7~PE15 上,具体哪根接哪根由芯片数据手册的复用表决定,不能随便挑。

提示:买屏之前先确认模块是 3.3V 逻辑还是带板载电平转换。带 5V 兼容芯片的模块,并口侧往往加了缓冲器,读写时序会被拉长,DataSetupTime 要相应放宽。

2.2 FSMC 的 Bank 划分与地址映射:一条 AHB 地址如何变成一次写周期

STM32F10x 的 FSMC 把 NOR/PSRAM 空间切成四块,每块对应一个片选:

片选基地址本资源的用途
FSMC_NE10x6000 0000默认接屏,最常用
FSMC_NE20x6400 0000备用,接第二块屏或外部 SRAM
FSMC_NE30x6800 0000备用
FSMC_NE40x6C00 0000备用

关键在地址线的错位对应。当数据宽度配成 16 位时,内部 AHB 地址的 HADDR[1] 对应外部 FSMC_A0,HADDR[2] 对应 FSMC_A1,依次类推。也就是说,外部地址每加 1,实际访问的是下一个 16 位半字,AHB 地址要加 2。所以用 A0 当 D/C 时:

/* Bank1 / NE1 基地址,A0 接 D/C */ #define LCD_BASE ((uint32_t)0x60000000) #define LCD_CMD_ADDR (LCD_BASE) /* A0 = 0,D/C 低,写命令 */ #define LCD_DAT_ADDR (LCD_BASE + (1 << 1)) /* A0 = 1,D/C 高,写数据 */

这段宏看着简单,却是后面一系列花屏问题的源头。很多人照着 8 位模式的例子写成LCD_BASE + 1,结果命令和数据全落到同一个半字上,屏幕要么全白要么满屏噪点。记住这个换算关系:16 位宽,偏移量是 2 的倍数。如果当初把 D/C 接到了 A16,那数据地址就是LCD_BASE + (1 << 17),同理推导。

FSMC 内部把一次访问拆成“地址建立 → 数据建立 → 保持”三个阶段,每个阶段的长度以 HCLK 周期为单位配置。72MHz 下 1 个周期约 13.9ns。ILI9341 手册对写周期的要求是几十纳秒级,具体数值不同批次手册略有出入,我一般按下面这组起步,跑不稳再往上加:

时序参数含义起始值
AddressSetupTime片选/地址有效到 WR 拉低1
AddressHoldTimeWR 拉高后地址保持0
DataSetupTimeWR 低电平期间数据稳定5
BusTurnAroundDuration读→写方向切换间隔0
AccessMode访问模式,模式 A 最常用A

DataSetupTime 设成 5,对应约 70ns 的数据稳定窗口,绝大多数模块能跑。设成 0 或 1 时,示波器上看 WR 低电平只有十几纳秒,屏内部采样跟不上,表现就是随机位置的像素颜色错乱——这种故障最折磨人,因为它是概率性的,改天换个温度又好了。

2.3 上电顺序与硬件层面的准备

软件之前先看硬件。背光 LED 串通常需要 3.0~3.4V、15~20mA,模块上自带限流电阻,但如果是裸屏自己搭,一定要串限流,否则一上电背光就废。逻辑电源 VDD 是 2.8V 或 3.3V,STM32 的 IO 是 3.3V,一般直连没问题,但要确认模块没有把 IO 侧也稳压到 1.8V。

复位脚 RESX 建议接 MCU 的一个普通 GPIO,而不是直接上拉。原因很实际:调试阶段频繁重新下载程序,MCU 复位了但屏没有,屏内部状态机停在上一次刷到一半的位置,你看到的就是半屏残影。用 GPIO 控制 RESX,每次初始化前强制拉低 10ms 再拉高,能省掉大量“这次怎么又不对”的困惑。

排线长度是另一个隐藏变量。杜邦线 20cm 以上,16 根数据线之间的串扰足以让 DataSetupTime 设 5 完全不够用,得加到 10 甚至 15 才能稳。所以调试阶段先把线做短,最好压在 10cm 以内,或者直接画转接板。面包板同理,接触电阻时大时小,颜色偶尔冒出一条绿线,多半是某根地线没插实。

3. 两套工程骨架对照:标准库与 HAL 的 FSMC 初始化到底差在哪

3.1 标准库版:GPIO 复用 + FSMC_NORSRAMInitTypeDef

标准库的写法是“先配 GPIO 复用,再填 FSMC 结构体,最后使能”。这套流程直接对应参考手册里 FSMC 章节的寄存器定义,看懂结构体就知道每个位在干什么。

#include "stm32f10x.h" #define LCD_BASE ((uint32_t)0x60000000) #define LCD_CMD_ADDR (LCD_BASE) #define LCD_DAT_ADDR (LCD_BASE + (1 << 1)) #define LCD_WR_REG(r) (*(volatile uint16_t *)LCD_CMD_ADDR = (uint16_t)(r)) #define LCD_WR_DATA(d) (*(volatile uint16_t *)LCD_DAT_ADDR = (uint16_t)(d)) #define LCD_RD_DATA() (*(volatile uint16_t *)LCD_DAT_ADDR) static void LCD_GPIO_Init(void) { GPIO_InitTypeDef gpio; /* FSMC 挂 AHB,GPIO 挂 APB2,两个时钟都要开 */ 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; /* 复用推挽,FSMC 信号必须用这个模式 */ gpio.GPIO_Speed = GPIO_Speed_50MHz; /* PD0/PD1/PD8~PD10/PD14/PD15 = D2/D3/D13~D15/D0/D1,PD4=NOE,PD5=NWE,PD7=NE1 */ gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_14 | GPIO_Pin_15; GPIO_Init(GPIOD, &gpio); /* PE7~PE15 = D4~D12 */ 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); /* PF0 = FSMC_A0,接屏的 D/CX */ gpio.GPIO_Pin = GPIO_Pin_0; GPIO_Init(GPIOF, &gpio); /* PG12 = RESX,普通推挽输出 */ gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Pin = GPIO_Pin_12; GPIO_Init(GPIOG, &gpio); } static void LCD_FSMC_Init(void) { FSMC_NORSRAMInitTypeDef init; FSMC_NORSRAMTimingInitTypeDef timing; timing.FSMC_AddressSetupTime = 1; /* 地址建立:1 个 HCLK */ timing.FSMC_AddressHoldTime = 0; timing.FSMC_DataSetupTime = 5; /* 数据建立:5 个 HCLK,约 70ns */ timing.FSMC_BusTurnAroundDuration = 0; timing.FSMC_CLKDivision = 0; /* 异步模式不用分频 */ timing.FSMC_DataLatency = 0; timing.FSMC_AccessMode = FSMC_AccessMode_A; init.FSMC_Bank = FSMC_Bank1_NORSRAM1; /* 对应 NE1 */ init.FSMC_DataAddressMux = FSMC_DataAddressMux_Disable; /* 地址数据不复用 */ init.FSMC_MemoryType = FSMC_MemoryType_SRAM; /* 屏当 SRAM 对待 */ init.FSMC_MemoryDataWidth = FSMC_MemoryDataWidth_16b; /* 16 位并口 */ init.FSMC_BurstAccessMode = FSMC_BurstAccessMode_Disable; init.FSMC_AsynchronousWait = FSMC_AsynchronousWait_Disable; init.FSMC_WaitSignalPolarity = FSMC_WaitSignalPolarity_Low; init.FSMC_WrapMode = FSMC_WrapMode_Disable; init.FSMC_WaitSignalActive = FSMC_WaitSignalActive_BeforeWaitState; init.FSMC_WriteOperation = FSMC_WriteOperation_Enable; /* 不使能就只能读 */ init.FSMC_WaitSignal = FSMC_WaitSignal_Disable; init.FSMC_ExtendedMode = FSMC_ExtendedMode_Disable; /* 读写共用一个时序 */ init.FSMC_WriteBurst = FSMC_WriteBurst_Disable; init.FSMC_ReadWriteTimingStruct = &timing; init.FSMC_WriteTimingStruct = &timing; FSMC_NORSRAMInit(&init); FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM1, ENABLE); }

逻辑上分三段。第一段是时钟,FSMC 在 AHB 上、GPIO 在 APB2 上,漏开任何一个,后面所有配置都写不进去,而且不报错——这是最典型的“代码没错但屏不亮”。第二段是 GPIO 模式,必须用GPIO_Mode_AF_PP,用成普通推挽的话,FSMC 硬件根本接管不了引脚,波形出不来。第三段才是 FSMC 本体。

参数里有三个值得单独说。FSMC_MemoryType_SRAM不是笔误,屏的并口在时序上就是个异步 SRAM,没有地址复用、没有突发,选 SRAM 类型最贴合。FSMC_ExtendedMode_Disable表示读和写共用一套时序;如果想给读操作单独放宽时间(有些模块读 ID 特别慢),要改成 Enable,并单独填FSMC_WriteTimingStruct。FSMC_WriteOperation_Enable必须打开,默认是关的,关了之后所有写操作静默失败,屏永远白着。

3.2 HAL 版:不用 CubeMX,手写 MX_FSMC_Init

HAL 把结构体换了名字,字段也拆得更细,但骨子里一样。用 CubeMX 生成的工程里,这段代码在Core/Src/main.c的MX_FSMC_Init()里;如果工程是手搭的,就自己补一份。

#include "stm32f1xx_hal.h" SRAM_HandleTypeDef hsram1; void MX_FSMC_Init(void) { FSMC_NORSRAM_TimingTypeDef Timing = {0}; hsram1.Instance = FSMC_NORSRAM_DEVICE; /* 0xA0000000 */ hsram1.Extended = FSMC_NORSRAM_EXTENDED_DEVICE; /* 0xA0000004 */ hsram1.Init.NSBank = FSMC_NORSRAM_BANK1; hsram1.Init.DataAddressMux = FSMC_DATA_ADDRESS_MUX_DISABLE; hsram1.Init.MemoryType = FSMC_MEMORY_TYPE_SRAM; hsram1.Init.MemoryDataWidth = FSMC_NORSRAM_MEM_BUS_WIDTH_16; hsram1.Init.BurstAccessMode = FSMC_BURST_ACCESS_MODE_DISABLE; hsram1.Init.WaitSignalPolarity = FSMC_WAIT_SIGNAL_POLARITY_LOW; hsram1.Init.WrapMode = FSMC_WRAP_MODE_DISABLE; hsram1.Init.WaitSignalActive = FSMC_WAIT_TIMING_BEFORE_WS; hsram1.Init.WriteOperation = FSMC_WRITE_OPERATION_ENABLE; hsram1.Init.WaitSignal = FSMC_WAIT_SIGNAL_DISABLE; hsram1.Init.ExtendedMode = FSMC_EXTENDED_MODE_DISABLE; hsram1.Init.AsynchronousWait = FSMC_ASYNCHRONOUS_WAIT_DISABLE; hsram1.Init.WriteBurst = FSMC_WRITE_BURST_DISABLE; Timing.AddressSetupTime = 1; Timing.AddressHoldTime = 0; Timing.DataSetupTime = 5; Timing.BusTurnAroundDuration = 0; Timing.CLKDivision = 0; Timing.DataLatency = 0; Timing.AccessMode = FSMC_ACCESS_MODE_A; /* 第三个参数是写时序;ExtendedMode 关掉时,传同一个结构体即可 */ HAL_SRAM_Init(&hsram1, &Timing, &Timing); }

字段名一一对应,FSMC_NORSRAM_BANK1对FSMC_Bank1_NORSRAM1,FSMC_NORSRAM_MEM_BUS_WIDTH_16对FSMC_MemoryDataWidth_16b,换汤不换药。真正要留意的是两处差异。

一是 GPIO 初始化在 HAL 里是分开调用的,要把GPIO_InitTypeDef.Alternate设成GPIO_AF_PP对应的宏(F1 系列 HAL 里就是GPIO_MODE_AF_PP),并把Pin和Mode一起填进HAL_GPIO_Init,漏了复用配置,HAL_SRAM_Init会返回HAL_OK但一根波形都没有。

二是HAL_SRAM_Init内部已经帮你调了__HAL_RCC_FSMC_CLK_ENABLE(),不需要再手动开时钟;如果你在MX_FSMC_Init之前还写了__HAL_RCC_FSMC_CLK_ENABLE(),重复调用不会出错,但也没必要。

读写宏两套写法完全一致,都是对着0x60000000和0x60000002直接寻址,不需要经过 HAL 的HAL_SRAM_Write_16b()之类的函数——那层封装带参数校验和状态机,一次调用几十条指令,刷屏时性能掉得厉害。这也是我一般会给的建议:FSMC 初始化用 HAL,读写用裸指针。

3.3 ILI9341 的初始化序列与关键寄存器

屏的初始化就是按手册顺序往寄存器里填值,顺序错一步,后面全乱。常见做法是照搬一份验证过的序列,然后根据自己模块的批次微调 Gamma 和电源参数。

void LCD_Init(void) { /* 硬复位:RESX 拉低至少 10us,拉高后等 120ms 让内部稳压稳定 */ HAL_GPIO_WritePin(GPIOG, GPIO_PIN_12, GPIO_PIN_RESET); HAL_Delay(10); HAL_GPIO_WritePin(GPIOG, GPIO_PIN_12, GPIO_PIN_SET); HAL_Delay(120); LCD_WR_REG(0xCF); LCD_WR_DATA(0x00); LCD_WR_DATA(0xC1); LCD_WR_DATA(0x30); /* 电源控制 B */ LCD_WR_REG(0xED); LCD_WR_DATA(0x64); LCD_WR_DATA(0x03); LCD_WR_DATA(0x12); LCD_WR_DATA(0x81); /* 序列读写保护 */ LCD_WR_REG(0xE8); LCD_WR_DATA(0x85); LCD_WR_DATA(0x00); LCD_WR_DATA(0x78); /* 驱动时序 A */ LCD_WR_REG(0xCB); LCD_WR_DATA(0x39); LCD_WR_DATA(0x2C); LCD_WR_DATA(0x00); LCD_WR_DATA(0x34); LCD_WR_DATA(0x02); /* 电源控制 A */ LCD_WR_REG(0xF7); LCD_WR_DATA(0x20); /* 泵比控制 */ LCD_WR_REG(0xEA); LCD_WR_DATA(0x00); LCD_WR_DATA(0x00); /* 驱动时序 B */ LCD_WR_REG(0xC0); LCD_WR_DATA(0x23); /* GVDD 电压 */ LCD_WR_REG(0xC1); LCD_WR_DATA(0x10); /* VCL */ LCD_WR_REG(0xC5); LCD_WR_DATA(0x3E); LCD_WR_DATA(0x28); /* VCOM 两字节 */ LCD_WR_REG(0xC7); LCD_WR_DATA(0x86); /* VCOM 偏移 */ LCD_WR_REG(0x36); LCD_WR_DATA(0x48); /* MADCTL:横屏 + BGR */ LCD_WR_REG(0x3A); LCD_WR_DATA(0x55); /* 像素格式 16bit */ LCD_WR_REG(0xB1); LCD_WR_DATA(0x00); LCD_WR_DATA(0x18); /* 帧率 79Hz */ LCD_WR_REG(0xB6); LCD_WR_DATA(0x08); LCD_WR_DATA(0x82); LCD_WR_DATA(0x27); /* 显示功能控制 */ LCD_WR_REG(0xF2); LCD_WR_DATA(0x00); /* 关 3Gamma */ LCD_WR_REG(0x26); LCD_WR_DATA(0x01); /* Gamma 曲线 1 */ LCD_WR_REG(0xE0); /* 正极性 Gamma,15 字节 */ LCD_WR_DATA(0x0F); LCD_WR_DATA(0x31); LCD_WR_DATA(0x2B); LCD_WR_DATA(0x0C); LCD_WR_DATA(0x0E); LCD_WR_DATA(0x08); LCD_WR_DATA(0x4E); LCD_WR_DATA(0xF1); LCD_WR_DATA(0x37); LCD_WR_DATA(0x07); LCD_WR_DATA(0x10); LCD_WR_DATA(0x03); LCD_WR_DATA(0x0E); LCD_WR_DATA(0x09); LCD_WR_DATA(0x00); LCD_WR_REG(0xE1); /* 负极性 Gamma,15 字节,与上面配对 */ LCD_WR_DATA(0x00); LCD_WR_DATA(0x0E); LCD_WR_DATA(0x14); LCD_WR_DATA(0x03); LCD_WR_DATA(0x11); LCD_WR_DATA(0x07); LCD_WR_DATA(0x31); LCD_WR_DATA(0xC1); LCD_WR_DATA(0x48); LCD_WR_DATA(0x08); LCD_WR_DATA(0x0F); LCD_WR_DATA(0x0C); LCD_WR_DATA(0x31); LCD_WR_DATA(0x36); LCD_WR_DATA(0x0F); LCD_WR_REG(0x11); HAL_Delay(120); /* Sleep Out,必须等够,这里偷懒必花屏 */ LCD_WR_REG(0x29); /* Display On */ }

0x11后面的 120ms 是最容易省掉又最容易出问题的一步。手册写的是“至少 5ms”,但实际模块内部电荷泵升压需要更久,延时不够的话,第一次刷屏会出现从下往上逐渐变亮的过程,看着像渐变动画,其实是 VCOM 还没稳。我一般固定给 120ms,宁可慢一点。

0x36的 MADCTL 决定屏幕方向。0x48 是横屏 + BGR 顺序,0x08 是竖屏 + BGR,0x28 是横屏 + RGB。颜色反相(红变蓝、蓝变红)几乎都是这一位没匹配上模块的接线,改 0x08 和 0x48 互换试一次就能确认。注意改完方向后,分辨率也跟着换:竖屏是 240(宽)×320(高),横屏是 320×240,后面开窗的坐标上限要同步改,不然写到屏幕外,行为未定义。

0x3A的 0x55 表示 16 位色,也就是 RGB565。改成 0x66 是 18 位色,一条像素要写三个字节,FSMC 16 位总线上没法一次写完,得拆两次,性能直接砍半,除非对色彩有硬要求,否则不推荐。

4. 驱动层 API 怎么切:开窗、刷色块与字符显示的最小闭环

4.1 开窗与连续写:0x2A / 0x2B / 0x2C 的配合

ILI9341 的显存写入机制是“先划区域,再灌数据”。0x2A 设列地址范围(X),0x2B 设行地址范围(Y),0x2C 是显存写入口。发完 0x2C 之后,每写一个 16 位数据,内部地址指针自动加一,写到行末自动跳到下一行起始。这个自增特性是刷屏性能的关键——一次开窗,之后全是连续写,不需要反复发命令。

/* 开窗:x0/y0 左上角,x1/y1 右下角,闭区间 */ 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_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); LCD_SetWindow(x0, y0, x1, y1); while (n--) { LCD_WR_DATA(color); } }

n用uint32_t不是讲究。整屏 320×240 = 76800,uint16_t装不下,溢出后只刷了一小半,剩下是上一帧的残影——这种 bug 在只看代码时很难发现,因为变量名和表达式都没错。开窗坐标用闭区间,所以宽高要 +1,这个 +1 漏掉的话,1 像素的边线永远画在最右侧或最底部,视觉上像是坐标偏移。

开窗还有个隐含约束:x1 必须 ≥ x0,y1 必须 ≥ y0。如果做图形裁剪时算出了反向区间,模块不会报错,而是按内部逻辑绕回去,表现是屏幕上出现一条莫名其妙的竖线或者整个区域不刷新。所以裁剪函数里该有的if (x0 > x1) swap不能省。

4.2 RGB565 与显存换算:颜色、字节序、对齐

RGB565 把一个像素压进 16 位:红占高 5 位、绿占中间 6 位、蓝占低 5 位。人眼对绿色最敏感,所以绿色多给一位。三种原色的常量值记下来能省很多调试时间:

颜色十六进制二进制高 5/6/5 位
红0xF80011111 000000 00000
绿0x07E000000 111111 00000
蓝0x001F00000 000000 11111
白0xFFFF全 1
黑0x0000全 0

从 24 位 RGB 转 565 的公式常见写法是(r & 0xF8) << 8 | (g & 0xFC) << 3 | (b >> 3),注意红和绿都是先截断再移位,不能直接右移——直接r << 8会把高 8 位全带进来,溢出到相邻通道上。

字节序是另一个坑。FSMC 配成 16 位宽时,*(volatile uint16_t*)LCD_DAT_ADDR = 0xF800这个操作,硬件会把低字节发在 D0~D7、高字节发在 D8~D15 上,符合 ILI9341 的预期。但如果有人图省事,把数据线的高低 8 位接反了(D0 接 D8、D8 接 D0),屏幕还是能显示,只是每个像素的颜色都变成0x00F8这种错位值,看着像色调偏移。这种硬件错误没法用软件优雅地修,只能返工接线,或者在写数据时手动做((c & 0xFF) << 8) | (c >> 8)的字节交换——后者能救急,但每次写都多两条指令,刷屏性能掉 10% 左右。

4.3 打点、画线与字符显示的最小实现

打点是最底层的原语,画线、画矩形、显示字符全建立在它上面。实现上不要每次都开关窗,那样一个点要发 10 个字节的命令,效率极低。正确做法是直接算好绝对坐标,一次开 1×1 的窗。

void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); LCD_WR_DATA(color); } /* 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; } } }

画线用 Bresenham 而不是y = kx + b的原因很实际:F103 没有 FPU,一次浮点乘加要几百个周期,画一条 300 像素的斜线能卡出肉眼可见的延迟。全整数版本每步只有几次加减和比较,快一个数量级。

字符显示的核心是字模。取模软件生成的点阵数据,按“每行若干字节、高位在前”排列,逐位判断亮灭。以 16×16 的汉字为例,一行 16 位 = 2 字节,共 16 行 = 32 字节:

/* 16x16 汉字,每字 32 字节,高位在前;index 为字库中的偏移 */ void LCD_ShowChinese(uint16_t x, uint16_t y, const uint8_t *font, uint16_t color) { uint8_t i, j; for (i = 0; i < 16; i++) { for (j = 0; j < 16; j++) { /* 每行 2 字节,j<8 取第一字节,否则取第二字节 */ uint8_t byte = font[i * 2 + (j >> 3)]; if (byte & (0x80 >> (j & 0x07))) { LCD_DrawPoint(x + j, y + i, color); } else { LCD_DrawPoint(x + j, y + i, BACK_COLOR); /* 有背景色才刷,否则留透明 */ } } } }

这段代码能跑,但效率不高:一个 16×16 汉字要调用 256 次LCD_DrawPoint,每次都开关窗,实测一屏 20 个字要 40ms 以上。优化方向是按行开窗,一次开一整行 16 像素,然后把 16 个颜色值连续写进去,调用次数从 256 降到 16,速度提 4~5 倍。如果只显示数字和字母,12 号 ASCII 字库更划算,一个字 12 字节,刷一屏的状态栏几乎无感。

5. 白屏、花屏、颜色反相:五条踩坑记录与逐条排查路径

5.1 上电白屏,背光亮但一个像素都没有

现象:背光正常点亮,屏幕均匀发白,没有任何图案,读 ID 返回 0x0000。

原因:基本可以断定是 FSMC 根本没有产生总线周期。常见三种:GPIO 没配成复用推挽,FSMC 接管不了引脚;FSMC_WriteOperation忘了使能,所有写操作静默丢弃;或者片选接的是 NE1,代码里配的却是FSMC_Bank1_NORSRAM2。

解决:先拿示波器看 PD7(NE1)在写数据时有没有拉低,没有就是片选配错了。有拉低但 PD5(NWE)不动,回到 GPIO 模式检查,用万用表量引脚对地阻抗,普通输入模式下是高阻,复用推挽下会呈现驱动能力。两种情况都正常但屏还是白,把LCD_WR_REG(0x29)之后加一句LCD_Fill(0,0,319,239,0xF800),全屏刷红,能刷出来说明初始化通了,问题在后续逻辑。

5.2 花屏、斜纹、随机位置的彩色噪点

现象:屏幕能亮,但内容错位,出现斜向条纹或者大片随机色块,静止时也在轻微抖动。

原因:九成是时序不够。DataSetupTime设得太小,WR 低电平宽度只有十几纳秒,ILI9341 内部采样窗口对不上,数据在总线上还没稳就被锁存。次要原因是排线过长或者地线不足,16 根数据线同时翻转时的地弹把信号抬高了。

解决:把FSMC_DataSetupTime从 5 逐步加到 9、15,每加一次重新编译下载看效果。加到这个值能稳定显示,再往回退一档找到临界点,最终取临界点 +2 作为量产值。如果加到 15 还是花,先换短线,再检查模块的 GND 是不是只接了一根——16 位并口至少接两根地线,最好三根。

注意:时序问题有温度相关性。实验室 25℃ 调好的参数,到了夏天高温环境可能又开始花。留 2 个周期的余量不是浪费。

5.3 红蓝反相,或者整体偏色发绿

现象:画一个红色矩形,显示成蓝色;或者白色显示成淡青色,怎么调 Gamma 都不对。

原因:0x36(MADCTL)的 BGR 位没和模块实际接线对上。模块面板的像素排列有 RGB 和 BGR 两种,出厂时由 FPC 上的电阻决定,软件必须跟着改。另一种可能是0x3A

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