2026/10/30 3:28:28

STM32驱动TFTLCD实战:FSMC并口初始化与刷屏排查

STM32驱动TFTLCD实战:FSMC并口初始化与刷屏排查 简介面向嵌入式初学者与课程实验场景的STM32 TFTLCD显示工程基于STM32F10x平台演示GPIO、SPI等接口与液晶驱动芯片的配合涵盖屏幕初始化、显示缓冲区管理、画点画线、字符与图像绘制以及屏幕刷新等关键环节可用于理解裸机驱动LCD的完整流程并动手验证。压缩包共40个文件、约87KB以18个h头文件和11个c源文件为主体配套Keil工程配置、启动汇编、hex烧录文件、JLink调试设置以及说明和批处理文件目录按SYSTEM、USER、HARDWARE等模块划分串口、延时、按键、LED、OLED、定时器和看门狗等外设代码可一并对照学习。目前已有5190人学习工程保留可编译与烧录文件便于从源码梳理驱动分层、时序配置和显示异常排查。读者可据此对照驱动芯片初始化命令、坐标设置与数据写入等细节快速定位花屏、偏移等常见问题也能作为课程实验、毕业设计及项目移植的基础模板。1. 一块 2.4 寸屏点亮的那三秒比调通串口踏实多了有位做工业面板的开发者跟我聊过一件事板子上电串口打印一切正常可挂上去的 TFTLCD 屏就是不亮白得发亮或者干脆全黑。没有报错没有日志屏幕本身就是个黑匣子它不告诉你 RS 接反了、还是复位时序短了 100 微秒。这类问题最耗时间也最容易劝退刚摸 STM32 外设的人。这份 TFTLCD 显示程序把从引脚初始化、驱动 IC 时序、开窗刷屏到字符与图片显示的整条链路都写进了工程编译烧录就能看到画面属于亲测能跑通的那一类。它适合两类人一类是刚学完 GPIO、想找个现成模板把屏点亮的另一类是手上老项目要加显示、不想从零写驱动的。2. 项目定位与技术选型这块屏是怎么被点亮的拿到一个显示工程先别急着看main.c。正确的顺序是看它怎么和屏通信、驱动的是什么芯片、代码怎么分层。这三件事决定了后面所有调试手段的有效性也决定你能不能在换屏、换板子的时候少返工。2.1 并口还是 SPI两种接法的取舍STM32 驱动 TFTLCD 的主流做法只有两条路8080 并口一般走 FSMC 或 FMSC 这类外部总线控制器和四线 SPI。这份工程用的是并口方案原因很直接——刷屏要快。屏幕分辨率的典型值是 240×320按 RGB565 算一帧 153600 字节SPI 在 18MHz 下把这一坨数据推出去得几十毫秒量级如果上层还要画控件帧率根本不够看并口下硬件自动拉 RE/WE 时序CPU 只管往一个地址写数据同样的全屏填充能压到几毫秒量级。代价也很明显。并口要吃掉 16 根数据线加若干控制线走线一多杜邦线插一排就很容易出现某根接触不良而且并行总线对布线有要求板子做小了走线打架是常事。我在选型时一般这么判断对比项8080 并口FSMC四线 SPI占用 IO16 位数据 CS/RS/WR/RD/RST 等4~5 根SCLK/MOSI/CS/RS/RST全屏刷新几毫秒量级十几到几十毫秒量级CPU 占用低时序由硬件产生高每字节都要循环等待接线难度高线多且容易接触不良低插错也能改适合场景刷图、动画、图形界面参数显示、低频刷新、引脚紧张的小板工程里的lcd.h顶部通常有一组宏用来切换总线宽度和基地址这份代码按 16 位并口组织。如果你手上的板子 IO 紧张不必整个推翻把写命令、写数据这两个原语换成 SPI 版实现上层LCD_Fill、LCD_ShowString一行都不用动——这一点在第 6 章抽接口层的时候会具体展开。另外一个容易忽略的点是背光。背光 LED 那条线很多板子直接接 3.3V代码里根本不管它结果屏是亮的但内容是黑的新手第一反应是驱动没跑起来实际是背光没有 PWM 控制、亮度被拉满导致对比度看不出。初始化代码里如果有一组LCD_BL相关的 GPIO 操作记得一起对。2.2 驱动 IC 与初始化序列的对应关系屏上那层玻璃只是像素阵列真正干活的是背后的驱动 IC。市面上 240×320 这个规格常见的型号是 ILI9341、ST7789 这一档两者初始化寄存器大体相似但细节不同尤其是 Gamma 校正和部分电源控制寄存器抄错一组就表现为颜色发灰或者偏色。这份工程的初始化表是配套具体模组的换屏之后第一件事就是确认这张表对不对得上。初始化序列本质上是一串写寄存器地址 写参数的调用顺序不能乱。典型流程是硬件复位拉低 RST 至少 10 微秒再拉高并等待 120 毫秒→ 软件复位0x01→ 读 ID 校验 → 电源控制 → 显存访问控制0x36→ 像素格式0x3A→ Gamma → 开显示0x29。其中0x36MADCTL决定扫描方向和 RGB/BGR 顺序是后面所有显示错位的根源0x3A一般写0x55表示 16 位 RGB565如果这里写成 18 位屏幕上就会看到颜色被重新分配了一样的花屏。这里有个血泪经验读 ID 那一步很多人直接跳过。读 ID 需要把数据线方向切到输入而 FSMC 配置成只写模式的话读回来永远是0xFFFF。所以正确做法是先把 ID 打印到串口确认读到0x9341或0x7789之类的值再往下走。ID 读不到后面所有调试都是在猜。2.3 目录结构与文件职责工程的组织方式比较朴素没有上 RTOS也没有做复杂的抽象层基本是一个外设一组文件的风格。这种结构对新手友好但也要接受它耦合较紧的毛病。Project/ ├── CORE/ # 内核启动与系统初始化 ├── STM32F10x_FWLib/ # 标准外设库含 GPIO / FSMC / RCC 等 ├── HARDWARE/ │ ├── LCD/ │ │ ├── lcd.c # 读写原语、初始化序列、填充与字符 │ │ ├── lcd.h # 总线宏、颜色宏、对外函数声明 │ │ ├── lcdfont.h # ASCII 字模12x6、16x8、24x12 常用三档 │ │ └── bmp.h # 图片取模数组按 RGB565 存放 │ └── LED/ # 板载指示灯用来做点亮反馈 ├── USER/ │ ├── main.c # 应用入口演示填充、画线、显示文字与图片 │ └── stm32f10x_it.c # 中断服务 └── OBJ/ # 编译输出分层的边界很清楚main.c只调用LCD_Init、LCD_Clear、LCD_ShowString这类语义化函数不去碰寄存器lcd.c负责把语义翻译成寄存器操作字模和图片放在独立头文件里按const数组存进 Flash不占 RAM。新手常犯的错误是把几百 KB 的图片数组直接塞进main.c编译后 RAM 溢出链接阶段就报错——这个结构里把大数组放const是个必须保留的习惯。lcdfont.h的字模是典型的横向取模、高位在前。取模软件的设置阴码/阳码、逐行/逐列、C51 格式必须和LCD_ShowChar里的解析逻辑一致这属于玄学环节明明数组长度对显示出来却是乱码十有八九是取模方向反了。3. 核心模块拆解从一条命令到满屏像素显示驱动的全部秘密就在三个动作里写命令、写数据、连续写像素。把这三件事讲透剩下的字符、图形、图片都是在它们之上做循环。3.1 写命令与写数据并口时序的两种写法并口的精髓在于把命令还是数据这件事交给地址线。RS 接到某根地址线常见是 A0 或者 A16访问不同地址时硬件自动拉高拉低CPU 侧就退化成了两次内存写。/* lcd.h —— 16 位并口下最基础的地址映射 */ #define LCD_BASE ((uint32_t)0x6C000000) /* FSMC Bank1 NE1 基地址 */ #define LCD_REG (*(volatile uint16_t *)(LCD_BASE)) /* RS 0写命令 */ #define LCD_RAM (*(volatile uint16_t *)(LCD_BASE 0x2)) /* RS 1写数据 *//* lcd.c —— 全部上层函数最终都落到这两行赋值上 */ void LCD_WR_REG(uint16_t reg) { LCD_REG reg; /* 地址线决定 RS 电平硬件自动产生 WR 脉冲 */ } void LCD_WR_DATA(uint16_t data) { LCD_RAM data; /* 数据宽度由 FSMC 配置决定必须保持 16 位一致 */ } uint16_t LCD_RD_DATA(void) { return LCD_RAM; /* 读之前要把 FSMC 的数据线方向切到输入否则恒为 0xFFFF */ }参数上最容易出错的是那个0x2偏移。16 位总线意味着一个像素占两个字节地址线 A0 对应 CPU 地址的 bit1所以下一根地址线的偏移是 2 而不是 1。偏移写错命令和数据会全部落到同一个地址上现象就是屏上出现无规律的噪点。另外LCD_BASE必须和 FSMC 初始化里的FSMC_NORSRAMInitStructure.FSMC_Bank以及FSMC_DataWidth对齐库函数里如果配了 8 位写 16 位数据会被拆成两次访问屏上就是半屏花。3.2 初始化序列的逐条含义初始化表一般写成结构体数组遍历执行。这样换屏只改表不改逻辑。/* 精简后的关键几组实际工程里会有 30 条左右 */ typedef struct { uint16_t reg; uint16_t val; uint16_t delay_ms; /* 该条执行后需要等待的时间0 表示不等 */ } lcd_init_cmd_t; static const lcd_init_cmd_t lcd_init_table[] { {0x01, 0x0000, 120}, /* 软件复位必须等 120ms 再往下走 */ {0x11, 0x0000, 120}, /* 退出睡眠 */ {0x36, 0x0068, 0}, /* MADCTL横屏 BGR 顺序具体值看模组丝印 */ {0x3A, 0x0055, 0}, /* 像素格式 16bit RGB565 */ {0x29, 0x0000, 20}, /* 开显示 */ }; void LCD_Init(void) { LCD_GPIO_Config(); /* 先把 RST / BL / 数据线全部配好顺序不能反 */ LCD_FSMC_Config(); /* 总线时序地址建立、数据保持按屏手册填 */ LCD_RST_LOW(); delay_ms(20); /* 复位脉宽低于 10us 部分模组认不到 */ LCD_RST_HIGH(); delay_ms(120); for (uint32_t i 0; i sizeof(lcd_init_table) / sizeof(lcd_init_table[0]); i) { LCD_WR_REG(lcd_init_table[i].reg); if (lcd_init_table[i].reg ! 0x01 lcd_init_table[i].reg ! 0x11) { LCD_WR_DATA(lcd_init_table[i].val); } if (lcd_init_table[i].delay_ms) { delay_ms(lcd_init_table[i].delay_ms); } } LCD_Clear(WHITE); }delay_ms这一列是初始化表里最容易被删掉的部分。有人觉得等 120 毫秒太久想省掉结果就是时好时坏——上电电压爬升慢的时候屏没吃完复位就收到了命令。0x01和0x11不需要写参数所以代码里单独判断了一下如果你的表里把它们写成带参数形式多写的一个字节会被驱动 IC 当成下一条命令后面整条链就歪了。0x36的值直接决定坐标原点在哪个角。0x68和0x48的区别就是 X/Y 交换加镜像屏幕上表现为文字倒了 90 度或者左右反了。这个值不用背改一次刷一次色块两分钟就能试出来。3.3 开窗与连续写像素刷屏效率的关键逐点设置坐标再写颜色是最慢的做法因为每次都要发0x2A、0x2B、0x2C三条命令。正确姿势是先划一个矩形窗口之后每写一个 16 位数据驱动 IC 自己把坐标往后推。/* 设置显示窗口后续所有像素按从左到右、从上到下的顺序自动填充 */ 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 total (uint32_t)(x1 - x0 1) * (y1 - y0 1); LCD_SetWindow(x0, y0, x1, y1); while (total--) { LCD_RAM color; /* 只有地址写不再有命令开销 */ } }total一定要用 32 位。全屏 240×320 是 76800uint16_t装不下越界之后循环次数变成 76800 取模屏幕只填了一部分剩下的保持上一次的内容——这种填一半的现象非常像内存被踩查半天查不出来。参数越界同理x1大于屏宽时写入会绕回下一行看起来像是画面错位。LCD_Clear就是LCD_Fill(0, 0, width - 1, height - 1, color)。这里顺手提一句工程里如果有LCD_DrawLine、LCD_DrawCircle这类函数它们内部也是反复调LCD_SetWindow加一次写像素效率一般画静态界面够用要做动画就得换成带显存缓冲的方案。3.4 字符、图片与取模参数字符显示是把字模数组按位展开成像素。这份工程用的是 12×6 和 16×8 两种 ASCII 点阵一个字符占 12 或 16 个字节。/* 12x6 ASCII每字符 12 字节横向取模高位在前 */ void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t fc, uint16_t bc, uint8_t size) { uint8_t i, j; uint8_t bytes (size / 8 ((size % 8) ? 1 : 0)) * (size / 2); uint32_t off (uint32_t)(ch - ) * bytes; /* 字库从空格开始排 */ const uint8_t *p asc2_1206 off; LCD_SetWindow(x, y, x size / 2 - 1, y size - 1); for (i 0; i bytes; i) { uint8_t dat p[i]; for (j 0; j 8; j) { LCD_RAM (dat 0x80) ? fc : bc; /* 1 画前景0 画背景 */ dat 1; } } }参数fc和bc是前景色与背景色。想叠加显示比如在图片上写字就把bc传成透明标志函数里改成背景色跳过不写代价是每个像素都要判断一次速度掉一截。图片显示则是把一个const uint16_t数组整个推进窗口长度必须是width * height多一个少一个都会从第二行开始整体偏移——这类问题用纯色图看不出来得拿带边框的测试图才暴露。4. 跑起来从导入工程到屏幕出现第一行字代码看懂和跑起来是两件事。这一章按顺序走一遍每一步都给出做完应该看到什么出了问题就停在那一步别往下冲。4.1 工程导入与编译环境工程是标准的 Keil MDK 结构带.uvprojx工程文件。打开之后先做三件事确认器件型号和实际芯片一致选错型号编译能过但启动文件和 Flash 算法会不匹配、确认Options for Target里勾了Use MicroLIBprintf重定向到串口的话必须勾、确认C/C页的宏定义里有对应的芯片型号宏标准库靠这个宏来选启动文件。如果手上有 arm-none-eabi 工具链也可以直接走 Makefile# 清理后重新编译-j4 按核数并行 make clean make -j4 # 确认产物elf 用于调试bin 和 hex 用于烧录 ls -l build/*.elf build/*.bin build/*.hex # 看 Flash 占用超过 128K 就得改分区或者裁字库 arm-none-eabi-size build/app.elfarm-none-eabi-size的输出里 text 段是 Flash、databss 是 RAM。字库和图片数组都在 text 段占几十 KB 很正常但如果 text 突然涨了几百 K说明有张大图忘了加const被塞到 RAM 里去了。烧录两种方式都行。Keil 里点 Download 最省事命令行可以这样# ST-Link 走 OpenOCD 烧录等价于 IDE 里的下载按钮 openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c program build/app.bin 0x08000000 verify reset exit0x08000000是内部 Flash 起始地址如果你的板子做了 IAP 分区得改成应用区偏移比如0x08008000否则程序跑起来会卡在中断向量表里。4.2 引脚、时钟与复用功能检查这一步是纯检查不写代码但能省掉 80% 的调试时间。需要对照的原理图信息有四项FSMC 的数据线和控制线接到哪几组 IO、背光控制脚是哪个、复位脚是哪个、有没有用外部晶振。FSMC 的引脚是固定的复用功能配错了不会报错只会静默不输出。常见翻车点是把NE1接到了片选脚却把FSMC_Bank配成NE2读写的地址段对不上或者FSMC_DataWidth配 8 位而硬件是 16 位接线。检查方法很土但有效初始化完之后用万用表或者示波器看WR引脚全屏填充的时候它应该在持续跳变不跳说明总线根本没工作。时钟也要看一眼。FSMC 挂在 AHB 上如果SystemInit里把主频配到 72MHz读写一个像素大概几十纳秒配到 24MHz 也能跑但刷屏肉眼可见地慢。另外总线时序那几个参数地址建立时间、数据保持时间必须按屏的手册填填太快屏跟不上现象是随机丢像素。4.3 分级验证先色块再文字最后图片main.c里的演示顺序建议照这个来一层通过再进下一层。int main(void) { delay_init(); NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); uart_init(115200); /* 先把串口调好后面所有调试都靠它 */ LCD_Init(); /* 内部已含一次 LCD_Clear(WHITE) */ /* 第一级三色块验证开窗和颜色顺序 */ LCD_Fill(0, 0, 119, 159, RED); LCD_Fill(120, 0, 239, 159, GREEN); LCD_Fill(0, 160, 239, 319, BLUE); /* 第二级字符验证字模方向和取模方式 */ LCD_ShowString(20, 20, TFTLCD OK, RED, WHITE, 16, 0); /* 第三级图片验证数组长度和 RGB565 字节序 */ LCD_ShowPicture(0, 0, 240, 320, gImage_test); while (1) { LED0 !LED0; delay_ms(500); } }第一级如果红绿蓝位置不对那是0x36的扫描方向问题改 MADCTL 值如果三个块都出来但颜色是蓝红互换那是 RGB/BGR 顺序反了同样在0x36里改一位。第二级出乱码去查取模软件设置。第三级出现整体平移或者只有上半屏去查数组长度和窗口高度是否一致。板载 LED 闪烁是最后一道保险它说明主循环没卡死画面问题是纯显示层的事。5. 避坑与排查白屏、花屏、半屏错位的真实原因5.1 白屏与全黑现象上电之后屏幕均匀发白看不到任何图案串口打印正常。原因基本锁定在三个地方复位没生效、背光被拉满导致对比度看不出、初始化序列没跑完就退出。有些模组的 RST 引脚在模块内部已经有 RC 复位电路外部再拉低反而冲突另一些模组的 RST 悬空全靠软件复位这时候0x01那条命令的 120 毫秒等待就必须保留。解决顺序先用万用表量背光脚电压再在LCD_Init结束后加一句LCD_Fill刷个纯黑黑能出来说明驱动活了只是没画东西黑出不来就回头查 RST 和 FSMC 的片选。全黑且背光不亮的情况更简单直接查背光供电那条线跟驱动代码没关系。5.2 花屏、错位与颜色反相现象能显示内容但满屏雪花点、或者图像整体偏移几十个像素、或者颜色明显发灰发蓝。雪花点优先怀疑总线。数据线某一位虚焊或者杜邦线松动写进去的数据就会随机翻转表现出来就是噪点。判断方法是刷一个固定色块噪点跟着出现就说明是数据通路。错位则八成是LCD_SetWindow的起止坐标写反了x0 x1时驱动 IC 会按自己的规则处理结果是整块内容被折到另一边。颜色反相分两种。整体发灰通常是 Gamma 校正那几条用了别的屏的参数把 Gamma 段整段注释掉再看一眼就能确认蓝红互换是 RGB/BGR 位改0x36。这里有个后悔药把0x36的候选值写成一个数组用按键或者串口命令轮流切换比反复烧录快得多。5.3 刷屏慢、撕裂与复位时序现象画面能显示但整屏刷新肉眼可见地一行一行扫下来或者刷新过程中出现上下半屏不同步。慢的原因通常是两个一是逐点设坐标而没有开窗命令开销远大于数据二是 FSMC 时序参数填得过保守地址建立时间给了几百纳秒。前者改成LCD_Fill那种连续写模式后者按屏手册的下限往上调一档。撕裂是另一回事它来自边写边显示驱动 IC 正在扫描的时候你改了显存上下半屏就来自两个时刻。彻底的解法是双缓冲或者等垂直消隐再写成本较高工程里的做法是尽量把整屏更新压缩在一次窗口写入内完成减少中间被打断的机会。还有一种很隐蔽的情况屏幕偶尔闪一下就恢复正常重启又好了。这类问题多半是电源纹波背光瞬间电流把 3.3V 拉低MCU 复位了但你没察觉。加个大电容或者在初始化里多等一会儿往往就消失了。6. 二次开发把显示层抽成可替换的 GUI 底座这份代码最值得动手改的地方不是加更多绘图函数而是把lcd.c里那堆直接操作寄存器的函数收进一个驱动结构体。原因很实际你迟早要换屏。240×320 换成 320×480或者并口换成 SPI如果没有这层抽象main.c里几百行调用全得改一遍。我一般这么抽/* lcd_drv.h —— 上层只认这组接口换屏只改注册的那一份实现 */ typedef struct { void (*init)(void); void (*set_window)(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1); void (*write_pixel)(uint16_t color); void (*fill)(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color); uint16_t width; uint16_t height; uint8_t dir; /* 0 竖屏 1 横屏决定坐标要不要交换 */ } lcd_drv_t; /* 具体实现文件里导出唯一的实例上层通过指针访问 */ extern const lcd_drv_t lcd_ili9341_8080; extern const lcd_drv_t lcd_st7789_spi; void lcd_register(const lcd_drv_t *drv); /* 启动时注册一次 */ void lcd_draw_text(uint16_t x, uint16_t y, const char *s, uint16_t fc, uint16_t bc);抽完之后LCD_ShowString这类函数只依赖set_window和write_pixel两个原语坐标换算统一放在lcd_register里做一次横竖屏切换就不用改任何业务代码。实际改造时按这个顺序验证一步一验别一次改完再烧改造点改动位置验证方法总线层替换lcd_port_xxx.c里的两个写原语读 ID 能读到正确值屏幕参数化width/height/dir字段四角色块刚好铺满不越界上层解耦业务代码改用lcd_drv_t *换一份实现重新编译业务代码零改动字库裁剪lcdfont.h只保留用到的字号编译后 text 段明显缩小验证手段上有个小技巧在fill里加一个计数器统计每次刷新的像素数串口打出来跟(x1-x01)*(y1-y01)对一下对不上就是坐标换算写错了。这比盯着屏幕数格子靠谱得多尤其在横屏模式下。最后说个我自己的教训。早些年调一块新屏花屏花了两个晚上把初始化表逐条注释、把时序参数从慢到快试了个遍最后发现是排线第 7 根虚接——那种插针式的 FPC 座子看着插进去了其实只进了一半。从那以后我每次上电前都强制走一遍先量通断、再刷纯色、最后上字模的三步不管这块板子是不是刚焊好的。硬件这行的坑多半不在代码里。希望帮到你。本文还有配套的精品资源点击获取