简介:这份资源面向嵌入式开发初学者与进阶工程师,提供基于STM32F407与FSMC接口驱动LCD液晶屏的完整工程方案,解决图形界面开发中屏幕刷新慢、字符与汉字显示难、图形绘制繁琐等问题。压缩包共91个文件,以41个C源文件与37个头文件为核心,辅以启动汇编、链接脚本、Keil工程配置及字模提取工具,整体约3.49MB,结构清晰便于按模块查阅。内容覆盖FSMC地址映射与数据线宽度配置、LCD初始化序列、ASCII字符与点阵汉字取模显示、读液晶数据指令、自定义图形API绘制线条矩形圆形,以及中断定时器刷新与电源管理等关键环节,并附带USB相关驱动与自检代码。目前已有1651人学习下载,适合希望掌握STM32高级外设与LCD驱动技术、快速搭建嵌入式显示方案的开发者参考借鉴。
1. 从并口屏到FSMC:为什么STM32F407驱动LCD要绕这条“硬件外挂”路
如果你手头有一块STM32F407的开发板,又恰好配了一块16位并口的TFT液晶屏,那你大概率会面临一个选择:是用GPIO模拟时序“软驱动”,还是启用芯片内部那个专门为外部存储器设计的FSMC外设。很多刚接触F407的朋友第一反应是拿几个IO口翻来覆去地写,结果刷一屏纯色都要几百毫秒,更别提跑LVGL这类图形库了。FSMC(Flexible Static Memory Controller,灵活静态存储控制器)就是解决这个痛点的硬件外挂——它把LCD当成一个SRAM来读写,地址线和数据线自动产生时序,CPU只需要往特定地址写数据,硬件自己完成并口通信。这套方案在工业HMI、医疗设备显示终端、电力仪表上非常常见,适合那些需要稳定刷屏、又不想外挂LCD控制器的项目。今天要拆的这份资源,就是围绕STM32F407的FSMC接口,把LCD驱动从底层寄存器配置到上层图形API调用完整串起来的一套实战代码包。
2. FSMC驱动LCD的底层逻辑:地址映射与时序参数怎么算
2.1 把LCD当成SRAM:FSMC的存储块划分与地址映射
FSMC在STM32F407里管理着几个外部存储区域,其中NOR/PSRAM存储块被划分为四个子区,每个子区有独立的片选信号NE1到NE4。LCD的片选通常接在NE1上,对应基地址0x60000000。关键技巧在于:LCD的RS(寄存器/数据选择)引脚不接地址线A0,而是接在某个地址线上,比如A0或者A6。这样CPU访问不同地址时,硬件自动拉高或拉低RS,实现命令和数据的区分。
假设RS接在A0上,那么:
- 写命令地址 = 基地址 + 0 = 0x60000000
- 写数据地址 = 基地址 + 2 = 0x60000002(因为16位数据宽度,地址步进为2)
如果RS接在A6上,则命令地址是0x60000000,数据地址是0x60000040。这个偏移量必须和硬件连线严格对应,否则会出现“能读ID但刷不了屏”的玄学问题。我一般会在头文件里用宏定义把这两个地址写死,后面所有操作都基于它们展开。
/* lcd_fsmc.h - 地址映射定义 */ #define LCD_BASE ((uint32_t)0x60000000) #define LCD_CMD_ADDR (LCD_BASE) /* RS=0 命令地址 */ #define LCD_DATA_ADDR (LCD_BASE + 0x40) /* RS=1 数据地址,A6接RS */ /* 写命令宏 */ #define LCD_WR_CMD(cmd) (*(__IO uint16_t *)LCD_CMD_ADDR = (cmd)) /* 写数据宏 */ #define LCD_WR_DATA(dat) (*(__IO uint16_t *)LCD_DATA_ADDR = (dat)) /* 读数据宏 */ #define LCD_RD_DATA() (*(__IO uint16_t *)LCD_DATA_ADDR)这段代码的核心就是两个指针操作。__IO告诉编译器这是易变内存,不要优化掉。写命令时CPU向0x60000000发起一个16位写事务,FSMC检测到地址落在NE1区域,自动拉低NE1片选,同时因为地址A6为0,RS引脚为低电平,LCD识别为命令。写数据时地址变成0x60000040,A6为高,RS拉高,LCD识别为数据。整个过程不需要手动翻转任何GPIO,这就是FSMC的价值。
2.2 时序寄存器配置:读写建立/保持时间怎么定
FSMC最让人头疼的就是时序参数。F407的FSMC每个子区有独立的时序寄存器FSMC_BTR和FSMC_BWTR,分别控制读时序和写时序。关键参数有四个:地址建立时间ADDSET、地址保持时间ADDHLD、数据建立时间DATAST、总线恢复时间BUSARE。对于常见的ILI9341、ST7789这类控制器,写时序通常比读时序快很多。
以STM32F407主频168MHz为例,HCLK周期约5.95ns。ILI9341的写周期最小要求66ns,读周期最小要求150ns。我一般这样配置:
/* fsmc_init.c - 时序配置片段 */ FSMC_NORSRAMInitTypeDef FSMC_NORSRAMInitStructure; FSMC_NORSRAMTimingInitTypeDef readTiming, writeTiming; /* 读时序:地址建立15个HCLK,数据保持60个HCLK */ readTiming.FSMC_AddressSetupTime = 15; readTiming.FSMC_AddressHoldTime = 0; readTiming.FSMC_DataSetupTime = 60; readTiming.FSMC_BusTurnAroundDuration = 0; readTiming.FSMC_CLKDivision = 0; readTiming.FSMC_DataLatency = 0; readTiming.FSMC_AccessMode = FSMC_AccessMode_B; /* 写时序:地址建立3个HCLK,数据保持6个HCLK */ writeTiming.FSMC_AddressSetupTime = 3; writeTiming.FSMC_AddressHoldTime = 0; writeTiming.FSMC_DataSetupTime = 6; writeTiming.FSMC_BusTurnAroundDuration = 0; writeTiming.FSMC_CLKDivision = 0; writeTiming.FSMC_DataLatency = 0; writeTiming.FSMC_AccessMode = FSMC_AccessMode_B; FSMC_NORSRAMInitStructure.FSMC_Bank = FSMC_Bank1_NORSRAM1; FSMC_NORSRAMInitStructure.FSMC_DataAddressMux = FSMC_DataAddressMux_Disable; FSMC_NORSRAMInitStructure.FSMC_MemoryType = FSMC_MemoryType_SRAM; FSMC_NORSRAMInitStructure.FSMC_MemoryDataWidth = FSMC_MemoryDataWidth_16b; FSMC_NORSRAMInitStructure.FSMC_BurstAccessMode = FSMC_BurstAccessMode_Disable; FSMC_NORSRAMInitStructure.FSMC_AsynchronousWait = FSMC_AsynchronousWait_Disable; FSMC_NORSRAMInitStructure.FSMC_WaitSignalPolarity = FSMC_WaitSignalPolarity_Low; FSMC_NORSRAMInitStructure.FSMC_WrapMode = FSMC_WrapMode_Disable; FSMC_NORSRAMInitStructure.FSMC_WaitSignalActive = FSMC_WaitSignalActive_BeforeWaitState; FSMC_NORSRAMInitStructure.FSMC_WriteOperation = FSMC_WriteOperation_Enable; FSMC_NORSRAMInitStructure.FSMC_WaitSignal = FSMC_WaitSignal_Disable; FSMC_NORSRAMInitStructure.FSMC_ExtendedMode = FSMC_ExtendedMode_Enable; FSMC_NORSRAMInitStructure.FSMC_WriteBurst = FSMC_WriteBurst_Disable; FSMC_NORSRAMInitStructure.FSMC_ReadWriteTimingStruct = &readTiming; FSMC_NORSRAMInitStructure.FSMC_WriteTimingStruct = &writeTiming; FSMC_NORSRAMInit(&FSMC_NORSRAMInitStructure); FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM1, ENABLE);这里有几个参数需要解释。FSMC_ExtendedMode_Enable必须打开,否则读写共用一套时序,读操作会慢得离谱。FSMC_AccessMode_B表示SRAM/CRAM模式,时序图里没有额外的等待周期。写时序的DataSetupTime设为6,算下来6×5.95≈35.7ns,加上地址建立时间3×5.95≈17.85ns,总共约53.5ns,满足ILI9341的66ns要求(实际还要算上片选和写信号的延迟,留有余量)。读时序的DataSetupTime设为60,约357ns,远大于150ns的最小值,这是为了兼容不同批次的屏,读ID时更稳。
提示:时序参数不是越小越好。我见过有人把写时序压到极限,结果屏幕出现雪花点或者局部花屏,这就是建立时间不够导致的。宁可多给几个周期,也不要卡在临界值上。
2.3 GPIO初始化:哪些引脚必须配复用推挽
FSMC用到的GPIO必须配置为复用推挽输出,速度建议设为100MHz。F407的FSMC引脚分布在PD、PE、PF、PG这几个端口上,具体用哪几个取决于你的数据宽度和地址线数量。16位数据模式需要D0到D15,加上NE1、NOE、NWE、A6等控制线,总共二十多根线。初始化时要注意:所有FSMC功能引脚都要开启AF12复用功能,不能只开时钟不配复用。
/* gpio_init.c - FSMC引脚配置示例 */ GPIO_InitTypeDef GPIO_InitStructure; /* 使能各端口时钟 */ RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOD | RCC_AHB1Periph_GPIOE | RCC_AHB1Periph_GPIOF | RCC_AHB1Periph_GPIOG, ENABLE); /* 使能FSMC时钟 */ RCC_AHB3PeriphClockCmd(RCC_AHB3Periph_FSMC, ENABLE); /* 配置PD口:D0-D1, D4-D5, D8-D9, D14-D15, NOE, NWE */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_14 | GPIO_Pin_15 | GPIO_Pin_4 | GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; GPIO_Init(GPIOD, &GPIO_InitStructure); GPIO_PinAFConfig(GPIOD, GPIO_PinSource0, GPIO_AF_FSMC); /* ... 其余引脚类似配置 ... */这段代码里GPIO_PuPd_UP是上拉,防止引脚悬空时产生干扰。GPIO_PinAFConfig把引脚映射到FSMC功能,这一步漏掉的话,引脚就是普通IO,FSMC根本不会输出信号。常见做法是把所有FSMC引脚写成一个数组,循环配置,代码更简洁。
3. 从寄存器到图形API:LCD驱动层与上层接口怎么衔接
3.1 基础驱动函数:读ID、写命令、写数据、读数据
底层地址映射和时序配好之后,接下来要封装几个基础函数。读ID是验证硬件连接是否正确的第一步,如果ID读出来是0x0000或者0xFFFF,说明数据线有虚焊或者时序不对。
/* lcd_drv.c - 基础驱动函数 */ uint16_t LCD_ReadID(void) { LCD_WR_CMD(0x04); /* ILI9341读ID命令,不同屏命令不同 */ LCD_RD_DATA(); /* 第一个返回参数丢弃 */ LCD_RD_DATA(); /* 第二个返回参数丢弃 */ return LCD_RD_DATA(); /* 第三个才是ID */ } void LCD_WriteReg(uint16_t reg, uint16_t val) { LCD_WR_CMD(reg); LCD_WR_DATA(val); } uint16_t LCD_ReadReg(uint16_t reg) { LCD_WR_CMD(reg); return LCD_RD_DATA(); } void LCD_SetCursor(uint16_t x, uint16_t y) { LCD_WR_CMD(0x2A); /* 列地址设置 */ LCD_WR_DATA(x >> 8); LCD_WR_DATA(x & 0xFF); LCD_WR_CMD(0x2B); /* 行地址设置 */ LCD_WR_DATA(y >> 8); LCD_WR_DATA(y & 0xFF); LCD_WR_CMD(0x2C); /* 写GRAM */ }LCD_ReadID里连续读了三次,因为ILI9341的读ID时序要求前两个返回是空数据,第三个才是厂商ID。不同控制器命令不一样,ST7789是0x04,NT35510是0xDA,这个要查对应数据手册。LCD_SetCursor设置好行列地址后,后续连续写数据就会自动填充GRAM,这是刷屏效率最高的方式。
3.2 图形API封装:画点、画线、填充矩形
有了基础函数,就可以往上封装图形API了。画点是所有图形的基础,填充矩形是刷屏和清屏的关键。
/* lcd_gfx.c - 图形API */ void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { LCD_SetCursor(x, y); LCD_WR_DATA(color); } void LCD_Fill(uint16_t sx, uint16_t sy, uint16_t ex, uint16_t ey, uint16_t color) { uint32_t total = (uint32_t)(ex - sx + 1) * (ey - sy + 1); LCD_SetCursor(sx, sy); while (total--) { LCD_WR_DATA(color); } } void LCD_DrawLine(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { int16_t dx = x2 - x1; int16_t dy = y2 - y1; int16_t ux = ((dx > 0) << 1) - 1; int16_t uy = ((dy > 0) << 1) - 1; int16_t x = x1, y = y1; int16_t eps = 0; dx = (dx > 0) ? dx : -dx; dy = (dy > 0) ? dy : -dy; if (dx > dy) { for (; x != x2; x += ux) { LCD_DrawPoint(x, y, color); eps += dy; if ((eps << 1) >= dx) { y += uy; eps -= dx; } } } else { for (; y != y2; y += uy) { LCD_DrawPoint(x, y, color); eps += dx; if ((eps << 1) >= dy) { x += ux; eps -= dy; } } } }LCD_Fill里用了一个while循环连续写同一个颜色,这是刷屏最快的写法。如果每次画点都调用LCD_SetCursor,效率会低十倍以上。LCD_DrawLine用的是Bresenham算法,只做整数加减和移位,没有浮点运算,在F407上跑起来很快。这套图形API虽然简单,但已经足够支撑菜单、波形、进度条这些常见界面元素了。
3.3 与LVGL对接:显示缓冲与刷新接口
如果项目需要更复杂的UI,通常会移植LVGL。LVGL需要一个显示缓冲区和刷新回调函数。FSMC驱动下,我一般用一行或者1/10屏幕大小的缓冲区,在刷新回调里把缓冲区内容一次性写进GRAM。
/* lvgl_port.c - LVGL显示接口 */ static lv_disp_draw_buf_t draw_buf; static lv_color_t buf_1[LCD_WIDTH * 40]; /* 40行缓冲区 */ void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint32_t total = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1); LCD_SetCursor(area->x1, area->y1); while (total--) { LCD_WR_DATA(color_p->full); color_p++; } lv_disp_flush_ready(drv); } void lvgl_init(void) { lv_init(); lv_disp_draw_buf_init(&draw_buf, buf_1, NULL, LCD_WIDTH * 40); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = LCD_WIDTH; disp_drv.ver_res = LCD_HEIGHT; disp_drv.flush_cb = lvgl_flush_cb; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv); }缓冲区大小决定了刷新效率和内存占用。40行缓冲区在F407的192KB SRAM里占约38KB,加上LVGL自身开销,内存还比较宽裕。如果缓冲区太小,LVGL会频繁调用刷新回调,反而降低效率。lv_disp_flush_ready必须调用,否则LVGL会一直等待,界面卡死。
4. 避坑与排查:FSMC驱动LCD最常见的五个翻车现场
4.1 读ID全是0xFFFF或者0x0000
现象:上电后读ID,串口打印出来全是0xFFFF或者0x0000,屏幕没有任何反应。
原因:数据线虚焊、时序参数不对、RS地址偏移量搞错、片选信号没接对。最常见的是RS接的地址线和代码里的偏移量不匹配。比如硬件上RS接A6,代码里却按A0算偏移,写命令和写数据全乱套。
解决:先用万用表量数据线通断,确认没有虚焊。然后把读时序的DataSetupTime加大到100以上,排除时序问题。最后用示波器看NE1和RS波形,确认写命令时RS为低,写数据时RS为高。如果波形不对,检查GPIO复用配置和地址宏定义。
4.2 刷屏出现雪花点或局部花屏
现象:填充纯色时,屏幕上有随机噪点,或者某几行颜色不对。
原因:写时序太快,数据建立时间不够。F407的FSMC在168MHz下,如果DataSetupTime设得太小,数据还没稳定就被LCD采样了。另外,如果PCB走线太长或者没有加匹配电阻,信号反射也会导致花屏。
解决:把写时序的DataSetupTime从6加大到15甚至20,观察花屏是否减轻。如果减轻了,说明就是时序问题。同时检查PCB上数据线是否等长,必要时在数据线上串22Ω或33Ω电阻。我一般会在初期调试时把时序设得很保守,等屏幕稳定了再逐步压缩。
4.3 屏幕能显示但颜色反了
现象:显示红色变成蓝色,或者整体颜色偏暗。
原因:RGB顺序搞反了,或者颜色数据是16位但高低字节写反了。ILI9341默认是RGB565,但有些屏是BGR顺序,需要在初始化时写0x36寄存器切换。
解决:在初始化序列里找到0x36(Memory Access Control)寄存器,修改bit3(BGR位)。如果颜色整体偏暗,检查背光电路是否正常,或者颜色数据是不是写成了8位。
4.4 移植LVGL后界面卡顿严重
现象:LVGL界面能显示,但滑动或者动画明显掉帧。
原因:刷新回调里每次只写一个像素,没有用连续写模式。或者缓冲区太小,LVGL频繁触发刷新。另外,如果FSMC时序太慢,每次写GRAM都要等很久。
解决:确保刷新回调里用LCD_SetCursor设置一次起始地址,然后连续写整个区域。缓冲区至少设为屏幕的1/10。检查FSMC写时序,DataSetupTime不要超过10,否则写一个像素的时间太长。如果还卡,考虑用DMA把缓冲区数据搬到FSMC数据地址,进一步降低CPU占用。
4.5 上电后屏幕白屏或黑屏,但背光亮
现象:背光正常,但屏幕没有任何内容,读ID也读不到。
原因:复位时序不对。LCD控制器需要在上电后保持复位低电平至少10ms,然后拉高再等待120ms才能发初始化命令。如果复位时间不够,控制器内部状态机没准备好。
解决:检查复位引脚的控制代码,确保低电平保持时间足够。有些开发板把复位直接接在MCU的复位电路上,这种情况下要确认上电时LCD复位引脚确实被拉低过。如果复位引脚接在普通GPIO上,初始化时先拉低,延时20ms,再拉高,延时150ms,然后再读ID。
5. 进阶技巧:用DMA2D和FSMC双缓冲把刷屏率再提一档
FSMC驱动LCD的瓶颈往往不在FSMC本身,而在CPU搬运数据的速度。F407没有DMA2D,但可以用DMA1或DMA2的存储器到存储器模式,把图形缓冲区的数据搬到FSMC数据地址。具体做法是:先在SRAM里开辟一块和屏幕分辨率对应的显存,所有绘图操作在显存里完成,然后启动DMA把显存搬到LCD的GRAM。这样CPU只需要配置DMA,搬运过程不占用CPU时间。
/* dma_flush.c - DMA搬运显存到LCD */ #define LCD_WIDTH 320 #define LCD_HEIGHT 240 static uint16_t lcd_buffer[LCD_WIDTH * LCD_HEIGHT]; void LCD_Flush_DMA(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); DMA_InitStructure.DMA_Channel = DMA_Channel_0; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)LCD_DATA_ADDR; DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)lcd_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_MemoryToMemory; DMA_InitStructure.DMA_BufferSize = LCD_WIDTH * LCD_HEIGHT; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable; DMA_Init(DMA2_Stream0, &DMA_InitStructure); LCD_SetCursor(0, 0); DMA_Cmd(DMA2_Stream0, ENABLE); while (DMA_GetFlagStatus(DMA2_Stream0, DMA_FLAG_TCIF0) == RESET); DMA_ClearFlag(DMA2_Stream0, DMA_FLAG_TCIF0); DMA_Cmd(DMA2_Stream0, DISABLE); }这段代码把整个显存一次性搬到LCD。DMA_PeripheralInc_Disable表示外设地址固定,始终写FSMC数据地址。DMA_MemoryInc_Enable表示内存地址递增,逐个读取显存。DMA_DIR_MemoryToMemory是存储器到存储器模式。搬运完成后清除标志位,关闭DMA流。实测在168MHz主频下,320×240的16位色显存搬运时间约8ms,刷屏率能到100fps以上,比CPU逐像素写快了三倍多。
注意:DMA搬运期间不能操作FSMC,否则会出现数据竞争。如果用了LVGL,需要在DMA传输完成后再调用
lv_disp_flush_ready,否则LVGL会提前刷新下一帧导致撕裂。
从那以后我每次调FSMC驱动LCD,都强制先读ID确认硬件通路,再逐步压缩时序参数,最后才上图形库。这套流程帮我省下了大量返工时间。希望帮到你。
本文还有配套的精品资源,点击获取