☰
STM32F407 FSMC驱动并口LCD:从时序计算到DMA加速实战
2026/10/14 18:49:44 网站建设 项目流程

简介:本资源面向嵌入式开发初学者与进阶工程师,提供基于STM32F407的FSMC驱动LCD完整工程方案,解决裸机环境下液晶显示与图形界面开发问题。包内共91个文件,以41个C源文件与37个头文件为核心,辅以启动汇编、链接脚本、Keil工程配置及字库与数学库文件,压缩包约3.49MB,结构清晰便于按模块查阅。内容覆盖FSMC地址映射与数据线宽度配置、LCD初始化序列、字符与汉字取模显示、读液晶数据指令、自定义图形API绘制线条矩形圆形,以及中断定时器刷新与电源管理等关键知识点,并附带汉字取模工具与USB相关驱动代码。目前已有1651人学习下载,适合希望掌握STM32高级外设、快速搭建LCD显示框架并复用图形接口的开发者参考实践。

1. 当并口屏遇上FSMC:为什么STM32F407的LCD驱动要这么玩

如果你手头有一块STM32F407的开发板,又刚好买了一块16位并口的TFT液晶屏,那你大概率会面临一个选择:是用GPIO模拟时序来驱动,还是用FSMC外设来驱动。我见过不少初学者用GPIO模拟的方式点亮了屏幕,代码跑起来也没问题,但一旦刷新频率上去、或者要在屏幕上刷图片、跑UI,画面就开始撕裂、闪烁,CPU占用率直接飙到七八成。这时候FSMC的价值就体现出来了——它把并口时序的生成从CPU手里接过去,硬件自动完成地址建立、数据保持、读写脉冲这些繁琐的时序控制,CPU只需要往一个特定的内存地址写数据,剩下的交给FSMC就行。

STM32F407的FSMC(Flexible Static Memory Controller,灵活静态存储控制器)原本是设计用来驱动SRAM、NOR Flash、NAND Flash这类静态存储器的,但它的时序参数足够灵活,完全可以拿来驱动8080/6800接口的并口LCD。关键在于把LCD当成一个“挂在外部总线上的存储器”来看待,把LCD的RS(寄存器选择)引脚接到FSMC的某根地址线上,这样读写不同的地址就对应着读写LCD的命令或数据。这个思路一旦理解,整个驱动框架就清晰了。这篇文章适合已经能跑通GPIO点屏、想进一步把刷新率和CPU效率提上来的嵌入式开发者,也适合刚接触FSMC、想找一个完整落地案例的工程师。

2. FSMC驱动LCD的硬件设计与时序参数计算

2.1 硬件连接:地址线、数据线和控制线怎么接

FSMC驱动LCD的硬件连接不是随便接的,接错了要么点不亮,要么时序混乱。先明确一点:LCD的并口接口通常有这些信号——CS(片选)、RS(命令/数据选择)、WR(写使能)、RD(读使能)、D0~D15(16位数据总线),有的还有RST(复位)和背光控制。FSMC这边,我们一般用NOR/PSRAM控制器对应的Bank1,因为Bank1有独立的片选信号NE1~NE4,每个Bank又可以分成4个子区域,每个子区域有独立的时序寄存器。

具体接法上,数据线D0~D15对应FSMC的FSMC_D0~FSMC_D15,这个基本是固定的,查芯片数据手册的复用功能表就能找到。控制线方面,FSMC_NOE接LCD的RD,FSMC_NWE接LCD的WR,FSMC_NE1接LCD的CS。最关键的是RS引脚——它决定当前操作是写命令还是写数据。常见做法是把RS接到FSMC的某根地址线上,比如FSMC_A0或者FSMC_A6。为什么是地址线?因为FSMC在访问不同地址时,地址线的电平会变化,我们把RS接到A0上,那么当CPU访问基地址+0时,A0为低,LCD认为是命令;访问基地址+2时(16位数据总线,地址步进为2),A0为高,LCD认为是数据。这样就用地址偏移区分了命令和数据,非常巧妙。

注意:不同开发板的LCD接口定义可能不同,有的板子把RS接到A6,有的接到A0,一定要先确认原理图,再决定代码里的地址偏移量。

2.2 时序参数:ADDSET、ADDHLD、DATAST怎么算

FSMC的NOR/PSRAM时序寄存器(FSMC_BTRx)里有几个关键参数:ADDSET(地址建立时间)、ADDHLD(地址保持时间)、DATAST(数据保持时间)、CLKDIV(时钟分频)。这些参数的单位是HCLK周期。STM32F407的HCLK一般是168MHz,一个周期约5.95ns。LCD的时序要求可以从它的数据手册里查到,比如常见的ILI9341控制器,写周期最小要求是66ns左右,读周期更慢一些。

以写周期为例,FSMC写操作的总时间大致是:ADDSET + DATAST + 1个HCLK周期(有些手册写的是ADDSET + DATAST + 2,具体看参考手册的时序图)。假设我们取ADDSET=1,DATAST=15,那么总时间大约是(1+15+1)*5.95≈101ns,满足66ns的要求,留有余量。读周期通常需要更长的DATAST,因为LCD的读时序一般比写慢,比如ILI9341的读周期要求是160ns左右,那DATAST可能要设到25以上。实际调试时,如果发现读出来的ID不对或者显示异常,优先怀疑时序参数偏小。

// FSMC NOR/PSRAM时序初始化结构体(以Bank1 NE1为例) FSMC_NORSRAMTimingInitTypeDef FSMC_TimeInitStruct; // 读写时序分开配置,读时序通常更慢 FSMC_TimeInitStruct.FSMC_AddressSetupTime = 1; // ADDSET,地址建立时间 FSMC_TimeInitStruct.FSMC_AddressHoldTime = 0; // ADDHLD,地址保持时间,NOR模式下一般不用 FSMC_TimeInitStruct.FSMC_DataSetupTime = 15; // DATAST,写数据保持时间 FSMC_TimeInitStruct.FSMC_BusTurnAroundDuration = 0; // 总线周转时间,NOR模式下一般不用 FSMC_TimeInitStruct.FSMC_CLKDivision = 0; // 时钟分频,异步模式不用 FSMC_TimeInitStruct.FSMC_DataLatency = 0; // 数据延迟,异步模式不用 FSMC_TimeInitStruct.FSMC_AccessMode = FSMC_AccessMode_B; // 模式B,适合SRAM/LCD // 读时序单独配置,DATAST要更大 FSMC_NORSRAMTimingInitTypeDef FSMC_ReadTimeInitStruct; FSMC_ReadTimeInitStruct.FSMC_AddressSetupTime = 1; FSMC_ReadTimeInitStruct.FSMC_AddressHoldTime = 0; FSMC_ReadTimeInitStruct.FSMC_DataSetupTime = 25; // 读数据保持时间,比写大 FSMC_ReadTimeInitStruct.FSMC_BusTurnAroundDuration = 0; FSMC_ReadTimeInitStruct.FSMC_CLKDivision = 0; FSMC_ReadTimeInitStruct.FSMC_DataLatency = 0; FSMC_ReadTimeInitStruct.FSMC_AccessMode = FSMC_AccessMode_B;

上面这段代码里,FSMC_AccessMode_B对应的是SRAM/PSRAM的Mode B时序,它的特点是读操作时NOE和NWE不会同时有效,适合LCD这种需要独立读写使能的设备。FSMC_DataSetupTime是最关键的参数,写太小会导致数据没稳住就被LCD采样,读太小会读到错误数据。我一般会先用保守值(比如写20、读30)把屏幕点亮,再逐步往下压,直到出现异常,然后回退两三个周期作为最终值。

2.3 FSMC初始化:从GPIO到控制器的完整配置

FSMC的初始化分三步:先配置GPIO复用功能,再配置FSMC控制器本身,最后配置时序寄存器。GPIO这边,所有用到的FSMC引脚都要设为复用推挽输出,速度选100MHz(F407的最高档),因为FSMC的时钟频率不低,GPIO速度不够会导致信号边沿变缓,影响时序。注意数据线D0~D15和地址线、控制线都要配,一个都不能漏。

// GPIO复用配置(以FSMC_D0~D15、NOE、NWE、NE1、A0为例) GPIO_InitTypeDef GPIO_InitStruct; // 使能FSMC和对应GPIO的时钟 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOD | RCC_AHB1Periph_GPIOE | RCC_AHB1Periph_GPIOF | RCC_AHB1Periph_GPIOG, ENABLE); RCC_AHB3PeriphClockCmd(RCC_AHB3Periph_FSMC, ENABLE); // 数据线D0~D15分布在GPIOD和GPIOE上,具体查手册 GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_14 | GPIO_Pin_15; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStruct.GPIO_OType = GPIO_OType_PP; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_100MHz; GPIO_InitStruct.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOD, &GPIO_InitStruct); GPIO_PinAFConfig(GPIOD, GPIO_PinSource0, GPIO_AF_FSMC); // ... 其他引脚类似配置 // FSMC控制器初始化 FSMC_NORSRAMInitTypeDef FSMC_InitStruct; FSMC_InitStruct.FSMC_Bank = FSMC_Bank1_NORSRAM1; // 使用Bank1 NE1 FSMC_InitStruct.FSMC_DataWidth = FSMC_DataWidth_16b; // 16位数据宽度 FSMC_InitStruct.FSMC_ExtendedMode = FSMC_ExtendedMode_Enable; // 读写时序分开 FSMC_InitStruct.FSMC_WriteOperation = FSMC_WriteOperation_Enable; FSMC_InitStruct.FSMC_WaitSignal = FSMC_WaitSignal_Disable; FSMC_InitStruct.FSMC_AsynchronousWait = FSMC_AsynchronousWait_Disable; FSMC_InitStruct.FSMC_WrapMode = FSMC_WrapMode_Disable; FSMC_InitStruct.FSMC_WriteBurst = FSMC_WriteBurst_Disable; FSMC_InitStruct.FSMC_ReadWriteTimingStruct = &FSMC_TimeInitStruct; FSMC_InitStruct.FSMC_WriteTimingStruct = &FSMC_TimeInitStruct; FSMC_NORSRAMInit(&FSMC_InitStruct); FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM1, ENABLE);

这段代码里,FSMC_ExtendedMode_Enable打开了扩展模式,允许读写使用不同的时序参数,这对LCD驱动很重要,因为读ID和读GRAM的时序要求通常比写命令慢。FSMC_DataWidth_16b要和硬件实际连接一致,如果LCD是8位接口,这里就要改成8位,同时地址步进也会变化。初始化完成后,就可以通过定义基地址指针来访问LCD了。

3. 用FSMC地址映射读写LCD:命令与数据的分离技巧

3.1 地址映射原理:为什么基地址+偏移就能区分命令和数据

FSMC把外部存储器映射到CPU的地址空间里,Bank1 NE1的基地址是0x60000000。当我们把LCD的RS接到FSMC_A0上时,访问0x60000000这个地址,A0为低,LCD认为收到的是命令;访问0x60000002这个地址(16位总线,地址加2),A0为高,LCD认为收到的是数据。这个映射关系是硬件自动完成的,CPU只管往这两个地址写值就行。

实际编程时,我们通常定义两个宏或者两个指针变量,一个指向命令地址,一个指向数据地址。这样在写LCD驱动时,代码可读性会好很多。比如:

// 定义LCD命令和数据地址(RS接A0,16位总线) #define LCD_BASE_ADDR ((uint32_t)0x60000000) #define LCD_CMD_ADDR (LCD_BASE_ADDR) // A0=0,命令 #define LCD_DATA_ADDR (LCD_BASE_ADDR + 2) // A0=1,数据 // 用指针方式访问 #define LCD_WRITE_CMD(cmd) (*(__IO uint16_t *)LCD_CMD_ADDR = (cmd)) #define LCD_WRITE_DATA(data) (*(__IO uint16_t *)LCD_DATA_ADDR = (data)) #define LCD_READ_DATA() (*(__IO uint16_t *)LCD_DATA_ADDR)

这里__IO是volatile的宏定义,防止编译器优化掉这些访问。因为FSMC的访问是硬件行为,编译器如果看不到“副作用”,可能会把多次写操作合并或者删除,加上volatile就安全了。另外,地址偏移量是2而不是1,因为数据总线是16位,FSMC内部地址是按字节编址的,但每次访问16位数据,地址要按2字节对齐。

3.2 写命令、写数据、读数据的标准流程

LCD的驱动流程一般是:先写命令寄存器地址,再写数据。比如初始化时,先写0x2A设置列地址,然后写两个字节的起始列和结束列;再写0x2B设置行地址,写起始行和结束行;最后写0x2C准备写GRAM,然后连续写像素数据。读操作类似,先写命令,再读数据。

// 写命令 void LCD_WriteCmd(uint16_t cmd) { LCD_WRITE_CMD(cmd); } // 写数据 void LCD_WriteData(uint16_t data) { LCD_WRITE_DATA(data); } // 读数据(用于读ID或读GRAM) uint16_t LCD_ReadData(void) { return LCD_READ_DATA(); } // 设置显示窗口(以ILI9341为例) void LCD_SetWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { LCD_WriteCmd(0x2A); // 列地址设置 LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteData(x2 >> 8); LCD_WriteData(x2 & 0xFF); LCD_WriteCmd(0x2B); // 行地址设置 LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteData(y2 >> 8); LCD_WriteData(y2 & 0xFF); LCD_WriteCmd(0x2C); // 准备写GRAM }

这段代码里,LCD_SetWindow是刷屏和画图的基础,设置好窗口后,后续的像素数据会按顺序填充到这个区域。注意每次写命令后跟的数据个数要严格按LCD手册来,多写或少写都会导致后续命令错位。我见过有人写列地址时只写了两个字节(只设了起始列没设结束列),结果屏幕显示区域完全不对,排查了半天才发现是数据个数不对。

3.3 读ID验证:判断FSMC和LCD是否正常通信

在正式初始化LCD之前,先读一下LCD的ID是个好习惯。常见的ILI9341的ID是0x9341,读ID的命令是0xD3,后面跟4个参数(第一个是空读),然后读第2和第3个字节组合成ID。如果读出来是0x0000或者0xFFFF,说明FSMC和LCD之间没有正常通信,可能是时序不对、接线错误或者片选没使能。

// 读LCD ID(以ILI9341为例) uint16_t LCD_ReadID(void) { uint16_t id = 0; LCD_WriteCmd(0xD3); // 读ID命令 LCD_ReadData(); // 空读一次 id = LCD_ReadData() << 8; // 读高字节 id |= LCD_ReadData(); // 读低字节 return id; }

读ID的时候,时序参数里的读DATAST要足够大,否则读回来的数据不稳定。如果读出来ID不对,先把读DATAST加大到30甚至40试试,如果还是不对,再检查硬件连接。另外,有些LCD的读ID命令需要先发一个0xD3,再发一个参数0x00,具体看手册。读ID这一步非常关键,它是后续所有调试的“黑匣子”——ID对了,说明硬件和底层时序基本没问题;ID不对,后面怎么调都是白费。

4. 避坑指南:FSMC驱动LCD最常见的5个翻车现场

4.1 屏幕白屏或花屏,读ID返回0xFFFF

现象:上电后屏幕一片白或者满屏雪花,读ID返回0xFFFF。原因通常是FSMC的片选没有正确使能,或者GPIO复用配置漏了某个引脚。解决:先检查FSMC_NORSRAMCmd是否调用了,再逐个核对GPIO_PinAFConfig有没有配全。特别是数据线D0~D15,少配一根就可能读回全1。另外,有些开发板的LCD片选是通过一个IO口控制的,不是直接接FSMC_NE1,这种情况需要先拉低那个IO口。

4.2 能读到ID但刷屏颜色不对,红蓝互换

现象:读ID正常,但刷红色显示蓝色,刷蓝色显示红色。原因:LCD的像素数据格式是RGB565,但高低字节顺序可能和FSMC的16位数据总线不匹配。解决:在写像素数据时交换高低字节,或者检查LCD初始化命令里的像素格式设置(0x3A寄存器)。常见做法是写数据时用(color >> 8) | (color << 8)交换一下。这个坑很隐蔽,因为ID能读对说明通信没问题,只是数据解释方式不同。

4.3 刷屏速度慢,CPU占用率高

现象:用FSMC刷屏,但速度并没有比GPIO模拟快多少,CPU占用率还是很高。原因:可能是每次写数据都调用了函数,函数调用开销大;或者没有使用DMA。解决:把LCD_WriteData改成宏或者内联函数,减少调用开销。更进一步,可以用FSMC的DMA功能,把像素数据从内存直接搬到FSMC的数据地址,CPU完全不参与。不过DMA方式需要配置好DMA通道和FSMC的地址映射,初次使用容易配错。

4.4 读GRAM数据错位,图片显示偏移

现象:读回来的GRAM数据整体偏移了几个像素,或者行列颠倒。原因:设置显示窗口时,列地址和行地址的起始、结束值写反了,或者窗口设置命令的数据个数不对。解决:仔细核对LCD手册里0x2A和0x2B命令的参数顺序,通常是先起始高字节、起始低字节、结束高字节、结束低字节。另外,有些LCD的GRAM是行列交换的,需要在初始化时设置0x36寄存器的MADCTL位。

4.5 时序参数压得太紧,低温或高负载下不稳定

现象:常温下显示正常,但一到低温环境或者CPU负载高时,屏幕出现闪烁或数据错误。原因:FSMC的时序参数设得太极限,没有留余量。解决:把ADDSET和DATAST适当加大,尤其是DATAST,建议在满足LCD手册要求的基础上再留20%余量。我一般会在最终产品里把写DATAST设为手册要求值的1.5倍,读DATAST设为2倍,虽然牺牲一点速度,但稳定性大幅提升。

5. 进阶技巧:用DMA加速刷屏与双缓冲思路

5.1 DMA搬运像素数据:让CPU彻底解放

FSMC的写操作虽然比GPIO快,但如果要刷一整屏(比如240x320=76800个像素),CPU还是要执行76800次写操作。用DMA可以把这些写操作交给DMA控制器,CPU只需要配置好源地址、目标地址和数据长度,然后启动DMA就行。目标地址就是LCD_DATA_ADDR,源地址是内存里的图像缓冲区,数据长度是像素数乘以2(16位)。

// DMA配置(以DMA2 Stream0为例,具体通道查手册) DMA_InitTypeDef DMA_InitStruct; DMA_InitStruct.DMA_Channel = DMA_Channel_0; DMA_InitStruct.DMA_PeripheralBaseAddr = LCD_DATA_ADDR; // 目标:LCD数据地址 DMA_InitStruct.DMA_Memory0BaseAddr = (uint32_t)imageBuffer; // 源:图像缓冲区 DMA_InitStruct.DMA_DIR = DMA_DIR_MemoryToMemory; // 实际是内存到外设 DMA_InitStruct.DMA_BufferSize = 240 * 320; // 像素数 DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址固定 DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址递增 DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStruct.DMA_Mode = DMA_Mode_Normal; DMA_InitStruct.DMA_Priority = DMA_Priority_High; DMA_InitStruct.DMA_FIFOMode = DMA_FIFOMode_Disable; DMA_Init(DMA2_Stream0, &DMA_InitStruct); DMA_Cmd(DMA2_Stream0, ENABLE);

这段代码里,DMA_DIR_MemoryToMemory看起来是内存到内存,但实际上外设地址是FSMC的数据地址,DMA控制器会把它当作外设来处理。DMA_PeripheralInc_Disable很关键,因为LCD的数据地址是固定的,每次写都往同一个地址写,不需要递增。DMA_MemoryInc_Enable让源地址自动递增,依次取出图像数据。启动DMA后,CPU就可以去处理其他任务,等DMA传输完成中断再回来。

5.2 双缓冲:解决刷屏撕裂的实用方案

即使有了DMA,如果一边刷屏一边更新图像数据,还是可能出现撕裂——屏幕上部分显示旧数据,部分显示新数据。双缓冲的思路是准备两块图像缓冲区,DMA刷其中一块的时候,CPU往另一块写新数据,刷完一帧后交换缓冲区。这样屏幕上永远显示的是完整的一帧,不会出现半新半旧的情况。

实现上,可以用两个数组bufferA和bufferB,一个标志位记录当前DMA正在刷哪块。DMA传输完成中断里切换标志位,并重新配置DMA的源地址为另一块缓冲区。注意DMA传输完成中断里不要做太多事情,否则会影响下一帧的启动时间。另外,双缓冲会占用双倍内存,对于240x320的RGB565图像,一块缓冲区是150KB,两块就是300KB,STM32F407有192KB的SRAM,不够用,需要外扩SRAM或者用更小的分辨率。如果内存不够,可以只对部分区域做双缓冲,比如状态栏区域。

5.3 验证刷屏性能:用帧率和CPU占用率说话

怎么判断FSMC+DMA的方案到底有没有提升?最直接的方法是测帧率。在刷屏函数前后翻转一个GPIO,用示波器看高电平持续时间,或者用定时器计数。我一般会在刷屏前启动定时器,刷完后停止,计算一帧耗时。对于240x320的屏幕,FSMC+DMA的方案通常能做到30fps以上,而纯GPIO模拟可能只有5~10fps。CPU占用率方面,DMA方式下CPU几乎不参与数据传输,占用率可以降到5%以下,而GPIO模拟方式CPU占用率通常在50%以上。

提示:测帧率时注意关闭编译器优化或者用volatile变量,否则编译器可能把整个刷屏循环优化掉,测出来的时间是假的。

最后说一个我自己的习惯:每次调FSMC驱动LCD,我都会先用保守时序把ID读出来,确认通信正常后再逐步压时序、加DMA。这个顺序不能反,否则出了问题根本分不清是硬件接线、时序参数还是DMA配置的锅。希望帮到你。

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

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

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

立即咨询