简介:面向STM32F407嵌入式开发者的1.8寸TFT液晶屏驱动工程,围绕Cortex-M4内核与串行通信接口,解决屏幕初始化、显示数据写入及画面刷新等关键问题,适合入门至中级嵌入式学习者快速上手显示驱动开发。整套资源共186个文件,压缩包仅5.72MB,以C语言源文件与头文件为主,并包含Keil工程文件、链接脚本、可执行映像及地址映射文件,可支持直接编译查看代码或烧录到开发板运行;其中头文件声明外设接口,C源文件实现驱动逻辑,hex文件可独立烧录验证。工程还集成了定时器、模数转换、时钟控制、CAN总线等标准外设驱动,既可辅助液晶屏驱动开发,也可作为STM32F4外设编程的参考范例。已有815人学习,对照源码可深入理解底层通信时序、寄存器配置和显示控制逻辑,便于迁移到同类型小尺寸液晶屏项目并自行修改扩展。
1. 为什么STM32F407ZET驱动1.8寸TFT_LCD总会在初始化上卡住:先分清屏、驱动IC和接线
接手一块1.8寸TFT_LCD模组,用STM32F407ZET驱动,很多人第一反应是查例程、找引脚配置文件,结果经常是白屏、花屏、颜色错乱。问题往往不在主控,而在你拿到的是哪一种面板、配的是哪一颗驱动IC、上电时序对不对。这块屏绝大多数是SPI接口、128×160分辨率、内置ST7735S或类似驱动芯片的模组,少数是并行8080接口。我在这篇文章里会按实际调试流程,把从引脚决策、初始化序列、模拟SPI与硬件SPI切换,到白屏和花屏的几个典型坑完整走一遍。适合刚把STM32F407ZET跑起来、准备点亮第一块彩屏的开发者,也适合想从模拟SPI换硬件SPI提升刷新率的从业者。
2. 驱动原理与引脚决策:SPI时序、寄存器与你的第一种接线方案
2.1 先看屏的驱动IC:ST7735S/ST7735R 的寄存器链路
1.8寸TFT_LCD模组背后一般有一颗驱动IC,最常见的是ST7735S或ST7735R,少数是ILI9163。屏是RGB565格式,分辨率128×160。你不知道驱动IC的情况下,先看模组丝印、卖家提供的规格书,或者直接查屏ID寄存器。ST7735S的ID通常是0x89,ILI9163的ID是0x01。读取方法是在初始化完成或者复位后,用串口把RDDID指令发出去,再读取MISO上的数据。如果你用的是模拟SPI,需要把MISO引脚也配上输入模式,否则读不出来。
上电时序上,LCD模组有明确要求:先供VCC,等待至少10ms;然后拉高背光LEDK/LEDA;再对RST引脚产生一个低电平脉冲,脉冲宽度不少于10us;之后释放RST,等待120ms,再开始发送初始化命令。很多时候,白屏是因为MCU和屏同时上电,RST时序靠代码控制,但又在供电稳定前就执行了。一个实用的做法是,在主程序入口延时200ms之后再碰LCD的任何引脚。
2.2 用模拟 SPI 还是硬件 SPI:STM32F407ZET 的引脚规划
STM32F407ZET有多个SPI外设,SPI1、SPI2、SPI3都可用。硬件SPI速度高,用DMA更省CPU,缺点是引脚复用配置麻烦,而且在高速模式下对SCK、MOSI的布线长度更敏感。模拟SPI则简单,任意GPIO都能承担SCK、MOSI、CS、DC、RST、BL,缺点是刷新一屏数据时CPU占用大,大约只有2到4MHz等效速率。
我的选择原则是:先拿模拟SPI调通显示逻辑,确认初始化序列和坐标计算没问题,再切换到硬件SPI提速。这样能把变量拆开,不会在硬件SPI配置和初始化序列同时出错时无从下手。
具体接线参考如下,STM32F407ZET的板子上我一般用SPI1所在的PA5-PA7,这样方便后续做DMA。当然,模拟SPI阶段用任意GPIO都可以,只要你能在代码里改掉。
| 屏端引脚 | 功能说明 | 推荐STM32F407ZET引脚 | 备注 |
|---|---|---|---|
| VCC | 3.3V电源 | 3.3V | 不要接5V,逻辑电平不匹配 |
| GND | 地 | GND | 与MCU共地 |
| CS | 片选,低有效 | PB12 | 模拟SPI可任选,硬件SPI建议固定 |
| DC | 数据/命令选择 | PA2 | 低电平写命令,高电平写数据 |
| RST | 复位,低有效 | PB0 | 上电后拉低再拉高 |
| BL | 背光控制 | PB1 | 简单接3.3V也行,想控制亮度就PWM |
| SCK | SPI时钟 | PA5 | SPI1_SCK |
| MOSI | 主出从入 | PA7 | SPI1_MOSI |
| MISO | 主入从出 | PA6 | 读ID时用到,不读可省 |
2.3 最小驱动代码:时序函数与写命令/写数据分离
把时序函数拆成最底层,是后续所有显示功能的地基。下面这段是基于模拟SPI的最小实现,跑通了再往上加功能:
// 模拟SPI引脚定义 #define LCD_CS_H GPIO_SetBits(GPIOB, GPIO_Pin_12) #define LCD_CS_L GPIO_ResetBits(GPIOB, GPIO_Pin_12) #define LCD_DC_H GPIO_SetBits(GPIOA, GPIO_Pin_2) #define LCD_DC_L GPIO_ResetBits(GPIOA, GPIO_Pin_2) #define LCD_RST_H GPIO_SetBits(GPIOB, GPIO_Pin_0) #define LCD_RST_L GPIO_ResetBits(GPIOB, GPIO_Pin_0) #define LCD_SCK_H GPIO_SetBits(GPIOA, GPIO_Pin_5) #define LCD_SCK_L GPIO_ResetBits(GPIOA, GPIO_Pin_5) #define LCD_MOSI_H GPIO_SetBits(GPIOA, GPIO_Pin_7) #define LCD_MOSI_L GPIO_ResetBits(GPIOA, GPIO_Pin_7) // 模拟SPI发送一字节,MSB先行 static void LCD_SPI_SendByte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) { LCD_MOSI_H; } else { LCD_MOSI_L; } LCD_SCK_H; LCD_SCK_L; dat <<= 1; } } // 写命令:DC低电平 static void LCD_WriteCmd(uint8_t cmd) { LCD_CS_L; LCD_DC_L; LCD_SPI_SendByte(cmd); LCD_CS_H; } // 写数据:DC高电平 static void LCD_WriteData(uint8_t dat) { LCD_CS_L; LCD_DC_H; LCD_SPI_SendByte(dat); LCD_CS_H; }这里的逻辑顺序是先拉低CS选中屏端,再按DC引脚区分命令或数据,发送完成后拉高CS。模拟SPI的SCK空闲电平是低,所以SCK拉高再拉低就是一个时钟周期。速度上每个字节8个循环,换算到72MHz主频时大约实际速率在3MHz左右,足够点亮屏幕,做菜单等简单界面不卡。
3. STM32F407ZET 初始化序列与填充函数:从白屏到画出第一行像素
3.1 ST7735S 初始化寄存器序列
ST7735S的初始化依赖一段固定序列,命令顺序不能乱。少了SLPOUT后会直接白屏,MadCtl设置错误会导致横竖屏坐标错乱。我实际调通一段快速初始化序列是这样的:
void LCD_Init(void) { // 上电稳定 delay_ms(200); LCD_RST_L; delay_ms(50); LCD_RST_H; delay_ms(120); LCD_WriteCmd(0x01); // SWRESET 软件复位 delay_ms(150); LCD_WriteCmd(0x11); // SLPOUT 退出睡眠 delay_ms(100); LCD_WriteCmd(0xB1); // FRMCTR1 帧率控制 LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteCmd(0xB2); // FRMCTR2 LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteCmd(0xB3); // FRMCTR3 LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteCmd(0xB4); // INVCTR 反色控制 LCD_WriteData(0x07); LCD_WriteCmd(0xC0); // PWCTR1 电源控制 LCD_WriteData(0xA2); LCD_WriteData(0x02); LCD_WriteData(0x84); LCD_WriteCmd(0xC1); // PWCTR2 LCD_WriteData(0xC5); LCD_WriteCmd(0xC2); // PWCTR3 LCD_WriteData(0x0A); LCD_WriteData(0x00); LCD_WriteCmd(0xC3); // PWCTR4 LCD_WriteData(0x8A); LCD_WriteData(0x2A); LCD_WriteCmd(0xC4); // PWCTR5 LCD_WriteData(0x8A); LCD_WriteData(0xEE); LCD_WriteCmd(0xC5); // VMCTR1 电压控制 LCD_WriteData(0x0E); LCD_WriteCmd(0x20); // 反色关闭 LCD_WriteCmd(0x36); // MADCTL 访问控制 LCD_WriteData(0xC8); LCD_WriteCmd(0x3A); // COLMOD 颜色模式 LCD_WriteData(0x05); // 16位RGB565 LCD_WriteCmd(0x2A); // CASET 列地址 LCD_WriteData(0x00); LCD_WriteData(0x00); LCD_WriteData(0x00); LCD_WriteData(0x7F); LCD_WriteCmd(0x2B); // RASET 行地址 LCD_WriteData(0x00); LCD_WriteData(0x00); LCD_WriteData(0x00); LCD_WriteData(0x9F); LCD_WriteCmd(0x21); // 内存访问控制相关 delay_ms(50); LCD_WriteCmd(0x29); // DISPON 打开显示 delay_ms(50); }注意MADCTL设置成0xC8,这个值决定了RGB与BGR顺序、横竖屏扫描方向以及行列交换。如果你的屏显示效果偏色,先检查这里;如果你发现画面与刷入数据相比旋转了90度,也改这个寄存器。0xC8对应的是RGB颜色顺序加上竖屏模式,横屏则改为0xA8或0x68,具体看面板走线。
3.2 画点、区域填充与滚动显示
WRAM写入命令是0x2C,写完后连续发送像素数据,屏会自动递增地址。为了性能,我只把目标区域用CASET和RASET框住,再做填充:
void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); // CASET LCD_WriteData(x0 >> 8); LCD_WriteData(x0 & 0xFF); LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteCmd(0x2B); // RASET LCD_WriteData(y0 >> 8); LCD_WriteData(y0 & 0xFF); LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteCmd(0x2C); // RAMWR } void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { LCD_SetWindow(x0, y0, x1, y1); uint32_t pixelCount = (x1 - x0 + 1) * (y1 - y0 + 1); // 连续写像素,颜色高字节在前 for (uint32_t i = 0; i < pixelCount; i++) { LCD_WriteData(color >> 8); LCD_WriteData(color & 0xFF); } }画点和区域填充分开后,后续写字符、画图、滑块条都能复用。像素颜色格式是RGB565,比如红色是0xF800,绿色是0x07E0,蓝色是0x001F。如果你拿到的数据是RGB888,必须先做转换,否则屏幕上会出现明显偏绿或偏蓝的情况。
3.3 关键参数:字体、坐标与颜色格式
1.8寸屏分辨率128×160,竖屏情况下左上角坐标是(0,0),右下角是(127,159)。字符显示一般用6×8或8×16点阵,菜单栏如果用16号字体,一屏可以显示10行,每行8个汉字。中文字库通常自己取模放到常量数组里,或者外挂Flash字库。和常见桌面端开发不同,这里没有系统字体,所有字符都要转成点阵数组。
关于坐标系,注意ST7735S的MADCTL不仅影响显示方向,还影响行/列地址的递增方向。如果你把x方向当作短边,初始化时要把CASET的范围限制在0-127,RASET限制在0-159。反过来如果屏被识别成160×128,画面就会有一半区域显示错乱。
4. 移植到硬件 SPI:复用映射、DMA 与帧率变化
4.1 GPIO 复用配置与 SPI 时钟使能
从模拟SPI切到硬件SPI,第一步是用CubeMX或者直接寄存器配置GPIO复用,关键点是打开GPIO时钟和SPI时钟,并把SCK、MOSI、CS、DC、RST设为复用功能模式。
// 使能SPI1与GPIOA、GPIOB时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; // 配置SCK与MOSI为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_PinAFConfig(GPIOA, GPIO_PinSource5, GPIO_AF_SPI1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource7, GPIO_AF_SPI1); // CS、DC、RST使用普通推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); // SPI1参数配置 SPI_InitTypeDef SPI_InitStructure; SPI_InitStructure.SPI_Direction = SPI_Direction_1Line_Tx; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE);SPI_BaudRatePrescaler_8在168MHz主频下,SPI1挂在APB2上,实际是84MHz/8=10.5MHz。这个速率对ST7735S来说通常可行,但有些模组信号质量不好的时候,降到4MHz更保险。我的经验是先以8分频跑通,再逐步提高,直到出现花屏就退回两级。CPOL和CPHA一定要和模组匹配,ST7735S一般是模式0,也就是CPOL=Low、CPHA=1Edge。
4.2 用 DMA 把显示缓存搬到屏幕
硬件SPI再配合DMA,刷屏时CPU几乎不参与,帧率比模拟SPI有明显提升。一个常见方案是先建一块128×160×2字节的显存,写入时先改显存,再一次性把整块显存用DMA推到GR420。
#define LCD_WIDTH 128 #define LCD_HEIGHT 160 uint16_t lcdBuffer[LCD_WIDTH * LCD_HEIGHT]; void LCD_Flush(void) { // 设置全屏窗口 LCD_SetWindow(0, 0, LCD_WIDTH - 1, LCD_HEIGHT - 1); // 禁用SPI中断,启动DMA DMA_InitTypeDef DMA_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); DMA_DeInit(DMA2_Stream3); DMA_InitStructure.DMA_Channel = DMA_Channel_3; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&SPI1->DR; DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)lcdBuffer; DMA_InitStructure.DMA_DIR = DMA_DIR_MemoryToPeripheral; DMA_InitStructure.DMA_BufferSize = LCD_WIDTH * LCD_HEIGHT * 2; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable; DMA_Init(DMA2_Stream3, &DMA_InitStructure); SPI_DMACmd(SPI1, SPI_DMAReq_Tx, ENABLE); DMA_Cmd(DMA2_Stream3, ENABLE); }注意,DMA发送的字节序取决于显存内RGB565的高低字节排列。如果lcdBuffer[0]是color变量整体,发送时高字节在前,那么DMA的MemoryDataSize设为Byte就能按顺序发出去。如果数据错乱成颜色对调,检查SPI_FirstBit_MSB以及显存内字节排列。DMA传输完成后,要等DMA的TCIF标志置位再关闭,否则下次写入会覆盖尚未完成的传输。
4.3 移植适配时的引脚边界
硬件SPI下,不是所有GPIO都能当SPI引脚用。STM32F407ZET的SPI1只能映射到PA5/PA6/PA7或PB3/PB4/PB5,SPI2在PB13-PB15,SPI3在PB3/PB4/PA15。如果PCB已经布好线,板子把SCK接到了PA4之类的引脚,那只能加硬件SPI映射或者继续用模拟SPI。另外,CS在大多数模组上是低有效,如果你用DMA连续刷一整屏,CS全程保持低没问题;但你用普通IO翻转CS时,每次写命令后再拉高会引入额外延时,对屏本身不是问题,只是速度上限低了些。
有一个常见踩坑点是SPI的NSS引脚。配置SPI_NSS_Soft之后,NSS引脚由软件控制,但GPIO配置为普通输出时不能把它和硬件NSS功能连在一起,否则SPI的传输可能被片选信号干扰。我的做法是把CS和NSS完全分开:CS由任意GPIO控制,NSS功能不用。
5. 避坑记录:白屏、花屏、反色、字符重影的常见原因
5.1 白屏或一半亮一半暗
现象:初始化完成后屏幕全白,背景灯亮,但没有内容。偶尔屏幕上半部分正常,下半部分白。
原因:最常见是上电时序不对,SLPOUT之后没有等到延时就直接发DISPON;其次是初始化序列里缺少0x20反色控制,或者电源控制寄存器设置不完整。还有一种是RST引脚在MCU和屏之间接线松动,导致芯片没有完成复位。
解决:先测RST波形,复位后至少延时120ms。然后把初始化序列末尾的DISPON命令单独提出来,在主循环中延时500ms后重发一次,如果屏幕能亮起来,说明是初始化时序问题。再把0x20、0x21都发一遍,排除反色位的影响。
5.2 花屏雪花
现象:刷字符或图片时,屏幕上随机出现噪点、横纹,刷新越快越明显。
原因:SPI时钟太高。STM32F407的SPI1可以跑到42MHz以上,但排线过长、杜邦线交叉时,信号过冲和串扰会让屏端采样错误。另外一个隐蔽原因是SPI模式下主设备发送一字节时,从设备需要时间处理,连续发送不间隔会导致数据错位。
解决:把SPI分频从2改成8,实测下来10.5MHz以下基本稳定。还有我建议把SCK到GND之间加一个100pF电容,采样边沿会更干净。用杜邦线连接时,线长控制在10cm以内。
5.3 显示反色与颜色错乱
现象:红色变成蓝色,或者整体像相底效果,偏色严重。
原因:MADCTL的RGB/BGR位设置错了,导致RGB565颜色分量被交换;又或者初始化序列中把0x20反色命令反了,屏幕变成负片效果。有些模组还需要执行0x3A COLMOD=05,如果面板默认是12位色深,颜色位数不一致也会出现色偏。
解决:按这张表的思路快速试错,每次修改后重新初始化再看效果:MADCTL=0xC8偏色就换0x08;反色效果就发0x21;如果颜色数不对,检查COLMOD参数是0x05还是0x03。
5.4 字符重影与坐标错乱
现象:画一个矩形,结果显示在屏幕边缘错位,或者字符叠加到前一行。
原因:CASET和RASET设置范围超过128×160,或者写入窗口后没有重新指定起始地址。另一个原因是MADCTL扫描方向不同,导致行地址与列地址对调。
解决:清屏时先固定调用LCD_SetWindow(0,0,127,159),再发0x2C,不要依赖上一次的窗口残留。绘制字符时,每画完一行就重设坐标,不要自动换行计算。
6. 让屏幕刷新快一倍:DMA + SPI 双缓冲的技巧
6.1 双缓冲的思路
想把帧率从十几帧拉到接近三十帧,最直接的做法是在显存上动手。准备两个缓冲区,前一个被SPI DMA读取时,后一个给CPU写入;等DMA完成中断到达后交换指针,形成流水线。这样CPU和SPI总线可以并行工作,而不会互相等待。
uint16_t frontBuffer[128 * 160]; uint16_t backBuffer[128 * 160]; volatile uint8_t bufferIndex = 0; void LCD_SwapBuffers(void) { bufferIndex ^= 1; } uint16_t* LCD_GetDrawBuffer(void) { return bufferIndex ? backBuffer : frontBuffer; } uint16_t* LCD_GetTxBuffer(void) { return bufferIndex ? frontBuffer : backBuffer; }实际刷新流程是:先向LCD_GetDrawBuffer写入画面,修改为需要显示的内容;然后调用LCD_SetWindow配置全屏区域,把TxBuffer的地址作为SPI DMA的源,启动传输。DMA传输完成中断里调用LCD_SwapBuffers,将写缓冲区和发送缓冲区对调。
注意一点,DMA源地址在启动前就锁定了,所以不能在传输过程中修改被读缓冲区的内容。传输时间约等于40KB数据除以SPI速率,如果你的SPI是10.5MHz,传完一屏大约40ms,那么你留给CPU绘图的时间只有40ms,复杂界面就会看到撕裂感。频闪不明显但帧率上不去时,可以考虑降低色彩位数或只刷新变化区域。
6.2 验证刷新率
验证刷新率有个简单办法:主循环里每次DMA完成中断计数,用秒计数统计帧率,再用逻辑分析仪看CS片选信号的高电平时间。如果你看到CS低电平持续40ms,说明瓶颈在SPI带宽;如果CS低电平时间只有10ms但总帧率还是低,问题在CPU绘图逻辑或缓冲切换能力不足。
6.3 一点习惯
从那以后我每次在STM32F407ZET上点这种小屏,都会强制走一遍四个动作:确认驱动IC型号,用模拟SPI跑通初始化序列,再测MADCTL颜色方向,最后才切硬件SPI和DMA。调过的屏多了,反而越来越不敢跳过这些步骤。希望帮到你。
本文还有配套的精品资源,点击获取